1. 项目介绍
- 使用 react 全家桶 + express 全家桶开发一个全栈项目
前端:
- react redux react-redux react-router-dom react-router
- redux redux-promise redux-thunk redux-logger connected-react-router
- 安装依赖
- npm i react react-dom @types/react @types/react-dom react-router-dom @types/react-router-dom react-transition-group @types/react-transition-group react-swipe @types/react-swipe antd qs @types/qs -S
- npm i webpack@4 webpack-cli@3 webpack-dev-server html-webpack-plugin@4 -D
- npm i typescript ts-loader source-map-loader style-loader css-loader less-loader less url-loader file-loader -D
- npm i redux react-redux @types/react-redux redux-thunk redux-logger @types/redux-logger redux-promise @types/redux-promise -S
- npm i connected-react-router -S
- 支持 typescript
- 生成 tscconfig.json 告诉 ts-loader 怎么编译 typescript 代码
- tsc --init
- ts 中有多种 import 方式,分别对应 js 中不同 的 export // import * as xx from "xx" commonjs 模块 import xx from 'xx' 标准 es6 模块
- 编写 webpack 配置文件
前端注意点
前端相关优化
- 滚动到底部加载防抖 下拉刷新节流
- 在下拉的过程中也可以下拉,也就是在回弹的过程中也可以下拉
- 可以只渲染可是区域内的卡片,如果卡片不在可是区域内,不渲染。但是为了滚动条正常,需要放一张假的卡片撑高
- 实现骨架屏
- 路由切回来的时候要滚动到上次的位置
后端
- express mongoose
- npm i express mongoose body-parser bcryptjs jsonwebtoken morgan cors validator helmet dotenv multer http-status-codes -S
- npm i typescript @types/node @types/express @types/mongoose @types/bcryptjs @types/jsonwebtoken @types/morgan @types/cors @types/validator ts-node-dev nodemon @types/helmet @types/multer -D
- 初始化 tsconfig.json
- npx tsconfig.json 是一个命令行工具 通过他生成配置文件 生成更符合的配置文件 可以选择是 node/react/rn 项目
- script 要想启一个 ts 版本的后台服务有两种方式:
- "start": "ts-node-dev --respawn src/index.ts" ts-node-dev 用 ts-node 启动开发环境,当源码发生改变的时候可以自动重启
- "dev": "nodemon --exec ts-node --files src/index.ts" nodemon 默认不支持 ts 要用 ts-node 执行 node 脚本
- 这两种方式都可以,任意选择
- .env 文件
- 敏感信息不要提交到 github,应放到环境变量配置文件,忽略提交
- .env.example 可以提交到 github
后端相关软件
- mongodb 安装步骤
- 在官网下载安装包 路径:官网首页-software - enterprise server - download
- 将解压后的文件放入 /usr/local ,默认情况下在 Finder 中是看不到 /usr 这个目录的(终端用得溜的请略过),可以打开 Finder 后按 shift + command +G 输入 /usr/local 后回车便能看到这个隐藏的目录了 (解压后的文件重命名为 MongoDB,和下面命令保持一致)
- 配置环境变量,打开终端,输入“open -e .bash_profile”,在打开的文件中加入 export PATH=${PATH}:/usr/local/MongoDB/bin
- 用 Command+S 保存配置,关闭上面的.bash_profile 编辑窗口,然后在终端输入"source .bash_profile"使配置生效。输入"mongod -version",回车后如果看到下面的版本号则说明 MongoDB 已经成功安装到了 Mac 上
- 创建一个文件夹,用于作为数据库(安装 MongoDB 时并不会自动生成,需要我们手动创建,可以在命令行输入创建,也可以直接在 Finder 中手动新建)我创建在/usr/local/MongoDB/bin/data/db
- 运行
- mongod --dbpath=/usr/local/MongoDB/bin/data/db 启动 MongoDB 数据库(bin 目录下的 mongod.exe 程序就是用于启动 MongoDB 的)
- 可视化工具 robo 3T robomongo.org/download => click download Robo3T only
- postman 接口测试工具 官网下载后可以直接使用 www.postman.com/downloads/
后端使用到的各种 npm 插件
- express 启动服务
- mongoose 链接数据库
- cors 用来跨域的中间件
- morgan 用来输入访问日志
- helmet 用来做安全过滤,比如一些攻击性脚本,xss
- multer 用于上传文件呢
- "dotenv/config" 这个包的作用是读取.env 文件然后写入 process.env 环境变量,可以通过 process.env.变量名拿到.env 文件中定义了的值
- http-status-codes 包含各种状态码
- jsonwebtoken 生成和验证 token
- bcryptjs 对用户密码进行加密,以及验证用户密码是否匹配存在数据库中的密码
个人中心接口
- 注册
- 登录
- token 验证
- 每一类接口处理函数放在一个 controller 文件里面进行统一管理, 个人中心的三个接口处理函数就都放在 controllers/user.ts
- 数据库相关的操作全放在 models/user.ts 中 涉及到 mongoose 的使用:包含 Schema/model,以及创建 model 的实例进行数据存储
- 在 schema 上除了定义数据字段具体内容以外,还可以给 model 模型扩展方法;给模型实例扩展方法;在 options 中设置对数据进行处理的方法(toJSON)
- 方法实际涉及到:
- 在每次保存注册信息之前执行的操作 通过 model 模型 new 出来的文档实例在调用 save 方法进行保存数据之前对密码进行加密操作
UserSchema.pre('save', async function(next: HookNextFunction){} 加密之后调用 next 方法向下继续执行保存工作
- 给 model 模型扩展方法,叫 login,用于在登录的时候校验输入的密码是否匹配数据库存入的
UserSchema.static('login', async function(this:any,username:string, password:string):Promise<UserDocument | null> {} 返回 user | null
- 给模型实例扩展方法 生成 token 因为要获取实例的具体属性 所以方法放实例上
UserSchema.methods.getAccessToken = function(this: UserDocument) :string {}
前端文件
默认样式

tsconfig.json
{
"compilerOptions": {
"outDir": "./dist",
"sourceMap": true,
"noImplicitAny": true,
"module": "commonjs",
"target": "es5",
"jsx": "react",
"esModuleInterop": true,
"baseUrl": ".",
"paths": {
"@/*":["src/*"]
}
},
"include": [
"./src/**/*"
]
}
webpack.config.js
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const webpack = require("webpack");
const tsImportPluginFactory = require("ts-import-plugin");
module.exports = {
mode: process.env.NODE_ENV == "production"? "production" : "development",
entry: "./src/index.tsx",
output: {
path: path.resolve(__dirname, "dist"),
filename: "bundle.js"
},
devServer: {
hot: true,
port: 8080,
progress: true,
contentBase: "./dist",
historyApiFallback: {
index: "./index.html"
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
"~": path.resolve(__dirname, "node_modules")
},
extensions: [".tsx", ".ts", ".js", ".json"],
modules: [path.resolve(__dirname, "node_modules")]
},
module: {
rules: [
{
test: /\.(j|t)sx?$/,
use:[
{
loader: "ts-loader",
options: {
transpileOnly: true,
getCustomTransformers: () => {
before: [ tsImportPluginFactory( [{
libraryName:'antd',
libraryDirectory:'es',
style:'css',
}]) ]
}
}
}
],
exclude: /node_modules/,
},
{
test: /\.css$/,
use: ['style-loader', {
loader: 'css-loader',
options: {
importLoaders: 0
}
}, {
loader: "postcss-loader",
options: {
plugins: [
require("autoprefixer")
]
}
}, {
loader: "px2rem-loader",
options: {
remUnit: 75,
remPrecesion:8
}
}]
},
{
test: /\.less$/,
use: ['style-loader', {
loader: 'css-loader',
options: {
importLoaders: 0
}
}, {
loader: "postcss-loader",
options: {
postcssOptions: {
plugins: [
[
"autoprefixer",
],
],
},
}
}, {
loader: "px2rem-loader",
options: {
remUnit: 75,
remPrecesion:8
}
}, "less-loader"]
},
{
test: /\.(jpg|png|gif|svg|jpeg)$/,
use: ["url-loader"]
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: "./src/index.html"
}),
new webpack.HotModuleReplacementPlugin(),
]
}
index.html
<body>
<script>
(function(){
function refreshRem() {
document.documentElement.style.fontSize = document.documentElement.clientWidth / 10 + "px";
}
refreshRem();
window.addEventListener("resize", refreshRem, false);
})()
</script>
<div id="root"></div>
</body>
实现路由 index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Switch, Route, Redirect} from "react-router-dom";
import { Provider } from 'react-redux';
import store from "./store";
import { ConfigProvider} from 'antd';
import zh_CN from 'antd/lib/locale-provider/zh_CN';
import "./assets/style/common.less";
import Home from './routes/Home';
import Mine from './routes/Mine';
import Profile from './routes/Profile';
import Register from './routes/Register';
import Login from './routes/Login';
import Detail from './routes/Detail';
import Cart from './routes/Cart';
import { ConnectedRouter } from 'connected-react-router';
import Tabs from './components/tabs/index';
import history from "@/history";
ReactDOM.render(<Provider store={store}>
<ConnectedRouter history={history}>
<ConfigProvider locale={zh_CN}>
<main className="main-container">
<Switch>
<Route path="/" exact component={Home}/>
<Route path="/mine" exact component={Mine}/>
<Route path="/profile" exact component={Profile}/>
<Route path="/register" exact component={Register}/>
<Route path="/login" exact component={Login}/>
<Route path="/detail/:id" exact component={Detail}/>
<Route path="/cart" exact component={Cart}/>
</Switch>
</main>
<Tabs/>
</ConfigProvider>
</ConnectedRouter>
</Provider>, document.getElementById("root"))
创建仓库

src/store/action-types.tsx
export const SET_CURRENT_CATEGORY = 'SET_CURRENT_CATEGORY';
export const VALIDATE = 'VALIDATE';
export const LOGOUT = 'LOGOUT';
export const SET_AVATAR = 'SET_AVATAR';
export const GET_SLIDERS = 'GET_SLIDERS';
export const GET_LESSONS = 'GET_LESSONS';
export const SET_LESSONS = 'SET_LESSONS';
export const SET_LESSON_LODING = 'SET_LESSON_LODING';
export const REFRESH_LESSONS = 'REFRESH_LESSONS';
export const ADD_CART_ITEM = 'ADD_CART_ITEM';
export const REMOVE_CART_ITEM = 'REMOVE_CART_ITEM';
export const CLEAR_CART_ITEM = 'CLEAR_CART_ITEM';
export const CHANGE_CART_ITEM_COUNT = 'CHANGE_CART_ITEM_COUNT';
export const CHANGE_CHECKED_CART_ITEMS = 'CHANGE_CHECKED_CART_ITEMS';
export const SETTLE = 'SETTLE';
src/store/index.tsx
import {createStore, applyMiddleware, Dispatch} from 'redux';
import logger from 'redux-logger';
import promise from 'redux-promise';
import thunk from 'redux-thunk';
import { routerMiddleware } from 'connected-react-router';
import history from '@/history';
import rootReducer from './reducers/index';
import { ICombineState } from '@/typings/state';
let store = applyMiddleware(routerMiddleware(history), promise, thunk, logger)(createStore)(rootReducer);
export type StoreDispatch = Dispatch;
export type StoreGetState = () => ICombineState;
export default store;
reducers: src/store/reducers
- index.tsx
- 使用 combinReducers合并所有的分reducer
- ICombineState 是声明的根state的类型
import {
AnyAction,
ReducersMapObject
} from "redux";
import { connectRouter} from 'connected-react-router';
import home from "./home";
import mine from './mine';
import profile from './profile';
import cart from './cart';
import history from '@/history';
import { Reducer } from "react";
import {ICombineState} from '@/typings/state';
import produce from 'immer';
import { combineReducers } from 'redux-immer';
let reducers:ReducersMapObject< ICombineState, AnyAction> = {
home,
mine,
profile,
cart,
router: connectRouter(history)
}
const rootReducer:Reducer<ICombineState,AnyAction> = combineReducers(produce, reducers);
export default rootReducer;
import { CartState, CartItem } from "@/typings/cart";
import { AnyAction } from "redux";
import * as actionTypes from '../action-types';
let initialState: CartState = [];
export default function(state: CartState = initialState, action: AnyAction): CartState {
switch(action.type){
case actionTypes.ADD_CART_ITEM:
let oldlesson = state.find(item => item.lesson.id === action.payload.id);
if(oldlesson) {
oldlesson.count ++;
}else{
state.push({
count: 1,
checked: false,
lesson: action.payload
});
}
return state;
case actionTypes.REMOVE_CART_ITEM:
let removeIndex = state.findIndex(item => item.lesson.id === action.payload);
if(removeIndex !== -1) {
state.splice(removeIndex, 1);
}
return state;
case actionTypes.CLEAR_CART_ITEM:
return [];
case actionTypes.CHANGE_CART_ITEM_COUNT:
let changelesson = state.find(item => item.lesson.id === action.payload.id);
if(changelesson) {
changelesson.count = action.payload.count;
}
return state;
case actionTypes.CHANGE_CHECKED_CART_ITEMS:
let checkedIds = action.payload;
return state.map((item: CartItem) => {
if(checkedIds.includes(item.lesson.id)) {
item.checked = true;
}else{
item.checked = false;
}
return item;
})
case actionTypes.SETTLE:
return state.filter((item: CartItem) => !item.checked)
default:
return state;
}
}
import { AnyAction } from 'redux';
import { IHomeState} from '@/typings/state';
import * as actionTypes from '../action-types';
const initialState: IHomeState = {
currentCategory: "all",
sliders: [],
lessons: {
loading: false,
list: [],
hasMore: true,
offset: 0,
limit: 5
}
}
export default function(state:IHomeState = initialState, action:AnyAction):IHomeState {
switch(action.type) {
case actionTypes.SET_CURRENT_CATEGORY:
return {...state, currentCategory: action.payload}
case actionTypes.GET_SLIDERS:
return {...state, sliders: action.payload.data}
case actionTypes.SET_LESSONS:
state.lessons.loading = false;
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;
return state;
case actionTypes.SET_LESSON_LODING:
state.lessons.loading = action.payload;
return state;
case actionTypes.REFRESH_LESSONS:
state.lessons.loading = false;
state.lessons.list = action.payload.list;
state.lessons.hasMore = action.payload.hasMore;
state.lessons.offset = action.payload.list.length;
return state;
default:
return state;
}
}
import { AnyAction } from 'redux';
import { IMineState} from '@/typings/state';
const initialState: IMineState = {
}
export default function(state:IMineState = initialState, action:AnyAction):IMineState {
return state;
}
import { AnyAction } from 'redux';
import { IProfileState, LOGIN_TYPES} from '@/typings/state';
import * as ActionTypes from '@/store/action-types';
const initialState: IProfileState = {
loginState: LOGIN_TYPES.UN_VALIDATE,
user: null,
error: null,
}
export default function(state:IProfileState = initialState, action:AnyAction):IProfileState {
switch(action.type) {
case ActionTypes.VALIDATE:
if(action.payload.success) {
return {
loginState: LOGIN_TYPES.LOGINED,
user:action.payload.data,
error: null
}
}else{
return {
loginState: LOGIN_TYPES.UNLOGINED,
user:null,
error: action.payload
}
}
case ActionTypes.LOGOUT:
return {
loginState: LOGIN_TYPES.UNLOGINED,
user:null,
error: null
}
case ActionTypes.SET_AVATAR:
return {
...state,
user: {
...state.user,
avatar: action.payload
}
}
default:
return state;
}
}
公共组件
- src/components/Nav
- src/components/Tabs
Tabs组件 -- 作为底部导航
import React from 'react';
import { NavLink, withRouter } from "react-router-dom";
import {
ShoppingCartOutlined,
HomeOutlined,
UserOutlined
} from '@ant-design/icons';
import "./index.less";
interface IProps {
}
function Tabs(_props:IProps) {
return (
<footer>
<NavLink exact to="/"><HomeOutlined /><span>首页</span></NavLink>
<NavLink to="/cart" id="shopcart"><ShoppingCartOutlined /><span>购物车</span></NavLink>
<NavLink to="/profile"><UserOutlined /><span>个人中心</span></NavLink>
</footer>
)
}
export default Tabs;
footer {
position: fixed;
left: 0;
bottom: 0;
height: 120px;
width: 100%;
z-index: 1000;
background-color: #fff;
border-top: 0.02rem solid #d5d5d5;
display: flex;
justify-content: center;
align-items: center;
a {
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
align-items: center;
color: #000;
span:first-child {
font-size: 0.5rem;
}
span:last-child {
font-size: 0.3rem;
line-height: 0.5rem;
}
&.active {
color: orange;
font-weight: bold;
}
}
}
Nav组件
import React, {PropsWithChildren} from 'react';
import { LeftOutlined } from '@ant-design/icons';
import {History} from 'history';
import "./index.less";
type Props = PropsWithChildren<{history: History}>
function Nav(props: Props) {
return (
<header className="nav-header">
<LeftOutlined onClick={() => props.history.goBack()} className="icon-nav"/>
{props.children}
</header>
)
}
export default Nav;
.nav-header {
position: fixed;
left: 0;
top: 0;
height: 1rem;
z-index: 1000;
width: 100%;
box-sizing: border-box;
text-align: center;
line-height: 1rem;
background-color: #2a2a2a;
color: #fff;
.icon-nav {
position: absolute;
left: 0.2rem;
line-height: 1rem;
}
}
首页 HOME

index
- Home组件是路由渲染出来的 属性中包含路由属性
- 需要连接仓库
- mapStateToProps 传入根状态 返回分状态
- 传入home组件的props属性的类型包含:路由相关 / mapStateToProps返回的分状态的类型 / mapDispatchToProps类型,还有children属性类型
type IProps = PropsWithChildren<RouteComponentProps & ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps>;
import React, {PropsWithChildren, useEffect, useLayoutEffect, useRef} from 'react';
import { connect} from 'react-redux';
import {RouteComponentProps} from 'react-router-dom';
import "./index.less";
import HomeHeader from './components/HomeHeader';
import {ICombineState, IHomeState} from '@/typings/state';
import mapDispatchToProps from '@/store/actions/home';
import HomeSliders from './components/HomeSliders/index';
import LessonList from './components/LessonList/index';
import { loadMore, downRefresh } from '@/utils';
import { Spin } from 'antd';
type IProps = PropsWithChildren<RouteComponentProps & ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps>;
function Home(props: IProps) {
let homeContainerRef = useRef<HTMLDivElement>(null);
let lessonListRef = useRef(null);
useEffect(() => {
let func = loadMore(homeContainerRef.current, props.getLessons);
downRefresh(homeContainerRef.current,props.refreshLessons);
homeContainerRef.current.addEventListener("scroll", lessonListRef.current);
if(props.lessons.list.length > 0) {
homeContainerRef.current.scrollTop = parseFloat(localStorage.getItem("homeScrollTop")) || 500;
}
return () => {
};
}, [])
useLayoutEffect(() => {
return () => {
console.log(homeContainerRef.current.scrollTop,'========')
localStorage.setItem("homeScrollTop", homeContainerRef.current.scrollTop + '');
}
},[])
return (
<div>
<HomeHeader
currentCategory = {props.currentCategory}
setCurrentCategory = {props.setCurrentCategory}
refreshLessons = {props.refreshLessons}
/>
<div className="refresh-loading">
<Spin size="large"/>
</div>
<div className="home-container" ref={homeContainerRef}>
<HomeSliders
sliders={props.sliders}
getSliders={props.getSliders}
/>
<LessonList
ref = {lessonListRef} // 函数组件不能传递ref,需要用forwardRef转换
getLessons={props.getLessons}
lessons={props.lessons}
currentCategory = {props.currentCategory}
homeContainerRef = {homeContainerRef}
/>
</div>
</div>
)
}
const mapStateToProps = (state: ICombineState):IHomeState => {
return {
...state.home,
...state.profile
}
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(Home);
.home-container {
position: fixed;
top: 100px;
width: 100%;
overflow-y: auto;
height: calc(100vh - 220px);
z-index: 200;
}
.refresh-loading {
position: fixed;
top: 100px;
text-align: center;
padding: 20px;
width: 100%;
z-index: 0;
}
Home内部组件
HomeHeader
- 切换类目的点击事件
- 给外面的ul绑定,而不是给每一个li绑定
- 给每一li设置了一个自定义属性
data-category
- 点击每一个li的时候,event.target指向当前的li
- 通过自定义属性获取当前li代表的类目
import React, { CSSProperties, useState } from 'react';
import { BarsOutlined} from '@ant-design/icons';
import { Transition } from 'react-transition-group';
import classnames from 'classnames';
import "./index.less";
import logo from '@/assets/images/logo.jpeg';
const duration =1000;
const defaultStyle:CSSProperties= {
transition: `all ${duration} ease-in-out`,
opacity: 0,
}
interface TransitionStyles {
entering: CSSProperties;
entered: CSSProperties;
exiting: CSSProperties;
exited: CSSProperties;
unmounted: CSSProperties;
}
const transitionStyles:TransitionStyles = {
entering: {opacity: 1},
entered: {opacity: 1},
exiting: {opacity: 0, display:"none"},
exited: {opacity: 0, display:"none"},
unmounted: {opacity: 0 , display:"none"}
}
interface IProps {
currentCategory: string;
setCurrentCategory: (currentCategory: string) => any;
refreshLessons: Function
}
function HomeHeader(props: IProps) {
let [isMenuVisible, setMenuVisible] = useState(false);
const setCurrentCategory = (event:React.MouseEvent<HTMLUListElement>) => {
let target:HTMLUListElement = event.target as HTMLUListElement;
console.log(target, 'target')
let category = target.dataset.category;
props.setCurrentCategory(category);
props.refreshLessons();
setMenuVisible(false);
}
return (
<header className="home-header">
<div className="logo-header">
{/* <img src={logo.default}/> */}
<img src={logo}/>
<BarsOutlined className="icon-header" onClick={() => setMenuVisible(!isMenuVisible)}/>
</div>
<Transition in={isMenuVisible} timeout={duration}>
{
(state: keyof TransitionStyles) => (
<ul
className="category"
onClick={setCurrentCategory}
style = {{
...defaultStyle,
...transitionStyles[state]
}}
>
<li data-category="all" className={classnames({
active: props.currentCategory === "all"
})}>
全部课程
</li>
<li data-category="react" className={classnames({
active: props.currentCategory === "react"
})}>
react课程
</li>
<li data-category="vue" className={classnames({
active: props.currentCategory === "vue"
})}>
vue课程
</li>
</ul>
)
}
</Transition>
</header>
)
}
export default HomeHeader;
@BG: #2a2a2a;
.home-header {
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 999;
.logo-header {
height: 100px;
background-color: @BG;
color: #fff;
display: flex;
justify-content: space-between;
align-items: center;
img {
width: 200px;
// width: 2rem;
margin-left: 0.2rem;
height: 100%;
}
.icon-header {
font-size: 0.6rem;
margin-right: 0.2rem;
}
}
.category {
position: absolute;
width: 100%;
top: 1rem;
left: 0;
padding: 0.1rem 0.5rem;
background-color: @BG;
li {
line-height: 0.6rem;
text-align: center;
color: #fff;
font-size: 0.3rem;
border-top: 0.02rem solid lighten(@BG, 20%);
&.active {
color: olivedrab;
}
}
}
}
类型文件

export * from './profile';
export * from './state';
export * from './slider';
export * from './lesson';
declare module '*.svg'
declare module '*.png'
declare module '*.jpg'
declare module '*.jpeg'
declare module '*.gif'
declare module '*.bmp'
declare module '*.tiff'
import { ILesson } from './lesson';
export interface CartItem {
lesson: ILesson,
count: number,
checked: boolean
}
export type CartState = CartItem[];
export interface ILesson {
id: string;
order: number;
title: string;
video: string;
poster: string;
url: string;
price: string;
category: string;
}
export interface LessonData {
success: boolean;
data: {
list:ILesson[];
hasMore: boolean;
}
}
export interface GetLessonData {
success: boolean;
data: ILesson
}
export interface RegisterPayload {
username: string;
password: string;
confirmPassword: string;
email: string;
}
export interface LoginPayload {
username: string;
password: string;
}
export interface ISLider {
_id: string;
url: string;
}
export interface sliderData {
success: boolean;
data: ISLider[];
}
import { RouterState } from 'connected-react-router';
import { CartState } from './cart';
import { ILesson } from './lesson';
import { ISLider } from './slider';
export interface Lessons {
loading: boolean;
list: ILesson[];
hasMore: boolean;
offset: number;
limit: number;
}
export interface IHomeState {
currentCategory: string;
sliders: ISLider[];
lessons: Lessons
}
export interface IMineState {
}
interface IUser {
id?: string;
username: string;
email: string;
avatar: string;
}
export enum LOGIN_TYPES {
UN_VALIDATE = 'UN_VALIDATE',
LOGINED = 'LOGINED',
UNLOGINED = 'UNLOGINED'
}
export interface IProfileState {
loginState: LOGIN_TYPES;
user: IUser|null;
error: string | null;
}
export interface ICombineState {
home: IHomeState,
mine: IMineState,
profile: IProfileState,
router: RouterState,
cart: CartState
}
个人中心组件 Profile
import React, {PropsWithChildren, useEffect, useState} from 'react';
import {connect} from 'react-redux';
import { ICombineState, IProfileState} from '@/typings/state';
import { RouteComponentProps } from 'react-router-dom';
import mapDispatchToProps from '@/store/actions/profile';
import { LOGIN_TYPES, } from '@/typings/state';
import Nav from '@/components/Nav/index';
import "./index.less";
import { Alert, Button, Descriptions, Upload, message } from 'antd';
import { RcFile, UploadChangeParam } from 'antd/lib/upload';
import { LoadingOutlined, PlusOutlined } from '@ant-design/icons';
type IProps = PropsWithChildren<RouteComponentProps & ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps>;
function Profile(props: IProps) {
let [uploading, setUploading] = useState(false);
useEffect(() => {
props.validate();
}, []);
console.log(props, '=============')
let content;
if(props.loginState === LOGIN_TYPES.UN_VALIDATE) {
content = null;
}else if(props.loginState === LOGIN_TYPES.LOGINED) {
const uploadButton = (
<div>
{uploading ? <LoadingOutlined /> : <PlusOutlined />}
<div className="ant-upload-text">上传</div>
</div>
);
const handleChange = (info: UploadChangeParam) => {
if(info.file.status === 'uploading') {
setUploading(true);
}else if(info.file.status === 'done') {
let { success, data } = info.file.response;
if(success) {
setUploading(false);
props.setAvatar(data);
}else {
message.error("上传头像失败")
}
}
}
content = (
<div className="user-info">
<Descriptions title="当前用户">
<Descriptions.Item label="用户名">{props.user.username}</Descriptions.Item>
<Descriptions.Item label="邮箱">{props.user.email}</Descriptions.Item>
<Descriptions.Item label="头像">
<Upload
name="avatar" // 往服务端上传头像的时候应该用那个字段名上传
listType="picture-card"
className="avatar-uploader"
showUploadList={false}
action="http://localhost:8001/user/uploadAvatar"
beforeUpload={beforeUpload}
data={{ userId: props.user.id }}
onChange={handleChange}
>
{
props.user.avatar ? <img src={props.user.avatar} style={{width: '100%'}}/> : uploadButton
}
</Upload>
</Descriptions.Item>
</Descriptions>
<Button type="primary" danger onClick={props.logout}>退出</Button>
</div>
)
}else {
content = (
<>
<Alert type="warning" message='未登录' description='尚未登录,请注册或者登录'/>
<div style={{textAlign:"center", padding:".5rem"}}>
<Button type="dashed" onClick={()=> props.history.push("/login")}>登录</Button>
<Button type="dashed" onClick={()=> props.history.push("/register")}>注册</Button>
</div>
</>
)
}
return (
<section>
<Nav history={props.history}>个人中心</Nav>
{content}
</section>
)
}
const mapStateToProps = (state: ICombineState):IProfileState => state.profile;
export default connect(
mapStateToProps,
mapDispatchToProps
)(Profile);
function beforeUpload(file:RcFile) {
const isJpgOrPng = file.type === 'image/jpg' || file.type === 'image/png';
if(!isJpgOrPng) {
message.error('你只能上传JPG/PNG文件');
}
const isLessThan2M = file.size / 1024 / 1024 < 2;
if(!isLessThan2M) {
message.error('图片必须小于2M');
}
return isJpgOrPng && isLessThan2M;
}
登陆组件 Login
import React, {PropsWithChildren} from 'react';
import { Button, Form, Input } from 'antd';
import { connect } from 'react-redux';
import { ICombineState, IProfileState } from '@/typings/state';
import mapDispatchToProps from '@/store/actions/profile';
import { RouteComponentProps } from 'react-router';
import { Link } from 'react-router-dom';
import { RegisterPayload } from '@/typings/profile';
import Nav from '@/components/Nav/index';
import "./index.less";
type Props = PropsWithChildren<RouteComponentProps & ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps>;
function Login(props: Props) {
const [form] = Form.useForm();
const onFinish = (values: RegisterPayload) => {
console.log('Received values of form: ', values);
props.login(values)
};
const formItemLayout = {
labelCol: {
xs: { span: 24 },
sm: { span: 8 },
},
wrapperCol: {
xs: { span: 24 },
sm: { span: 16 },
},
};
const tailFormItemLayout = {
wrapperCol: {
xs: {
span: 24,
offset: 0,
},
sm: {
span: 16,
offset: 8,
},
},
};
return (
<>
<Nav history={props.history}>登录</Nav>
<Form
{...formItemLayout}
form={form}
name="login"
onFinish={onFinish}
initialValues={{
residence: ['zhejiang', 'hangzhou', 'xihu'],
prefix: '86',
}}
scrollToFirstError
className="login-form"
>
<Form.Item
name="username"
label="用户名"
rules={[
{
required: true,
message: '用户名不能为空',
},
]}
>
<Input/>
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[
{
required: true,
message: '密码不能为空',
},
]}
>
<Input/>
</Form.Item>
<Form.Item {...tailFormItemLayout}>
<Button type="primary" htmlType="submit">
登录
</Button> 或者 <Link to="/register">注册</Link>
</Form.Item>
</Form>
</>
)
}
let mapStateToProps = (state:ICombineState):IProfileState => state.profile;
export default connect(
mapStateToProps,
mapDispatchToProps
)(Login);
注册组件 Register.ts
import React, {PropsWithChildren} from 'react';
import { Button, Form, Input } from 'antd';
import { connect } from 'react-redux';
import { ICombineState, IProfileState } from '@/typings/state';
import mapDispatchToProps from '@/store/actions/profile';
import { RouteComponentProps } from 'react-router';
import { Link } from 'react-router-dom';
import { RegisterPayload } from '@/typings/profile';
import Nav from '@/components/Nav/index';
import "./index.less";
type Props = PropsWithChildren<RouteComponentProps & ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps>;
function Register(props: Props) {
const [form] = Form.useForm();
const onFinish = (values: RegisterPayload) => {
console.log('Received values of form: ', values);
props.register(values)
};
const formItemLayout = {
labelCol: {
xs: { span: 24 },
sm: { span: 8 },
},
wrapperCol: {
xs: { span: 24 },
sm: { span: 16 },
},
};
const tailFormItemLayout = {
wrapperCol: {
xs: {
span: 24,
offset: 0,
},
sm: {
span: 16,
offset: 8,
},
},
};
return (
<>
<Nav history={props.history}>注册</Nav>
<Form
{...formItemLayout}
form={form}
name="register"
onFinish={onFinish}
initialValues={{
residence: ['zhejiang', 'hangzhou', 'xihu'],
prefix: '86',
}}
scrollToFirstError
className="register-form"
>
<Form.Item
name="username"
label="用户名"
rules={[
{
required: true,
message: '用户名不能为空',
},
]}
>
<Input/>
</Form.Item>
<Form.Item
name="password"
label="密码"
rules={[
{
required: true,
message: '密码不能为空',
},
]}
>
<Input/>
</Form.Item>
<Form.Item
name="confirmPassword"
label="确认密码"
dependencies={['password']}
rules={[
{
required: true,
message: '确认密码不能为空',
},
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('密码不一致'));
},
}),
]}
>
<Input.Password/>
</Form.Item>
<Form.Item
name="email"
label="邮箱"
rules={[
{
required: true,
message: '邮箱不能为空',
},
{
type: 'email',
message: '邮箱格式有误',
},
]}
>
<Input.Password/>
</Form.Item>
<Form.Item {...tailFormItemLayout}>
<Button type="primary" htmlType="submit">
注册
</Button> 或者 <Link to="/login">登录</Link>
</Form.Item>
</Form>
</>
)
}
let mapStateToProps = (state:ICombineState):IProfileState => state.profile;
export default connect(
mapStateToProps,
mapDispatchToProps
)(Register);
详情页组件 Detail
import Nav from '@/components/Nav';
import { GetLessonData, ICombineState, ILesson } from '@/typings';
import { Button, Card } from 'antd';
import React, { PropsWithChildren, useEffect, useState } from 'react';
import { RouteComponentProps } from 'react-router';
import { StaticContext } from 'react-router';
import { getLesson} from '@/api/home';
import { ShoppingCartOutlined } from '@ant-design/icons';
import { connect } from 'react-redux';
import actions from '@/store/actions/cart';
interface Params {
id: string;
}
type Props = PropsWithChildren<RouteComponentProps<Params, StaticContext, ILesson> & ICombineState & typeof actions>
function Detail(props: Props) {
let [lesson, setLesson] = useState<ILesson>({} as ILesson);
useEffect(() => {
(async function(){
let lesson = props.location.state;
if(!lesson) {
let result:GetLessonData = await getLesson<GetLessonData>(props.match.params.id);
if(result.success) {
lesson = result.data;
}
};
setLesson(lesson);
})()
}, []);
const addCartItem = (lesson:ILesson) => {
let cover:HTMLDivElement = document.querySelector(".ant-card-cover");
let coverLeft = cover.getBoundingClientRect().left;
let coverTop = cover.getBoundingClientRect().top;
let coverWidth = cover.offsetWidth;
let coverHeight = cover.offsetHeight;
let cart:HTMLElement = document.querySelector("#shopcart>span");
console.log(cart,"////////////////////")
let cartWidth = cart.offsetWidth;
let cartHeight = cart.offsetHeight;
let cartRight = cart.getBoundingClientRect().right;
let cartBottom = cart.getBoundingClientRect().bottom;
let cloneCover:HTMLDivElement = cover.cloneNode(true) as HTMLDivElement;
cloneCover.style.cssText = (
`
z-index: 1000;
opacity: 0.8;
position: fixed;
width: ${coverWidth}px;
height: ${coverHeight}px;
left: ${coverLeft}px;
top: ${coverTop}px;
transition: all 2s ease-in-out;
`
);
document.body.appendChild(cloneCover);
setTimeout(() => {
cloneCover.style.left = `${cartRight - cartWidth / 2}px`;
cloneCover.style.top = `${cartBottom - cartHeight / 2}px`;
cloneCover.style.width = '0px';
cloneCover.style.height = '0px';
cloneCover.style.opacity = '.5';
}, 0)
setTimeout(() => {
cloneCover.parentNode.removeChild(cloneCover);
},2000)
props.addCartItem(lesson);
}
return (
<>
<Nav history={props.history}>课程详情</Nav>
<Card
hoverable
style={{width: "100%"}}
cover={<img src={lesson.poster}/>}
>
<Card.Meta
title={lesson.title}
description={
<>
<p>{lesson.poster}</p>
<p>
<Button
className="add-cart"
icon={<ShoppingCartOutlined />}
onClick={() => addCartItem(lesson)}
>加入购物车</Button>
</p>
</>
}
/>
</Card>
</>
)
}
export default connect(
(state: ICombineState): ICombineState => state,
actions
)(Detail);
购物车组件 Cart.tsx
import { ICombineState, ILesson } from '@/typings';
import { CartItem, CartState } from '@/typings/cart';
import React, { PropsWithChildren } from 'react';
import { connect } from 'react-redux';
import { RouteComponentProps } from 'react-router';
import mapDispatchToProps from '@/store/actions/cart';
import { Button, Col, InputNumber, Popconfirm, Row, Table } from 'antd';
import Nav from '@/components/Nav';
type Props = PropsWithChildren<RouteComponentProps & typeof mapDispatchToProps & ReturnType<typeof mapStateToProps>>;
function Cart(props: Props) {
const column = [
{
title: '商品',
dataIndex: 'lesson',
render: (val:ILesson, row: CartItem) => {
return (
<>
<p>{val.title}</p>
<p>{val.price}</p>
</>
)
}
},
{
title: '数量',
dataIndex: 'count',
render: (val:number, row: CartItem) => {
return (
<InputNumber
size="small"
min={1}
value={val}
onChange={(value) => props.changeCartItemCount(row.lesson.id, value)}
/>
)
}
},
{
title: '操作',
render: (val:number, row: CartItem) => {
return (
<Popconfirm
title="是否要删除商品"
onConfirm={() => props.removeCartItem(row.lesson.id)}
okText = "是"
cancelText = "否"
>
<Button size="small" type="primary">删除</Button>
</Popconfirm>
)
}
}
];
const rowSelection = {
selectedRowKeys: props.cart.filter((item:CartItem) => item.checked).map((item:CartItem) => item.lesson.id),
onChange: (selectedRowKeys: string[]) => {
props.changeCheckedCartItems(selectedRowKeys);
}
}
let totalCount = props.cart.filter((item:CartItem) => item.checked).reduce((total:number, item:CartItem) => total + item.count, 0);
let totalPrice = props.cart.filter((item:CartItem) => item.checked).reduce((total:number, item:CartItem) => total + item.count * parseFloat(item.lesson.price.slice(1)), 0);
return (
<>
<Nav history={props.history}>购物车</Nav>
<Table
columns = {column}
dataSource = {props.cart}
pagination={false}
rowSelection = {rowSelection}
rowKey={row => row.lesson.id}
></Table>
<Row style={{padding :"5px"}}>
<Col span={4}><Button type="dashed" size="small" onClick={props.clearCartItems}>清空</Button></Col>
<Col span={7}>{`以选择了${totalCount}商品`}</Col>
<Col span={9}>{`总价${totalPrice}元`}</Col>
<Col span={4}><Button type="primary" size="small" onClick={props.settle}>结算</Button></Col>
</Row>
</>
)
}
let mapStateToProps = (state: ICombineState): {cart: CartState} => ({cart : state.cart});
export default connect(
mapStateToProps,
mapDispatchToProps
)(Cart);
src/api文件夹
home.tsx
import request from './index';
import { sliderData } from '../typings/index';
export function getSliders() {
return request.get<sliderData, sliderData>('/slider/list');
}
export function getLessons<T>(
offset:number,
limit: number,
currentCategory: string = 'all',
) {
return request.get<T,T>(`/lesson/list?category=${currentCategory}&offset=${offset}&limit=${limit}`)
}
export function getLesson<T>(
id: string
) {
return request.get<T,T>(`/lesson/${id}`)
}
index.ts
import axios, { AxiosRequestConfig } from 'axios';
axios.defaults.baseURL = "http://localhost:8001";
axios.defaults.headers.post['Content-Type'] = "application/json;charset=UTF-8";
axios.interceptors.request.use((config:AxiosRequestConfig) => {
let access_token = sessionStorage.getItem("access_token");
if (access_token)
config.headers["Authorization"] = `Bearer ${access_token}`;
return config;
}, (error: any) => Promise.reject(error));
axios.interceptors.response.use(response => response.data, error => Promise.reject(error));
export default axios;
profile.ts
import request from './index';
import { RegisterPayload, LoginPayload } from '@/typings/profile';
export function validate() {
return request.get("/user/validate");
}
export function register<T>(values: RegisterPayload) {
return request.post<T,T>("/user/register", values);
}
export function login<T>(values: LoginPayload) {
return request.post<T,T>("/user/login", values);
}