需求描述
需要开发一个在线旅游网站,主要在移动端使用,界面美观,体验要好。可以查看景点并在线预订门票信息,游客可以查看景点和门票信息,如果要预订门票需要注册成为会员。为了方便联系,必须收集用户的真实手机号码,景点数据较多,首页数据要快。另外,可以在线维护景点及门票信息,且有统计功能。开发周期有限,希望快速看到效果
需求分析
功能性需求 | 非功能性需求 |
登录、注册 | 首页速度要快 |
景点、门票展示 | 界面美观,体验要好 |
在线预订门票 | 移动端项目 |
后台管理 | 时间有限 |
统计报表 | 便于维护,按模块开发 |
技术栈选择
Django+vue+mysql+redis
移动端前后端完成分离开发
版本迭代计划
v1.0: 首页基本功能
v1.1:景点详情、评论、搜索
v1.2:用户的注册和登录功能
v1.3 : 提交订单、订单管理
v 1.4 :后台管理、报表统计
一、首页拆解
拆分为
- 标题
- 轮播图
- 热门推荐
- 精选景点
- 底部导航(固定在底部)
可以使用VantUI开发
1.1 轮播图开发
开发步骤:
第一步,查找Vant中可以使用的组件
第二步,实现组件模板部分
第三步,模型准备数据
第四步,模拟数据,实现轮播效果
1.2 热门推荐组件开发
第一步,查找Vant中可以使用的组件
第二步,完成布局,实现组件模板部分
拆分为 热门推荐标题,图片,景点名称,价格,是可以左右滑动,所以要加滚动条 overflow-x: scroll;和采用flex布局,flex-direction:column
1.3 精选景点开发
第一步:查找Vant中可以使用的组件
精选标题,更多 使用 vant 中cell单元格,评分使用 Rate评分
第二步,完成布局,实现组件模板部分
第三步,模型准备数据
第四步,模拟数据,实现效果
景点列表组件开发
因为精选景点点击右边菜单还有个列表,对应一个新的页面,但这个页面和当前这个精选页面有重复的代码,所以单独抽取出来
开发步骤:
第一步:分析组件的复用情况
第二步:新建组件文件,实现组件内容
第三步:设置props从父组件传递数据
1.4 页面底部组件开发
第一步:查找Vant中可以使用的组件
使用Tabbar标签栏
第二步,完成布局,实现组件模板部分
第三步,模型准备数据
第四步,模拟数据,实现效果
底部导航有多个页面用到所以应抽出为公共部分,
二、ORM模型设计
开发步骤:
第一步:分析并设计数据库模型
第二步:完成ORM模型编码
第三步:监测ORM模型
第四步:模型同步
2.1 分析并设计数据库模型
系统模块:轮播图,用户反馈
景点模块:景点、景点详情,景点评论
用户模块:用户,用户详细信息,登录历史
订单模块:订单,订单明细,支付相关
轮播图(system_slider)字段
名称(name),描述(desc),展现位置(types),图片地址(img),排序字段(reorder),生效开始时间(start_time),生效结束时间(end_time),跳转地址(targer_url),是否删除(is_valid),创建时间(created_at),修改时间(update_at)
class Slider(models.Model):
"""轮播图"""
name = models.CharField('名称',max_length=32)
desc = models.CharField('描述', max_length=100,null=True,blank=True)
types = models.SmallIntegerField('展现的位置', default=10)
img = models.ImageField('图片地址', max_length=255,upload_to='%Y%m/slider')
reorder = models.SmallIntegerField('排序字段', default=0,help_text='数字越大越靠前')
start_time = models.DateTimeField('生效开始时间', null=True,blank=True)
end_time = models.DateTimeField('生效结束时间', null=True,blank=True)
target_url = models.CharField('跳转的地址', max_length=255,null=True,blank=True)
is_valid = models.BooleanField('是否有效', default=True)
created_at = models.DateTimeField('创建时间',auto_now_add=True)
updated_at = models.DateTimeField('修改时间',auto_now=True)
class Meta:
db_table = "system_slider"
ordering = ['-reorder']
2.2 设计轮播图接口
开发步骤:
- 设计接口返回标准 (定义接口返回结构,接口错误信息约定)
- 编写接口代码
- 模拟HTTP请求,测试验证接口
接口返回结构
data = {
"mata":{},
"objects":[]
}
def slider_list(request):
"""轮播图接口"""
data = {
"mata":{},
"objects":[]
}
queryset = Slider.objects.filter(is_valid=True)
for item in queryset:
data['objects'].append({
'id':item.id,
'img':item.img.url,
'target_url':item.target_url,
'name':item.name
})
return http.JsonResponse(data)
2.3 景点ORM模型设计
景点字段名:
class Sight(models.Model):
"""景点基础信息"""
name = models.CharField('名称',max_length=64)
desc = models.CharField('描述',max_length=128)
main_img = models.ImageField('主图',upload_to='%Y%m/sight',max_length=256)
banner_img = models.ImageField('详细主图',upload_to='%Y%m/sight',max_length=256)
content = models.TextField('详细')
score = models.FloatField('评分',default=5)
min_price = models.FloatField('最低价格',default=0)
province = models.CharField('省份',max_length=32)
city = models.CharField('市区',max_length=32)
area = models.CharField('区/县',max_length=32,null=True)
town = models.CharField('乡镇',max_length=32,null=True)
is_top = models.BooleanField('是否为精选景点',default=False)
is_hot = models.BooleanField('是否为热门景点',default=False)
is_valid = models.BooleanField('是否有效',default=True)
created_at = models.DateTimeField('创建时间',auto_now_add=True)
updated_at = models.DateTimeField('修改时间',auto_now=True)
class Meta:
db_table = 'sight'
2.4 景点列表API接口
开发步骤
- 设计接口返回内容及字段
- 编写接口代码(查数据,分页)
- 模拟HTTP请求,测试验证接口
接口结构
data = {
'meta':{
},
'objects':[
]
}
可能景点有很多,需要用到分页,这里使用Django中的ListView,使用面向对象的方式写视图函数
class SightListView(ListView):
"""景点列表"""
# 设置每页的数目
paginate_by = 5
def get_queryset(self):
"""重写查询方法"""
query = Q(is_valid=True)
# 1 热门景点
is_hot = self.request.GET.get('is_hot', None)
if is_hot:
query = query & Q(is_hot=True)
# 2 精选景点
is_top = self.request.GET.get('is_top', None)
if is_top:
query = query & Q(is_top=True)
# 3 按景点名称搜索
queryset = Sight.objects.filter(query)
return queryset
def render_to_response(self, context, **response_kwargs):
page_obj = context['page_obj']
data = {
'meta': {
# 总共多少条记录
'total_count': page_obj.paginator.count,
# 总共有多少页
'page_count': page_obj.paginator.num_pages,
# 当前是多少页
'current_page': page_obj.number,
},
'objects': [
]
}
for item in page_obj.object_list:
data['objects'].append({
'id': item.id,
'name': item.name,
'main_img': item.main_img.url,
'score': item.score,
'province': item.province,
'city': item.city,
'comment_count': 0
})
return http.JsonResponse(data)
2.5 轮播图接口的数据获取(接口联调)
实现步骤
- 阅读接口文档
- 配置接口地址
- 使用axios获取数据
- 将数据设置到模型层
建立utils 文件夹,下面新建apis.js,用来存放接口
//接口地址
const apiHost = 'http://localhost:8080/api'
/**
* 系统模块的接口
*/
const SystemApis = {
// 轮播图列表
sliderListUrl: apiHost + '/system/slider/list/'
}
export{
SystemApis,
}
utils下新建ajax.js文件,
import axios from 'axios'
export const ajax = axios.create({
headers: {
source: 'h5',
icode: 'acbd',
'Content-Type': 'application/x-www-form-urlencoded'
},
withCredentials: true
})
ajax.interceptors.request.use(function (config) {
// 在发送请求之前做些什么
console.log('请求拦截到了')
// window.app.$toast.loading({
// message: '加载中...',
// forbidClick: true,
// loadingType: 'spinner'
// })
return config
}, function (error) {
// 对请求错误做些什么
// window.app.$toast.clear()
return Promise.reject(error)
})
ajax.interceptors.response.use(function (response) {
// 对响应数据做点什么
console.log('响应拦截到了')
// window.app.$toast.clear()
return response
}, function (error) {
// 对响应错误做点什么
if (error.response) {
if (error.response.status === 401) {
window.alert('未登录,即将跳转到登录页面')
} else if (error.response.status === 500) {
window.app.$notify({
message: '服务器正忙,请稍后重试',
type: 'danger'
})
// window.alert('服务器正忙,请稍后重试')
}
}
// window.app.$toast.clear()
return Promise.reject(error)
})
根目录新建vue.config.js,在里面解决跨域问题
配置中添加
devServer:{
proxy: {
'/api': {
target: 'http://127.0.0.1:8000/', //跨域请求的公共地址
ws: false, //也可以忽略不写,不写不会影响跨域
changeOrigin: true, //是否开启跨域,值为 true 就是开启, false 不开启
pathRewrite: {
'^/api': '' //注册全局路径, 但是在你请求的时候前面需要加上 /api
}
}
},
}
在轮播图模块,获取轮播图数据,并按照接口返回的字段在模板中进行绑定
<script>
import {ajax} from '@/utils/ajax'
import { SystemApis } from "@/utils/apis";
export default{
data(){
return{
bannerList:[]
}
},
methods:{
getDataList(){
//获取轮播图数据
ajax.get(SystemApis.sliderListUrl).then(res => {
console.log('res',res.data.objects)
this.bannerList = res.data.objects
})
}
},
created(){
this.getDataList()
}
}
</script>
2.6 景点列表接口数据获取
实现步骤
- 阅读接口文档
- 配置接口地址
- 使用axios获取数据
- 将数据设置到模型层
三 、景点搜索页面
实现步骤
- 查找Vant中可以使用的组件
- 实现组件模板部分
- 模型层数据准备
可以将页面拆分为标题,搜索框,景点列表,分页,和底部导航
标题:可以使用Vant中的 navbar导航栏
搜索框:使用Vant中的Search搜索
景点列表:已抽出为公共部分可以导入使用
分页:可以使用Vant中的 pagination分页
底部导航 :已拆分为公共部分,可直接导入使用
新建Search.vue完成上面所拆分的页面
使用VueRouter添加Search搜索页面,在底部导航中将使用:to进行name绑定
在router下面的index.js中添加
import Vue from 'vue'
import VueRouter from 'vue-router'
import HomeView from '../views/HomeView.vue'
import Search from "../views/Search";
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'Home',
component: HomeView
},
//搜索页面
{
path: '/search',
name: 'Search',
component: Search
},
]
const router = new VueRouter({
routes
})
export default router
底部导航路由绑定
<template>
<!--底部导航(公共组件)-->
<div>
<van-tabbar v-model="active" route>
<van-tabbar-item name="home" icon="home-o" :to="{'name':'Home'}">首页</van-tabbar-item>
<van-tabbar-item name="search" icon="search" :to="{'name':'Search'}">搜索</van-tabbar-item>
<van-tabbar-item name="mine" icon="friends-o">我的</van-tabbar-item>
</van-tabbar>
</div>
</template>
四、搭建前端详情页
实现步骤:
- 设计URL路由规则
- 新建详情页页面
- 修改景点列表组件,支持路由跳转
- 拆分详情页组件
详情页可拆分为,图片页,评论页,景点介绍页,门票页(后面做),新建相关页面使用<router-link>绑定路由链接,
分析首页精选列表和热门榜单,都可以点击更多和单个点击,使用<router-link>为其绑定相关路由链接
4.1 详情页相关组件
详情页可拆分为 页面头部(固定),大图,评分和景点介绍,地址,门票列表,评论列表
实现步骤:
第一步:查找Vant中可以使用的组件
第二步,完成布局,实现组件模板部分
第三步,模型准备数据
第四步,模拟数据,实现效果
页面头部:NavBar 导航栏
大图:Vant中Image图片
评分和景点介绍:可以从Vant中寻找icon图标
地址:使用Vant中的单元格
门票列表:可以用Vant中的Cell单元格(标题),Tag标记,Button按钮
热门评论:很多地方用到,应单独抽取出来,可以重复用
五、ORM模型设计
开发步骤:
- 分析并设计模型
- 完成ORM模型编码
- 检测ORM模型
- 模型同步
景点详细信息、景点评论、景点门票、景点图片、用户账户
景点详细信息
class Info(models.Model):
""" 景点详情 """
sight = models.OneToOneField(Sight, on_delete=models.CASCADE)
entry_explain = models.CharField('入园参考', max_length=1024, null=True, blank=True)
play_way = models.TextField('特色玩法',null=True, blank=True)
tips = models.TextField('温馨提示', null=True, blank=True)
traffic = models.TextField('交通到达', null=True, blank=True)
class Meta:
db_table = 'sight_info'
景点评论
class Comment(CommonModel):
""" 评论及回复 """
user = models.ForeignKey(User, verbose_name='评论人',
related_name='comments',
on_delete=models.CASCADE)
sight = models.ForeignKey(Sight, verbose_name='景点',
related_name='comments',
on_delete=models.CASCADE)
content = models.TextField('评论内容', blank=True, null=True)
is_top = models.BooleanField('是否置顶', default=False)
love_count = models.IntegerField('点赞次数', default=0)
score = models.FloatField('评分', default=5)
ip_address = models.CharField('IP地址', blank=True, null=True, max_length=64)
is_public = models.SmallIntegerField('是否公开', default=1)
reply = models.ForeignKey(
'self', blank=True, null=True,
related_name='reply_comment',
verbose_name='回复',
on_delete=models.CASCADE)
images = GenericRelation(ImageRelated,
verbose_name='关联的图片',
related_query_name="rel_comment_images")
class Meta:
db_table = 'sight_comment'
ordering = ['-love_count', '-created_at']
景点门票
class Ticket(CommonModel):
""" 门票 """
sight = models.ForeignKey(Sight, related_name='tickets', verbose_name='景点门票',
on_delete=models.PROTECT)
name = models.CharField('名称', max_length=128)
desc = models.CharField('描述', max_length=64, null=True, blank=True)
types = models.SmallIntegerField('类型',
choices=TicketTypes.choices,
default=TicketTypes.ADULT,
help_text='默认为成人票')
price = models.FloatField('价格(原价)')
discount = models.FloatField('折扣', default=10)
total_stock = models.PositiveIntegerField('总库存', default=0)
remain_stock = models.PositiveIntegerField('剩余库存', default=0)
expire_date = models.IntegerField('有效期', default=1)
return_policy = models.CharField('退改政策', max_length=64, default='条件退')
has_invoice = models.BooleanField('是否提供发票', default=True)
entry_way = models.SmallIntegerField('入园方式',
choices=EntryWay.choices,
default=EntryWay.BY_TICKET)
tips = models.TextField('预定须知',null=True, blank=True)
remark = models.TextField('其他说明', null=True, blank=True)
status = models.SmallIntegerField('状态',
choices=TicketStatus.choices,
default=TicketStatus.OPEN)
class Meta:
db_table = 'sight_ticket'
景点图片
class ImageRelated(models.Model):
"""图片关联"""
img = models.ImageField('图片',upload_to='%Y%m/file/',max_length=155)
summary = models.CharField('图片说明',max_length=32,null=True,blank=True)
user = models.ForeignKey(User,related_name='upload_images',verbose_name='上传的用户',on_delete=models.SET(None))
content_type = models.ForeignKey(ContentType,on_delete=models.CASCADE)
object_id = models.IntegerField('关联的模型')
content_object = GenericForeignKey('content_type','object_id')
class Meta:
db_table = 'system_image_related'
用户账户
class User(CommonModel):
"""用户模型"""
username = models.CharField('用户名',max_length=32,unique=True)
password = models.CharField('密码',max_length=256)
nickname = models.CharField('昵称',max_length=32,unique=True)
avatar = models.ImageField('用户头像',upload_to='avatar/%Y%m',null=True,blank=True)
class Meta:
db_table = 'account_user'
5.1 重构响应对象
开发步骤:
- 设计响应单个对象的基类
- 设计响应列表的基类
- 设计错误基础类
接口处 有很多重复的代码,接口结构都是{meta:{},objects:[]},重构后便于维护和修改
首先在utils下新建serializers.py
class BaseSerializer(object):
def __init__(self, obj):
self.obj = obj
def to_dict(self):
return {}
class MetaSerializer(object):
"""分页元数据"""
def __init__(self, page, page_count, total_count):
"""
:param page: 当前页
:param page_count:总页数
:param total_count: 总记录数
"""
self.page = page
self.page_count = page_count
self.total_count = total_count
def to_dict(self):
return {
'total_count': self.total_count,
'page_count': self.page_count,
'current_page': self.page
}
class BaseListPageSerializer(object):
"""分页类的封装"""
def __init__(self, page_obj, paginator=[], object_list=[]):
"""
:param page_obj: 当前页的对象
:param paginator: 分页器的对象
:param object_list: 当前页的数据列表
"""
self.page_obj = page_obj
self.paginator = paginator if paginator else page_obj.paginator
self.object_list = object_list if object_list else page_obj.object_list
def get_object(self, obj):
"""对象内容,子类进行重写"""
return {}
def to_dict(self):
page = self.page_obj.number
page_count = self.paginator.num_pages
total_count = self.paginator.count
meta = MetaSerializer(page=page, page_count=page_count, total_count=total_count).to_dict()
objects = []
for obj in self.object_list:
objects.append(self.get_object(obj))
return {
'meta': meta,
'objects': objects
}
sight下新建serializers.py文件类继承着BaseListPageSerializer,对get_object方法进行重写
from utils.serializers import BaseListPageSerializer
class SightListSerializer(BaseListPageSerializer):
"""景点列表"""
def get_object(self, obj):
return {
'id': obj.id,
'name': obj.name,
'main_img': obj.main_img.url,
'score': obj.score,
'province': obj.province,
'city': obj.city,
'price': obj.min_price,
'comment_count': 0
}
设计错误类,比如400错误响应字段
from django.http import JsonResponse
class NotFoundJsonResponse(JsonResponse):
"""400对应JSON响应"""
def __init__(self,*args,**kwargs):
data = {
'error_code':'404000',
'error_msg':'您访问的内容不存在或已被删除'
}
super().__init__(data,*args,**kwargs)
5.2 景点详情接口
开发步骤:
- 设计接口返回内容及字段
- 编写接口代码
- 模拟HTTP请求,测试验证接口
视图继承DetailView,get_query(写出从哪个模型获取数据),重写render_to_response(),返回对象
sight/serializers继承BaseSerializer重写to_dict函数,写出接口返回对象
class SightDetailSerializer(BaseSerializer):
def to_dict(self):
obj = self.obj
return {
'id' : obj.id,
'name':obj.name,
'img':obj.banner_img.url,
'content':obj.content,
'score':obj.score,
'province':obj.province,
'min_price':obj.min_price,
'area':obj.area,
'city':obj.city,
'town':obj.town,
'comment_count':0
}
class SightDetailView(DetailView):
"""景点详细接口"""
def get_queryset(self):
return Sight.objects.all()
def render_to_response(self, context, **response_kwargs):
page_obj = context['object']
if page_obj:
if page_obj.is_valid == True:
data = serializers.SightDetailSerializer(page_obj).to_dict()
return http.JsonResponse(data)
return NotFoundJsonResponse()
5.3 景点评论列表开发
开发步骤:
- 设计接口及返回字段
- 编写接口代码
- 模拟HTTP请求,测试验证接口
视图中使用继承ListView的方法来写接口,注意设置paginate_by大小
class SightCommentView(ListView):
"""景点评论接口"""
paginate_by = 5
def get_queryset(self):
sight_id = self.kwargs.get('pk',None)
sight = Sight.objects.filter(id=sight_id,is_valid=True).first()
if sight:
return Comment.objects.filter(sight=sight,is_valid=True)
return Comment.objects.none()
def render_to_response(self, context, **response_kwargs):
page_obj = context['page_obj']
print(page_obj)
if page_obj:
data = serializers.CommentListSerializer(page_obj).to_dict()
return http.JsonResponse(data)
return NotFoundJsonResponse()
class CommentListSerializer(BaseListPageSerializer):
"""评论列表"""
def get_object(self, obj):
user = obj.user
images = []
for image in obj.images.filter(is_valid=True):
images.append({
'img': image.img.url,
'summary': image.summary,
})
return {
'user': {
'pk': user.pk,
'nickname': user.nickname},
'pk': obj.pk,
'content': obj.content,
'is_top': obj.is_top,
'love_count': obj.love_count,
'score': obj.score,
'images': images,
//时间转换
'create_at': obj.created_at.strftime('%Y-%m-%d')
}
5.4 门票列表接口开发
开发步骤:
- 设计接口返回内容及字段
- 编写接口代码
- 模拟HTTP请求,测试验证接口
视图使用面向对象的方法继承ListView,
class SightTicketView(ListView):
"""景点下的接口列表"""
paginate_by = 10
def get_queryset(self):
sight_id = self.kwargs.get('pk', None)
return Ticket.objects.filter(is_valid=True, sight_id=sight_id)
def render_to_response(self, context, **response_kwargs):
page_obj = context['page_obj']
print(page_obj)
if page_obj:
data = serializers.TicketListSerializer(page_obj).to_dict()
return http.JsonResponse(data)
return NotFoundJsonResponse()
class TicketListSerializer(BaseListPageSerializer):
"""门票列表"""
def get_object(self, obj):
return {
'pk': obj.pk,
'name': obj.name,
'desc':obj.desc,
'types': obj.types,
'price': obj.price,
'discount': obj.discount,
'total_stock': obj.total_stock,
'remain_stock': obj.remain_stock
}
5.5 景点详细信息接口
开发步骤:
- 设计接口返回内容及字段
- 编写接口代码
- 模拟HTTP请求,测试验证接口
视图使用面向对象的方法,继承DetailView,
class SightInfoDetailView(DetailView):
"""景点介绍"""
slug_field = 'sight_pk'
def get_queryset(self):
return Info.objects.all()
def render_to_response(self, context, **response_kwargs):
page_obj = context['object']
if page_obj:
data = serializers.SightInfoSerializer(page_obj).to_dict()
return http.JsonResponse(data)
return NotFoundJsonResponse()
class SightInfoSerializer(BaseSerializer):
"""景点介绍"""
def to_dict(self):
obj = self.obj
return {
'pk': obj.sight.pk,
'entry_explain': obj.entry_explain,
'play_way': obj.play_way,
'tips': obj.tips,
'traffic': obj.traffic
}
六、景点搜索接口联调
实现步骤:
- 修改景点列表接口,实现搜索
- 配置接口地址
- 使用axios获取数据
- 将数据设置到模型层
由前端指定每页数据大小
Django重写get_paginate_by方法
request.GET.get()从前端传过来的数据,返回
实现从首页热门推荐和精选景点点击全部跳转到搜索页,将热门推荐榜单和精选景点榜单全部展示出来
七、景点详情接口联调
实现步骤:
- 阅读接口文档
- 配置接口地址
- 使用axios获取数据
- 将数据设置到模型层
景点详情url是动态的id,用特殊字符替换id,页面传递时再用实际的id替换回来
SightDatailUrl: apiHost + '/sight/sight/detail/#{id}/',
getDetailList() {
const url = SightApis.SightDatailUrl.replace('#{id}', this.id);
ajax.get(url).then(({ data }) => {
this.detailList = data
})
},
7.1 景点门票接口联调
实现步骤:
- 阅读接口文档
- 配置接口地址
- 使用axios获取数据
- 将数据设置到模型层
getTicketList() {
const url = SightApis.SightTicketUrl.replace('#{id}', this.id)
console.log(url)
ajax.get(url).then(({ data: { objects } }) => {
this.ticketList = objects
})
},
7.2 景点评论接口联调
实现步骤:
- 阅读接口文档
- 配置接口地址
- 使用axios获取数据
- 将数据设置到模型层
7.3 前端代码优化
添加loading动画(使用Vant中的Toast 轻提示)
添加请求拦截
添加滚动加载和下拉刷新 (使用vant中的List 列表)
<div class="page-sight-comment">
<van-nav-bar left-text="返回" left-arrow @click-left="goBack" fixed />
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list class="sight-comment" v-model="loading" :finished="finished" finished-text="没有更多了" :error.sync="error"
error-text="请求失败,点击重新加载" @load="getCommentList">
<!-- <van-cell v-for="item in list" :key="item" :title="item" /> -->
<CommentItem v-for="item in commentList" :key="item.pk" :item="item" />
</van-list>
</van-pull-refresh>
</div>
import { ajax } from "@/utils/ajax";
import { SightApis } from "@/utils/apis";
import CommentItem from '@/components/sight/CommentItem'
export default {
data() {
return {
// 评论列表
commentList: [],
// 当前的页码
currentPage: 1,
// 正在加载中
loading: false,
// 所有的内容加载完
finished: false,
// 请求失败
error: false,
// 是否正在下拉刷新中
refreshing: false
}
},
components: {
CommentItem
},
methods: {
goBack() {
this.$router.go(-1)
},
onRefresh() {
// 清空数据
this.commentList = []
this.currentPage = 1
this.finished = false
this.error = false
// 重新加载数据
this.getCommentList()
},
getCommentList() {
const url = SightApis.SightCommenttUrl.replace('#{id}', this.id)
ajax.get(url, {
params: {
page: this.currentPage
}
}).then(({ data: { meta, objects } }) => {
this.commentList = this.commentList.concat(objects)
// 加载状态结束
this.loading = false
// 设置下一页的页码
this.currentPage = meta.current_page + 1
// 数据全部加载完成: 当前页面 == 总页数
if (meta.current_page === meta.page_count) {
this.finished = true
}
this.refreshing = false
}).catch(() => {
this.loading = false
this.error = true
this.refreshing = false
})
}
},
mounted() {
this.id = this.$route.params.id
}
}
</script>
八、用户登录页面开发
登录页面拆分
顶部导航条 (Vant NavBar 导航栏)
表单 (Vant Form表单)
登录按钮 (Vant Form表单)
登录注意信息
8.1 用户注册页面的开发
实现步骤:
- 查找Vant中可以使用的组件
- 实现组件模板部分
- 实现验证码组件(需要用到window中的setInterval和clearInterval)
验证码点击后不可再点击可以使用 disabled参数
vue中ref相当于给Dom起了个名字,
<template>
<van-button size="small" type="primary" @click.prevent="sendSmsCode" :disabled="isSmsSend">{{sendBtnText}}
</van-button>
</template>
<script>
export default {
props: ['phoneNum'],
data() {
return {
isSmsSend: false,
sendBtnText: '发送验证码',
counter: 60,
timer: null
}
},
methods: {
//重置验证码
reset() {
this.isSmsSend = false,
this.sendBtnText = '发送验证码'
if (this.timer) {
clearInterval(this.timer)
this.counter = 60
this.timer = null
}
},
//倒计时
countDown() {
this.timer = setInterval(() => {
this.sendBtnText = `${this.counter}秒后重新发送`
this.counter--
if (this.counter < 0) {
this.reset()
}
}, 1000)
},
sendSmsCode() {
//判断手机号是否输入
if (!this.phoneNum) {
this.$notify('请输入手机号')
return false
}
//调用接口
//开启倒计时,60秒后才可点击
this.isSmsSend = true
this.countDown()
}
}
}
</script>
8.2 个人中心页面开发
拆分:
顶部标题(vant NavBar 导航栏)
头像
欢迎你
订单 (vant 上查找图标)
底部菜单 (引用之前写的组件)
九、用户登录,退出接口开发
开发步骤
- 设计接口返回内容及字段
- 编写接口代码
- 模拟HTTP请求,测试验证接口(使用postman)
登录为post请求
9.1 用户详情接口开发
开发步骤
- 设计接口返回内容及字段
- 编写接口代码
- 模拟HTTP请求,测试验证接口(使用postman)
用户登录了才能查询信息(可以使用is_authenticated判断是否登录)
9.2 短信验证码接口
开发步骤
- 设计接口返回内容及字段
- 编写接口代码
- 模拟HTTP请求,测试验证接口(使用postman)
验证码使用redis缓存,需要在根settings中配置
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/2",
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
}
}
}
在系统模块中新建表单完成验证码功能
import random
import re
from django import forms
from django.core.cache import cache
class SendSmsCodeForm(forms.Form):
""" 发送验证码的表单 """
phone_num = forms.CharField(label='手机号码', required=True, error_messages={
'required': '请输入手机号码'
})
def clean_phone_num(self):
""" 验证是否为手机号 """
phone_num = self.cleaned_data['phone_num']
pattern = r'^1[0-9]{10}$'
if not re.search(pattern, phone_num):
raise forms.ValidationError('手机号%s输入不正确',
code='invalid_phone',
params=(phone_num, ))
return phone_num
def send_sms_code(self):
""" 生成验证码并发送短信 """
sms_code = random.randint(100000, 999999)
phone_num = self.cleaned_data.get('phone_num', None)
try:
# TODO 调用发送验证码的短信接口
# redis中的key
key = 'sms_code_{}'.format(phone_num)
# 将验证码存入redis
timeout = 5 * 60
cache.set(key, sms_code, timeout=timeout)
return {
'phone_num': phone_num,
'sms_code': sms_code,
'timeout': timeout
}
except Exception as e:
print(e)
return None
视图中
class SmsCodeView(FormView):
form_class = SendSmsCodeForm
# 1. 拿到手机号,判断是否为真实的手机号码
# 2. 生成验证码,并存储
# TODO 3. 调用短信的发送接口
# 4. 告诉用户验证码发送是否成功(会把验证码直接告诉用户)
def form_valid(self, form):
""" 表单已经通过验证 """
data = form.send_sms_code()
if data is not None:
return http.JsonResponse(data, status=201)
return ServerErrorJsonResponse()
def form_invalid(self, form):
""" 表单没有通过验证 """
err_list = json.loads(form.errors.as_json())
return BadrequestJsonResponse(err_list)
9.3 用户注册接口
开发步骤
- 设计接口返回内容及字段
- 编写接口代码
- 模拟HTTP请求,测试验证接口(使用postman)
1.表单验证用户输入信息
2.创建用户基础信息表,用户详细信息表
3.执行登录
4.保存登录日志
注册的表单
class RegisterForm(forms.Form):
username = forms.CharField(label='手机号码', max_length=16, required=True, error_messages={
'required': '请输入手机号码'
})
password = forms.CharField(label='密码', max_length=266, required=True, error_messages={
'required': '请输入密码'
})
nickname = forms.CharField(label='验证码', max_length=16, required=True, error_messages={
'required': '请输入昵称'
})
sms_code = forms.CharField(label='验证码', max_length=6, required=True, error_messages={
'required': '请输入验证码'
})
def clean_username(self):
"""验证用户名的钩子函数"""
username = self.cleaned_data['username']
pattern = r'^1[0-9]{10}$'
if not re.search(pattern, username):
raise forms.ValidationError('手机号%输入不正确', params=(username,), code='invalid_phone')
if User.objects.filter(username=username).exists():
raise forms.ValidationError('用户名已被使用')
return username
def clean_nickname(self):
"""昵称验证"""
nickname = self.cleaned_data['nickname']
if User.objects.filter(nickname=nickname).exists():
raise forms.ValidationError('昵称已被使用')
return nickname
def clean(self):
data = super().clean()
if self.errors:
return
# 获取手机号
phone_num = self.cleaned_data.get('username', None)
# 获取验证码
sms_code = self.cleaned_data.get('sms_code', None)
key = '{}{}'.format(REGISTER_MSM_CODE_KEY, phone_num)
code = cache.get(key)
if code is None:
raise forms.ValidationError('验证码已经失效')
if str(code) != sms_code:
raise forms.ValidationError('验证码输入错误')
return data
@transaction.atomic
def do_register(self, request):
"""执行注册"""
# 创建基础信息表
try:
data = self.cleaned_data
version = request.headers.get('version', ''),
source = request.headers.get('source', '')
user = User.objects.create_user(
username=data.get('username', None),
password=data.get('password', None),
nickname=data.get('nickname', None)
)
# 创建详细信息表
profile = Profile.objects.create(
user=user,
username=user.username,
version=version,
source=source
)
# 执行登录
login(request, user)
# 记录登录日志
user.last_login = now()
user.save()
ip = request.META.get('REMOTE_ADDR', '')
user.add_login_record(username=user, ip=ip, source=source, version=version)
return user, profile
except Exception as e:
print(e)
return None
注册的视图
class UserRegisterView(FormView):
form_class = RegisterForm
http_method_names = ['post']
def form_valid(self, form):
result = form.do_register(request=self.request)
if result is not None:
user, profile = result
data = {
'user': serializers.UserSerializer(user).to_dict(),
'profile': serializers.UserProfileSerializer(profile).to_dict()
}
return http.JsonResponse(data, status=201)
return ServerErrorJsonResponse()
def form_invalid(self, form):
""" 表单没有通过验证 """
err_list = json.loads(form.errors.as_json())
return BadrequestJsonResponse(err_list)
9.4 用户登录接口联调
实现步骤
第一步:在登录组件中调用登录接口
第二步:设置用户信息到Vuex
第三步:在个人中心显示用户信息
第四步:刷新时调用个人信息接口
9.5 验证码获取接口联调
实现步骤
第一步:阅读接口文档
第二步:配置接口地址
第三步:使用axios获取数据
第四步:将数据设置到模型层
9.6 用户注册接口联调
实现步骤
第一步:阅读接口文档
第二步:配置接口地址
第三步:使用axios获取数据
第四步:将数据设置到模型层
十、订单页面开发
页面拆分
页面标题
景点门票标题(预定须知)
出行日期(Vant中日历组件)
购买数量(Vant Stepper 步进器)
收件人,手机号码
订单合计 (Vant SubmitBar 提交订单栏)
10.1 提交订单页
实现步骤:
- 配置路由规则,新建组件
- 查找Vant中可以使用的组件
- 实现组件模板部分
- 模拟数据,实现效果
10.2 订单支付页面
拆分
顶部导航 (Vant navbar)
订单号 (Vant中cell表格)
门票 (使用flex布局)
确认按钮(Vant SubmitBar 提交订单栏)
弹出的确认栏 (Vant中的Dialog 弹出框)
实现步骤:
- 配置路由规则,新建组件
- 查找Vant中可以使用的组件
- 实现组件模板部分
- 模拟数据,实现效果
10.3 我的订单列表页面开发
拆分:
顶部导航
订单状态tab切换(Vant中可找到tab)
十一、订单模块ORM模型设计
订单表
class Order(CommonModel):
""" 订单 """
sn = models.CharField('订单编号', max_length=32)
user = models.ForeignKey(User, related_name='orders', on_delete=models.PROTECT)
buy_count = models.IntegerField('购买数量', default=1)
buy_amount = models.FloatField('总价')
to_user = models.CharField('收货人', max_length=32)
to_area = models.CharField('省市区', max_length=32, default='')
to_address = models.CharField('详细地址', max_length=256, default='')
to_phone = models.CharField('手机号码', max_length=32)
remark = models.CharField('备注', max_length=255, null=True, blank=True)
# 快递信息
express_type = models.CharField('快递', max_length=32, null=True, blank=True)
express_no = models.CharField('单号', max_length=32, null=True, blank=True)
status = models.SmallIntegerField('订单状态',
choices=OrderStatus.choices,
default=OrderStatus.SUBMIT)
types = models.SmallIntegerField('订单类型',
choices=OrderTypes.choices,
default=OrderTypes.SIGHT_TICKET)
class Meta:
db_table = 'order'
订单明细表
class OrderItem(CommonModel):
""" 订单明细 """
user = models.ForeignKey(User, related_name='order_items',
on_delete=models.CASCADE)
order = models.ForeignKey(Order, verbose_name='订单',
related_name='order_items',
null=True,
on_delete=models.CASCADE)
# 商品快照
flash_name = models.CharField('商品名称', max_length=128)
flash_img = models.ImageField('商品的主图')
flash_price = models.FloatField('兑换价格')
flash_origin_price = models.FloatField('原价')
flash_discount = models.FloatField('折扣')
count = models.PositiveIntegerField('购买数量')
amount = models.FloatField('总额')
status = models.SmallIntegerField('状态',
choices=OrderStatus.choices,
default=OrderStatus.SUBMIT)
remark = models.CharField('备注', max_length=255, null=True, blank=True)
# 复合关联
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
class Meta:
db_table = 'order_item'
支付凭证
class Payment(CommonModel):
""" 支付凭证 """
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='payments')
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='payments')
amount = models.FloatField('金额', help_text='支付实际的金额')
sn = models.CharField('流水号', max_length=32)
third_sn = models.CharField('第三方订单号', max_length=128, null=True, blank=True)
status = models.SmallIntegerField('支付状态', default=1)
meta = models.CharField('其他数据', max_length=128, null=True, blank=True)
remark = models.CharField('备注信息', max_length=128, null=True, blank=True)
class Meta:
db_table = 'order_payment'
11.1 门票下单接口开发
开发步骤:
- 设计接口返回内容及字段
- 编写接口代码
- 模拟HTTP请求,测试验证接口
1.判断用户是否已经登录
2.获取post数据
3.数据的验证(手机号,门票ID,库存)
4.关联用户,生成订单号,计算购买总价,生成订单
5.返回内容:订单ID
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.db.models import F
from order.models import Order, OrderItem
from sight.models import Ticket
from utils import tools
class SubmitTicketForm(forms.ModelForm):
"""门票订单提交表单"""
ticket_id = forms.IntegerField(label='门票ID', required=True)
play_date = forms.DateField(label='出行的时间', required=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.ticket = None
class Meta:
model = Order
fields = ('to_user', 'to_phone', 'buy_count',)
def clean_ticket_id(self):
ticket_id = self.cleaned_data['ticket_id']
ticket = Ticket.objects.filter(is_valid=True, pk=ticket_id).first()
if ticket is None:
raise forms.ValidationError('门票信息不存在')
else:
if ticket.remain_stock <= 0:
raise forms.ValidationError('当日门票已售完')
self.ticket = ticket
return ticket_id
@transaction.atomic
def save(self, user, commit=False):
obj = super().save(commit=commit)
obj.user = user
obj.sn = tools.gen_trans_id()
# 计算价格
buy_count = self.cleaned_data['buy_count']
buy_amount = self.ticket.sell_price * buy_count
obj.buy_amount = buy_amount
obj.save()
# 扣减库存
self.ticket.remain_stock = F('remain_stock') - buy_count
self.ticket.save()
# 关联订单明细,保存快照
ctype = ContentType.objects.get_for_model(Ticket)
OrderItem.objects.create(
user=user,
order=obj,
flash_name=self.ticket.name,
flash_img=self.ticket.sight.main_img,
flash_price=self.ticket.sell_price,
flash_origin_price=self.ticket.price,
flash_discount=self.ticket.discount,
count=buy_count,
amount=buy_amount,
content_type=ctype,
object_id=self.ticket.id,
remark='出行时间:{}'.format(self.cleaned_data['play_date'])
)
return obj
method_decorator(login_required,name='dispatch')
class TicketOrderSubmitView(FormView):
"""门票订单提交接口"""
form_class = SubmitTicketForm
def form_invalid(self, form):
err = json.loads(form.errors.as_json())
return BadrequestJsonResponse(err)
def form_valid(self, form):
obj = form.save(user=self.request.user)
return http.JsonResponse({
'sn':obj.sn
},status=201)
11.2 订单详情接口开发
开发步骤:
设计接口返回内容及字段
编写接口代码
模拟HTTP请求,测试验证接口
使用BaseDetailView来写视图函数,使用sn号来查询订单的详情
class OrderDetail(BaseDetailView):
slug_field = 'sn'
slug_url_kwarg = 'sn'
def get_queryset(self):
user = self.request.user
obj = Order.objects.filter(user=user,is_valid=True)
return obj
def get(self,request,*args,**kwargs):
order_obj = self.get_object()
data = serializers.OrderDetailSerializer(order_obj).to_dict()
return http.JsonResponse(data)
class OrderItemSerializer(BaseSerializer):
"""订单明细"""
def to_dict(self):
obj = self.obj
return {
'pk':obj.pk,
'flash_img':obj.flash_name,
'flash_name':obj.flash_img.url,
'flash_price':obj.flash_price,
'flash_origin_price':obj.flash_origin_price,
'flash_discount':obj.flash_discount,
'count':obj.count,
'amount':obj.amount,
'object_id':obj.object_id,
'app_label':obj.content_type.app_label,
'model':obj.content_type.model
}
class OrderDetailSerializer(BaseSerializer):
def to_dict(self):
obj = self.obj
items=[]
for item in obj.order_items.all():
items.append(OrderItemSerializer(item).to_dict())
return {
'sn':obj.sn,
'buy_count':obj.buy_count,
'buy_amount':obj.buy_count,
'types':obj.types,
'status':obj.status,
'created_at':obj.created_at,
'remark':obj.remark,
'to_user':obj.to_user,
'to_area':obj.to_area,
'to_address':obj.to_address,
'to_phone':obj.to_phone,
'express_type':obj.express_type,
'express_no':obj.express_no,
'items':items
}
11.3 门票支付接口开发
开发步骤:
设计接口返回内容及字段
编写接口代码
模拟HTTP请求,测试验证接口
使用面向对象方式,dispatch进行分发,根据不同的请求方式,执行不同的逻辑
@method_decorator(login_required, name='dispatch')
class OrderDetail(BaseDetailView):
slug_field = 'sn'
slug_url_kwarg = 'sn'
def get_queryset(self):
user = self.request.user
obj = Order.objects.filter(user=user, is_valid=True)
return obj
def get(self, request, *args, **kwargs):
order_obj = self.get_object()
data = serializers.OrderDetailSerializer(order_obj).to_dict()
return http.JsonResponse(data)
@transaction.atomic
def post(self, request, *args, **kwargs):
"""订单支付"""
# 选择支付方式
# 数据验证
# 完成支付,改变订单状态
order_obj = self.get_object()
if order_obj.status == choices.OrderStatus.SUBMIT:
order_obj.status = choices.OrderStatus.PAID
order_obj.save()
order_obj.order_items.update(status=choices.OrderStatus.PAID)
return http.JsonResponse('', status=201, safe=False)
return http.JsonResponse('', status=200, safe=False)
11.4 取消订单接口开发
开发步骤:
设计接口返回内容及字段
编写接口代码
模拟HTTP请求,测试验证接口
@transaction.atomic
def put(self, request, *args, **kwargs):
"""取消订单"""
# 获取订单对象
# 数据验证
order_obj = self.get_object()
if order_obj.status == choices.OrderStatus.SUBMIT:
# 改变状态
order_obj.status = choices.OrderStatus.CANCELED
order_obj.save()
# 改变状态
items = order_obj.order_items.filter(status=choices.OrderStatus.SUBMIT)
# 加回已经扣减的库存
for item in items:
obj = item.content_object
obj.remain_stock = F('remain_stock') + item.count
obj.save()
items.update(status=choices.OrderStatus.CANCELED)
return http.JsonResponse('', status=201, safe=False)
return http.JsonResponse('', 200, safe=False)
11.5 删除订单接口开发
开发步骤:
设计接口返回内容及字段
编写接口代码
模拟HTTP请求,测试验证接口
def delete(self,request,*args,**kwargs):
"""删除订单"""
# 获取订单对象
# 验证数据(已支付,已取消)
# 是否已经删除过了
order_obj = self.get_object()
if order_obj.status == choices.OrderStatus.PAID or order_obj.status == choices.OrderStatus.CANCELED:
if order_obj.is_valid:
order_obj.is_valid = False
order_obj.save()
return http.JsonResponse('',status=201,safe=False)
return http.JsonResponse('',status=200,safe=False)
11.6 我的订单列表接口开发
开发步骤:
设计接口返回内容及字段
编写接口代码
模拟HTTP请求,测试验证接口
视图中继承ListView,
1.获取当前用户
2.查询当前用户,is_valid=True和订单状态(前台穿过来)查询
class OrderListView(ListView):
paginate_by = 5
def get_queryset(self):
# 获取用户
user = self.request.user
# 获取前台传过来的status
status = self.request.GET.get('status',None)
query = Q(user=user,is_valid=True)
if status and status !='0':
query = query & Q(status=status)
# 在Order中查询
return Order.objects.filter(query)
def render_to_response(self, context, **response_kwargs):
page_obj = context['page_obj']
if page_obj:
data = serializers.OrderListSerializer(page_obj).to_dict()
return http.JsonResponse(data)
return NotFoundJsonResponse()
def get_paginate_by(self, queryset):
"""根据接口参数limit来控制分页大小"""
page_size = self.request.GET.get('limit',None)
return page_size or self.paginate_by
11.7 门票详细信息接口开发
开发步骤:
设计接口返回内容及字段
编写接口代码
模拟HTTP请求,测试验证接口
class TicketDetailView(DetailView):
"""门票列表"""
def get_queryset(self):
return Ticket.objects.filter(is_valid=True)
def render_to_response(self, context, **response_kwargs):
page_obj = context['object']
if page_obj:
data = serializers.TicketDetailSerializer(page_obj).to_dict()
return http.JsonResponse(data)
return NotFoundJsonResponse()
十二、门票接口联调
12.1 门票下单接口联调
实现步骤
- 完成页面间的跳转
- 配置接口地址及请求参数
- 处理数据返回
12.2 门票详细信息接口联调
实现步骤:
- 阅读接口文档
- 配置接口地址
- 使用axios获取数据
- 将数据设置到模型层
12.3 门票支付接口联调
实现步骤:
- 阅读接口文档
- 配置接口地址
- 使用axios获取数据
- 将数据设置到模型层
用到两个接口,订单支付,订单信息接口
12.4 我的订单列表接口联调
- 完成页面间的跳转
- 配置接口地址及请求参数
- 处理数据返回
- 路由切换后的数据处理
12.4 取消订单接口联调
- 完成页面间的跳转
- 配置接口地址及请求参数
- 处理数据返回
12.5 删除订单接口联调
- 阅读接口文档
- 配置接口地址
- 使用axios获取数据
- 将数据设置到模型层
删除后的订单要在页面消失,在删除的ajax中将item.sn设为控制,再使用v-show将item.sn绑定,以为删除的该条目的sn为空值,所以为false,会在页面中隐藏