feat: implement account binding functionality for GitHub and LinuxDo

This commit is contained in:
shiyu
2025-06-01 15:05:26 +08:00
parent c458f3c6f7
commit d76bf5b751
17 changed files with 670 additions and 64 deletions

View File

@@ -10,6 +10,7 @@ import { getMainRoutes, getAdminRoutes } from './routes';
import { AuthProvider } from './auth/AuthContext';
import AnonymousPage from './pages/anonymous/Index';
import AdminLayout from './layouts/AdminLayout';
import Bind from './pages/bind/Index';
const PrivateRoute = ({ children }: { children: JSX.Element }) => {
return isAuthenticated() ? children : <Navigate to="/login" />;
@@ -56,8 +57,8 @@ function App() {
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/bind" element={<Bind />} />
<Route path="/anonymous" element={<AnonymousPage />} />
<Route path="/" element={
<PrivateRoute>
<MainLayout />

View File

@@ -1,4 +1,4 @@
import {type BaseResult, type AuthResponse, type LoginRequest, type RegisterRequest, type UserProfile, type UpdateUserRequest} from './types';
import {type BaseResult, type AuthResponse, type LoginRequest, type RegisterRequest, type UserProfile, type UpdateUserRequest, type BindAccountRequest} from './types';
import {fetchApi, BASE_URL} from './fetchClient';
// 认证数据本地存储键
@@ -89,6 +89,22 @@ export async function updateUserInfo(data: UpdateUserRequest): Promise<BaseResul
}
}
// 绑定账户
export async function bindAccount(data: BindAccountRequest): Promise<BaseResult<AuthResponse>> {
const response = await fetchApi<AuthResponse>('/auth/bind', {
method: 'POST',
body: JSON.stringify(data),
});
if (response.success && response.data) {
clearAuthData(); // 清除旧的认证数据
console.log('绑定成功,保存认证数据:', response.data);
saveAuthData(response.data); // 保存新的认证数据
}
return response;
}
// 保存认证数据到本地存储
export const saveAuthData = (authData: AuthResponse): void => {
localStorage.setItem(TOKEN_KEY, authData.token);
@@ -150,7 +166,6 @@ export async function handleOAuthCallback(): Promise<boolean> {
saveAuthData(authResponse);
// 清除URL中的token参数
const url = new URL(window.location.href);
url.searchParams.delete('token');
window.history.replaceState({}, document.title, url.toString());
@@ -160,7 +175,7 @@ export async function handleOAuthCallback(): Promise<boolean> {
return false;
} catch (error) {
console.error('第三方登录处理失败:', error);
clearAuthData(); // 清除可能部分保存的数据
clearAuthData();
return false;
}
}
@@ -170,4 +185,8 @@ export async function handleOAuthCallback(): Promise<boolean> {
export function getGitHubLoginUrl(): string {
return `${BASE_URL}/auth/github/login`;
}
export function getLinuxDoLoginUrl(): string {
return `${BASE_URL}/auth/linuxdo/login`;
}

View File

@@ -14,7 +14,10 @@ export {
saveAuthData,
clearAuthData,
isAuthenticated,
getStoredUser
getStoredUser,
bindAccount,
getGitHubLoginUrl,
getLinuxDoLoginUrl
} from './authApi';
// 导出Picture API

View File

@@ -271,3 +271,17 @@ export const VectorDbType = {
export interface VectorDbInfo {
type: string;
}
export type BindType = 0 | 1;
export const BindType = {
GitHub: 0 as BindType,
LinuxDo: 1 as BindType,
};
export interface BindAccountRequest {
email: string;
password: string;
bindType: BindType;
thirdPartyUserId: string;
}

View File

@@ -251,6 +251,29 @@ const ConfigTabs: React.FC<ConfigTabsProps> = ({
</Form>
</ConfigSection>
</TabPane>
<TabPane tab="LinuxDo认证" key="linuxdo">
<ConfigSection
title="LinuxDo OAuth 配置"
icon={<GlobalOutlined />}
description="LinuxDo OAuth 应用配置,用于实现第三方登录功能"
isMobile={isMobile}
>
<Form form={formsMap.Authentication} layout="vertical" size={isMobile ? "middle" : "large"}>
{renderConfigFormItems(formsMap.Authentication, "Authentication", ["LinuxDoClientId", "LinuxDoClientSecret", "LinuxDoCallbackUrl"])}
<Divider style={{ margin: '12px 0 20px' }} />
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={() => onSaveAllForGroup(formsMap.Authentication, "Authentication", ["LinuxDoClientId", "LinuxDoClientSecret", "LinuxDoCallbackUrl"])}
style={{ width: isMobile ? '100%' : '240px' }}
>
LinuxDo
</Button>
</Form.Item>
</Form>
</ConfigSection>
</TabPane>
</Tabs>
)
},

View File

@@ -37,7 +37,10 @@ const allDescriptions: Record<string, Record<string, string>> = {
Authentication: {
GitHubClientId: 'GitHub OAuth 应用客户端ID',
GitHubClientSecret: 'GitHub OAuth 应用客户端密钥',
GitHubCallbackUrl: 'GitHub OAuth 认证回调地址'
GitHubCallbackUrl: 'GitHub OAuth 认证回调地址',
LinuxDoClientId: 'LinuxDo OAuth 应用客户端ID',
LinuxDoClientSecret: 'LinuxDo OAuth 应用客户端密钥',
LinuxDoCallbackUrl: 'LinuxDo OAuth 认证回调地址'
},
AppSettings: {
ServerUrl: '服务器URL'

View File

@@ -0,0 +1,214 @@
import React, { useState, useEffect } from 'react';
import { Form, Input, Button, Typography, Row, Col, Card, message, Alert } from 'antd';
import { UserOutlined, LockOutlined, GithubOutlined, LinkOutlined } from '@ant-design/icons';
import { useNavigate, useSearchParams } from 'react-router';
import { bindAccount, BindType } from '../../api';
import useIsMobile from '../../hooks/useIsMobile';
const { Title, Text } = Typography;
const Bind: React.FC = () => {
const [loading, setLoading] = useState(false);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const isMobile = useIsMobile();
const githubId = searchParams.get('githubId');
const linuxdoId = searchParams.get('linuxdoId');
const thirdPartyUserId = githubId || linuxdoId;
const bindType = githubId ? BindType.GitHub : BindType.LinuxDo;
useEffect(() => {
// 检查是否有必要的参数
if (!thirdPartyUserId) {
message.error('缺少必要的绑定参数');
navigate('/login');
}
}, [thirdPartyUserId, navigate]);
const onFinish = async (values: any) => {
if (!thirdPartyUserId) {
message.error('缺少第三方用户ID');
return;
}
setLoading(true);
try {
const response = await bindAccount({
email: values.email,
password: values.password,
bindType: bindType,
thirdPartyUserId: thirdPartyUserId
});
if (response.success && response.data) {
message.success(response.message || '账户绑定成功!');
navigate('/');
} else {
message.error(response.message || '绑定失败,请检查邮箱和密码');
}
} catch (error) {
console.error('绑定出错:', error);
message.error('绑定过程中出现错误,请稍后重试');
} finally {
setLoading(false);
}
};
const getBindTypeIcon = () => {
switch (bindType) {
case BindType.GitHub:
return <GithubOutlined style={{ fontSize: '24px', color: '#24292e' }} />;
case BindType.LinuxDo:
return <img src="/images/linuxdo.svg" alt="LinuxDo" style={{ width: '32px', height: '32px' }} />;
default:
return <LinkOutlined style={{ fontSize: '24px' }} />;
}
};
const getBindTypeText = () => {
switch (bindType) {
case BindType.GitHub:
return 'GitHub';
case BindType.LinuxDo:
return 'LinuxDo';
default:
return '第三方';
}
};
if (!thirdPartyUserId) {
return null;
}
return (
<Row style={{ minHeight: '100vh', backgroundColor: '#f5f5f5', padding: isMobile ? '20px' : '40px' }}>
<Col
xs={24}
sm={20}
md={16}
lg={12}
xl={8}
style={{
margin: '0 auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Card
style={{
width: '100%',
maxWidth: '500px',
borderRadius: '12px',
boxShadow: '0 4px 16px rgba(0,0,0,0.1)',
border: 'none'
}}
bodyStyle={{ padding: isMobile ? '24px' : '40px' }}
>
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
<div style={{ marginBottom: '16px' }}>
{getBindTypeIcon()}
</div>
<Title level={2} style={{
marginBottom: '8px',
fontWeight: 700,
color: '#18181b'
}}>
{getBindTypeText()}
</Title>
<Text style={{ fontSize: '16px', color: '#666' }}>
Foxel账户信息来绑定{getBindTypeText()}
</Text>
</div>
<Alert
message="账户绑定说明"
description={
<div>
<p> Foxel账户</p>
<p> Foxel账户</p>
<p> 使{getBindTypeText()}</p>
</div>
}
type="info"
showIcon
style={{ marginBottom: '24px' }}
/>
<Form
name="bind_form"
onFinish={onFinish}
size="large"
layout="vertical"
>
<Form.Item
label="邮箱"
name="email"
rules={[
{ required: true, message: '请输入您的邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' }
]}
>
<Input
prefix={<UserOutlined style={{ color: '#bfbfbf' }} />}
placeholder="请输入邮箱地址"
style={{
height: '48px',
borderRadius: '8px'
}}
/>
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[
{ required: true, message: '请输入您的密码' },
{ min: 6, message: '密码长度不能少于6位' }
]}
>
<Input.Password
prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
placeholder="请输入密码6位以上"
style={{
height: '48px',
borderRadius: '8px'
}}
/>
</Form.Item>
<Form.Item style={{ marginBottom: '16px' }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
style={{
width: '100%',
height: '48px',
borderRadius: '8px',
fontWeight: 500,
fontSize: '16px'
}}
>
{loading ? '绑定中...' : `绑定${getBindTypeText()}账户`}
</Button>
</Form.Item>
<div style={{ textAlign: 'center' }}>
<Button
type="link"
onClick={() => navigate('/login')}
style={{ padding: '0', color: '#666' }}
>
</Button>
</div>
</Form>
</Card>
</Col>
</Row>
);
};
export default Bind;

View File

@@ -1,8 +1,8 @@
import React, {useState, useEffect} from 'react';
import {Form, Input, Button, Checkbox, Typography, Row, Col, Divider, message} from 'antd';
import {UserOutlined, LockOutlined, GithubOutlined, GoogleOutlined} from '@ant-design/icons';
import {UserOutlined, LockOutlined, GithubOutlined} from '@ant-design/icons';
import {useNavigate, Link} from 'react-router';
import {login, saveAuthData, isAuthenticated, handleOAuthCallback, getGitHubLoginUrl} from '../../api';
import {login, saveAuthData, isAuthenticated, handleOAuthCallback, getGitHubLoginUrl, getLinuxDoLoginUrl} from '../../api';
import useIsMobile from '../../hooks/useIsMobile';
const {Title, Text} = Typography;
@@ -67,6 +67,10 @@ const Login: React.FC = () => {
window.location.href = getGitHubLoginUrl();
};
const handleLinuxDoLogin = () => {
window.location.href = getLinuxDoLoginUrl();
};
return (
<Row style={{height: '100vh', overflow: 'hidden'}}>
{/* 左侧登录表单 */}
@@ -184,9 +188,10 @@ const Login: React.FC = () => {
}}
/>
<Button
icon={<GoogleOutlined/>}
icon={<img src="/images/linuxdo.svg" alt="LinuxDo" style={{width: '20px', height: '20px'}} />}
size="large"
shape="circle"
onClick={handleLinuxDoLogin}
style={{
backgroundColor: '#f6f6f6',
border: 'none',

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Form, Input, Button, Checkbox, Typography, Row, Col, Divider, message } from 'antd';
import { UserOutlined, LockOutlined, MailOutlined, GithubOutlined, GoogleOutlined } from '@ant-design/icons';
import { UserOutlined, LockOutlined, MailOutlined, GithubOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router';
import { register, saveAuthData, isAuthenticated, handleOAuthCallback, getGitHubLoginUrl } from '../../api';
import { register, saveAuthData, isAuthenticated, handleOAuthCallback, getGitHubLoginUrl, getLinuxDoLoginUrl } from '../../api';
import useIsMobile from '../../hooks/useIsMobile';
const { Title, Text } = Typography;
@@ -68,6 +68,10 @@ const Register: React.FC = () => {
window.location.href = getGitHubLoginUrl();
};
const handleLinuxDoLogin = () => {
window.location.href = getLinuxDoLoginUrl();
};
return (
<Row style={{ height: '100vh', overflow: 'hidden' }}>
{/* 左侧注册表单 */}
@@ -245,9 +249,10 @@ const Register: React.FC = () => {
}}
/>
<Button
icon={<GoogleOutlined />}
icon={<img src="/images/linuxdo.svg" alt="LinuxDo" style={{width: '20px', height: '20px'}} />}
size="large"
shape="circle"
onClick={handleLinuxDoLogin}
style={{
backgroundColor: '#f6f6f6',
border: 'none',