mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-18 18:07:28 +00:00
Merge branch 'QuantumNous:main' into main
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
|
||||
/>
|
||||
<title>New API</title>
|
||||
<analytics></analytics>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
9
web/jsconfig.json
Normal file
9
web/jsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -181,8 +181,8 @@ export function PreCode(props) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (ref.current) {
|
||||
const code =
|
||||
ref.current.querySelector('code')?.innerText ?? '';
|
||||
const codeElement = ref.current.querySelector('code');
|
||||
const code = codeElement?.textContent ?? '';
|
||||
copy(code).then((success) => {
|
||||
if (success) {
|
||||
Toast.success(t('代码已复制到剪贴板'));
|
||||
|
||||
@@ -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 from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Avatar, Button, Dropdown, Typography } from '@douyinfe/semi-ui';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
@@ -39,6 +39,7 @@ const UserArea = ({
|
||||
navigate,
|
||||
t,
|
||||
}) => {
|
||||
const dropdownRef = useRef(null);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SkeletonWrapper
|
||||
@@ -52,90 +53,93 @@ const UserArea = ({
|
||||
|
||||
if (userState.user) {
|
||||
return (
|
||||
<Dropdown
|
||||
position='bottomRight'
|
||||
render={
|
||||
<Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
navigate('/console/personal');
|
||||
}}
|
||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<IconUserSetting
|
||||
size='small'
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span>{t('个人设置')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
navigate('/console/token');
|
||||
}}
|
||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<IconKey
|
||||
size='small'
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span>{t('令牌管理')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
navigate('/console/topup');
|
||||
}}
|
||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<IconCreditCard
|
||||
size='small'
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span>{t('钱包管理')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={logout}
|
||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<IconExit
|
||||
size='small'
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span>{t('退出')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className='flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'
|
||||
<div className='relative' ref={dropdownRef}>
|
||||
<Dropdown
|
||||
position='bottomRight'
|
||||
getPopupContainer={() => dropdownRef.current}
|
||||
render={
|
||||
<Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
navigate('/console/personal');
|
||||
}}
|
||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<IconUserSetting
|
||||
size='small'
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span>{t('个人设置')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
navigate('/console/token');
|
||||
}}
|
||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<IconKey
|
||||
size='small'
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span>{t('令牌管理')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
navigate('/console/topup');
|
||||
}}
|
||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<IconCreditCard
|
||||
size='small'
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span>{t('钱包管理')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={logout}
|
||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<IconExit
|
||||
size='small'
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span>{t('退出')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
size='extra-small'
|
||||
color={stringToColor(userState.user.username)}
|
||||
className='mr-1'
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className='flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'
|
||||
>
|
||||
{userState.user.username[0].toUpperCase()}
|
||||
</Avatar>
|
||||
<span className='hidden md:inline'>
|
||||
<Typography.Text className='!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1'>
|
||||
{userState.user.username}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className='text-xs text-semi-color-text-2 dark:text-gray-400'
|
||||
/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Avatar
|
||||
size='extra-small'
|
||||
color={stringToColor(userState.user.username)}
|
||||
className='mr-1'
|
||||
>
|
||||
{userState.user.username[0].toUpperCase()}
|
||||
</Avatar>
|
||||
<span className='hidden md:inline'>
|
||||
<Typography.Text className='!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1'>
|
||||
{userState.user.username}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className='text-xs text-semi-color-text-2 dark:text-gray-400'
|
||||
/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const showRegisterButton = !isSelfUseMode;
|
||||
|
||||
@@ -45,6 +45,7 @@ const PaymentSetting = () => {
|
||||
StripePriceId: '',
|
||||
StripeUnitPrice: 8.0,
|
||||
StripeMinTopUp: 1,
|
||||
StripePromotionCodesEnabled: false,
|
||||
});
|
||||
|
||||
let [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -19,7 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { API, copy, showError, showInfo, showSuccess } from '../../helpers';
|
||||
import {
|
||||
API,
|
||||
copy,
|
||||
showError,
|
||||
showInfo,
|
||||
showSuccess,
|
||||
setStatusData,
|
||||
} from '../../helpers';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -71,18 +78,40 @@ const PersonalSetting = () => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let status = localStorage.getItem('status');
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
setStatus(status);
|
||||
if (status.turnstile_check) {
|
||||
let saved = localStorage.getItem('status');
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
setStatus(parsed);
|
||||
if (parsed.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
setTurnstileSiteKey(parsed.turnstile_site_key);
|
||||
} else {
|
||||
setTurnstileEnabled(false);
|
||||
setTurnstileSiteKey('');
|
||||
}
|
||||
}
|
||||
getUserData().then((res) => {
|
||||
console.log(userState);
|
||||
});
|
||||
// Always refresh status from server to avoid stale flags (e.g., admin just enabled OAuth)
|
||||
(async () => {
|
||||
try {
|
||||
const res = await API.get('/api/status');
|
||||
const { success, data } = res.data;
|
||||
if (success && data) {
|
||||
setStatus(data);
|
||||
setStatusData(data);
|
||||
if (data.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(data.turnstile_site_key);
|
||||
} else {
|
||||
setTurnstileEnabled(false);
|
||||
setTurnstileSiteKey('');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore and keep local status
|
||||
}
|
||||
})();
|
||||
|
||||
getUserData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -39,6 +39,9 @@ const RatioSetting = () => {
|
||||
CompletionRatio: '',
|
||||
GroupRatio: '',
|
||||
GroupGroupRatio: '',
|
||||
ImageRatio: '',
|
||||
AudioRatio: '',
|
||||
AudioCompletionRatio: '',
|
||||
AutoGroups: '',
|
||||
DefaultUseAutoGroup: false,
|
||||
ExposeRatioEnabled: false,
|
||||
@@ -61,7 +64,10 @@ const RatioSetting = () => {
|
||||
item.key === 'UserUsableGroups' ||
|
||||
item.key === 'CompletionRatio' ||
|
||||
item.key === 'ModelPrice' ||
|
||||
item.key === 'CacheRatio'
|
||||
item.key === 'CacheRatio' ||
|
||||
item.key === 'ImageRatio' ||
|
||||
item.key === 'AudioRatio' ||
|
||||
item.key === 'AudioCompletionRatio'
|
||||
) {
|
||||
try {
|
||||
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
TagInput,
|
||||
Spin,
|
||||
Card,
|
||||
Radio,
|
||||
} from '@douyinfe/semi-ui';
|
||||
const { Text } = Typography;
|
||||
import {
|
||||
@@ -44,6 +45,7 @@ import { useTranslation } from 'react-i18next';
|
||||
const SystemSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
let [inputs, setInputs] = useState({
|
||||
|
||||
PasswordLoginEnabled: '',
|
||||
PasswordRegisterEnabled: '',
|
||||
EmailVerificationEnabled: '',
|
||||
@@ -87,6 +89,15 @@ const SystemSetting = () => {
|
||||
LinuxDOClientSecret: '',
|
||||
LinuxDOMinimumTrustLevel: '',
|
||||
ServerAddress: '',
|
||||
// SSRF防护配置
|
||||
'fetch_setting.enable_ssrf_protection': true,
|
||||
'fetch_setting.allow_private_ip': '',
|
||||
'fetch_setting.domain_filter_mode': false, // true 白名单,false 黑名单
|
||||
'fetch_setting.ip_filter_mode': false, // true 白名单,false 黑名单
|
||||
'fetch_setting.domain_list': [],
|
||||
'fetch_setting.ip_list': [],
|
||||
'fetch_setting.allowed_ports': [],
|
||||
'fetch_setting.apply_ip_filter_for_domain': false,
|
||||
});
|
||||
|
||||
const [originInputs, setOriginInputs] = useState({});
|
||||
@@ -98,6 +109,11 @@ const SystemSetting = () => {
|
||||
useState(false);
|
||||
const [linuxDOOAuthEnabled, setLinuxDOOAuthEnabled] = useState(false);
|
||||
const [emailToAdd, setEmailToAdd] = useState('');
|
||||
const [domainFilterMode, setDomainFilterMode] = useState(true);
|
||||
const [ipFilterMode, setIpFilterMode] = useState(true);
|
||||
const [domainList, setDomainList] = useState([]);
|
||||
const [ipList, setIpList] = useState([]);
|
||||
const [allowedPorts, setAllowedPorts] = useState([]);
|
||||
|
||||
const getOptions = async () => {
|
||||
setLoading(true);
|
||||
@@ -113,6 +129,37 @@ const SystemSetting = () => {
|
||||
case 'EmailDomainWhitelist':
|
||||
setEmailDomainWhitelist(item.value ? item.value.split(',') : []);
|
||||
break;
|
||||
case 'fetch_setting.allow_private_ip':
|
||||
case 'fetch_setting.enable_ssrf_protection':
|
||||
case 'fetch_setting.domain_filter_mode':
|
||||
case 'fetch_setting.ip_filter_mode':
|
||||
case 'fetch_setting.apply_ip_filter_for_domain':
|
||||
item.value = toBoolean(item.value);
|
||||
break;
|
||||
case 'fetch_setting.domain_list':
|
||||
try {
|
||||
const domains = item.value ? JSON.parse(item.value) : [];
|
||||
setDomainList(Array.isArray(domains) ? domains : []);
|
||||
} catch (e) {
|
||||
setDomainList([]);
|
||||
}
|
||||
break;
|
||||
case 'fetch_setting.ip_list':
|
||||
try {
|
||||
const ips = item.value ? JSON.parse(item.value) : [];
|
||||
setIpList(Array.isArray(ips) ? ips : []);
|
||||
} catch (e) {
|
||||
setIpList([]);
|
||||
}
|
||||
break;
|
||||
case 'fetch_setting.allowed_ports':
|
||||
try {
|
||||
const ports = item.value ? JSON.parse(item.value) : [];
|
||||
setAllowedPorts(Array.isArray(ports) ? ports : []);
|
||||
} catch (e) {
|
||||
setAllowedPorts(['80', '443', '8080', '8443']);
|
||||
}
|
||||
break;
|
||||
case 'PasswordLoginEnabled':
|
||||
case 'PasswordRegisterEnabled':
|
||||
case 'EmailVerificationEnabled':
|
||||
@@ -140,6 +187,13 @@ const SystemSetting = () => {
|
||||
});
|
||||
setInputs(newInputs);
|
||||
setOriginInputs(newInputs);
|
||||
// 同步模式布尔到本地状态
|
||||
if (typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined') {
|
||||
setDomainFilterMode(!!newInputs['fetch_setting.domain_filter_mode']);
|
||||
}
|
||||
if (typeof newInputs['fetch_setting.ip_filter_mode'] !== 'undefined') {
|
||||
setIpFilterMode(!!newInputs['fetch_setting.ip_filter_mode']);
|
||||
}
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues(newInputs);
|
||||
}
|
||||
@@ -276,6 +330,46 @@ const SystemSetting = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const submitSSRF = async () => {
|
||||
const options = [];
|
||||
|
||||
// 处理域名过滤模式与列表
|
||||
options.push({
|
||||
key: 'fetch_setting.domain_filter_mode',
|
||||
value: domainFilterMode,
|
||||
});
|
||||
if (Array.isArray(domainList)) {
|
||||
options.push({
|
||||
key: 'fetch_setting.domain_list',
|
||||
value: JSON.stringify(domainList),
|
||||
});
|
||||
}
|
||||
|
||||
// 处理IP过滤模式与列表
|
||||
options.push({
|
||||
key: 'fetch_setting.ip_filter_mode',
|
||||
value: ipFilterMode,
|
||||
});
|
||||
if (Array.isArray(ipList)) {
|
||||
options.push({
|
||||
key: 'fetch_setting.ip_list',
|
||||
value: JSON.stringify(ipList),
|
||||
});
|
||||
}
|
||||
|
||||
// 处理端口配置
|
||||
if (Array.isArray(allowedPorts)) {
|
||||
options.push({
|
||||
key: 'fetch_setting.allowed_ports',
|
||||
value: JSON.stringify(allowedPorts),
|
||||
});
|
||||
}
|
||||
|
||||
if (options.length > 0) {
|
||||
await updateOptions(options);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddEmail = () => {
|
||||
if (emailToAdd && emailToAdd.trim() !== '') {
|
||||
const domain = emailToAdd.trim();
|
||||
@@ -587,6 +681,179 @@ const SystemSetting = () => {
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Form.Section text={t('SSRF防护设置')}>
|
||||
<Text extraText={t('SSRF防护详细说明')}>
|
||||
{t('配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全')}
|
||||
</Text>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Form.Checkbox
|
||||
field='fetch_setting.enable_ssrf_protection'
|
||||
noLabel
|
||||
extraText={t('SSRF防护开关详细说明')}
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('fetch_setting.enable_ssrf_protection', e)
|
||||
}
|
||||
>
|
||||
{t('启用SSRF防护(推荐开启以保护服务器安全)')}
|
||||
</Form.Checkbox>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Form.Checkbox
|
||||
field='fetch_setting.allow_private_ip'
|
||||
noLabel
|
||||
extraText={t('私有IP访问详细说明')}
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('fetch_setting.allow_private_ip', e)
|
||||
}
|
||||
>
|
||||
{t('允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)')}
|
||||
</Form.Checkbox>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Form.Checkbox
|
||||
field='fetch_setting.apply_ip_filter_for_domain'
|
||||
noLabel
|
||||
extraText={t('域名IP过滤详细说明')}
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e)
|
||||
}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
{t('对域名启用 IP 过滤(实验性)')}
|
||||
</Form.Checkbox>
|
||||
<Text strong>
|
||||
{t(domainFilterMode ? '域名白名单' : '域名黑名单')}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
{t('支持通配符格式,如:example.com, *.api.example.com')}
|
||||
</Text>
|
||||
<Radio.Group
|
||||
type='button'
|
||||
value={domainFilterMode ? 'whitelist' : 'blacklist'}
|
||||
onChange={(val) => {
|
||||
const selected = val && val.target ? val.target.value : val;
|
||||
const isWhitelist = selected === 'whitelist';
|
||||
setDomainFilterMode(isWhitelist);
|
||||
setInputs(prev => ({
|
||||
...prev,
|
||||
'fetch_setting.domain_filter_mode': isWhitelist,
|
||||
}));
|
||||
}}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<Radio value='whitelist'>{t('白名单')}</Radio>
|
||||
<Radio value='blacklist'>{t('黑名单')}</Radio>
|
||||
</Radio.Group>
|
||||
<TagInput
|
||||
value={domainList}
|
||||
onChange={(value) => {
|
||||
setDomainList(value);
|
||||
// 触发Form的onChange事件
|
||||
setInputs(prev => ({
|
||||
...prev,
|
||||
'fetch_setting.domain_list': value
|
||||
}));
|
||||
}}
|
||||
placeholder={t('输入域名后回车,如:example.com')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Text strong>
|
||||
{t(ipFilterMode ? 'IP白名单' : 'IP黑名单')}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
{t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')}
|
||||
</Text>
|
||||
<Radio.Group
|
||||
type='button'
|
||||
value={ipFilterMode ? 'whitelist' : 'blacklist'}
|
||||
onChange={(val) => {
|
||||
const selected = val && val.target ? val.target.value : val;
|
||||
const isWhitelist = selected === 'whitelist';
|
||||
setIpFilterMode(isWhitelist);
|
||||
setInputs(prev => ({
|
||||
...prev,
|
||||
'fetch_setting.ip_filter_mode': isWhitelist,
|
||||
}));
|
||||
}}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<Radio value='whitelist'>{t('白名单')}</Radio>
|
||||
<Radio value='blacklist'>{t('黑名单')}</Radio>
|
||||
</Radio.Group>
|
||||
<TagInput
|
||||
value={ipList}
|
||||
onChange={(value) => {
|
||||
setIpList(value);
|
||||
// 触发Form的onChange事件
|
||||
setInputs(prev => ({
|
||||
...prev,
|
||||
'fetch_setting.ip_list': value
|
||||
}));
|
||||
}}
|
||||
placeholder={t('输入IP地址后回车,如:8.8.8.8')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||
<Text strong>{t('允许的端口')}</Text>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
{t('支持单个端口和端口范围,如:80, 443, 8000-8999')}
|
||||
</Text>
|
||||
<TagInput
|
||||
value={allowedPorts}
|
||||
onChange={(value) => {
|
||||
setAllowedPorts(value);
|
||||
// 触发Form的onChange事件
|
||||
setInputs(prev => ({
|
||||
...prev,
|
||||
'fetch_setting.allowed_ports': value
|
||||
}));
|
||||
}}
|
||||
placeholder={t('输入端口后回车,如:80 或 8000-8999')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
{t('端口配置详细说明')}
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Button onClick={submitSSRF} style={{ marginTop: 16 }}>
|
||||
{t('更新SSRF防护设置')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Form.Section text={t('配置登录注册')}>
|
||||
<Row
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Tabs,
|
||||
TabPane,
|
||||
Popover,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconMail,
|
||||
@@ -83,6 +84,9 @@ const AccountManagement = ({
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
const isBound = (accountId) => Boolean(accountId);
|
||||
const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Card className='!rounded-2xl'>
|
||||
{/* 卡片头部 */}
|
||||
@@ -142,7 +146,7 @@ const AccountManagement = ({
|
||||
size='small'
|
||||
onClick={() => setShowEmailBindModal(true)}
|
||||
>
|
||||
{userState.user && userState.user.email !== ''
|
||||
{isBound(userState.user?.email)
|
||||
? t('修改绑定')
|
||||
: t('绑定')}
|
||||
</Button>
|
||||
@@ -165,9 +169,11 @@ const AccountManagement = ({
|
||||
{t('微信')}
|
||||
</div>
|
||||
<div className='text-sm text-gray-500 truncate'>
|
||||
{userState.user && userState.user.wechat_id !== ''
|
||||
? t('已绑定')
|
||||
: t('未绑定')}
|
||||
{!status.wechat_login
|
||||
? t('未启用')
|
||||
: isBound(userState.user?.wechat_id)
|
||||
? t('已绑定')
|
||||
: t('未绑定')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,7 +185,7 @@ const AccountManagement = ({
|
||||
disabled={!status.wechat_login}
|
||||
onClick={() => setShowWeChatBindModal(true)}
|
||||
>
|
||||
{userState.user && userState.user.wechat_id !== ''
|
||||
{isBound(userState.user?.wechat_id)
|
||||
? t('修改绑定')
|
||||
: status.wechat_login
|
||||
? t('绑定')
|
||||
@@ -220,8 +226,7 @@ const AccountManagement = ({
|
||||
onGitHubOAuthClicked(status.github_client_id)
|
||||
}
|
||||
disabled={
|
||||
(userState.user && userState.user.github_id !== '') ||
|
||||
!status.github_oauth
|
||||
isBound(userState.user?.github_id) || !status.github_oauth
|
||||
}
|
||||
>
|
||||
{status.github_oauth ? t('绑定') : t('未启用')}
|
||||
@@ -264,8 +269,7 @@ const AccountManagement = ({
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
(userState.user && userState.user.oidc_id !== '') ||
|
||||
!status.oidc_enabled
|
||||
isBound(userState.user?.oidc_id) || !status.oidc_enabled
|
||||
}
|
||||
>
|
||||
{status.oidc_enabled ? t('绑定') : t('未启用')}
|
||||
@@ -298,26 +302,56 @@ const AccountManagement = ({
|
||||
</div>
|
||||
<div className='flex-shrink-0'>
|
||||
{status.telegram_oauth ? (
|
||||
userState.user.telegram_id !== '' ? (
|
||||
<Button disabled={true} size='small'>
|
||||
isBound(userState.user?.telegram_id) ? (
|
||||
<Button
|
||||
disabled
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
>
|
||||
{t('已绑定')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className='scale-75'>
|
||||
<TelegramLoginButton
|
||||
dataAuthUrl='/api/oauth/telegram/bind'
|
||||
botName={status.telegram_bot_name}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='outline'
|
||||
size='small'
|
||||
onClick={() => setShowTelegramBindModal(true)}
|
||||
>
|
||||
{t('绑定')}
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<Button disabled={true} size='small'>
|
||||
<Button
|
||||
disabled
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
>
|
||||
{t('未启用')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Modal
|
||||
title={t('绑定 Telegram')}
|
||||
visible={showTelegramBindModal}
|
||||
onCancel={() => setShowTelegramBindModal(false)}
|
||||
footer={null}
|
||||
>
|
||||
<div className='my-3 text-sm text-gray-600'>
|
||||
{t('点击下方按钮通过 Telegram 完成绑定')}
|
||||
</div>
|
||||
<div className='flex justify-center'>
|
||||
<div className='scale-90'>
|
||||
<TelegramLoginButton
|
||||
dataAuthUrl='/api/oauth/telegram/bind'
|
||||
botName={status.telegram_bot_name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* LinuxDO绑定 */}
|
||||
<Card className='!rounded-xl'>
|
||||
@@ -350,8 +384,7 @@ const AccountManagement = ({
|
||||
onLinuxDOOAuthClicked(status.linuxdo_client_id)
|
||||
}
|
||||
disabled={
|
||||
(userState.user && userState.user.linux_do_id !== '') ||
|
||||
!status.linuxdo_oauth
|
||||
isBound(userState.user?.linux_do_id) || !status.linuxdo_oauth
|
||||
}
|
||||
>
|
||||
{status.linuxdo_oauth ? t('绑定') : t('未启用')}
|
||||
|
||||
@@ -44,6 +44,7 @@ import CodeViewer from '../../../playground/CodeViewer';
|
||||
import { StatusContext } from '../../../../context/Status';
|
||||
import { UserContext } from '../../../../context/User';
|
||||
import { useUserPermissions } from '../../../../hooks/common/useUserPermissions';
|
||||
import { useSidebar } from '../../../../hooks/common/useSidebar';
|
||||
|
||||
const NotificationSettings = ({
|
||||
t,
|
||||
@@ -97,6 +98,9 @@ const NotificationSettings = ({
|
||||
isSidebarModuleAllowed,
|
||||
} = useUserPermissions();
|
||||
|
||||
// 使用useSidebar钩子获取刷新方法
|
||||
const { refreshUserConfig } = useSidebar();
|
||||
|
||||
// 左侧边栏设置处理函数
|
||||
const handleSectionChange = (sectionKey) => {
|
||||
return (checked) => {
|
||||
@@ -132,6 +136,9 @@ const NotificationSettings = ({
|
||||
});
|
||||
if (res.data.success) {
|
||||
showSuccess(t('侧边栏设置保存成功'));
|
||||
|
||||
// 刷新useSidebar钩子中的用户配置,实现实时更新
|
||||
await refreshUserConfig();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
@@ -334,7 +341,7 @@ const NotificationSettings = ({
|
||||
loading={sidebarLoading}
|
||||
className='!rounded-lg'
|
||||
>
|
||||
{t('保存边栏设置')}
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -85,6 +85,26 @@ const REGION_EXAMPLE = {
|
||||
'claude-3-5-sonnet-20240620': 'europe-west1',
|
||||
};
|
||||
|
||||
// 支持并且已适配通过接口获取模型列表的渠道类型
|
||||
const MODEL_FETCHABLE_TYPES = new Set([
|
||||
1,
|
||||
4,
|
||||
14,
|
||||
34,
|
||||
17,
|
||||
26,
|
||||
24,
|
||||
47,
|
||||
25,
|
||||
20,
|
||||
23,
|
||||
31,
|
||||
35,
|
||||
40,
|
||||
42,
|
||||
48,
|
||||
]);
|
||||
|
||||
function type2secretPrompt(type) {
|
||||
// inputs.type === 15 ? '按照如下格式输入:APIKey|SecretKey' : (inputs.type === 18 ? '按照如下格式输入:APPID|APISecret|APIKey' : '请输入渠道对应的鉴权密钥')
|
||||
switch (type) {
|
||||
@@ -144,6 +164,8 @@ const EditChannelModal = (props) => {
|
||||
settings: '',
|
||||
// 仅 Vertex: 密钥格式(存入 settings.vertex_key_type)
|
||||
vertex_key_type: 'json',
|
||||
// 企业账户设置
|
||||
is_enterprise_account: false,
|
||||
};
|
||||
const [batch, setBatch] = useState(false);
|
||||
const [multiToSingle, setMultiToSingle] = useState(false);
|
||||
@@ -169,6 +191,7 @@ const EditChannelModal = (props) => {
|
||||
const [channelSearchValue, setChannelSearchValue] = useState('');
|
||||
const [useManualInput, setUseManualInput] = useState(false); // 是否使用手动输入模式
|
||||
const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加)
|
||||
const [isEnterpriseAccount, setIsEnterpriseAccount] = useState(false); // 是否为企业账户
|
||||
|
||||
// 2FA验证查看密钥相关状态
|
||||
const [twoFAState, setTwoFAState] = useState({
|
||||
@@ -215,7 +238,7 @@ const EditChannelModal = (props) => {
|
||||
pass_through_body_enabled: false,
|
||||
system_prompt: '',
|
||||
});
|
||||
const showApiConfigCard = inputs.type !== 45; // 控制是否显示 API 配置卡片(仅当渠道类型不是 豆包 时显示)
|
||||
const showApiConfigCard = true; // 控制是否显示 API 配置卡片
|
||||
const getInitValues = () => ({ ...originInputs });
|
||||
|
||||
// 处理渠道额外设置的更新
|
||||
@@ -322,6 +345,10 @@ const EditChannelModal = (props) => {
|
||||
case 36:
|
||||
localModels = ['suno_music', 'suno_lyrics'];
|
||||
break;
|
||||
case 45:
|
||||
localModels = getChannelModels(value);
|
||||
setInputs((prevInputs) => ({ ...prevInputs, base_url: 'https://ark.cn-beijing.volces.com' }));
|
||||
break;
|
||||
default:
|
||||
localModels = getChannelModels(value);
|
||||
break;
|
||||
@@ -413,15 +440,27 @@ const EditChannelModal = (props) => {
|
||||
parsedSettings.azure_responses_version || '';
|
||||
// 读取 Vertex 密钥格式
|
||||
data.vertex_key_type = parsedSettings.vertex_key_type || 'json';
|
||||
// 读取企业账户设置
|
||||
data.is_enterprise_account = parsedSettings.openrouter_enterprise === true;
|
||||
} catch (error) {
|
||||
console.error('解析其他设置失败:', error);
|
||||
data.azure_responses_version = '';
|
||||
data.region = '';
|
||||
data.vertex_key_type = 'json';
|
||||
data.is_enterprise_account = false;
|
||||
}
|
||||
} else {
|
||||
// 兼容历史数据:老渠道没有 settings 时,默认按 json 展示
|
||||
data.vertex_key_type = 'json';
|
||||
data.is_enterprise_account = false;
|
||||
}
|
||||
|
||||
if (
|
||||
data.type === 45 &&
|
||||
(!data.base_url ||
|
||||
(typeof data.base_url === 'string' && data.base_url.trim() === ''))
|
||||
) {
|
||||
data.base_url = 'https://ark.cn-beijing.volces.com';
|
||||
}
|
||||
|
||||
setInputs(data);
|
||||
@@ -433,6 +472,8 @@ const EditChannelModal = (props) => {
|
||||
} else {
|
||||
setAutoBan(true);
|
||||
}
|
||||
// 同步企业账户状态
|
||||
setIsEnterpriseAccount(data.is_enterprise_account || false);
|
||||
setBasicModels(getChannelModels(data.type));
|
||||
// 同步更新channelSettings状态显示
|
||||
setChannelSettings({
|
||||
@@ -692,6 +733,8 @@ const EditChannelModal = (props) => {
|
||||
});
|
||||
// 重置密钥模式状态
|
||||
setKeyMode('append');
|
||||
// 重置企业账户状态
|
||||
setIsEnterpriseAccount(false);
|
||||
// 清空表单中的key_mode字段
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('key_mode', undefined);
|
||||
@@ -802,7 +845,9 @@ const EditChannelModal = (props) => {
|
||||
delete localInputs.key;
|
||||
}
|
||||
} else {
|
||||
localInputs.key = batch ? JSON.stringify(keys) : JSON.stringify(keys[0]);
|
||||
localInputs.key = batch
|
||||
? JSON.stringify(keys)
|
||||
: JSON.stringify(keys[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -822,6 +867,10 @@ const EditChannelModal = (props) => {
|
||||
showInfo(t('请至少选择一个模型!'));
|
||||
return;
|
||||
}
|
||||
if (localInputs.type === 45 && (!localInputs.base_url || localInputs.base_url.trim() === '')) {
|
||||
showInfo(t('请输入API地址!'));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
localInputs.model_mapping &&
|
||||
localInputs.model_mapping !== '' &&
|
||||
@@ -851,6 +900,21 @@ const EditChannelModal = (props) => {
|
||||
};
|
||||
localInputs.setting = JSON.stringify(channelExtraSettings);
|
||||
|
||||
// 处理type === 20的企业账户设置
|
||||
if (localInputs.type === 20) {
|
||||
let settings = {};
|
||||
if (localInputs.settings) {
|
||||
try {
|
||||
settings = JSON.parse(localInputs.settings);
|
||||
} catch (error) {
|
||||
console.error('解析settings失败:', error);
|
||||
}
|
||||
}
|
||||
// 设置企业账户标识,无论是true还是false都要传到后端
|
||||
settings.openrouter_enterprise = localInputs.is_enterprise_account === true;
|
||||
localInputs.settings = JSON.stringify(settings);
|
||||
}
|
||||
|
||||
// 清理不需要发送到后端的字段
|
||||
delete localInputs.force_format;
|
||||
delete localInputs.thinking_to_content;
|
||||
@@ -858,6 +922,7 @@ const EditChannelModal = (props) => {
|
||||
delete localInputs.pass_through_body_enabled;
|
||||
delete localInputs.system_prompt;
|
||||
delete localInputs.system_prompt_override;
|
||||
delete localInputs.is_enterprise_account;
|
||||
// 顶层的 vertex_key_type 不应发送给后端
|
||||
delete localInputs.vertex_key_type;
|
||||
|
||||
@@ -899,6 +964,56 @@ const EditChannelModal = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 密钥去重函数
|
||||
const deduplicateKeys = () => {
|
||||
const currentKey = formApiRef.current?.getValue('key') || inputs.key || '';
|
||||
|
||||
if (!currentKey.trim()) {
|
||||
showInfo(t('请先输入密钥'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 按行分割密钥
|
||||
const keyLines = currentKey.split('\n');
|
||||
const beforeCount = keyLines.length;
|
||||
|
||||
// 使用哈希表去重,保持原有顺序
|
||||
const keySet = new Set();
|
||||
const deduplicatedKeys = [];
|
||||
|
||||
keyLines.forEach((line) => {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine && !keySet.has(trimmedLine)) {
|
||||
keySet.add(trimmedLine);
|
||||
deduplicatedKeys.push(trimmedLine);
|
||||
}
|
||||
});
|
||||
|
||||
const afterCount = deduplicatedKeys.length;
|
||||
const deduplicatedKeyText = deduplicatedKeys.join('\n');
|
||||
|
||||
// 更新表单和状态
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('key', deduplicatedKeyText);
|
||||
}
|
||||
handleInputChange('key', deduplicatedKeyText);
|
||||
|
||||
// 显示去重结果
|
||||
const message = t(
|
||||
'去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥',
|
||||
{
|
||||
before: beforeCount,
|
||||
after: afterCount,
|
||||
},
|
||||
);
|
||||
|
||||
if (beforeCount === afterCount) {
|
||||
showInfo(t('未发现重复密钥'));
|
||||
} else {
|
||||
showSuccess(message);
|
||||
}
|
||||
};
|
||||
|
||||
const addCustomModels = () => {
|
||||
if (customModel.trim() === '') return;
|
||||
const modelArray = customModel.split(',').map((model) => model.trim());
|
||||
@@ -994,24 +1109,41 @@ const EditChannelModal = (props) => {
|
||||
</Checkbox>
|
||||
)}
|
||||
{batch && (
|
||||
<Checkbox
|
||||
disabled={isEdit}
|
||||
checked={multiToSingle}
|
||||
onChange={() => {
|
||||
setMultiToSingle((prev) => !prev);
|
||||
setInputs((prev) => {
|
||||
const newInputs = { ...prev };
|
||||
if (!multiToSingle) {
|
||||
newInputs.multi_key_mode = multiKeyMode;
|
||||
} else {
|
||||
delete newInputs.multi_key_mode;
|
||||
}
|
||||
return newInputs;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('密钥聚合模式')}
|
||||
</Checkbox>
|
||||
<>
|
||||
<Checkbox
|
||||
disabled={isEdit}
|
||||
checked={multiToSingle}
|
||||
onChange={() => {
|
||||
setMultiToSingle((prev) => {
|
||||
const nextValue = !prev;
|
||||
setInputs((prevInputs) => {
|
||||
const newInputs = { ...prevInputs };
|
||||
if (nextValue) {
|
||||
newInputs.multi_key_mode = multiKeyMode;
|
||||
} else {
|
||||
delete newInputs.multi_key_mode;
|
||||
}
|
||||
return newInputs;
|
||||
});
|
||||
return nextValue;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('密钥聚合模式')}
|
||||
</Checkbox>
|
||||
|
||||
{inputs.type !== 41 && (
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
theme='outline'
|
||||
onClick={deduplicateKeys}
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{t('密钥去重')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
) : null;
|
||||
@@ -1175,6 +1307,21 @@ const EditChannelModal = (props) => {
|
||||
onChange={(value) => handleInputChange('type', value)}
|
||||
/>
|
||||
|
||||
{inputs.type === 20 && (
|
||||
<Form.Switch
|
||||
field='is_enterprise_account'
|
||||
label={t('是否为企业账户')}
|
||||
checkedText={t('是')}
|
||||
uncheckedText={t('否')}
|
||||
onChange={(value) => {
|
||||
setIsEnterpriseAccount(value);
|
||||
handleInputChange('is_enterprise_account', value);
|
||||
}}
|
||||
extraText={t('企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选')}
|
||||
initValue={inputs.is_enterprise_account}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form.Input
|
||||
field='name'
|
||||
label={t('名称')}
|
||||
@@ -1198,7 +1345,10 @@ const EditChannelModal = (props) => {
|
||||
value={inputs.vertex_key_type || 'json'}
|
||||
onChange={(value) => {
|
||||
// 更新设置中的 vertex_key_type
|
||||
handleChannelOtherSettingsChange('vertex_key_type', value);
|
||||
handleChannelOtherSettingsChange(
|
||||
'vertex_key_type',
|
||||
value,
|
||||
);
|
||||
// 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件
|
||||
if (value === 'api_key') {
|
||||
setBatch(false);
|
||||
@@ -1218,7 +1368,8 @@ const EditChannelModal = (props) => {
|
||||
/>
|
||||
)}
|
||||
{batch ? (
|
||||
inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
inputs.type === 41 &&
|
||||
(inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
<Form.Upload
|
||||
field='vertex_files'
|
||||
label={t('密钥文件 (.json)')}
|
||||
@@ -1254,7 +1405,7 @@ const EditChannelModal = (props) => {
|
||||
autoComplete='new-password'
|
||||
onChange={(value) => handleInputChange('key', value)}
|
||||
extraText={
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex items-center gap-2 flex-wrap'>
|
||||
{isEdit &&
|
||||
isMultiKeyChannel &&
|
||||
keyMode === 'append' && (
|
||||
@@ -1282,7 +1433,8 @@ const EditChannelModal = (props) => {
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
{inputs.type === 41 &&
|
||||
(inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
<>
|
||||
{!batch && (
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
@@ -1789,6 +1941,30 @@ const EditChannelModal = (props) => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inputs.type === 45 && (
|
||||
<div>
|
||||
<Form.Select
|
||||
field='base_url'
|
||||
label={t('API地址')}
|
||||
placeholder={t('请选择API地址')}
|
||||
onChange={(value) =>
|
||||
handleInputChange('base_url', value)
|
||||
}
|
||||
optionList={[
|
||||
{
|
||||
value: 'https://ark.cn-beijing.volces.com',
|
||||
label: 'https://ark.cn-beijing.volces.com'
|
||||
},
|
||||
{
|
||||
value: 'https://ark.ap-southeast.bytepluses.com',
|
||||
label: 'https://ark.ap-southeast.bytepluses.com'
|
||||
}
|
||||
]}
|
||||
defaultValue='https://ark.cn-beijing.volces.com'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1872,13 +2048,15 @@ const EditChannelModal = (props) => {
|
||||
>
|
||||
{t('填入所有模型')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
onClick={() => fetchUpstreamModelList('models')}
|
||||
>
|
||||
{t('获取模型列表')}
|
||||
</Button>
|
||||
{MODEL_FETCHABLE_TYPES.has(inputs.type) && (
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
onClick={() => fetchUpstreamModelList('models')}
|
||||
>
|
||||
{t('获取模型列表')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size='small'
|
||||
type='warning'
|
||||
|
||||
@@ -247,6 +247,32 @@ const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a specific key
|
||||
const handleDeleteKey = async (keyIndex) => {
|
||||
const operationId = `delete_${keyIndex}`;
|
||||
setOperationLoading((prev) => ({ ...prev, [operationId]: true }));
|
||||
|
||||
try {
|
||||
const res = await API.post('/api/channel/multi_key/manage', {
|
||||
channel_id: channel.id,
|
||||
action: 'delete_key',
|
||||
key_index: keyIndex,
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(t('密钥已删除'));
|
||||
await loadKeyStatus(currentPage, pageSize); // Reload current page
|
||||
onRefresh && onRefresh(); // Refresh parent component
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('删除密钥失败'));
|
||||
} finally {
|
||||
setOperationLoading((prev) => ({ ...prev, [operationId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (page) => {
|
||||
setCurrentPage(page);
|
||||
@@ -384,7 +410,7 @@ const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
|
||||
title: t('操作'),
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 100,
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
{record.status === 1 ? (
|
||||
@@ -406,6 +432,21 @@ const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
|
||||
{t('启用')}
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={t('确定要删除此密钥吗?')}
|
||||
content={t('此操作不可撤销,将永久删除该密钥')}
|
||||
onConfirm={() => handleDeleteKey(record.index)}
|
||||
okType={'danger'}
|
||||
position={'topRight'}
|
||||
>
|
||||
<Button
|
||||
type='danger'
|
||||
size='small'
|
||||
loading={operationLoading[`delete_${record.index}`]}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -21,6 +21,8 @@ import React from 'react';
|
||||
import { Button, Form } from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
|
||||
import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
|
||||
|
||||
const MjLogsFilters = ({
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
@@ -54,6 +56,11 @@ const MjLogsFilters = ({
|
||||
showClear
|
||||
pure
|
||||
size='small'
|
||||
presets={DATE_RANGE_PRESETS.map(preset => ({
|
||||
text: t(preset.text),
|
||||
start: preset.start(),
|
||||
end: preset.end()
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -35,8 +35,9 @@ import {
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
TASK_ACTION_GENERATE,
|
||||
TASK_ACTION_TEXT_GENERATE,
|
||||
TASK_ACTION_FIRST_TAIL_GENERATE,
|
||||
TASK_ACTION_GENERATE, TASK_ACTION_REFERENCE_GENERATE,
|
||||
TASK_ACTION_TEXT_GENERATE
|
||||
} from '../../../constants/common.constant';
|
||||
import { CHANNEL_OPTIONS } from '../../../constants/channel.constants';
|
||||
|
||||
@@ -111,6 +112,18 @@ const renderType = (type, t) => {
|
||||
{t('文生视频')}
|
||||
</Tag>
|
||||
);
|
||||
case TASK_ACTION_FIRST_TAIL_GENERATE:
|
||||
return (
|
||||
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
|
||||
{t('首尾生视频')}
|
||||
</Tag>
|
||||
);
|
||||
case TASK_ACTION_REFERENCE_GENERATE:
|
||||
return (
|
||||
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
|
||||
{t('参照生视频')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
@@ -343,7 +356,9 @@ export const getTaskLogsColumns = ({
|
||||
// 仅当为视频生成任务且成功,且 fail_reason 是 URL 时显示可点击链接
|
||||
const isVideoTask =
|
||||
record.action === TASK_ACTION_GENERATE ||
|
||||
record.action === TASK_ACTION_TEXT_GENERATE;
|
||||
record.action === TASK_ACTION_TEXT_GENERATE ||
|
||||
record.action === TASK_ACTION_FIRST_TAIL_GENERATE ||
|
||||
record.action === TASK_ACTION_REFERENCE_GENERATE;
|
||||
const isSuccess = record.status === 'SUCCESS';
|
||||
const isUrl = typeof text === 'string' && /^https?:\/\//.test(text);
|
||||
if (isSuccess && isVideoTask && isUrl) {
|
||||
|
||||
@@ -21,6 +21,8 @@ import React from 'react';
|
||||
import { Button, Form } from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
|
||||
import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
|
||||
|
||||
const TaskLogsFilters = ({
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
@@ -54,6 +56,11 @@ const TaskLogsFilters = ({
|
||||
showClear
|
||||
pure
|
||||
size='small'
|
||||
presets={DATE_RANGE_PRESETS.map(preset => ({
|
||||
text: t(preset.text),
|
||||
start: preset.start(),
|
||||
end: preset.end()
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,8 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Button, Typography, Spin } from '@douyinfe/semi-ui';
|
||||
import { IconExternalOpen, IconCopy } from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ContentModal = ({
|
||||
isModalOpen,
|
||||
@@ -26,17 +29,120 @@ const ContentModal = ({
|
||||
modalContent,
|
||||
isVideo,
|
||||
}) => {
|
||||
const [videoError, setVideoError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen && isVideo) {
|
||||
setVideoError(false);
|
||||
setIsLoading(true);
|
||||
}
|
||||
}, [isModalOpen, isVideo]);
|
||||
|
||||
const handleVideoError = () => {
|
||||
setVideoError(true);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleVideoLoaded = () => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
navigator.clipboard.writeText(modalContent);
|
||||
};
|
||||
|
||||
const handleOpenInNewTab = () => {
|
||||
window.open(modalContent, '_blank');
|
||||
};
|
||||
|
||||
const renderVideoContent = () => {
|
||||
if (videoError) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px' }}>
|
||||
视频无法在当前浏览器中播放,这可能是由于:
|
||||
</Text>
|
||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
|
||||
• 视频服务商的跨域限制
|
||||
</Text>
|
||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
|
||||
• 需要特定的请求头或认证
|
||||
</Text>
|
||||
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}>
|
||||
• 防盗链保护机制
|
||||
</Text>
|
||||
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<Button
|
||||
icon={<IconExternalOpen />}
|
||||
onClick={handleOpenInNewTab}
|
||||
style={{ marginRight: '8px' }}
|
||||
>
|
||||
在新标签页中打开
|
||||
</Button>
|
||||
<Button
|
||||
icon={<IconCopy />}
|
||||
onClick={handleCopyUrl}
|
||||
>
|
||||
复制链接
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '16px', padding: '8px', backgroundColor: '#f8f9fa', borderRadius: '4px' }}>
|
||||
<Text
|
||||
type="tertiary"
|
||||
style={{ fontSize: '10px', wordBreak: 'break-all' }}
|
||||
>
|
||||
{modalContent}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{isLoading && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 10
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)}
|
||||
<video
|
||||
src={modalContent}
|
||||
controls
|
||||
style={{ width: '100%' }}
|
||||
autoPlay
|
||||
crossOrigin="anonymous"
|
||||
onError={handleVideoError}
|
||||
onLoadedData={handleVideoLoaded}
|
||||
onLoadStart={() => setIsLoading(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={isModalOpen}
|
||||
onOk={() => setIsModalOpen(false)}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
closable={null}
|
||||
bodyStyle={{ height: '400px', overflow: 'auto' }}
|
||||
bodyStyle={{
|
||||
height: isVideo ? '450px' : '400px',
|
||||
overflow: 'auto',
|
||||
padding: isVideo && videoError ? '0' : '24px'
|
||||
}}
|
||||
width={800}
|
||||
>
|
||||
{isVideo ? (
|
||||
<video src={modalContent} controls style={{ width: '100%' }} autoPlay />
|
||||
renderVideoContent()
|
||||
) : (
|
||||
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
|
||||
)}
|
||||
|
||||
@@ -21,6 +21,8 @@ import React from 'react';
|
||||
import { Button, Form } from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
|
||||
import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
|
||||
|
||||
const LogsFilters = ({
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
@@ -55,6 +57,11 @@ const LogsFilters = ({
|
||||
showClear
|
||||
pure
|
||||
size='small'
|
||||
presets={DATE_RANGE_PRESETS.map(preset => ({
|
||||
text: t(preset.text),
|
||||
start: preset.start(),
|
||||
end: preset.end()
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -40,3 +40,5 @@ export const API_ENDPOINTS = [
|
||||
|
||||
export const TASK_ACTION_GENERATE = 'generate';
|
||||
export const TASK_ACTION_TEXT_GENERATE = 'textGenerate';
|
||||
export const TASK_ACTION_FIRST_TAIL_GENERATE = 'firstTailGenerate';
|
||||
export const TASK_ACTION_REFERENCE_GENERATE = 'referenceGenerate';
|
||||
|
||||
49
web/src/constants/console.constants.js
Normal file
49
web/src/constants/console.constants.js
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// ========== 日期预设常量 ==========
|
||||
export const DATE_RANGE_PRESETS = [
|
||||
{
|
||||
text: '今天',
|
||||
start: () => dayjs().startOf('day').toDate(),
|
||||
end: () => dayjs().endOf('day').toDate()
|
||||
},
|
||||
{
|
||||
text: '近 7 天',
|
||||
start: () => dayjs().subtract(6, 'day').startOf('day').toDate(),
|
||||
end: () => dayjs().endOf('day').toDate()
|
||||
},
|
||||
{
|
||||
text: '本周',
|
||||
start: () => dayjs().startOf('week').toDate(),
|
||||
end: () => dayjs().endOf('week').toDate()
|
||||
},
|
||||
{
|
||||
text: '近 30 天',
|
||||
start: () => dayjs().subtract(29, 'day').startOf('day').toDate(),
|
||||
end: () => dayjs().endOf('day').toDate()
|
||||
},
|
||||
{
|
||||
text: '本月',
|
||||
start: () => dayjs().startOf('month').toDate(),
|
||||
end: () => dayjs().endOf('month').toDate()
|
||||
},
|
||||
];
|
||||
@@ -118,7 +118,6 @@ export const buildApiPayload = (
|
||||
model: inputs.model,
|
||||
group: inputs.group,
|
||||
messages: processedMessages,
|
||||
group: inputs.group,
|
||||
stream: inputs.stream,
|
||||
};
|
||||
|
||||
@@ -132,13 +131,15 @@ export const buildApiPayload = (
|
||||
seed: 'seed',
|
||||
};
|
||||
|
||||
|
||||
Object.entries(parameterMappings).forEach(([key, param]) => {
|
||||
if (
|
||||
parameterEnabled[key] &&
|
||||
inputs[param] !== undefined &&
|
||||
inputs[param] !== null
|
||||
) {
|
||||
payload[param] = inputs[param];
|
||||
const enabled = parameterEnabled[key];
|
||||
const value = inputs[param];
|
||||
const hasValue = value !== undefined && value !== null;
|
||||
|
||||
|
||||
if (enabled && hasValue) {
|
||||
payload[param] = value;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1030,6 +1030,8 @@ export function renderModelPrice(
|
||||
audioInputSeperatePrice = false,
|
||||
audioInputTokens = 0,
|
||||
audioInputPrice = 0,
|
||||
imageGenerationCall = false,
|
||||
imageGenerationCallPrice = 0,
|
||||
) {
|
||||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
|
||||
groupRatio,
|
||||
@@ -1072,7 +1074,8 @@ export function renderModelPrice(
|
||||
(audioInputTokens / 1000000) * audioInputPrice * groupRatio +
|
||||
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
|
||||
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
|
||||
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
|
||||
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
|
||||
(imageGenerationCallPrice * groupRatio);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1134,7 +1137,13 @@ export function renderModelPrice(
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p></p>
|
||||
{imageGenerationCall && imageGenerationCallPrice > 0 && (
|
||||
<p>
|
||||
{i18next.t('图片生成调用:${{price}} / 1次', {
|
||||
price: imageGenerationCallPrice,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
{(() => {
|
||||
// 构建输入部分描述
|
||||
@@ -1214,6 +1223,16 @@ export function renderModelPrice(
|
||||
},
|
||||
)
|
||||
: '',
|
||||
imageGenerationCall && imageGenerationCallPrice > 0
|
||||
? i18next.t(
|
||||
' + 图片生成调用 ${{price}} / 1次 * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
price: imageGenerationCallPrice,
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
)
|
||||
: '',
|
||||
].join('');
|
||||
|
||||
return i18next.t(
|
||||
|
||||
@@ -75,13 +75,17 @@ export async function copy(text) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (e) {
|
||||
try {
|
||||
// 构建input 执行 复制命令
|
||||
var _input = window.document.createElement('input');
|
||||
_input.value = text;
|
||||
window.document.body.appendChild(_input);
|
||||
_input.select();
|
||||
window.document.execCommand('Copy');
|
||||
window.document.body.removeChild(_input);
|
||||
// 构建 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);
|
||||
} catch (e) {
|
||||
okay = false;
|
||||
console.error(e);
|
||||
|
||||
@@ -25,13 +25,9 @@ import {
|
||||
showInfo,
|
||||
showSuccess,
|
||||
loadChannelModels,
|
||||
copy,
|
||||
copy
|
||||
} from '../../helpers';
|
||||
import {
|
||||
CHANNEL_OPTIONS,
|
||||
ITEMS_PER_PAGE,
|
||||
MODEL_TABLE_PAGE_SIZE,
|
||||
} from '../../constants';
|
||||
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants';
|
||||
import { useIsMobile } from '../common/useIsMobile';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
@@ -68,7 +64,7 @@ export const useChannelsData = () => {
|
||||
|
||||
// Status filter
|
||||
const [statusFilter, setStatusFilter] = useState(
|
||||
localStorage.getItem('channel-status-filter') || 'all',
|
||||
localStorage.getItem('channel-status-filter') || 'all'
|
||||
);
|
||||
|
||||
// Type tabs states
|
||||
@@ -83,9 +79,10 @@ export const useChannelsData = () => {
|
||||
const [testingModels, setTestingModels] = useState(new Set());
|
||||
const [selectedModelKeys, setSelectedModelKeys] = useState([]);
|
||||
const [isBatchTesting, setIsBatchTesting] = useState(false);
|
||||
const [testQueue, setTestQueue] = useState([]);
|
||||
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
|
||||
const [modelTablePage, setModelTablePage] = useState(1);
|
||||
|
||||
// 使用 ref 来避免闭包问题,类似旧版实现
|
||||
const shouldStopBatchTestingRef = useRef(false);
|
||||
|
||||
// Multi-key management states
|
||||
const [showMultiKeyManageModal, setShowMultiKeyManageModal] = useState(false);
|
||||
@@ -119,12 +116,9 @@ export const useChannelsData = () => {
|
||||
// Initialize from localStorage
|
||||
useEffect(() => {
|
||||
const localIdSort = localStorage.getItem('id-sort') === 'true';
|
||||
const localPageSize =
|
||||
parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
|
||||
const localEnableTagMode =
|
||||
localStorage.getItem('enable-tag-mode') === 'true';
|
||||
const localEnableBatchDelete =
|
||||
localStorage.getItem('enable-batch-delete') === 'true';
|
||||
const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
|
||||
const localEnableTagMode = localStorage.getItem('enable-tag-mode') === 'true';
|
||||
const localEnableBatchDelete = localStorage.getItem('enable-batch-delete') === 'true';
|
||||
|
||||
setIdSort(localIdSort);
|
||||
setPageSize(localPageSize);
|
||||
@@ -182,10 +176,7 @@ export const useChannelsData = () => {
|
||||
// Save column preferences
|
||||
useEffect(() => {
|
||||
if (Object.keys(visibleColumns).length > 0) {
|
||||
localStorage.setItem(
|
||||
'channels-table-columns',
|
||||
JSON.stringify(visibleColumns),
|
||||
);
|
||||
localStorage.setItem('channels-table-columns', JSON.stringify(visibleColumns));
|
||||
}
|
||||
}, [visibleColumns]);
|
||||
|
||||
@@ -299,21 +290,14 @@ export const useChannelsData = () => {
|
||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||
if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') {
|
||||
setLoading(true);
|
||||
await searchChannels(
|
||||
enableTagMode,
|
||||
typeKey,
|
||||
statusF,
|
||||
page,
|
||||
pageSize,
|
||||
idSort,
|
||||
);
|
||||
await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reqId = ++requestCounter.current;
|
||||
setLoading(true);
|
||||
const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : '';
|
||||
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
|
||||
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
|
||||
const res = await API.get(
|
||||
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`,
|
||||
@@ -327,10 +311,7 @@ export const useChannelsData = () => {
|
||||
if (success) {
|
||||
const { items, total, type_counts } = data;
|
||||
if (type_counts) {
|
||||
const sumAll = Object.values(type_counts).reduce(
|
||||
(acc, v) => acc + v,
|
||||
0,
|
||||
);
|
||||
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
|
||||
setTypeCounts({ ...type_counts, all: sumAll });
|
||||
}
|
||||
setChannelFormat(items, enableTagMode);
|
||||
@@ -354,18 +335,11 @@ export const useChannelsData = () => {
|
||||
setSearching(true);
|
||||
try {
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
await loadChannels(
|
||||
page,
|
||||
pageSz,
|
||||
sortFlag,
|
||||
enableTagMode,
|
||||
typeKey,
|
||||
statusF,
|
||||
);
|
||||
await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF);
|
||||
return;
|
||||
}
|
||||
|
||||
const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : '';
|
||||
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
|
||||
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
|
||||
const res = await API.get(
|
||||
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`,
|
||||
@@ -373,10 +347,7 @@ export const useChannelsData = () => {
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
const { items = [], total = 0, type_counts = {} } = data;
|
||||
const sumAll = Object.values(type_counts).reduce(
|
||||
(acc, v) => acc + v,
|
||||
0,
|
||||
);
|
||||
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
|
||||
setTypeCounts({ ...type_counts, all: sumAll });
|
||||
setChannelFormat(items, enableTagMode);
|
||||
setChannelCount(total);
|
||||
@@ -395,14 +366,7 @@ export const useChannelsData = () => {
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
await loadChannels(page, pageSize, idSort, enableTagMode);
|
||||
} else {
|
||||
await searchChannels(
|
||||
enableTagMode,
|
||||
activeTypeKey,
|
||||
statusFilter,
|
||||
page,
|
||||
pageSize,
|
||||
idSort,
|
||||
);
|
||||
await searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -488,16 +452,9 @@ export const useChannelsData = () => {
|
||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||
setActivePage(page);
|
||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||
loadChannels(page, pageSize, idSort, enableTagMode).then(() => {});
|
||||
loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
|
||||
} else {
|
||||
searchChannels(
|
||||
enableTagMode,
|
||||
activeTypeKey,
|
||||
statusFilter,
|
||||
page,
|
||||
pageSize,
|
||||
idSort,
|
||||
);
|
||||
searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -513,14 +470,7 @@ export const useChannelsData = () => {
|
||||
showError(reason);
|
||||
});
|
||||
} else {
|
||||
searchChannels(
|
||||
enableTagMode,
|
||||
activeTypeKey,
|
||||
statusFilter,
|
||||
1,
|
||||
size,
|
||||
idSort,
|
||||
);
|
||||
searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -551,10 +501,7 @@ export const useChannelsData = () => {
|
||||
showError(res?.data?.message || t('渠道复制失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(
|
||||
t('渠道复制失败: ') +
|
||||
(error?.response?.data?.message || error?.message || error),
|
||||
);
|
||||
showError(t('渠道复制失败: ') + (error?.response?.data?.message || error?.message || error));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -593,11 +540,7 @@ export const useChannelsData = () => {
|
||||
data.priority = parseInt(data.priority);
|
||||
break;
|
||||
case 'weight':
|
||||
if (
|
||||
data.weight === undefined ||
|
||||
data.weight < 0 ||
|
||||
data.weight === ''
|
||||
) {
|
||||
if (data.weight === undefined || data.weight < 0 || data.weight === '') {
|
||||
showInfo('权重必须是非负整数!');
|
||||
return;
|
||||
}
|
||||
@@ -740,136 +683,226 @@ export const useChannelsData = () => {
|
||||
const res = await API.post(`/api/channel/fix`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
showSuccess(
|
||||
t('已修复 ${success} 个通道,失败 ${fails} 个通道。')
|
||||
.replace('${success}', data.success)
|
||||
.replace('${fails}', data.fails),
|
||||
);
|
||||
showSuccess(t('已修复 ${success} 个通道,失败 ${fails} 个通道。').replace('${success}', data.success).replace('${fails}', data.fails));
|
||||
await refresh();
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
// Test channel
|
||||
// Test channel - 单个模型测试,参考旧版实现
|
||||
const testChannel = async (record, model) => {
|
||||
setTestQueue((prev) => [...prev, { channel: record, model }]);
|
||||
if (!isProcessingQueue) {
|
||||
setIsProcessingQueue(true);
|
||||
const testKey = `${record.id}-${model}`;
|
||||
|
||||
// 检查是否应该停止批量测试
|
||||
if (shouldStopBatchTestingRef.current && isBatchTesting) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
// Process test queue
|
||||
const processTestQueue = async () => {
|
||||
if (!isProcessingQueue || testQueue.length === 0) return;
|
||||
|
||||
const { channel, model, indexInFiltered } = testQueue[0];
|
||||
|
||||
if (currentTestChannel && currentTestChannel.id === channel.id) {
|
||||
let pageNo;
|
||||
if (indexInFiltered !== undefined) {
|
||||
pageNo = Math.floor(indexInFiltered / MODEL_TABLE_PAGE_SIZE) + 1;
|
||||
} else {
|
||||
const filteredModelsList = currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((m) =>
|
||||
m.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
|
||||
);
|
||||
const modelIdx = filteredModelsList.indexOf(model);
|
||||
pageNo =
|
||||
modelIdx !== -1
|
||||
? Math.floor(modelIdx / MODEL_TABLE_PAGE_SIZE) + 1
|
||||
: 1;
|
||||
}
|
||||
setModelTablePage(pageNo);
|
||||
}
|
||||
// 添加到正在测试的模型集合
|
||||
setTestingModels(prev => new Set([...prev, model]));
|
||||
|
||||
try {
|
||||
setTestingModels((prev) => new Set([...prev, model]));
|
||||
const res = await API.get(
|
||||
`/api/channel/test/${channel.id}?model=${model}`,
|
||||
);
|
||||
const res = await API.get(`/api/channel/test/${record.id}?model=${model}`);
|
||||
|
||||
// 检查是否在请求期间被停止
|
||||
if (shouldStopBatchTestingRef.current && isBatchTesting) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const { success, message, time } = res.data;
|
||||
|
||||
setModelTestResults((prev) => ({
|
||||
// 更新测试结果
|
||||
setModelTestResults(prev => ({
|
||||
...prev,
|
||||
[`${channel.id}-${model}`]: { success, time },
|
||||
[testKey]: {
|
||||
success,
|
||||
message,
|
||||
time: time || 0,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}));
|
||||
|
||||
if (success) {
|
||||
updateChannelProperty(channel.id, (ch) => {
|
||||
ch.response_time = time * 1000;
|
||||
ch.test_time = Date.now() / 1000;
|
||||
// 更新渠道响应时间
|
||||
updateChannelProperty(record.id, (channel) => {
|
||||
channel.response_time = time * 1000;
|
||||
channel.test_time = Date.now() / 1000;
|
||||
});
|
||||
if (!model) {
|
||||
|
||||
if (!model || model === '') {
|
||||
showInfo(
|
||||
t('通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。')
|
||||
.replace('${name}', channel.name)
|
||||
.replace('${name}', record.name)
|
||||
.replace('${time.toFixed(2)}', time.toFixed(2)),
|
||||
);
|
||||
} else {
|
||||
showInfo(
|
||||
t('通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。')
|
||||
.replace('${name}', record.name)
|
||||
.replace('${model}', model)
|
||||
.replace('${time.toFixed(2)}', time.toFixed(2)),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
showError(`${t('模型')} ${model}: ${message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
// 处理网络错误
|
||||
const testKey = `${record.id}-${model}`;
|
||||
setModelTestResults(prev => ({
|
||||
...prev,
|
||||
[testKey]: {
|
||||
success: false,
|
||||
message: error.message || t('网络错误'),
|
||||
time: 0,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}));
|
||||
showError(`${t('模型')} ${model}: ${error.message || t('测试失败')}`);
|
||||
} finally {
|
||||
setTestingModels((prev) => {
|
||||
// 从正在测试的模型集合中移除
|
||||
setTestingModels(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(model);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
|
||||
setTestQueue((prev) => prev.slice(1));
|
||||
};
|
||||
|
||||
// Monitor queue changes
|
||||
useEffect(() => {
|
||||
if (testQueue.length > 0 && isProcessingQueue) {
|
||||
processTestQueue();
|
||||
} else if (testQueue.length === 0 && isProcessingQueue) {
|
||||
setIsProcessingQueue(false);
|
||||
setIsBatchTesting(false);
|
||||
}
|
||||
}, [testQueue, isProcessingQueue]);
|
||||
|
||||
// Batch test models
|
||||
// 批量测试单个渠道的所有模型,参考旧版实现
|
||||
const batchTestModels = async () => {
|
||||
if (!currentTestChannel) return;
|
||||
if (!currentTestChannel || !currentTestChannel.models) {
|
||||
showError(t('渠道模型信息不完整'));
|
||||
return;
|
||||
}
|
||||
|
||||
const models = currentTestChannel.models.split(',').filter(model =>
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
|
||||
);
|
||||
|
||||
if (models.length === 0) {
|
||||
showError(t('没有找到匹配的模型'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBatchTesting(true);
|
||||
setModelTablePage(1);
|
||||
shouldStopBatchTestingRef.current = false; // 重置停止标志
|
||||
|
||||
const filteredModels = currentTestChannel.models
|
||||
.split(',')
|
||||
.filter((model) =>
|
||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
|
||||
);
|
||||
// 清空该渠道之前的测试结果
|
||||
setModelTestResults(prev => {
|
||||
const newResults = { ...prev };
|
||||
models.forEach(model => {
|
||||
const testKey = `${currentTestChannel.id}-${model}`;
|
||||
delete newResults[testKey];
|
||||
});
|
||||
return newResults;
|
||||
});
|
||||
|
||||
setTestQueue(
|
||||
filteredModels.map((model, idx) => ({
|
||||
channel: currentTestChannel,
|
||||
model,
|
||||
indexInFiltered: idx,
|
||||
})),
|
||||
);
|
||||
setIsProcessingQueue(true);
|
||||
try {
|
||||
showInfo(t('开始批量测试 ${count} 个模型,已清空上次结果...').replace('${count}', models.length));
|
||||
|
||||
// 提高并发数量以加快测试速度,参考旧版的并发限制
|
||||
const concurrencyLimit = 5;
|
||||
const results = [];
|
||||
|
||||
for (let i = 0; i < models.length; i += concurrencyLimit) {
|
||||
// 检查是否应该停止
|
||||
if (shouldStopBatchTestingRef.current) {
|
||||
showInfo(t('批量测试已停止'));
|
||||
break;
|
||||
}
|
||||
|
||||
const batch = models.slice(i, i + concurrencyLimit);
|
||||
showInfo(t('正在测试第 ${current} - ${end} 个模型 (共 ${total} 个)')
|
||||
.replace('${current}', i + 1)
|
||||
.replace('${end}', Math.min(i + concurrencyLimit, models.length))
|
||||
.replace('${total}', models.length)
|
||||
);
|
||||
|
||||
const batchPromises = batch.map(model => testChannel(currentTestChannel, model));
|
||||
const batchResults = await Promise.allSettled(batchPromises);
|
||||
results.push(...batchResults);
|
||||
|
||||
// 再次检查是否应该停止
|
||||
if (shouldStopBatchTestingRef.current) {
|
||||
showInfo(t('批量测试已停止'));
|
||||
break;
|
||||
}
|
||||
|
||||
// 短暂延迟避免过于频繁的请求
|
||||
if (i + concurrencyLimit < models.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldStopBatchTestingRef.current) {
|
||||
// 等待一小段时间确保所有结果都已更新
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// 使用当前状态重新计算结果统计
|
||||
setModelTestResults(currentResults => {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
models.forEach(model => {
|
||||
const testKey = `${currentTestChannel.id}-${model}`;
|
||||
const result = currentResults[testKey];
|
||||
if (result && result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// 显示完成消息
|
||||
setTimeout(() => {
|
||||
showSuccess(t('批量测试完成!成功: ${success}, 失败: ${fail}, 总计: ${total}')
|
||||
.replace('${success}', successCount)
|
||||
.replace('${fail}', failCount)
|
||||
.replace('${total}', models.length)
|
||||
);
|
||||
}, 100);
|
||||
|
||||
return currentResults; // 不修改状态,只是为了获取最新值
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('批量测试过程中发生错误: ') + error.message);
|
||||
} finally {
|
||||
setIsBatchTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 停止批量测试
|
||||
const stopBatchTesting = () => {
|
||||
shouldStopBatchTestingRef.current = true;
|
||||
setIsBatchTesting(false);
|
||||
setTestingModels(new Set());
|
||||
showInfo(t('已停止批量测试'));
|
||||
};
|
||||
|
||||
// 清空测试结果
|
||||
const clearTestResults = () => {
|
||||
setModelTestResults({});
|
||||
showInfo(t('已清空测试结果'));
|
||||
};
|
||||
|
||||
// Handle close modal
|
||||
const handleCloseModal = () => {
|
||||
// 如果正在批量测试,先停止测试
|
||||
if (isBatchTesting) {
|
||||
setTestQueue([]);
|
||||
setIsProcessingQueue(false);
|
||||
setIsBatchTesting(false);
|
||||
showSuccess(t('已停止测试'));
|
||||
} else {
|
||||
setShowModelTestModal(false);
|
||||
setModelSearchKeyword('');
|
||||
setSelectedModelKeys([]);
|
||||
setModelTablePage(1);
|
||||
shouldStopBatchTestingRef.current = true;
|
||||
showInfo(t('关闭弹窗,已停止批量测试'));
|
||||
}
|
||||
|
||||
setShowModelTestModal(false);
|
||||
setModelSearchKeyword('');
|
||||
setIsBatchTesting(false);
|
||||
setTestingModels(new Set());
|
||||
setSelectedModelKeys([]);
|
||||
setModelTablePage(1);
|
||||
// 可选择性保留测试结果,这里不清空以便用户查看
|
||||
};
|
||||
|
||||
// Type counts
|
||||
@@ -1012,4 +1045,4 @@ export const useChannelsData = () => {
|
||||
setCompactMode,
|
||||
setActivePage,
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -21,6 +21,10 @@ import { useState, useEffect, useMemo, useContext } from 'react';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { API } from '../../helpers';
|
||||
|
||||
// 创建一个全局事件系统来同步所有useSidebar实例
|
||||
const sidebarEventTarget = new EventTarget();
|
||||
const SIDEBAR_REFRESH_EVENT = 'sidebar-refresh';
|
||||
|
||||
export const useSidebar = () => {
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const [userConfig, setUserConfig] = useState(null);
|
||||
@@ -124,9 +128,12 @@ export const useSidebar = () => {
|
||||
|
||||
// 刷新用户配置的方法(供外部调用)
|
||||
const refreshUserConfig = async () => {
|
||||
if (Object.keys(adminConfig).length > 0) {
|
||||
if (Object.keys(adminConfig).length > 0) {
|
||||
await loadUserConfig();
|
||||
}
|
||||
|
||||
// 触发全局刷新事件,通知所有useSidebar实例更新
|
||||
sidebarEventTarget.dispatchEvent(new CustomEvent(SIDEBAR_REFRESH_EVENT));
|
||||
};
|
||||
|
||||
// 加载用户配置
|
||||
@@ -137,6 +144,21 @@ export const useSidebar = () => {
|
||||
}
|
||||
}, [adminConfig]);
|
||||
|
||||
// 监听全局刷新事件
|
||||
useEffect(() => {
|
||||
const handleRefresh = () => {
|
||||
if (Object.keys(adminConfig).length > 0) {
|
||||
loadUserConfig();
|
||||
}
|
||||
};
|
||||
|
||||
sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
|
||||
|
||||
return () => {
|
||||
sidebarEventTarget.removeEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
|
||||
};
|
||||
}, [adminConfig]);
|
||||
|
||||
// 计算最终的显示配置
|
||||
const finalConfig = useMemo(() => {
|
||||
const result = {};
|
||||
|
||||
@@ -102,7 +102,7 @@ export const useDashboardStats = (
|
||||
},
|
||||
{
|
||||
title: t('统计Tokens'),
|
||||
value: isNaN(consumeTokens) ? 0 : consumeTokens,
|
||||
value: isNaN(consumeTokens) ? 0 : consumeTokens.toLocaleString(),
|
||||
icon: <IconTextStroked />,
|
||||
avatarColor: 'pink',
|
||||
trendData: trendData.tokens,
|
||||
|
||||
@@ -447,6 +447,8 @@ export const useLogsData = () => {
|
||||
other?.audio_input_seperate_price || false,
|
||||
other?.audio_input_token_count || 0,
|
||||
other?.audio_input_price || 0,
|
||||
other?.image_generation_call || false,
|
||||
other?.image_generation_call_price || 0,
|
||||
);
|
||||
}
|
||||
expandDataLocal.push({
|
||||
|
||||
@@ -837,6 +837,7 @@
|
||||
"确定要充值 $": "Confirm to top up $",
|
||||
"微信/支付宝 实付金额:": "WeChat/Alipay actual payment amount:",
|
||||
"Stripe 实付金额:": "Stripe actual payment amount:",
|
||||
"允许在 Stripe 支付中输入促销码": "Allow entering promotion codes during Stripe checkout",
|
||||
"支付中...": "Paying",
|
||||
"支付宝": "Alipay",
|
||||
"收益统计": "Income statistics",
|
||||
@@ -1889,6 +1890,10 @@
|
||||
"确定要删除所有已自动禁用的密钥吗?": "Are you sure you want to delete all automatically disabled keys?",
|
||||
"此操作不可撤销,将永久删除已自动禁用的密钥": "This operation cannot be undone, and all automatically disabled keys will be permanently deleted.",
|
||||
"删除自动禁用密钥": "Delete auto disabled keys",
|
||||
"确定要删除此密钥吗?": "Are you sure you want to delete this key?",
|
||||
"此操作不可撤销,将永久删除该密钥": "This operation cannot be undone, and the key will be permanently deleted.",
|
||||
"密钥已删除": "Key has been deleted",
|
||||
"删除密钥失败": "Failed to delete key",
|
||||
"图标": "Icon",
|
||||
"模型图标": "Model icon",
|
||||
"请输入图标名称": "Please enter the icon name",
|
||||
@@ -1999,6 +2004,16 @@
|
||||
"查看渠道密钥": "View channel key",
|
||||
"渠道密钥信息": "Channel key information",
|
||||
"密钥获取成功": "Key acquisition successful",
|
||||
"模型补全倍率(仅对自定义模型有效)": "Model completion ratio (only effective for custom models)",
|
||||
"图片倍率": "Image ratio",
|
||||
"音频倍率": "Audio ratio",
|
||||
"音频补全倍率": "Audio completion ratio",
|
||||
"图片输入相关的倍率设置,键为模型名称,值为倍率": "Image input related ratio settings, key is model name, value is ratio",
|
||||
"音频输入相关的倍率设置,键为模型名称,值为倍率": "Audio input related ratio settings, key is model name, value is ratio",
|
||||
"音频输出补全相关的倍率设置,键为模型名称,值为倍率": "Audio output completion related ratio settings, key is model name, value is ratio",
|
||||
"为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-image-1\": 2}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-image-1\": 2}",
|
||||
"为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-4o-audio-preview\": 16}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-4o-audio-preview\": 16}",
|
||||
"为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-4o-realtime\": 2}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-4o-realtime\": 2}",
|
||||
"顶栏管理": "Header Management",
|
||||
"控制顶栏模块显示状态,全局生效": "Control header module display status, global effect",
|
||||
"用户主页,展示系统信息": "User homepage, displaying system information",
|
||||
@@ -2058,7 +2073,7 @@
|
||||
"需要登录访问": "Require Login",
|
||||
"开启后未登录用户无法访问模型广场": "When enabled, unauthenticated users cannot access the model marketplace",
|
||||
"参与官方同步": "Participate in official sync",
|
||||
"关闭后,此模型将不会被“同步官方”自动覆盖或创建": "When turned off, this model will be skipped by Sync official (no auto create/overwrite)",
|
||||
"关闭后,此模型将不会被\"同步官方\"自动覆盖或创建": "When turned off, this model will be skipped by Sync official (no auto create/overwrite)",
|
||||
"同步": "Sync",
|
||||
"同步向导": "Sync Wizard",
|
||||
"选择方式": "Select method",
|
||||
@@ -2084,5 +2099,36 @@
|
||||
"原价": "Original price",
|
||||
"优惠": "Discount",
|
||||
"折": "% off",
|
||||
"节省": "Save"
|
||||
"节省": "Save",
|
||||
"今天": "Today",
|
||||
"近 7 天": "Last 7 Days",
|
||||
"本周": "This Week",
|
||||
"本月": "This Month",
|
||||
"近 30 天": "Last 30 Days",
|
||||
"代理设置": "Proxy Settings",
|
||||
"更新Worker设置": "Update Worker Settings",
|
||||
"SSRF防护设置": "SSRF Protection Settings",
|
||||
"配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全": "Configure Server-Side Request Forgery (SSRF) protection to secure internal network resources",
|
||||
"SSRF防护详细说明": "SSRF protection prevents malicious users from using your server to access internal network resources. Configure whitelists for trusted domains/IPs and restrict allowed ports. Applies to file downloads, webhooks, and notifications.",
|
||||
"启用SSRF防护(推荐开启以保护服务器安全)": "Enable SSRF Protection (Recommended for server security)",
|
||||
"SSRF防护开关详细说明": "Master switch controls whether SSRF protection is enabled. When disabled, all SSRF checks are bypassed, allowing access to any URL. ⚠️ Only disable this feature in completely trusted environments.",
|
||||
"允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)": "Allow access to private IP addresses (127.0.0.1, 192.168.x.x and other internal addresses)",
|
||||
"私有IP访问详细说明": "⚠️ Security Warning: Enabling this allows access to internal network resources (localhost, private networks). Only enable if you need to access internal services and understand the security implications.",
|
||||
"域名白名单": "Domain Whitelist",
|
||||
"支持通配符格式,如:example.com, *.api.example.com": "Supports wildcard format, e.g.: example.com, *.api.example.com",
|
||||
"域名白名单详细说明": "Whitelisted domains bypass all SSRF checks and are allowed direct access. Supports exact domains (example.com) or wildcards (*.api.example.com) for subdomains. When whitelist is empty, all domains go through SSRF validation.",
|
||||
"输入域名后回车,如:example.com": "Enter domain and press Enter, e.g.: example.com",
|
||||
"支持CIDR格式,如:8.8.8.8, 192.168.1.0/24": "Supports CIDR format, e.g.: 8.8.8.8, 192.168.1.0/24",
|
||||
"IP白名单详细说明": "Controls which IP addresses are allowed access. Use single IPs (8.8.8.8) or CIDR notation (192.168.1.0/24). Empty whitelist allows all IPs (subject to private IP settings), non-empty whitelist only allows listed IPs.",
|
||||
"输入IP地址后回车,如:8.8.8.8": "Enter IP address and press Enter, e.g.: 8.8.8.8",
|
||||
"允许的端口": "Allowed Ports",
|
||||
"支持单个端口和端口范围,如:80, 443, 8000-8999": "Supports single ports and port ranges, e.g.: 80, 443, 8000-8999",
|
||||
"端口配置详细说明": "Restrict external requests to specific ports. Use single ports (80, 443) or ranges (8000-8999). Empty list allows all ports. Default includes common web ports.",
|
||||
"输入端口后回车,如:80 或 8000-8999": "Enter port and press Enter, e.g.: 80 or 8000-8999",
|
||||
"更新SSRF防护设置": "Update SSRF Protection Settings",
|
||||
"对域名启用 IP 过滤(实验性)": "Enable IP filtering for domains (experimental)",
|
||||
"域名IP过滤详细说明": "⚠️ This is an experimental option. A domain may resolve to multiple IPv4/IPv6 addresses. If enabled, ensure the IP filter list covers these addresses, otherwise access may fail.",
|
||||
"域名黑名单": "Domain Blacklist",
|
||||
"白名单": "Whitelist",
|
||||
"黑名单": "Blacklist"
|
||||
}
|
||||
|
||||
@@ -9,5 +9,29 @@
|
||||
"语言": "语言",
|
||||
"展开侧边栏": "展开侧边栏",
|
||||
"关闭侧边栏": "关闭侧边栏",
|
||||
"注销成功!": "注销成功!"
|
||||
"注销成功!": "注销成功!",
|
||||
"代理设置": "代理设置",
|
||||
"更新Worker设置": "更新Worker设置",
|
||||
"SSRF防护设置": "SSRF防护设置",
|
||||
"配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全": "配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全",
|
||||
"SSRF防护详细说明": "SSRF防护可防止恶意用户利用您的服务器访问内网资源。您可以配置受信任域名/IP的白名单,并限制允许的端口。适用于文件下载、Webhook回调和通知功能。",
|
||||
"启用SSRF防护(推荐开启以保护服务器安全)": "启用SSRF防护(推荐开启以保护服务器安全)",
|
||||
"SSRF防护开关详细说明": "总开关控制是否启用SSRF防护功能。关闭后将跳过所有SSRF检查,允许访问任意URL。⚠️ 仅在完全信任环境中关闭此功能。",
|
||||
"允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)": "允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)",
|
||||
"私有IP访问详细说明": "⚠️ 安全警告:启用此选项将允许访问内网资源(本地主机、私有网络)。仅在需要访问内部服务且了解安全风险的情况下启用。",
|
||||
"域名白名单": "域名白名单",
|
||||
"支持通配符格式,如:example.com, *.api.example.com": "支持通配符格式,如:example.com, *.api.example.com",
|
||||
"域名白名单详细说明": "白名单中的域名将绕过所有SSRF检查,直接允许访问。支持精确域名(example.com)或通配符(*.api.example.com)匹配子域名。白名单为空时,所有域名都需要通过SSRF检查。",
|
||||
"输入域名后回车,如:example.com": "输入域名后回车,如:example.com",
|
||||
"IP白名单": "IP白名单",
|
||||
"支持CIDR格式,如:8.8.8.8, 192.168.1.0/24": "支持CIDR格式,如:8.8.8.8, 192.168.1.0/24",
|
||||
"IP白名单详细说明": "控制允许访问的IP地址。支持单个IP(8.8.8.8)或CIDR网段(192.168.1.0/24)。空白名单允许所有IP(但仍受私有IP设置限制),非空白名单仅允许列表中的IP访问。",
|
||||
"输入IP地址后回车,如:8.8.8.8": "输入IP地址后回车,如:8.8.8.8",
|
||||
"允许的端口": "允许的端口",
|
||||
"支持单个端口和端口范围,如:80, 443, 8000-8999": "支持单个端口和端口范围,如:80, 443, 8000-8999",
|
||||
"端口配置详细说明": "限制外部请求只能访问指定端口。支持单个端口(80, 443)或端口范围(8000-8999)。空列表允许所有端口。默认包含常用Web端口。",
|
||||
"输入端口后回车,如:80 或 8000-8999": "输入端口后回车,如:80 或 8000-8999",
|
||||
"更新SSRF防护设置": "更新SSRF防护设置",
|
||||
"域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。",
|
||||
"允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码"
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export default function SettingsPaymentGateway(props) {
|
||||
StripePriceId: '',
|
||||
StripeUnitPrice: 8.0,
|
||||
StripeMinTopUp: 1,
|
||||
StripePromotionCodesEnabled: false,
|
||||
});
|
||||
const [originInputs, setOriginInputs] = useState({});
|
||||
const formApiRef = useRef(null);
|
||||
@@ -63,6 +64,10 @@ export default function SettingsPaymentGateway(props) {
|
||||
props.options.StripeMinTopUp !== undefined
|
||||
? parseFloat(props.options.StripeMinTopUp)
|
||||
: 1,
|
||||
StripePromotionCodesEnabled:
|
||||
props.options.StripePromotionCodesEnabled !== undefined
|
||||
? props.options.StripePromotionCodesEnabled
|
||||
: false,
|
||||
};
|
||||
setInputs(currentInputs);
|
||||
setOriginInputs({ ...currentInputs });
|
||||
@@ -114,6 +119,16 @@ export default function SettingsPaymentGateway(props) {
|
||||
value: inputs.StripeMinTopUp.toString(),
|
||||
});
|
||||
}
|
||||
if (
|
||||
originInputs['StripePromotionCodesEnabled'] !==
|
||||
inputs.StripePromotionCodesEnabled &&
|
||||
inputs.StripePromotionCodesEnabled !== undefined
|
||||
) {
|
||||
options.push({
|
||||
key: 'StripePromotionCodesEnabled',
|
||||
value: inputs.StripePromotionCodesEnabled ? 'true' : 'false',
|
||||
});
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const requestQueue = options.map((opt) =>
|
||||
@@ -225,6 +240,15 @@ export default function SettingsPaymentGateway(props) {
|
||||
placeholder={t('例如:2,就是最低充值2$')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field='StripePromotionCodesEnabled'
|
||||
size='default'
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
label={t('允许在 Stripe 支付中输入促销码')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitStripeSetting}>{t('更新 Stripe 设置')}</Button>
|
||||
</Form.Section>
|
||||
|
||||
@@ -44,6 +44,9 @@ export default function ModelRatioSettings(props) {
|
||||
ModelRatio: '',
|
||||
CacheRatio: '',
|
||||
CompletionRatio: '',
|
||||
ImageRatio: '',
|
||||
AudioRatio: '',
|
||||
AudioCompletionRatio: '',
|
||||
ExposeRatioEnabled: false,
|
||||
});
|
||||
const refForm = useRef();
|
||||
@@ -219,6 +222,72 @@ export default function ModelRatioSettings(props) {
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('图片输入倍率(仅部分模型支持该计费)')}
|
||||
extraText={t('图片输入相关的倍率设置,键为模型名称,值为倍率,仅部分模型支持该计费')}
|
||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-image-1": 2}')}
|
||||
field={'ImageRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, ImageRatio: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('音频倍率(仅部分模型支持该计费)')}
|
||||
extraText={t('音频输入相关的倍率设置,键为模型名称,值为倍率')}
|
||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-audio-preview": 16}')}
|
||||
field={'AudioRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, AudioRatio: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.TextArea
|
||||
label={t('音频补全倍率(仅部分模型支持该计费)')}
|
||||
extraText={t('音频输出补全相关的倍率设置,键为模型名称,值为倍率')}
|
||||
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-realtime": 2}')}
|
||||
field={'AudioCompletionRatio'}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
trigger='blur'
|
||||
stopValidateWithError
|
||||
rules={[
|
||||
{
|
||||
validator: (rule, value) => verifyJSON(value),
|
||||
message: '不是合法的 JSON 字符串',
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setInputs({ ...inputs, AudioCompletionRatio: value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={16}>
|
||||
<Form.Switch
|
||||
|
||||
@@ -20,10 +20,16 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig, transformWithEsbuild } from 'vite';
|
||||
import pkg from '@douyinfe/vite-plugin-semi';
|
||||
import path from 'path';
|
||||
const { vitePluginSemi } = pkg;
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: 'treat-js-files-as-jsx',
|
||||
|
||||
Reference in New Issue
Block a user