Merge remote-tracking branch 'origin/main' into nightly
# Conflicts: # web/src/helpers/render.jsx # web/src/hooks/usage-logs/useUsageLogsData.jsx # web/src/i18n/locales/en.json
This commit is contained in:
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { API, showError } from '../../../helpers';
|
||||
import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui';
|
||||
const { Title } = Typography;
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MarkdownRenderer from '../markdown/MarkdownRenderer';
|
||||
|
||||
// 检查是否为 URL
|
||||
// Check whether content is a URL.
|
||||
const isUrl = (content) => {
|
||||
try {
|
||||
new URL(content.trim());
|
||||
@@ -38,27 +38,23 @@ const isUrl = (content) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 检查是否为 HTML 内容
|
||||
// Check whether content contains HTML.
|
||||
const isHtmlContent = (content) => {
|
||||
if (!content || typeof content !== 'string') return false;
|
||||
|
||||
// 检查是否包含HTML标签
|
||||
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
|
||||
return htmlTagRegex.test(content);
|
||||
};
|
||||
|
||||
// 安全地渲染HTML内容
|
||||
// Parse HTML content and extract inline styles.
|
||||
const sanitizeHtml = (html) => {
|
||||
// 创建一个临时元素来解析HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
|
||||
// 提取样式
|
||||
const styles = Array.from(tempDiv.querySelectorAll('style'))
|
||||
.map((style) => style.innerHTML)
|
||||
.join('\n');
|
||||
|
||||
// 提取body内容,如果没有body标签则使用全部内容
|
||||
const bodyContent = tempDiv.querySelector('body');
|
||||
const content = bodyContent ? bodyContent.innerHTML : html;
|
||||
|
||||
@@ -76,15 +72,11 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
const { t } = useTranslation();
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [htmlStyles, setHtmlStyles] = useState('');
|
||||
const [processedHtmlContent, setProcessedHtmlContent] = useState('');
|
||||
|
||||
const loadContent = async () => {
|
||||
// 先从缓存中获取
|
||||
const cachedContent = localStorage.getItem(cacheKey) || '';
|
||||
if (cachedContent) {
|
||||
setContent(cachedContent);
|
||||
processContent(cachedContent);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -93,7 +85,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
const { success, message, data } = res.data;
|
||||
if (success && data) {
|
||||
setContent(data);
|
||||
processContent(data);
|
||||
localStorage.setItem(cacheKey, data);
|
||||
} else {
|
||||
if (!cachedContent) {
|
||||
@@ -111,16 +102,12 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const processContent = (rawContent) => {
|
||||
if (isHtmlContent(rawContent)) {
|
||||
const { content: htmlContent, styles } = sanitizeHtml(rawContent);
|
||||
setProcessedHtmlContent(htmlContent);
|
||||
setHtmlStyles(styles);
|
||||
} else {
|
||||
setProcessedHtmlContent('');
|
||||
setHtmlStyles('');
|
||||
const htmlPayload = useMemo(() => {
|
||||
if (!isHtmlContent(content)) {
|
||||
return { content: '', styles: '' };
|
||||
}
|
||||
};
|
||||
return sanitizeHtml(content);
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
loadContent();
|
||||
@@ -129,8 +116,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
// 处理HTML样式注入
|
||||
useEffect(() => {
|
||||
const styleId = `document-renderer-styles-${cacheKey}`;
|
||||
const { styles } = htmlPayload;
|
||||
|
||||
if (htmlStyles) {
|
||||
if (styles) {
|
||||
let styleEl = document.getElementById(styleId);
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
@@ -138,7 +126,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
styleEl.type = 'text/css';
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.innerHTML = htmlStyles;
|
||||
styleEl.innerHTML = styles;
|
||||
} else {
|
||||
const el = document.getElementById(styleId);
|
||||
if (el) el.remove();
|
||||
@@ -148,7 +136,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
const el = document.getElementById(styleId);
|
||||
if (el) el.remove();
|
||||
};
|
||||
}, [htmlStyles, cacheKey]);
|
||||
}, [cacheKey, htmlPayload]);
|
||||
|
||||
// 显示加载状态
|
||||
if (loading) {
|
||||
@@ -207,15 +195,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
|
||||
// 如果是 HTML 内容,直接渲染
|
||||
if (isHtmlContent(content)) {
|
||||
const { content: htmlContent, styles } = sanitizeHtml(content);
|
||||
|
||||
// 设置样式(如果有的话)
|
||||
useEffect(() => {
|
||||
if (styles && styles !== htmlStyles) {
|
||||
setHtmlStyles(styles);
|
||||
}
|
||||
}, [content, styles, htmlStyles]);
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-50'>
|
||||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
||||
@@ -225,7 +204,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
</Title>
|
||||
<div
|
||||
className='prose prose-lg max-w-none'
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
dangerouslySetInnerHTML={{ __html: htmlPayload.content }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
52
web/src/components/common/ErrorBoundary.jsx
Normal file
52
web/src/components/common/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { Empty, Button } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationFailure,
|
||||
IllustrationFailureDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { withTranslation } from 'react-i18next';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('[ErrorBoundary]', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
const { t } = this.props;
|
||||
return (
|
||||
<div className='flex flex-col justify-center items-center h-screen p-8'>
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationFailure style={{ width: 250, height: 250 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationFailureDark style={{ width: 250, height: 250 }} />
|
||||
}
|
||||
description={t('页面渲染出错,请刷新页面重试')}
|
||||
/>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
style={{ marginTop: 16 }}
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
{t('刷新页面')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()(ErrorBoundary);
|
||||
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui';
|
||||
import { Server, Gauge, ExternalLink } from 'lucide-react';
|
||||
import { Server, Gauge, ExternalLink, Copy } from 'lucide-react';
|
||||
import {
|
||||
IllustrationConstruction,
|
||||
IllustrationConstructionDark,
|
||||
@@ -87,11 +87,18 @@ const ApiInfoPanel = ({
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className='!text-semi-color-primary break-all cursor-pointer hover:underline mb-1'
|
||||
onClick={() => handleCopyUrl(api.url)}
|
||||
>
|
||||
{api.url}
|
||||
<div className='flex items-center gap-1 mb-1'>
|
||||
<span
|
||||
className='!text-semi-color-primary break-all cursor-pointer hover:underline'
|
||||
onClick={() => handleCopyUrl(api.url)}
|
||||
>
|
||||
{api.url}
|
||||
</span>
|
||||
<Copy
|
||||
size={14}
|
||||
className='flex-shrink-0 text-gray-400 hover:text-semi-color-primary cursor-pointer transition-colors'
|
||||
onClick={() => handleCopyUrl(api.url)}
|
||||
/>
|
||||
</div>
|
||||
<div className='text-gray-500'>{api.description}</div>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,9 @@ const ChartsPanel = ({
|
||||
spec_model_line,
|
||||
spec_pie,
|
||||
spec_rank_bar,
|
||||
spec_user_rank,
|
||||
spec_user_trend,
|
||||
isAdminUser,
|
||||
CARD_PROPS,
|
||||
CHART_CONFIG,
|
||||
FLEX_CENTER_GAP2,
|
||||
@@ -51,9 +54,15 @@ const ChartsPanel = ({
|
||||
onChange={setActiveChartTab}
|
||||
>
|
||||
<TabPane tab={<span>{t('消耗分布')}</span>} itemKey='1' />
|
||||
<TabPane tab={<span>{t('消耗趋势')}</span>} itemKey='2' />
|
||||
<TabPane tab={<span>{t('调用趋势')}</span>} itemKey='2' />
|
||||
<TabPane tab={<span>{t('调用次数分布')}</span>} itemKey='3' />
|
||||
<TabPane tab={<span>{t('调用次数排行')}</span>} itemKey='4' />
|
||||
{isAdminUser && (
|
||||
<TabPane tab={<span>{t('用户消耗排行')}</span>} itemKey='5' />
|
||||
)}
|
||||
{isAdminUser && (
|
||||
<TabPane tab={<span>{t('用户消耗趋势')}</span>} itemKey='6' />
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
}
|
||||
@@ -72,6 +81,12 @@ const ChartsPanel = ({
|
||||
{activeChartTab === '4' && (
|
||||
<VChart spec={spec_rank_bar} option={CHART_CONFIG} />
|
||||
)}
|
||||
{activeChartTab === '5' && isAdminUser && (
|
||||
<VChart spec={spec_user_rank} option={CHART_CONFIG} />
|
||||
)}
|
||||
{activeChartTab === '6' && isAdminUser && (
|
||||
<VChart spec={spec_user_trend} option={CHART_CONFIG} />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -86,12 +86,22 @@ const Dashboard = () => {
|
||||
);
|
||||
|
||||
// ========== 数据处理 ==========
|
||||
const loadUserData = async () => {
|
||||
if (dashboardData.isAdminUser) {
|
||||
const userData = await dashboardData.loadUserQuotaData();
|
||||
if (userData && userData.length > 0) {
|
||||
dashboardCharts.updateUserChartData(userData);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const initChart = async () => {
|
||||
await dashboardData.loadQuotaData().then((data) => {
|
||||
if (data && data.length > 0) {
|
||||
dashboardCharts.updateChartData(data);
|
||||
}
|
||||
});
|
||||
await loadUserData();
|
||||
await dashboardData.loadUptimeData();
|
||||
};
|
||||
|
||||
@@ -100,10 +110,12 @@ const Dashboard = () => {
|
||||
if (data && data.length > 0) {
|
||||
dashboardCharts.updateChartData(data);
|
||||
}
|
||||
await loadUserData();
|
||||
};
|
||||
|
||||
const handleSearchConfirm = async () => {
|
||||
await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData);
|
||||
await loadUserData();
|
||||
};
|
||||
|
||||
// ========== 数据准备 ==========
|
||||
@@ -182,6 +194,9 @@ const Dashboard = () => {
|
||||
spec_model_line={dashboardCharts.spec_model_line}
|
||||
spec_pie={dashboardCharts.spec_pie}
|
||||
spec_rank_bar={dashboardCharts.spec_rank_bar}
|
||||
spec_user_rank={dashboardCharts.spec_user_rank}
|
||||
spec_user_trend={dashboardCharts.spec_user_trend}
|
||||
isAdminUser={dashboardData.isAdminUser}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
CHART_CONFIG={CHART_CONFIG}
|
||||
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
||||
|
||||
@@ -23,6 +23,7 @@ import SiderBar from './SiderBar';
|
||||
import App from '../../App';
|
||||
import FooterBar from './Footer';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import ErrorBoundary from '../common/ErrorBoundary';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { useIsMobile } from '../../hooks/common/useIsMobile';
|
||||
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
|
||||
@@ -216,7 +217,9 @@ const PageLayout = () => {
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</Content>
|
||||
{!shouldHideFooter && (
|
||||
<Layout.Footer
|
||||
|
||||
@@ -95,13 +95,15 @@ const ThemeToggle = ({ theme, onThemeToggle, t }) => {
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
icon={currentButtonIcon}
|
||||
aria-label={t('切换主题')}
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 !rounded-full !bg-semi-color-fill-0 hover:!bg-semi-color-fill-1'
|
||||
/>
|
||||
<span className='inline-flex'>
|
||||
<Button
|
||||
icon={currentButtonIcon}
|
||||
aria-label={t('切换主题')}
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 !rounded-full !bg-semi-color-fill-0 hover:!bg-semi-color-fill-1'
|
||||
/>
|
||||
</span>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Input, Slider, Typography, Button, Tag } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Slider,
|
||||
Typography,
|
||||
Button,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Hash,
|
||||
@@ -241,15 +248,14 @@ const ParameterControl = ({
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
<InputNumber
|
||||
placeholder='MaxTokens'
|
||||
name='max_tokens'
|
||||
required
|
||||
autoComplete='new-password'
|
||||
defaultValue={0}
|
||||
value={inputs.max_tokens}
|
||||
onChange={(value) => onInputChange('max_tokens', value)}
|
||||
className='!rounded-lg'
|
||||
onNumberChange={(value) => onInputChange('max_tokens', value)}
|
||||
min={0}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
disabled={!parameterEnabled.max_tokens || disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -65,11 +65,15 @@ export const loadConfig = () => {
|
||||
const savedConfig = localStorage.getItem(STORAGE_KEYS.CONFIG);
|
||||
if (savedConfig) {
|
||||
const parsedConfig = JSON.parse(savedConfig);
|
||||
const parsedMaxTokens = parseInt(parsedConfig?.inputs?.max_tokens, 10);
|
||||
|
||||
const mergedConfig = {
|
||||
inputs: {
|
||||
...DEFAULT_CONFIG.inputs,
|
||||
...parsedConfig.inputs,
|
||||
max_tokens: Number.isNaN(parsedMaxTokens)
|
||||
? parsedConfig?.inputs?.max_tokens
|
||||
: parsedMaxTokens,
|
||||
},
|
||||
parameterEnabled: {
|
||||
...DEFAULT_CONFIG.parameterEnabled,
|
||||
|
||||
@@ -21,9 +21,8 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ModelPricingCombined from '../../pages/Setting/Ratio/ModelPricingCombined';
|
||||
import GroupRatioSettings from '../../pages/Setting/Ratio/GroupRatioSettings';
|
||||
import ModelRatioSettings from '../../pages/Setting/Ratio/ModelRatioSettings';
|
||||
import ModelSettingsVisualEditor from '../../pages/Setting/Ratio/ModelSettingsVisualEditor';
|
||||
import ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor';
|
||||
import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync';
|
||||
import ToolPriceSettings from '../../pages/Setting/Ratio/ToolPriceSettings';
|
||||
@@ -96,18 +95,14 @@ const RatioSetting = () => {
|
||||
|
||||
return (
|
||||
<Spin spinning={loading} size='large'>
|
||||
{/* 模型倍率设置以及价格编辑器 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<Tabs type='card' defaultActiveKey='visual'>
|
||||
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
|
||||
<ModelRatioSettings options={inputs} refresh={onRefresh} />
|
||||
<Tabs type='card' defaultActiveKey='pricing'>
|
||||
<Tabs.TabPane tab={t('模型定价设置')} itemKey='pricing'>
|
||||
<ModelPricingCombined options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('分组相关设置')} itemKey='group'>
|
||||
<GroupRatioSettings options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('价格设置')} itemKey='visual'>
|
||||
<ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('未设置价格模型')} itemKey='unset_models'>
|
||||
<ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
|
||||
@@ -91,6 +91,7 @@ const SystemSetting = () => {
|
||||
EmailDomainRestrictionEnabled: '',
|
||||
EmailAliasRestrictionEnabled: '',
|
||||
SMTPSSLEnabled: '',
|
||||
SMTPForceAuthLogin: '',
|
||||
EmailDomainWhitelist: [],
|
||||
TelegramOAuthEnabled: '',
|
||||
TelegramBotToken: '',
|
||||
@@ -182,6 +183,7 @@ const SystemSetting = () => {
|
||||
case 'EmailDomainRestrictionEnabled':
|
||||
case 'EmailAliasRestrictionEnabled':
|
||||
case 'SMTPSSLEnabled':
|
||||
case 'SMTPForceAuthLogin':
|
||||
case 'LinuxDOOAuthEnabled':
|
||||
case 'discord.enabled':
|
||||
case 'oidc.enabled':
|
||||
@@ -1335,6 +1337,15 @@ const SystemSetting = () => {
|
||||
>
|
||||
{t('启用SMTP SSL')}
|
||||
</Form.Checkbox>
|
||||
<Form.Checkbox
|
||||
field='SMTPForceAuthLogin'
|
||||
noLabel
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('SMTPForceAuthLogin', e)
|
||||
}
|
||||
>
|
||||
{t('强制使用 AUTH LOGIN')}
|
||||
</Form.Checkbox>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitSMTP}>{t('保存 SMTP 设置')}</Button>
|
||||
|
||||
@@ -88,7 +88,7 @@ const renderStatus = (text, record, t) => {
|
||||
};
|
||||
|
||||
// Render group column
|
||||
const renderGroupColumn = (text, record, t) => {
|
||||
const renderGroupColumn = (text, record, t, groupRatios = {}) => {
|
||||
if (text === 'auto') {
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -104,7 +104,17 @@ const renderGroupColumn = (text, record, t) => {
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return renderGroup(text);
|
||||
const ratio = groupRatios[text];
|
||||
return (
|
||||
<span className='flex items-center gap-1'>
|
||||
{renderGroup(text)}
|
||||
{ratio !== undefined && (
|
||||
<Tag size='small' color='green' shape='circle'>
|
||||
{ratio}x
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Render token key column with show/hide and copy functionality
|
||||
@@ -469,6 +479,7 @@ export const getTokensColumns = ({
|
||||
setEditingToken,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
groupRatios = {},
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
@@ -490,7 +501,7 @@ export const getTokensColumns = ({
|
||||
title: t('分组'),
|
||||
dataIndex: 'group',
|
||||
key: 'group',
|
||||
render: (text, record) => renderGroupColumn(text, record, t),
|
||||
render: (text, record) => renderGroupColumn(text, record, t, groupRatios),
|
||||
},
|
||||
{
|
||||
title: t('密钥'),
|
||||
|
||||
@@ -49,6 +49,7 @@ const TokensTable = (tokensData) => {
|
||||
setEditingToken,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
groupRatios,
|
||||
t,
|
||||
} = tokensData;
|
||||
|
||||
@@ -67,6 +68,7 @@ const TokensTable = (tokensData) => {
|
||||
setEditingToken,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
groupRatios,
|
||||
});
|
||||
}, [
|
||||
t,
|
||||
@@ -81,6 +83,7 @@ const TokensTable = (tokensData) => {
|
||||
setEditingToken,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
groupRatios,
|
||||
]);
|
||||
|
||||
// Handle compact mode by removing fixed positioning
|
||||
|
||||
@@ -366,6 +366,14 @@ const EditTokenModal = (props) => {
|
||||
placeholder={t('令牌分组,默认为用户的分组')}
|
||||
optionList={groups}
|
||||
renderOptionItem={renderGroupOption}
|
||||
filter={(input, option) => {
|
||||
const q = input.toLowerCase();
|
||||
return (
|
||||
option.value?.toLowerCase().includes(q) ||
|
||||
(typeof option.label === 'string' &&
|
||||
option.label.toLowerCase().includes(q))
|
||||
);
|
||||
}}
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
renderTieredModelPriceSimple,
|
||||
} from '../../../helpers';
|
||||
import { IconHelpCircle } from '@douyinfe/semi-icons';
|
||||
import { Route, Sparkles } from 'lucide-react';
|
||||
import { CircleAlert, Route, Sparkles } from 'lucide-react';
|
||||
|
||||
const colors = [
|
||||
'amber',
|
||||
@@ -142,12 +142,58 @@ function renderType(type, t) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderIsStream(bool, t) {
|
||||
function buildStreamStatusTooltip(ss, t) {
|
||||
if (!ss) return null;
|
||||
const lines = [
|
||||
t('流状态') + ':' + t('异常'),
|
||||
(ss.end_reason || 'unknown'),
|
||||
];
|
||||
if (ss.error_count > 0) {
|
||||
lines.push(`${t('软错误')}: ${ss.error_count}`);
|
||||
}
|
||||
if (ss.end_error) {
|
||||
lines.push(ss.end_error);
|
||||
}
|
||||
return (
|
||||
<div style={{ lineHeight: 1.6, display: 'flex', flexDirection: 'column' }}>
|
||||
{lines.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderIsStream(bool, t, streamStatus) {
|
||||
const isError = streamStatus && streamStatus.status !== 'ok';
|
||||
|
||||
if (bool) {
|
||||
return (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('流')}
|
||||
</Tag>
|
||||
<span style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('流')}
|
||||
</Tag>
|
||||
{isError && (
|
||||
<Tooltip content={buildStreamStatusTooltip(streamStatus, t)}>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -4,
|
||||
top: -4,
|
||||
lineHeight: 1,
|
||||
color: '#ef4444',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<CircleAlert
|
||||
size={14}
|
||||
strokeWidth={2.5}
|
||||
color='currentColor'
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
@@ -663,7 +709,7 @@ export const getLogsColumns = ({
|
||||
<Space>
|
||||
{renderUseTime(text, t)}
|
||||
{renderFirstUseTime(other?.frt, t)}
|
||||
{renderIsStream(record.is_stream, t)}
|
||||
{renderIsStream(record.is_stream, t, other?.stream_status)}
|
||||
</Space>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user