Spaces:
Paused
Paused
Upload 5 files
Browse files- services/AISummary.js +452 -0
- services/EyeTimeQuery.js +111 -0
- services/EyeTimeRecorder.js +182 -0
- services/StatsQuery.js +253 -0
- services/StatsRecorder.js +236 -0
services/AISummary.js
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// AISummary.js - AI总结模块
|
| 2 |
+
const cron = require('node-cron');
|
| 3 |
+
const {config} = require("dotenv");
|
| 4 |
+
|
| 5 |
+
class AISummary {
|
| 6 |
+
/**
|
| 7 |
+
* 获取用户时区的执行时间点列表(格式化字符串)
|
| 8 |
+
* @returns {string[]} 例如: ['4:00', '8:00', '12:00', '16:00', '20:00']
|
| 9 |
+
*/
|
| 10 |
+
getScheduleStrings() {
|
| 11 |
+
try {
|
| 12 |
+
const scheduleHours = this.generateScheduleHours();
|
| 13 |
+
return scheduleHours.map(hour => {
|
| 14 |
+
const h = hour.toString().padStart(2, '0');
|
| 15 |
+
return `${h}:00`;
|
| 16 |
+
});
|
| 17 |
+
} catch (error) {
|
| 18 |
+
console.error('[AISummary] 获取schedules失败:', error.message);
|
| 19 |
+
return [];
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
constructor(statsRecorder, statsQuery, config = {}) {
|
| 24 |
+
this.recorder = statsRecorder;
|
| 25 |
+
this.query = statsQuery;
|
| 26 |
+
|
| 27 |
+
// AI配置
|
| 28 |
+
this.aiConfig = {
|
| 29 |
+
apiUrl: config.aiApiUrl || process.env.AI_API_URL || 'https://api.openai.com/v1/chat/completions',
|
| 30 |
+
apiKey: config.aiApiKey || process.env.AI_API_KEY || '',
|
| 31 |
+
model: config.aiModel || process.env.AI_MODEL || 'gpt-4',
|
| 32 |
+
maxTokens: config.aiMaxTokens || 1000,
|
| 33 |
+
timezoneOffset: config.timezoneOffset || parseInt(process.env.DEFAULT_TIMEZONE_OFFSET) || 8,
|
| 34 |
+
aiPrompt: config.aiPrompt || '无',
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
// 发布配置
|
| 38 |
+
this.publishConfig = {
|
| 39 |
+
publishEnabled: config.publishEnabled || process.env.PUBLISH_ENABLED || false,
|
| 40 |
+
apiUrl: config.publishApiUrl || process.env.PUBLISH_API_URL || '',
|
| 41 |
+
apiKey: config.publishApiKey || process.env.PUBLISH_API_KEY || '',
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
// 定时任务配置
|
| 45 |
+
this.scheduleConfig = {
|
| 46 |
+
// 时间间隔(小时),默认4小时
|
| 47 |
+
intervalHours: config.intervalHours || parseInt(process.env.SCHEDULE_INTERVAL_HOURS) || 4,
|
| 48 |
+
|
| 49 |
+
// 起始时间(小时),默认0点(但不触发)
|
| 50 |
+
startHour: config.startHour !== undefined ? config.startHour : 0,
|
| 51 |
+
|
| 52 |
+
// 结束时间(小时),默认24点
|
| 53 |
+
endHour: config.endHour !== undefined ? config.endHour : 24,
|
| 54 |
+
|
| 55 |
+
// 是否跳过起始时间的触发,默认true
|
| 56 |
+
skipStartHour: config.skipStartHour !== false
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
// 定时任务实例
|
| 60 |
+
this.cronJobs = [];
|
| 61 |
+
|
| 62 |
+
// 是否启用定时任务
|
| 63 |
+
this.enabled = config.enabled !== false;
|
| 64 |
+
|
| 65 |
+
// 存储最近的总结结果(内存缓存)
|
| 66 |
+
this.recentSummaries = new Map();
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// 生成执行时间点
|
| 70 |
+
generateScheduleHours() {
|
| 71 |
+
const { intervalHours, startHour, endHour, skipStartHour } = this.scheduleConfig;
|
| 72 |
+
|
| 73 |
+
// 参数验证
|
| 74 |
+
if (intervalHours <= 0 || intervalHours > 24) {
|
| 75 |
+
throw new Error('intervalHours must be between 1 and 24');
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
if (startHour < 0 || startHour >= 24) {
|
| 79 |
+
throw new Error('startHour must be between 0 and 23');
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
if (endHour <= startHour || endHour > 24) {
|
| 83 |
+
throw new Error('endHour must be greater than startHour and not exceed 24');
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
const scheduleHours = [];
|
| 87 |
+
let currentHour = startHour + intervalHours; // 从起始时间后的第一个间隔开始
|
| 88 |
+
|
| 89 |
+
// 如果不跳过起始时间,则添加起始时间
|
| 90 |
+
if (!skipStartHour) {
|
| 91 |
+
scheduleHours.push(startHour);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// 生成后续时间点
|
| 95 |
+
while (currentHour < endHour) {
|
| 96 |
+
scheduleHours.push(currentHour);
|
| 97 |
+
currentHour += intervalHours;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
return scheduleHours;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// 启动定时任务
|
| 104 |
+
start() {
|
| 105 |
+
if (!this.enabled) {
|
| 106 |
+
console.log('[AISummary] 定时任务未启用');
|
| 107 |
+
return;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
if (!this.aiConfig.apiKey) {
|
| 111 |
+
console.error('[AISummary] AI API Key未配置,无法启动');
|
| 112 |
+
return;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
try {
|
| 116 |
+
const scheduleHours = this.generateScheduleHours();
|
| 117 |
+
const offset = this.aiConfig.timezoneOffset;
|
| 118 |
+
|
| 119 |
+
console.log('[AISummary] 定时任务配置:');
|
| 120 |
+
console.log(` - 用户时区: UTC${offset >= 0 ? '+' : ''}${offset}`);
|
| 121 |
+
console.log(` - 时间间隔: 每${this.scheduleConfig.intervalHours}小时`);
|
| 122 |
+
console.log(` - 起始时间: ${this.scheduleConfig.startHour}:00 (${this.scheduleConfig.skipStartHour ? '不触发' : '触发'})`);
|
| 123 |
+
console.log(` - 结束时间: ${this.scheduleConfig.endHour}:00`);
|
| 124 |
+
console.log(` - 执行时间点: ${scheduleHours.map(h => `${h}:00`).join('、')}`);
|
| 125 |
+
|
| 126 |
+
// 为每个时间点创建定时任务
|
| 127 |
+
scheduleHours.forEach(userHour => {
|
| 128 |
+
// 将用户时区时间转换为UTC时间
|
| 129 |
+
const utcHour = (userHour - offset + 24) % 24;
|
| 130 |
+
const cronTime = `0 ${utcHour} * * *`;
|
| 131 |
+
const triggerType = `cron-${userHour}`;
|
| 132 |
+
|
| 133 |
+
const job = cron.schedule(cronTime, async () => {
|
| 134 |
+
console.log(`[AISummary] 定时任务触发 (用户时区 ${userHour}:00 = UTC ${utcHour}:00)`);
|
| 135 |
+
await this.runDailySummaryForAllDevices(triggerType);
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
this.cronJobs.push(job);
|
| 139 |
+
console.log(` ✓ 已创建定时任务: 用户时区 ${userHour}:00 (UTC ${utcHour}:00) - cron: ${cronTime}`);
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
console.log(`[AISummary] 定时任务已启动,共 ${this.cronJobs.length} 个任务`);
|
| 143 |
+
|
| 144 |
+
} catch (error) {
|
| 145 |
+
console.error('[AISummary] 定时任务启动失败:', error.message);
|
| 146 |
+
throw error;
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
// 停止定时任务
|
| 151 |
+
stop() {
|
| 152 |
+
this.cronJobs.forEach(job => job.stop());
|
| 153 |
+
this.cronJobs = [];
|
| 154 |
+
console.log('[AISummary] 定时任务已停止');
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
// 为所有设备运行每日总结
|
| 158 |
+
async runDailySummaryForAllDevices(trigger = 'cron') {
|
| 159 |
+
try {
|
| 160 |
+
const devices = await this.query.getDevices();
|
| 161 |
+
console.log(`[AISummary] 开始为 ${devices.length} 个设备生成总结`);
|
| 162 |
+
|
| 163 |
+
for (const device of devices) {
|
| 164 |
+
try {
|
| 165 |
+
await this.generateDailySummary(device.device, null, null, trigger);
|
| 166 |
+
} catch (error) {
|
| 167 |
+
console.error(`[AISummary] 设备 ${device.device} 总结失败:`, error.message);
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
console.log('[AISummary] 所有设备总结完成');
|
| 172 |
+
} catch (error) {
|
| 173 |
+
console.error('[AISummary] 运行总结任务失败:', error);
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
// 生成每日总结 (对外接口)
|
| 178 |
+
async generateDailySummary(deviceId, date = null, timezoneOffset = null, trigger = 'manual') {
|
| 179 |
+
const tz = timezoneOffset !== null ? timezoneOffset : this.aiConfig.timezoneOffset;
|
| 180 |
+
|
| 181 |
+
// 如果没有指定日期,使用当天的数据(考虑时区)
|
| 182 |
+
let targetDate;
|
| 183 |
+
if (date) {
|
| 184 |
+
targetDate = new Date(date);
|
| 185 |
+
} else {
|
| 186 |
+
// 获取用户时区的当前时间
|
| 187 |
+
const now = new Date();
|
| 188 |
+
const userNow = new Date(now.getTime() + tz * 60 * 60 * 1000);
|
| 189 |
+
targetDate = new Date(userNow);
|
| 190 |
+
}
|
| 191 |
+
// 归零到当天0点
|
| 192 |
+
targetDate.setHours(0, 0, 0, 0);
|
| 193 |
+
|
| 194 |
+
console.log(`[AISummary] 开始为设备 ${deviceId} 生成 ${targetDate.toISOString().split('T')[0]} 的总结 (触发方式: ${trigger})`);
|
| 195 |
+
|
| 196 |
+
// 1. 获取统计数据
|
| 197 |
+
const statsData = await this.collectDailyData(deviceId, targetDate, tz);
|
| 198 |
+
|
| 199 |
+
if (statsData.totalUsage === 0) {
|
| 200 |
+
console.log(`[AISummary] 设备 ${deviceId} 在 ${targetDate.toISOString().split('T')[0]} 无使用数据`);
|
| 201 |
+
return {
|
| 202 |
+
success: false,
|
| 203 |
+
message: 'No usage data for this day'
|
| 204 |
+
};
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
// 2. 调用AI生成总结
|
| 208 |
+
const aiSummary = await this.callAI(statsData, deviceId);
|
| 209 |
+
|
| 210 |
+
// 3. 发布总结
|
| 211 |
+
const publishResult = await this.publishSummary(deviceId, targetDate, aiSummary, statsData);
|
| 212 |
+
|
| 213 |
+
// 4. 保存到内存缓存
|
| 214 |
+
const summaryRecord = {
|
| 215 |
+
summary: aiSummary,
|
| 216 |
+
date: targetDate.toISOString().split('T')[0],
|
| 217 |
+
timestamp: new Date().toISOString(),
|
| 218 |
+
trigger: trigger, // 'manual', 'cron-x'
|
| 219 |
+
publishResult
|
| 220 |
+
};
|
| 221 |
+
|
| 222 |
+
this.recentSummaries.set(deviceId, summaryRecord);
|
| 223 |
+
|
| 224 |
+
console.log(`[AISummary] 设备 ${deviceId} 总结完成并已保存`);
|
| 225 |
+
|
| 226 |
+
return {
|
| 227 |
+
success: true,
|
| 228 |
+
deviceId,
|
| 229 |
+
...summaryRecord
|
| 230 |
+
};
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
// 收集每日数据
|
| 234 |
+
async collectDailyData(deviceId, date, timezoneOffset) {
|
| 235 |
+
// 获取当天统计
|
| 236 |
+
const dailyStats = await this.query.getDailyStats(deviceId, date, timezoneOffset);
|
| 237 |
+
|
| 238 |
+
// 获取最近200条切换记录
|
| 239 |
+
let recentSwitches = [];
|
| 240 |
+
if (this.recorder.recentAppSwitches.has(deviceId)) {
|
| 241 |
+
const switches = this.recorder.recentAppSwitches.get(deviceId);
|
| 242 |
+
recentSwitches = switches
|
| 243 |
+
.filter(entry => {
|
| 244 |
+
const entryDate = new Date(entry.timestamp);
|
| 245 |
+
const entryDateOnly = new Date(entryDate);
|
| 246 |
+
entryDateOnly.setHours(0, 0, 0, 0);
|
| 247 |
+
return entryDateOnly.getTime() === date.getTime();
|
| 248 |
+
})
|
| 249 |
+
.slice(0, 200)
|
| 250 |
+
.map(entry => {
|
| 251 |
+
// 将UTC时间转换为用户本地时间
|
| 252 |
+
const userTime = new Date(entry.timestamp);
|
| 253 |
+
userTime.setMinutes(userTime.getMinutes() + timezoneOffset * 60);
|
| 254 |
+
|
| 255 |
+
return {
|
| 256 |
+
appName: entry.appName,
|
| 257 |
+
timestamp: userTime.toISOString(), // 保持ISO格式
|
| 258 |
+
localTime: userTime.toLocaleTimeString('zh-CN', { hour12: false }), // 添加本地时间字符串
|
| 259 |
+
running: entry.running !== false
|
| 260 |
+
};
|
| 261 |
+
});
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
return {
|
| 265 |
+
deviceId,
|
| 266 |
+
date: date.toISOString().split('T')[0],
|
| 267 |
+
totalUsage: dailyStats.totalUsage,
|
| 268 |
+
appStats: dailyStats.appStats,
|
| 269 |
+
hourlyStats: dailyStats.hourlyStats,
|
| 270 |
+
recentSwitches,
|
| 271 |
+
timezoneOffset
|
| 272 |
+
};
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
// 调用AI API
|
| 277 |
+
async callAI(statsData, deviceId) {
|
| 278 |
+
const prompt = this.buildPrompt(statsData, deviceId);
|
| 279 |
+
|
| 280 |
+
try {
|
| 281 |
+
const response = await fetch(this.aiConfig.apiUrl, {
|
| 282 |
+
method: 'POST',
|
| 283 |
+
headers: {
|
| 284 |
+
'Content-Type': 'application/json',
|
| 285 |
+
'Authorization': `Bearer ${this.aiConfig.apiKey}`
|
| 286 |
+
},
|
| 287 |
+
body: JSON.stringify({
|
| 288 |
+
model: this.aiConfig.model,
|
| 289 |
+
messages: [
|
| 290 |
+
{
|
| 291 |
+
role: 'system',
|
| 292 |
+
content: '你是一个时间分析师'
|
| 293 |
+
},
|
| 294 |
+
{
|
| 295 |
+
role: 'user',
|
| 296 |
+
content: prompt
|
| 297 |
+
}
|
| 298 |
+
],
|
| 299 |
+
max_tokens: this.aiConfig.maxTokens,
|
| 300 |
+
temperature: 0.7
|
| 301 |
+
})
|
| 302 |
+
});
|
| 303 |
+
|
| 304 |
+
if (!response.ok) {
|
| 305 |
+
const errorText = await response.text();
|
| 306 |
+
throw new Error(`AI API 请求失败: ${response.status} ${errorText}`);
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
const result = await response.json();
|
| 310 |
+
return result.choices[0].message.content;
|
| 311 |
+
|
| 312 |
+
} catch (error) {
|
| 313 |
+
console.error('[AISummary] AI调用失败:', error);
|
| 314 |
+
throw error;
|
| 315 |
+
}
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
// 构建AI提示词
|
| 319 |
+
buildPrompt(statsData, deviceId) {
|
| 320 |
+
const { date, totalUsage, appStats, hourlyStats, recentSwitches } = statsData;
|
| 321 |
+
|
| 322 |
+
// 计算应用使用占比
|
| 323 |
+
const appUsageList = Object.entries(appStats)
|
| 324 |
+
.map(([app, minutes]) => ({
|
| 325 |
+
app,
|
| 326 |
+
minutes,
|
| 327 |
+
percentage: ((minutes / totalUsage) * 100).toFixed(1)
|
| 328 |
+
}))
|
| 329 |
+
.sort((a, b) => b.minutes - a.minutes);
|
| 330 |
+
|
| 331 |
+
// 构建提示词
|
| 332 |
+
let prompt = `总结以下设备的应用使用情况,控制在300字以内\n\n`;
|
| 333 |
+
|
| 334 |
+
prompt += `- 设备ID: ${deviceId}\n`;
|
| 335 |
+
prompt += `- 统计日期: ${date}\n\n`;
|
| 336 |
+
|
| 337 |
+
prompt += `## 总体使用情况\n`;
|
| 338 |
+
prompt += `- 总使用时长: ${Math.floor(totalUsage / 60)}小时${totalUsage % 60}分钟\n`;
|
| 339 |
+
prompt += `- 使用应用数量: ${Object.keys(appStats).length}个\n\n`;
|
| 340 |
+
|
| 341 |
+
prompt += `## 应用使用占比(TOP 20)\n`;
|
| 342 |
+
appUsageList.slice(0, 20).forEach(({ app, minutes, percentage }) => {
|
| 343 |
+
prompt += `- ${app}: ${Math.floor(minutes / 60)}小时${minutes % 60}分钟 (${percentage}%)\n`;
|
| 344 |
+
});
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
prompt += `\n## 最近应用切换记录 (最新${Math.min(recentSwitches.length, 100)}条)\n`;
|
| 348 |
+
recentSwitches.slice(0, 10).forEach(({ appName, timestamp, running }) => {
|
| 349 |
+
const time = new Date(timestamp).toLocaleTimeString('zh-CN', { hour12: false });
|
| 350 |
+
const status = running ? '打开' : '关闭';
|
| 351 |
+
prompt += `- ${time} ${status} ${appName}\n`;
|
| 352 |
+
});
|
| 353 |
+
prompt += this.aiConfig.aiPrompt;
|
| 354 |
+
prompt += `注意:控制在300字以内,不要返回md格式,只能换行`;
|
| 355 |
+
|
| 356 |
+
return prompt;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
// 发布总结到指定API
|
| 360 |
+
async publishSummary(deviceId, date, summary, statsData) {
|
| 361 |
+
if (!this.publishConfig.apiUrl || this.publishConfig.publishEnabled){
|
| 362 |
+
console.log('[AISummary] 未配置发布功能,跳过发布');
|
| 363 |
+
return { published: false, reason: 'No publish API configured' };
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
try {
|
| 367 |
+
const payload = {
|
| 368 |
+
deviceId,
|
| 369 |
+
date: date.toISOString().split('T')[0],
|
| 370 |
+
timestamp: new Date().toISOString(),
|
| 371 |
+
summary
|
| 372 |
+
};
|
| 373 |
+
|
| 374 |
+
const headers = {
|
| 375 |
+
'Content-Type': 'application/json'
|
| 376 |
+
};
|
| 377 |
+
|
| 378 |
+
if (this.publishConfig.apiKey) {
|
| 379 |
+
headers['Authorization'] = `Bearer ${this.publishConfig.apiKey}`;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
const response = await fetch(this.publishConfig.apiUrl, {
|
| 383 |
+
method: 'POST',
|
| 384 |
+
headers,
|
| 385 |
+
body: JSON.stringify(payload)
|
| 386 |
+
});
|
| 387 |
+
|
| 388 |
+
if (!response.ok) {
|
| 389 |
+
const errorText = await response.text();
|
| 390 |
+
throw new Error(`发布API请求失败: ${response.status} ${errorText}`);
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
const result = await response.json();
|
| 394 |
+
console.log('[AISummary] 总结已成功发布');
|
| 395 |
+
|
| 396 |
+
return {
|
| 397 |
+
published: true,
|
| 398 |
+
response: result
|
| 399 |
+
};
|
| 400 |
+
|
| 401 |
+
} catch (error) {
|
| 402 |
+
console.error('[AISummary] 发布失败:', error);
|
| 403 |
+
return {
|
| 404 |
+
published: false,
|
| 405 |
+
error: error.message
|
| 406 |
+
};
|
| 407 |
+
}
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
// 手动触发总结 (用于测试或按需生成)
|
| 411 |
+
async triggerSummary(deviceId, options = {}) {
|
| 412 |
+
const {
|
| 413 |
+
date = null,
|
| 414 |
+
timezoneOffset = null
|
| 415 |
+
} = options;
|
| 416 |
+
|
| 417 |
+
return await this.generateDailySummary(deviceId, date, timezoneOffset, 'manual');
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
// 获取最近一次总结
|
| 421 |
+
getRecentSummary(deviceId) {
|
| 422 |
+
if (!this.recentSummaries.has(deviceId)) {
|
| 423 |
+
return null;
|
| 424 |
+
}
|
| 425 |
+
return this.recentSummaries.get(deviceId);
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
// 获取所有设备的最近总结
|
| 429 |
+
getAllRecentSummaries() {
|
| 430 |
+
const summaries = {};
|
| 431 |
+
this.recentSummaries.forEach((summary, deviceId) => {
|
| 432 |
+
summaries[deviceId] = summary;
|
| 433 |
+
});
|
| 434 |
+
return summaries;
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
// 预留:周总结功能
|
| 438 |
+
async generateWeeklySummary(deviceId, weekOffset = 0, timezoneOffset = null) {
|
| 439 |
+
// TODO: 实现周总结
|
| 440 |
+
console.log('[AISummary] 周总结功能待实现');
|
| 441 |
+
throw new Error('Weekly summary not implemented yet');
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
// 预留:月总结功能
|
| 445 |
+
async generateMonthlySummary(deviceId, monthOffset = 0, timezoneOffset = null) {
|
| 446 |
+
// TODO: 实现月总结
|
| 447 |
+
console.log('[AISummary] 月总结功能待实现');
|
| 448 |
+
throw new Error('Monthly summary not implemented yet');
|
| 449 |
+
}
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
module.exports = AISummary;
|
services/EyeTimeQuery.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// EyeTimeQuery.js - 仅统计用眼分钟数(日/周/月), 支持时区
|
| 2 |
+
const { mongoose } = require('../index');
|
| 3 |
+
const DateRangeHelper = require('../utils/DateRange');
|
| 4 |
+
const TimezoneUtils = require('../utils/timezone');
|
| 5 |
+
|
| 6 |
+
const DailyEyeTime = mongoose.model('DailyEyeTime');
|
| 7 |
+
|
| 8 |
+
class EyeTimeQuery {
|
| 9 |
+
constructor(timezoneOffset = 8) {
|
| 10 |
+
// 初始化时区工具与日期范围助手
|
| 11 |
+
this.timezoneOffset = timezoneOffset;
|
| 12 |
+
this.timezoneUtils = new TimezoneUtils(timezoneOffset);
|
| 13 |
+
this.dateHelper = new DateRangeHelper(timezoneOffset);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* 获取某日的用眼分钟数
|
| 18 |
+
* @param {Date} date - 查询目标日期(UTC 或本地时间均可)
|
| 19 |
+
* @param {number} timezoneOffset - 时区偏移(小时)
|
| 20 |
+
*/
|
| 21 |
+
async getDailyMinutes(date, timezoneOffset = this.timezoneOffset) {
|
| 22 |
+
const tzUtils = new TimezoneUtils(timezoneOffset);
|
| 23 |
+
|
| 24 |
+
// 解析输入日期为本地日期
|
| 25 |
+
const localDate = tzUtils.parseDate(date);
|
| 26 |
+
|
| 27 |
+
// 构建数据库查询的日期(本地零点对应的 UTC 时间)
|
| 28 |
+
const localDayStart = new Date(Date.UTC(
|
| 29 |
+
localDate.getUTCFullYear(),
|
| 30 |
+
localDate.getUTCMonth(),
|
| 31 |
+
localDate.getUTCDate(),
|
| 32 |
+
0, 0, 0, 0
|
| 33 |
+
));
|
| 34 |
+
const dbDate = tzUtils.localToUtc(localDayStart);
|
| 35 |
+
|
| 36 |
+
// 查询记录
|
| 37 |
+
const dayRecords = await DailyEyeTime.find({
|
| 38 |
+
date: dbDate
|
| 39 |
+
}).lean();
|
| 40 |
+
|
| 41 |
+
// 初始化小时分布
|
| 42 |
+
const hourlyStats = Array(24).fill(0);
|
| 43 |
+
|
| 44 |
+
for (const rec of dayRecords) {
|
| 45 |
+
if (rec.hourlyUsage && Array.isArray(rec.hourlyUsage)) {
|
| 46 |
+
for (let i = 0; i < 24; i++) {
|
| 47 |
+
hourlyStats[i] += rec.hourlyUsage[i] || 0;
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// 总分钟数直接用小时分布求和
|
| 53 |
+
const totalUsage = Math.round(hourlyStats.reduce((a, b) => a + b, 0) * 100) / 100;
|
| 54 |
+
|
| 55 |
+
return {
|
| 56 |
+
date: localDate.toISOString().split('T')[0],
|
| 57 |
+
totalUsage,
|
| 58 |
+
hourlyStats: hourlyStats.map(v => Math.round(v * 100) / 100),
|
| 59 |
+
timezoneOffset
|
| 60 |
+
};
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* 获取周统计结果
|
| 66 |
+
*/
|
| 67 |
+
async getWeeklyMinutes(weekOffset = 0, timezoneOffset = this.timezoneOffset) {
|
| 68 |
+
const { startDate, endDate } = this.dateHelper.getWeekRange(weekOffset);
|
| 69 |
+
const result = {};
|
| 70 |
+
|
| 71 |
+
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
| 72 |
+
const stats = await this.getDailyMinutes(new Date(d), timezoneOffset);
|
| 73 |
+
result[stats.date] = stats.totalUsage;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
return {
|
| 77 |
+
weekOffset,
|
| 78 |
+
weekRange: {
|
| 79 |
+
start: startDate.toISOString().split('T')[0],
|
| 80 |
+
end: endDate.toISOString().split('T')[0]
|
| 81 |
+
},
|
| 82 |
+
dailyTotals: result,
|
| 83 |
+
timezoneOffset
|
| 84 |
+
};
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/**
|
| 88 |
+
* 获取月统计结果
|
| 89 |
+
*/
|
| 90 |
+
async getMonthlyMinutes(monthOffset = 0, timezoneOffset = this.timezoneOffset) {
|
| 91 |
+
const { startDate, endDate } = this.dateHelper.getMonthRange(monthOffset, timezoneOffset);
|
| 92 |
+
const result = {};
|
| 93 |
+
|
| 94 |
+
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
| 95 |
+
const stats = await this.getDailyMinutes(new Date(d), timezoneOffset);
|
| 96 |
+
result[stats.date] = stats.totalUsage;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
return {
|
| 100 |
+
monthOffset,
|
| 101 |
+
monthRange: {
|
| 102 |
+
start: startDate.toISOString().split('T')[0],
|
| 103 |
+
end: endDate.toISOString().split('T')[0]
|
| 104 |
+
},
|
| 105 |
+
dailyTotals: result,
|
| 106 |
+
timezoneOffset
|
| 107 |
+
};
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
module.exports = EyeTimeQuery;
|
services/EyeTimeRecorder.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// EyeTimeRecorder.js
|
| 2 |
+
const { mongoose } = require('../index');
|
| 3 |
+
const TimezoneUtils = require('../utils/timezone');
|
| 4 |
+
|
| 5 |
+
const DailyEyeTime = mongoose.model('DailyEyeTime', {
|
| 6 |
+
date: Date, // 本地时区日期零点对应的 UTC 时间戳
|
| 7 |
+
hourlyUsage: [Number] // 24小时数组,每项代表分钟数
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
class EyeTimeRecorder {
|
| 11 |
+
constructor(timezoneConfig) {
|
| 12 |
+
// 初始化时区工具
|
| 13 |
+
this.timezoneUtils = new TimezoneUtils(timezoneConfig.timezoneOffset || 'Asia/Shanghai');
|
| 14 |
+
|
| 15 |
+
// 存储所有设备的状态
|
| 16 |
+
this.deviceStates = new Map(); // { deviceId: { isActive: boolean, lastUpdateTime: timestamp } }
|
| 17 |
+
|
| 18 |
+
// 全局状态追踪
|
| 19 |
+
this.globalActive = false;
|
| 20 |
+
this.lastRecordTime = null;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* 记录设备活动状态
|
| 25 |
+
* @param {string} deviceId - 设备ID
|
| 26 |
+
* @param {boolean} isActive - 是否活跃(用眼中)
|
| 27 |
+
*/
|
| 28 |
+
async recordActivity(deviceId, isActive = true) {
|
| 29 |
+
const now = Date.now();
|
| 30 |
+
const wasGlobalActive = this.globalActive;
|
| 31 |
+
|
| 32 |
+
// 更新设备状态
|
| 33 |
+
this.deviceStates.set(deviceId, {
|
| 34 |
+
isActive: isActive,
|
| 35 |
+
lastUpdateTime: now
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
// 重新计算全局状态(只要有一个设备活跃,全局就活跃)
|
| 39 |
+
this.globalActive = false;
|
| 40 |
+
for (const [, state] of this.deviceStates) {
|
| 41 |
+
if (state.isActive) {
|
| 42 |
+
this.globalActive = true;
|
| 43 |
+
break;
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// 如果全局状态从活跃变为非活跃,需要记录最后一段时间
|
| 48 |
+
if (wasGlobalActive && !this.globalActive && this.lastRecordTime) {
|
| 49 |
+
await this._saveUsageTime(this.lastRecordTime, now);
|
| 50 |
+
this.lastRecordTime = null;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// 如果全局状态从非活跃变为活跃,开始新的记录
|
| 54 |
+
if (!wasGlobalActive && this.globalActive) {
|
| 55 |
+
this.lastRecordTime = now;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// 如果全局状态持续活跃,记录这段时间并更新起始时间
|
| 59 |
+
if (wasGlobalActive && this.globalActive && this.lastRecordTime) {
|
| 60 |
+
await this._saveUsageTime(this.lastRecordTime, now);
|
| 61 |
+
this.lastRecordTime = now;
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/**
|
| 66 |
+
* 保存使用时间到数据库
|
| 67 |
+
* @private
|
| 68 |
+
*/
|
| 69 |
+
async _saveUsageTime(startTime, endTime) {
|
| 70 |
+
const durationMs = endTime - startTime;
|
| 71 |
+
if (durationMs <= 0) return;
|
| 72 |
+
|
| 73 |
+
// 将时间段按本地时区的小时和日期拆分
|
| 74 |
+
const segments = this._splitTimeSegments(startTime, endTime);
|
| 75 |
+
|
| 76 |
+
// 为每个时间段更新数据库
|
| 77 |
+
for (const segment of segments) {
|
| 78 |
+
await this._updateDailyRecord(segment.date, segment.hour, segment.minutes);
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* 将时间段按本地时区的日期和小时拆分
|
| 84 |
+
* @private
|
| 85 |
+
*/
|
| 86 |
+
_splitTimeSegments(startTime, endTime) {
|
| 87 |
+
const segments = [];
|
| 88 |
+
let currentTime = startTime;
|
| 89 |
+
|
| 90 |
+
while (currentTime < endTime) {
|
| 91 |
+
// 转换为本地时间
|
| 92 |
+
const localDate = this.timezoneUtils.utcToLocal(new Date(currentTime));
|
| 93 |
+
const currentHour = localDate.getHours();
|
| 94 |
+
|
| 95 |
+
// 计算当前小时结束时间(本地时间)
|
| 96 |
+
const hourEndLocal = new Date(localDate);
|
| 97 |
+
hourEndLocal.setHours(currentHour, 59, 59, 999);
|
| 98 |
+
|
| 99 |
+
// 转换回UTC
|
| 100 |
+
const hourEndUtc = this.timezoneUtils.localToUtc(hourEndLocal).getTime();
|
| 101 |
+
|
| 102 |
+
// 确定这个时间段的结束时间
|
| 103 |
+
const segmentEnd = Math.min(endTime, hourEndUtc + 1); // +1ms 进入下一小时
|
| 104 |
+
|
| 105 |
+
// 计算分钟数
|
| 106 |
+
const segmentDurationMs = segmentEnd - currentTime;
|
| 107 |
+
const minutes = segmentDurationMs / (60 * 1000);
|
| 108 |
+
|
| 109 |
+
// 获取本地时区零点的正确 UTC 时间
|
| 110 |
+
// 例如:北京时间 2025-11-24 -> UTC 2025-11-23T16:00Z
|
| 111 |
+
const localDayStartUtc = this.timezoneUtils.getLocalDayStart(new Date(currentTime));
|
| 112 |
+
|
| 113 |
+
segments.push({
|
| 114 |
+
date: localDayStartUtc, // 存储为本地时区零点对应的 UTC 时间
|
| 115 |
+
hour: currentHour,
|
| 116 |
+
minutes: minutes
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
currentTime = segmentEnd;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
return segments;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/**
|
| 126 |
+
* 更新数据库中的每日记录
|
| 127 |
+
* @private
|
| 128 |
+
*/
|
| 129 |
+
async _updateDailyRecord(date, hour, minutes) {
|
| 130 |
+
try {
|
| 131 |
+
// 查找或创建当天的记录
|
| 132 |
+
let record = await DailyEyeTime.findOne({ date: date });
|
| 133 |
+
|
| 134 |
+
if (!record) {
|
| 135 |
+
// 创建新记录,初始化24小时数组为0
|
| 136 |
+
record = new DailyEyeTime({
|
| 137 |
+
date: date, // 存储为本地时区零点的 UTC 时间
|
| 138 |
+
hourlyUsage: Array(24).fill(0)
|
| 139 |
+
});
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
// 累加该小时的使用时间
|
| 143 |
+
record.hourlyUsage[hour] += minutes;
|
| 144 |
+
|
| 145 |
+
// 保存到数据库
|
| 146 |
+
await record.save();
|
| 147 |
+
|
| 148 |
+
} catch (error) {
|
| 149 |
+
console.error('保存用眼时间记录失败:', error);
|
| 150 |
+
throw error;
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
/**
|
| 155 |
+
* 获取设备当前状态(可选方法,用于调试)
|
| 156 |
+
*/
|
| 157 |
+
getDeviceStatus(deviceId) {
|
| 158 |
+
return this.deviceStates.get(deviceId) || null;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
/**
|
| 162 |
+
* 获取全局活跃状态(可选方法,用于调试)
|
| 163 |
+
*/
|
| 164 |
+
isGlobalActive() {
|
| 165 |
+
return this.globalActive;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
/**
|
| 169 |
+
* 获取所有活跃设备列表(可选方法,用于调试)
|
| 170 |
+
*/
|
| 171 |
+
getActiveDevices() {
|
| 172 |
+
const activeDevices = [];
|
| 173 |
+
for (const [deviceId, state] of this.deviceStates) {
|
| 174 |
+
if (state.isActive) {
|
| 175 |
+
activeDevices.push(deviceId);
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
return activeDevices;
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
module.exports = EyeTimeRecorder;
|
services/StatsQuery.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// StatsQuery.js - 简化版(适配新的存储逻辑)
|
| 2 |
+
const { mongoose } = require('../index');
|
| 3 |
+
const DateRangeHelper = require('../utils/DateRange');
|
| 4 |
+
const TimezoneUtils = require('../utils/timezone');
|
| 5 |
+
|
| 6 |
+
const DailyStat = mongoose.model('DailyStat');
|
| 7 |
+
|
| 8 |
+
class StatsQuery {
|
| 9 |
+
constructor(recorder, config = {}) {
|
| 10 |
+
this.recorder = recorder;
|
| 11 |
+
this.timezoneOffset = config.timezoneOffset || 8;
|
| 12 |
+
this.dateRangeHelper = new DateRangeHelper(this.timezoneOffset);
|
| 13 |
+
this.tzUtils = new TimezoneUtils(this.timezoneOffset);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
// 查询统计数据(直接查询本地日期)
|
| 17 |
+
async _queryStats(query, startDate, endDate) {
|
| 18 |
+
// 构建查询日期数组(本地时区日期)
|
| 19 |
+
const queryDates = [];
|
| 20 |
+
const current = new Date(startDate);
|
| 21 |
+
|
| 22 |
+
while (current <= endDate) {
|
| 23 |
+
// 转换为数据库存储格式(本地零点对应的 UTC 时间)
|
| 24 |
+
const localDayStart = new Date(Date.UTC(
|
| 25 |
+
current.getUTCFullYear(),
|
| 26 |
+
current.getUTCMonth(),
|
| 27 |
+
current.getUTCDate(),
|
| 28 |
+
0, 0, 0, 0
|
| 29 |
+
));
|
| 30 |
+
const dbDate = this.tzUtils.localToUtc(localDayStart);
|
| 31 |
+
queryDates.push(dbDate);
|
| 32 |
+
|
| 33 |
+
// 移动到下一天
|
| 34 |
+
current.setUTCDate(current.getUTCDate() + 1);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
query.date = { $in: queryDates };
|
| 38 |
+
return DailyStat.find(query);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// 处理统计数据
|
| 42 |
+
_processStats(allStats, startDate, endDate, isSingleDay = false) {
|
| 43 |
+
const dailyStats = {};
|
| 44 |
+
const appDailyStats = {};
|
| 45 |
+
const hourlyStats = Array(24).fill(0);
|
| 46 |
+
const appHourlyStats = {};
|
| 47 |
+
let totalUsage = 0;
|
| 48 |
+
|
| 49 |
+
allStats.forEach(stat => {
|
| 50 |
+
const appName = stat.appName;
|
| 51 |
+
|
| 52 |
+
// 将数据库的 date 转换回本地日期
|
| 53 |
+
const dbDate = new Date(stat.date);
|
| 54 |
+
const localDayStart = this.tzUtils.utcToLocal(dbDate);
|
| 55 |
+
const localDateOnly = new Date(Date.UTC(
|
| 56 |
+
localDayStart.getUTCFullYear(),
|
| 57 |
+
localDayStart.getUTCMonth(),
|
| 58 |
+
localDayStart.getUTCDate()
|
| 59 |
+
));
|
| 60 |
+
|
| 61 |
+
const dateKey = localDateOnly.toISOString().split('T')[0];
|
| 62 |
+
|
| 63 |
+
// 初始化
|
| 64 |
+
if (!dailyStats[dateKey]) dailyStats[dateKey] = 0;
|
| 65 |
+
if (!appDailyStats[appName]) appDailyStats[appName] = {};
|
| 66 |
+
if (!appDailyStats[appName][dateKey]) appDailyStats[appName][dateKey] = 0;
|
| 67 |
+
if (!appHourlyStats[appName]) {
|
| 68 |
+
appHourlyStats[appName] = Array(24).fill(0);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// 累加每小时数据
|
| 72 |
+
stat.hourlyUsage.forEach((minutes, hour) => {
|
| 73 |
+
if (minutes > 0) {
|
| 74 |
+
dailyStats[dateKey] += minutes;
|
| 75 |
+
appDailyStats[appName][dateKey] += minutes;
|
| 76 |
+
hourlyStats[hour] += minutes;
|
| 77 |
+
appHourlyStats[appName][hour] += minutes;
|
| 78 |
+
totalUsage += minutes;
|
| 79 |
+
}
|
| 80 |
+
});
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
return { dailyStats, appDailyStats, hourlyStats, appHourlyStats, totalUsage };
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// ============ 对外接口 ============
|
| 87 |
+
|
| 88 |
+
// 获取某天的统计数据
|
| 89 |
+
async getDailyStats(deviceId, date) {
|
| 90 |
+
const localDate = this.tzUtils.parseDate(date);
|
| 91 |
+
|
| 92 |
+
const allStats = await this._queryStats(
|
| 93 |
+
{ deviceId },
|
| 94 |
+
localDate,
|
| 95 |
+
localDate
|
| 96 |
+
);
|
| 97 |
+
|
| 98 |
+
const { hourlyStats, appHourlyStats, totalUsage } = this._processStats(
|
| 99 |
+
allStats,
|
| 100 |
+
localDate,
|
| 101 |
+
localDate,
|
| 102 |
+
true
|
| 103 |
+
);
|
| 104 |
+
|
| 105 |
+
// 只保留有数据的应用
|
| 106 |
+
const appStats = {};
|
| 107 |
+
Object.keys(appHourlyStats).forEach(appName => {
|
| 108 |
+
const total = appHourlyStats[appName].reduce((sum, val) => sum + val, 0);
|
| 109 |
+
if (total > 0) appStats[appName] = total;
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
return {
|
| 113 |
+
totalUsage,
|
| 114 |
+
appStats,
|
| 115 |
+
hourlyStats,
|
| 116 |
+
appHourlyStats: Object.keys(appStats).length > 0 ? appHourlyStats : {}
|
| 117 |
+
};
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
// 获取周统计
|
| 121 |
+
async getWeeklyAppStats(deviceId, appName = null, weekOffset = 0) {
|
| 122 |
+
const { startDate, endDate } = this.dateRangeHelper.getWeekRange(weekOffset);
|
| 123 |
+
|
| 124 |
+
const query = { deviceId };
|
| 125 |
+
if (appName) query.appName = appName;
|
| 126 |
+
|
| 127 |
+
const allStats = await this._queryStats(query, startDate, endDate);
|
| 128 |
+
const { dailyStats, appDailyStats } = this._processStats(allStats, startDate, endDate);
|
| 129 |
+
|
| 130 |
+
return {
|
| 131 |
+
weekOffset,
|
| 132 |
+
weekRange: {
|
| 133 |
+
start: startDate.toISOString().split('T')[0],
|
| 134 |
+
end: endDate.toISOString().split('T')[0]
|
| 135 |
+
},
|
| 136 |
+
dailyTotals: dailyStats,
|
| 137 |
+
appDailyStats: appName ? { [appName]: appDailyStats[appName] || {} } : appDailyStats
|
| 138 |
+
};
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
// 获取月统计
|
| 142 |
+
async getMonthlyAppStats(deviceId, appName = null, monthOffset = 0) {
|
| 143 |
+
const { startDate, endDate } = this.dateRangeHelper.getMonthRange(monthOffset);
|
| 144 |
+
|
| 145 |
+
const query = { deviceId };
|
| 146 |
+
if (appName) query.appName = appName;
|
| 147 |
+
|
| 148 |
+
const allStats = await this._queryStats(query, startDate, endDate);
|
| 149 |
+
const { dailyStats, appDailyStats } = this._processStats(allStats, startDate, endDate);
|
| 150 |
+
|
| 151 |
+
return {
|
| 152 |
+
monthOffset,
|
| 153 |
+
monthRange: {
|
| 154 |
+
start: startDate.toISOString().split('T')[0],
|
| 155 |
+
end: endDate.toISOString().split('T')[0]
|
| 156 |
+
},
|
| 157 |
+
dailyTotals: dailyStats,
|
| 158 |
+
appDailyStats: appName ? { [appName]: appDailyStats[appName] || {} } : appDailyStats
|
| 159 |
+
};
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// 获取某天所有设备统计
|
| 163 |
+
async getDailyStatsForAllDevices(date) {
|
| 164 |
+
const localDate = this.tzUtils.parseDate(date);
|
| 165 |
+
|
| 166 |
+
const allStats = await this._queryStats({}, localDate, localDate);
|
| 167 |
+
const { hourlyStats, appHourlyStats, totalUsage } = this._processStats(
|
| 168 |
+
allStats,
|
| 169 |
+
localDate,
|
| 170 |
+
localDate,
|
| 171 |
+
true
|
| 172 |
+
);
|
| 173 |
+
|
| 174 |
+
const appStats = {};
|
| 175 |
+
Object.keys(appHourlyStats).forEach(appName => {
|
| 176 |
+
const total = appHourlyStats[appName].reduce((sum, val) => sum + val, 0);
|
| 177 |
+
if (total > 0) appStats[appName] = total;
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
return { totalUsage, appStats, hourlyStats, appHourlyStats };
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
// 获取周统计所有设备
|
| 184 |
+
async getWeeklyAppStatsForAllDevices(appName = null, weekOffset = 0) {
|
| 185 |
+
const { startDate, endDate } = this.dateRangeHelper.getWeekRange(weekOffset);
|
| 186 |
+
|
| 187 |
+
const query = {};
|
| 188 |
+
if (appName) query.appName = appName;
|
| 189 |
+
|
| 190 |
+
const allStats = await this._queryStats(query, startDate, endDate);
|
| 191 |
+
const { dailyStats, appDailyStats } = this._processStats(allStats, startDate, endDate);
|
| 192 |
+
|
| 193 |
+
return {
|
| 194 |
+
weekOffset,
|
| 195 |
+
weekRange: {
|
| 196 |
+
start: startDate.toISOString().split('T')[0],
|
| 197 |
+
end: endDate.toISOString().split('T')[0]
|
| 198 |
+
},
|
| 199 |
+
dailyTotals: dailyStats,
|
| 200 |
+
appDailyStats: appName ? { [appName]: appDailyStats[appName] || {} } : appDailyStats
|
| 201 |
+
};
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
// 获取月统计所有设备
|
| 205 |
+
async getMonthlyAppStatsForAllDevices(appName = null, monthOffset = 0) {
|
| 206 |
+
const { startDate, endDate } = this.dateRangeHelper.getMonthRange(monthOffset);
|
| 207 |
+
|
| 208 |
+
const query = {};
|
| 209 |
+
if (appName) query.appName = appName;
|
| 210 |
+
|
| 211 |
+
const allStats = await this._queryStats(query, startDate, endDate);
|
| 212 |
+
const { dailyStats, appDailyStats } = this._processStats(allStats, startDate, endDate);
|
| 213 |
+
|
| 214 |
+
return {
|
| 215 |
+
monthOffset,
|
| 216 |
+
monthRange: {
|
| 217 |
+
start: startDate.toISOString().split('T')[0],
|
| 218 |
+
end: endDate.toISOString().split('T')[0]
|
| 219 |
+
},
|
| 220 |
+
dailyTotals: dailyStats,
|
| 221 |
+
appDailyStats: appName ? { [appName]: appDailyStats[appName] || {} } : appDailyStats
|
| 222 |
+
};
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// 获取设备列表
|
| 226 |
+
async getDevices() {
|
| 227 |
+
return Array.from(this.recorder.recentAppSwitches.keys()).map(deviceId => {
|
| 228 |
+
let currentApp = "Unknown";
|
| 229 |
+
let runningSince = new Date();
|
| 230 |
+
let isRunning = true;
|
| 231 |
+
const batteryInfo = this.recorder.getLatestBatteryInfo(deviceId);
|
| 232 |
+
|
| 233 |
+
if (this.recorder.recentAppSwitches.has(deviceId) && this.recorder.recentAppSwitches.get(deviceId).length > 0) {
|
| 234 |
+
const lastSwitch = this.recorder.recentAppSwitches.get(deviceId)[0];
|
| 235 |
+
currentApp = lastSwitch.appName;
|
| 236 |
+
runningSince = lastSwitch.timestamp;
|
| 237 |
+
isRunning = lastSwitch.running !== false;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
return {
|
| 241 |
+
device: deviceId,
|
| 242 |
+
currentApp,
|
| 243 |
+
running: isRunning,
|
| 244 |
+
runningSince,
|
| 245 |
+
batteryLevel: batteryInfo.level,
|
| 246 |
+
isCharging: batteryInfo.isCharging,
|
| 247 |
+
batteryTimestamp: batteryInfo.timestamp
|
| 248 |
+
};
|
| 249 |
+
});
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
module.exports = StatsQuery;
|
services/StatsRecorder.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// StatsRecorder.js - 记录信息模块
|
| 2 |
+
const { mongoose } = require('../index');
|
| 3 |
+
const EyeTimeRecorder = require('./EyeTimeRecorder');
|
| 4 |
+
const TimezoneUtils = require('../utils/timezone'); // 引入时区工具
|
| 5 |
+
|
| 6 |
+
let eyeTimeRecorder;
|
| 7 |
+
let timezoneUtils;
|
| 8 |
+
|
| 9 |
+
// 定义新的数据模型 - 按天/小时/应用存储
|
| 10 |
+
const DailyStat = mongoose.model('DailyStat', {
|
| 11 |
+
deviceId: String,
|
| 12 |
+
date: Date, // 本地时区日期零点 (存储为该时区零点的 UTC 时间戳)
|
| 13 |
+
appName: String,
|
| 14 |
+
hourlyUsage: [Number] // 24小时数组,每项代表分钟数
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
class StatsRecorder {
|
| 18 |
+
constructor(timezoneConfig = 8) {
|
| 19 |
+
// 初始化时区工具(接收时区偏移或时区名称)
|
| 20 |
+
timezoneUtils = new TimezoneUtils(timezoneConfig);
|
| 21 |
+
eyeTimeRecorder = new EyeTimeRecorder({timezoneOffset: timezoneConfig});
|
| 22 |
+
// 设备应用切换记录
|
| 23 |
+
this.recentAppSwitches = new Map(); // {deviceId: [{appName, timestamp}]}
|
| 24 |
+
// 电池信息存储
|
| 25 |
+
this.batteryInfo = new Map(); // {deviceId: {level, isCharging, timestamp}}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// 记录电池信息和充电状态
|
| 29 |
+
recordBattery(deviceId, level, isCharging = false) {
|
| 30 |
+
const now = new Date();
|
| 31 |
+
|
| 32 |
+
this.batteryInfo.set(deviceId, {
|
| 33 |
+
level: level,
|
| 34 |
+
isCharging: isCharging,
|
| 35 |
+
timestamp: now
|
| 36 |
+
});
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// 获取最新电池信息
|
| 40 |
+
getLatestBatteryInfo(deviceId) {
|
| 41 |
+
const info = this.batteryInfo.get(deviceId);
|
| 42 |
+
if (!info) {
|
| 43 |
+
return {
|
| 44 |
+
level: 0,
|
| 45 |
+
isCharging: false,
|
| 46 |
+
timestamp: null
|
| 47 |
+
};
|
| 48 |
+
}
|
| 49 |
+
return info;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// 记录应用使用时间
|
| 53 |
+
async recordUsage(deviceId, appName, running) {
|
| 54 |
+
const now = new Date();
|
| 55 |
+
|
| 56 |
+
await eyeTimeRecorder.recordActivity(deviceId, running) // 记录公共使用时间(用眼时长)
|
| 57 |
+
|
| 58 |
+
if (!this.recentAppSwitches.has(deviceId)) {
|
| 59 |
+
this.recentAppSwitches.set(deviceId, []);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const deviceSwitches = this.recentAppSwitches.get(deviceId);
|
| 63 |
+
|
| 64 |
+
// 处理停止运行的情况
|
| 65 |
+
if (running === false) {
|
| 66 |
+
if (deviceSwitches.length > 0) {
|
| 67 |
+
const lastSwitch = deviceSwitches[0];
|
| 68 |
+
if (lastSwitch.running !== false) {
|
| 69 |
+
const minutesSinceLastSwitch = this.calculatePreciseMinutes(lastSwitch.timestamp, now);
|
| 70 |
+
// 更新应用分时段时间统计,传递完整的开始时间戳
|
| 71 |
+
await this.updateDailyStat(deviceId, lastSwitch.appName, lastSwitch.timestamp, minutesSinceLastSwitch);
|
| 72 |
+
}
|
| 73 |
+
deviceSwitches[0].running = false;
|
| 74 |
+
deviceSwitches.unshift({
|
| 75 |
+
appName: "设备待机",
|
| 76 |
+
timestamp: now,
|
| 77 |
+
running: false
|
| 78 |
+
});
|
| 79 |
+
}
|
| 80 |
+
return;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// 使用时间计算
|
| 84 |
+
let minutesSinceLastSwitch = 0;
|
| 85 |
+
if (deviceSwitches.length > 0) {
|
| 86 |
+
const lastSwitch = deviceSwitches[0];
|
| 87 |
+
if (lastSwitch.running !== false) {
|
| 88 |
+
minutesSinceLastSwitch = this.calculatePreciseMinutes(lastSwitch.timestamp, now);
|
| 89 |
+
// 关键修改:传递完整的开始时间戳
|
| 90 |
+
await this.updateDailyStat(deviceId, lastSwitch.appName, lastSwitch.timestamp, minutesSinceLastSwitch);
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// 添加新记录
|
| 95 |
+
deviceSwitches.unshift({
|
| 96 |
+
appName: appName,
|
| 97 |
+
timestamp: now,
|
| 98 |
+
running: true
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
+
if (deviceSwitches.length > 20) {
|
| 102 |
+
deviceSwitches.pop();
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
// 精确计算时间差,返回小数分钟(精确到2位)
|
| 107 |
+
calculatePreciseMinutes(startTime, endTime) {
|
| 108 |
+
const milliseconds = endTime - startTime;
|
| 109 |
+
const minutes = milliseconds / (60 * 1000);
|
| 110 |
+
// 保留2位小数
|
| 111 |
+
return Math.round(minutes * 100) / 100;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// 获取本地时区的日期零点(返回该零点对应的 UTC Date 对象)
|
| 115 |
+
getLocalDayStart(timestamp) {
|
| 116 |
+
// 将 UTC 时间转换为本地时间
|
| 117 |
+
const localTime = timezoneUtils.utcToLocal(new Date(timestamp));
|
| 118 |
+
|
| 119 |
+
// 获取本地时间的日期部分
|
| 120 |
+
const year = localTime.getUTCFullYear();
|
| 121 |
+
const month = localTime.getUTCMonth();
|
| 122 |
+
const day = localTime.getUTCDate();
|
| 123 |
+
|
| 124 |
+
// 创建本地零点时间
|
| 125 |
+
const localDayStart = new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
|
| 126 |
+
|
| 127 |
+
// 转换回 UTC(这样存储的就是本地零点对应的 UTC 时间)
|
| 128 |
+
return timezoneUtils.localToUtc(localDayStart);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
// 更新每日统计
|
| 132 |
+
async updateDailyStat(deviceId, appName, startTimestamp, durationMinutes) {
|
| 133 |
+
// 使用本地时区的日期零点
|
| 134 |
+
const dayStart = this.getLocalDayStart(startTimestamp);
|
| 135 |
+
|
| 136 |
+
let stat = await DailyStat.findOne({
|
| 137 |
+
deviceId,
|
| 138 |
+
date: dayStart,
|
| 139 |
+
appName
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
if (!stat) {
|
| 143 |
+
stat = new DailyStat({
|
| 144 |
+
deviceId,
|
| 145 |
+
date: dayStart,
|
| 146 |
+
appName,
|
| 147 |
+
hourlyUsage: Array(24).fill(0)
|
| 148 |
+
});
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
await this.distributePreciseMinutes(stat, startTimestamp, durationMinutes);
|
| 152 |
+
await stat.save();
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
async distributePreciseMinutes(stat, startTimestamp, totalMinutes) {
|
| 156 |
+
let remainingMinutes = totalMinutes;
|
| 157 |
+
let currentTimestamp = new Date(startTimestamp);
|
| 158 |
+
|
| 159 |
+
while (remainingMinutes > 0) {
|
| 160 |
+
// 获取当前时间戳对应的本地日期零点
|
| 161 |
+
const currentDayStart = this.getLocalDayStart(currentTimestamp);
|
| 162 |
+
|
| 163 |
+
// 获取本地时区的小时、分钟、秒
|
| 164 |
+
const localTime = timezoneUtils.utcToLocal(currentTimestamp);
|
| 165 |
+
const currentHour = localTime.getUTCHours();
|
| 166 |
+
const currentMinute = localTime.getUTCMinutes();
|
| 167 |
+
const currentSecond = localTime.getUTCSeconds();
|
| 168 |
+
|
| 169 |
+
// 如果跨日期了,需要获取新的统计记录
|
| 170 |
+
let currentStat = stat;
|
| 171 |
+
if (currentDayStart.getTime() !== stat.date.getTime()) {
|
| 172 |
+
currentStat = await DailyStat.findOne({
|
| 173 |
+
deviceId: stat.deviceId,
|
| 174 |
+
date: currentDayStart,
|
| 175 |
+
appName: stat.appName
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
if (!currentStat) {
|
| 179 |
+
currentStat = new DailyStat({
|
| 180 |
+
deviceId: stat.deviceId,
|
| 181 |
+
date: currentDayStart,
|
| 182 |
+
appName: stat.appName,
|
| 183 |
+
hourlyUsage: Array(24).fill(0)
|
| 184 |
+
});
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
// 计算当前小时内已使用的分钟数
|
| 189 |
+
const usedInCurrentHour = currentStat.hourlyUsage[currentHour];
|
| 190 |
+
|
| 191 |
+
// 计算当前时间点到下一个小时开始还有多少分钟
|
| 192 |
+
const minutesToNextHour = 60 - currentMinute - (currentSecond > 0 ? (currentSecond / 60) : 0);
|
| 193 |
+
|
| 194 |
+
// 当前小时的剩余容量
|
| 195 |
+
const availableSpace = Math.max(0, 60 - usedInCurrentHour);
|
| 196 |
+
|
| 197 |
+
// 实际能在当前小时分配的时间
|
| 198 |
+
const minutesToAdd = Math.min(remainingMinutes, minutesToNextHour, availableSpace);
|
| 199 |
+
|
| 200 |
+
if (minutesToAdd > 0) {
|
| 201 |
+
const preciseMinutesToAdd = Math.round(minutesToAdd * 100) / 100;
|
| 202 |
+
currentStat.hourlyUsage[currentHour] = Math.round((currentStat.hourlyUsage[currentHour] + preciseMinutesToAdd) * 100) / 100;
|
| 203 |
+
|
| 204 |
+
// 如果是跨日期的新统计记录,需要保存
|
| 205 |
+
if (currentStat !== stat) {
|
| 206 |
+
await currentStat.save();
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
remainingMinutes = Math.round((remainingMinutes - preciseMinutesToAdd) * 100) / 100;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// 移动到下一个时间点
|
| 213 |
+
if (minutesToAdd >= minutesToNextHour) {
|
| 214 |
+
// 移动到下一个小时的开始
|
| 215 |
+
currentTimestamp = new Date(currentTimestamp.getTime() + minutesToNextHour * 60 * 1000);
|
| 216 |
+
} else {
|
| 217 |
+
// 在当前小时内完成了分配
|
| 218 |
+
break;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
// 避免浮点数精度问题
|
| 222 |
+
if (remainingMinutes < 0.01) {
|
| 223 |
+
remainingMinutes = 0;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
// 防止无限循环(最多处理30天)
|
| 227 |
+
const daysDifference = Math.floor((currentTimestamp - startTimestamp) / (24 * 60 * 60 * 1000));
|
| 228 |
+
if (daysDifference > 30) {
|
| 229 |
+
console.warn(`超过30天限制,剩余 ${remainingMinutes} 分钟无法分配,设备: ${stat.deviceId}, 应用: ${stat.appName}`);
|
| 230 |
+
break;
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
module.exports = StatsRecorder;
|