feat: sockt 弹窗功能添加

This commit is contained in:
zdl
2025-10-21 17:50:21 +08:00
parent c93f689954
commit 09c9273190
17 changed files with 3739 additions and 161 deletions

View File

@@ -329,7 +329,7 @@ class MockSocketService {
// 在连接后3秒发送欢迎消息
setTimeout(() => {
this.emit('trade_notification', {
this.emit('new_event', {
type: 'system_notification',
severity: 'info',
title: '连接成功',
@@ -445,7 +445,7 @@ class MockSocketService {
// 延迟发送(模拟层叠效果)
setTimeout(() => {
this.emit('trade_notification', alert);
this.emit('new_event', alert);
logger.info('mockSocketService', 'Mock notification sent', alert);
}, i * 500); // 每条消息间隔500ms
}
@@ -478,7 +478,7 @@ class MockSocketService {
id: `test_${Date.now()}`,
};
this.emit('trade_notification', notification);
this.emit('new_event', notification);
logger.info('mockSocketService', 'Test notification sent', notification);
}

View File

@@ -0,0 +1,402 @@
// src/services/notificationHistoryService.js
/**
* 通知历史记录服务
* 持久化存储通知历史,支持查询、筛选、搜索、导出
*/
import { logger } from '../utils/logger';
const STORAGE_KEY = 'notification_history';
const MAX_HISTORY_SIZE = 500; // 最多保留 500 条历史记录
class NotificationHistoryService {
constructor() {
this.history = this.loadHistory();
}
/**
* 从 localStorage 加载历史记录
*/
loadHistory() {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (data) {
const parsed = JSON.parse(data);
// 确保是数组
return Array.isArray(parsed) ? parsed : [];
}
} catch (error) {
logger.error('notificationHistoryService', 'loadHistory', error);
}
return [];
}
/**
* 保存历史记录到 localStorage
*/
saveHistory() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.history));
logger.debug('notificationHistoryService', 'History saved', {
count: this.history.length
});
} catch (error) {
logger.error('notificationHistoryService', 'saveHistory', error);
// localStorage 可能已满,尝试清理旧数据
if (error.name === 'QuotaExceededError') {
this.cleanup(100); // 清理 100 条
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.history));
} catch (retryError) {
logger.error('notificationHistoryService', 'saveHistory retry failed', retryError);
}
}
}
}
/**
* 保存通知到历史记录
* @param {object} notification - 通知对象
*/
saveNotification(notification) {
const record = {
id: notification.id || `notif_${Date.now()}`,
notification: { ...notification }, // 深拷贝
receivedAt: Date.now(),
readAt: null,
clickedAt: null,
};
// 检查是否已存在(去重)
const existingIndex = this.history.findIndex(r => r.id === record.id);
if (existingIndex !== -1) {
logger.debug('notificationHistoryService', 'Notification already exists', { id: record.id });
return;
}
// 添加到历史记录开头
this.history.unshift(record);
// 限制最大数量
if (this.history.length > MAX_HISTORY_SIZE) {
this.history = this.history.slice(0, MAX_HISTORY_SIZE);
}
this.saveHistory();
logger.info('notificationHistoryService', 'Notification saved', { id: record.id });
}
/**
* 标记通知为已读
* @param {string} id - 通知 ID
*/
markAsRead(id) {
const record = this.history.find(r => r.id === id);
if (record && !record.readAt) {
record.readAt = Date.now();
this.saveHistory();
logger.debug('notificationHistoryService', 'Marked as read', { id });
}
}
/**
* 标记通知为已点击
* @param {string} id - 通知 ID
*/
markAsClicked(id) {
const record = this.history.find(r => r.id === id);
if (record && !record.clickedAt) {
record.clickedAt = Date.now();
// 点击也意味着已读
if (!record.readAt) {
record.readAt = Date.now();
}
this.saveHistory();
logger.debug('notificationHistoryService', 'Marked as clicked', { id });
}
}
/**
* 获取历史记录
* @param {object} filters - 筛选条件
* @param {string} filters.type - 通知类型
* @param {string} filters.priority - 优先级
* @param {string} filters.readStatus - 阅读状态 ('read' | 'unread' | 'all')
* @param {number} filters.startDate - 开始日期(时间戳)
* @param {number} filters.endDate - 结束日期(时间戳)
* @param {number} filters.page - 页码(从 1 开始)
* @param {number} filters.pageSize - 每页数量
* @returns {object} - { records, total, page, pageSize }
*/
getHistory(filters = {}) {
let filtered = [...this.history];
// 按类型筛选
if (filters.type && filters.type !== 'all') {
filtered = filtered.filter(r => r.notification.type === filters.type);
}
// 按优先级筛选
if (filters.priority && filters.priority !== 'all') {
filtered = filtered.filter(r => r.notification.priority === filters.priority);
}
// 按阅读状态筛选
if (filters.readStatus === 'read') {
filtered = filtered.filter(r => r.readAt !== null);
} else if (filters.readStatus === 'unread') {
filtered = filtered.filter(r => r.readAt === null);
}
// 按日期范围筛选
if (filters.startDate) {
filtered = filtered.filter(r => r.receivedAt >= filters.startDate);
}
if (filters.endDate) {
filtered = filtered.filter(r => r.receivedAt <= filters.endDate);
}
// 分页
const page = filters.page || 1;
const pageSize = filters.pageSize || 20;
const total = filtered.length;
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const records = filtered.slice(startIndex, endIndex);
return {
records,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
/**
* 搜索历史记录
* @param {string} keyword - 搜索关键词
* @returns {array} - 匹配的记录
*/
searchHistory(keyword) {
if (!keyword || keyword.trim() === '') {
return [];
}
const lowerKeyword = keyword.toLowerCase();
const results = this.history.filter(record => {
const { title, content, message } = record.notification;
const searchText = `${title || ''} ${content || ''} ${message || ''}`.toLowerCase();
return searchText.includes(lowerKeyword);
});
logger.info('notificationHistoryService', 'Search completed', {
keyword,
resultsCount: results.length
});
return results;
}
/**
* 删除单条历史记录
* @param {string} id - 通知 ID
*/
deleteRecord(id) {
const initialLength = this.history.length;
this.history = this.history.filter(r => r.id !== id);
if (this.history.length < initialLength) {
this.saveHistory();
logger.info('notificationHistoryService', 'Record deleted', { id });
return true;
}
return false;
}
/**
* 批量删除历史记录
* @param {array} ids - 通知 ID 数组
*/
deleteRecords(ids) {
const initialLength = this.history.length;
this.history = this.history.filter(r => !ids.includes(r.id));
const deletedCount = initialLength - this.history.length;
if (deletedCount > 0) {
this.saveHistory();
logger.info('notificationHistoryService', 'Batch delete completed', {
deletedCount
});
}
return deletedCount;
}
/**
* 清空所有历史记录
*/
clearHistory() {
this.history = [];
this.saveHistory();
logger.info('notificationHistoryService', 'History cleared');
}
/**
* 清理旧数据
* @param {number} count - 要清理的数量
*/
cleanup(count) {
if (this.history.length > count) {
this.history = this.history.slice(0, -count);
this.saveHistory();
logger.info('notificationHistoryService', 'Cleanup completed', { count });
}
}
/**
* 获取统计数据
* @returns {object} - 统计信息
*/
getStats() {
const total = this.history.length;
const read = this.history.filter(r => r.readAt !== null).length;
const unread = total - read;
const clicked = this.history.filter(r => r.clickedAt !== null).length;
// 按类型统计
const byType = {};
const byPriority = {};
this.history.forEach(record => {
const { type, priority } = record.notification;
// 类型统计
if (type) {
byType[type] = (byType[type] || 0) + 1;
}
// 优先级统计
if (priority) {
byPriority[priority] = (byPriority[priority] || 0) + 1;
}
});
// 计算点击率
const clickRate = total > 0 ? ((clicked / total) * 100).toFixed(2) : '0.00';
return {
total,
read,
unread,
clicked,
clickRate,
byType,
byPriority,
};
}
/**
* 导出历史记录为 JSON
* @param {object} filters - 筛选条件(可选)
*/
exportToJSON(filters = {}) {
const { records } = this.getHistory(filters);
const exportData = {
exportedAt: new Date().toISOString(),
total: records.length,
records: records.map(r => ({
id: r.id,
notification: r.notification,
receivedAt: new Date(r.receivedAt).toISOString(),
readAt: r.readAt ? new Date(r.readAt).toISOString() : null,
clickedAt: r.clickedAt ? new Date(r.clickedAt).toISOString() : null,
})),
};
return JSON.stringify(exportData, null, 2);
}
/**
* 导出历史记录为 CSV
* @param {object} filters - 筛选条件(可选)
*/
exportToCSV(filters = {}) {
const { records } = this.getHistory(filters);
let csv = 'ID,Type,Priority,Title,Content,Received At,Read At,Clicked At\n';
records.forEach(record => {
const { id, notification, receivedAt, readAt, clickedAt } = record;
const { type, priority, title, content, message } = notification;
const escapeCsv = (str) => {
if (!str) return '';
return `"${String(str).replace(/"/g, '""')}"`;
};
csv += [
escapeCsv(id),
escapeCsv(type),
escapeCsv(priority),
escapeCsv(title),
escapeCsv(content || message),
new Date(receivedAt).toISOString(),
readAt ? new Date(readAt).toISOString() : '',
clickedAt ? new Date(clickedAt).toISOString() : '',
].join(',') + '\n';
});
return csv;
}
/**
* 触发下载文件
* @param {string} content - 文件内容
* @param {string} filename - 文件名
* @param {string} mimeType - MIME 类型
*/
downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
logger.info('notificationHistoryService', 'File downloaded', { filename });
}
/**
* 导出并下载 JSON 文件
* @param {object} filters - 筛选条件(可选)
*/
downloadJSON(filters = {}) {
const json = this.exportToJSON(filters);
const filename = `notifications_${new Date().toISOString().split('T')[0]}.json`;
this.downloadFile(json, filename, 'application/json');
}
/**
* 导出并下载 CSV 文件
* @param {object} filters - 筛选条件(可选)
*/
downloadCSV(filters = {}) {
const csv = this.exportToCSV(filters);
const filename = `notifications_${new Date().toISOString().split('T')[0]}.csv`;
this.downloadFile(csv, filename, 'text/csv');
}
}
// 导出单例
export const notificationHistoryService = new NotificationHistoryService();
export default notificationHistoryService;

