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:
Seefs
2026-02-25 13:39:54 +08:00
124 changed files with 7729 additions and 1971 deletions

View File

@@ -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}

View File

@@ -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)}
/>

View File

@@ -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;

View 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));
}

View File

@@ -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('点击预览视频')}

View File

@@ -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 = () => {

View File

@@ -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

View 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;

View File

@@ -144,8 +144,6 @@ const ContentModal = ({
maxHeight: '100%',
objectFit: 'contain',
}}
autoPlay
crossOrigin='anonymous'
onError={handleVideoError}
onLoadedData={handleVideoLoaded}
onLoadStart={() => setIsLoading(true)}

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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),
)
: '',
);
}}

View File

@@ -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;