上一篇文章讲的是后端渲染的项目 - Egg.js 试水 - 天气预报。但是没有引入数据库。这次的试水项目是文章的增删改查,将数据库引进,并且实现前后端分离。
如题,我们直接进入正题~🙆
项目结构
article-project
├── client
├── service
└── README.md
因为是前后端分离的项目,那么我们就以文件夹client
存放客户端,以文件夹service
存放服务端。README.md
是项目说明文件。
客户端初始化
为了快速演示,我们使用vue-cli
脚手架帮我们生成项目,并引入了vue-ant-design
。
项目初始化
推荐使用yarn进行包管理。
$ npm install -g @vue/cli
# 或者
$ yarn global add @vue/cli
然后新建一个项目。
$ vue create client
接着我们进入项目并启动。
$ cd client
$ npm run serve
# 或者
$ yarn run serve
此时,我们访问浏览器地址http://localhost:8080/
,就会看到欢迎页面。
最后我们引入ant-design-vue
。
$ npm install ant-design-vue
# 或
$ yarn add ant-design-vue
在这里,我们全局引入ant-design-vue
的组件。实际开发中,按需引入比较友好,特别是只是使用了该UI
框架部分功能组件的时候。
// src/main.js
import Vue from 'vue'
import App from './App.vue'
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/antd.css'
// 使用了 ant-design-vue
Vue.use(Antd)
Vue.config.productionTip = false;
new Vue({
render: h => h(App),
}).$mount('#app');
当然,在此项目中,还牵涉到几种
npm
包,之后只写yarn
或者npm
命令行操作。
路由设置
路由的跳转需要vue-router
的协助。
# 路由
$ yarn add vue-router
# 进度条
$ yarn add nprogress
这里只用到登录页,首页,文章列表页面和文章的新增/编辑页面。所以我的路由配置如下:
// src/router/index.js
import Vue from 'vue'
import Router from 'vue-router'
import Index from '@/views/index'
import { UserLayout, BlankLayout } from '@/components/layouts'
import NProgress from 'nprogress' // progress bar
import 'nprogress/nprogress.css' // progress bar 样式
const whiteList = ['login'] // 不允许重定向的白名单列表
import { getStore } from "@/utils/storage"
Vue.use(Router)
const router = new Router({
routes: [
{
path: '/',
name: 'index',
redirect: '/dashboard/workplace',
component: Index,
children: [
{
path: 'dashboard/workplace',
name: 'dashboard',
component: () => import('@/views/dashboard')
},
{
path: 'article/list',
name: 'article_list',
component: () => import('@/views/article/list')
},
{
path: 'article/info',
name: 'article_info',
component: () => import('@/views/article/info')
}
]
},
{
path: '/user',
component: UserLayout,
redirect: '/user/login',
// hidden: true,
children: [
{
path: 'login',
name: 'login',
component: () => import(/* webpackChunkName: "user" */ '@/views/user/login')
}
]
},
{
path: '/exception',
component: BlankLayout,
redirect: '/exception/404',
children: [
{
path: '404',
name: '404',
component: () => import(/* webpackChunkName: "user" */ '@/views/exception/404')
}
]
},
{
path: '*',
component: () => import(/* webpackChunkName: "user" */ '@/views/exception/404')
}
],
// base: process.env.BASE_URL,
scrollBehavior: () => ({ y: 0 }),
})
router.beforeEach((to, from, next) => {
NProgress.start() // start progress bar
if(getStore('token', false)) { // 有token
if(to.name === 'index' || to.path === '/index' || to.path === '/') {
next({ path: '/dashboard/workplace'})
NProgress.done()
return false
}
next()
} else {
if(to.path !== '/user/login') {
(new Vue()).$notification['error']({
message: '验证失效,请重新登录!'
})
}
if(whiteList.includes(to.name)) {
// 在免登录白名单,直接进入
next()
} else {
next({
path: '/user/login',
query: {
redirect: to.fullPath
}
})
NProgress.done()
}
}
next()
})
router.afterEach(route => {
NProgress.done()
})
export default router
上面,我们设定了不允许跳转的白名单列表,也就是在用户非授权的前提下,允许该页面的展示,而其他页面(比如列表页面)在未授权的情况下,会自动跳转到登陆页面。然后,我们设定了该系统所需的各种路由,比如 dashboard/workplace
导航页面,article/list
文章列表页面。当所请求的路径匹配不上设定的路由的时候,自动跳转到 404
的组件内容。
在上面的代码中,我们还处理了路由的狗子函数 beforeEach
和 afterEach
,并且引入了加载进度 NProgress
的提示,当路由跳转成功,则进度加载完成。 ✅
接口请求设置
这里接口请求使用了axios,我们来集成下。
# axios
$ yarn add axios
我们即将要代理的后端服务的地址是127.0.0.1:7001
,所以我们的配置如下:
// vue.config.js
...
devServer: {
host: '0.0.0.0',
port: '9008',
https: false,
hotOnly: false,
proxy: { // 配置跨域
'/api': {
//要访问的跨域的api的域名
target: 'http://127.0.0.1:7001/',
ws: true,
changOrigin: true
},
},
},
...
我们在 utils
文件夹内封装下 axios
🙂
// src/utils/request.js
import Vue from 'vue'
import axios from 'axios'
import store from '@/store'
import notification from 'ant-design-vue/es/notification'
import { ACCESS_TOKEN } from '@/store/mutation-types'
import { notice } from './notice';
const err = (error) => {
if (error.response) {}
return Promise.reject(error)
}
function loginTimeOut () {
notification.error({ message: '登录信息失效', description: '请重新登录' })
store.dispatch('user/logout').then(() => {
setTimeout(() => {
window.location.reload()
}, 1500)
})
}
// 创建 auth axios 实例
const auth = axios.create({
headers: {
'Content-Type': 'application/json;charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest'
},
baseURL: '/', // api base_url
timeout: 10000 // 请求超时时间 10秒钟
})
// request interceptor
auth.interceptors.request.use(config => {
const token = Vue.ls.get(ACCESS_TOKEN)
if (token) {
// 让每个请求携带自定义 token 请根据实际情况自行修改
config.headers[ 'Authorization' ] = 'JWT '+ token
}
return config
}, err)
// response interceptor
auth.interceptors.response.use(
response => {
if (response.code === 10140) {
loginTimeOut()
} else {
return response.data
}
},
error => { // 错误处理
console.log(error.response, 'come here')
if(error.response && error.response.status === 403) {
notice({
title: '未授权,你没有访问权限,请联系管理员!',
}, 'notice', 'error', 5)
return
}
notice({
title: (error.response && error.response.data && error.response.data.msg) || (error.response && `${error.response.status} - ${error.response.statusText}`),
}, 'notice', 'error', 5)
}
)
export {
auth
}
上面,我们在 request.js
文件中,创建了 axios
的实例,并且对请求 request
和响应 response
做了拦截;在 request
中设置了登陆凭证,在 response
中处理了返回的数据。最后,我们通过 export
导出了该实例。
通过
export
,是为了方便后面添加更多的实例并导出
样式预处理器
当然,为了更好的管理我们的页面样式,建议还是添加一种 CSS 预处理器
。这里我们选择了 less 预处理器。
# less 和 less-loader
$ yarn add less --dev
$ yarn add less-loader --dev
仅仅是安装还不行,我们还得在 vue.config.js
中进行配置。
// vue.config.js
...
css: {
loaderOptions: {
less: {
modifyVars: {
blue: '#3a82f8',
'text-color': '#333'
},
javascriptEnabled: true
}
}
},
...
布局文章页面
文章列表页的骨架:
<!--src/views/article/list.vue-->
<template>
<div class="article-list">
<a-table
style="border: none;"
bordered
:loading="loading"
:rowKey="row => row.id"
:columns="columns"
:data-source="data"
:pagination="pagination"
@change="change"/>
</div>
</template>
上面,我们通过 ant-design-vue
中的 table
组件,设定一个表格的 UI
,table
组件封装了各种属性和方法,比如 loading
, pagination
等。
文章编辑/新增页的骨架:
<!--src/views/article/info.vue-->
<template>
<div class="article-info">
<a-spin :spinning="loading">
<a-row style="display: flex; justify-content: flex-end; margin-bottom: 20px;">
<a-button type="primary" @click="$router.go(-1)">返回</a-button>
</a-row>
<a-form :form="form" v-bind="formItemLayout">
<a-form-item
label="标题">
<a-input
placeholder="请输入标题"
v-decorator="[
'title',
{rules: [{ required: true, message: '请输入标题'}]}
]"/>
</a-form-item>
<a-form-item
label="分组">
<a-select
showSearch
v-decorator="[
'group',
{rules: [{ required: true, message: '请选择分组'}]}
]"
placeholder="请选择分组">
<a-select-option value="分组1">分组1</a-select-option>
<a-select-option value="分组2">分组2</a-select-option>
<a-select-option value="分组3">分组3</a-select-option>
<a-select-option value="分组4">分组4</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="作者">
<a-input
placeholder="请输入作者"
v-decorator="[
'author',
{rules: [{ required: true, message: '请输入作者'}]}
]"/>
</a-form-item>
<a-form-item
label="内容">
<a-textarea
:autosize="{ minRows: 10, maxRows: 12 }"
placeholder="请输入文章内容"
v-decorator="[
'content',
{rules: [{ required: true, message: '请输入文章内容'}]}
]"/>
</a-form-item>
</a-form>
<a-row style="margin-top: 20px; display: flex; justify-content: space-around;">
<a-button @click="$router.go(-1)">取消</a-button>
<a-button type="primary" icon="upload" @click="submit">提交</a-button>
</a-row>
</a-spin>
</div>
</template>
上面我们使用了 ant-design-vue
中的 spin
组件,用来提示数据正在加载中;用了 form
组件,来编写表单的内容和提示;用了 input
组件, select
组件和 textarea
组件,充当 form
组件的表单内容。
前端的项目有了雏形,下面搭建下服务端的项目。
服务端初始化
这里我们直接使用 eggjs 框架来实现服务端。你可以考虑使用 typescript
方式的来初始化项目,但是我们这里直接使用 javascript
而不是它的超级 typescript
来初始化项目。
初始化项目
$ mkdir service
$ cd service
$ npm init egg --type=simple
$ npm i
启动项目:
$ npm run dev
在浏览器中打开 localhost:7001
地址,我们就可以看到 eggjs
的欢迎页面。当然,我们这里基本上不会涉及到浏览器页面,因为我们开发的是 api 接口
。更多的是使用 postman
工具进行调试。
引入数据库
这里使用的数据库是 mysql,但是我们不是直接使它,而是安装封装过的 mysql2
和 egg-sequelize
。
在 Node.js 社区中,sequelize 是一个广泛使用的 ORM 框架,它支持 MySQL、PostgreSQL、SQLite 和 MSSQL 等多个数据源。它会辅助我们将定义好的 Model 对象加载到 app 和 ctx 上。
# 安装mysql
$ yarn add mysql2
# 安装sequelize
$ yarn add egg-sequelize
当然,我们需要一个数据库进行连接,那就得安装一个数据库,如果你使用的是mac os
的话,你可以通过下面的方法进行安装:
brew install mysql
brew services start mysql
window
系统的话,可以考虑下载相关的安装包执行就行了,这里不展开说了。
数据库安装好后,我们管理数据库,可以通过控制台命令行进行控制,也可以通过图形化工具进行控制。推荐后者,我们下载了一个 Navicat Premiun
的工具。
Navicat Premiun 是一款数据库管理工具。
当然还可以下载 phpstudy
进行辅助开发。
连接数据库
配置数据库的基本信息,前提是我们已经创建好了这个数据库。假设我们创建了一个名为 article
的数据库,用户是 reng
,密码是123456
。那么,我们就可以像下面这样连接。
// config/config.default.js
...
config.sequelize = {
dialect: 'mysql',
host: '127.0.0.1',
port: 3306,
database: 'article',
username: 'reng',
password: '123456',
operatorsAliases: false
};
...
当然,这是通过包 egg-sequelize
处理的,我们也要将其引入,告诉 eggjs
去使用这个插件。如下👇
// config/plugin.js
...
sequelize: {
enable: true,
package: 'egg-sequelize',
},
...
创建数据库表
你可以直接通过控制台命令行执行 mysql
语句创建。但是,我们直接使用迁移操作完成。
在项目中,我们希望将所有的数据库 Migrations
相关的内容都放在 database
目录下面,所以我们在根目录下新建一个 .sequelizerc
配置文件:
// .sequelizerc
'use strict';
const path = require('path');
module.exports = {
config: path.join(__dirname, 'database/config.json'),
'migrations-path': path.join(__dirname, 'database/migrations'),
'seeders-path': path.join(__dirname, 'database/seeders'),
'models-path': path.join(__dirname, 'app/model'),
};
初始化 Migrations
配置文件和目录。
npx sequelize init:config
npx sequelize init:migrations
更加详细内容,可见 eggjs sequelize 章节。
我们按照官网上的操作初始化了文章列表的数据库表 articles
。对应的 model
内容如下:
// app/model/article.js
'use strict';
module.exports = app => {
const { STRING, INTEGER, DATE, NOW, TEXT } = app.Sequelize;
const Article = app.model.define('articles', {
id: {type: INTEGER, primaryKey: true, autoIncrement: true},//记录id
title: {type: STRING(255)},// 标题
group: {type: STRING(255)}, // 分组
author: {type: STRING(255)},// 作者
content: {type: TEXT}, // 内容
created_at: {type: DATE, defaultValue: NOW},// 创建时间
updated_at: {type: DATE, defaultValue: NOW}// 更新时间
}, {
freezeTableName: true // 不自动将表名添加复数
});
return Article;
};
上面,我们创建 model
数据表 articles
对应的字段名,主键是 id
,会进行自增长。
CRUD API
C - Create (创建),R - Read (读取),U - Update (更新),D - Delete (删除)
上面服务端的工作,已经帮我们做好编写接口的准备了。那么,下面结合数据库,我们来实现下文章增删改查
的操作。
我们使用的是 MVC
的架构,那么我们的现有代码逻辑自然会这样流向:
app/router.js
获取文章路由到 -> app/controller/article.js
中对应的方法 -> 到app/service/article.js
中的方法。那么,我们就主要展示在 controller
层和 service
层做的事情吧。毕竟 router
层没啥好讲的。
获取文章列表
[get] /api/get-article-list
// app/controller/article.js
...
async getList() {
const { ctx } = this
const { page, page_size } = ctx.request.query
let lists = await ctx.service.article.findArticle({ page, page_size })
ctx.returnBody(200, '获取文章列表成功!', {
count: lists && lists.count || 0,
results: lists && lists.rows || []
}, '00000')
}
...
上面设定了获取文章列表的方法 getList
,并且指定了前端传递过来的参数 page
和 page_size
参数,然后调用了下面的方法 findArticle
来获取文章列表信息,然后返回相关查询信息。
// app/service/article.js
...
async findArticle(obj) {
const { ctx } = this
return await ctx.model.Article.findAndCountAll({
order: [['created_at', 'ASC']],
offset: (parseInt(obj.page) - 1) * parseInt(obj.page_size),
limit: parseInt(obj.page_size)
})
}
...
如上,我们以字段 created_at
升序 ASC
的方式,对传递过来的 page
(第几页) 和 page_size
(每页条数) 进行数据库的查询。
获取文章详情
[get] /api/get-article
// app/controller/article.js
...
async getItem() {
const { ctx } = this
const { id } = ctx.request.query
let articleDetail = await ctx.service.article.getArticle(id)
if(!articleDetail) {
ctx.returnBody(400, '不存在此条数据!', {}, '00001')
return
}
ctx.returnBody(200, '获取文章成功!', articleDetail, '00000')
}
...
上面设定了获取文章信息的方法 getItem
,并且指定了前端传递过来的参数 id
文章的唯一标识,然后调用了下面的方法 getArticle
来获取文章信息,然后返回相关查询信息。
// app/service/article.js
...
async getArticle(id) {
const { ctx } = this
return await ctx.model.Article.findOne({
where: {
id
}
})
}
...
这里,我们通过匹配 id
进行数据库的查询,当存在该唯一标识的文章信息,则返回,否则返回空。
添加文章
[post] /api/post-article
// app/controller/article.js
...
async postItem() {
const { ctx } = this
const { author, title, content, group } = ctx.request.body
// 新文章
let newArticle = { author, title, content, group }
let article = await ctx.service.article.addArticle(newArticle)
if(!article) {
ctx.returnBody(400, '网络错误,请稍后再试!', {}, '00001')
return
}
ctx.returnBody(200, '新建文章成功!', article, '00000')
}
...
上面设定了获取添加文章的方法 postItem
,并且指定了前端传递过来的表单信息 author
, title
, content
, group
,然后调用了下面的方法 addArticle
来获取文章信息,然后返回相关查询信息。
使用
postItem
,而不是addItem
是为了表明接口请求方式为post
。方法的命名按照个人习惯来即可
// app/service/article.js
...
async addArticle(data) {
const { ctx } = this
return await ctx.model.Article.create(data) // 写入数据
}
...
如上,我们将表单传递过来的数据写入到数据库中。
编辑文章
[put] /api/put-article
// app/controller/article.js
...
async putItem() {
const { ctx } = this
const { id } = ctx.request.query
const { author, title, content, group } = ctx.request.body
// 存在文章
let editArticle = { author, title, content, group }
let article = await ctx.service.article.editArticle(id, editArticle)
if(!article) {
ctx.returnBody(400, '网络错误,请稍后再试!', {}, '00001')
return
}
ctx.returnBody(200, '编辑文章成功!', article, '00000')
}
...
上面设定了获取更改文章的方法 putItem
,并且指定了前端传递过来的表单信息 author
, title
, content
, group
和该文章的唯一标识 id
。然后调用了下面的方法 editArticle
来获取文章信息,然后返回相关查询信息。
// app/service/article.js
...
async editArticle(id, data) {
const { ctx } = this
return await ctx.model.Article.update(data, { // 更改数据
where: {
id
}
})
}
...
上面,通过唯一标识 id
查询数据库中是否存在该条数据,如果存在,则更新该数据。
删除文章
[delete] /api/delete-article
// app/controller/article.js
...
async deleteItem() {
const { ctx } = this
const { id } = ctx.request.query
let articleDetail = await ctx.service.article.deleteArticle(id)
if(!articleDetail) {
ctx.returnBody(400, '不存在此条数据!', {}, '00001')
return
}
ctx.returnBody(200, '删除文章成功!', articleDetail, '00000')
}
...
上面设定了获取删除文章的方法 deleteItem
,并且指定了前端传递过来的该文章的唯一标识 id
。然后调用了下面的方法 deleteArticle
来获取文章信息,然后返回相关查询信息。
// app/service/article.js
...
async deleteArticle(id) {
const { ctx } = this
return await ctx.model.Article.destroy({
where: {
id
}
})
}
...
上面,通过唯一标识 id
查询数据库中是否存在该条数据,如果存在,则删除该数据。
在完成接口的编写后,我们可以通过 postman
应用去验证下是否返回期待的数据。
前端对接接口
接下来就得切回来 client
文件夹进行操作了。我们在上面已经简单封装了请求方法。这里来编写文章 CRUD
的请求方法,我们为了方便调用,将其统一挂载在Vue
实例下👇
// src/api/index.js
import article from './article'
const api = {
article
}
export default api
export const ApiPlugin = {}
ApiPlugin.install = function (Vue, options) {
Vue.prototype.api = api // 挂载api在原型上
}
获取文章列表
// src/api/article.js
...
export function getList(params) {
return auth({
url: '/api/get-article-list',
method: 'get',
params
})
}
...
我们定义了获取文章列表的路径 /api/get-article-list
,其为 get
方法,并且传递相关的参数 params
过去给服务端。
origin
已经在上文中设定
// src/views/article/list.vue
...
getList() {
let vm = this
vm.loading = true
vm.api.article.getList({
page: vm.pagination.current,
page_size: vm.pagination.pageSize
}).then(res => {
if(res.code === '00000'){
vm.pagination.total = res.data && res.data.count || 0
vm.data = res.data && res.data.results || []
} else {
vm.$message.warning(res.msg || '获取文章列表失败')
}
}).finally(() => {
vm.loading = false
})
}
...
传递过去服务端的 params
参数是 page
和 page_size
。在请求成功后,获取到后端返回的数据,并写入页面。
获取文章详情
// src/api/article.js
...
export function getItem(params) {
return auth({
url: '/api/get-article',
method: 'get',
params
})
}
...
我们定义了获取文章的路径 /api/get-article
,其为 get
方法,并且传递相关的参数 params
过去给服务端。
// src/views/article/info.vue
...
getDetail(id) {
let vm = this
vm.loading = true
vm.api.article.getItem({ id }).then(res => {
if(res.code === '00000') {
// 数据回填
vm.form.setFieldsValue({
title: res.data && res.data.title || undefined,
author: res.data && res.data.author || undefined,
content: res.data && res.data.content || undefined,
group: res.data && res.data.group || undefined,
})
} else {
vm.$message.warning(res.msg || '获取文章详情失败!')
}
}).finally(() => {
vm.loading = false
})
},
...
传递过去服务端的 params
参数是 id
。在请求成功后,获取到后端返回的数据,回填到表单中。
添加文章
// src/api/article.js
...
export function postItem(data) {
return auth({
url: '/api/post-article',
method: 'post',
data
})
}
...
我们定义了获取添加文章的路径 /api/post-article
,其为 post
方法,并且传递相关的参数 data
过去给服务端。
// src/views/article/info.vue
...
submit() {
let vm = this
vm.loading = true
// 表单验证
vm.form.validateFields((err, values) => {
if(err){
vm.loading = false
return
}
let data = {
title: values.title,
group: values.group,
author: values.author,
content: values.content
}
vm.api.article.postItem(data).then(res => {
if(res.code === '00000') {
vm.$message.success(res.msg || '新增成功!')
vm.$router.push({
path: '/article/list'
})
} else {
vm.$message.warning(res.msg || '新增失败!')
}
}).finally(() => {
vm.loading = false
})
})
},
...
data
参数是 title
,group
,author
和 content
传递过去服务端之前会进行校验,校验成功后才会发送到服务端。在请求成功后,获取到后端返回的数据,则页面跳转到路由 /article/list
。
编辑文章
// src/api/article.js
...
export function putItem(params, data) {
return auth({
url: '/api/put-article',
method: 'put',
params,
data
})
}
...
我们定义了获取编辑文章的路径 /api/put-article
,其为 put
方法,并且传递相关的参数 param
和 data
过去给服务端。
// src/views/article/info.vue
...
submit() {
let vm = this
vm.loading = true
vm.form.validateFields((err, values) => {
if(err){
vm.loading = false
return
}
let data = {
title: values.title,
group: values.group,
author: values.author,
content: values.content
}
vm.api.article.putItem({id: vm.$route.query.id}, data).then(res => {
if(res.code === '00000') {
vm.$message.success(res.msg || '新增成功!')
vm.$router.push({
path: '/article/list'
})
} else {
vm.$message.warning(res.msg || '新增失败!')
}
}).finally(() => {
vm.loading = false
})
})
}
...
params
参数是 id
,对应文章的唯一标识;data
参数是 title
,group
,author
和 content
传递过去服务端之前会进行校验,校验成功后才会发送到服务端,同 post
新增文章。在请求成功后,获取到后端返回的数据,则页面跳转到路由 /article/list
。
删除文章
// src/api/article.js
...
export function deleteItem(params) {
return auth({
url: '/api/delete-article',
method: 'delete',
params
})
}
...
我们定义了获取删除文章的路径 /api/delete-article
,其为 delete
方法,并且传递相关的参数 param
过去给服务端。
// src/views/article/list.vue
...
delete(text, record, index) {
let vm = this
vm.$confirm({
title: `确定删除【${record.title}】`,
content: '',
okText: '确定',
okType: 'danger',
cancelText: '取消',
onOk() {
vm.api.article.deleteItem({ id: record.id }).then(res => {
if(res.code === '00000') {
vm.$message.success(res.msg || '删除成功!')
vm.handlerSearch()
} else {
vm.$message.warning(res.msg || '删除失败!')
}
})
},
onCancel() {},
})
}
...
这里会弹出一个弹窗,需要用户二次确认是否删除。当点击确认,则将文章的唯一标识 id
传递过去飞服务端。删除成功后,重新请求获取文章列表。
效果图
在 egg-demo/article-project/client/ 前端项目中,页面包含了登录页面,欢迎页面和文章页面。
欢迎页面忽略不计
登录页
登陆页面通过用户名和密码进行登录。
文章列表
文章列表展示了每页多少条(可选),并且可以触发编辑,删除和新增文章等操作。
文章编辑/新增
点击上面文章列表的编辑/信则,会跳转到编辑/新增页面。如果是编辑页面,则会有信息回填到表单中,下面是新增的操作,所以表单为空内容。
源码
代码仓库为egg-demo/article-project/,感兴趣可以进行扩展学习。