Uric组件2022文档
预备知识点:
Python基础
Mysql
前端
Django
DRF组件+VUE3
一 前端项目初始化
1.1 客户端项目创建
我们使用的vue-cli脚手架作为我们前端开发使用的框架,下面看一下vue-cli的安装。
安装脚手架:
$ npm install -g @vue/cli
# OR
$ yarn global add @vue/cli
项目前端环境版本依赖
node的版本:v14.16.0以上
vue-cli需要的安装和运行需要借助到node.js的环境(换句话说也是js代码的解释器)
vue:3版本
@vue/cli 4.5.13
1.1.1 Node.js的安装
Node.js是一个服务端语言,它的语法和JavaScript类似,所以可以说它是属于前端的后端语言,后端语言和前端语言的区别:
-
运行环境:后端语言一般运行在服务器端,前端语言运行在客户端的浏览器上
-
功能:后端语言可以操作文件,可以读写数据库,前端语言不能操作文件,不能读写数据库。
我们一般安装LTS(长线支持版本):
下载地址:https://nodejs.org/en/download/【上面已经安装了nvm,那么这里不用手动安装了】
下载之后双击安装,一路点击下一步就可以了。
mac安装:
node.js的版本有两大分支:
官方发布的node.js版本:0.xx.xx 这种版本号就是官方发布的版本
社区发布的node.js版本:xx.xx.x 就是社区开发的版本
Node.js如果安装成功,可以查看Node.js的版本,在终端输入如下命令:
node -v
npm -v #pip
在安装node.js完成后,在node.js中会同时帮我们安装一个包管理器npm。我们可以借助npm命令来安装node.js的第三方包。这个工具相当于python的pip管理器,php的composer,go语言的go get,java的maven。
1.1.2 npm
常用指令
npm install -g 包名 # 安装模块 -g表示全局安装,如果没有-g,则表示在当前项目跟母下.node_modules下保存
npm list # 查看当前目录下已安装的node包
npm view 包名 engines # 查看包所依赖的Node的版本
npm outdated # 检查包是否已经过时,命令会列出所有已过时的包
npm update 包名 # 更新node包
npm uninstall 包名 # 卸载node包
npm 命令 -h # 查看指定命令的帮助文档
如果npm大家觉得速度比较慢,可以安装cnpm来进行国内包源的下载
cnpm介绍
- 说明:因为谷歌安装插件是从国外服务器下载,受网络影响大,可能出现异常,如果谷歌的服务器在中国就好了,所以我们乐于分享的淘宝团队干了这事来自官网:“这是一个完整npmjs.org镜像,你可以用此代替官方版本(只读),同步频率目前为10分钟一次以保证尽量与官方服务同步“。
- 官方网址:http://npm.taobao.org
- 安装:命令提示符执行
npm install cnpm -g --registry=https://registry.npm.taobao.org
- 注意:安装完后最好查看其版本cnpm -v或关闭命令提示符重新打开,安装完直接使用有可能会出现错误
//临时使用
npm install jquery --registry https://registry.npm.taobao.org
//可以把这个选型配置到文件中,这样不用每一次都很麻烦
npm config set registry https://registry.npm.taobao.org
//验证是否配置成功
npm config list 或者 npm config get registry
//安装cnpm,在任意目录下都可执行,--global是全局安装,不可省略
npm install --global cnpm 或者 npm install -g cnpm --registry=https://registry.npm.taobao.org
//安装后直接使用
cnpm install jquery
说明:NPM(节点包管理器)是的NodeJS的包管理器,用于节点插件管理(包括安装,卸载,管理依赖等)
- 使用NPM安装插件:命令提示符执行
npm install <name> [-g] [--save-dev]
<name>
:节点插件名称。
例:npm install gulp-less --save-dev
-g
:全局安装。 将会安装在C:\ Users \ Administrator \ AppData \ Roaming \ npm,并且写入系统环境变量;非全局安装:将会安装在当前定位目录;全局安装可以通过命令行任何地方调用它,本地安装将安装在定位目录的node_modules文件夹下,通过要求()调用;--save
:将保存至的package.json(的package.json是的NodeJS项目配置文件)-dev
:保存至的package.json的devDependencies节点,不指定-dev将保存至依赖节点
为什么要保存至的的package.json?因为节点插件包相对来说非常庞大,所以不加入版本管理,将配置信息写入的package.json并将其加入版本管理,其他开发者对应下载即可(命令提示符执行npm install,则会根据package.json下载所有需要的包)
1.1.3 vue-cli创建项目
创建项目
使用vue自动化工具可以快速搭建单页应用项目目录。
该工具为现代化的前端开发工作流提供了开箱即用的构建配置。只需几分钟即可创建并启动一个带热重载、保存时静态检查以及可用于生产环境的构建配置的项目:
vue create uric_web
// 启动开发服务器 ctrl+c 停止服务
cd uric_web
npm run serve // 运行这个命令就可以启动node提供的测试http服务器
// 那么访问一下命令执行完成后提示出来的网址就可以看到网站了:http://localhost:8080/
项目创建完成之后,我们会看到urilsweb项目其实是一个文件夹,我们进入到文件夹内部就会发现一些目录和文件,我们简单介绍一下它们都是干什么的
项目目录结构介绍
核心文件和目录
src/ 主开发目录,要开发的客户端代码文件(单文件组件,样式、工具函数等等)全部在这个目录下
static/ 静态资源目录,项目中的静态资源(css,js,图片等文件)放在这个文件夹
dist/ 项目打包发布文件夹,目前没有这个文件夹,最后要上线单文件项目文件都在这个文件夹中
后面使用npm build 打包项目,让项目中的vue组件经过编译变成js 代码以后,dist就出现了
node_modules/ node的包目录,项目运行的依赖包存储目录,
package.json和package-lock.json文件中会自动记录了这个目录下所有的包以及包的版本信息,
如果node_modules没有,但是有package.json,则可以在终端下,通过npm install进行恢复。
config/ 配置目录,是环境配置目录与项目无关。
build/ 项目打包时依赖的目录
src/router/ 路由,是我们创建项目的时候,如果选择安装vue-router,就自动会生成这个目录。
src/assets/ 静态资源存储目录,与static目录作用类似。
src/components/ 组件存储目录,就是浏览器中用户看到的页面的一部分内容。
src/views/ 组件存储目录,就是浏览器中用户看到的页面内容,views往往会加载并包含components中的组件进来
目录结构详细介绍
项目执行流程图
1.1.4 展示中心组件
src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App).use(router).mount('#app')
src/App.vue
<template>
<router-view/>
</template>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
#nav {
padding: 30px;
}
#nav a {
font-weight: bold;
color: #2c3e50;
}
#nav a.router-link-exact-active {
color: #42b983;
}
</style>
router/index.js,代码:
import { createRouter, createWebHistory } from 'vue-router'
import ShowCenter from '../views/ShowCenter.vue'
const routes = [
{
path: '/',
name: 'ShowCenter',
component: ShowCenter
}
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
})
export default router
views/ShowCenter.vue
<template>
<div class="showcenter">
<h1>show center</h1>
</div>
</template>
<script>
export default {
name: 'ShowCenter',
}
</script>
访问http://localhost:8080 就看到了我们的展示中心页面。
1.1.5 调整配置
为了方便开发,我们做一些配置调整
Vue.config.js是一个可选的配置文件,如果项目的根目录存在这个文件,那么它就会被 @vue/cli-service
自动加载。你也可以使用package.json中的vue字段,但要注意严格遵守JSON的格式来写。这里使用配置vue.config.js的方式进行处理。
const {defineConfig} = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true,
devServer: {
host: "localhost",
/* 本地ip地址 */
//host: "192.168.0.131",
// host: "www.uric.cn", //局域网和本地访问
port: "8000",
// hot: true,
/* 自动打开浏览器 */
// open: false,
/*overlay: {
warning: false,
error: true
},*/
/* 跨域代理 */
/*proxy: {
"/api": {
/!* 目标代理服务器地址 *!/
target: "http://xxxx.top", //
/!* 允许跨域 *!/
changeOrigin: true,
ws: true,
pathRewrite: {
"^/api": ""
}
}
}*/
},
})
我们现在为前端和后端分别设置两个不同的域名:
位置 | 域名 |
---|---|
前端 | www.uric.cn |
后端 | api.uric.cn |
Linux/mac系统下执行指令
vi /etc/hosts
# windows下是C:/windows/system32/drivers/etc/hosts
加上如下内容
127.0.0.1 localhost
127.0.0.1 api.uric.cn
127.0.0.1 www.uric.cn
部分使用windows开发的同学,如果hosts修改保存不了,可以复制这个文件到桌面,修改完成以后,在粘贴到对应目录下。
在开发过程中,我们可能经常会在前端项目的业务里面使用到某些变量,我们可以添加到配置文件中,比如我们在src目录下创建一个settings.js文件
export default { // 注意,对象要抛出后,其他文件中才能引入使用
host:c. 'http://api.urils.cn:8000' // 我们的后台项目将来就通过这个域名和端口来启动
}
为了方便后面其他页面组件使用settings中的配置变量,我们在main.js文件中引入封装成vue对象的全局属性.
main.js,代码:
import {createApp} from 'vue'
import App from './App.vue'
import router from './router'
import settings from "@/settings";
const app = createApp(App)
app.use(router).mount('#app')
app.config.globalProperties.$settings = settings;
1.1.6 安装axios
后面我们需要在前端来获取后端的数据,意味着要发送请求,我们使用axios模块来进行http请求的发送,
它的特点和ajax一样:异步请求。
项目根目录下执行如下指令
npm install -S axios --registry https://registry.npm.taobao.org
ShowCenter
组件:
免费七日天气查询接口:https://www.tianqiapi.com/index/doc?version=week
上面网站需注册开发者账号网址:https://yikeapi.com/account
<template>
<div class="showcenter">
<h1>show center</h1>
</div>
</template>
<script>
import axios from 'axios'
export default {
name: 'ShowCenter',
mounted() {
console.log(this.$settings.host)
axios.get('https://www.yiketianqi.com/free/week?unescape=1&appid=88647137&appsecret=Y2gbJXXX&city=北京')
.then((response) => {
console.log("response:::", response.data.data.forecast)
})
}
}
</script>
接下来我们将展示中心页面写的好看一些。
我们当前前端项目使用的用于展示界面的前端插件是Ant Design,能够帮我们快速优雅的完成前端页面效果,下面介绍一下。
1.2 ant-design插件
介绍
Ant Design 是一个致力于提升『用户』和『设计者』使用体验的中台设计语言。它模糊了产品经理、交互设计师、视觉设计师、前端工程师、开发工程师等角色边界,将进行 UE 设计和 UI 设计人员统称为『设计者』,利用统一的规范进行设计赋能,全面提高中台产品体验和研发效率,是蚂蚁金服出品的开源框架。
Ant Design 官方介绍: "在中台产品的研发过程中,会出现不同的设计规范和实现方式,但其中往往存在很多类似的页面和组件,给设计师和工程师带来很多困扰和重复建设,大大降低了产品的研发效率。"
蚂蚁金服体验技术部经过大量的项目实践和总结,沉淀出设计语言 Ant Design,这可不单纯只是设计原则、控件规范和视觉尺寸,还配套有前端代码实现方案。也就是说采用Ant Design后,UI设计和前端界面研发可同步完成,效率大大提升。目前有阿里、美团、滴滴、简书采用。Ant Design有Web版和Moblie版。
如果前端这些插件都是我们通过js或者jquery手撸的话,工作量太重不说,效率还低。
Ant Design 则封装了一系列高质量的 React 组件,十分适用于在企业级的应用中,框架提供的 api 十分详尽,上手和使用相对简单,值得一提的是, Ant Design 使用 ES6 进行编写,因此使用过程中对 ES6 也是一次学习的机会。
我们现在学习的是vue框架和ant-desigin的兼容,那么已经有高手开源出了一套ant-design的vue实现,下面我们就来学习使用。
ant-desigin特点
- 专为Web应用程序设计的企业级UI。
- 开箱即用的一组高质量React组件。
- 用具有可预测的静态类型的TypeScript编写。
- 整套设计资源和开发工具。
- 支持数十种语言的国际化。
- 强大的主题自定义细节。
常用网址
官网:https://ant.design/,antdv工具的使用,全称 Ant Design of Vue。
官网地址:https://next.antdv.com/docs/vue/getting-started-cn
Ant Design of Vue的使用,我们在项目中学习。
安装上手
$ npm i --save ant-design-vue@next
注意:package.json中的
"ant-design-vue": "^3.2.7"
,一定是3以上版本
在main.js文件中引入
import {createApp} from 'vue'
import App from './App.vue'
import router from './router'
import Antd from 'ant-design-vue';
import 'ant-design-vue/dist/antd.css';
import './settings'
import settings from "@/settings";
const app = createApp(App)
app.use(router).use(Antd).mount('#app')
app.config.globalProperties.$settings = settings;
下面测试一下效果,我们在ShowCenter.vue组件中引入一个ant-design的button按钮,看看怎么样
<template>
<div class="showcenter">
<h1>show center</h1>
<a-button type="primary">Primary</a-button>
</div>
</template>
<script>
import axios from 'axios'
export default {
name: 'ShowCenter',
mounted() {
console.log(this.$settings.host)
axios.get('http://wthrcdn.etouch.cn/weather_mini?city=北京')
.then((response) => {
console.log("response:::", response.data.data.forecast)
})
}
}
</script>
好,效果有了。
中文支持
ShowCenter.vue展示中文日历,没有配置之前:
<template>
<div class="showcenter">
<h1>show center</h1>
<a-button type="primary">Primary</a-button>
<div :style="{ width: '300px', border: '1px solid #d9d9d9', borderRadius: '4px' }">
<a-calendar v-model:value="value" :fullscreen="false" @panelChange="onPanelChange"/>
</div>
</div>
</template>
<script>
import axios from 'axios'
import {ref} from 'vue';
export default {
name: 'ShowCenter',
setup() {
const value = ref();
const onPanelChange = (value, mode) => {
console.log(value, mode);
};
return {
value,
onPanelChange,
};
},
mounted() {
console.log(this.$settings.host)
axios.get('http://wthrcdn.etouch.cn/weather_mini?city=北京')
.then((response) => {
console.log("response:::", response.data.data.forecast)
})
}
}
</script>
ant-design-vue
目前的默认文案是英文。在使用某些插件(比如时间日期选择框等)的时候,需要我们来做中文支持,设置如下。
src/App.vue,代码:
<template>
<a-config-provider :locale="locale">
<div class="showcenter">
<h1>show center</h1>
<a-button type="primary">Primary</a-button>
<div :style="{ width: '300px', border: '1px solid #d9d9d9', borderRadius: '4px' }">
<a-calendar v-model:value="value" :fullscreen="false" @panelChange="onPanelChange"/>
</div>
</div>
</a-config-provider>
</template>
<script>
import axios from 'axios'
import {ref} from 'vue';
import zhCN from 'ant-design-vue/es/locale/zh_CN';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn');
export default {
name: 'ShowCenter',
data() {
return {
locale: zhCN,
};
},
setup() {
const value = ref();
const onPanelChange = (value, mode) => {
console.log(value, mode);
};
return {
value,
onPanelChange,
};
},
mounted() {
console.log(this.$settings.host)
axios.get('http://wthrcdn.etouch.cn/weather_mini?city=北京')
.then((response) => {
console.log("response:::", response.data.data.forecast)
})
}
}
</script>
需要安装一个包:
npm install dayjs
echarts图表插件
由于我们有很多的图表数据展示,而Ant-desigin中没有很优秀的图表插件,所以我们还需要借助其他开源插件,比如echarts和hcharts,我们本次采用的是百度开源的echarts,那么我们如何引入使用呢,看下面的步骤。
下载:
npm install echarts --save --registry=https://registry.npm.taobao.org
为了方便后面组件的使用,我们在src/main.js中引入一下
// import echarts from 'echarts'
let echarts = require('echarts')
app.config.globalProperties.$echarts = echarts
在ShowCenter.vue组件中简单使用
<template>
<h1>show center</h1>
<p>
<a-button type="primary">Primary</a-button>
</p>
<a-config-provider :locale="locale">
<div class="showcenter">
<div class="calendar" :style="{ width: '400px', border: '1px solid #d9d9d9', borderRadius: '4px'}">
<a-calendar v-model:value="value" :fullscreen="false" @panelChange="onPanelChange"/>
</div>
<div class="chart" ref="chart"></div>
</div>
</a-config-provider>
</template>
<script>
import axios from 'axios'
import {ref} from 'vue';
import zhCN from 'ant-design-vue/es/locale/zh_CN';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn');
export default {
name: 'ShowCenter',
data() {
return {
locale: zhCN,
};
},
methods: {
init_chart() {
// 基于准备好的dom,初始化echarts实例
let myChart = this.$echarts.init(this.$refs.chart)
// 绘制图表
let option = {
tooltip: {
trigger: 'axis',
axisPointer: {
// Use axis to trigger tooltip
type: 'shadow' // 'shadow' as default; can also be 'line' or 'shadow'
}
},
legend: {},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'value'
},
yAxis: {
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
},
series: [
{
name: 'Direct',
type: 'bar',
stack: 'total',
label: {
show: true
},
emphasis: {
focus: 'series'
},
data: [320, 302, 301, 334, 390, 330, 320]
},
{
name: 'Mail Ad',
type: 'bar',
stack: 'total',
label: {
show: true
},
emphasis: {
focus: 'series'
},
data: [120, 132, 101, 134, 90, 230, 210]
},
{
name: 'Affiliate Ad',
type: 'bar',
stack: 'total',
label: {
show: true
},
emphasis: {
focus: 'series'
},
data: [220, 182, 191, 234, 290, 330, 310]
},
{
name: 'Video Ad',
type: 'bar',
stack: 'total',
label: {
show: true
},
emphasis: {
focus: 'series'
},
data: [150, 212, 201, 154, 190, 330, 410]
},
{
name: 'Search Engine',
type: 'bar',
stack: 'total',
label: {
show: true
},
emphasis: {
focus: 'series'
},
data: [820, 832, 901, 934, 1290, 1330, 1320]
}
]
};
myChart.setOption(option);
},
},
setup() {
const value = ref();
const onPanelChange = (value, mode) => {
console.log(value, mode);
};
return {
value,
onPanelChange,
};
},
mounted() {
console.log(this.$settings.host)
axios.get('http://wthrcdn.etouch.cn/weather_mini?city=北京')
.then((response) => {
console.log("response:::", response.data.data.forecast)
})
this.init_chart();
}
}
</script>
<style>
.calendar, .chart {
width: 500px;
height: 500px;
float: left;
margin: 0 auto 0 100px;
}
</style>
改进组合API:
<template>
<h1>show center</h1>
<p>
<a-button type="primary">Primary</a-button>
</p>
<div class="chart" ref="chart"></div>
<div class="chart2" ref="chart2"></div>
</template>
<script setup>
import {onMounted, ref} from "vue";
import zhCN from 'ant-design-vue/es/locale/zh_CN';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import * as echarts from 'echarts';
dayjs.locale('zh-cn');
const locale = zhCN
// (1)
const value = ref();
const onPanelChange = (value, mode) => {
console.log(value, mode);
};
// (2)
let init_chart = () => {
var myChart = echarts.init(chart.value);
var option;
// 绘制图表
option = {
tooltip: {
trigger: 'axis',
axisPointer: {
// Use axis to trigger tooltip
type: 'shadow' // 'shadow' as default; can also be 'line' or 'shadow'
}
},
legend: {},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'value'
},
yAxis: {
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
},
series: [
{
name: 'Direct',
type: 'bar',
stack: 'total',
label: {
show: true
},
emphasis: {
focus: 'series'
},
data: [320, 302, 301, 334, 390, 330, 320]
},
{
name: 'Mail Ad',
type: 'bar',
stack: 'total',
label: {
show: true
},
emphasis: {
focus: 'series'
},
data: [120, 132, 101, 134, 90, 230, 210]
},
{
name: 'Affiliate Ad',
type: 'bar',
stack: 'total',
label: {
show: true
},
emphasis: {
focus: 'series'
},
data: [220, 182, 191, 234, 290, 330, 310]
},
{
name: 'Video Ad',
type: 'bar',
stack: 'total',
label: {
show: true
},
emphasis: {
focus: 'series'
},
data: [150, 212, 201, 154, 190, 330, 410]
},
{
name: 'Search Engine',
type: 'bar',
stack: 'total',
label: {
show: true
},
emphasis: {
focus: 'series'
},
data: [820, 832, 901, 934, 1290, 1330, 1320]
}
]
};
option && myChart.setOption(option);
}
const chart = ref();
onMounted(() => {
init_chart()
init_chart2()
});
// (3)
const chart2 = ref();
let init_chart2 = () => {
console.log(chart2.value)
var myChart = echarts.init(chart2.value);
var option;
option = {
tooltip: {
trigger: 'item'
},
legend: {
top: '5%',
left: 'center'
},
series: [
{
name: 'Access From',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: '40',
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: [
{value: 1048, name: 'Search Engine'},
{value: 735, name: 'Direct'},
{value: 580, name: 'Email'},
{value: 484, name: 'Union Ads'},
{value: 300, name: 'Video Ads'}
]
}
]
};
option && myChart.setOption(option);
}
</script>
<style>
.chart, .chart2 {
width: 500px;
height: 500px;
float: left;
margin: 0 auto 0 100px;
}
</style>
好,到此前端项目初始化完成。
1.3 组件初始化
登录组件初始化
views/Login.vue,代码
<template>
<div class="login box">
<img src="../assets/login.jpg" alt="">
<div class="login">
<div class="login-title">
<p class="hi">Hello,Urils!</p>
</div>
<div class="login_box">
<div class="title">
<span>登录</span>
</div>
<div class="inp">
<a-input v-model:value="username" type="text" placeholder="用户名" class="user"></a-input>
<a-input v-model:value="password" type="password" class="pwd" placeholder="密码"></a-input>
<div class="rember">
<p>
<input type="checkbox" class="no" v-model="remember"/>
<span>记住密码</span>
</p>
</div>
<button class="login_btn" @click="login">登录</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Login',
data() {
return {
username: '',
password: '',
remember: true
}
},
methods: {
login() {
}
}
}
</script>
<style scoped>
.login .hi{
font-size: 20px;
font-family: "Times New Roman";
font-style: italic;
}
.box {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.box img {
width: 100%;
min-height: 100%;
}
.box .login {
position: absolute;
width: 500px;
height: 400px;
left: 0;
margin: auto;
right: 0;
bottom: 0;
top: -338px;
}
.login .login-title {
width: 100%;
text-align: center;
}
.login-title img {
width: 190px;
height: auto;
}
.login-title p {
font-size: 18px;
color: #fff;
letter-spacing: .29px;
padding-top: 10px;
padding-bottom: 50px;
}
.login_box {
width: 400px;
height: auto;
background: rgba(255, 255, 255, 0.3);
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, .5);
border-radius: 4px;
margin: 0 auto;
padding-bottom: 40px;
}
.login_box .title {
font-size: 20px;
color: #9b9b9b;
letter-spacing: .32px;
border-bottom: 1px solid #e6e6e6;
display: flex;
justify-content: space-around;
padding: 50px 60px 0 60px;
margin-bottom: 20px;
cursor: pointer;
}
.login_box .title span:nth-of-type(1) {
color: #4a4a4a;
border-bottom: 2px solid #396fcc;
}
.inp {
width: 350px;
margin: 0 auto;
}
.inp input {
outline: 0;
width: 100%;
height: 45px;
border-radius: 4px;
border: 1px solid #d9d9d9;
text-indent: 20px;
font-size: 14px;
background: #fff !important;
}
.inp input.user {
margin-bottom: 16px;
}
.inp .rember {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
margin-top: 10px;
}
.inp .rember p:first-of-type {
font-size: 12px;
color: #4a4a4a;
letter-spacing: .19px;
margin-left: 22px;
display: -ms-flexbox;
display: flex;
-ms-flex-align: center;
align-items: center;
/*position: relative;*/
}
.inp .rember p:nth-of-type(2) {
font-size: 14px;
color: #9b9b9b;
letter-spacing: .19px;
cursor: pointer;
}
.inp .rember input {
outline: 0;
width: 30px;
height: 45px;
border-radius: 4px;
border: 1px solid #d9d9d9;
text-indent: 20px;
font-size: 14px;
background: #fff !important;
}
.inp .rember p span {
display: inline-block;
font-size: 12px;
width: 100px;
/*position: absolute;*/
/*left: 20px;*/
}
#geetest {
margin-top: 20px;
}
.login_btn {
width: 100%;
height: 45px;
background: #396fcc;
border-radius: 5px;
font-size: 16px;
color: #fff;
letter-spacing: .26px;
margin-top: 30px;
}
.inp .go_login {
text-align: center;
font-size: 14px;
color: #9b9b9b;
letter-spacing: .26px;
padding-top: 20px;
}
.inp .go_login span {
color: #84cc39;
cursor: pointer;
}
</style>
src/router/index.js,代码:
import { createRouter, createWebHistory } from 'vue-router'
import ShowCenter from '../views/ShowCenter.vue'
import Login from '../views/Login.vue'
const routes = [
{
path: '/',
name: 'ShowCenter',
component: ShowCenter
},
{
path: '/login',
name: 'Login',
component: Login
}
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
})
export default router
由于除了登录页面之外我们后面所有的组件都具备顶部导航栏和左侧菜单栏的效果,所以我直接将共有效果放到了一个Base.vue组件中。里面通过 ant design vue中的
布局组件:https://next.antdv.com/components/layout-cn
Base组件初始化
布局和导航菜单搭建App页面效果,简单如下
views/Base.vue,代码:
<template>
<a-layout style="min-height: 100vh">
<a-layout-sider v-model:collapsed="collapsed" collapsible>
<div class="logo" />
<a-menu v-model:selectedKeys="selectedKeys" theme="dark" mode="inline">
<a-menu-item key="1">
<pie-chart-outlined />
<span>Option 1</span>
</a-menu-item>
<a-menu-item key="2">
<desktop-outlined />
<span>Option 2</span>
</a-menu-item>
<a-sub-menu key="sub1">
<template #title>
<span>
<user-outlined />
<span>User</span>
</span>
</template>
<a-menu-item key="3">Tom</a-menu-item>
<a-menu-item key="4">Bill</a-menu-item>
<a-menu-item key="5">Alex</a-menu-item>
</a-sub-menu>
<a-sub-menu key="sub2">
<template #title>
<span>
<team-outlined />
<span>Team</span>
</span>
</template>
<a-menu-item key="6">Team 1</a-menu-item>
<a-menu-item key="8">Team 2</a-menu-item>
</a-sub-menu>
<a-menu-item key="9">
<file-outlined />
<span>File</span>
</a-menu-item>
</a-menu>
</a-layout-sider>
<a-layout>
<a-layout-header style="background: #fff; padding: 0" />
<a-layout-content style="margin: 0 16px">
<a-breadcrumb style="margin: 16px 0">
<a-breadcrumb-item>User</a-breadcrumb-item>
<a-breadcrumb-item>Bill</a-breadcrumb-item>
</a-breadcrumb>
<div :style="{ padding: '24px', background: '#fff', minHeight: '360px' }">
Bill is a cat.
</div>
</a-layout-content>
<a-layout-footer style="text-align: center">
Ant Design ©2018 Created by Ant UED
</a-layout-footer>
</a-layout>
</a-layout>
</template>
<script>
import { PieChartOutlined, DesktopOutlined, UserOutlined, TeamOutlined, FileOutlined } from '@ant-design/icons-vue';
import { defineComponent, ref } from 'vue';
export default defineComponent({
components: {
PieChartOutlined,
DesktopOutlined,
UserOutlined,
TeamOutlined,
FileOutlined,
},
data() {
return {
collapsed: ref(false),
selectedKeys: ref(['1']),
};
},
});
</script>
<style>
#components-layout-demo-side .logo {
height: 32px;
margin: 16px;
background: rgba(255, 255, 255, 0.3);
}
.site-layout .site-layout-background {
background: #fff;
}
[data-theme='dark'] .site-layout .site-layout-background {
background: #141414;
}
</style>
设置路由:
{
path: '/base',
name: 'Base',
component: Base
},
Base组件修改
Base.vue修改菜单中的标题信息,Base.vue,代码:
<template>
<a-layout style="min-height: 100vh">
<a-layout-sider v-model:collapsed="collapsed" collapsible>
<div class="logo"
style="font-style: italic;text-align: center;font-size: 20px;color:#fff;margin: 10px 0;background-color: #333;line-height: 50px;font-family: 'Times New Roman'">
<span> Urils</span>
</div>
<div class="logo"/>
<a-menu v-for="menu in menu_list" v-model:selectedKeys="selectedKeys" theme="dark" mode="inline">
<a-menu-item v-if="menu.children.length===0" :key="menu.id">
<router-link :to="menu.menu_url">
<desktop-outlined/>
<span> {{ menu.title }}</span>
</router-link>
</a-menu-item>
<a-sub-menu v-else :key="menu.id">
<template #title>
<span>
<user-outlined/>
<span>{{ menu.title }}</span>
</span>
</template>
<a-menu-item v-for="child_menu in menu.children" :key="child_menu.id">
<router-link :to="child_menu.menu_url">{{ child_menu.title }}</router-link>
</a-menu-item>
</a-sub-menu>
</a-menu>
</a-layout-sider>
<a-layout>
<a-layout-header style="background: #369; padding: 0"/>
<a-layout-content style="margin: 0 16px">
<router-view></router-view>
</a-layout-content>
<a-layout-footer style="text-align: center">
Ant Design ©2018 Created by Ant UED
</a-layout-footer>
</a-layout>
</a-layout>
</template>
<script>
import {DesktopOutlined, FileOutlined, PieChartOutlined, TeamOutlined, UserOutlined} from '@ant-design/icons-vue';
import {defineComponent, ref} from 'vue';
export default defineComponent({
components: {
PieChartOutlined,
DesktopOutlined,
UserOutlined,
TeamOutlined,
FileOutlined,
},
data() {
return {
collapsed: ref(false),
selectedKeys: ref(['1']),
menu_list: [
{
id: 1, icon: 'mail', title: '展示中心', tube: '', 'menu_url': '/urils/show_center', children: []
},
{
id: 2, icon: 'mail', title: '资产管理', 'menu_url': '/urils/host', children: []
},
{
"id": 3, icon: 'bold', title: '批量任务', tube: '', menu_url: '/urils/workbench', children: [
{id: 10, icon: 'mail', title: '执行任务', 'menu_url': '/urils/multi_exec'},
{id: 11, icon: 'mail', title: '命令管理', 'menu_url': '/urils/template_manage'},
]
},
{
id: 4, icon: 'highlight', title: '代码发布', tube: '', menu_url: '/urils/workbench', children: [
{id: 12, title: '应用管理', menu_url: '/urils/release'},
{id: 13, title: '发布申请', menu_url: '/urils/release'}
]
},
{id: 5, icon: 'mail', title: '定时计划', tube: '', menu_url: '/urils/workbench', children: []},
{
id: 6, icon: 'mail', title: '配置管理', tube: '', menu_url: '/urils/workbench', children: [
{id: 14, title: '环境管理', 'menu_url': '/urils/environment'},
{id: 15, title: '服务配置', 'menu_url': '/urils/workbench'},
{id: 16, title: '应用配置', 'menu_url': '/urils/workbench'}
]
},
{id: 7, icon: 'mail', title: '监控预警', tube: '', 'menu_url': '/urils/workbench', children: []},
{
id: 8, icon: 'mail', title: '报警', tube: '', 'menu_url': '/urils/workbench', children: [
{id: 17, title: '报警历史', 'menu_url': '/urils/workbench'},
{id: 18, title: '报警联系人', 'menu_url': '/urils/workbench'},
{id: 19, title: '报警联系组', 'menu_url': '/urils/workbench'}
]
},
{
id: 9, icon: 'mail', title: '用户管理', tube: '', menu_url: '/urils/workbench', children: [
{id: 20, title: '账户管理', tube: '', menu_url: '/urils/workbench'},
{id: 21, title: '角色管理', tube: '', menu_url: '/urils/workbench'},
{id: 22, title: '系统设置', tube: '', menu_url: '/urils/workbench'}
]
}
]
};
},
});
</script>
<style>
#components-layout-demo-side .logo {
height: 32px;
margin: 16px;
background: rgba(255, 255, 255, 0.3);
}
.site-layout .site-layout-background {
background: #fff;
}
[data-theme='dark'] .site-layout .site-layout-background {
background: #141414;
}
</style>
路由router/index.js:
import {createRouter, createWebHistory} from 'vue-router'
import Login from '../views/Login.vue'
import Base from '../views/Base'
const routes = [
{
meta: {
title: 'uric自动化运维平台'
},
path: '/uric',
alias: '/', // 给当前路径起一个别名
name: 'Base',
component: Base, // 快捷键:Alt+Enter快速导包
},
{
meta: {
title: '账户登陆'
},
path: '/login',
name: 'Login',
component: Login // 快捷键:Alt+Enter快速导包
},
{
path: '/',
name: 'ShowCenter',
component: ShowCenter
},
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
})
export default router
接着我们再创建一个测试路由的组件,Host.vue,代码:
<template>
<div class="host">
<h1>host页面</h1>
</div>
</template>
<script>
export default {
name: 'Host'
}
</script>
<style scoped>
</style>
子路由配置
由于我们使用了组件嵌套,所以我们要通过路由嵌套来进行控制
Router/index.js
import {createRouter, createWebHistory} from 'vue-router'
import Login from '../views/Login.vue'
import Base from '../views/Base'
import ShowCenter from '../views/ShowCenter'
import Host from '../views/Host'
const routes = [
{
meta: {
title: 'uric自动化运维平台'
},
path: '/uric',
alias: '/', // 给当前路径起一个别名
name: 'Base',
component: Base, // 快捷键:Alt+Enter快速导包,
children: [
{
path: 'show_center',
alias: '', // 给当前路径起一个别名
name: 'ShowCenter',
component: ShowCenter
},
{
path: 'host',
name: 'Host',
component: Host
},
],
},
{
meta: {
title: '账户登陆'
},
path: '/login',
name: 'Login',
component: Login // 快捷键:Alt+Enter快速导包
},
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
})
export default router
二 后端项目初始化
(1)虚拟环境
Python创建虚拟环境
创建虚拟环境是为了让项目运行在一个独立的局部的Python环境中,使得不同环境的项目互不干扰。
1. 安装虚拟环境的第三方包 virtualenv
pip install virtualenv
使用清华源安装:pip install virtualenv -i https://pypi.python.org/simple/
2. 创建虚拟环境
cd 到存放虚拟环境光的地址
virtualenv ENV 在当前目录下创建名为ENV的虚拟环境(如果第三方包virtualenv安装在python3下面,此时创建的虚拟环境就是基于python3的)
virtualenv -p 指定python版本创建虚拟环境 参数
virtualenv -p /usr/local/bin/python3.6 虚拟环境名称
virtualenv --system-site-packages ENV 参数 --system-site-packages 指定创建虚拟环境时继承系统三方库
3. 激活/退出虚拟环境
cd ~/ENV 跳转到虚拟环境的文件夹
source bin/activate 激活虚拟环境
pip list 查看当前虚拟环境下所安装的第三方库
deactivate 退出虚拟环境
4. 删除虚拟环境
直接删除虚拟环境所在目录即可
window系统没有bin文件夹,cd进入Scripts路径下运行:
activate.bat
(2)搭建项目
基于Pycharm创建Django项目时可以直接构建虚拟环境
可以直接在pycharm中使用创建好的虚拟环境,安装和查看第三方库。也可以在终端中使用虚拟环境,转到pycharm中设定的虚拟环境的位置,一般在工程的根目录。这个虚拟环境和上述用命令创建的虚拟环境一样,采用上述激活/退出虚拟环境命令即可执行相应操作。
测试,安装requests模块
(3)项目目录调整
# 默认结构
└── uric_api
├── manage.py
└── uric_api
├── asgi.py
├── __init__.py
├── settings.py
├── urls.py
└── wsgi.py
# 调整结构
└── uric_api # 后端项目目录
├── __init__.py
├── logs # 项目运行时/开发时日志目录
├── manage.py # 开发阶段的启动文件
├── scripts # 保存项目运营时的脚本文件 bash
│ └── __init__.py
└── uric_api # 项目主应用,开发时的代码保存
├── apps # 开发者的代码保存目录,以模块[子应用]为目录保存(包)
│ └── __init__.py
├── asgi.py
├── __init__.py
├── libs # 第三方类库的保存目录[第三方组件、模块](包)
│ └── __init__.py
├── settings
│ ├── dev.py # 项目开发时的本地配置
│ ├── __init__.py
│ ├── prod.py # 项目上线时的运行配置
│ └── test.py # 测试人员使用的配置(咱们不需要)
├── settings.py
├── urls.py # 总路由(包)
├── utils # 多个模块[子应用]的公共函数类库[自己开发的组件]
│ └── __init__.py
└── wsgi.py
注意:创建文件夹的时候,是创建包(含
__init__.py
文件的)还是创建单纯的文件夹,看目录里面放的是什么,如果放的是py文件相关的代码,最好创建包,如果不是,那就创建单纯的文件夹。
切换manage.py启动项目时使用的配置文件。mange.py,代码:
(4)注册DRF组件
下载:
pip install djangorestframework -i https://pypi.douban.com/simple
settings.dev.py,代码:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
]
调整子应用保存以后,创建并注册子应用需要调整如下,
例如:创建home子应用
cd uric_api/apps
python ../../manage.py startapp home
子应用的注册,settings.dev.py,代码:
import sys
BASE_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(BASE_DIR / 'apps'))
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
"home"
]
创建了一个测试视图,提供给外界访问。home.views.py,代码:
from rest_framework.views import APIView
from rest_framework.response import Response
class TestAPIView(APIView):
def get(self,request):
return Response({"message":"hello"},)
home.urls.py,代码:
from django.urls import path
from . import views
urlpatterns = [
path("test", views.TestAPIView.as_view())
]
总路由加载home子应用的路由信息,uric_api.urls
,代码:
from django.contrib import admin
from django.urls import path,include
urlpatterns = [
path('admin/', admin.site.urls),
path("", include("home.urls")),
]
(5)日志配置
参考django官方文档,网址:https://docs.djangoproject.com/zh-hans/3.2/topics/logging/
在settings/dev.py文件中追加如下配置:
# 日志配置
LOGGING = {
# 使用的python内置的logging模块,那么python可能会对它进行升级,所以需要写一个版本号,目前就是1版本
'version': 1,
# 是否去掉目前项目中其他地方中以及使用的日志功能,但是将来我们可能会引入第三方的模块,里面可能内置了日志功能,所以尽量不要关闭,肯定False
'disable_existing_loggers': False,
# 日志的处理格式
'formatters': {
# 详细格式,往往用于记录日志到文件/其他第三方存储设备
'verbose': {
# levelname等级,asctime记录时间,module表示日志发生的文件名称,lineno行号,message错误信息
'format': '{levelname} {asctime} {module}:{lineno:d} {message}',
# 日志格式中的,变量分隔符
'style': '{',
},
'simple': { # 简单格式,往往用于终端
'format': '{levelname} {module}:{lineno} {message}',
'style': '{',
},
},
'filters': { # 日志的过滤设置,可以对日志进行输出时的过滤用的
# 在debug=True下产生的一些日志信息,要不要记录日志,需要的话就在handlers中加上这个过滤器,不需要就不加
'require_debug_true': {
'()': 'django.utils.log.RequireDebugTrue',
},
},
'handlers': { # 日志的处理方式
'console': { # 终端下显示
'level': 'DEBUG', # 日志的最低等级
'filters': ['require_debug_true'],
'class': 'logging.StreamHandler', # 处理日志的核心类
'formatter': 'simple'
},
'file': { # 文件中记录日志
'level': 'INFO',
'class': 'logging.handlers.RotatingFileHandler',
# 日志位置,日志文件名,日志保存目录必须手动创建
'filename': BASE_DIR.parent / "logs/uric.log",
# 单个日志文件的最大值,这里我们设置300M
'maxBytes': 300 * 1024 * 1024,
# 备份日志文件的数量,设置最大日志数量为10
'backupCount': 10,
# 日志格式:详细格式
'formatter': 'verbose',
# 设置默认编码,否则打印出来汉字乱码
'encoding': 'utf-8',
},
},
# 日志实例对象
'loggers': {
'django': { # 固定名称,将来django内部也会有异常的处理,只会调用django下标的日志对象
'handlers': ['console', 'file'],
'propagate': True, # 是否让日志信息继续冒泡给其他的日志处理系统
},
"DRF":{
'handlers': ['file'],
'propagate': True, # 是否让日志信息继续冒泡给其他的日志处理系统
}
}
}
案例:构建中间件,记录每次请求信息
from django.utils.deprecation import MiddlewareMixin
import logging
import time
class LogMiddleware(MiddlewareMixin):
start = 0
def process_request(self, request):
self.start = time.time()
def process_response(self, request, response):
cost_timer = time.time() - self.start
logger = logging.getLogger("django")
if cost_timer > 0.5:
logger.warning(f"请求路径: {request.path} 耗时{cost_timer}秒")
return response
(6)异常处理
新建utils/exceptions.py
import logging
from rest_framework.views import exception_handler
from rest_framework.response import Response
from rest_framework import status
from django.db import DatabaseError
logger = logging.getLogger("django")
def custom_exception_handler(exc, context):
"""
自定义异常处理
:param exc: 异常类实例对象
:param context: 抛出异常的执行上下文[context,是一个字典格式的数据,里面记录了异常发生时的环境信息]
:return: Response 响应对象
"""
# 先让drf内置的异常处理函数先解决掉它能识别的异常
response = exception_handler(exc, context)
if response is None:
"""drf无法处理的异常"""
view = context["view"]
if isinstance(exc, DatabaseError):
logger.error('[%s] %s' % (view, exc))
response = Response({"errmsg":"服务器内部存储错误"}, status=status.HTTP_507_INSUFFICIENT_STORAGE)
return response
settings/dev.py配置文件中添加
REST_FRAMEWORK = {
# 异常处理
'EXCEPTION_HANDLER': 'uric_api.utils.exceptions.custom_exception_handler',
}
# 视图更改
class TestAPIView(APIView):
def get(self, request):
from django.db import DatabaseError
raise DatabaseError("mysql连接失败")
return Response({"message": "hello"})
(7)连接数据库
create database uric default charset=utf8mb4; -- utf8也会导致有些极少的中文出现乱码的问题,mysql5.5之后官方才进行处理,出来了utf8mb4,这个是真正的utf8,能够容纳所有的中文。
为当前项目创建数据库用户[这个用户只能看到uric这个数据库]
# mysql8.0版本以上执行
# 创建用户:create user '用户名'@'主机地址' identified by '密码';
create user 'uricUser01'@'%' identified by 'uric'; # %表示任意主机都可以通过当前账户登录到mysql
# 分配权限:grant 权限选项 on 数据库名.数据表 to '用户名'@'主机地址' with grant option;
grant all privileges on uric.* to 'uricUser'@'%' with grant option;
# mysql8.0版本以下执行,创建数据库用户并设置数据库权限给当前新用户,并刷新内存中的权限记录
# create user uric_user identified by 'uric';
# grant all privileges on uric.* to 'uric_user'@'%';
# flush privileges;
配置数据库连接:打开settings/dev.py文件,并配置
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"HOST": "127.0.0.1",
"PORT": 3306,
"USER": "uric_user",
"PASSWORD": "uric",
"NAME": "uric",
}
}
在项目主模块的 __init__.py
中导入pymysql
from pymysql import install_as_MySQLdb
install_as_MySQLdb()
注意是主模块的初始化文件,不是项目根目录的初始化文件!
初始化Django默认表:
python manage.py makemigrations
python manage.py migrate
初始化Django默认表:
python manage.py makemigrations
python manage.py migrate
(8)跨域设置
cors解决跨域请求
我们现在为前端和后端分别设置两个不同的域名:
位置 | 域名 |
---|---|
客户端 | www.uric.cn |
服务端 | api.uric.cn |
编辑/etc/hosts
文件,可以设置本地域名
sudo vim /etc/hosts
window中在C:\Windows\System32\drivers\etc
在文件中增加两条信息
127.0.0.1 localhost
127.0.0.1 api.uric.cn
127.0.0.1 www.uric.cn
现在,前端与后端分处不同的域名,我们需要为后端添加跨域访问的支持,否则前端无法使用axios无法请求后端提供的api数据,开发中,我们使用CORS来解决后端对跨域访问的支持。CORS 即 Cross Origin Resource Sharing 跨域资源共享。
在 Response(headers={"Access-Control-Allow-Origin":'客户端地址或*'})
class CorsMiddleWare(MiddlewareMixin):
def process_response(self, request, response):
response["Access-Control-Allow-Origin"] = "*"
return response
跨域复杂请求
跨域请求分两种:简单请求、复杂请求.
简单请求
简单请求必须满足下述条件.
HTTP方法为这三种方法之一:HEAD、GET、POST
HTTP头消息不超出以下字段:
Accept、Accept-Language、Content-Language、Last-Event-ID
且Content-Type只能为下列类型中的某一个:
- application/x-www-from-urlencoded
- multipart/form-data
- text/plain.
==任何不满足上述要求的请求,都会被认为是复杂请求.
复杂请求会先发出一个预请求——预检,OPTIONS请求.==
from django.utils.deprecation import MiddlewareMixin
class CorsMiddleWare(MiddlewareMixin):
def process_response(self, request, response):
# 如下,等于'*'后,便可允许所有简单请求的跨域访问
response['Access-Control-Allow-Origin'] = '*'
# 判断是否为复杂请求
if request.method == 'OPTIONS':
response['Access-Control-Allow-Headers'] = 'Content-Type'
response['Access-Control-Allow-Methods'] = 'PUT,PATCH,DELETE'
return response
cors-headers组件
文档:https://github.com/ottoyiu/django-cors-headers/
安装
pip install django-cors-headers -i https://pypi.douban.com/simple/
添加应用,settings.dev.py,代码:
INSTALLED_APPS = (
...
'rest_framework',
'corsheaders',
...
)
中间件设置【必须写在第一个位置】,settings.dev.py,代码:
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', #放在中间件的最上面,就是给响应头加上了一个响应头跨域
...
]
需要添加跨域白名单,确定一下哪些客户端可以跨域。settings.dev.py,代码:
# CORS组的配置信息
CORS_ORIGIN_WHITELIST = (
#'www.uric.cn:8080', # 如果这样写不行的话,就加上协议(http://www.uric.cn:8080,因为不同的corsheaders版本可能有不同的要求)
'http://www.uric.cn:8080',
)
CORS_ALLOW_CREDENTIALS = False # 是否允许ajax跨域请求时携带cookie,False表示不用,我们后面也用不到cookie,所以关掉它就可以了,以防有人通过cookie来搞我们的网站
允许客户端通过api.uric.cn访问Django项目,settings.dev.py,代码:
ALLOWED_HOSTS = ["api.uric.cn",]
完成了上面的步骤,我们将来就可以通过后端提供数据给前端使用ajax访问了。前端使用 axios就可以访问到后端提供给的数据接口,但是如果要附带cookie信息,前端还要设置一下,这个等我们搭建客户端项目时再配置。
(9)git设置
完成了上面的操作以后,服务端的初始化算基本完成了。我们现在已经写了那么多代码的话,肯定要对代码进行版本跟踪和管理,这时候,我们就要使用git通过gitee/github/gitlab进行创建代码版本。
创建Git仓库:
# Git 全局设置:
git config --global user.name "Yuan先生"
git config --global user.email "916852314@163.com"
# 创建 git 仓库:
mkdir uric
cd uric
git init
touch README.md
git add README.md
git commit -m "first commit"
git remote add origin https://gitee.com/pythonyuan/uric.git
git push -u origin master
# 已有仓库
cd existing_git_repo
git remote add origin https://gitee.com/pythonyuan/uric.git
git push -u origin master
接下来,在终端下设置当前开发者的身份。这里,我们把客户端和服务端的代码保存到一个代码仓库中。所以直接在uric目录下创建代码仓库。
cd Desktop/
cd uric/
git init # 初始化git代码库
# 设置身份
git config --global user.name "Yuan先生"
git config --global user.email "916852314@163.com"
# 设置远程仓库
git remote add origin https://gitee.com/pythonyuan/uric.git
# 接下来,我们就可以把上面服务端初始化的代码保存一个版本
git add .
git commit -m "api服务端初始化完成"
# 生成本地ssh密码,链接到服务端
ssh-keygen -t rsa -C "916852314@qq.com"
# 查看上面生成的ssh公钥,并复制到gitee码云上面
cat ~/.ssh/id_rsa.pub # ssh-rsa
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDG/4nDPBNat3NgYdDM/ttxDTfRrlc5sH6KDgX+YXB8Zv8/YDJT7y2MPLPFTt/WXE4bxfFxn/5/87LELSbcOFz9VGzYSeZtysnX70rbxxP59/m6X6/oLiH4D++0zu5879gbHSOU5P5V0m1qofF4DD1so6R5FbO1aavFyIOt15IpKHLg9jkSIw3x6QSY3dojlnbR41Xu5XutdA1D1F3cjUjPQzGlMtnW3S79tocrLzHk2PDrqsDydvJGqQw//M9HCQqzZDUTAgMVldP8f0Pyzop4nnfrwPGf5uwWx0Pve6k4cpnGKwS0rnOcjU0fUqnbVq6Qaye5wR8IfFgoPMDBZCy4UAwMNtbP5YTx8nBVHr6b2N7ZNRYLZQXbPwra3ic8TmgLcUNyYsvNa98VTS56pLcSNKUBnSqY70OilbKAyysrPWN9Q5a69bbh4xwJRIf+7NEqvtBKpI2Beg7nXeWs2CS9pkJ5hwLIHtAzouwjrQZshVSjqg9n4R61AOOObwIpLLc= 916852314@qq.com
# git remote remove origin
# git remote add origin git@gitee.com:pythonyuan/uric.git
访问个人中心的设置ssh公钥的地址:https://gitee.com/profile/sshkeys
# 把git版本库历史记录同步到gitee码云上面
git push -u origin master # 此时就不需要提交用户名密码了
三 登录认证
3.1、创建users应用
创建用户模块的子应用
cd uric_api/apps
python ../../manage.py startapp users
在settings/dev.py文件中注册子应用。
INSTALLED_APPS = [
...
'users',
]
子路由,users.urls.py,代码:
from django.urls import path
from . import views
urlpatterns = [
]
总路由注册users子路由,uric_api.urls.py,代码:
from django.contrib import admin
from django.urls import path,include
urlpatterns = [
path('admin/', admin.site.urls),
path("", include("home.urls")),
path("users/", include("users.urls")),
]
3.2、创建自定义的用户模型类
Django默认已经提供了认证系统Auth模块,我们认证的时候,会使用auth模块里面给我们提供的表。认证系统包含:
- 用户管理
- 权限
- 用户组
- 密码哈希系统
- 用户登录或内容显示的表单和视图
- 一个可插拔的后台系统 admin
我们可以在终端下通过命令创建一个管理员账号,并登陆到admin站点中。
python manage.py migrate
python manage.py createsuperuser
Django默认用户的认证机制依赖Session机制,我们在项目中将引入JWT认证机制,将用户的身份凭据存放在Token中,然后对接Django的认证系统,帮助我们来实现:
- 用户的数据模型
- 用户密码的加密与验证
- 用户的权限系统
Django认证系统中提供的用户模型类及方法很方便,我们可以使用这个模型类,但是字段有些无法满足项目需求,如本项目中需要保存用户的手机号,需要给模型类添加额外的字段。
Django提供了django.contrib.auth.models.AbstractUser
用户抽象模型类允许我们继承,扩展字段来使用Django认证系统的用户模型类。
我们可以在apps中创建Django应用users,并在配置文件中注册users应用。
在创建好的应用models.py中定义用户的用户模型类。
from django.db import models
from django.contrib.auth.models import AbstractUser
# Create your models here.
class User(AbstractUser):
mobile = models.CharField(max_length=15, unique=True, verbose_name='手机号码')
# upload_to 表示上传文件的存储子路由,需要在settings配置中,配置上传文件的支持
avatar = models.ImageField(upload_to='avatar', verbose_name='用户头像', null=True, blank=True)
class Meta:
db_table = 'uric_user'
verbose_name = '用户信息'
verbose_name_plural = verbose_name
# 下面的3个表现不用创建,留着以后使用
class Menu(models.Model):
"""
一级菜单表
"""
title = models.CharField(max_length=12)
weight = models.IntegerField(default=0)
icon = models.CharField(max_length=16, null=True, blank=True)
def __str__(self):
return self.title
class Meta:
db_table = 'uric_menu'
verbose_name = '一级菜单表'
verbose_name_plural = verbose_name
unique_together = ('title', 'weight')
class Permission(models.Model):
url = models.CharField(max_length=32)
title = models.CharField(max_length=32)
menus = models.ForeignKey('Menu',on_delete=models.CASCADE , null=True, blank=True)
parent = models.ForeignKey('self',on_delete=models.CASCADE , null=True, blank=True)
url_name = models.CharField(max_length=32, unique=True)
def __str__(self):
return self.title
class Meta:
db_table = 'uric_permission'
verbose_name = '权限表'
verbose_name_plural = verbose_name
class Role(models.Model):
name = models.CharField(max_length=12)
permissions = models.ManyToManyField(to='Permission')
def __str__(self):
return self.name
class Meta:
db_table = 'uric_role'
verbose_name = '角色表'
verbose_name_plural = verbose_name
创建用户模型对象两种方式:
User.objects.create_superuser()
Python manage.py createsuperuser
我们自定义的用户模型类还不能直接被Django的认证系统所识别,需要在配置文件中告知Django认证系统使用我们自定义的模型类。
在配置文件中进行设置,settings.dev.py
,代码:
#设置Auth认证模块使用的用户模型为我们自己定义的用户模型
# 格式:“子应用目录名.模型类名”
AUTH_USER_MODEL = 'users.User'
AUTH_USER_MODEL
参数的设置以点.
来分隔,表示应用名.模型类名
。
注意:Django建议我们对于AUTH_USER_MODEL参数的设置一定要在第一次数据库迁移之前就设置好,否则后续使用可能出现未知错误。
接下来,因为我们新建的用户模型需要同步到数据库中。所有需要数据迁移。同时当前用户模型中,我们声明了头像字段需要进行图片处理的。所以我们安装Pillow模块。
# 安装Pillow模块
pip install pillow
# 数据迁移
python manage.py makemigrations
python manage.py migrate
如果之前曾经进行了一次数据迁移,mysql中为原来的用户表与django其他的数据进行关联。此时我们再次数据迁移,因为修改了用户表,所以会出现外键关联报错。
解决方法如下:
1. 删除数据库中所有的数据表[如果数据表中有重要数据,必须先导出备份]
2. 删除当前users子应用中的migrations目录下所有以数字开头的python文件
3. 删除源码中django.contrib.auth和django.contrib.admin中的migrations目录下所有以数字开头的python文件
4. 重新执行数据迁移
python manage.py makemigrations
python manage.py migrate
5. 把上面备份的数据通过终端重新恢复
3.3、simpleui的安装和使用
simpleui是Django的第三方扩展,比使用Django的admin站点更强大也更方便,更好看。
文档:https://simpleui.72wo.com/docs/simpleui/
GitHub地址:https://github.com/happybeta/simpleui
通过如下命令安装simpleui的最新版,它文档里面的安装方法好久没有更新了,会导致你安装不成功,所以我们使用下面的网址进行安装
pip install django-simpleui -i https://pypi.douban.com/simple
在配置文件中注册如下应用,settings.dev
,代码:
INSTALLED_APPS = [
'simpleui', # 必须写在django.contrib.admin之前
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'corsheaders',
'home',
'users',
]
# 修改使用中文界面
LANGUAGE_CODE = 'zh-Hans'
# 修改时区
TIME_ZONE = 'Asia/Shanghai'
3.4、JWT
3.4.1、JWT介绍
在用户注册或登录后,我们想记录用户的登录状态,或者为用户创建身份认证的凭证。我们不再使用Session认证机制,而使用Json Web Token认证机制。
很多公司开发的一些移动端可能不支持cookie,并且我们通过cookie和session做接口登录认证的话,效率其实并不是很高,我们的接口可能提供给多个客户端,session数据保存在服务端,那么就需要每次都调用session数据进行验证,比较耗时,所以引入了token认证的概念,我们也可以通过token来完成,我们来看看jwt是怎么玩的。
Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。
JWT的构成
JWT就一段字符串,由三段信息构成的,将这三段信息文本用.
链接一起就构成了Jwt字符串。就像这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
第一部分我们称它为头部(header),第二部分我们称其为载荷(payload, 类似于飞机上承载的物品),第三部分是签证(signature).
header
jwt的头部承载两部分信息:
- 声明类型,这里是jwt
- 声明加密的算法 通常直接使用 HMAC SHA256
完整的头部就像下面这样的JSON:
{
'typ': 'JWT',
'alg': 'HS256'
}
然后将头部进行base64.b64encode()编码,构成了第一部分.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
python中base64加密解密
import base64,json
data = {
'typ': 'JWT',
'alg': 'HS256'
}
header = base64.b64encode(json.dumps(data).encode()).decode()
# 各个语言中都有base64加密解密的功能,所以我们jwt为了安全,需要配合第三段加密
payload
载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息可以存放下面三个部分信息。
- 标准声明
- 公共声明
- 私有声明
标准声明 (建议但不强制使用) :
-
iss: jwt签发者
-
sub: jwt所面向的用户
-
aud: 接收jwt的一方
-
exp: jwt的过期时间,这个过期时间必须要大于签发时间
-
nbf: 定义在什么时间之前,该jwt都是不可用的.
-
iat: jwt的签发时间
-
jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
以上是JWT 规定的7个官方字段,供选用
公共声明 : 公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端直接可以查看.
私有声明 : 私有声明是服务端和客户端所共同定义的声明,一般使用了ace算法进行对称加密和解密的,意味着该部分信息可以归类为明文信息。
定义一个payload,json格式的数据:
{
"sub": "1234567890",
"exp": "3422335555", #时间戳形式
"name": "John Doe",
"admin": true,
"info": "232323ssdgerere3335dssss" # ACE算法加密
}
然后将其进行base64.b64encode() 编码,得到JWT的第二部分。
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
import base64,json
data = {
"sub": "1234567890",
"exp": "3422335555",
"name": "John Doe",
"admin": True,
"info": "232323ssdgerere3335dssss"
}
preload = base64.b64encode(json.dumps(data).encode()).decode()
# 各个语言中都有base64编码和解码,所以我们jwt为了安全,需要配合第三段签证来进行加密保证jwt不会被人篡改。
signature
JWT的第三部分是一个签证信息,这个签证信息由三部分组成:
- header (base64后的)
- preload (base64后的)
- secret 密钥
这个部分需要base64加密后的header和base64加密后的payload使用.
连接组成的字符串,然后通过header中声明的加密方式进行加盐secret
组合加密,然后就构成了jwt的第三部分。
python,代码:
import base64, json, hashlib
if __name__ == '__main__':
# 头部
data = {'typ': 'JWT', 'alg': 'HS256'}
header = base64.b64encode(json.dumps(data).encode()).decode()
# 载荷
data = {"sub": "1234567890", "exp": "3422335555", "name": "John Doe", "admin": True,
"info": "232323ssdgerere3335dssss"}
preload = base64.b64encode(json.dumps(data).encode()).decode()
# 签证
# from django.conf import settings
# secret = settings.SECRET_KEY
secret = 'django-insecure-(_+qtd5edmhm%2rdsg+qc3wi@s_k*3cbk-+k2gpg3@qx)z6r+p'
sign = f"{header}.{preload}.{secret}"
hs256 = hashlib.sha256()
hs256.update(sign.encode())
signature = hs256.hexdigest()
jwt = f"f{header}.{preload}.{signature}"
print(jwt)
将这三部分用.
连接成一个完整的字符串,构成了最终的jwt:
feyJ0eXAiOiAiSldUIiwgImFsZyI6ICJIUzI1NiJ9.eyJzdWIiOiAiMTIzNDU2Nzg5MCIsICJleHAiOiAiMzQyMjMzNTU1NSIsICJuYW1lIjogIkpvaG4gRG9lIiwgImFkbWluIjogdHJ1ZSwgImluZm8iOiAiMjMyMzIzc3NkZ2VyZXJlMzMzNWRzc3NzIn0=.374b156a33e579c780eb1594a5738c580a13ea0f905487dc66c15856b6110ebf
注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。
jwt的优点:
1. 实现分布式的单点登陆非常方便
2. 数据实际保存在客户端,所以我们可以分担服务端的存储压力
3. JWT不仅可用于认证,还可用于信息交换。善用JWT有助于减少服务器请求数据库的次数,jwt的构成非常简单,字节占用很小,所以它是非常便于传输的。
jwt的缺点:
1. 数据保存在了客户端,我们服务端只认jwt,不识别客户端。
2. jwt可以设置过期时间,但是因为数据保存在了客户端,所以对于过期时间不好调整。# secret_key轻易不要改,一改所有客户端都要重新登录
认证流程图
关于签发和核验JWT,我们可以使用Django REST framework JWT扩展来完成。
文档网站 ; https://jpadilla.github.io/django-rest-framework-jwt/
3.4.2、JWT安装与配置
(1)安装
pip install djangorestframework-jwt -i https://mirrors.aliyun.com/pypi/simple/
配置(github网址:https://github.com/jpadilla/django-rest-framework-jwt)
REST_FRAMEWORK = {
# 自定义异常处理
'EXCEPTION_HANDLER': 'uric_api.utils.exceptions.custom_exception_handler',
# 自定义认证
'DEFAULT_AUTHENTICATION_CLASSES': (
# jwt认证
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
# session认证
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.BasicAuthentication',
),
}
import datetime
JWT_AUTH = {
# jwt的有效时间
'JWT_EXPIRATION_DELTA': datetime.timedelta(weeks=1),
'JWT_ALLOW_REFRESH': True,
}
我们django创建项目的时候,在settings配置文件中直接就给生成了一个serect_key,我们直接可以使用它作为我们jwt的serect_kek,其实djangorestframework-jwt默认配置中就使用的它。
(2)获取token内置函数
Django REST framework JWT提供了登录获取token的视图,可以直接给这视图指定url路由即可使用。
在users子应用路由urls.py中
from rest_framework_jwt.views import obtain_jwt_token
urlpatterns = [
path('login/', obtain_jwt_token),
]
在主路由中,引入当前子应用的路由文件
urlpatterns = [
...
path('user/', include("users.urls")),
]
接下来,我们可以通过postman来测试下功能,但是jwt是通过username和password来进行登录认证处理的,所以我们要给真实数据,jwt会去我们配置的user表中去查询用户数据的。
添加测试用户命令:
python manage.py createsuperuser
用户名:yuan
密码:123
启动项目:
python manage.py runserver api.uric.cn:8000
得到的载荷信息,我们可以通过js内置的base64编码函数来读取里面内容。举例代码:
let token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6InJvb3QiLCJleHAiOjE2MjcxMTkzODQsImVtYWlsIjoiIn0.Xz_QJ5BPSOsjIB-EymwHaptgG-v1Ic8Aa0FhYhcEErE"
let data = token.split(".")
let user_info = JSON.parse(atob(data[1]))
// atob() // base64解码
// btoa() // base64编码
(3)验证token的有效性
配置路由
from django.urls import path
from . import views
from rest_framework_jwt.views import obtain_jwt_token,verify_jwt_token,refresh_jwt_token
urlpatterns = [
path('login/', obtain_jwt_token),
path('verify/', verify_jwt_token), # 这是只是校验token有效性
path(r'refresh_jwt_token/', refresh_jwt_token), # 校验并生成新的token
]
刷新token除了配置上面的refresh_jwt_token之外,还需要在配置文件中加上如下配置
JWT_AUTH = {
# 'JWT_SECRET_KEY': settings.SECRET_KEY,
'JWT_EXPIRATION_DELTA': datetime.timedelta(seconds=30),
'JWT_ALLOW_REFRESH': True, # 这个参数要改True,才能刷新token
}
注意:必须在请求体中token键值对方式请求
postman测试:
先获取token:
验证token,有效期内通过:
过了有效期:
继续测试refresh_jwt_token的刷新token的功能:
3.5、登录认证客户端
3.5.1 本地存储token
<template>
<div class="login box">
<img src="../assets/login.jpg" alt="">
<div class="login">
<div class="login-title">
<p class="hi">Hello,Uric!</p>
</div>
<div class="login_box">
<div class="title">
<span>登录</span>
</div>
<div class="inp">
<a-input v-model:value="username" type="text" placeholder="用户名" class="user"></a-input>
<a-input v-model:value="password" type="password" class="pwd" placeholder="密码"></a-input>
<div class="rember">
<p>
<a-checkbox v-model:checked="remember">记住密码</a-checkbox>
</p>
</div>
<button class="login_btn" @click="login">登录</button>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from "axios"
export default {
name: 'Login',
data() {
return {
username: '',
password: '',
remember: false
}
},
methods: {
login() {
axios.post(this.$settings.host + `/users/login/`, {
username: this.username,
password: this.password,
}).then((response) => {
// locatStorage或者sessionStorage中存储token
// 先清空原有的token
localStorage.removeItem("token");
sessionStorage.removeItem("token");
if (this.remember) {
// 记住登录
localStorage.token = response.data.token;
} else {
sessionStorage.token = response.data.token;
}
// 跳转到首页
let self = this;
this.$success({
title: 'uric系统提示',
content: `登录成功!`,
onOk() {
self.$router.push("/uric");
}
})
// 下一个页面,首页加载时验证token有效性
}).catch(error => {
this.$message.error('用户名或者密码有误,请重新输入!');
});
}
}
}
</script>
<style scoped>
.login .hi {
font-size: 20px;
font-family: "Times New Roman";
font-style: italic;
}
.box {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.box img {
width: 100%;
min-height: 100%;
}
.box .login {
position: absolute;
width: 500px;
height: 400px;
left: 0;
margin: auto;
right: 0;
bottom: 0;
top: -338px;
}
.login .login-title {
width: 100%;
text-align: center;
}
.login-title img {
width: 190px;
height: auto;
}
.login-title p {
font-size: 18px;
color: #fff;
letter-spacing: .29px;
padding-top: 10px;
padding-bottom: 50px;
}
.login_box {
width: 400px;
height: auto;
background: rgba(255, 255, 255, 0.3);
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, .5);
border-radius: 4px;
margin: 0 auto;
padding-bottom: 40px;
}
.login_box .title {
font-size: 20px;
color: #9b9b9b;
letter-spacing: .32px;
border-bottom: 1px solid #e6e6e6;
display: flex;
justify-content: space-around;
padding: 50px 60px 0 60px;
margin-bottom: 20px;
cursor: pointer;
}
.login_box .title span:nth-of-type(1) {
color: #4a4a4a;
border-bottom: 2px solid #396fcc;
}
.inp {
width: 350px;
margin: 0 auto;
}
.inp input {
outline: 0;
width: 100%;
height: 45px;
border-radius: 4px;
border: 1px solid #d9d9d9;
text-indent: 20px;
font-size: 14px;
background: #fff !important;
}
.inp input.user {
margin-bottom: 16px;
}
.inp .rember {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
margin-top: 10px;
}
.inp .rember p:first-of-type {
font-size: 12px;
color: #4a4a4a;
letter-spacing: .19px;
margin-left: 22px;
display: -ms-flexbox;
display: flex;
-ms-flex-align: center;
align-items: center;
/*position: relative;*/
}
.inp .rember p:nth-of-type(2) {
font-size: 14px;
color: #9b9b9b;
letter-spacing: .19px;
cursor: pointer;
}
.inp .rember input {
outline: 0;
width: 30px;
height: 45px;
border-radius: 4px;
border: 1px solid #d9d9d9;
text-indent: 20px;
font-size: 14px;
background: #fff !important;
}
.inp .rember p span {
display: inline-block;
font-size: 12px;
width: 100px;
/*position: absolute;*/
/*left: 20px;*/
}
#geetest {
margin-top: 20px;
}
.login_btn {
width: 100%;
height: 45px;
background: #396fcc;
border-radius: 5px;
font-size: 16px;
color: #fff;
letter-spacing: .26px;
margin-top: 30px;
}
.inp .go_login {
text-align: center;
font-size: 14px;
color: #9b9b9b;
letter-spacing: .26px;
padding-top: 20px;
}
.inp .go_login span {
color: #84cc39;
cursor: pointer;
}
</style>
3.5.2 基于vuex对本地数据持久化存储
Vuex 是一个专门为 Vue.js 应用程序开发的状态管理模式,它采用集中式存储管理应用的所有组件状态,并以相应的规则保证状态以一种可预测的方式发生变化。可以理解为:将多个组件共享的变量全部存储在一个对象里面,然后将这个对象放在顶层的 Vue 实例中,让其他组件可以使用,它最大的特点是响应式。
一般情况下,我们会在 Vuex 中存放一些需要在多个界面中进行共享的信息。比如用户的登录状态、用户名称、头像、地理位置信息、商品的收藏、购物车中的物品等,这些状态信息,我们可以放在统一的地方,对它进行保存和管理。
安装依赖包:
npm install vuex
import createPersistedState from 'vuex-persistedstate'
在src路径下创建store文件夹下创建index.js文件
import {createStore} from 'vuex'
import createPersistedState from 'vuex-persistedstate'
export default createStore({
state: {
token: '',
remember: true
},
plugins: [createPersistedState({ // setState,getState自动触发,防止刷新vuex清空,所以存到本地
key: 'vuex',
setState(key, state) {
if (state.remember) {
localStorage[key] = JSON.stringify(state)
} else {
sessionStorage[key] = JSON.stringify(state)
}
},
getState(key, state) {
if (localStorage[key]) {
return JSON.parse(localStorage[key])
} else {
return JSON.parse(sessionStorage[key])
}
}
})],
getters: {
get_user_info(state, getters) {
let data = state.token.split(".")
return JSON.parse(atob(data[1]))
},
token(state, getters) {
return state.token
},
remember(state, getters) {
return state.remember
}
},
mutations: { // 类似methods
setToken(state, token) {
// 设置本地保存token
state.token = token
},
setRemember(state, remember) {
// 设置记住登陆状态
// localStorage.removeItem('vuex');
// sessionStorage.removeItem('vuex');
state.remember = remember
},
},
actions: {},
modules: {}
})
在main.js中加入代码:
import store from './store'
app.use(store).use(router).use(Antd).mount('#app');
Login.vue代码更新为:
<template>
<div class="login box">
<img src="../assets/login.jpg" alt="">
<div class="login">
<div class="login-title">
<p class="hi">Hello,Urils!</p>
</div>
<div class="login_box">
<div class="title">
<span>登录</span>
</div>
<div class="inp">
<a-input v-model:value="username" type="text" placeholder="用户名" class="user"></a-input>
<a-input v-model:value="password" type="password" class="pwd" placeholder="密码"></a-input>
<div class="rember">
<p>
<a-checkbox v-model:checked="remember">记住密码</a-checkbox>
</p>
</div>
<button class="login_btn" @click="login">登录</button>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from "axios"
export default {
name: 'Login',
data() {
return {
username: 'yuan',
password: '123',
remember: true // 记录登陆状态
}
},
watch: { // 监听数据是否发生变化
remember() {
this.$store.commit('setRemember', this.remember);
}
},
created() {
// 默认用户没有记录登陆状态
this.remember = this.$store.state.remember;
},
methods: {
login() {
axios.post(`${this.$settings.host}/users/login/`, {
username: this.username,
password: this.password
}).then(response => {
// vuex存储token
this.$store.commit('setToken', response.data.token);
let self = this;
this.$success({
title: 'Uric系统提示',
content: '登陆成功!欢迎回来!',
onOk() {
// 在这里,不能直接使用this,因为此处的this被重新赋值了,不再是原来的外界的vue对象了,而是一个antd-vue提供的对话框对象了
self.$router.push('/')
}
})
}).catch((res) => {
// 登陆失败!
this.$message.error('用户名或者密码有误,请重新输入!');
})
}
}
}
</script>
<style scoped>
.login .hi {
font-size: 20px;
font-family: "Times New Roman";
font-style: italic;
}
.box {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.box img {
width: 100%;
min-height: 100%;
}
.box .login {
position: absolute;
width: 500px;
height: 400px;
left: 0;
margin: auto;
right: 0;
bottom: 0;
top: -338px;
}
.login .login-title {
width: 100%;
text-align: center;
}
.login-title img {
width: 190px;
height: auto;
}
.login-title p {
font-size: 18px;
color: #fff;
letter-spacing: .29px;
padding-top: 10px;
padding-bottom: 50px;
}
.login_box {
width: 400px;
height: auto;
background: rgba(255, 255, 255, 0.3);
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, .5);
border-radius: 4px;
margin: 0 auto;
padding-bottom: 40px;
}
.login_box .title {
font-size: 20px;
color: #9b9b9b;
letter-spacing: .32px;
border-bottom: 1px solid #e6e6e6;
display: flex;
justify-content: space-around;
padding: 50px 60px 0 60px;
margin-bottom: 20px;
cursor: pointer;
}
.login_box .title span:nth-of-type(1) {
color: #4a4a4a;
border-bottom: 2px solid #396fcc;
}
.inp {
width: 350px;
margin: 0 auto;
}
.inp input {
outline: 0;
width: 100%;
height: 45px;
border-radius: 4px;
border: 1px solid #d9d9d9;
text-indent: 20px;
font-size: 14px;
background: #fff !important;
}
.inp input.user {
margin-bottom: 16px;
}
.inp .rember {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
margin-top: 10px;
}
.inp .rember p:first-of-type {
font-size: 12px;
color: #4a4a4a;
letter-spacing: .19px;
margin-left: 22px;
display: -ms-flexbox;
display: flex;
-ms-flex-align: center;
align-items: center;
/*position: relative;*/
}
.inp .rember p:nth-of-type(2) {
font-size: 14px;
color: #9b9b9b;
letter-spacing: .19px;
cursor: pointer;
}
.inp .rember input {
outline: 0;
width: 30px;
height: 45px;
border-radius: 4px;
border: 1px solid #d9d9d9;
text-indent: 20px;
font-size: 14px;
background: #fff !important;
}
.inp .rember p span {
display: inline-block;
font-size: 12px;
width: 100px;
/*position: absolute;*/
/*left: 20px;*/
}
#geetest {
margin-top: 20px;
}
.login_btn {
width: 100%;
height: 45px;
background: #396fcc;
border-radius: 5px;
font-size: 16px;
color: #fff;
letter-spacing: .26px;
margin-top: 30px;
}
.inp .go_login {
text-align: center;
font-size: 14px;
color: #9b9b9b;
letter-spacing: .26px;
padding-top: 20px;
}
.inp .go_login span {
color: #84cc39;
cursor: pointer;
}
</style>
3.5.3、后端token验证
配置文件:
REST_FRAMEWORK = {
# 自定义异常处理
'EXCEPTION_HANDLER': 'uric_api.utils.exceptions.custom_exception_handler',
# 自定义认证
'DEFAULT_AUTHENTICATION_CLASSES': (
# jwt认证
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
),
# 'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',)
}
视图更新为:
from rest_framework.permissions import IsAuthenticated
class HostView(APIView):
permission_classes = [IsAuthenticated, ]
def get(self, reqeust):
data = [
{
'id': 1,
'category_name': '数据库服务器',
'name': 'izbp13e05jqwodd605vm3gz',
'ip_addr': '47.58.131.12',
'port': 22,
'remark': ''
},
{
'id': 2,
'category_name': '数据库服务器',
'name': 'iZbp1a3jw4l12ho53ivhkkZ',
'ip_addr': '12.18.125.22',
'port': 22,
'remark': ''
},
{
'id': 3,
'category_name': '缓存服务器',
'name': 'iZbp1b1xqfqw257gs563k2iZ',
'ip_addr': '12.19.135.130',
'port': 22,
'remark': ''
},
{
'id': 4,
'category_name': '缓存服务器',
'name': 'iZbp1b1jw4l01ho53muhkkZ',
'ip_addr': '47.98.101.89',
'port': 22,
'remark': ''
}
],
return Response(data)
3.5.4、路由守卫
Host.vue
发送ajax请求
<template>
<h3>主机管理</h3>
</template>
<script>
import axios from "axios";
export default {
name: "Host",
mounted() {
axios.get(this.$settings.host + "/host", {
headers: {
"Authorization": "jwt " + this.$store.getters.token
}
}).then((res) => {
console.log("res:", res)
}).catch((err) => {
console.log("err:", err)
this.$router.push("/login")
})
}
}
</script>
<style scoped>
</style>
有些页面需要验证才能访问,可以通过路由守卫完成
import {createRouter, createWebHistory} from 'vue-router'
import Login from '../views/Login.vue'
import Base from '../views/Base'
import ShowCenter from '../views/ShowCenter'
import Host from '../views/Host'
import store from "../store"
const routes = [
{
path: '/uric',
alias: '/', // 给当前路径起一个别名
name: 'Base',
component: Base, // 快捷键:Alt+Enter快速导包,
children: [
{
meta: {
title: '展示中心',
authenticate: false,
},
path: 'show_center',
alias: '', // 给当前路径起一个别名
name: 'ShowCenter',
component: ShowCenter
},
{
meta: {
title: '主机管理',
authenticate: true,
},
path: 'host',
name: 'Host',
component: Host
},
],
},
{
meta: {
title: '账户登陆'
},
path: '/login',
name: 'Login',
component: Login // 快捷键:Alt+Enter快速导包
},
];
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
});
// 路由守卫,主要是编写一些在页面跳转过程中, 需要自动执行的代码。例如:修改页面头部标题,验证权限。
router.beforeEach((to, from, next) => {
alert("页面跳转");
document.title = to.meta.title;
console.log("token:::", store.getters.token);
if (to.meta.authenticate && (store.getters.token === '')) {
// 如果访问需要登录的页面,但是没有token则默认跳转到login登陆页面
next({name: 'Login'})
} else {
next()
}
});
export default router
3.5.5、注销
<template>
<a-layout style="min-height: 100vh">
<a-layout-sider v-model:collapsed="collapsed" collapsible>
<div class="logo"
style="font-style: italic;text-align: center;font-size: 20px;color:#fff;margin: 10px 0;line-height: 50px;font-family: 'Times New Roman'">
<span><a-switch v-model:checked="checked"/> DevOps</span>
</div>
<div class="logo"/>
<a-menu v-for="menu in menu_list" v-model:selectedKeys="selectedKeys" theme="dark" mode="inline">
<a-menu-item v-if="menu.children.length===0" :key="menu.id">
<router-link :to="menu.menu_url">
<desktop-outlined/>
<span> {{ menu.title }}</span>
</router-link>
</a-menu-item>
<a-sub-menu v-else :key="menu.id">
<template #title>
<span>
<user-outlined/>
<span>{{ menu.title }}</span>
</span>
</template>
<a-menu-item v-for="child_menu in menu.children" :key="child_menu.id">
<router-link :to="child_menu.menu_url">{{ child_menu.title }}</router-link>
</a-menu-item>
</a-sub-menu>
</a-menu>
</a-layout-sider>
<a-layout>
<a-layout-header style="background: #fff; padding: 20px">
<a-row type="flex" justify="start">
<a-col :span="6">
<a-breadcrumb>
<a-breadcrumb-item href="">
<home-outlined/>
</a-breadcrumb-item>
<a-breadcrumb-item href="">
<user-outlined/>
<span>Application List</span>
</a-breadcrumb-item>
<a-breadcrumb-item>Application</a-breadcrumb-item>
</a-breadcrumb>
</a-col>
<a-col :span="1" :offset="17">
<a-breadcrumb>
<a-button @click="logout" type="primary" class="logout">
注销
</a-button>
</a-breadcrumb>
</a-col>
</a-row>
</a-layout-header>
<a-layout-content style="margin: 0 16px">
<router-view></router-view>
</a-layout-content>
<a-layout-footer style="text-align: center">
Ant Design ©2018 Created by Ant UED
</a-layout-footer>
</a-layout>
</a-layout>
</template>
<script>
import {
DesktopOutlined,
FileOutlined,
PieChartOutlined,
TeamOutlined,
UserOutlined,
HomeOutlined
} from '@ant-design/icons-vue';
import {defineComponent, ref} from 'vue';
export default defineComponent({
setup() {
const checked = ref(true);
return {
checked,
};
},
components: {
PieChartOutlined,
DesktopOutlined,
UserOutlined,
TeamOutlined,
FileOutlined,
HomeOutlined,
},
data() {
return {
collapsed: ref(false),
selectedKeys: ref(['1']),
menu_list: [
{
id: 1, icon: 'mail', title: '展示中心', tube: '', 'menu_url': '/uric/show_center', children: []
},
{
id: 2, icon: 'mail', title: '资产管理', 'menu_url': '/uric/host', children: []
},
{
"id": 3, icon: 'bold', title: '批量任务', tube: '', menu_url: '/uric/workbench', children: [
{id: 10, icon: 'mail', title: '执行任务', 'menu_url': '/uric/multi_exec'},
{id: 11, icon: 'mail', title: '命令管理', 'menu_url': '/uric/template_manage'},
]
},
{
id: 4, icon: 'highlight', title: '代码发布', tube: '', menu_url: '/uric/workbench', children: [
{id: 12, title: '应用管理', menu_url: '/uric/release'},
{id: 13, title: '发布申请', menu_url: '/uric/release'}
]
},
{id: 5, icon: 'mail', title: '定时计划', tube: '', menu_url: '/uric/workbench', children: []},
{
id: 6, icon: 'mail', title: '配置管理', tube: '', menu_url: '/uric/workbench', children: [
{id: 14, title: '环境管理', 'menu_url': '/uric/environment'},
{id: 15, title: '服务配置', 'menu_url': '/uric/workbench'},
{id: 16, title: '应用配置', 'menu_url': '/uric/workbench'}
]
},
{id: 7, icon: 'mail', title: '监控预警', tube: '', 'menu_url': '/uric/workbench', children: []},
{
id: 8, icon: 'mail', title: '报警', tube: '', 'menu_url': '/uric/workbench', children: [
{id: 17, title: '报警历史', 'menu_url': '/uric/workbench'},
{id: 18, title: '报警联系人', 'menu_url': '/uric/workbench'},
{id: 19, title: '报警联系组', 'menu_url': '/uric/workbench'}
]
},
{
id: 9, icon: 'mail', title: '用户管理', tube: '', menu_url: '/uric/workbench', children: [
{id: 20, title: '账户管理', tube: '', menu_url: '/uric/workbench'},
{id: 21, title: '角色管理', tube: '', menu_url: '/uric/workbench'},
{id: 22, title: '系统设置', tube: '', menu_url: '/uric/workbench'}
]
}
]
};
},
methods: {
logout() {
let self = this;
this.$confirm({
title: 'Uric系统提示',
content: '您确认要注销登陆吗?',
onOk() {
self.$store.commit('setToken', '')
self.$router.push('/login')
}
})
},
}
});
</script>
<style>
#components-layout-demo-side .logo {
height: 32px;
margin: 16px;
}
.site-layout .site-layout-background {
background: #fff;
}
[data-theme='dark'] .site-layout .site-layout-background {
background: #141414;
}
.logout {
line-height: 1.5715;
}
</style>
四 主机管理
4.1、主机基本功能
4.1.1、前端页面初始化
Host.vue代码:
<template>
<a-row>
<a-col :span="6">
<div class="add_host" style="margin: 15px;">
<a-button @click="showHostModal" type="primary">
新建
</a-button>
<a-button type="primary" style="margin-left: 20px;">
批量导入
</a-button>
</div>
</a-col>
</a-row>
<a-table bordered :dataSource="hostList.data" :columns="hostFormColumns">
<template #bodyCell="{ column, text, record }">
<template v-if="column.dataIndex === 'action'">
<a-popconfirm
v-if="hostList.data.length"
title="Sure to delete?"
@confirm="deleteHost(record)"
>
<a>Delete</a>
</a-popconfirm>
</template>
</template>
</a-table>
<div>
<a-modal v-model:visible="hostFormVisible" title="添加主机" @ok="onHostFormSubmit" @cancel="resetForm()" :width="800">
<a-form
ref="formRef"
name="custom-validation"
:model="hostForm.form"
:rules="hostForm.rules"
v-bind="layout"
@finish="handleFinish"
@validate="handleValidate"
@finishFailed="handleFinishFailed"
>
<a-form-item label="主机类别" prop="zone" name="category">
<a-row>
<a-col :span="12">
<a-select
ref="select"
v-model:value="hostForm.form.category"
@change="handleCategorySelectChange"
>
<a-select-option :value="category.id" v-for="category in categoryList.data" :key="category.id">
{{ category.name }}
</a-select-option>
</a-select>
</a-col>
</a-row>
</a-form-item>
<a-form-item has-feedback label="主机名称" name="name">
<a-input v-model:value="hostForm.form.name" type="text" autocomplete="off"/>
</a-form-item>
<a-form-item has-feedback label="连接地址" name="username">
<a-row>
<a-col :span="8">
<a-input placeholder="用户名" addon-before="ssh" v-model:value="hostForm.form.username" type="text"
autocomplete="off"/>
</a-col>
<a-col :span="8">
<a-input placeholder="ip地址" addon-before="@" v-model:value="hostForm.form.ip_addr" type="text"
autocomplete="off"/>
</a-col>
<a-col :span="8">
<a-input placeholder="端口号" addon-before="-p" v-model:value="hostForm.form.port" type="text"
autocomplete="off"/>
</a-col>
</a-row>
</a-form-item>
<a-form-item has-feedback label="连接密码" name="password">
<a-input v-model:value="hostForm.form.password" type="password" autocomplete="off"/>
</a-form-item>
<a-form-item has-feedback label="备注信息" name="remark">
<a-textarea placeholder="请输入主机备注信息" v-model:value="hostForm.form.remark" type="text"
:auto-size="{ minRows: 3, maxRows: 5 }" autocomplete="off"/>
</a-form-item>
<a-form-item :wrapper-col="{ span: 14, offset: 4 }">
<a-button @click="resetForm">Reset</a-button>
</a-form-item>
</a-form>
</a-modal>
<a-modal
:width="600"
title="新建主机类别"
:visible="HostCategoryFromVisible"
@cancel="hostCategoryFormCancel"
>
<template #footer>
<a-button key="back" @click="hostCategoryFormCancel">取消</a-button>
<a-button key="submit" type="primary" :loading="loading" @click="onHostCategoryFromSubmit">提交</a-button>
</template>
<a-form-model ref="hostCategoryRuleForm" v-model:value="hostCategoryForm.form" :rules="hostCategoryForm.rules"
:label-col="hostCategoryForm.labelCol" :wrapper-col="hostCategoryForm.wrapperCol">
<a-form-model-item ref="name" label="类别名称" prop="name">
<a-row>
<a-col :span="24">
<a-input placeholder="请输入主机类别名称" v-model:value="hostCategoryForm.form.name"/>
</a-col>
</a-row>
</a-form-model-item>
</a-form-model>
</a-modal>
</div>
</template>
<script>
import {defineComponent, ref, reactive} from 'vue';
import axios from "axios";
import settings from "@/settings";
import store from "@/store";
import {message} from 'ant-design-vue';
export default {
setup() {
const handleChange = value => {
console.log(`selected ${value}`);
};
const handleCategorySelectChange = (value) => {
// 切换主机类别的回调处理
console.log(value)
};
const formRef = ref();
const HostCategoryFromVisible = ref(false);
const hostList = reactive({
data: [
{
'id': 1,
'category_name': '数据库服务器',
'name': 'izbp13e05jqwodd605vm3gz',
'ip_addr': '47.58.131.12',
'port': 22,
'remark': ''
},
{
'id': 2,
'category_name': '数据库服务器',
'name': 'iZbp1a3jw4l12ho53ivhkkZ',
'ip_addr': '12.18.125.22',
'port': 22,
'remark': ''
},
{
'id': 3,
'category_name': '缓存服务器',
'name': 'iZbp1b1xqfqw257gs563k2iZ',
'ip_addr': '12.19.135.130',
'port': 22,
'remark': ''
},
{
'id': 4,
'category_name': '缓存服务器',
'name': 'iZbp1b1jw4l01ho53muhkkZ',
'ip_addr': '47.98.101.89',
'port': 22,
'remark': ''
}
]
})
const categoryList = reactive({
data: [
{'id': 1, 'name': '数据库服务'},
{'id': 2, 'name': '缓存服务'},
{'id': 3, 'name': 'web服务'},
{'id': 4, 'name': '静态文件存储服务'}
]
})
const hostForm = reactive({
labelCol: {span: 6},
wrapperCol: {span: 14},
other: '',
form: {
name: '',
category: "",
ip_addr: '',
username: '',
port: '',
remark: '',
password: ''
},
rules: {
name: [
{required: true, message: '请输入主机名称', trigger: 'blur'},
{min: 3, max: 10, message: '长度在3-10位之间', trigger: 'blur'}
],
password: [
{required: true, message: '请输入连接密码', trigger: 'blur'},
{min: 3, max: 10, message: '长度在3-10位之间', trigger: 'blur'}
],
category: [
{required: true, message: '请选择类别', trigger: 'change'}
],
username: [
{required: true, message: '请输入用户名', trigger: 'blur'},
{min: 3, max: 10, message: '长度在3-10位', trigger: 'blur'}
],
ip_addr: [
{required: true, message: '请输入连接地址', trigger: 'blur'},
{max: 15, message: '长度最大15位', trigger: 'blur'}
],
port: [
{required: true, message: '请输入端口号', trigger: 'blur'},
{max: 5, message: '长度最大5位', trigger: 'blur'}
]
}
});
let validateName = async (_rule, value) => {
if (value === '') {
return Promise.reject('请输入类别名称');
} else {
return Promise.resolve();
}
};
const hostCategoryForm = reactive({
labelCol: {span: 6},
wrapperCol: {span: 14},
other: '',
form: {
name: ''
},
rules: {
name: [{
required: true,
message: '请输入类别名称',
validator: validateName,
trigger: 'blur'
},
{min: 3, max: 10, message: '长度在3-10位之间', trigger: 'blur'}
]
}
})
const layout = {
labelCol: {
span: 4,
},
wrapperCol: {
span: 14,
},
};
const handleFinish = values => {
console.log(values, hostForm);
};
const handleFinishFailed = errors => {
console.log(errors);
};
const resetForm = () => {
formRef.value.resetFields();
};
const handleValidate = (...args) => {
console.log(args);
};
const hostFormVisible = ref(false);
const showHostModal = () => {
hostFormVisible.value = true;
};
const showHostCategoryFormModal = () => {
// 显示添加主机类别的表单窗口
HostCategoryFromVisible.value = true
}
const hostCategoryFormCancel = () => {
// 添加主机类别的表单取消
hostCategoryForm.form.name = ""; // 清空表单内容
HostCategoryFromVisible.value = false // 关闭对话框
}
const onHostCategoryFromSubmit = () => {
// 添加主机类别的表单提交处理
}
const onHostFormSubmit = () => {
// 添加主机的表单提交处理
}
const get_host_list = () => {
// 获取主机类别列表
}
const get_category_list = () => {
// 获取主机类别列表
}
const deleteHost = record => {
// 删除主机接口
}
get_host_list()
get_category_list()
return {
selectHostCategory: ref('yuan'),
hostForm,
formRef,
layout,
HostCategoryFromVisible,
handleCategorySelectChange,
handleFinishFailed,
handleFinish,
resetForm,
handleValidate,
hostFormVisible,
showHostModal,
onHostFormSubmit,
deleteHost,
showHostCategoryFormModal,
hostCategoryForm,
hostCategoryFormCancel,
onHostCategoryFromSubmit,
hostFormColumns: [
{
title: '类别',
dataIndex: 'category_name',
key: 'category_name'
},
{
title: '主机名称',
dataIndex: 'name',
key: 'name',
sorter: true,
width: 230
},
{
title: '连接地址',
dataIndex: 'ip_addr',
key: 'ip_addr',
ellipsis: true,
sorter: true,
width: 150
},
{
title: '端口',
dataIndex: 'port',
key: 'port',
ellipsis: true
},
{
title: '备注信息',
dataIndex: 'remark',
key: 'remark',
ellipsis: true
},
{
title: '操作',
key: 'action',
width: 200,
dataIndex: "action",
scopedSlots: {customRender: 'action'}
}
],
hostList,
categoryList,
};
},
};
</script>
4.1.2、主机类别与主机接口
(1)创建app
cd uric_api/apps/
python ../../manage.py startapp host
(2)注册
INSTALLED_APPS = [
'host',
]
(3)构建路由
总路由
path('host/', include('host.urls')),
在host应用中创建urls.py文件,写如下内容
from django.urls import path,re_path
from host import views
urlpatterns = [
]
(4)模型models
urci_api/utils/models.py
基础模型类
from django.db import models
# Create your models here.
class BaseModel(models.Model):
"""公共模型"""
name = models.CharField(max_length=500,default="", null=True, blank=True, verbose_name='名称/标题')
is_show = models.BooleanField(default=True, verbose_name="是否显示")
orders = models.IntegerField(default=1, verbose_name="排序")
is_deleted = models.BooleanField(default=False, verbose_name="是否删除")
created_time = models.DateTimeField(auto_now_add=True, verbose_name="添加时间")
updated_time = models.DateTimeField(auto_now=True, verbose_name="修改时间")
description = models.TextField(null=True, blank=True, default="", verbose_name="描述信息")
class Meta:
# 数据库迁移时,设置了bstract = True的model类不会生成数据库表
abstract = True
def __str__(self):
return self.name
host/models.py
的主机相关的模型代码
from django.db import models
# Create your models here.
from uric_api.utils.models import BaseModel, models
from users.models import User
class HostCategory(BaseModel):
"""主机类别"""
class Meta:
db_table = "host_category"
verbose_name = "主机类别"
verbose_name_plural = verbose_name # 取消提示文字中关于英文复数+s的情况
class Host(BaseModel):
# 真正在数据库中的字段实际上叫 category_id,而category则代表了关联的哪个分类模型对象
category = models.ForeignKey('HostCategory', on_delete=models.DO_NOTHING, verbose_name='主机类别', related_name='hc',
null=True, blank=True)
ip_addr = models.CharField(blank=True, null=True, max_length=500, verbose_name='连接地址')
port = models.IntegerField(verbose_name='端口')
username = models.CharField(max_length=50, verbose_name='登录用户')
users = models.ManyToManyField(User)
class Meta:
db_table = "host"
verbose_name = "主机信息"
verbose_name_plural = verbose_name
def __str__(self):
return self.name + ':' + self.ip_addr
(5)序列化器
host.serializers
代码:
from rest_framework import serializers
from . import models
class HostCategoryModelSeiralizer(serializers.ModelSerializer):
"""主机分类的序列化器"""
class Meta:
model = models.HostCategory
fields = ['id', 'name']
class HostModelSerializers(serializers.ModelSerializer):
"""主机信息的序列化器"""
category_name = serializers.CharField(source='category.name', read_only=True)
password = serializers.CharField(max_length=32, write_only=True, label="登录密码")
class Meta:
model = models.Host
fields = ['id', 'category', 'category_name', 'name', 'ip_addr', 'port', 'description', 'username', 'password']
def validate(self, attrs):
"""当用户添加、编辑主机信息会自动执行这个方法"""
ip_addr = attrs.get('ip_addr')
port = attrs.get('port')
username = attrs.get('username')
password = attrs.get('password')
# todo 验证主机信息是否正确
return attrs
# 添加host记录,如果第一次添加host记录,那么需要我们生成全局的公钥和私钥
def create(self, validated_data):
print('接受通过验证以后的数据字典:',validated_data)
ip_addr = validated_data.get('ip_addr')
port = validated_data.get('port')
username = validated_data.get('username')
password = validated_data.get('password')
# todo 生成公私钥和管理主机的公私钥
# 剔除密码字段,保存host记录
password = validated_data.pop('password')
instance = models.Host.objects.create(
**validated_data
)
return instance
(6)视图函数:
# Create your views here.
from rest_framework.generics import ListAPIView,CreateAPIView
from rest_framework.viewsets import ModelViewSet
from .models import HostCategory,Host
from .serializers import HostCategoryModelSeiralizer,HostModelSerializers
from rest_framework.permissions import IsAuthenticated
class HostCategoryListAPIView(ListAPIView, CreateAPIView):
"""主机类别"""
queryset = HostCategory.objects.filter(is_show=True, is_deleted=False).order_by("orders","-id").all()
serializer_class = HostCategoryModelSeiralizer
permission_classes = [IsAuthenticated]
class HostModelViewSet(ModelViewSet):
"""主机信息"""
queryset = Host.objects.all()
serializer_class = HostModelSerializers
permission_classes = [IsAuthenticated]
路由,代码:
from django.urls import path, include, re_path
from host import views
urlpatterns = [
path('', views.HostModelViewSet.as_view({'get': 'list', 'post': 'create'})),
re_path('(?P<pk>\d+)', views.HostModelViewSet.as_view({'delete': 'destroy'})),
path('category', views.HostCategoryListAPIView.as_view()),
]
测试主机类别接口:
测试主机接口:
4.1.3、前端对接后端接口
<template>
<a-row>
<a-col :span="6">
<div class="add_host" style="margin: 15px;">
<a-button @click="showHostModal" type="primary">
新建
</a-button>
<a-button type="primary" style="margin-left: 20px;">
批量导入
</a-button>
</div>
</a-col>
</a-row>
<a-table :dataSource="hostList.data" :columns="hostFormColumns">
<template #bodyCell="{ column, text, record }">
<template v-if="column.dataIndex === 'action'">
<a-popconfirm
v-if="hostList.data.length"
title="Sure to delete?"
@confirm="deleteHost(record)"
>
<a>Delete</a>
</a-popconfirm>
</template>
</template>
</a-table>
<div>
<a-modal v-model:visible="hostFormVisible" title="添加主机" @ok="onHostFormSubmit" @cancel="resetForm()" :width="800">
<a-form
ref="formRef"
name="custom-validation"
:model="hostForm.form"
:rules="hostForm.rules"
v-bind="layout"
@finish="handleFinish"
@validate="handleValidate"
@finishFailed="handleFinishFailed"
>
<a-form-item label="主机类别" prop="zone" name="category">
<a-row>
<a-col :span="12">
<a-select
ref="select"
v-model:value="hostForm.form.category"
@change="handleCategorySelectChange"
>
<a-select-option :value="category.id" v-for="category in categoryList.data" :key="category.id">
{{ category.name }}
</a-select-option>
</a-select>
</a-col>
</a-row>
</a-form-item>
<a-form-item has-feedback label="主机名称" name="name">
<a-input v-model:value="hostForm.form.name" type="text" autocomplete="off"/>
</a-form-item>
<a-form-item has-feedback label="连接地址" name="username">
<a-row>
<a-col :span="8">
<a-input placeholder="用户名" addon-before="ssh" v-model:value="hostForm.form.username" type="text"
autocomplete="off"/>
</a-col>
<a-col :span="8">
<a-input placeholder="ip地址" addon-before="@" v-model:value="hostForm.form.ip_addr" type="text"
autocomplete="off"/>
</a-col>
<a-col :span="8">
<a-input placeholder="端口号" addon-before="-p" v-model:value="hostForm.form.port" type="text"
autocomplete="off"/>
</a-col>
</a-row>
</a-form-item>
<a-form-item has-feedback label="连接密码" name="password">
<a-input v-model:value="hostForm.form.password" type="password" autocomplete="off"/>
</a-form-item>
<a-form-item has-feedback label="备注信息" name="remark">
<a-textarea placeholder="请输入主机备注信息" v-model:value="hostForm.form.remark" type="text"
:auto-size="{ minRows: 3, maxRows: 5 }" autocomplete="off"/>
</a-form-item>
<a-form-item :wrapper-col="{ span: 14, offset: 4 }">
<a-button @click="resetForm">Reset</a-button>
</a-form-item>
</a-form>
</a-modal>
<a-modal
:width="600"
title="新建主机类别"
:visible="HostCategoryFromVisible"
@cancel="hostCategoryFormCancel"
>
<template #footer>
<a-button key="back" @click="hostCategoryFormCancel">取消</a-button>
<a-button key="submit" type="primary" :loading="loading" @click="onHostCategoryFromSubmit">提交</a-button>
</template>
<a-form-model ref="hostCategoryRuleForm" v-model:value="hostCategoryForm.form" :rules="hostCategoryForm.rules"
:label-col="hostCategoryForm.labelCol" :wrapper-col="hostCategoryForm.wrapperCol">
<a-form-model-item ref="name" label="类别名称" prop="name">
<a-row>
<a-col :span="24">
<a-input placeholder="请输入主机类别名称" v-model:value="hostCategoryForm.form.name"/>
</a-col>
</a-row>
</a-form-model-item>
</a-form-model>
</a-modal>
</div>
</template>
<script>
import {defineComponent, ref, reactive} from 'vue';
import axios from "axios";
import settings from "@/settings";
import store from "@/store";
import {message} from 'ant-design-vue';
export default {
setup() {
const handleChange = value => {
console.log(`selected ${value}`);
};
const handleCategorySelectChange = (value) => {
// 切换主机类别的回调处理
console.log(value)
};
const formRef = ref();
const HostCategoryFromVisible = ref(false);
const hostList = reactive({
data: [
{
'id': 1,
'category_name': '数据库服务器',
'name': 'izbp13e05jqwodd605vm3gz',
'ip_addr': '47.58.131.12',
'port': 22,
'remark': ''
},
{
'id': 2,
'category_name': '数据库服务器',
'name': 'iZbp1a3jw4l12ho53ivhkkZ',
'ip_addr': '12.18.125.22',
'port': 22,
'remark': ''
},
{
'id': 3,
'category_name': '缓存服务器',
'name': 'iZbp1b1xqfqw257gs563k2iZ',
'ip_addr': '12.19.135.130',
'port': 22,
'remark': ''
},
{
'id': 4,
'category_name': '缓存服务器',
'name': 'iZbp1b1jw4l01ho53muhkkZ',
'ip_addr': '47.98.101.89',
'port': 22,
'remark': ''
}
]
})
const categoryList = reactive({
data: [
{'id': 1, 'name': '数据库服务'},
{'id': 2, 'name': '缓存服务'},
{'id': 3, 'name': 'web服务'},
{'id': 4, 'name': '静态文件存储服务'}
]
})
const hostForm = reactive({
labelCol: {span: 6},
wrapperCol: {span: 14},
other: '',
form: {
name: '',
category: "",
ip_addr: '',
username: '',
port: '',
remark: '',
password: ''
},
rules: {
name: [
{required: true, message: '请输入主机名称', trigger: 'blur'},
{min: 3, max: 10, message: '长度在3-10位之间', trigger: 'blur'}
],
password: [
{required: true, message: '请输入连接密码', trigger: 'blur'},
{min: 3, max: 10, message: '长度在3-10位之间', trigger: 'blur'}
],
category: [
{required: true, message: '请选择类别', trigger: 'change'}
],
username: [
{required: true, message: '请输入用户名', trigger: 'blur'},
{min: 3, max: 10, message: '长度在3-10位', trigger: 'blur'}
],
ip_addr: [
{required: true, message: '请输入连接地址', trigger: 'blur'},
{max: 15, message: '长度最大15位', trigger: 'blur'}
],
port: [
{required: true, message: '请输入端口号', trigger: 'blur'},
{max: 5, message: '长度最大5位', trigger: 'blur'}
]
}
});
let validateName = async (_rule, value) => {
if (value === '') {
return Promise.reject('请输入类别名称');
} else {
return Promise.resolve();
}
};
const hostCategoryForm = reactive({
labelCol: {span: 6},
wrapperCol: {span: 14},
other: '',
form: {
name: ''
},
rules: {
name: [{
required: true,
message: '请输入类别名称',
validator: validateName,
trigger: 'blur'
},
{min: 3, max: 10, message: '长度在3-10位之间', trigger: 'blur'}
]
}
})
const layout = {
labelCol: {
span: 4,
},
wrapperCol: {
span: 14,
},
};
const handleFinish = values => {
console.log(values, hostForm);
};
const handleFinishFailed = errors => {
console.log(errors);
};
const resetForm = () => {
formRef.value.resetFields();
};
const handleValidate = (...args) => {
console.log(args);
};
const hostFormVisible = ref(false);
const showHostModal = () => {
hostFormVisible.value = true;
};
const onHostFormSubmit = () => {
// 将数据提交到后台进行保存,但是先进行连接校验,验证没有问题,再保存
const formData = new FormData();
for (let attr in hostForm.form) {
formData.append(attr, hostForm.form[attr])
}
axios.post(`${settings.host}/host/`, formData, {
headers: {
Authorization: store.getters.token,
}
}
).then((response) => {
console.log("response>>>", response)
hostList.data.unshift(response.data)
// 清空
resetForm()
hostFormVisible.value = false; // 关闭对话框
message.success('成功添加主机信息!')
}).catch((response) => {
message.error(response.data)
});
}
const deleteHost = record => {
console.log(record);
axios.delete(`${settings.host}/host/${record.id}`, {
headers: {
Authorization: store.getters.token
}
}).then(response => {
let index = hostList.data.indexOf(record)
hostList.data.splice(index, 1);
}).catch(err => {
message.error('删除主机失败!')
})
}
const showHostCategoryFormModal = () => {
// 显示添加主机类别的表单窗口
HostCategoryFromVisible.value = true
}
const hostCategoryFormCancel = () => {
// 添加主机类别的表单取消
hostCategoryForm.form.name = ""; // 清空表单内容
HostCategoryFromVisible.value = false // 关闭对话框
}
const onHostCategoryFromSubmit = () => {
// 添加主机类别的表单提交处理
// 将数据提交到后台进行保存,但是先进行连接校验,验证没有问题,再保存
axios.post(`${settings.host}/host/category`, hostCategoryForm.form, {
headers: {
Authorization: store.getters.token
}
}).then(response => {
message.success({
content: "创建主机类别成功!",
duration: 1,
}).then(() => {
console.log("response:::", response)
categoryList.data.unshift(response.data)
hostCategoryFormCancel()
})
})
}
const get_host_list = () => {
// 获取主机类别列表
axios.get(`${settings.host}/host`, {
headers: {
Authorization: store.getters.token
}
}).then(response => {
hostList.data = response.data
}).catch(err => {
message.error('无法获取主机类别列表信息!')
})
}
const get_category_list = () => {
// 获取主机类别列表
axios.get(`${settings.host}/host/category`, {
headers: {
Authorization: store.getters.token
}
}).then(response => {
categoryList.data = response.data
}).catch(err => {
message.error('无法获取主机类别列表信息!')
})
}
// 获取主机列表
get_host_list()
get_category_list()
return {
selectHostCategory: ref('yuan'),
hostForm,
formRef,
layout,
HostCategoryFromVisible,
handleCategorySelectChange,
handleFinishFailed,
handleFinish,
resetForm,
handleValidate,
hostFormVisible,
showHostModal,
onHostFormSubmit,
deleteHost,
showHostCategoryFormModal,
hostCategoryForm,
hostCategoryFormCancel,
onHostCategoryFromSubmit,
hostFormColumns: [
{
title: '类别',
dataIndex: 'category_name',
key: 'category_name'
},
{
title: '主机名称',
dataIndex: 'name',
key: 'name',
sorter: true,
width: 230
},
{
title: '连接地址',
dataIndex: 'ip_addr',
key: 'ip_addr',
ellipsis: true,
sorter: true,
width: 150
},
{
title: '端口',
dataIndex: 'port',
key: 'port',
ellipsis: true
},
{
title: '备注信息',
dataIndex: 'remark',
key: 'remark',
ellipsis: true
},
{
title: '操作',
key: 'action',
width: 200,
dataIndex: "action",
scopedSlots: {customRender: 'action'}
}
],
hostList,
categoryList,
};
},
};
</script>
4.1.4、ssh与paramiko模块
(1)ssh命令
ssh命令是openssh套件中的客户端连接工具,可以给予ssh加密协议实现安全的远程登录服务器,实现对服务器的远程管理。
简单说,SSH是一种网络协议,用于计算机之间的加密登录。如果一个用户从本地计算机,使用SSH协议登录另一台远程计算机,我们就可以认为,这种登录是安全的,即使被中途截获,密码也不会泄露。最早的时候,互联网通信都是明文通信,一旦被截获,内容就暴露无疑。1995年,芬兰学者Tatu Ylonen设计了SSH协议,将登录信息全部加密,成为互联网安全的一个基本解决方案,迅速在全世界获得推广,目前已经成为Linux系统的标准配置。
SSH(远程连接工具)连接原理:ssh服务是一个守护进程(demon),系统后台监听客户端的连接,ssh服务端的进程名为sshd,负责实时监听客户端的请求(IP 22端口),包括公共秘钥等交换等信息。
ssh服务端由2部分组成: openssh(提供ssh服务) openssl(提供加密的程序)
- 检查主机上有没有安装SSH服务,使用命令:ssh 若提示命令未找到,则需要安装ssh服务;步骤如下:输入sudo apt-get update命令以实现更新Ubuntu系统–>输入sudo apt-get install openssh-server命令以安装ssh 若输出ssh命令的使用说明,则代表已经安装了。
- 检查主机上有没有启动SSH服务,使用命令:service –status-all | grep ssh 若服务已经启动的话,可以看到[+] ssh 若服务还没启动的话,可以看到[-] ssh
- 启动ssh服务,使用命令sudo service sshd start
SSH远程登录之口令登录
ssh 用户名@IP地址 -p 端口号
SSH的默认端口是22
(2)paramiko模块
Paramiko是SSHv2协议的Python(2.7,3.4+)实现,同时提供了客户端和服务器功能。尽管Paramiko利用Python C扩展进行了低级加密(Cryptography),但它本身是围绕SSH网络概念的纯Python接口,通过paramiko我们可以完成远程主机连接,指令执行、上传下载文件等操作。下面学习具体用法
官方网址: http://www.paramiko.org/
详细api接口文档:http://docs.paramiko.org/en/stable/
- SSHClient的作用类似于Linux的ssh命令,是对SSH会话的封装,该类封装了传输(Transport),通道(Channel)及SFTPClient建立的方法(open_sftp),通常用于执行远程命令。
- SFTPClient的作用类似与Linux的sftp命令,是对SFTP客户端的封装,用以实现远程文件操作,如文件上传、下载、修改文件权限等操作。
ssh连接并执行指令,我们使用paramiko,paramiko依赖于pycrypto模块,所以我们先安装pycrypto
pip install pycrypto
pip install paramiko
Paramiko中的几个概念:
- Client:ssh客户端短连接模式
- Transport:可以建立ssh会话长连接模式
- Channel:是一种类Socket,一种安全的SSH传输通道;
- Transport:是一种加密的会话,使用时会同步创建了一个加密的Tunnels(通道),这个Tunnels叫做Channel;需要open_session来完成长连接对话。
- Session:是client与Server保持连接的对象,用connect()/start_client()/start_server()开始会话。
示例1: client模式,直接执行指令
import paramiko
import traceback
from paramiko.ssh_exception import AuthenticationException
if __name__ == '__main__':
# 通过parammiko创建一个ssh短连接客户端实例对象
ssh = paramiko.SSHClient()
# 自动在本机第一次连接远程服务器时,记录主机指纹
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
# 1. 直接密码远程连接的方式
ssh.connect(hostname='', port=22, username='root', password='', timeout=10)
# 注意,如果你测试某个服务器的连接时,如果你本地已经配置了这个远程服务器的免密登录(公私钥模式),那么就不能测试出密码是否正确了,因为首先会通过公私钥模式登录,不会使用你的密码的。
# 2. 使用秘钥免密登录的方式
# pkey = PkeyModel.objects.get(name='').private
# pkey = RSAKey.from_private_key(StringIO(pkey))
# ssh.connect(hostname='47.98.130.212', port=22, username='root', pkey=pkey, timeout=10)
# 连接成功以后,就可以发送操作指令
# stdin 输入[本机发送给远程主机的信息]
# stdout 输出[远程主机返回给本机的信息]
# stderr 错误
stdin, stdout, stderr = ssh.exec_command('ls -la')
# 读取stdout对象中返回的内容,返回结果bytes类型数据
result = stdout.read()
print( result.decode() )
# 关闭连接
ssh.close()
except AuthenticationException as e:
print(e.message)
print(traceback.format_exc())
print("连接参数有误,请检查连接信息是否正确!~")
示例2: transport模式
import paramiko
import traceback
from paramiko.ssh_exception import AuthenticationException
if __name__ == '__main__':
# 通过parammiko创建一个ssh短连接客户端实例对象
ssh = paramiko.SSHClient()
# 自动在本机第一次连接远程服务器时,记录主机指纹
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
# 1. 直接密码远程连接的方式
ssh.connect(hostname='47.98.130.212', port=22, username='root', password='123', timeout=10)
# 注意,如果你测试某个服务器的连接时,如果你本地已经配置了这个远程服务器的免密登录(公私钥模式),那么就不能测试出密码是否正确了,因为首先会通过公私钥模式登录,不会使用你的密码的。
# 2. 使用秘钥免密登录的方式
# pkey = PkeyModel.objects.get(name='xxxxxxx').private
# pkey = RSAKey.from_private_key(StringIO(pkey))
# ssh.connect(hostname='47.98.130.212', port=22, username='root', pkey=pkey, timeout=10)
while True:
# 保存本次ssh的连接的回话状态
cli = ssh.get_transport().open_session()
# 设置回话超时时间
cli.settimeout(120)
command = input("请输入您要发送的指令:")
if command == "exit":
break
# 发送指令
cli.exec_command(command)
# 接受操作指令以后,远程主机返回的结果
stdout = cli.makefile("rb", -1)
# 读取结果并转换编码
content = stdout.read().decode()
print(content)
# 关闭连接
ssh.close()
except AuthenticationException as e:
print(e.message)
print(traceback.format_exc())
远程执行命令
该命令的输入与输出流为标准输入(stdin)、输出(stdout)、错误(stderr)的Python文件对像
命令执行完毕后,通道将关闭,不能再使用。如果您想执行另一个命令,您必须打开一个新频道。
exec_command(command, bufsize=-1, timeout=None, get_pty=False, environment=None)
参数说明:
command(str类型),执行的命令串;
bufsize(int类型),文件缓冲区大小,默认为-1(不限制)
get_pty( term='vt100' , width=80 , height=24 , width_pixels=0 , height_pixels=0 )
在远程服务器上生成新的交互式shell
invoke_shell(term='vt100', width=80, height=24, width_pixels=0, height_pixels=0, environment=None)
在此频道上请求交互式 shell 会话。如果服务器允许,则通道将直接连接到 shell 的 stdin、stdout 和 stderr。
通常您会在此之前调用get_pyt,在这种情况下,shell 将通过 pty 进行操作,并且通道将连接到 pty 的 stdin 和 stdout。
当shell退出时,通道将被关闭并且不能被重用。如果您想打开另一个 shell,您必须打开一个新频道。
例如:在SSH server端创建一个交互式的shell,且可以按自己的需求配置伪终端,可以在invoke_shell()函数中添加参数配置
chan = ssh.invoke_shell()
chan.send(cmd) #利用send函数发送cmd到SSH server,添加做回车来执行shell命令(cmd中需要有\n命令才能执行)。注意不同的情况,如果执行完telnet命令后,telnet的换行符是\r\n
chan.recv(bufsize) #通过recv函数获取回显
一般回显数据较多,需要通过while循环读取回显数据
(3)基于ssh的主机验证功能
创建utils.ssh.py
文件,集成ssh相关功能:
from paramiko.client import SSHClient, AutoAddPolicy
from paramiko.rsakey import RSAKey
from paramiko.ssh_exception import AuthenticationException, SSHException
from io import StringIO
from paramiko.ssh_exception import NoValidConnectionsError
class SSHParamiko(object):
def __init__(self, hostname, port=22, username='root', pkey=None, password=None, connect_timeout=2):
if pkey is None and password is None:
raise SSHException('私钥或者密码必须选择传入一个')
self.client = None
self.params = {
'hostname': hostname,
'port': port,
'username': username,
'password': password,
'pkey': RSAKey.from_private_key(StringIO(pkey)) if isinstance(pkey, str) else pkey,
'timeout': connect_timeout,
}
# 检测连接并获取连接
def is_validated(self):
print(f"self.client={self.client}")
if self.client is not None:
# 告知当前执行上下文,self.client已经实例化
raise RuntimeError('已经建立连接了!!!')
print(f"正在ping通{self.client}")
if not self.client:
try:
# 创建客户端连接对象
self.client = SSHClient()
# 在本机第一次连接远程主机时记录指纹信息
self.client.set_missing_host_key_policy(AutoAddPolicy)
# 建立连接
self.client.connect(**self.params)
except (TimeoutError, NoValidConnectionsError, AuthenticationException) as e:
return None
return True
在host.serializers.py
中添加验证功能:
from rest_framework import serializers
from . import models
from uric_api.utils.ssh import SSHParamiko
class HostCategoryModelSeiralizer(serializers.ModelSerializer):
"""主机分类的序列化器"""
class Meta:
model = models.HostCategory
fields = ['id', 'name']
class HostModelSerializers(serializers.ModelSerializer):
"""主机信息的序列化器"""
password = serializers.CharField(max_length=32, write_only=True, label="登录密码")
category_name = serializers.CharField(source="category.name", read_only=True)
class Meta:
model = models.Host
fields = ['id', "category_name", 'category', 'name', 'ip_addr', 'port', 'description', 'username', 'password']
def validate(self, attrs):
"""当用户添加、编辑主机信息会自动执行这个方法"""
ip_addr = attrs.get('ip_addr')
port = attrs.get('port')
username = attrs.get('username')
password = attrs.get('password')
# todo 基于ssh验证主机信息是否正确
cli = SSHParamiko(ip_addr, port, username, password=str(password))
if cli.is_validated(): # 测试该链接是否能够使用
return attrs
raise serializers.ValidationError("主机认证失败,用户或密码错误!")
# 添加host记录,如果第一次添加host记录,那么需要我们生成全局的公钥和私钥
def create(self, validated_data):
print('接受通过验证以后的数据字典:', validated_data)
ip_addr = validated_data.get('ip_addr')
port = validated_data.get('port')
username = validated_data.get('username')
password = validated_data.get('password')
# todo 生成公私钥和管理主机的公私钥
# 剔除密码字段,保存host记录
password = validated_data.pop('password')
instance = models.Host.objects.create(
**validated_data
)
return instance
4.2、批量导入主机数据
4.2.1、客户端发送excel文件
Host.vue
<template>
<a-row>
<a-col :span="6">
<div class="add_host" style="margin: 15px;">
<a-button @click="showHostModal" type="primary">
新建
</a-button>
<a-button type="primary" @click="showExcelModal" style="margin-left: 20px;">
批量导入
</a-button>
</div>
</a-col>
</a-row>
<a-table :dataSource="hostList.data" :columns="hostFormColumns">
<template #bodyCell="{ column, text, record }">
<template v-if="column.dataIndex === 'action'">
<a-popconfirm
v-if="hostList.data.length"
title="Sure to delete?"
@confirm="deleteHost(record)"
>
<a>Delete</a>
</a-popconfirm>
</template>
</template>
</a-table>
<a-modal v-model:visible="hostFormVisible" title="添加主机" @ok="onHostFormSubmit" @cancel="resetForm()" :width="800">
<a-form
ref="formRef"
name="custom-validation"
:model="hostForm.form"
:rules="hostForm.rules"
v-bind="layout"
@finish="handleFinish"
@validate="handleValidate"
@finishFailed="handleFinishFailed"
>
<a-form-item label="主机类别" prop="zone" name="category">
<a-row>
<a-col :span="12">
<a-select
ref="select"
v-model:value="hostForm.form.category"
@change="handleCategorySelectChange"
>
<a-select-option :value="category.id" v-for="category in categoryList.data" :key="category.id">
{{ category.name }}
</a-select-option>
</a-select>
</a-col>
</a-row>
</a-form-item>
<a-form-item has-feedback label="主机名称" name="name">
<a-input v-model:value="hostForm.form.name" type="text" autocomplete="off"/>
</a-form-item>
<a-form-item has-feedback label="连接地址" name="username">
<a-row>
<a-col :span="8">
<a-input placeholder="用户名" addon-before="ssh" v-model:value="hostForm.form.username" type="text"
autocomplete="off"/>
</a-col>
<a-col :span="8">
<a-input placeholder="ip地址" addon-before="@" v-model:value="hostForm.form.ip_addr" type="text"
autocomplete="off"/>
</a-col>
<a-col :span="8">
<a-input placeholder="端口号" addon-before="-p" v-model:value="hostForm.form.port" type="text"
autocomplete="off"/>
</a-col>
</a-row>
</a-form-item>
<a-form-item has-feedback label="连接密码" name="password">
<a-input v-model:value="hostForm.form.password" type="password" autocomplete="off"/>
</a-form-item>
<a-form-item has-feedback label="备注信息" name="remark">
<a-textarea placeholder="请输入主机备注信息" v-model:value="hostForm.form.remark" type="text"
:auto-size="{ minRows: 3, maxRows: 5 }" autocomplete="off"/>
</a-form-item>
<a-form-item :wrapper-col="{ span: 14, offset: 4 }">
<a-button @click="resetForm">Reset</a-button>
</a-form-item>
</a-form>
</a-modal>
<a-modal
:width="600"
title="新建主机类别"
:visible="HostCategoryFromVisible"
@cancel="hostCategoryFormCancel"
>
<template #footer>
<a-button key="back" @click="hostCategoryFormCancel">取消</a-button>
<a-button key="submit" type="primary" :loading="loading" @click="onHostCategoryFromSubmit">提交</a-button>
</template>
<a-form-model ref="hostCategoryRuleForm" v-model:value="hostCategoryForm.form" :rules="hostCategoryForm.rules"
:label-col="hostCategoryForm.labelCol" :wrapper-col="hostCategoryForm.wrapperCol">
<a-form-model-item ref="name" label="类别名称" prop="name">
<a-row>
<a-col :span="24">
<a-input placeholder="请输入主机类别名称" v-model:value="hostCategoryForm.form.name"/>
</a-col>
</a-row>
</a-form-model-item>
</a-form-model>
</a-modal>
<!-- 批量导入主机 -->
<div>
<a-modal v-model:visible="excelVisible" title="导入excel批量创建主机" @ok="onExcelSubmit" @cancel="excelFormCancel"
:width="800">
<a-alert type="info" message="导入或输入的密码仅作首次验证使用,并不会存储密码。" banner closable/>
<br/>
<p>
<a-form-item has-feedback label="模板下载" help="请下载使用该模板填充数据后导入">
<a download="主机导入模板.xls">主机导入模板.xls</a>
</a-form-item>
</p>
<p>
<a-form-item label="默认密码"
help="如果Excel中密码为空则使用该密码">
<a-input v-model:value="default_password" placeholder="请输入默认主机密码" type="password"/>
</a-form-item>
</p>
<a-form-item label="导入数据">
<div class="clearfix">
<a-upload
:file-list="fileList"
name="file"
:before-upload="beforeUpload"
>
<a-button>
<upload-outlined></upload-outlined>
Click to Upload
</a-button>
</a-upload>
</div>
</a-form-item>
</a-modal>
</div>
</template>
<script>
import {defineComponent, ref, reactive} from 'vue';
import axios from "axios";
import settings from "@/settings";
import store from "@/store";
import {message} from 'ant-design-vue';
import {UploadOutlined} from '@ant-design/icons-vue';
export default {
components: {
UploadOutlined,
},
setup() {
const handleCategorySelectChange = (value) => {
// 切换主机类别的回调处理
console.log(value)
};
const formRef = ref();
const HostCategoryFromVisible = ref(false);
const default_password = ref("");
const hostList = reactive({
data: []
})
const categoryList = reactive({
data: []
})
const hostForm = reactive({
labelCol: {span: 6},
wrapperCol: {span: 14},
other: '',
form: {
name: '',
category: "",
ip_addr: '',
username: '',
port: '',
remark: '',
password: ''
},
rules: {
name: [
{required: true, message: '请输入主机名称', trigger: 'blur'},
{min: 3, max: 30, message: '长度在3-10位之间', trigger: 'blur'}
],
password: [
{required: true, message: '请输入连接密码', trigger: 'blur'},
{min: 3, max: 30, message: '长度在3-10位之间', trigger: 'blur'}
],
category: [
{required: true, message: '请选择类别', trigger: 'change'}
],
username: [
{required: true, message: '请输入用户名', trigger: 'blur'},
{min: 3, max: 30, message: '长度在3-10位', trigger: 'blur'}
],
ip_addr: [
{required: true, message: '请输入连接地址', trigger: 'blur'},
{max: 30, message: '长度最大15位', trigger: 'blur'}
],
port: [
{required: true, message: '请输入端口号', trigger: 'blur'},
{max: 5, message: '长度最大5位', trigger: 'blur'}
]
}
});
let validateName = async (_rule, value) => {
if (value === '') {
return Promise.reject('请输入类别名称');
} else {
return Promise.resolve();
}
};
const hostCategoryForm = reactive({
labelCol: {span: 6},
wrapperCol: {span: 14},
other: '',
form: {
name: ''
},
rules: {
name: [{
required: true,
message: '请输入类别名称',
validator: validateName,
trigger: 'blur'
},
{min: 3, max: 10, message: '长度在3-10位之间', trigger: 'blur'}
]
}
})
const layout = {
labelCol: {
span: 4,
},
wrapperCol: {
span: 14,
},
};
const handleFinish = values => {
console.log(values, hostForm);
};
const handleFinishFailed = errors => {
console.log(errors);
};
const resetForm = () => {
formRef.value.resetFields();
};
const handleValidate = (...args) => {
console.log(args);
};
const hostFormVisible = ref(false);
const excelVisible = ref(false);
const showHostModal = () => {
hostFormVisible.value = true;
};
const onHostFormSubmit = () => {
// 将数据提交到后台进行保存,但是先进行连接校验,验证没有问题,再保存
const formData = new FormData();
for (let attr in hostForm.form) {
formData.append(attr, hostForm.form[attr])
}
axios.post(`${settings.host}/host/`, formData, {
headers: {
Authorization: store.getters.token,
}
}
).then((response) => {
console.log("response>>>", response)
hostList.data.unshift(response.data)
// 清空
resetForm()
hostFormVisible.value = false; // 关闭对话框
message.success('成功添加主机信息!')
}).catch((err) => {
message.error('添加主机失败')
});
}
const deleteHost = record => {
console.log(record);
axios.delete(`${settings.host}/host/${record.id}`, {
headers: {
Authorization: store.getters.token
}
}).then(response => {
let index = hostList.data.indexOf(record)
hostList.data.splice(index, 1);
}).catch(err => {
message.error('删除主机失败!')
})
}
const showHostCategoryFormModal = () => {
// 显示添加主机类别的表单窗口
HostCategoryFromVisible.value = true
}
const hostCategoryFormCancel = () => {
// 添加主机类别的表单取消
hostCategoryForm.form.name = ""; // 清空表单内容
HostCategoryFromVisible.value = false // 关闭对话框
}
const excelFormCancel = () => {
excelVisible.value = false
}
const onHostCategoryFromSubmit = () => {
// 添加主机类别的表单提交处理
// 将数据提交到后台进行保存,但是先进行连接校验,验证没有问题,再保存
axios.post(`${settings.host}/host/category`, hostCategoryForm.form, {
headers: {
Authorization: store.getters.token
}
}).then(response => {
message.success({
content: "创建主机类别成功!",
duration: 1,
}).then(() => {
console.log("response:::", response)
categoryList.data.unshift(response.data)
hostCategoryFormCancel()
})
})
}
const get_host_list = () => {
// 获取主机类别列表
axios.get(`${settings.host}/host`, {
headers: {
Authorization: store.getters.token
}
}).then(response => {
hostList.data = response.data
}).catch(err => {
message.error('无法获取主机类别列表信息!')
})
}
const get_category_list = () => {
// 获取主机类别列表
axios.get(`${settings.host}/host/category`, {
headers: {
Authorization: store.getters.token
}
}).then(response => {
categoryList.data = response.data
}).catch(err => {
message.error('无法获取主机类别列表信息!')
})
}
// 获取主机列表
get_host_list()
get_category_list()
// 上传excel文件
const showExcelModal = () => {
// 显示批量上传主机的窗口
excelVisible.value = true
}
const handleChange = info => {
if (info.file.status !== 'uploading') {
console.log(info.file, info.fileList);
}
if (info.file.status === 'done') {
message.success(`${info.file.name} file uploaded successfully`);
} else if (info.file.status === 'error') {
message.error(`${info.file.name} file upload failed.`);
}
};
const fileList = ref([]);
const beforeUpload = (file) => {
// 当用户选择上传文件以后,需要手动把当前文件添加到待上传文件列表this.excel_fileList中
fileList.value = [...fileList.value, file];
return false;
}
const onExcelSubmit = () => {
// 将数据提交到后台进行保存,但是先进行连接校验,验证没有问题,再保存
const formData = new FormData();
console.log("fileList.value:", fileList.value)
fileList.value.forEach(file => {
console.log(">>>", file)
formData.append('host_excel', file);
});
axios.post(`${settings.host}/host/excel_host`, formData, {
headers: {
Authorization: store.getters.token,
'Content-Type': 'multipart/form-data', // 上传文件必须设置请求头中的提交内容格式:multipart/form-data
}
}
).then((response) => {
console.log("response:::", response)
console.log("hostList:::", hostList)
excelFormCancel()// 关闭对话框
fileList.value = []
hostList.data.push(...response.data.data)
message.success('批量创建主机成功!!')
}).catch((response) => {
message.error(response.data.message)
});
}
return {
beforeUpload,
onExcelSubmit,
selectHostCategory: ref('yuan'),
hostForm,
formRef,
layout,
HostCategoryFromVisible,
handleCategorySelectChange,
handleFinishFailed,
handleFinish,
resetForm,
handleValidate,
hostFormVisible,
excelVisible,
showHostModal,
onHostFormSubmit,
deleteHost,
showHostCategoryFormModal,
hostCategoryForm,
hostCategoryFormCancel,
excelFormCancel,
onHostCategoryFromSubmit,
showExcelModal,
default_password,
fileList,
headers: {
authorization: 'authorization-text',
},
handleChange,
hostFormColumns: [
{
title: '类别',
dataIndex: 'category_name',
key: 'category_name'
},
{
title: '主机名称',
dataIndex: 'name',
key: 'name',
sorter: true,
width: 230
},
{
title: '连接地址',
dataIndex: 'ip_addr',
key: 'ip_addr',
ellipsis: true,
sorter: true,
width: 150
},
{
title: '端口',
dataIndex: 'port',
key: 'port',
ellipsis: true
},
{
title: '备注信息',
dataIndex: 'remark',
key: 'remark',
ellipsis: true
},
{
title: '操作',
key: 'action',
width: 200,
dataIndex: "action",
scopedSlots: {customRender: 'action'}
}
],
hostList,
categoryList,
};
},
};
</script>
4.2.2、基于excel批量创建主机
host.urls.py
path('excel_host', views.ExcelHostView.as_view()),
host.views.py
from openpyxl import load_workbook
def read_host_excel_data(io_data, default_password=''):
"""
从excel中读取主机列表信息
io_data: 主机列表的字节流
default_password: 主机的默认登录密码
"""
# 加载某一个excel文件
wb = load_workbook(io_data)
# 获取worksheet对象的两种方式
worksheet = wb.worksheets[1]
# c1 = worksheet.cell(2, 1) # 第二行第一列
# print("c1 data:::", c1.value)
# 查询出数据库现有的所有分类数据[ID,name]
# 由于拿到的是分类名称,所以我们要找到对应名称的分类id,才能去数据库里面存储
category_list = HostCategory.objects.values_list('id', 'name')
# 主机列表
host_info_list = []
for row in worksheet.iter_rows(2):
if not row[0].value: continue
one_row_dict = {} # 单个主机信息字典
for category_data in category_list:
# print(category_data[1],type(category_data[1]),category,type(category))
if category_data[1].strip() == row[0].value:
one_row_dict['category'] = category_data[0]
break
one_row_dict["name"] = row[1].value # 主机别名
one_row_dict['ip_addr'] = row[2].value # 主机地址
one_row_dict['port'] = row[3].value # 主机端口号
one_row_dict['username'] = row[4].value # 登录账户名
excel_pwd = row[5].value
try:
pwd = str(excel_pwd) # 这样强转容易报错,最好捕获一下异常,并记录单元格位置,给用户保存信息时,可以提示用户哪个单元格的数据有问题
except Exception as e:
pwd = default_password
if not pwd.strip():
pwd = default_password
one_row_dict['password'] = pwd
one_row_dict['description'] = row[6].value
print("one_row_dict", one_row_dict)
host_info_list.append(one_row_dict)
# 校验主机数据
# 将做好的主机信息字典数据通过我们添加主机时的序列化器进行校验
res_data = {} # 存放上传成功之后需要返回的主机数据和某些错误信息数据
serializers_host_res_data = []
res_error_data = []
for k, host_data in enumerate(host_info_list):
# 反序列化校验每一个主机信息
serailizer = HostModelSerializers(data=host_data)
if serailizer.is_valid():
new_host_obj = serailizer.save()
serializers_host_res_data.append(new_host_obj)
else:
# 报错,并且错误信息中应该体验错误的数据位置
res_error_data.append({'error': f'该{k + 1}行数据有误,其他没有问题的数据,已经添加成功了,请求失败数据改完之后,重新上传这个错误数据,成功的数据不需要上传了'})
# # 再次调用序列化器进行数据的序列化,返回给客户端
serializer = HostModelSerializers(instance=serializers_host_res_data, many=True)
res_data['data'] = serializer.data
res_data['error'] = res_error_data
return res_data
class ExcelHostView(APIView):
def post(self, request):
"""批量导入主机列表"""
# 接受客户端上传的数据
host_excel = request.FILES.get("host_excel")
default_password = request.data.get("default_password")
print("host_excel:::", host_excel, type(host_excel))
print("default_password:::", default_password, type(default_password))
# # 把上传文件全部写入到字节流,就不需要保存到服务端硬盘了。
io_data = BytesIO()
for line in host_excel:
io_data.write(line)
#
data = read_host_excel_data(io_data, default_password)
#
return Response(data)
4.3、consoles功能
4.3.1、免密登陆
前面我们已经完成了主机列表展示和添加了,我们实现添加主机时,新添加主机需要填写连接主机的用户名和密码,我们接下来就需要通过密码来进行主机连接验证,如果用户名和密码没有问题,那么添加到主机列表中,以后对这个主机的操作都能够完成免密操作,所以我们有两件事情要做:
1、在添加主机信息时连接一次远程主机
2、配置公私钥进行免密登录
host.models.py
from django.db import models
# Create your models here.
from uric_api.utils.models import BaseModel, models
from users.models import User
from uric_api.utils.ssh import SSHParamiko
class HostCategory(BaseModel):
"""主机类别"""
class Meta:
db_table = "host_category"
verbose_name = "主机类别"
verbose_name_plural = verbose_name # 取消提示文字中关于英文复数+s的情况
class Host(BaseModel):
# 真正在数据库中的字段实际上叫 category_id,而category则代表了关联的哪个分类模型对象
category = models.ForeignKey('HostCategory', on_delete=models.DO_NOTHING, verbose_name='主机类别', related_name='hc',
null=True, blank=True)
ip_addr = models.CharField(blank=True, null=True, max_length=500, verbose_name='连接地址')
port = models.IntegerField(verbose_name='端口')
username = models.CharField(max_length=50, verbose_name='登录用户')
users = models.ManyToManyField(User)
class Meta:
db_table = "host"
verbose_name = "主机信息"
verbose_name_plural = verbose_name
def __str__(self):
return self.name + ':' + self.ip_addr
# 全局密钥和共钥,所有用户都使用这个一对
class PkeyModel(BaseModel):
name = models.CharField(max_length=500, unique=True) # 名称
private = models.TextField(verbose_name="私钥")
public = models.TextField(verbose_name="公钥")
def __repr__(self):
return f'<Pkey {self.name}>'
数据库迁移。
对PkeyModel的操作,我们简单封装2个方法用于存储和读取公私钥,方便后续操作
创建uric/utils/key.py
from functools import lru_cache
from host.models import PkeyModel
class PkeyManager(object):
keys = ('public_key', 'private_key',)
# 由于我们可能经常会执行这个get操作,所以我们使用了django的缓存机制,对方法的结果进行缓存,
# 第二次调用 get()方法 时,并没有真正执行方法,而是直接返回缓存的结果,
# 参数maxsize为最多缓存的次数,如果为None,则无限制,设置为2n时,性能最佳
@classmethod
@lru_cache(maxsize=64)
def get(cls, name):
info = PkeyModel.objects.filter(name=name).first()
if not info:
raise KeyError(f'没有这个 {name!r} 秘钥对')
# 以元组格式,返回公私钥
return (info.private, info.public)
@classmethod
def set(cls, name, private_key, public_key, description=None):
"""保存公私钥"""
PkeyModel.objects.update_or_create(name=name, defaults={
'private': private_key,
'public': public_key,
'description': description
})
utils.ssh
from paramiko.client import SSHClient, AutoAddPolicy
from paramiko.rsakey import RSAKey
from paramiko.ssh_exception import AuthenticationException, SSHException
from io import StringIO
from paramiko.ssh_exception import NoValidConnectionsError
class SSHParamiko(object):
def __init__(self, hostname, port=22, username='root', pkey=None, password=None, connect_timeout=3):
if pkey is None and password is None:
raise SSHException('私钥或者密码必须选择传入一个')
self.client = None
self.params = {
'hostname': hostname,
'port': port,
'username': username,
'password': password,
'pkey': RSAKey.from_private_key(StringIO(pkey)) if isinstance(pkey, str) else pkey,
'timeout': connect_timeout,
}
# 检测连接并获取连接
def get_connected_client(self):
if self.client is not None:
# 告知当前执行上下文,self.client已经实例化
raise RuntimeError('已经建立连接了!!!')
if not self.client:
try:
# 创建客户端连接对象
self.client = SSHClient()
# 在本机第一次连接远程主机时记录指纹信息
self.client.set_missing_host_key_policy(AutoAddPolicy)
# 建立连接: 口令密码或者密钥
self.client.connect(**self.params)
except (TimeoutError, NoValidConnectionsError, AuthenticationException) as e:
return None
return self.client
@staticmethod
def gen_key():
# 生成公私钥键值对
iodata = StringIO()
key = RSAKey.generate(2048) # 生成长度为2024的秘钥对
key.write_private_key(iodata)
# 返回值是一个元祖,两个成员分别是私钥和公钥
return iodata.getvalue(), 'ssh-rsa ' + key.get_base64()
# 将公钥上传到对应主机
def upload_key(self, public_key):
print("self.client:::", self.client)
# 700 是文档拥有可读可写可执行,同一组用户或者其他用户都不具有操作权限
# 600 是文件拥有者可读可写,不可执行,同一组用户或者其他用户都不具有操作权限
cmd = f'mkdir -p -m 700 ~/.ssh && \
echo {public_key!r} >> ~/.ssh/authorized_keys && \
chmod 600 ~/.ssh/authorized_keys'
code, out = self.execute_cmd(cmd)
print("out", out)
if code != 0:
raise Exception(f'添加公钥失败: {out}')
def execute_cmd(self, cmd, timeout=1800, environment=None):
# 设置执行指令过程,一旦遇到错误/异常,则直接退出操作,不再继续执行。
cmd = 'set -e\n' + cmd
channel = self.client.get_transport().open_session()
channel.settimeout(timeout)
channel.set_combine_stderr(True) # 正确和错误输出都在一个管道对象里面输出出来
channel.exec_command(cmd)
try:
out_data = channel.makefile("rb", -1).read().decode()
except UnicodeDecodeError:
out_data = channel.makefile("rb", -1).read().decode("GBK")
return channel.recv_exit_status(), out_data
序列化器:host.serializers
from rest_framework import serializers
from . import models
from uric_api.utils.ssh import SSHParamiko
from uric_api.utils.key import PkeyManager
from django.conf import settings
class HostCategoryModelSeiralizer(serializers.ModelSerializer):
"""主机分类的序列化器"""
class Meta:
model = models.HostCategory
fields = ['id', 'name']
class HostModelSerializers(serializers.ModelSerializer):
"""主机信息的序列化器"""
password = serializers.CharField(max_length=32, write_only=True, label="登录密码")
category_name = serializers.CharField(source="category.name", read_only=True)
class Meta:
model = models.Host
fields = ['id', "category_name", 'category', 'name', 'ip_addr', 'port', 'description', 'username', 'password']
def get_public_key(self):
# todo 生成公私钥和管理主机的公私钥
# 生成公私钥和管理主机的公私钥
# 创建公私钥之前,我们先看看之前是否已经创建过公私钥了
try:
# 尝试从数据库中提取公私钥
private_key, public_key = PkeyManager.get(settings.DEFAULT_KEY_NAME)
except KeyError as e:
# 没有公私钥存储到数据库中,则生成公私钥
private_key, public_key = self.ssh.gen_key()
# 将公钥和私钥保存到数据库中
PkeyManager.set(settings.DEFAULT_KEY_NAME, private_key, public_key, 'ssh全局秘钥对')
return public_key
def validate(self, attrs):
"""当用户添加、编辑主机信息会自动执行这个方法"""
ip_addr = attrs.get('ip_addr')
port = attrs.get('port')
username = attrs.get('username')
password = attrs.get('password')
# todo 基于ssh验证主机信息是否正确
self.ssh = SSHParamiko(ip_addr, port, username, password=str(password))
self.client = self.ssh.get_connected_client()
if self.client: # 测试该链接是否能够使用
public_key = self.get_public_key()
# 上传公钥到服务器中
print("public_key", public_key)
try:
self.ssh.upload_key(public_key)
except Exception as e:
raise serializers.ValidationError('添加远程主机失败,请检查输入的主机信息!')
return attrs
raise serializers.ValidationError("主机认证信息错误!")
# 添加host记录,如果第一次添加host记录,那么需要我们生成全局的公钥和私钥
def create(self, validated_data):
print('接受通过验证以后的数据字典:', validated_data)
ip_addr = validated_data.get('ip_addr')
port = validated_data.get('port')
username = validated_data.get('username')
password = validated_data.get('password')
# 剔除密码字段,保存host记录
password = validated_data.pop('password')
instance = models.Host.objects.create(
**validated_data
)
return instance
settings.dev配置:
DEFAULT_KEY_NAME = "global"
- from django.conf import settings
测试:
ssh root@47.112.179.213
vim ~/.ssh/authorized_keys
远程服务器的authorized_keys 目前为空。
此时Pkeymodel没有记录,添加主机成功后Pkeymodel中生成记录,远程服务器的authorized_keys中也有了记录。
接下来测试是否可以免密登陆
import paramiko
import traceback
from paramiko.ssh_exception import AuthenticationException
from paramiko.rsakey import RSAKey
from io import StringIO
if __name__ == '__main__':
# 通过parammiko创建一个ssh短连接客户端实例对象
ssh = paramiko.SSHClient()
# 自动在本机第一次连接远程服务器时,记录主机指纹
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
# 1. 直接密码远程连接的方式
# ssh.connect(hostname='47.112.179.213', port=22, username='root', password='Bazinga$yuan', timeout=10)
# 注意,如果你测试某个服务器的连接时,如果你本地已经配置了这个远程服务器的免密登录(公私钥模式),那么就不能测试出密码是否正确了,因为首先会通过公私钥模式登录,不会使用你的密码的。
# 2. 使用秘钥免密登录的方式
# pkey = PkeyModel.objects.get(name='').private
private_key = ''''''
pkey = RSAKey.from_private_key(StringIO(private_key))
ssh.connect(hostname='47.112.179.213', port=22, username='root', pkey=pkey, timeout=10)
# 连接成功以后,就可以发送操作指令
# stdin 输入[本机发送给远程主机的信息]
# stdout 输出[远程主机返回给本机的信息]
# stderr 错误
stdin, stdout, stderr = ssh.exec_command('ls -la')
# 读取stdout对象中返回的内容,返回结果bytes类型数据
result = stdout.read()
print(result.decode())
# 关闭连接
ssh.close()
except AuthenticationException as e:
print(e.message)
print(traceback.format_exc())
print("连接参数有误,请检查连接信息是否正确!~")
4.3.2、websocket与django-channels
(1)websocket协议
http请求的特点:
基于TCP
基于请求响应
短链接
无状态保存
WebSocket
是一种在单个TCP
连接上进行全双工通讯的协议。WebSocket
允许服务端主动向客户端推送数据。在WebSocket
协议中,客户端浏览器和服务器只需要完成一次握手就可以创建持久性的连接,并在浏览器和服务器之间进行双向的数据传输。
在一个HTTP访问周期里,如果要执行一个长时间任务,为了避免浏览器等待,后台必须使用异步动作。与此同时也要满足实时需求,用户提交了任务后可以随时去访问任务详情页面,在这里用户能够实时地看到任务的执行进度。
针对异步任务处理,我们使用了Celery把任务放到后台执行。Celery 是一个基于python开发的分布式异步消息任务队列,通过它可以轻松的实现任务的异步处理,Celery在处理一个任务的时候,会把这个任务的进度记录在数据库中。
实现任务的后台执行后,下一步就要解决实时地更新进度信息到网页的问题。从上一步可以知道,数据库中已经存在了任务的进度信息,网页直接访问数据库就可以拿到数据。但是数据库中的进度信息更新的速率不固定,如果使用间隔时间比较短的ajax轮询来访问数据库,会产生很多无用请求,造成资源浪费。综合考虑,我们决定使用WebSocket来实现推送任务进度信息的功能。网站是使用Django搭建的,原生的MTV(模型-模板-视图)设计模式只支持Http请求。幸好Django开发团队在过去的几年里也看到实时网络应用的蓬勃发展,发布了Channels,以插件的形式为Django带来了实时能力。下面两张图展示了原生Django与集成Channels的Django。
原生Django
Django本身不支持WebSocket,但可以通过集成Channels框架来实现WebSocket
Channels是针对Django项目的一个增强框架,可以使Django不仅支持HTTP协议,还能支持WebSocket,MQTT等多种协议,同时Channels还整合了Django的auth以及session系统方便进行用户管理及认证。
集成Channels的Django
对比两张图可以看出,Channels为Django带来了一些新特性,最明显的就是添加了对WebSocket的支持。Channels最重要的部分也可以看做是任务队列,消息被生产者推到通道,然后传递给监听通道的消费者之一。它与传统的任务队列的主要的区别在于Channels通过网络工作,使生产者和消费者透明地运行在多台机器上,这个网络层就叫做channel layer。Channels推荐使用redis作为它的channel layer backend,但也可以使用其它类型的工具,例如rabbitmq、内存或者IPC。关于Channels的一些基本概念,推荐阅读官方文档。
WebSocket的请求头中重要的字段:
Connection和Upgrade:表示客户端发起的WebSocket请求
Sec-WebSocket-Version:客户端所使用的WebSocket协议版本号,服务端会确认是否支持该版本号
Sec-WebSocket-Key:一个Base64编码值,由浏览器随机生成,用于升级request WebSocket的响应头中重要的字段
HTTP/1.1 101 Switching Protocols:切换协议,WebSocket协议通过HTTP协议来建立运输层的TCP连接
Connection和Upgrade:表示服务端发起的WebSocket响应
Sec-WebSocket-Accept:表示服务器接受了客户端的请求,由Sec-WebSocket-Key计算得来
WebSocket协议的优点:
支持双向通信,实时性更强
数据格式比较轻量,性能开销小,通信高效
支持扩展,用户可以扩展协议或者实现自定义的子协议(比如支持自定义压缩算法等)
WebSocket协议的缺点:
少部分浏览器不支持,浏览器支持的程度与方式有区别
长连接对后端处理业务的代码稳定性要求更高,后端推送功能相对复杂
成熟的HTTP生态下有大量的组件可以复用,WebSocket较少
WebSocket的应用场景:
即时聊天通信,网站消息通知
在线协同编辑,如腾讯文档
多玩家在线游戏,视频弹幕,股票基金实施报价
(2)channels语法
继承WebSocketConsumer
的连接。
AuthMiddlewareStack:用于WebSocket认证,继承了Cookie Middleware,SessionMiddleware,SessionMiddleware。django的channels封装了django的auth模块,使用这个配置我们就可以在consumer中通过下边的代码获取到用户的信息
def connect(self):
self.user = self.scope["user"]
self.scope类似于django中的request,包含了请求的type、path、header、cookie、session、user等等有用的信息
(3)websocket案例
配置和使用
channels==2.1.3
channels-redis==2.3.0
注意版本号
启动 Redis 服务默认使用 6379 端口,Django 将使用该端口连接 Redis 服务。
更新项目配置文件 settings.py 中的 INSTALLED_APPS 项
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'app01.apps.App01Config',
'channels',
]
ASGI_APPLICATION = "chat.routing.application"
# WebSocket
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [('127.0.0.1', 6379)],
},
},
}
在 wsgi.py
同级目录新增文件 routing.py
,其作用类型与 urls.py
,用于分发webscoket
请求:
from django.urls import path
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from table.consumers import TableConsumer
application = ProtocolTypeRouter({
# Empty for now (http->django views is added by default)
'websocket': AuthMiddlewareStack(
URLRouter([
path('ws/table/<slug:table_id>/', TableConsumer),
])
),
})
routing.py
路由文件跟django
的url.py
功能类似,语法也一样,意思就是访问ws/table/
都交给TableConsumer处理。
新增 app 名为 table
,在 table
目录下新增 consumers.py
:
from channels.generic.websocket import AsyncJsonWebsocketConsumer
class RoomConsumer(AsyncJsonWebsocketConsumer):
room_id = None
async def connect(self):
self.room_id = 'room_{}'.format(self.scope['url_route']['kwargs']['room_id'])
print("room_id", self.room_id)
print("self.channel_name", self.channel_name)
# # Join room group
print(f"将{self.channel_name}客户端对象添加到组group{self.room_id}中")
await self.channel_layer.group_add(self.room_id, self.channel_name)
await self.accept()
async def disconnect(self, close_code):
# # Leave room group
await self.channel_layer.group_discard(self.room_id, self.channel_name)
# Receive message from WebSocket
async def receive_json(self, content, **kwargs):
print("::::", content, type(content))
# # Send message to room group
self.from_client = str(self.scope["client"])
await self.channel_layer.group_send(self.room_id,
{'type': 'message', 'msg': content, "from_client": self.from_client})
# Receive message from room group
async def message(self, event):
message = event['msg']
# print("self.scope", self.scope)
# Send message to WebSocket
print(":::", event["from_client"] + ":" + message)
await self.send_json(event["from_client"] + ">>>" + message)
TableConsumer
类中的函数依次用于处理连接、断开连接、接收消息和处理对应类型的消息,其中channel_layer.group_send(self.table, {'type': 'message', 'message': content})
方法,self.table
参数为当前组的组id, {'type': 'message', 'message': content}
部分分为两部分,type
用于指定该消息的类型,根据消息类型调用不同的函数去处理消息,而 message
内为消息主体。
在 table
目录下的 views.py
中新增函数:
from django.shortcuts import render
def table(request, table_id):
return render(request, 'table.html', {
'room_name_json': table_id
})
from django.contrib import admin
from django.urls import path, re_path
from table.views import table
urlpatterns = [
path('admin/', admin.site.urls),
re_path('table/(\d+)', table),
]
前端实现WebSocket:
WebSocket对象一个支持四个消息:onopen,onmessage,oncluse和onerror,我们这里用了两个onmessage和onclose
onopen: 当浏览器和websocket服务端连接成功后会触发onopen消息
onerror: 如果连接失败,或者发送、接收数据失败,或者数据处理出错都会触发onerror消息
onmessage: 当浏览器接收到websocket服务器发送过来的数据时,就会触发onmessage消息,参数e包含了服务端发送过来的数据
onclose: 当浏览器接收到websocket服务器发送过来的关闭连接请求时,会触发onclose消息载请注明出处。
在 table
的 templates\table
目录下新增 table.html
:
<!-- chat/templates/chat/room.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Chat Room</title>
</head>
<body>
<textarea id="chat-log" cols="100" rows="20"></textarea><br/>
<input id="chat-message-input" type="text" size="100"/><br/>
<input id="chat-message-submit" type="button" value="Send"/>
</body>
<script>
var roomName = {{ room_name_json }};
var chatSocket = new WebSocket('ws://' + window.location.host + '/ws/table/' + roomName + '/');
chatSocket.onmessage = function (e) {
var data = JSON.parse(e.data);
document.querySelector('#chat-log').value += (JSON.stringify(data).slice(1,-1) + '\n');
};
chatSocket.onclose = function (e) {
console.error('Chat socket closed unexpectedly');
};
document.querySelector('#chat-message-input').focus();
document.querySelector('#chat-message-input').onkeyup = function (e) {
if (e.keyCode === 13) { // enter, return
document.querySelector('#chat-message-submit').click();
}
};
document.querySelector('#chat-message-submit').onclick = function (e) {
var messageInputDom = document.querySelector('#chat-message-input');
var message = messageInputDom.value;
chatSocket.send(JSON.stringify(message));
messageInputDom.value = '';
};
</script>
</html>
4.3.3、consoles功能
(1)前端实现
前端的终端效果我们使用xterm.js来完成。
Xterm.js的安装和使用:vue中使用xterm,要在客户端项目根目录下进行安装,
npm install xterm
xterm.js初始化
在main.js文件中加上如下内容
import 'xterm/css/xterm.css'
import 'xterm/lib/xterm'
在Host.vue中,主机列表后面的console对应的地址,实现参数跳转。
<template v-if="column.dataIndex === 'consoles'">
<router-link :to="`/uric/console/${record.id}`">Console</router-link>
</template>
设置路由router:
import Console from '../views/Console'
{
meta: {
title: 'Console',
authenticate: true,
},
path: 'console/:host_id',
name: 'Console',
component: Console
},
创建Console.vue
组件
<template>
<div class="console">
<div id="terminal"></div>
</div>
</template>
<script>
import {Terminal} from 'xterm'
export default {
name: "Console",
mounted() {
this.show_terminal()
},
methods: {
show_terminal() {
// 初始化terminal窗口
let term = new Terminal({
rendererType: "canvas", //渲染类型
// rows: 40, //行数
convertEol: true, // 启用时,光标将设置为下一行的开头
scrollback: 100, // 终端中的回滚量
disableStdin: false, //是否应禁用输入。
cursorStyle: 'underline', //光标样式
cursorBlink: true, //光标闪烁
theme: {
foreground: '#ffffff', //字体
background: '#060101', //背景色
cursor: 'help',//设置光标
}
});
// 建立websocket
let ws = new WebSocket(`ws://api.uric.cn:8000/ws/ssh/${this.$route.params.id}/`);
let cmd = ''; // 拼接用户输入的命令
// 监听接收来自服务端响应的数据
ws.onmessage = function (event) {
if (!cmd) {
//所要执行的操作
term.write(event.data);
} else {
console.log(event.data.split('\r\n'))
cmd = ''
let res = event.data.replace(event.data.split('\r\n', 1)[0] + "\r\n", '');
term.write('\r\n' + res)
}
}
term.prompt = () => {
term.write('\r\n');
// term.write('\r\n$ ')
}
term.onKey(e => {
console.log(e.key)
const ev = e.domEvent
const printable = !ev.altKey && !ev.altGraphKey && !ev.ctrlKey && !ev.metaKey
if (ev.key === "Enter") {
// 按下回车键进行指令的发送
ws.send(cmd);
} else if (ev.key === "BackSpace") {
// Do not delete the prompt
if (term._core.buffer.x > 2) {
term.write('\b \b')
}
} else if (printable) {
term.write(e.key);
cmd += e.key
}
})
term.open(document.getElementById('terminal'));
}
}
}
</script>
<style scoped>
</style>
top命令
(2) 后端实现
django没有原生支持的websocket模块,所以我们通过dwebsocket或者channels来完成。
安装channels
pip install channels==2.3.1
pip install channels-redis==2.4.1
配置channel
INSTALLED_APPS = [
...
'channels',
]
# 配置channel的通道层
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [('127.0.0.1', 6379)],
},
},
}
# 在host应用下面创建一个routing.py文件
ASGI_APPLICATION = 'uric_api.routing.application'
uric_api/uric_api/routing.py
文件
from channels.routing import ProtocolTypeRouter, ChannelNameRouter
# ProtocolTypeRouter 根据不同的请求协议,分发到不同的协议处理系统,如果是websocket协议,那么自动找routing.ws_router进行路由转发,如果是channel,那么通过executors.SSHExecutor路由进行转发,如果是http协议,那么还是按照之前的方式进行分发
from host import ws_urls # 这里类似原来的http编写代码时的路由,只是当时的路由信息,填写在了urls,而接下来,我们要编写websocket的路由,则写在routing,模块下
application = ProtocolTypeRouter({
'websocket': ws_urls.ws_router
})
host.ws_urls.py
from django.urls import path
from channels.routing import URLRouter
from .consumer import SSHCmdConsumer
# 由于我们可能会对websocket请求进行一些验证或者身份认证,所以我们在consumer应用下面在创建一个middleware文件,里面可以配置一些认证规则
ws_router = URLRouter([
path('ws/ssh/<int:id>/', SSHCmdConsumer),
])
host.consumer.py
# websocket的视图类代码
# channels中所有的webscoetk视图类,都必须直接或间接继承于WebsocketConsumer
from channels.generic.websocket import WebsocketConsumer
from host.models import Host
from threading import Thread
from uric_api.utils.key import PkeyManager
from django.conf import settings
from uric_api.utils.ssh import SSHParamiko
class SSHCmdConsumer(WebsocketConsumer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.id = self.scope['url_route']['kwargs']['id']
# websocket通讯的管道对象
self.ssh_chan = None
# 这就是基于paramiko连接远程服务器时的ssh操作对象
self.ssh = None
def read_response(self):
while True:
data = self.ssh_chan.recv(64 * 1024)
if not data:
self.close()
break
self.send(data.decode())
# 4 接收客户端发送过来的指令,并发送给主机执行指令
def receive(self, text_data=None, bytes_data=None):
data = text_data or bytes_data
print('receive:', data, type(data))
if data:
self.ssh_chan.send(data + '\r\n')
def disconnect(self, code):
"""websocket断开连接以后,服务端这边也要和远程主机关闭ssh通信"""
self.ssh_chan.close()
self.ssh.close()
print('Connection close')
# 1 请求来了自动触发父类connect方法,我们继承拓展父类的connect方法,因为我们和客户端建立连接的同时,就可以和客户端想要操作的主机建立一个ssh连接通道。
def connect(self):
print('connect连接来啦')
self.accept() # 建立websocket连接,进行连接的三次握手
self.send('Connecting ...\r\n')
host = Host.objects.filter(pk=self.id).first()
try:
private_key, public_key = PkeyManager.get(settings.DEFAULT_KEY_NAME)
print(private_key, public_key)
self.ssh = SSHParamiko(host.ip_addr, host.port, host.username, private_key)
self.client = self.ssh.get_connected_client()
except Exception as e:
self.send(f'Exception: {e}\r\n')
self.close()
return
self.ssh_chan = self.client.invoke_shell(
term='xterm') # invoke_shell激活shell终端模式,也就是长连接模式,exec_command()函数是将服务器执行完的结果一次性返回给你;invoke_shell()函数类似shell终端,可以将执行结果分批次返回,所以我们接受数据时需要循环的取数据
self.ssh_chan.transport.set_keepalive(30) # 连接中没有任何信息时,该连接能够维持30秒
# 和主机的连接一旦建立,主机就会将连接信息返回给服务端和主机的连接通道中,并且以后我们还要在这个通道中进行指令发送和指令结果的读取,所以我们开启单独的线程,去连接中一直等待和获取指令执行结果的返回数据
t = Thread(target=self.read_response)
t.start()
五、批量与定时任务
5.1、批量命令执行
5.1.1、前端实现
Ace-editor编辑器::文档地址: https://github.com/CarterLi/vue3-ace-editor
客户端根目录下,下载安装
npm install vue3-ace-editor
在需要引入编辑器插件的组件中先挂载插件,然后再使用,那么我们现在就需要在MultiExec.vue中注册插件。
MultiExec.vue
<template>
<div class="multi_exec">
<div>
<h3>执行主机:</h3>
<div>
<a-tag closable @close="close_host(info_index)" v-for="(info,info_index) in show_host_info" :key="info.id">
{{ `${info.name}(${info.ip_addr}:${info.port})` }}
</a-tag>
</div>
</div>
<div style="margin-top: 10px;">
<a-button @click="showModal" icon="plus">从主机列表中选择</a-button>
<div>
<a-modal v-model:visible="MultiExecVisible" title="" @ok="onMultiExecSubmit" @cancel="excelFormCancel"
:width="1000">
<a-row>
<a-col :span="8">
<a-form-item label="主机类别:" :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol">
<a-select style="width: 160px;" placeholder="请选择" v-model="host_form.form.category"
@change="has_change_category">
<a-select-option :value="value.id" v-for="(value, index) in categorys" :key="value.id">
{{ value.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="主机别名:">
<a-input placeholder="请输入"/>
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="已选:">
<span style="margin-left: 8px">
<template v-if="hasSelected">
{{ `${selectedRowKeys.length}` }}
</template>
</span>
</a-form-item>
</a-col>
<a-col :span="4">
<a-button type="primary" icon="sync" style="margin-top: 3px;" @click="refresh_data">刷新</a-button>
</a-col>
</a-row>
<div>
<a-table
:columns="columns"
:data-source="data"
:pagination="false"
:rowKey="record => record.id"
:row-selection="{ selectedRowKeys: selectedRowKeys, onChange: onSelectChange }"
></a-table>
</div>
</a-modal>
</div>
</div>
<v-ace-editor
v-model:value="content"
@init="editorInit"
lang="html"
theme="chrome"
style="height: 200px"/>
<div>
<a-button type="primary" icon="thunderbolt" @click="execute_cmd">开始执行</a-button>
</div>
</div>
</template>
<script>
import {VAceEditor} from 'vue3-ace-editor';
import 'ace-builds/src-noconflict/mode-html';
import 'ace-builds/src-noconflict/theme-chrome';
import axios from "axios";
import store from "@/store";
import {message} from 'ant-design-vue';
const formItemLayout = {
labelCol: {span: 8},
wrapperCol: {span: 14},
};
const columns = [
{
// slots: {title: 'customTitle'},
scopedSlots: {customRender: 'action'},
}, {
title: '类别',
dataIndex: 'category_name',
key: 'category_name',
}, {
title: '主机名称',
dataIndex: 'name',
key: 'name',
}, {
title: '连接地址',
dataIndex: 'ip_addr',
key: 'ip_addr',
width: 200,
}, {
title: '端口',
dataIndex: 'port',
key: 'port',
}, {
title: '备注信息',
dataIndex: 'description',
key: 'description',
},
];
export default {
name: "MultiExec",
data() {
return {
formItemLayout, // 弹窗的首行表单配置信息
columns, // 弹窗的表格的每一列数据的配置信息
show_host_info: [], // 显示选中的所有主机内容
MultiExecVisible: false, // 是否显示主机列表的弹窗
host_form: {
form: {
category: undefined,// 当前选择的主机分类ID
}
},
data: [], // 当前显示表格中的主机列表数据
categorys: [], // 主机分类列表
selectedRowKeys: [], // 已经勾选的主机ID列表
selected_host_ids: [], // 选中的主机id列表
content: ""
}
},
// 计算属性
computed: {
hasSelected() {
return this.selectedRowKeys.length > 0;
},
},
created() {
this.get_host_category_list()
this.get_host_list()
},
methods: {
showModal() {
this.MultiExecVisible = true;
},
// 选中主机时触发的,selectedRowKeys被选中的主机id列表
onSelectChange(selectedRowKeys) {
this.selectedRowKeys = selectedRowKeys;
},
onMultiExecSubmit() {
this.data.forEach((v, k) => {
if (this.selectedRowKeys.includes(v.id)) { // 判断某元素是否在数组中用includes比较合适,不能用in
this.show_host_info.push({
id: v.id,
name: v.name,
ip_addr: v.ip_addr,
port: v.port,
})
this.selected_host_ids.push(v.id);
}
})
// 关闭弹窗
this.MultiExecVisible = false;
},
get_host_category_list() {
// 获取主机类别
// let token = sessionStorage.token || localStorage.token;
axios.get(`${this.$settings.host}/host/category`, {
headers: {
Authorization: "jwt " + store.getters.token,
}
}).then((response) => {
this.categorys = response.data;
})
},
get_host_list(category = null) {
// 获取主机列表
let params = {}
if (category !== null) {
params.category = category
}
// let token = sessionStorage.token || localStorage.token;
axios.get(`${this.$settings.host}/host/`, {
params: params,
headers: {
Authorization: "jwt " + store.getters.token,
}
}).then((response) => {
this.data = response.data;
})
},
has_change_category(category) {
// 切换主机分类时,重新获取主机列表
this.get_host_list(category)
},
refresh_data() {
// 刷新数据
this.host_form.form.category = undefined
this.get_host_list();
},
close_host(info_index) {
// 移除已经勾选的主机信息
this.show_host_info.splice(info_index, 1);
let ids_list = this.selected_host_ids.splice(info_index, 1);
let id_index = this.selectedRowKeys.indexOf(ids_list[0]);
this.selectedRowKeys.splice(id_index, 1);
},
execute_cmd() {
// let token = sessionStorage.token || localStorage.token;
axios.post(`${this.$settings.host}/mtask/cmd_exec`, {
host_ids: this.selected_host_ids,
cmd: this.content,
}, {
headers: {
Authorization: "jwt " + store.getters.token,
}
}).then((res) => {
console.log(res);
message.success('批量任务执行成功!')
}).catch((err) => {
message.error('批量任务执行失败!')
})
}
},
components: {
VAceEditor,
},
}
</script>
<style>
</style>
路由代码,src/router/index.js
,代码:
{
path: 'multi_exec',
name: 'MultiExec',
component: MultiExec,
}
5.1.2、后端实现
(1)创建批量任务应用
关于批量任务,我们是一个单独的模块,所以我们也创建一个单独的应用来完成。
python ../../manage.py startapp mtask
配置应用
INSTALLED_APPS = [
...
'mtask',
]
mtask/urls.py
from django.urls import path
from . import views
urlpatterns = [
]
总路由,uric_api.urls
,代码:
from django.contrib import admin
from django.urls import path,include
urlpatterns = [
...
path('mtask/', include('mtask.urls')),
]
(2)获取所有主机信息
服务端提供主机列表数据
在原来的主机管理的视图集的基础上,增加一个按分类显示数据即可。
host/views.py
class HostModelViewSet(ModelViewSet):
serializer_class = HostModelSerializers
permission_classes = [IsAuthenticated]
def get_queryset(self):
# 重写qureyset方法,补充过滤主机列表参数,获取主机列表
category_id = self.request.query_params.get("category", None)
queryset = Host.objects
if category_id is not None:
queryset = queryset.filter(category_id=category_id)
return queryset.all()
(3)执行指令操作
后端实现执行指令的api接口
mtask/urls.py
path('cmd_exec', views.CmdExecView.as_view()),
mtask/views.py
from django.shortcuts import render
# Create your views here.
from rest_framework.views import APIView
from host.models import Host
from uric_api.utils.key import PkeyManager
from django.conf import settings
from rest_framework.response import Response
from uric_api.utils.ssh import SSHParamiko
# Create your views here.
class CmdExecView(APIView):
def post(self, request):
host_ids = request.data.get('host_ids')
cmd = request.data.get('cmd')
print("host_ids", host_ids)
if host_ids and cmd:
exec_host_list = Host.objects.filter(id__in=host_ids)
pkey, _ = PkeyManager.get(settings.DEFAULT_KEY_NAME) # 获取ssh秘钥
response_list = []
for host in exec_host_list:
ssh = SSHParamiko(host.ip_addr, host.port, host.username, pkey)
ssh.get_connected_client()
# ssh 远程执行指令
res_code, res_data = ssh.execute_cmd(cmd)
# res_code为0表示ok,不为0说明指令执行有问题
response_list.append({
'host_info': {
'id': host.id,
'name': host.name,
'ip_addr': host.ip_addr,
'port': host.port,
},
'res_code': res_code,
'res_data': res_data,
})
return Response(response_list)
else:
return Response({'error': '没有该主机或者没有输入指令'}, status=400)
5.2、指令模板
5.2.1、前端实现
<template>
<div class="multi_exec">
<div>
<h3>执行主机:</h3>
<div>
<a-tag closable @close="close_host(info_index)" v-for="(info,info_index) in show_host_info" :key="info.id">
{{ `${info.name}(${info.ip_addr}:${info.port})` }}
</a-tag>
</div>
</div>
<div style="margin-top: 10px;">
<a-button @click="showModal" icon="plus">从主机列表中选择</a-button>
<a-button @click="showModal2">从执行模板中选择</a-button>
<div style="margin: 20px;">
<a-modal v-model:visible="visible2" title="选择执行模板" @ok="handleOk2" width="960px">
<div>
<a-row>
<a-col :span="10">
<a-form-item label="模板类别:" :label-col="formItemLayout.labelCol"
:wrapper-col="formItemLayout.wrapperCol">
<a-select style="width: 160px;" placeholder="请选择" @change="">
</a-select>
</a-form-item>
</a-col>
<a-col :span="10">
<a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol"
label="模板名称:">
<a-input placeholder="请输入"/>
</a-form-item>
</a-col>
<a-col :span="4">
<a-button type="primary" icon="sync" style="margin-top: 3px;" @click="">刷新</a-button>
</a-col>
</a-row>
</div>
<div>
<a-table :columns="tem_columns" :data-source="tem_data" :rowKey="record => record.id"
:row-selection="{ radioselectedRow: radioselectedRow, onChange: onSelectChange2,type: 'radio' }">
</a-table>
</div>
</a-modal>
</div>
<div>
<a-modal v-model:visible="MultiExecVisible" title="" @ok="onMultiExecSubmit" @cancel="excelFormCancel"
:width="1000">
<a-row>
<a-col :span="8">
<a-form-item label="主机类别:" :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol">
<a-select style="width: 160px;" placeholder="请选择" v-model="host_form.form.category"
@change="has_change_category">
<a-select-option :value="value.id" v-for="(value, index) in categorys" :key="value.id">
{{ value.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="主机别名:">
<a-input placeholder="请输入"/>
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item :label-col="formItemLayout.labelCol" :wrapper-col="formItemLayout.wrapperCol" label="已选:">
<span style="margin-left: 8px">
<template v-if="hasSelected">
{{ `${selectedRowKeys.length}` }}
</template>
</span>
</a-form-item>
</a-col>
<a-col :span="4">
<a-button type="primary" icon="sync" style="margin-top: 3px;" @click="refresh_data">刷新</a-button>
</a-col>
</a-row>
<div>
<a-table
:columns="columns"
:data-source="data"
:pagination="false"
:rowKey="record => record.id"
:row-selection="{ selectedRowKeys: selectedRowKeys, onChange: onSelectChange }"
></a-table>
</div>
</a-modal>
</div>
</div>
<v-ace-editor
v-model:value="content"
@init="editorInit"
lang="html"
theme="chrome"
style="height: 200px"/>
<div>
<a-button type="primary" icon="thunderbolt" @click="execute_cmd">开始执行</a-button>
</div>
<hr>
</div>
</template>
<script>
import {VAceEditor} from 'vue3-ace-editor';
import 'ace-builds/src-noconflict/mode-html';
import 'ace-builds/src-noconflict/theme-chrome';
import axios from "axios";
import store from "@/store";
import {message} from 'ant-design-vue';
const formItemLayout = {
labelCol: {span: 8},
wrapperCol: {span: 14},
};
const columns = [
{
// slots: {title: 'customTitle'},
scopedSlots: {customRender: 'action'},
}, {
title: '类别',
dataIndex: 'category_name',
key: 'category_name',
}, {
title: '主机名称',
dataIndex: 'name',
key: 'name',
}, {
title: '连接地址',
dataIndex: 'ip_addr',
key: 'ip_addr',
width: 200,
}, {
title: '端口',
dataIndex: 'port',
key: 'port',
}, {
title: '备注信息',
dataIndex: 'description',
key: 'description',
},
];
const tem_columns = [
{
title: '模板名称',
dataIndex: 'name',
key: 'name',
},
{
title: '模板类型',
dataIndex: 'category_name',
key: 'category_name',
},
{
title: '模板内容',
dataIndex: 'cmd',
key: 'cmd',
width: 200,
},
{
title: '描述信息',
dataIndex: 'description',
key: 'description',
},
];
export default {
name: "MultiExec",
data() {
return {
formItemLayout, // 弹窗的首行表单配置信息
columns, // 弹窗的表格的每一列数据的配置信息
show_host_info: [], // 显示选中的所有主机内容
MultiExecVisible: false, // 是否显示主机列表的弹窗
host_form: {
form: {
category: undefined,// 当前选择的主机分类ID
}
},
data: [], // 当前显示表格中的主机列表数据
categorys: [], // 主机分类列表
selectedRowKeys: [], // 已经勾选的主机ID列表
selected_host_ids: [], // 选中的主机id列表
visible2: false,
template_form: {
form: {
category: undefined,
}
},
tem_categorys: [], // 指令模板分类列表
tem_data: [], // 指令模板列表
radioselectedRow: [], //
content: "",
tem_columns,
}
}
,
// 计算属性
computed: {
hasSelected() {
return this.selectedRowKeys.length > 0;
}
,
}
,
created() {
this.get_host_category_list()
/this.get_host_list()
// this.get_templates_category_list()
// this.get_templates_list()
}
,
methods: {
showModal() {
this.MultiExecVisible = true;
}
,
// 选中主机时触发的,selectedRowKeys被选中的主机id列表
onSelectChange(selectedRowKeys) {
this.selectedRowKeys = selectedRowKeys;
}
,
onMultiExecSubmit() {
this.data.forEach((v, k) => {
if (this.selectedRowKeys.includes(v.id)) { // 判断某元素是否在数组中用includes比较合适,不能用in
this.show_host_info.push({
id: v.id,
name: v.name,
ip_addr: v.ip_addr,
port: v.port,
})
this.selected_host_ids.push(v.id);
}
})
// 关闭弹窗
this.MultiExecVisible = false;
}
,
get_host_category_list() {
// 获取主机类别
// let token = sessionStorage.token || localStorage.token;
axios.get(`${this.$settings.host}/host/category`, {
headers: {
Authorization: "jwt " + store.getters.token,
}
}).then((response) => {
this.categorys = response.data;
})
}
,
get_host_list(category = null) {
// 获取主机列表
let params = {}
if (category !== null) {
params.category = category
}
// let token = sessionStorage.token || localStorage.token;
axios.get(`${this.$settings.host}/host/`, {
params: params,
headers: {
Authorization: "jwt " + store.getters.token,
}
}).then((response) => {
this.data = response.data;
})
}
,
has_change_category(category) {
// 切换主机分类时,重新获取主机列表
this.get_host_list(category)
}
,
refresh_data() {
// 刷新数据
this.host_form.form.category = undefined
this.get_host_list();
}
,
close_host(info_index) {
// 移除已经勾选的主机信息
this.show_host_info.splice(info_index, 1);
let ids_list = this.selected_host_ids.splice(info_index, 1);
let id_index = this.selectedRowKeys.indexOf(ids_list[0]);
this.selectedRowKeys.splice(id_index, 1);
}
,
execute_cmd() {
// let token = sessionStorage.token || localStorage.token;
axios.post(`${this.$settings.host}/mtask/cmd_exec`, {
host_ids: this.selected_host_ids,
cmd: this.content,
}, {
headers: {
Authorization: "jwt " + store.getters.token,
}
}).then((res) => {
console.log(res);
message.success('批量任务执行成功!')
}).catch((err) => {
message.error('批量任务执行失败!')
})
},
showModal2() {
this.visible2 = true;
},
handleOk2(e) {
let tid = this.radioselectedRow[0]; //选中的模板id值
// 通过模板id值,找到该模板记录中的cmd值,并赋值给content属性
this.tem_data.forEach((v, k) => {
if (v.id === tid) {
this.content = v.cmd;
}
})
this.visible2 = false;
},
onSelectChange2(radioselectedRow) {
// [6, 7, 8, 9]
console.log('>>>>> ', radioselectedRow);
this.radioselectedRow = radioselectedRow;
},
handleSelectChange2(value) {
// 切换模板分类
this.get_templates_list(value)
},
refresh_data2() {
this.get_templates_list();
},
get_templates_list(category = null) {
let params = {}
if (category !== null) {
params.category = category
}
axios.get(`${this.$settings.host}/mtask/templates`, {
params: params,
headers: {
Authorization: "jwt " + store.getters.token
}
}).then(response => {
this.tem_data = response.data;
})
},
get_templates_category_list() {
axios.get(`${this.$settings.host}/mtask/templates/categorys`, {
headers: {
Authorization: "jwt " + store.getters.token
}
})
.then(response => {
this.tem_categorys = response.data;
})
},
}
,
components: {
VAceEditor,
}
,
}
</script>
<style>
</style>
5.2.2、后端实现
mtask/models.py
from django.db import models
from uric_api.utils.models import BaseModel
class CmdTemplateCategory(BaseModel):
class Meta:
db_table = "cmd_template_category"
verbose_name = "模板分类"
verbose_name_plural = verbose_name
def __str__(self):
return self.name
class CmdTemplate(BaseModel):
category = models.ForeignKey('CmdTemplateCategory', on_delete=models.CASCADE,verbose_name='模板类别')
cmd = models.TextField(verbose_name='模板内容')
class Meta:
db_table = "cmd_template"
verbose_name = "指令模板"
verbose_name_plural = verbose_name
数据迁移,终端下在服务端项目根目录下执行
python manage.py makemigrations
python manage.py migrate
添加测试数据,mysql中执行以下SQL语句。
INSERT INTO uric.cmd_template_category (id, name, is_show, orders, is_deleted, created_time, updated_time, description) VALUES (1, '文件操作', 1, 1, 0, '2021-08-07 20:52:55', '2021-08-07 20:52:55', null);
INSERT INTO uric.cmd_template_category (id, name, is_show, orders, is_deleted, created_time, updated_time, description) VALUES (2, '文件夹操作', 1, 1, 0, '2021-08-07 20:52:55', '2021-08-07 20:52:55', null);
INSERT INTO uric.cmd_template (id, name, is_show, orders, is_deleted, created_time, updated_time, description, cmd, category_id) VALUES (1, '列出当前目录下所有文件', 1, 1, 0, '2021-08-07 20:55:45', '2021-08-07 20:55:45', null, 'ls', 2);
INSERT INTO uric.cmd_template (id, name, is_show, orders, is_deleted, created_time, updated_time, description, cmd, category_id) VALUES (2, '创建文件', 1, 1, 0, '2021-08-07 20:55:45', '2021-08-07 20:55:45', null, 'touch index.html', 1);
INSERT INTO uric.cmd_template (id, name, is_show, orders, is_deleted, created_time, updated_time, description, cmd, category_id) VALUES (3, '家目录下创建文件夹', 1, 1, 0, '2021-08-07 20:55:45', '2021-08-07 20:55:45', null, 'cd /home
mkdir uric', 2);
mtask/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('cmd_exec', views.CmdExecView.as_view()),
path('templates', views.TemplateView.as_view()),
path('templates/categorys', views.TemplateCategoryView.as_view()),
]
mtask/views.py
from rest_framework.generics import ListAPIView, CreateAPIView
from .models import CmdTemplate, CmdTemplateCategory
from .serializers import CmdTemplateModelSerialzer, CmdTemplateCategoryModelSerialzer
from rest_framework.permissions import IsAuthenticated
class TemplateView(ListAPIView, CreateAPIView):
# 获取所有执行模板
permission_classes = [IsAuthenticated]
queryset = CmdTemplate.objects.all()
serializer_class = CmdTemplateModelSerialzer
class TemplateCategoryView(ListAPIView, CreateAPIView):
# 获取执行模板类别
permission_classes = [IsAuthenticated]
queryset = CmdTemplateCategory.objects.all()
serializer_class = CmdTemplateCategoryModelSerialzer
mtask.serializers,代码:
from rest_framework import serializers
from .models import CmdTemplateCategory, CmdTemplate
class CmdTemplateModelSerialzer(serializers.ModelSerializer):
category_name = serializers.CharField(source="category.name", read_only=True)
class Meta:
model = CmdTemplate
fields = ["id", "name", "cmd", "description", "category_name", "category"]
class CmdTemplateCategoryModelSerialzer(serializers.ModelSerializer):
class Meta:
model = CmdTemplateCategory
fields = "__all__"
标签:name,运维,self,Uric,host,文档,import,data,id
From: https://www.cnblogs.com/fuminer/p/16991370.html