403 lines
12 KiB
JavaScript
403 lines
12 KiB
JavaScript
// 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;
|