feat: 添加基金监控系统基础功能

添加基金监控系统相关文件,包括邮件发送功能、基金数据配置、测试脚本等。主要包含以下内容:

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>
This commit is contained in:
LL
2025-12-12 14:14:07 +08:00
commit 0cfefbebd8
106 changed files with 26164 additions and 0 deletions

657
recommend_fund.js Normal file
View File

@@ -0,0 +1,657 @@
/**
* 推荐基金页面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');
}
}