# 极客园-PC
**Repository Path**: PunchOvO/geek-pc
## Basic Information
- **Project Name**: 极客园-PC
- **Description**: 极客园PC端项目:个人自媒体管理端。链接为线上体验版本,管理geek-mobile移动端的内容
- **Primary Language**: JavaScript
- **License**: Not specified
- **Default Branch**: punch
- **Homepage**: http://geek-pc.itheima.net/login
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2023-08-08
- **Last Updated**: 2023-12-14
## Categories & Tags
**Categories**: Uncategorized
**Tags**: React, Redux, Ant-Design, Sass
## README
# 项目概述
极客园 PC 端项目:个人自媒体管理端,对标CSDN、博客园等竞品仿写。基于 React + hooks + TypeScript + Ant Design + Redux 环境开发此项目
- 项目功能和演示,包括
- 登录、退出
- 首页
- 内容(文章)管理:文章列表、发布文章、修改文章
- 技术栈:
- 项目搭建: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
> 项目启动:yarn | (npm run) start
## 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 {}
```
### 15.1 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
```
### 15.2 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 生成的类名是全局唯一的,就不存在权重不够或者类名重复导致的样式冲突问题
### 15.3 CSSModules全局样式
> 能够在 CSSModules 中使用全局样式
>
> - 在 `*.module.css` 文件中,类名都是“局部的”,也就是只在当前组件内生效
>
> - 有些特殊情况下,如果不想要让某个类名是局部的,就需要通过 `:global()` 来处理,处理后,这个类名就变为全局的了
>
> - 从代码上来看,全局的类名是不会被 CSSModules 处理的
```scss
/* 该类型会被 CSSModules 处理 */
.title {
color: yellowgreen;
}
/* 如果这个类名,不需要进行 CSSModules 处理,可以通过添加 :global() 来包裹 */
:global(.title) {
color: yellowgreen;
}
```
### 15.4 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 (
)
}
```
## 16. 嵌套路由配置
> 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'
```
## 17. 菜单高亮切换
> 能够在点击对应菜单时,保持对应菜单高亮
>
> 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 (
// ...
)
}
```
## 18. 展示个人信息
`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}
// ...
)
}
}
```
## 19. 退出登录
`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 (
// ...
退出
// ...
)
}
}
```
## 20. 请求统一添加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))
}
}
```
## 21. 处理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)
})
```
## 22. 首页展示
`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 (
)
}
```