diff --git a/package-lock.json b/package-lock.json index c94fec3..ac545a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,13 @@ "version": "0.0.0", "dependencies": { "@popperjs/core": "^2.11.8", + "@reduxjs/toolkit": "^2.6.0", "bootstrap": "^5.3.3", - "bootstrap-icons": "^1.11.3", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-icons": "^5.5.0", - "react-router-dom": "^7.2.0" + "react-redux": "^9.2.0", + "react-router-dom": "^7.2.0", + "redux-persist": "^6.0.0" }, "devDependencies": { "@eslint/js": "^9.21.0", @@ -955,6 +956,29 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.6.0.tgz", + "integrity": "sha512-mWJCYpewLRyTuuzRSEC/IwIBBkYg2dKtQas8mty5MaV2iXzcmicS3gW554FDeOvLnY3x13NIk8MB1e8wHO7rqQ==", + "dependencies": { + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz", @@ -1264,7 +1288,7 @@ "version": "19.0.10", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", - "dev": true, + "devOptional": true, "dependencies": { "csstype": "^3.0.2" } @@ -1278,6 +1302,11 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" + }, "node_modules/@vitejs/plugin-react": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", @@ -1557,21 +1586,6 @@ "@popperjs/core": "^2.11.8" } }, - "node_modules/bootstrap-icons": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.11.3.tgz", - "integrity": "sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/twbs" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/bootstrap" - } - ] - }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1810,7 +1824,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "devOptional": true }, "node_modules/data-view-buffer": { "version": "1.0.2", @@ -2733,6 +2747,15 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/immutable": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", @@ -3681,20 +3704,34 @@ "react": "^19.0.0" } }, - "node_modules/react-icons": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", - "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", - "peerDependencies": { - "react": "*" - } - }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -3754,6 +3791,27 @@ "node": ">=8.10.0" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, + "node_modules/redux-persist": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz", + "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==", + "peerDependencies": { + "redux": ">4.0.0" + } + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -3796,6 +3854,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==" + }, "node_modules/resolve": { "version": "2.0.0-next.5", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", @@ -4385,6 +4448,14 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", + "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", diff --git a/package.json b/package.json index 250f969..e788603 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,13 @@ }, "dependencies": { "@popperjs/core": "^2.11.8", + "@reduxjs/toolkit": "^2.6.0", "bootstrap": "^5.3.3", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.2.0" + "react-redux": "^9.2.0", + "react-router-dom": "^7.2.0", + "redux-persist": "^6.0.0" }, "devDependencies": { "@eslint/js": "^9.21.0", diff --git a/src/components/NotificationSnackbar.jsx b/src/components/NotificationSnackbar.jsx new file mode 100644 index 0000000..61d29c9 --- /dev/null +++ b/src/components/NotificationSnackbar.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { hideNotification } from '../store/notification.slice.js'; +import Snackbar from './Snackbar.jsx'; + +const NotificationSnackbar = () => { + const notification = useSelector((state) => state.notification); + const dispatch = useDispatch(); + + if (!notification) return null; // 没有通知时不渲染 + + return ( + dispatch(hideNotification())} + /> + ); +}; + +export default NotificationSnackbar; diff --git a/src/components/Snackbar.jsx b/src/components/Snackbar.jsx new file mode 100644 index 0000000..1b84c48 --- /dev/null +++ b/src/components/Snackbar.jsx @@ -0,0 +1,62 @@ +import React, { useEffect } from 'react'; + +const Snackbar = ({ type = 'primary', message, duration = 3000, onClose }) => { + if (!message) return null; + + useEffect(() => { + if (message) { + const timer = setTimeout(() => { + if (onClose) onClose(); + }, duration); + return () => clearTimeout(timer); + } + }, [duration, onClose]); + + const icons = { + success: 'check-circle-fill', + primary: 'info-fill', + warning: 'exclamation-triangle-fill', + danger: 'exclamation-triangle-fill', + }; + + return ( + <> + + + + + + + + + + + + +
+ + + +
{message}
+ +
+ + ); +}; + +export default Snackbar; diff --git a/src/layouts/Mainlayout.jsx b/src/layouts/Mainlayout.jsx index c0eae23..341d730 100644 --- a/src/layouts/Mainlayout.jsx +++ b/src/layouts/Mainlayout.jsx @@ -1,11 +1,13 @@ import React from 'react'; import HeaderWithNav from './HeaderWithNav'; -import "../styles/layouts.scss" +import '../styles/style.scss'; +import NotificationSnackbar from '../components/NotificationSnackbar'; export default function Mainlayout({ children }) { return ( <> + {children} ); diff --git a/src/main.jsx b/src/main.jsx index 4b86945..dbe6730 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -3,12 +3,20 @@ import { createRoot } from 'react-dom/client'; import './styles/base.scss'; import App from './App.jsx'; import { BrowserRouter } from 'react-router-dom'; -import 'bootstrap' +import 'bootstrap'; +import { Provider } from 'react-redux'; +import store, { persistor } from './store/store.js'; +import { PersistGate } from 'redux-persist/integration/react'; +import Loading from './components/Loading.jsx'; createRoot(document.getElementById('root')).render( - - - + } persistor={persistor}> + + + + + + ); diff --git a/src/services/api.js b/src/services/api.js new file mode 100644 index 0000000..1f003dc --- /dev/null +++ b/src/services/api.js @@ -0,0 +1,91 @@ +import axios from 'axios'; + +// Create Axios instance with base URL +const api = axios.create({ + baseURL: 'api', + withCredentials: true, // Include cookies if needed +}); + +// Request Interceptor +api.interceptors.request.use( + (config) => { + return config; + }, + (error) => { + console.error('Request error:', error); + return Promise.reject(error); + } +); + +// Response Interceptor +api.interceptors.response.use( + (response) => { + return response; + }, + (error) => { + // Handle errors in the response + if (error.response) { + // monitor /verify + if (error.response.status === 401 && error.config.url === '/check-token') { + if (window.location.pathname !== '/login' && window.location.pathname !== '/register') { + window.location.href = '/login'; + } + } + + // The request was made and the server responded with a status code + console.error('API Error Response:', error.response.status, error.response.data.message); + // alert(`Error: ${error.response.status} - ${error.response.data.message || 'Something went wrong'}`); + } else if (error.request) { + // The request was made but no response was received + console.error('API Error Request:', error.request); + // alert('Network error: No response from server'); + } else { + // Something happened in setting up the request + console.error('API Error Message:', error.message); + // alert('Error: ' + error.message); + } + return Promise.reject(error); // Reject the promise + } +); + +// Define common HTTP methods +const get = async (url, params = {}) => { + const res = await api.get(url, { params }); + return res.data; +}; + +// Handle POST requests for JSON data +const post = async (url, data, isMultipart = false) => { + const headers = isMultipart + ? { 'Content-Type': 'multipart/form-data' } // For file uploads + : { 'Content-Type': 'application/json' }; // For JSON data + + const res = await api.post(url, data, { headers }); + return res.data; +}; + +// Handle PUT requests +const put = async (url, data) => { + const res = await api.put(url, data, { + headers: { 'Content-Type': 'application/json' }, + }); + return res.data; +}; + +// Handle DELETE requests +const del = async (url) => { + const res = await api.delete(url); + return res.data; +}; + +const upload = async (url, data) => { + const axiosInstance = await axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + const res = await axiosInstance.post(url, data); + return res.data; +}; +export { get, post, put, del, upload }; diff --git a/src/services/userServices.js b/src/services/userServices.js new file mode 100644 index 0000000..6facc91 --- /dev/null +++ b/src/services/userServices.js @@ -0,0 +1,13 @@ +import { get, post } from "./api"; + +export const checkToken = async () => { + const response = await get("/check-token"); + const { user, message } = response; + return user; +}; + +export const login = async (config) => { + const response = await post('login', config); + const {message, user} = response; + return user; +} \ No newline at end of file diff --git a/src/store/notification.slice.js b/src/store/notification.slice.js new file mode 100644 index 0000000..5e49057 --- /dev/null +++ b/src/store/notification.slice.js @@ -0,0 +1,14 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const notificationSlice = createSlice({ + name: 'notification', + initialState: null, + reducers: { + showNotification: (state, action) => action.payload, + hideNotification: () => null, + }, +}); + +export const { showNotification, hideNotification } = notificationSlice.actions; +const notificationReducer = notificationSlice.reducer; +export default notificationReducer; diff --git a/src/store/store.js b/src/store/store.js new file mode 100644 index 0000000..3b4dbfc --- /dev/null +++ b/src/store/store.js @@ -0,0 +1,31 @@ +import { combineReducers, configureStore } from '@reduxjs/toolkit'; +import { persistReducer, persistStore } from 'redux-persist'; +import sessionStorage from 'redux-persist/lib/storage/session'; +import notificationReducer from './notification.slice.js'; + +const rootRducer = combineReducers({ + notification: notificationReducer, +}); + +const persistConfig = { + key: 'root', + storage: sessionStorage, + whitelist: [], +}; + +// Persist configuration +const persistedReducer = persistReducer(persistConfig, rootRducer); + +const store = configureStore({ + reducer: persistedReducer, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: false, // Disable serializable check for redux-persist + }), + devTools: true, +}); + +// Create the persistor to manage rehydrating the store +export const persistor = persistStore(store); + +export default store; diff --git a/src/styles/layouts.scss b/src/styles/style.scss similarity index 55% rename from src/styles/layouts.scss rename to src/styles/style.scss index cc47d7a..5824214 100644 --- a/src/styles/layouts.scss +++ b/src/styles/style.scss @@ -1,3 +1,7 @@ .dropdown-toggle { outline: 0; } + +.snackbar { + top:6.5rem; +} \ No newline at end of file