赞
踩
一直想做一款旅游景区管理系统,看了很多优秀的开源项目但是发现没有合适的。于是利用空闲休息时间开始自己写了一套管理系统。学习过程中遇到问题可以咨询留言。
https://github.com/geeeeeeeek/python_travel
系统分为后台和前台两部分。
后台的主要功能:
前台的主要功能:
Django是一款基于Python开发的全栈式一体化Web应用框架。2003年问世之初,它只是美国一家报社的内部工具,2005年7月使用BSD许可证完成了开源。Django采用MTV设计模式,即Model(模型)+ Template(模板)+ View(视图)。它遵循MVC设计,并且内置了对象关系映射(ORM)层,使得开发者无需关心底层的数据存取细节,可以更专注于业务逻辑的开发。
Django的目的是削减代码量,简单且迅速地搭建以数据库为主体的复杂Web站点。它是全栈式框架,因此安装起来很简单,而且使用者众多。这使得Django除具有完备的官方文档之外,还有大量的关联文档、丰富的第三方库可供使用。与其他框架相比,Django用起来要轻松得多。
优点:
(1) 安装mysql数据库,启动服务
(2) 打开cmd命令行,进入mysql,并新建数据库
mysql -u root -p
CREATE DATABASE IF NOT EXISTS python_travel DEFAULT CHARSET utf8 COLLATE utf8_general_ci;
(3) 恢复sql数据
use xxx
source xxxx.sql
(4) 修改settings.py中的配置信息
(5) 安装python 3.8
(6) 安装依赖包
pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple
(7) 运行命令
python manage.py runserver
(1) 安装node 16
(2) cmd进入web目录下,安装依赖,执行:
npm install
(3) 运行项目
npm run dev
server ├── myapp // 主应用 │ └── auth // 认证管理 │ └── middlewares // 中间件 │ └── permission // 权限 │ └── views // 视图与接口(主要业务代码) │ └── models.py // 状态码 │ └── serializers.py // 状态码 │ └── urls.py // 状态码 │ └── utils.py // 状态码 ├── entity // 实体类 ├── interceptor // 拦截器 ├── mapper // 数据库映射 ├── server // 配置目录 ├── upload // 静态资源目录 ├── requiements.txt // 依赖项
├── build // 构建相关 ├── public // 公共文件 │ ├── favicon.ico // favicon图标 │ └── index.html // html模板 ├── src // 源代码 │ ├── api // 所有请求 │ ├── assets // 主题 字体等静态资源 │ ├── router // 路由 │ ├── store // 全局 store管理 │ ├── utils // 全局公用方法 │ ├── views // view界面 │ ├── App.vue // 入口页面 │ ├── main.js // 入口 加载组件 初始化等 │ └── settings.js // 系统配置 ├── .eslintignore // 忽略语法检查 ├── .eslintrc.js // eslint 配置项 ├── .gitignore // git 忽略项 ├── babel.config.js // babel.config.js ├── package.json // package.json └── vite.config.js // vue配置
在景区管理系统中,需要存储和管理景区信息、评论信息、分类信息、标签信息、用户信息、通知信息、日志信息。
实体设计如下:
关系如下:
数据表设计如下:
// 景区 Table thing { thing_id int [pk] classification_id int [ref: > C.classification_id] tag_id int [ref: <> tag.tag_id] title varchar cover varchar price varchar address varchar // 地址 level varchar // 星级, 如:5A、4A、3A、2A status int // 上线0 下架1 repertory int score varchar description text create_time datetime pv int wish_count int recommend_count int wish int [ref: <> user.user_id] collect int [ref: <> user.user_id] } // 分类 Table classification as C { classification_id int [pk] pid int title varchar create_time datetime } // 标签 Table tag { tag_id int [pk] title varchar create_time datetime } // 评论 Table comment { comment_id int [pk] content varchar user_id int [ref: > user.user_id] thing_id int [ref: > thing.thing_id] comment_time datetime like_count int } // 用户 Table user { user_id int [pk] role varchar // 1管理员 2普通用户 3演示帐号 status int // 0正常 1封号 username varchar password varchar nickname varchar avatar varchar description varchar wish int [ref: <> thing.thing_id] email varchar mobile varchar score int // 积分 push_email varchar // 推送邮箱 push_switch int // 推送开关 token varchar admin_token varchar } // 登录日志 Table login_log { log_id int [pk] username varchar ip varchar log_time datetime } // 操作日志 Table op_log { id int [pk] re_ip varchar re_time datetime re_url varchar re_method varchar re_content varchar access_time varchar } // 异常日志 Table error_log { id int [pk] ip varchar method varchar content varchar log_time varchar } // 订单 Table order { order_id int [pk] user_id int [ref: > user.user_id] thing_id int [ref: > thing.thing_id] status varchar // 1未支付 2已支付 7订单取消 create_time datetime pay_time datetime // 支付时间 receiver_name varchar // 收货人姓名 receiver_address varchar // 地址 receiver_phone varchar // 收货人电话 remark varchar // 备注 } // 通知 Table notice { id int [pk] content varchar create_time datetime }
无论是景区管理、用户管理、标签管理、分类管理、评价管理、日志管理、消息管理等功能都是基于springboot+vue框架开发的,开发流程是:
下面用景区管理功能来演绎这个流程,其它的管理功能都是这个流程。
第一步:编写实体类
在server下的myapp下的models.py下面新建Thing类。并写入如下代码:
class Thing(models.Model): STATUS_CHOICES = ( ('0', '上架'), ('1', '下架'), ) id = models.BigAutoField(primary_key=True) classification = models.ForeignKey(Classification, on_delete=models.CASCADE, blank=True, null=True, related_name='classification_thing') tag = models.ManyToManyField(Tag, blank=True) title = models.CharField(max_length=100, blank=True, null=True) cover = models.ImageField(upload_to='cover/', null=True) description = models.TextField(max_length=1000, blank=True, null=True) price = models.CharField(max_length=50, blank=True, null=True) mobile = models.CharField(max_length=50, blank=True, null=True) age = models.CharField(max_length=10, blank=True, null=True) location = models.CharField(max_length=50, blank=True, null=True) status = models.CharField(max_length=1, choices=STATUS_CHOICES, default='0') create_time = models.DateTimeField(auto_now_add=True, null=True) pv = models.IntegerField(default=0) recommend_count = models.IntegerField(default=0) wish = models.ManyToManyField(User, blank=True, related_name="wish_things") wish_count = models.IntegerField(default=0) collect = models.ManyToManyField(User, blank=True, related_name="collect_things") collect_count = models.IntegerField(default=0) class Meta: db_table = "b_thing"
第二步:编写序列化层
在server下的myapp下的serializers.py下新建ThingSerializer类,并写入代码:
class ThingSerializer(serializers.ModelSerializer):
# 额外字段
classification_title = serializers.ReadOnlyField(source='classification.title')
class Meta:
model = Thing
fields = '__all__'
第三步:编写views层
在server的myapp下的views下,新建Thing.py代码,并写入代码,实现增删改查
# 查 @api_view(['GET']) def list_api(request): if request.method == 'GET': keyword = request.GET.get("keyword", None) c = request.GET.get("c", None) tag = request.GET.get("tag", None) if keyword: things = Thing.objects.filter(title__contains=keyword).order_by('create_time') elif c: classification = Classification.objects.get(pk=c) things = classification.classification_thing.all() elif tag: tag = Tag.objects.get(id=tag) print(tag) things = tag.thing_set.all() else: things = Thing.objects.all().order_by('create_time') serializer = ThingSerializer(things, many=True) return APIResponse(code=0, msg='查询成功', data=serializer.data) # 删 @api_view(['GET']) def detail(request): try: pk = request.GET.get('id', -1) thing = Thing.objects.get(pk=pk) except Thing.DoesNotExist: utils.log_error(request, '对象不存在') return APIResponse(code=1, msg='对象不存在') if request.method == 'GET': serializer = ThingSerializer(thing) return APIResponse(code=0, msg='查询成功', data=serializer.data) # 增 @api_view(['POST']) @authentication_classes([AdminTokenAuthtication]) def create(request): if isDemoAdminUser(request): return APIResponse(code=1, msg='演示帐号无法操作') serializer = ThingSerializer(data=request.data) if serializer.is_valid(): serializer.save() return APIResponse(code=0, msg='创建成功', data=serializer.data) else: print(serializer.errors) utils.log_error(request, '参数错误') return APIResponse(code=1, msg='创建失败') # 改 @api_view(['POST']) @authentication_classes([AdminTokenAuthtication]) def update(request): if isDemoAdminUser(request): return APIResponse(code=1, msg='演示帐号无法操作') try: pk = request.GET.get('id', -1) thing = Thing.objects.get(pk=pk) except Thing.DoesNotExist: return APIResponse(code=1, msg='对象不存在') serializer = UpdateThingSerializer(thing, data=request.data) if serializer.is_valid(): serializer.save() return APIResponse(code=0, msg='查询成功', data=serializer.data) else: print(serializer.errors) utils.log_error(request, '参数错误') return APIResponse(code=1, msg='更新失败') # 删 @api_view(['POST']) @authentication_classes([AdminTokenAuthtication]) def delete(request): if isDemoAdminUser(request): return APIResponse(code=1, msg='演示帐号无法操作') try: ids = request.GET.get('ids') ids_arr = ids.split(',') Thing.objects.filter(id__in=ids_arr).delete() except Thing.DoesNotExist: return APIResponse(code=1, msg='对象不存在') return APIResponse(code=0, msg='删除成功')
然后将该接口添加到urls.py中即可。
第四步:编写界面和API
打开前端web工程,在views文件夹下新建thing.vue文件,并编写代码:
<template> <div> <!--页面区域--> <div class="page-view"> <div class="table-operations"> <a-space> <a-button type="primary" @click="handleAdd">新增</a-button> <a-button @click="handleBatchDelete">批量删除</a-button> <a-input-search addon-before="名称" enter-button @search="onSearch" @change="onSearchChange" /> </a-space> </div> <a-table size="middle" rowKey="id" :loading="data.loading" :columns="columns" :data-source="data.dataList" :scroll="{ x: 'max-content' }" :row-selection="rowSelection" :pagination="{ size: 'default', current: data.page, pageSize: data.pageSize, onChange: (current) => (data.page = current), showSizeChanger: false, showTotal: (total) => `共${total}条数据`, }" > <template #bodyCell="{ text, record, index, column }"> <template v-if="column.key === 'operation'"> <span> <a @click="handleEdit(record)">编辑</a> <a-divider type="vertical" /> <a-popconfirm title="确定删除?" ok-text="是" cancel-text="否" @confirm="confirmDelete(record)"> <a href="#">删除</a> </a-popconfirm> </span> </template> </template> </a-table> </div> <!--弹窗区域--> <div> <a-modal :visible="modal.visile" :forceRender="true" :title="modal.title" width="880px" ok-text="确认" cancel-text="取消" @cancel="handleCancel" @ok="handleOk" > <div> <a-form ref="myform" :label-col="{ style: { width: '80px' } }" :model="modal.form" :rules="modal.rules"> <a-row :gutter="24"> <a-col span="24"> <a-form-item label="景区名称" name="title"> <a-input placeholder="请输入" v-model:value="modal.form.title"></a-input> </a-form-item> </a-col> <a-col span="12"> <a-form-item label="分类" name="classification"> <a-select placeholder="请选择" allowClear :options="modal.cData" :field-names="{ label: 'title', value: 'id',}" v-model:value="modal.form.classification"> </a-select> </a-form-item> </a-col> <a-col span="12"> <a-form-item label="地区"> <a-select mode="multiple" placeholder="请选择" allowClear v-model:value="modal.form.tag"> <template v-for="item in modal.tagData"> <a-select-option :value="item.id">{{item.title}}</a-select-option> </template> </a-select> </a-form-item> </a-col> <a-col span="24"> <a-form-item label="封面"> <a-upload-dragger name="file" accept="image/*" :multiple="false" :before-upload="beforeUpload" v-model:file-list="fileList" > <p class="ant-upload-drag-icon"> <template v-if="modal.form.coverUrl"> <img :src="modal.form.coverUrl" style="width: 60px;height: 80px;"/> </template> <template v-else> <file-image-outlined /> </template> </p> <p class="ant-upload-text"> 请选择要上传的封面图片 </p> </a-upload-dragger> </a-form-item> </a-col> <a-col span="24"> <a-form-item label="内容简介"> <a-textarea placeholder="请输入" v-model:value="modal.form.description"></a-textarea> </a-form-item> </a-col> <a-col span="12"> <a-form-item label="门票定价" name="price"> <a-input-number placeholder="请输入" :min="0" v-model:value="modal.form.price" style="width: 100%;"></a-input-number> </a-form-item> </a-col> <a-col span="12"> <a-form-item label="地址"> <a-input placeholder="请输入" v-model:value="modal.form.address" style="width: 100%;"></a-input> </a-form-item> </a-col> <a-col span="12"> <a-form-item label="等级"> <a-select placeholder="请选择" allowClear v-model:value="modal.form.level"> <a-select-option key="5A" value="5A">5A</a-select-option> <a-select-option key="4A" value="4A">4A</a-select-option> <a-select-option key="3A" value="3A">3A</a-select-option> <a-select-option key="2A" value="2A">2A</a-select-option> </a-select> </a-form-item> </a-col> <a-col span="12"> <a-form-item label="状态" name="status"> <a-select placeholder="请选择" allowClear v-model:value="modal.form.status"> <a-select-option key="0" value="0">上架</a-select-option> <a-select-option key="1" value="1">下架</a-select-option> </a-select> </a-form-item> </a-col> </a-row> </a-form> </div> </a-modal> </div> </div> </template> <script setup lang="ts"> import { FormInstance, message, SelectProps } from 'ant-design-vue'; import { createApi, listApi, updateApi, deleteApi } from '/@/api/admin/thing'; import {listApi as listClassificationApi} from '/@/api/admin/classification' import {listApi as listTagApi} from '/@/api/admin/tag' import {BASE_URL} from "/@/store/constants"; import { FileImageOutlined } from '@ant-design/icons-vue'; const columns = reactive([ { title: '序号', dataIndex: 'index', key: 'index', width: 60 }, { title: '名称', dataIndex: 'title', key: 'title' }, { title: '门票价格', dataIndex: 'price', key: 'price' }, { title: '地址', dataIndex: 'address', key: 'address' }, { title: '等级', dataIndex: 'level', key: 'level' }, { title: '简介', dataIndex: 'description', key: 'description', customRender: ({ text, record, index, column }) => text ? text.substring(0, 10) + '...' : '--' }, { title: '状态', dataIndex: 'status', key: 'status', customRender: ({ text, record, index, column }) => text === '0' ? '上架' : '下架' }, { title: '操作', dataIndex: 'action', key: 'operation', align: 'center', fixed: 'right', width: 140, }, ]); const beforeUpload = (file: File) => { // 改文件名 const fileName = new Date().getTime().toString() + '.' + file.type.substring(6); const copyFile = new File([file], fileName); console.log(copyFile); modal.form.imageFile = copyFile; return false; }; // 文件列表 const fileList = ref<any[]>([]); // 页面数据 const data = reactive({ dataList: [], loading: false, keyword: '', selectedRowKeys: [] as any[], pageSize: 10, page: 1, }); // 弹窗数据源 const modal = reactive({ visile: false, editFlag: false, title: '', cData: [], tagData: [{}], form: { id: undefined, title: undefined, classification: undefined, tag: [], repertory: undefined, price: undefined, address:undefined, level:undefined, status: undefined, cover: undefined, coverUrl: undefined, imageFile: undefined }, rules: { title: [{ required: true, message: '请输入名称', trigger: 'change' }], classification: [{ required: true, message: '请选择分类', trigger: 'change' }], price: [{ required: true, message: '请输入定价', trigger: 'change' }], status: [{ required: true, message: '请选择状态', trigger: 'change' }] }, }); const myform = ref<FormInstance>(); onMounted(() => { getDataList(); getCDataList(); getTagDataList(); }); const getDataList = () => { data.loading = true; listApi({ keyword: data.keyword, }) .then((res) => { data.loading = false; console.log(res); res.data.forEach((item: any, index: any) => { item.index = index + 1; }); data.dataList = res.data; }) .catch((err) => { data.loading = false; console.log(err); }); } const getCDataList = () => { listClassificationApi({}).then(res => { modal.cData = res.data }) } const getTagDataList = ()=> { listTagApi({}).then(res => { res.data.forEach((item, index) => { item.index = index + 1 }) modal.tagData = res.data }) } const onSearchChange = (e: Event) => { data.keyword = e?.target?.value; console.log(data.keyword); }; const onSearch = () => { getDataList(); }; const rowSelection = ref({ onChange: (selectedRowKeys: (string | number)[], selectedRows: DataItem[]) => { console.log(`selectedRowKeys: ${selectedRowKeys}`, 'selectedRows: ', selectedRows); data.selectedRowKeys = selectedRowKeys; }, }); const handleAdd = () => { resetModal(); modal.visile = true; modal.editFlag = false; modal.title = '新增'; // 重置 for (const key in modal.form) { modal.form[key] = undefined; } modal.form.cover = undefined }; const handleEdit = (record: any) => { resetModal(); modal.visile = true; modal.editFlag = true; modal.title = '编辑'; // 重置 for (const key in modal.form) { modal.form[key] = undefined; } for (const key in record) { if(record[key]) { modal.form[key] = record[key]; } } if(modal.form.cover) { modal.form.coverUrl = BASE_URL + modal.form.cover modal.form.cover = undefined } }; const confirmDelete = (record: any) => { console.log('delete', record); deleteApi({ ids: record.id }) .then((res) => { getDataList(); }) .catch((err) => { message.error(err.msg || '操作失败'); }); }; const handleBatchDelete = () => { console.log(data.selectedRowKeys); if (data.selectedRowKeys.length <= 0) { console.log('hello'); message.warn('请勾选删除项'); return; } deleteApi({ ids: data.selectedRowKeys.join(',') }) .then((res) => { message.success('删除成功'); data.selectedRowKeys = []; getDataList(); }) .catch((err) => { message.error(err.msg || '操作失败'); }); }; const handleOk = () => { myform.value ?.validate() .then(() => { const formData = new FormData(); if(modal.editFlag) { formData.append('id', modal.form.id) } formData.append('title', modal.form.title) if (modal.form.classification) { formData.append('classification', modal.form.classification) } if (modal.form.tag) { modal.form.tag.forEach(function (value) { if(value){ formData.append('tag', value) } }) } if (modal.form.imageFile) { formData.append('cover', modal.form.imageFile) } formData.append('description', modal.form.description || '') formData.append('price', modal.form.price || '') formData.append('address', modal.form.address || '') formData.append('level', modal.form.level || '') if (modal.form.status) { formData.append('status', modal.form.status) } if (modal.editFlag) { updateApi({ id: modal.form.id },formData) .then((res) => { hideModal(); getDataList(); }) .catch((err) => { console.log(err); message.error(err.msg || '操作失败'); }); } else { createApi(formData) .then((res) => { hideModal(); getDataList(); }) .catch((err) => { console.log(err); message.error(err.msg || '操作失败'); }); } }) .catch((err) => { console.log('不能为空'); }); }; const handleCancel = () => { hideModal(); }; // 恢复表单初始状态 const resetModal = () => { myform.value?.resetFields(); fileList.value = [] }; // 关闭弹窗 const hideModal = () => { modal.visile = false; }; </script> <style scoped lang="less"> .page-view { min-height: 100%; background: #fff; padding: 24px; display: flex; flex-direction: column; } .table-operations { margin-bottom: 16px; text-align: right; } .table-operations > button { margin-right: 8px; } </style>
这就是景区管理功能的实现流程,其它的功能管理实现一模一样的。按照这个流程编写即可。
基于ant-design框架的a-table的分页插件。
// 分页变量 const data = reactive({ dataList: [], loading: false, keyword: '', selectedRowKeys: [] as any[], pageSize: 10, page: 1, }); // 分页插件 :pagination="{ size: 'default', current: data.page, pageSize: data.pageSize, onChange: (current) => (data.page = current), showSizeChanger: false, showTotal: (total) => `共${total}条数据`, }"
前端的请求工具是基于axios开发的,位于utils的http文件夹中。封装了request请求和拦截器。
const service: AxiosInstance = axios.create({ // baseURL: import.meta.env.BASE_URL + '', baseURL: BASE_URL + '', timeout: 15000, }); // axios实例拦截请求 service.interceptors.request.use( (config: InternalAxiosRequestConfig) => { config.headers.ADMINTOKEN = localStorage.getItem(ADMIN_USER_TOKEN); config.headers.TOKEN = localStorage.getItem(USER_TOKEN); return config; }, (error: AxiosError) => { return Promise.reject(error); }, ); // axios实例拦截响应 service.interceptors.response.use( (response: AxiosResponse) => { if (response.status == 200) { if (response.data.code == 0 || response.data.code == 200) { return response; } else { return Promise.reject(response.data); } } else { return Promise.reject(response.data); } }, // 请求失败 (error: any) => { console.log(error.response.status); if (error.response.status == 404) { // todo } else if (error.response.status == 403) { // todo } return Promise.reject(error); }, );
权限控制使用了BaseAuthentication实现的,具体代码可参考authentication.py
from rest_framework import exceptions from rest_framework.authentication import BaseAuthentication from myapp.models import User # 接口认证 class AdminTokenAuthtication(BaseAuthentication): def authenticate(self, request): adminToken = request.META.get("HTTP_ADMINTOKEN") print("检查adminToken==>" + adminToken) users = User.objects.filter(admin_token=adminToken) """ 判定条件: 1. 传了adminToken 2. 查到了该帐号 3. 该帐号是管理员或演示帐号 """ if not adminToken or len(users) == 0 or users[0].role == '2': raise exceptions.AuthenticationFailed("AUTH_FAIL_END") else: print('adminToken验证通过')
前端的路由是基于vue-router框架实现的,路由文件位于src的rooter的root.js文件中。预览如下:
{ path: '/admin', name: 'admin', redirect: '/admin/thing', component: () => import('/@/views/admin/main.vue'), children: [ { path: 'overview', name: 'overview', component: () => import('/@/views/admin/overview.vue') }, { path: 'order', name: 'order', component: () => import('/@/views/admin/order.vue') }, { path: 'thing', name: 'thing', component: () => import('/@/views/admin/thing.vue') }, { path: 'comment', name: 'comment', component: () => import('/@/views/admin/comment.vue') }, { path: 'user', name: 'user', component: () => import('/@/views/admin/user.vue') }, { path: 'classification', name: 'classification', component: () => import('/@/views/admin/classification.vue') }, { path: 'tag', name: 'tag', component: () => import('/@/views/admin/tag.vue') }, { path: 'ad', name: 'ad', component: () => import('/@/views/admin/ad.vue') }, { path: 'notice', name: 'notice', component: () => import('/@/views/admin/notice.vue') }, { path: 'loginLog', name: 'loginLog', component: () => import('/@/views/admin/login-log.vue') }, { path: 'opLog', name: 'opLog', component: () => import('/@/views/admin/op-log.vue') }, { path: 'errorLog', name: 'errorLog', component: () => import('/@/views/admin/error-log.vue') }, { path: 'sysInfo', name: 'sysInfo', component: () => import('/@/views/admin/sys-info.vue') }, ] },
限流(Throttle)就是限制客户端对API 的调用频率,是API开发者必须要考虑的因素。比如个别客户端(比如爬虫程序)短时间发起大量请求,超过了服务器能够处理的能力,将会影响其它用户的正常使用。又或者某个接口占用数据库资源比较多,如果同一时间该接口被大量调用,服务器可能会陷入僵死状态。为了保证API服务的稳定性,并防止接口受到恶意用户的攻击,我们必须要对我们的API服务进行限流。
我们使用了django的AnonRateThrottle限流类来实现的。可以参见myapp的auth目录下的MyRateThrottle.py文件
class MyRateThrottle(AnonRateThrottle):
THROTTLE_RATES = {"anon": "2/min"} # 限流每分钟只能请求2次
当某个api接口需要限流的时候,只需要添加注解即可,如下所示
@api_view(['POST'])
@throttle_classes([MyRateThrottle]) # 限流注解
def create(request):
serializer = CommentSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return APIResponse(code=0, msg='创建成功', data=serializer.data)
else:
print(serializer.errors)
return APIResponse(code=1, msg='创建失败')
需要mysql 5.7以上
使用国内镜像安装,设置命令为:
npm config set registry https://registry.npm.taobao.org
将用户的权限提高,修改b_user表的role字段
修改store文件夹下的constants.js文件中的BASE_URL,改成你自己的后端地址
在views文件夹下创建新的vue文件,写入界面代码,然后在router的root.js中添加路由即可。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。