1.搭建开发环境 #

1.1 创建项目 #

mkdir ketangclient
cd ketangclient
npm init -y

1.2 安装依赖 #

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

1.3 webpack.config.js #

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
      }

};

1.4 tsconfig.json #

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/' 目录。

1.5 src\index.tsx #

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 />);

1.6 src\index.html #

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>

1.7 package.json #

package.json

{
  "scripts": {
    "dev": "cross-env NODE_ENV=development webpack serve",
    "build": "cross-env NODE_ENV=production webpack"
  },
}

2.移动端适配 #

2.1 生成配置文件 #

npx tailwindcss init -p

2.2 webpack.config.js #

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
      }
};

2.3 src\index.tsx #

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 />);

2.4 global.less #

src\styles\global.less

@tailwind base;
@tailwind components;
@tailwind utilities;

2.5 postcss.config.js #

postcss.config.js

module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}

2.6 tailwind.config.js #

tailwind.config.js

module.exports = {
  content: [
    './src/**/*.{js,jsx,ts,tsx}'
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

3.实现底部路由 #

3.1 src\index.html #

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>

3.2 setRemUnit.js #

public\setRemUnit.js

let docEle = document.documentElement;
function setRemUnit() {
  docEle.style.fontSize = docEle.clientWidth*2 / 37.5 + "px";
}
setRemUnit();
window.addEventListener("resize", setRemUnit);

3.3 src\index.tsx #

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>
+);

3.4 Tabs\index.tsx #

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;

3.5 Home\index.tsx #

src\views\Home\index.tsx

import React from 'react';
const Home: React.FC = () => {
    return <div>Home</div>;
}
export default Home;

3.6 Cart\index.tsx #

src\views\Cart\index.tsx

import React from 'react';
const Cart: React.FC = () => {
    return <div>Cart</div>;
}
export default Cart;

3.7 Profile\index.tsx #

src\views\Profile\index.tsx

import React from 'react';
const Profile: React.FC = () => {
    return <div>Profile</div>;
}
export default Profile;

4.首页头部导航布局 #

4.1 tsconfig.json #

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"
  ]
}

4.2 images.d.ts #

src\types\images.d.ts

declare module '*.png';

4.3 Home\index.tsx #

src\views\Home\index.tsx

import React from 'react';
+import HomeHeader from './components/HomeHeader';
const Home: React.FC = () => {
    return (
        <>
+        <HomeHeader/>
        </>
    );
}
export default Home;

4.4 HomeHeader.tsx #

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;

5.创建Redux仓库 #

5.1 src\index.tsx #

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>
);

5.2 Home\index.tsx #

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;

5.3 HomeHeader.tsx #

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;

5.4 history.ts #

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
}

5.5 home.ts #

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;

5.6 cart.ts #

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;

5.7 profile.ts #

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;

5.8 store\index.ts #

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;

6.前台轮播图 #

6.1 api\index.ts #

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;

6.2 home.ts #

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");
}

6.3 home.ts #

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;

6.4 response.ts #

src\types\response.ts

interface ResponseBody<T> {
  success: boolean;
  data: T;
}

6.5 Home\index.tsx #

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;

6.6 HomeHeader.tsx #

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;

6.7 HomeSwiper.tsx #

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;

6.8 hooks\index.ts #

src\views\Home\hooks\index.ts

export {default as useFetchSliders} from './useFetchSliders';
export {default as useCategory} from './useCategory';

6.9 useCategory.ts #

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;

6.10 useFetchSliders.ts #

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;

7.课程列表 #

7.1 home.ts #

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}`
+  );
+}

7.2 home.ts #

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;

7.3 Home\index.tsx #

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;

7.4 LessonList.tsx #

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;

7.5 useFetchLessons.ts #

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;

7.6 hooks\index.ts #

src\views\Home\hooks\index.ts

export {default as useFetchSliders} from './useFetchSliders';
export {default as useCategory} from './useCategory';
+export {default as useFetchLessons} from './useFetchLessons';

8.上拉加载 #

8.1 Home\index.tsx #

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;

8.2 home.ts #

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;

8.3 loadMoreUtils.ts #

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);
    };
}

9.下拉刷新 #

9.1 Home\index.tsx #

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;

9.2 pullRefreshUtils.ts #

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;
    }
  };
}

10.课程详情 #

10.1 src\index.tsx #

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>
    );
}

10.2 Detail\index.tsx #

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;

10.3 home.ts #

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}`);
+}

