1
This commit is contained in:
823
pages/dashboard/dashboard.js
Normal file
823
pages/dashboard/dashboard.js
Normal file
@@ -0,0 +1,823 @@
|
||||
// pages/dashboard/dashboard.js
|
||||
const gpaPredictor = require('../../utils/gpaPredictor');
|
||||
const util = require('../../utils/util');
|
||||
const learningTracker = require('../../utils/learningTracker');
|
||||
|
||||
Page({
|
||||
data: {
|
||||
// 设备信息
|
||||
windowWidth: 375,
|
||||
windowHeight: 667,
|
||||
pixelRatio: 2,
|
||||
|
||||
// 顶部统计
|
||||
stats: {
|
||||
totalDays: 0,
|
||||
totalHours: 0,
|
||||
avgGPA: 0
|
||||
},
|
||||
|
||||
// 雷达图数据
|
||||
radarData: {
|
||||
indicators: [
|
||||
{ name: '专注度', max: 100 },
|
||||
{ name: '活跃度', max: 100 },
|
||||
{ name: '学习时长', max: 100 },
|
||||
{ name: '知识广度', max: 100 },
|
||||
{ name: '互动性', max: 100 },
|
||||
{ name: '坚持度', max: 100 }
|
||||
],
|
||||
values: [85, 90, 75, 88, 70, 95]
|
||||
},
|
||||
|
||||
// GPA预测数据
|
||||
gpaHistory: [],
|
||||
prediction: {
|
||||
nextSemester: 0,
|
||||
trend: 0
|
||||
},
|
||||
|
||||
// 饼图数据
|
||||
pieData: [],
|
||||
|
||||
// 柱状图数据
|
||||
barData: {
|
||||
aboveAvg: 0,
|
||||
ranking: 0
|
||||
},
|
||||
|
||||
updateTime: ''
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
// 清理可能存在的旧模拟数据
|
||||
this.cleanOldData();
|
||||
|
||||
// 获取设备信息
|
||||
const systemInfo = wx.getSystemInfoSync();
|
||||
this.setData({
|
||||
windowWidth: systemInfo.windowWidth,
|
||||
windowHeight: systemInfo.windowHeight,
|
||||
pixelRatio: systemInfo.pixelRatio
|
||||
});
|
||||
|
||||
this.loadAllData();
|
||||
},
|
||||
|
||||
/**
|
||||
* 清理旧的模拟数据
|
||||
*/
|
||||
cleanOldData() {
|
||||
// 检查是否需要清理(可通过版本号判断)
|
||||
const dataVersion = wx.getStorageSync('data_version');
|
||||
if (dataVersion !== '2.0') {
|
||||
console.log('[Dashboard] 清理旧数据...');
|
||||
|
||||
// 只清理GPA历史记录,让其从真实课程重新生成
|
||||
wx.removeStorageSync('gpa_history');
|
||||
|
||||
// 标记数据版本
|
||||
wx.setStorageSync('data_version', '2.0');
|
||||
|
||||
console.log('[Dashboard] 数据清理完成');
|
||||
}
|
||||
},
|
||||
|
||||
onShow() {
|
||||
// 开始跟踪学习时间
|
||||
learningTracker.onPageShow('tools')
|
||||
|
||||
// 刷新数据
|
||||
this.loadAllData();
|
||||
},
|
||||
|
||||
onHide() {
|
||||
// 停止跟踪学习时间
|
||||
learningTracker.onPageHide()
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
// 记录学习时长
|
||||
learningTracker.onPageUnload()
|
||||
},
|
||||
|
||||
onPullDownRefresh() {
|
||||
this.loadAllData();
|
||||
setTimeout(() => {
|
||||
wx.stopPullDownRefresh();
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载所有数据
|
||||
*/
|
||||
loadAllData() {
|
||||
this.loadStats();
|
||||
this.loadRadarData();
|
||||
this.loadGPAData();
|
||||
this.loadPieData();
|
||||
this.loadBarData();
|
||||
|
||||
this.setData({
|
||||
updateTime: util.formatTime(new Date(), 'yyyy-MM-dd hh:mm')
|
||||
});
|
||||
|
||||
// 延迟绘制图表,确保DOM已渲染
|
||||
setTimeout(() => {
|
||||
this.drawRadarChart();
|
||||
this.drawLineChart();
|
||||
this.drawPieChart();
|
||||
this.drawBarChart();
|
||||
}, 300);
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载统计数据
|
||||
*/
|
||||
loadStats() {
|
||||
// 从学习追踪器获取真实数据
|
||||
const stats = learningTracker.getStats();
|
||||
|
||||
// 加载GPA数据
|
||||
const gpaCourses = wx.getStorageSync('gpaCourses') || [];
|
||||
const avgGPA = gpaPredictor.calculateAverageGPA(gpaCourses);
|
||||
|
||||
this.setData({
|
||||
stats: {
|
||||
totalDays: stats.continuousDays,
|
||||
totalHours: stats.totalHours,
|
||||
avgGPA: avgGPA || 0
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 计算连续学习天数
|
||||
*/
|
||||
calculateContinuousDays(records) {
|
||||
if (!records || records.length === 0) return 0;
|
||||
|
||||
// 按日期排序
|
||||
const sortedRecords = records.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
|
||||
let continuousDays = 0;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
for (let i = 0; i < sortedRecords.length; i++) {
|
||||
const recordDate = new Date(sortedRecords[i].date);
|
||||
recordDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const daysDiff = Math.floor((today - recordDate) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysDiff === i) {
|
||||
continuousDays++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return continuousDays;
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载雷达图数据
|
||||
*/
|
||||
loadRadarData() {
|
||||
// 从存储加载真实计算的学习画像
|
||||
const learningProfile = wx.getStorageSync('learning_profile') || {
|
||||
focus: 0, // 专注度
|
||||
activity: 0, // 活跃度
|
||||
duration: 0, // 学习时长
|
||||
breadth: 0, // 知识广度
|
||||
interaction: 0, // 互动性
|
||||
persistence: 0 // 坚持度
|
||||
};
|
||||
|
||||
this.setData({
|
||||
'radarData.values': [
|
||||
Math.min(100, Math.round(learningProfile.focus)),
|
||||
Math.min(100, Math.round(learningProfile.activity)),
|
||||
Math.min(100, Math.round(learningProfile.duration)),
|
||||
Math.min(100, Math.round(learningProfile.breadth)),
|
||||
Math.min(100, Math.round(learningProfile.interaction)),
|
||||
Math.min(100, Math.round(learningProfile.persistence))
|
||||
]
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载GPA数据和预测
|
||||
*/
|
||||
loadGPAData() {
|
||||
// 从GPA计算器获取真实数据
|
||||
const gpaCourses = wx.getStorageSync('gpaCourses') || [];
|
||||
|
||||
console.log('[Dashboard] 读取到的课程数据:', gpaCourses);
|
||||
|
||||
// 强制从课程数据重新生成历史记录(不使用缓存)
|
||||
let gpaHistory = [];
|
||||
|
||||
if (gpaCourses.length > 0) {
|
||||
// 按学期分组计算平均GPA
|
||||
const semesterMap = {};
|
||||
gpaCourses.forEach(course => {
|
||||
const semester = course.semester || '2024-2';
|
||||
if (!semesterMap[semester]) {
|
||||
semesterMap[semester] = { courses: [] };
|
||||
}
|
||||
semesterMap[semester].courses.push(course);
|
||||
});
|
||||
|
||||
console.log('[Dashboard] 学期分组:', semesterMap);
|
||||
|
||||
// 计算每个学期的加权平均GPA
|
||||
Object.keys(semesterMap).sort().forEach(semester => {
|
||||
const data = semesterMap[semester];
|
||||
const avgGPA = gpaPredictor.calculateAverageGPA(data.courses);
|
||||
gpaHistory.push({
|
||||
semester: semester,
|
||||
gpa: parseFloat(avgGPA)
|
||||
});
|
||||
});
|
||||
|
||||
// 保存到存储
|
||||
if (gpaHistory.length > 0) {
|
||||
wx.setStorageSync('gpa_history', gpaHistory);
|
||||
console.log('[Dashboard] GPA历史记录:', gpaHistory);
|
||||
}
|
||||
}
|
||||
|
||||
// 设置数据(即使为空也设置,避免undefined)
|
||||
this.setData({
|
||||
gpaHistory: gpaHistory.length > 0 ? gpaHistory : []
|
||||
});
|
||||
|
||||
// 只有在有足够数据时才进行预测(至少2个学期)
|
||||
if (gpaHistory.length >= 2) {
|
||||
const predictionResult = gpaPredictor.polynomialRegression(gpaHistory, 2, 3);
|
||||
|
||||
console.log('[Dashboard] GPA预测结果:', predictionResult);
|
||||
|
||||
this.setData({
|
||||
prediction: {
|
||||
nextSemester: predictionResult.predictions[0]?.gpa || 0,
|
||||
trend: predictionResult.trend,
|
||||
confidence: predictionResult.confidence
|
||||
}
|
||||
});
|
||||
} else if (gpaHistory.length === 1) {
|
||||
// 只有一个学期,无法预测,显示当前GPA
|
||||
console.log('[Dashboard] 只有一个学期数据,无法预测');
|
||||
this.setData({
|
||||
prediction: {
|
||||
nextSemester: gpaHistory[0].gpa,
|
||||
trend: 0,
|
||||
confidence: 0
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 没有数据
|
||||
console.log('[Dashboard] 没有GPA数据');
|
||||
this.setData({
|
||||
prediction: {
|
||||
nextSemester: 0,
|
||||
trend: 0,
|
||||
confidence: 0
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载饼图数据
|
||||
*/
|
||||
loadPieData() {
|
||||
// 从学习追踪器获取真实的模块使用数据
|
||||
const moduleUsage = wx.getStorageSync('module_usage') || {
|
||||
course: 0,
|
||||
forum: 0,
|
||||
tools: 0,
|
||||
ai: 0
|
||||
};
|
||||
|
||||
const total = Object.values(moduleUsage).reduce((sum, val) => sum + val, 0);
|
||||
|
||||
// 如果没有数据,显示提示
|
||||
if (total === 0) {
|
||||
this.setData({
|
||||
pieData: [
|
||||
{ name: '课程中心', time: 0, percent: 25, color: '#4A90E2' },
|
||||
{ name: '学科论坛', time: 0, percent: 25, color: '#50C878' },
|
||||
{ name: '学习工具', time: 0, percent: 25, color: '#9B59B6' },
|
||||
{ name: '启思AI', time: 0, percent: 25, color: '#6C5CE7' }
|
||||
]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pieData = [
|
||||
{
|
||||
name: '课程中心',
|
||||
time: parseFloat(moduleUsage.course.toFixed(1)),
|
||||
percent: Math.round((moduleUsage.course / total) * 100),
|
||||
color: '#4A90E2'
|
||||
},
|
||||
{
|
||||
name: '学科论坛',
|
||||
time: parseFloat(moduleUsage.forum.toFixed(1)),
|
||||
percent: Math.round((moduleUsage.forum / total) * 100),
|
||||
color: '#50C878'
|
||||
},
|
||||
{
|
||||
name: '学习工具',
|
||||
time: parseFloat(moduleUsage.tools.toFixed(1)),
|
||||
percent: Math.round((moduleUsage.tools / total) * 100),
|
||||
color: '#9B59B6'
|
||||
},
|
||||
{
|
||||
name: '启思AI',
|
||||
time: parseFloat(moduleUsage.ai.toFixed(1)),
|
||||
percent: Math.round((moduleUsage.ai / total) * 100),
|
||||
color: '#6C5CE7'
|
||||
}
|
||||
];
|
||||
|
||||
this.setData({ pieData });
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载柱状图数据
|
||||
*/
|
||||
loadBarData() {
|
||||
const gpaCourses = wx.getStorageSync('gpaCourses') || [];
|
||||
|
||||
// 计算超过平均分的课程数
|
||||
let aboveAvg = 0;
|
||||
gpaCourses.forEach(course => {
|
||||
if (course.score > 75) { // 假设75为平均分
|
||||
aboveAvg++;
|
||||
}
|
||||
});
|
||||
|
||||
// 模拟排名(基于平均GPA)
|
||||
const avgGPA = parseFloat(this.data.stats.avgGPA);
|
||||
let ranking = 30; // 默认前30%
|
||||
if (avgGPA >= 3.5) ranking = 10;
|
||||
else if (avgGPA >= 3.0) ranking = 20;
|
||||
|
||||
this.setData({
|
||||
barData: {
|
||||
aboveAvg,
|
||||
ranking
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 绘制雷达图
|
||||
*/
|
||||
drawRadarChart() {
|
||||
const query = wx.createSelectorQuery();
|
||||
query.select('#radarChart').boundingClientRect();
|
||||
query.exec((res) => {
|
||||
if (!res[0]) return;
|
||||
|
||||
const canvasWidth = res[0].width;
|
||||
const canvasHeight = res[0].height;
|
||||
const ctx = wx.createCanvasContext('radarChart', this);
|
||||
const centerX = canvasWidth / 2;
|
||||
const centerY = canvasHeight / 2;
|
||||
const radius = Math.min(canvasWidth, canvasHeight) / 2 - 60;
|
||||
const data = this.data.radarData.values;
|
||||
const indicators = this.data.radarData.indicators;
|
||||
const angleStep = (Math.PI * 2) / indicators.length;
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
|
||||
// 绘制背景网格
|
||||
ctx.setLineWidth(1);
|
||||
ctx.setStrokeStyle('#E0E0E0');
|
||||
|
||||
for (let level = 1; level <= 5; level++) {
|
||||
ctx.beginPath();
|
||||
const r = (radius / 5) * level;
|
||||
for (let i = 0; i <= indicators.length; i++) {
|
||||
const angle = i * angleStep - Math.PI / 2;
|
||||
const x = centerX + r * Math.cos(angle);
|
||||
const y = centerY + r * Math.sin(angle);
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 绘制轴线和标签
|
||||
ctx.setFillStyle('#333333');
|
||||
ctx.setFontSize(24);
|
||||
ctx.setTextAlign('center');
|
||||
|
||||
for (let i = 0; i < indicators.length; i++) {
|
||||
const angle = i * angleStep - Math.PI / 2;
|
||||
const x = centerX + radius * Math.cos(angle);
|
||||
const y = centerY + radius * Math.sin(angle);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX, centerY);
|
||||
ctx.lineTo(x, y);
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制维度标签
|
||||
const labelDistance = radius + 30;
|
||||
const labelX = centerX + labelDistance * Math.cos(angle);
|
||||
const labelY = centerY + labelDistance * Math.sin(angle);
|
||||
|
||||
ctx.fillText(indicators[i].name, labelX, labelY);
|
||||
}
|
||||
|
||||
// 绘制数据区域
|
||||
ctx.beginPath();
|
||||
ctx.setFillStyle('rgba(102, 126, 234, 0.3)');
|
||||
ctx.setStrokeStyle('#667eea');
|
||||
ctx.setLineWidth(2);
|
||||
|
||||
for (let i = 0; i <= data.length; i++) {
|
||||
const index = i % data.length;
|
||||
const value = data[index];
|
||||
const angle = index * angleStep - Math.PI / 2;
|
||||
const r = (value / 100) * radius;
|
||||
const x = centerX + r * Math.cos(angle);
|
||||
const y = centerY + r * Math.sin(angle);
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
// 绘制数据点
|
||||
ctx.arc(x, y, 4, 0, Math.PI * 2);
|
||||
ctx.moveTo(x, y);
|
||||
}
|
||||
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.draw();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 绘制折线图(GPA趋势)
|
||||
*/
|
||||
drawLineChart() {
|
||||
const query = wx.createSelectorQuery();
|
||||
query.select('#lineChart').boundingClientRect();
|
||||
query.exec((res) => {
|
||||
if (!res[0]) return;
|
||||
|
||||
const canvasWidth = res[0].width;
|
||||
const canvasHeight = res[0].height;
|
||||
const ctx = wx.createCanvasContext('lineChart', this);
|
||||
const history = this.data.gpaHistory;
|
||||
const prediction = this.data.prediction;
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
|
||||
// 如果没有数据,显示提示
|
||||
if (!history || history.length === 0) {
|
||||
ctx.setFillStyle('#999999');
|
||||
ctx.setFontSize(24);
|
||||
ctx.setTextAlign('center');
|
||||
ctx.fillText('暂无GPA数据', canvasWidth / 2, canvasHeight / 2);
|
||||
ctx.fillText('请先在GPA工具中录入成绩', canvasWidth / 2, canvasHeight / 2 + 30);
|
||||
ctx.draw();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[绘制折线图] 历史数据:', history);
|
||||
|
||||
const padding = { top: 40, right: 40, bottom: 60, left: 60 };
|
||||
const width = canvasWidth - padding.left - padding.right;
|
||||
const height = canvasHeight - padding.top - padding.bottom;
|
||||
|
||||
// 合并历史和预测数据
|
||||
const allData = [...history];
|
||||
const predictionResult = gpaPredictor.polynomialRegression(history, 2, 3);
|
||||
if (predictionResult.predictions && predictionResult.predictions.length > 0) {
|
||||
allData.push(...predictionResult.predictions);
|
||||
console.log('[绘制折线图] 预测数据:', predictionResult.predictions);
|
||||
}
|
||||
|
||||
const maxGPA = 4.5; // 调整最大值为4.5
|
||||
const minGPA = 0;
|
||||
const stepX = allData.length > 1 ? width / (allData.length - 1) : width / 2;
|
||||
const scaleY = height / (maxGPA - minGPA);
|
||||
|
||||
// 绘制坐标轴
|
||||
ctx.setStrokeStyle('#CCCCCC');
|
||||
ctx.setLineWidth(1);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, padding.top);
|
||||
ctx.lineTo(padding.left, padding.top + height);
|
||||
ctx.lineTo(padding.left + width, padding.top + height);
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制网格线和Y轴刻度
|
||||
ctx.setStrokeStyle('#F0F0F0');
|
||||
ctx.setFillStyle('#999999');
|
||||
ctx.setFontSize(20);
|
||||
ctx.setTextAlign('right');
|
||||
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const gpaValue = i * 1.0;
|
||||
const y = padding.top + height - (gpaValue * scaleY);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, y);
|
||||
ctx.lineTo(padding.left + width, y);
|
||||
ctx.stroke();
|
||||
ctx.fillText(gpaValue.toFixed(1), padding.left - 10, y + 6);
|
||||
}
|
||||
|
||||
// 绘制历史数据折线
|
||||
if (history.length > 0) {
|
||||
ctx.setStrokeStyle('#667eea');
|
||||
ctx.setLineWidth(3);
|
||||
ctx.beginPath();
|
||||
|
||||
history.forEach((item, index) => {
|
||||
const x = padding.left + index * stepX;
|
||||
const y = padding.top + height - (item.gpa * scaleY);
|
||||
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制历史数据点和数值
|
||||
ctx.setFillStyle('#667eea');
|
||||
history.forEach((item, index) => {
|
||||
const x = padding.left + index * stepX;
|
||||
const y = padding.top + height - (item.gpa * scaleY);
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 显示GPA值
|
||||
ctx.setFillStyle('#333333');
|
||||
ctx.setFontSize(18);
|
||||
ctx.setTextAlign('center');
|
||||
ctx.fillText(item.gpa.toFixed(2), x, y - 15);
|
||||
});
|
||||
}
|
||||
|
||||
// 绘制预测数据折线(虚线)
|
||||
if (predictionResult.predictions && predictionResult.predictions.length > 0 && history.length > 0) {
|
||||
ctx.setStrokeStyle('#A29BFE');
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.setLineWidth(2);
|
||||
ctx.beginPath();
|
||||
|
||||
const startIndex = history.length - 1;
|
||||
const startX = padding.left + startIndex * stepX;
|
||||
const startY = padding.top + height - (history[startIndex].gpa * scaleY);
|
||||
ctx.moveTo(startX, startY);
|
||||
|
||||
predictionResult.predictions.forEach((item, index) => {
|
||||
const x = padding.left + (startIndex + index + 1) * stepX;
|
||||
const y = padding.top + height - (item.gpa * scaleY);
|
||||
ctx.lineTo(x, y);
|
||||
});
|
||||
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// 绘制预测数据点和数值
|
||||
ctx.setFillStyle('#A29BFE');
|
||||
predictionResult.predictions.forEach((item, index) => {
|
||||
const x = padding.left + (startIndex + index + 1) * stepX;
|
||||
const y = padding.top + height - (item.gpa * scaleY);
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 显示预测GPA值
|
||||
ctx.setFillStyle('#A29BFE');
|
||||
ctx.setFontSize(18);
|
||||
ctx.setTextAlign('center');
|
||||
ctx.fillText(item.gpa.toFixed(2), x, y - 15);
|
||||
});
|
||||
}
|
||||
|
||||
// 绘制X轴标签(旋转避免重叠)
|
||||
ctx.setFillStyle('#999999');
|
||||
ctx.setFontSize(16);
|
||||
ctx.setTextAlign('left');
|
||||
|
||||
allData.forEach((item, index) => {
|
||||
const x = padding.left + index * stepX;
|
||||
const y = padding.top + height + 20;
|
||||
|
||||
// 旋转文字45度
|
||||
ctx.save();
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(Math.PI / 4);
|
||||
ctx.fillText(item.semester, 0, 0);
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
ctx.draw();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 绘制饼图
|
||||
*/
|
||||
drawPieChart() {
|
||||
const query = wx.createSelectorQuery();
|
||||
query.select('#pieChart').boundingClientRect();
|
||||
query.exec((res) => {
|
||||
if (!res[0]) return;
|
||||
|
||||
const canvasWidth = res[0].width;
|
||||
const canvasHeight = res[0].height;
|
||||
const ctx = wx.createCanvasContext('pieChart', this);
|
||||
const data = this.data.pieData;
|
||||
const centerX = canvasWidth / 2;
|
||||
const centerY = canvasHeight / 2;
|
||||
const radius = Math.min(canvasWidth, canvasHeight) / 2 - 40;
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
|
||||
// 计算总数(用于处理全0情况)
|
||||
const total = data.reduce((sum, item) => sum + item.time, 0);
|
||||
|
||||
if (total === 0) {
|
||||
// 如果没有数据,绘制完整的灰色圆环
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
||||
ctx.setFillStyle('#EEEEEE');
|
||||
ctx.fill();
|
||||
|
||||
// 绘制中心圆
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius * 0.5, 0, Math.PI * 2);
|
||||
ctx.setFillStyle('#FFFFFF');
|
||||
ctx.fill();
|
||||
} else {
|
||||
// 有数据时正常绘制
|
||||
let startAngle = -Math.PI / 2;
|
||||
|
||||
data.forEach((item, index) => {
|
||||
if (item.time > 0) { // 只绘制有数据的扇形
|
||||
const angle = (item.percent / 100) * Math.PI * 2;
|
||||
const endAngle = startAngle + angle;
|
||||
|
||||
// 绘制扇形
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX, centerY);
|
||||
ctx.arc(centerX, centerY, radius, startAngle, endAngle);
|
||||
ctx.closePath();
|
||||
ctx.setFillStyle(item.color);
|
||||
ctx.fill();
|
||||
|
||||
startAngle = endAngle;
|
||||
}
|
||||
});
|
||||
|
||||
// 绘制中心圆(甜甜圈效果)
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius * 0.5, 0, Math.PI * 2);
|
||||
ctx.setFillStyle('#FFFFFF');
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.draw();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 绘制柱状图
|
||||
*/
|
||||
drawBarChart() {
|
||||
const query = wx.createSelectorQuery();
|
||||
query.select('#barChart').boundingClientRect();
|
||||
query.exec((res) => {
|
||||
if (!res[0]) return;
|
||||
|
||||
const canvasWidth = res[0].width;
|
||||
const canvasHeight = res[0].height;
|
||||
const ctx = wx.createCanvasContext('barChart', this);
|
||||
const gpaCourses = wx.getStorageSync('gpaCourses') || [];
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
|
||||
// 取前6门课程
|
||||
const courses = gpaCourses.slice(0, 6);
|
||||
|
||||
if (courses.length === 0) {
|
||||
ctx.setFillStyle('#999999');
|
||||
ctx.setFontSize(28);
|
||||
ctx.fillText('暂无成绩数据', canvasWidth / 2 - 70, canvasHeight / 2);
|
||||
ctx.draw();
|
||||
return;
|
||||
}
|
||||
|
||||
const padding = { top: 40, right: 40, bottom: 80, left: 60 };
|
||||
const width = canvasWidth - padding.left - padding.right;
|
||||
const height = canvasHeight - padding.top - padding.bottom;
|
||||
const barWidth = width / (courses.length * 2 + 1);
|
||||
const maxScore = 100;
|
||||
|
||||
// 绘制坐标轴
|
||||
ctx.setStrokeStyle('#CCCCCC');
|
||||
ctx.setLineWidth(1);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, padding.top);
|
||||
ctx.lineTo(padding.left, padding.top + height);
|
||||
ctx.lineTo(padding.left + width, padding.top + height);
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制网格线
|
||||
ctx.setStrokeStyle('#F0F0F0');
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const y = padding.top + (height / 5) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, y);
|
||||
ctx.lineTo(padding.left + width, y);
|
||||
ctx.stroke();
|
||||
|
||||
// Y轴刻度
|
||||
ctx.setFillStyle('#999999');
|
||||
ctx.setFontSize(20);
|
||||
ctx.fillText((100 - i * 20).toString(), padding.left - 35, y + 6);
|
||||
}
|
||||
|
||||
// 绘制柱状图
|
||||
courses.forEach((course, index) => {
|
||||
const x = padding.left + (index * 2 + 1) * barWidth;
|
||||
const scoreHeight = (course.score / maxScore) * height;
|
||||
const avgHeight = (75 / maxScore) * height; // 假设75为平均分
|
||||
|
||||
// 个人成绩
|
||||
ctx.setFillStyle('#667eea');
|
||||
ctx.fillRect(x, padding.top + height - scoreHeight, barWidth * 0.8, scoreHeight);
|
||||
|
||||
// 平均成绩(对比)
|
||||
ctx.setFillStyle('#CCCCCC');
|
||||
ctx.fillRect(x + barWidth, padding.top + height - avgHeight, barWidth * 0.8, avgHeight);
|
||||
|
||||
// 课程名称
|
||||
ctx.setFillStyle('#333333');
|
||||
ctx.setFontSize(18);
|
||||
ctx.save();
|
||||
ctx.translate(x + barWidth, padding.top + height + 20);
|
||||
ctx.rotate(Math.PI / 4);
|
||||
const courseName = course.name.length > 4 ? course.name.substring(0, 4) + '...' : course.name;
|
||||
ctx.fillText(courseName, 0, 0);
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
// 图例
|
||||
ctx.setFillStyle('#667eea');
|
||||
ctx.fillRect(padding.left + width - 120, padding.top - 30, 20, 15);
|
||||
ctx.setFillStyle('#333333');
|
||||
ctx.setFontSize(20);
|
||||
ctx.fillText('个人', padding.left + width - 95, padding.top - 18);
|
||||
|
||||
ctx.setFillStyle('#CCCCCC');
|
||||
ctx.fillRect(padding.left + width - 50, padding.top - 30, 20, 15);
|
||||
ctx.fillText('平均', padding.left + width - 25, padding.top - 18);
|
||||
|
||||
ctx.draw();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 显示某天详情
|
||||
*/
|
||||
showDayDetail(e) {
|
||||
const { date } = e.currentTarget.dataset;
|
||||
const dailyActivity = wx.getStorageSync('daily_activity') || {};
|
||||
const activity = dailyActivity[date] || 0;
|
||||
|
||||
wx.showModal({
|
||||
title: date,
|
||||
content: `学习活跃度: ${activity}分钟`,
|
||||
showCancel: false
|
||||
});
|
||||
}
|
||||
});
|
||||
7
pages/dashboard/dashboard.json
Normal file
7
pages/dashboard/dashboard.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationBarTitleText": "学习数据",
|
||||
"navigationBarBackgroundColor": "#667eea",
|
||||
"navigationBarTextStyle": "white",
|
||||
"enablePullDownRefresh": true,
|
||||
"backgroundColor": "#F5F6FA"
|
||||
}
|
||||
123
pages/dashboard/dashboard.wxml
Normal file
123
pages/dashboard/dashboard.wxml
Normal file
@@ -0,0 +1,123 @@
|
||||
<!--pages/dashboard/dashboard.wxml-->
|
||||
<view class="dashboard-container">
|
||||
<!-- 顶部统计卡片 -->
|
||||
<view class="stats-header">
|
||||
<view class="stats-card">
|
||||
<view class="stat-value">{{stats.totalDays}}</view>
|
||||
<view class="stat-label">连续学习天数</view>
|
||||
<view class="stat-icon">🔥</view>
|
||||
</view>
|
||||
<view class="stats-card">
|
||||
<view class="stat-value">{{stats.totalHours}}</view>
|
||||
<view class="stat-label">累计学习小时</view>
|
||||
<view class="stat-icon">⏰</view>
|
||||
</view>
|
||||
<view class="stats-card">
|
||||
<view class="stat-value">{{stats.avgGPA}}</view>
|
||||
<view class="stat-label">平均绩点</view>
|
||||
<view class="stat-icon">📊</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 学习画像雷达图 -->
|
||||
<view class="chart-section">
|
||||
<view class="section-header">
|
||||
<view class="section-title">
|
||||
<text class="title-icon">🎯</text>
|
||||
<text class="title-text">学习能力画像</text>
|
||||
</view>
|
||||
<view class="section-desc">6维度综合评估</view>
|
||||
</view>
|
||||
<view class="chart-container">
|
||||
<canvas canvas-id="radarChart" class="chart-canvas" id="radarChart"></canvas>
|
||||
<view class="chart-legend">
|
||||
<view class="legend-item" wx:for="{{radarData.indicators}}" wx:key="name">
|
||||
<view class="legend-dot" style="background: #667eea;"></view>
|
||||
<text class="legend-name">{{item.name}}</text>
|
||||
<text class="legend-value">{{radarData.values[index]}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- GPA趋势与预测 -->
|
||||
<view class="chart-section">
|
||||
<view class="section-header">
|
||||
<view class="section-title">
|
||||
<text class="title-icon">📈</text>
|
||||
<text class="title-text">GPA趋势预测</text>
|
||||
</view>
|
||||
<view class="section-desc">基于多项式回归分析</view>
|
||||
</view>
|
||||
<view class="chart-container">
|
||||
<canvas canvas-id="lineChart" class="chart-canvas" id="lineChart"></canvas>
|
||||
<view class="prediction-info">
|
||||
<view class="prediction-item">
|
||||
<text class="pred-label">预测下学期:</text>
|
||||
<text class="pred-value">{{prediction.nextSemester}}</text>
|
||||
</view>
|
||||
<view class="prediction-item">
|
||||
<text class="pred-label">趋势:</text>
|
||||
<text class="pred-value {{prediction.trend > 0 ? 'trend-up' : 'trend-down'}}">
|
||||
{{prediction.trend > 0 ? '上升' : '下降'}} {{prediction.trend}}%
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 模块使用时长饼图 -->
|
||||
<view class="chart-section">
|
||||
<view class="section-header">
|
||||
<view class="section-title">
|
||||
<text class="title-icon">⏱️</text>
|
||||
<text class="title-text">时间分配</text>
|
||||
</view>
|
||||
<view class="section-desc">各功能使用占比</view>
|
||||
</view>
|
||||
<view class="chart-container">
|
||||
<canvas canvas-id="pieChart" class="chart-canvas pie-canvas" id="pieChart"></canvas>
|
||||
<view class="pie-legend">
|
||||
<view class="pie-legend-item" wx:for="{{pieData}}" wx:key="name">
|
||||
<view class="pie-dot" style="background: {{item.color}};"></view>
|
||||
<text class="pie-name">{{item.name}}</text>
|
||||
<text class="pie-percent">{{item.percent}}%</text>
|
||||
<text class="pie-time">{{item.time}}h</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 成绩对比柱状图 -->
|
||||
<view class="chart-section">
|
||||
<view class="section-header">
|
||||
<view class="section-title">
|
||||
<text class="title-icon">📊</text>
|
||||
<text class="title-text">成绩对比</text>
|
||||
</view>
|
||||
<view class="section-desc">个人 vs 班级平均</view>
|
||||
</view>
|
||||
<view class="chart-container">
|
||||
<canvas canvas-id="barChart" class="chart-canvas" id="barChart"></canvas>
|
||||
<view class="bar-summary">
|
||||
<view class="summary-item">
|
||||
<text class="summary-label">超过平均:</text>
|
||||
<text class="summary-value">{{barData.aboveAvg}}门课程</text>
|
||||
</view>
|
||||
<view class="summary-item">
|
||||
<text class="summary-label">排名:</text>
|
||||
<text class="summary-value">前{{barData.ranking}}%</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据来源说明 -->
|
||||
<view class="data-source">
|
||||
<view class="source-icon">ℹ️</view>
|
||||
<view class="source-text">
|
||||
<text class="source-title">数据说明</text>
|
||||
<text class="source-desc">以上数据基于您的学习记录自动生成,更新时间: {{updateTime}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
279
pages/dashboard/dashboard.wxss
Normal file
279
pages/dashboard/dashboard.wxss
Normal file
@@ -0,0 +1,279 @@
|
||||
/* pages/dashboard/dashboard.wxss */
|
||||
@import "/styles/design-tokens.wxss";
|
||||
@import "/styles/premium-animations.wxss";
|
||||
|
||||
.dashboard-container {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 30rpx;
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 30rpx);
|
||||
}
|
||||
|
||||
/* 顶部统计卡片 */
|
||||
.stats-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 30rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
flex: 1;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 20rpx;
|
||||
padding: 30rpx 20rpx;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1);
|
||||
animation: fadeInUp 0.6s ease;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 22rpx;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
right: 20rpx;
|
||||
font-size: 32rpx;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* 图表区域 */
|
||||
.chart-section {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 20rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 30rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1);
|
||||
animation: fadeInUp 0.6s ease;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 30rpx;
|
||||
padding-bottom: 20rpx;
|
||||
border-bottom: 2rpx solid #F0F0F0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
font-size: 36rpx;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 24rpx;
|
||||
color: #999999;
|
||||
margin-left: 48rpx;
|
||||
}
|
||||
|
||||
/* 画布容器 */
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
width: 100%;
|
||||
height: 500rpx;
|
||||
}
|
||||
|
||||
.pie-canvas {
|
||||
height: 400rpx;
|
||||
}
|
||||
|
||||
/* 雷达图图例 */
|
||||
.chart-legend {
|
||||
margin-top: 30rpx;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.legend-name {
|
||||
flex: 1;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.legend-value {
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
/* 预测信息 */
|
||||
.prediction-info {
|
||||
margin-top: 30rpx;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 20rpx;
|
||||
background: #F8F9FA;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.prediction-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pred-label {
|
||||
font-size: 24rpx;
|
||||
color: #999999;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.pred-value {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.trend-up {
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.trend-down {
|
||||
color: #FF5252;
|
||||
}
|
||||
|
||||
/* 饼图图例 */
|
||||
.pie-legend {
|
||||
margin-top: 30rpx;
|
||||
}
|
||||
|
||||
.pie-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid #F0F0F0;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.pie-legend-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.pie-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.pie-name {
|
||||
flex: 1;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.pie-percent {
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.pie-time {
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
/* 柱状图总结 */
|
||||
.bar-summary {
|
||||
margin-top: 30rpx;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 20rpx;
|
||||
background: #F8F9FA;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 24rpx;
|
||||
color: #999999;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
/* 数据来源说明 */
|
||||
.data-source {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 30rpx;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.source-icon {
|
||||
font-size: 36rpx;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.source-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.source-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: bold;
|
||||
color: #FFFFFF;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.source-desc {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(40rpx);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user