一.创作思路
在平时办公中,我们往往需要对数据进行各种数据分析与图形可视化成图表,这些操作我们可以采用wps,word等等办公软件,于是我想自己尝试写一个线上的平台,专门实现上传文件,勾选相应的数据,采用Echarts生成图表,将Echars图表生成图表的全部步骤让用户自行选择生成图表,用户可以将生成的图表保存下来进行使用。目前开发中主要分析与预测学生数据为主,同时搭配上自主选择生成图表的工作台模块,以便后续的升级。
二.技术思路
1.技术路线:使用Vue3+pinia+router+Echarts+datav
2.功能模块:
1、 数据管理模块: 用于数据的收集、存储和管理,其中包括学生成绩数据、个人信息 等。 2、 成绩可视化模块: 能够根据用户需求,将成绩数据以柱状图、折线图、饼状图等形 式进行可视化展示。 3、 个性化分析模块: 根据用户的选择和参数设定,对成绩数据进行个性化分析。 4、 用户界面: 提供给用户的操作界面,包括数据输入、参数设置、图表展示等功能。三.界面展示
1、登录注册页面:用户通过邮箱号和密码登录进入学生成绩可视化平台
1.1.实现思路:背景的动态粒子效果采用tsparticles,大家如果感兴趣的话,可以点击连接去官网查看。邮箱登录以及验证码方面,我主要是采用node里面的一个邮箱模块nodemailer,采用mysql存储用户信息,其实我感觉用mongdb可能更方便一些,但是当时没考虑这么多。
2.数据分析界面:用户可以在操作台勾选数据,选择自己想要生成的图标,定制化设计图表,提供各种可视化图表,如折线图、柱状图,饼状图等。
2.1.生成饼状图:用户可以点击鼠标左键,在平台上勾选相应的数据进行分析,用户可以自己定义图表的样式,布局,实时更改。然后图片中展示数据是采用mockjs随机生成。
2.2.勾选数据实现原理:使用xlsx模块将用户上传的文件数据解析出来存储在pinia,使用表格渲染到界面给每一个格子添加一个点击,移入,移出事件,方便用户勾选,主要数据代码如下
<template>
<!-- 表格模板,使用 v-for 指令遍历 datatables.tables 中的数据 -->
<table class="csv-table" ref="tablesbox">
<tr v-for="(row, index) in datatables.tables" :key="index">
<!-- 为每个单元格绑定点击和鼠标悬停事件,传递行列信息 -->
<td v-for="(value, key) in row" :key="key" @click="draw" @mouseover="xuanran" :data-coloum="index"
:data-row="key" :data-postion="index * 10 + key">{{ value }}</td>
</tr>
</table>
</template>
<script setup>
import { ref, onMounted, watch, toRefs } from "vue";
import { useCounterStore } from "@/stores/counter"
// 使用 Pinia store
const datatables = useCounterStore()
// 将 store 中的属性转为 refs
const { Mouseselected, tables } = toRefs(datatables)
// 定义 ref 变量
let isMouseDown = false;
const tablesbox = ref()
let num = []
// 在组件挂载时添加事件监听器
onMounted(() => {
document.addEventListener('mousedown', () => {
isMouseDown = true;
tablesbox.value.classList.add('cursor')
});
document.addEventListener('mouseup', () => {
isMouseDown = false;
tablesbox.value.classList.remove('cursor')
});
})
// 定义起点和父节点坐标
let start = { x: 0, y: 0 }
let parent = { x: 0, y: 0 }
// 绘制选中的单元格
const draw = (e) => {
guiling() // 清除所有单元格的样式
e.target.style.backgroundColor = 'rgb(180, 178, 178,0.7)';
e.target.style.border = '2px dotted rgba(205, 208, 20)';
start.y = e.target.getAttribute('data-coloum')
start.x = e.target.getAttribute('data-row')
datatables.dataprencet = [e.target.innerHTML]
}
// 渲染鼠标悬停时的效果
const xuanran = (e) => {
if (isMouseDown) {
guiling() // 清除所有单元格的样式
parent.y = e.target.getAttribute('data-coloum')
parent.x = e.target.getAttribute('data-row')
num = getSurroundedCoordinates(start.x, start.y, parent.x, parent.y)
for (let index = 0; index < num.length; index++) {
let data = document.querySelector(`[data-postion="${num[index][1]}${num[index][0]}"]`)
data.style.backgroundColor = 'rgb(180, 178, 178,0.2)';
data.style.border = '2px dotted rgba(0, 0, 0)';
}
datatables.dataprencet = caiji(num)
}
}
// 清空全部样式
function guiling() {
const childNodes = document.querySelectorAll('td')
childNodes.forEach(element => {
element.style = 'border: 2px solid #dddddd;width: 50px;text-align: center;'
});
}
// 获取被矩形包围的所有坐标
function getSurroundedCoordinates(x1, y1, x2, y2) {
const grid = Array.from({ length: datatables.tables.length }, () => Array.from({ length: datatables.tables[0].length }, () => 0));
grid[x1][y1] = 1;
grid[x2][y2] = 1;
const minX = Math.min(x1, x2);
const maxX = Math.max(x1, x2);
const minY = Math.min(y1, y2);
const maxY = Math.max(y1, y2);
const surroundedCoordinates = [];
for (let i = minX; i <= maxX; i++) {
for (let j = minY; j <= maxY; j++) {
surroundedCoordinates.push([i, j]);
}
}
return surroundedCoordinates;
}
// 采集选中区域的数据
function caiji(params) {
let numdata = []
for (let index = 0; index < params.length; index++) {
let data = document.querySelector(`[data-postion="${params[index][1]}${params[index][0]}"]`)
numdata.push(data.innerHTML)
}
num = []
console.log(numdata);
return numdata
}
// 监听 Mouseselected 的变化,清空样式并重置 dataprencet
watch(Mouseselected, (newValue) => {
guiling()
datatables.dataprencet = []
}, { deep: true });
// 监听 tables 的变化,更新 tables 的值
watch(tables, (newValue) => {
tables.value = newValue
}, { deep: true });
</script>
<style scoped>
.csv-table {
border-collapse: collapse;
width: 100%;
user-select: none;
color: #ffffff;
}
.csv-table td {
border: 2px solid #90b8ce;
width: 50px;
text-align: center;
text-wrap: wrap;
}
.csv-table th {
background-color: #f2f2f2;
}
.cursor {
cursor: crosshair;
}
</style>
2.3生成图表的原理: 根据勾选的数据生成图表,灵感来自于Echars官方网站有在线运行生成图表的一个功能,以我这个饼状图举例,连接如下:点击跳转官方给我们提供的代码样式如下:
option = {
title: {
text: 'Referer of a Website',
subtext: 'Fake Data',
left: 'center'
},
tooltip: {
trigger: 'item'
},
legend: {
orient: 'vertical',
left: 'left'
},
series: [
{
name: 'Access From',
type: 'pie',
radius: '50%',
data: [
{ value: 1048, name: 'Search Engine' },
{ value: 735, name: 'Direct' },
{ value: 580, name: 'Email' },
{ value: 484, name: 'Union Ads' },
{ value: 300, name: 'Video Ads' }
],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
};
可以看出来是一个对象,那么我们只需将对象里面每一个属性对应的值,使用v-model绑定或者使用watch监听,实现更新,在生成图表时,将对象传入进行更新即可。折线图与饼状图原理与其相同,这里不做过多介绍,感兴趣的朋友可以下载文件亲自上手使用。
2.4. mockjs随机生成数据功能部分代码如下:
// npm install
import Mock from 'mockjs'
const dataTemplate = {
'list|100': [
['@natural(1, 10)', '@id', '@cname', '@integer(0, 100)', '@integer(0, 100)', '@integer(0, 100)', '@integer(0, 100)', '@integer(0, 100)', '@integer(0, 100)']
]
};
// 生成模拟数据
const mockData = Mock.mock(dataTemplate).list;
// 添加表头
const we = [
['班级', '学号', '姓名', '语文', '数学', '英语', '物理', '化学', '生物'],
...mockData
];
// 输出模拟数据
export default we
3.上传与下载模板:这里的模板主要是为学生成绩可视化分析做准备,这里上传与下载使用了el-upload也就是element plus官方的组件库,下载模板主要是使用XLSX库.
3.1. 上传与下载模板文件的代码展示:
// 文件上传前的处理函数
const beforeUpload = (file) => {
if (!file) return false;
const fileName = file.name;
const fileExtension = fileName.split('.').pop().toLowerCase();
if (fileExtension !== 'csv' && fileExtension !== 'xlsx') {
alert('只能上传 CSV 文件和 SQL 文件');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const data = new Uint8Array(e.target.result);
const workbook = XLSX.read(data, { type: 'array' });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const csvDataArray = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' }); // 添加 defval: '' 选项来保留空值
csvData.value = csvDataArray;
let a = JSON.stringify(csvData.value)
student.value = JSON.parse(a)
datawe.jiazai()
router.push("/Maininterface/PersonalCenter/2")
};
reader.readAsArrayBuffer(file);
// 返回 false 阻止默认上传行为
return false;
};
// 准备 CSV 数据
const csvDatas = ref('班级,学号,姓名,语文,英语,数学,生物,化学,物理');
// 下载文件名
const filename = ref('可视化模板.csv');
// 下载 CSV 文件的函数
const downloadCSV = () => {
// 创建 CSV 数据的 Blob 对象
const blob = new Blob([csvDatas.value], { type: 'text/csv;charset=utf-8;' });
// 创建下载链接
const downloadLink = document.createElement('a');
const url = URL.createObjectURL(blob);
// 设置下载链接的属性
downloadLink.setAttribute('href', url);
downloadLink.setAttribute('download', filename.value);
// 添加下载链接到文档中
document.body.appendChild(downloadLink);
// 模拟用户点击下载链接来触发下载
downloadLink.click();
// 清理创建的对象和链接
URL.revokeObjectURL(url);
document.body.removeChild(downloadLink);
};
4.学生成绩的可视化分析展示:
4.1. 在用户上传了模板文件后,我们使用遍历的方式将数据提取出来,由于我们模板文件前列的标题固定为 班级,学号,姓名 先使用XLSX库将数据提取为二维数组,让对学生数据进行求和,取平均值,排名,将对应的数据更新完毕后,传入对应的图表组件中,更新,代码如下。
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { echarsdata } from '@/api/user.js'
export const usenamePoechars = defineStore('Poechars', () => {
//存储学生信息
const student = ref([])
//存储当前选中查看的学生的基本信息
const studentchoose = ref([])
//存储学生成绩的排名
const studentpaiming = ref([])
//存储表头列
const header = ref([])
//存储排名信息(只有姓名与总分)
const studenpaiming = ref([])
//存储排名信息(只有学号与总分)
const studenpaiming2 = ref([])
//成绩平均值
const pingjun = ref([])
//获取用户上传的表单数据
async function jiazai() {
//模拟用户上传的数据
// student.value = await echarsdata();
// console.log(student.value);
// const arrayToCSV = (arr) => {
// return arr.map(row => row.join(',')).join('\n');
// };
// // 准备 CSV 数据
// const csvData = arrayToCSV(student.value);
// // 下载文件名
// const filename = 'student_data.csv';
// // 下载 CSV 文件的函数
// const downloadCSV = () => {
// // 创建 CSV 数据的 Blob 对象
// const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' });
// // 创建下载链接
// const downloadLink = document.createElement('a');
// const url = URL.createObjectURL(blob);
// // 设置下载链接的属性
// downloadLink.setAttribute('href', url);
// downloadLink.setAttribute('download', filename);
// // 添加下载链接到文档中
// document.body.appendChild(downloadLink);
// // 模拟用户点击下载链接来触发下载
// downloadLink.click();
// // 清理创建的对象和链接
// URL.revokeObjectURL(url);
// document.body.removeChild(downloadLink);
// };
// // 调用下载函数
// downloadCSV();
header.value = student.value[0]
//存储学生数据信息
const studentnum = student.value.slice(1)
for (let index = 3; index < studentnum[0].length; index++) {
pingjun.value.push(parseInt(studentnum.reduce((totil, per) => totil + per[index], 0) / studentnum.length))
}
studentchoose.value = student.value[1]
studentnum.forEach(item => {
studenpaiming.value.push({ name: item[2], value: item.slice(3).reduce((accumulator, currentValue) => accumulator + currentValue, 0) })
studenpaiming2.value.push({ id: item[1], value: item.slice(3).reduce((accumulator, currentValue) => accumulator + currentValue, 0) })
})
studenpaiming.value.sort((a, b) => b.value - a.value);
studenpaiming2.value.sort((a, b) => b.value - a.value);
}
//判断用户是否上传了标准文件
//存储用户上传的需要预测的数据
const updatawenjian = ref([])
//记录需要预测的数据数量
const numwenjian = ref([])
//存储当前需要预测的学生的总分
const numscore = ref([])
//存储预测文件的开头标题
let headeryuecei = ref([])
const updatafangfa = (value) => {
updatawenjian.value.push(value.splice(1))
headeryuecei.value = value
console.log(headeryuecei.value);
numwenjian.value.push(numwenjian.value.length + 1)
}
//存储文件的预测名
const wenjianname = ref([])
function appname(value) {
wenjianname.value.push(value)
}
function yuecei(qwedata) {
//存储数量
let numty = []
//存储第一科的分数
let frist = []
//存储第2科的分数
let frist2 = []
//存储第3科的分数
let frist3 = []
//存储第4科的分数
let frist4 = []
//存储第5科的分数
let frist5 = []
//存储第6科的分数
let frist6 = []
//存储总科科分数
let scores = []
let num = []
numscore.value = []
let studentname = ""
for (let index = 0; index < updatawenjian.value.length; index++) {
let number = updatawenjian.value[index].filter(item => item[1] === qwedata)
num.push(number)
numty.push(index)
}
console.log(num);
num.forEach(item => {
studentname = item[0][2]
let score = item[0].slice(3).reduce((pr, cr) => pr + cr, 0);
frist.push(item[0].slice(3)[0])
frist2.push(item[0].slice(3)[1])
frist3.push(item[0].slice(3)[2])
frist4.push(item[0].slice(3)[3])
frist5.push(item[0].slice(3)[4])
frist6.push(item[0].slice(3)[5])
scores.push(score)
numscore.value.push(score)
})
console.log(frist2);
numty.push(numty.length + 1)
// 计算总分平均分
const averageScore = scores.reduce((acc, score) => acc + score, 0) / scores.length;
//第一科平均分
const fristScore = frist.reduce((acc, score) => acc + score, 0) / frist.length
//第二科平均分
const frist2Score = frist2.reduce((acc, score) => acc + score, 0) / frist2.length
//第三科平均分
const frist3Score = frist3.reduce((acc, score) => acc + score, 0) / frist3.length
//第四科平均分
const frist4Score = frist4.reduce((acc, score) => acc + score, 0) / frist4.length
//第五科平均分
const frist5Score = frist5.reduce((acc, score) => acc + score, 0) / frist5.length
//第六科平均分
const frist6Score = frist6.reduce((acc, score) => acc + score, 0) / frist6.length
scores.push(huigui(numscore.value, numwenjian.value, averageScore))
frist.push(huigui(frist, numwenjian.value, fristScore))
frist2.push(huigui(frist2, numwenjian.value, frist2Score))
frist3.push(huigui(frist3, numwenjian.value, frist3Score))
frist4.push(huigui(frist4, numwenjian.value, frist4Score))
frist5.push(huigui(frist5, numwenjian.value, frist5Score))
frist6.push(huigui(frist6, numwenjian.value, frist6Score))
return [{ score: scores, value: numty, name: "总分" },
{ score: frist, value: numty, name: headeryuecei.value[0][3] },
{ score: frist2, value: numty, name: headeryuecei.value[0][4] },
{ score: frist3, value: numty, name: headeryuecei.value[0][5] },
{ score: frist4, value: numty, name: headeryuecei.value[0][6] },
{ score: frist5, value: numty, name: headeryuecei.value[0][7] },
{ score: frist6, value: numty, name: headeryuecei.value[0][8] }]
}
function huigui(scores, exams, averageScore) {
// 使用线性回归拟合模型
function linearRegression(x, y) {
const n = x.length;
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
for (let i = 0; i < n; i++) {
sumX += x[i];
sumY += y[i];
sumXY += x[i] * y[i];
sumX2 += x[i] * x[i];
}
//计算斜率和截距
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
const intercept = (sumY - slope * sumX) / n;
return { slope, intercept };
}
// 计算回归方程
const regressionEquation = linearRegression(exams, scores);
// 预测下次考试的分数
const nextExamNumber = exams.length + 1;
let predictedScore = regressionEquation.slope * nextExamNumber + regressionEquation.intercept;
// 确保预测到的分数不为负数,如果为负数,统一返回0
predictedScore = predictedScore < 0 ? 0 : predictedScore;
return predictedScore;
}
return { student, studentchoose, pingjun, jiazai, header, studenpaiming, studenpaiming2, updatawenjian, numwenjian, yuecei, updatafangfa, numscore, appname, wenjianname }
}, {
persist: true
})
4.2:Echar图表更新步骤,以上图学生个人可视化分析的里面的雷达图举例。
<template>
<div ref="myEchartss" :style="{ width: '100%', height: '100%' }"></div>
</template>
<script setup>
import { usenamePoechars } from '@/stores/Poechars.js'
import * as echarts from 'echarts';
import { ref, onMounted, watch, toRefs } from 'vue';
const datawe = usenamePoechars();
const { student, pingjun, studentchoose, header } = toRefs(datawe);
let datas = [
{ Name: '语文', TotalScore: 150, Score: 76, AvgScore: 72 },
{ Name: '数学', TotalScore: 150, Score: 71, AvgScore: 63 },
{ Name: '英语', TotalScore: 150, Score: 56, AvgScore: 58 },
{ Name: '物理', TotalScore: 100, Score: 81, AvgScore: 68 },
{ Name: '化学', TotalScore: 100, Score: 77, AvgScore: 65 },
{ Name: '生物', TotalScore: 100, Score: 77, AvgScore: 65 }
];
let colorList = ['#36A87A', '#3f76f2'];
let aveList = datas.map((n) => { return n.AvgScore; });
let uList = datas.map((n) => { return n.Score; });
let nameList = [];
datas.forEach((item) => {
nameList.push({
name: item.Name,
max: 150,
AvgScore: item.AvgScore,
Score: item.Score
});
});
let option = {
title: {
text: `综合得分:${datas.reduce((er, per) => er + per.Score, 0)}分`,
left: 'center',
textStyle: {
// 图例文字的样式
fontSize: 18,
color: '#fff'
}
},
legend: {
data: ['你的得分', '平均得分'],
left: 'center',
top: 'bottom',
itemGap: 50,
textStyle: {
// 图例文字的样式
fontSize: 14,
color: '#fff'
}
},
radar: {
center: ['50%', '55%'], // 图表位置
radius: '50%', // 图表大小
// 设置雷达图中间射线的颜色
axisLine: {
lineStyle: {
color: '#999',
fontSize: 30
}
},
indicator: nameList,
// 雷达图背景的颜色,在这儿随便设置了一个颜色,完全不透明度为0,就实现了透明背景
splitArea: {
areaStyle: {
color: '#a0cfff' // 图表背景的颜色
}
},
name: {
lineHeight: 18,
formatter: (labelName, raw) => {
const { AvgScore, Score } = raw;
return (
labelName + '\n' + `{score|${Score}}` + '/' + `{avg|${AvgScore}}`
);
},
rich: {
score: {
color: colorList[0],
fontSize: 16
},
avg: {
color: colorList[1],
fontSize: 16
}
}
}
},
series: [
{
type: 'radar',
data: [
{
value: uList,
name: '你的得分',
// 设置区域边框和区域的颜色
itemStyle: {
color: colorList[0]
},
label: {
show: false,
fontSize: 16,
position: 'right',
color: colorList[0],
formatter: function (params) {
return params.value;
}
},
areaStyle: {
color: colorList[0],
opacity: 0.2
}
},
{
value: aveList,
name: '平均得分',
// 设置区域边框和区域的颜色
itemStyle: {
color: colorList[1]
},
label: {
show: false,
fontSize: 16,
position: 'left',
color: colorList[1],
formatter: function (params) {
return params.value;
}
},
areaStyle: {
color: colorList[1],
opacity: 0.2
}
}
]
}
]
}
const myEchartss = ref(null);
onMounted(() => {
initChart(option);
});
function initChart(options) {
const chartDom = myEchartss.value;
if (!chartDom) return;
const chart = echarts.init(chartDom);
chart.setOption(options);
window.onresize = () => {
chart.resize();
};
}
watch(studentchoose, (newvalue) => {
datas = []
for (let index = 3; index < header.value.length; index++) {
datas.push({ Name: header.value[index], TotalScore: 150, Score: studentchoose.value[index], AvgScore: pingjun.value[index - 3] })
}
aveList = datas.map((n) => { return n.AvgScore; });
uList = datas.map((n) => { return n.Score; });
nameList = [];
datas.forEach((item) => {
nameList.push({
name: item.Name,
max: 150,
AvgScore: item.AvgScore,
Score: item.Score
});
});
option = {
title: {
text: `综合得分:${datas.reduce((er, per) => er + per.Score, 0)}分`,
left: 'center'
},
legend: {
data: ['你的得分', '平均得分'],
left: 'center',
top: 'bottom',
itemGap: 50,
textStyle: {
// 图例文字的样式
fontSize: 14
}
},
radar: {
center: ['50%', '55%'], // 图表位置
radius: '50%', // 图表大小
// 设置雷达图中间射线的颜色
axisLine: {
lineStyle: {
color: '#999',
fontSize: 30
}
},
indicator: nameList,
// 雷达图背景的颜色,在这儿随便设置了一个颜色,完全不透明度为0,就实现了透明背景
splitArea: {
areaStyle: {
color: '#d7cece' // 图表背景的颜色
}
},
name: {
lineHeight: 18,
formatter: (labelName, raw) => {
const { AvgScore, Score } = raw;
return (
labelName + '\n' + `{score|${Score}}` + '/' + `{avg|${AvgScore}}`
);
},
rich: {
score: {
color: colorList[0],
fontSize: 16
},
avg: {
color: colorList[1],
fontSize: 16
}
}
}
},
series: [
{
type: 'radar',
data: [
{
value: uList,
name: '你的得分',
// 设置区域边框和区域的颜色
itemStyle: {
color: colorList[0]
},
label: {
show: false,
fontSize: 16,
position: 'right',
color: colorList[0],
formatter: function (params) {
return params.value;
}
},
areaStyle: {
color: colorList[0],
opacity: 0.2
}
},
{
value: aveList,
name: '平均得分',
// 设置区域边框和区域的颜色
itemStyle: {
color: colorList[1]
},
label: {
show: false,
fontSize: 16,
position: 'left',
color: colorList[1],
formatter: function (params) {
return params.value;
}
},
areaStyle: {
color: colorList[1],
opacity: 0.2
}
}
]
}
]
}
initChart(option);
}, {
deep: true
})
</script>
<style></style>
及通过watch监听studentchoose的数据变化,来刷新界面。
5.学生成绩预测界面:
将学生每一期的数据上传后(至少上传两个文件)然后基于最基础的公式y=kx+b来进行预测,后续会考虑采用更先进的方式进行综合的预测。