2025-07-19 03:30:44 +08:00
|
|
|
|
/*
|
|
|
|
|
|
Copyright (C) 2025 QuantumNous
|
|
|
|
|
|
|
|
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
|
|
|
|
it under the terms of the GNU Affero General Public License as
|
|
|
|
|
|
published by the Free Software Foundation, either version 3 of the
|
|
|
|
|
|
License, or (at your option) any later version.
|
|
|
|
|
|
|
|
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
|
|
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
|
|
GNU Affero General Public License for more details.
|
|
|
|
|
|
|
|
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
|
|
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
|
|
|
|
|
|
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2025-07-20 02:27:33 +08:00
|
|
|
|
import { Toast, Pagination } from '@douyinfe/semi-ui';
|
2026-04-25 13:24:20 +08:00
|
|
|
|
import { toastConstants, BILLING_PRICING_VARS, BILLING_VAR_REGEX } from '../constants';
|
2023-08-12 10:49:30 +08:00
|
|
|
|
import React from 'react';
|
2024-03-23 21:24:39 +08:00
|
|
|
|
import { toast } from 'react-toastify';
|
2025-08-30 21:15:10 +08:00
|
|
|
|
import {
|
|
|
|
|
|
THINK_TAG_REGEX,
|
|
|
|
|
|
MESSAGE_ROLES,
|
|
|
|
|
|
} from '../constants/playground.constants';
|
2025-06-22 18:10:00 +08:00
|
|
|
|
import { TABLE_COMPACT_MODES_KEY } from '../constants';
|
2025-08-18 04:14:35 +08:00
|
|
|
|
import { MOBILE_BREAKPOINT } from '../hooks/common/useIsMobile';
|
2023-04-22 20:39:27 +08:00
|
|
|
|
|
2023-08-12 10:49:30 +08:00
|
|
|
|
const HTMLToastContent = ({ htmlContent }) => {
|
|
|
|
|
|
return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
|
|
|
|
|
|
};
|
|
|
|
|
|
export default HTMLToastContent;
|
2023-04-22 20:39:27 +08:00
|
|
|
|
export function isAdmin() {
|
|
|
|
|
|
let user = localStorage.getItem('user');
|
|
|
|
|
|
if (!user) return false;
|
|
|
|
|
|
user = JSON.parse(user);
|
|
|
|
|
|
return user.role >= 10;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function isRoot() {
|
|
|
|
|
|
let user = localStorage.getItem('user');
|
|
|
|
|
|
if (!user) return false;
|
|
|
|
|
|
user = JSON.parse(user);
|
|
|
|
|
|
return user.role >= 100;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-05-14 19:29:02 +08:00
|
|
|
|
export function getSystemName() {
|
|
|
|
|
|
let system_name = localStorage.getItem('system_name');
|
2023-11-07 23:32:43 +08:00
|
|
|
|
if (!system_name) return 'New API';
|
2023-05-14 19:29:02 +08:00
|
|
|
|
return system_name;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function getLogo() {
|
|
|
|
|
|
let logo = localStorage.getItem('logo');
|
|
|
|
|
|
if (!logo) return '/logo.png';
|
2024-03-23 21:24:39 +08:00
|
|
|
|
return logo;
|
2023-05-14 19:29:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2024-07-19 13:39:05 +08:00
|
|
|
|
export function getUserIdFromLocalStorage() {
|
|
|
|
|
|
let user = localStorage.getItem('user');
|
|
|
|
|
|
if (!user) return -1;
|
|
|
|
|
|
user = JSON.parse(user);
|
|
|
|
|
|
return user.id;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-05-14 19:29:02 +08:00
|
|
|
|
export function getFooterHTML() {
|
|
|
|
|
|
return localStorage.getItem('footer_html');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-04-22 20:39:27 +08:00
|
|
|
|
export async function copy(text) {
|
|
|
|
|
|
let okay = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await navigator.clipboard.writeText(text);
|
|
|
|
|
|
} catch (e) {
|
2024-12-30 17:13:49 +08:00
|
|
|
|
try {
|
2025-09-20 11:09:28 +08:00
|
|
|
|
// 构建 textarea 执行复制命令,保留多行文本格式
|
|
|
|
|
|
const textarea = window.document.createElement('textarea');
|
|
|
|
|
|
textarea.value = text;
|
|
|
|
|
|
textarea.setAttribute('readonly', '');
|
|
|
|
|
|
textarea.style.position = 'fixed';
|
|
|
|
|
|
textarea.style.left = '-9999px';
|
|
|
|
|
|
textarea.style.top = '-9999px';
|
|
|
|
|
|
window.document.body.appendChild(textarea);
|
|
|
|
|
|
textarea.select();
|
|
|
|
|
|
window.document.execCommand('copy');
|
|
|
|
|
|
window.document.body.removeChild(textarea);
|
2024-12-30 17:13:49 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
okay = false;
|
|
|
|
|
|
console.error(e);
|
|
|
|
|
|
}
|
2023-04-22 20:39:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
return okay;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
📱 refactor(web): remove legacy isMobile util and migrate to useIsMobile hook
BREAKING CHANGE:
helpers/utils.js no longer exports `isMobile()`.
Any external code that relied on this function must switch to the `useIsMobile` React hook.
Summary
-------
1. Deleted the obsolete `isMobile()` function from helpers/utils.js.
2. Introduced `MOBILE_BREAKPOINT` constant and `matchMedia`-based detection for non-React contexts.
3. Reworked toast positioning logic in utils.js to rely on `matchMedia`.
4. Updated render.js:
• Removed isMobile import.
• Added MOBILE_BREAKPOINT detection in `truncateText`.
5. Migrated every page/component to the `useIsMobile` hook:
• Layout: HeaderBar, PageLayout, SiderBar
• Pages: Home, Detail, Playground, User (Add/Edit), Token, Channel, Redemption, Ratio Sync
• Components: ChannelsTable, ChannelSelectorModal, ConflictConfirmModal
6. Purged all remaining `isMobile()` calls and legacy imports.
7. Added missing `const isMobile = useIsMobile()` declarations where required.
Benefits
--------
• Unifies mobile detection with a React-friendly hook.
• Eliminates duplicated logic and improves maintainability.
• Keeps non-React helpers lightweight by using `matchMedia` directly.
2025-07-16 02:54:58 +08:00
|
|
|
|
// isMobile 函数已移除,请改用 useIsMobile Hook
|
2023-04-22 20:39:27 +08:00
|
|
|
|
|
|
|
|
|
|
let showErrorOptions = { autoClose: toastConstants.ERROR_TIMEOUT };
|
2023-04-25 09:46:58 +08:00
|
|
|
|
let showWarningOptions = { autoClose: toastConstants.WARNING_TIMEOUT };
|
2023-04-22 20:39:27 +08:00
|
|
|
|
let showSuccessOptions = { autoClose: toastConstants.SUCCESS_TIMEOUT };
|
|
|
|
|
|
let showInfoOptions = { autoClose: toastConstants.INFO_TIMEOUT };
|
|
|
|
|
|
let showNoticeOptions = { autoClose: false };
|
|
|
|
|
|
|
2025-08-30 21:15:10 +08:00
|
|
|
|
const isMobileScreen = window.matchMedia(
|
|
|
|
|
|
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
|
|
|
|
|
|
).matches;
|
📱 refactor(web): remove legacy isMobile util and migrate to useIsMobile hook
BREAKING CHANGE:
helpers/utils.js no longer exports `isMobile()`.
Any external code that relied on this function must switch to the `useIsMobile` React hook.
Summary
-------
1. Deleted the obsolete `isMobile()` function from helpers/utils.js.
2. Introduced `MOBILE_BREAKPOINT` constant and `matchMedia`-based detection for non-React contexts.
3. Reworked toast positioning logic in utils.js to rely on `matchMedia`.
4. Updated render.js:
• Removed isMobile import.
• Added MOBILE_BREAKPOINT detection in `truncateText`.
5. Migrated every page/component to the `useIsMobile` hook:
• Layout: HeaderBar, PageLayout, SiderBar
• Pages: Home, Detail, Playground, User (Add/Edit), Token, Channel, Redemption, Ratio Sync
• Components: ChannelsTable, ChannelSelectorModal, ConflictConfirmModal
6. Purged all remaining `isMobile()` calls and legacy imports.
7. Added missing `const isMobile = useIsMobile()` declarations where required.
Benefits
--------
• Unifies mobile detection with a React-friendly hook.
• Eliminates duplicated logic and improves maintainability.
• Keeps non-React helpers lightweight by using `matchMedia` directly.
2025-07-16 02:54:58 +08:00
|
|
|
|
if (isMobileScreen) {
|
2023-04-22 20:39:27 +08:00
|
|
|
|
showErrorOptions.position = 'top-center';
|
|
|
|
|
|
// showErrorOptions.transition = 'flip';
|
|
|
|
|
|
|
|
|
|
|
|
showSuccessOptions.position = 'top-center';
|
|
|
|
|
|
// showSuccessOptions.transition = 'flip';
|
|
|
|
|
|
|
|
|
|
|
|
showInfoOptions.position = 'top-center';
|
|
|
|
|
|
// showInfoOptions.transition = 'flip';
|
|
|
|
|
|
|
|
|
|
|
|
showNoticeOptions.position = 'top-center';
|
|
|
|
|
|
// showNoticeOptions.transition = 'flip';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function showError(error) {
|
|
|
|
|
|
console.error(error);
|
|
|
|
|
|
if (error.message) {
|
|
|
|
|
|
if (error.name === 'AxiosError') {
|
2023-04-26 11:42:56 +08:00
|
|
|
|
switch (error.response.status) {
|
|
|
|
|
|
case 401:
|
2025-05-20 00:50:09 +08:00
|
|
|
|
// 清除用户状态
|
|
|
|
|
|
localStorage.removeItem('user');
|
2023-04-26 11:42:56 +08:00
|
|
|
|
// toast.error('错误:未登录或登录已过期,请重新登录!', showErrorOptions);
|
|
|
|
|
|
window.location.href = '/login?expired=true';
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 429:
|
2023-10-31 22:41:34 +08:00
|
|
|
|
Toast.error('错误:请求次数过多,请稍后再试!');
|
2023-04-22 20:39:27 +08:00
|
|
|
|
break;
|
2023-04-26 11:42:56 +08:00
|
|
|
|
case 500:
|
2023-10-31 22:41:34 +08:00
|
|
|
|
Toast.error('错误:服务器内部错误,请联系管理员!');
|
2023-04-22 20:39:27 +08:00
|
|
|
|
break;
|
2023-04-26 11:42:56 +08:00
|
|
|
|
case 405:
|
2023-10-31 22:41:34 +08:00
|
|
|
|
Toast.info('本站仅作演示之用,无服务端!');
|
2023-04-22 20:39:27 +08:00
|
|
|
|
break;
|
|
|
|
|
|
default:
|
2023-10-31 22:41:34 +08:00
|
|
|
|
Toast.error('错误:' + error.message);
|
2023-04-22 20:39:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2023-10-31 22:41:34 +08:00
|
|
|
|
Toast.error('错误:' + error.message);
|
2023-04-22 20:39:27 +08:00
|
|
|
|
} else {
|
2023-10-31 22:41:34 +08:00
|
|
|
|
Toast.error('错误:' + error);
|
2023-04-22 20:39:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-04-25 09:46:58 +08:00
|
|
|
|
export function showWarning(message) {
|
2023-10-31 22:41:34 +08:00
|
|
|
|
Toast.warning(message);
|
2023-04-25 09:46:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2023-04-22 20:39:27 +08:00
|
|
|
|
export function showSuccess(message) {
|
2023-10-31 22:41:34 +08:00
|
|
|
|
Toast.success(message);
|
2023-04-22 20:39:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function showInfo(message) {
|
2023-10-31 22:41:34 +08:00
|
|
|
|
Toast.info(message);
|
2023-04-22 20:39:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2023-08-12 10:49:30 +08:00
|
|
|
|
export function showNotice(message, isHTML = false) {
|
|
|
|
|
|
if (isHTML) {
|
|
|
|
|
|
toast(<HTMLToastContent htmlContent={message} />, showNoticeOptions);
|
|
|
|
|
|
} else {
|
2023-10-31 22:41:34 +08:00
|
|
|
|
Toast.info(message);
|
2023-08-12 10:49:30 +08:00
|
|
|
|
}
|
2023-04-22 20:39:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function openPage(url) {
|
|
|
|
|
|
window.open(url);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function removeTrailingSlash(url) {
|
2025-04-19 00:20:25 +08:00
|
|
|
|
if (!url) return '';
|
2023-04-22 20:39:27 +08:00
|
|
|
|
if (url.endsWith('/')) {
|
|
|
|
|
|
return url.slice(0, -1);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return url;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2023-04-23 12:43:10 +08:00
|
|
|
|
|
2024-08-01 17:36:26 +08:00
|
|
|
|
export function getTodayStartTimestamp() {
|
|
|
|
|
|
var now = new Date();
|
|
|
|
|
|
now.setHours(0, 0, 0, 0);
|
|
|
|
|
|
return Math.floor(now.getTime() / 1000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-04-23 12:43:10 +08:00
|
|
|
|
export function timestamp2string(timestamp) {
|
|
|
|
|
|
let date = new Date(timestamp * 1000);
|
|
|
|
|
|
let year = date.getFullYear().toString();
|
|
|
|
|
|
let month = (date.getMonth() + 1).toString();
|
|
|
|
|
|
let day = date.getDate().toString();
|
|
|
|
|
|
let hour = date.getHours().toString();
|
|
|
|
|
|
let minute = date.getMinutes().toString();
|
|
|
|
|
|
let second = date.getSeconds().toString();
|
|
|
|
|
|
if (month.length === 1) {
|
|
|
|
|
|
month = '0' + month;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (day.length === 1) {
|
|
|
|
|
|
day = '0' + day;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (hour.length === 1) {
|
|
|
|
|
|
hour = '0' + hour;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (minute.length === 1) {
|
|
|
|
|
|
minute = '0' + minute;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (second.length === 1) {
|
|
|
|
|
|
second = '0' + second;
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
2024-03-23 21:24:39 +08:00
|
|
|
|
year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + second
|
2023-04-23 12:43:10 +08:00
|
|
|
|
);
|
2023-04-26 17:13:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 14:37:31 +08:00
|
|
|
|
export function timestamp2string1(
|
|
|
|
|
|
timestamp,
|
|
|
|
|
|
dataExportDefaultTime = 'hour',
|
|
|
|
|
|
showYear = false,
|
|
|
|
|
|
) {
|
2024-01-07 18:31:14 +08:00
|
|
|
|
let date = new Date(timestamp * 1000);
|
2026-01-01 15:42:15 +08:00
|
|
|
|
let year = date.getFullYear();
|
2024-01-07 18:31:14 +08:00
|
|
|
|
let month = (date.getMonth() + 1).toString();
|
|
|
|
|
|
let day = date.getDate().toString();
|
|
|
|
|
|
let hour = date.getHours().toString();
|
|
|
|
|
|
if (month.length === 1) {
|
|
|
|
|
|
month = '0' + month;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (day.length === 1) {
|
|
|
|
|
|
day = '0' + day;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (hour.length === 1) {
|
|
|
|
|
|
hour = '0' + hour;
|
|
|
|
|
|
}
|
2026-01-01 15:42:15 +08:00
|
|
|
|
// 仅在跨年时显示年份
|
|
|
|
|
|
let str = showYear ? year + '-' + month + '-' + day : month + '-' + day;
|
2024-01-13 00:33:52 +08:00
|
|
|
|
if (dataExportDefaultTime === 'hour') {
|
2024-03-23 21:24:39 +08:00
|
|
|
|
str += ' ' + hour + ':00';
|
2024-01-13 00:33:52 +08:00
|
|
|
|
} else if (dataExportDefaultTime === 'week') {
|
|
|
|
|
|
let nextWeek = new Date(timestamp * 1000 + 6 * 24 * 60 * 60 * 1000);
|
2026-01-01 15:42:15 +08:00
|
|
|
|
let nextWeekYear = nextWeek.getFullYear();
|
2024-01-13 00:33:52 +08:00
|
|
|
|
let nextMonth = (nextWeek.getMonth() + 1).toString();
|
|
|
|
|
|
let nextDay = nextWeek.getDate().toString();
|
|
|
|
|
|
if (nextMonth.length === 1) {
|
2024-03-23 21:24:39 +08:00
|
|
|
|
nextMonth = '0' + nextMonth;
|
2024-01-13 00:33:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (nextDay.length === 1) {
|
2024-03-23 21:24:39 +08:00
|
|
|
|
nextDay = '0' + nextDay;
|
2024-01-13 00:33:52 +08:00
|
|
|
|
}
|
2026-01-01 15:42:15 +08:00
|
|
|
|
// 周视图结束日期也仅在跨年时显示年份
|
2026-02-02 14:37:31 +08:00
|
|
|
|
let nextStr = showYear
|
|
|
|
|
|
? nextWeekYear + '-' + nextMonth + '-' + nextDay
|
|
|
|
|
|
: nextMonth + '-' + nextDay;
|
2026-01-01 15:42:15 +08:00
|
|
|
|
str += ' - ' + nextStr;
|
2024-01-13 00:33:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
return str;
|
2024-01-07 18:31:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 15:42:15 +08:00
|
|
|
|
// 检查时间戳数组是否跨年
|
|
|
|
|
|
export function isDataCrossYear(timestamps) {
|
|
|
|
|
|
if (!timestamps || timestamps.length === 0) return false;
|
2026-02-02 14:37:31 +08:00
|
|
|
|
const years = new Set(
|
|
|
|
|
|
timestamps.map((ts) => new Date(ts * 1000).getFullYear()),
|
|
|
|
|
|
);
|
2026-01-01 15:42:15 +08:00
|
|
|
|
return years.size > 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-04-26 17:13:08 +08:00
|
|
|
|
export function downloadTextAsFile(text, filename) {
|
|
|
|
|
|
let blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
|
|
|
|
|
let url = URL.createObjectURL(blob);
|
|
|
|
|
|
let a = document.createElement('a');
|
|
|
|
|
|
a.href = url;
|
|
|
|
|
|
a.download = filename;
|
|
|
|
|
|
a.click();
|
2023-05-11 20:59:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const verifyJSON = (str) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
JSON.parse(str);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
2023-10-14 16:32:01 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2024-05-24 15:28:16 +08:00
|
|
|
|
export function verifyJSONPromise(value) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
JSON.parse(value);
|
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return Promise.reject('不是合法的 JSON 字符串');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-10-14 16:32:01 +08:00
|
|
|
|
export function shouldShowPrompt(id) {
|
|
|
|
|
|
let prompt = localStorage.getItem(`prompt-${id}`);
|
|
|
|
|
|
return !prompt;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function setPromptShown(id) {
|
|
|
|
|
|
localStorage.setItem(`prompt-${id}`, 'true');
|
2024-03-23 21:24:39 +08:00
|
|
|
|
}
|
2024-05-08 14:57:36 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 比较两个对象的属性,找出有变化的属性,并返回包含变化属性信息的数组
|
|
|
|
|
|
* @param {Object} oldObject - 旧对象
|
|
|
|
|
|
* @param {Object} newObject - 新对象
|
|
|
|
|
|
* @return {Array} 包含变化属性信息的数组,每个元素是一个对象,包含 key, oldValue 和 newValue
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function compareObjects(oldObject, newObject) {
|
|
|
|
|
|
const changedProperties = [];
|
|
|
|
|
|
|
|
|
|
|
|
// 比较两个对象的属性
|
|
|
|
|
|
for (const key in oldObject) {
|
|
|
|
|
|
if (oldObject.hasOwnProperty(key) && newObject.hasOwnProperty(key)) {
|
|
|
|
|
|
if (oldObject[key] !== newObject[key]) {
|
|
|
|
|
|
changedProperties.push({
|
|
|
|
|
|
key: key,
|
|
|
|
|
|
oldValue: oldObject[key],
|
|
|
|
|
|
newValue: newObject[key],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return changedProperties;
|
|
|
|
|
|
}
|
2025-06-03 23:56:39 +08:00
|
|
|
|
|
|
|
|
|
|
// playground message
|
|
|
|
|
|
|
|
|
|
|
|
// 生成唯一ID
|
|
|
|
|
|
let messageId = 4;
|
|
|
|
|
|
export const generateMessageId = () => `${messageId++}`;
|
|
|
|
|
|
|
|
|
|
|
|
// 提取消息中的文本内容
|
|
|
|
|
|
export const getTextContent = (message) => {
|
|
|
|
|
|
if (!message || !message.content) return '';
|
|
|
|
|
|
|
|
|
|
|
|
if (Array.isArray(message.content)) {
|
2025-08-30 21:15:10 +08:00
|
|
|
|
const textContent = message.content.find((item) => item.type === 'text');
|
2025-06-03 23:56:39 +08:00
|
|
|
|
return textContent?.text || '';
|
|
|
|
|
|
}
|
|
|
|
|
|
return typeof message.content === 'string' ? message.content : '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理 think 标签
|
|
|
|
|
|
export const processThinkTags = (content, reasoningContent = '') => {
|
|
|
|
|
|
if (!content || !content.includes('<think>')) {
|
|
|
|
|
|
return { content, reasoningContent };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const thoughts = [];
|
|
|
|
|
|
const replyParts = [];
|
|
|
|
|
|
let lastIndex = 0;
|
|
|
|
|
|
let match;
|
|
|
|
|
|
|
|
|
|
|
|
THINK_TAG_REGEX.lastIndex = 0;
|
|
|
|
|
|
while ((match = THINK_TAG_REGEX.exec(content)) !== null) {
|
|
|
|
|
|
replyParts.push(content.substring(lastIndex, match.index));
|
|
|
|
|
|
thoughts.push(match[1]);
|
|
|
|
|
|
lastIndex = match.index + match[0].length;
|
|
|
|
|
|
}
|
|
|
|
|
|
replyParts.push(content.substring(lastIndex));
|
|
|
|
|
|
|
2025-08-30 21:15:10 +08:00
|
|
|
|
const processedContent = replyParts
|
|
|
|
|
|
.join('')
|
|
|
|
|
|
.replace(/<\/?think>/g, '')
|
|
|
|
|
|
.trim();
|
2025-06-03 23:56:39 +08:00
|
|
|
|
const thoughtsStr = thoughts.join('\n\n---\n\n');
|
2025-08-30 21:15:10 +08:00
|
|
|
|
const processedReasoningContent =
|
|
|
|
|
|
reasoningContent && thoughtsStr
|
|
|
|
|
|
? `${reasoningContent}\n\n---\n\n${thoughtsStr}`
|
|
|
|
|
|
: reasoningContent || thoughtsStr;
|
2025-06-03 23:56:39 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
content: processedContent,
|
2025-08-30 21:15:10 +08:00
|
|
|
|
reasoningContent: processedReasoningContent,
|
2025-06-03 23:56:39 +08:00
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理未完成的 think 标签
|
|
|
|
|
|
export const processIncompleteThinkTags = (content, reasoningContent = '') => {
|
|
|
|
|
|
if (!content) return { content: '', reasoningContent };
|
|
|
|
|
|
|
|
|
|
|
|
const lastOpenThinkIndex = content.lastIndexOf('<think>');
|
|
|
|
|
|
if (lastOpenThinkIndex === -1) {
|
|
|
|
|
|
return processThinkTags(content, reasoningContent);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const fragmentAfterLastOpen = content.substring(lastOpenThinkIndex);
|
|
|
|
|
|
if (!fragmentAfterLastOpen.includes('</think>')) {
|
2025-08-30 21:15:10 +08:00
|
|
|
|
const unclosedThought = fragmentAfterLastOpen
|
|
|
|
|
|
.substring('<think>'.length)
|
|
|
|
|
|
.trim();
|
2025-06-03 23:56:39 +08:00
|
|
|
|
const cleanContent = content.substring(0, lastOpenThinkIndex);
|
|
|
|
|
|
const processedReasoningContent = unclosedThought
|
2025-08-30 21:15:10 +08:00
|
|
|
|
? reasoningContent
|
|
|
|
|
|
? `${reasoningContent}\n\n---\n\n${unclosedThought}`
|
|
|
|
|
|
: unclosedThought
|
2025-06-03 23:56:39 +08:00
|
|
|
|
: reasoningContent;
|
|
|
|
|
|
|
|
|
|
|
|
return processThinkTags(cleanContent, processedReasoningContent);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return processThinkTags(content, reasoningContent);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 构建消息内容(包含图片)
|
2025-08-30 21:15:10 +08:00
|
|
|
|
export const buildMessageContent = (
|
|
|
|
|
|
textContent,
|
|
|
|
|
|
imageUrls = [],
|
|
|
|
|
|
imageEnabled = false,
|
|
|
|
|
|
) => {
|
2025-06-03 23:56:39 +08:00
|
|
|
|
if (!textContent && (!imageUrls || imageUrls.length === 0)) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-30 21:15:10 +08:00
|
|
|
|
const validImageUrls = imageUrls.filter((url) => url && url.trim() !== '');
|
2025-06-03 23:56:39 +08:00
|
|
|
|
|
|
|
|
|
|
if (imageEnabled && validImageUrls.length > 0) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
{ type: 'text', text: textContent || '' },
|
2025-08-30 21:15:10 +08:00
|
|
|
|
...validImageUrls.map((url) => ({
|
2025-06-03 23:56:39 +08:00
|
|
|
|
type: 'image_url',
|
2025-08-30 21:15:10 +08:00
|
|
|
|
image_url: { url: url.trim() },
|
|
|
|
|
|
})),
|
2025-06-03 23:56:39 +08:00
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return textContent || '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 创建新消息
|
|
|
|
|
|
export const createMessage = (role, content, options = {}) => ({
|
|
|
|
|
|
role,
|
|
|
|
|
|
content,
|
|
|
|
|
|
createAt: Date.now(),
|
|
|
|
|
|
id: generateMessageId(),
|
2025-08-30 21:15:10 +08:00
|
|
|
|
...options,
|
2025-06-03 23:56:39 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 创建加载中的助手消息
|
2025-08-30 21:15:10 +08:00
|
|
|
|
export const createLoadingAssistantMessage = () =>
|
|
|
|
|
|
createMessage(MESSAGE_ROLES.ASSISTANT, '', {
|
2025-06-03 23:56:39 +08:00
|
|
|
|
reasoningContent: '',
|
|
|
|
|
|
isReasoningExpanded: true,
|
|
|
|
|
|
isThinkingComplete: false,
|
|
|
|
|
|
hasAutoCollapsed: false,
|
2025-08-30 21:15:10 +08:00
|
|
|
|
status: 'loading',
|
|
|
|
|
|
});
|
2025-06-03 23:56:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 检查消息是否包含图片
|
|
|
|
|
|
export const hasImageContent = (message) => {
|
2025-08-30 21:15:10 +08:00
|
|
|
|
return (
|
|
|
|
|
|
message &&
|
2025-06-03 23:56:39 +08:00
|
|
|
|
Array.isArray(message.content) &&
|
2025-08-30 21:15:10 +08:00
|
|
|
|
message.content.some((item) => item.type === 'image_url')
|
|
|
|
|
|
);
|
2025-06-03 23:56:39 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化消息用于API请求
|
|
|
|
|
|
export const formatMessageForAPI = (message) => {
|
|
|
|
|
|
if (!message) return null;
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
role: message.role,
|
2025-08-30 21:15:10 +08:00
|
|
|
|
content: message.content,
|
2025-06-03 23:56:39 +08:00
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 验证消息是否有效
|
|
|
|
|
|
export const isValidMessage = (message) => {
|
2025-08-30 21:15:10 +08:00
|
|
|
|
return message && message.role && (message.content || message.content === '');
|
2025-06-03 23:56:39 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 获取最后一条用户消息
|
|
|
|
|
|
export const getLastUserMessage = (messages) => {
|
|
|
|
|
|
if (!Array.isArray(messages)) return null;
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
|
|
|
|
if (messages[i].role === MESSAGE_ROLES.USER) {
|
|
|
|
|
|
return messages[i];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 获取最后一条助手消息
|
|
|
|
|
|
export const getLastAssistantMessage = (messages) => {
|
|
|
|
|
|
if (!Array.isArray(messages)) return null;
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
|
|
|
|
if (messages[i].role === MESSAGE_ROLES.ASSISTANT) {
|
|
|
|
|
|
return messages[i];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
};
|
2025-06-10 20:10:07 +08:00
|
|
|
|
|
|
|
|
|
|
// 计算相对时间(几天前、几小时前等)
|
|
|
|
|
|
export const getRelativeTime = (publishDate) => {
|
|
|
|
|
|
if (!publishDate) return '';
|
|
|
|
|
|
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const pubDate = new Date(publishDate);
|
|
|
|
|
|
|
|
|
|
|
|
// 如果日期无效,返回原始字符串
|
|
|
|
|
|
if (isNaN(pubDate.getTime())) return publishDate;
|
|
|
|
|
|
|
|
|
|
|
|
const diffMs = now.getTime() - pubDate.getTime();
|
|
|
|
|
|
const diffSeconds = Math.floor(diffMs / 1000);
|
|
|
|
|
|
const diffMinutes = Math.floor(diffSeconds / 60);
|
|
|
|
|
|
const diffHours = Math.floor(diffMinutes / 60);
|
|
|
|
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
|
|
|
|
const diffWeeks = Math.floor(diffDays / 7);
|
|
|
|
|
|
const diffMonths = Math.floor(diffDays / 30);
|
|
|
|
|
|
const diffYears = Math.floor(diffDays / 365);
|
|
|
|
|
|
|
|
|
|
|
|
// 如果是未来时间,显示具体日期
|
|
|
|
|
|
if (diffMs < 0) {
|
|
|
|
|
|
return formatDateString(pubDate);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 根据时间差返回相应的描述
|
|
|
|
|
|
if (diffSeconds < 60) {
|
|
|
|
|
|
return '刚刚';
|
|
|
|
|
|
} else if (diffMinutes < 60) {
|
|
|
|
|
|
return `${diffMinutes} 分钟前`;
|
|
|
|
|
|
} else if (diffHours < 24) {
|
|
|
|
|
|
return `${diffHours} 小时前`;
|
|
|
|
|
|
} else if (diffDays < 7) {
|
|
|
|
|
|
return `${diffDays} 天前`;
|
|
|
|
|
|
} else if (diffWeeks < 4) {
|
|
|
|
|
|
return `${diffWeeks} 周前`;
|
|
|
|
|
|
} else if (diffMonths < 12) {
|
|
|
|
|
|
return `${diffMonths} 个月前`;
|
|
|
|
|
|
} else if (diffYears < 2) {
|
|
|
|
|
|
return '1 年前';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 超过2年显示具体日期
|
|
|
|
|
|
return formatDateString(pubDate);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化日期字符串
|
|
|
|
|
|
export const formatDateString = (date) => {
|
|
|
|
|
|
const year = date.getFullYear();
|
|
|
|
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
|
|
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
|
|
|
|
return `${year}-${month}-${day}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化日期时间字符串(包含时间)
|
|
|
|
|
|
export const formatDateTimeString = (date) => {
|
|
|
|
|
|
const year = date.getFullYear();
|
|
|
|
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
|
|
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
|
|
|
|
const hours = String(date.getHours()).padStart(2, '0');
|
|
|
|
|
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
|
|
|
|
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
|
|
|
|
|
};
|
2025-06-22 18:10:00 +08:00
|
|
|
|
|
|
|
|
|
|
function readTableCompactModes() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const json = localStorage.getItem(TABLE_COMPACT_MODES_KEY);
|
|
|
|
|
|
return json ? JSON.parse(json) : {};
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return {};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function writeTableCompactModes(modes) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
localStorage.setItem(TABLE_COMPACT_MODES_KEY, JSON.stringify(modes));
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function getTableCompactMode(tableKey = 'global') {
|
|
|
|
|
|
const modes = readTableCompactModes();
|
|
|
|
|
|
return !!modes[tableKey];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function setTableCompactMode(compact, tableKey = 'global') {
|
|
|
|
|
|
const modes = readTableCompactModes();
|
|
|
|
|
|
modes[tableKey] = compact;
|
|
|
|
|
|
writeTableCompactModes(modes);
|
|
|
|
|
|
}
|
2025-07-19 13:28:09 +08:00
|
|
|
|
|
|
|
|
|
|
// -------------------------------
|
|
|
|
|
|
// Select 组件统一过滤逻辑
|
2025-07-27 00:01:12 +08:00
|
|
|
|
// 使用方式: <Select filter={selectFilter} ... />
|
|
|
|
|
|
// 统一的 Select 搜索过滤逻辑 -- 支持同时匹配 option.value 与 option.label
|
|
|
|
|
|
export const selectFilter = (input, option) => {
|
2025-07-19 13:28:09 +08:00
|
|
|
|
if (!input) return true;
|
2025-07-27 00:01:12 +08:00
|
|
|
|
|
|
|
|
|
|
const keyword = input.trim().toLowerCase();
|
|
|
|
|
|
const valueText = (option?.value ?? '').toString().toLowerCase();
|
|
|
|
|
|
const labelText = (option?.label ?? '').toString().toLowerCase();
|
|
|
|
|
|
|
|
|
|
|
|
return valueText.includes(keyword) || labelText.includes(keyword);
|
2025-07-19 13:28:09 +08:00
|
|
|
|
};
|
2025-07-20 02:27:33 +08:00
|
|
|
|
|
2025-07-24 03:19:32 +08:00
|
|
|
|
// -------------------------------
|
|
|
|
|
|
// 模型定价计算工具函数
|
|
|
|
|
|
export const calculateModelPrice = ({
|
|
|
|
|
|
record,
|
|
|
|
|
|
selectedGroup,
|
|
|
|
|
|
groupRatio,
|
|
|
|
|
|
tokenUnit,
|
|
|
|
|
|
displayPrice,
|
|
|
|
|
|
currency,
|
2026-03-07 00:23:36 +08:00
|
|
|
|
quotaDisplayType = 'USD',
|
2025-08-10 17:17:49 +08:00
|
|
|
|
precision = 4,
|
2025-07-24 03:19:32 +08:00
|
|
|
|
}) => {
|
2025-08-10 17:17:49 +08:00
|
|
|
|
// 1. 选择实际使用的分组
|
|
|
|
|
|
let usedGroup = selectedGroup;
|
|
|
|
|
|
let usedGroupRatio = groupRatio[selectedGroup];
|
|
|
|
|
|
|
|
|
|
|
|
if (selectedGroup === 'all' || usedGroupRatio === undefined) {
|
|
|
|
|
|
// 在模型可用分组中选择倍率最小的分组,若无则使用 1
|
|
|
|
|
|
let minRatio = Number.POSITIVE_INFINITY;
|
2025-08-30 21:15:10 +08:00
|
|
|
|
if (
|
|
|
|
|
|
Array.isArray(record.enable_groups) &&
|
|
|
|
|
|
record.enable_groups.length > 0
|
|
|
|
|
|
) {
|
2025-08-10 17:17:49 +08:00
|
|
|
|
record.enable_groups.forEach((g) => {
|
|
|
|
|
|
const r = groupRatio[g];
|
|
|
|
|
|
if (r !== undefined && r < minRatio) {
|
|
|
|
|
|
minRatio = r;
|
|
|
|
|
|
usedGroup = g;
|
|
|
|
|
|
usedGroupRatio = r;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果找不到合适分组倍率,回退为 1
|
|
|
|
|
|
if (usedGroupRatio === undefined) {
|
|
|
|
|
|
usedGroupRatio = 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 18:57:14 +08:00
|
|
|
|
// 2. 动态计费(tiered_expr)
|
|
|
|
|
|
if (record.billing_mode === 'tiered_expr' && record.billing_expr) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
isDynamicPricing: true,
|
|
|
|
|
|
billingExpr: record.billing_expr,
|
|
|
|
|
|
usedGroup,
|
|
|
|
|
|
usedGroupRatio,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 根据计费类型计算价格
|
2025-07-24 03:19:32 +08:00
|
|
|
|
if (record.quota_type === 0) {
|
|
|
|
|
|
// 按量计费
|
2026-03-07 00:23:36 +08:00
|
|
|
|
const isTokensDisplay = quotaDisplayType === 'TOKENS';
|
2025-08-10 17:17:49 +08:00
|
|
|
|
const inputRatioPriceUSD = record.model_ratio * 2 * usedGroupRatio;
|
2025-07-24 03:19:32 +08:00
|
|
|
|
const unitDivisor = tokenUnit === 'K' ? 1000 : 1;
|
|
|
|
|
|
const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
|
2026-03-07 00:23:36 +08:00
|
|
|
|
const hasRatioValue = (value) =>
|
|
|
|
|
|
value !== undefined &&
|
|
|
|
|
|
value !== null &&
|
|
|
|
|
|
value !== '' &&
|
|
|
|
|
|
Number.isFinite(Number(value));
|
|
|
|
|
|
|
|
|
|
|
|
const formatRatio = (value) =>
|
|
|
|
|
|
hasRatioValue(value) ? Number(Number(value).toFixed(6)) : null;
|
|
|
|
|
|
|
|
|
|
|
|
if (isTokensDisplay) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
inputRatio: formatRatio(record.model_ratio),
|
|
|
|
|
|
completionRatio: formatRatio(record.completion_ratio),
|
|
|
|
|
|
cacheRatio: formatRatio(record.cache_ratio),
|
|
|
|
|
|
createCacheRatio: formatRatio(record.create_cache_ratio),
|
|
|
|
|
|
imageRatio: formatRatio(record.image_ratio),
|
|
|
|
|
|
audioInputRatio: formatRatio(record.audio_ratio),
|
|
|
|
|
|
audioOutputRatio: formatRatio(record.audio_completion_ratio),
|
|
|
|
|
|
isPerToken: true,
|
|
|
|
|
|
isTokensDisplay: true,
|
|
|
|
|
|
usedGroup,
|
|
|
|
|
|
usedGroupRatio,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2025-07-24 03:19:32 +08:00
|
|
|
|
|
2025-09-29 23:23:31 +08:00
|
|
|
|
let symbol = '$';
|
|
|
|
|
|
if (currency === 'CNY') {
|
|
|
|
|
|
symbol = '¥';
|
|
|
|
|
|
} else if (currency === 'CUSTOM') {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const statusStr = localStorage.getItem('status');
|
|
|
|
|
|
if (statusStr) {
|
|
|
|
|
|
const s = JSON.parse(statusStr);
|
|
|
|
|
|
symbol = s?.custom_currency_symbol || '¤';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
symbol = '¤';
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
symbol = '¤';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-06 22:33:51 +08:00
|
|
|
|
|
|
|
|
|
|
const formatTokenPrice = (priceUSD) => {
|
|
|
|
|
|
const rawDisplayPrice = displayPrice(priceUSD);
|
|
|
|
|
|
const numericPrice =
|
|
|
|
|
|
parseFloat(rawDisplayPrice.replace(/[^0-9.]/g, '')) / unitDivisor;
|
|
|
|
|
|
return `${symbol}${numericPrice.toFixed(precision)}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const inputPrice = formatTokenPrice(inputRatioPriceUSD);
|
|
|
|
|
|
const audioInputPrice = hasRatioValue(record.audio_ratio)
|
|
|
|
|
|
? formatTokenPrice(inputRatioPriceUSD * Number(record.audio_ratio))
|
|
|
|
|
|
: null;
|
|
|
|
|
|
|
2025-07-24 03:19:32 +08:00
|
|
|
|
return {
|
2026-03-06 22:33:51 +08:00
|
|
|
|
inputPrice,
|
|
|
|
|
|
completionPrice: formatTokenPrice(
|
|
|
|
|
|
inputRatioPriceUSD * Number(record.completion_ratio),
|
|
|
|
|
|
),
|
|
|
|
|
|
cachePrice: hasRatioValue(record.cache_ratio)
|
|
|
|
|
|
? formatTokenPrice(inputRatioPriceUSD * Number(record.cache_ratio))
|
|
|
|
|
|
: null,
|
|
|
|
|
|
createCachePrice: hasRatioValue(record.create_cache_ratio)
|
|
|
|
|
|
? formatTokenPrice(inputRatioPriceUSD * Number(record.create_cache_ratio))
|
|
|
|
|
|
: null,
|
|
|
|
|
|
imagePrice: hasRatioValue(record.image_ratio)
|
|
|
|
|
|
? formatTokenPrice(inputRatioPriceUSD * Number(record.image_ratio))
|
|
|
|
|
|
: null,
|
|
|
|
|
|
audioInputPrice,
|
|
|
|
|
|
audioOutputPrice:
|
|
|
|
|
|
audioInputPrice && hasRatioValue(record.audio_completion_ratio)
|
|
|
|
|
|
? formatTokenPrice(
|
|
|
|
|
|
inputRatioPriceUSD *
|
|
|
|
|
|
Number(record.audio_ratio) *
|
|
|
|
|
|
Number(record.audio_completion_ratio),
|
|
|
|
|
|
)
|
|
|
|
|
|
: null,
|
2025-07-24 03:19:32 +08:00
|
|
|
|
unitLabel,
|
2025-08-10 17:17:49 +08:00
|
|
|
|
isPerToken: true,
|
2026-03-07 00:23:36 +08:00
|
|
|
|
isTokensDisplay: false,
|
2025-08-10 17:17:49 +08:00
|
|
|
|
usedGroup,
|
|
|
|
|
|
usedGroupRatio,
|
2025-07-24 03:19:32 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
2025-08-10 17:17:49 +08:00
|
|
|
|
|
2025-08-10 21:09:49 +08:00
|
|
|
|
if (record.quota_type === 1) {
|
|
|
|
|
|
// 按次计费
|
|
|
|
|
|
const priceUSD = parseFloat(record.model_price) * usedGroupRatio;
|
|
|
|
|
|
const displayVal = displayPrice(priceUSD);
|
2025-08-10 17:17:49 +08:00
|
|
|
|
|
2025-08-10 21:09:49 +08:00
|
|
|
|
return {
|
|
|
|
|
|
price: displayVal,
|
|
|
|
|
|
isPerToken: false,
|
2026-03-07 00:23:36 +08:00
|
|
|
|
isTokensDisplay: false,
|
2025-08-10 21:09:49 +08:00
|
|
|
|
usedGroup,
|
|
|
|
|
|
usedGroupRatio,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 未知计费类型,返回占位信息
|
2025-08-10 17:17:49 +08:00
|
|
|
|
return {
|
2025-08-10 21:09:49 +08:00
|
|
|
|
price: '-',
|
2025-08-10 17:17:49 +08:00
|
|
|
|
isPerToken: false,
|
2026-03-07 00:23:36 +08:00
|
|
|
|
isTokensDisplay: false,
|
2025-08-10 17:17:49 +08:00
|
|
|
|
usedGroup,
|
|
|
|
|
|
usedGroupRatio,
|
|
|
|
|
|
};
|
2025-07-24 03:19:32 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-07 00:23:36 +08:00
|
|
|
|
export const getModelPriceItems = (
|
|
|
|
|
|
priceData,
|
|
|
|
|
|
t,
|
|
|
|
|
|
quotaDisplayType = 'USD',
|
|
|
|
|
|
) => {
|
2026-03-16 18:57:14 +08:00
|
|
|
|
if (priceData.isDynamicPricing) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'dynamic',
|
|
|
|
|
|
label: t('动态计费'),
|
|
|
|
|
|
value: '',
|
|
|
|
|
|
suffix: '',
|
|
|
|
|
|
isDynamic: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-24 03:19:32 +08:00
|
|
|
|
if (priceData.isPerToken) {
|
2026-03-07 00:23:36 +08:00
|
|
|
|
if (quotaDisplayType === 'TOKENS' || priceData.isTokensDisplay) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'input-ratio',
|
|
|
|
|
|
label: t('输入倍率'),
|
|
|
|
|
|
value: priceData.inputRatio,
|
|
|
|
|
|
suffix: 'x',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'completion-ratio',
|
|
|
|
|
|
label: t('补全倍率'),
|
|
|
|
|
|
value: priceData.completionRatio,
|
|
|
|
|
|
suffix: 'x',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'cache-ratio',
|
|
|
|
|
|
label: t('缓存读取倍率'),
|
|
|
|
|
|
value: priceData.cacheRatio,
|
|
|
|
|
|
suffix: 'x',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'create-cache-ratio',
|
|
|
|
|
|
label: t('缓存创建倍率'),
|
|
|
|
|
|
value: priceData.createCacheRatio,
|
|
|
|
|
|
suffix: 'x',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'image-ratio',
|
|
|
|
|
|
label: t('图片输入倍率'),
|
|
|
|
|
|
value: priceData.imageRatio,
|
|
|
|
|
|
suffix: 'x',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'audio-input-ratio',
|
|
|
|
|
|
label: t('音频输入倍率'),
|
|
|
|
|
|
value: priceData.audioInputRatio,
|
|
|
|
|
|
suffix: 'x',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'audio-output-ratio',
|
|
|
|
|
|
label: t('音频补全倍率'),
|
|
|
|
|
|
value: priceData.audioOutputRatio,
|
|
|
|
|
|
suffix: 'x',
|
|
|
|
|
|
},
|
|
|
|
|
|
].filter(
|
|
|
|
|
|
(item) =>
|
|
|
|
|
|
item.value !== null && item.value !== undefined && item.value !== '',
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-06 22:33:51 +08:00
|
|
|
|
const unitSuffix = ` / 1${priceData.unitLabel} Tokens`;
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'input',
|
|
|
|
|
|
label: t('输入价格'),
|
|
|
|
|
|
value: priceData.inputPrice,
|
|
|
|
|
|
suffix: unitSuffix,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'completion',
|
|
|
|
|
|
label: t('补全价格'),
|
|
|
|
|
|
value: priceData.completionPrice,
|
|
|
|
|
|
suffix: unitSuffix,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'cache',
|
|
|
|
|
|
label: t('缓存读取价格'),
|
|
|
|
|
|
value: priceData.cachePrice,
|
|
|
|
|
|
suffix: unitSuffix,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'create-cache',
|
|
|
|
|
|
label: t('缓存创建价格'),
|
|
|
|
|
|
value: priceData.createCachePrice,
|
|
|
|
|
|
suffix: unitSuffix,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'image',
|
|
|
|
|
|
label: t('图片输入价格'),
|
|
|
|
|
|
value: priceData.imagePrice,
|
|
|
|
|
|
suffix: unitSuffix,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'audio-input',
|
|
|
|
|
|
label: t('音频输入价格'),
|
|
|
|
|
|
value: priceData.audioInputPrice,
|
|
|
|
|
|
suffix: unitSuffix,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'audio-output',
|
|
|
|
|
|
label: t('音频补全价格'),
|
|
|
|
|
|
value: priceData.audioOutputPrice,
|
|
|
|
|
|
suffix: unitSuffix,
|
|
|
|
|
|
},
|
|
|
|
|
|
].filter((item) => item.value !== null && item.value !== undefined && item.value !== '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'fixed',
|
|
|
|
|
|
label: t('模型价格'),
|
|
|
|
|
|
value: priceData.price,
|
|
|
|
|
|
suffix: ` / ${t('次')}`,
|
|
|
|
|
|
},
|
|
|
|
|
|
].filter((item) => item.value !== null && item.value !== undefined && item.value !== '');
|
|
|
|
|
|
};
|
2025-08-10 17:17:49 +08:00
|
|
|
|
|
2026-03-16 18:57:14 +08:00
|
|
|
|
// 格式化动态计费摘要(用于卡片视图,与 formatPriceInfo 风格统一)
|
2026-03-16 20:11:55 +08:00
|
|
|
|
export const formatDynamicPriceSummary = (billingExpr, t, groupRatio = 1) => {
|
2026-03-16 18:57:14 +08:00
|
|
|
|
if (!billingExpr) return <span style={{ color: 'var(--semi-color-text-1)' }}>{t('动态计费')}</span>;
|
|
|
|
|
|
|
2026-04-24 13:47:52 +08:00
|
|
|
|
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
|
|
|
|
|
let symbol = '$';
|
|
|
|
|
|
let rate = 1;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const s = JSON.parse(localStorage.getItem('status') || '{}');
|
|
|
|
|
|
if (quotaDisplayType === 'CNY') {
|
|
|
|
|
|
symbol = '¥';
|
|
|
|
|
|
rate = s?.usd_exchange_rate || 7;
|
|
|
|
|
|
} else if (quotaDisplayType === 'CUSTOM') {
|
|
|
|
|
|
symbol = s?.custom_currency_symbol || '¤';
|
|
|
|
|
|
rate = s?.custom_currency_exchange_rate || 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
|
2026-03-16 20:11:55 +08:00
|
|
|
|
const gr = groupRatio || 1;
|
2026-03-17 15:29:43 +08:00
|
|
|
|
const exprBody = billingExpr.replace(/^v\d+:/, '');
|
|
|
|
|
|
const tierMatches = exprBody.match(/tier\(/g) || [];
|
2026-03-16 18:57:14 +08:00
|
|
|
|
const tierCount = tierMatches.length;
|
|
|
|
|
|
|
2026-03-16 22:00:36 +08:00
|
|
|
|
const varCoeffs = {};
|
2026-03-17 15:29:43 +08:00
|
|
|
|
const varRe = new RegExp(BILLING_VAR_REGEX.source, 'g');
|
2026-03-16 22:00:36 +08:00
|
|
|
|
let vm;
|
2026-03-17 15:29:43 +08:00
|
|
|
|
while ((vm = varRe.exec(exprBody)) !== null) {
|
2026-03-16 22:00:36 +08:00
|
|
|
|
if (!(vm[1] in varCoeffs)) varCoeffs[vm[1]] = Number(vm[2]);
|
|
|
|
|
|
}
|
|
|
|
|
|
const hasCoeffs = 'p' in varCoeffs || 'c' in varCoeffs;
|
|
|
|
|
|
|
2026-04-25 13:24:20 +08:00
|
|
|
|
const varLabels = BILLING_PRICING_VARS.map((v) => [v.key, v.label]);
|
2026-03-17 15:29:43 +08:00
|
|
|
|
|
2026-04-24 00:34:06 +08:00
|
|
|
|
const hasTimeCondition = /\b(?:hour|minute|weekday|month|day)\(/.test(exprBody);
|
2026-03-17 15:29:43 +08:00
|
|
|
|
const hasRequestCondition = /\b(?:param|header)\(/.test(exprBody);
|
2026-03-16 18:57:14 +08:00
|
|
|
|
|
|
|
|
|
|
const tags = [];
|
|
|
|
|
|
if (tierCount > 1) tags.push(`${tierCount}${t('档')}`);
|
|
|
|
|
|
if (hasTimeCondition) tags.push(t('含时间条件'));
|
|
|
|
|
|
if (hasRequestCondition) tags.push(t('含请求条件'));
|
|
|
|
|
|
|
|
|
|
|
|
const unitSuffix = ' / 1M Tokens';
|
|
|
|
|
|
const lineStyle = { color: 'var(--semi-color-text-1)' };
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
2026-03-16 22:00:36 +08:00
|
|
|
|
{hasCoeffs && (
|
2026-03-16 18:57:14 +08:00
|
|
|
|
<>
|
2026-03-16 22:00:36 +08:00
|
|
|
|
{varLabels.map(([key, label]) =>
|
|
|
|
|
|
key in varCoeffs ? (
|
|
|
|
|
|
<span key={key} style={lineStyle}>
|
2026-04-24 13:47:52 +08:00
|
|
|
|
{`${t(label)} ${symbol}${(varCoeffs[key] * gr * rate).toFixed(4)}${unitSuffix}`}
|
2026-03-16 22:00:36 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
) : null,
|
2026-03-16 18:57:14 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-03-16 22:00:36 +08:00
|
|
|
|
{(tierCount > 1 || hasTimeCondition || hasRequestCondition) && (
|
2026-03-16 18:57:14 +08:00
|
|
|
|
<span style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
|
|
|
|
|
<span
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'inline-block',
|
|
|
|
|
|
padding: '1px 6px',
|
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
|
background: 'var(--semi-color-warning-light-default)',
|
|
|
|
|
|
color: 'var(--semi-color-warning)',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t('动态计费')}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{tags.map((tag) => (
|
|
|
|
|
|
<span
|
|
|
|
|
|
key={tag}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'inline-block',
|
|
|
|
|
|
padding: '1px 6px',
|
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
|
background: 'var(--semi-color-fill-1)',
|
|
|
|
|
|
color: 'var(--semi-color-text-2)',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{tag}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</span>
|
2026-03-16 22:00:36 +08:00
|
|
|
|
)}
|
2026-03-16 18:57:14 +08:00
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-06 22:33:51 +08:00
|
|
|
|
// 格式化价格信息(用于卡片视图)
|
2026-03-07 00:23:36 +08:00
|
|
|
|
export const formatPriceInfo = (priceData, t, quotaDisplayType = 'USD') => {
|
|
|
|
|
|
const items = getModelPriceItems(priceData, t, quotaDisplayType);
|
2025-08-10 17:17:49 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<>
|
2026-03-06 22:33:51 +08:00
|
|
|
|
{items.map((item) => (
|
|
|
|
|
|
<span key={item.key} style={{ color: 'var(--semi-color-text-1)' }}>
|
|
|
|
|
|
{item.label} {item.value}
|
|
|
|
|
|
{item.suffix}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
2025-08-10 17:17:49 +08:00
|
|
|
|
</>
|
|
|
|
|
|
);
|
2025-07-24 03:19:32 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-07-20 02:27:33 +08:00
|
|
|
|
// -------------------------------
|
2025-07-20 11:24:04 +08:00
|
|
|
|
// CardPro 分页配置函数
|
2025-07-20 02:27:33 +08:00
|
|
|
|
// 用于创建 CardPro 的 paginationArea 配置
|
|
|
|
|
|
export const createCardProPagination = ({
|
|
|
|
|
|
currentPage,
|
|
|
|
|
|
pageSize,
|
|
|
|
|
|
total,
|
|
|
|
|
|
onPageChange,
|
|
|
|
|
|
onPageSizeChange,
|
2025-07-20 11:24:04 +08:00
|
|
|
|
isMobile = false,
|
2025-07-20 02:27:33 +08:00
|
|
|
|
pageSizeOpts = [10, 20, 50, 100],
|
|
|
|
|
|
showSizeChanger = true,
|
2025-07-22 16:11:21 +08:00
|
|
|
|
t = (key) => key,
|
2025-07-20 02:27:33 +08:00
|
|
|
|
}) => {
|
|
|
|
|
|
if (!total || total <= 0) return null;
|
|
|
|
|
|
|
2025-07-22 16:11:21 +08:00
|
|
|
|
const start = (currentPage - 1) * pageSize + 1;
|
|
|
|
|
|
const end = Math.min(currentPage * pageSize, total);
|
|
|
|
|
|
const totalText = `${t('显示第')} ${start} ${t('条 - 第')} ${end} ${t('条,共')} ${total} ${t('条')}`;
|
|
|
|
|
|
|
2025-07-20 02:27:33 +08:00
|
|
|
|
return (
|
2025-07-22 16:11:21 +08:00
|
|
|
|
<>
|
|
|
|
|
|
{/* 桌面端左侧总数信息 */}
|
|
|
|
|
|
{!isMobile && (
|
|
|
|
|
|
<span
|
2025-08-30 21:15:10 +08:00
|
|
|
|
className='text-sm select-none'
|
2025-07-22 16:11:21 +08:00
|
|
|
|
style={{ color: 'var(--semi-color-text-2)' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{totalText}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 右侧分页控件 */}
|
|
|
|
|
|
<Pagination
|
|
|
|
|
|
currentPage={currentPage}
|
|
|
|
|
|
pageSize={pageSize}
|
|
|
|
|
|
total={total}
|
|
|
|
|
|
pageSizeOpts={pageSizeOpts}
|
|
|
|
|
|
showSizeChanger={showSizeChanger}
|
|
|
|
|
|
onPageSizeChange={onPageSizeChange}
|
|
|
|
|
|
onPageChange={onPageChange}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
size={isMobile ? 'small' : 'default'}
|
2025-07-22 16:11:21 +08:00
|
|
|
|
showQuickJumper={isMobile}
|
|
|
|
|
|
showTotal
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
2025-07-20 02:27:33 +08:00
|
|
|
|
);
|
|
|
|
|
|
};
|
2025-07-23 03:29:11 +08:00
|
|
|
|
|
2025-07-24 17:22:20 +08:00
|
|
|
|
// 模型定价筛选条件默认值
|
|
|
|
|
|
const DEFAULT_PRICING_FILTERS = {
|
|
|
|
|
|
search: '',
|
|
|
|
|
|
showWithRecharge: false,
|
|
|
|
|
|
currency: 'USD',
|
|
|
|
|
|
showRatio: false,
|
|
|
|
|
|
viewMode: 'card',
|
|
|
|
|
|
tokenUnit: 'M',
|
|
|
|
|
|
filterGroup: 'all',
|
|
|
|
|
|
filterQuotaType: 'all',
|
|
|
|
|
|
filterEndpointType: 'all',
|
2025-08-04 21:36:31 +08:00
|
|
|
|
filterVendor: 'all',
|
2025-08-10 14:05:25 +08:00
|
|
|
|
filterTag: 'all',
|
2025-07-24 17:22:20 +08:00
|
|
|
|
currentPage: 1,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-07-23 03:29:11 +08:00
|
|
|
|
// 重置模型定价筛选条件
|
|
|
|
|
|
export const resetPricingFilters = ({
|
|
|
|
|
|
handleChange,
|
|
|
|
|
|
setShowWithRecharge,
|
|
|
|
|
|
setCurrency,
|
|
|
|
|
|
setShowRatio,
|
2025-07-24 03:19:32 +08:00
|
|
|
|
setViewMode,
|
2025-07-23 03:29:11 +08:00
|
|
|
|
setFilterGroup,
|
|
|
|
|
|
setFilterQuotaType,
|
2025-07-24 03:25:57 +08:00
|
|
|
|
setFilterEndpointType,
|
2025-08-04 21:36:31 +08:00
|
|
|
|
setFilterVendor,
|
2025-08-10 14:05:25 +08:00
|
|
|
|
setFilterTag,
|
2025-07-24 03:19:32 +08:00
|
|
|
|
setCurrentPage,
|
2025-07-24 17:10:08 +08:00
|
|
|
|
setTokenUnit,
|
2025-07-23 03:29:11 +08:00
|
|
|
|
}) => {
|
2025-07-24 17:22:20 +08:00
|
|
|
|
handleChange?.(DEFAULT_PRICING_FILTERS.search);
|
|
|
|
|
|
setShowWithRecharge?.(DEFAULT_PRICING_FILTERS.showWithRecharge);
|
|
|
|
|
|
setCurrency?.(DEFAULT_PRICING_FILTERS.currency);
|
|
|
|
|
|
setShowRatio?.(DEFAULT_PRICING_FILTERS.showRatio);
|
|
|
|
|
|
setViewMode?.(DEFAULT_PRICING_FILTERS.viewMode);
|
|
|
|
|
|
setTokenUnit?.(DEFAULT_PRICING_FILTERS.tokenUnit);
|
|
|
|
|
|
setFilterGroup?.(DEFAULT_PRICING_FILTERS.filterGroup);
|
|
|
|
|
|
setFilterQuotaType?.(DEFAULT_PRICING_FILTERS.filterQuotaType);
|
|
|
|
|
|
setFilterEndpointType?.(DEFAULT_PRICING_FILTERS.filterEndpointType);
|
2025-08-04 21:36:31 +08:00
|
|
|
|
setFilterVendor?.(DEFAULT_PRICING_FILTERS.filterVendor);
|
2025-08-10 14:05:25 +08:00
|
|
|
|
setFilterTag?.(DEFAULT_PRICING_FILTERS.filterTag);
|
2025-07-24 17:22:20 +08:00
|
|
|
|
setCurrentPage?.(DEFAULT_PRICING_FILTERS.currentPage);
|
2025-07-23 03:29:11 +08:00
|
|
|
|
};
|