登录框架搭建完成

This commit is contained in:
mmc
2026-04-21 14:57:52 +08:00
parent 79f8252b08
commit 262ef5e24f
16 changed files with 278 additions and 161 deletions

7
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"js-cookie": "^3.0.5",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"nprogress": "^0.2.0",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -4094,6 +4095,12 @@
"integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
"license": "MIT"
},
"node_modules/nprogress": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz",
"integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==",
"license": "MIT"
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",

View File

@@ -12,7 +12,6 @@
},
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@google/genai": "^1.29.0",
"@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
@@ -24,6 +23,7 @@
"js-cookie": "^3.0.5",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"nprogress": "^0.2.0",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -3,5 +3,6 @@ import { RouterProvider } from 'react-router-dom';
import { router } from './router';
export default function App() {
return <RouterProvider router={router} />;
return <RouterProvider router={router} />;
}

View File

@@ -2,9 +2,10 @@ import axios, { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'ax
import envConfig from '../../env';
import Cookies from 'js-cookie';
import { message } from 'antd';
import store from '../store';
import { logout } from '../store/userSlice';
// 🔥 删掉这两行!!!
// import store from '../store';
// import { logout } from '../store/userSlice';
const API_BASE_URL = envConfig.baseURL;
@@ -13,19 +14,15 @@ const api = axios.create({
timeout: 20000,
headers: {
'Content-Type': 'application/json',
},
withCredentials: true,
});
// 请求拦截器:自动添加token
// 请求拦截器
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 从 localStorage 获取 token
const token = localStorage.getItem('token');
if (token && config.headers) {
// 将 token 添加到请求头
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
@@ -38,43 +35,23 @@ api.interceptors.request.use(
// 响应拦截器
api.interceptors.response.use(
(response: AxiosResponse) => {
// 处理成功响应
const { data } = response;
// 如果后端规范定义了 code,可以在这里统一处理
// if (data.code !== 200) {
// message.error(data.message || '请求失败');
// return Promise.reject(new Error(data.message || 'Error'));
// }
return data;
return response.data;
},
(error: AxiosError) => {
// 处理错误响应
if (error.response) {
switch (error.response.status) {
case 401:
// 未授权,清除本地信息并跳转到登录页
localStorage.removeItem('token');
localStorage.removeItem('isAuthenticated');
localStorage.removeItem('user');
store.dispatch(logout());
window.location.href = '/login';
message.error('会话已过期,请重新登录');
break;
case 403:
message.error('拒绝访问:权限不足');
break;
case 404:
message.error('资源不存在');
break;
case 500:
message.error('服务器内部错误');
break;
default:
message.error(error.message || '网络错误');
}
} else {
message.error('连接服务器失败');
async (error: AxiosError) => { // 👈 加 async
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('isAuthenticated');
localStorage.removeItem('user');
// 🔥 🔥 🔥 在这里动态导入!!!
const store = await import('../store');
const { logout } = await import('../store/userSlice');
store.default.dispatch(logout());
window.location.href = '/login';
message.error('会话已过期,请重新登录');
}
return Promise.reject(error);
}
);

View File

@@ -9,7 +9,8 @@ import {
ConfigProvider,
theme,
Breadcrumb,
Badge
Badge,
message
} from 'antd';
import {
DashboardOutlined,
@@ -26,6 +27,8 @@ import {
} from '@ant-design/icons';
import { MOCK_USER } from '../constants';
import logo from '../assets/logo.png';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
const { Header, Sider, Content } = Layout;
export default function AppLayout() {
@@ -33,10 +36,7 @@ export default function AppLayout() {
const [time, setTime] = useState(new Date().toLocaleString());
const navigate = useNavigate();
const location = useLocation();
// 从 localStorage 获取真实用户信息
const userJson = localStorage.getItem('user');
const user = userJson ? JSON.parse(userJson) : MOCK_USER;
const { userInfo } = useSelector((state: RootState) => state.user);
useEffect(() => {
const timer = setInterval(() => setTime(new Date().toLocaleString()), 1000);
@@ -61,7 +61,7 @@ export default function AppLayout() {
{ key: '/serialNumGenerate', icon: <UserOutlined />, label: '序列号生成', roles: ['admin'] },
];
const filteredMenu = menuItems.filter(item => item.roles.includes(user.role));
const filteredMenu = menuItems.filter(item => item.roles.includes(userInfo.role));
const currentPathLabel = menuItems.find(i => i.key === location.pathname)?.label || '概览控制台';
@@ -85,6 +85,7 @@ export default function AppLayout() {
}
}}
>
<Layout style={{ minHeight: '100vh' }}>
<Sider trigger={null} collapsible collapsed={collapsed} width={220} theme="dark">
<div className="h-16 flex items-center justify-center border-b border-white/10 overflow-hidden">
@@ -100,11 +101,7 @@ export default function AppLayout() {
items={filteredMenu}
onClick={({ key }) => navigate(key)}
/>
{!collapsed && (
<div className="absolute bottom-4 left-4 right-4 p-2 bg-white/5 rounded text-[12px] text-gray-400">
当前权限: <span className="text-white font-bold">{user.role === 'admin' ? '系统管理员' : user.role === 'operator' ? '操作员' : '观察员'}</span>
</div>
)}
</Sider>
<Layout>
<Header className="px-6 flex items-center justify-between shadow-sm border-b border-gray-100 h-16 bg-white shrink-0 z-10">
@@ -127,8 +124,7 @@ export default function AppLayout() {
<div className="flex items-center gap-3 pr-6 border-r border-gray-100">
<span className="text-gray-400 font-mono text-[13px]">{time}</span>
<Badge status="success" />
<span className="text-gray-800 font-medium">{user.name}</span>
<Avatar src={user.avatar} size="small" icon={<UserOutlined />} />
<span className="text-gray-800 font-medium">{userInfo.username}</span>
</div>
<Button
type="text"

View File

@@ -1,14 +1,53 @@
import React from 'react';
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { Navigate, Outlet } from 'react-router-dom';
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
import Cookies from 'js-cookie';
const whiteList = ['/login', '/auth-redirect', '/bind', '/register', '/oauthLogin'];
export default function ProtectedRoute() {
const isAuthenticated = localStorage.getItem('isAuthenticated') === 'true';
//const { isAuthenticated } = useSelector(state => state.user);
const { isAuthenticated } = useSelector((state) => state.user);
const location = useLocation();
const navigate = useNavigate();
const { pathname, search } = location;
// 白名单
if (whiteList.includes(pathname)) {
return <Outlet />;
}
// 未登录
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
return <Navigate to={`/login?redirect=${pathname}`} replace />;
}
// 已登录,访问登录页 → 跳首页
if (pathname === '/login') {
const queryParams = new URLSearchParams(search);
const goRemote = queryParams.get('goRemote');
const clear = queryParams.get('clear');
if (goRemote) {
if (clear === '1') {
Cookies.remove('userId');
window.location.href = 'http://ri.satabot.com/#/login?goRemote=1';
return null;
}
const rawUserId = Cookies.get('userId');
if (!rawUserId) {
navigate('/');
} else {
const encoded = btoa(unescape(encodeURIComponent(rawUserId)));
window.location.href = `http://ai.satabot.com/?userId=${encoded}`;
}
return null;
}
return <Navigate to="/" replace />;
}
return <Outlet />;

View File

@@ -21,5 +21,12 @@
@layer base {
body {
@apply bg-bg text-text-main font-sans;
width:100vm;
height: 100vh;
}
#root{
width:100%;
height:100%;
overflow: hidden;
}
}

View File

@@ -1,6 +1,28 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import Cookies from 'js-cookie'
const TokenKey = 'Admin-Token'
const TokenKeyAi = 'ai-Token'
const UserId = 'userId'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return twMerge(clsx(inputs));
}
export function getToken() {
return Cookies.get(TokenKey)
}
export function setToken(token) {
return Cookies.set(TokenKey, token)
}
export function setTokenAi(token) {
return Cookies.set(TokenKeyAi, token, {
domain: "ai.satabot.com",
path: "/"
});
}

View File

@@ -2,13 +2,17 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import { Provider } from 'react-redux';
import { store } from './store';
import { store } from './store/index.ts';
import { App as Appantd } from 'antd';
import './index.css';
createRoot(document.getElementById('root')!).render(
<Provider store={store}>
<StrictMode>
<App />
<Appantd>
<App />
</Appantd>
</StrictMode>,
</Provider>

View File

@@ -8,6 +8,7 @@ import {
Card,
message,
Checkbox,
App,
} from 'antd';
import {
UserOutlined,
@@ -17,47 +18,48 @@ import { motion } from 'motion/react';
import bgcImg from '../assets/bgc2.png';
import logincard from '../assets/logincard.png';
import { useDispatch } from 'react-redux';
import { login } from '../store/userSlice';
import { setUserInfo, userLogin } from '../store/userSlice';
import { useAppDispatch } from '../store';
const { Text, Title } = Typography;
export default function Login() {
const navigate = useNavigate();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const { message, modal, notification } = App.useApp();
// 账号密码登录
const onFinish = (values: any) => {
console.log('Login attempt:', values);
const onFinish = async (values: any) => {
try {
// 👇 调用 dispatch,并用 unwrap() 拿到 return 的数据
const loginResult = await dispatch(userLogin(values)).unwrap();
let role = 'viewer';
let name = '观察员';
// loginResult 就是你 userLogin 里 return 的 res
console.log("登录成功返回的数据:", loginResult);
if (values.username === 'admin') {
role = 'admin';
name = '系统管理员';
} else if (values.username === 'operator') {
role = 'operator';
name = '操作员';
if (loginResult.code == 200) {
//dispatch(setUserInfo(loginResult))
message.success(`登录成功`);
navigate('/');
} else {
message.error("登录失败:" + loginResult.msg);
}
// 登录成功后跳转
} catch (err) {
// 接口失败、密码错误都会进这里
//console.error("登录失败:", err);
//message.error('登录失败,请检查账号密码');
}
const userInfo = {
username: values.username,
name: name,
role: role,
avatar: `https://api.dicebear.com7.x/avataaars/svg?seed=${values.username}`
};
localStorage.setItem('isAuthenticated', 'true');
localStorage.setItem('token', 'mock_token_' + Date.now());
localStorage.setItem('user', JSON.stringify(userInfo));
message.success(`欢迎回来, ${name}`);
dispatch(login({
token: 'mock_token_' + Date.now(),
userInfo: userInfo
}));
navigate('/');
//navigate('/');
};
return (

View File

@@ -1,10 +0,0 @@
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';
export const store = configureStore({
reducer: {
user: userReducer,
},
});
export default store;

18
src/store/index.ts Normal file
View File

@@ -0,0 +1,18 @@
import { configureStore } from '@reduxjs/toolkit';
import { useDispatch } from 'react-redux';
// 👇 保留 import,但是把类型定义放到 store 后面
import userReducer from './userSlice';
export const store = configureStore({
reducer: {
user: userReducer,
},
});
// 🔥 核心:类型必须放在 store 后面!
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch = () => useDispatch<AppDispatch>();
export default store;

13
src/store/types.ts Normal file
View File

@@ -0,0 +1,13 @@
// store/types.ts
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';
// 先创建一个临时 reducer 用于类型推导,不导出
const dummyStore = configureStore({
reducer: {
user: userReducer,
},
});
export type RootState = ReturnType<typeof dummyStore.getState>;
export type AppDispatch = typeof dummyStore.dispatch;

View File

@@ -1,37 +0,0 @@
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
token: localStorage.getItem('token') || null,
userInfo: localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user')) : null,
isAuthenticated: localStorage.getItem('isAuthenticated') === 'true',
};
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
// 登录
login: (state, action) => {
state.token = action.payload.token;
state.userInfo = action.payload.userInfo;
state.isAuthenticated = true;
localStorage.setItem('token', action.payload.token);
localStorage.setItem('user', JSON.stringify(action.payload.userInfo));
localStorage.setItem('isAuthenticated', 'true');
},
// 退出
logout: (state) => {
state.token = null;
state.userInfo = null;
state.isAuthenticated = false;
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('isAuthenticated');
},
},
});
export const { login, logout } = userSlice.actions;
export default userSlice.reducer;

77
src/store/userSlice.ts Normal file
View File

@@ -0,0 +1,77 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { login } from '@/api/login';
import Cookies from 'js-cookie';
import { setToken, setTokenAi } from '@/lib/utils';
export const userLogin = createAsyncThunk(
'user/login',
async (userInfo: any, { rejectWithValue }) => {
try {
const data = {
username: userInfo.username.trim(),
password: userInfo.password,
sourceType: 1
}
const res = await login(data);
localStorage.setItem('username', userInfo.username.trim());
localStorage.setItem('password', userInfo.password);
const searchParams = new URLSearchParams(window.location.search);
const goRemote = searchParams.get('goRemote');
if (goRemote) {
setTokenAi(res.token);
} else {
setToken(res.token);
}
document.cookie = `sharetoken=${res.token}; path=/; domain=.satabot.com; secure`;
localStorage.setItem('token', res.token);
Cookies.set('userId', res.userId);
return res;
} catch (err) {
return rejectWithValue(err);
}
}
);
const initialState = {
token: localStorage.getItem('token') || null,
userInfo: null,
isAuthenticated: !!localStorage.getItem('token'),
};
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
logout: (state) => {
state.token = null;
state.userInfo = null;
state.isAuthenticated = false;
localStorage.clear();
Cookies.remove('userId');
},
setUserInfo: (state, action) => {
state.userInfo = action.payload;
},
},
extraReducers: (builder) => {
builder
.addCase(userLogin.fulfilled, (state, action) => {
state.userInfo = action.payload;
//state.token = action.payload.token;
state.isAuthenticated = true;
})
.addCase(userLogin.rejected, (state, action) => {
console.error('登录失败', action.payload);
});
},
});
export const { logout, setUserInfo } = userSlice.actions;
export default userSlice.reducer;

View File

@@ -1,24 +1,25 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig, loadEnv} from 'vite';
import path, { resolve } from 'path';
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({mode}) => {
const env = loadEnv(mode, '.', '');
return {
plugins: [react(), tailwindcss()],
define: {
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
},
};
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
plugins: [react(), tailwindcss()],
define: {
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
},
resolve: {
alias: {
//'@': path.resolve(__dirname, '.'),
'@': resolve('src'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
},
};
});