mkdir ketangclient
cd ketangclient
npm init -y
npm install react react-dom @types/react @types/react-dom @reduxjs/toolkit redux-logger @types/redux-logger antd-mobile axios @types/axios csstype
npm install webpack webpack-cli babel-loader @babel/core @babel/preset-react
npm install --save-dev webpack webpack-cli html-webpack-plugin ts-loader style-loader css-loader less less-loader typescript webpack-dev-server cross-env copy-webpack-plugin postcss-loader postcss tailwindcss autoprefixer redux react-redux redux-first-history @ant-design/icons @types/react-router-dom react-transition-group @types/react-transition-group
webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require("copy-webpack-plugin");
module.exports = {
mode: process.env.NODE_ENV == "production" ? "production" : "development",
entry: './src/index.tsx',
devtool: "source-map",
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
assetModuleFilename: 'images/[hash][ext][query]',
publicPath: '/'
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
alias: {
'@': path.resolve(__dirname, 'src')
}
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
},
{
test: /\.less$/,
use: ['style-loader', 'css-loader', 'less-loader']
},
{
test: /\.(png|jpg|jpeg|gif|svg)$/,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 4 * 1024
}
}
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html'
}),
new CopyWebpackPlugin({
patterns: [
{ from: path.resolve(__dirname, 'public'), to: path.resolve(__dirname, 'dist') }
]
})
],
devServer: {
static: {
directory: path.join(__dirname, 'public'),
},
hot: true,
historyApiFallback: true
}
};
tsconfig.json
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": false,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": [
"src"
]
}
配置选项 | 描述 |
---|---|
target |
指定 TypeScript 应该将代码编译成哪个 ECMAScript 版本。 |
lib |
指定编译时要包含哪些库文件,如 DOM API 和最新 ECMAScript 特性的类型定义。 |
allowJs |
允许编译器编译 JavaScript 文件。 |
skipLibCheck |
跳过所有声明文件(.d.ts 文件)的类型检查。 |
esModuleInterop |
允许从 CommonJS 模块中以默认导出方式导入。 |
allowSyntheticDefaultImports |
允许从没有默认导出的模块中使用默认导入语法。 |
strict |
启用所有严格的类型检查选项。 |
forceConsistentCasingInFileNames |
确保文件引用始终保持大小写一致。 |
module |
指定生成的代码应该使用哪种模块系统。 |
moduleResolution |
指定如何解析模块。 |
resolveJsonModule |
允许作为模块导入 JSON 文件。 |
isolatedModules |
确保每个文件都可以被安全地单独编译。 |
noEmit |
指示编译器不生成任何编译文件。 |
jsx |
指定如何处理 JSX 语法。 |
baseUrl |
设置基本目录为项目根目录。 |
paths |
将 '@/' 映射到 'src/' 目录。 |
src\index.tsx
import ReactDOM from 'react-dom/client';
const App = () => {
return <div>Hello</div>;
};
const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(<App />);
src\index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>React App with TypeScript</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
package.json
{
"scripts": {
"dev": "cross-env NODE_ENV=development webpack serve",
"build": "cross-env NODE_ENV=production webpack"
},
}
npx tailwindcss init -p
webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require("copy-webpack-plugin");
module.exports = {
mode: process.env.NODE_ENV == "production" ? "production" : "development",
entry: './src/index.tsx',
devtool: "source-map",
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
assetModuleFilename: 'images/[hash][ext][query]'
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
alias: {
'@': path.resolve(__dirname, 'src')
}
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/
},
{
test: /\.css$/,
+ use: ['style-loader', 'css-loader', 'postcss-loader']
},
{
test: /\.less$/,
+ use: ['style-loader', 'css-loader', 'postcss-loader','less-loader']
},
{
test: /\.(png|jpg|jpeg|gif|svg)$/,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 4 * 1024
}
}
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html'
}),
new CopyWebpackPlugin({
patterns: [
{ from: path.resolve(__dirname, 'public'), to: path.resolve(__dirname, 'dist') }
]
})
],
devServer: {
static: {
directory: path.join(__dirname, 'public'),
},
hot: true,
historyApiFallback: true
}
};
src\index.tsx
import ReactDOM from 'react-dom/client'
+import "./styles/global.less";
const App = () => {
return (
+ <h1 className="font-bold underline">
+ Hello world!
+ </h1>
);
};
const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(<App />);
src\styles\global.less
@tailwind base;
@tailwind components;
@tailwind utilities;
postcss.config.js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
tailwind.config.js
module.exports = {
content: [
'./src/**/*.{js,jsx,ts,tsx}'
],
theme: {
extend: {},
},
plugins: [],
}
src\index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>珠峰课堂</title>
+ <script src="/setRemUnit.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
public\setRemUnit.js
let docEle = document.documentElement;
function setRemUnit() {
docEle.style.fontSize = docEle.clientWidth*2 / 37.5 + "px";
}
setRemUnit();
window.addEventListener("resize", setRemUnit);
src\index.tsx
import ReactDOM from 'react-dom/client';
+import { BrowserRouter,Routes, Route } from "react-router-dom";
import "./styles/global.less";
+import Tabs from "./components/Tabs";
+import Home from "./views/Home";
+import Cart from "./views/Cart";
+import Profile from "./views/Profile";
+const root = ReactDOM.createRoot(document.getElementById('root')!);
+root.render(
+ <BrowserRouter>
+ <main className="pt-10 pb-10 w-full">
+ <Routes>
+ <Route path="/" element={<Home />} />
+ <Route path="/Cart" element={<Cart />} />
+ <Route path="/profile" element={<Profile />} />
+ </Routes>
+ <Tabs />
+ </main>
+ </BrowserRouter>
+);
src\components\Tabs\index.tsx
import React from 'react';
import { NavLink } from 'react-router-dom';
import { HomeOutlined, ShoppingCartOutlined, UserOutlined } from '@ant-design/icons';
const Tabs: React.FC = () => {
return (
<footer className="z-50 fixed bottom-0 left-0 w-full h-10 bg-white border-t border-gray-300 flex justify-center items-center">
<NavLink
to="/"
className={({ isActive }) => isActive ? "flex flex-1 flex-col justify-center items-center text-orange-500 font-bold" : "flex flex-1 flex-col justify-center items-center text-black"}
>
<HomeOutlined className="text-base" />
<span className="text-sm">首页</span>
</NavLink>
<NavLink
to="/cart"
className={({ isActive }) => isActive ? "flex flex-1 flex-col justify-center items-center text-orange-500 font-bold" : "flex flex-1 flex-col justify-center items-center text-black"}
>
<ShoppingCartOutlined className="text-base" />
<span className="text-sm">购物车</span>
</NavLink>
<NavLink
to="/profile"
className={({ isActive }) => isActive ? "flex flex-1 flex-col justify-center items-center text-orange-500 font-bold" : "flex flex-1 flex-col justify-center items-center text-black"}
>
<UserOutlined className="text-base" />
<span className="text-sm">个人中心</span>
</NavLink>
</footer>
);
}
export default Tabs;
src\views\Home\index.tsx
import React from 'react';
const Home: React.FC = () => {
return <div>Home</div>;
}
export default Home;
src\views\Cart\index.tsx
import React from 'react';
const Cart: React.FC = () => {
return <div>Cart</div>;
}
export default Cart;
src\views\Profile\index.tsx
import React from 'react';
const Profile: React.FC = () => {
return <div>Profile</div>;
}
export default Profile;
tsconfig.jsonnpm
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": false,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": [
"src",
+ "types"
]
}
src\types\images.d.ts
declare module '*.png';
src\views\Home\index.tsx
import React from 'react';
+import HomeHeader from './components/HomeHeader';
const Home: React.FC = () => {
return (
<>
+ <HomeHeader/>
</>
);
}
export default Home;
src\views\Home\components\HomeHeader.tsx
import React, { useState } from 'react';
import { BarsOutlined } from '@ant-design/icons';
import { Transition } from 'react-transition-group';
import logo from '@/assets/images/logo.png';
const duration = 2000;
const HomeHeader: React.FC = () => {
const [isMenuVisible, setIsMenuVisible] = useState(false);
const [currentCategory, setCurrentCategory] = useState('all');
const handleCategoryClick = (event: React.MouseEvent<HTMLUListElement>) => {
const target = event.target as HTMLElement;
const category = target.dataset.category;
if (category) {
setCurrentCategory(category);
setIsMenuVisible(false);
}
};
return (
<header className="fixed top-0 left-0 w-full z-50">
<div className="flex justify-between items-center h-10 bg-gray-800 text-white">
<img src={logo} alt="logo" className="w-20 ml-5" />
<BarsOutlined onClick={() => setIsMenuVisible(!isMenuVisible)} className="text-base mr-5" />
</div>
<Transition in={isMenuVisible} timeout={duration}>
{(state) => (
<ul
className={`absolute w-full top-10 left-0 bg-gray-800 transition-opacity ${state === 'entering' || state === 'entered' ? 'opacity-100' : 'opacity-0'}`}
style={{
transitionDuration: `${duration}ms`,
}}
onClick={handleCategoryClick}
>
<li data-category="all" className={`py-1 text-center text-base ${currentCategory === 'all' ? 'text-red-500' : 'text-white'}`}>全部课程</li>
<li data-category="react" className={`py-1 text-center text-base ${currentCategory === 'react' ? 'text-red-500' : 'text-white'}`}>React课程</li>
<li data-category="vue" className={`py-1 text-center text-base ${currentCategory === 'vue' ? 'text-red-500' : 'text-white'}`}>Vue课程</li>
</ul>
)}
</Transition>
</header>
);
};
export default HomeHeader;
src\index.tsx
import ReactDOM from 'react-dom/client';
import { Routes, Route } from "react-router-dom";
+import { HistoryRouter } from "redux-first-history/rr6";
+import { Provider } from "react-redux";
import "./styles/global.less";
import Tabs from "./components/Tabs";
import Home from "./views/Home";
import Cart from "./views/Cart";
import Profile from "./views/Profile";
+import { store, history } from "./store";
const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(
+ <Provider store={store}>
+ <HistoryRouter history={history}>
<main className="pt-10 pb-10 w-full">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/Cart" element={<Cart />} />
<Route path="/profile" element={<Profile />} />
</Routes>
<Tabs />
</main>
+ </HistoryRouter>
+ </Provider>
);
src\views\Home\index.tsx
import React from 'react';
import HomeHeader from './components/HomeHeader';
+import { useSelector, useDispatch } from 'react-redux'
+import { AppDispatch,RootState } from '@/store';
+import { HomeState,changeCurrentCategory } from '@/store/slices/home';
const Home: React.FC = () => {
+ const { currentCategory } = useSelector<RootState,HomeState>((state: RootState) => state.home)
+ const dispatch = useDispatch<AppDispatch>();
+ const handleChangeCategory = (newCategory: string) => {
+ dispatch(changeCurrentCategory(newCategory));
+ };
return (
<>
<HomeHeader
+ currentCategory={currentCategory}
+ changeCurrentCategory={handleChangeCategory}
/>
</>
);
}
export default Home;
src\views\Home\components\HomeHeader.tsx
import React, { useState } from 'react';
import { BarsOutlined } from '@ant-design/icons';
import { Transition } from 'react-transition-group';
import logo from '@/assets/images/logo.png';
+interface HomeHeaderProps {
+ currentCategory?: string;
+ changeCurrentCategory: (newCategory: string) => void;
+};
const duration = 2000;
+const HomeHeader: React.FC<HomeHeaderProps> = ({currentCategory,changeCurrentCategory}) => {
const [isMenuVisible, setIsMenuVisible] = useState(false);
const handleCategoryClick = (event: React.MouseEvent<HTMLUListElement>) => {
const target = event.target as HTMLElement;
const category = target.dataset.category;
if (category) {
+ changeCurrentCategory(category);
setIsMenuVisible(false);
}
};
return (
<header className="fixed top-0 left-0 w-full z-50">
<div className="flex justify-between items-center h-10 bg-gray-800 text-white">
<img src={logo} alt="logo" className="w-20 ml-5" />
<BarsOutlined onClick={() => setIsMenuVisible(!isMenuVisible)} className="text-base mr-5" />
</div>
<Transition in={isMenuVisible} timeout={duration}>
{(state) => (
<ul
className={`absolute w-full top-10 left-0 bg-gray-800 transition-opacity ${state === 'entering' || state === 'entered' ? 'opacity-100' : 'opacity-0'}`}
style={{
transitionDuration: `${duration}ms`,
}}
onClick={handleCategoryClick}
>
+ <li data-category="all" className={`py-1 text-center text-base ${currentCategory === 'all' ? 'text-red-500' : 'text-white'}`}>全部课程</li>
+ <li data-category="react" className={`py-1 text-center text-base ${currentCategory === 'react' ? 'text-red-500' : 'text-white'}`}>React课程</li>
+ <li data-category="vue" className={`py-1 text-center text-base ${currentCategory === 'vue' ? 'text-red-500' : 'text-white'}`}>Vue课程</li>
</ul>
)}
</Transition>
</header>
);
};
export default HomeHeader;
src\store\history.ts
import { createBrowserHistory } from 'history';
import { createReduxHistoryContext, IHistoryContext } from "redux-first-history";
const history = createBrowserHistory();
const { routerMiddleware, createReduxHistory, routerReducer }: IHistoryContext = createReduxHistoryContext({ history });
export {
routerReducer,
routerMiddleware,
createReduxHistory,
history
}
src\store\slices\home.ts
import { createSlice, Slice } from '@reduxjs/toolkit';
export interface HomeState {
currentCategory: string;
}
interface ChangeCategoryAction {
type: string;
payload: string;
}
const initialState: HomeState = {
currentCategory: 'all'
};
const homeSlice: Slice = createSlice({
name: 'home',
initialState,
reducers: {
changeCurrentCategory(state:HomeState, action:ChangeCategoryAction) {
state.currentCategory = action.payload;
}
}
});
export const { changeCurrentCategory } = homeSlice.actions;
export default homeSlice.reducer;
src\store\slices\cart.ts
import { createSlice, Slice } from '@reduxjs/toolkit';
interface CartState {}
const initialState: CartState = {};
const cartSlice: Slice = createSlice({
name: 'cart',
initialState,
reducers: {}
});
export default cartSlice.reducer;
src\store\slices\profile.ts
import { createSlice, Slice } from '@reduxjs/toolkit';
interface ProfileState {}
const initialState: ProfileState = {};
const profileSlice: Slice = createSlice({
name: 'profile',
initialState,
reducers: {}
});
export default profileSlice.reducer;
src\store\index.ts
import { configureStore, Middleware } from "@reduxjs/toolkit";
import home from "./slices/home";
import cart from "./slices/cart";
import profile from "./slices/profile";
import { routerMiddleware, createReduxHistory, routerReducer } from "./history";
import logger from "redux-logger";
import { History } from "history";
export const store = configureStore({
reducer: {
home,
cart,
profile,
router: routerReducer,
},
middleware: (getDefaultMiddleware) => {
const middlewares = getDefaultMiddleware().concat(routerMiddleware);
if (process.env.NODE_ENV !== "production") {
middlewares.push(logger as Middleware);
}
return middlewares;
},
});
export const history: History = createReduxHistory(store);
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
src\api\index.ts
import axios, { AxiosResponse} from "axios";
axios.defaults.baseURL = "http://127.0.0.1:9898";
axios.defaults.headers.post["Content-Type"] = "application/json;charset=UTF-8";
axios.interceptors.request.use(
(config) => {
return config;
},
(error) => {
return Promise.reject(error);
}
);
axios.interceptors.response.use(
(response: AxiosResponse) => response.data,
(error) => Promise.reject(error)
);
export default axios;
src\api\home.ts
import axios from "./";
import {Slider} from "@/store/slices/home";
type SlidersResponse = ResponseBody<Slider[]>;
export function getSliders(){
return axios.get<SlidersResponse,SlidersResponse>("/slider/list");
}
src\store\slices\home.ts
import { createSlice, Slice } from '@reduxjs/toolkit';
+export interface Slider {
+ url: string;
+}
export interface HomeState {
currentCategory: string;
+ sliders: Slider[];
}
const initialState: HomeState = {
currentCategory: 'all',
+ sliders:[]
};
interface ChangeCategoryAction {
type: string;
payload: string;
}
+interface SetSlidersAction {
+ type: string;
+ payload: Slider[];
+}
const homeSlice: Slice = createSlice({
name: 'home',
initialState,
reducers: {
changeCurrentCategory(state:HomeState, action:ChangeCategoryAction) {
state.currentCategory = action.payload;
},
+ setSliders(state:HomeState, action:SetSlidersAction) {
+ state.sliders = action.payload;
+ }
}
});
+export const { changeCurrentCategory,setSliders } = homeSlice.actions;
export default homeSlice.reducer;
src\types\response.ts
interface ResponseBody<T> {
success: boolean;
data: T;
}
src\views\Home\index.tsx
import React from 'react';
import HomeHeader from './components/HomeHeader';
+import HomeSwiper from "./components/HomeSwiper";
+import { useCategory, useFetchSliders } from './hooks';
const Home: React.FC = () => {
+ const { currentCategory, handleChangeCategory } = useCategory();
+ const { sliders } = useFetchSliders();
return (
<>
<HomeHeader
currentCategory={currentCategory}
changeCurrentCategory={handleChangeCategory}
/>
+ <div className="fixed top-10 bottom-10 left-0 w-full overflow-y-auto bg-white">
+ {sliders.length>0&&<HomeSwiper sliders={sliders} />}
+ </div>
</>
);
}
export default Home;
src\views\Home\components\HomeHeader.tsx
import React, { useState } from 'react';
import { BarsOutlined } from '@ant-design/icons';
import { Transition } from 'react-transition-group';
import logo from '@/assets/images/logo.png';
interface HomeHeaderProps {
currentCategory?: string;
changeCurrentCategory: (newCategory: string) => void;
};
const duration = 2000;
const HomeHeader: React.FC<HomeHeaderProps> = ({currentCategory,changeCurrentCategory}) => {
const [isMenuVisible, setIsMenuVisible] = useState(false);
const handleCategoryClick = (event: React.MouseEvent<HTMLUListElement>) => {
const target = event.target as HTMLElement;
const category = target.dataset.category;
if (category) {
changeCurrentCategory(category);
setIsMenuVisible(false);
}
};
return (
<header className="fixed top-0 left-0 w-full z-50">
<div className="flex justify-between items-center h-10 bg-gray-800 text-white">
<img src={logo} alt="logo" className="w-20 ml-5" />
<BarsOutlined onClick={() => setIsMenuVisible(!isMenuVisible)} className="text-base mr-5" />
</div>
<Transition in={isMenuVisible} timeout={duration}>
{(state) => (
<ul
className={`absolute w-full top-10 left-0 bg-gray-800 transition-opacity ${state === 'entering' || state === 'entered' ? 'opacity-100' : 'opacity-0'}`}
style={{
transitionDuration: `${duration}ms`,
}}
onClick={handleCategoryClick}
>
<li data-category="all" className={`py-1 text-center text-base ${currentCategory === 'all' ? 'text-red-500' : 'text-white'}`}>全部课程</li>
<li data-category="react" className={`py-1 text-center text-base ${currentCategory === 'react' ? 'text-red-500' : 'text-white'}`}>React课程</li>
<li data-category="vue" className={`py-1 text-center text-base ${currentCategory === 'vue' ? 'text-red-500' : 'text-white'}`}>Vue课程</li>
</ul>
)}
</Transition>
</header>
);
};
export default HomeHeader;
src\views\Home\components\HomeSwiper.tsx
import React from "react";
import { Swiper, Image } from "antd-mobile";
import { Slider } from "@/store/slices/home";
interface HomeSwiperProps {
sliders: Array<Slider>;
}
const HomeSwiper: React.FC<HomeSwiperProps> = ({ sliders }) => {
return (
<Swiper autoplay={true} loop={true}>
{sliders.map((slider) => (
<Swiper.Item key={slider.url}>
<div className="h-32">
<Image style={{'--height':'100%'}} src={slider.url} lazy />
</div>
</Swiper.Item>
))}
</Swiper>
);
};
export default HomeSwiper;
src\views\Home\hooks\index.ts
export {default as useFetchSliders} from './useFetchSliders';
export {default as useCategory} from './useCategory';
src\views\Home\hooks\useCategory.ts
import { useDispatch, useSelector } from "react-redux";
import { HomeState, changeCurrentCategory } from "@/store/slices/home";
import { AppDispatch, RootState } from "@/store";
export const useCategory = () => {
const dispatch = useDispatch<AppDispatch>();
const { currentCategory } = useSelector<RootState, HomeState>(
(state: RootState) => state.home
);
const handleChangeCategory = (newCategory: string) => {
dispatch(changeCurrentCategory(newCategory));
};
return {
currentCategory,
handleChangeCategory,
};
};
export default useCategory;
src\views\Home\hooks\useFetchSliders.ts
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { setSliders, HomeState } from "@/store/slices/home";
import { AppDispatch, RootState } from "@/store";
import { getSliders } from "@/api/home";
export const useFetchSliders = () => {
const { sliders } = useSelector<RootState, HomeState>(
(state: RootState) => state.home
);
const dispatch = useDispatch<AppDispatch>();
const fetchSliders = () => {
getSliders().then((slidersData) => {
dispatch(setSliders(slidersData.data));
});
};
useEffect(fetchSliders, [dispatch]);
return { sliders };
};
export default useFetchSliders;
src\api\home.ts
import axios from "./";
import { Slider } from "@/store/slices/home";
type SlidersResponse = ResponseBody<Slider[]>;
export function getSliders() {
return axios.get<SlidersResponse, SlidersResponse>("/slider/list");
}
+export function getLessons(
+ currentCategory = "all",
+ offset: number,
+ limit: number
+) {
+ return axios.get(
+ `/lesson/list?category=${currentCategory}&offset=${offset}&limit=${limit}`
+ );
+}
src\store\slices\home.ts
import { createSlice, Slice } from "@reduxjs/toolkit";
export interface Slider {
url: string;
}
+export interface Lesson {
+ id:string;
+ order: number;
+ title: string;
+ video: string;
+ poster: string;
+ url: string;
+ price: string;
+ category: string;
+}
+export interface Lessons {
+ loading: boolean;
+ list: Lesson[];
+ hasMore: boolean;
+ offset: number;
+ limit: number;
+}
export interface HomeState {
currentCategory: string;
sliders: Slider[];
+ lessons: Lessons;
}
const initialState: HomeState = {
currentCategory: "all",
sliders: [],
+ lessons: {
+ loading: false,
+ list: [],
+ hasMore: true,
+ offset: 0,
+ limit: 5,
+ },
};
interface ChangeCategoryAction {
type: string;
payload: string;
}
interface SetSlidersAction {
type: string;
payload: Slider[];
}
+interface SetLessonsLoadingAction {
+ type: string;
+ payload: boolean;
+}
+interface SetLessonsAction {
+ type: string;
+ payload: {
+ list: Lesson[];
+ hasMore: boolean;
+ };
+}
const homeSlice: Slice = createSlice({
name: "home",
initialState,
reducers: {
changeCurrentCategory(state: HomeState, action: ChangeCategoryAction) {
state.currentCategory = action.payload;
},
setSliders(state: HomeState, action: SetSlidersAction) {
state.sliders = action.payload;
},
+ setLessonsLoading(state: HomeState, action:SetLessonsLoadingAction) {
+ state.lessons.loading = action.payload;
+ },
+ setLessons(state: HomeState, action: SetLessonsAction) {
+ state.lessons.list = [...state.lessons.list, ...action.payload.list];
+ state.lessons.hasMore = action.payload.hasMore;
+ state.lessons.offset = state.lessons.offset + action.payload.list.length;
+ },
+ resetLessons(state: HomeState, action: SetLessonsAction) {
+ state.lessons.list = action.payload.list;
+ state.lessons.hasMore = action.payload.hasMore;
+ state.lessons.offset = action.payload.list.length;
+ }
},
});
+export const { changeCurrentCategory, setSliders,setLessonsLoading,setLessons,resetLessons} = homeSlice.actions;
export default homeSlice.reducer;
src\views\Home\index.tsx
import React from 'react';
import HomeHeader from './components/HomeHeader';
import HomeSwiper from "./components/HomeSwiper";
+import LessonList from "./components/LessonList";
+import { useCategory, useFetchSliders, useFetchLessons } from './hooks';
const Home: React.FC = () => {
const { currentCategory, handleChangeCategory } = useCategory();
const { sliders } = useFetchSliders();
+ const { lessons, fetchMoreLessons } = useFetchLessons();
return (
<>
<HomeHeader
currentCategory={currentCategory}
changeCurrentCategory={handleChangeCategory}
/>
<div className="fixed top-10 bottom-10 left-0 w-full overflow-y-auto bg-white">
{sliders.length>0&&<HomeSwiper sliders={sliders} />}
+ <LessonList lessons={lessons} fetchLessons={fetchMoreLessons}/>
</div>
</>
);
}
export default Home;
src\views\Home\components\LessonList.tsx
import React from "react";
import { Image, Button, Card, Skeleton, Footer } from "antd-mobile";
import { Link } from "react-router-dom";
import { Lesson, Lessons } from "@/store/slices/home";
interface LessonListProps {
lessons: Lessons;
fetchMoreLessons: () => void;
}
const LessonList: React.FC<LessonListProps> = ({ lessons, fetchMoreLessons }) => {
return (
<section>
{lessons.list.length > 0 ? (
lessons.list.map((lesson: Lesson) => (
<Link key={lesson.id} to={{ pathname: `/detail/${lesson.id}` }} state={lesson}>
<Card headerStyle={{ display: 'flex', justifyContent: 'center' }} title={lesson.title}>
<Image src={lesson.poster} />
</Card>
</Link>
))
) : (
<Skeleton.Title animated />
)}
{lessons.hasMore ? (
<Button
onClick={fetchMoreLessons}
loading={lessons.loading}
disabled={lessons.loading}
block
>
{lessons.loading ? "" : "加载更多"}
</Button>
) : (
<Footer label='没有更多了'></Footer>
)}
</section>
);
};
export default LessonList;
src\views\Home\hooks\useFetchLessons.ts
import { useEffect } from "react";
import { useDispatch, useSelector,useStore } from "react-redux";
import { setLessonsLoading, setLessons, HomeState,resetLessons } from "@/store/slices/home";
import { getLessons } from "@/api/home";
import { AppDispatch, RootState } from "@/store";
export const useFetchLessons = () => {
const { currentCategory, lessons } = useSelector(
(state: RootState): HomeState => state.home
);
const store = useStore<RootState>();
const dispatch = useDispatch<AppDispatch>();
const fetchMoreLessons = () => {
const {currentCategory,lessons:{ loading, hasMore, offset, limit} } = store.getState().home;
if (loading || !hasMore) return;
dispatch(setLessonsLoading(true));
getLessons(currentCategory, offset, limit)
.then((response) => {
dispatch(
setLessons({
list: response.data.list,
hasMore: response.data.hasMore,
})
);
})
.finally(() => {
dispatch(setLessonsLoading(false));
});
};
const fetchLessons = () => {
const {currentCategory,lessons:{ loading, limit} } = store.getState().home;
if (loading) return;
dispatch(setLessonsLoading(true));
getLessons(currentCategory, 0, limit)
.then((response) => {
dispatch(
resetLessons({
list: response.data.list,
hasMore: response.data.hasMore,
})
);
})
.finally(() => {
dispatch(setLessonsLoading(false));
});
};
useEffect(fetchLessons, [currentCategory]);
return { lessons, fetchLessons,fetchMoreLessons };
};
export default useFetchLessons;
src\views\Home\hooks\index.ts
export {default as useFetchSliders} from './useFetchSliders';
export {default as useCategory} from './useCategory';
+export {default as useFetchLessons} from './useFetchLessons';
src\views\Home\index.tsx
import React,{useRef,useEffect} from 'react';
import HomeHeader from './components/HomeHeader';
import HomeSwiper from "./components/HomeSwiper";
import LessonList from "./components/LessonList";
import { useCategory, useFetchSliders, useFetchLessons } from './hooks';
+import { loadMoreOnScroll } from '@/utils/loadMoreUtils';
const Home: React.FC = () => {
const { currentCategory, handleChangeCategory } = useCategory();
const { sliders } = useFetchSliders();
const { lessons, fetchMoreLessons } = useFetchLessons();
+ const contentRef = useRef<HTMLDivElement>(null);
+ useEffect(() => {
+ if (contentRef.current) {
+ loadMoreOnScroll(contentRef.current, fetchMoreLessons);
+ }
+ }, []);
return (
<>
<HomeHeader
currentCategory={currentCategory}
changeCurrentCategory={handleChangeCategory}
/>
+ <div ref={contentRef} className="fixed top-10 bottom-10 left-0 w-full overflow-y-auto bg-white">
{sliders.length>0&&<HomeSwiper sliders={sliders} />}
<LessonList
lessons={lessons}
fetchMoreLessons={fetchMoreLessons}
/>
</div>
</>
);
}
export default Home;
src\store\slices\home.ts
import { createSlice, Slice } from "@reduxjs/toolkit";
export interface Slider {
url: string;
}
export interface Lesson {
id:string;
order: number;
title: string;
video: string;
poster: string;
url: string;
price: string;
category: string;
}
export interface Lessons {
loading: boolean;
list: Lesson[];
hasMore: boolean;
offset: number;
limit: number;
}
export interface HomeState {
currentCategory: string;
sliders: Slider[];
lessons: Lessons;
}
const initialState: HomeState = {
currentCategory: "all",
sliders: [],
lessons: {
loading: false,
list: [],
hasMore: true,
offset: 0,
limit: 5,
},
};
interface ChangeCategoryAction {
type: string;
payload: string;
}
interface SetSlidersAction {
type: string;
payload: Slider[];
}
interface SetLessonsLoadingAction {
type: string;
payload: boolean;
}
interface SetLessonsAction {
type: string;
payload: {
list: Lesson[];
hasMore: boolean;
};
}
const homeSlice: Slice = createSlice({
name: "home",
initialState,
reducers: {
changeCurrentCategory(state: HomeState, action: ChangeCategoryAction) {
state.currentCategory = action.payload;
},
setSliders(state: HomeState, action: SetSlidersAction) {
state.sliders = action.payload;
},
setLessonsLoading(state: HomeState, action:SetLessonsLoadingAction) {
state.lessons.loading = action.payload;
},
setLessons(state: HomeState, action: SetLessonsAction) {
state.lessons.list = [...state.lessons.list, ...action.payload.list];
state.lessons.hasMore = action.payload.hasMore;
state.lessons.offset = state.lessons.offset + action.payload.list.length;
},
+ resetLessons(state: HomeState, action: SetLessonsAction) {
+ state.lessons.list = action.payload.list;
+ state.lessons.hasMore = action.payload.hasMore;
+ state.lessons.offset = action.payload.list.length;
+ },
},
});
+export const { changeCurrentCategory, setSliders,setLessonsLoading,setLessons,resetLessons} = homeSlice.actions;
export default homeSlice.reducer;
src\utils\loadMoreUtils.ts
export function loadMoreOnScroll(element: HTMLElement, callback: () => void): void {
const handleScroll = () => {
const clientHeight = element.clientHeight;
const scrollTop = element.scrollTop;
const scrollHeight = element.scrollHeight;
console.log(clientHeight, scrollTop, scrollHeight);
if (clientHeight + scrollTop + 10 >= scrollHeight) {
callback();
}
};
const debouncedScroll = debounce(handleScroll, 300);
element.addEventListener("scroll", debouncedScroll);
}
export function debounce(func: (...args: any[]) => void, wait: number): (...args: any[]) => void {
let timeout: NodeJS.Timeout | null = null;
return function(...args: any[]) {
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(() => func(...args), wait);
};
}
src\views\Home\index.tsx
import React,{useRef,useEffect} from 'react';
import HomeHeader from './components/HomeHeader';
import HomeSwiper from "./components/HomeSwiper";
import LessonList from "./components/LessonList";
import { useCategory, useFetchSliders, useFetchLessons } from './hooks';
import { loadMoreOnScroll } from '@/utils/loadMoreUtils';
+import { pullRefresh } from '@/utils/pullRefreshUtils';
+import {DotLoading } from 'antd-mobile';
const Home: React.FC = () => {
const { currentCategory, handleChangeCategory } = useCategory();
const { sliders } = useFetchSliders();
const { lessons, fetchMoreLessons,fetchLessons } = useFetchLessons();
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (contentRef.current) {
loadMoreOnScroll(contentRef.current, fetchMoreLessons);
}
}, []);
+ useEffect(() => {
+ if (contentRef.current) {
+ pullRefresh(contentRef.current, fetchLessons);
+ }
+ }, []);
return (
<>
+ <div className="fixed top-[50px] w-full text-center text-6xl">
+ <DotLoading />
+ </div>
<HomeHeader
currentCategory={currentCategory}
changeCurrentCategory={handleChangeCategory}
/>
<div ref={contentRef} className="fixed top-10 bottom-10 left-0 w-full overflow-y-auto bg-white">
{sliders.length>0&&<HomeSwiper sliders={sliders} />}
<LessonList
lessons={lessons}
fetchMoreLessons={fetchMoreLessons}
/>
</div>
</>
);
}
export default Home;
src\utils\pullRefreshUtils.ts
type EventWithTouch = Event & { touches: TouchList };
export function pullRefresh(element: HTMLElement, callback: () => void): void {
let startY: number;
let distance: number;
const originalTop: number = element.offsetTop;
let startTop: number;
let timer: number | null = null;
const touchMoveThrottled = throttle((event: EventWithTouch) => {
const pageY = event.touches[0].pageY;
if (pageY > startY) {
distance = pageY - startY;
element.style.top = `${startTop + distance}px`;
} else {
element.removeEventListener('touchmove', touchMoveThrottled);
element.removeEventListener('touchend', touchEnd);
}
}, 30);
function touchEnd(): void {
element.removeEventListener('touchmove', touchMoveThrottled);
element.removeEventListener('touchend', touchEnd);
if (distance > 30) {
callback();
}
timer = window.setInterval(() => {
const currentTop = element.offsetTop;
if (currentTop > originalTop) {
element.style.top = `${currentTop - 1}px`;
} else {
if (timer) clearInterval(timer);
element.style.top = `${originalTop}px`;
}
}, 16);
}
element.addEventListener('touchstart', (event: EventWithTouch) => {
if (timer) clearInterval(timer);
if (element.scrollTop === 0) {
startTop = element.offsetTop;
startY = event.touches[0].pageY;
element.addEventListener('touchmove', touchMoveThrottled);
element.addEventListener('touchend', touchEnd);
}
});
}
export function throttle(func: (...args: any[]) => void, delay: number): (...args: any[]) => void {
let prev = Date.now();
return function(this: any, ...args: any[]) {
const now = Date.now();
if (now - prev >= delay) {
func.apply(this, args);
prev = now;
}
};
}
src\index.tsx
import ReactDOM from 'react-dom/client';
import { Routes, Route } from "react-router-dom";
import { HistoryRouter } from "redux-first-history/rr6";
import { Provider } from "react-redux";
import "./styles/global.less";
import Tabs from "./components/Tabs";
import Home from "./views/Home";
import Cart from "./views/Cart";
+import Detail from "./views/Detail";
import Profile from "./views/Profile";
import { store, history } from "./store";
const rootElement = document.getElementById('root');
if(rootElement){
const root = ReactDOM.createRoot(rootElement);
root.render(
<Provider store={store}>
<HistoryRouter history={history}>
<main className="pt-10 pb-10 w-full">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/Cart" element={<Cart />} />
<Route path="/profile" element={<Profile />} />
+ <Route path="/detail/:id" element={<Detail/>} />
</Routes>
<Tabs />
</main>
</HistoryRouter>
</Provider>
);
}
src\views\Detail\index.tsx
import React, { useState, useEffect } from "react";
import { Card, Image } from "antd-mobile";
import { getLesson } from "@/api/home";
import { useLocation, useParams } from 'react-router-dom';
import { Lesson } from '@/store/slices/home';
const LessonDetail: React.FC = () => {
const location = useLocation();
const { id } = useParams<{ id: string }>();
const [lesson, setLesson] = useState<Lesson | null>(null);
useEffect(() => {
async function fetchLesson() {
const lessonFromLocation = location.state?.lesson as Lesson | undefined;
if (!lessonFromLocation) {
const result = await getLesson(id as string);
if (result.success) {
setLesson(result.data);
}
} else {
setLesson(lessonFromLocation);
}
}
fetchLesson();
}, [id, location.state]);
if (!lesson) {
return <NavHeader>Loading...</NavHeader>;
}
return (
<>
{lesson && (
<>
<Card
headerStyle={{ display: 'flex', justifyContent: 'center' }}
title={lesson.title}
>
<Image src={lesson.poster} />
</Card>
</>
)}
</>
);
};
export default LessonDetail;
src\api\home.ts
import axios from "./";
import { Slider,Lesson } from "@/store/slices/home";
type SlidersResponse = ResponseBody<Slider[]>;
+type LessonResponse = ResponseBody<Lesson>;
export function getSliders() {
return axios.get<SlidersResponse, SlidersResponse>("/slider/list");
}
export function getLessons(
currentCategory = "all",
offset: number,
limit: number
) {
return axios.get(
`/lesson/list?category=${currentCategory}&offset=${offset}&limit=${limit}`
);
}
+export function getLesson(id:string) {
+ return axios.get<LessonResponse,LessonResponse>(`/lesson/${id}`);
+}
src\views\Detail\index.tsx
import React, { useState, useEffect } from "react";
import { Card, Image } from "antd-mobile";
import { getLesson } from "@/api/home";
import { useLocation, useParams } from 'react-router-dom';
import { Lesson } from '@/store/slices/home';
+import NavHeader from "@/components/NavHeader";
const LessonDetail: React.FC = () => {
const location = useLocation();
const { id } = useParams<{ id: string }>();
const [lesson, setLesson] = useState<Lesson | null>(null);
useEffect(() => {
async function fetchLesson() {
const lessonFromLocation = location.state?.lesson as Lesson | undefined;
if (!lessonFromLocation) {
const result = await getLesson(id as string);
if (result.success) {
setLesson(result.data);
}
} else {
setLesson(lessonFromLocation);
}
}
fetchLesson();
}, [id, location.state]);
if (!lesson) {
return <NavHeader>Loading...</NavHeader>;
}
return (
<>
+ <NavHeader>{lesson?.title}</NavHeader>
{lesson && (
<>
<Card
headerStyle={{ display: 'flex', justifyContent: 'center' }}
title={lesson.title}
>
<Image src={lesson.poster} />
</Card>
</>
)}
</>
);
};
export default LessonDetail;
src\views\Home\components\HomeHeader.tsx
import React from "react";
import { LeftOutlined } from "@ant-design/icons";
import { useNavigate } from 'react-router-dom';
type NavigationHeaderProps = {
children: React.ReactNode;
};
const NavigationHeader: React.FC<NavigationHeaderProps> = ({ children }) => {
const navigate = useNavigate();
return (
<div className="fixed top-0 left-0 w-full h-10 z-50 flex items-center justify-center bg-gray-800 text-white text-sm">
<LeftOutlined onClick={() => navigate(-1)} className="absolute left-5" />
{children}
</div>
);
};
export default NavigationHeader;
src\views\Detail\index.tsx
import React, { useState, useEffect } from "react";
+import { useDispatch } from "react-redux";
+import { Card, Image, Button } from "antd-mobile";
import { getLesson } from "@/api/home";
import { useLocation, useParams } from 'react-router-dom';
import { Lesson } from '@/store/slices/home';
import NavHeader from "@/components/NavHeader";
+import { addCartItem } from "@/store/slices/cart";
const LessonDetail: React.FC = () => {
const location = useLocation();
const { id } = useParams<{ id: string }>();
const [lesson, setLesson] = useState<Lesson | null>(null);
+ const dispatch = useDispatch();
useEffect(() => {
async function fetchLesson() {
const lessonFromLocation = location.state?.lesson as Lesson | undefined;
if (!lessonFromLocation) {
const result = await getLesson(id as string);
if (result.success) {
setLesson(result.data);
}
} else {
setLesson(lessonFromLocation);
}
}
fetchLesson();
}, [id, location.state]);
+ const handleAddCartItem = () => {
+ if (lesson) {
+ animateImageToCart();
+ dispatch(addCartItem(lesson));
+ }
+ };
+ const animateImageToCart = () => {
+ const lessonImage = document.querySelector('.adm-image') as HTMLElement | null;
+ const cartIcon = document.querySelector('.anticon-shopping-cart') as HTMLElement | null;
+ if (!lessonImage || !cartIcon) return;
+ const clonedImage = lessonImage.cloneNode(true) as HTMLElement;
+ const imageRect = lessonImage.getBoundingClientRect();
+ const cartRect = cartIcon.getBoundingClientRect();
+ clonedImage.style.cssText = `
+ z-index: 1000;
+ opacity: 0.8;
+ position: fixed;
+ width: ${imageRect.width}px;
+ height: ${imageRect.height}px;
+ top: ${imageRect.top}px;
+ left: ${imageRect.left}px;
+ transition: transform 2s ease-in-out, opacity 2s ease-in-out;
+ `;
+ document.body.appendChild(clonedImage);
+ const translateX = cartRect.left - imageRect.left - imageRect.width / 2 + cartRect.width / 2;
+ const translateY = cartRect.top - imageRect.top - imageRect.height / 2 + cartRect.height / 2;
+ setTimeout(() => {
+ clonedImage.style.transform = `translate(${translateX}px, ${translateY}px) scale(0)`;
+ clonedImage.style.opacity = '0.3';
+ setTimeout(() => clonedImage.remove(), 2000);
+ }, 0);
+ }
if (!lesson) {
return <NavHeader>Loading...</NavHeader>;
}
return (
<>
<NavHeader>{lesson?.title}</NavHeader>
{lesson && (
<>
<Card
headerStyle={{ display: 'flex', justifyContent: 'center' }}
title={lesson.title}
>
<Image src={lesson.poster} />
</Card>
+ <Button
+ className="add-cart"
+ onClick={handleAddCartItem}
+ >
+ 加入购物车
+ </Button>
</>
)}
</>
);
};
export default LessonDetail;
src\store\slices\cart.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Lesson } from "./home";
export interface CartLesson {
checked: boolean;
count: number;
lesson: Lesson;
}
const initialState: CartLesson[] = [];
const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
addCartItem(state, action: PayloadAction<Lesson>) {
const index = state.findIndex(item => item.lesson.id === action.payload.id);
if (index === -1) {
state.push({ checked: false, count: 1, lesson: action.payload });
} else {
state[index].count += 1;
}
},
removeCartItem(state, action: PayloadAction<string>) {
return state.filter(item => item.lesson.id !== action.payload);
},
clearCartItems() {
return [];
},
changeCartItemCount(state, action: PayloadAction<{ id: string, count: number }>) {
const item = state.find(item => item.lesson.id === action.payload.id);
if (item) {
item.count = action.payload.count;
}
},
changeCheckedCartItems(state, action: PayloadAction<string[]>) {
state.forEach(item => {
item.checked = action.payload.includes(item.lesson.id!);
});
},
settle(state) {
return state.filter(item => !item.checked);
}
}
});
export const {
addCartItem,
removeCartItem,
clearCartItems,
changeCartItemCount,
changeCheckedCartItems,
settle
} = cartSlice.actions;
export default cartSlice.reducer;
src\views\Cart\index.tsx
import React, { useState, useRef } from "react";
import { useSelector, useDispatch } from "react-redux";
import { Button, Input, SwipeAction, Modal, Grid, Space, Checkbox, List, Dialog } from "antd-mobile";
import NavHeader from "@/components/NavHeader";
import { RootState } from "@/store";
import { CartLesson, removeCartItem, clearCartItems, changeCartItemCount, changeCheckedCartItems, settle } from "@/store/slices/cart";
import { CheckboxValue } from "antd-mobile/es/components/checkbox";
const Cart: React.FC = () => {
const cart = useSelector((state: RootState) => state.cart);
const dispatch = useDispatch();
const confirmSettle = () => {
Modal.confirm({
content: '请问你是否要结算',
onConfirm: () => {
dispatch(settle());
},
});
};
let totalCount = cart
.filter((item) => item.checked)
.reduce((total, item) => total + item.count, 0);
let totalPrice = cart
.filter((item) => item.checked)
.reduce(
(total, item) =>
total + parseFloat(item.lesson.price!.replace(/[^0-9\.]/g, "")) * item.count,
0
);
return (
<div className="p-1">
<NavHeader>购物车</NavHeader>
<CartItems
cart={cart}
changeCartItemCount={(id, count) => dispatch(changeCartItemCount({ id, count }))}
removeCartItem={(id) => dispatch(removeCartItem(id))}
changeCheckedCartItems={(ids) => dispatch(changeCheckedCartItems(ids))}
/>
<Grid columns={15} gap={8} className="items-center h-16">
<Grid.Item span={3}>
<Button
color="warning"
size="small"
onClick={() => dispatch(clearCartItems())}
>清空</Button>
</Grid.Item>
<Grid.Item span={5}>
已选择{totalCount}件商品
</Grid.Item>
<Grid.Item span={4}>¥{totalPrice}元</Grid.Item>
<Grid.Item span={3}>
<Button color="primary" size="small" onClick={confirmSettle}>结算</Button>
</Grid.Item>
</Grid>
</div>
);
};
interface CartItemsProps {
cart: CartLesson[];
changeCartItemCount: (id: string, count: number) => void;
removeCartItem: (id: string) => void;
changeCheckedCartItems: (ids: string[]) => void;
}
const CartItems: React.FC<CartItemsProps> = ({ cart, changeCartItemCount, removeCartItem, changeCheckedCartItems }) => {
const [value, setValue] = useState<string[]>([]);
const swipeActionRef = useRef<any>(null);
return (
<Space direction='vertical'>
<Checkbox
indeterminate={value.length > 0 && value.length < cart.length}
checked={value.length === cart.length}
onChange={checked => {
let newValue = checked ? cart.map(item => item.lesson.id!) : [];
setValue(newValue);
changeCheckedCartItems(newValue);
}}
>全选</Checkbox>
<Checkbox.Group
value={value}
onChange={(v: CheckboxValue[]) => {
setValue(v as string[]);
changeCheckedCartItems(v as string[]);
}}
>
<Space direction='vertical'>
<List>
{cart.map((item, index) => (
<List.Item key={index}>
<SwipeAction
ref={swipeActionRef}
closeOnAction={false}
closeOnTouchOutside={false}
rightActions={[
{
key: 'remove',
text: '删除',
color: 'red',
onClick: async () => {
const result = await Dialog.confirm({
content: '确定要删除吗?',
});
if (result) {
removeCartItem(item.lesson.id!);
}
swipeActionRef.current?.close();
},
}
]}
>
<Grid columns={12} gap={8}>
<Grid.Item span={1}>
<Checkbox value={item.lesson.id} checked={item.checked}></Checkbox>
</Grid.Item>
<Grid.Item span={6}>
{item.lesson.title}
</Grid.Item>
<Grid.Item span={2}>
¥{item.lesson.price}
</Grid.Item>
<Grid.Item span={3}>
<Input
value={item.count.toString()}
onChange={(val: string) => {
changeCartItemCount(item.lesson.id!, Number(val));
}}
/>
</Grid.Item>
</Grid>
</SwipeAction>
</List.Item>
))}
</List>
</Space>
</Checkbox.Group>
</Space>
);
};
export default Cart;
src\views\Profile\index.tsx
import { useEffect } from "react";
import { Button, List, Toast, Result, Mask } from "antd-mobile";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from 'react-router-dom';
import { validateProfile } from "@/store/slices/profile";
import type {AppDispatch} from '@/store';
import NavHeader from "@/components/NavHeader";
import { LOGIN_TYPES } from '@/constants';
const Profile = () => {
const dispatch = useDispatch<AppDispatch>();
const navigate = useNavigate();
const { loginState} = useSelector((state: any) => state.profile);
useEffect(() => {
if (loginState === LOGIN_TYPES.UN_VALIDATE) {
dispatch(validateProfile()).unwrap().catch(() => Toast.show({
icon:'fail',
content: '验证失败'
}));
}
}, [dispatch, loginState]);
let content = null;
switch (loginState) {
case LOGIN_TYPES.UN_VALIDATE:
content = <Mask visible={true} />;
break;
case LOGIN_TYPES.LOGINED:
content = (
<div className="user-info p-5">
<List >
<List.Item extra="珠峰架构">用户名</List.Item>
<List.Item extra="15718812345">手机号</List.Item>
<List.Item extra="zhangsan@qq.com">邮箱</List.Item>
</List>
<Button color="primary" >退出登录</Button>
</div>
);
break;
case LOGIN_TYPES.UNLOGIN:
content = (
<Result
status='warning'
title='亲爱的用户你好,你当前尚未登录,请你选择注册或者登录'
description={
<div style={{ textAlign: "center", padding: "50px" }}>
<Button color="primary" onClick={() => navigate("/login")}>登录</Button>
<Button
color="primary"
style={{ marginLeft: "50px" }}
onClick={() => navigate("/register")}
>注册</Button>
</div>
}
/>
);
break;
default:
content = null;
}
return (
<section>
<NavHeader>个人中心</NavHeader>
{content}
</section>
);
};
export default Profile;
src\store\slices\profile.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { LOGIN_TYPES } from '@/constants';
import { validate } from '@/api/profile';
interface ProfileState {
loginState: LOGIN_TYPES;
user: any;
error: any;
}
const initialState: ProfileState = {
loginState: LOGIN_TYPES.UN_VALIDATE,
user: null,
error: null
};
export const validateProfile = createAsyncThunk(
'profile/validate',
async (_, { rejectWithValue }) => {
try {
const response = await validate();
return response.data;
} catch (error:any) {
return rejectWithValue(error.response.data);
}
}
);
const profileSlice = createSlice({
name: 'profile',
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(validateProfile.fulfilled, (state, action) => {
state.loginState = LOGIN_TYPES.LOGINED;
state.user = action.payload;
state.error = null;
});
builder.addCase(validateProfile.rejected, (state, action) => {
state.loginState = LOGIN_TYPES.UNLOGIN;
state.user = null;
state.error = action.payload;
});
}
});
export default profileSlice.reducer;
src\constants.ts
export enum LOGIN_TYPES {
UN_VALIDATE = "UN_VALIDATE",
LOGINED = "LOGINED",
UNLOGIN = "UNLOGIN"
}
src\api\profile.ts
import axios from ".";
export function validate() {
return axios.get("/user/validate");
}
src\index.tsx
import ReactDOM from 'react-dom/client';
import { Routes, Route } from "react-router-dom";
import { HistoryRouter } from "redux-first-history/rr6";
import { Provider } from "react-redux";
import "./styles/global.less";
import Tabs from "./components/Tabs";
import Home from "./views/Home";
import Cart from "./views/Cart";
import Detail from "./views/Detail";
import Profile from "./views/Profile";
import { store, history } from "./store";
+import Login from "./views/Login";
+import Register from "./views/Register";
const rootElement = document.getElementById('root');
if(rootElement){
const root = ReactDOM.createRoot(rootElement);
root.render(
<Provider store={store}>
<HistoryRouter history={history}>
<main className="pt-10 pb-10 w-full">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/Cart" element={<Cart />} />
<Route path="/profile" element={<Profile />} />
<Route path="/detail/:id" element={<Detail/>} />
+ <Route path="/register" element={<Register/>} />
+ <Route path="/login" element={<Login/>} />
</Routes>
<Tabs />
</main>
</HistoryRouter>
</Provider>
);
}
src\views\Profile\index.tsx
import { useEffect } from "react";
import { Button, List, Toast, Result, Mask } from "antd-mobile";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from 'react-router-dom';
+import { validateProfile ,logout} from "@/store/slices/profile";
import type {AppDispatch} from '@/store';
import NavHeader from "@/components/NavHeader";
import { LOGIN_TYPES } from '@/constants';
const Profile = () => {
const dispatch = useDispatch<AppDispatch>();
const navigate = useNavigate();
+ const { loginState,user } = useSelector((state: any) => state.profile);
useEffect(() => {
if (loginState === LOGIN_TYPES.UN_VALIDATE) {
dispatch(validateProfile()).unwrap().catch(() => Toast.show({
icon:'fail',
content: '验证失败'
}));
}
}, [dispatch, loginState]);
+ const handleLogout = () => {
+ dispatch(logout());
+ Toast.show({ icon: 'success', content: '已退出登录' });
+ navigate('/login');
+ };
+ const renderUserInfo = () => (
+ <div className="p-5">
+ <List>
+ <List.Item extra={user?.username}>用户名</List.Item>
+ <List.Item extra={user?.email}>邮箱</List.Item>
+ </List>
+ <Button color="primary" onClick={handleLogout}>退出登录</Button>
+ </div>
+ );
+ const renderLoginPrompt = () => (
+ <Result
+ status='warning'
+ title='亲爱的用户你好,你当前尚未登录,请你选择注册或者登录'
+ description={
+ <div className="text-center p-12">
+ <Button color="primary" onClick={() => navigate("/login")}>登录</Button>
+ <Button color="primary" className="ml-12" onClick={() => navigate("/register")}>注册</Button>
+ </div>
+ }
+ />
+ );
let content = null;
switch (loginState) {
case LOGIN_TYPES.UN_VALIDATE:
content = <Mask visible={true} />;
break;
case LOGIN_TYPES.LOGINED:
+ content = renderUserInfo();
break;
case LOGIN_TYPES.UNLOGIN:
+ content = renderLoginPrompt();
break;
default:
content = null;
}
return (
<section>
<NavHeader>个人中心</NavHeader>
{content}
</section>
);
};
export default Profile;
src\store\slices\profile.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { LOGIN_TYPES } from '@/constants';
+import { validate,register,login } from '@/api/profile';
+import {RegisterLoginPayload,User} from "@/types/entity"
interface ProfileState {
loginState: LOGIN_TYPES;
+ user: User | null;
error: any;
}
const initialState: ProfileState = {
loginState: LOGIN_TYPES.UN_VALIDATE,
user: null,
error: null
};
+export const validateProfile = createAsyncThunk(
+ 'profile/validate',
+ async (_, { rejectWithValue }) => {
+ try {
+ const response = await validate();
+ return response.data;
+ } catch (error:any) {
+ return rejectWithValue(error.response.data);
+ }
+ }
+);
+export const registerUser = createAsyncThunk(
+ 'profile/register',
+ async (values: RegisterLoginPayload, { rejectWithValue }) => {
+ try {
+ const response = await register(values);
+ return response.data;
+ } catch (error: any) {
+ return rejectWithValue(error.response.data);
+ }
+ }
+);
export const loginUser = createAsyncThunk(
'profile/login',
async (values: RegisterLoginPayload, { rejectWithValue }) => {
try {
const response = await login(values);
return response.data;
} catch (error: any) {
return rejectWithValue(error.response.data);
}
}
);
const profileSlice = createSlice({
name: 'profile',
initialState,
reducers: {
+ logout(state) {
+ state.user = null;
+ state.loginState = LOGIN_TYPES.UNLOGIN;
+ sessionStorage.removeItem('access_token');
+ }
},
extraReducers: (builder) => {
builder.addCase(validateProfile.fulfilled, (state, action) => {
state.loginState = LOGIN_TYPES.LOGINED;
state.user = action.payload;
state.error = null;
});
builder.addCase(validateProfile.rejected, (state, action) => {
state.loginState = LOGIN_TYPES.UNLOGIN;
state.user = null;
state.error = action.payload;
})
+ .addCase(registerUser.fulfilled, (state, action) => {
+ })
+ .addCase(registerUser.rejected, (state, action) => {
+ })
+ .addCase(loginUser.fulfilled, (state, action) => {
+ debugger
+ state.user = action.payload.user;
+ state.loginState = LOGIN_TYPES.LOGINED;
+ })
+ .addCase(loginUser.rejected, (state, action) => {
+ state.error = action.payload;
+ state.loginState = LOGIN_TYPES.UNLOGIN;
+ });
}
});
export const { logout } = profileSlice.actions;
export default profileSlice.reducer;
src\api\index.ts
import axios, { AxiosResponse} from "axios";
axios.defaults.baseURL = "http://127.0.0.1:9898";
axios.defaults.headers.post["Content-Type"] = "application/json;charset=UTF-8";
axios.interceptors.request.use(
(config) => {
+ let access_token = sessionStorage.getItem("access_token");
+ if (access_token) {
+ config.headers=config.headers||{};
+ config.headers.Authorization = "Bearer " + access_token;
+ }
return config;
},
(error) => {
return Promise.reject(error);
}
);
axios.interceptors.response.use(
(response: AxiosResponse) => response.data,
(error) => Promise.reject(error)
);
export default axios;
src\api\profile.ts
import axios from ".";
+import {RegisterLoginPayload} from "@/types/entity"
export function validate() {
return axios.get("/user/validate");
}
+export const register = (values:RegisterLoginPayload) => {
+ return axios.post('/user/register', values);
+};
+
+export const login = (values:RegisterLoginPayload) => {
+ return axios.post('/user/login', values);
+};
src\types\entity.ts
export interface RegisterLoginPayload {
username: string;
password: string;
email?: string;
}
export interface User {
username: string;
password: string;
email: string;
avatar: string;
}
src\views\Register\index.tsx
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { Button, Form, Input, Toast } from "antd-mobile";
import { registerUser } from "@/store/slices/profile";
import {RegisterLoginPayload} from "@/types/entity";
import NavHeader from "@/components/NavHeader";
import { AppDispatch } from "@/store";
const Register = () => {
const dispatch = useDispatch<AppDispatch>();
const navigate = useNavigate();
const onFinish = async (values:RegisterLoginPayload) => {
try {
await dispatch(registerUser(values)).unwrap();
Toast.show({ icon: 'success', content: '注册成功' });
navigate('/login');
} catch (error) {
Toast.show({ icon: 'fail', content: '注册失败' });
}
};
const onFinishFailed = (errorInfo:any) => {
Toast.show({ icon: 'fail', content: `表单验证失败: ${errorInfo}` });
};
return (
<>
<NavHeader>用户注册</NavHeader>
<Form
onFinish={onFinish}
onFinishFailed={onFinishFailed}
className="p-5"
>
<Form.Item label="用户名" name="username" rules={[{ required: true, message: "请输入你的用户名!" }]}>
<Input placeholder="用户名" />
</Form.Item>
<Form.Item label="密码" name="password" rules={[{ required: true, message: "请输入你的密码!" }]}>
<Input type="password" placeholder="密码" />
</Form.Item>
<Form.Item label="确认密码" name="confirmPassword" rules={[{ required: true, message: "请输入你的确认密码!" }]}>
<Input type="password" placeholder="确认密码" />
</Form.Item>
<Form.Item label="邮箱" name="email" rules={[{ required: true, message: "请输入你的邮箱!" }]}>
<Input type="email" placeholder="邮箱" />
</Form.Item>
<Form.Item>
<Button type="submit" color="primary">注册</Button>
<div className="text-center mt-2">
或者 <a href="/login">立刻登录!</a>
</div>
</Form.Item>
</Form>
</>
);
};
export default Register;
src\views\Login\index.tsx
import React from "react";
import { useDispatch } from "react-redux";
import { Button, Form, Input, Toast } from "antd-mobile";
import { loginUser } from "@/store/slices/profile";
import NavHeader from "@/components/NavHeader";
import { useNavigate } from "react-router-dom";
import {RegisterLoginPayload} from "@/types/entity";
import { AppDispatch } from "@/store";
const Login = () => {
const dispatch = useDispatch<AppDispatch>();
const navigate = useNavigate();
const onFinish = async (values:RegisterLoginPayload) => {
try {
const result = await dispatch(loginUser(values)).unwrap();
sessionStorage.setItem('access_token',result.token);
Toast.show({ icon: 'success', content: '登录成功' });
navigate('/profile');
} catch (error) {
Toast.show({ icon: 'fail', content: '登录失败' });
}
};
const onFinishFailed = (errorInfo:any) => {
Toast.show({ icon: 'fail', content: `表单验证失败: ${errorInfo}` });
};
return (
<>
<NavHeader>用户登录</NavHeader>
<Form
onFinish={onFinish}
onFinishFailed={onFinishFailed}
className="p-5"
>
<Form.Item label="用户名" name="username" rules={[{ required: true, message: "请输入你的用户名!" }]}>
<Input placeholder="用户名" />
</Form.Item>
<Form.Item label="密码" name="password" rules={[{ required: true, message: "请输入你的密码!" }]}>
<Input type="password" placeholder="密码" />
</Form.Item>
<Form.Item>
<Button type="submit" color="primary">登录</Button>
<div className="text-center mt-2">
或者 <a href="/register">立刻注册!</a>
</div>
</Form.Item>
</Form>
</>
);
};
export default Login;
src\views\Profile\index.tsx
import { useEffect,useState } from "react";
+import { Button, List, Toast, Result, Mask, ImageUploader } from "antd-mobile";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from 'react-router-dom';
+import { validateProfile ,logout,uploadUserAvatar} from "@/store/slices/profile";
import type {AppDispatch} from '@/store';
import NavHeader from "@/components/NavHeader";
import { LOGIN_TYPES } from '@/constants';
const Profile = () => {
const dispatch = useDispatch<AppDispatch>();
const navigate = useNavigate();
+ const [fileList, setFileList] = useState(() => user?.avatar ? [{url: user.avatar}] : []);
const { loginState,user } = useSelector((state: any) => state.profile);
useEffect(() => {
if (loginState === LOGIN_TYPES.UN_VALIDATE) {
dispatch(validateProfile()).unwrap().catch(() => Toast.show({
icon:'fail',
content: '验证失败'
}));
}
}, [dispatch, loginState]);
const handleLogout = () => {
dispatch(logout());
Toast.show({ icon: 'success', content: '已退出登录' });
navigate('/login');
};
+ const handleUpload = async (file:File) => {
+ const result = await dispatch(uploadUserAvatar({ userId: user.id, avatar: file })).unwrap();
+ setFileList([{ url: result }]);
+ return { url: result };
+ };
+ const beforeUpload = (file: File) => {
+ const isLessThan2M = file.size / 1024 / 1024 < 2;
+ if (!isLessThan2M) {
+ Toast.show({ icon: 'fail', content: "图片必须小于2MB!" });
+ return null;
+ }
+ return file;
+ };
const renderUserInfo = () => (
<div className="p-5">
<List>
<List.Item extra={user?.username}>用户名</List.Item>
<List.Item extra={user?.email}>邮箱</List.Item>
+ <List.Item extra={
+ <ImageUploader
+ value={fileList}
+ onChange={setFileList}
+ upload={handleUpload}
+ maxCount={1}
+ accept="image/*"
+ beforeUpload={beforeUpload}
+ imageFit="cover"
+ />
+ }>头像</List.Item>
</List>
<Button color="primary" onClick={handleLogout}>退出登录</Button>
</div>
);
const renderLoginPrompt = () => (
<Result
status='warning'
title='亲爱的用户你好,你当前尚未登录,请你选择注册或者登录'
description={
<div className="text-center p-12">
<Button color="primary" onClick={() => navigate("/login")}>登录</Button>
<Button color="primary" className="ml-12" onClick={() => navigate("/register")}>注册</Button>
</div>
}
/>
);
let content = null;
switch (loginState) {
case LOGIN_TYPES.UN_VALIDATE:
content = <Mask visible={true} />;
break;
case LOGIN_TYPES.LOGINED:
content = renderUserInfo();
break;
case LOGIN_TYPES.UNLOGIN:
content = renderLoginPrompt();
break;
default:
break;
}
return (
<section>
<NavHeader>个人中心</NavHeader>
{content}
</section>
);
};
export default Profile;
src\styles\global.less
@tailwind base;
@tailwind components;
@tailwind utilities;
+span.adm-image-uploader-upload-button-icon>svg{
+ display: inline;
+}
src\api\profile.ts
import axios from ".";
import {RegisterLoginPayload} from "@/types/entity"
export function validate() {
return axios.get("/user/validate");
}
export const register = (values:RegisterLoginPayload) => {
return axios.post('/user/register', values);
};
export const login = (values:RegisterLoginPayload) => {
return axios.post('/user/login', values);
};
+export const uploadAvatar = (userId: string, avatar: File) => {
+ const formData = new FormData();
+ formData.append('userId', userId);
+ formData.append('avatar', avatar, avatar.name);
+ return axios.post('/user/uploadAvatar', formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data'
+ }
+ });
+};
src\store\slices\profile.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { LOGIN_TYPES } from '@/constants';
+import { validate,register,login,uploadAvatar} from '@/api/profile';
import {RegisterLoginPayload,User} from "@/types/entity"
interface ProfileState {
loginState: LOGIN_TYPES;
user: User | null;
error: any;
}
const initialState: ProfileState = {
loginState: LOGIN_TYPES.UN_VALIDATE,
user: null,
error: null
};
export const validateProfile = createAsyncThunk(
'profile/validate',
async (_, { rejectWithValue }) => {
try {
const response = await validate();
return response.data;
} catch (error:any) {
return rejectWithValue(error.response.data);
}
}
);
export const registerUser = createAsyncThunk(
'profile/register',
async (values: RegisterLoginPayload, { rejectWithValue }) => {
try {
const response = await register(values);
return response.data;
} catch (error: any) {
return rejectWithValue(error.response.data);
}
}
);
export const loginUser = createAsyncThunk(
'profile/login',
async (values: RegisterLoginPayload, { rejectWithValue }) => {
try {
const response = await login(values);
return response.data;
} catch (error: any) {
return rejectWithValue(error.response.data);
}
}
);
+export const uploadUserAvatar = createAsyncThunk(
+ 'profile/uploadAvatar',
+ async ({ userId, avatar }: { userId: string, avatar: File }, { rejectWithValue }) => {
+ try {
+ const response = await uploadAvatar(userId, avatar);
+ return response.data;
+ } catch (error: any) {
+ return rejectWithValue(error.response.data);
+ }
+ }
+);
const profileSlice = createSlice({
name: 'profile',
initialState,
reducers: {
logout(state) {
state.user = null;
state.loginState = LOGIN_TYPES.UNLOGIN;
sessionStorage.removeItem('access_token');
},
},
extraReducers: (builder) => {
builder.addCase(validateProfile.fulfilled, (state, action) => {
state.loginState = LOGIN_TYPES.LOGINED;
state.user = action.payload;
state.error = null;
});
builder.addCase(validateProfile.rejected, (state, action) => {
state.loginState = LOGIN_TYPES.UNLOGIN;
state.user = null;
state.error = action.payload;
})
.addCase(registerUser.fulfilled, (state, action) => {
})
.addCase(registerUser.rejected, (state, action) => {
})
.addCase(loginUser.fulfilled, (state, action) => {
state.user = action.payload.user;
state.loginState = LOGIN_TYPES.LOGINED;
})
.addCase(loginUser.rejected, (state, action) => {
state.error = action.payload;
state.loginState = LOGIN_TYPES.UNLOGIN;
});
}
});
export const { logout } = profileSlice.actions;
export default profileSlice.reducer;
src\views\Home\index.tsx
import React,{useRef,useEffect} from 'react';
import HomeHeader from './components/HomeHeader';
import HomeSwiper from "./components/HomeSwiper";
import LessonList from "./components/LessonList";
import { useCategory, useFetchSliders, useFetchLessons } from './hooks';
+import { loadMoreOnScroll, pullRefresh, throttle } from '@/utils';
import {DotLoading } from 'antd-mobile';
const Home: React.FC = () => {
const { currentCategory, handleChangeCategory } = useCategory();
const { sliders } = useFetchSliders();
const { lessons, fetchMoreLessons,fetchLessons } = useFetchLessons();
const contentRef = useRef<HTMLDivElement>(null);
+ const lessonListRef = useRef<() => void>(() => {});
useEffect(() => {
if (contentRef.current) {
loadMoreOnScroll(contentRef.current, fetchMoreLessons);
pullRefresh(contentRef.current, fetchLessons);
+ contentRef.current.addEventListener("scroll", throttle(() => lessonListRef.current(), 13));
+ contentRef.current.addEventListener('scroll', () => {
+ sessionStorage.setItem('scrollTop', contentRef.current!.scrollTop.toString());
+ });
+ const scrollTop = sessionStorage.getItem('scrollTop');
+ if (scrollTop) {
+ contentRef.current.scrollTop = +scrollTop;
+ lessonListRef.current();
+ }
}
}, []);
return (
<>
<div className="fixed top-[50px] w-full text-center text-6xl">
<DotLoading />
</div>
<HomeHeader
currentCategory={currentCategory}
changeCurrentCategory={handleChangeCategory}
/>
<div ref={contentRef} className="fixed top-10 bottom-10 left-0 w-full overflow-y-auto bg-white">
{sliders.length>0&&<HomeSwiper sliders={sliders} />}
<LessonList
lessons={lessons}
fetchMoreLessons={fetchMoreLessons}
+ ref={lessonListRef}
+ homeContainerRef={contentRef}
/>
</div>
</>
);
}
export default Home;
src\views\Home\components\LessonList.tsx
+import React, { useReducer, useEffect, forwardRef } from "react";
+import { Image, Button, Card, Skeleton, NoticeBar } from "antd-mobile";
+import { Property } from 'csstype';
import { Link } from "react-router-dom";
import { Lessons } from "@/store/slices/home";
interface LessonListProps {
lessons: Lessons;
fetchMoreLessons: () => void;
+ homeContainerRef: React.RefObject<HTMLDivElement>;
}
+function getScale(){
+ const remSize = parseFloat(document.documentElement.style.fontSize);
+ const scale = (remSize / 20);
+ return scale;
+}
+const position: Property.Position = 'absolute';
+const LessonList = forwardRef<() => void, LessonListProps>(({ lessons, fetchMoreLessons, homeContainerRef }, ref) => {
+ const [, forceUpdate] = useReducer(x => x + 1, 0);
+ useEffect(() => {
+ if (lessons.list.length === 0) {
+ fetchMoreLessons();
+ }
+ (ref as React.MutableRefObject<() => void>).current = forceUpdate;
+ }, []);
+ const scale = getScale();
+ const itemSize = 300 *scale;
+ const screenHeight = window.innerHeight - 100 * scale;
+ const homeContainer = homeContainerRef.current;
+ let start = 0, end = 0;
+ const scrollTop = homeContainer ? Math.max(homeContainer.scrollTop - 160*scale, 0) : 0;
+ start = Math.floor(scrollTop / itemSize);
+ end = start + Math.floor(screenHeight / itemSize);
+ start = Math.max(start - 2, 0);
+ end = Math.min(end + 2, lessons.list.length);
+ const visibleList = lessons.list.map((item, index) => ({ ...item, index })).slice(start, end);
+ const style = { position, top: 0, left: 0, width: '100%', height: itemSize + 'px' };
+ const bottomTop = lessons.list.length * itemSize + 'px';
+ return (
+ <section>
+ {visibleList.length > 0 ? (
+ <div style={{ position: 'relative', width: '100%', height: bottomTop }}>
+ {visibleList.map((lesson) => (
+ <Link
+ style={{ ...style, top: `${itemSize * lesson.index}px` }}
+ key={lesson.id}
+ to={{ pathname: `/detail/${lesson.id}` }}
+ state={lesson}
+ >
+ <Card headerStyle={{ display: 'flex', justifyContent: 'center' }} title={lesson.title}>
+ <Image src={lesson.poster} />
+ </Card>
+ </Link>
+ ))}
+ {lessons.hasMore && (
+ <Button
+ style={{ textAlign: "center", position: 'absolute', top: bottomTop }}
+ onClick={fetchMoreLessons}
+ loading={lessons.loading}
+ block
+ >
+ {lessons.loading ? "" : "加载更多"}
+ </Button>
+ )}
+ {!lessons.hasMore && (
+ <NoticeBar style={{ width: '100%', position: 'absolute', top: bottomTop,display:'flex',justifyContent:'center' }} content="到底了" color="alert" />
+ )}
+ </div>
+ ) : (
+ <Skeleton.Title animated />
+ )}
+ </section>
+ );
+})
export default LessonList;
src\styles\global.less
@tailwind base;
@tailwind components;
@tailwind utilities;
span.adm-image-uploader-upload-button-icon>svg{
display: inline;
}
+.adm-notice-bar span.adm-notice-bar-content{
+ flex:none;
+}
src\index.tsx
+import React from 'react';
import ReactDOM from 'react-dom/client';
import { Routes, Route } from "react-router-dom";
import { HistoryRouter } from "redux-first-history/rr6";
+import {Mask} from "antd-mobile";
import { Provider } from "react-redux";
import "./styles/global.less";
import Tabs from "./components/Tabs";
import { store, history } from "./store";
+const Home = React.lazy(() => import("./views/Home"));
+const Cart = React.lazy(() => import("./views/Cart"));
+const Profile = React.lazy(() => import("./views/Profile"));
+const Register = React.lazy(() => import("./views/Register"));
+const Login = React.lazy(() => import("./views/Login"));
+const Detail = React.lazy(() => import("./views/Detail"));
const rootElement = document.getElementById('root');
if(rootElement){
const root = ReactDOM.createRoot(rootElement);
root.render(
<Provider store={store}>
<HistoryRouter history={history}>
+ <React.Suspense fallback={<Mask visible={true} />}>
<main className="pt-10 pb-10 w-full">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/Cart" element={<Cart />} />
<Route path="/profile" element={<Profile />} />
<Route path="/detail/:id" element={<Detail/>} />
<Route path="/register" element={<Register/>} />
<Route path="/login" element={<Login/>} />
</Routes>
<Tabs />
</main>
+ </React.Suspense>
</HistoryRouter>
</Provider>
);
}
npm install bcryptjs body-parser cors dotenv express helmet http-status-codes jsonwebtoken mongoose morgan multer ts-node tsconfig-paths validator
npm install --save-dev @types/bcryptjs @types/cors @types/express @types/helmet @types/jsonwebtoken @types/morgan @types/multer @types/node @types/validator cross-env nodemon ts-node-dev typescript @types/mongoose
src\index.ts
import express, { Express, Request, Response, NextFunction } from "express";
import mongoose from "mongoose";
import HttpException from "./exceptions/HttpException";
import cors from "cors";
import morgan from "morgan";
import helmet from "helmet";
import errorMiddleware from "./middlewares/errorMiddleware";
import * as sliderController from './controller/slider';
import * as lessonController from "./controller/lesson";
import path from "path";
import { Slider} from './models';
const app: Express = express();
app.use(morgan("dev"));
app.use(cors());
app.use(helmet());
app.use(express.static(path.resolve(__dirname, "public")));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.get("/", (_req: Request, res: Response) => {
res.json({ success: true, message: "hello world" });
});
app.get('/slider/list', sliderController.list);
app.get('/lesson/list', lessonController.list);
app.get("/lesson/:id", lessonController.get);
app.use((_req: Request, _res: Response, next: NextFunction) => {
const error: HttpException = new HttpException(404, "Route not found");
next(error);
});
app.use(errorMiddleware);
const PORT: number = 9898;
(async function () {
const dbURL = "mongodb://127.0.0.1/zhufengketang";
await mongoose.connect(dbURL);
await createSliders();
app.listen(PORT, () => {
console.log(`Running on http://localhost:${PORT}`);
});
})();
async function createSliders() {
const sliders = await Slider.find();
if (sliders.length == 0) {
const sliders:any = [
{ url: 'http://img.zhufengpeixun.cn/post_reactnative.png' },
{ url: 'http://img.zhufengpeixun.cn/post_react.png' },
{ url: 'http://img.zhufengpeixun.cn/post_vue.png' },
{ url: 'http://img.zhufengpeixun.cn/post_wechat.png' },
{ url: 'http://img.zhufengpeixun.cn/post_architect.jpg' }
];
Slider.create(sliders);
}
}
src\exceptions\HttpException.ts
class HttpException extends Error {
constructor(public status: number, public message: string, public errors?: any) {
super(message);
}
}
export default HttpException;
src\middlewares\errorMiddleware.ts
import HttpException from "../exceptions/HttpException";
import { Request, Response, NextFunction } from "express";
import statusCodes from "http-status-codes";
const errorMiddleware = (
error: HttpException,
_request: Request,
response: Response,
_next: NextFunction
) => {
response.status(error.status || statusCodes.INTERNAL_SERVER_ERROR).send({
success: false,
message: error.message,
errors: error.errors,
});
};
export default errorMiddleware;
src\controller\slider.ts
import { Request, Response } from "express";
import { ISliderDocument, Slider } from "../models";
export const list = async (_req: Request, res: Response) => {
let sliders: ISliderDocument[] = await Slider.find();
res.json({ success: true, data: sliders });
};
src\models\slider.ts
import mongoose, { Schema, Document } from "mongoose";
export interface ISliderDocument extends Document {
url: string;
}
const SliderSchema: Schema<ISliderDocument> = new Schema(
{
url: String,
},
{ timestamps: true }
);
export const Slider = mongoose.model < ISliderDocument > ("Slider", SliderSchema);
src\models\index.ts
export * from './slider';
tsconfig.json
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"lib": [
"dom",
"es6",
"es2017",
"esnext.asynciterable"
],
"sourceMap": true,
"outDir": "./dist",
"moduleResolution": "node",
"removeComments": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"resolveJsonModule": true,
"baseUrl": "."
},
"exclude": [
"node_modules"
],
"include": [
"./src/**/*.tsx",
"./src/**/*.ts"
]
}
package.json
{
"scripts": {
"build": "tsc",
"start": "ts-node-dev --respawn src/index.ts",
"dev": "nodemon --exec ts-node --files src/index.ts"
}
}
src\index.ts
import express, { Express, Request, Response, NextFunction } from "express";
import mongoose from "mongoose";
import HttpException from "./exceptions/HttpException";
import cors from "cors";
import morgan from "morgan";
import helmet from "helmet";
import errorMiddleware from "./middlewares/errorMiddleware";
import * as sliderController from './controller/slider';
+import * as lessonController from "./controller/lesson";
import path from "path";
import { Slider,Lesson } from './models';
const app: Express = express();
app.use(morgan("dev"));
app.use(cors());
app.use(helmet());
app.use(express.static(path.resolve(__dirname, "public")));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.get("/", (_req: Request, res: Response) => {
res.json({ success: true, message: "hello world" });
});
app.get('/slider/list', sliderController.list);
+app.get('/lesson/list', lessonController.list);
+app.get("/lesson/:id", lessonController.get);
app.use((_req: Request, _res: Response, next: NextFunction) => {
const error: HttpException = new HttpException(404, "Route not found");
next(error);
});
app.use(errorMiddleware);
const PORT: number = 9898;
(async function () {
const dbURL = "mongodb://127.0.0.1/zhufengketang";
await mongoose.connect(dbURL);
await createSliders();
await createLessons();
app.listen(PORT, () => {
console.log(`Running on http://localhost:${PORT}`);
});
})();
async function createSliders() {
const sliders = await Slider.find();
if (sliders.length == 0) {
const sliders:any = [
{ url: 'http://img.zhufengpeixun.cn/post_reactnative.png' },
{ url: 'http://img.zhufengpeixun.cn/post_react.png' },
{ url: 'http://img.zhufengpeixun.cn/post_vue.png' },
{ url: 'http://img.zhufengpeixun.cn/post_wechat.png' },
{ url: 'http://img.zhufengpeixun.cn/post_architect.jpg' }
];
Slider.create(sliders);
}
}
async function createLessons() {
const lessons = await Lesson.find();
if (lessons.length == 0) {
const lessons: any = [
{
order: 1,
title: "1.React全栈架构",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/react_poster.jpg",
url: "http://img.zhufengpeixun.cn/react_url.png",
price: "¥100.00元",
category: "react",
},
{
order: 2,
title: "2.React全栈架构",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/react_poster.jpg",
url: "http://img.zhufengpeixun.cn/react_url.png",
price: "¥200.00元",
category: "react",
},
{
order: 3,
title: "3.React全栈架构",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/react_poster.jpg",
url: "http://img.zhufengpeixun.cn/react_url.png",
price: "¥300.00元",
category: "react",
},
{
order: 4,
title: "4.React全栈架构",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/react_poster.jpg",
url: "http://img.zhufengpeixun.cn/react_url.png",
price: "¥400.00元",
category: "react",
},
{
order: 5,
title: "5.React全栈架构",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/react_poster.jpg",
url: "http://img.zhufengpeixun.cn/react_url.png",
price: "¥500.00元",
category: "react",
},
{
order: 6,
title: "6.Vue从入门到项目实战",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/vue_poster.png",
url: "http://img.zhufengpeixun.cn/vue_url.png",
price: "¥100.00元",
category: "vue",
},
{
order: 7,
title: "7.Vue从入门到项目实战",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/vue_poster.png",
url: "http://img.zhufengpeixun.cn/vue_url.png",
price: "¥200.00元",
category: "vue",
},
{
order: 8,
title: "8.Vue从入门到项目实战",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/vue_poster.png",
url: "http://img.zhufengpeixun.cn/vue_url.png",
price: "¥300.00元",
category: "vue",
},
{
order: 9,
title: "9.Vue从入门到项目实战",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/vue_poster.png",
url: "http://img.zhufengpeixun.cn/vue_url.png",
price: "¥400.00元",
category: "vue",
},
{
order: 10,
title: "10.Vue从入门到项目实战",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/vue_poster.png",
url: "http://img.zhufengpeixun.cn/vue_url.png",
price: "¥500.00元",
category: "vue",
},
{
order: 11,
title: "11.React全栈架构",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/react_poster.jpg",
url: "http://img.zhufengpeixun.cn/react_url.png",
price: "¥600.00元",
category: "react",
},
{
order: 12,
title: "12.React全栈架构",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/react_poster.jpg",
url: "http://img.zhufengpeixun.cn/react_url.png",
price: "¥700.00元",
category: "react",
},
{
order: 13,
title: "13.React全栈架构",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/react_poster.jpg",
url: "http://img.zhufengpeixun.cn/react_url.png",
price: "¥800.00元",
category: "react",
},
{
order: 14,
title: "14.React全栈架构",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/react_poster.jpg",
url: "http://img.zhufengpeixun.cn/react_url.png",
price: "¥900.00元",
category: "react",
},
{
order: 15,
title: "15.React全栈架构",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/react_poster.jpg",
url: "http://img.zhufengpeixun.cn/react_url.png",
price: "¥1000.00元",
category: "react",
},
{
order: 16,
title: "16.Vue从入门到项目实战",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/vue_poster.png",
url: "http://img.zhufengpeixun.cn/vue_url.png",
price: "¥600.00元",
category: "vue",
},
{
order: 17,
title: "17.Vue从入门到项目实战",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/vue_poster.png",
url: "http://img.zhufengpeixun.cn/vue_url.png",
price: "¥700.00元",
category: "vue",
},
{
order: 18,
title: "18.Vue从入门到项目实战",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/vue_poster.png",
url: "http://img.zhufengpeixun.cn/vue_url.png",
price: "¥800.00元",
category: "vue",
},
{
order: 19,
title: "19.Vue从入门到项目实战",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/vue_poster.png",
url: "http://img.zhufengpeixun.cn/vue_url.png",
price: "¥900.00元",
category: "vue",
},
{
order: 20,
title: "20.Vue从入门到项目实战",
video: "http://img.zhufengpeixun.cn/gee2.mp4",
poster: "http://img.zhufengpeixun.cn/vue_poster.png",
url: "http://img.zhufengpeixun.cn/vue_url.png",
price: "¥1000.00元",
category: "vue",
},
];
Lesson.create(lessons);
}
}
src\controller\lesson.ts
import { Request, Response } from "express";
import { ILessonDocument, Lesson } from "../models";
import {FilterQuery} from 'mongoose';
export const list = async (req: Request, res: Response) => {
let { category } = req.query;//查询参数里会有一个category 类型
let offset: any = req.query.offset;//偏移量
let limit: any = req.query.limit;//限制的条数
offset = isNaN(offset) ? 0 : parseInt(offset); //偏移量
limit = isNaN(limit) ? 5 : parseInt(limit); //每页条数
let query: FilterQuery<ILessonDocument> = {};
if (category && category != "all") query.category = category as string;
let total = await Lesson.countDocuments(query);//计算总条数
let list:any = await Lesson.find(query)//课程列表
.sort({ order: 1 })//排序
.skip(offset)//跳过指定的条数
.limit(limit);//限定返回的条数
list = list.map((item:ILessonDocument)=>item.toJSON());
setTimeout(function () {
//code:0表示成功 list本页的数据 hasMore 是否还有更多数据
res.json({ code: 0, data: { list, hasMore: total > offset + limit } });
}, 1000);
};
export const get = async (req: Request, res: Response) => {
let id = req.params.id;
let lesson = await Lesson.findById(id);
res.json({ success: true, data: lesson });
};
src\models\lesson.ts
import mongoose, { Schema, Document } from "mongoose";
export interface ILessonDocument extends Document {
order: number; //顺序
title: string; //标题
video: string; //视频
poster: string; //海报
url: string; //url地址
price: string; //价格
category: string; //分类
}
const LessonSchema: Schema<ILessonDocument> = new Schema(
{
order: Number, //顺序
title: String, //标题
video: String, //视频
poster: String, //海报
url: String, //url地址
price: String, //价格
category: String, //分类
},
{ timestamps: true,toJSON:{
transform(_doc,ret){
ret.id=ret._id;
delete ret._id;
delete ret.__v;
delete ret.password;
return ret;
}
} }
);
export const Lesson = mongoose.model < ILessonDocument > ("Lesson", LessonSchema);
src\models\index.ts
export * from './slider';
+export * from './lesson';
src\index.ts
import express, { Express, Request, Response, NextFunction } from "express";
import mongoose from "mongoose";
import cors from "cors";
import morgan from "morgan";
import helmet from "helmet";
+import path from "path";
+import multer from "multer";
+import dotenv from "dotenv";
import HttpException from "./exceptions/HttpException";
import errorMiddleware from "./middlewares/errorMiddleware";
import * as sliderController from './controller/slider';
import * as lessonController from "./controller/lesson";
+import * as userController from "./controller/user";
import { Slider } from './models';
+dotenv.config();
+const storage = multer.diskStorage({
+ destination: path.join(__dirname, "public", "uploads"),
+ filename(_req: Request, file: Express.Multer.File, cb) {
+ cb(null, Date.now() + path.extname(file.originalname));
+ },
+});
+const upload = multer({ storage });
const app: Express = express();
app.use(morgan("dev"));
app.use(cors());
app.use(helmet());
app.use(express.static(path.resolve(__dirname, "public"),{
+ setHeaders(res){
+ res.setHeader("Cross-Origin-Resource-Policy","cross-origin");
+ }
}));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.get("/", (_req: Request, res: Response) => {
res.json({ success: true, message: "hello world" });
});
app.get('/slider/list', sliderController.list);
app.get('/lesson/list', lessonController.list);
app.get("/lesson/:id", lessonController.get);
+app.get("/user/validate", userController.validate);
+app.post("/user/register", userController.register);
+app.post("/user/login", userController.login);
+app.post("/user/uploadAvatar",upload.single("avatar"),userController.uploadAvatar);
app.use((_req: Request, _res: Response, next: NextFunction) => {
const error: HttpException = new HttpException(404, "Route not found");
next(error);
});
app.use(errorMiddleware);
const PORT: number = 9898;
(async function () {
const dbURL = "mongodb://127.0.0.1/zhufengketang";
await mongoose.connect(dbURL);
await createSliders();
app.listen(PORT, () => {
console.log(`Running on http://localhost:${PORT}`);
});
})();
async function createSliders() {
const sliders = await Slider.find();
if (sliders.length == 0) {
const sliders:any = [
{ url: 'http://img.zhufengpeixun.cn/post_reactnative.png' },
{ url: 'http://img.zhufengpeixun.cn/post_react.png' },
{ url: 'http://img.zhufengpeixun.cn/post_vue.png' },
{ url: 'http://img.zhufengpeixun.cn/post_wechat.png' },
{ url: 'http://img.zhufengpeixun.cn/post_architect.jpg' }
];
Slider.create(sliders);
}
}
src\models\index.ts
export * from './slider';
export * from './lesson';
+export * from "./user";
src\models\user.ts
import mongoose, { Schema, Model, Document } from 'mongoose';
import validator from 'validator';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
export interface IUserDocument extends Document {
username: string;
password: string;
email: string;
avatar: string;
generateToken: () => string;
}
const UserSchema = new Schema<IUserDocument>({
username: {
type: String,
required: [true, '用户名不能为空'],
minlength: [6, '最小长度不能少于6位'],
maxlength: [12, '最大长度不能大于12位']
},
password: { type: String, required: true },
avatar: String,
email: {
type: String,
required: true,
validate: [validator.isEmail, '无效的邮箱地址'],
trim: true,
}
}, {
timestamps: true, toJSON: {
transform(_doc, ret) {
ret.id = ret._id;
delete ret._id;
delete ret.__v;
delete ret.password;
return ret;
}
}
});
UserSchema.methods.generateToken = function () {
const payload = { id: this._id };
console.log('process.env.JWT_SECRET_KEY', process.env.JWT_SECRET_KEY);
return jwt.sign(payload, process.env.JWT_SECRET_KEY!, { expiresIn: '1h' });
};
UserSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 10);
next();
});
UserSchema.statics.login = async function (username, password) {
const user = await this.findOne({ username });
if (user && await bcrypt.compare(password, user.password)) {
return user;
}
return null;
};
interface IUserModel<T extends Document> extends Model<T> {
login: (username: string, password: string) => IUserDocument | null
}
export const User: IUserModel<IUserDocument> = mongoose.model<IUserDocument, IUserModel<IUserDocument>>('User', UserSchema);
src\controller\user.ts
import { Request, Response, NextFunction } from 'express';
import { validateRegisterInput } from '../utils/validator';
import HttpException from '../exceptions/HttpException';
import StatusCodes from 'http-status-codes';
import { IUserDocument, User } from '../models/user';
import { UserPayload } from '../typings/jwt';
import jwt from 'jsonwebtoken';
export const validate = async (req: Request, res: Response, next: NextFunction) => {
const authorization = req.headers.authorization;
if (!authorization) {
return next(new HttpException(StatusCodes.UNAUTHORIZED, 'authorization未提供'));
}
const token = authorization.split(' ')[1];
if (!token) {
return next(new HttpException(StatusCodes.UNAUTHORIZED, 'token未提供'));
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET_KEY!) as UserPayload;
const user = await User.findById(payload.id);
if (!user) {
return next(new HttpException(StatusCodes.UNAUTHORIZED, '用户不合法'));
}
res.json({ success: true, data: user });
} catch (error) {
next(new HttpException(StatusCodes.UNAUTHORIZED, 'token不合法'));
}
};
export const register = async (req: Request, res: Response, next: NextFunction) => {
try {
let { username, password, confirmPassword, email } = req.body;
const { valid, errors } = validateRegisterInput(username, password, confirmPassword, email);
if (!valid) {
throw new HttpException(StatusCodes.UNPROCESSABLE_ENTITY, `参数验证失败!`, errors);
}
let user: IUserDocument = new User({
username,
email,
password
});
let oldUser: IUserDocument | null = await User.findOne({ username: user.username });
if (oldUser) {
throw new HttpException(StatusCodes.UNPROCESSABLE_ENTITY, `用户名重复!`);
}
await user.save();
let token = user.generateToken();
res.json({
success: true,
data: { token }
});
} catch (error) {
next(error);
}
}
export const login = async (req: Request, res: Response, next: NextFunction) => {
try {
let { username, password } = req.body;
let user = await User.login(username, password);
if (user) {
let token = user.generateToken();
res.json({
success: true,
data: {token,user:user.toJSON()}
});
} else {
throw new HttpException(StatusCodes.UNAUTHORIZED, `登录失败`);
}
} catch (error) {
next(error);
}
}
export const uploadAvatar = async (req: Request, res: Response, next: NextFunction) => {
if (!req.file) {
return next(new HttpException(StatusCodes.BAD_REQUEST, '未上传文件'));
}
const userId = req.body.userId;
const domain = process.env.DOMAIN || `${req.protocol}://${req.get('host')}`;
const avatar = `${domain}/uploads/${req.file.filename}`;
await User.findByIdAndUpdate(userId, { avatar });
res.json({ success: true, data: avatar });
};
src\typings\express.d.ts
import { IUserDocument } from "../models/user";
declare global {
namespace Express {
export interface Request {
currentUser?: IUserDocument | null;
file?: Multer.File
}
}
}
src\typings\jwt.ts
import { IUserDocument } from "../models/user";
export interface UserPayload {
id: IUserDocument['_id']
}
src\utils\validator.ts
import validator from "validator";
import { IUserDocument } from "../models/user";
export interface RegisterInput extends Partial<IUserDocument> {
confirmPassword?: string;
}
export interface RegisterInputValidateResult {
errors: RegisterInput;
valid: boolean;
}
export const validateRegisterInput = (username:string, password:string, confirmPassword:string, email:string) => {
const errors:Record<string,string> = {};
if (validator.isEmpty(username)) {
errors.username = '用户名不能为空';
}
if (!validator.isLength(username, { min: 6, max: 12 })) {
errors.username = '用户名长度必须在6到12个字符之间';
}
if (validator.isEmpty(password)) {
errors.password = '密码不能为空';
}
if (!validator.equals(password, confirmPassword)) {
errors.confirmPassword = '两次输入的密码不一致';
}
if (!validator.isEmail(email)) {
errors.email = '邮箱格式不正确';
}
return {
errors,
valid: Object.keys(errors).length === 0
};
};
在 Tailwind CSS 中,@tailwind
指令是用来在你的 CSS 文件中引入 Tailwind 的各个部分。Tailwind 主要分为三大部分:base
、components
和 utilities
。下面是对每一部分的简单讲解:
@tailwind base;
这条指令引入了 Tailwind 的基础样式。基础样式包括了一组样式重置和默认样式,这些都是构建在 Tailwind 之上的网站所共有的。这包括对标准 HTML 元素的样式定义,比如设置默认的字体族、段落间距、标题大小等。
使用 @tailwind base;
会在生成的 CSS 中包含这些基础样式,确保你的页面在不同浏览器中有一致的外观和感觉。
@tailwind components;
这条指令用于引入 Tailwind CSS 组件的样式。在 Tailwind CSS 中,组件不是指 UI 组件库中的组件,而是指一些预先设计好的、可复用的 CSS 类集合。这些类可以用于构建常见的 UI 元素,如按钮、表单、导航栏等。
在你的项目中,你可以自定义这些组件类,并使用 @apply
指令来应用 Tailwind 的实用程序类。@tailwind components;
指令确保这些自定义组件的样式被正确加载和应用。
@tailwind utilities;
最后,@tailwind utilities;
指令用于引入 Tailwind 的实用程序类。这是 Tailwind CSS 的核心部分,包含了成千上万的低级实用程序类,用于设置边距、颜色、字体大小、布局等。
这些实用程序类设计得非常灵活,可以通过组合它们来快速构建复杂的设计。由于它们是低级的,因此你可以通过组合少量的类来精确控制元素的每一个样式属性。
当然,这里是对您提供的 Tailwind CSS 类名的解释:
fixed: 应用 position: fixed;
,元素相对于浏览器窗口定位。
absolute: 应用 position: absolute;
,元素相对于最近的相对定位(非 static)祖先元素定位。
flex: 应用 display: flex;
,使元素成为弹性容器。
justify-between: 在弹性容器内,子元素之间的空间分布均匀。
items-center: 在弹性容器内,子元素在交叉轴(通常是垂直方向)上居中对齐。
top-0: 元素的顶部边缘与包含块顶部对齐。
left-0: 元素的左边缘与包含块左侧对齐。
w-full: 元素宽度设置为100%。
w-20: 元素宽度设置为 5rem(80px,如果基础字体大小为16px)。
h-10: 元素高度设置为 2.5rem(40px,如果基础字体大小为16px)。
z-50: 元素的 z-index
设置为50。
bg-gray-800: 元素背景色设置为深灰色(Tailwind 的灰色色谱中的一个较深的颜色)。
text-white: 文本颜色设置为白色。
ml-5: 元素的左外边距设置为1.25rem(20px,如果基础字体大小为16px)。
mr-5: 元素的右外边距设置为1.25rem(20px,如果基础字体大小为16px)。
text-base: 文本大小设置为基础大小(通常是1rem或16px)。
transition-opacity: 应用渐变效果,只影响不透明度。
opacity-10: 元素的不透明度设置为10%。
py-1: 元素的垂直内边距(上下内边距)设置为0.25rem(4px,如果基础字体大小为16px)。
text-center: 文本居中对齐。
text-red-500: 文本颜色设置为中等亮度的红色(Tailwind 的红色色谱中的一个颜色)。
当你在 TypeScript 中使用 declare module 'redux-first-history/rr6';
语句时,你正在创建一个模块声明。这种声明是 TypeScript 的一个特性,用于告诉 TypeScript 编译器关于一个模块的信息,特别是当这个模块没有自己的类型声明时。
模块声明的作用
告知 TypeScript 关于模块的存在:
当 TypeScript 编译器处理到导入语句(例如 import something from 'redux-first-history/rr6'
)时,它会尝试找到对应模块的类型信息。如果模块没有附带类型声明(这在很多 JavaScript 库中很常见),TypeScript 编译器默认会将这个模块的所有导出视为 any
类型,这意味着你失去了类型检查的好处。
为模块提供自定义的类型信息:
通过 declare module
语句,你可以为特定模块提供自定义的类型信息。这在使用没有 TypeScript 支持的第三方库时非常有用。
用法
declare module 'redux-first-history/rr6';
'redux-first-history/rr6'
的模块的声明。any
类型。高级用法
如果你熟悉这个模块的 API,并希望提供更具体的类型信息,你可以在声明中添加这些信息。例如:
declare module 'redux-first-history/rr6' {
export function someFunction(): string;
export class SomeClass {}
}
在这个例子中,我们声明了 redux-first-history/rr6
模块提供了一个名为 someFunction
的函数和一个名为 SomeClass
的类。
总结
使用 declare module
语句是在 TypeScript 中处理没有类型声明的 JavaScript 模块的一种有效方式。它允许你告知 TypeScript 编译器这个模块的存在,以及(如果需要)提供关于模块 API 的具体类型信息。这样可以使你的 TypeScript 项目能够更灵活地与各种 JavaScript 库协同工作。