2025-07-19 03:30:44 +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
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2025-11-19 14:34:30 +08:00
|
|
|
|
import React, { useContext, useEffect, useRef, useState } from 'react';
|
2024-03-01 22:33:30 +08:00
|
|
|
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
2025-08-18 04:14:35 +08:00
|
|
|
|
import { UserContext } from '../../context/User';
|
2024-11-10 23:56:22 +08:00
|
|
|
|
import {
|
|
|
|
|
|
API,
|
|
|
|
|
|
getLogo,
|
|
|
|
|
|
showError,
|
|
|
|
|
|
showInfo,
|
|
|
|
|
|
showSuccess,
|
|
|
|
|
|
updateAPI,
|
2025-05-20 04:43:11 +08:00
|
|
|
|
getSystemName,
|
2025-06-04 00:14:15 +08:00
|
|
|
|
setUserData,
|
2025-04-04 12:00:38 +08:00
|
|
|
|
onGitHubOAuthClicked,
|
2025-11-22 18:38:24 +08:00
|
|
|
|
onDiscordOAuthClicked,
|
2025-04-04 12:00:38 +08:00
|
|
|
|
onOIDCClicked,
|
2025-08-30 21:15:10 +08:00
|
|
|
|
onLinuxDOOAuthClicked,
|
2025-09-29 17:45:09 +08:00
|
|
|
|
prepareCredentialRequestOptions,
|
|
|
|
|
|
buildAssertionResult,
|
|
|
|
|
|
isPasskeySupported,
|
2025-08-18 04:14:35 +08:00
|
|
|
|
} from '../../helpers';
|
2024-03-15 16:05:33 +08:00
|
|
|
|
import Turnstile from 'react-turnstile';
|
2025-10-09 22:21:56 +08:00
|
|
|
|
import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
2024-03-15 16:05:33 +08:00
|
|
|
|
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
|
|
|
|
|
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
2024-03-02 17:15:52 +08:00
|
|
|
|
import TelegramLoginButton from 'react-telegram-login';
|
2023-04-22 20:39:27 +08:00
|
|
|
|
|
✨ 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
|
|
|
|
import {
|
|
|
|
|
|
IconGithubLogo,
|
|
|
|
|
|
IconMail,
|
|
|
|
|
|
IconLock,
|
|
|
|
|
|
IconKey,
|
|
|
|
|
|
} from '@douyinfe/semi-icons';
|
2025-08-18 04:14:35 +08:00
|
|
|
|
import OIDCIcon from '../common/logo/OIDCIcon';
|
|
|
|
|
|
import WeChatIcon from '../common/logo/WeChatIcon';
|
|
|
|
|
|
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
|
|
|
|
|
|
import TwoFAVerification from './TwoFAVerification';
|
2024-12-13 19:03:14 +08:00
|
|
|
|
import { useTranslation } from 'react-i18next';
|
2025-11-22 18:38:24 +08:00
|
|
|
|
import { SiDiscord }from 'react-icons/si';
|
2023-04-22 20:39:27 +08:00
|
|
|
|
|
2023-11-07 23:32:43 +08:00
|
|
|
|
const LoginForm = () => {
|
2025-06-25 22:46:11 +08:00
|
|
|
|
let navigate = useNavigate();
|
|
|
|
|
|
const { t } = useTranslation();
|
2024-03-15 16:05:33 +08:00
|
|
|
|
const [inputs, setInputs] = useState({
|
|
|
|
|
|
username: '',
|
|
|
|
|
|
password: '',
|
2024-03-23 21:24:39 +08:00
|
|
|
|
wechat_verification_code: '',
|
2024-03-15 16:05:33 +08:00
|
|
|
|
});
|
|
|
|
|
|
const { username, password } = inputs;
|
2025-06-25 23:13:55 +08:00
|
|
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
|
|
const [submitted, setSubmitted] = useState(false);
|
|
|
|
|
|
const [userState, userDispatch] = useContext(UserContext);
|
2024-03-15 16:05:33 +08:00
|
|
|
|
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
|
|
|
|
|
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
|
|
|
|
|
const [turnstileToken, setTurnstileToken] = useState('');
|
2024-11-18 18:52:14 +08:00
|
|
|
|
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
|
2025-05-20 04:43:11 +08:00
|
|
|
|
const [showEmailLogin, setShowEmailLogin] = useState(false);
|
2025-05-22 21:42:21 +08:00
|
|
|
|
const [wechatLoading, setWechatLoading] = useState(false);
|
|
|
|
|
|
const [githubLoading, setGithubLoading] = useState(false);
|
2025-11-22 18:38:24 +08:00
|
|
|
|
const [discordLoading, setDiscordLoading] = useState(false);
|
2025-05-22 21:42:21 +08:00
|
|
|
|
const [oidcLoading, setOidcLoading] = useState(false);
|
|
|
|
|
|
const [linuxdoLoading, setLinuxdoLoading] = useState(false);
|
|
|
|
|
|
const [emailLoginLoading, setEmailLoginLoading] = useState(false);
|
|
|
|
|
|
const [loginLoading, setLoginLoading] = useState(false);
|
|
|
|
|
|
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
2025-08-30 21:15:10 +08:00
|
|
|
|
const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] =
|
|
|
|
|
|
useState(false);
|
2025-05-22 21:42:21 +08:00
|
|
|
|
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
2025-08-02 14:53:28 +08:00
|
|
|
|
const [showTwoFA, setShowTwoFA] = useState(false);
|
2025-09-29 17:45:09 +08:00
|
|
|
|
const [passkeySupported, setPasskeySupported] = useState(false);
|
|
|
|
|
|
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
2025-10-09 22:21:56 +08:00
|
|
|
|
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
|
|
|
|
|
const [hasUserAgreement, setHasUserAgreement] = useState(false);
|
|
|
|
|
|
const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
|
2025-11-19 14:34:30 +08:00
|
|
|
|
const [githubButtonText, setGithubButtonText] = useState('使用 GitHub 继续');
|
|
|
|
|
|
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false);
|
|
|
|
|
|
const githubTimeoutRef = useRef(null);
|
2024-11-18 18:52:14 +08:00
|
|
|
|
|
2024-03-15 16:05:33 +08:00
|
|
|
|
const logo = getLogo();
|
2025-05-20 04:43:11 +08:00
|
|
|
|
const systemName = getSystemName();
|
2023-04-22 20:39:27 +08:00
|
|
|
|
|
2024-11-18 18:52:14 +08:00
|
|
|
|
let affCode = new URLSearchParams(window.location.search).get('aff');
|
|
|
|
|
|
if (affCode) {
|
|
|
|
|
|
localStorage.setItem('aff', affCode);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-06-25 22:46:11 +08:00
|
|
|
|
const [status] = useState(() => {
|
|
|
|
|
|
const savedStatus = localStorage.getItem('status');
|
|
|
|
|
|
return savedStatus ? JSON.parse(savedStatus) : {};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (status.turnstile_check) {
|
|
|
|
|
|
setTurnstileEnabled(true);
|
|
|
|
|
|
setTurnstileSiteKey(status.turnstile_site_key);
|
|
|
|
|
|
}
|
2025-10-09 22:21:56 +08:00
|
|
|
|
|
|
|
|
|
|
// 从 status 获取用户协议和隐私政策的启用状态
|
|
|
|
|
|
setHasUserAgreement(status.user_agreement_enabled || false);
|
|
|
|
|
|
setHasPrivacyPolicy(status.privacy_policy_enabled || false);
|
2025-06-25 22:46:11 +08:00
|
|
|
|
}, [status]);
|
|
|
|
|
|
|
2025-09-29 17:45:09 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
isPasskeySupported()
|
|
|
|
|
|
.then(setPasskeySupported)
|
|
|
|
|
|
.catch(() => setPasskeySupported(false));
|
2025-11-19 14:34:30 +08:00
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (githubTimeoutRef.current) {
|
|
|
|
|
|
clearTimeout(githubTimeoutRef.current);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2025-09-29 17:45:09 +08:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
2024-03-15 16:05:33 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (searchParams.get('expired')) {
|
2024-12-13 19:03:14 +08:00
|
|
|
|
showError(t('未登录或登录已过期,请重新登录'));
|
2024-03-15 16:05:33 +08:00
|
|
|
|
}
|
2025-06-26 03:51:19 +08:00
|
|
|
|
}, []);
|
2023-04-22 20:39:27 +08:00
|
|
|
|
|
2024-03-15 16:05:33 +08:00
|
|
|
|
const onWeChatLoginClicked = () => {
|
2025-10-09 22:21:56 +08:00
|
|
|
|
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
|
|
|
|
|
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-05-22 21:42:21 +08:00
|
|
|
|
setWechatLoading(true);
|
2024-03-15 16:05:33 +08:00
|
|
|
|
setShowWeChatLoginModal(true);
|
2025-05-22 21:42:21 +08:00
|
|
|
|
setWechatLoading(false);
|
2024-03-15 16:05:33 +08:00
|
|
|
|
};
|
2023-04-22 20:39:27 +08:00
|
|
|
|
|
2024-03-15 16:05:33 +08:00
|
|
|
|
const onSubmitWeChatVerificationCode = async () => {
|
|
|
|
|
|
if (turnstileEnabled && turnstileToken === '') {
|
|
|
|
|
|
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
|
|
|
|
|
return;
|
2023-11-06 22:11:05 +08:00
|
|
|
|
}
|
2025-05-22 21:42:21 +08:00
|
|
|
|
setWechatCodeSubmitLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await API.get(
|
|
|
|
|
|
`/api/oauth/wechat?code=${inputs.wechat_verification_code}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
const { success, message, data } = res.data;
|
|
|
|
|
|
if (success) {
|
|
|
|
|
|
userDispatch({ type: 'login', payload: data });
|
|
|
|
|
|
localStorage.setItem('user', JSON.stringify(data));
|
|
|
|
|
|
setUserData(data);
|
|
|
|
|
|
updateAPI();
|
|
|
|
|
|
navigate('/');
|
|
|
|
|
|
showSuccess('登录成功!');
|
|
|
|
|
|
setShowWeChatLoginModal(false);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
showError(message);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
showError('登录失败,请重试');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setWechatCodeSubmitLoading(false);
|
2024-03-15 16:05:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
2023-11-07 23:32:43 +08:00
|
|
|
|
|
2024-03-15 16:05:33 +08:00
|
|
|
|
function handleChange(name, value) {
|
|
|
|
|
|
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleSubmit(e) {
|
2025-10-09 22:21:56 +08:00
|
|
|
|
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
|
|
|
|
|
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2024-03-15 16:05:33 +08:00
|
|
|
|
if (turnstileEnabled && turnstileToken === '') {
|
|
|
|
|
|
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setSubmitted(true);
|
2025-05-22 21:42:21 +08:00
|
|
|
|
setLoginLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (username && password) {
|
|
|
|
|
|
const res = await API.post(
|
|
|
|
|
|
`/api/user/login?turnstile=${turnstileToken}`,
|
|
|
|
|
|
{
|
|
|
|
|
|
username,
|
|
|
|
|
|
password,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
const { success, message, data } = res.data;
|
|
|
|
|
|
if (success) {
|
2025-08-02 14:53:28 +08:00
|
|
|
|
// 检查是否需要2FA验证
|
|
|
|
|
|
if (data && data.require_2fa) {
|
|
|
|
|
|
setShowTwoFA(true);
|
|
|
|
|
|
setLoginLoading(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
🎛️ refactor: HeaderBar into modular components, add shared skeletons, and primary-colored nav hover
Summary
- Split HeaderBar into maintainable components and hooks
- Centralized skeleton loading UI via a reusable SkeletonWrapper
- Improved navigation UX with primary-colored hover indication
- Preserved API surface and passed linters
Why
- Improve readability, reusability, and testability of the header
- Remove duplicated skeleton logic across files
- Provide clearer hover feedback consistent with the theme
What’s changed
- Components (web/src/components/layout/HeaderBar/)
- New container: index.js
- New UI components: HeaderLogo.js, Navigation.js, ActionButtons.js, UserArea.js, MobileMenuButton.js, NewYearButton.js, NotificationButton.js, ThemeToggle.js, LanguageSelector.js
- New shared skeleton: SkeletonWrapper.js
- Updated entry: HeaderBar.js now re-exports ./HeaderBar/index.js
- Hooks (web/src/hooks/common/)
- New: useHeaderBar.js (state and actions for header)
- New: useNotifications.js (announcements state, unread calc, open/close)
- New: useNavigation.js (main nav link config)
- Skeleton refactor
- Navigation.js: replaced inline skeletons with <SkeletonWrapper type="navigation" .../>
- UserArea.js: replaced inline skeletons with <SkeletonWrapper type="userArea" .../>
- HeaderLogo.js: replaced image/title skeletons with <SkeletonWrapper type="image"/>, <SkeletonWrapper type="title"/>
- Navigation hover UX
- Added primary-colored hover to nav items for clearer pointer feedback
- Final hover style: hover:text-semi-color-primary (kept rounded + transition classes)
Non-functional
- No breaking API changes; HeaderBar usage stays the same
- All modified files pass lint checks
Notes for future work
- SkeletonWrapper is extensible: add new cases (e.g., card) in one place
- Components are small and test-friendly; unit tests can be added per component
Affected files (key)
- web/src/components/layout/HeaderBar.js
- web/src/components/layout/HeaderBar/index.js
- web/src/components/layout/HeaderBar/Navigation.js
- web/src/components/layout/HeaderBar/UserArea.js
- web/src/components/layout/HeaderBar/HeaderLogo.js
- web/src/components/layout/HeaderBar/ActionButtons.js
- web/src/components/layout/HeaderBar/MobileMenuButton.js
- web/src/components/layout/HeaderBar/NewYearButton.js
- web/src/components/layout/HeaderBar/NotificationButton.js
- web/src/components/layout/HeaderBar/ThemeToggle.js
- web/src/components/layout/HeaderBar/LanguageSelector.js
- web/src/components/layout/HeaderBar/SkeletonWrapper.js
- web/src/hooks/common/useHeaderBar.js
- web/src/hooks/common/useNotifications.js
- web/src/hooks/common/useNavigation.js
2025-08-18 03:20:56 +08:00
|
|
|
|
|
2025-05-22 21:42:21 +08:00
|
|
|
|
userDispatch({ type: 'login', payload: data });
|
|
|
|
|
|
setUserData(data);
|
|
|
|
|
|
updateAPI();
|
|
|
|
|
|
showSuccess('登录成功!');
|
|
|
|
|
|
if (username === 'root' && password === '123456') {
|
|
|
|
|
|
Modal.error({
|
|
|
|
|
|
title: '您正在使用默认密码!',
|
|
|
|
|
|
content: '请立刻修改默认密码!',
|
|
|
|
|
|
centered: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
navigate('/console');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
showError(message);
|
2023-09-29 18:07:20 +08:00
|
|
|
|
}
|
2024-03-15 16:05:33 +08:00
|
|
|
|
} else {
|
2025-05-22 21:42:21 +08:00
|
|
|
|
showError('请输入用户名和密码!');
|
2024-03-15 16:05:33 +08:00
|
|
|
|
}
|
2025-05-22 21:42:21 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
showError('登录失败,请重试');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoginLoading(false);
|
2023-04-22 20:39:27 +08:00
|
|
|
|
}
|
2024-03-15 16:05:33 +08:00
|
|
|
|
}
|
2023-04-22 20:39:27 +08:00
|
|
|
|
|
2024-03-15 16:05:33 +08:00
|
|
|
|
// 添加Telegram登录处理函数
|
|
|
|
|
|
const onTelegramLoginClicked = async (response) => {
|
2025-10-09 22:21:56 +08:00
|
|
|
|
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
|
|
|
|
|
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2024-03-23 21:24:39 +08:00
|
|
|
|
const fields = [
|
|
|
|
|
|
'id',
|
|
|
|
|
|
'first_name',
|
|
|
|
|
|
'last_name',
|
|
|
|
|
|
'username',
|
|
|
|
|
|
'photo_url',
|
|
|
|
|
|
'auth_date',
|
|
|
|
|
|
'hash',
|
|
|
|
|
|
'lang',
|
|
|
|
|
|
];
|
2024-03-15 16:05:33 +08:00
|
|
|
|
const params = {};
|
|
|
|
|
|
fields.forEach((field) => {
|
|
|
|
|
|
if (response[field]) {
|
|
|
|
|
|
params[field] = response[field];
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-05-22 21:42:21 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const res = await API.get(`/api/oauth/telegram/login`, { params });
|
|
|
|
|
|
const { success, message, data } = res.data;
|
|
|
|
|
|
if (success) {
|
|
|
|
|
|
userDispatch({ type: 'login', payload: data });
|
|
|
|
|
|
localStorage.setItem('user', JSON.stringify(data));
|
|
|
|
|
|
showSuccess('登录成功!');
|
|
|
|
|
|
setUserData(data);
|
|
|
|
|
|
updateAPI();
|
|
|
|
|
|
navigate('/');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
showError(message);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
showError('登录失败,请重试');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 包装的GitHub登录点击处理
|
|
|
|
|
|
const handleGitHubClick = () => {
|
2025-10-09 22:21:56 +08:00
|
|
|
|
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
|
|
|
|
|
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-11-19 14:34:30 +08:00
|
|
|
|
if (githubButtonDisabled) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-05-22 21:42:21 +08:00
|
|
|
|
setGithubLoading(true);
|
2025-11-19 14:34:30 +08:00
|
|
|
|
setGithubButtonDisabled(true);
|
|
|
|
|
|
setGithubButtonText(t('正在跳转 GitHub...'));
|
|
|
|
|
|
if (githubTimeoutRef.current) {
|
|
|
|
|
|
clearTimeout(githubTimeoutRef.current);
|
|
|
|
|
|
}
|
|
|
|
|
|
githubTimeoutRef.current = setTimeout(() => {
|
|
|
|
|
|
setGithubLoading(false);
|
|
|
|
|
|
setGithubButtonText(t('请求超时,请刷新页面后重新发起 GitHub 登录'));
|
|
|
|
|
|
setGithubButtonDisabled(true);
|
|
|
|
|
|
}, 20000);
|
2025-05-22 21:42:21 +08:00
|
|
|
|
try {
|
2025-12-08 22:32:45 +08:00
|
|
|
|
onGitHubOAuthClicked(status.github_client_id, { shouldLogout: true });
|
2025-05-22 21:42:21 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
// 由于重定向,这里不会执行到,但为了完整性添加
|
|
|
|
|
|
setTimeout(() => setGithubLoading(false), 3000);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-22 18:38:24 +08:00
|
|
|
|
// 包装的Discord登录点击处理
|
|
|
|
|
|
const handleDiscordClick = () => {
|
|
|
|
|
|
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
|
|
|
|
|
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setDiscordLoading(true);
|
|
|
|
|
|
try {
|
2025-12-08 22:32:45 +08:00
|
|
|
|
onDiscordOAuthClicked(status.discord_client_id, { shouldLogout: true });
|
2025-11-22 18:38:24 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
// 由于重定向,这里不会执行到,但为了完整性添加
|
|
|
|
|
|
setTimeout(() => setDiscordLoading(false), 3000);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-05-22 21:42:21 +08:00
|
|
|
|
// 包装的OIDC登录点击处理
|
|
|
|
|
|
const handleOIDCClick = () => {
|
2025-10-09 22:21:56 +08:00
|
|
|
|
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
|
|
|
|
|
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-05-22 21:42:21 +08:00
|
|
|
|
setOidcLoading(true);
|
|
|
|
|
|
try {
|
2025-12-08 22:32:45 +08:00
|
|
|
|
onOIDCClicked(
|
|
|
|
|
|
status.oidc_authorization_endpoint,
|
|
|
|
|
|
status.oidc_client_id,
|
|
|
|
|
|
false,
|
|
|
|
|
|
{ shouldLogout: true },
|
|
|
|
|
|
);
|
2025-05-22 21:42:21 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
// 由于重定向,这里不会执行到,但为了完整性添加
|
|
|
|
|
|
setTimeout(() => setOidcLoading(false), 3000);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 包装的LinuxDO登录点击处理
|
|
|
|
|
|
const handleLinuxDOClick = () => {
|
2025-10-09 22:21:56 +08:00
|
|
|
|
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
|
|
|
|
|
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-05-22 21:42:21 +08:00
|
|
|
|
setLinuxdoLoading(true);
|
|
|
|
|
|
try {
|
2025-12-08 22:32:45 +08:00
|
|
|
|
onLinuxDOOAuthClicked(status.linuxdo_client_id, { shouldLogout: true });
|
2025-05-22 21:42:21 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
// 由于重定向,这里不会执行到,但为了完整性添加
|
|
|
|
|
|
setTimeout(() => setLinuxdoLoading(false), 3000);
|
2024-03-15 16:05:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
2024-03-01 22:33:30 +08:00
|
|
|
|
|
2025-05-22 21:42:21 +08:00
|
|
|
|
// 包装的邮箱登录选项点击处理
|
|
|
|
|
|
const handleEmailLoginClick = () => {
|
|
|
|
|
|
setEmailLoginLoading(true);
|
|
|
|
|
|
setShowEmailLogin(true);
|
|
|
|
|
|
setEmailLoginLoading(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-29 17:45:09 +08:00
|
|
|
|
const handlePasskeyLogin = async () => {
|
2025-10-09 22:21:56 +08:00
|
|
|
|
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
|
|
|
|
|
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-09-29 17:45:09 +08:00
|
|
|
|
if (!passkeySupported) {
|
|
|
|
|
|
showInfo('当前环境无法使用 Passkey 登录');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!window.PublicKeyCredential) {
|
|
|
|
|
|
showInfo('当前浏览器不支持 Passkey');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setPasskeyLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const beginRes = await API.post('/api/user/passkey/login/begin');
|
|
|
|
|
|
const { success, message, data } = beginRes.data;
|
|
|
|
|
|
if (!success) {
|
|
|
|
|
|
showError(message || '无法发起 Passkey 登录');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
✨ 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
|
|
|
|
const publicKeyOptions = prepareCredentialRequestOptions(
|
|
|
|
|
|
data?.options || data?.publicKey || data,
|
|
|
|
|
|
);
|
|
|
|
|
|
const assertion = await navigator.credentials.get({
|
|
|
|
|
|
publicKey: publicKeyOptions,
|
|
|
|
|
|
});
|
2025-09-29 17:45:09 +08:00
|
|
|
|
const payload = buildAssertionResult(assertion);
|
|
|
|
|
|
if (!payload) {
|
|
|
|
|
|
showError('Passkey 验证失败,请重试');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
✨ 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
|
|
|
|
const finishRes = await API.post(
|
|
|
|
|
|
'/api/user/passkey/login/finish',
|
|
|
|
|
|
payload,
|
|
|
|
|
|
);
|
2025-09-29 17:45:09 +08:00
|
|
|
|
const finish = finishRes.data;
|
|
|
|
|
|
if (finish.success) {
|
|
|
|
|
|
userDispatch({ type: 'login', payload: finish.data });
|
|
|
|
|
|
setUserData(finish.data);
|
|
|
|
|
|
updateAPI();
|
|
|
|
|
|
showSuccess('登录成功!');
|
|
|
|
|
|
navigate('/console');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
showError(finish.message || 'Passkey 登录失败,请重试');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (error?.name === 'AbortError') {
|
|
|
|
|
|
showInfo('已取消 Passkey 登录');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
showError('Passkey 登录失败,请重试');
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setPasskeyLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-05-22 21:42:21 +08:00
|
|
|
|
// 包装的重置密码点击处理
|
|
|
|
|
|
const handleResetPasswordClick = () => {
|
|
|
|
|
|
setResetPasswordLoading(true);
|
|
|
|
|
|
navigate('/reset');
|
|
|
|
|
|
setResetPasswordLoading(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 包装的其他登录选项点击处理
|
|
|
|
|
|
const handleOtherLoginOptionsClick = () => {
|
|
|
|
|
|
setOtherLoginOptionsLoading(true);
|
|
|
|
|
|
setShowEmailLogin(false);
|
|
|
|
|
|
setOtherLoginOptionsLoading(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-08-02 14:53:28 +08:00
|
|
|
|
// 2FA验证成功处理
|
|
|
|
|
|
const handle2FASuccess = (data) => {
|
|
|
|
|
|
userDispatch({ type: 'login', payload: data });
|
|
|
|
|
|
setUserData(data);
|
|
|
|
|
|
updateAPI();
|
|
|
|
|
|
showSuccess('登录成功!');
|
|
|
|
|
|
navigate('/console');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 返回登录页面
|
|
|
|
|
|
const handleBackToLogin = () => {
|
|
|
|
|
|
setShowTwoFA(false);
|
|
|
|
|
|
setInputs({ username: '', password: '', wechat_verification_code: '' });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-05-20 04:43:11 +08:00
|
|
|
|
const renderOAuthOptions = () => {
|
|
|
|
|
|
return (
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex flex-col items-center'>
|
|
|
|
|
|
<div className='w-full max-w-md'>
|
|
|
|
|
|
<div className='flex items-center justify-center mb-6 gap-2'>
|
|
|
|
|
|
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
|
|
|
|
|
<Title heading={3} className='!text-gray-800'>
|
|
|
|
|
|
{systemName}
|
|
|
|
|
|
</Title>
|
2025-05-20 04:43:11 +08:00
|
|
|
|
</div>
|
2023-11-07 23:32:43 +08:00
|
|
|
|
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
|
|
|
|
|
<div className='flex justify-center pt-6 pb-2'>
|
|
|
|
|
|
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
|
|
|
|
|
{t('登 录')}
|
|
|
|
|
|
</Title>
|
2025-05-20 04:43:11 +08:00
|
|
|
|
</div>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='px-2 py-8'>
|
|
|
|
|
|
<div className='space-y-3'>
|
2025-05-20 04:43:11 +08:00
|
|
|
|
{status.wechat_login && (
|
2024-03-23 21:24:39 +08:00
|
|
|
|
<Button
|
2025-05-20 04:43:11 +08:00
|
|
|
|
theme='outline'
|
2025-08-30 21:15:10 +08:00
|
|
|
|
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={
|
|
|
|
|
|
<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
|
|
|
|
|
|
}
|
2025-05-20 04:43:11 +08:00
|
|
|
|
onClick={onWeChatLoginClicked}
|
2025-05-22 21:42:21 +08:00
|
|
|
|
loading={wechatLoading}
|
2024-03-23 21:24:39 +08:00
|
|
|
|
>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<span className='ml-3'>{t('使用 微信 继续')}</span>
|
2024-03-15 16:05:33 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2025-05-20 04:43:11 +08:00
|
|
|
|
|
|
|
|
|
|
{status.github_oauth && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
theme='outline'
|
2025-08-30 21:15:10 +08:00
|
|
|
|
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={<IconGithubLogo size='large' />}
|
2025-05-22 21:42:21 +08:00
|
|
|
|
onClick={handleGitHubClick}
|
|
|
|
|
|
loading={githubLoading}
|
2025-11-19 14:34:30 +08:00
|
|
|
|
disabled={githubButtonDisabled}
|
2024-03-23 21:24:39 +08:00
|
|
|
|
>
|
2025-11-19 14:34:30 +08:00
|
|
|
|
<span className='ml-3'>{githubButtonText}</span>
|
2025-05-20 04:43:11 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-11-22 18:38:24 +08:00
|
|
|
|
{status.discord_oauth && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
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={<SiDiscord style={{ color: '#5865F2', width: '20px', height: '20px' }} />}
|
|
|
|
|
|
onClick={handleDiscordClick}
|
|
|
|
|
|
loading={discordLoading}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className='ml-3'>{t('使用 Discord 继续')}</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-05-20 04:43:11 +08:00
|
|
|
|
{status.oidc_enabled && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
theme='outline'
|
2025-08-30 21:15:10 +08:00
|
|
|
|
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
|
|
|
|
|
type='tertiary'
|
2025-05-20 10:38:31 +08:00
|
|
|
|
icon={<OIDCIcon style={{ color: '#1877F2' }} />}
|
2025-05-22 21:42:21 +08:00
|
|
|
|
onClick={handleOIDCClick}
|
|
|
|
|
|
loading={oidcLoading}
|
2025-05-20 04:43:11 +08:00
|
|
|
|
>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<span className='ml-3'>{t('使用 OIDC 继续')}</span>
|
2025-05-20 04:43:11 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{status.linuxdo_oauth && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
theme='outline'
|
2025-08-30 21:15:10 +08:00
|
|
|
|
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={
|
|
|
|
|
|
<LinuxDoIcon
|
|
|
|
|
|
style={{
|
|
|
|
|
|
color: '#E95420',
|
|
|
|
|
|
width: '20px',
|
|
|
|
|
|
height: '20px',
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
2025-05-22 21:42:21 +08:00
|
|
|
|
onClick={handleLinuxDOClick}
|
|
|
|
|
|
loading={linuxdoLoading}
|
2025-05-20 04:43:11 +08:00
|
|
|
|
>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
|
2025-05-20 04:43:11 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{status.telegram_oauth && (
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex justify-center my-2'>
|
2025-05-20 04:43:11 +08:00
|
|
|
|
<TelegramLoginButton
|
|
|
|
|
|
dataOnauth={onTelegramLoginClicked}
|
|
|
|
|
|
botName={status.telegram_bot_name}
|
2024-03-15 16:05:33 +08:00
|
|
|
|
/>
|
2025-05-20 04:43:11 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-09-29 17:45:09 +08:00
|
|
|
|
{status.passkey_login && passkeySupported && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
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={<IconKey size='large' />}
|
|
|
|
|
|
onClick={handlePasskeyLogin}
|
|
|
|
|
|
loading={passkeyLoading}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-05-20 04:43:11 +08:00
|
|
|
|
<Divider margin='12px' align='center'>
|
|
|
|
|
|
{t('或')}
|
|
|
|
|
|
</Divider>
|
|
|
|
|
|
|
|
|
|
|
|
<Button
|
2025-08-30 21:15:10 +08:00
|
|
|
|
theme='solid'
|
|
|
|
|
|
type='primary'
|
|
|
|
|
|
className='w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors'
|
|
|
|
|
|
icon={<IconMail size='large' />}
|
2025-05-22 21:42:21 +08:00
|
|
|
|
onClick={handleEmailLoginClick}
|
|
|
|
|
|
loading={emailLoginLoading}
|
2024-03-23 21:24:39 +08:00
|
|
|
|
>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<span className='ml-3'>{t('使用 邮箱或用户名 登录')}</span>
|
2025-05-20 04:43:11 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-10-09 22:21:56 +08:00
|
|
|
|
{(hasUserAgreement || hasPrivacyPolicy) && (
|
|
|
|
|
|
<div className='mt-6'>
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
checked={agreedToTerms}
|
|
|
|
|
|
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Text size='small' className='text-gray-600'>
|
|
|
|
|
|
{t('我已阅读并同意')}
|
|
|
|
|
|
{hasUserAgreement && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<a
|
|
|
|
|
|
href='/user-agreement'
|
|
|
|
|
|
target='_blank'
|
|
|
|
|
|
rel='noopener noreferrer'
|
|
|
|
|
|
className='text-blue-600 hover:text-blue-800 mx-1'
|
|
|
|
|
|
>
|
|
|
|
|
|
{t('用户协议')}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{hasUserAgreement && hasPrivacyPolicy && t('和')}
|
|
|
|
|
|
{hasPrivacyPolicy && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<a
|
|
|
|
|
|
href='/privacy-policy'
|
|
|
|
|
|
target='_blank'
|
|
|
|
|
|
rel='noopener noreferrer'
|
|
|
|
|
|
className='text-blue-600 hover:text-blue-800 mx-1'
|
|
|
|
|
|
>
|
|
|
|
|
|
{t('隐私政策')}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</Checkbox>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-06-26 04:29:44 +08:00
|
|
|
|
{!status.self_use_mode_enabled && (
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='mt-6 text-center text-sm'>
|
2025-06-26 04:29:44 +08:00
|
|
|
|
<Text>
|
|
|
|
|
|
{t('没有账户?')}{' '}
|
|
|
|
|
|
<Link
|
2025-08-30 21:15:10 +08:00
|
|
|
|
to='/register'
|
|
|
|
|
|
className='text-blue-600 hover:text-blue-800 font-medium'
|
2025-06-26 04:29:44 +08:00
|
|
|
|
>
|
|
|
|
|
|
{t('注册')}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-05-20 04:43:11 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const renderEmailLoginForm = () => {
|
|
|
|
|
|
return (
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex flex-col items-center'>
|
|
|
|
|
|
<div className='w-full max-w-md'>
|
|
|
|
|
|
<div className='flex items-center justify-center mb-6 gap-2'>
|
|
|
|
|
|
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
2025-05-20 04:43:11 +08:00
|
|
|
|
<Title heading={3}>{systemName}</Title>
|
2024-03-15 16:05:33 +08:00
|
|
|
|
</div>
|
2025-05-20 04:43:11 +08:00
|
|
|
|
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
|
|
|
|
|
<div className='flex justify-center pt-6 pb-2'>
|
|
|
|
|
|
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
|
|
|
|
|
{t('登 录')}
|
|
|
|
|
|
</Title>
|
2025-05-20 04:43:11 +08:00
|
|
|
|
</div>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='px-2 py-8'>
|
2025-09-29 17:45:09 +08:00
|
|
|
|
{status.passkey_login && passkeySupported && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
theme='outline'
|
|
|
|
|
|
type='tertiary'
|
|
|
|
|
|
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors mb-4'
|
|
|
|
|
|
icon={<IconKey size='large' />}
|
|
|
|
|
|
onClick={handlePasskeyLogin}
|
|
|
|
|
|
loading={passkeyLoading}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<Form className='space-y-3'>
|
2025-05-20 04:43:11 +08:00
|
|
|
|
<Form.Input
|
2025-08-30 21:15:10 +08:00
|
|
|
|
field='username'
|
2025-06-04 08:46:21 +08:00
|
|
|
|
label={t('用户名或邮箱')}
|
|
|
|
|
|
placeholder={t('请输入您的用户名或邮箱地址')}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
name='username'
|
2025-05-20 04:43:11 +08:00
|
|
|
|
onChange={(value) => handleChange('username', value)}
|
2025-05-20 11:02:20 +08:00
|
|
|
|
prefix={<IconMail />}
|
2025-05-20 04:43:11 +08:00
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<Form.Input
|
2025-08-30 21:15:10 +08:00
|
|
|
|
field='password'
|
2025-05-20 04:43:11 +08:00
|
|
|
|
label={t('密码')}
|
|
|
|
|
|
placeholder={t('请输入您的密码')}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
name='password'
|
|
|
|
|
|
mode='password'
|
2025-05-20 04:43:11 +08:00
|
|
|
|
onChange={(value) => handleChange('password', value)}
|
2025-05-20 11:02:20 +08:00
|
|
|
|
prefix={<IconLock />}
|
2025-05-20 04:43:11 +08:00
|
|
|
|
/>
|
|
|
|
|
|
|
2025-10-09 22:21:56 +08:00
|
|
|
|
{(hasUserAgreement || hasPrivacyPolicy) && (
|
|
|
|
|
|
<div className='pt-4'>
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
checked={agreedToTerms}
|
|
|
|
|
|
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Text size='small' className='text-gray-600'>
|
|
|
|
|
|
{t('我已阅读并同意')}
|
|
|
|
|
|
{hasUserAgreement && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<a
|
|
|
|
|
|
href='/user-agreement'
|
|
|
|
|
|
target='_blank'
|
|
|
|
|
|
rel='noopener noreferrer'
|
|
|
|
|
|
className='text-blue-600 hover:text-blue-800 mx-1'
|
|
|
|
|
|
>
|
|
|
|
|
|
{t('用户协议')}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{hasUserAgreement && hasPrivacyPolicy && t('和')}
|
|
|
|
|
|
{hasPrivacyPolicy && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<a
|
|
|
|
|
|
href='/privacy-policy'
|
|
|
|
|
|
target='_blank'
|
|
|
|
|
|
rel='noopener noreferrer'
|
|
|
|
|
|
className='text-blue-600 hover:text-blue-800 mx-1'
|
|
|
|
|
|
>
|
|
|
|
|
|
{t('隐私政策')}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</Checkbox>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='space-y-2 pt-2'>
|
2025-05-20 04:43:11 +08:00
|
|
|
|
<Button
|
2025-08-30 21:15:10 +08:00
|
|
|
|
theme='solid'
|
|
|
|
|
|
className='w-full !rounded-full'
|
|
|
|
|
|
type='primary'
|
|
|
|
|
|
htmlType='submit'
|
2025-05-20 04:43:11 +08:00
|
|
|
|
onClick={handleSubmit}
|
2025-05-22 21:42:21 +08:00
|
|
|
|
loading={loginLoading}
|
2025-10-09 22:21:56 +08:00
|
|
|
|
disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms}
|
2025-05-20 04:43:11 +08:00
|
|
|
|
>
|
|
|
|
|
|
{t('继续')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
<Button
|
2025-08-30 21:15:10 +08:00
|
|
|
|
theme='borderless'
|
2025-05-20 04:43:11 +08:00
|
|
|
|
type='tertiary'
|
2025-08-30 21:15:10 +08:00
|
|
|
|
className='w-full !rounded-full'
|
2025-05-22 21:42:21 +08:00
|
|
|
|
onClick={handleResetPasswordClick}
|
|
|
|
|
|
loading={resetPasswordLoading}
|
2025-05-20 04:43:11 +08:00
|
|
|
|
>
|
|
|
|
|
|
{t('忘记密码?')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
|
2025-08-30 21:15:10 +08:00
|
|
|
|
{(status.github_oauth ||
|
2025-11-22 18:38:24 +08:00
|
|
|
|
status.discord_oauth ||
|
2025-08-30 21:15:10 +08:00
|
|
|
|
status.oidc_enabled ||
|
|
|
|
|
|
status.wechat_login ||
|
|
|
|
|
|
status.linuxdo_oauth ||
|
|
|
|
|
|
status.telegram_oauth) && (
|
2025-06-04 08:34:52 +08:00
|
|
|
|
<>
|
|
|
|
|
|
<Divider margin='12px' align='center'>
|
|
|
|
|
|
{t('或')}
|
|
|
|
|
|
</Divider>
|
|
|
|
|
|
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='mt-4 text-center'>
|
2025-06-04 08:34:52 +08:00
|
|
|
|
<Button
|
2025-08-30 21:15:10 +08:00
|
|
|
|
theme='outline'
|
|
|
|
|
|
type='tertiary'
|
|
|
|
|
|
className='w-full !rounded-full'
|
2025-06-04 08:34:52 +08:00
|
|
|
|
onClick={handleOtherLoginOptionsClick}
|
|
|
|
|
|
loading={otherLoginOptionsLoading}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t('其他登录选项')}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2025-05-20 10:38:31 +08:00
|
|
|
|
|
2025-06-26 04:29:44 +08:00
|
|
|
|
{!status.self_use_mode_enabled && (
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='mt-6 text-center text-sm'>
|
2025-06-26 04:29:44 +08:00
|
|
|
|
<Text>
|
|
|
|
|
|
{t('没有账户?')}{' '}
|
|
|
|
|
|
<Link
|
2025-08-30 21:15:10 +08:00
|
|
|
|
to='/register'
|
|
|
|
|
|
className='text-blue-600 hover:text-blue-800 font-medium'
|
2025-06-26 04:29:44 +08:00
|
|
|
|
>
|
|
|
|
|
|
{t('注册')}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-05-20 04:43:11 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 微信登录模态框
|
|
|
|
|
|
const renderWeChatLoginModal = () => {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Modal
|
|
|
|
|
|
title={t('微信扫码登录')}
|
|
|
|
|
|
visible={showWeChatLoginModal}
|
|
|
|
|
|
maskClosable={true}
|
|
|
|
|
|
onOk={onSubmitWeChatVerificationCode}
|
|
|
|
|
|
onCancel={() => setShowWeChatLoginModal(false)}
|
|
|
|
|
|
okText={t('登录')}
|
|
|
|
|
|
centered={true}
|
2025-05-22 21:42:21 +08:00
|
|
|
|
okButtonProps={{
|
|
|
|
|
|
loading: wechatCodeSubmitLoading,
|
|
|
|
|
|
}}
|
2025-05-20 04:43:11 +08:00
|
|
|
|
>
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex flex-col items-center'>
|
|
|
|
|
|
<img src={status.wechat_qrcode} alt='微信二维码' className='mb-4' />
|
2025-05-20 04:43:11 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='text-center mb-4'>
|
|
|
|
|
|
<p>
|
|
|
|
|
|
{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
|
|
|
|
|
|
</p>
|
2025-05-20 04:43:11 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-08-18 03:39:17 +08:00
|
|
|
|
<Form>
|
2025-05-20 04:43:11 +08:00
|
|
|
|
<Form.Input
|
2025-08-30 21:15:10 +08:00
|
|
|
|
field='wechat_verification_code'
|
2025-05-20 04:43:11 +08:00
|
|
|
|
placeholder={t('验证码')}
|
|
|
|
|
|
label={t('验证码')}
|
|
|
|
|
|
value={inputs.wechat_verification_code}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
onChange={(value) =>
|
|
|
|
|
|
handleChange('wechat_verification_code', value)
|
|
|
|
|
|
}
|
2025-05-20 04:43:11 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-08-02 14:53:28 +08:00
|
|
|
|
// 2FA验证弹窗
|
|
|
|
|
|
const render2FAModal = () => {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Modal
|
|
|
|
|
|
title={
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex items-center'>
|
|
|
|
|
|
<div className='w-8 h-8 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mr-3'>
|
|
|
|
|
|
<svg
|
|
|
|
|
|
className='w-4 h-4 text-green-600 dark:text-green-400'
|
|
|
|
|
|
fill='currentColor'
|
|
|
|
|
|
viewBox='0 0 20 20'
|
|
|
|
|
|
>
|
|
|
|
|
|
<path
|
|
|
|
|
|
fillRule='evenodd'
|
|
|
|
|
|
d='M6 8a2 2 0 11-4 0 2 2 0 014 0zM8 7a1 1 0 100 2h8a1 1 0 100-2H8zM6 14a2 2 0 11-4 0 2 2 0 014 0zM8 13a1 1 0 100 2h8a1 1 0 100-2H8z'
|
|
|
|
|
|
clipRule='evenodd'
|
|
|
|
|
|
/>
|
2025-08-02 14:53:28 +08:00
|
|
|
|
</svg>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
两步验证
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
visible={showTwoFA}
|
|
|
|
|
|
onCancel={handleBackToLogin}
|
|
|
|
|
|
footer={null}
|
|
|
|
|
|
width={450}
|
|
|
|
|
|
centered
|
|
|
|
|
|
>
|
🎛️ refactor: HeaderBar into modular components, add shared skeletons, and primary-colored nav hover
Summary
- Split HeaderBar into maintainable components and hooks
- Centralized skeleton loading UI via a reusable SkeletonWrapper
- Improved navigation UX with primary-colored hover indication
- Preserved API surface and passed linters
Why
- Improve readability, reusability, and testability of the header
- Remove duplicated skeleton logic across files
- Provide clearer hover feedback consistent with the theme
What’s changed
- Components (web/src/components/layout/HeaderBar/)
- New container: index.js
- New UI components: HeaderLogo.js, Navigation.js, ActionButtons.js, UserArea.js, MobileMenuButton.js, NewYearButton.js, NotificationButton.js, ThemeToggle.js, LanguageSelector.js
- New shared skeleton: SkeletonWrapper.js
- Updated entry: HeaderBar.js now re-exports ./HeaderBar/index.js
- Hooks (web/src/hooks/common/)
- New: useHeaderBar.js (state and actions for header)
- New: useNotifications.js (announcements state, unread calc, open/close)
- New: useNavigation.js (main nav link config)
- Skeleton refactor
- Navigation.js: replaced inline skeletons with <SkeletonWrapper type="navigation" .../>
- UserArea.js: replaced inline skeletons with <SkeletonWrapper type="userArea" .../>
- HeaderLogo.js: replaced image/title skeletons with <SkeletonWrapper type="image"/>, <SkeletonWrapper type="title"/>
- Navigation hover UX
- Added primary-colored hover to nav items for clearer pointer feedback
- Final hover style: hover:text-semi-color-primary (kept rounded + transition classes)
Non-functional
- No breaking API changes; HeaderBar usage stays the same
- All modified files pass lint checks
Notes for future work
- SkeletonWrapper is extensible: add new cases (e.g., card) in one place
- Components are small and test-friendly; unit tests can be added per component
Affected files (key)
- web/src/components/layout/HeaderBar.js
- web/src/components/layout/HeaderBar/index.js
- web/src/components/layout/HeaderBar/Navigation.js
- web/src/components/layout/HeaderBar/UserArea.js
- web/src/components/layout/HeaderBar/HeaderLogo.js
- web/src/components/layout/HeaderBar/ActionButtons.js
- web/src/components/layout/HeaderBar/MobileMenuButton.js
- web/src/components/layout/HeaderBar/NewYearButton.js
- web/src/components/layout/HeaderBar/NotificationButton.js
- web/src/components/layout/HeaderBar/ThemeToggle.js
- web/src/components/layout/HeaderBar/LanguageSelector.js
- web/src/components/layout/HeaderBar/SkeletonWrapper.js
- web/src/hooks/common/useHeaderBar.js
- web/src/hooks/common/useNotifications.js
- web/src/hooks/common/useNavigation.js
2025-08-18 03:20:56 +08:00
|
|
|
|
<TwoFAVerification
|
2025-08-02 14:53:28 +08:00
|
|
|
|
onSuccess={handle2FASuccess}
|
|
|
|
|
|
onBack={handleBackToLogin}
|
|
|
|
|
|
isModal={true}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-05-20 04:43:11 +08:00
|
|
|
|
return (
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
2025-06-25 22:57:04 +08:00
|
|
|
|
{/* 背景模糊晕染球 */}
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div
|
|
|
|
|
|
className='blur-ball blur-ball-indigo'
|
|
|
|
|
|
style={{ top: '-80px', right: '-80px', transform: 'none' }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className='blur-ball blur-ball-teal'
|
|
|
|
|
|
style={{ top: '50%', left: '-120px' }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className='w-full max-w-sm mt-[60px]'>
|
|
|
|
|
|
{showEmailLogin ||
|
|
|
|
|
|
!(
|
|
|
|
|
|
status.github_oauth ||
|
2025-11-22 18:38:24 +08:00
|
|
|
|
status.discord_oauth ||
|
2025-08-30 21:15:10 +08:00
|
|
|
|
status.oidc_enabled ||
|
|
|
|
|
|
status.wechat_login ||
|
|
|
|
|
|
status.linuxdo_oauth ||
|
|
|
|
|
|
status.telegram_oauth
|
|
|
|
|
|
)
|
2025-05-20 04:43:11 +08:00
|
|
|
|
? renderEmailLoginForm()
|
|
|
|
|
|
: renderOAuthOptions()}
|
|
|
|
|
|
{renderWeChatLoginModal()}
|
2025-08-02 14:53:28 +08:00
|
|
|
|
{render2FAModal()}
|
2025-06-05 11:19:00 +08:00
|
|
|
|
|
|
|
|
|
|
{turnstileEnabled && (
|
2025-08-30 21:15:10 +08:00
|
|
|
|
<div className='flex justify-center mt-6'>
|
2025-06-05 11:19:00 +08:00
|
|
|
|
<Turnstile
|
|
|
|
|
|
sitekey={turnstileSiteKey}
|
|
|
|
|
|
onVerify={(token) => {
|
|
|
|
|
|
setTurnstileToken(token);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-05-20 04:43:11 +08:00
|
|
|
|
</div>
|
2024-03-15 16:05:33 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2023-04-22 20:39:27 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default LoginForm;
|