Merge branch 'main' into feature/date-shortcut
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 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1027,6 +1027,8 @@ export function renderModelPrice(
|
||||
audioInputSeperatePrice = false,
|
||||
audioInputTokens = 0,
|
||||
audioInputPrice = 0,
|
||||
imageGenerationCall = false,
|
||||
imageGenerationCallPrice = 0,
|
||||
) {
|
||||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
|
||||
groupRatio,
|
||||
@@ -1069,7 +1071,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 (
|
||||
<>
|
||||
@@ -1131,7 +1134,13 @@ export function renderModelPrice(
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p></p>
|
||||
{imageGenerationCall && imageGenerationCallPrice > 0 && (
|
||||
<p>
|
||||
{i18next.t('图片生成调用:${{price}} / 1次', {
|
||||
price: imageGenerationCallPrice,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
{(() => {
|
||||
// 构建输入部分描述
|
||||
@@ -1211,6 +1220,16 @@ export function renderModelPrice(
|
||||
},
|
||||
)
|
||||
: '',
|
||||
imageGenerationCall && imageGenerationCallPrice > 0
|
||||
? i18next.t(
|
||||
' + 图片生成调用 ${{price}} / 1次 * {{ratioType}} {{ratio}}',
|
||||
{
|
||||
price: imageGenerationCallPrice,
|
||||
ratio: groupRatio,
|
||||
ratioType: ratioLabel,
|
||||
},
|
||||
)
|
||||
: '',
|
||||
].join('');
|
||||
|
||||
return i18next.t(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1999,6 +1999,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 +2068,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",
|
||||
@@ -2089,5 +2099,31 @@
|
||||
"近 7 天": "Last 7 Days",
|
||||
"本周": "This Week",
|
||||
"本月": "This Month",
|
||||
"近 30 天": "Last 30 Days"
|
||||
"近 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,28 @@
|
||||
"语言": "语言",
|
||||
"展开侧边栏": "展开侧边栏",
|
||||
"关闭侧边栏": "关闭侧边栏",
|
||||
"注销成功!": "注销成功!"
|
||||
"注销成功!": "注销成功!",
|
||||
"代理设置": "代理设置",
|
||||
"更新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 过滤列表覆盖这些地址,否则可能导致访问失败。"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user