Merge branch 'upstream-main' into feature/improve-param-override
# Conflicts: # relay/channel/api_request_test.go # relay/common/override_test.go # web/src/components/table/channels/modals/EditChannelModal.jsx
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
||||
showSuccess,
|
||||
updateAPI,
|
||||
getSystemName,
|
||||
getOAuthProviderIcon,
|
||||
setUserData,
|
||||
onGitHubOAuthClicked,
|
||||
onDiscordOAuthClicked,
|
||||
@@ -130,6 +131,17 @@ const LoginForm = () => {
|
||||
return {};
|
||||
}
|
||||
}, [statusState?.status]);
|
||||
const hasCustomOAuthProviders =
|
||||
(status.custom_oauth_providers || []).length > 0;
|
||||
const hasOAuthLoginOptions = Boolean(
|
||||
status.github_oauth ||
|
||||
status.discord_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth ||
|
||||
hasCustomOAuthProviders,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.turnstile_check) {
|
||||
@@ -598,7 +610,7 @@ const LoginForm = () => {
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<IconLock size='large' />}
|
||||
icon={getOAuthProviderIcon(provider.icon || '', 20)}
|
||||
onClick={() => handleCustomOAuthClick(provider)}
|
||||
loading={customOAuthLoading[provider.slug]}
|
||||
>
|
||||
@@ -817,12 +829,7 @@ const LoginForm = () => {
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{(status.github_oauth ||
|
||||
status.discord_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth) && (
|
||||
{hasOAuthLoginOptions && (
|
||||
<>
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
@@ -952,14 +959,7 @@ const LoginForm = () => {
|
||||
/>
|
||||
<div className='w-full max-w-sm mt-[60px]'>
|
||||
{showEmailLogin ||
|
||||
!(
|
||||
status.github_oauth ||
|
||||
status.discord_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth
|
||||
)
|
||||
!hasOAuthLoginOptions
|
||||
? renderEmailLoginForm()
|
||||
: renderOAuthOptions()}
|
||||
{renderWeChatLoginModal()}
|
||||
|
||||
@@ -27,8 +27,10 @@ import {
|
||||
showSuccess,
|
||||
updateAPI,
|
||||
getSystemName,
|
||||
getOAuthProviderIcon,
|
||||
setUserData,
|
||||
onDiscordOAuthClicked,
|
||||
onCustomOAuthClicked,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import {
|
||||
@@ -98,6 +100,7 @@ const RegisterForm = () => {
|
||||
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] =
|
||||
useState(false);
|
||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||
const [customOAuthLoading, setCustomOAuthLoading] = useState({});
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
@@ -126,6 +129,17 @@ const RegisterForm = () => {
|
||||
return {};
|
||||
}
|
||||
}, [statusState?.status]);
|
||||
const hasCustomOAuthProviders =
|
||||
(status.custom_oauth_providers || []).length > 0;
|
||||
const hasOAuthRegisterOptions = Boolean(
|
||||
status.github_oauth ||
|
||||
status.discord_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth ||
|
||||
hasCustomOAuthProviders,
|
||||
);
|
||||
|
||||
const [showEmailVerification, setShowEmailVerification] = useState(false);
|
||||
|
||||
@@ -319,6 +333,17 @@ const RegisterForm = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomOAuthClick = (provider) => {
|
||||
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: true }));
|
||||
try {
|
||||
onCustomOAuthClicked(provider, { shouldLogout: true });
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setCustomOAuthLoading((prev) => ({ ...prev, [provider.slug]: false }));
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailRegisterClick = () => {
|
||||
setEmailRegisterLoading(true);
|
||||
setShowEmailRegister(true);
|
||||
@@ -469,6 +494,23 @@ const RegisterForm = () => {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.custom_oauth_providers &&
|
||||
status.custom_oauth_providers.map((provider) => (
|
||||
<Button
|
||||
key={provider.slug}
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={getOAuthProviderIcon(provider.icon || '', 20)}
|
||||
onClick={() => handleCustomOAuthClick(provider)}
|
||||
loading={customOAuthLoading[provider.slug]}
|
||||
>
|
||||
<span className='ml-3'>
|
||||
{t('使用 {{name}} 继续', { name: provider.name })}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{status.telegram_oauth && (
|
||||
<div className='flex justify-center my-2'>
|
||||
<TelegramLoginButton
|
||||
@@ -650,12 +692,7 @@ const RegisterForm = () => {
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{(status.github_oauth ||
|
||||
status.discord_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth) && (
|
||||
{hasOAuthRegisterOptions && (
|
||||
<>
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
@@ -745,14 +782,7 @@ const RegisterForm = () => {
|
||||
/>
|
||||
<div className='w-full max-w-sm mt-[60px]'>
|
||||
{showEmailRegister ||
|
||||
!(
|
||||
status.github_oauth ||
|
||||
status.discord_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth
|
||||
)
|
||||
!hasOAuthRegisterOptions
|
||||
? renderEmailRegisterForm()
|
||||
: renderOAuthOptions()}
|
||||
{renderWeChatLoginModal()}
|
||||
|
||||
214
web/src/components/common/modals/RiskAcknowledgementModal.jsx
Normal file
214
web/src/components/common/modals/RiskAcknowledgementModal.jsx
Normal file
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
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 React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Typography,
|
||||
Checkbox,
|
||||
Input,
|
||||
Space,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconAlertTriangle } from '@douyinfe/semi-icons';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import MarkdownRenderer from '../markdown/MarkdownRenderer';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const RiskMarkdownBlock = React.memo(function RiskMarkdownBlock({
|
||||
markdownContent,
|
||||
}) {
|
||||
if (!markdownContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='rounded-lg'
|
||||
style={{
|
||||
border: '1px solid var(--semi-color-warning-light-hover)',
|
||||
padding: '12px',
|
||||
contentVisibility: 'auto',
|
||||
}}
|
||||
>
|
||||
<MarkdownRenderer content={markdownContent} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
|
||||
visible,
|
||||
title,
|
||||
markdownContent = '',
|
||||
detailTitle = '',
|
||||
detailItems = [],
|
||||
checklist = [],
|
||||
inputPrompt = '',
|
||||
requiredText = '',
|
||||
inputPlaceholder = '',
|
||||
mismatchText = '',
|
||||
cancelText = '',
|
||||
confirmText = '',
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [checkedItems, setCheckedItems] = useState([]);
|
||||
const [typedText, setTypedText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setCheckedItems(Array(checklist.length).fill(false));
|
||||
setTypedText('');
|
||||
}, [visible, checklist.length]);
|
||||
|
||||
const allChecked = useMemo(() => {
|
||||
if (checklist.length === 0) return true;
|
||||
return checkedItems.length === checklist.length && checkedItems.every(Boolean);
|
||||
}, [checkedItems, checklist.length]);
|
||||
|
||||
const typedMatched = useMemo(() => {
|
||||
if (!requiredText) return true;
|
||||
return typedText.trim() === requiredText.trim();
|
||||
}, [typedText, requiredText]);
|
||||
|
||||
const detailText = useMemo(() => detailItems.join(', '), [detailItems]);
|
||||
const canConfirm = allChecked && typedMatched;
|
||||
|
||||
const handleChecklistChange = useCallback((index, checked) => {
|
||||
setCheckedItems((previous) => {
|
||||
const next = [...previous];
|
||||
next[index] = checked;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={
|
||||
<Space align='center'>
|
||||
<IconAlertTriangle style={{ color: 'var(--semi-color-warning)' }} />
|
||||
<span>{title}</span>
|
||||
</Space>
|
||||
}
|
||||
width={isMobile ? '100%' : 860}
|
||||
centered
|
||||
maskClosable={false}
|
||||
closeOnEsc={false}
|
||||
onCancel={onCancel}
|
||||
bodyStyle={{
|
||||
maxHeight: isMobile ? '70vh' : '72vh',
|
||||
overflowY: 'auto',
|
||||
padding: isMobile ? '12px 16px' : '18px 22px',
|
||||
}}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={onCancel}>{cancelText}</Button>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='danger'
|
||||
disabled={!canConfirm}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div className='flex flex-col gap-4'>
|
||||
|
||||
<RiskMarkdownBlock markdownContent={markdownContent} />
|
||||
|
||||
{detailItems.length > 0 ? (
|
||||
<div
|
||||
className='flex flex-col gap-2 rounded-lg'
|
||||
style={{
|
||||
border: '1px solid var(--semi-color-warning-light-hover)',
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
padding: isMobile ? '10px 12px' : '12px 14px',
|
||||
}}
|
||||
>
|
||||
{detailTitle ? <Text strong>{detailTitle}</Text> : null}
|
||||
<div className='font-mono text-xs break-all bg-orange-50 border border-orange-200 rounded-md p-2'>
|
||||
{detailText}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{checklist.length > 0 ? (
|
||||
<div
|
||||
className='flex flex-col gap-2 rounded-lg'
|
||||
style={{
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
padding: isMobile ? '10px 12px' : '12px 14px',
|
||||
}}
|
||||
>
|
||||
{checklist.map((item, index) => (
|
||||
<Checkbox
|
||||
key={`risk-check-${index}`}
|
||||
checked={!!checkedItems[index]}
|
||||
onChange={(event) => {
|
||||
handleChecklistChange(index, event.target.checked);
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{requiredText ? (
|
||||
<div
|
||||
className='flex flex-col gap-2 rounded-lg'
|
||||
style={{
|
||||
border: '1px solid var(--semi-color-danger-light-hover)',
|
||||
background: 'var(--semi-color-danger-light-default)',
|
||||
padding: isMobile ? '10px 12px' : '12px 14px',
|
||||
}}
|
||||
>
|
||||
{inputPrompt ? <Text strong>{inputPrompt}</Text> : null}
|
||||
<div className='font-mono text-xs break-all rounded-md p-2 bg-gray-50 border border-gray-200'>
|
||||
{requiredText}
|
||||
</div>
|
||||
<Input
|
||||
value={typedText}
|
||||
onChange={setTypedText}
|
||||
placeholder={inputPlaceholder}
|
||||
autoFocus={visible}
|
||||
onCopy={(event) => event.preventDefault()}
|
||||
onCut={(event) => event.preventDefault()}
|
||||
onPaste={(event) => event.preventDefault()}
|
||||
onDrop={(event) => event.preventDefault()}
|
||||
/>
|
||||
{!typedMatched && typedText ? (
|
||||
<Text type='danger' size='small'>
|
||||
{mismatchText}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default RiskAcknowledgementModal;
|
||||
@@ -27,14 +27,20 @@ import {
|
||||
Modal,
|
||||
Banner,
|
||||
Card,
|
||||
Collapse,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Select,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconPlus, IconEdit, IconDelete } from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import {
|
||||
IconPlus,
|
||||
IconEdit,
|
||||
IconDelete,
|
||||
IconRefresh,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess, getOAuthProviderIcon } from '../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -120,6 +126,69 @@ const OAUTH_PRESETS = {
|
||||
},
|
||||
};
|
||||
|
||||
const OAUTH_PRESET_ICONS = {
|
||||
'github-enterprise': 'github',
|
||||
gitlab: 'gitlab',
|
||||
gitea: 'gitea',
|
||||
nextcloud: 'nextcloud',
|
||||
keycloak: 'keycloak',
|
||||
authentik: 'authentik',
|
||||
ory: 'openid',
|
||||
};
|
||||
|
||||
const getPresetIcon = (preset) => OAUTH_PRESET_ICONS[preset] || '';
|
||||
|
||||
const PRESET_RESET_VALUES = {
|
||||
name: '',
|
||||
slug: '',
|
||||
icon: '',
|
||||
authorization_endpoint: '',
|
||||
token_endpoint: '',
|
||||
user_info_endpoint: '',
|
||||
scopes: '',
|
||||
user_id_field: '',
|
||||
username_field: '',
|
||||
display_name_field: '',
|
||||
email_field: '',
|
||||
well_known: '',
|
||||
auth_style: 0,
|
||||
access_policy: '',
|
||||
access_denied_message: '',
|
||||
};
|
||||
|
||||
const DISCOVERY_FIELD_LABELS = {
|
||||
authorization_endpoint: 'Authorization Endpoint',
|
||||
token_endpoint: 'Token Endpoint',
|
||||
user_info_endpoint: 'User Info Endpoint',
|
||||
scopes: 'Scopes',
|
||||
user_id_field: 'User ID Field',
|
||||
username_field: 'Username Field',
|
||||
display_name_field: 'Display Name Field',
|
||||
email_field: 'Email Field',
|
||||
};
|
||||
|
||||
const ACCESS_POLICY_TEMPLATES = {
|
||||
level_active: `{
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
{"field": "trust_level", "op": "gte", "value": 2},
|
||||
{"field": "active", "op": "eq", "value": true}
|
||||
]
|
||||
}`,
|
||||
org_or_role: `{
|
||||
"logic": "or",
|
||||
"conditions": [
|
||||
{"field": "org", "op": "eq", "value": "core"},
|
||||
{"field": "roles", "op": "contains", "value": "admin"}
|
||||
]
|
||||
}`,
|
||||
};
|
||||
|
||||
const ACCESS_DENIED_TEMPLATES = {
|
||||
level_hint: '需要等级 {{required}},你当前等级 {{current}}(字段:{{field}})',
|
||||
org_hint: '仅限指定组织或角色访问。组织={{current.org}},角色={{current.roles}}',
|
||||
};
|
||||
|
||||
const CustomOAuthSetting = ({ serverAddress }) => {
|
||||
const { t } = useTranslation();
|
||||
const [providers, setProviders] = useState([]);
|
||||
@@ -129,8 +198,47 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
||||
const [formValues, setFormValues] = useState({});
|
||||
const [selectedPreset, setSelectedPreset] = useState('');
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [discoveryLoading, setDiscoveryLoading] = useState(false);
|
||||
const [discoveryInfo, setDiscoveryInfo] = useState(null);
|
||||
const [advancedActiveKeys, setAdvancedActiveKeys] = useState([]);
|
||||
const formApiRef = React.useRef(null);
|
||||
|
||||
const mergeFormValues = (newValues) => {
|
||||
setFormValues((prev) => ({ ...prev, ...newValues }));
|
||||
if (!formApiRef.current) return;
|
||||
Object.entries(newValues).forEach(([key, value]) => {
|
||||
formApiRef.current.setValue(key, value);
|
||||
});
|
||||
};
|
||||
|
||||
const getLatestFormValues = () => {
|
||||
const values = formApiRef.current?.getValues?.();
|
||||
return values && typeof values === 'object' ? values : formValues;
|
||||
};
|
||||
|
||||
const normalizeBaseUrl = (url) => (url || '').trim().replace(/\/+$/, '');
|
||||
|
||||
const inferBaseUrlFromProvider = (provider) => {
|
||||
const endpoint = provider?.authorization_endpoint || provider?.token_endpoint;
|
||||
if (!endpoint) return '';
|
||||
try {
|
||||
const url = new URL(endpoint);
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const resetDiscoveryState = () => {
|
||||
setDiscoveryInfo(null);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalVisible(false);
|
||||
resetDiscoveryState();
|
||||
setAdvancedActiveKeys([]);
|
||||
};
|
||||
|
||||
const fetchProviders = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -154,23 +262,30 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
||||
setEditingProvider(null);
|
||||
setFormValues({
|
||||
enabled: false,
|
||||
icon: '',
|
||||
scopes: 'openid profile email',
|
||||
user_id_field: 'sub',
|
||||
username_field: 'preferred_username',
|
||||
display_name_field: 'name',
|
||||
email_field: 'email',
|
||||
auth_style: 0,
|
||||
access_policy: '',
|
||||
access_denied_message: '',
|
||||
});
|
||||
setSelectedPreset('');
|
||||
setBaseUrl('');
|
||||
resetDiscoveryState();
|
||||
setAdvancedActiveKeys([]);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (provider) => {
|
||||
setEditingProvider(provider);
|
||||
setFormValues({ ...provider });
|
||||
setSelectedPreset('');
|
||||
setBaseUrl('');
|
||||
setSelectedPreset(OAUTH_PRESETS[provider.slug] ? provider.slug : '');
|
||||
setBaseUrl(inferBaseUrlFromProvider(provider));
|
||||
resetDiscoveryState();
|
||||
setAdvancedActiveKeys([]);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
@@ -189,6 +304,8 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const currentValues = getLatestFormValues();
|
||||
|
||||
// Validate required fields
|
||||
const requiredFields = [
|
||||
'name',
|
||||
@@ -204,7 +321,7 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
||||
}
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!formValues[field]) {
|
||||
if (!currentValues[field]) {
|
||||
showError(t(`请填写 ${field}`));
|
||||
return;
|
||||
}
|
||||
@@ -213,11 +330,11 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
||||
// Validate endpoint URLs must be full URLs
|
||||
const endpointFields = ['authorization_endpoint', 'token_endpoint', 'user_info_endpoint'];
|
||||
for (const field of endpointFields) {
|
||||
const value = formValues[field];
|
||||
const value = currentValues[field];
|
||||
if (value && !value.startsWith('http://') && !value.startsWith('https://')) {
|
||||
// Check if user selected a preset but forgot to fill server address
|
||||
// Check if user selected a preset but forgot to fill issuer URL
|
||||
if (selectedPreset && !baseUrl) {
|
||||
showError(t('请先填写服务器地址,以自动生成完整的端点 URL'));
|
||||
showError(t('请先填写 Issuer URL,以自动生成完整的端点 URL'));
|
||||
} else {
|
||||
showError(t('端点 URL 必须是完整地址(以 http:// 或 https:// 开头)'));
|
||||
}
|
||||
@@ -226,80 +343,199 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = { ...currentValues, enabled: !!currentValues.enabled };
|
||||
delete payload.preset;
|
||||
delete payload.base_url;
|
||||
|
||||
let res;
|
||||
if (editingProvider) {
|
||||
res = await API.put(
|
||||
`/api/custom-oauth-provider/${editingProvider.id}`,
|
||||
formValues
|
||||
payload
|
||||
);
|
||||
} else {
|
||||
res = await API.post('/api/custom-oauth-provider/', formValues);
|
||||
res = await API.post('/api/custom-oauth-provider/', payload);
|
||||
}
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(editingProvider ? t('更新成功') : t('创建成功'));
|
||||
setModalVisible(false);
|
||||
closeModal();
|
||||
fetchProviders();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(editingProvider ? t('更新失败') : t('创建失败'));
|
||||
showError(
|
||||
error?.response?.data?.message ||
|
||||
(editingProvider ? t('更新失败') : t('创建失败')),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetchFromDiscovery = async () => {
|
||||
const cleanBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
const configuredWellKnown = (formValues.well_known || '').trim();
|
||||
const wellKnownUrl =
|
||||
configuredWellKnown ||
|
||||
(cleanBaseUrl ? `${cleanBaseUrl}/.well-known/openid-configuration` : '');
|
||||
|
||||
if (!wellKnownUrl) {
|
||||
showError(t('请先填写 Discovery URL 或 Issuer URL'));
|
||||
return;
|
||||
}
|
||||
|
||||
setDiscoveryLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/custom-oauth-provider/discovery', {
|
||||
well_known_url: configuredWellKnown || '',
|
||||
issuer_url: cleanBaseUrl || '',
|
||||
});
|
||||
if (!res.data.success) {
|
||||
throw new Error(res.data.message || t('未知错误'));
|
||||
}
|
||||
const data = res.data.data?.discovery || {};
|
||||
const resolvedWellKnown = res.data.data?.well_known_url || wellKnownUrl;
|
||||
|
||||
const discoveredValues = {
|
||||
well_known: resolvedWellKnown,
|
||||
};
|
||||
const autoFilledFields = [];
|
||||
if (data.authorization_endpoint) {
|
||||
discoveredValues.authorization_endpoint = data.authorization_endpoint;
|
||||
autoFilledFields.push('authorization_endpoint');
|
||||
}
|
||||
if (data.token_endpoint) {
|
||||
discoveredValues.token_endpoint = data.token_endpoint;
|
||||
autoFilledFields.push('token_endpoint');
|
||||
}
|
||||
if (data.userinfo_endpoint) {
|
||||
discoveredValues.user_info_endpoint = data.userinfo_endpoint;
|
||||
autoFilledFields.push('user_info_endpoint');
|
||||
}
|
||||
|
||||
const scopesSupported = Array.isArray(data.scopes_supported)
|
||||
? data.scopes_supported
|
||||
: [];
|
||||
if (scopesSupported.length > 0 && !formValues.scopes) {
|
||||
const preferredScopes = ['openid', 'profile', 'email'].filter((scope) =>
|
||||
scopesSupported.includes(scope),
|
||||
);
|
||||
discoveredValues.scopes =
|
||||
preferredScopes.length > 0
|
||||
? preferredScopes.join(' ')
|
||||
: scopesSupported.slice(0, 5).join(' ');
|
||||
autoFilledFields.push('scopes');
|
||||
}
|
||||
|
||||
const claimsSupported = Array.isArray(data.claims_supported)
|
||||
? data.claims_supported
|
||||
: [];
|
||||
const claimMap = {
|
||||
user_id_field: 'sub',
|
||||
username_field: 'preferred_username',
|
||||
display_name_field: 'name',
|
||||
email_field: 'email',
|
||||
};
|
||||
Object.entries(claimMap).forEach(([field, claim]) => {
|
||||
if (!formValues[field] && claimsSupported.includes(claim)) {
|
||||
discoveredValues[field] = claim;
|
||||
autoFilledFields.push(field);
|
||||
}
|
||||
});
|
||||
|
||||
const hasCoreEndpoint =
|
||||
discoveredValues.authorization_endpoint ||
|
||||
discoveredValues.token_endpoint ||
|
||||
discoveredValues.user_info_endpoint;
|
||||
if (!hasCoreEndpoint) {
|
||||
showError(t('未在 Discovery 响应中找到可用的 OAuth 端点'));
|
||||
return;
|
||||
}
|
||||
|
||||
mergeFormValues(discoveredValues);
|
||||
setDiscoveryInfo({
|
||||
wellKnown: wellKnownUrl,
|
||||
autoFilledFields,
|
||||
scopesSupported: scopesSupported.slice(0, 12),
|
||||
claimsSupported: claimsSupported.slice(0, 12),
|
||||
});
|
||||
showSuccess(t('已从 Discovery 自动填充配置'));
|
||||
} catch (error) {
|
||||
showError(
|
||||
t('获取 Discovery 配置失败:') + (error?.message || t('未知错误')),
|
||||
);
|
||||
} finally {
|
||||
setDiscoveryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePresetChange = (preset) => {
|
||||
setSelectedPreset(preset);
|
||||
if (preset && OAUTH_PRESETS[preset]) {
|
||||
const presetConfig = OAUTH_PRESETS[preset];
|
||||
const cleanUrl = baseUrl ? baseUrl.replace(/\/+$/, '') : '';
|
||||
const newValues = {
|
||||
name: presetConfig.name,
|
||||
slug: preset,
|
||||
scopes: presetConfig.scopes,
|
||||
user_id_field: presetConfig.user_id_field,
|
||||
username_field: presetConfig.username_field,
|
||||
display_name_field: presetConfig.display_name_field,
|
||||
email_field: presetConfig.email_field,
|
||||
auth_style: presetConfig.auth_style ?? 0,
|
||||
};
|
||||
// Only fill endpoints if server address is provided
|
||||
if (cleanUrl) {
|
||||
newValues.authorization_endpoint = cleanUrl + presetConfig.authorization_endpoint;
|
||||
newValues.token_endpoint = cleanUrl + presetConfig.token_endpoint;
|
||||
newValues.user_info_endpoint = cleanUrl + presetConfig.user_info_endpoint;
|
||||
}
|
||||
setFormValues((prev) => ({ ...prev, ...newValues }));
|
||||
// Update form fields directly via formApi
|
||||
if (formApiRef.current) {
|
||||
Object.entries(newValues).forEach(([key, value]) => {
|
||||
formApiRef.current.setValue(key, value);
|
||||
});
|
||||
}
|
||||
resetDiscoveryState();
|
||||
const cleanUrl = normalizeBaseUrl(baseUrl);
|
||||
if (!preset || !OAUTH_PRESETS[preset]) {
|
||||
mergeFormValues(PRESET_RESET_VALUES);
|
||||
return;
|
||||
}
|
||||
|
||||
const presetConfig = OAUTH_PRESETS[preset];
|
||||
const newValues = {
|
||||
...PRESET_RESET_VALUES,
|
||||
name: presetConfig.name,
|
||||
slug: preset,
|
||||
icon: getPresetIcon(preset),
|
||||
scopes: presetConfig.scopes,
|
||||
user_id_field: presetConfig.user_id_field,
|
||||
username_field: presetConfig.username_field,
|
||||
display_name_field: presetConfig.display_name_field,
|
||||
email_field: presetConfig.email_field,
|
||||
auth_style: presetConfig.auth_style ?? 0,
|
||||
};
|
||||
if (cleanUrl) {
|
||||
newValues.authorization_endpoint =
|
||||
cleanUrl + presetConfig.authorization_endpoint;
|
||||
newValues.token_endpoint = cleanUrl + presetConfig.token_endpoint;
|
||||
newValues.user_info_endpoint = cleanUrl + presetConfig.user_info_endpoint;
|
||||
}
|
||||
mergeFormValues(newValues);
|
||||
};
|
||||
|
||||
const handleBaseUrlChange = (url) => {
|
||||
setBaseUrl(url);
|
||||
if (url && selectedPreset && OAUTH_PRESETS[selectedPreset]) {
|
||||
const presetConfig = OAUTH_PRESETS[selectedPreset];
|
||||
const cleanUrl = url.replace(/\/+$/, ''); // Remove trailing slashes
|
||||
const cleanUrl = normalizeBaseUrl(url);
|
||||
const newValues = {
|
||||
authorization_endpoint: cleanUrl + presetConfig.authorization_endpoint,
|
||||
token_endpoint: cleanUrl + presetConfig.token_endpoint,
|
||||
user_info_endpoint: cleanUrl + presetConfig.user_info_endpoint,
|
||||
};
|
||||
setFormValues((prev) => ({ ...prev, ...newValues }));
|
||||
// Update form fields directly via formApi (use merge mode to preserve other fields)
|
||||
if (formApiRef.current) {
|
||||
Object.entries(newValues).forEach(([key, value]) => {
|
||||
formApiRef.current.setValue(key, value);
|
||||
});
|
||||
}
|
||||
mergeFormValues(newValues);
|
||||
}
|
||||
};
|
||||
|
||||
const applyAccessPolicyTemplate = (templateKey) => {
|
||||
const template = ACCESS_POLICY_TEMPLATES[templateKey];
|
||||
if (!template) return;
|
||||
mergeFormValues({ access_policy: template });
|
||||
showSuccess(t('已填充策略模板'));
|
||||
};
|
||||
|
||||
const applyDeniedTemplate = (templateKey) => {
|
||||
const template = ACCESS_DENIED_TEMPLATES[templateKey];
|
||||
if (!template) return;
|
||||
mergeFormValues({ access_denied_message: template });
|
||||
showSuccess(t('已填充提示模板'));
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('图标'),
|
||||
dataIndex: 'icon',
|
||||
key: 'icon',
|
||||
width: 80,
|
||||
render: (icon) => getOAuthProviderIcon(icon || '', 18),
|
||||
},
|
||||
{
|
||||
title: t('名称'),
|
||||
dataIndex: 'name',
|
||||
@@ -325,7 +561,10 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
||||
title: t('Client ID'),
|
||||
dataIndex: 'client_id',
|
||||
key: 'client_id',
|
||||
render: (id) => (id ? id.substring(0, 20) + '...' : '-'),
|
||||
render: (id) => {
|
||||
if (!id) return '-';
|
||||
return id.length > 20 ? `${id.substring(0, 20)}...` : id;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
@@ -352,6 +591,10 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
||||
},
|
||||
];
|
||||
|
||||
const discoveryAutoFilledLabels = (discoveryInfo?.autoFilledFields || [])
|
||||
.map((field) => DISCOVERY_FIELD_LABELS[field] || field)
|
||||
.join(', ');
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Form.Section text={t('自定义 OAuth 提供商')}>
|
||||
@@ -391,56 +634,142 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
||||
<Modal
|
||||
title={editingProvider ? t('编辑 OAuth 提供商') : t('添加 OAuth 提供商')}
|
||||
visible={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
okText={t('保存')}
|
||||
cancelText={t('取消')}
|
||||
width={800}
|
||||
onCancel={closeModal}
|
||||
width={860}
|
||||
centered
|
||||
bodyStyle={{ maxHeight: '72vh', overflowY: 'auto', paddingRight: 6 }}
|
||||
footer={
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Space spacing={8} align='center'>
|
||||
<Text type='secondary'>{t('启用供应商')}</Text>
|
||||
<Switch
|
||||
checked={!!formValues.enabled}
|
||||
size='large'
|
||||
onChange={(checked) => mergeFormValues({ enabled: !!checked })}
|
||||
/>
|
||||
<Tag color={formValues.enabled ? 'green' : 'grey'}>
|
||||
{formValues.enabled ? t('已启用') : t('已禁用')}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Button onClick={closeModal}>{t('取消')}</Button>
|
||||
<Button type='primary' onClick={handleSubmit}>
|
||||
{t('保存')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
initValues={formValues}
|
||||
onValueChange={(values) => setFormValues(values)}
|
||||
onValueChange={() => {
|
||||
setFormValues((prev) => ({ ...prev, ...getLatestFormValues() }));
|
||||
}}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
>
|
||||
{!editingProvider && (
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={12}>
|
||||
<Form.Select
|
||||
field="preset"
|
||||
label={t('预设模板')}
|
||||
placeholder={t('选择预设模板(可选)')}
|
||||
value={selectedPreset}
|
||||
onChange={handlePresetChange}
|
||||
optionList={[
|
||||
{ value: '', label: t('自定义') },
|
||||
...Object.entries(OAUTH_PRESETS).map(([key, config]) => ({
|
||||
value: key,
|
||||
label: config.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="base_url"
|
||||
label={
|
||||
selectedPreset
|
||||
? t('服务器地址') + ' *'
|
||||
: t('服务器地址')
|
||||
}
|
||||
placeholder={t('例如:https://gitea.example.com')}
|
||||
value={baseUrl}
|
||||
onChange={handleBaseUrlChange}
|
||||
extraText={
|
||||
selectedPreset
|
||||
? t('必填:请输入服务器地址以自动生成完整端点 URL')
|
||||
: t('选择预设模板后填写服务器地址可自动填充端点')
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
{t('Configuration')}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
{t('先填写配置,再自动填充 OAuth 端点,能显著减少手工输入')}
|
||||
</Text>
|
||||
{discoveryInfo && (
|
||||
<Banner
|
||||
type='success'
|
||||
closeIcon={null}
|
||||
style={{ marginBottom: 12 }}
|
||||
description={
|
||||
<div>
|
||||
<div>
|
||||
{t('已从 Discovery 获取配置,可继续手动修改所有字段。')}
|
||||
</div>
|
||||
{discoveryAutoFilledLabels ? (
|
||||
<div>
|
||||
{t('自动填充字段')}:
|
||||
{' '}
|
||||
{discoveryAutoFilledLabels}
|
||||
</div>
|
||||
) : null}
|
||||
{discoveryInfo.scopesSupported?.length ? (
|
||||
<div>
|
||||
{t('Discovery scopes')}:
|
||||
{' '}
|
||||
{discoveryInfo.scopesSupported.join(', ')}
|
||||
</div>
|
||||
) : null}
|
||||
{discoveryInfo.claimsSupported?.length ? (
|
||||
<div>
|
||||
{t('Discovery claims')}:
|
||||
{' '}
|
||||
{discoveryInfo.claimsSupported.join(', ')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Select
|
||||
field="preset"
|
||||
label={t('预设模板')}
|
||||
placeholder={t('选择预设模板(可选)')}
|
||||
value={selectedPreset}
|
||||
onChange={handlePresetChange}
|
||||
optionList={[
|
||||
{ value: '', label: t('自定义') },
|
||||
...Object.entries(OAUTH_PRESETS).map(([key, config]) => ({
|
||||
value: key,
|
||||
label: config.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<Form.Input
|
||||
field="base_url"
|
||||
label={t('发行者 URL(Issuer URL)')}
|
||||
placeholder={t('例如:https://gitea.example.com')}
|
||||
value={baseUrl}
|
||||
onChange={handleBaseUrlChange}
|
||||
extraText={
|
||||
selectedPreset
|
||||
? t('填写后会自动拼接预设端点')
|
||||
: t('可选:用于自动生成端点或 Discovery URL')
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', height: '100%' }}>
|
||||
<Button
|
||||
icon={<IconRefresh />}
|
||||
onClick={handleFetchFromDiscovery}
|
||||
loading={discoveryLoading}
|
||||
block
|
||||
>
|
||||
{t('获取 Discovery 配置')}
|
||||
</Button>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field="well_known"
|
||||
label={t('发现文档地址(Discovery URL,可选)')}
|
||||
placeholder={t('例如:https://example.com/.well-known/openid-configuration')}
|
||||
extraText={t('可留空;留空时会尝试使用 Issuer URL + /.well-known/openid-configuration')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
@@ -461,6 +790,41 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={18}>
|
||||
<Form.Input
|
||||
field='icon'
|
||||
label={t('图标')}
|
||||
placeholder={t('例如:github / si:google / https://example.com/logo.png / 🐱')}
|
||||
extraText={
|
||||
<span>
|
||||
{t(
|
||||
'图标使用 react-icons(Simple Icons)或 URL/emoji,例如:github、gitlab、si:google',
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6} style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: 74,
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 24,
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
}}
|
||||
>
|
||||
{getOAuthProviderIcon(formValues.icon || '', 24)}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
@@ -500,7 +864,7 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
||||
label={t('Authorization Endpoint')}
|
||||
placeholder={
|
||||
selectedPreset && OAUTH_PRESETS[selectedPreset]
|
||||
? t('填写服务器地址后自动生成:') +
|
||||
? t('填写 Issuer URL 后自动生成:') +
|
||||
OAUTH_PRESETS[selectedPreset].authorization_endpoint
|
||||
: 'https://example.com/oauth/authorize'
|
||||
}
|
||||
@@ -544,15 +908,14 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="scopes"
|
||||
label={t('Scopes')}
|
||||
label={t('Scopes(可选)')}
|
||||
placeholder="openid profile email"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="well_known"
|
||||
label={t('Well-Known URL')}
|
||||
placeholder={t('OIDC Discovery 端点(可选)')}
|
||||
extraText={
|
||||
discoveryInfo?.scopesSupported?.length
|
||||
? t('Discovery 建议 scopes:') +
|
||||
discoveryInfo.scopesSupported.join(', ')
|
||||
: t('可手动填写,多个 scope 用空格分隔')
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -568,7 +931,7 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="user_id_field"
|
||||
label={t('用户 ID 字段')}
|
||||
label={t('用户 ID 字段(可选)')}
|
||||
placeholder={t('例如:sub、id、data.user.id')}
|
||||
extraText={t('用于唯一标识用户的字段路径')}
|
||||
/>
|
||||
@@ -576,7 +939,7 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="username_field"
|
||||
label={t('用户名字段')}
|
||||
label={t('用户名字段(可选)')}
|
||||
placeholder={t('例如:preferred_username、login')}
|
||||
/>
|
||||
</Col>
|
||||
@@ -586,41 +949,100 @@ const CustomOAuthSetting = ({ serverAddress }) => {
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="display_name_field"
|
||||
label={t('显示名称字段')}
|
||||
label={t('显示名称字段(可选)')}
|
||||
placeholder={t('例如:name、full_name')}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field="email_field"
|
||||
label={t('邮箱字段')}
|
||||
label={t('邮箱字段(可选)')}
|
||||
placeholder={t('例如:email')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
|
||||
{t('高级选项')}
|
||||
</Text>
|
||||
<Collapse
|
||||
keepDOM
|
||||
activeKey={advancedActiveKeys}
|
||||
style={{ marginTop: 16 }}
|
||||
onChange={(activeKey) => {
|
||||
const keys = Array.isArray(activeKey) ? activeKey : [activeKey];
|
||||
setAdvancedActiveKeys(keys.filter(Boolean));
|
||||
}}
|
||||
>
|
||||
<Collapse.Panel header={t('高级选项')} itemKey='advanced'>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Select
|
||||
field="auth_style"
|
||||
label={t('认证方式')}
|
||||
optionList={[
|
||||
{ value: 0, label: t('自动检测') },
|
||||
{ value: 1, label: t('POST 参数') },
|
||||
{ value: 2, label: t('Basic Auth 头') },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Select
|
||||
field="auth_style"
|
||||
label={t('认证方式')}
|
||||
optionList={[
|
||||
{ value: 0, label: t('自动检测') },
|
||||
{ value: 1, label: t('POST 参数') },
|
||||
{ value: 2, label: t('Basic Auth 头') },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Checkbox field="enabled" noLabel>
|
||||
{t('启用此 OAuth 提供商')}
|
||||
</Form.Checkbox>
|
||||
</Col>
|
||||
</Row>
|
||||
<Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
|
||||
{t('准入策略')}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
{t('可选:基于用户信息 JSON 做组合条件准入,条件不满足时返回自定义提示')}
|
||||
</Text>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field='access_policy'
|
||||
value={formValues.access_policy || ''}
|
||||
onChange={(value) => mergeFormValues({ access_policy: value })}
|
||||
label={t('准入策略 JSON(可选)')}
|
||||
rows={6}
|
||||
placeholder={`{
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
{"field": "trust_level", "op": "gte", "value": 2},
|
||||
{"field": "active", "op": "eq", "value": true}
|
||||
]
|
||||
}`}
|
||||
extraText={t('支持逻辑 and/or 与嵌套 groups;操作符支持 eq/ne/gt/gte/lt/lte/in/not_in/contains/exists')}
|
||||
showClear
|
||||
/>
|
||||
<Space spacing={8} style={{ marginTop: 8 }}>
|
||||
<Button size='small' theme='light' onClick={() => applyAccessPolicyTemplate('level_active')}>
|
||||
{t('填充模板:等级+激活')}
|
||||
</Button>
|
||||
<Button size='small' theme='light' onClick={() => applyAccessPolicyTemplate('org_or_role')}>
|
||||
{t('填充模板:组织或角色')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='access_denied_message'
|
||||
value={formValues.access_denied_message || ''}
|
||||
onChange={(value) => mergeFormValues({ access_denied_message: value })}
|
||||
label={t('拒绝提示模板(可选)')}
|
||||
placeholder={t('例如:需要等级 {{required}},你当前等级 {{current}}')}
|
||||
extraText={t('可用变量:{{provider}} {{field}} {{op}} {{required}} {{current}} 以及 {{current.path}}')}
|
||||
showClear
|
||||
/>
|
||||
<Space spacing={8} style={{ marginTop: 8 }}>
|
||||
<Button size='small' theme='light' onClick={() => applyDeniedTemplate('level_hint')}>
|
||||
{t('填充模板:等级提示')}
|
||||
</Button>
|
||||
<Button size='small' theme='light' onClick={() => applyDeniedTemplate('org_hint')}>
|
||||
{t('填充模板:组织提示')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Form.Section>
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
onLinuxDOOAuthClicked,
|
||||
onDiscordOAuthClicked,
|
||||
onCustomOAuthClicked,
|
||||
getOAuthProviderIcon,
|
||||
} from '../../../../helpers';
|
||||
import TwoFASetting from '../components/TwoFASetting';
|
||||
|
||||
@@ -148,12 +149,14 @@ const AccountManagement = ({
|
||||
|
||||
// Check if custom OAuth provider is bound
|
||||
const isCustomOAuthBound = (providerId) => {
|
||||
return customOAuthBindings.some((b) => b.provider_id === providerId);
|
||||
const normalizedId = Number(providerId);
|
||||
return customOAuthBindings.some((b) => Number(b.provider_id) === normalizedId);
|
||||
};
|
||||
|
||||
// Get binding info for a provider
|
||||
const getCustomOAuthBinding = (providerId) => {
|
||||
return customOAuthBindings.find((b) => b.provider_id === providerId);
|
||||
const normalizedId = Number(providerId);
|
||||
return customOAuthBindings.find((b) => Number(b.provider_id) === normalizedId);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -524,10 +527,10 @@ const AccountManagement = ({
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center flex-1 min-w-0'>
|
||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
||||
<IconLock
|
||||
size='default'
|
||||
className='text-slate-600 dark:text-slate-300'
|
||||
/>
|
||||
{getOAuthProviderIcon(
|
||||
provider.icon || binding?.provider_icon || '',
|
||||
20,
|
||||
)}
|
||||
</div>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='font-medium text-gray-900'>
|
||||
|
||||
@@ -86,6 +86,7 @@ const NotificationSettings = ({
|
||||
channel: true,
|
||||
models: true,
|
||||
deployment: true,
|
||||
subscription: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
setting: true,
|
||||
@@ -169,6 +170,7 @@ const NotificationSettings = ({
|
||||
channel: true,
|
||||
models: true,
|
||||
deployment: true,
|
||||
subscription: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
setting: true,
|
||||
@@ -296,6 +298,11 @@ const NotificationSettings = ({
|
||||
title: t('模型部署'),
|
||||
description: t('模型部署管理'),
|
||||
},
|
||||
{
|
||||
key: 'subscription',
|
||||
title: t('订阅管理'),
|
||||
description: t('订阅套餐管理'),
|
||||
},
|
||||
{
|
||||
key: 'redemption',
|
||||
title: t('兑换码管理'),
|
||||
|
||||
@@ -62,9 +62,14 @@ import CodexOAuthModal from './CodexOAuthModal';
|
||||
import ParamOverrideEditorModal from './ParamOverrideEditorModal';
|
||||
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||
import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
|
||||
import StatusCodeRiskGuardModal from './StatusCodeRiskGuardModal';
|
||||
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
|
||||
import { useSecureVerification } from '../../../../hooks/common/useSecureVerification';
|
||||
import { createApiCalls } from '../../../../services/secureVerification';
|
||||
import {
|
||||
collectInvalidStatusCodeEntries,
|
||||
collectNewDisallowedStatusCodeRedirects,
|
||||
} from './statusCodeRiskGuard';
|
||||
import {
|
||||
IconSave,
|
||||
IconClose,
|
||||
@@ -195,6 +200,8 @@ const EditChannelModal = (props) => {
|
||||
allow_service_tier: false,
|
||||
disable_store: false, // false = 允许透传(默认开启)
|
||||
allow_safety_identifier: false,
|
||||
allow_include_obfuscation: false,
|
||||
allow_inference_geo: false,
|
||||
claude_beta_query: false,
|
||||
};
|
||||
const [batch, setBatch] = useState(false);
|
||||
@@ -209,6 +216,7 @@ const EditChannelModal = (props) => {
|
||||
const [fullModels, setFullModels] = useState([]);
|
||||
const [modelGroups, setModelGroups] = useState([]);
|
||||
const [customModel, setCustomModel] = useState('');
|
||||
const [modelSearchValue, setModelSearchValue] = useState('');
|
||||
const [modalImageUrl, setModalImageUrl] = useState('');
|
||||
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
|
||||
const [modelModalVisible, setModelModalVisible] = useState(false);
|
||||
@@ -249,6 +257,25 @@ const EditChannelModal = (props) => {
|
||||
return [];
|
||||
}
|
||||
}, [inputs.model_mapping]);
|
||||
const modelSearchMatchedCount = useMemo(() => {
|
||||
const keyword = modelSearchValue.trim();
|
||||
if (!keyword) {
|
||||
return modelOptions.length;
|
||||
}
|
||||
return modelOptions.reduce(
|
||||
(count, option) => count + (selectFilter(keyword, option) ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
}, [modelOptions, modelSearchValue]);
|
||||
const modelSearchHintText = useMemo(() => {
|
||||
const keyword = modelSearchValue.trim();
|
||||
if (!keyword || modelSearchMatchedCount !== 0) {
|
||||
return '';
|
||||
}
|
||||
return t('未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加', {
|
||||
name: keyword,
|
||||
});
|
||||
}, [modelSearchMatchedCount, modelSearchValue, t]);
|
||||
const paramOverrideMeta = useMemo(() => {
|
||||
const raw =
|
||||
typeof inputs.param_override === 'string'
|
||||
@@ -338,6 +365,12 @@ const EditChannelModal = (props) => {
|
||||
window.open(targetUrl, '_blank', 'noopener');
|
||||
};
|
||||
const [verifyLoading, setVerifyLoading] = useState(false);
|
||||
const statusCodeRiskConfirmResolverRef = useRef(null);
|
||||
const [statusCodeRiskConfirmVisible, setStatusCodeRiskConfirmVisible] =
|
||||
useState(false);
|
||||
const [statusCodeRiskDetailItems, setStatusCodeRiskDetailItems] = useState(
|
||||
[],
|
||||
);
|
||||
|
||||
// 表单块导航相关状态
|
||||
const formSectionRefs = useRef({
|
||||
@@ -359,6 +392,7 @@ const EditChannelModal = (props) => {
|
||||
const doubaoApiClickCountRef = useRef(0);
|
||||
const initialModelsRef = useRef([]);
|
||||
const initialModelMappingRef = useRef('');
|
||||
const initialStatusCodeMappingRef = useRef('');
|
||||
|
||||
// 2FA状态更新辅助函数
|
||||
const updateTwoFAState = (updates) => {
|
||||
@@ -811,6 +845,10 @@ const EditChannelModal = (props) => {
|
||||
data.disable_store = parsedSettings.disable_store || false;
|
||||
data.allow_safety_identifier =
|
||||
parsedSettings.allow_safety_identifier || false;
|
||||
data.allow_include_obfuscation =
|
||||
parsedSettings.allow_include_obfuscation || false;
|
||||
data.allow_inference_geo =
|
||||
parsedSettings.allow_inference_geo || false;
|
||||
data.claude_beta_query = parsedSettings.claude_beta_query || false;
|
||||
} catch (error) {
|
||||
console.error('解析其他设置失败:', error);
|
||||
@@ -822,6 +860,8 @@ const EditChannelModal = (props) => {
|
||||
data.allow_service_tier = false;
|
||||
data.disable_store = false;
|
||||
data.allow_safety_identifier = false;
|
||||
data.allow_include_obfuscation = false;
|
||||
data.allow_inference_geo = false;
|
||||
data.claude_beta_query = false;
|
||||
}
|
||||
} else {
|
||||
@@ -832,6 +872,8 @@ const EditChannelModal = (props) => {
|
||||
data.allow_service_tier = false;
|
||||
data.disable_store = false;
|
||||
data.allow_safety_identifier = false;
|
||||
data.allow_include_obfuscation = false;
|
||||
data.allow_inference_geo = false;
|
||||
data.claude_beta_query = false;
|
||||
}
|
||||
|
||||
@@ -868,6 +910,7 @@ const EditChannelModal = (props) => {
|
||||
.map((model) => (model || '').trim())
|
||||
.filter(Boolean);
|
||||
initialModelMappingRef.current = data.model_mapping || '';
|
||||
initialStatusCodeMappingRef.current = data.status_code_mapping || '';
|
||||
|
||||
let parsedIonet = null;
|
||||
if (data.other_info) {
|
||||
@@ -1173,6 +1216,7 @@ const EditChannelModal = (props) => {
|
||||
}, [inputs]);
|
||||
|
||||
useEffect(() => {
|
||||
setModelSearchValue('');
|
||||
if (props.visible) {
|
||||
if (isEdit) {
|
||||
loadChannel();
|
||||
@@ -1194,11 +1238,22 @@ const EditChannelModal = (props) => {
|
||||
if (!isEdit) {
|
||||
initialModelsRef.current = [];
|
||||
initialModelMappingRef.current = '';
|
||||
initialStatusCodeMappingRef.current = '';
|
||||
}
|
||||
}, [isEdit, props.visible]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (statusCodeRiskConfirmResolverRef.current) {
|
||||
statusCodeRiskConfirmResolverRef.current(false);
|
||||
statusCodeRiskConfirmResolverRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 统一的模态框重置函数
|
||||
const resetModalState = () => {
|
||||
resolveStatusCodeRiskConfirm(false);
|
||||
formApiRef.current?.reset();
|
||||
// 重置渠道设置状态
|
||||
setChannelSettings({
|
||||
@@ -1216,6 +1271,7 @@ const EditChannelModal = (props) => {
|
||||
// 重置豆包隐藏入口状态
|
||||
setDoubaoApiEditUnlocked(false);
|
||||
doubaoApiClickCountRef.current = 0;
|
||||
setModelSearchValue('');
|
||||
// 清空表单中的key_mode字段
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('key_mode', undefined);
|
||||
@@ -1328,6 +1384,22 @@ const EditChannelModal = (props) => {
|
||||
});
|
||||
});
|
||||
|
||||
const resolveStatusCodeRiskConfirm = (confirmed) => {
|
||||
setStatusCodeRiskConfirmVisible(false);
|
||||
setStatusCodeRiskDetailItems([]);
|
||||
if (statusCodeRiskConfirmResolverRef.current) {
|
||||
statusCodeRiskConfirmResolverRef.current(confirmed);
|
||||
statusCodeRiskConfirmResolverRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmStatusCodeRisk = (detailItems) =>
|
||||
new Promise((resolve) => {
|
||||
statusCodeRiskConfirmResolverRef.current = resolve;
|
||||
setStatusCodeRiskDetailItems(detailItems);
|
||||
setStatusCodeRiskConfirmVisible(true);
|
||||
});
|
||||
|
||||
const hasModelConfigChanged = (normalizedModels, modelMappingStr) => {
|
||||
if (!isEdit) return true;
|
||||
const initialModels = initialModelsRef.current;
|
||||
@@ -1518,6 +1590,27 @@ const EditChannelModal = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const invalidStatusCodeEntries = collectInvalidStatusCodeEntries(
|
||||
localInputs.status_code_mapping,
|
||||
);
|
||||
if (invalidStatusCodeEntries.length > 0) {
|
||||
showError(
|
||||
`${t('状态码复写包含无效的状态码')}: ${invalidStatusCodeEntries.join(', ')}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const riskyStatusCodeRedirects = collectNewDisallowedStatusCodeRedirects(
|
||||
initialStatusCodeMappingRef.current,
|
||||
localInputs.status_code_mapping,
|
||||
);
|
||||
if (riskyStatusCodeRedirects.length > 0) {
|
||||
const confirmed = await confirmStatusCodeRisk(riskyStatusCodeRedirects);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (localInputs.base_url && localInputs.base_url.endsWith('/')) {
|
||||
localInputs.base_url = localInputs.base_url.slice(
|
||||
0,
|
||||
@@ -1570,13 +1663,16 @@ const EditChannelModal = (props) => {
|
||||
// type === 1 (OpenAI) 或 type === 14 (Claude): 设置字段透传控制(显式保存布尔值)
|
||||
if (localInputs.type === 1 || localInputs.type === 14) {
|
||||
settings.allow_service_tier = localInputs.allow_service_tier === true;
|
||||
// 仅 OpenAI 渠道需要 store 和 safety_identifier
|
||||
// 仅 OpenAI 渠道需要 store / safety_identifier / include_obfuscation
|
||||
if (localInputs.type === 1) {
|
||||
settings.disable_store = localInputs.disable_store === true;
|
||||
settings.allow_safety_identifier =
|
||||
localInputs.allow_safety_identifier === true;
|
||||
settings.allow_include_obfuscation =
|
||||
localInputs.allow_include_obfuscation === true;
|
||||
}
|
||||
if (localInputs.type === 14) {
|
||||
settings.allow_inference_geo = localInputs.allow_inference_geo === true;
|
||||
settings.claude_beta_query = localInputs.claude_beta_query === true;
|
||||
}
|
||||
}
|
||||
@@ -1599,6 +1695,8 @@ const EditChannelModal = (props) => {
|
||||
delete localInputs.allow_service_tier;
|
||||
delete localInputs.disable_store;
|
||||
delete localInputs.allow_safety_identifier;
|
||||
delete localInputs.allow_include_obfuscation;
|
||||
delete localInputs.allow_inference_geo;
|
||||
delete localInputs.claude_beta_query;
|
||||
|
||||
let res;
|
||||
@@ -2917,9 +3015,18 @@ const EditChannelModal = (props) => {
|
||||
rules={[{ required: true, message: t('请选择模型') }]}
|
||||
multiple
|
||||
filter={selectFilter}
|
||||
allowCreate
|
||||
autoClearSearchValue={false}
|
||||
searchPosition='dropdown'
|
||||
optionList={modelOptions}
|
||||
onSearch={(value) => setModelSearchValue(value)}
|
||||
innerBottomSlot={
|
||||
modelSearchHintText ? (
|
||||
<Text className='px-3 py-2 block text-xs !text-semi-color-text-2'>
|
||||
{modelSearchHintText}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => handleInputChange('models', value)}
|
||||
renderSelectedItem={(optionNode) => {
|
||||
@@ -3444,6 +3551,24 @@ const EditChannelModal = (props) => {
|
||||
'safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私',
|
||||
)}
|
||||
/>
|
||||
|
||||
<Form.Switch
|
||||
field='allow_include_obfuscation'
|
||||
label={t(
|
||||
'允许 stream_options.include_obfuscation 透传',
|
||||
)}
|
||||
checkedText={t('开')}
|
||||
uncheckedText={t('关')}
|
||||
onChange={(value) =>
|
||||
handleChannelOtherSettingsChange(
|
||||
'allow_include_obfuscation',
|
||||
value,
|
||||
)
|
||||
}
|
||||
extraText={t(
|
||||
'include_obfuscation 用于控制 Responses 流混淆字段。默认关闭以避免客户端关闭该安全保护',
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3469,6 +3594,22 @@ const EditChannelModal = (props) => {
|
||||
'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用',
|
||||
)}
|
||||
/>
|
||||
|
||||
<Form.Switch
|
||||
field='allow_inference_geo'
|
||||
label={t('允许 inference_geo 透传')}
|
||||
checkedText={t('开')}
|
||||
uncheckedText={t('关')}
|
||||
onChange={(value) =>
|
||||
handleChannelOtherSettingsChange(
|
||||
'allow_inference_geo',
|
||||
value,
|
||||
)
|
||||
}
|
||||
extraText={t(
|
||||
'inference_geo 字段用于控制 Claude 数据驻留推理区域。默认关闭以避免未经授权透传地域信息',
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
@@ -3613,6 +3754,12 @@ const EditChannelModal = (props) => {
|
||||
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
|
||||
/>
|
||||
</SideSheet>
|
||||
<StatusCodeRiskGuardModal
|
||||
visible={statusCodeRiskConfirmVisible}
|
||||
detailItems={statusCodeRiskDetailItems}
|
||||
onCancel={() => resolveStatusCodeRiskConfirm(false)}
|
||||
onConfirm={() => resolveStatusCodeRiskConfirm(true)}
|
||||
/>
|
||||
{/* 使用通用安全验证模态框 */}
|
||||
<SecureVerificationModal
|
||||
visible={isModalVisible}
|
||||
|
||||
@@ -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, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
@@ -64,6 +64,7 @@ const EditTagModal = (props) => {
|
||||
const [modelOptions, setModelOptions] = useState([]);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [customModel, setCustomModel] = useState('');
|
||||
const [modelSearchValue, setModelSearchValue] = useState('');
|
||||
const originInputs = {
|
||||
tag: '',
|
||||
new_tag: null,
|
||||
@@ -74,6 +75,25 @@ const EditTagModal = (props) => {
|
||||
header_override: null,
|
||||
};
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
const modelSearchMatchedCount = useMemo(() => {
|
||||
const keyword = modelSearchValue.trim();
|
||||
if (!keyword) {
|
||||
return modelOptions.length;
|
||||
}
|
||||
return modelOptions.reduce(
|
||||
(count, option) => count + (selectFilter(keyword, option) ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
}, [modelOptions, modelSearchValue]);
|
||||
const modelSearchHintText = useMemo(() => {
|
||||
const keyword = modelSearchValue.trim();
|
||||
if (!keyword || modelSearchMatchedCount !== 0) {
|
||||
return '';
|
||||
}
|
||||
return t('未匹配到模型,按回车键可将「{{name}}」作为自定义模型名添加', {
|
||||
name: keyword,
|
||||
});
|
||||
}, [modelSearchMatchedCount, modelSearchValue, t]);
|
||||
const formApiRef = useRef(null);
|
||||
const getInitValues = () => ({ ...originInputs });
|
||||
|
||||
@@ -292,6 +312,7 @@ const EditTagModal = (props) => {
|
||||
fetchModels().then();
|
||||
fetchGroups().then();
|
||||
fetchTagModels().then();
|
||||
setModelSearchValue('');
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({
|
||||
...getInitValues(),
|
||||
@@ -461,9 +482,18 @@ const EditTagModal = (props) => {
|
||||
placeholder={t('请选择该渠道所支持的模型,留空则不更改')}
|
||||
multiple
|
||||
filter={selectFilter}
|
||||
allowCreate
|
||||
autoClearSearchValue={false}
|
||||
searchPosition='dropdown'
|
||||
optionList={modelOptions}
|
||||
onSearch={(value) => setModelSearchValue(value)}
|
||||
innerBottomSlot={
|
||||
modelSearchHintText ? (
|
||||
<Text className='px-3 py-2 block text-xs !text-semi-color-text-2'>
|
||||
{modelSearchHintText}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => handleInputChange('models', value)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import RiskAcknowledgementModal from '../../../common/modals/RiskAcknowledgementModal';
|
||||
import {
|
||||
STATUS_CODE_RISK_I18N_KEYS,
|
||||
STATUS_CODE_RISK_CHECKLIST_KEYS,
|
||||
} from './statusCodeRiskGuard';
|
||||
|
||||
const StatusCodeRiskGuardModal = React.memo(function StatusCodeRiskGuardModal({
|
||||
visible,
|
||||
detailItems,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const checklist = useMemo(
|
||||
() => STATUS_CODE_RISK_CHECKLIST_KEYS.map((item) => t(item)),
|
||||
[t, i18n.language],
|
||||
);
|
||||
|
||||
return (
|
||||
<RiskAcknowledgementModal
|
||||
visible={visible}
|
||||
title={t(STATUS_CODE_RISK_I18N_KEYS.title)}
|
||||
markdownContent={t(STATUS_CODE_RISK_I18N_KEYS.markdown)}
|
||||
detailTitle={t(STATUS_CODE_RISK_I18N_KEYS.detailTitle)}
|
||||
detailItems={detailItems}
|
||||
checklist={checklist}
|
||||
inputPrompt={t(STATUS_CODE_RISK_I18N_KEYS.inputPrompt)}
|
||||
requiredText={t(STATUS_CODE_RISK_I18N_KEYS.confirmText)}
|
||||
inputPlaceholder={t(STATUS_CODE_RISK_I18N_KEYS.inputPlaceholder)}
|
||||
mismatchText={t(STATUS_CODE_RISK_I18N_KEYS.mismatchText)}
|
||||
cancelText={t('取消')}
|
||||
confirmText={t(STATUS_CODE_RISK_I18N_KEYS.confirmButton)}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default StatusCodeRiskGuardModal;
|
||||
132
web/src/components/table/channels/modals/statusCodeRiskGuard.js
Normal file
132
web/src/components/table/channels/modals/statusCodeRiskGuard.js
Normal file
@@ -0,0 +1,132 @@
|
||||
const NON_REDIRECTABLE_STATUS_CODES = new Set([504, 524]);
|
||||
|
||||
export const STATUS_CODE_RISK_I18N_KEYS = {
|
||||
title: '高危操作确认',
|
||||
detailTitle: '检测到以下高危状态码重定向规则',
|
||||
inputPrompt: '操作确认',
|
||||
confirmButton: '我确认开启高危重试',
|
||||
markdown: '高危状态码重试风险告知与免责声明Markdown',
|
||||
confirmText: '高危状态码重试风险确认输入文本',
|
||||
inputPlaceholder: '高危状态码重试风险输入框占位文案',
|
||||
mismatchText: '高危状态码重试风险输入不匹配提示',
|
||||
};
|
||||
|
||||
export const STATUS_CODE_RISK_CHECKLIST_KEYS = [
|
||||
'高危状态码重试风险确认项1',
|
||||
'高危状态码重试风险确认项2',
|
||||
'高危状态码重试风险确认项3',
|
||||
'高危状态码重试风险确认项4',
|
||||
];
|
||||
|
||||
function parseStatusCodeKey(rawKey) {
|
||||
if (typeof rawKey !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const normalized = rawKey.trim();
|
||||
if (!/^[1-5]\d{2}$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return Number.parseInt(normalized, 10);
|
||||
}
|
||||
|
||||
function parseStatusCodeMappingTarget(rawValue) {
|
||||
if (typeof rawValue === 'number' && Number.isInteger(rawValue)) {
|
||||
return rawValue >= 100 && rawValue <= 599 ? rawValue : null;
|
||||
}
|
||||
if (typeof rawValue === 'string') {
|
||||
const normalized = rawValue.trim();
|
||||
if (!/^[1-5]\d{2}$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
const code = Number.parseInt(normalized, 10);
|
||||
return code >= 100 && code <= 599 ? code : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function collectInvalidStatusCodeEntries(statusCodeMappingStr) {
|
||||
if (
|
||||
typeof statusCodeMappingStr !== 'string' ||
|
||||
statusCodeMappingStr.trim() === ''
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(statusCodeMappingStr);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const invalid = [];
|
||||
for (const [rawKey, rawValue] of Object.entries(parsed)) {
|
||||
const fromCode = parseStatusCodeKey(rawKey);
|
||||
const toCode = parseStatusCodeMappingTarget(rawValue);
|
||||
if (fromCode === null || toCode === null) {
|
||||
invalid.push(`${rawKey} → ${rawValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
return invalid;
|
||||
}
|
||||
|
||||
export function collectDisallowedStatusCodeRedirects(statusCodeMappingStr) {
|
||||
if (
|
||||
typeof statusCodeMappingStr !== 'string' ||
|
||||
statusCodeMappingStr.trim() === ''
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(statusCodeMappingStr);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const riskyMappings = [];
|
||||
Object.entries(parsed).forEach(([rawFrom, rawTo]) => {
|
||||
const fromCode = parseStatusCodeKey(rawFrom);
|
||||
const toCode = parseStatusCodeMappingTarget(rawTo);
|
||||
if (fromCode === null || toCode === null) {
|
||||
return;
|
||||
}
|
||||
if (!NON_REDIRECTABLE_STATUS_CODES.has(fromCode)) {
|
||||
return;
|
||||
}
|
||||
if (fromCode === toCode) {
|
||||
return;
|
||||
}
|
||||
riskyMappings.push(`${fromCode} -> ${toCode}`);
|
||||
});
|
||||
|
||||
return Array.from(new Set(riskyMappings)).sort();
|
||||
}
|
||||
|
||||
export function collectNewDisallowedStatusCodeRedirects(
|
||||
originalStatusCodeMappingStr,
|
||||
currentStatusCodeMappingStr,
|
||||
) {
|
||||
const currentRisky = collectDisallowedStatusCodeRedirects(
|
||||
currentStatusCodeMappingStr,
|
||||
);
|
||||
if (currentRisky.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const originalRiskySet = new Set(
|
||||
collectDisallowedStatusCodeRedirects(originalStatusCodeMappingStr),
|
||||
);
|
||||
|
||||
return currentRisky.filter((mapping) => !originalRiskySet.has(mapping));
|
||||
}
|
||||
@@ -84,8 +84,8 @@ function renderDuration(submit_time, finishTime) {
|
||||
|
||||
// 返回带有样式的颜色标签
|
||||
return (
|
||||
<Tag color={color} shape='circle' prefixIcon={<Clock size={14} />}>
|
||||
{durationSec} 秒
|
||||
<Tag color={color} shape='circle'>
|
||||
{durationSec} s
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -149,7 +149,7 @@ const renderPlatform = (platform, t) => {
|
||||
);
|
||||
if (option) {
|
||||
return (
|
||||
<Tag color={option.color} shape='circle' prefixIcon={<Video size={14} />}>
|
||||
<Tag color={option.color} shape='circle'>
|
||||
{option.label}
|
||||
</Tag>
|
||||
);
|
||||
@@ -157,13 +157,13 @@ const renderPlatform = (platform, t) => {
|
||||
switch (platform) {
|
||||
case 'suno':
|
||||
return (
|
||||
<Tag color='green' shape='circle' prefixIcon={<Music size={14} />}>
|
||||
<Tag color='green' shape='circle'>
|
||||
Suno
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
<Tag color='white' shape='circle'>
|
||||
{t('未知')}
|
||||
</Tag>
|
||||
);
|
||||
@@ -240,7 +240,7 @@ export const getTaskLogsColumns = ({
|
||||
openContentModal,
|
||||
isAdminUser,
|
||||
openVideoModal,
|
||||
showUserInfoFunc,
|
||||
openAudioModal,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
@@ -278,7 +278,6 @@ export const getTaskLogsColumns = ({
|
||||
color={colors[parseInt(text) % colors.length]}
|
||||
size='large'
|
||||
shape='circle'
|
||||
prefixIcon={<Hash size={14} />}
|
||||
onClick={() => {
|
||||
copyText(text);
|
||||
}}
|
||||
@@ -294,7 +293,7 @@ export const getTaskLogsColumns = ({
|
||||
{
|
||||
key: COLUMN_KEYS.USERNAME,
|
||||
title: t('用户'),
|
||||
dataIndex: 'user_id',
|
||||
dataIndex: 'username',
|
||||
render: (userId, record, index) => {
|
||||
if (!isAdminUser) {
|
||||
return <></>;
|
||||
@@ -302,22 +301,14 @@ export const getTaskLogsColumns = ({
|
||||
const displayText = String(record.username || userId || '?');
|
||||
return (
|
||||
<Space>
|
||||
<Tooltip content={displayText}>
|
||||
<Avatar
|
||||
size='extra-small'
|
||||
color={stringToColor(displayText)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => showUserInfoFunc && showUserInfoFunc(userId)}
|
||||
>
|
||||
{displayText.slice(0, 1)}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
<Typography.Text
|
||||
ellipsis={{ showTooltip: true }}
|
||||
style={{ cursor: 'pointer', color: 'var(--semi-color-primary)' }}
|
||||
onClick={() => showUserInfoFunc && showUserInfoFunc(userId)}
|
||||
<Avatar
|
||||
size='extra-small'
|
||||
color={stringToColor(displayText)}
|
||||
>
|
||||
{userId}
|
||||
{displayText.slice(0, 1)}
|
||||
</Avatar>
|
||||
<Typography.Text>
|
||||
{displayText}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
);
|
||||
@@ -396,7 +387,27 @@ export const getTaskLogsColumns = ({
|
||||
dataIndex: 'fail_reason',
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => {
|
||||
// 仅当为视频生成任务且成功,且 fail_reason 是 URL 时显示可点击链接
|
||||
// Suno audio preview
|
||||
const isSunoSuccess =
|
||||
record.platform === 'suno' &&
|
||||
record.status === 'SUCCESS' &&
|
||||
Array.isArray(record.data) &&
|
||||
record.data.some((c) => c.audio_url);
|
||||
if (isSunoSuccess) {
|
||||
return (
|
||||
<a
|
||||
href='#'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openAudioModal(record.data);
|
||||
}}
|
||||
>
|
||||
{t('点击预览音乐')}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// 视频预览:优先使用 result_url,兼容旧数据 fail_reason 中的 URL
|
||||
const isVideoTask =
|
||||
record.action === TASK_ACTION_GENERATE ||
|
||||
record.action === TASK_ACTION_TEXT_GENERATE ||
|
||||
@@ -404,14 +415,15 @@ export const getTaskLogsColumns = ({
|
||||
record.action === TASK_ACTION_REFERENCE_GENERATE ||
|
||||
record.action === TASK_ACTION_REMIX_GENERATE;
|
||||
const isSuccess = record.status === 'SUCCESS';
|
||||
const isUrl = typeof text === 'string' && /^https?:\/\//.test(text);
|
||||
if (isSuccess && isVideoTask && isUrl) {
|
||||
const resultUrl = record.result_url;
|
||||
const hasResultUrl = typeof resultUrl === 'string' && /^https?:\/\//.test(resultUrl);
|
||||
if (isSuccess && isVideoTask && hasResultUrl) {
|
||||
return (
|
||||
<a
|
||||
href='#'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openVideoModal(text);
|
||||
openVideoModal(resultUrl);
|
||||
}}
|
||||
>
|
||||
{t('点击预览视频')}
|
||||
|
||||
@@ -40,6 +40,7 @@ const TaskLogsTable = (taskLogsData) => {
|
||||
copyText,
|
||||
openContentModal,
|
||||
openVideoModal,
|
||||
openAudioModal,
|
||||
showUserInfoFunc,
|
||||
isAdminUser,
|
||||
t,
|
||||
@@ -54,10 +55,11 @@ const TaskLogsTable = (taskLogsData) => {
|
||||
copyText,
|
||||
openContentModal,
|
||||
openVideoModal,
|
||||
openAudioModal,
|
||||
showUserInfoFunc,
|
||||
isAdminUser,
|
||||
});
|
||||
}, [t, COLUMN_KEYS, copyText, openContentModal, openVideoModal, showUserInfoFunc, isAdminUser]);
|
||||
}, [t, COLUMN_KEYS, copyText, openContentModal, openVideoModal, openAudioModal, showUserInfoFunc, isAdminUser]);
|
||||
|
||||
// Filter columns based on visibility settings
|
||||
const getVisibleColumns = () => {
|
||||
|
||||
@@ -25,7 +25,7 @@ import TaskLogsActions from './TaskLogsActions';
|
||||
import TaskLogsFilters from './TaskLogsFilters';
|
||||
import ColumnSelectorModal from './modals/ColumnSelectorModal';
|
||||
import ContentModal from './modals/ContentModal';
|
||||
import UserInfoModal from '../usage-logs/modals/UserInfoModal';
|
||||
import AudioPreviewModal from './modals/AudioPreviewModal';
|
||||
import { useTaskLogsData } from '../../../hooks/task-logs/useTaskLogsData';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { createCardProPagination } from '../../../helpers/utils';
|
||||
@@ -46,7 +46,11 @@ const TaskLogsPage = () => {
|
||||
modalContent={taskLogsData.videoUrl}
|
||||
isVideo={true}
|
||||
/>
|
||||
<UserInfoModal {...taskLogsData} />
|
||||
<AudioPreviewModal
|
||||
isModalOpen={taskLogsData.isAudioModalOpen}
|
||||
setIsModalOpen={taskLogsData.setIsAudioModalOpen}
|
||||
audioClips={taskLogsData.audioClips}
|
||||
/>
|
||||
|
||||
<Layout>
|
||||
<CardPro
|
||||
|
||||
181
web/src/components/table/task-logs/modals/AudioPreviewModal.jsx
Normal file
181
web/src/components/table/task-logs/modals/AudioPreviewModal.jsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
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 React, { useState, useRef, useEffect } from 'react';
|
||||
import { Modal, Typography, Tag, Button } from '@douyinfe/semi-ui';
|
||||
import { IconExternalOpen, IconCopy } from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
if (!seconds || seconds <= 0) return '--:--';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const AudioClipCard = ({ clip }) => {
|
||||
const { t } = useTranslation();
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const audioRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHasError(false);
|
||||
}, [clip.audio_url]);
|
||||
|
||||
const title = clip.title || t('未命名');
|
||||
const tags = clip.tags || clip.metadata?.tags || '';
|
||||
const duration = clip.duration || clip.metadata?.duration;
|
||||
const imageUrl = clip.image_url || clip.image_large_url;
|
||||
const audioUrl = clip.audio_url;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
background: 'var(--semi-color-bg-1)',
|
||||
}}
|
||||
>
|
||||
{imageUrl && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={title}
|
||||
style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '8px',
|
||||
objectFit: 'cover',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
<Text strong ellipsis={{ showTooltip: true }} style={{ fontSize: 15 }}>
|
||||
{title}
|
||||
</Text>
|
||||
{duration > 0 && (
|
||||
<Tag size='small' color='grey' shape='circle'>
|
||||
{formatDuration(duration)}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tags && (
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
ellipsis={{ showTooltip: true, rows: 1 }}
|
||||
>
|
||||
{tags}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasError ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Text type='warning' size='small'>
|
||||
{t('音频无法播放')}
|
||||
</Text>
|
||||
<Button
|
||||
size='small'
|
||||
icon={<IconExternalOpen />}
|
||||
onClick={() => window.open(audioUrl, '_blank')}
|
||||
>
|
||||
{t('在新标签页中打开')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
icon={<IconCopy />}
|
||||
onClick={() => navigator.clipboard.writeText(audioUrl)}
|
||||
>
|
||||
{t('复制链接')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
controls
|
||||
preload='none'
|
||||
onError={() => setHasError(true)}
|
||||
style={{ width: '100%', height: 36 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AudioPreviewModal = ({ isModalOpen, setIsModalOpen, audioClips }) => {
|
||||
const { t } = useTranslation();
|
||||
const clips = Array.isArray(audioClips) ? audioClips : [];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('音乐预览')}
|
||||
visible={isModalOpen}
|
||||
onOk={() => setIsModalOpen(false)}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
closable={null}
|
||||
footer={null}
|
||||
bodyStyle={{
|
||||
maxHeight: '70vh',
|
||||
overflow: 'auto',
|
||||
padding: '16px',
|
||||
}}
|
||||
width={560}
|
||||
>
|
||||
{clips.length === 0 ? (
|
||||
<Text type='tertiary'>{t('无')}</Text>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{clips.map((clip, idx) => (
|
||||
<AudioClipCard key={clip.clip_id || clip.id || idx} clip={clip} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioPreviewModal;
|
||||
@@ -144,8 +144,6 @@ const ContentModal = ({
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
autoPlay
|
||||
crossOrigin='anonymous'
|
||||
onError={handleVideoError}
|
||||
onLoadedData={handleVideoLoaded}
|
||||
onLoadStart={() => setIsLoading(true)}
|
||||
|
||||
@@ -133,6 +133,12 @@ function renderType(type, t) {
|
||||
{t('错误')}
|
||||
</Tag>
|
||||
);
|
||||
case 6:
|
||||
return (
|
||||
<Tag color='teal' shape='circle'>
|
||||
{t('退款')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='grey' shape='circle'>
|
||||
@@ -368,7 +374,7 @@ export const getLogsColumns = ({
|
||||
}
|
||||
|
||||
return isAdminUser &&
|
||||
(record.type === 0 || record.type === 2 || record.type === 5) ? (
|
||||
(record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6) ? (
|
||||
<Space>
|
||||
<span style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<Tooltip content={record.channel_name || t('未知渠道')}>
|
||||
@@ -459,7 +465,7 @@ export const getLogsColumns = ({
|
||||
title: t('令牌'),
|
||||
dataIndex: 'token_name',
|
||||
render: (text, record, index) => {
|
||||
return record.type === 0 || record.type === 2 || record.type === 5 ? (
|
||||
return record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6 ? (
|
||||
<div>
|
||||
<Tag
|
||||
color='grey'
|
||||
@@ -482,7 +488,7 @@ export const getLogsColumns = ({
|
||||
title: t('分组'),
|
||||
dataIndex: 'group',
|
||||
render: (text, record, index) => {
|
||||
if (record.type === 0 || record.type === 2 || record.type === 5) {
|
||||
if (record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6) {
|
||||
if (record.group) {
|
||||
return <>{renderGroup(record.group)}</>;
|
||||
} else {
|
||||
@@ -522,7 +528,7 @@ export const getLogsColumns = ({
|
||||
title: t('模型'),
|
||||
dataIndex: 'model_name',
|
||||
render: (text, record, index) => {
|
||||
return record.type === 0 || record.type === 2 || record.type === 5 ? (
|
||||
return record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6 ? (
|
||||
<>{renderModelName(record, copyText, t)}</>
|
||||
) : (
|
||||
<></>
|
||||
@@ -589,7 +595,7 @@ export const getLogsColumns = ({
|
||||
cacheText = `${t('缓存写')} ${formatTokenCount(cacheSummary.cacheWriteTokens)}`;
|
||||
}
|
||||
|
||||
return record.type === 0 || record.type === 2 || record.type === 5 ? (
|
||||
return record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6 ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
@@ -623,7 +629,7 @@ export const getLogsColumns = ({
|
||||
dataIndex: 'completion_tokens',
|
||||
render: (text, record, index) => {
|
||||
return parseInt(text) > 0 &&
|
||||
(record.type === 0 || record.type === 2 || record.type === 5) ? (
|
||||
(record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6) ? (
|
||||
<>{<span> {text} </span>}</>
|
||||
) : (
|
||||
<></>
|
||||
@@ -635,7 +641,7 @@ export const getLogsColumns = ({
|
||||
title: t('花费'),
|
||||
dataIndex: 'quota',
|
||||
render: (text, record, index) => {
|
||||
if (!(record.type === 0 || record.type === 2 || record.type === 5)) {
|
||||
if (!(record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6)) {
|
||||
return <></>;
|
||||
}
|
||||
const other = getLogOther(record.other);
|
||||
@@ -722,6 +728,16 @@ export const getLogsColumns = ({
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => {
|
||||
let other = getLogOther(record.other);
|
||||
if (record.type === 6) {
|
||||
return (
|
||||
<Typography.Paragraph
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{ maxWidth: 240 }}
|
||||
>
|
||||
{t('异步任务退款')}
|
||||
</Typography.Paragraph>
|
||||
);
|
||||
}
|
||||
if (other == null || record.type !== 2) {
|
||||
return (
|
||||
<Typography.Paragraph
|
||||
|
||||
@@ -148,6 +148,7 @@ const LogsFilters = ({
|
||||
<Form.Select.Option value='3'>{t('管理')}</Form.Select.Option>
|
||||
<Form.Select.Option value='4'>{t('系统')}</Form.Select.Option>
|
||||
<Form.Select.Option value='5'>{t('错误')}</Form.Select.Option>
|
||||
<Form.Select.Option value='6'>{t('退款')}</Form.Select.Option>
|
||||
</Form.Select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,6 +39,21 @@ function formatTokenRate(n, d) {
|
||||
return `${r.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function formatCachedTokenRate(cachedTokens, promptTokens, mode) {
|
||||
if (mode === 'cached_over_prompt_plus_cached') {
|
||||
const denominator = Number(promptTokens || 0) + Number(cachedTokens || 0);
|
||||
return formatTokenRate(cachedTokens, denominator);
|
||||
}
|
||||
if (mode === 'cached_over_prompt') {
|
||||
return formatTokenRate(cachedTokens, promptTokens);
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
|
||||
function hasTextValue(value) {
|
||||
return typeof value === 'string' && value.trim() !== '';
|
||||
}
|
||||
|
||||
const ChannelAffinityUsageCacheModal = ({
|
||||
t,
|
||||
showChannelAffinityUsageCacheModal,
|
||||
@@ -107,7 +122,7 @@ const ChannelAffinityUsageCacheModal = ({
|
||||
t,
|
||||
]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const { rows, supportsTokenStats } = useMemo(() => {
|
||||
const s = stats || {};
|
||||
const hit = Number(s.hit || 0);
|
||||
const total = Number(s.total || 0);
|
||||
@@ -118,48 +133,62 @@ const ChannelAffinityUsageCacheModal = ({
|
||||
const totalTokens = Number(s.total_tokens || 0);
|
||||
const cachedTokens = Number(s.cached_tokens || 0);
|
||||
const promptCacheHitTokens = Number(s.prompt_cache_hit_tokens || 0);
|
||||
const cachedTokenRateMode = String(s.cached_token_rate_mode || '').trim();
|
||||
const supportsTokenStats =
|
||||
cachedTokenRateMode === 'cached_over_prompt' ||
|
||||
cachedTokenRateMode === 'cached_over_prompt_plus_cached' ||
|
||||
cachedTokenRateMode === 'mixed';
|
||||
|
||||
return [
|
||||
{ key: t('规则'), value: s.rule_name || params.rule_name || '-' },
|
||||
{ key: t('分组'), value: s.using_group || params.using_group || '-' },
|
||||
{
|
||||
key: t('Key 摘要'),
|
||||
value: params.key_hint || '-',
|
||||
},
|
||||
{
|
||||
key: t('Key 指纹'),
|
||||
value: s.key_fp || params.key_fp || '-',
|
||||
},
|
||||
{ key: t('TTL(秒)'), value: windowSeconds > 0 ? windowSeconds : '-' },
|
||||
{
|
||||
key: t('命中率'),
|
||||
value: `${hit}/${total} (${formatRate(hit, total)})`,
|
||||
},
|
||||
{
|
||||
key: t('Prompt tokens'),
|
||||
value: promptTokens,
|
||||
},
|
||||
{
|
||||
key: t('Cached tokens'),
|
||||
value: `${cachedTokens} (${formatTokenRate(cachedTokens, promptTokens)})`,
|
||||
},
|
||||
{
|
||||
key: t('Prompt cache hit tokens'),
|
||||
value: promptCacheHitTokens,
|
||||
},
|
||||
{
|
||||
key: t('Completion tokens'),
|
||||
value: completionTokens,
|
||||
},
|
||||
{
|
||||
key: t('Total tokens'),
|
||||
value: totalTokens,
|
||||
},
|
||||
{
|
||||
key: t('最近一次'),
|
||||
value: lastSeenAt > 0 ? timestamp2string(lastSeenAt) : '-',
|
||||
},
|
||||
];
|
||||
const data = [];
|
||||
const ruleName = String(s.rule_name || params.rule_name || '').trim();
|
||||
const usingGroup = String(s.using_group || params.using_group || '').trim();
|
||||
const keyHint = String(params.key_hint || '').trim();
|
||||
const keyFp = String(s.key_fp || params.key_fp || '').trim();
|
||||
|
||||
if (hasTextValue(ruleName)) {
|
||||
data.push({ key: t('规则'), value: ruleName });
|
||||
}
|
||||
if (hasTextValue(usingGroup)) {
|
||||
data.push({ key: t('分组'), value: usingGroup });
|
||||
}
|
||||
if (hasTextValue(keyHint)) {
|
||||
data.push({ key: t('Key 摘要'), value: keyHint });
|
||||
}
|
||||
if (hasTextValue(keyFp)) {
|
||||
data.push({ key: t('Key 指纹'), value: keyFp });
|
||||
}
|
||||
if (windowSeconds > 0) {
|
||||
data.push({ key: t('TTL(秒)'), value: windowSeconds });
|
||||
}
|
||||
if (total > 0) {
|
||||
data.push({ key: t('命中率'), value: `${hit}/${total} (${formatRate(hit, total)})` });
|
||||
}
|
||||
if (lastSeenAt > 0) {
|
||||
data.push({ key: t('最近一次'), value: timestamp2string(lastSeenAt) });
|
||||
}
|
||||
|
||||
if (supportsTokenStats) {
|
||||
if (promptTokens > 0) {
|
||||
data.push({ key: t('Prompt tokens'), value: promptTokens });
|
||||
}
|
||||
if (promptTokens > 0 || cachedTokens > 0) {
|
||||
data.push({
|
||||
key: t('Cached tokens'),
|
||||
value: `${cachedTokens} (${formatCachedTokenRate(cachedTokens, promptTokens, cachedTokenRateMode)})`,
|
||||
});
|
||||
}
|
||||
if (promptCacheHitTokens > 0) {
|
||||
data.push({ key: t('Prompt cache hit tokens'), value: promptCacheHitTokens });
|
||||
}
|
||||
if (completionTokens > 0) {
|
||||
data.push({ key: t('Completion tokens'), value: completionTokens });
|
||||
}
|
||||
if (totalTokens > 0) {
|
||||
data.push({ key: t('Total tokens'), value: totalTokens });
|
||||
}
|
||||
}
|
||||
|
||||
return { rows: data, supportsTokenStats };
|
||||
}, [stats, params, t]);
|
||||
|
||||
return (
|
||||
@@ -179,15 +208,27 @@ const ChannelAffinityUsageCacheModal = ({
|
||||
{t(
|
||||
'命中判定:usage 中存在 cached tokens(例如 cached_tokens/prompt_cache_hit_tokens)即视为命中。',
|
||||
)}
|
||||
{' '}
|
||||
{t(
|
||||
'Cached tokens 占比口径由后端返回:Claude 语义按 cached/(prompt+cached),其余按 cached/prompt。',
|
||||
)}
|
||||
{' '}
|
||||
{t('当前仅 OpenAI / Claude 语义支持缓存 token 统计,其他通道将隐藏 token 相关字段。')}
|
||||
{stats && !supportsTokenStats ? (
|
||||
<>
|
||||
{' '}
|
||||
{t('该记录不包含可用的 token 统计口径。')}
|
||||
</>
|
||||
) : null}
|
||||
</Text>
|
||||
</div>
|
||||
<Spin spinning={loading} tip={t('加载中...')}>
|
||||
{stats ? (
|
||||
{stats && rows.length > 0 ? (
|
||||
<Descriptions data={rows} />
|
||||
) : (
|
||||
<div style={{ padding: '24px 0' }}>
|
||||
<Text type='tertiary' size='small'>
|
||||
{loading ? t('加载中...') : t('暂无数据')}
|
||||
{loading ? t('加载中...') : t('暂无可展示数据')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
Avatar,
|
||||
Row,
|
||||
Col,
|
||||
Input,
|
||||
InputNumber,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
@@ -56,6 +55,7 @@ import {
|
||||
IconUserGroup,
|
||||
IconPlus,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import UserBindingManagementModal from './UserBindingManagementModal';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
@@ -68,6 +68,7 @@ const EditUserModal = (props) => {
|
||||
const [addAmountLocal, setAddAmountLocal] = useState('');
|
||||
const isMobile = useIsMobile();
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [bindingModalVisible, setBindingModalVisible] = useState(false);
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const isEdit = Boolean(userId);
|
||||
@@ -81,6 +82,7 @@ const EditUserModal = (props) => {
|
||||
discord_id: '',
|
||||
wechat_id: '',
|
||||
telegram_id: '',
|
||||
linux_do_id: '',
|
||||
email: '',
|
||||
quota: 0,
|
||||
group: 'default',
|
||||
@@ -115,8 +117,17 @@ const EditUserModal = (props) => {
|
||||
useEffect(() => {
|
||||
loadUser();
|
||||
if (userId) fetchGroups();
|
||||
setBindingModalVisible(false);
|
||||
}, [props.editingUser.id]);
|
||||
|
||||
const openBindingModal = () => {
|
||||
setBindingModalVisible(true);
|
||||
};
|
||||
|
||||
const closeBindingModal = () => {
|
||||
setBindingModalVisible(false);
|
||||
};
|
||||
|
||||
/* ----------------------- submit ----------------------- */
|
||||
const submit = async (values) => {
|
||||
setLoading(true);
|
||||
@@ -196,7 +207,7 @@ const EditUserModal = (props) => {
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<div className='p-2'>
|
||||
<div className='p-2 space-y-3'>
|
||||
{/* 基本信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
@@ -316,56 +327,51 @@ const EditUserModal = (props) => {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 绑定信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='purple'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconLink size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('绑定信息')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('第三方账户绑定状态(只读)')}
|
||||
{/* 绑定信息入口 */}
|
||||
{userId && (
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center min-w-0'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='purple'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconLink size={16} />
|
||||
</Avatar>
|
||||
<div className='min-w-0'>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('绑定信息')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('管理用户已绑定的第三方账户,支持筛选与解绑')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={openBindingModal}
|
||||
>
|
||||
{t('管理绑定')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
{[
|
||||
'github_id',
|
||||
'discord_id',
|
||||
'oidc_id',
|
||||
'wechat_id',
|
||||
'email',
|
||||
'telegram_id',
|
||||
].map((field) => (
|
||||
<Col span={24} key={field}>
|
||||
<Form.Input
|
||||
field={field}
|
||||
label={t(
|
||||
`已绑定的 ${field.replace('_id', '').toUpperCase()} 账户`,
|
||||
)}
|
||||
readonly
|
||||
placeholder={t(
|
||||
'此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
|
||||
<UserBindingManagementModal
|
||||
visible={bindingModalVisible}
|
||||
onCancel={closeBindingModal}
|
||||
userId={userId}
|
||||
isMobile={isMobile}
|
||||
formApiRef={formApiRef}
|
||||
/>
|
||||
|
||||
{/* 添加额度模态框 */}
|
||||
<Modal
|
||||
centered
|
||||
@@ -401,7 +407,10 @@ const EditUserModal = (props) => {
|
||||
<div className='mb-3'>
|
||||
<div className='mb-1'>
|
||||
<Text size='small'>{t('金额')}</Text>
|
||||
<Text size='small' type='tertiary'> ({t('仅用于换算,实际保存的是额度')})</Text>
|
||||
<Text size='small' type='tertiary'>
|
||||
{' '}
|
||||
({t('仅用于换算,实际保存的是额度')})
|
||||
</Text>
|
||||
</div>
|
||||
<InputNumber
|
||||
prefix={getCurrencyConfig().symbol}
|
||||
@@ -411,7 +420,9 @@ const EditUserModal = (props) => {
|
||||
onChange={(val) => {
|
||||
setAddAmountLocal(val);
|
||||
setAddQuotaLocal(
|
||||
val != null && val !== '' ? displayAmountToQuota(Math.abs(val)) * Math.sign(val) : '',
|
||||
val != null && val !== ''
|
||||
? displayAmountToQuota(Math.abs(val)) * Math.sign(val)
|
||||
: '',
|
||||
);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
@@ -430,7 +441,11 @@ const EditUserModal = (props) => {
|
||||
setAddQuotaLocal(val);
|
||||
setAddAmountLocal(
|
||||
val != null && val !== ''
|
||||
? Number((quotaToDisplayAmount(Math.abs(val)) * Math.sign(val)).toFixed(2))
|
||||
? Number(
|
||||
(
|
||||
quotaToDisplayAmount(Math.abs(val)) * Math.sign(val)
|
||||
).toFixed(2),
|
||||
)
|
||||
: '',
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
/*
|
||||
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 React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
getOAuthProviderIcon,
|
||||
} from '../../../../helpers';
|
||||
import {
|
||||
Modal,
|
||||
Spin,
|
||||
Typography,
|
||||
Card,
|
||||
Checkbox,
|
||||
Tag,
|
||||
Button,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconLink,
|
||||
IconMail,
|
||||
IconDelete,
|
||||
IconGithubLogo,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { SiDiscord, SiTelegram, SiWechat, SiLinux } from 'react-icons/si';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const UserBindingManagementModal = ({
|
||||
visible,
|
||||
onCancel,
|
||||
userId,
|
||||
isMobile,
|
||||
formApiRef,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [bindingLoading, setBindingLoading] = React.useState(false);
|
||||
const [showBoundOnly, setShowBoundOnly] = React.useState(true);
|
||||
const [statusInfo, setStatusInfo] = React.useState({});
|
||||
const [customOAuthBindings, setCustomOAuthBindings] = React.useState([]);
|
||||
const [bindingActionLoading, setBindingActionLoading] = React.useState({});
|
||||
|
||||
const loadBindingData = React.useCallback(async () => {
|
||||
if (!userId) return;
|
||||
|
||||
setBindingLoading(true);
|
||||
try {
|
||||
const [statusRes, customBindingRes] = await Promise.all([
|
||||
API.get('/api/status'),
|
||||
API.get(`/api/user/${userId}/oauth/bindings`),
|
||||
]);
|
||||
|
||||
if (statusRes.data?.success) {
|
||||
setStatusInfo(statusRes.data.data || {});
|
||||
} else {
|
||||
showError(statusRes.data?.message || t('操作失败'));
|
||||
}
|
||||
|
||||
if (customBindingRes.data?.success) {
|
||||
setCustomOAuthBindings(customBindingRes.data.data || []);
|
||||
} else {
|
||||
showError(customBindingRes.data?.message || t('操作失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(
|
||||
error.response?.data?.message || error.message || t('操作失败'),
|
||||
);
|
||||
} finally {
|
||||
setBindingLoading(false);
|
||||
}
|
||||
}, [t, userId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!visible) return;
|
||||
setShowBoundOnly(true);
|
||||
setBindingActionLoading({});
|
||||
loadBindingData();
|
||||
}, [visible, loadBindingData]);
|
||||
|
||||
const setBindingLoadingState = (key, value) => {
|
||||
setBindingActionLoading((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleUnbindBuiltInAccount = (bindingItem) => {
|
||||
if (!userId) return;
|
||||
|
||||
Modal.confirm({
|
||||
title: t('确认解绑'),
|
||||
content: t('确定要解绑 {{name}} 吗?', { name: bindingItem.name }),
|
||||
okText: t('确认'),
|
||||
cancelText: t('取消'),
|
||||
onOk: async () => {
|
||||
const loadingKey = `builtin-${bindingItem.key}`;
|
||||
setBindingLoadingState(loadingKey, true);
|
||||
try {
|
||||
const res = await API.delete(
|
||||
`/api/user/${userId}/bindings/${bindingItem.key}`,
|
||||
);
|
||||
if (!res.data?.success) {
|
||||
showError(res.data?.message || t('操作失败'));
|
||||
return;
|
||||
}
|
||||
formApiRef.current?.setValue(bindingItem.field, '');
|
||||
showSuccess(t('解绑成功'));
|
||||
} catch (error) {
|
||||
showError(
|
||||
error.response?.data?.message || error.message || t('操作失败'),
|
||||
);
|
||||
} finally {
|
||||
setBindingLoadingState(loadingKey, false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnbindCustomOAuthAccount = (provider) => {
|
||||
if (!userId) return;
|
||||
|
||||
Modal.confirm({
|
||||
title: t('确认解绑'),
|
||||
content: t('确定要解绑 {{name}} 吗?', { name: provider.name }),
|
||||
okText: t('确认'),
|
||||
cancelText: t('取消'),
|
||||
onOk: async () => {
|
||||
const loadingKey = `custom-${provider.id}`;
|
||||
setBindingLoadingState(loadingKey, true);
|
||||
try {
|
||||
const res = await API.delete(
|
||||
`/api/user/${userId}/oauth/bindings/${provider.id}`,
|
||||
);
|
||||
if (!res.data?.success) {
|
||||
showError(res.data?.message || t('操作失败'));
|
||||
return;
|
||||
}
|
||||
setCustomOAuthBindings((prev) =>
|
||||
prev.filter(
|
||||
(item) => Number(item.provider_id) !== Number(provider.id),
|
||||
),
|
||||
);
|
||||
showSuccess(t('解绑成功'));
|
||||
} catch (error) {
|
||||
showError(
|
||||
error.response?.data?.message || error.message || t('操作失败'),
|
||||
);
|
||||
} finally {
|
||||
setBindingLoadingState(loadingKey, false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const currentValues = formApiRef.current?.getValues?.() || {};
|
||||
|
||||
const builtInBindingItems = [
|
||||
{
|
||||
key: 'email',
|
||||
field: 'email',
|
||||
name: t('邮箱'),
|
||||
enabled: true,
|
||||
value: currentValues.email,
|
||||
icon: (
|
||||
<IconMail
|
||||
size='default'
|
||||
className='text-slate-600 dark:text-slate-300'
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'github',
|
||||
field: 'github_id',
|
||||
name: 'GitHub',
|
||||
enabled: Boolean(statusInfo.github_oauth),
|
||||
value: currentValues.github_id,
|
||||
icon: (
|
||||
<IconGithubLogo
|
||||
size='default'
|
||||
className='text-slate-600 dark:text-slate-300'
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'discord',
|
||||
field: 'discord_id',
|
||||
name: 'Discord',
|
||||
enabled: Boolean(statusInfo.discord_oauth),
|
||||
value: currentValues.discord_id,
|
||||
icon: (
|
||||
<SiDiscord size={20} className='text-slate-600 dark:text-slate-300' />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'oidc',
|
||||
field: 'oidc_id',
|
||||
name: 'OIDC',
|
||||
enabled: Boolean(statusInfo.oidc_enabled),
|
||||
value: currentValues.oidc_id,
|
||||
icon: (
|
||||
<IconLink
|
||||
size='default'
|
||||
className='text-slate-600 dark:text-slate-300'
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'wechat',
|
||||
field: 'wechat_id',
|
||||
name: t('微信'),
|
||||
enabled: Boolean(statusInfo.wechat_login),
|
||||
value: currentValues.wechat_id,
|
||||
icon: (
|
||||
<SiWechat size={20} className='text-slate-600 dark:text-slate-300' />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'telegram',
|
||||
field: 'telegram_id',
|
||||
name: 'Telegram',
|
||||
enabled: Boolean(statusInfo.telegram_oauth),
|
||||
value: currentValues.telegram_id,
|
||||
icon: (
|
||||
<SiTelegram size={20} className='text-slate-600 dark:text-slate-300' />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'linuxdo',
|
||||
field: 'linux_do_id',
|
||||
name: 'LinuxDO',
|
||||
enabled: Boolean(statusInfo.linuxdo_oauth),
|
||||
value: currentValues.linux_do_id,
|
||||
icon: (
|
||||
<SiLinux size={20} className='text-slate-600 dark:text-slate-300' />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const customBindingMap = new Map(
|
||||
customOAuthBindings.map((item) => [Number(item.provider_id), item]),
|
||||
);
|
||||
|
||||
const customProviderMap = new Map(
|
||||
(statusInfo.custom_oauth_providers || []).map((provider) => [
|
||||
Number(provider.id),
|
||||
provider,
|
||||
]),
|
||||
);
|
||||
|
||||
customOAuthBindings.forEach((binding) => {
|
||||
if (!customProviderMap.has(Number(binding.provider_id))) {
|
||||
customProviderMap.set(Number(binding.provider_id), {
|
||||
id: binding.provider_id,
|
||||
name: binding.provider_name,
|
||||
icon: binding.provider_icon,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const customBindingItems = Array.from(customProviderMap.values()).map(
|
||||
(provider) => {
|
||||
const binding = customBindingMap.get(Number(provider.id));
|
||||
return {
|
||||
key: `custom-${provider.id}`,
|
||||
providerId: provider.id,
|
||||
name: provider.name,
|
||||
enabled: true,
|
||||
value: binding?.provider_user_id || '',
|
||||
icon: getOAuthProviderIcon(
|
||||
provider.icon || binding?.provider_icon || '',
|
||||
20,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const allBindingItems = [
|
||||
...builtInBindingItems.map((item) => ({ ...item, type: 'builtin' })),
|
||||
...customBindingItems.map((item) => ({ ...item, type: 'custom' })),
|
||||
];
|
||||
|
||||
const boundCount = allBindingItems.filter((item) =>
|
||||
Boolean(item.value),
|
||||
).length;
|
||||
|
||||
const visibleBindingItems = showBoundOnly
|
||||
? allBindingItems.filter((item) => Boolean(item.value))
|
||||
: allBindingItems;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
centered
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
width={isMobile ? '100%' : 760}
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<IconLink className='mr-2' />
|
||||
{t('账户绑定管理')}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Spin spinning={bindingLoading}>
|
||||
<div className='max-h-[68vh] overflow-y-auto pr-1 pb-2'>
|
||||
<div className='flex items-center justify-between mb-4 gap-3 flex-wrap'>
|
||||
<Checkbox
|
||||
checked={showBoundOnly}
|
||||
onChange={(e) => setShowBoundOnly(Boolean(e.target.checked))}
|
||||
>
|
||||
{t('仅显示已绑定')}
|
||||
</Checkbox>
|
||||
<Text type='tertiary'>
|
||||
{t('已绑定')} {boundCount} / {allBindingItems.length}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{visibleBindingItems.length === 0 ? (
|
||||
<Card className='!rounded-xl border-dashed'>
|
||||
<Text type='tertiary'>{t('暂无已绑定项')}</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
|
||||
{visibleBindingItems.map((item, index) => {
|
||||
const isBound = Boolean(item.value);
|
||||
const loadingKey =
|
||||
item.type === 'builtin'
|
||||
? `builtin-${item.key}`
|
||||
: `custom-${item.providerId}`;
|
||||
const statusText = isBound
|
||||
? item.value
|
||||
: item.enabled
|
||||
? t('未绑定')
|
||||
: t('未启用');
|
||||
const shouldSpanTwoColsOnDesktop =
|
||||
visibleBindingItems.length % 2 === 1 &&
|
||||
index === visibleBindingItems.length - 1;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.key}
|
||||
className={`!rounded-xl ${shouldSpanTwoColsOnDesktop ? 'lg:col-span-2' : ''}`}
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3 min-h-[92px]'>
|
||||
<div className='flex items-center flex-1 min-w-0'>
|
||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='font-medium text-gray-900 flex items-center gap-2'>
|
||||
<span>{item.name}</span>
|
||||
<Tag size='small' color='white'>
|
||||
{item.type === 'builtin'
|
||||
? t('内置')
|
||||
: t('自定义')}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className='text-sm text-gray-500 truncate'>
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
icon={<IconDelete />}
|
||||
size='small'
|
||||
disabled={!isBound}
|
||||
loading={Boolean(bindingActionLoading[loadingKey])}
|
||||
onClick={() => {
|
||||
if (item.type === 'builtin') {
|
||||
handleUnbindBuiltInAccount(item);
|
||||
return;
|
||||
}
|
||||
handleUnbindCustomOAuthAccount({
|
||||
id: item.providerId,
|
||||
name: item.name,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('解绑')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserBindingManagementModal;
|
||||
Reference in New Issue
Block a user