View File

@@ -0,0 +1,450 @@
// src/services/notificationMetricsService.js
/**
* 通知性能监控服务
* 追踪推送到达率、点击率、响应时间等指标
*/
import { logger } from '../utils/logger';
const STORAGE_KEY = 'notification_metrics';
const MAX_HISTORY_DAYS = 30; // 保留最近 30 天数据
class NotificationMetricsService {
constructor() {
this.metrics = this.loadMetrics();
}
/**
* 从 localStorage 加载指标数据
*/
loadMetrics() {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (data) {
const parsed = JSON.parse(data);
// 清理过期数据
this.cleanOldData(parsed);
return parsed;
}
} catch (error) {
logger.error('notificationMetricsService', 'loadMetrics', error);
}
// 返回默认结构
return this.getDefaultMetrics();
}
/**
* 获取默认指标结构
*/
getDefaultMetrics() {
return {
summary: {
totalSent: 0,
totalReceived: 0,
totalClicked: 0,
totalDismissed: 0,
totalResponseTime: 0, // 总响应时间(毫秒)
},
byType: {
announcement: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
stock_alert: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
event_alert: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
analysis_report: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
},
byPriority: {
urgent: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
important: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
normal: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
},
hourlyDistribution: Array(24).fill(0), // 每小时推送分布
dailyData: {}, // 按日期存储的每日数据
lastUpdated: Date.now(),
};
}
/**
* 保存指标数据到 localStorage
*/
saveMetrics() {
try {
this.metrics.lastUpdated = Date.now();
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.metrics));
logger.debug('notificationMetricsService', 'Metrics saved', this.metrics.summary);
} catch (error) {
logger.error('notificationMetricsService', 'saveMetrics', error);
}
}
/**
* 清理过期数据
*/
cleanOldData(metrics) {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - MAX_HISTORY_DAYS);
const cutoffDateStr = cutoffDate.toISOString().split('T')[0];
if (metrics.dailyData) {
Object.keys(metrics.dailyData).forEach(date => {
if (date < cutoffDateStr) {
delete metrics.dailyData[date];
}
});
}
}
/**
* 获取当前日期字符串
*/
getTodayDateStr() {
return new Date().toISOString().split('T')[0];
}
/**
* 获取当前小时
*/
getCurrentHour() {
return new Date().getHours();
}
/**
* 确保每日数据结构存在
*/
ensureDailyData(dateStr) {
if (!this.metrics.dailyData[dateStr]) {
this.metrics.dailyData[dateStr] = {
sent: 0,
received: 0,
clicked: 0,
dismissed: 0,
};
}
}
/**
* 追踪通知发送
* @param {object} notification - 通知对象
*/
trackSent(notification) {
const { type, priority } = notification;
const today = this.getTodayDateStr();
const hour = this.getCurrentHour();
// 汇总统计
this.metrics.summary.totalSent++;
// 按类型统计
if (this.metrics.byType[type]) {
this.metrics.byType[type].sent++;
}
// 按优先级统计
if (priority && this.metrics.byPriority[priority]) {
this.metrics.byPriority[priority].sent++;
}
// 每小时分布
this.metrics.hourlyDistribution[hour]++;
// 每日数据
this.ensureDailyData(today);
this.metrics.dailyData[today].sent++;
this.saveMetrics();
logger.debug('notificationMetricsService', 'Tracked sent', { type, priority });
}
/**
* 追踪通知接收
* @param {object} notification - 通知对象
*/
trackReceived(notification) {
const { type, priority, id, timestamp } = notification;
const today = this.getTodayDateStr();
// 汇总统计
this.metrics.summary.totalReceived++;
// 按类型统计
if (this.metrics.byType[type]) {
this.metrics.byType[type].received++;
}
// 按优先级统计
if (priority && this.metrics.byPriority[priority]) {
this.metrics.byPriority[priority].received++;
}
// 每日数据
this.ensureDailyData(today);
this.metrics.dailyData[today].received++;
// 存储接收时间(用于计算响应时间)
this.storeReceivedTime(id, timestamp || Date.now());
this.saveMetrics();
logger.debug('notificationMetricsService', 'Tracked received', { type, priority });
}
/**
* 追踪通知点击
* @param {object} notification - 通知对象
*/
trackClicked(notification) {
const { type, priority, id } = notification;
const today = this.getTodayDateStr();
const clickTime = Date.now();
// 汇总统计
this.metrics.summary.totalClicked++;
// 按类型统计
if (this.metrics.byType[type]) {
this.metrics.byType[type].clicked++;
}
// 按优先级统计
if (priority && this.metrics.byPriority[priority]) {
this.metrics.byPriority[priority].clicked++;
}
// 每日数据
this.ensureDailyData(today);
this.metrics.dailyData[today].clicked++;
// 计算响应时间
const receivedTime = this.getReceivedTime(id);
if (receivedTime) {
const responseTime = clickTime - receivedTime;
this.metrics.summary.totalResponseTime += responseTime;
this.removeReceivedTime(id);
logger.debug('notificationMetricsService', 'Response time', { responseTime });
}
this.saveMetrics();
logger.debug('notificationMetricsService', 'Tracked clicked', { type, priority });
}
/**
* 追踪通知关闭
* @param {object} notification - 通知对象
*/
trackDismissed(notification) {
const { type, priority, id } = notification;
const today = this.getTodayDateStr();
// 汇总统计
this.metrics.summary.totalDismissed++;
// 按类型统计
if (this.metrics.byType[type]) {
this.metrics.byType[type].dismissed++;
}
// 按优先级统计
if (priority && this.metrics.byPriority[priority]) {
this.metrics.byPriority[priority].dismissed++;
}
// 每日数据
this.ensureDailyData(today);
this.metrics.dailyData[today].dismissed++;
// 清理接收时间记录
this.removeReceivedTime(id);
this.saveMetrics();
logger.debug('notificationMetricsService', 'Tracked dismissed', { type, priority });
}
/**
* 存储通知接收时间(用于计算响应时间)
*/
storeReceivedTime(id, timestamp) {
if (!this.receivedTimes) {
this.receivedTimes = new Map();
}
this.receivedTimes.set(id, timestamp);
}
/**
* 获取通知接收时间
*/
getReceivedTime(id) {
if (!this.receivedTimes) {
return null;
}
return this.receivedTimes.get(id);
}
/**
* 移除通知接收时间记录
*/
removeReceivedTime(id) {
if (this.receivedTimes) {
this.receivedTimes.delete(id);
}
}
/**
* 获取汇总统计
*/
getSummary() {
const summary = { ...this.metrics.summary };
// 计算平均响应时间
if (summary.totalClicked > 0) {
summary.avgResponseTime = Math.round(summary.totalResponseTime / summary.totalClicked);
} else {
summary.avgResponseTime = 0;
}
// 计算点击率
if (summary.totalReceived > 0) {
summary.clickRate = ((summary.totalClicked / summary.totalReceived) * 100).toFixed(2);
} else {
summary.clickRate = '0.00';
}
// 计算到达率(假设 sent = received
if (summary.totalSent > 0) {
summary.deliveryRate = ((summary.totalReceived / summary.totalSent) * 100).toFixed(2);
} else {
summary.deliveryRate = '100.00';
}
return summary;
}
/**
* 获取按类型统计
*/
getByType() {
const result = {};
Object.keys(this.metrics.byType).forEach(type => {
const data = this.metrics.byType[type];
result[type] = {
...data,
clickRate: data.received > 0 ? ((data.clicked / data.received) * 100).toFixed(2) : '0.00',
};
});
return result;
}
/**
* 获取按优先级统计
*/
getByPriority() {
const result = {};
Object.keys(this.metrics.byPriority).forEach(priority => {
const data = this.metrics.byPriority[priority];
result[priority] = {
...data,
clickRate: data.received > 0 ? ((data.clicked / data.received) * 100).toFixed(2) : '0.00',
};
});
return result;
}
/**
* 获取每小时分布
*/
getHourlyDistribution() {
return [...this.metrics.hourlyDistribution];
}
/**
* 获取每日数据
* @param {number} days - 获取最近多少天的数据
*/
getDailyData(days = 7) {
const result = [];
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
const data = this.metrics.dailyData[dateStr] || {
sent: 0,
received: 0,
clicked: 0,
dismissed: 0,
};
result.push({
date: dateStr,
...data,
clickRate: data.received > 0 ? ((data.clicked / data.received) * 100).toFixed(2) : '0.00',
});
}
return result;
}
/**
* 获取完整指标数据
*/
getAllMetrics() {
return {
summary: this.getSummary(),
byType: this.getByType(),
byPriority: this.getByPriority(),
hourlyDistribution: this.getHourlyDistribution(),
dailyData: this.getDailyData(30),
lastUpdated: this.metrics.lastUpdated,
};
}
/**
* 重置所有指标
*/
reset() {
this.metrics = this.getDefaultMetrics();
this.saveMetrics();
logger.info('notificationMetricsService', 'Metrics reset');
}
/**
* 导出指标数据为 JSON
*/
exportToJSON() {
return JSON.stringify(this.getAllMetrics(), null, 2);
}
/**
* 导出指标数据为 CSV
*/
exportToCSV() {
const summary = this.getSummary();
const dailyData = this.getDailyData(30);
let csv = '# Summary\n';
csv += 'Metric,Value\n';
csv += `Total Sent,${summary.totalSent}\n`;
csv += `Total Received,${summary.totalReceived}\n`;
csv += `Total Clicked,${summary.totalClicked}\n`;
csv += `Total Dismissed,${summary.totalDismissed}\n`;
csv += `Delivery Rate,${summary.deliveryRate}%\n`;
csv += `Click Rate,${summary.clickRate}%\n`;
csv += `Avg Response Time,${summary.avgResponseTime}ms\n\n`;
csv += '# Daily Data\n';
csv += 'Date,Sent,Received,Clicked,Dismissed,Click Rate\n';
dailyData.forEach(day => {
csv += `${day.date},${day.sent},${day.received},${day.clicked},${day.dismissed},${day.clickRate}%\n`;
});
return csv;
}
}
// 导出单例
export const notificationMetricsService = new NotificationMetricsService();
export default notificationMetricsService;

View File

@@ -187,6 +187,46 @@ class SocketService {
return this.socket?.id || null;
}
/**
* 手动重连
* @returns {boolean} 是否触发重连
*/
reconnect() {
if (!this.socket) {
logger.warn('socketService', 'Cannot reconnect: socket not initialized');
return false;
}
if (this.connected) {
logger.info('socketService', 'Already connected, no need to reconnect');
return false;
}
logger.info('socketService', 'Manually triggering reconnection...');
// 重置重连计数
this.reconnectAttempts = 0;
// 触发重连
this.socket.connect();
return true;
}
/**
* 获取当前重连尝试次数
*/
getReconnectAttempts() {
return this.reconnectAttempts;
}
/**
* 获取最大重连次数
*/
getMaxReconnectAttempts() {
return this.maxReconnectAttempts;
}
// ==================== 事件推送专用方法 ====================
/**