11.头部导航 #

11.1 Detail\index.tsx #

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;

11.2 HomeHeader.tsx #

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;

12.加入购物车 #

12.1 Detail\index.tsx #

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;

12.2 cart.ts #

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;

12.3 Cart\index.tsx #

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;

13.个人中心 #

13.1 Profile\index.tsx #

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;

13.2 profile.ts #

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;

13.3 constants.ts #

src\constants.ts

export enum LOGIN_TYPES {
    UN_VALIDATE = "UN_VALIDATE",
    LOGINED = "LOGINED",
    UNLOGIN = "UNLOGIN"
}

13.4 profile.ts #

src\api\profile.ts

import axios from ".";
export function validate() {
  return axios.get("/user/validate");
}

14.注册登陆 #

14.1 src\index.tsx #

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>
    );
}

14.2 Profile\index.tsx #

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;

14.3 profile.ts #

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;

14.4 api\index.ts #

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;

14.5 profile.ts #

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);
+};

14.6 entity.ts #

src\types\entity.ts

export interface RegisterLoginPayload {
  username: string;
  password: string;
  email?: string;
}

export interface User {
  username: string;
  password: string;
  email: string;
  avatar: string;
}

14.7 Register\index.tsx #

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;

14.8 Login\index.tsx #

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;

15.上传头像 #

15.1 Profile\index.tsx #

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;

15.2 global.less #

src\styles\global.less

@tailwind base;
@tailwind components;
@tailwind utilities;

+span.adm-image-uploader-upload-button-icon>svg{
+  display: inline;
+}

15.3 profile.ts #

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'
+    }
+  });
+};

15.4 profile.ts #

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;

16.虚拟列表 #

16.1 Home\index.tsx #

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;

16.2 LessonList.tsx #

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;

16.3 global.less #

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;
+}

17.路由懒加载 #

17.1 src\index.tsx #

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>
    );
}

后台 #

1.安装 #

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

2.前台轮播图 #

2.1 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} 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);
    }
}

2.2 HttpException.ts #

src\exceptions\HttpException.ts

class HttpException extends Error {
    constructor(public status: number, public message: string, public errors?: any) {
        super(message);
    }
}
export default HttpException;

2.3 errorMiddleware.ts #

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;

2.4 slider.ts #

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 });
};

2.5 slider.ts #

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);

2.6 models\index.ts #

src\models\index.ts

export * from './slider';

2.7 tsconfig.json #

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"
  ]
}

2.8 package.json #

package.json

{
  "scripts": {
    "build": "tsc",
    "start": "ts-node-dev --respawn src/index.ts",
    "dev": "nodemon --exec ts-node --files src/index.ts"
  }
}

3.课程列表 #

3.1 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);
  }
}

3.2 lesson.ts #

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 });
};

3.3 lesson.ts #

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);

3.4 src\models\index.ts #

src\models\index.ts

export * from './slider';
+export * from './lesson';

4.用户 #

4.1 src\index.ts #

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);
    }
}

4.2 models\index.ts #

src\models\index.ts

export * from './slider';
export * from './lesson';
+export * from "./user";

4.3 user.ts #

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);

4.4 user.ts #

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 });
};

4.5 express.d.ts #

src\typings\express.d.ts

import { IUserDocument } from "../models/user";
declare global {
    namespace Express {
        export interface Request {
            currentUser?: IUserDocument | null;
            file?: Multer.File
        }
    }
}

4.6 jwt.ts #

src\typings\jwt.ts

import { IUserDocument } from "../models/user";

export interface UserPayload {
    id: IUserDocument['_id']
}

4.7 validator.ts #

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
  };
};

参考 #

1. @tailwind #

v1.tailwindcss.com

在 Tailwind CSS 中,@tailwind 指令是用来在你的 CSS 文件中引入 Tailwind 的各个部分。Tailwind 主要分为三大部分:basecomponentsutilities。下面是对每一部分的简单讲解:

  1. @tailwind base;

