Files
jj2/script.js
LL 0cfefbebd8 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>
2025-12-12 14:14:07 +08:00

1015 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// API配置
const API_URL = 'api.php?action=fund_data';
const STATS_URL = 'api.php?action=get_stats';
const FUND_CHART_API = (code) => `api.php?action=get_fund_chart&fund_code=${code}`;
// 全局数据存储
let fundData = null;
let visitStats = null;
// 自动刷新相关变量
let autoRefreshInterval = null;
const AUTO_REFRESH_INTERVAL = 10000; // 10秒自动刷新一次
// 取消控制器,避免并发请求
let fundController = null;
const cardCharts = new Map();
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', function() {
// 应用已保存的主题(优先使用用户选择,其次匹配系统偏好)
applySavedTheme();
// 添加动画样式
addAnimationStyles();
loadFundData();
loadVisitStats();
// 启动自动刷新
startAutoRefresh();
document.getElementById('refreshBtn').addEventListener('click', function() {
loadFundData();
loadVisitStats();
});
document.getElementById('themeBtn').addEventListener('click', toggleTheme);
document.getElementById('statsBtn').addEventListener('click', function() {
toggleStats();
loadVisitStats(); // 刷新统计数据
});
});
/**
* 启动自动刷新基金数据
*/
function startAutoRefresh() {
// 清除可能存在的旧定时器
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
}
// 添加防抖变量
let isLoading = false;
// 设置新的定时器
autoRefreshInterval = setInterval(function() {
// 如果上一个请求还在处理中,跳过本次请求
if (isLoading) {
console.log('上一个请求未完成,跳过本次刷新...');
return;
}
console.log('自动刷新基金数据...');
isLoading = true;
loadFundData(true).finally(() => {
isLoading = false;
});
}, AUTO_REFRESH_INTERVAL);
}
/**
* 停止自动刷新
*/
function stopAutoRefresh() {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
}
}
/**
* 更新页面元素的数值不刷新整个UI
* @param {Object} newData - 新的基金数据
* @param {Object} oldData - 旧的基金数据
*/
function updateElements(newData, oldData) {
if (!oldData) return;
const { summary: newSummary, channelStats: newChannelStats } = newData;
const { summary: oldSummary, channelStats: oldChannelStats } = oldData;
// 更新仪表盘统计数据
if (newSummary.total !== oldSummary.total) {
const el = document.querySelector('[data-stat="total-funds"]');
if (el) {
el.textContent = newSummary.total;
el.classList.add('highlight');
setTimeout(() => el.classList.remove('highlight'), 300);
}
}
if (newSummary.upCount !== oldSummary.upCount) {
const el = document.querySelector('[data-stat="up-funds"]');
if (el) {
el.textContent = newSummary.upCount;
el.classList.add('highlight');
setTimeout(() => el.classList.remove('highlight'), 300);
}
}
if (newSummary.downCount !== oldSummary.downCount) {
const el = document.querySelector('[data-stat="down-funds"]');
if (el) {
el.textContent = newSummary.downCount;
el.classList.add('highlight');
setTimeout(() => el.classList.remove('highlight'), 300);
}
}
if (formatNumber(newSummary.avgChange) !== formatNumber(oldSummary.avgChange)) {
const el = document.querySelector('[data-stat="avg-change"]');
if (el) {
el.textContent = formatNumber(newSummary.avgChange) + '%';
el.classList.add('highlight');
setTimeout(() => el.classList.remove('highlight'), 300);
}
}
// 更新总盈亏
if (newSummary.formattedTotalProfitLoss !== oldSummary.formattedTotalProfitLoss) {
const el = document.querySelector('[data-stat="total-profit"]');
if (el) {
const profitClass = newSummary.profitLossClass;
el.innerHTML = `
${newSummary.profitLossIcon} ${newSummary.formattedTotalProfitLoss}
<span class="${profitClass}-badge">
${newSummary.formattedProfitLossRate}%
</span>
`;
el.classList.add('highlight');
setTimeout(() => el.classList.remove('highlight'), 300);
}
}
// 更新渠道统计数据
for (const [channelName, newStats] of Object.entries(newChannelStats)) {
const oldStats = oldChannelStats[channelName];
if (!oldStats) continue;
// 更新平均涨跌
if (formatNumber(newStats.avgChange) !== formatNumber(oldStats.avgChange)) {
const elements = document.querySelectorAll(`.channel-avg-change[data-channel="${channelName}"]`);
elements.forEach(el => {
el.textContent = formatNumber(newStats.avgChange) + '%';
// 高亮变化的元素
el.classList.add('highlight');
setTimeout(() => el.classList.remove('highlight'), 300);
});
}
// 更新渠道盈亏
if (newStats.totalProfitLoss !== oldStats.totalProfitLoss) {
const profitClass = newStats.totalProfitLoss > 0 ? 'profit-positive' : 'profit-negative';
const profitIcon = newStats.totalProfitLoss > 0 ? '📈' : '📉';
const elements = document.querySelectorAll(`.channel-profit-section[data-channel="${channelName}"]`);
elements.forEach(el => {
el.className = 'channel-profit-section ' + profitClass;
el.textContent = `${profitIcon} 渠道盈亏: ${formatCurrency(newStats.totalProfitLoss)} (${formatNumber(newStats.profitLossRate)}%)`;
// 高亮变化的元素
el.classList.add('highlight');
setTimeout(() => el.classList.remove('highlight'), 300);
});
}
}
// 更新基金卡片数据
for (const [channelName, newStats] of Object.entries(newChannelStats)) {
const oldStats = oldChannelStats[channelName];
if (!oldStats) continue;
for (const [fundCode, newFund] of Object.entries(newStats.funds)) {
const oldFund = oldStats.funds[fundCode];
if (!oldFund) continue;
// 解析基本数据
const parsedUnitNetValue = parseFloat(newFund.unit_net_value || newFund.dwjz) || 1;
const parsedCurrentNetValue = parseFloat(newFund.current_net_value || newFund.gsz) || 1;
const parsedShares = parseFloat(newFund.shares || (newFund.investment / parsedUnitNetValue)) || 0;
const todayProfitLoss = parsedShares * (parsedCurrentNetValue - parsedUnitNetValue);
const oldParsedUnitNetValue = parseFloat(oldFund.unit_net_value || oldFund.dwjz) || 1;
const oldParsedCurrentNetValue = parseFloat(oldFund.current_net_value || oldFund.gsz) || 1;
const oldParsedShares = parseFloat(oldFund.shares || (oldFund.investment / oldParsedUnitNetValue)) || 0;
const oldTodayProfitLoss = oldParsedShares * (oldParsedCurrentNetValue - oldParsedUnitNetValue);
// 更新估算净值
if (newFund.gsz !== oldFund.gsz) {
const gszEl = document.querySelector(`.est-net-value[data-fund="${fundCode}"]`);
if (gszEl) {
gszEl.textContent = newFund.gsz;
gszEl.classList.add('highlight');
setTimeout(() => gszEl.classList.remove('highlight'), 300);
}
}
// 更新涨跌幅
if (newFund.gszzl !== oldFund.gszzl) {
const changeEl = document.querySelector(`.change-display[data-fund="${fundCode}"]`);
if (changeEl) {
const change = parseFloat(newFund.gszzl);
const changeDisplayClass = change > 0 ? 'change-positive' : 'change-negative';
const changeIcon = change > 0 ? '📈' : (change < 0 ? '📉' : '➡️');
const changeDisplay = change > 0 ? '+' + newFund.gszzl + '%' : newFund.gszzl + '%';
changeEl.className = 'change-display ' + changeDisplayClass + ' highlight';
changeEl.innerHTML = `<span class="change-icon">${changeIcon}</span>${changeDisplay}`;
setTimeout(() => changeEl.classList.remove('highlight'), 300);
}
}
// 更新今日盈亏
if (Math.abs(todayProfitLoss - oldTodayProfitLoss) > 0.01) {
const todayProfitEl = document.querySelector(`.today-profit[data-fund="${fundCode}"]`);
if (todayProfitEl) {
const todayProfitClass = todayProfitLoss >= 0 ? 'profit-positive' : 'profit-negative';
const todayProfitIcon = todayProfitLoss >= 0 ? '📈' : '📉';
todayProfitEl.className = 'profit-value today-profit ' + todayProfitClass + ' highlight';
todayProfitEl.textContent = `${todayProfitIcon} ${formatCurrency(todayProfitLoss)}`;
setTimeout(() => todayProfitEl.classList.remove('highlight'), 300);
}
}
// 更新更新时间
if (newFund.gztime !== oldFund.gztime) {
const timeEl = document.querySelector(`.update-time[data-fund="${fundCode}"]`);
if (timeEl) {
timeEl.innerHTML = `<i class="fas fa-clock"></i> ${newFund.gztime}`;
}
}
}
}
}
/**
* 加载基金数据
* @param {boolean} isAutoRefresh - 是否为自动刷新模式
* @returns {Promise} - 返回Promise以便使用finally方法
*/
async function loadFundData(isAutoRefresh = false) {
try {
// 只有手动刷新时才显示加载动画
if (!isAutoRefresh) {
showLoading();
}
// 若存在未完成的请求,则取消
if (fundController) {
try { fundController.abort(); } catch (e) {}
}
fundController = new AbortController();
const response = await fetch(API_URL, { signal: fundController.signal });
const result = await response.json();
if (result.success) {
// 保存旧数据用于比较
const oldData = { ...fundData };
// 更新数据
fundData = result.data;
// 更新最后更新时间
document.getElementById('lastUpdate').innerHTML = `
<strong><i class="fas fa-clock"></i> 最后更新:</strong> ${fundData.timestamp}
`;
if (isAutoRefresh && oldData) {
// 自动刷新时只更新变化的元素,不重新渲染整个页面
updateElements(fundData, oldData);
// 检查是否有数据变化
const hasDataChanged = JSON.stringify(fundData) !== JSON.stringify(oldData);
if (hasDataChanged) {
showUpdateNotification();
}
} else {
// 首次加载或手动刷新时重新渲染整个页面
renderPage();
}
} else {
throw new Error('API返回错误');
}
} catch (error) {
// 忽略主动取消造成的错误
if (error && (error.name === 'AbortError' || error.message === 'The operation was aborted.')) {
return;
}
console.error('加载基金数据失败:', error);
// 只有手动刷新时才显示错误信息
if (!isAutoRefresh) {
showError('数据加载失败,请稍后重试');
}
// 重新抛出错误以便外部捕获
throw error;
}
}
/**
* 显示数据更新通知
*/
function showUpdateNotification() {
// 检查是否已存在通知元素
let notification = document.getElementById('updateNotification');
// 如果不存在,创建通知元素
if (!notification) {
notification = document.createElement('div');
notification.id = 'updateNotification';
notification.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background-color: #4CAF50;
color: white;
padding: 12px 20px;
border-radius: 6px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
z-index: 1000;
opacity: 0;
transition: opacity 0.3s ease;
font-size: 14px;
`;
document.body.appendChild(notification);
}
// 设置通知内容
notification.textContent = '基金数据已更新';
// 显示通知
notification.style.opacity = '1';
// 3秒后隐藏通知
setTimeout(() => {
notification.style.opacity = '0';
}, 3000);
// 添加数据变化的视觉反馈
highlightChangedElements();
}
/**
* 高亮显示变化的元素
*/
function highlightChangedElements() {
// 为关键数据元素添加高亮效果
const keyElements = document.querySelectorAll('.fund-item .fund-value, .fund-item .fund-change, .fund-item .fund-profit, .stat-card .stat-number');
keyElements.forEach(element => {
// 保存原始样式
const originalBackground = element.style.backgroundColor;
const originalTransition = element.style.transition;
// 添加高亮样式
element.style.backgroundColor = '#fff3cd2d';
element.style.transition = 'background-color 0.5s ease';
// 1.5秒后恢复原始样式
setTimeout(() => {
element.style.backgroundColor = originalBackground;
element.style.transition = originalTransition;
}, 1500);
});
// 为上涨/下跌状态变化的元素添加特殊效果
const statusElements = document.querySelectorAll('.fund-item .fund-status');
statusElements.forEach(element => {
// 添加闪烁动画
element.style.animation = 'pulse 1s ease-in-out 2';
// 动画结束后移除动画样式
setTimeout(() => {
element.style.animation = '';
}, 2000);
});
}
// 添加动画样式
function addAnimationStyles() {
// 检查是否已存在动画样式
if (document.getElementById('animationStyles')) return;
const styleElement = document.createElement('style');
styleElement.id = 'animationStyles';
styleElement.textContent = `
@keyframes pulse {
0% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(1.05); }
100% { opacity: 1; transform: scale(1); }
}
.highlight {
animation: highlight 0.3s ease-in-out;
}
@keyframes highlight {
0% { background-color: transparent; }
50% { background-color: rgba(255, 255, 0, 0.03); }
100% { background-color: transparent; }
}
`;
document.head.appendChild(styleElement);
}
// 加载访问统计
async function loadVisitStats() {
try {
const response = await fetch(STATS_URL);
const result = await response.json();
if (result.success) {
visitStats = result.data;
updateStatsDisplay();
}
} catch (error) {
console.error('加载访问统计失败:', error);
}
}
// 更新统计显示
function updateStatsDisplay() {
if (!visitStats) return;
// 更新页脚统计
document.getElementById('todayVisits').textContent = visitStats.today_visits;
document.getElementById('totalVisits').textContent = visitStats.total_visits;
document.getElementById('uniqueVisitors').textContent = visitStats.unique_visitors;
// 更新统计面板
const statsGrid = document.getElementById('statsGrid');
const recentVisits = document.getElementById('recentVisits');
if (statsGrid) {
statsGrid.innerHTML = `
<div class="stat-item">
<div class="stat-value">${visitStats.total_visits}</div>
<div class="stat-label">三天访问量</div>
</div>
<div class="stat-item">
<div class="stat-value">${visitStats.today_visits}</div>
<div class="stat-label">今日访问</div>
</div>
<div class="stat-item">
<div class="stat-value">${visitStats.unique_visitors}</div>
<div class="stat-label">独立访客</div>
</div>
<div class="stat-item">
<div class="stat-value">${visitStats.today_unique_visitors}</div>
<div class="stat-label">今日独立访客</div>
</div>
`;
}
if (recentVisits && visitStats.recent_visits) {
let visitsHTML = '<h4 style="margin-bottom: 15px; opacity: 0.9;">最近访问记录</h4>';
if (visitStats.recent_visits.length === 0) {
visitsHTML += '<p style="text-align: center; opacity: 0.7;">暂无访问记录</p>';
} else {
visitStats.recent_visits.forEach(visit => {
visitsHTML += `
<div class="visit-item">
<div>
<span class="visit-ip">${visit.ip}</span>
<span style="margin-left: 10px; opacity: 0.7;">${getBrowserInfo(visit.user_agent)}</span>
</div>
<div class="visit-time">${formatTime(visit.date)}</div>
</div>
`;
});
}
recentVisits.innerHTML = visitsHTML;
}
}
// 获取浏览器信息
function getBrowserInfo(userAgent) {
if (userAgent.includes('Chrome')) return 'Chrome';
if (userAgent.includes('Firefox')) return 'Firefox';
if (userAgent.includes('Safari')) return 'Safari';
if (userAgent.includes('Edge')) return 'Edge';
if (userAgent.includes('MSIE') || userAgent.includes('Trident')) return 'IE';
return '其他浏览器';
}
// 显示/隐藏统计面板
function toggleStats() {
const panel = document.getElementById('statsPanel');
if (panel.style.display === 'none') {
panel.style.display = 'block';
loadVisitStats(); // 确保数据最新
} else {
panel.style.display = 'none';
}
}
// 格式化时间
function formatTime(dateString) {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
if (diff < 60000) { // 1分钟内
return '刚刚';
} else if (diff < 3600000) { // 1小时内
return Math.floor(diff / 60000) + '分钟前';
} else if (diff < 86400000) { // 1天内
return Math.floor(diff / 3600000) + '小时前';
} else {
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
}
}
// 显示加载状态
function showLoading() {
document.getElementById('content').innerHTML = `
<div class="loading">
<div class="loading-spinner"></div>
<p>正在加载基金数据...</p>
</div>
`;
}
// 显示错误信息
function showError(message) {
document.getElementById('content').innerHTML = `
<div class="error-section">
<h3><i class="fas fa-exclamation-triangle"></i> 数据加载失败</h3>
<p>${message}</p>
</div>
`;
}
// 渲染页面
function renderPage() {
if (!fundData) return;
const { summary, channelStats, errors, timestamp } = fundData;
// 更新最后更新时间
document.getElementById('lastUpdate').innerHTML = `
<strong><i class="fas fa-clock"></i> 最后更新:</strong> ${timestamp}
`;
// 构建页面内容
let contentHTML = `
<div class="dashboard">
<div class="stat-card total-funds">
<div class="stat-icon"><i class="fas fa-chart-pie"></i></div>
<div class="stat-number" data-stat="total-funds">${summary.total}</div>
<div class="stat-label">监控基金</div>
</div>
<div class="stat-card up-funds">
<div class="stat-icon"><i class="fas fa-arrow-up"></i></div>
<div class="stat-number" data-stat="up-funds">${summary.upCount}</div>
<div class="stat-label">上涨</div>
</div>
<div class="stat-card down-funds">
<div class="stat-icon"><i class="fas fa-arrow-down"></i></div>
<div class="stat-number" data-stat="down-funds">${summary.downCount}</div>
<div class="stat-label">下跌</div>
</div>
<div class="stat-card avg-change">
<div class="stat-icon"><i class="fas fa-percentage"></i></div>
<div class="stat-number" data-stat="avg-change">${formatNumber(summary.avgChange)}%</div>
<div class="stat-label">平均涨跌</div>
</div>
<div class="stat-card total-investment">
<div class="stat-icon"><i class="fas fa-money-bill-wave"></i></div>
<div class="stat-number" data-stat="total-investment">${summary.formattedTotalInvestment}</div>
<div class="stat-label">总投资</div>
</div>
<div class="stat-card total-profit">
<div class="stat-icon"><i class="fas fa-coins"></i></div>
<div class="stat-number" data-stat="total-profit">
${summary.profitLossIcon} ${summary.formattedTotalProfitLoss}
<span class="${summary.profitLossClass}-badge">
${summary.formattedProfitLossRate}%
</span>
</div>
<div class="stat-label">总盈亏</div>
</div>
</div>
`;
// 渠道统计
contentHTML += '<div class="channel-dashboard">';
for (const [channelName, stats] of Object.entries(channelStats)) {
const channelClass = getChannelClass(channelName);
const profitClass = stats.totalProfitLoss > 0 ? 'profit-positive' : 'profit-negative';
const profitIcon = stats.totalProfitLoss > 0 ? '📈' : '📉';
contentHTML += `
<div class="channel-stat-card ${channelClass}" data-channel="${channelName}">
<div class="channel-header">
<div class="channel-icon">${getChannelIcon(channelName)}</div>
<div class="channel-name">${channelName}</div>
</div>
<div class="channel-details">
<div class="channel-detail-item">
<div class="channel-detail-label">基金数量</div>
<div class="channel-detail-value">${stats.count} 只</div>
</div>
<div class="channel-detail-item">
<div class="channel-detail-label">平均涨跌</div>
<div class="channel-detail-value channel-avg-change" data-channel="${channelName}">${formatNumber(stats.avgChange)}%</div>
</div>
<div class="channel-detail-item">
<div class="channel-detail-label">投资金额</div>
<div class="channel-detail-value">${formatCurrency(stats.totalInvestment)}</div>
</div>
<div class="channel-detail-item">
<div class="channel-detail-label">当前价值</div>
<div class="channel-detail-value">${formatCurrency(stats.totalCurrentValue)}</div>
</div>
</div>
<div class="channel-profit-section ${profitClass}" data-channel="${channelName}">
${profitIcon} 渠道盈亏: ${formatCurrency(stats.totalProfitLoss)} (${formatNumber(stats.profitLossRate)}%)
</div>
</div>
`;
}
contentHTML += '</div>';
// 错误信息
if (errors && errors.length > 0) {
contentHTML += `
<div class="error-section">
<h3><i class="fas fa-exclamation-triangle"></i> 数据获取异常</h3>
<ul class="error-list">
${errors.map(error => `<li>${error}</li>`).join('')}
</ul>
</div>
`;
}
// 基金卡片
contentHTML += generateChannelSections(channelStats);
document.getElementById('content').innerHTML = contentHTML;
// 添加动画效果
setTimeout(() => {
const cards = document.querySelectorAll('.fund-card, .stat-card, .channel-stat-card');
cards.forEach((card, index) => {
card.style.animationDelay = (index * 0.05) + 's';
});
}, 100);
// 绑定基金卡片点击翻转与图表加载
bindFundCardFlipEvents();
}
// 绑定基金卡片翻转与图表加载
function bindFundCardFlipEvents() {
const cards = document.querySelectorAll('.flip-card');
cards.forEach(card => {
card.addEventListener('click', async (e) => {
const fundCode = card.getAttribute('data-fundcode');
const isFlipped = card.classList.contains('flipped');
if (!isFlipped) {
card.classList.add('flipped');
await loadCardChart(fundCode, card);
} else {
card.classList.remove('flipped');
destroyCardChart(fundCode);
}
});
// 背面关闭按钮只控制翻回
const closeBtn = card.querySelector('.flip-close');
if (closeBtn) {
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
card.classList.remove('flipped');
const fundCode = card.getAttribute('data-fundcode');
destroyCardChart(fundCode);
});
}
// 阻止点击图表容器导致翻回(便于交互)
const chartContainer = card.querySelector('.card-back .chart-container');
if (chartContainer) {
chartContainer.addEventListener('click', (e) => e.stopPropagation());
chartContainer.addEventListener('touchstart', (e) => e.stopPropagation(), { passive: true });
}
});
}
async function loadCardChart(fundCode, cardEl) {
try {
const response = await fetch(FUND_CHART_API(fundCode));
const result = await response.json();
if (!result.success) throw new Error(result.message || '图表数据加载失败');
renderCardChart(fundCode, result.data, cardEl);
} catch (err) {
console.error('加载卡片图表失败:', err);
const container = cardEl.querySelector('.card-back .chart-container');
if (container) {
container.innerHTML = `<div style="padding:12px;color:var(--gray)">图表加载失败:${err.message}</div>`;
}
}
}
function renderCardChart(fundCode, chartData, cardEl) {
const canvas = cardEl.querySelector(`#fund-chart-${fundCode}`);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const isDark = document.body.classList.contains('theme-dark');
const axisColor = isDark ? '#cbd5e1' : '#374151';
const gridColor = isDark ? 'rgba(203,213,225,0.15)' : 'rgba(55,65,81,0.15)';
const lineColor = '#6366f1';
const fillColor = isDark ? 'rgba(99,102,241,0.15)' : 'rgba(99,102,241,0.2)';
if (cardCharts.has(fundCode)) {
try { cardCharts.get(fundCode).destroy(); } catch (e) {}
cardCharts.delete(fundCode);
}
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: chartData.labels,
datasets: [{
label: '估算净值',
data: chartData.netValues,
borderColor: lineColor,
backgroundColor: fillColor,
borderWidth: 3,
fill: true,
tension: 0.4,
pointRadius: 3,
pointHoverRadius: 6,
pointBackgroundColor: '#ffffff',
pointBorderColor: lineColor,
pointBorderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { mode: 'index', intersect: false }
},
scales: {
x: { ticks: { color: axisColor }, grid: { color: gridColor } },
y: { ticks: { color: axisColor }, grid: { color: gridColor } }
}
}
});
cardCharts.set(fundCode, chart);
}
function destroyCardChart(fundCode) {
if (cardCharts.has(fundCode)) {
try { cardCharts.get(fundCode).destroy(); } catch (e) {}
cardCharts.delete(fundCode);
}
}
// 生成渠道分组的基金卡片
function generateChannelSections(channelStats) {
let html = '';
for (const [channelName, stats] of Object.entries(channelStats)) {
// 按涨跌幅排序
const sortedFunds = Object.entries(stats.funds)
.sort(([,a], [,b]) => parseFloat(b.gszzl) - parseFloat(a.gszzl));
const channelClass = getChannelClass(channelName);
const channelIcon = getChannelIcon(channelName);
html += `
<div class="channel-section">
<div style="display: flex; align-items: center; margin-bottom: 16px; padding: 12px; background: rgba(255,255,255,0.1); border-radius: 10px; color: white;">
<div style="font-size: 1.5rem; margin-right: 10px;">${channelIcon}</div>
<div>
<h2 style="margin: 0; font-size: 1.2rem; font-weight: 700;">${channelName}渠道</h2>
<p style="margin: 2px 0 0 0; opacity: 0.9; font-size: 0.85rem;">${getChannelDescription(channelName)}</p>
</div>
</div>
<div class="funds-grid">
`;
let rank = 1;
for (const [fundCode, fund] of sortedFunds) {
const change = parseFloat(fund.gszzl);
const changeClass = change > 0 ? 'positive' : (change < 0 ? 'negative' : '');
const changeDisplayClass = change > 0 ? 'change-positive' : 'change-negative';
const changeIcon = change > 0 ? '📈' : (change < 0 ? '📉' : '➡️');
const changeDisplay = change > 0 ? '+' + fund.gszzl + '%' : fund.gszzl + '%';
// 历史涨幅样式
// 添加调试代码确保昨日涨幅数据正确处理
console.log(`基金${fund.fundcode}昨日涨幅数据:`, {
yesterday_change: fund.yesterday_change,
formatted_yesterday_change: fund.formatted_yesterday_change
});
const yesterdayChange = fund.yesterday_change || 0; // 确保有默认值
const yesterdayChangeClass = yesterdayChange > 0 ? 'profit-positive' : (yesterdayChange < 0 ? 'profit-negative' : '');
// 盈亏信息
const investment = fund.investment;
const currentValue = fund.current_value;
const profitLoss = fund.profit_loss;
const profitLossRate = (profitLoss / investment) * 100;
// 份额和净值信息(从后端新计算的数据)
const shares = (fund.shares || (investment / (parseFloat(fund.dwjz) || 1))).toFixed(4);
const unitNetValue = (fund.unit_net_value || parseFloat(fund.dwjz)).toFixed(4);
const currentNetValue = (fund.current_net_value || parseFloat(fund.gsz)).toFixed(4);
// 解析基本数据
const parsedCurrentNetValue = parseFloat(currentNetValue); // 当日净值
const parsedUnitNetValue = parseFloat(unitNetValue); // 前一日净值(昨日单位净值)
const parsedShares = parseFloat(shares); // 份额
// 计算标准涨幅
const todayChange = parsedUnitNetValue !== 0 ? ((parsedCurrentNetValue - parsedUnitNetValue) / parsedUnitNetValue) * 100 : 0;
// 计算今日盈亏(基于净值变化)
const todayProfitLoss = parsedShares * (parsedCurrentNetValue - parsedUnitNetValue);
// 计算今日价值(累计方式)
const calculatedCurrentValue = parsedShares * parsedCurrentNetValue; // 直接计算当前价值
// 添加日志以调试计算问题
// if (fund.fundcode === '012863') {
// console.log(`基金012863计算详情:`);
// console.log(`- 份额: ${parsedShares.toFixed(4)}`);
// console.log(`- 前一日净值: ${parsedUnitNetValue.toFixed(4)}`);
// console.log(`- 当日净值: ${parsedCurrentNetValue.toFixed(4)}`);
// console.log(`- 计算的标准涨幅: ${todayChange.toFixed(2)}%`);
// console.log(`- 投资金额: ${investment.toFixed(2)}元`);
// console.log(`- 今日盈亏: ${todayProfitLoss.toFixed(2)}元 (份额 × (当日净值 - 昨日单位净值))`);
// console.log(`- 当前价值: ${calculatedCurrentValue.toFixed(2)}元 (份额 × 当日净值)`);
// }
// 今日盈亏样式
const todayProfitClass = todayProfitLoss >= 0 ? 'profit-positive' : 'profit-negative';
const todayProfitIcon = todayProfitLoss >= 0 ? '📈' : '📉';
html += `
<div class="fund-card flip-card ${changeClass}" data-fundcode="${fund.fundcode}">
<div class="flip-inner">
<div class="card-face card-front">
<div class="fund-header">
<div class="fund-name">
${fund.name}
<span class="rank-badge">#${rank}</span>
</div>
<div class="fund-code">${fund.fundcode}</div>
</div>
<div class="fund-details">
<div class="detail-item">
<div class="detail-label">单位净值</div>
<div class="detail-value">${fund.dwjz}</div>
</div>
<div class="detail-item">
<div class="detail-label">估算净值</div>
<div class="detail-value est-net-value" data-fund="${fund.fundcode}">${fund.gsz}</div>
</div>
<div class="detail-item">
<div class="detail-label">昨日涨幅</div>
<div class="detail-value yesterday-change ${yesterdayChangeClass}">
${fund.formatted_yesterday_change}
</div>
</div>
</div>
<div class="change-display ${changeDisplayClass}" data-fund="${fund.fundcode}">
<span class="change-icon">${changeIcon}</span>
${changeDisplay}
</div>
<div class="profit-section">
<div class="profit-row">
<span class="profit-label">持仓:</span>
<span class="profit-value">${formatCurrency(investment)}</span>
</div>
<div class="profit-row">
<span class="profit-label">份额:</span>
<span class="profit-value">${shares}</span>
</div>
<div class="profit-row">
<span class="profit-label">净值:</span>
<span class="profit-value">${unitNetValue}</span>
</div>
<div class="profit-row">
<span class="profit-label">当前净值:</span>
<span class="profit-value">${currentNetValue}</span>
</div>
<div class="profit-row">
<span class="profit-label">今日盈亏:</span>
<span class="profit-value ${todayProfitClass} today-profit" data-fund="${fund.fundcode}">
${todayProfitIcon} ${formatCurrency(todayProfitLoss)}
</span>
</div>
</div>
<div class="channel-info">
<div class="channel-badge ${channelClass}">
${channelIcon} ${channelName}
</div>
<div class="update-time" data-fund="${fund.fundcode}">
<i class="fas fa-clock"></i> ${fund.gztime}
</div>
</div>
<div class="buy-hint">
<i class="fas fa-lightbulb"></i> 建议: ${channelName}申购
</div>
</div>
<div class="card-face card-back">
<button class="flip-close" aria-label="关闭">×</button>
<div class="chart-container">
<canvas id="fund-chart-${fund.fundcode}"></canvas>
</div>
</div>
</div>
</div>
`;
rank++;
}
html += '</div></div>';
}
return html;
}
// 工具函数
function formatNumber(number) {
return Number(number).toFixed(2);
}
function formatCurrency(amount) {
if (amount >= 10000) {
return (amount / 10000).toFixed(2) + '万元';
}
return amount.toFixed(2) + '元';
}
function getChannelClass(channelName) {
const channelMap = {
'招商银行': 'cmb',
'天天基金': 'tt',
'支付宝': 'zfb'
};
return channelMap[channelName] || 'cmb';
}
function getChannelIcon(channelName) {
const iconMap = {
'招商银行': '🏦',
'天天基金': '📱',
'支付宝': '💙'
};
return iconMap[channelName] || '🏦';
}
function getChannelDescription(channelName) {
const descMap = {
'招商银行': '银行渠道,申购费率较低,服务稳定',
'天天基金': '专业基金平台,品种齐全,数据精准',
'支付宝': '便捷支付,操作简单,用户体验佳'
};
return descMap[channelName] || '基金购买渠道';
}
function toggleTheme() {
const body = document.body;
const isDark = body.classList.toggle('theme-dark');
try {
localStorage.setItem('theme', isDark ? 'dark' : 'light');
} catch (e) { /* 忽略存储错误 */ }
}
/**
* 根据本地存储或系统偏好应用主题
*/
function applySavedTheme() {
let saved = null;
try {
saved = localStorage.getItem('theme');
} catch (e) { /* 忽略 */ }
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const useDark = saved ? (saved === 'dark') : prefersDark;
if (useDark) {
document.body.classList.add('theme-dark');
} else {
document.body.classList.remove('theme-dark');
}
}
// 自动刷新机制已在startAutoRefresh函数中实现