2025-08-17 00:49:54 +08:00
|
|
|
|
/*
|
|
|
|
|
|
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 {
|
|
|
|
|
|
Button,
|
|
|
|
|
|
Card,
|
|
|
|
|
|
Input,
|
|
|
|
|
|
Space,
|
|
|
|
|
|
Typography,
|
|
|
|
|
|
Avatar,
|
|
|
|
|
|
Tabs,
|
2025-08-17 11:45:55 +08:00
|
|
|
|
TabPane,
|
2025-08-30 21:15:10 +08:00
|
|
|
|
Popover,
|
2025-09-28 17:31:38 +08:00
|
|
|
|
Modal,
|
2025-08-17 00:49:54 +08:00
|
|
|
|
} from '@douyinfe/semi-ui';
|
|
|
|
|
|
import {
|
|
|
|
|
|
IconMail,
|
|
|
|
|
|
IconShield,
|
|
|
|
|
|
IconGithubLogo,
|
|
|
|
|
|
IconKey,
|
|
|
|
|
|
IconLock,
|
2025-08-30 21:15:10 +08:00
|
|
|
|
IconDelete,
|
2025-08-17 00:49:54 +08:00
|
|
|
|
} from '@douyinfe/semi-icons';
|
2025-11-22 18:38:24 +08:00
|
|
|
|
import { SiTelegram, SiWechat, SiLinux, SiDiscord } from 'react-icons/si';
|
2025-08-17 00:49:54 +08:00
|
|
|
|
import { UserPlus, ShieldCheck } from 'lucide-react';
|
|
|
|
|
|
import TelegramLoginButton from 'react-telegram-login';
|
|
|
|
|
|
import {
|
2026-02-05 21:18:43 +08:00
|
|
|
|
API,
|
|
|
|
|
|
showError,
|
|
|
|
|
|
showSuccess,
|
2025-08-17 00:49:54 +08:00
|
|
|
|
onGitHubOAuthClicked,
|
|
|
|
|
|
onOIDCClicked,
|
2025-08-30 21:15:10 +08:00
|
|
|
|
onLinuxDOOAuthClicked,
|
2025-11-22 18:38:24 +08:00
|
|
|
|
onDiscordOAuthClicked,
|
2026-02-05 21:18:43 +08:00
|
|
|
|
onCustomOAuthClicked,
|
2025-08-17 00:49:54 +08:00
|
|
|
|
} from '../../../../helpers';
|
|
|
|
|
|
import TwoFASetting from '../components/TwoFASetting';
|
|
|
|
|
|
|
|
|
|
|
|
const AccountManagement = ({
|
|
|
|
|
|
t,
|
|
|
|
|
|
userState,
|
|
|
|
|
|
status,
|
|
|
|
|
|
systemToken,
|
|
|
|
|
|
setShowEmailBindModal,
|
|
|
|
|
|
setShowWeChatBindModal,
|
|
|
|
|
|
generateAccessToken,
|
|
|
|
|
|
handleSystemTokenClick,
|
|
|
|
|
|
setShowChangePasswordModal,
|
2025-08-30 21:15:10 +08:00
|
|
|
|
setShowAccountDeleteModal,
|
2025-09-29 17:45:09 +08:00
|
|
|
|
passkeyStatus,
|
|
|
|
|
|
passkeySupported,
|
|
|
|
|
|
passkeyRegisterLoading,
|
|
|
|
|
|
passkeyDeleteLoading,
|
|
|
|
|
|
onPasskeyRegister,
|
|
|
|
|
|
onPasskeyDelete,
|
2025-08-17 00:49:54 +08:00
|
|
|
|
}) => {
|
2025-08-17 11:45:55 +08:00
|
|
|
|
const renderAccountInfo = (accountId, label) => {
|
|
|
|
|
|
if (!accountId || accountId === '') {
|
2025-08-30 21:15:10 +08:00
|
|
|
|
return <span className='text-gray-500'>{t('未绑定')}</span>;
|
2025-08-17 11:45:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const popContent = (
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='text-xs p-2'>
|
2025-08-17 11:45:55 +08:00
|
|
|
|
<Typography.Paragraph copyable={{ content: accountId }}>
|
|
|
|
|
|
{accountId}
|
|
|
|
|
|
</Typography.Paragraph>
|
|
|
|
|
|
{label ? (
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='mt-1 text-[11px] text-gray-500'>{label}</div>
|
2025-08-17 11:45:55 +08:00
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Popover content={popContent} position='top' trigger='hover'>
|
|
|
|
|
|
<span className='block max-w-full truncate text-gray-600 hover:text-blue-600 cursor-pointer'>
|
2025-08-17 11:45:55 +08:00
|
|
|
|
{accountId}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
2025-09-28 17:31:38 +08:00
|
|
|
|
const isBound = (accountId) => Boolean(accountId);
|
2025-09-29 23:23:31 +08:00
|
|
|
|
const [showTelegramBindModal, setShowTelegramBindModal] =
|
|
|
|
|
|
React.useState(false);
|
2026-02-05 21:18:43 +08:00
|
|
|
|
const [customOAuthBindings, setCustomOAuthBindings] = React.useState([]);
|
|
|
|
|
|
const [customOAuthLoading, setCustomOAuthLoading] = React.useState({});
|
|
|
|
|
|
|
|
|
|
|
|
// Fetch custom OAuth bindings
|
|
|
|
|
|
const loadCustomOAuthBindings = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await API.get('/api/user/oauth/bindings');
|
|
|
|
|
|
if (res.data.success) {
|
|
|
|
|
|
setCustomOAuthBindings(res.data.data || []);
|
2026-02-05 21:48:05 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
showError(res.data.message || t('获取绑定信息失败'));
|
2026-02-05 21:18:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2026-02-05 21:48:05 +08:00
|
|
|
|
showError(error.response?.data?.message || error.message || t('获取绑定信息失败'));
|
2026-02-05 21:18:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Unbind custom OAuth provider
|
|
|
|
|
|
const handleUnbindCustomOAuth = async (providerId, providerName) => {
|
|
|
|
|
|
Modal.confirm({
|
|
|
|
|
|
title: t('确认解绑'),
|
|
|
|
|
|
content: t('确定要解绑 {{name}} 吗?', { name: providerName }),
|
|
|
|
|
|
okText: t('确认'),
|
|
|
|
|
|
cancelText: t('取消'),
|
|
|
|
|
|
onOk: async () => {
|
|
|
|
|
|
setCustomOAuthLoading((prev) => ({ ...prev, [providerId]: true }));
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await API.delete(`/api/user/oauth/bindings/${providerId}`);
|
|
|
|
|
|
if (res.data.success) {
|
|
|
|
|
|
showSuccess(t('解绑成功'));
|
|
|
|
|
|
await loadCustomOAuthBindings();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
showError(res.data.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2026-02-05 21:48:05 +08:00
|
|
|
|
showError(error.response?.data?.message || error.message || t('操作失败'));
|
2026-02-05 21:18:43 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
setCustomOAuthLoading((prev) => ({ ...prev, [providerId]: false }));
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Handle bind custom OAuth
|
|
|
|
|
|
const handleBindCustomOAuth = (provider) => {
|
|
|
|
|
|
onCustomOAuthClicked(provider);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Check if custom OAuth provider is bound
|
|
|
|
|
|
const isCustomOAuthBound = (providerId) => {
|
|
|
|
|
|
return customOAuthBindings.some((b) => b.provider_id === providerId);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Get binding info for a provider
|
|
|
|
|
|
const getCustomOAuthBinding = (providerId) => {
|
|
|
|
|
|
return customOAuthBindings.find((b) => b.provider_id === providerId);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
|
loadCustomOAuthBindings();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-09-29 17:45:09 +08:00
|
|
|
|
const passkeyEnabled = passkeyStatus?.enabled;
|
|
|
|
|
|
const lastUsedLabel = passkeyStatus?.last_used_at
|
|
|
|
|
|
? new Date(passkeyStatus.last_used_at).toLocaleString()
|
|
|
|
|
|
: t('尚未使用');
|
2025-09-28 17:31:38 +08:00
|
|
|
|
|
2025-08-17 00:49:54 +08:00
|
|
|
|
return (
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Card className='!rounded-2xl'>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
{/* 卡片头部 */}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex items-center mb-4'>
|
|
|
|
|
|
<Avatar size='small' color='teal' className='mr-3 shadow-md'>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
<UserPlus size={16} />
|
|
|
|
|
|
</Avatar>
|
|
|
|
|
|
<div>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Typography.Text className='text-lg font-medium'>
|
|
|
|
|
|
{t('账户管理')}
|
|
|
|
|
|
</Typography.Text>
|
|
|
|
|
|
<div className='text-xs text-gray-600'>
|
|
|
|
|
|
{t('账户绑定、安全设置和身份验证')}
|
|
|
|
|
|
</div>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Tabs type='card' defaultActiveKey='binding'>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
{/* 账户绑定 Tab */}
|
|
|
|
|
|
<TabPane
|
|
|
|
|
|
tab={
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex items-center'>
|
|
|
|
|
|
<UserPlus size={16} className='mr-2' />
|
2025-08-17 00:49:54 +08:00
|
|
|
|
{t('账户绑定')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
itemKey='binding'
|
2025-08-17 00:49:54 +08:00
|
|
|
|
>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='py-4'>
|
|
|
|
|
|
<div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
{/* 邮箱绑定 */}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Card className='!rounded-xl'>
|
|
|
|
|
|
<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'>
|
|
|
|
|
|
<IconMail
|
|
|
|
|
|
size='default'
|
|
|
|
|
|
className='text-slate-600 dark:text-slate-300'
|
|
|
|
|
|
/>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex-1 min-w-0'>
|
|
|
|
|
|
<div className='font-medium text-gray-900'>
|
|
|
|
|
|
{t('邮箱')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className='text-sm text-gray-500 truncate'>
|
|
|
|
|
|
{renderAccountInfo(
|
|
|
|
|
|
userState.user?.email,
|
|
|
|
|
|
t('邮箱地址'),
|
|
|
|
|
|
)}
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex-shrink-0'>
|
2025-08-17 11:45:55 +08:00
|
|
|
|
<Button
|
2025-08-30 21:15:10 +08:00
|
|
|
|
type='primary'
|
|
|
|
|
|
theme='outline'
|
|
|
|
|
|
size='small'
|
2025-08-17 11:45:55 +08:00
|
|
|
|
onClick={() => setShowEmailBindModal(true)}
|
|
|
|
|
|
>
|
2025-09-28 17:31:38 +08:00
|
|
|
|
{isBound(userState.user?.email)
|
2025-08-17 11:45:55 +08:00
|
|
|
|
? t('修改绑定')
|
|
|
|
|
|
: t('绑定')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 微信绑定 */}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Card className='!rounded-xl'>
|
|
|
|
|
|
<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'>
|
|
|
|
|
|
<SiWechat
|
|
|
|
|
|
size={20}
|
|
|
|
|
|
className='text-slate-600 dark:text-slate-300'
|
|
|
|
|
|
/>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex-1 min-w-0'>
|
|
|
|
|
|
<div className='font-medium text-gray-900'>
|
|
|
|
|
|
{t('微信')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className='text-sm text-gray-500 truncate'>
|
2025-09-28 17:31:38 +08:00
|
|
|
|
{!status.wechat_login
|
|
|
|
|
|
? t('未启用')
|
|
|
|
|
|
: isBound(userState.user?.wechat_id)
|
|
|
|
|
|
? t('已绑定')
|
|
|
|
|
|
: t('未绑定')}
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex-shrink-0'>
|
2025-08-17 11:45:55 +08:00
|
|
|
|
<Button
|
2025-08-30 21:15:10 +08:00
|
|
|
|
type='primary'
|
|
|
|
|
|
theme='outline'
|
|
|
|
|
|
size='small'
|
2025-08-17 11:45:55 +08:00
|
|
|
|
disabled={!status.wechat_login}
|
|
|
|
|
|
onClick={() => setShowWeChatBindModal(true)}
|
|
|
|
|
|
>
|
2025-09-28 17:31:38 +08:00
|
|
|
|
{isBound(userState.user?.wechat_id)
|
2025-08-17 11:45:55 +08:00
|
|
|
|
? t('修改绑定')
|
|
|
|
|
|
: status.wechat_login
|
|
|
|
|
|
? t('绑定')
|
|
|
|
|
|
: t('未启用')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
{/* GitHub绑定 */}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Card className='!rounded-xl'>
|
|
|
|
|
|
<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'>
|
|
|
|
|
|
<IconGithubLogo
|
|
|
|
|
|
size='default'
|
|
|
|
|
|
className='text-slate-600 dark:text-slate-300'
|
|
|
|
|
|
/>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex-1 min-w-0'>
|
|
|
|
|
|
<div className='font-medium text-gray-900'>
|
|
|
|
|
|
{t('GitHub')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className='text-sm text-gray-500 truncate'>
|
|
|
|
|
|
{renderAccountInfo(
|
|
|
|
|
|
userState.user?.github_id,
|
|
|
|
|
|
t('GitHub ID'),
|
|
|
|
|
|
)}
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex-shrink-0'>
|
2025-08-17 11:45:55 +08:00
|
|
|
|
<Button
|
2025-08-30 21:15:10 +08:00
|
|
|
|
type='primary'
|
|
|
|
|
|
theme='outline'
|
|
|
|
|
|
size='small'
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
onGitHubOAuthClicked(status.github_client_id)
|
|
|
|
|
|
}
|
2025-08-17 11:45:55 +08:00
|
|
|
|
disabled={
|
2025-09-29 23:23:31 +08:00
|
|
|
|
isBound(userState.user?.github_id) ||
|
|
|
|
|
|
!status.github_oauth
|
2025-08-17 11:45:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{status.github_oauth ? t('绑定') : t('未启用')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
2025-11-22 18:38:24 +08:00
|
|
|
|
{/* Discord绑定 */}
|
|
|
|
|
|
<Card className='!rounded-xl'>
|
|
|
|
|
|
<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'>
|
|
|
|
|
|
<SiDiscord
|
|
|
|
|
|
size={20}
|
|
|
|
|
|
className='text-slate-600 dark:text-slate-300'
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className='flex-1 min-w-0'>
|
|
|
|
|
|
<div className='font-medium text-gray-900'>
|
|
|
|
|
|
{t('Discord')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className='text-sm text-gray-500 truncate'>
|
|
|
|
|
|
{renderAccountInfo(
|
|
|
|
|
|
userState.user?.discord_id,
|
|
|
|
|
|
t('Discord ID'),
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className='flex-shrink-0'>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type='primary'
|
|
|
|
|
|
theme='outline'
|
|
|
|
|
|
size='small'
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
onDiscordOAuthClicked(status.discord_client_id)
|
|
|
|
|
|
}
|
|
|
|
|
|
disabled={
|
|
|
|
|
|
isBound(userState.user?.discord_id) ||
|
|
|
|
|
|
!status.discord_oauth
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{status.discord_oauth ? t('绑定') : t('未启用')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
2025-08-17 00:49:54 +08:00
|
|
|
|
{/* OIDC绑定 */}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Card className='!rounded-xl'>
|
|
|
|
|
|
<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'>
|
|
|
|
|
|
<IconShield
|
|
|
|
|
|
size='default'
|
|
|
|
|
|
className='text-slate-600 dark:text-slate-300'
|
|
|
|
|
|
/>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex-1 min-w-0'>
|
|
|
|
|
|
<div className='font-medium text-gray-900'>
|
|
|
|
|
|
{t('OIDC')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className='text-sm text-gray-500 truncate'>
|
|
|
|
|
|
{renderAccountInfo(
|
|
|
|
|
|
userState.user?.oidc_id,
|
|
|
|
|
|
t('OIDC ID'),
|
|
|
|
|
|
)}
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex-shrink-0'>
|
2025-08-17 11:45:55 +08:00
|
|
|
|
<Button
|
2025-08-30 21:15:10 +08:00
|
|
|
|
type='primary'
|
|
|
|
|
|
theme='outline'
|
|
|
|
|
|
size='small'
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
onOIDCClicked(
|
|
|
|
|
|
status.oidc_authorization_endpoint,
|
|
|
|
|
|
status.oidc_client_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2025-08-17 11:45:55 +08:00
|
|
|
|
disabled={
|
2025-09-28 17:31:38 +08:00
|
|
|
|
isBound(userState.user?.oidc_id) || !status.oidc_enabled
|
2025-08-17 11:45:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{status.oidc_enabled ? t('绑定') : t('未启用')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Telegram绑定 */}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Card className='!rounded-xl'>
|
|
|
|
|
|
<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'>
|
|
|
|
|
|
<SiTelegram
|
|
|
|
|
|
size={20}
|
|
|
|
|
|
className='text-slate-600 dark:text-slate-300'
|
|
|
|
|
|
/>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex-1 min-w-0'>
|
|
|
|
|
|
<div className='font-medium text-gray-900'>
|
|
|
|
|
|
{t('Telegram')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className='text-sm text-gray-500 truncate'>
|
|
|
|
|
|
{renderAccountInfo(
|
|
|
|
|
|
userState.user?.telegram_id,
|
|
|
|
|
|
t('Telegram ID'),
|
|
|
|
|
|
)}
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex-shrink-0'>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
{status.telegram_oauth ? (
|
2025-09-28 17:31:38 +08:00
|
|
|
|
isBound(userState.user?.telegram_id) ? (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
disabled
|
|
|
|
|
|
size='small'
|
|
|
|
|
|
type='primary'
|
|
|
|
|
|
theme='outline'
|
|
|
|
|
|
>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
{t('已绑定')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
) : (
|
2025-09-28 17:31:38 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
type='primary'
|
|
|
|
|
|
theme='outline'
|
|
|
|
|
|
size='small'
|
|
|
|
|
|
onClick={() => setShowTelegramBindModal(true)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t('绑定')}
|
|
|
|
|
|
</Button>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
)
|
|
|
|
|
|
) : (
|
2025-09-28 17:31:38 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
disabled
|
|
|
|
|
|
size='small'
|
|
|
|
|
|
type='primary'
|
|
|
|
|
|
theme='outline'
|
|
|
|
|
|
>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
{t('未启用')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
2025-09-28 17:31:38 +08:00
|
|
|
|
<Modal
|
|
|
|
|
|
title={t('绑定 Telegram')}
|
|
|
|
|
|
visible={showTelegramBindModal}
|
|
|
|
|
|
onCancel={() => setShowTelegramBindModal(false)}
|
|
|
|
|
|
footer={null}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className='my-3 text-sm text-gray-600'>
|
|
|
|
|
|
{t('点击下方按钮通过 Telegram 完成绑定')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className='flex justify-center'>
|
|
|
|
|
|
<div className='scale-90'>
|
|
|
|
|
|
<TelegramLoginButton
|
|
|
|
|
|
dataAuthUrl='/api/oauth/telegram/bind'
|
|
|
|
|
|
botName={status.telegram_bot_name}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Modal>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
|
|
|
|
|
|
{/* LinuxDO绑定 */}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Card className='!rounded-xl'>
|
|
|
|
|
|
<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'>
|
|
|
|
|
|
<SiLinux
|
|
|
|
|
|
size={20}
|
|
|
|
|
|
className='text-slate-600 dark:text-slate-300'
|
|
|
|
|
|
/>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex-1 min-w-0'>
|
|
|
|
|
|
<div className='font-medium text-gray-900'>
|
|
|
|
|
|
{t('LinuxDO')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className='text-sm text-gray-500 truncate'>
|
|
|
|
|
|
{renderAccountInfo(
|
|
|
|
|
|
userState.user?.linux_do_id,
|
|
|
|
|
|
t('LinuxDO ID'),
|
|
|
|
|
|
)}
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex-shrink-0'>
|
2025-08-17 11:45:55 +08:00
|
|
|
|
<Button
|
2025-08-30 21:15:10 +08:00
|
|
|
|
type='primary'
|
|
|
|
|
|
theme='outline'
|
|
|
|
|
|
size='small'
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
onLinuxDOOAuthClicked(status.linuxdo_client_id)
|
|
|
|
|
|
}
|
2025-08-17 11:45:55 +08:00
|
|
|
|
disabled={
|
2025-09-29 23:23:31 +08:00
|
|
|
|
isBound(userState.user?.linux_do_id) ||
|
|
|
|
|
|
!status.linuxdo_oauth
|
2025-08-17 11:45:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{status.linuxdo_oauth ? t('绑定') : t('未启用')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
2026-02-05 21:18:43 +08:00
|
|
|
|
|
|
|
|
|
|
{/* 自定义 OAuth 提供商绑定 */}
|
|
|
|
|
|
{status.custom_oauth_providers &&
|
|
|
|
|
|
status.custom_oauth_providers.map((provider) => {
|
|
|
|
|
|
const bound = isCustomOAuthBound(provider.id);
|
|
|
|
|
|
const binding = getCustomOAuthBinding(provider.id);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Card key={provider.slug} className='!rounded-xl'>
|
|
|
|
|
|
<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'
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className='flex-1 min-w-0'>
|
|
|
|
|
|
<div className='font-medium text-gray-900'>
|
|
|
|
|
|
{provider.name}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className='text-sm text-gray-500 truncate'>
|
|
|
|
|
|
{bound
|
|
|
|
|
|
? renderAccountInfo(
|
|
|
|
|
|
binding?.provider_user_id,
|
|
|
|
|
|
t('{{name}} ID', { name: provider.name }),
|
|
|
|
|
|
)
|
|
|
|
|
|
: t('未绑定')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className='flex-shrink-0'>
|
|
|
|
|
|
{bound ? (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type='danger'
|
|
|
|
|
|
theme='outline'
|
|
|
|
|
|
size='small'
|
|
|
|
|
|
loading={customOAuthLoading[provider.id]}
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
handleUnbindCustomOAuth(provider.id, provider.name)
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t('解绑')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type='primary'
|
|
|
|
|
|
theme='outline'
|
|
|
|
|
|
size='small'
|
|
|
|
|
|
onClick={() => handleBindCustomOAuth(provider)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t('绑定')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TabPane>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 安全设置 Tab */}
|
|
|
|
|
|
<TabPane
|
|
|
|
|
|
tab={
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex items-center'>
|
|
|
|
|
|
<ShieldCheck size={16} className='mr-2' />
|
2025-08-17 00:49:54 +08:00
|
|
|
|
{t('安全设置')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
itemKey='security'
|
2025-08-17 00:49:54 +08:00
|
|
|
|
>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='py-4'>
|
|
|
|
|
|
<div className='space-y-6'>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
<Space vertical className='w-full'>
|
|
|
|
|
|
{/* 系统访问令牌 */}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Card className='!rounded-xl w-full'>
|
|
|
|
|
|
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
|
|
|
|
|
|
<div className='flex items-start w-full sm:w-auto'>
|
|
|
|
|
|
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
|
|
|
|
|
|
<IconKey size='large' className='text-slate-600' />
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex-1'>
|
|
|
|
|
|
<Typography.Title heading={6} className='mb-1'>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
{t('系统访问令牌')}
|
|
|
|
|
|
</Typography.Title>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Typography.Text type='tertiary' className='text-sm'>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
{t('用于API调用的身份验证令牌,请妥善保管')}
|
|
|
|
|
|
</Typography.Text>
|
|
|
|
|
|
{systemToken && (
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='mt-3'>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
readonly
|
|
|
|
|
|
value={systemToken}
|
|
|
|
|
|
onClick={handleSystemTokenClick}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
size='large'
|
2025-08-17 00:49:54 +08:00
|
|
|
|
prefix={<IconKey />}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button
|
2025-08-30 21:15:10 +08:00
|
|
|
|
type='primary'
|
|
|
|
|
|
theme='solid'
|
2025-08-17 00:49:54 +08:00
|
|
|
|
onClick={generateAccessToken}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
|
2025-08-17 00:49:54 +08:00
|
|
|
|
icon={<IconKey />}
|
|
|
|
|
|
>
|
|
|
|
|
|
{systemToken ? t('重新生成') : t('生成令牌')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 密码管理 */}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Card className='!rounded-xl w-full'>
|
|
|
|
|
|
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
|
|
|
|
|
|
<div className='flex items-start w-full sm:w-auto'>
|
|
|
|
|
|
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
|
|
|
|
|
|
<IconLock size='large' className='text-slate-600' />
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Typography.Title heading={6} className='mb-1'>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
{t('密码管理')}
|
|
|
|
|
|
</Typography.Title>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Typography.Text type='tertiary' className='text-sm'>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
{t('定期更改密码可以提高账户安全性')}
|
|
|
|
|
|
</Typography.Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button
|
2025-08-30 21:15:10 +08:00
|
|
|
|
type='primary'
|
|
|
|
|
|
theme='solid'
|
2025-08-17 00:49:54 +08:00
|
|
|
|
onClick={() => setShowChangePasswordModal(true)}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
|
2025-08-17 00:49:54 +08:00
|
|
|
|
icon={<IconLock />}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t('修改密码')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
2025-09-29 17:45:09 +08:00
|
|
|
|
{/* Passkey 设置 */}
|
|
|
|
|
|
<Card className='!rounded-xl w-full'>
|
|
|
|
|
|
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
|
|
|
|
|
|
<div className='flex items-start w-full sm:w-auto'>
|
|
|
|
|
|
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
|
|
|
|
|
|
<IconKey size='large' className='text-slate-600' />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Typography.Title heading={6} className='mb-1'>
|
|
|
|
|
|
{t('Passkey 登录')}
|
|
|
|
|
|
</Typography.Title>
|
|
|
|
|
|
<Typography.Text type='tertiary' className='text-sm'>
|
|
|
|
|
|
{passkeyEnabled
|
|
|
|
|
|
? t('已启用 Passkey,无需密码即可登录')
|
|
|
|
|
|
: t('使用 Passkey 实现免密且更安全的登录体验')}
|
|
|
|
|
|
</Typography.Text>
|
|
|
|
|
|
<div className='mt-2 text-xs text-gray-500 space-y-1'>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
{t('最后使用时间')}:{lastUsedLabel}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/*{passkeyEnabled && (*/}
|
|
|
|
|
|
{/* <div>*/}
|
|
|
|
|
|
{/* {t('备份支持')}:*/}
|
|
|
|
|
|
{/* {passkeyStatus?.backup_eligible*/}
|
|
|
|
|
|
{/* ? t('支持备份')*/}
|
|
|
|
|
|
{/* : t('不支持')}*/}
|
|
|
|
|
|
{/* ,{t('备份状态')}:*/}
|
|
|
|
|
|
{/* {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/}
|
|
|
|
|
|
{/* </div>*/}
|
|
|
|
|
|
{/*)}*/}
|
|
|
|
|
|
{!passkeySupported && (
|
|
|
|
|
|
<div className='text-amber-600'>
|
|
|
|
|
|
{t('当前设备不支持 Passkey')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button
|
2025-09-30 12:26:24 +08:00
|
|
|
|
type={passkeyEnabled ? 'danger' : 'primary'}
|
|
|
|
|
|
theme={passkeyEnabled ? 'solid' : 'solid'}
|
|
|
|
|
|
onClick={
|
|
|
|
|
|
passkeyEnabled
|
|
|
|
|
|
? () => {
|
|
|
|
|
|
Modal.confirm({
|
|
|
|
|
|
title: t('确认解绑 Passkey'),
|
✨ feat: Add topup billing history with admin manual completion
Implement comprehensive topup billing system with user history viewing and admin management capabilities.
## Features Added
### Frontend
- Add topup history modal with paginated billing records
- Display order details: trade number, payment method, amount, money, status, create time
- Implement empty state with proper illustrations
- Add payment method column with localized display (Stripe, Alipay, WeChat)
- Add admin manual completion feature for pending orders
- Add Coins icon for recharge amount display
- Integrate "Bills" button in RechargeCard header
- Optimize code quality by using shared utility functions (isAdmin)
- Extract constants for status and payment method mappings
- Use React.useMemo for performance optimization
### Backend
- Create GET `/api/user/topup/self` endpoint for user topup history with pagination
- Create POST `/api/user/topup/complete` endpoint for admin manual order completion
- Add `payment_method` field to TopUp model for tracking payment types
- Implement `GetUserTopUps` method with proper pagination and ordering
- Implement `ManualCompleteTopUp` with transaction safety and row-level locking
- Add application-level mutex locks to prevent concurrent order processing
- Record payment method in Epay and Stripe payment flows
- Ensure idempotency and data consistency with proper error handling
### Internationalization
- Add i18n keys for Chinese (zh), English (en), and French (fr)
- Support for billing-related UI text and status messages
## Technical Improvements
- Use database transactions with FOR UPDATE row-level locking
- Implement sync.Map-based mutex for order-level concurrency control
- Proper error handling and user-friendly toast notifications
- Follow existing codebase patterns for empty states and modals
- Maintain code quality with extracted render functions and constants
## Files Changed
- Backend: controller/topup.go, controller/topup_stripe.go, model/topup.go, router/api-router.go
- Frontend: web/src/components/topup/modals/TopupHistoryModal.jsx (new), web/src/components/topup/RechargeCard.jsx, web/src/components/topup/index.jsx
- i18n: web/src/i18n/locales/{zh,en,fr}.json
2025-10-07 00:22:45 +08:00
|
|
|
|
content: t(
|
|
|
|
|
|
'解绑后将无法使用 Passkey 登录,确定要继续吗?',
|
|
|
|
|
|
),
|
2025-09-30 12:26:24 +08:00
|
|
|
|
okText: t('确认解绑'),
|
|
|
|
|
|
cancelText: t('取消'),
|
|
|
|
|
|
okType: 'danger',
|
|
|
|
|
|
onOk: onPasskeyDelete,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
: onPasskeyRegister
|
|
|
|
|
|
}
|
|
|
|
|
|
className={`w-full sm:w-auto ${passkeyEnabled ? '!bg-slate-500 hover:!bg-slate-600' : ''}`}
|
2025-09-29 17:45:09 +08:00
|
|
|
|
icon={<IconKey />}
|
|
|
|
|
|
disabled={!passkeySupported && !passkeyEnabled}
|
✨ feat: Add topup billing history with admin manual completion
Implement comprehensive topup billing system with user history viewing and admin management capabilities.
## Features Added
### Frontend
- Add topup history modal with paginated billing records
- Display order details: trade number, payment method, amount, money, status, create time
- Implement empty state with proper illustrations
- Add payment method column with localized display (Stripe, Alipay, WeChat)
- Add admin manual completion feature for pending orders
- Add Coins icon for recharge amount display
- Integrate "Bills" button in RechargeCard header
- Optimize code quality by using shared utility functions (isAdmin)
- Extract constants for status and payment method mappings
- Use React.useMemo for performance optimization
### Backend
- Create GET `/api/user/topup/self` endpoint for user topup history with pagination
- Create POST `/api/user/topup/complete` endpoint for admin manual order completion
- Add `payment_method` field to TopUp model for tracking payment types
- Implement `GetUserTopUps` method with proper pagination and ordering
- Implement `ManualCompleteTopUp` with transaction safety and row-level locking
- Add application-level mutex locks to prevent concurrent order processing
- Record payment method in Epay and Stripe payment flows
- Ensure idempotency and data consistency with proper error handling
### Internationalization
- Add i18n keys for Chinese (zh), English (en), and French (fr)
- Support for billing-related UI text and status messages
## Technical Improvements
- Use database transactions with FOR UPDATE row-level locking
- Implement sync.Map-based mutex for order-level concurrency control
- Proper error handling and user-friendly toast notifications
- Follow existing codebase patterns for empty states and modals
- Maintain code quality with extracted render functions and constants
## Files Changed
- Backend: controller/topup.go, controller/topup_stripe.go, model/topup.go, router/api-router.go
- Frontend: web/src/components/topup/modals/TopupHistoryModal.jsx (new), web/src/components/topup/RechargeCard.jsx, web/src/components/topup/index.jsx
- i18n: web/src/i18n/locales/{zh,en,fr}.json
2025-10-07 00:22:45 +08:00
|
|
|
|
loading={
|
|
|
|
|
|
passkeyEnabled
|
|
|
|
|
|
? passkeyDeleteLoading
|
|
|
|
|
|
: passkeyRegisterLoading
|
|
|
|
|
|
}
|
2025-09-29 17:45:09 +08:00
|
|
|
|
>
|
|
|
|
|
|
{passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
2025-08-17 00:49:54 +08:00
|
|
|
|
{/* 两步验证设置 */}
|
|
|
|
|
|
<TwoFASetting t={t} />
|
|
|
|
|
|
|
|
|
|
|
|
{/* 危险区域 */}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Card className='!rounded-xl w-full'>
|
|
|
|
|
|
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
|
|
|
|
|
|
<div className='flex items-start w-full sm:w-auto'>
|
|
|
|
|
|
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
|
|
|
|
|
|
<IconDelete size='large' className='text-slate-600' />
|
2025-08-17 00:49:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Typography.Title
|
|
|
|
|
|
heading={6}
|
|
|
|
|
|
className='mb-1 text-slate-700'
|
|
|
|
|
|
>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
{t('删除账户')}
|
|
|
|
|
|
</Typography.Title>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Typography.Text type='tertiary' className='text-sm'>
|
2025-08-17 00:49:54 +08:00
|
|
|
|
{t('此操作不可逆,所有数据将被永久删除')}
|
|
|
|
|
|
</Typography.Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button
|
2025-08-30 21:15:10 +08:00
|
|
|
|
type='danger'
|
|
|
|
|
|
theme='solid'
|
2025-08-17 00:49:54 +08:00
|
|
|
|
onClick={() => setShowAccountDeleteModal(true)}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
className='w-full sm:w-auto !bg-slate-500 hover:!bg-slate-600'
|
2025-08-17 00:49:54 +08:00
|
|
|
|
icon={<IconDelete />}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t('删除账户')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TabPane>
|
|
|
|
|
|
</Tabs>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default AccountManagement;
|