这条指令引入了 Tailwind 的基础样式。基础样式包括了一组样式重置和默认样式,这些都是构建在 Tailwind 之上的网站所共有的。这包括对标准 HTML 元素的样式定义,比如设置默认的字体族、段落间距、标题大小等。

使用 @tailwind base; 会在生成的 CSS 中包含这些基础样式,确保你的页面在不同浏览器中有一致的外观和感觉。

  1. @tailwind components;

这条指令用于引入 Tailwind CSS 组件的样式。在 Tailwind CSS 中,组件不是指 UI 组件库中的组件,而是指一些预先设计好的、可复用的 CSS 类集合。这些类可以用于构建常见的 UI 元素,如按钮、表单、导航栏等。

在你的项目中,你可以自定义这些组件类,并使用 @apply 指令来应用 Tailwind 的实用程序类。@tailwind components; 指令确保这些自定义组件的样式被正确加载和应用。

  1. @tailwind utilities;

最后,@tailwind utilities; 指令用于引入 Tailwind 的实用程序类。这是 Tailwind CSS 的核心部分,包含了成千上万的低级实用程序类,用于设置边距、颜色、字体大小、布局等。

这些实用程序类设计得非常灵活,可以通过组合它们来快速构建复杂的设计。由于它们是低级的,因此你可以通过组合少量的类来精确控制元素的每一个样式属性。

2.tailwind #

当然,这里是对您提供的 Tailwind CSS 类名的解释:

  1. fixed: 应用 position: fixed;,元素相对于浏览器窗口定位。

  2. absolute: 应用 position: absolute;,元素相对于最近的相对定位(非 static)祖先元素定位。

  3. flex: 应用 display: flex;,使元素成为弹性容器。

  4. justify-between: 在弹性容器内,子元素之间的空间分布均匀。

  5. items-center: 在弹性容器内,子元素在交叉轴(通常是垂直方向)上居中对齐。

  6. top-0: 元素的顶部边缘与包含块顶部对齐。

  7. left-0: 元素的左边缘与包含块左侧对齐。

  8. w-full: 元素宽度设置为100%。

  9. w-20: 元素宽度设置为 5rem(80px,如果基础字体大小为16px)。

  10. h-10: 元素高度设置为 2.5rem(40px,如果基础字体大小为16px)。

  11. z-50: 元素的 z-index 设置为50。

  12. bg-gray-800: 元素背景色设置为深灰色(Tailwind 的灰色色谱中的一个较深的颜色)。

  13. text-white: 文本颜色设置为白色。

  14. ml-5: 元素的左外边距设置为1.25rem(20px,如果基础字体大小为16px)。

  15. mr-5: 元素的右外边距设置为1.25rem(20px,如果基础字体大小为16px)。

  16. text-base: 文本大小设置为基础大小(通常是1rem或16px)。

  17. transition-opacity: 应用渐变效果,只影响不透明度。

  18. opacity-10: 元素的不透明度设置为10%。

  19. py-1: 元素的垂直内边距(上下内边距)设置为0.25rem(4px,如果基础字体大小为16px)。

  20. text-center: 文本居中对齐。

  21. text-red-500: 文本颜色设置为中等亮度的红色(Tailwind 的红色色谱中的一个颜色)。

3.declare module #

当你在 TypeScript 中使用 declare module 'redux-first-history/rr6'; 语句时,你正在创建一个模块声明。这种声明是 TypeScript 的一个特性,用于告诉 TypeScript 编译器关于一个模块的信息,特别是当这个模块没有自己的类型声明时。

模块声明的作用

  1. 告知 TypeScript 关于模块的存在: 当 TypeScript 编译器处理到导入语句(例如 import something from 'redux-first-history/rr6')时,它会尝试找到对应模块的类型信息。如果模块没有附带类型声明(这在很多 JavaScript 库中很常见),TypeScript 编译器默认会将这个模块的所有导出视为 any 类型,这意味着你失去了类型检查的好处。

  2. 为模块提供自定义的类型信息: 通过 declare module 语句,你可以为特定模块提供自定义的类型信息。这在使用没有 TypeScript 支持的第三方库时非常有用。

用法

declare module 'redux-first-history/rr6';

高级用法

如果你熟悉这个模块的 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 库协同工作。