Vue + TypeScript - EduBossFed项目 - 课程管理 - 添加/编辑课程
# 21.14 课程管理
# 布局
封装请求 services/course.ts
/**
* 课程相关请求模块
*/
import request from '@/utils/request'
export const getQueryCourses = (data: any) => {
return request({
method: 'POST',
url: '/boss/course/getQueryCourses',
data
})
}
2
3
4
5
6
7
8
9
10
11
12
13
封装列表资源组件 views/course/components/CourseList.vue
<template>
<div class="course-list">
<el-card class="box-card">
<div slot="header">
<span>数据筛选</span>
</div>
<el-form
ref="form"
label-width="80px"
label-position="left"
:model="filterParams"
>
<el-form-item label="课程名称" prop="courseName">
<el-input
v-model="filterParams.courseName"
placeholder="课程名称"
></el-input>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="filterParams.status">
<el-option label="全部" value=""></el-option>
<el-option label="上架" value="1"></el-option>
<el-option label="下架" value="0"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button
:disabled="loading"
@click="handleReset"
>重置</el-button>
<el-button
type="primary"
:disabled="loading"
@click="handleFilter"
>查询</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="box-card">
<div slot="header">
<span>查询结果:</span>
<el-button
style="float: right; margin-top: -10px"
type="primary"
>添加课程</el-button>
</div>
<el-table
:data="courses"
v-loading="loading"
style="width: 100%; margin-bottom: 20px"
>
<el-table-column
prop="id"
label="ID"
min-width="50">
</el-table-column>
<el-table-column
prop="courseName"
label="课程名称"
min-width="150">
</el-table-column>
<el-table-column
prop="price"
label="价格"
min-width="100">
</el-table-column>
<el-table-column
prop="sortNum"
label="排序"
min-width="150">
</el-table-column>
<el-table-column
prop="status"
label="上架状态"
min-width="120">
123
</el-table-column>
<el-table-column
prop="price"
label="操作"
min-width="200"
align="center"
>
<template>
<el-button>编辑</el-button>
<el-button>内容管理</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
background
layout="prev, pager, next"
:total="totalCount"
:disabled="loading"
:current-page.sync="filterParams.currentPage"
@current-change="handleCurrentChange"
/>
</el-card>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
import { getQueryCourses } from '@/services/course'
import { Form } from 'element-ui'
export default Vue.extend({
name: 'CourseList',
data () {
return {
filterParams: {
currentPage: 1,
pageSize: 10,
courseName: '',
status: ''
},
courses: [],
totalCount: 0,
loading: true
}
},
created () {
this.loadCourses()
},
methods: {
async loadCourses () {
this.loading = true
const { data } = await getQueryCourses(this.filterParams)
this.courses = data.data.records
this.totalCount = data.data.total
this.loading = false
},
handleCurrentChange (page: number) {
this.filterParams.currentPage = page
this.loadCourses()
},
handleFilter () {
this.filterParams.currentPage = 1
this.loadCourses()
},
handleReset () {
(this.$refs.form as Form).resetFields()
this.filterParams.currentPage = 1
this.loadCourses()
}
}
})
</script>
<style lang="scss" scoped>
.el-card {
margin-bottom: 20px;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
加载资源列表 views/course/index.vue
<template>
<div class="course">
<course-list />
</div>
</template>
<script lang="ts">
import Vue from 'vue'
import CourseList from './components/CourseList.vue'
export default Vue.extend({
name: 'CourseIndex',
components: {
CourseList
}
})
</script>
<style lang="scss" scoped></style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 上架状态 - 展示
views/course/components/CourseList.vue
<el-table-column
prop="status"
label="上架状态"
min-width="120">
<template slot-scope="scope">
<el-switch
v-model="scope.row.status"
active-color="#13ce66"
inactive-color="#ff4949"
:active-value="1"
:inactive-value="0"
/>
</template>
</el-table-column>
2
3
4
5
6
7
8
9
10
11
12
13
14
# 上架状态 - 处理课程上下架
封装请求 services/course.ts
export const changeState = (params: any) => {
return request({
method: 'GET',
url: '/boss/course/changeState',
params
})
}
2
3
4
5
6
7
加载请求 views/course/components/CourseList.vue
<el-table-column
prop="status"
label="上架状态"
min-width="120">
<template slot-scope="scope">
<el-switch
...
:disabled="scope.row.isStatusLoading"
@change="onStateChange(scope.row)"
/>
</template>
</el-table-column>
<script lang="ts">
import Vue from 'vue'
import {
getQueryCourses,
changeState
} from '@/services/course'
import { Form } from 'element-ui'
export default Vue.extend({
name: 'CourseList',
...
methods: {
async loadCourses () {
this.loading = true
const { data } = await getQueryCourses(this.filterParams)
data.data.records.forEach((item: any) => {
item.isStatusLoading = false
})
...
},
...
async onStateChange (course: any) {
course.isStatusLoading = true
const { data } = await changeState({
courseId: course.id,
status: course.status
})
this.$message.success(`${course.status === 0 ? '下架' : '上架'}成功`)
course.isStatusLoading = false
}
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# 添加课程 - Steps 步骤条
封装添加课程组件 views/course/create.vue
<template>
<div class="course-create">
添加课程
</div>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
name: 'CourseCreate'
})
</script>
<style lang="scss" scoped></style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
配置路由:router/index.ts
{
path: '/course/create',
name: 'course-create',
component: () => import(/* webpackChunkName: 'course-create' */ '@/views/course/create.vue')
}
2
3
4
5
为 添加课程 按钮注册点击事件 views/course/components/CourseList.vue
<div slot="header">
<span>查询结果:</span>
<el-button
style="float: right; margin-top: -10px"
type="primary"
@click="$router.push({
name: 'course-create'
})"
>添加课程</el-button>
</div>
2
3
4
5
6
7
8
9
10
<el-steps :active="1" simple>
<el-step title="步骤 1" icon="el-icon-edit"></el-step>
<el-step title="步骤 2" icon="el-icon-upload"></el-step>
<el-step title="步骤 3" icon="el-icon-picture"></el-step>
</el-steps>
<el-steps :active="1" finish-status="success" simple style="margin-top: 20px">
<el-step title="步骤 1" ></el-step>
<el-step title="步骤 2" ></el-step>
<el-step title="步骤 3" ></el-step>
</el-steps>
2
3
4
5
6
7
8
9
10
11
views/course/create.vue
<template>
<div class="course-create">
<el-card class="box-card">
<div slot="header">
<el-steps :active="activeStep" finish-status="success" simple style="margin-top: 20px">
<el-step title="基本信息"></el-step>
<el-step title="课程封面"></el-step>
<el-step title="销售信息"></el-step>
<el-step title="秒杀活动"></el-step>
<el-step title="课程详情"></el-step>
</el-steps>
</div>
<el-form>
<div v-show="activeStep === 0">
基本信息
</div>
<div v-show="activeStep === 1">
课程封面
</div>
<div v-show="activeStep === 2">
销售信息
</div>
<div v-show="activeStep === 3">
秒杀活动
</div>
<div v-show="activeStep === 4">
课程详情
<el-form-item>
<el-button type="primary">保存</el-button>
</el-form-item>
</div>
<el-form-item v-if="activeStep >= 0 && activeStep < 4">
<el-button @click="activeStep++">下一步</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
name: 'CourseCreate',
data () {
return {
activeStep: 0
}
}
})
</script>
<style lang="scss" scoped></style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
实现点击步骤条可以去到对应步骤页
<div slot="header">
<el-steps :active="activeStep" finish-status="success" simple style="margin-top: 20px">
<el-step
:title="item.title"
v-for="(item, index) in steps"
:key="index"
@click.native="activeStep = index"
></el-step>
</el-steps>
</div>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
name: 'CourseCreate',
data () {
return {
activeStep: 0,
steps: [
{ title: '基本信息' },
{ title: '课程封面' },
{ title: '销售信息' },
{ title: '秒杀活动' },
{ title: '课程详情' }
]
}
}
})
</script>
<style lang="scss" scoped>
.el-step {
cursor: pointer;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 添加课程 - 搭建表单结构
views/course/create.vue
# 基本信息 - InputNumber 计数器
<div v-show="activeStep === 0">
<el-form-item label="课程名称">
<el-input></el-input>
</el-form-item>
<el-form-item label="课程简介">
<el-input></el-input>
</el-form-item>
<el-form-item label="课程概述">
<el-input type="textarea" ></el-input>
</el-form-item>
<el-form-item label="讲师姓名">
<el-input></el-input>
</el-form-item>
<el-form-item label="讲师简介">
<el-input></el-input>
</el-form-item>
<el-form-item label="课程排序">
<el-input-number label="描述文字"></el-input-number>
</el-form-item>
</div>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 课程封面 - Upload 上传
Element 的 el-upload
样式并不能完全满足我们的需求,我们需要去修改它的样式,你会发现你修改不了,因为我们加了 scoped
,它只作用于当前组件中的元素
在有作用域的样式中,默认样式只能作用到子组件的根节点,父组件的样式将不会渗透到子组件中。当我们想通过类名作用于其内部的某个元素,需要使用到深度作用操作符
参考 Vue Loader - Scoped CSS - 深度作用选择器
如果你希望
scoped
样式中的一个选择器能够作用得“更深”,例如影响子组件,你可以使用>>>
操作符<style scoped> .a >>> .b { /* ... */ } </style>
1
2
3上述代码将会编译成:
.a[data-v-f3f3eg9] .b { /* ... */ }
1有些像 Sass 之类的预处理器无法正确解析
>>>
。这种情况下你可以使用/deep/
或::v-deep
操作符取而代之——两者都是>>>
的别名,同样可以正常工作
<div v-show="activeStep === 1">
<el-form-item label="课程封面">
<el-upload
class="avatar-uploader"
action="https://jsonplaceholder.typicode.com/posts/"
:show-file-list="false"
:on-success="handleAvatarSuccess"
:before-upload="beforeAvatarUpload">
<img v-if="imageUrl" :src="imageUrl" class="avatar">
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
</el-form-item>
<el-form-item label="解锁封面">
<el-upload
class="avatar-uploader"
action="https://jsonplaceholder.typicode.com/posts/"
:show-file-list="false"
:on-success="handleAvatarSuccess"
:before-upload="beforeAvatarUpload">
<img v-if="imageUrl" :src="imageUrl" class="avatar">
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
</el-form-item>
</div>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
name: 'CourseCreate',
data () {
return {
...
imageUrl: '' // 预览图片地址
}
},
methods: {
handleAvatarSuccess (res: any, file: any) {
this.imageUrl = URL.createObjectURL(file.raw)
},
beforeAvatarUpload (file: any) {
const isJPG = file.type === 'image/jpeg'
const isLt2M = file.size / 1024 / 1024 < 2
if (!isJPG) {
this.$message.error('上传头像图片只能是 JPG 格式!')
}
if (!isLt2M) {
this.$message.error('上传头像图片大小不能超过 2MB!')
}
return isJPG && isLt2M
}
}
})
</script>
<style lang="scss" scoped>
...
::v-deep .avatar-uploader .el-upload {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
}
::v-deep .avatar-uploader .el-upload:hover {
border-color: #409EFF;
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 178px;
height: 178px;
line-height: 178px;
text-align: center;
}
.avatar {
width: 178px;
height: 178px;
display: block;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# 销售信息 - Input 复合型输入框
<div v-show="activeStep === 2">
<el-form-item label="售卖价格">
<el-input type="number">
<template slot="append">元</template>
</el-input>
</el-form-item>
<el-form-item label="商品原价">
<el-input type="number">
<template slot="append">元</template>
</el-input>
</el-form-item>
<el-form-item label="销量">
<el-input type="number">
<template slot="append">单</template>
</el-input>
</el-form-item>
<el-form-item label="活动标签">
<el-input></el-input>
</el-form-item>
</div>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 秒杀活动 - DatePicker 日期选择器
<div v-show="activeStep === 3">
<el-form-item label="限时秒杀">
<el-switch
v-model="isSeckill"
active-color="#13ce66"
inactive-color="#ff4949"
>
</el-switch>
</el-form-item>
<template v-if="isSeckill">
<el-form-item label="开始时间">
<el-date-picker
type="date"
placeholder="选择日期时间"
value-format="yyyy-MM-dd"
/>
</el-form-item>
<el-form-item label="结束时间">
<el-date-picker
type="date"
placeholder="选择日期时间"
value-format="yyyy-MM-dd"
/>
</el-form-item>
<el-form-item label="秒杀价">
<el-input type="number">
<template slot="append">元</template>
</el-input>
</el-form-item>
<el-form-item label="秒杀库存">
<el-input type="number">
<template slot="append">个</template>
</el-input>
</el-form-item>
</template>
</div>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
name: 'CourseCreate',
data () {
return {
...
isSeckill: false // 是否开启秒杀
}
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# 课程详情
<div v-show="activeStep === 4">
<el-form-item label="课程详情">
<el-input type="textarea"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary">保存</el-button>
</el-form-item>
</div>
2
3
4
5
6
7
8
# 添加课程 - 基本信息数据绑定
封装请求 services/course.ts
export const saveOrUpdateCourse = (data: any) => {
return request({
method: 'POST',
url: '/boss/course/saveOrUpdateCourse',
data
})
}
2
3
4
5
6
7
加载请求 views/course/create.vue
<div v-show="activeStep === 0">
<el-form-item label="课程名称">
<el-input v-model="course.courseName"></el-input>
</el-form-item>
<el-form-item label="课程简介">
<el-input v-model="course.brief"></el-input>
</el-form-item>
<el-form-item label="课程概述">
<el-input
style="margin-bottom: 10px"
v-model="course.previewFirstField"
type="textarea"
placeholder="概述1"
></el-input>
<el-input
v-model="course.previewSecondField"
type="textarea"
placeholder="概述2"
></el-input>
</el-form-item>
<el-form-item label="讲师姓名">
<el-input v-model="course.teacherDTO.teacherName"></el-input>
</el-form-item>
<el-form-item label="讲师简介">
<el-input v-model="course.teacherDTO.description"></el-input>
</el-form-item>
<el-form-item label="课程排序">
<el-input-number
label="描述文字"
v-model="course.sortNum"
></el-input-number>
</el-form-item>
</div>
<script lang="ts">
import Vue from 'vue'
import {
saveOrUpdateCourse
} from '@/services/course'
export default Vue.extend({
name: 'CourseCreate',
data () {
return {
...
course: {
// id: 0,
courseName: '',
brief: '',
teacherDTO: {
// id: 0,
// courseId: 0,
teacherName: '',
teacherHeadPicUrl: '',
position: '',
description: ''
},
courseDescriptionMarkDown: '',
price: 0,
discounts: 0,
priceTag: '',
discountsTag: '',
isNew: true,
isNewDes: '',
courseListImg: '',
courseImgUrl: '',
sortNum: 0,
previewFirstField: '',
previewSecondField: '',
status: 0, // 0:未发布,1:已发布
sales: 0,
activityCourse: false, // 是否开启活动秒杀
activityCourseDTO: {
// id: 0,
// courseId: 0,
beginTime: '',
endTime: '',
amount: 0,
stock: 0
},
autoOnlineTime: ''
}
}
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# 添加课程 - 上传课程封面 - Upload 上传
通过 Upload 上传组件 上传图片到服务端,拿到服务端返回的图片地址
Upload 上传文件组件,支持自动上传,只需要把上传需要参数配置一下就可以了
参数参考 Upload - Attribute
参数 说明 action 必选参数,上传的地址 headers 设置上传的请求头部 由于 Upload组件 内部的轻请求行为用的不是 Axios,它不会走我们之前设置的自动添加 Token 的 axios拦截器。如果要使用 Upload组件 自带的上传行为则还需要单独配置
headers
自己写代码上传文件发请求
参数 说明 http-request 覆盖默认的上传行为,可以自定义上传
封装请求 services/course.ts
export const uploadCourseImage = (data: any) => {
// 该接口要求的请求数据类型是:multipart/form-data
// 所以需要提交 FormData 数据对象,ContenType 会被自动转换
return request({
method: 'POST',
url: '/boss/course/upload',
data
})
}
2
3
4
5
6
7
8
9
加载请求 views/course/create.vue
<el-form-item label="课程封面">
<!--
upload 上传文件组件,它支持自动上传,你只需要把上传需要参数配置一下就可以了
-->
<el-upload
class="avatar-uploader"
action="https://jsonplaceholder.typicode.com/posts/"
:show-file-list="false"
:on-success="handleAvatarSuccess"
:before-upload="beforeAvatarUpload"
:http-request="handleUpload"
>
<img v-if="imageUrl" :src="imageUrl" class="avatar">
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
</el-form-item>
<script lang="ts">
import Vue from 'vue'
import {
...
uploadCourseImage
} from '@/services/course'
export default Vue.extend({
name: 'CourseCreate',
...
methods: {
...
async handleUpload (options: any) {
const fd = new FormData()
fd.append('file', options.file)
const { data } = await uploadCourseImage(fd)
console.log(data)
}
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<el-form-item label="课程封面">
<el-upload
class="avatar-uploader"
action="https://jsonplaceholder.typicode.com/posts/"
:show-file-list="false"
:before-upload="beforeAvatarUpload"
:http-request="handleUpload"
>
<img
v-if="course.courseListImg"
:src="course.courseListImg"
class="avatar"
>
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
</el-form-item>
<script lang="ts">
...
export default Vue.extend({
name: 'CourseCreate',
...
methods: {
...
async handleUpload (options: any) {
...
this.course.courseListImg = data.data.name
}
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 添加课程 - 上传课程封面 - 封装上传组件
课程封面 和 介绍封面 作用一样,因此对上传组件进行封装方便重用和维护
- 组件需要根据绑定的数据进行图片预览
- 组件需要把上传成功的图片地址同步到绑定的数据中
我们期望的形式:
<course-image v-model="course.courseListImg" />
v-model
的本质还是父子组件通信
- 它会给子组件传递一个名字叫 value 的数据(Props)
- 默认监听($emit) input 事件,修改绑定的数据(自定义事件)
由于 v-model 只是语法糖,
<input v-model="message">
与下面的两行代码是一致的:
<input v-bind:value="message" v-on:input="message = $event.target.value" />
<input :value="message" @input="message = $event.target.value" />
封装组件 views/course/components/CourseImage.vue
<template>
<div class="course-image">
<el-upload
class="avatar-uploader"
action="https://jsonplaceholder.typicode.com/posts/"
:show-file-list="false"
:before-upload="beforeAvatarUpload"
:http-request="handleUpload"
>
<img
v-if="value"
:src="value"
class="avatar"
>
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
import {
uploadCourseImage
} from '@/services/course'
export default Vue.extend({
name: 'CourseImage',
props: {
value: {
type: String
}
},
methods: {
beforeAvatarUpload (file: any) {
const isJPG = file.type === 'image/jpeg'
const isLt2M = file.size / 1024 / 1024 < 2
if (!isJPG) {
this.$message.error('上传头像图片只能是 JPG 格式!')
}
if (!isLt2M) {
this.$message.error('上传头像图片大小不能超过 2MB!')
}
return isJPG && isLt2M
},
async handleUpload (options: any) {
const fd = new FormData()
fd.append('file', options.file)
const { data } = await uploadCourseImage(fd)
this.$emit('input', data.data.name)
}
}
})
</script>
<style lang='scss' scoped>
::v-deep .avatar-uploader .el-upload {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
}
::v-deep .avatar-uploader .el-upload:hover {
border-color: #409EFF;
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 178px;
height: 178px;
line-height: 178px;
text-align: center;
}
.avatar {
width: 178px;
height: 178px;
display: block;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
注册组件 views/course/create.vue
<div v-show="activeStep === 1">
<el-form-item label="课程封面">
<!--
upload 上传文件组件,它支持自动上传,你只需要把上传需要参数配置一下就可以了
-->
<!--
1. 组件需要根据绑定的数据进行图片预览
2. 组件需要把上传成功的图片地址同步到绑定的数据中
v-model 的本质还是父子组件通信
1. 它会给子组件传递一个名字叫 value 的数据(Props)
2. 默认监听 input 事件,修改绑定的数据(自定义事件)
-->
<course-image v-model="course.courseListImg" />
</el-form-item>
<el-form-item label="介绍封面">
<course-image v-model="course.courseImgUrl" />
</el-form-item>
</div>
<script lang="ts">
import Vue from 'vue'
import {
saveOrUpdateCourse
} from '@/services/course'
import CourseImage from './components/CourseImage.vue'
export default Vue.extend({
name: 'CourseCreate',
components: {
CourseImage
},
...
methods: {}
})
</script>
<style lang="scss" scoped>
.el-step {
cursor: pointer;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
更加个性化的定制 views/course/components/CourseImage.vue
<script lang="ts">
...
export default Vue.extend({
name: 'CourseImage',
props: {
...
limit: {
type: Number,
default: 2
}
},
methods: {
beforeAvatarUpload (file: any) {
const isJPG = file.type === 'image/jpeg'
const isLt2M = file.size / 1024 / 1024 < this.limit
if (!isJPG) {
this.$message.error('上传头像图片只能是 JPG 格式!')
}
if (!isLt2M) {
this.$message.error(`上传头像图片大小不能超过 ${this.limit}MB!`)
}
return isJPG && isLt2M
},
...
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<course-image
v-model="course.courseImgUrl"
:limit="5"
/>
2
3
4
# 添加课程 - 上传进度提示 - Progress 进度条
接下来分享一个小功能:给上传图片加上自定义进度条提示。Upload 上传 本身就支持上传的进度条提示,但是是在展示上传文件列表的前提下实现的,所以也就看不到进度提示
<el-progress type="circle" :percentage="0"></el-progress>
<el-progress type="circle" :percentage="25"></el-progress>
<el-progress type="circle" :percentage="100" status="success"></el-progress>
<el-progress type="circle" :percentage="70" status="warning"></el-progress>
<el-progress type="circle" :percentage="50" status="exception"></el-progress>
2
3
4
5
views/course/components/CourseImage.vue
<template>
<div class="course-image">
<el-progress
v-if="isUploading"
type="circle"
:percentage="percentage"
:width="178"
/>
<el-upload
v-else
class="avatar-uploader"
action="https://jsonplaceholder.typicode.com/posts/"
:show-file-list="false"
:before-upload="beforeAvatarUpload"
:http-request="handleUpload"
>
<img
v-if="value"
:src="value"
class="avatar"
>
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
import {
uploadCourseImage
} from '@/services/course'
export default Vue.extend({
name: 'CourseImage',
...
data () {
return {
isUploading: false,
percentage: 0
}
},
methods: {
...
async handleUpload (options: any) {
this.isUploading = true
const fd = new FormData()
fd.append('file', options.file)
const { data } = await uploadCourseImage(fd)
this.isUploading = false
this.$emit('input', data.data.name)
}
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
处理进度条的进度变化
services/course.ts
export const uploadCourseImage = (data: any) => {
return request({
method: 'POST',
url: '/boss/course/upload',
data,
// HTML5 新增的上传响应时间:progress(原生)
onUploadProgress (e) {
console.log(e.loaded) // 已上传的数据大小
console.log(e.total) // 上传文件的总大小
}
})
}
2
3
4
5
6
7
8
9
10
11
12
通过 Math.floor(e.loaded / e.total * 100)
来获取进度
export const uploadCourseImage = (data: any,
onUploadProgress?: (progressEvent: ProgressEvent) => void) => {
return request({
method: 'POST',
url: '/boss/course/upload',
data,
onUploadProgress
})
}
2
3
4
5
6
7
8
9
views/course/components/CourseImage.vue
<el-progress
v-if="isUploading"
type="circle"
:percentage="percentage"
:width="178"
:status="percentage === 100 ? 'success' : undefined"
/>
<script lang="ts">
...
export default Vue.extend({
name: 'CourseImage',
...
methods: {
...
async handleUpload (options: any) {
this.isUploading = true
const fd = new FormData()
fd.append('file', options.file)
const { data } = await uploadCourseImage(fd, e => {
this.percentage = Math.floor(e.loaded / e.total * 100)
})
this.isUploading = false
this.percentage = 0
this.$emit('input', data.data.name)
}
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 添加课程 - 剩余数据绑定及发布
views/course/create.vue
<template>
<div class="course-create">
<el-card class="box-card">
<div slot="header">
<el-steps :active="activeStep" finish-status="success" simple style="margin-top: 20px">
<el-step
:title="item.title"
v-for="(item, index) in steps"
:key="index"
@click.native="activeStep = index"
></el-step>
</el-steps>
</div>
<el-form label-width="80px" label-position="left">
...
<div v-show="activeStep === 2">
<el-form-item label="售卖价格">
<el-input v-model.number="course.discounts" type="number">
<template slot="append">元</template>
</el-input>
</el-form-item>
<el-form-item label="商品原价">
<el-input v-model.number="course.price" type="number">
<template slot="append">元</template>
</el-input>
</el-form-item>
<el-form-item label="销量">
<el-input v-model.number="course.sales" type="number">
<template slot="append">单</template>
</el-input>
</el-form-item>
<el-form-item label="活动标签">
<el-input v-model="course.discountsTag"></el-input>
</el-form-item>
</div>
<div v-show="activeStep === 3">
<el-form-item label="限时秒杀">
<el-switch
v-model="isSeckill"
active-color="#13ce66"
inactive-color="#ff4949"
>
</el-switch>
</el-form-item>
<template v-if="isSeckill">
<el-form-item label="开始时间">
<el-date-picker
v-model="course.activityCourseDTO.beginTime"
type="date"
placeholder="选择日期时间"
value-format="yyyy-MM-dd"
/>
</el-form-item>
<el-form-item label="结束时间">
<el-date-picker
v-model="course.activityCourseDTO.endTime"
type="date"
placeholder="选择日期时间"
value-format="yyyy-MM-dd"
/>
</el-form-item>
<el-form-item label="秒杀价">
<el-input v-model.number="course.activityCourseDTO.amount" type="number">
<template slot="append">元</template>
</el-input>
</el-form-item>
<el-form-item label="秒杀库存">
<el-input v-model.number="course.activityCourseDTO.stock" type="number">
<template slot="append">个</template>
</el-input>
</el-form-item>
</template>
</div>
<div v-show="activeStep === 4">
<el-form-item label="课程详情">
<el-input v-model="course.courseDescriptionMarkDown" type="textarea"></el-input>
</el-form-item>
<el-form-item label="是否发布">
<el-switch
v-model="course.status"
:active-value="1"
:inactive-value="0"
active-color="#13ce66"
inactive-color="#ff4949"
>
</el-switch>
</el-form-item>
<el-form-item>
<el-button
type="primary"
@click="handleSave"
>保存</el-button>
</el-form-item>
</div>
<el-form-item v-if="activeStep >= 0 && activeStep < 4">
<el-button @click="activeStep++">下一步</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script lang="ts">
...
export default Vue.extend({
name: 'CourseCreate',
...
methods: {
async handleSave () {
const { data } = await saveOrUpdateCourse(this.course)
if (data.code === '000000') {
this.$message.success('保存成功')
this.$router.push('/course')
} else {
this.$message.error('保存失败')
}
}
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# 添加课程 - 富文本编辑器介绍
- ckeditor/ckeditor5:一个非常老牌的富文本编辑器,其功能、稳定性各方面都很不错。内置许多插件,易于扩展
- quilljs/quill:近几年才出来的产品,用户群体庞大。扩展性和功能性也都很不错
- yabwe/medium-editor:也是比较老牌的编辑器,近几年更新程度一般。但是功能还是很强大的
- wangeditor-team/wangEditor:国人开发的一款编辑器。功能和易用性很不错
- fex-team/ueditor:百度推出的富文本编辑器,功能非常强大,和百度自身的业务集成非常便利。虽然已不再维护但是还是可用的
- tinymce/tinymce:功能和拓展性优秀的编辑器,可以尝试
这些富文本编辑器各有优缺点,没有一定的好坏,尽量在满足自己的功能需求的前提下选择,已经不再维护的项目就不建议再使用了
本项目将采用 wangEditor
这款国人开发的编辑器
# 添加课程 - 封装使用富文本编辑器组件
参考 wangEditor
# 安装 wangeditor
npm i wangeditor --save
2
3
封装富文本编辑器组件 components/TextEditor/index.vue
<template>
<div ref="editor" class="text-editor"></div>
</template>
<script lang="ts">
import Vue from 'vue'
import E from 'wangeditor'
export default Vue.extend({
name: 'TextEditor',
// 组件已经渲染好,可以初始化操作 DOM 了
mounted () {
const editor = new E(this.$refs.editor as any)
editor.create()
}
})
</script>
<style lang='scss' scoped></style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
注册组件 views/course/create.vue
<el-form-item label="课程详情">
<text-editor />
<!-- <el-input v-model="course.courseDescriptionMarkDown" type="textarea"></el-input> -->
</el-form-item>
<script lang="ts">
...
import TextEditor from '@/components/TextEditor/index.vue'
export default Vue.extend({
name: 'CourseCreate',
components: {
...
TextEditor
},
...
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
绑定数据 <text-editor v-model="course.courseDescriptionMarkDown" />
前面提到 v-model
的本质还是父子组件通信,因此我们来组件中声明接收 views/course/create.vue
<script lang="ts">
...
export default Vue.extend({
name: 'TextEditor',
props: {
value: {
type: String,
default: ''
}
},
// 组件已经渲染好,可以初始化操作 DOM 了
mounted () {
this.initEditor()
},
methods: {
initEditor () {
const editor = new E(this.$refs.editor as any)
// 事件监听 必须在 create 之前
editor.config.onchange = (value: string) => {
this.$emit('input', value)
}
editor.create()
// 设置初始值 必须在 create 之后
editor.txt.html(this.value)
}
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 添加课程 - 富文本编辑器 - 图片上传
这个富文本编辑器默认只能插入网络图片。这里我们还希望上传本地图片,参考 上传图片
components/TextEditor/index.vue
<script lang="ts">
...
import { uploadCourseImage } from '@/services/course'
export default Vue.extend({
name: 'TextEditor',
...
methods: {
initEditor () {
...
// 上传图片
editor.config.customUploadImg =
async function (resultFiles: any, insertImgFn: any) {
// resultFiles 是 input 中选中的文件列表
// insertImgFn 是获取图片 url 后,插入到编辑器的方法
// 1. 把用户选择的 resultFiles 上传到服务器
const fd = new FormData()
fd.append('file', resultFiles[0])
const { data } = await uploadCourseImage(fd)
// 2. 上传图片,返回结果,将图片插入到编辑器中
insertImgFn(data.data.name)
}
}
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 编辑课程
我们希望点击编辑按钮来到和添加课程一样的界面,把要编辑课程的数据展示到表单页面。点击保存时把编辑的课程内容更新一下就可以了
封装组件 views/course/components/CreateOrUpdate.vue
<template>
<el-card class="box-card">
<div slot="header">
<el-steps :active="activeStep" finish-status="success" simple style="margin-top: 20px">
<el-step
:title="item.title"
v-for="(item, index) in steps"
:key="index"
@click.native="activeStep = index"
></el-step>
</el-steps>
</div>
<el-form label-width="80px" label-position="left">
<div v-show="activeStep === 0">
<el-form-item label="课程名称">
<el-input v-model="course.courseName"></el-input>
</el-form-item>
<el-form-item label="课程简介">
<el-input v-model="course.brief"></el-input>
</el-form-item>
<el-form-item label="课程概述">
<el-input
style="margin-bottom: 10px"
v-model="course.previewFirstField"
type="textarea"
placeholder="概述1"
></el-input>
<el-input
v-model="course.previewSecondField"
type="textarea"
placeholder="概述2"
></el-input>
</el-form-item>
<el-form-item label="讲师姓名">
<el-input v-model="course.teacherDTO.teacherName"></el-input>
</el-form-item>
<el-form-item label="讲师简介">
<el-input v-model="course.teacherDTO.description"></el-input>
</el-form-item>
<el-form-item label="课程排序">
<el-input-number
label="描述文字"
v-model="course.sortNum"
></el-input-number>
</el-form-item>
</div>
<div v-show="activeStep === 1">
<el-form-item label="课程封面">
<!--
upload 上传文件组件,它支持自动上传,你只需要把上传需要参数配置一下就可以了
-->
<!--
1. 组件需要根据绑定的数据进行图片预览
2. 组件需要把上传成功的图片地址同步到绑定的数据中
v-model 的本质还是父子组件通信
1. 它会给子组件传递一个名字叫 value 的数据(Props)
2. 默认监听 input 事件,修改绑定的数据(自定义事件)
-->
<course-image v-model="course.courseListImg" />
</el-form-item>
<el-form-item label="介绍封面">
<course-image
v-model="course.courseImgUrl"
:limit="5"
/>
</el-form-item>
</div>
<div v-show="activeStep === 2">
<el-form-item label="售卖价格">
<el-input v-model.number="course.discounts" type="number">
<template slot="append">元</template>
</el-input>
</el-form-item>
<el-form-item label="商品原价">
<el-input v-model.number="course.price" type="number">
<template slot="append">元</template>
</el-input>
</el-form-item>
<el-form-item label="销量">
<el-input v-model.number="course.sales" type="number">
<template slot="append">单</template>
</el-input>
</el-form-item>
<el-form-item label="活动标签">
<el-input v-model="course.discountsTag"></el-input>
</el-form-item>
</div>
<div v-show="activeStep === 3">
<el-form-item label="限时秒杀">
<el-switch
v-model="course.activityCourse"
active-color="#13ce66"
inactive-color="#ff4949"
>
</el-switch>
</el-form-item>
<template v-if="course.activityCourse">
<el-form-item label="开始时间">
<el-date-picker
v-model="course.activityCourseDTO.beginTime"
type="date"
placeholder="选择日期时间"
value-format="yyyy-MM-dd"
/>
</el-form-item>
<el-form-item label="结束时间">
<el-date-picker
v-model="course.activityCourseDTO.endTime"
type="date"
placeholder="选择日期时间"
value-format="yyyy-MM-dd"
/>
</el-form-item>
<el-form-item label="秒杀价">
<el-input v-model.number="course.activityCourseDTO.amount" type="number">
<template slot="append">元</template>
</el-input>
</el-form-item>
<el-form-item label="秒杀库存">
<el-input v-model.number="course.activityCourseDTO.stock" type="number">
<template slot="append">个</template>
</el-input>
</el-form-item>
</template>
</div>
<div v-show="activeStep === 4">
<el-form-item label="课程详情">
<text-editor v-model="course.courseDescriptionMarkDown" />
<!-- <el-input v-model="course.courseDescriptionMarkDown" type="textarea"></el-input> -->
</el-form-item>
<el-form-item label="是否发布">
<el-switch
v-model="course.status"
:active-value="1"
:inactive-value="0"
active-color="#13ce66"
inactive-color="#ff4949"
>
</el-switch>
</el-form-item>
<el-form-item>
<el-button
type="primary"
@click="handleSave"
>保存</el-button>
</el-form-item>
</div>
<el-form-item v-if="activeStep >= 0 && activeStep < 4">
<el-button @click="activeStep++">下一步</el-button>
</el-form-item>
</el-form>
</el-card>
</template>
<script lang="ts">
import Vue from 'vue'
import {
saveOrUpdateCourse
} from '@/services/course'
import CourseImage from './CourseImage.vue'
import TextEditor from '@/components/TextEditor/index.vue'
export default Vue.extend({
name: 'CreateOrUpdateCourse',
props: {
isEdit: {
type: Boolean,
default: false
}
},
components: {
CourseImage,
TextEditor
},
data () {
return {
activeStep: 0,
steps: [
{ title: '基本信息' },
{ title: '课程封面' },
{ title: '销售信息' },
{ title: '秒杀活动' },
{ title: '课程详情' }
],
// imageUrl: '', // 预览图片地址
isSeckill: false, // 是否开启秒杀
course: {
// id: 0,
courseName: '',
brief: '',
teacherDTO: {
// id: 0,
// courseId: 0,
teacherName: '',
teacherHeadPicUrl: '',
position: '',
description: ''
},
courseDescriptionMarkDown: '',
price: 0,
discounts: 0,
priceTag: '',
discountsTag: '',
isNew: true,
isNewDes: '',
courseListImg: '',
courseImgUrl: '',
sortNum: 0,
previewFirstField: '',
previewSecondField: '',
status: 0, // 0:未发布,1:已发布
sales: 0,
activityCourse: false, // 是否开启活动秒杀
activityCourseDTO: {
// id: 0,
// courseId: 0,
beginTime: '',
endTime: '',
amount: 0,
stock: 0
},
autoOnlineTime: ''
}
}
},
methods: {
async handleSave () {
const { data } = await saveOrUpdateCourse(this.course)
if (data.code === '000000') {
this.$message.success('保存成功')
this.$router.push('/course')
} else {
this.$message.error('保存失败')
}
}
}
})
</script>
<style lang='scss' scoped>
.el-step {
cursor: pointer;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
注册组件 views/course/create.vue
<template>
<div class="course-create">
<create-or-update />
</div>
</template>
<script lang="ts">
import Vue from 'vue'
import CreateOrUpdate from './components/CreateOrUpdate.vue'
export default Vue.extend({
name: 'CourseCreate',
components: {
CreateOrUpdate
}
})
</script>
<style lang="scss" scoped></style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
编辑组件 views/course/edit.vue
<template>
<div class="course-edit">
<create-or-update is-edit />
</div>
</template>
<script lang="ts">
import Vue from 'vue'
import CreateOrUpdate from './components/CreateOrUpdate.vue'
export default Vue.extend({
name: 'CourseEdit',
components: {
CreateOrUpdate
}
})
</script>
<style lang="scss" scoped></style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
为编辑页配置路由 router/index.ts
{
path: '/course/:courseId/edit',
name: 'course-edit',
component: () => import(/* webpackChunkName: 'course-edit' */ '@/views/course/edit.vue'),
props: true // 将路由路径参数映射到组件的 props 数据中
}
2
3
4
5
6
views/course/edit.vue
<script lang="ts">
...
export default Vue.extend({
name: 'CourseEdit',
props: {
courseId: {
type: [String, Number],
required: true
}
},
...
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
注册编辑事件 views/course/components/CourseList.vue
<template slot-scope="scope">
<el-button
@click="$router.push({
name: 'course-edit',
params: {
courseId: scope.row.id
}
})"
>编辑</el-button>
<el-button>内容管理</el-button>
</template>
2
3
4
5
6
7
8
9
10
11
把课程Id传递给封装的组件,以便其根据Id加载要编辑的课程数据 views/course/edit.vue
<template>
<div class="course-edit">
<create-or-update
is-edit
:course-id="courseId"
/>
</div>
</template>
2
3
4
5
6
7
8
views/course/components/CreateOrUpdate.vue
<script lang="ts">
...
export default Vue.extend({
name: 'CreateOrUpdateCourse',
props: {
isEdit: {
type: Boolean,
default: false
},
courseId: {
type: [String, Number]
}
}
...
created () {
if (this.isEdit) {
this.loadCourse()
}
},
...
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
封装请求 services/course.ts
export const getCourseById = (courseId: string | number) => {
return request({
method: 'GET',
url: '/boss/course/getCourseById',
// url: `/boss/course/getCourseById?courseId=${courseId}`,
params: {
courseId
}
})
}
2
3
4
5
6
7
8
9
10
加载请求 views/course/components/CreateOrUpdate.vue
<script lang="ts">
...
import {
...
getCourseById
} from '@/services/course'
...
export default Vue.extend({
name: 'CreateOrUpdateCourse',
...
methods: {
async loadCourse () {
const { data } = await getCourseById(this.courseId)
this.course = data.data
},
...
},
...
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# JS 中一个用来处理时间的类库 moment
npm i moment
2
<script lang="ts">
...
import moment from 'moment'
export default Vue.extend({
name: 'CreateOrUpdateCourse',
...
methods: {
async loadCourse () {
const { data } = await getCourseById(this.courseId)
const { activityCourseDTO } = data.data
if (activityCourseDTO) {
activityCourseDTO.beginTime = moment(activityCourseDTO.beginTime).format('YYYY-MM-DD')
activityCourseDTO.endTime = moment(activityCourseDTO.endTime).format('YYYY-MM-DD')
this.course = data.data
} else { // 接口中 课程未开启秒杀 字段可能为 null
this.course = data.data
this.course.activityCourseDTO = {
beginTime: '',
endTime: '',
amount: 0,
stock: 0
}
}
},
...
},
...
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
到此,添加/编辑课程就完成了