Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 

1087 строки
30 KiB

  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 { Toast, Pagination } from '@douyinfe/semi-ui';
  16. import { toastConstants } from '../constants';
  17. import React from 'react';
  18. import { toast } from 'react-toastify';
  19. import {
  20. THINK_TAG_REGEX,
  21. MESSAGE_ROLES,
  22. } from '../constants/playground.constants';
  23. import { TABLE_COMPACT_MODES_KEY } from '../constants';
  24. import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile';
  25. import i18n from '../i18n/i18n';
  26. const HTMLToastContent = ({ htmlContent }) => {
  27. return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
  28. };
  29. export default HTMLToastContent;
  30. export function isAdmin() {
  31. let user = localStorage.getItem('user');
  32. if (!user) return false;
  33. user = JSON.parse(user);
  34. return user.role >= 10;
  35. }
  36. export function isRoot() {
  37. let user = localStorage.getItem('user');
  38. if (!user) return false;
  39. user = JSON.parse(user);
  40. return user.role >= 100;
  41. }
  42. export function getSystemName() {
  43. let system_name = localStorage.getItem('system_name');
  44. if (!system_name) return 'New API';
  45. return system_name;
  46. }
  47. export function getLogo() {
  48. let logo = localStorage.getItem('logo');
  49. if (!logo) return '/logo.png';
  50. return logo;
  51. }
  52. export function getUserIdFromLocalStorage() {
  53. let user = localStorage.getItem('user');
  54. if (!user) return -1;
  55. user = JSON.parse(user);
  56. return user.id;
  57. }
  58. export function getFooterHTML() {
  59. return localStorage.getItem('footer_html');
  60. }
  61. export async function copy(text) {
  62. let okay = true;
  63. try {
  64. await navigator.clipboard.writeText(text);
  65. } catch (e) {
  66. try {
  67. // 构建 textarea 执行复制命令,保留多行文本格式
  68. const textarea = window.document.createElement('textarea');
  69. textarea.value = text;
  70. textarea.setAttribute('readonly', '');
  71. textarea.style.position = 'fixed';
  72. textarea.style.left = '-9999px';
  73. textarea.style.top = '-9999px';
  74. window.document.body.appendChild(textarea);
  75. textarea.select();
  76. window.document.execCommand('copy');
  77. window.document.body.removeChild(textarea);
  78. } catch (e) {
  79. okay = false;
  80. console.error(e);
  81. }
  82. }
  83. return okay;
  84. }
  85. // isMobile 函数已移除,请改用 useIsMobile Hook
  86. let showErrorOptions = { autoClose: toastConstants.ERROR_TIMEOUT };
  87. let showWarningOptions = { autoClose: toastConstants.WARNING_TIMEOUT };
  88. let showSuccessOptions = { autoClose: toastConstants.SUCCESS_TIMEOUT };
  89. let showInfoOptions = { autoClose: toastConstants.INFO_TIMEOUT };
  90. let showNoticeOptions = { autoClose: false };
  91. const isMobileScreen = window.matchMedia(
  92. `(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
  93. ).matches;
  94. if (isMobileScreen) {
  95. showErrorOptions.position = 'top-center';
  96. // showErrorOptions.transition = 'flip';
  97. showSuccessOptions.position = 'top-center';
  98. // showSuccessOptions.transition = 'flip';
  99. showInfoOptions.position = 'top-center';
  100. // showInfoOptions.transition = 'flip';
  101. showNoticeOptions.position = 'top-center';
  102. // showNoticeOptions.transition = 'flip';
  103. }
  104. export function showError(error) {
  105. console.error(error);
  106. if (error.message) {
  107. if (error.name === 'AxiosError') {
  108. switch (error.response.status) {
  109. case 401:
  110. // 清除用户状态
  111. localStorage.removeItem('user');
  112. // toast.error('错误:未登录或登录已过期,请重新登录!', showErrorOptions);
  113. window.location.href = '/login?expired=true';
  114. break;
  115. case 429:
  116. Toast.error(i18n.t('错误:请求次数过多,请稍后再试!'));
  117. break;
  118. case 500:
  119. Toast.error(i18n.t('错误:服务器内部错误,请联系管理员!'));
  120. break;
  121. case 405:
  122. Toast.info(i18n.t('本站仅作演示之用,无服务端!'));
  123. break;
  124. default:
  125. Toast.error(i18n.t('错误:') + error.message);
  126. }
  127. return;
  128. }
  129. Toast.error(i18n.t('错误:') + error.message);
  130. } else {
  131. Toast.error(i18n.t('错误:') + error);
  132. }
  133. }
  134. export function showWarning(message) {
  135. Toast.warning(message);
  136. }
  137. export function showSuccess(message) {
  138. Toast.success(message);
  139. }
  140. export function showInfo(message) {
  141. Toast.info(message);
  142. }
  143. export function showNotice(message, isHTML = false) {
  144. if (isHTML) {
  145. toast(<HTMLToastContent htmlContent={message} />, showNoticeOptions);
  146. } else {
  147. Toast.info(message);
  148. }
  149. }
  150. export function openPage(url) {
  151. window.open(url);
  152. }
  153. export function removeTrailingSlash(url) {
  154. if (!url) return '';
  155. if (url.endsWith('/')) {
  156. return url.slice(0, -1);
  157. } else {
  158. return url;
  159. }
  160. }
  161. export function getTodayStartTimestamp() {
  162. var now = new Date();
  163. now.setHours(0, 0, 0, 0);
  164. return Math.floor(now.getTime() / 1000);
  165. }
  166. export function timestamp2string(timestamp) {
  167. let date = new Date(timestamp * 1000);
  168. let year = date.getFullYear().toString();
  169. let month = (date.getMonth() + 1).toString();
  170. let day = date.getDate().toString();
  171. let hour = date.getHours().toString();
  172. let minute = date.getMinutes().toString();
  173. let second = date.getSeconds().toString();
  174. if (month.length === 1) {
  175. month = '0' + month;
  176. }
  177. if (day.length === 1) {
  178. day = '0' + day;
  179. }
  180. if (hour.length === 1) {
  181. hour = '0' + hour;
  182. }
  183. if (minute.length === 1) {
  184. minute = '0' + minute;
  185. }
  186. if (second.length === 1) {
  187. second = '0' + second;
  188. }
  189. return (
  190. year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + second
  191. );
  192. }
  193. export function timestamp2string1(
  194. timestamp,
  195. dataExportDefaultTime = 'hour',
  196. showYear = false,
  197. ) {
  198. let date = new Date(timestamp * 1000);
  199. let year = date.getFullYear();
  200. let month = (date.getMonth() + 1).toString();
  201. let day = date.getDate().toString();
  202. let hour = date.getHours().toString();
  203. if (month.length === 1) {
  204. month = '0' + month;
  205. }
  206. if (day.length === 1) {
  207. day = '0' + day;
  208. }
  209. if (hour.length === 1) {
  210. hour = '0' + hour;
  211. }
  212. // 仅在跨年时显示年份
  213. let str = showYear ? year + '-' + month + '-' + day : month + '-' + day;
  214. if (dataExportDefaultTime === 'hour') {
  215. str += ' ' + hour + ':00';
  216. } else if (dataExportDefaultTime === 'week') {
  217. let nextWeek = new Date(timestamp * 1000 + 6 * 24 * 60 * 60 * 1000);
  218. let nextWeekYear = nextWeek.getFullYear();
  219. let nextMonth = (nextWeek.getMonth() + 1).toString();
  220. let nextDay = nextWeek.getDate().toString();
  221. if (nextMonth.length === 1) {
  222. nextMonth = '0' + nextMonth;
  223. }
  224. if (nextDay.length === 1) {
  225. nextDay = '0' + nextDay;
  226. }
  227. // 周视图结束日期也仅在跨年时显示年份
  228. let nextStr = showYear
  229. ? nextWeekYear + '-' + nextMonth + '-' + nextDay
  230. : nextMonth + '-' + nextDay;
  231. str += ' - ' + nextStr;
  232. }
  233. return str;
  234. }
  235. // 检查时间戳数组是否跨年
  236. export function isDataCrossYear(timestamps) {
  237. if (!timestamps || timestamps.length === 0) return false;
  238. const years = new Set(
  239. timestamps.map((ts) => new Date(ts * 1000).getFullYear()),
  240. );
  241. return years.size > 1;
  242. }
  243. export function downloadTextAsFile(text, filename) {
  244. let blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
  245. let url = URL.createObjectURL(blob);
  246. let a = document.createElement('a');
  247. a.href = url;
  248. a.download = filename;
  249. a.click();
  250. }
  251. export const verifyJSON = (str) => {
  252. try {
  253. JSON.parse(str);
  254. } catch (e) {
  255. return false;
  256. }
  257. return true;
  258. };
  259. export function verifyJSONPromise(value) {
  260. try {
  261. JSON.parse(value);
  262. return Promise.resolve();
  263. } catch (e) {
  264. return Promise.reject(i18n.t('不是合法的 JSON 字符串'));
  265. }
  266. }
  267. export function shouldShowPrompt(id) {
  268. let prompt = localStorage.getItem(`prompt-${id}`);
  269. return !prompt;
  270. }
  271. export function setPromptShown(id) {
  272. localStorage.setItem(`prompt-${id}`, 'true');
  273. }
  274. /**
  275. * 比较两个对象的属性,找出有变化的属性,并返回包含变化属性信息的数组
  276. * @param {Object} oldObject - 旧对象
  277. * @param {Object} newObject - 新对象
  278. * @return {Array} 包含变化属性信息的数组,每个元素是一个对象,包含 key, oldValue 和 newValue
  279. */
  280. export function compareObjects(oldObject, newObject) {
  281. const changedProperties = [];
  282. // 比较两个对象的属性
  283. for (const key in oldObject) {
  284. if (oldObject.hasOwnProperty(key) && newObject.hasOwnProperty(key)) {
  285. if (oldObject[key] !== newObject[key]) {
  286. changedProperties.push({
  287. key: key,
  288. oldValue: oldObject[key],
  289. newValue: newObject[key],
  290. });
  291. }
  292. }
  293. }
  294. return changedProperties;
  295. }
  296. // playground message
  297. // 生成唯一ID
  298. let messageId = 4;
  299. export const generateMessageId = () => `${messageId++}`;
  300. // 提取消息中的文本内容
  301. export const getTextContent = (message) => {
  302. if (!message || !message.content) return '';
  303. if (Array.isArray(message.content)) {
  304. const textContent = message.content.find((item) => item.type === 'text');
  305. return textContent?.text || '';
  306. }
  307. return typeof message.content === 'string' ? message.content : '';
  308. };
  309. // 处理 think 标签
  310. export const processThinkTags = (content, reasoningContent = '') => {
  311. if (!content || !content.includes('<think>')) {
  312. return { content, reasoningContent };
  313. }
  314. const thoughts = [];
  315. const replyParts = [];
  316. let lastIndex = 0;
  317. let match;
  318. THINK_TAG_REGEX.lastIndex = 0;
  319. while ((match = THINK_TAG_REGEX.exec(content)) !== null) {
  320. replyParts.push(content.substring(lastIndex, match.index));
  321. thoughts.push(match[1]);
  322. lastIndex = match.index + match[0].length;
  323. }
  324. replyParts.push(content.substring(lastIndex));
  325. const processedContent = replyParts
  326. .join('')
  327. .replace(/<\/?think>/g, '')
  328. .trim();
  329. const thoughtsStr = thoughts.join('\n\n---\n\n');
  330. const processedReasoningContent =
  331. reasoningContent && thoughtsStr
  332. ? `${reasoningContent}\n\n---\n\n${thoughtsStr}`
  333. : reasoningContent || thoughtsStr;
  334. return {
  335. content: processedContent,
  336. reasoningContent: processedReasoningContent,
  337. };
  338. };
  339. // 处理未完成的 think 标签
  340. export const processIncompleteThinkTags = (content, reasoningContent = '') => {
  341. if (!content) return { content: '', reasoningContent };
  342. const lastOpenThinkIndex = content.lastIndexOf('<think>');
  343. if (lastOpenThinkIndex === -1) {
  344. return processThinkTags(content, reasoningContent);
  345. }
  346. const fragmentAfterLastOpen = content.substring(lastOpenThinkIndex);
  347. if (!fragmentAfterLastOpen.includes('</think>')) {
  348. const unclosedThought = fragmentAfterLastOpen
  349. .substring('<think>'.length)
  350. .trim();
  351. const cleanContent = content.substring(0, lastOpenThinkIndex);
  352. const processedReasoningContent = unclosedThought
  353. ? reasoningContent
  354. ? `${reasoningContent}\n\n---\n\n${unclosedThought}`
  355. : unclosedThought
  356. : reasoningContent;
  357. return processThinkTags(cleanContent, processedReasoningContent);
  358. }
  359. return processThinkTags(content, reasoningContent);
  360. };
  361. // 构建消息内容(包含图片)
  362. export const buildMessageContent = (
  363. textContent,
  364. imageUrls = [],
  365. imageEnabled = false,
  366. ) => {
  367. if (!textContent && (!imageUrls || imageUrls.length === 0)) {
  368. return '';
  369. }
  370. const validImageUrls = imageUrls.filter((url) => url && url.trim() !== '');
  371. if (imageEnabled && validImageUrls.length > 0) {
  372. return [
  373. { type: 'text', text: textContent || '' },
  374. ...validImageUrls.map((url) => ({
  375. type: 'image_url',
  376. image_url: { url: url.trim() },
  377. })),
  378. ];
  379. }
  380. return textContent || '';
  381. };
  382. // 创建新消息
  383. export const createMessage = (role, content, options = {}) => ({
  384. role,
  385. content,
  386. createAt: Date.now(),
  387. id: generateMessageId(),
  388. ...options,
  389. });
  390. // 创建加载中的助手消息
  391. export const createLoadingAssistantMessage = () =>
  392. createMessage(MESSAGE_ROLES.ASSISTANT, '', {
  393. reasoningContent: '',
  394. isReasoningExpanded: true,
  395. isThinkingComplete: false,
  396. hasAutoCollapsed: false,
  397. status: 'loading',
  398. });
  399. // 检查消息是否包含图片
  400. export const hasImageContent = (message) => {
  401. return (
  402. message &&
  403. Array.isArray(message.content) &&
  404. message.content.some((item) => item.type === 'image_url')
  405. );
  406. };
  407. // 格式化消息用于API请求
  408. export const formatMessageForAPI = (message) => {
  409. if (!message) return null;
  410. return {
  411. role: message.role,
  412. content: message.content,
  413. };
  414. };
  415. // 验证消息是否有效
  416. export const isValidMessage = (message) => {
  417. return message && message.role && (message.content || message.content === '');
  418. };
  419. // 获取最后一条用户消息
  420. export const getLastUserMessage = (messages) => {
  421. if (!Array.isArray(messages)) return null;
  422. for (let i = messages.length - 1; i >= 0; i--) {
  423. if (messages[i].role === MESSAGE_ROLES.USER) {
  424. return messages[i];
  425. }
  426. }
  427. return null;
  428. };
  429. // 获取最后一条助手消息
  430. export const getLastAssistantMessage = (messages) => {
  431. if (!Array.isArray(messages)) return null;
  432. for (let i = messages.length - 1; i >= 0; i--) {
  433. if (messages[i].role === MESSAGE_ROLES.ASSISTANT) {
  434. return messages[i];
  435. }
  436. }
  437. return null;
  438. };
  439. // 计算相对时间(几天前、几小时前等)
  440. export const getRelativeTime = (publishDate) => {
  441. if (!publishDate) return '';
  442. const now = new Date();
  443. const pubDate = new Date(publishDate);
  444. // 如果日期无效,返回原始字符串
  445. if (isNaN(pubDate.getTime())) return publishDate;
  446. const diffMs = now.getTime() - pubDate.getTime();
  447. const diffSeconds = Math.floor(diffMs / 1000);
  448. const diffMinutes = Math.floor(diffSeconds / 60);
  449. const diffHours = Math.floor(diffMinutes / 60);
  450. const diffDays = Math.floor(diffHours / 24);
  451. const diffWeeks = Math.floor(diffDays / 7);
  452. const diffMonths = Math.floor(diffDays / 30);
  453. const diffYears = Math.floor(diffDays / 365);
  454. // 如果是未来时间,显示具体日期
  455. if (diffMs < 0) {
  456. return formatDateString(pubDate);
  457. }
  458. // 根据时间差返回相应的描述
  459. if (diffSeconds < 60) {
  460. return i18n.t('刚刚');
  461. } else if (diffMinutes < 60) {
  462. return `${diffMinutes} ${i18n.t('分钟前')}`;
  463. } else if (diffHours < 24) {
  464. return `${diffHours} ${i18n.t('小时前')}`;
  465. } else if (diffDays < 7) {
  466. return `${diffDays} ${i18n.t('天前')}`;
  467. } else if (diffWeeks < 4) {
  468. return `${diffWeeks} ${i18n.t('周前')}`;
  469. } else if (diffMonths < 12) {
  470. return `${diffMonths} ${i18n.t('个月前')}`;
  471. } else if (diffYears < 2) {
  472. return i18n.t('1 年前');
  473. } else {
  474. // 超过2年显示具体日期
  475. return formatDateString(pubDate);
  476. }
  477. };
  478. // 格式化日期字符串
  479. export const formatDateString = (date) => {
  480. const year = date.getFullYear();
  481. const month = String(date.getMonth() + 1).padStart(2, '0');
  482. const day = String(date.getDate()).padStart(2, '0');
  483. return `${year}-${month}-${day}`;
  484. };
  485. // 格式化日期时间字符串(包含时间)
  486. export const formatDateTimeString = (date) => {
  487. const year = date.getFullYear();
  488. const month = String(date.getMonth() + 1).padStart(2, '0');
  489. const day = String(date.getDate()).padStart(2, '0');
  490. const hours = String(date.getHours()).padStart(2, '0');
  491. const minutes = String(date.getMinutes()).padStart(2, '0');
  492. return `${year}-${month}-${day} ${hours}:${minutes}`;
  493. };
  494. function readTableCompactModes() {
  495. try {
  496. const json = localStorage.getItem(TABLE_COMPACT_MODES_KEY);
  497. return json ? JSON.parse(json) : {};
  498. } catch {
  499. return {};
  500. }
  501. }
  502. function writeTableCompactModes(modes) {
  503. try {
  504. localStorage.setItem(TABLE_COMPACT_MODES_KEY, JSON.stringify(modes));
  505. } catch {
  506. // ignore
  507. }
  508. }
  509. export function getTableCompactMode(tableKey = 'global') {
  510. const modes = readTableCompactModes();
  511. return !!modes[tableKey];
  512. }
  513. export function setTableCompactMode(compact, tableKey = 'global') {
  514. const modes = readTableCompactModes();
  515. modes[tableKey] = compact;
  516. writeTableCompactModes(modes);
  517. }
  518. // -------------------------------
  519. // Select 组件统一过滤逻辑
  520. // 使用方式: <Select filter={selectFilter} ... />
  521. // 统一的 Select 搜索过滤逻辑 -- 支持同时匹配 option.value 与 option.label
  522. export const selectFilter = (input, option) => {
  523. if (!input) return true;
  524. const keyword = input.trim().toLowerCase();
  525. const valueText = (option?.value ?? '').toString().toLowerCase();
  526. const labelText = (option?.label ?? '').toString().toLowerCase();
  527. return valueText.includes(keyword) || labelText.includes(keyword);
  528. };
  529. // -------------------------------
  530. // 模型定价计算工具函数
  531. export const hasDisplayPricing = (record) =>
  532. Array.isArray(record?.display_pricing) && record.display_pricing.length > 0;
  533. export const normalizeDisplayPricingItems = (record) => {
  534. if (!hasDisplayPricing(record)) return [];
  535. return [...record.display_pricing]
  536. .map((item) => {
  537. const discountRate =
  538. Number(item.discount_rate) > 0 && Number(item.discount_rate) <= 1
  539. ? Number(item.discount_rate)
  540. : 1;
  541. const price = Number(item.price || 0);
  542. return {
  543. ...item,
  544. price,
  545. discount_rate: discountRate,
  546. effectivePrice: price * discountRate,
  547. unit: item.unit || 'call',
  548. sort_order: Number(item.sort_order || 0),
  549. };
  550. })
  551. .sort((a, b) => {
  552. if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order;
  553. return String(a.specification || '').localeCompare(
  554. String(b.specification || ''),
  555. );
  556. });
  557. };
  558. export const formatDisplayPricingItem = ({
  559. item,
  560. displayPrice,
  561. t = (key) => key,
  562. }) => {
  563. const unit = item.unit || 'call';
  564. const unitText = formatDisplayPricingUnit(unit, t);
  565. const effectivePrice = Number(item.effectivePrice ?? item.price ?? 0);
  566. const hasDiscount = item.discount_rate > 0 && item.discount_rate < 1;
  567. return {
  568. ...item,
  569. formattedPrice: `${displayPrice(effectivePrice)} / ${unitText}`,
  570. originalPrice: hasDiscount
  571. ? `${displayPrice(Number(item.price || 0))} / ${unitText}`
  572. : '',
  573. discountText: hasDiscount
  574. ? `${Math.round(item.discount_rate * 100) / 10}折`
  575. : '-',
  576. };
  577. };
  578. const DISPLAY_PRICING_UNIT_FALLBACKS = {
  579. second: '秒',
  580. call: '次',
  581. image: '张图片',
  582. video: '个视频',
  583. };
  584. export const formatDisplayPricingUnit = (unit, t = (key) => key) => {
  585. const normalizedUnit = unit || 'call';
  586. const translated = t(normalizedUnit);
  587. if (translated && translated !== normalizedUnit) {
  588. return translated;
  589. }
  590. return DISPLAY_PRICING_UNIT_FALLBACKS[normalizedUnit] || normalizedUnit;
  591. };
  592. export const summarizeDisplayPricing = ({
  593. record,
  594. displayPrice,
  595. t = (key) => key,
  596. }) => {
  597. const items = normalizeDisplayPricingItems(record);
  598. if (items.length === 0) return null;
  599. const groups = new Map();
  600. items.forEach((item) => {
  601. const unit = item.unit || 'call';
  602. const list = groups.get(unit) || [];
  603. list.push(item);
  604. groups.set(unit, list);
  605. });
  606. const lines = Array.from(groups.entries()).map(([unit, list]) => {
  607. const prices = list.map((item) => Number(item.effectivePrice || 0));
  608. const min = Math.min(...prices);
  609. const max = Math.max(...prices);
  610. const priceText =
  611. min === max
  612. ? displayPrice(min)
  613. : `${displayPrice(min)} - ${displayPrice(max)}`;
  614. return `${priceText} / ${formatDisplayPricingUnit(unit, t)}`;
  615. });
  616. return {
  617. isDisplayPricing: true,
  618. price: lines.join('\n'),
  619. unit: '',
  620. details: items
  621. .slice(0, 3)
  622. .map((item) => formatDisplayPricingItem({ item, displayPrice, t })),
  623. hiddenDetailCount: Math.max(0, items.length - 3),
  624. };
  625. };
  626. export const hasMatrixPricing = (record) =>
  627. !!(
  628. record?.pricing_config &&
  629. Array.isArray(record.pricing_config.table) &&
  630. record.pricing_config.table.length > 0
  631. );
  632. export const getMatrixPricingUnit = (pricingConfig, tokenUnit = 'M') => {
  633. if (pricingConfig?.billing_unit === 'per_1m_tokens') {
  634. return `1${tokenUnit === 'K' ? 'K' : 'M'} tokens`;
  635. }
  636. return '次';
  637. };
  638. const getDisplayCurrencySymbol = (currency) => {
  639. if (currency === 'CNY') return '¥';
  640. if (currency === 'CUSTOM') {
  641. try {
  642. const statusStr = localStorage.getItem('status');
  643. if (statusStr) {
  644. const s = JSON.parse(statusStr);
  645. return s?.custom_currency_symbol || '¤';
  646. }
  647. } catch {
  648. // ignore
  649. }
  650. return '¤';
  651. }
  652. return '$';
  653. };
  654. export const formatMatrixPrice = ({
  655. price,
  656. pricingConfig,
  657. tokenUnit,
  658. displayPrice,
  659. currency,
  660. precision = 4,
  661. }) => {
  662. const unitDivisor =
  663. pricingConfig?.billing_unit === 'per_1m_tokens' && tokenUnit === 'K'
  664. ? 1000
  665. : 1;
  666. const displayVal = displayPrice(Number(price || 0));
  667. const numPrice = parseFloat(displayVal.replace(/[^0-9.]/g, '')) / unitDivisor;
  668. return `${getDisplayCurrencySymbol(currency)}${numPrice.toFixed(precision)}`;
  669. };
  670. export const summarizeMatrixPricing = ({
  671. record,
  672. usedGroupRatio = 1,
  673. tokenUnit,
  674. displayPrice,
  675. currency,
  676. precision = 4,
  677. }) => {
  678. const cfg = record?.pricing_config;
  679. const prices = (cfg?.table || [])
  680. .map((row) => Number(row.price))
  681. .filter((price) => Number.isFinite(price));
  682. if (prices.length === 0) {
  683. return { price: '-', unit: getMatrixPricingUnit(cfg, tokenUnit) };
  684. }
  685. const min = Math.min(...prices) * usedGroupRatio;
  686. const max = Math.max(...prices) * usedGroupRatio;
  687. const minText = formatMatrixPrice({
  688. price: min,
  689. pricingConfig: cfg,
  690. tokenUnit,
  691. displayPrice,
  692. currency,
  693. precision,
  694. });
  695. const maxText = formatMatrixPrice({
  696. price: max,
  697. pricingConfig: cfg,
  698. tokenUnit,
  699. displayPrice,
  700. currency,
  701. precision,
  702. });
  703. return {
  704. price: min === max ? minText : `${minText} - ${maxText}`,
  705. unit: getMatrixPricingUnit(cfg, tokenUnit),
  706. details: (cfg.table || []).slice(0, 3).map((row) => {
  707. const dimensionText = Object.entries(row)
  708. .filter(([key]) => key !== 'price' && key !== 'source')
  709. .map(
  710. ([key, value]) => `${key}=${value === '*' ? '任意' : String(value)}`,
  711. )
  712. .join(' · ');
  713. return {
  714. dimensionText,
  715. formattedPrice: formatMatrixPrice({
  716. price: Number(row.price || 0) * usedGroupRatio,
  717. pricingConfig: cfg,
  718. tokenUnit,
  719. displayPrice,
  720. currency,
  721. precision,
  722. }),
  723. };
  724. }),
  725. hiddenDetailCount: Math.max(0, (cfg.table || []).length - 3),
  726. };
  727. };
  728. export const calculateModelPrice = ({
  729. record,
  730. selectedGroup,
  731. groupRatio,
  732. tokenUnit,
  733. displayPrice,
  734. currency,
  735. precision = 4,
  736. t = (key) => key,
  737. }) => {
  738. // 1. 选择实际使用的分组
  739. const ratios = groupRatio || {};
  740. let usedGroup = selectedGroup;
  741. let usedGroupRatio = ratios[selectedGroup];
  742. if (selectedGroup === 'all' || usedGroupRatio === undefined) {
  743. // 在模型可用分组中选择倍率最小的分组,若无则使用 1
  744. let minRatio = Number.POSITIVE_INFINITY;
  745. if (
  746. Array.isArray(record.enable_groups) &&
  747. record.enable_groups.length > 0
  748. ) {
  749. record.enable_groups.forEach((g) => {
  750. const r = ratios[g];
  751. if (r !== undefined && r < minRatio) {
  752. minRatio = r;
  753. usedGroup = g;
  754. usedGroupRatio = r;
  755. }
  756. });
  757. }
  758. // 如果找不到合适分组倍率,回退为 1
  759. if (usedGroupRatio === undefined) {
  760. usedGroupRatio = 1;
  761. }
  762. }
  763. // 2. 根据计费类型计算价格
  764. if (hasDisplayPricing(record)) {
  765. const summary = summarizeDisplayPricing({
  766. record,
  767. displayPrice,
  768. t,
  769. });
  770. return {
  771. ...summary,
  772. usedGroup,
  773. usedGroupRatio,
  774. };
  775. }
  776. if (hasMatrixPricing(record)) {
  777. const summary = summarizeMatrixPricing({
  778. record,
  779. usedGroupRatio,
  780. tokenUnit,
  781. displayPrice,
  782. currency,
  783. precision,
  784. });
  785. return {
  786. ...summary,
  787. isMatrix: true,
  788. isPerToken: record.pricing_config?.billing_unit === 'per_1m_tokens',
  789. usedGroup,
  790. usedGroupRatio,
  791. };
  792. }
  793. if (record.quota_type === 0) {
  794. // 按量计费
  795. const inputRatioPriceUSD = record.model_ratio * 2 * usedGroupRatio;
  796. const completionRatioPriceUSD =
  797. record.model_ratio * record.completion_ratio * 2 * usedGroupRatio;
  798. // 缓存读取价格 = 输入价格 × 缓存倍率(默认 1 表示与输入价格相同)
  799. const cacheRatio = record.cache_ratio ?? 1;
  800. const cacheRatioPriceUSD = inputRatioPriceUSD * cacheRatio;
  801. // 缓存创建价格 = 输入价格 × 缓存创建倍率(默认 1.25)
  802. const cacheCreationRatio = record.cache_creation_ratio ?? 1.25;
  803. const cacheCreationRatioPriceUSD = inputRatioPriceUSD * cacheCreationRatio;
  804. const unitDivisor = tokenUnit === 'K' ? 1000 : 1;
  805. const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
  806. const rawDisplayInput = displayPrice(inputRatioPriceUSD);
  807. const rawDisplayCompletion = displayPrice(completionRatioPriceUSD);
  808. const rawDisplayCache = displayPrice(cacheRatioPriceUSD);
  809. const rawDisplayCacheCreation = displayPrice(cacheCreationRatioPriceUSD);
  810. const numInput =
  811. parseFloat(rawDisplayInput.replace(/[^0-9.]/g, '')) / unitDivisor;
  812. const numCompletion =
  813. parseFloat(rawDisplayCompletion.replace(/[^0-9.]/g, '')) / unitDivisor;
  814. const numCache =
  815. parseFloat(rawDisplayCache.replace(/[^0-9.]/g, '')) / unitDivisor;
  816. const numCacheCreation =
  817. parseFloat(rawDisplayCacheCreation.replace(/[^0-9.]/g, '')) / unitDivisor;
  818. let symbol = '$';
  819. if (currency === 'CNY') {
  820. symbol = '¥';
  821. } else if (currency === 'CUSTOM') {
  822. try {
  823. const statusStr = localStorage.getItem('status');
  824. if (statusStr) {
  825. const s = JSON.parse(statusStr);
  826. symbol = s?.custom_currency_symbol || '¤';
  827. } else {
  828. symbol = '¤';
  829. }
  830. } catch (e) {
  831. symbol = '¤';
  832. }
  833. }
  834. return {
  835. inputPrice: `${symbol}${numInput.toFixed(precision)}`,
  836. completionPrice: `${symbol}${numCompletion.toFixed(precision)}`,
  837. cachePrice: `${symbol}${numCache.toFixed(precision)}`,
  838. cacheCreationPrice: `${symbol}${numCacheCreation.toFixed(precision)}`,
  839. cacheRatio,
  840. cacheCreationRatio,
  841. unitLabel,
  842. isPerToken: true,
  843. usedGroup,
  844. usedGroupRatio,
  845. };
  846. }
  847. if (record.quota_type === 1) {
  848. // 按次计费
  849. const priceUSD = parseFloat(record.model_price) * usedGroupRatio;
  850. const displayVal = displayPrice(priceUSD);
  851. return {
  852. price: displayVal,
  853. isPerToken: false,
  854. usedGroup,
  855. usedGroupRatio,
  856. };
  857. }
  858. // 未知计费类型,返回占位信息
  859. return {
  860. price: '-',
  861. isPerToken: false,
  862. usedGroup,
  863. usedGroupRatio,
  864. };
  865. };
  866. // 格式化价格信息(用于卡片视图)
  867. export const formatPriceInfo = (priceData, t) => {
  868. if (priceData.isPerToken) {
  869. return (
  870. <span style={{ color: 'var(--semi-color-text-1)' }}>
  871. {t('输入')} {priceData.inputPrice} / {t('输出')}{' '}
  872. {priceData.completionPrice} / {priceData.unitLabel} tokens
  873. </span>
  874. );
  875. }
  876. return (
  877. <span style={{ color: 'var(--semi-color-text-1)' }}>
  878. {t('模型价格')} {priceData.price}
  879. </span>
  880. );
  881. };
  882. // -------------------------------
  883. // CardPro 分页配置函数
  884. // 用于创建 CardPro 的 paginationArea 配置
  885. export const createCardProPagination = ({
  886. currentPage,
  887. pageSize,
  888. total,
  889. onPageChange,
  890. onPageSizeChange,
  891. isMobile = false,
  892. pageSizeOpts = [10, 20, 50, 100],
  893. showSizeChanger = true,
  894. t = (key) => key,
  895. }) => {
  896. if (!total || total <= 0) return null;
  897. const start = (currentPage - 1) * pageSize + 1;
  898. const end = Math.min(currentPage * pageSize, total);
  899. const totalText = `${t('显示第')} ${start} ${t('条 - 第')} ${end} ${t('条,共')} ${total} ${t('条')}`;
  900. return (
  901. <>
  902. {/* 桌面端左侧总数信息 */}
  903. {!isMobile && (
  904. <span
  905. className='text-sm select-none'
  906. style={{ color: 'var(--semi-color-text-2)' }}
  907. >
  908. {totalText}
  909. </span>
  910. )}
  911. {/* 右侧分页控件 */}
  912. <Pagination
  913. currentPage={currentPage}
  914. pageSize={pageSize}
  915. total={total}
  916. pageSizeOpts={pageSizeOpts}
  917. showSizeChanger={showSizeChanger}
  918. onPageSizeChange={onPageSizeChange}
  919. onPageChange={onPageChange}
  920. size={isMobile ? 'small' : 'default'}
  921. showQuickJumper={isMobile}
  922. showTotal
  923. />
  924. </>
  925. );
  926. };
  927. // 模型定价筛选条件默认值
  928. const DEFAULT_PRICING_FILTERS = {
  929. search: '',
  930. showWithRecharge: false,
  931. currency: 'USD',
  932. showRatio: false,
  933. viewMode: 'card',
  934. tokenUnit: 'M',
  935. filterGroup: 'all',
  936. filterQuotaType: 'all',
  937. filterEndpointType: 'all',
  938. filterVendor: 'all',
  939. filterTag: 'all',
  940. currentPage: 1,
  941. };
  942. // 重置模型定价筛选条件
  943. export const resetPricingFilters = ({
  944. handleChange,
  945. setShowWithRecharge,
  946. setCurrency,
  947. setShowRatio,
  948. setViewMode,
  949. setFilterGroup,
  950. setFilterQuotaType,
  951. setFilterEndpointType,
  952. setFilterVendor,
  953. setFilterTag,
  954. setCurrentPage,
  955. setTokenUnit,
  956. }) => {
  957. handleChange?.(DEFAULT_PRICING_FILTERS.search);
  958. setShowWithRecharge?.(DEFAULT_PRICING_FILTERS.showWithRecharge);
  959. setCurrency?.(DEFAULT_PRICING_FILTERS.currency);
  960. setShowRatio?.(DEFAULT_PRICING_FILTERS.showRatio);
  961. setViewMode?.(DEFAULT_PRICING_FILTERS.viewMode);
  962. setTokenUnit?.(DEFAULT_PRICING_FILTERS.tokenUnit);
  963. setFilterGroup?.(DEFAULT_PRICING_FILTERS.filterGroup);
  964. setFilterQuotaType?.(DEFAULT_PRICING_FILTERS.filterQuotaType);
  965. setFilterEndpointType?.(DEFAULT_PRICING_FILTERS.filterEndpointType);
  966. setFilterVendor?.(DEFAULT_PRICING_FILTERS.filterVendor);
  967. setFilterTag?.(DEFAULT_PRICING_FILTERS.filterTag);
  968. setCurrentPage?.(DEFAULT_PRICING_FILTERS.currentPage);
  969. };