1015 lines
40 KiB
JavaScript
1015 lines
40 KiB
JavaScript
// 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函数中实现
|