添加基金监控系统相关文件,包括邮件发送功能、基金数据配置、测试脚本等。主要包含以下内容: 1. 添加PHPMailer库及相关语言文件 2. 添加基金配置数据文件(fund_config.json, fund_names.json等) 3. 添加邮件发送测试脚本(test_email.php, test_fund_email.php等) 4. 添加.gitignore文件忽略不必要的文件 5. 添加composer.json配置依赖 Signed-off-by: LL <LL>
658 lines
21 KiB
JavaScript
658 lines
21 KiB
JavaScript
/**
|
||
* 推荐基金页面JavaScript功能
|
||
*/
|
||
|
||
// 配置常量
|
||
const API_TIMEOUT = 5000; // API 请求超时时间(毫秒)
|
||
const MAX_RETRY_COUNT = 3; // 最大重试次数
|
||
const RETRY_DELAY = 1000; // 重试延迟(毫秒)
|
||
const BATCH_SIZE = 10; // 批量请求数量
|
||
const UPDATE_INTERVAL = 60000; // 自动更新间隔(毫秒)
|
||
const HIGHLIGHT_DURATION = 300; // 高亮效果持续时间
|
||
|
||
// 全局状态
|
||
let updateIntervalId = null;
|
||
let isLoading = false;
|
||
let fundDataCache = {}; // 缓存基金数据
|
||
let lastData = {}; // 上一次的数据,用于比较变化
|
||
let errorCount = 0;
|
||
const MAX_ERROR_COUNT = 5; // 连续错误次数上限
|
||
|
||
/**
|
||
* 格式化数字(保留两位小数)
|
||
* @param {number} num - 要格式化的数字
|
||
* @returns {string} 格式化后的字符串
|
||
*/
|
||
function formatNumber(num) {
|
||
if (typeof num !== 'number' || isNaN(num)) {
|
||
return '--';
|
||
}
|
||
return num.toFixed(2);
|
||
}
|
||
|
||
/**
|
||
* 显示加载状态
|
||
* @param {boolean} show - 是否显示
|
||
*/
|
||
function showLoading(show) {
|
||
const loadingElement = document.getElementById('loading-indicator');
|
||
if (!loadingElement && show) {
|
||
// 创建加载指示器
|
||
const div = document.createElement('div');
|
||
div.id = 'loading-indicator';
|
||
div.className = 'loading-overlay';
|
||
div.innerHTML = `
|
||
<div class="loading-spinner">
|
||
<i class="fas fa-circle-notch fa-spin"></i>
|
||
<span>加载基金数据中...</span>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(div);
|
||
} else if (loadingElement) {
|
||
loadingElement.style.display = show ? 'flex' : 'none';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清理JSONP请求的资源
|
||
* @param {string} scriptId - script标签ID
|
||
* @param {string} callbackName - 回调函数名
|
||
*/
|
||
function cleanupResources(scriptId, callbackName) {
|
||
// 清理script标签
|
||
const script = document.getElementById(scriptId);
|
||
if (script && script.parentNode) {
|
||
script.parentNode.removeChild(script);
|
||
}
|
||
|
||
// 清理回调函数
|
||
if (window[callbackName]) {
|
||
delete window[callbackName];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理API错误
|
||
* @param {string} errorMessage - 错误消息
|
||
*/
|
||
function handleApiError(errorMessage) {
|
||
errorCount++;
|
||
console.error(errorMessage);
|
||
|
||
// 如果连续错误次数过多,显示错误提示
|
||
if (errorCount >= MAX_ERROR_COUNT) {
|
||
updateErrorDisplay('获取基金数据时遇到问题,请稍后再试', true);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取单个基金数据(使用 JSONP 方式,支持预加载)
|
||
* @param {string} fundCode - 基金代码
|
||
* @param {number} retryCount - 当前重试次数
|
||
* @returns {Promise<Object>} 基金数据对象
|
||
*/
|
||
function fetchFundData(fundCode, retryCount = 0) {
|
||
// 清理基金代码,确保只包含数字和字母
|
||
const cleanFundCode = String(fundCode).replace(/[^\dA-Za-z]/g, '').trim();
|
||
|
||
if (!cleanFundCode) {
|
||
console.error('无效的基金代码:', fundCode);
|
||
return Promise.resolve({
|
||
success: false,
|
||
error: '无效的基金代码',
|
||
fundCode: fundCode
|
||
});
|
||
}
|
||
|
||
// 1. 检查是否有预加载数据
|
||
if (window.preloadedFundData && window.preloadedFundData[cleanFundCode]) {
|
||
console.log(`使用预加载数据: ${cleanFundCode}`);
|
||
return Promise.resolve({
|
||
success: true,
|
||
data: window.preloadedFundData[cleanFundCode],
|
||
fromCache: true
|
||
});
|
||
}
|
||
|
||
// 2. 检查缓存
|
||
const cachedData = fundDataCache[cleanFundCode];
|
||
if (cachedData && (Date.now() - cachedData.timestamp) < 30000) { // 30秒缓存
|
||
return Promise.resolve({
|
||
success: true,
|
||
data: cachedData.data,
|
||
fromCache: true
|
||
});
|
||
}
|
||
|
||
return new Promise((resolve, reject) => {
|
||
// 设置超时
|
||
const timeoutId = setTimeout(() => {
|
||
reject(new Error('请求超时'));
|
||
}, API_TIMEOUT);
|
||
|
||
// 创建唯一的回调函数名
|
||
const callbackName = `jsonp_callback_${cleanFundCode}_${Date.now()}`;
|
||
const scriptId = `jsonp_script_${cleanFundCode}`;
|
||
|
||
// 定义全局回调函数
|
||
window[callbackName] = function(data) {
|
||
clearTimeout(timeoutId);
|
||
|
||
// 检查数据有效性
|
||
if (!data || !data.fundcode || data.fundcode !== cleanFundCode) {
|
||
cleanupResources(scriptId, callbackName);
|
||
handleApiError(`基金${cleanFundCode}数据格式错误`);
|
||
resolve({
|
||
success: false,
|
||
error: '数据格式错误',
|
||
fundCode: cleanFundCode
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 缓存数据
|
||
fundDataCache[cleanFundCode] = {
|
||
data: data,
|
||
timestamp: Date.now()
|
||
};
|
||
|
||
// 重置错误计数
|
||
errorCount = 0;
|
||
|
||
// 清理资源并返回结果
|
||
cleanupResources(scriptId, callbackName);
|
||
resolve({
|
||
success: true,
|
||
data: data
|
||
});
|
||
};
|
||
|
||
// 创建脚本元素
|
||
const script = document.createElement('script');
|
||
script.id = scriptId;
|
||
script.src = `http://fundgz.1234567.com.cn/js/${cleanFundCode}.js?callback=${callbackName}`;
|
||
script.onerror = function() {
|
||
clearTimeout(timeoutId);
|
||
cleanupResources(scriptId, callbackName);
|
||
reject(new Error('脚本加载失败'));
|
||
};
|
||
|
||
// 添加到文档中
|
||
document.body.appendChild(script);
|
||
}).catch(error => {
|
||
console.error(`获取基金 ${cleanFundCode} 数据失败:`, error.message);
|
||
|
||
// 重试逻辑
|
||
if (retryCount < MAX_RETRY_COUNT) {
|
||
console.log(`尝试重试 ${cleanFundCode}, 第 ${retryCount + 1} 次`);
|
||
return new Promise(resolve => {
|
||
setTimeout(() => {
|
||
resolve(fetchFundData(cleanFundCode, retryCount + 1));
|
||
}, RETRY_DELAY * (retryCount + 1));
|
||
});
|
||
}
|
||
|
||
handleApiError(`获取基金 ${cleanFundCode} 数据失败`);
|
||
return {
|
||
success: false,
|
||
error: error.message,
|
||
fundCode: cleanFundCode
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 批量获取基金数据
|
||
* @param {Array<string>} fundCodes - 基金代码数组
|
||
* @returns {Promise<Array>} 基金数据结果数组
|
||
*/
|
||
async function fetchFundsDataInBatch(fundCodes) {
|
||
// 分批处理,避免一次请求过多
|
||
const batches = [];
|
||
for (let i = 0; i < fundCodes.length; i += BATCH_SIZE) {
|
||
batches.push(fundCodes.slice(i, i + BATCH_SIZE));
|
||
}
|
||
|
||
const allResults = [];
|
||
|
||
// 逐批处理
|
||
for (const batch of batches) {
|
||
// 使用Promise.allSettled确保所有请求都完成
|
||
const batchPromises = batch.map(code => fetchFundData(code));
|
||
const batchResults = await Promise.allSettled(batchPromises);
|
||
|
||
batchResults.forEach((result, index) => {
|
||
if (result.status === 'fulfilled') {
|
||
allResults.push(result.value);
|
||
} else {
|
||
console.error(`处理批处理中基金 ${batch[index]} 时出错:`, result.reason);
|
||
allResults.push({
|
||
success: false,
|
||
error: result.reason?.message || '未知错误',
|
||
fundCode: batch[index]
|
||
});
|
||
}
|
||
});
|
||
|
||
// 批处理之间添加短暂延迟,避免请求过于密集
|
||
if (batches.indexOf(batch) < batches.length - 1) {
|
||
await new Promise(resolve => setTimeout(resolve, 500));
|
||
}
|
||
}
|
||
|
||
return allResults;
|
||
}
|
||
|
||
/**
|
||
* 更新基金错误状态显示
|
||
* @param {string} fundCode - 基金代码
|
||
*/
|
||
function updateFundError(fundCode) {
|
||
// 更新基金名称显示
|
||
document.querySelectorAll(`.fund-name[data-fund-code="${fundCode}"]`).forEach(el => {
|
||
if (el.textContent === '加载中...') {
|
||
el.textContent = `${fundCode} (数据获取失败)`;
|
||
el.classList.add('error-text');
|
||
}
|
||
});
|
||
|
||
// 更新涨幅显示
|
||
document.querySelectorAll(`.fund-change[data-fund-code="${fundCode}"]`).forEach(el => {
|
||
el.textContent = '--';
|
||
el.classList.add('error');
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 加载所有基金信息
|
||
*/
|
||
async function loadAllFundInfo() {
|
||
if (isLoading) return;
|
||
isLoading = true;
|
||
showLoading(true);
|
||
|
||
try {
|
||
// 保存旧数据用于比较
|
||
lastData = {};
|
||
for (const fundCode in fundDataCache) {
|
||
lastData[fundCode] = fundDataCache[fundCode].data;
|
||
}
|
||
|
||
// 获取所有需要的基金代码
|
||
const fundElements = document.querySelectorAll('[data-fund-code]');
|
||
const fundCodes = Array.from(new Set(Array.from(fundElements).map(el => {
|
||
const code = el.getAttribute('data-fund-code');
|
||
// 清理基金代码
|
||
return String(code || '').replace(/[^\dA-Za-z]/g, '').trim();
|
||
}))).filter(code => code.length > 0); // 过滤空代码
|
||
|
||
if (fundCodes.length === 0) {
|
||
console.log('没有找到需要加载的基金');
|
||
return;
|
||
}
|
||
|
||
console.log(`开始加载 ${fundCodes.length} 支基金数据`, fundCodes);
|
||
|
||
// 批量获取基金数据
|
||
const results = await fetchFundsDataInBatch(fundCodes);
|
||
|
||
// 更新UI
|
||
let successCount = 0;
|
||
results.forEach(result => {
|
||
if (result.success && result.data) {
|
||
updateFundDisplay(result.data);
|
||
successCount++;
|
||
} else {
|
||
// 对于失败的请求,尝试更新为错误状态
|
||
if (result.fundCode) {
|
||
updateFundError(result.fundCode);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 更新IP组的总涨幅
|
||
updateIpTotalChanges();
|
||
|
||
console.log(`基金数据加载完成: 成功${successCount}/${fundCodes.length}`);
|
||
} catch (error) {
|
||
console.error('加载基金信息时发生错误:', error);
|
||
updateErrorDisplay('加载基金数据失败,请稍后重试', true);
|
||
} finally {
|
||
isLoading = false;
|
||
showLoading(false);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新单个基金的显示信息
|
||
* @param {Object} fundData - 基金数据
|
||
*/
|
||
function updateFundDisplay(fundData) {
|
||
if (!fundData || !fundData.fundcode) return;
|
||
|
||
const fundCode = fundData.fundcode;
|
||
const name = fundData.name || '未知名称';
|
||
const gszzl = parseFloat(fundData.gszzl) || 0;
|
||
|
||
// 检查是否有数据变化
|
||
const oldData = lastData[fundCode];
|
||
const hasChanged = !oldData || oldData.gszzl !== fundData.gszzl;
|
||
|
||
// 更新基金名称
|
||
document.querySelectorAll(`.fund-name[data-fund-code="${fundCode}"]`).forEach(el => {
|
||
el.textContent = name;
|
||
});
|
||
|
||
// 更新基金涨幅
|
||
document.querySelectorAll(`.fund-change[data-fund-code="${fundCode}"]`).forEach(el => {
|
||
const oldValue = el.textContent;
|
||
|
||
el.textContent = `${formatNumber(gszzl)}%`;
|
||
|
||
// 根据涨跌设置颜色
|
||
el.classList.remove('positive', 'negative', 'neutral', 'highlight');
|
||
if (gszzl > 0) {
|
||
el.classList.add('positive');
|
||
} else if (gszzl < 0) {
|
||
el.classList.add('negative');
|
||
} else {
|
||
el.classList.add('neutral');
|
||
}
|
||
|
||
// 如果有变化,添加高亮效果
|
||
if (hasChanged) {
|
||
el.classList.add('highlight');
|
||
setTimeout(() => {
|
||
el.classList.remove('highlight');
|
||
}, HIGHLIGHT_DURATION);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 更新IP组的总涨幅
|
||
*/
|
||
function updateIpTotalChanges() {
|
||
// 获取所有IP分组
|
||
const ipGroups = document.querySelectorAll('[id^="ip-"]');
|
||
|
||
ipGroups.forEach(group => {
|
||
const ipId = group.id;
|
||
const changeElements = group.querySelectorAll('.fund-change');
|
||
let totalChange = 0;
|
||
let validCount = 0;
|
||
|
||
changeElements.forEach(el => {
|
||
const text = el.textContent.trim();
|
||
if (text !== '--' && text !== '') {
|
||
const change = parseFloat(text.replace('%', ''));
|
||
if (!isNaN(change)) {
|
||
totalChange += change;
|
||
validCount++;
|
||
}
|
||
}
|
||
});
|
||
|
||
// 计算平均涨幅
|
||
const avgChange = validCount > 0 ? totalChange / validCount : 0;
|
||
|
||
// 更新显示
|
||
const totalChangeElement = document.querySelector(`#total-change-${ipId.split('-')[1]}`);
|
||
if (totalChangeElement) {
|
||
const oldValue = totalChangeElement.textContent;
|
||
const newValue = `${formatNumber(avgChange)}%`;
|
||
|
||
totalChangeElement.textContent = newValue;
|
||
|
||
// 如果有变化,添加高亮效果
|
||
if (oldValue !== newValue) {
|
||
totalChangeElement.classList.add('highlight');
|
||
setTimeout(() => {
|
||
totalChangeElement.classList.remove('highlight');
|
||
}, HIGHLIGHT_DURATION);
|
||
}
|
||
|
||
// 根据涨跌设置颜色
|
||
totalChangeElement.classList.remove('positive', 'negative', 'neutral');
|
||
if (avgChange > 0) {
|
||
totalChangeElement.classList.add('positive');
|
||
} else if (avgChange < 0) {
|
||
totalChangeElement.classList.add('negative');
|
||
} else {
|
||
totalChangeElement.classList.add('neutral');
|
||
}
|
||
|
||
// 将平均涨幅写入分组容器的data属性,便于排序
|
||
const dropdownItem = document.getElementById(ipId)?.closest('.dropdown-item');
|
||
if (dropdownItem) {
|
||
dropdownItem.dataset.avgChange = String(avgChange);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 更新错误显示
|
||
* @param {string} message - 错误信息
|
||
* @param {boolean} show - 是否显示错误
|
||
*/
|
||
function updateErrorDisplay(message, show = true) {
|
||
let errorElement = document.getElementById('api-error');
|
||
|
||
if (!errorElement) {
|
||
errorElement = document.createElement('div');
|
||
errorElement.id = 'api-error';
|
||
errorElement.className = 'error-message';
|
||
document.body.prepend(errorElement);
|
||
}
|
||
|
||
if (show) {
|
||
errorElement.textContent = message;
|
||
errorElement.style.display = 'block';
|
||
|
||
// 5秒后自动隐藏错误信息
|
||
setTimeout(() => {
|
||
errorElement.style.display = 'none';
|
||
}, 5000);
|
||
} else {
|
||
errorElement.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 切换下拉菜单显示/隐藏
|
||
* @param {string} id - 要切换的元素ID
|
||
*/
|
||
function toggleDropdown(id) {
|
||
const element = document.getElementById(id);
|
||
if (element) {
|
||
element.classList.toggle('show');
|
||
|
||
// 切换箭头方向
|
||
const header = element.previousElementSibling;
|
||
if (header) {
|
||
const arrow = header.querySelector('.dropdown-arrow i');
|
||
if (arrow) {
|
||
arrow.classList.toggle('fa-chevron-down');
|
||
arrow.classList.toggle('fa-chevron-up');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 初始化函数
|
||
*/
|
||
function init() {
|
||
// 初始加载基金数据
|
||
loadAllFundInfo();
|
||
|
||
// 设置自动更新
|
||
updateIntervalId = setInterval(loadAllFundInfo, UPDATE_INTERVAL);
|
||
|
||
// 清理函数
|
||
window.addEventListener('beforeunload', cleanup);
|
||
|
||
// 为所有下拉菜单添加点击事件委托
|
||
document.addEventListener('click', function(e) {
|
||
// 检查是否点击了下拉菜单标题
|
||
const dropdownHeader = e.target.closest('.dropdown-header');
|
||
|
||
if (dropdownHeader) {
|
||
// 获取父级dropdown-item元素
|
||
const dropdownItem = dropdownHeader.closest('.dropdown-item');
|
||
if (dropdownItem) {
|
||
// 关闭所有其他下拉菜单
|
||
document.querySelectorAll('.dropdown-item').forEach(item => {
|
||
if (item !== dropdownItem) {
|
||
item.classList.remove('open');
|
||
const arrow = item.querySelector('.dropdown-arrow i');
|
||
if (arrow) {
|
||
arrow.classList.remove('fa-chevron-up');
|
||
arrow.classList.add('fa-chevron-down');
|
||
}
|
||
}
|
||
});
|
||
|
||
// 切换当前下拉菜单
|
||
dropdownItem.classList.toggle('open');
|
||
|
||
// 切换箭头方向
|
||
const arrow = dropdownHeader.querySelector('.dropdown-arrow i');
|
||
if (arrow) {
|
||
arrow.classList.toggle('fa-chevron-down');
|
||
arrow.classList.toggle('fa-chevron-up');
|
||
}
|
||
}
|
||
} else {
|
||
// 点击其他地方,关闭所有下拉菜单
|
||
document.querySelectorAll('.dropdown-item').forEach(item => {
|
||
item.classList.remove('open');
|
||
const arrow = item.querySelector('.dropdown-arrow i');
|
||
if (arrow) {
|
||
arrow.classList.remove('fa-chevron-up');
|
||
arrow.classList.add('fa-chevron-down');
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// 初始化排序工具条
|
||
initSortToolbar();
|
||
}
|
||
|
||
/**
|
||
* 清理函数
|
||
*/
|
||
function cleanup() {
|
||
if (updateIntervalId) {
|
||
clearInterval(updateIntervalId);
|
||
updateIntervalId = null;
|
||
}
|
||
|
||
// 清理所有可能的JSONP回调函数
|
||
Object.keys(window).forEach(key => {
|
||
if (key.startsWith('jsonp_callback_')) {
|
||
delete window[key];
|
||
}
|
||
});
|
||
|
||
// 清理所有JSONP脚本标签
|
||
document.querySelectorAll('script[id^="jsonp_script_"]').forEach(script => {
|
||
if (script.parentNode) {
|
||
script.parentNode.removeChild(script);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 页面加载完成后初始化
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
/**
|
||
* 初始化排序工具条
|
||
*/
|
||
function initSortToolbar() {
|
||
const container = document.querySelector('.dropdown-container');
|
||
const buttons = document.querySelectorAll('.sort-btn');
|
||
const resetBtn = document.querySelector('.sort-reset');
|
||
if (!container || buttons.length === 0) return;
|
||
|
||
const applyActive = (activeBtn) => {
|
||
buttons.forEach(btn => btn.classList.toggle('active', btn === activeBtn));
|
||
// 更新箭头指示符
|
||
buttons.forEach(btn => {
|
||
const indicator = btn.querySelector('.order-indicator');
|
||
if (indicator) {
|
||
indicator.textContent = btn.dataset.order === 'asc' ? '↑' : '↓';
|
||
}
|
||
});
|
||
};
|
||
|
||
const sortIpGroups = (key, order) => {
|
||
const items = Array.from(container.querySelectorAll('.dropdown-item'));
|
||
const getVal = (el) => {
|
||
switch (key) {
|
||
case 'time': {
|
||
const t = el.dataset.latestTime || '';
|
||
// 兼容 YYYY-MM-DD HH:mm:ss
|
||
const timeNum = Date.parse(t.replace(/-/g, '/')) || 0;
|
||
return timeNum;
|
||
}
|
||
case 'count':
|
||
return parseInt(el.dataset.fundCount || '0', 10);
|
||
case 'avg':
|
||
return parseFloat(el.dataset.avgChange || '0');
|
||
default:
|
||
return 0;
|
||
}
|
||
};
|
||
|
||
items.sort((a, b) => {
|
||
const av = getVal(a);
|
||
const bv = getVal(b);
|
||
if (av === bv) return 0;
|
||
return order === 'asc' ? (av - bv) : (bv - av);
|
||
});
|
||
|
||
// 重新插入排序后的元素
|
||
items.forEach(el => container.appendChild(el));
|
||
};
|
||
|
||
// 绑定按钮事件
|
||
buttons.forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const sortKey = btn.dataset.sort;
|
||
const currentOrder = btn.dataset.order || 'desc';
|
||
// 如果重复点击同一按钮,切换升降序
|
||
const newOrder = (btn.classList.contains('active') && currentOrder === 'desc') ? 'asc' : 'desc';
|
||
btn.dataset.order = newOrder;
|
||
applyActive(btn);
|
||
sortIpGroups(sortKey, newOrder);
|
||
});
|
||
});
|
||
|
||
// 重置排序:恢复时间倒序
|
||
if (resetBtn) {
|
||
resetBtn.addEventListener('click', () => {
|
||
// 所有按钮恢复箭头为倒序
|
||
buttons.forEach(b => { b.dataset.order = 'desc'; });
|
||
const defaultBtn = document.querySelector('.sort-btn[data-sort="time"]');
|
||
if (defaultBtn) {
|
||
defaultBtn.dataset.order = 'desc';
|
||
applyActive(defaultBtn);
|
||
sortIpGroups('time', 'desc');
|
||
}
|
||
});
|
||
}
|
||
|
||
// 默认执行一次“按时间倒序”排序,保持与服务端一致
|
||
const defaultBtn = document.querySelector('.sort-btn[data-sort="time"]');
|
||
if (defaultBtn) {
|
||
applyActive(defaultBtn);
|
||
sortIpGroups('time', defaultBtn.dataset.order || 'desc');
|
||
}
|
||
}
|