前端使用vue,后端用spring boot + kotlin + mybatis + mysql。
刚开始前端部分我是用flutter开源项目魔改的,看上去是这样的:
感觉还挺好看,但是最大的问题是,用flutter web编译出来的web文件性能太差了,就这一个页面,js文件足有2M:
后果就是点一下都能直接卡成ppt。
所以这条路线被我放弃了(所以更改的内容我也直接省略了),改用了vue来搭建前端页面。
跟flutter使用现有项目改造不同,vue的界面全部是自己写的。
这个部分网上很多,不再赘述。
样子参照的是这个设备官方的云平台:
也就是:左边是设备列表,右边是数据面板。
创建一个新的vue项目之后,把原来的helloWorld.vue里面的内容删光。
然后根View用一个div,id设置为dashboard:
然后写dashboard的样式,布局采用flex布局,宽高设置为和屏幕一致,从开头开始排列子项目,方向为横向排列:
#dashboard{
display: flex;
justify-content: start;
flex-direction: row;
width: 100vw;
height: 100vh;
}
然后是设备列表和数据面板部分,也都是用div,id分别设置为devices,datapanel:
devices宽度设置为固定值200px,内部的布局方式同样是flex,方向为从开头纵向布局,在交叉轴上拉伸子项目,背景色灰色:
#devices{
width: 200px;
background-color: gray;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-content: stretch;
}
datapanel背景色浅灰色,宽度不设常量,但是flex-grow设置为1,而devices的flex-grow默认是0,这样当dashboard有多余空间的时候,datapanel就能够占据所有多余空间:
#datapanel{
background-color: #eee;
flex-grow: 1;
}
设备列表使用flex布局加button来实现的,先用h3写一个标题:
设备列表
给这个标题一个比较大的上外边距,因为背景色是灰色,所以这个标题的颜色改成白色:
h3{
margin-bottom: 100px;
color: white;
}
用v-for来遍历生成三个按键,首先在export default的data里面写一个字符串数组来代表所有的按键,一个变量来代表当前选中的按键:
export default {
name: 'HelloWorld',
data () {
return {
currentButton : "设备一",
buttons : ["设备一", "设备二", "设备三"],
}
}
}
这里选中的按键背景色和样式要和其他的两个按键不一样,所以这个地方要用不用的class来实现:
设备列表
按键的样式,以及选中的按键的样式:
.tab-button{
padding: 10px;
background-color: aqua;
}
.tab-button.active{
background-color: #eee;
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
border-right-color: transparent;
}
效果是这样子的:
数据面板分成了两部分,第一部分是设备的名字和在线提示,第二部分才是所有的数据。
这里用一个div来表示这个整体的面板:
布局:
#panel{
width: 100%;
height: auto;
box-shadow: 0px 0px 10px black;
margin: 5px;
margin-left: 20px;
background-color: white;
}
这里加了一点阴影效果,更有层次感。
给titleline里面增加一个标题和在线提示:
设备数据
在线
在线的背景色设置为绿色:
#titleline{
height: 50px;
display: flex;
flex-direction: row;
justify-items: flex-start;
align-items: center;
}
#title{
font: bold;
margin: 20px;
}
#desc{
background: green;
margin: 5px;
color: white;
padding: 2px;
}
效果:
数据面板显示所有的数据分项,一行两个。
整体还是用flex布局,方向为row,flex-wrap设置为wrap,多出来的子项目就会自动排列在下一行,子项目的宽度都设备为50%,就能一行排列两个子项目:
#single-field{
display: flex;
flex-wrap: wrap;
flex-direction: row;
justify-content: flex-start;
}
最后是每个分项,前后没有边框,只有上下有一条淡淡的边框,鼠标放上去之后,背景色改变:
.field{
/* display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center; */
display: grid;
grid-template-columns: repeat(3,33.33%);
align-items: center;
width: 50%;
border-bottom: 1px solid gray;
}
.field:hover{
background-color:#eee;
}
这里的field内部的布局本来是用的flex,但是flex布局的问题是剩余空间会影响view的位置,导致中间的view无法上下对齐,所以这里改用grid布局。将一行平均分成三份,就可以上下对齐了。
整体效果:
点击每个详情之后,需要弹出一个对话框,显示当前项目的图标,可以选择日期等,所以这里要安装两个库:
npm install echarts -s
npm install vuejs-datepicker -save
上面一个是echarts图表库,后面一个是datepicker日期选择库。
在script里面引入这两个库:
import * as echarts from 'echarts'
import Datepicker from 'vuejs-datepicker';
然后将Datepicker设定为组件:
components:{
Datepicker
},
这样就能在html中使用datepicker。
在dashboard里面最后面增加一个div用来存放弹出框:
请选择日期,默认当天:
chart就是要显示的图表,over为灰色的底层:
#picker{
background-color: white;
z-index: 1001;
height: 40px;
border-radius: 0.5em;
}
input{
border: 1px solid #ccc;
border-radius: 0.5em;
}
#chart-container{
height: auto;
z-index: 1000;
background-color: white;
border-radius: 0.5em;
display: flex;
flex-direction: column;
justify-content: flex-start;
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%,-50%);
}
#picker-container{
display: flex;
flex-direction: row;
justify-content: center;
flex-grow: 0;
height: 40px;
z-index: 1001;
width: 960px;
background-color: white;
align-items: center;
}
#chartBottom{
position: fixed;
top: 0px;
left: 0px;
}
#chart {
font-size: 24px;
height: 640px;
width: 960px;
background-color: white;
flex-grow: 1;
/* transform: translate(-50%, -50%); */
z-index: 1000;
}
.over {
position: fixed;
width: 100%;
height: 100%;
opacity: 0.7;
filter: alpha(opacity=70);
top: 0;
left: 0;
z-index: 999;
background-color: #111111;
}
这里面最需要注意的是z-index这个数据,如果设置不正确,弹出来的日期选择框会被遮挡。
效果:
首先在页面加载的时候,初始化echarts组件,然后请求各种分项数据,所以这一部分代码写在vue生命周期的mounted里面:
mounted(){
fetch("/dtu/latest")
.then(response => response.json())
.then(json =>{
this.electricData = [
{name: "A 相电压",value : json["voltaA"],index : 3},
{name: "B 相电压",value : json["voltaB"],index : 4},
{name: "C 相电压",value : json["voltaC"],index : 5},
{name: "A 相电流",value : json["currentA"],index : 6},
{name: "B 相电流",value : json["currentB"],index : 7},
{name: "C 相电流",value : json["ccurrentC"],index : 8},
{name: "PA 相有功功率",value : json["powerA"],index : 9},
{name: "PB 相有功功率",value : json["powerB"],index : 10},
{name: "PC 相有功功率",value : json["powerC"],index : 11},
{name: "COSA 相功率因数",value : json["factorA"],index : 12},
{name: "COSB 相功率因数",value : json["factorB"],index : 13},
{name: "COSC 相功率因数",value : json["factorC"],index : 14},
{name: "P 总有功功率",value : json["totalP"],index : 15},
{name: "Q 总无功功率",value : json["totalQ"],index : 16},
{name: "三相平均功率因数",value : json["avgFactor"],index : 17},
{name: "F 频率",value : json["frequeency"],index : 18},
{name: "正向有功电度",value : json["positiveP"],index : 19},
{name: "正向无功电度",value : json["positiveQ"],index : 20},
{name: "反向有功电度",value : json["negativeP"],index : 21},
{name: "反向无功电度",value : json["negativeQ"],index : 22},
{name: "A 相无功功率",value : json["aq"],index : 23},
{name: "B 相无功功率",value : json["bq"],index : 24},
{name: "C 相无功功率",value : json["cq"],index : 25},
{name: "A 相视在功率",value : json["sightA"],index : 26},
{name: "B 相视在功率",value : json["sightB"],index : 27},
{name: "C 相视在功率",value : json["sightC"],index : 28},
{name: "S 总视在功率",value : json["totalSight"],index : 29}
]
}
);
this.myChart = echarts.init(document.getElementById("chart"));
}
这里的网络请求使用的是fetch,得到结果后,改变数据列表,界面也随之刷新。
在点击详情后,用fetch来请求分项的图表数据并显示:
showChart(title,index){
console.log(index);
this.title = title;
this.index = index;
this.popup = 1;
fetch("/dtu/field",{
method : "POST",
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: Object.entries({fieldIndex : index}).map(([key, value]) => `${key}=${value}`).join('&')
}).then(res => res.json())
.then(json => {
console.log(json);
var series = [{name : title,type :'line'}];
var data = [];
json.forEach(element => {
data.push(element.value);
});
series[0].data = data;
this.showEChart(title,series,json.map(r => r.time),"");
});
},
showEChart(chart_title, chart_data, chart_time,yName) {
const option = {
title: {
text: chart_title
},
tooltip: {},
legend: {
data: chart_data.map(item => item.name),
top: 20,
align: 'right'
},
toolbox: {
show: true,
feature: {
dataZoom: {
yAxisIndex: "none"
}
}
},
xAxis: {
data: chart_time,
axisLabel: {
rotate: 90
}
},
yAxis: {
name : yName
},
series: chart_data,
grid: {
bottom: 60
}
};
// 使用刚指定的配置项和数据显示图表。
this.myChart.setOption(option);
}
在选定了日期后,显示特定日期的数据:
showSelectedDateData(){
console.log('日期是' + this.chartDate);
fetch("/dtu/selected",{
method : "POST",
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: Object.entries({index : this.index,date : [this.chartDate.getFullYear(),this.chartDate.getMonth() + 1,this.chartDate.getDate()].join('-')}).map(([key, value]) => `${key}=${value}`).join('&')
}).then(res => res.json())
.then(json => {
console.log(json);
var series = [{name : this.title,type :'line'}];
var data = [];
json.forEach(element => {
data.push(element.value);
});
series[0].data = data;
this.showEChart(this.title,series,json.map(r => r.time),"");
});
}
效果:
数据库表这个时候已经设计好了,只需要用mybatis-generator插件来导出相应的model类,但是这个插件跟kotlin并不兼容,需要手动把@model注解删掉,然后手动生成一遍getter和setter。
新建一个DtuMapper接口文件,加上
@Repository注解。
来与数据库进行通信:
@Repository
interface DTUMapper {
@Select("select * from dtu_ammeter order by id desc limit 1")
fun getLatestData() : DtuAmmeter
@Select("select createtime,${'$'}{field} from dtu_ammeter where datediff(createtime,now())=0 order by createtime desc limit 100")
fun getSingleFieldList(field : String) : List
@Select("select createtime,${'$'}{field} from dtu_ammeter where datediff(createtime,#{date})=0 order by createtime")
fun getSelectedDateData(field: String,date : String) : List
}
这里需要说明的是mybatis的模板语法也和kotlin不太配,这里${}的语法,kotlin中识别为字符串格式化语法。所以这个$符号要特别写成
${'$'}。
第一个接口是获取当前最新的数据,第二个接口是获取每个分项的图表数据,第三个接口是获取特定日期分项的图表数据,与前端的网络请求相对应。
新建一个DtuService文件,加上
@Service注解
来沟通数据源和业务逻辑。
@Service
class DTUService {
@Autowired
private lateinit var dtuMapper: DTUMapper
private val dateFormat = SimpleDateFormat("HH:mm:ss").apply {
timeZone = TimeZone.getTimeZone("Asia/Shanghai")
}
fun getLatestData() = dtuMapper.getLatestData()
fun getSingleFieldList(field: String) = dtuMapper.getSingleFieldList(field).map { d ->
mapDtuToSingleField(d,field)
}
fun getSelectedDateData(field: String, date: String) = dtuMapper.getSelectedDateData(field, date).map{d -> mapDtuToSingleField(d,field)}
private fun mapDtuToSingleField(d : DtuAmmeter,field: String) : SingleFieldModel{
val fields = DtuAmmeter::class.java.declaredFields
val method = DtuAmmeter::class.java.methods.find { m -> m.name.toLowerCase() == "get${field.toLowerCase()}" }
var value = 0.0
for (f in fields) {
if (f.name == field) value = when (f.type) {
Integer::class.java -> (method!!.invoke(d) as Int).toDouble()
java.lang.Long::class.java -> (method!!.invoke(d) as Long).toDouble()
else -> 0.0
}
}
return SingleFieldModel(
dateFormat.format(d.createtime),
value)
}
}
上面两个接口都是按部就班写的。第三个接口用了反射,避免只能穷举才能处理所有字段数据。
需要注意的是,用插件生成的model类是java写的,所以model里面的类型也是java原生类型,包括int和它的装箱类型Integer,long和Long,Integer :class.java如果写成Int :class.java,就会直接跳过这个分支。
Long:class.java也必须写成全路径形式的java.lang.Long:class.java才能正确识别.
创建一个kotlin类,
DTUController,并且加上
@Controller @RequestMapping("dtu")
两个注解来接受网络请求:
@Controller
@RequestMapping("dtu")
class DTUController {
private val jMapper = JsonMapper()
@Autowired
private lateinit var service: DTUService
//返回单条最新dtu数据
@RequestMapping("latest")
@ResponseBody
fun getLatestData(response: HttpServletResponse,request: HttpServletRequest): DtuAmmeter {
//avoidNetConflict(response, request)
return service.getLatestData()
}
//返回页面
@RequestMapping("dtu")
fun getDTUPage() : String{
return "dtu/index"
}
//返回单个字段的图表数据
@RequestMapping("field")
@ResponseBody
fun getSingleFieldData(@RequestParam("fieldIndex") index :Int,response: HttpServletResponse,request: HttpServletRequest) : List{
//avoidNetConflict(response, request)
val fields = DtuAmmeter ::class.java.declaredFields
if (index > fields.size) throw IndexOutOfBoundsException("下标越界异常")
return service.getSingleFieldList(fields[index].name)
}
//返回特定日期的单个字段的图表数据
@RequestMapping("selected")
@ResponseBody
fun getSelectedDateData(@RequestParam("date") date : String,
@RequestParam("index") index : Int,
request: HttpServletRequest,
response: HttpServletResponse) : List{
//avoidNetConflict(response, request)
println(date)
val fields = DtuAmmeter ::class.java.declaredFields
if (index > fields.size) throw IndexOutOfBoundsException("下标越界异常")
return service.getSelectedDateData(fields[index].name,date)
}
//防止前端调试的时候出现跨域问题
private fun avoidNetConflict(
response: HttpServletResponse,
request: HttpServletRequest
) {
response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"))
response.setHeader("Access-Control-Allow-Credentials", "true")
response.setHeader("P3P", "CP=CAO PSA OUR");
if (request.getHeader("Access-Control-Request-Method") != null && "OPTIONS" == request.method) {
response.addHeader("Access-Control-Allow-Methods", "POST,GET,TRACE,OPTIONS")
response.addHeader("Access-Control-Allow-Headers", "Content-Type,Origin,Accept")
response.addHeader("Access-Control-Max-Age", "120")
}
}
}
所有的方法的作用都在注释里面。
涉及到单个字段的数据,都是用反射来处理,避免穷举。