# 极客园项目-PC
**Repository Path**: jetwang88/geek-pc
## Basic Information
- **Project Name**: 极客园项目-PC
- **Description**: 基于React技术栈
- **Primary Language**: Unknown
- **License**: Not specified
- **Default Branch**: master
- **Homepage**: http://www.jetwang.cn/geek-pc
- **GVP Project**: No
## Statistics
- **Stars**: 1
- **Forks**: 0
- **Created**: 2023-04-06
- **Last Updated**: 2023-07-27
## Categories & Tags
**Categories**: Uncategorized
**Tags**: react-redux, React, Ant-Design
## README
# 极客园项目-PC
> 极客园 PC 端项目:个人自媒体管理端
- 项目功能和演示,包括
- 登录、退出
- 首页
- 内容(文章)管理:文章列表、发布文章、修改文章
- 技术栈:
- 项目搭建:React 官方脚手架 `create-react-app`
- react hooks
- 状态管理:redux,以及:`react-redux` 绑定库
- UI 组件库:`antd` v4
- ajax请求库:`axios`
- 路由:`react-router-dom` 以及 `history`
- 富文本编辑器:`react-quill`
- CSS 预编译器:`sass`
- CSS Modules 避免组件之间的样式冲突
## 1. 创建项目
> npx create-react-app geek-pc
## 2. 整理目录结构
```js
/src
/assets 项目资源文件,比如,图片 等
/components 通用组件
/pages 页面组件
/router 路由配置
/store Redux 状态仓库
/utils 工具,比如,token、axios 的封装等
App.css 根组件样式文件
App.js 根组件
index.css 全局样式
index.js 项目入口
```
## 3. 安装sass
> yarn add sass
`index.scss`
```scss
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
body {
font-family: PingFang SC, 'Courier New', Courier, monospace, sans-serif;
}
#root, .app {
height: 100%;
}
```
## 4. 配置路由
> 1. 安装路由:`yarn add react-router-dom`
> 2. 在 pages 目录中创建两个文件夹:Login、Layout、NotFound
> 3. 分别在三个目录中创建 index.jsx 文件,并创建一个简单的组件后导出
> 4. 在 App 组件中,导入路由组件以及 3 个页面组件
> 5. 配置 Login、Layout、NotFound 的路由规则
`App.js`
```jsx
import { useRoutes } from 'react-router-dom'
// 导入页面组件
import Login from './pages/Login'
import Layout from './pages/Layout'
import NotFound from './pages/NotFound'
const routes = [
{
path: '/',
element:
},
{
path: '/login',
element:
},
{
path: '*',
element:
},
]
export default function App() {
const element = useRoutes(routes)
return (
{ element }
)
}
```
`index.js`
```js
import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.scss'
import App from './App'
import { BrowserRouter } from 'react-router-dom'
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
)
```
## 5. 使用antd组件
> 1. 安装 antd 组件库:`yarn add antd@4`
> 2. 全局导入 antd 组件库的样式
> 3. 导入 Button 组件
> 4. 在 Login 页面渲染 Button 组件
`index.js`
```diff
import React from 'react'
import ReactDOM from 'react-dom/client'
+ // 先导入 antd 样式文件
+ import 'antd/dist/antd.min.css'
+ // 再导入全局样式文件,防止样式覆盖
import './index.scss'
import App from './App'
import { BrowserRouter } from 'react-router-dom'
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
)
```
`Login.js`
```js
import React from 'react'
import { Button } from 'antd'
export default function login() {
return (
)
}
```
## 6. 配置路径别名
> 能够配置@路径别名简化路径处理
>
> 1. 安装修改 CRA 配置的包:`yarn add -D @craco/craco`
> 2. 在项目根目录中创建 craco 的配置文件:`craco.config.js`,并在配置文件中配置路径别名
> 3. 修改 `package.json` 中的脚本命令
> 4. 在代码中,就可以通过 `@` 来表示 src 目录的绝对路径
> 5. 重启项目,让配置生效
`craco.config.js`
```js
const path = require('path')
module.exports = {
// webpack 配置
webpack: {
// 配置别名
alias: {
// 约定:使用 @ 表示 src 文件所在路径
'@': path.resolve(__dirname, 'src')
}
}
}
```
`package.json`
```json
// 将 start/build/test 三个命令修改为 craco 方式
"scripts": {
"start": "craco start",
"build": "craco build",
"test": "craco test",
"eject": "react-scripts eject"
},
```
`App.js`
```diff
import { useRoutes } from 'react-router-dom'
// 导入页面组件
import Login from './pages/Login'
import Layout from './pages/Layout'
+ import NotFound from '@/pages/NotFound'
const routes = [
{
path: '/',
element:
},
{
path: '/login',
element:
},
{
path: '*',
element:
},
]
export default function App() {
const element = useRoutes(routes)
return (
{ element }
)
}
```
## 7. 路径别名提示
> 能够让vscode识别@路径并给出路径提示
>
> 1. 在项目根目录创建 `jsconfig.json` 配置文件
> 2. 在配置文件中添加以下配置
`jsconfig.json`
```json
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
}
}
```
## 8. 登录界面
### 8.1 基本结构
`Login.jsx`
```jsx
import React from 'react'
import logo from '@/assets/logo.png'
import { Card } from 'antd'
import './index.scss'
export default function login() {
return (
{/* 登录表单 */}
)
}
```
`Login/index.scss`
```css
.login {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
background: center/cover url(../../assets/login.png);
.login-logo {
width: 200px;
height: 60px;
display: block;
margin: 0 auto 20px;
}
.login-container {
width: 440px;
height: 360px;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 0 50px rgb(0 0 0 / 10%);
}
.login-checkbox-label {
color: #1890ff;
}
}
```
### 8.2 表单结构
```jsx
import React from 'react'
import logo from '@/assets/logo.png'
import { Card, Form, Input, Checkbox, Button } from 'antd'
import './index.scss'
export default function login() {
return (
)
}
```
### 8.3 表单校验
```jsx
import React from 'react'
import logo from '@/assets/logo.png'
import { Card, Form, Input, Checkbox, Button } from 'antd'
import './index.scss'
export default function login() {
return (
)
}
```
### 8.4 获取表单值
```jsx
function onFinish(values) {
console.log(values)
}
```
## 9. 引入Redux
> 1. 安装 redux 相关的包:`yarn add @reduxjs/toolkit react-redux axios`
> 2. 在 store 目录中分别创建:modules 文件夹、index.js 文件
> 3. 新建login模块,存储token
`store目录结构`
```js
/store
/modules
login.js
index.js
```
`modules/login.js`
```js
import { createSlice } from "@reduxjs/toolkit"
export const login = createSlice({
// 1.命名空间
name: 'login',
// 2.初始化状态
initialState: {
token: ''
},
// 3.定义reducers
reducers: {
// action函数(同步)
setToken(preState, action) {
preState.token = action.payload
},
delToken(preState) {
preState.token = ''
}
}
})
// 导出reducer
export default login.reducer
```
`store/index.js`
```js
import { configureStore } from "@reduxjs/toolkit"
import login from './modules/login'
export default configureStore({
reducer: {
login
}
})
```
`index.js`
```js
......
import { Provider } from 'react-redux'
import store from './store'
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
)
```
## 10. 封装axios
> 1. 创建 utils/request.js 文件
> 2. 创建 axios 实例,配置 baseURL,简化接口路径
> 3. 在 utils/index.js 中,默认导出 request
`utils/request.js`
```js
import axios from "axios"
const request = axios.create({
baseURL: 'http://geek.itheima.net/v1_0',
timeout: 5000
})
export default request
```
## 11. Redux登录
> 1. 在 Login 组件中分发登录的异步 action
> 2. 在store中login模块定义异步action,获取token并存储到redux和持久化处理
> 3. 登录成功后,跳转到首页
`modules/login.js`
```js
import { createSlice } from "@reduxjs/toolkit"
import request from '@/utils/request'
export const login = createSlice({
// 1.命名空间
name: 'login',
// 2.初始化状态
initialState: {
token: localStorage.getItem('geek-token') || ''
},
// 3.定义reducers
reducers: {
// action函数(同步)
setToken(preState, action) {
preState.token = action.payload
},
delToken(preState) {
preState.token = ''
}
}
})
// 导出action
export const { setToken, delToken } = login.actions
// 异步action
export function loginAction(formData) {
return async (dispatch) => {
// 获取token
const { data: { data } } = await request.post('/authorizations', formData)
dispatch(setToken(data.token))
// 本地存储一份
localStorage.setItem('geek-token', data.token)
}
}
// 导出reducer
export default login.reducer
```
`Login/index.tsx`
```tsx
import React from 'react'
import logo from '@/assets/logo.png'
import { Card, Form, Input, Checkbox, Button, message } from 'antd'
import './index.scss'
import { useDispatch } from 'react-redux'
import { useNavigate } from 'react-router-dom'
import { loginAction } from '@/store/modules/login'
export default function Login() {
const dispatch = useDispatch()
const history = useNavigate()
const onFinish = async values => {
try {
await dispatch(loginAction(values))
history('/')
} catch (e) {
message.error(e.message)
}
}
......
}
```
> 使用 Redux 的套路:
>
> `组件 dispatch 异步 action -> 提供异步 action -> 完成异步操作 -> 继续 dispatch 普通 action 来发起状态更新 -> reducers 处理状态更新`
## 12. 封装token工具模块
> 能够统一处理 token 的持久化相关操作
>
> 1. 创建 utils/token.js 文件
> 2. 分别提供 getToken/saveToken/clearToken/isAuth 四个工具函数并导出
> 3. 将登录操作中用到 token 的地方,替换为该工具函数
`utils/token.js`
```js
const TOKEN_KEY = 'geek-pc'
// 获取token
const getToken = () => localStorage.getItem(TOKEN_KEY)
// 存储token
const saveToken = token => localStorage.setItem(TOKEN_KEY, token)
// 清除token
const clearToken = () => localStorage.removeItem(TOKEN_KEY)
// 是否登录
const isAuth = () => !!getToken()
export { isAuth, getToken, saveToken, clearToken }
```
```js
import { getToken, saveToken } from "@/utils/token"
export const login = createSlice({
// 1.命名空间
name: 'login',
// 2.初始化状态
initialState: {
token: getToken() || ''
},
.....
})
```
## 13. 路由鉴权
> 能够实现未登录时访问拦截并跳转到登录页
>
> 1. 在 components 目录中,创建 AuthRoute/index.js 文件
> 2. 使用**鉴权方法**,判断是否登录
> 3. 登录时,直接渲染相应页面组件
> 4. 未登录时,重定向到登录页面
> 5. 将需要鉴权的页面路由配置,替换为 AuthRoute 组件
`components/AuthRoute`
```jsx
import { Navigate } from "react-router-dom"
import { isAuth } from "@/utils/token"
export default function AuthRoute({ element }) {
return (
<>
{isAuth() ? element : }
>
)
}
```
`App.js`
```jsx
import { useRoutes } from 'react-router-dom'
import AuthRoute from './components/AuthRoute'
// 导入页面组件
import Login from './pages/Login'
import Layout from './pages/Layout'
import NotFound from '@/pages/NotFound'
const routes = [
{
path: '/',
element: } />
},
{
path: '/login',
element:
},
{
path: '*',
element:
},
]
export default function App() {
const element = useRoutes(routes)
return (
{element}
)
}
```
## 14. 首页布局
> 能够根据antd布局组件搭建基础布局
>
> 1. 打开 antd/Layout 布局组件文档,找到示例:顶部-侧边通栏
> 2. 拷贝示例代码到我们的 Layout 页面中
> 3. 分析并调整页面布局
`pages/Layout/index.js`
```jsx
import './index.scss'
import React from 'react'
import { Layout, Menu, Popconfirm } from 'antd'
import { LogoutOutlined, WindowsFilled, SnippetsFilled, HighlightFilled } from '@ant-design/icons'
const { Header, Sider } = Layout
export default function GeekLayout() {
// 菜单
const items = [
{ label: '数据概览', key: '1', icon: },
{ label: '内容管理', key: '2', icon: },
{ label: '发布文章', key: '3', icon: },
]
const onClick = e => {
console.log(e.key)
}
return (
{/* + 菜单 */}
内容
)
}
```
`page/Layout/index.scss `
```scss
.ant-layout {
height: 100%;
}
.header {
padding: 0;
}
.logo {
width: 200px;
height: 60px;
background: url(../../assets/logo.png) no-repeat center / 160px auto;
}
.layout-content {
overflow-y: auto;
}
.user-info {
position: absolute;
right: 0;
top: 0;
padding-right: 20px;
color: #fff;
.user-name {
margin-right: 20px;
}
.user-logout {
display: inline-block;
cursor: pointer;
}
}
```
## 15. CSSModules
> - CSS Modules 即:CSS 模块,可以理解为对 CSS 进行模块化处理
> - 目的:为了在 React 开发时,**解决组件之间类名重复导致的样式冲突问题**
> - 使用 CSS Modules 前后的对比:
> - 使用前:自己手动为每个组件起一个唯一的类名
> - 使用后:自动生成类名,即使将来多人合作开发项目,也不会导致类名冲突
> - React 脚手架中为 CSSModules 自动生成的类名格式为:`[filename]\_[classname]\_\_[hash]`
> - filename:文件名称
> - classname:自己在 CSS 文件中写的类名
> - hash:随机生成的哈希值
```scss
/* GeekLayout 组件的 css 文件中:*/
.header {}
/* React 项目中,CSS Modules 处理后生成的类名:*/
.GeekLayout_header__adb4t {}
```
## 16. CSSModules使用
> **内容**:
>
> 1. CSS 文件名称以 `.module.css` 结尾的,此时,React 就会将其当做 CSSModules 来处理,比如,`index.module.scss`
> 2. 如果不想使用 CSSModules 的功能,只需要让样式文件名称中不带`.module` 即可,比如,`index.css`
>
> **步骤**:
>
> 1. 创建样式文件,名称格式为:`index.module.scss`
>
> 2. 在 `index.module.scss` 文件中,按照原来的方式写 CSS 即可
>
> 3. **在 JS 中通过 `import styles from './index.module.scss'` 来导入样式文件**
>
> 4. 在 JSX 结构中,通过 `className={styles.类名}` 形式来使用样式(此处的 类名 就是 CSS 中写的类名)
```jsx
// Login/index.module.css
.a {
color: red;
}
// Login/index.js
import styles from './index.module.scss'
// 对象中的属性 a 就是:我们自己写的类名
// 属性的值 就是:React 脚手架帮我们自动生成的一个类名,这个类名是随机生成的,所以,是全局唯一的!!!
// styles => { a: "Login_a__2O2Gg" }
const Login = () => {
return (
Login
)
}
export default Login
```
## 17. CSSModules规则
> 能够说出为什么 CSSModules 中的类名推荐使用驼峰命名法
1. **CSSModules 类名推荐使用驼峰命名法**,这有利于在组件的 JS 代码中访问
```scss
/* index.mdouel.css */
/* 推荐使用 驼峰命名法 */
.a {
color: red;
}
.listItem {
font-size: 30px;
}
/* 不推荐使用 短横线(-)链接的形式 */
.list-item {
font-size: 30px;
}
```
2. **不推荐嵌套样式**
- 对于 CSS 来说,嵌套样式,很重要的一个目的就是提升 CSS 样式权重,避免样式冲突
- 但是,CSSModules 生成的类名是全局唯一的,就不存在权重不够或者类名重复导致的样式冲突问题
## 18. CSSModules全局样式
> 能够在 CSSModules 中使用全局样式
>
> - 在 `*.module.css` 文件中,类名都是“局部的”,也就是只在当前组件内生效
>
> - 有些特殊情况下,如果不想要让某个类名是局部的,就需要通过 `:global()` 来处理,处理后,这个类名就变为全局的了
>
> - 从代码上来看,全局的类名是不会被 CSSModules 处理的
```scss
/* 该类型会被 CSSModules 处理 */
.title {
color: yellowgreen;
}
/* 如果这个类名,不需要进行 CSSModules 处理,可以通过添加 :global() 来包裹 */
:global(.title) {
color: yellowgreen;
}
```
## 19. CSSModules配合SASS使用
> 能够将 CSSModules 配合 SASS 使用
>
> - 每个组件的根节点使用 CSSModules 形式的类名( 根元素的类名: `root` )
> - 其他所有的子节点,都使用普通的 CSS 类名
>
> 这样处理的优势:解决组件间样式冲突问题的同时,让给组件添加样式尽量简单
>
> 说明:对`layout`组件进行样式模块化改造
```scss
.root {
// 根节点自己的样式
:global {
// 所有子节点的样式,都放在此处,因为是在 global 中,所以,此处的类名不会被 CSSModules 处理
.header {}
.logo {}
.user-info {}
}
}
```
> 组件中使用 CSSModules:
```jsx
import styles from './index.module.scss'
const GeekLayout = () => {
return (
)
}
```
## 20. 嵌套路由配置
> 1. 在 pages 目录中,分别创建:Home(数据概览)、Article(内容管理)、Publish(发布文章)页面文件夹
> 2. 分别在三个文件夹中创建 index.js 并创建基础组件后导出
> 3. 在 router公共布局页面下,配置children子路由
> 4. 在Layout父组件中,放置子路由挂载点`Outlet`
`router/index.js`
```js
import Home from '@/pages/Home'
import Article from '@/pages/Article'
import Publish from '@/pages/Publish'
import Login from '@/pages/Login'
import NotFound from '@/pages/NotFound'
import AuthRoute from '@/components/AuthRoute'
import Layout from '@/pages/Layout'
const routes = [
{
path: '/',
element: } />,
children: [
{
path: '/',
element:
},
{
path: 'article',
element:
},
{
path: 'publish',
element:
}
]
},
{
path: '/login',
element:
},
{
path: '*',
element:
},
]
export default routes
```
`pages/layout/index.jsx`
```tsx
import { Outlet } from 'react-router-dom'
```
## 21. 菜单高亮切换
> 能够在点击对应菜单时,保持对应菜单高亮
>
> 1. 将 Menu 的 key 属性修改为与其对应的路由地址
> 2. 获取到当前正在访问页面的路由地址
> 3. 将当前路由地址设置为 selectedKeys 属性的值
```tsx
+ import { useLocation, useNavigate } from 'react-router-dom'
const GeekLayout = () => {
+ const history = useNavigate()
+ const location = useLocation()
+ const selectedKey = location.pathname
// 菜单
const items = [
{ label: '数据概览', key: '/', icon: },
{ label: '内容管理', key: '/article', icon: },
{ label: '发布文章', key: '/publish', icon: },
]
const onClick = (e) => {
- // 跳转子路由
+ history(e.key)
}
return (
// ...
)
}
```
## 22. 展示个人信息
`modules/user.js`
```js
import request from "@/utils/request"
import { createSlice } from "@reduxjs/toolkit"
export const user = createSlice({
name: 'user',
initialState: {
info: {}
},
reducers: {
setUser(preState, action) {
preState.info = action.payload
}
}
})
export const { setUser } = user.actions
export const getUserAction = () => {
return async (dispatch, getState) => {
const { data: { data }} = await request.get('/user/profile', {
headers: {
Authorization: `Bearer ${getState().login.token}`
}
})
dispatch(setUser(data))
}
}
export default user.reducer
```
`store/index.js`
```js
import { configureStore } from "@reduxjs/toolkit"
import login from './modules/login'
import user from './modules/user'
export default configureStore({
reducer: {
login,
user
}
})
```
`Layout/index.jsx`
```jsx
import { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { getUserAction } from '@/store/modules/user'
const GeekLayout = () => {
const dispatch = useDispatch()
const user = useSelector(state => state.user)
useEffect(() => {
try {
dispatch(getUserAction())
} catch {}
}, [dispatch])
render() {
return (
// ...
{user.info.name}
// ...
)
}
}
```
## 23. 退出登录
`modules/login.js`
```js
import { getToken, saveToken, clearToken } from "@/utils/token"
export function logoutAction() {
return (dispatch) => {
dispatch(delToken())
clearToken()
}
}
```
`Layout/index.js`
```tsx
import { logoutAction } from '@/store/modules/login'
export default function GeekLayout() {
const onLogout = () => {
dispatch(logoutAction())
history('/login')
}
render() {
return (
// ...
退出
// ...
)
}
}
```
## 24. 请求统一添加token
> 能够通过拦截器统一添加token
>
> 因为不管是登录时,还是每次刷新页面时,已经将 token 存储在 redux 中了,
>
> 所以,可以直接通过 `store.getState()` 来获取到 redux 状态
>
> 1. 导入 store (!!! 可能导致循环引用,待解决, 暂时使用本地缓存...)
> 2. 判断是否是登录请求
> 3. 如果是,不做任何处理
> 4. 如果不是,统一添加 Authorization 请求头
```js
// 前置拦截器
request.interceptors.request.use(config => {
// 获取token
const token = getToken()
// 除登录请求外,其余请求统一加上token
if (!config.url.startsWith('/authorizations')) {
config.headers.Authorization = `Bearer ${token}`
}
return config
}, err => {
return Promise.reject(err)
})
```
```js
export const getUserAction = () => {
return async (dispatch, getState) => {
const { data: { data }} = await request.get('/user/profile')
dispatch(setUser(data))
}
}
```
## 25. 处理token失效清空(看不懂)
> 能够统一处理token失效重定向到登录页面
>
> 为了能够**在非组件环境下拿到路由信息,进行路由跳转等操作**,需要使用路由中提供的 `Router` 组件,并自定义 `history` 对象
>
> 1. 安装:`yarn add history`
> 2. 创建 router/history.js 文件
> 3. 在该文件中,创建一个 hisotry 对象并导出
> 4. 在入口index.js 中导入 history 对象,并设置为 Router 的 history
> 5. 通过响应拦截器处理 token 失效
`router/history.js`
```js
/**
* 获取react-router实例对象,在js中跳转页面
*/
import { useState, useLayoutEffect } from 'react';
import { createBrowserHistory, createHashHistory } from 'history';
import { Router } from 'react-router-dom';
// 1. history
// export const history = createBrowserHistory();
// 2. hash
// == 创建路由实例对象 =》作用:js中使用跳转页面 ==
export const history = createHashHistory();
// == 函数组件 => 作用:包裹根组件,注册history
export const HistoryRouter = ({ history, children }) => {
const [state, setState] = useState({
action: history.action,
location: history.location
});
useLayoutEffect(() => {
history.listen(setState);
}, [history]);
return
};
```
`index.js`
```js
import React from 'react'
import ReactDOM from 'react-dom/client'
// 先导入 antd 样式文件
import 'antd/dist/antd.min.css'
// 再导入全局样式文件,防止样式覆盖
import './index.scss'
import App from './App'
import { Provider } from 'react-redux'
import store from './store'
import { HistoryRouter, history } from '@/router/history'
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
)
```
`utils/request.js`
```js
// 响应拦截器
request.interceptors.response.use((config) => { return config }, err => {
if (!err.response) {
message.error('网络繁忙,请稍后重试!')
return Promise.reject(err)
}
if (err.response.status === 401) {
message.error(err.response.data?.message, 1.5, () => {
// 删除token
clearToken()
customHistory.push('/login', {
from: customHistory.location.pathname
})
})
}
return Promise.reject(err)
})
```
## 26. 首页展示
`Home/index.module.scss`
```scss
.root {
width: 100%;
height: 100%;
background: #f5f5f5 url(../../assets/chart.png) no-repeat;
}
```
`Home/index.tsx`
```jsx
import React from 'react'
import style from './index.module.scss'
export default function Home() {
return (
)
}
```
## 27. 文章筛选区域结构
`Article/index.jsx`
```tsx
import { Link } from 'react-router-dom'
import { Card, Breadcrumb, Form, Button, Radio, DatePicker, Select } from 'antd'
import 'moment/locale/zh-cn'
import locale from 'antd/es/date-picker/locale/zh_CN'
const { Option } = Select
const { RangePicker } = DatePicker
export default function Arrticl() {
return (
首页
内容管理
}
style={{ marginBottom: 20 }}
>
全部
草稿
待审核
审核通过
审核失败
)
}
```
## 28. 渲染文章表格
`article.js`
```js
import request from "@/utils/request"
import { createSlice } from "@reduxjs/toolkit"
export const article = createSlice({
name: 'article',
initialState: {
channelList: [],
articleList: [],
query: {
count: 0,
page: 1,
pageSize: 10
}
},
reducers: {
setChannelList(preState, action) {
preState.channelList = action.payload
},
setArticleList(preState, action) {
preState.articleList = action.payload.results
preState.query = {
count: action.payload.total_count,
page: action.payload.page,
pageSize: action.payload.per_page
}
}
}
})
export const { setChannelList, setArticleList } = article.actions
export function getChannelAction() {
return async (dispatch) => {
const { data: { data: { channels } } } = await request.get('/channels')
dispatch(setChannelList(channels))
}
}
export function getArticleAction(params) {
return async (dispatch) => {
const { data: { data } } = await request.get('/mp/articles', { params })
dispatch(setArticleList(data))
}
}
export default article.reducer
```
`Article/index.jsx`
```jsx
import { Link } from 'react-router-dom'
import { Card, Breadcrumb, Form, Button, Radio, DatePicker, Select, Table, Tag, Space } from 'antd'
import 'moment/locale/zh-cn'
import locale from 'antd/es/date-picker/locale/zh_CN'
import { EditOutlined, DeleteOutlined } from '@ant-design/icons'
import img404 from '@/assets/error.png'
import { useDispatch, useSelector } from 'react-redux'
import { useEffect } from 'react'
import { getArticleAction, getChannelAction } from '@/store/modules/article'
const { Option } = Select
const { RangePicker } = DatePicker
export default function Arrticl() {
// 优化文章状态的处理
const articleStatus = {
0: { color: 'yellow', text: '草稿' },
1: { color: '#ccc', text: '待审核' },
2: { color: 'green', text: '审核通过' },
3: { color: 'red', text: '审核失败' },
}
const columns = [
{
title: '封面',
dataIndex: 'cover',
render: cover => {
return
},
},
{
title: '标题',
dataIndex: 'title',
width: 220,
},
{
title: '状态',
dataIndex: 'status',
render: data => {
const tagData = articleStatus[data]
return {tagData.text}
},
},
{
title: '发布时间',
dataIndex: 'pubdate',
},
{
title: '阅读数',
dataIndex: 'read_count',
},
{
title: '评论数',
dataIndex: 'comment_count',
},
{
title: '点赞数',
dataIndex: 'like_count',
},
{
title: '操作',
render: data => {
return (
} />
} />
)
},
},
]
/* const data = [
{
id: '8218',
comment_count: 0,
cover: 'http://geek.itheima.net/resources/images/15.jpg',
like_count: 0,
pubdate: '2019-03-11 09:00:00',
read_count: 2,
status: 2,
title: 'webview离线化加载h5资源解决方案',
},
] */
const dispatch = useDispatch()
const {
channelList: channels,
query: { page, pageSize, count },
articleList,
} = useSelector(state => state.article)
useEffect(() => {
dispatch(getChannelAction())
}, [dispatch])
useEffect(() => {
dispatch(getArticleAction())
}, [dispatch])
return (
)
}
```
## 29. 筛选功能
```jsx
const onSearch = values => {
const { status, channel_id, date } = values
// 将表单中选中数据,组装成接口需要的数据格式,然后,传递给接口
const params = { channel_id }
// 处理状态
if (status !== -1) {
params.status = status
}
// 日期范围
if (typeof date !== 'undefined' && date !== null) {
params.begin_pubdate = date[0].format('YYYY-MM-DD HH:mm:ss')
params.end_pubdate = date[1].format('YYYY-MM-DD HH:mm:ss')
}
dispatch(getArticleAction(params))
}
return (
+
```
```scss
:global {
.publish-quill {
.ql-editor {
height: 300px;
}
}
}
```
## 38. 封装频道选择组件
```jsx
import React, { useEffect } from 'react'
import { Select } from 'antd'
import { useDispatch, useSelector } from 'react-redux'
import { getChannelAction } from '@/store/modules/article'
const { Option } = Select
export default function Channel({ width, value, onChange }) {
const { channelList: channels } = useSelector(state => state.article)
const dispatch = useDispatch()
useEffect(() => {
dispatch(getChannelAction())
}, [dispatch])
return (
)
}
```
```jsx
```
## 39. 上传封面
> 1. 为 Upload 组件添加 action 属性,指定封面图片上传接口地址
> 2. 根据接口文档,给 Upload 组件添加 name 属性,值为:image(注意:不加该 name 属性会报错!)
> 3. 创建状态 fileList 存储已上传封面图片地址,并设置为 Upload 组件的 fileList 属性值
> 4. 为 Upload 添加 onChange 属性,监听封面图片上传、删除等操作
> 5. 在 change 事件中拿到当前图片数据,并存储到状态 fileList 中
```jsx
单图
三图
无图
{/* 自动 */}
```
```js
const [fileList, setFileList] = useState()
const onUploadChange = info => {
const fileList = info.fileList.map(file => {
if (file.response) {
return {
url: file.response.data.url
}
}
return file
})
setFileList(fileList)
}
```
## 40. 控制封面数量
> 1. 创建状态 maxCount
> 2. 给 Radio 添加 onChange 监听单图、三图、无图的切换事件
> 3. 在切换事件中修改 maxCount 值
> 4. 只在 maxCount 不为零时展示 Upload 组件
> 5. 修改 Upload 组件的 maxCount、multiple 属性
```jsx
const [maxCount, setMaxCount] = useState()
const changeType = e => {
const count = e.target.value
setMaxCount(count)
}
```
```jsx
单图
三图
无图
{/* 自动 */}
{maxCount > 0 && (
1}
maxCount={maxCount}
action="http://geek.itheima.net/v1_0/upload"
fileList={fileList}
onChange={onUploadChange}
>
)}
```
## 41. 动态切换封面数量
> 问题:如果当前有 3 张图,选择单图只显示一张(3张 -> 1张),再切换到三图继续显示三张(1张 -> 3张),该如何实现?
>
> 回答:通过 ref 存储所有图片,需要几张就展示几张(也就是把 ref 当仓库,用多少拿多少)
>
> 1. 过 `useRef` hook 创建 ref 对象,在上传图片时,存储所有已上传图片
> 2. 如果是单图,只展示第一张图片
> 3. 如果是三图,展示所有图片
```js
const [fileList, setFileList] = useState([])
const [maxCount, setMaxCount] = useState(1)
const fileListRef = useRef([])
const changeType = e => {
const count = e.target.value
setMaxCount(count)
if (count === 1) {
// 单图,只展示一张
const firstImg = fileListRef.current[0]
setFileList(firstImg === undefined ? [] : [firstImg])
} else if (count === 3) {
// 三图 展示全部
setFileList(fileListRef.current)
}
}
const onUploadChange = info => {
const fileList = info.fileList.map(file => {
if (file.response) {
return {
url: file.response.data.url,
}
}
return file
})
setFileList(fileList)
fileListRef.current = fileList
}
```
## 42. 提交数据-发布文章
```jsx
const onFinish = async values => {
// 说明:如果选择 3 图,图片数量必须是 3 张,否则,后端会当做单图处理
// 后端根据上传图片数量,来识别是单图或三图
if (values.type !== fileList.length) {
return message.warning('封面数量和所选类型不匹配')
}
const { type, ...rest } = values
const data = {
...rest,
cover: {
type,
images: fileList.map(item => item.url)
}
}
console.log('接口需要的数据格式:', data)
}
```
## 43. 发布文章
> 1. 在组装表单提交数据后,分发提交数据的异步 action
> 2. 在 action 中拿到数据并发送请求
> 3. 提交数据后,跳转到内容管理页面
```js
export function updateArticleAction(data) {
return async () => {
await request.post('/mp/articles?draft=false', data)
}
}
```
```js
const onFinish = async values => {
// 说明:如果选择 3 图,图片数量必须是 3 张,否则,后端会当做单图处理
// 后端根据上传图片数量,来识别是单图或三图
if (values.type !== fileList.length) {
return message.warning('封面数量和所选类型不匹配')
}
const { type, ...rest } = values
const data = {
...rest,
cover: {
type,
images: fileList.map(item => item.url)
}
}
try {
await dispatch(updateArticleAction(data))
message.success('发布成功', 1, () => {
nav('/article')
})
} catch(e) {
message.error(e.message)
}
}
```
## 44. 存入草稿
> **目标**:能够在点击存入草稿时获取到表单数据
>
> **分析说明**:
>
> 如果要在非提交按钮中获取到表单数据,需要通过调用 Form 的实例方法来实现。有两个方法:
>
> 1. `getFieldsValue()` 仅获取表单数据,不进行表单校验
> 2. `validateFields()` 先**进行表单校验,再获取表单数据**【此处,使用该方法】
>
> - 存入草稿功能类似于发布文章,只是 draft 参数值为 true。因此,可以复用发布文章的逻辑
>
> **步骤**:
>
> 1. 通过 `Form.useForm()` 创建表单的控制实例
> 2. 将创建好的实例,设置为 Form 的 form 属性值
> 3. 为存入草稿按钮绑定点击事件
> 4. 在事件中,调用表单的 validateFields 方法,先进行表单校验再获取表单值
```jsx
const [form] = Form.useForm()
const saveDraft = async () => {
try {
const values = await form.validateFields()
console.log(values)
} catch(e) {
message.error(e.message)
}
}
```
## 45. 存入草稿和发布
> 存入草稿功能类似于发布文章,只是 draft 值为true。所以,可以复用发布文章的逻辑
>
> 1. 封装方法 publishArticle,该方法有两个参数:1 表单数据 2 类型(发布 or 草稿)
> 2. 在该方法中实现封面图片判断、表单数据格式处理、action 分发等功能
> 3. 复用该方法,即在发布文章或存入草稿时,传入相应参数调用
```js
// 复用发布方法
const saveArticles = async (values, saveType) => {
if (values.type !== fileList.length) return message.warning('封面数量与所选类型不匹配')
const { type, ...rest } = values
const data = {
...rest,
cover: {
type,
// images: fileList
images: fileList.map(item => item.url),
},
}
try {
// true 草稿
// false 正常发布
await dispatch(updateArticleAction(data, saveType !== 'add'))
const showMsg = saveType === 'add' ? '发布成功' : '存入草稿成功'
message.success(showMsg, 1, () => {
nav('/article')
})
} catch (e){
message.error(e.message)
}
}
```
```js
const saveDraft = async () => {
try {
const values = await form.validateFields()
// console.log(values)
saveArticles(values, 'draft')
} catch (e) {
message.error(e.message)
}
}
const onFinish = async values => {
saveArticles(values, 'add')
}
```
```js
export function updateArticleAction(data, draft) {
return async () => {
await request.post(`/mp/articles?draft=${draft ? 'true' : 'false'}`, data)
}
}
```
## 46. 修改文章
> 能够在编辑文章时展示编辑文章时的文案
>
> 通过路由的 `useParams` hook 可以拿到路由参数
>
> 1. 通过路由参数拿到文章id
> 2. 根据文章 id 是否存在判断是否为编辑状态
> 3. 如果是编辑状态,展示编辑时的文案信息
```diff
+import { useParams } from 'react-router-dom'
const Publish = () => {
+ const params = useParams()
+ const isEdit = params.id !== undefined
return (
首页
+ {isEdit ? '修改文章' : '发布文章'}
}
>
// ...
)
}
```
```diff
const routes = [
{
path: '/',
element: } />,
children: [
{
path: '/',
element:
},
{
path: 'article',
element:
},
{
+ path: 'publish',
element:
},
+ {
+ path: 'publish/:id',
+ element:
}
]
},
{
path: '/login',
element:
},
{
path: '*',
element:
},
]
```
## 47. 回显编辑数据
```js
const articleId = params.id
const isEdit = articleId !== undefined
useEffect(() => {
const loadData = async () => {
if (!isEdit) return
try {
// 获取文章数据
const {
data: { data },
} = await request.get(`/mp/articles/${articleId}`)
const {
id,
title,
channel_id,
content,
cover: { type, images },
} = data
// 文章数据
const article = {
id,
title,
channel_id,
content,
type,
images,
}
const { images: imgs, ...formValue } = article
// 动态设置表单数据
form.setFieldsValue(formValue)
// 格式化封面图片数据
const imageList = imgs.map(item => ({ url: item }))
setFileList(imageList)
setMaxCount(formValue.type)
fileListRef.current = imageList
} catch (e) {
message.error(e.message)
}
}
// 执行
loadData()
}, [articleId, isEdit, form])
```
## 48. 完成编辑
```js
export function updateArticleAction(data, draft, isEdit) {
return async () => {
if (isEdit) {
// 编辑
await request.put(`/mp/articles/${data.id}?draft=${draft ? 'true' : 'false'}`, data)
} else {
await request.post(`/mp/articles?draft=${draft ? 'true' : 'false'}`, data)
}
}
}
```
```js
{
......
// 查看是否是修改文章
if (isEdit) {
data.id = articleId
}
try {
// true 草稿
// false 正常发布
await dispatch(updateArticleAction(data, saveType !== 'add', isEdit))
const text = isEdit ? '修改' : '发布'
const showMsg = saveType === 'add' ? `${text}成功` : '存入草稿成功'
message.success(showMsg, 1, () => {
nav('/article')
}
} catch (e) {
message.error(e.message)
}
}
```
## 49. 404页面
```js
import React, { useState } from 'react'
import styles from './index.module.scss'
import { Link } from 'react-router-dom'
export default function NotFound() {
const [count] = useState(10)
return (
对不起,您访问的页面不存在~
将在 {count} 秒后,返回首页(或者:点击立即返回
首页)
)
}
```
```scss
.root {
width: 100vw;
height: 100vh;
padding-top: 20px;
text-align: center;
// css中使用@需要在前边加~
background: #191742 url('~@/assets/404.gif') no-repeat center center;
:global {
// 全局样式
h1,
p {
color: #fff;
}
}
}
```
## 50. 清理函数组件的倒计时
> 要想在组件更新后清理定时器,就需要让两处的 timerId 值是同一个,也就是要**保持 timerId 的值在组件更新期间保持不变**。此时,就用到:`useRef` Hook 了。
>
> 对于倒计时的定时器来说,只需要在组件创建时,开启一次即可。为了做到这一点,可以通过 `useEffect(() => {}, [])` 来实现
>
> 注意:此处的关键点是依赖项参数为:`[]`(空数组)。因此,不能在 effect 回调函数中,依赖外部的数据。
>
> 但是,页面中的计数器数值又要更新,因此就会有一个新的问题:如何在不依赖于外部数据的情况下,在 effect 回调中,更新状态?
```jsx
import React, { useEffect, useRef, useState } from 'react'
import styles from './index.module.scss'
import { Link, useNavigate } from 'react-router-dom'
export default function NotFound() {
const [count, setCount] = useState(10)
let timerRef = useRef(-1)
const nav = useNavigate()
useEffect(() => {
timerRef.current = setInterval(() => {
setCount(count => count - 1)
}, 1000)
// 清除定时器
return () => {
clearInterval(timerRef.current)
}
}, [])
useEffect(() => {
if (count === 0) {
nav('/', { replace: true })
}
}, [count, nav])
return (
对不起,您访问的页面不存在~
将在 {count} 秒后,返回首页(或者:点击立即返回
首页)
)
}
```
## 51. 项目打包
> 能够通过命令对项目进行打包
>
> 1. 在项目根目录打开终端,输入打包命令:`yarn build`
> 2. 等待打包完成,打包后的内容被放在项目根下的 build 文件夹中
>
> 扩展:查看打包进度
>
> 1. 安装:`npm i simple-progress-webpack-plugin -D`
> 2. 配置craco.config.js:
```js
const path = require('path')
const SimpleProgressWebpackPlugin = require('simple-progress-webpack-plugin')
module.exports = {
// webpack 配置
webpack: {
// 配置别名
alias: {
// 约定:使用 @ 表示 src 文件所在路径
'@': path.resolve(__dirname, 'src')
},
plugins: [
new SimpleProgressWebpackPlugin()
]
}
}
```
## 52. 懒加载
> 能够对路由进行懒加载实现代码分割
>
> React 中提供了 `React.lazy()` 来动态加载组件,可以实现:延迟加载在初次渲染时未用到的组件
>
> 注意:
>
> 1. `React.lazy()` 需要配合 `React.Suspense` 组件来使用
> 2. `React.lazy` 目前只支持默认导出(default exports),[参考文档](https://zh-hans.reactjs.org/docs/code-splitting.html#named-exports)
>
> `React.Suspense` 组件:指定加载中的提示,在动态加载的组件未加载完成前展示加载中效果
>
> 1. 在 App 组件中,导入 Suspense 组件
> 2. 在 Router 内部,使用 Suspense 组件包裹组件内容
> 3. 为 Suspense 组件提供 fallback 属性,指定 loading 占位内容
> 4. 导入 lazy 函数,并修改为懒加载方式导入路由组件
```js
// import Login from '@/pages/Login'
// import NotFound from '@/pages/NotFound'
import AuthRoute from '@/components/AuthRoute'
// import Layout from '@/pages/Layout'
import { lazy } from 'react'
// 懒加载
const Layout = lazy(() => import('@/pages/Layout'))
const Login = lazy(() => import('@/pages/Login'))
const NotFound = lazy(() => import('@/pages/NotFound'))
```
`App.js`
```js
import { useRoutes } from 'react-router-dom'
import routes from '@/router'
import { Suspense } from 'react'
import { Spin } from 'antd'
import './App.scss'
export default function App() {
const element = useRoutes(routes)
return (
}>
{element}
)
}
```
`App.scss`
```scss
.lazyLoad {
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
```
## 53. 项目打包本地预览
> 能够在本地预览打包后的项目
>
> **步骤**:
>
> 1. 全局安装本地服务包:`npm i -g serve`,该包提供了 `serve` 命令,用来启动本地服务
> 2. 在项目根目录中执行命令:`serve -s ./build`,在 build 目录中开启服务器
> 3. 在浏览器中访问:`http://localhost:5000/` 预览项目
>
> - 或者可以直接通过 `npx serve -s ./build` 来直接使用,省略全局安装的过程
## 54. 自定义环境变量
> 能够通过自定义环境变量配置接口地址
>
> 常用的环境有两个:
>
> 1. 开发环境:项目开发期间的环境,一般使用 `.env.development` 文件来创建自定义环境变量
> 2. 生产环境:项目打包上线的环境,一般使用 `.env.production` 文件来创建自定义环境变量
>
> * 在运行 `yarn start` 时,CRA 会自动读取 `.env.development` 中的环境变量;
>
> * 在运行 `yarn build` 时,会自动读取 `.env.production` 中的环境变量
>
> React CRA 中约定,所有自定义环境变量名称必须以:`REACT_APP_` 开头
`.env.development`
```js
# 设置接口地址
REACT_APP_URL=http://geek.itheima.net/v1_0
```
`.env.production`
```js
# 设置接口地址
REACT_APP_URL=http://geek.itheima.net/v1_0
```
`request.js`
```js
import axios from 'axios'
import { clearToken, getToken } from './token'
import { message } from 'antd'
import { history as customHistory } from '@/router/history'
const baseURL = process.env.REACT_APP_URL
const request = axios.create({
baseURL,
timeout: 5000
})
```