diff --git a/src/main/resources/static/js/dataStatistics.js b/src/main/resources/static/js/dataStatistics.js
index 066aa45..2edace5 100644
--- a/src/main/resources/static/js/dataStatistics.js
+++ b/src/main/resources/static/js/dataStatistics.js
@@ -1,808 +1,37 @@
(function() {
'use strict';
- // ==================== 工具函数 ====================
-
- function detectEmbedMode(element) {
- const isInArticle = element.closest('article') ||
- element.closest('.post-content') ||
- element.closest('.content') ||
- element.closest('[class*="content"]');
- const isInSidebar = element.closest('aside') ||
- element.closest('.sidebar') ||
- element.closest('[class*="sidebar"]');
-
- return {
- isEmbed: isInArticle || isInSidebar,
- isArticle: isInArticle,
- isSidebar: isInSidebar
- };
- }
-
- const LOADING_CLASS_MAP = {
- 'xhhaocom-dataStatistics-v2-traffic': 'xhhaocom-dataStatistics-v2-traffic-loading',
- 'xhhaocom-dataStatistics-v2-activity': 'xhhaocom-dataStatistics-v2-activity-loading',
- 'xhhaocom-dataStatistics-v2-uptime-kuma': 'xhhaocom-dataStatistics-v2-uptime-kuma-loading',
- 'xhhaocom-dataStatistics-v2-github-pin': 'xhhaocom-dataStatistics-v2-github-loading',
- 'xhhaocom-dataStatistics-v2-github-stats': 'xhhaocom-dataStatistics-v2-github-loading',
- 'xhhaocom-dataStatistics-v2-github-top-langs': 'xhhaocom-dataStatistics-v2-github-loading',
- 'xhhaocom-dataStatistics-v2-github-graph': 'xhhaocom-dataStatistics-v2-github-loading'
- };
-
- function showLoading(element) {
- const className = element.className;
- const loadingClass = LOADING_CLASS_MAP[className] || 'xhhaocom-dataStatistics-v2-github-loading';
- element.innerHTML = `
加载中
`;
- }
-
- function formatNumber(num) {
- if (num >= 1000000) {
- return (num / 1000000).toFixed(1) + 'M';
- } else if (num >= 1000) {
- return (num / 1000).toFixed(1) + 'K';
- }
- return num.toString();
- }
-
- function extractValue(data) {
- if (data == null) return 0;
- if (typeof data === 'object' && 'value' in data) {
- return parseInt(data.value) || 0;
- }
- return parseInt(data) || 0;
- }
-
- function safeFetch(url) {
- return fetch(url).then(r => {
- if (!r.ok) {
- throw new Error(`HTTP ${r.status}`);
- }
- return r.json();
- });
- }
-
- // ==================== 国际化相关 ====================
-
- const regionDisplay = typeof Intl !== 'undefined' && typeof Intl.DisplayNames === 'function'
- ? new Intl.DisplayNames(['zh-CN'], { type: 'region' })
- : null;
-
- const specialRegionMap = {
- HK: '中国香港',
- MO: '中国澳门',
- TW: '中国台湾'
- };
-
- function getCountryName(code = '') {
- const normalized = code.toUpperCase();
- if (!normalized) return '';
-
- let result = normalized;
- if (regionDisplay) {
- const localized = regionDisplay.of(normalized);
- if (localized && localized !== normalized) {
- result = localized;
- }
- }
-
- if (specialRegionMap[normalized]) {
- if (!result.includes('中国')) {
- result = specialRegionMap[normalized];
- } else {
- const trimmed = result.replace(/^中国/, '');
- result = `中国${trimmed}`;
- }
- }
-
- return result;
- }
-
- // ==================== 图标和常量 ====================
-
- const icons = {
- 'chart-line': '',
- 'account-group': '',
- 'account': '',
- 'fire': '',
- 'lightning-bolt': '',
- 'eye': ''
- };
-
- const MAX_ACTIVITY_EVENTS = 30;
- const TRAFFIC_TYPE_LABELS = {
- daily: '今日概览',
- weekly: '近7天趋势',
- monthly: '近30天趋势',
- quarterly: '近90天趋势',
- yearly: '近一年趋势'
- };
-
- function createIcon(iconName, size = 24) {
- const svg = icons[iconName];
- if (!svg) return '';
- return svg.replace('viewBox="0 0 24 24"', `viewBox="0 0 24 24" width="${size}" height="${size}"`);
- }
-
- // ==================== DOM 创建函数 ====================
-
- function createStatCard(iconName, value, label, isRealtime = false) {
- const card = document.createElement('div');
- card.className = 'xhhaocom-dataStatistics-v2-traffic-card';
- card.setAttribute('data-variant', isRealtime ? 'realtime' : 'history');
-
- const iconEl = document.createElement('span');
- iconEl.className = 'xhhaocom-dataStatistics-v2-traffic-icon';
- iconEl.innerHTML = createIcon(iconName, 24);
-
- const valueEl = document.createElement('div');
- valueEl.className = 'xhhaocom-dataStatistics-v2-traffic-value';
- valueEl.textContent = formatNumber(value);
-
- const labelEl = document.createElement('div');
- labelEl.className = 'xhhaocom-dataStatistics-v2-traffic-label';
- labelEl.textContent = label;
-
- card.appendChild(iconEl);
- card.appendChild(valueEl);
- card.appendChild(labelEl);
-
- if (isRealtime) {
- const realtimeEl = document.createElement('div');
- realtimeEl.className = 'xhhaocom-dataStatistics-v2-traffic-realtime';
- realtimeEl.dataset.tooltip = '实时数据';
- card.appendChild(realtimeEl);
- }
-
- return card;
- }
-
- function createActivityMetric(iconName, value, label) {
- const metric = document.createElement('div');
- metric.className = 'xhhaocom-dataStatistics-v2-activity-metric';
-
- const iconEl = document.createElement('span');
- iconEl.className = 'xhhaocom-dataStatistics-v2-activity-metric-icon';
- iconEl.innerHTML = createIcon(iconName, 18);
-
- const contentEl = document.createElement('div');
- contentEl.className = 'xhhaocom-dataStatistics-v2-activity-metric-content';
-
- const valueEl = document.createElement('div');
- valueEl.className = 'xhhaocom-dataStatistics-v2-activity-metric-value';
- valueEl.textContent = formatNumber(value);
-
- const labelEl = document.createElement('div');
- labelEl.className = 'xhhaocom-dataStatistics-v2-activity-metric-label';
- labelEl.textContent = label;
-
- contentEl.appendChild(valueEl);
- contentEl.appendChild(labelEl);
-
- metric.appendChild(iconEl);
- metric.appendChild(contentEl);
-
- return metric;
- }
-
- // ==================== 格式化函数 ====================
-
- function formatTimeChinese(date) {
- const hours = date.getHours();
- const minutes = date.getMinutes();
- const seconds = date.getSeconds();
- const period = hours >= 12 ? '下午' : '上午';
- const displayHours = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours);
- return `${period} ${String(displayHours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
- }
-
- function formatDeviceInfo(event) {
- const osMap = {
- 'Mac OS': 'macOS',
- 'Windows': 'Windows',
- 'Android': 'Android',
- 'iOS': 'iOS',
- 'Linux': 'Linux'
- };
- const deviceMap = {
- 'desktop': '桌面电脑',
- 'mobile': '手机',
- 'tablet': '平板电脑',
- 'laptop': '笔记本'
- };
-
- let browser = event.browser || '';
- if (browser && browser.toLowerCase().includes('webview')) {
- if (!browser.includes('(') || !browser.includes(')')) {
- browser = browser.replace(/\s*webview\s*/gi, ' (webview)');
- }
- }
-
- const country = getCountryName(event.country);
- const os = event.os ? (osMap[event.os] || event.os) : '';
- const device = event.device ? (deviceMap[event.device] || event.device) : '';
-
- let description = country ? `来自 ${country} 的访客` : '一位访客';
-
- if (os && device) {
- description += `在搭载 ${os} 的 ${device} 上`;
- } else if (os) {
- description += `在搭载 ${os} 的设备上`;
- } else if (device) {
- description += `在 ${device} 上`;
- }
-
- description += browser ? `使用 ${browser} 浏览器进行访问。` : '进行访问。';
-
- return description;
- }
-
- // ==================== 访问统计组件 ====================
-
- function initTrafficStats(element, embedMode) {
- element.className = 'xhhaocom-dataStatistics-v2-traffic';
- showLoading(element);
-
- const type = element.getAttribute('data-type') || 'weekly';
- const visitUrl = `/apis/api.data.statistics.xhhao.com/v1alpha1/umami/visits?type=${type}`;
- const realtimeUrl = '/apis/api.data.statistics.xhhao.com/v1alpha1/umami/realtime';
-
- Promise.all([
- safeFetch(visitUrl),
- safeFetch(realtimeUrl)
- ])
- .then(([visitData, realtimeData]) => {
- if (!visitData && !realtimeData) {
- element.innerHTML = '暂无数据
';
- return;
- }
-
- element.innerHTML = '';
-
- const section = document.createElement('div');
- section.className = 'xhhaocom-dataStatistics-v2-traffic-section';
-
- const header = document.createElement('div');
- header.className = 'xhhaocom-dataStatistics-v2-traffic-header';
- header.innerHTML = `
-
- 访问统计
- ${TRAFFIC_TYPE_LABELS[type] || '访问概览'}
-
- 历史与实时数据一目了然
- `;
- section.appendChild(header);
-
- const grid = document.createElement('div');
- grid.className = 'xhhaocom-dataStatistics-v2-traffic-grid';
- section.appendChild(grid);
-
- if (visitData) {
- const pageviews = extractValue(visitData.pageviews);
- const visits = extractValue(visitData.visits);
- const visitors = extractValue(visitData.visitors);
-
- grid.appendChild(createStatCard('chart-line', pageviews, '页面浏览量'));
- grid.appendChild(createStatCard('account-group', visits, '访问次数'));
- grid.appendChild(createStatCard('account', visitors, '访客数'));
- }
-
- if (realtimeData?.totals) {
- const realtimeViews = parseInt(realtimeData.totals.views) || 0;
- const realtimeVisitors = parseInt(realtimeData.totals.visitors) || 0;
-
- if (realtimeViews > 0 || realtimeVisitors > 0) {
- grid.appendChild(createStatCard('fire', realtimeViews, '实时浏览量', true));
- grid.appendChild(createStatCard('lightning-bolt', realtimeVisitors, '实时访客', true));
- }
- }
-
- element.appendChild(section);
+ const modulesPath = '/plugins/plugin-data-statistics/assets/static/js/modules/dataStatistics';
+
+ function loadScript(src) {
+ return new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ script.src = src;
+ script.onload = resolve;
+ script.onerror = reject;
+ document.head.appendChild(script);
+ });
+ }
+
+ const moduleFiles = [
+ 'utils.js',
+ 'i18n.js',
+ 'constants.js',
+ 'formatters.js',
+ 'dom.js',
+ 'traffic.js',
+ 'activity.js',
+ 'uptime.js',
+ 'github.js'
+ ];
- if (element.children.length === 0) {
- element.innerHTML = '暂无数据
';
- }
+ Promise.all(moduleFiles.map(file => loadScript(modulesPath + file)))
+ .then(() => {
+ const indexScript = document.createElement('script');
+ indexScript.src = '/plugins/plugin-data-statistics/assets/static/js/index.js';
+ document.head.appendChild(indexScript);
})
.catch(err => {
- console.error('[Traffic Stats]', err);
- element.innerHTML = '加载失败
';
+ console.error('[Data Statistics] Failed to load modules:', err);
});
-
- const updateRealtime = () => {
- safeFetch(realtimeUrl)
- .then(realtimeData => {
- if (realtimeData?.totals) {
- const realtimeCards = element.querySelectorAll('.xhhaocom-dataStatistics-v2-traffic-card');
- const realtimeViews = parseInt(realtimeData.totals.views) || 0;
- const realtimeVisitors = parseInt(realtimeData.totals.visitors) || 0;
-
- realtimeCards.forEach(card => {
- const label = card.querySelector('.xhhaocom-dataStatistics-v2-traffic-label')?.textContent;
- const valueEl = card.querySelector('.xhhaocom-dataStatistics-v2-traffic-value');
- if (!valueEl) return;
-
- if (label === '实时浏览量') {
- valueEl.textContent = formatNumber(realtimeViews);
- } else if (label === '实时访客') {
- valueEl.textContent = formatNumber(realtimeVisitors);
- }
- });
- }
- })
- .catch(err => console.error('[Realtime Update]', err));
- };
-
- setTimeout(updateRealtime, 1000);
- const interval = setInterval(updateRealtime, 30000);
- element.setAttribute('data-cleanup', interval);
- }
-
- // ==================== 实时活动组件 ====================
-
- function initRealtimeActivity(element, embedMode) {
- element.className = 'xhhaocom-dataStatistics-v2-activity';
- showLoading(element);
-
- const realtimeUrl = '/apis/api.data.statistics.xhhao.com/v1alpha1/umami/realtime';
-
- const updateActivity = () => {
- safeFetch(realtimeUrl)
- .then(data => {
- if (!data?.events || !Array.isArray(data.events) || data.events.length === 0) {
- element.innerHTML = '暂无活动
';
- return;
- }
-
- element.innerHTML = '';
-
- const section = document.createElement('div');
- section.className = 'xhhaocom-dataStatistics-v2-activity-section';
-
- const header = document.createElement('div');
- header.className = 'xhhaocom-dataStatistics-v2-activity-header';
- header.innerHTML = `
-
- 近30分钟网站活动
-
-
- 实时数据
-
-
-
- 捕捉最新访客动态与来源
-
- `;
- section.appendChild(header);
-
- const totals = data.totals || {};
- const listContainer = document.createElement('div');
- listContainer.className = 'xhhaocom-dataStatistics-v2-activity-body';
-
- const metricsBar = document.createElement('div');
- metricsBar.className = 'xhhaocom-dataStatistics-v2-activity-metrics';
- const uniqueVisitors = parseInt(totals.visitors) || 0;
- const totalViews = parseInt(totals.views) || 0;
- const activePages = new Set();
- data.events.forEach(event => {
- if (event.urlPath) {
- activePages.add(event.urlPath);
- }
- });
-
- metricsBar.appendChild(createActivityMetric('fire', totalViews, '实时浏览量'));
- metricsBar.appendChild(createActivityMetric('account', uniqueVisitors, '实时访客'));
- metricsBar.appendChild(createActivityMetric('eye', activePages.size, '活跃页面数'));
- listContainer.appendChild(metricsBar);
-
- const events = data.events.slice(0, MAX_ACTIVITY_EVENTS);
- const list = document.createElement('div');
- list.className = 'xhhaocom-dataStatistics-v2-activity-list';
-
- events.forEach(event => {
- const item = document.createElement('div');
- item.className = 'xhhaocom-dataStatistics-v2-activity-item';
- const time = new Date(event.createdAt);
- const timeStr = formatTimeChinese(time);
- const urlPath = event.urlPath || '/';
-
- item.innerHTML = `
-
-
- ${timeStr}
-
- ${createIcon('eye', 14)}
- ${urlPath}
-
-
-
-
- ${createIcon('account', 14)}
-
- ${formatDeviceInfo(event)}
-
-
- `;
-
- list.appendChild(item);
- });
- listContainer.appendChild(list);
- section.appendChild(listContainer);
-
- element.appendChild(section);
- })
- .catch(err => {
- console.error('[Activity]', err);
- element.innerHTML = '加载失败
';
- });
- };
-
- updateActivity();
- const interval = setInterval(updateActivity, 30000);
- element.setAttribute('data-cleanup', interval);
- }
-
- // ==================== Uptime Kuma 组件 ====================
-
- function initUptimeKumaStatus(element) {
- element.className = 'xhhaocom-dataStatistics-v2-uptime-kuma';
- showLoading(element);
-
- const statusUrl = '/apis/api.data.statistics.xhhao.com/v1alpha1/uptime/status';
-
- const updateStatus = () => {
- safeFetch(statusUrl)
- .then(result => {
- element.innerHTML = '';
-
- const status = result?.status;
- const statusPageUrl = result?.statusPageUrl || '';
- const hasLink = Boolean(statusPageUrl);
-
- const wrapper = document.createElement(hasLink ? 'a' : 'div');
- wrapper.className = 'xhhaocom-dataStatistics-v2-uptime-kuma__content';
- wrapper.title = '查看我的项目状态';
- wrapper.dataset.tipTitle = '查看我的项目状态';
-
- if (hasLink) {
- wrapper.href = statusPageUrl;
- wrapper.target = '_blank';
- wrapper.rel = 'noopener noreferrer';
- } else {
- wrapper.classList.add('is-static');
- }
-
- const statusDot = document.createElement('span');
- statusDot.className = 'xhhaocom-dataStatistics-v2-uptime-kuma-dot';
- statusDot.title = '查看我的项目状态';
- statusDot.dataset.tipTitle = '查看我的项目状态';
-
- const statusText = document.createElement('span');
- statusText.className = 'xhhaocom-dataStatistics-v2-uptime-kuma-text';
-
- const statusConfig = {
- 0: { class: 'error', text: '全部业务异常', wrapperClass: 'error' },
- 1: { class: 'success', text: '所有业务正常', wrapperClass: 'success' },
- 2: { class: 'warning', text: '部分业务异常', wrapperClass: 'warning' }
- };
-
- const config = statusConfig[status] || { class: 'loading', text: '加载中', wrapperClass: 'muted' };
-
- statusDot.classList.add(`xhhaocom-dataStatistics-v2-uptime-kuma-dot--${config.class}`);
- statusText.textContent = config.text;
- wrapper.classList.add(`xhhaocom-dataStatistics-v2-uptime-kuma__content--${config.wrapperClass}`);
-
- wrapper.appendChild(statusDot);
- wrapper.appendChild(statusText);
- element.appendChild(wrapper);
- })
- .catch(err => {
- console.error('[Uptime Kuma Status]', err);
- element.innerHTML = '加载失败
';
- });
- };
-
- updateStatus();
- const interval = setInterval(updateStatus, 60000);
- element.setAttribute('data-cleanup', interval);
- }
-
- // ==================== GitHub 相关 ====================
-
- let githubConfigCache = null;
-
- function getGithubConfig() {
- if (githubConfigCache) {
- return Promise.resolve(githubConfigCache);
- }
- return safeFetch('/apis/api.data.statistics.xhhao.com/v1alpha1/github/config')
- .then(config => {
- githubConfigCache = config;
- return config;
- });
- }
-
- function createGithubImage(element, imageUrl, altText, errorClass) {
- const img = document.createElement('img');
- img.src = imageUrl;
- img.alt = altText;
- img.style.maxWidth = '100%';
- img.onerror = () => {
- element.innerHTML = `加载失败
`;
- };
- element.innerHTML = '';
- element.appendChild(img);
- }
-
- function initGithubPin(element) {
- element.className = 'xhhaocom-dataStatistics-v2-github-pin';
- showLoading(element);
-
- const repo = element.getAttribute('data-repo') || '';
-
- getGithubConfig()
- .then(config => {
- if (!config.username) {
- throw new Error('GitHub 用户名未配置');
- }
-
- const params = new URLSearchParams();
- params.append('username', config.username);
- if (repo) {
- params.append('repo', repo);
- }
-
- const imageUrl = config.proxyUrl + 'api/pin/?' + params.toString();
- createGithubImage(element, imageUrl, 'GitHub Repository Stats', 'xhhaocom-dataStatistics-v2-github-error');
- })
- .catch(err => {
- console.error('[GitHub Pin]', err);
- element.innerHTML = '加载失败
';
- });
- }
-
- function initGithubStats(element) {
- element.className = 'xhhaocom-dataStatistics-v2-github-stats';
- showLoading(element);
-
- const locale = element.getAttribute('data-locale') || '';
- const showIcons = element.getAttribute('data-show-icons') || '';
- const theme = element.getAttribute('data-theme') || '';
-
- getGithubConfig()
- .then(config => {
- if (!config.username) {
- throw new Error('GitHub 用户名未配置');
- }
-
- const params = new URLSearchParams();
- params.append('username', config.username);
- if (locale) params.append('locale', locale);
- if (showIcons) params.append('show_icons', showIcons);
- if (theme) params.append('theme', theme);
-
- const imageUrl = config.proxyUrl + 'api?' + params.toString();
- createGithubImage(element, imageUrl, 'GitHub Stats', 'xhhaocom-dataStatistics-v2-github-error');
- })
- .catch(err => {
- console.error('[GitHub Stats]', err);
- element.innerHTML = '加载失败
';
- });
- }
-
- function initGithubTopLangs(element) {
- element.className = 'xhhaocom-dataStatistics-v2-github-top-langs';
- showLoading(element);
-
- const layout = element.getAttribute('data-layout') || '';
- const hideProgress = element.getAttribute('data-hide-progress') || '';
- const statsFormat = element.getAttribute('data-stats-format') || '';
-
- getGithubConfig()
- .then(config => {
- if (!config.username) {
- throw new Error('GitHub 用户名未配置');
- }
-
- const params = new URLSearchParams();
- params.append('username', config.username);
- if (layout) params.append('layout', layout);
- if (hideProgress) params.append('hide_progress', hideProgress);
- if (statsFormat) params.append('stats_format', statsFormat);
-
- const imageUrl = config.proxyUrl + 'api/top-langs/?' + params.toString();
- createGithubImage(element, imageUrl, 'GitHub Top Languages', 'xhhaocom-dataStatistics-v2-github-error');
- })
- .catch(err => {
- console.error('[GitHub Top Langs]', err);
- element.innerHTML = '加载失败
';
- });
- }
-
- function initGithubGraph(element) {
- element.className = 'xhhaocom-dataStatistics-v2-github-graph';
- showLoading(element);
-
- const theme = element.getAttribute('data-theme') || 'minimal';
-
- getGithubConfig()
- .then(config => {
- if (!config.username) {
- throw new Error('GitHub 用户名未配置');
- }
-
- const params = new URLSearchParams();
- params.append('username', config.username);
- if (theme) {
- params.append('theme', theme);
- }
-
- const imageUrl = config.graphProxyUrl + 'graph?' + params.toString();
- createGithubImage(element, imageUrl, 'GitHub Activity Graph', 'xhhaocom-dataStatistics-v2-github-error');
- })
- .catch(err => {
- console.error('[GitHub Graph]', err);
- element.innerHTML = '加载失败
';
- });
- }
-
- // ==================== GitHub 统一容器处理 ====================
-
- const GITHUB_INIT_MAP = {
- 'stats': initGithubStats,
- 'pin': initGithubPin,
- 'top-langs': initGithubTopLangs,
- 'graph': initGithubGraph
- };
-
- function initGithubStatisticsContainer(container) {
- if (container.hasAttribute('data-initialized')) {
- return;
- }
- container.setAttribute('data-initialized', 'true');
-
- let types = (container.getAttribute('data-types') || 'graph').split(',').filter(Boolean);
-
- const typeOrder = ['graph', 'stats', 'pin', 'top-langs'];
- types = types.sort((a, b) => {
- const indexA = typeOrder.indexOf(a);
- const indexB = typeOrder.indexOf(b);
-
- if (indexA === -1) return 1;
- if (indexB === -1) return -1;
- return indexA - indexB;
- });
-
- container.innerHTML = '';
-
- types.forEach((type, index) => {
- if (index > 0) {
- const br = document.createElement('br');
- container.appendChild(br);
- }
- const element = document.createElement('div');
- const initFn = GITHUB_INIT_MAP[type];
-
- if (!initFn) {
- console.warn(`[GitHub Statistics] Unknown type: ${type}`);
- return;
- }
-
- // 设置 data 属性
- if (type === 'stats') {
- const locale = container.getAttribute('data-stats-locale');
- const showIcons = container.getAttribute('data-stats-show-icons');
- const theme = container.getAttribute('data-stats-theme');
- if (locale) element.setAttribute('data-locale', locale);
- if (showIcons) element.setAttribute('data-show-icons', showIcons);
- if (theme) element.setAttribute('data-theme', theme);
- } else if (type === 'pin') {
- const repo = container.getAttribute('data-pin-repo');
- if (repo) element.setAttribute('data-repo', repo);
- } else if (type === 'top-langs') {
- const layout = container.getAttribute('data-top-langs-layout');
- const hideProgress = container.getAttribute('data-top-langs-hide-progress');
- const statsFormat = container.getAttribute('data-top-langs-stats-format');
- if (layout) element.setAttribute('data-layout', layout);
- if (hideProgress) element.setAttribute('data-hide-progress', hideProgress);
- if (statsFormat) element.setAttribute('data-stats-format', statsFormat);
- } else if (type === 'graph') {
- const theme = container.getAttribute('data-graph-theme') || 'minimal';
- element.setAttribute('data-theme', theme);
- }
-
- // 确保每个组件都是块级元素,自动换行
- element.style.display = 'block';
- element.style.width = '100%';
-
- container.appendChild(element);
- initFn(element);
- });
- }
-
- // ==================== 组件初始化映射 ====================
-
- const COMPONENT_INIT_MAP = {
- 'traffic': initTrafficStats,
- 'activity': initRealtimeActivity,
- 'uptime-kuma': initUptimeKumaStatus,
- 'github-pin': initGithubPin,
- 'github-stats': initGithubStats,
- 'github-top-langs': initGithubTopLangs,
- 'github-graph': initGithubGraph
- };
-
- const COMPONENT_SELECTORS = [
- '.xhhaocom-dataStatistics-v2-traffic',
- '.xhhaocom-dataStatistics-v2-activity',
- '.xhhaocom-dataStatistics-v2-uptime-kuma',
- '.xhhaocom-dataStatistics-v2-github-pin',
- '.xhhaocom-dataStatistics-v2-github-stats',
- '.xhhaocom-dataStatistics-v2-github-top-langs',
- '.xhhaocom-dataStatistics-v2-github-graph'
- ];
-
- function detectComponentType(className) {
- if (className.includes('traffic')) return 'traffic';
- if (className.includes('activity')) return 'activity';
- if (className.includes('uptime-kuma')) return 'uptime-kuma';
- if (className.includes('github-pin')) return 'github-pin';
- if (className.includes('github-stats')) return 'github-stats';
- if (className.includes('github-top-langs')) return 'github-top-langs';
- if (className.includes('github-graph')) return 'github-graph';
- return null;
- }
-
- function initComponent(element, componentType) {
- const initFn = COMPONENT_INIT_MAP[componentType];
- if (!initFn) return;
-
- if (componentType === 'traffic' || componentType === 'activity') {
- initFn(element, detectEmbedMode(element));
- } else {
- initFn(element);
- }
- }
-
- // ==================== 主初始化函数 ====================
-
- function init() {
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', init);
- return;
- }
-
- // 处理 GitHub 统一容器
- document.querySelectorAll('.github-statistics-container').forEach(container => {
- initGithubStatisticsContainer(container);
- });
-
- // 处理单个组件
- COMPONENT_SELECTORS.forEach(selector => {
- document.querySelectorAll(selector).forEach(element => {
- if (element.hasAttribute('data-initialized')) {
- return;
- }
-
- const componentType = detectComponentType(element.className);
- if (componentType) {
- element.setAttribute('data-initialized', 'true');
- initComponent(element, componentType);
- }
- });
- });
- }
-
- // ==================== 导出和启动 ====================
-
- window.xhhaocomDataStatisticsV2Init = init;
- init();
-
- if (typeof MutationObserver !== 'undefined') {
- const observer = new MutationObserver(() => {
- init();
- });
- observer.observe(document.body, {
- childList: true,
- subtree: true
- });
- }
})();
diff --git a/src/main/resources/static/js/index.js b/src/main/resources/static/js/index.js
new file mode 100644
index 0000000..085c257
--- /dev/null
+++ b/src/main/resources/static/js/index.js
@@ -0,0 +1,98 @@
+(function() {
+ 'use strict';
+
+ const { detectEmbedMode } = window.xhhaocomDataStatisticsV2Utils || {};
+ const { initTrafficStats } = window.xhhaocomDataStatisticsV2Traffic || {};
+ const { initRealtimeActivity } = window.xhhaocomDataStatisticsV2Activity || {};
+ const { initUptimeKumaStatus } = window.xhhaocomDataStatisticsV2Uptime || {};
+ const {
+ initGithubPin,
+ initGithubStats,
+ initGithubTopLangs,
+ initGithubGraph,
+ initGithubStatisticsContainer
+ } = window.xhhaocomDataStatisticsV2Github || {};
+
+ const COMPONENT_INIT_MAP = {
+ 'traffic': initTrafficStats,
+ 'activity': initRealtimeActivity,
+ 'uptime-kuma': initUptimeKumaStatus,
+ 'github-pin': initGithubPin,
+ 'github-stats': initGithubStats,
+ 'github-top-langs': initGithubTopLangs,
+ 'github-graph': initGithubGraph
+ };
+
+ const COMPONENT_SELECTORS = [
+ '.xhhaocom-dataStatistics-v2-traffic',
+ '.xhhaocom-dataStatistics-v2-activity',
+ '.xhhaocom-dataStatistics-v2-uptime-kuma',
+ '.xhhaocom-dataStatistics-v2-github-pin',
+ '.xhhaocom-dataStatistics-v2-github-stats',
+ '.xhhaocom-dataStatistics-v2-github-top-langs',
+ '.xhhaocom-dataStatistics-v2-github-graph'
+ ];
+
+ function detectComponentType(className) {
+ if (className.includes('traffic')) return 'traffic';
+ if (className.includes('activity')) return 'activity';
+ if (className.includes('uptime-kuma')) return 'uptime-kuma';
+ if (className.includes('github-pin')) return 'github-pin';
+ if (className.includes('github-stats')) return 'github-stats';
+ if (className.includes('github-top-langs')) return 'github-top-langs';
+ if (className.includes('github-graph')) return 'github-graph';
+ return null;
+ }
+
+ function initComponent(element, componentType) {
+ const initFn = COMPONENT_INIT_MAP[componentType];
+ if (!initFn) return;
+
+ if (componentType === 'traffic' || componentType === 'activity') {
+ const embedMode = detectEmbedMode ? detectEmbedMode(element) : { isEmbed: false, isArticle: false, isSidebar: false };
+ initFn(element, embedMode);
+ } else {
+ initFn(element);
+ }
+ }
+
+ function init() {
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ return;
+ }
+
+ if (initGithubStatisticsContainer) {
+ document.querySelectorAll('.github-statistics-container').forEach(container => {
+ initGithubStatisticsContainer(container);
+ });
+ }
+
+ COMPONENT_SELECTORS.forEach(selector => {
+ document.querySelectorAll(selector).forEach(element => {
+ if (element.hasAttribute('data-initialized')) {
+ return;
+ }
+
+ const componentType = detectComponentType(element.className);
+ if (componentType) {
+ element.setAttribute('data-initialized', 'true');
+ initComponent(element, componentType);
+ }
+ });
+ });
+ }
+
+ window.xhhaocomDataStatisticsV2Init = init;
+ init();
+
+ if (typeof MutationObserver !== 'undefined') {
+ const observer = new MutationObserver(() => {
+ init();
+ });
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true
+ });
+ }
+})();
diff --git a/src/main/resources/static/js/modules/dataStatistics/activity.js b/src/main/resources/static/js/modules/dataStatistics/activity.js
new file mode 100644
index 0000000..1a51a81
--- /dev/null
+++ b/src/main/resources/static/js/modules/dataStatistics/activity.js
@@ -0,0 +1,119 @@
+(function() {
+ 'use strict';
+
+ const { showLoading, safeFetch } = window.xhhaocomDataStatisticsV2Utils || {};
+ const { MAX_ACTIVITY_EVENTS } = window.xhhaocomDataStatisticsV2Constants || {};
+ const { createActivityMetric } = window.xhhaocomDataStatisticsV2DOM || {};
+ const { formatTimeChinese, formatDeviceInfo } = window.xhhaocomDataStatisticsV2Formatters || {};
+ const { createIcon } = window.xhhaocomDataStatisticsV2Constants || {};
+
+ function initRealtimeActivity(element, embedMode) {
+ element.className = 'xhhaocom-dataStatistics-v2-activity';
+ if (showLoading) showLoading(element);
+
+ const realtimeUrl = '/apis/api.data.statistics.xhhao.com/v1alpha1/umami/realtime';
+
+ const updateActivity = () => {
+ if (!safeFetch) return;
+ safeFetch(realtimeUrl)
+ .then(data => {
+ if (!data?.events || !Array.isArray(data.events) || data.events.length === 0) {
+ element.innerHTML = '暂无活动
';
+ return;
+ }
+
+ element.innerHTML = '';
+
+ const section = document.createElement('div');
+ section.className = 'xhhaocom-dataStatistics-v2-activity-section';
+
+ const header = document.createElement('div');
+ header.className = 'xhhaocom-dataStatistics-v2-activity-header';
+ header.innerHTML = `
+
+ 近30分钟网站活动
+
+
+ 实时数据
+
+
+
+ 捕捉最新访客动态与来源
+
+ `;
+ section.appendChild(header);
+
+ const totals = data.totals || {};
+ const listContainer = document.createElement('div');
+ listContainer.className = 'xhhaocom-dataStatistics-v2-activity-body';
+
+ const metricsBar = document.createElement('div');
+ metricsBar.className = 'xhhaocom-dataStatistics-v2-activity-metrics';
+ const uniqueVisitors = parseInt(totals.visitors) || 0;
+ const totalViews = parseInt(totals.views) || 0;
+ const activePages = new Set();
+ data.events.forEach(event => {
+ if (event.urlPath) {
+ activePages.add(event.urlPath);
+ }
+ });
+
+ if (createActivityMetric) {
+ metricsBar.appendChild(createActivityMetric('fire', totalViews, '实时浏览量'));
+ metricsBar.appendChild(createActivityMetric('account', uniqueVisitors, '实时访客'));
+ metricsBar.appendChild(createActivityMetric('eye', activePages.size, '活跃页面数'));
+ }
+ listContainer.appendChild(metricsBar);
+
+ const maxEvents = MAX_ACTIVITY_EVENTS || 30;
+ const events = data.events.slice(0, maxEvents);
+ const list = document.createElement('div');
+ list.className = 'xhhaocom-dataStatistics-v2-activity-list';
+
+ events.forEach(event => {
+ const item = document.createElement('div');
+ item.className = 'xhhaocom-dataStatistics-v2-activity-item';
+ const time = new Date(event.createdAt);
+ const timeStr = formatTimeChinese ? formatTimeChinese(time) : time.toLocaleString();
+ const urlPath = event.urlPath || '/';
+
+ item.innerHTML = `
+
+
+ ${timeStr}
+
+ ${createIcon ? createIcon('eye', 14) : ''}
+ ${urlPath}
+
+
+
+
+ ${createIcon ? createIcon('account', 14) : ''}
+
+ ${formatDeviceInfo ? formatDeviceInfo(event) : ''}
+
+
+ `;
+
+ list.appendChild(item);
+ });
+ listContainer.appendChild(list);
+ section.appendChild(listContainer);
+
+ element.appendChild(section);
+ })
+ .catch(err => {
+ console.error('[Activity]', err);
+ element.innerHTML = '加载失败
';
+ });
+ };
+
+ updateActivity();
+ const interval = setInterval(updateActivity, 30000);
+ element.setAttribute('data-cleanup', interval);
+ }
+
+ window.xhhaocomDataStatisticsV2Activity = {
+ initRealtimeActivity
+ };
+})();
diff --git a/src/main/resources/static/js/modules/dataStatistics/constants.js b/src/main/resources/static/js/modules/dataStatistics/constants.js
new file mode 100644
index 0000000..9771a42
--- /dev/null
+++ b/src/main/resources/static/js/modules/dataStatistics/constants.js
@@ -0,0 +1,34 @@
+(function() {
+ 'use strict';
+
+ const icons = {
+ 'chart-line': '',
+ 'account-group': '',
+ 'account': '',
+ 'fire': '',
+ 'lightning-bolt': '',
+ 'eye': ''
+ };
+
+ const MAX_ACTIVITY_EVENTS = 30;
+ const TRAFFIC_TYPE_LABELS = {
+ daily: '今日概览',
+ weekly: '近7天趋势',
+ monthly: '近30天趋势',
+ quarterly: '近90天趋势',
+ yearly: '近一年趋势'
+ };
+
+ function createIcon(iconName, size = 24) {
+ const svg = icons[iconName];
+ if (!svg) return '';
+ return svg.replace('viewBox="0 0 24 24"', `viewBox="0 0 24 24" width="${size}" height="${size}"`);
+ }
+
+ window.xhhaocomDataStatisticsV2Constants = {
+ icons,
+ MAX_ACTIVITY_EVENTS,
+ TRAFFIC_TYPE_LABELS,
+ createIcon
+ };
+})();
diff --git a/src/main/resources/static/js/modules/dataStatistics/dom.js b/src/main/resources/static/js/modules/dataStatistics/dom.js
new file mode 100644
index 0000000..32d60c2
--- /dev/null
+++ b/src/main/resources/static/js/modules/dataStatistics/dom.js
@@ -0,0 +1,70 @@
+(function() {
+ 'use strict';
+
+ const { createIcon } = window.xhhaocomDataStatisticsV2Constants || {};
+ const { formatNumber } = window.xhhaocomDataStatisticsV2Formatters || {};
+
+ function createStatCard(iconName, value, label, isRealtime = false) {
+ const card = document.createElement('div');
+ card.className = 'xhhaocom-dataStatistics-v2-traffic-card';
+ card.setAttribute('data-variant', isRealtime ? 'realtime' : 'history');
+
+ const iconEl = document.createElement('span');
+ iconEl.className = 'xhhaocom-dataStatistics-v2-traffic-icon';
+ iconEl.innerHTML = createIcon ? createIcon(iconName, 24) : '';
+
+ const valueEl = document.createElement('div');
+ valueEl.className = 'xhhaocom-dataStatistics-v2-traffic-value';
+ valueEl.textContent = formatNumber ? formatNumber(value) : value;
+
+ const labelEl = document.createElement('div');
+ labelEl.className = 'xhhaocom-dataStatistics-v2-traffic-label';
+ labelEl.textContent = label;
+
+ card.appendChild(iconEl);
+ card.appendChild(valueEl);
+ card.appendChild(labelEl);
+
+ if (isRealtime) {
+ const realtimeEl = document.createElement('div');
+ realtimeEl.className = 'xhhaocom-dataStatistics-v2-traffic-realtime';
+ realtimeEl.dataset.tooltip = '实时数据';
+ card.appendChild(realtimeEl);
+ }
+
+ return card;
+ }
+
+ function createActivityMetric(iconName, value, label) {
+ const metric = document.createElement('div');
+ metric.className = 'xhhaocom-dataStatistics-v2-activity-metric';
+
+ const iconEl = document.createElement('span');
+ iconEl.className = 'xhhaocom-dataStatistics-v2-activity-metric-icon';
+ iconEl.innerHTML = createIcon ? createIcon(iconName, 18) : '';
+
+ const contentEl = document.createElement('div');
+ contentEl.className = 'xhhaocom-dataStatistics-v2-activity-metric-content';
+
+ const valueEl = document.createElement('div');
+ valueEl.className = 'xhhaocom-dataStatistics-v2-activity-metric-value';
+ valueEl.textContent = formatNumber ? formatNumber(value) : value;
+
+ const labelEl = document.createElement('div');
+ labelEl.className = 'xhhaocom-dataStatistics-v2-activity-metric-label';
+ labelEl.textContent = label;
+
+ contentEl.appendChild(valueEl);
+ contentEl.appendChild(labelEl);
+
+ metric.appendChild(iconEl);
+ metric.appendChild(contentEl);
+
+ return metric;
+ }
+
+ window.xhhaocomDataStatisticsV2DOM = {
+ createStatCard,
+ createActivityMetric
+ };
+})();
diff --git a/src/main/resources/static/js/modules/dataStatistics/formatters.js b/src/main/resources/static/js/modules/dataStatistics/formatters.js
new file mode 100644
index 0000000..e03e426
--- /dev/null
+++ b/src/main/resources/static/js/modules/dataStatistics/formatters.js
@@ -0,0 +1,70 @@
+(function() {
+ 'use strict';
+
+ const { getCountryName } = window.xhhaocomDataStatisticsV2I18n || {};
+
+ function formatNumber(num) {
+ if (num >= 1000000) {
+ return (num / 1000000).toFixed(1) + 'M';
+ } else if (num >= 1000) {
+ return (num / 1000).toFixed(1) + 'K';
+ }
+ return num.toString();
+ }
+
+ function formatTimeChinese(date) {
+ const hours = date.getHours();
+ const minutes = date.getMinutes();
+ const seconds = date.getSeconds();
+ const period = hours >= 12 ? '下午' : '上午';
+ const displayHours = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours);
+ return `${period} ${String(displayHours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
+ }
+
+ function formatDeviceInfo(event) {
+ const osMap = {
+ 'Mac OS': 'macOS',
+ 'Windows': 'Windows',
+ 'Android': 'Android',
+ 'iOS': 'iOS',
+ 'Linux': 'Linux'
+ };
+ const deviceMap = {
+ 'desktop': '桌面电脑',
+ 'mobile': '手机',
+ 'tablet': '平板电脑',
+ 'laptop': '笔记本'
+ };
+
+ let browser = event.browser || '';
+ if (browser && browser.toLowerCase().includes('webview')) {
+ if (!browser.includes('(') || !browser.includes(')')) {
+ browser = browser.replace(/\s*webview\s*/gi, ' (webview)');
+ }
+ }
+
+ const country = getCountryName ? getCountryName(event.country) : '';
+ const os = event.os ? (osMap[event.os] || event.os) : '';
+ const device = event.device ? (deviceMap[event.device] || event.device) : '';
+
+ let description = country ? `来自 ${country} 的访客` : '一位访客';
+
+ if (os && device) {
+ description += `在搭载 ${os} 的 ${device} 上`;
+ } else if (os) {
+ description += `在搭载 ${os} 的设备上`;
+ } else if (device) {
+ description += `在 ${device} 上`;
+ }
+
+ description += browser ? `使用 ${browser} 浏览器进行访问。` : '进行访问。';
+
+ return description;
+ }
+
+ window.xhhaocomDataStatisticsV2Formatters = {
+ formatNumber,
+ formatTimeChinese,
+ formatDeviceInfo
+ };
+})();
diff --git a/src/main/resources/static/js/modules/dataStatistics/github.js b/src/main/resources/static/js/modules/dataStatistics/github.js
new file mode 100644
index 0000000..5fa28bf
--- /dev/null
+++ b/src/main/resources/static/js/modules/dataStatistics/github.js
@@ -0,0 +1,221 @@
+(function() {
+ 'use strict';
+
+ const { showLoading, safeFetch } = window.xhhaocomDataStatisticsV2Utils || {};
+
+ let githubConfigCache = null;
+
+ function getGithubConfig() {
+ if (githubConfigCache) {
+ return Promise.resolve(githubConfigCache);
+ }
+ if (!safeFetch) return Promise.reject(new Error('safeFetch not available'));
+ return safeFetch('/apis/api.data.statistics.xhhao.com/v1alpha1/github/config')
+ .then(config => {
+ githubConfigCache = config;
+ return config;
+ });
+ }
+
+ function createGithubImage(element, imageUrl, altText, errorClass) {
+ const img = document.createElement('img');
+ img.src = imageUrl;
+ img.alt = altText;
+ img.style.maxWidth = '100%';
+ img.onerror = () => {
+ element.innerHTML = `加载失败
`;
+ };
+ element.innerHTML = '';
+ element.appendChild(img);
+ }
+
+ function initGithubPin(element) {
+ element.className = 'xhhaocom-dataStatistics-v2-github-pin';
+ if (showLoading) showLoading(element);
+
+ const repo = element.getAttribute('data-repo') || '';
+
+ getGithubConfig()
+ .then(config => {
+ if (!config.username) {
+ throw new Error('GitHub 用户名未配置');
+ }
+
+ const params = new URLSearchParams();
+ params.append('username', config.username);
+ if (repo) {
+ params.append('repo', repo);
+ }
+
+ const imageUrl = config.proxyUrl + 'api/pin/?' + params.toString();
+ createGithubImage(element, imageUrl, 'GitHub Repository Stats', 'xhhaocom-dataStatistics-v2-github-error');
+ })
+ .catch(err => {
+ console.error('[GitHub Pin]', err);
+ element.innerHTML = '加载失败
';
+ });
+ }
+
+ function initGithubStats(element) {
+ element.className = 'xhhaocom-dataStatistics-v2-github-stats';
+ if (showLoading) showLoading(element);
+
+ const locale = element.getAttribute('data-locale') || '';
+ const showIcons = element.getAttribute('data-show-icons') || '';
+ const theme = element.getAttribute('data-theme') || '';
+
+ getGithubConfig()
+ .then(config => {
+ if (!config.username) {
+ throw new Error('GitHub 用户名未配置');
+ }
+
+ const params = new URLSearchParams();
+ params.append('username', config.username);
+ if (locale) params.append('locale', locale);
+ if (showIcons) params.append('show_icons', showIcons);
+ if (theme) params.append('theme', theme);
+
+ const imageUrl = config.proxyUrl + 'api?' + params.toString();
+ createGithubImage(element, imageUrl, 'GitHub Stats', 'xhhaocom-dataStatistics-v2-github-error');
+ })
+ .catch(err => {
+ console.error('[GitHub Stats]', err);
+ element.innerHTML = '加载失败
';
+ });
+ }
+
+ function initGithubTopLangs(element) {
+ element.className = 'xhhaocom-dataStatistics-v2-github-top-langs';
+ if (showLoading) showLoading(element);
+
+ const layout = element.getAttribute('data-layout') || '';
+ const hideProgress = element.getAttribute('data-hide-progress') || '';
+ const statsFormat = element.getAttribute('data-stats-format') || '';
+
+ getGithubConfig()
+ .then(config => {
+ if (!config.username) {
+ throw new Error('GitHub 用户名未配置');
+ }
+
+ const params = new URLSearchParams();
+ params.append('username', config.username);
+ if (layout) params.append('layout', layout);
+ if (hideProgress) params.append('hide_progress', hideProgress);
+ if (statsFormat) params.append('stats_format', statsFormat);
+
+ const imageUrl = config.proxyUrl + 'api/top-langs/?' + params.toString();
+ createGithubImage(element, imageUrl, 'GitHub Top Languages', 'xhhaocom-dataStatistics-v2-github-error');
+ })
+ .catch(err => {
+ console.error('[GitHub Top Langs]', err);
+ element.innerHTML = '加载失败
';
+ });
+ }
+
+ function initGithubGraph(element) {
+ element.className = 'xhhaocom-dataStatistics-v2-github-graph';
+ if (showLoading) showLoading(element);
+
+ const theme = element.getAttribute('data-theme') || 'minimal';
+
+ getGithubConfig()
+ .then(config => {
+ if (!config.username) {
+ throw new Error('GitHub 用户名未配置');
+ }
+
+ const params = new URLSearchParams();
+ params.append('username', config.username);
+ if (theme) {
+ params.append('theme', theme);
+ }
+
+ const imageUrl = config.graphProxyUrl + 'graph?' + params.toString();
+ createGithubImage(element, imageUrl, 'GitHub Activity Graph', 'xhhaocom-dataStatistics-v2-github-error');
+ })
+ .catch(err => {
+ console.error('[GitHub Graph]', err);
+ element.innerHTML = '加载失败
';
+ });
+ }
+
+ function initGithubStatisticsContainer(container) {
+ if (container.hasAttribute('data-initialized')) {
+ return;
+ }
+ container.setAttribute('data-initialized', 'true');
+
+ let types = (container.getAttribute('data-types') || 'graph').split(',').filter(Boolean);
+
+ const typeOrder = ['graph', 'stats', 'pin', 'top-langs'];
+ types = types.sort((a, b) => {
+ const indexA = typeOrder.indexOf(a);
+ const indexB = typeOrder.indexOf(b);
+
+ if (indexA === -1) return 1;
+ if (indexB === -1) return -1;
+ return indexA - indexB;
+ });
+
+ container.innerHTML = '';
+
+ const GITHUB_INIT_MAP = {
+ 'stats': initGithubStats,
+ 'pin': initGithubPin,
+ 'top-langs': initGithubTopLangs,
+ 'graph': initGithubGraph
+ };
+
+ types.forEach((type, index) => {
+ if (index > 0) {
+ const br = document.createElement('br');
+ container.appendChild(br);
+ }
+ const element = document.createElement('div');
+ const initFn = GITHUB_INIT_MAP[type];
+
+ if (!initFn) {
+ console.warn(`[GitHub Statistics] Unknown type: ${type}`);
+ return;
+ }
+
+ if (type === 'stats') {
+ const locale = container.getAttribute('data-stats-locale');
+ const showIcons = container.getAttribute('data-stats-show-icons');
+ const theme = container.getAttribute('data-stats-theme');
+ if (locale) element.setAttribute('data-locale', locale);
+ if (showIcons) element.setAttribute('data-show-icons', showIcons);
+ if (theme) element.setAttribute('data-theme', theme);
+ } else if (type === 'pin') {
+ const repo = container.getAttribute('data-pin-repo');
+ if (repo) element.setAttribute('data-repo', repo);
+ } else if (type === 'top-langs') {
+ const layout = container.getAttribute('data-top-langs-layout');
+ const hideProgress = container.getAttribute('data-top-langs-hide-progress');
+ const statsFormat = container.getAttribute('data-top-langs-stats-format');
+ if (layout) element.setAttribute('data-layout', layout);
+ if (hideProgress) element.setAttribute('data-hide-progress', hideProgress);
+ if (statsFormat) element.setAttribute('data-stats-format', statsFormat);
+ } else if (type === 'graph') {
+ const theme = container.getAttribute('data-graph-theme') || 'minimal';
+ element.setAttribute('data-theme', theme);
+ }
+
+ element.style.display = 'block';
+ element.style.width = '100%';
+
+ container.appendChild(element);
+ initFn(element);
+ });
+ }
+
+ window.xhhaocomDataStatisticsV2Github = {
+ initGithubPin,
+ initGithubStats,
+ initGithubTopLangs,
+ initGithubGraph,
+ initGithubStatisticsContainer
+ };
+})();
diff --git a/src/main/resources/static/js/modules/dataStatistics/i18n.js b/src/main/resources/static/js/modules/dataStatistics/i18n.js
new file mode 100644
index 0000000..7d78955
--- /dev/null
+++ b/src/main/resources/static/js/modules/dataStatistics/i18n.js
@@ -0,0 +1,41 @@
+(function() {
+ 'use strict';
+
+ const regionDisplay = typeof Intl !== 'undefined' && typeof Intl.DisplayNames === 'function'
+ ? new Intl.DisplayNames(['zh-CN'], { type: 'region' })
+ : null;
+
+ const specialRegionMap = {
+ HK: '中国香港',
+ MO: '中国澳门',
+ TW: '中国台湾'
+ };
+
+ function getCountryName(code = '') {
+ const normalized = code.toUpperCase();
+ if (!normalized) return '';
+
+ let result = normalized;
+ if (regionDisplay) {
+ const localized = regionDisplay.of(normalized);
+ if (localized && localized !== normalized) {
+ result = localized;
+ }
+ }
+
+ if (specialRegionMap[normalized]) {
+ if (!result.includes('中国')) {
+ result = specialRegionMap[normalized];
+ } else {
+ const trimmed = result.replace(/^中国/, '');
+ result = `中国${trimmed}`;
+ }
+ }
+
+ return result;
+ }
+
+ window.xhhaocomDataStatisticsV2I18n = {
+ getCountryName
+ };
+})();
diff --git a/src/main/resources/static/js/modules/dataStatistics/traffic.js b/src/main/resources/static/js/modules/dataStatistics/traffic.js
new file mode 100644
index 0000000..7ee1a8d
--- /dev/null
+++ b/src/main/resources/static/js/modules/dataStatistics/traffic.js
@@ -0,0 +1,116 @@
+(function() {
+ 'use strict';
+
+ const { showLoading, extractValue, safeFetch } = window.xhhaocomDataStatisticsV2Utils || {};
+ const { TRAFFIC_TYPE_LABELS } = window.xhhaocomDataStatisticsV2Constants || {};
+ const { createStatCard } = window.xhhaocomDataStatisticsV2DOM || {};
+ const { formatNumber } = window.xhhaocomDataStatisticsV2Formatters || {};
+
+ function initTrafficStats(element, embedMode) {
+ element.className = 'xhhaocom-dataStatistics-v2-traffic';
+ if (showLoading) showLoading(element);
+
+ const type = element.getAttribute('data-type') || 'weekly';
+ const visitUrl = `/apis/api.data.statistics.xhhao.com/v1alpha1/umami/visits?type=${type}`;
+ const realtimeUrl = '/apis/api.data.statistics.xhhao.com/v1alpha1/umami/realtime';
+
+ Promise.all([
+ safeFetch ? safeFetch(visitUrl) : Promise.resolve(null),
+ safeFetch ? safeFetch(realtimeUrl) : Promise.resolve(null)
+ ])
+ .then(([visitData, realtimeData]) => {
+ if (!visitData && !realtimeData) {
+ element.innerHTML = '暂无数据
';
+ return;
+ }
+
+ element.innerHTML = '';
+
+ const section = document.createElement('div');
+ section.className = 'xhhaocom-dataStatistics-v2-traffic-section';
+
+ const header = document.createElement('div');
+ header.className = 'xhhaocom-dataStatistics-v2-traffic-header';
+ const typeLabel = TRAFFIC_TYPE_LABELS ? (TRAFFIC_TYPE_LABELS[type] || '访问概览') : '访问概览';
+ header.innerHTML = `
+
+ 访问统计
+ ${typeLabel}
+
+ 历史与实时数据一目了然
+ `;
+ section.appendChild(header);
+
+ const grid = document.createElement('div');
+ grid.className = 'xhhaocom-dataStatistics-v2-traffic-grid';
+ section.appendChild(grid);
+
+ if (visitData) {
+ const pageviews = extractValue ? extractValue(visitData.pageviews) : 0;
+ const visits = extractValue ? extractValue(visitData.visits) : 0;
+ const visitors = extractValue ? extractValue(visitData.visitors) : 0;
+
+ if (createStatCard) {
+ grid.appendChild(createStatCard('chart-line', pageviews, '页面浏览量'));
+ grid.appendChild(createStatCard('account-group', visits, '访问次数'));
+ grid.appendChild(createStatCard('account', visitors, '访客数'));
+ }
+ }
+
+ if (realtimeData?.totals) {
+ const realtimeViews = parseInt(realtimeData.totals.views) || 0;
+ const realtimeVisitors = parseInt(realtimeData.totals.visitors) || 0;
+
+ if (realtimeViews > 0 || realtimeVisitors > 0) {
+ if (createStatCard) {
+ grid.appendChild(createStatCard('fire', realtimeViews, '实时浏览量', true));
+ grid.appendChild(createStatCard('lightning-bolt', realtimeVisitors, '实时访客', true));
+ }
+ }
+ }
+
+ element.appendChild(section);
+
+ if (element.children.length === 0) {
+ element.innerHTML = '暂无数据
';
+ }
+ })
+ .catch(err => {
+ console.error('[Traffic Stats]', err);
+ element.innerHTML = '加载失败
';
+ });
+
+ const updateRealtime = () => {
+ if (!safeFetch) return;
+ safeFetch(realtimeUrl)
+ .then(realtimeData => {
+ if (realtimeData?.totals) {
+ const realtimeCards = element.querySelectorAll('.xhhaocom-dataStatistics-v2-traffic-card');
+ const realtimeViews = parseInt(realtimeData.totals.views) || 0;
+ const realtimeVisitors = parseInt(realtimeData.totals.visitors) || 0;
+
+ realtimeCards.forEach(card => {
+ const label = card.querySelector('.xhhaocom-dataStatistics-v2-traffic-label')?.textContent;
+ const valueEl = card.querySelector('.xhhaocom-dataStatistics-v2-traffic-value');
+ if (!valueEl) return;
+
+ if (label === '实时浏览量') {
+ valueEl.textContent = formatNumber ? formatNumber(realtimeViews) : realtimeViews;
+ } else if (label === '实时访客') {
+ valueEl.textContent = formatNumber ? formatNumber(realtimeVisitors) : realtimeVisitors;
+ }
+ });
+ }
+ })
+ .catch(err => console.error('[Realtime Update]', err));
+ };
+
+ setTimeout(updateRealtime, 1000);
+ const interval = setInterval(updateRealtime, 30000);
+ element.setAttribute('data-cleanup', interval);
+ }
+
+ window.xhhaocomDataStatisticsV2Traffic = {
+ initTrafficStats
+ };
+})();
diff --git a/src/main/resources/static/js/modules/dataStatistics/uptime.js b/src/main/resources/static/js/modules/dataStatistics/uptime.js
new file mode 100644
index 0000000..f26e8d3
--- /dev/null
+++ b/src/main/resources/static/js/modules/dataStatistics/uptime.js
@@ -0,0 +1,73 @@
+(function() {
+ 'use strict';
+
+ const { showLoading, safeFetch } = window.xhhaocomDataStatisticsV2Utils || {};
+
+ function initUptimeKumaStatus(element) {
+ element.className = 'xhhaocom-dataStatistics-v2-uptime-kuma';
+ if (showLoading) showLoading(element);
+
+ const statusUrl = '/apis/api.data.statistics.xhhao.com/v1alpha1/uptime/status';
+
+ const updateStatus = () => {
+ if (!safeFetch) return;
+ safeFetch(statusUrl)
+ .then(result => {
+ element.innerHTML = '';
+
+ const status = result?.status;
+ const statusPageUrl = result?.statusPageUrl || '';
+ const hasLink = Boolean(statusPageUrl);
+
+ const wrapper = document.createElement(hasLink ? 'a' : 'div');
+ wrapper.className = 'xhhaocom-dataStatistics-v2-uptime-kuma__content';
+ wrapper.title = '查看我的项目状态';
+ wrapper.dataset.tipTitle = '查看我的项目状态';
+
+ if (hasLink) {
+ wrapper.href = statusPageUrl;
+ wrapper.target = '_blank';
+ wrapper.rel = 'noopener noreferrer';
+ } else {
+ wrapper.classList.add('is-static');
+ }
+
+ const statusDot = document.createElement('span');
+ statusDot.className = 'xhhaocom-dataStatistics-v2-uptime-kuma-dot';
+ statusDot.title = '查看我的项目状态';
+ statusDot.dataset.tipTitle = '查看我的项目状态';
+
+ const statusText = document.createElement('span');
+ statusText.className = 'xhhaocom-dataStatistics-v2-uptime-kuma-text';
+
+ const statusConfig = {
+ 0: { class: 'error', text: '全部业务异常', wrapperClass: 'error' },
+ 1: { class: 'success', text: '所有业务正常', wrapperClass: 'success' },
+ 2: { class: 'warning', text: '部分业务异常', wrapperClass: 'warning' }
+ };
+
+ const config = statusConfig[status] || { class: 'loading', text: '加载中', wrapperClass: 'muted' };
+
+ statusDot.classList.add(`xhhaocom-dataStatistics-v2-uptime-kuma-dot--${config.class}`);
+ statusText.textContent = config.text;
+ wrapper.classList.add(`xhhaocom-dataStatistics-v2-uptime-kuma__content--${config.wrapperClass}`);
+
+ wrapper.appendChild(statusDot);
+ wrapper.appendChild(statusText);
+ element.appendChild(wrapper);
+ })
+ .catch(err => {
+ console.error('[Uptime Kuma Status]', err);
+ element.innerHTML = '加载失败
';
+ });
+ };
+
+ updateStatus();
+ const interval = setInterval(updateStatus, 60000);
+ element.setAttribute('data-cleanup', interval);
+ }
+
+ window.xhhaocomDataStatisticsV2Uptime = {
+ initUptimeKumaStatus
+ };
+})();
diff --git a/src/main/resources/static/js/modules/dataStatistics/utils.js b/src/main/resources/static/js/modules/dataStatistics/utils.js
new file mode 100644
index 0000000..38d2d4e
--- /dev/null
+++ b/src/main/resources/static/js/modules/dataStatistics/utils.js
@@ -0,0 +1,59 @@
+(function() {
+ 'use strict';
+
+ const LOADING_CLASS_MAP = {
+ 'xhhaocom-dataStatistics-v2-traffic': 'xhhaocom-dataStatistics-v2-traffic-loading',
+ 'xhhaocom-dataStatistics-v2-activity': 'xhhaocom-dataStatistics-v2-activity-loading',
+ 'xhhaocom-dataStatistics-v2-uptime-kuma': 'xhhaocom-dataStatistics-v2-uptime-kuma-loading',
+ 'xhhaocom-dataStatistics-v2-github-pin': 'xhhaocom-dataStatistics-v2-github-loading',
+ 'xhhaocom-dataStatistics-v2-github-stats': 'xhhaocom-dataStatistics-v2-github-loading',
+ 'xhhaocom-dataStatistics-v2-github-top-langs': 'xhhaocom-dataStatistics-v2-github-loading',
+ 'xhhaocom-dataStatistics-v2-github-graph': 'xhhaocom-dataStatistics-v2-github-loading'
+ };
+
+ function detectEmbedMode(element) {
+ const isInArticle = element.closest('article') ||
+ element.closest('.post-content') ||
+ element.closest('.content') ||
+ element.closest('[class*="content"]');
+ const isInSidebar = element.closest('aside') ||
+ element.closest('.sidebar') ||
+ element.closest('[class*="sidebar"]');
+
+ return {
+ isEmbed: isInArticle || isInSidebar,
+ isArticle: isInArticle,
+ isSidebar: isInSidebar
+ };
+ }
+
+ function showLoading(element) {
+ const className = element.className;
+ const loadingClass = LOADING_CLASS_MAP[className] || 'xhhaocom-dataStatistics-v2-github-loading';
+ element.innerHTML = `加载中
`;
+ }
+
+ function extractValue(data) {
+ if (data == null) return 0;
+ if (typeof data === 'object' && 'value' in data) {
+ return parseInt(data.value) || 0;
+ }
+ return parseInt(data) || 0;
+ }
+
+ function safeFetch(url) {
+ return fetch(url).then(r => {
+ if (!r.ok) {
+ throw new Error(`HTTP ${r.status}`);
+ }
+ return r.json();
+ });
+ }
+
+ window.xhhaocomDataStatisticsV2Utils = {
+ detectEmbedMode,
+ showLoading,
+ extractValue,
+ safeFetch
+ };
+})();
diff --git a/src/main/resources/static/js/modules/siteCharts/api.js b/src/main/resources/static/js/modules/siteCharts/api.js
new file mode 100644
index 0000000..75a798a
--- /dev/null
+++ b/src/main/resources/static/js/modules/siteCharts/api.js
@@ -0,0 +1,9 @@
+import { API_ENDPOINT } from './constants.js';
+
+export async function fetchChartData() {
+ const response = await fetch(API_ENDPOINT);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ return response.json();
+}
diff --git a/src/main/resources/static/js/modules/siteCharts/chartManager.js b/src/main/resources/static/js/modules/siteCharts/chartManager.js
new file mode 100644
index 0000000..24fd7f1
--- /dev/null
+++ b/src/main/resources/static/js/modules/siteCharts/chartManager.js
@@ -0,0 +1,24 @@
+const chartRegistry = new Map();
+
+export function registerCharts(container, charts) {
+ const actualCharts = charts.filter(chart => chart && typeof chart.destroy === 'function');
+ if (actualCharts.length > 0) {
+ chartRegistry.set(container, actualCharts);
+ }
+}
+
+export function disposeCharts(container) {
+ const charts = chartRegistry.get(container);
+ if (charts) {
+ charts.forEach(instance => {
+ if (instance?.destroy) {
+ instance.destroy();
+ }
+ });
+ chartRegistry.delete(container);
+ }
+}
+
+export function getCharts(container) {
+ return chartRegistry.get(container) || [];
+}
diff --git a/src/main/resources/static/js/modules/siteCharts/chartRenderers.js b/src/main/resources/static/js/modules/siteCharts/chartRenderers.js
new file mode 100644
index 0000000..bddf2f6
--- /dev/null
+++ b/src/main/resources/static/js/modules/siteCharts/chartRenderers.js
@@ -0,0 +1,758 @@
+import { COLOR_PALETTE, DAY_IN_MS, WEEKDAY_LABELS, MONTH_NAMES } from './constants.js';
+import { safeGet, createBarColors } from './utils.js';
+import { createSection, buildCanvasCard } from './domUtils.js';
+
+export function renderTaxonomyCharts(container, tags, categories) {
+ const section = createSection(
+ container,
+ '标签与分类统计',
+ '展示全部标签和分类的文章数量占比'
+ );
+
+ const cards = [];
+
+ const tagData = (tags || [])
+ .map(tag => ({
+ name:
+ tag?.name ??
+ safeGet(tag, 'spec.displayName') ??
+ safeGet(tag, 'metadata.name') ??
+ '未命名标签',
+ count: Number(
+ tag?.count ??
+ tag?.total ??
+ safeGet(tag, 'status.visiblePostCount', 0)
+ )
+ }))
+ .filter(item => item.count > 0)
+ .sort((a, b) => b.count - a.count);
+
+ const categoryData = (categories || [])
+ .map(category => ({
+ name:
+ category?.name ??
+ safeGet(category, 'spec.displayName') ??
+ safeGet(category, 'metadata.name') ??
+ '未命名分类',
+ count: Number(
+ category?.total ??
+ category?.count ??
+ safeGet(category, 'status.visiblePostCount', 0)
+ )
+ }))
+ .filter(item => item.count > 0)
+ .sort((a, b) => b.count - a.count);
+
+ if (!tagData.length && !categoryData.length) {
+ section.innerHTML = '暂无标签或分类数据
';
+ return [];
+ }
+
+ const charts = [];
+
+ if (tagData.length) {
+ const unusedTags = (tags?.length || 0) - tagData.length;
+ const tagFootnote = unusedTags > 0
+ ? `已使用标签 ${tagData.length} 个(另有 ${unusedTags} 个未使用)`
+ : `已使用标签 ${tagData.length} 个`;
+ const canvas = buildCanvasCard(section, tagFootnote);
+ const card = canvas.closest('.xhhaocom-chartboard-card');
+ if (card) {
+ card.classList.add('xhhaocom-chartboard-card--animated');
+ }
+ const chart = new Chart(canvas, {
+ type: 'doughnut',
+ data: {
+ labels: tagData.map(item => item.name),
+ datasets: [{
+ data: tagData.map(item => item.count),
+ backgroundColor: tagData.map((_, index) => COLOR_PALETTE[index % COLOR_PALETTE.length]),
+ borderWidth: 2,
+ borderColor: '#ffffff',
+ cutout: '55%',
+ hoverOffset: 8,
+ hoverBorderWidth: 3
+ }]
+ },
+ options: {
+ maintainAspectRatio: false,
+ animation: {
+ animateRotate: true,
+ animateScale: true,
+ duration: 1200,
+ easing: 'easeOutQuart'
+ },
+ interaction: {
+ intersect: false,
+ mode: 'point'
+ },
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ enabled: true,
+ backgroundColor: 'rgba(0, 0, 0, 0.8)',
+ padding: 12,
+ cornerRadius: 8,
+ displayColors: true,
+ callbacks: {
+ label: context => `${context.label}: ${context.raw} 篇文章`
+ }
+ }
+ },
+ onHover: (event, activeElements) => {
+ canvas.style.cursor = activeElements.length > 0 ? 'pointer' : 'default';
+ }
+ }
+ });
+
+ chart.canvas.style.height = '220px';
+ chart.canvas.style.maxHeight = '220px';
+ chart.resize();
+
+ charts.push(chart);
+ if (card) {
+ cards.push(card);
+ }
+ }
+
+ if (categoryData.length) {
+ const unusedCategories = (categories?.length || 0) - categoryData.length;
+ const categoryFootnote = unusedCategories > 0
+ ? `已使用分类 ${categoryData.length} 个(另有 ${unusedCategories} 个未使用)`
+ : `已使用分类 ${categoryData.length} 个`;
+ const canvas = buildCanvasCard(section, categoryFootnote);
+ const card = canvas.closest('.xhhaocom-chartboard-card');
+ if (card) {
+ card.classList.add('xhhaocom-chartboard-card--animated');
+ }
+
+ const sortedData = [...categoryData].sort((a, b) => a.count - b.count);
+
+ const chart = new Chart(canvas, {
+ type: 'line',
+ data: {
+ labels: sortedData.map(item => item.name),
+ datasets: [{
+ label: '文章数量',
+ data: sortedData.map(item => item.count),
+ borderColor: COLOR_PALETTE[0],
+ backgroundColor: COLOR_PALETTE[0] + '20',
+ borderWidth: 3,
+ fill: true,
+ tension: 0.4,
+ pointRadius: 5,
+ pointHoverRadius: 8,
+ pointBackgroundColor: COLOR_PALETTE[0],
+ pointBorderColor: '#ffffff',
+ pointBorderWidth: 2,
+ pointHoverBackgroundColor: COLOR_PALETTE[0],
+ pointHoverBorderColor: '#ffffff',
+ pointHoverBorderWidth: 3
+ }]
+ },
+ options: {
+ maintainAspectRatio: false,
+ animation: {
+ duration: 1500,
+ easing: 'easeOutQuart'
+ },
+ interaction: {
+ intersect: false,
+ mode: 'index'
+ },
+ scales: {
+ x: {
+ beginAtZero: false,
+ grid: {
+ display: false
+ },
+ ticks: {
+ font: {
+ size: 11
+ },
+ maxRotation: 45,
+ minRotation: 0
+ }
+ },
+ y: {
+ beginAtZero: true,
+ grid: {
+ color: 'rgba(0, 0, 0, 0.05)',
+ drawBorder: false
+ },
+ ticks: {
+ font: {
+ size: 11
+ },
+ callback: value => Number(value)
+ }
+ }
+ },
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ enabled: true,
+ backgroundColor: 'rgba(0, 0, 0, 0.8)',
+ padding: 12,
+ cornerRadius: 8,
+ displayColors: true,
+ callbacks: {
+ label: context => `${context.label}: ${context.raw} 篇文章`
+ }
+ }
+ },
+ onHover: (event, activeElements) => {
+ canvas.style.cursor = activeElements.length > 0 ? 'pointer' : 'default';
+ }
+ }
+ });
+ charts.push(chart);
+ if (card) {
+ cards.push(card);
+ }
+ }
+
+ if (cards.length === 1) {
+ cards[0].style.gridColumn = 'span 2';
+ }
+
+ return charts;
+}
+
+export function renderArticleHeatmap(container, articles) {
+ const chartArea = createSection(
+ container,
+ '文章发布趋势',
+ '按日期统计文章发布数量'
+ );
+
+ const dataMap = new Map();
+ (articles || []).forEach(article => {
+ const rawDate = article.date || article.name;
+ if (!rawDate) {
+ return;
+ }
+ const date = new Date(rawDate);
+ if (Number.isNaN(date.valueOf())) {
+ return;
+ }
+ date.setHours(0, 0, 0, 0);
+ const key = date.toISOString().slice(0, 10);
+ const total = Number(article.total ?? article.count ?? 0);
+ dataMap.set(key, (dataMap.get(key) || 0) + total);
+ });
+
+ if (!dataMap.size) {
+ chartArea.innerHTML = '暂无文章数据
';
+ return [];
+ }
+
+ const now = new Date();
+ now.setHours(0, 0, 0, 0);
+ const end = new Date(now);
+ const start = new Date(end.getTime() - 364 * DAY_IN_MS);
+
+ const firstMonday = new Date(start);
+ const offsetToMonday = (firstMonday.getDay() + 6) % 7;
+ firstMonday.setDate(firstMonday.getDate() - offsetToMonday);
+
+ const totalDays = Math.floor((end - firstMonday) / DAY_IN_MS) + 1;
+ const weeksCount = Math.ceil(totalDays / 7);
+ const weeks = Array.from({ length: weeksCount }, (_, index) =>
+ new Date(firstMonday.getTime() + index * 7 * DAY_IN_MS)
+ );
+
+ const maxValue = Math.max(...dataMap.values(), 0);
+
+ const card = document.createElement('div');
+ card.className = 'xhhaocom-chartboard-card xhhaocom-chartboard-card--heatmap';
+ card.style.gridColumn = '1 / -1';
+
+ const heatmap = document.createElement('div');
+ heatmap.className = 'xhhaocom-chartboard-heatmap';
+
+ const tooltip = document.createElement('div');
+ tooltip.className = 'xhhaocom-chartboard-heatmap__tooltip';
+ tooltip.style.display = 'none';
+ card.appendChild(tooltip);
+
+ const monthsRow = document.createElement('div');
+ monthsRow.className = 'xhhaocom-chartboard-heatmap__months';
+
+ const weekdaysCol = document.createElement('div');
+ weekdaysCol.className = 'xhhaocom-chartboard-heatmap__weekdays';
+ WEEKDAY_LABELS.forEach(day => {
+ const label = document.createElement('div');
+ label.className = 'xhhaocom-chartboard-heatmap__weekday';
+ label.textContent = day;
+ weekdaysCol.appendChild(label);
+ });
+
+ const grid = document.createElement('div');
+ grid.className = 'xhhaocom-chartboard-heatmap__grid';
+ const updateCellSize = () => {
+ const cardRect = card.getBoundingClientRect();
+ if (cardRect.width === 0) {
+ requestAnimationFrame(updateCellSize);
+ return;
+ }
+
+ const isMobile = window.innerWidth <= 768;
+ if (isMobile) {
+ const cellSize = '12px';
+ monthsRow.style.gridTemplateColumns = `repeat(${weeksCount}, ${cellSize})`;
+ grid.style.gridTemplateColumns = `repeat(${weeksCount}, ${cellSize})`;
+ document.documentElement.style.setProperty('--chartboard-heatmap-cell', cellSize);
+ document.documentElement.style.setProperty('--chartboard-heatmap-cell-width', cellSize);
+ return;
+ }
+ const padding = 40;
+ const weekdayWidth = 30;
+ const weekdayGap = 10;
+ const availableWidth = cardRect.width - padding - weekdayWidth - weekdayGap;
+ const cellGap = 4;
+ const cellWidth = Math.max(8, Math.floor((availableWidth - (weeksCount - 1) * cellGap) / weeksCount));
+
+ const cellSize = `${cellWidth}px`;
+ monthsRow.style.gridTemplateColumns = `repeat(${weeksCount}, ${cellSize})`;
+ grid.style.gridTemplateColumns = `repeat(${weeksCount}, ${cellSize})`;
+ document.documentElement.style.setProperty('--chartboard-heatmap-cell', cellSize);
+ document.documentElement.style.setProperty('--chartboard-heatmap-cell-width', cellSize);
+ };
+ const resizeObserver = new ResizeObserver(() => {
+ updateCellSize();
+ });
+
+ let resizeTimeout;
+ const handleWindowResize = () => {
+ clearTimeout(resizeTimeout);
+ resizeTimeout = setTimeout(() => {
+ updateCellSize();
+ }, 150);
+ };
+
+ const initSize = () => {
+ resizeObserver.observe(card);
+ window.addEventListener('resize', handleWindowResize);
+ window.addEventListener('orientationchange', handleWindowResize);
+ updateCellSize();
+ };
+
+ const computeLevel = value => {
+ if (!value || !maxValue) {
+ return 0;
+ }
+ if (maxValue <= 1) {
+ return value > 0 ? 1 : 0;
+ }
+ const q1 = Math.max(1, Math.ceil(maxValue * 0.25));
+ const q2 = Math.max(q1 + 1, Math.ceil(maxValue * 0.5));
+ const q3 = Math.max(q2 + 1, Math.ceil(maxValue * 0.75));
+ if (value >= q3) return 4;
+ if (value >= q2) return 3;
+ if (value >= q1) return 2;
+ return 1;
+ };
+
+ const showTooltip = (event, dateKey, value) => {
+ tooltip.innerHTML = `${dateKey}${value ? `发布 ${value} 篇文章` : '无文章发布'}`;
+ tooltip.style.display = 'flex';
+
+ const cardRect = card.getBoundingClientRect();
+ const tooltipRect = tooltip.getBoundingClientRect();
+
+ let left = event.clientX - cardRect.left + 12;
+ let top = event.clientY - cardRect.top - tooltipRect.height - 10;
+
+ if (left + tooltipRect.width > cardRect.width) {
+ left = cardRect.width - tooltipRect.width - 8;
+ }
+ if (top < 0) {
+ top = event.clientY - cardRect.top + 12;
+ }
+
+ tooltip.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;
+ };
+
+ const hideTooltip = () => {
+ tooltip.style.display = 'none';
+ tooltip.style.transform = 'translate(-9999px, -9999px)';
+ };
+
+ let monthSegments = [];
+ {
+ const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
+ const lastMonth = new Date(end.getFullYear(), end.getMonth(), 1);
+
+ while (cursor <= lastMonth) {
+ const monthStart = new Date(cursor);
+ const monthEnd = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0);
+
+ const effectiveStart = monthStart < start ? new Date(start) : monthStart;
+ const effectiveEnd = monthEnd > end ? new Date(end) : monthEnd;
+
+ const startOffsetDays = Math.floor((effectiveStart - firstMonday) / DAY_IN_MS);
+ const endOffsetDays = Math.floor((effectiveEnd - firstMonday) / DAY_IN_MS);
+ const startWeek = Math.max(0, Math.min(weeksCount - 1, Math.floor(startOffsetDays / 7)));
+ const endWeekExclusive = Math.max(startWeek + 1, Math.min(weeksCount, Math.floor(endOffsetDays / 7) + 1));
+
+ monthSegments.push({
+ label: MONTH_NAMES[cursor.getMonth()],
+ start: startWeek,
+ end: endWeekExclusive
+ });
+
+ cursor.setMonth(cursor.getMonth() + 1);
+ }
+ }
+
+ if (monthSegments.length) {
+ const normalized = [];
+ let cursorIndex = 0;
+ monthSegments.forEach(segment => {
+ let start = Math.max(cursorIndex, segment.start);
+ let end = Math.max(start + 1, segment.end);
+
+ start = Math.min(start, weeksCount - 1);
+ end = Math.min(end, weeksCount);
+ if (start >= weeksCount) {
+ return;
+ }
+
+ normalized.push({
+ label: segment.label,
+ start,
+ end
+ });
+ cursorIndex = end;
+ });
+ monthSegments = normalized;
+ }
+
+ weeks.forEach((weekStart, weekIndex) => {
+ const column = document.createElement('div');
+ column.className = 'xhhaocom-chartboard-heatmap__column';
+
+ for (let dayIndex = 0; dayIndex < 7; dayIndex++) {
+ const day = new Date(weekStart.getTime() + dayIndex * DAY_IN_MS);
+ const cell = document.createElement('div');
+ cell.className = 'xhhaocom-chartboard-heatmap__day';
+
+ const dayKey = day.toISOString().slice(0, 10);
+ const isWithinRange = day >= start && day <= end;
+
+ if (!isWithinRange) {
+ cell.classList.add('is-outside');
+ } else {
+ const value = dataMap.get(dayKey) || 0;
+ const level = computeLevel(value);
+ cell.dataset.level = level.toString();
+ cell.dataset.value = value.toString();
+ cell.dataset.date = dayKey;
+
+ const handleMouseMove = event => showTooltip(event, dayKey, value);
+ cell.addEventListener('mouseenter', handleMouseMove);
+ cell.addEventListener('mousemove', handleMouseMove);
+ cell.addEventListener('mouseleave', hideTooltip);
+ }
+
+ column.appendChild(cell);
+ }
+
+ grid.appendChild(column);
+ });
+
+ card.addEventListener('mouseleave', hideTooltip);
+
+ let cursor = 0;
+ monthSegments.forEach(segment => {
+ if (segment.start > cursor) {
+ const filler = document.createElement('div');
+ filler.className = 'xhhaocom-chartboard-heatmap__month is-placeholder';
+ filler.style.gridColumn = `span ${segment.start - cursor}`;
+ monthsRow.appendChild(filler);
+ }
+
+ const span = Math.max(1, segment.end - segment.start);
+ const label = document.createElement('div');
+ label.className = 'xhhaocom-chartboard-heatmap__month';
+ label.textContent = segment.label;
+ label.style.gridColumn = `span ${span}`;
+ monthsRow.appendChild(label);
+
+ cursor = segment.end;
+ });
+
+ if (cursor < weeksCount) {
+ const filler = document.createElement('div');
+ filler.className = 'xhhaocom-chartboard-heatmap__month is-placeholder';
+ filler.style.gridColumn = `span ${weeksCount - cursor}`;
+ monthsRow.appendChild(filler);
+ }
+
+ heatmap.appendChild(weekdaysCol);
+ heatmap.appendChild(monthsRow);
+ heatmap.appendChild(grid);
+
+ const footerRow = document.createElement('div');
+ footerRow.className = 'xhhaocom-chartboard-heatmap__footer';
+
+ const dateRange = document.createElement('div');
+ dateRange.className = 'xhhaocom-chartboard-heatmap__date-range';
+ dateRange.textContent = `${start.toISOString().slice(0, 10)} 至 ${end.toISOString().slice(0, 10)}`;
+ footerRow.appendChild(dateRange);
+
+ const legend = document.createElement('div');
+ legend.className = 'xhhaocom-chartboard-heatmap__legend';
+ const legendLabelMin = document.createElement('span');
+ legendLabelMin.textContent = '较少';
+ legend.appendChild(legendLabelMin);
+ [0, 1, 2, 3, 4].forEach(level => {
+ const dot = document.createElement('span');
+ dot.className = 'xhhaocom-chartboard-heatmap__legend-dot';
+ dot.dataset.level = level.toString();
+ legend.appendChild(dot);
+ });
+ const legendLabelMax = document.createElement('span');
+ legendLabelMax.textContent = '较多';
+ legend.appendChild(legendLabelMax);
+ footerRow.appendChild(legend);
+
+ heatmap.appendChild(footerRow);
+ card.appendChild(heatmap);
+
+ chartArea.appendChild(card);
+
+ initSize();
+ return [{ type: 'heatmap' }];
+}
+
+export function renderCommentChart(container, comments, softColors, strongColors) {
+ const chartArea = createSection(
+ container,
+ '评论活跃用户',
+ '按评论作者统计评论数量'
+ );
+ if (!comments?.length) {
+ chartArea.innerHTML = '暂无评论数据
';
+ return [];
+ }
+
+ const normalized = comments
+ .map(comment => ({
+ name: comment?.username || comment?.name || comment?.email || '匿名',
+ count: Number(comment?.count ?? 0)
+ }))
+ .filter(item => item.count > 0)
+ .sort((a, b) => b.count - a.count)
+ .slice(0, 10);
+
+ if (!normalized.length) {
+ chartArea.innerHTML = '暂无评论数据
';
+ return [];
+ }
+
+ const canvas = buildCanvasCard(chartArea, `活跃评论用户 Top ${normalized.length}`);
+ const card = canvas.closest('.xhhaocom-chartboard-card');
+ if (card) {
+ card.classList.add('xhhaocom-chartboard-card--animated');
+ }
+
+ const barColors = createBarColors(normalized.length, softColors, strongColors);
+
+
+ const chart = new Chart(canvas, {
+ type: 'bar',
+ data: {
+ labels: normalized.map(item => item.name),
+ datasets: [{
+ label: '评论数量',
+ data: normalized.map(item => item.count),
+ backgroundColor: barColors.map(item => item.background),
+ borderColor: barColors.map(item => item.border),
+ borderWidth: 1.5,
+ borderRadius: {
+ topLeft: 14,
+ topRight: 14,
+ bottomLeft: 14,
+ bottomRight: 14
+ },
+ barPercentage: 0.65,
+ categoryPercentage: 0.6
+ }]
+ },
+ options: {
+ maintainAspectRatio: false,
+ animation: {
+ duration: 1400,
+ easing: 'easeOutQuart'
+ },
+ interaction: {
+ mode: 'index',
+ intersect: false
+ },
+ scales: {
+ y: {
+ beginAtZero: true,
+ grid: {
+ color: 'rgba(148, 163, 184, 0.18)',
+ drawBorder: false,
+ borderDash: [4, 4]
+ },
+ ticks: {
+ precision: 0,
+ font: { size: 12 }
+ }
+ },
+ x: {
+ grid: {
+ drawBorder: false
+ },
+ ticks: {
+ font: { size: 12 },
+ autoSkip: false
+ }
+ }
+ },
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ backgroundColor: 'rgba(15, 23, 42, 0.88)',
+ cornerRadius: 8,
+ padding: 12,
+ displayColors: false,
+ callbacks: {
+ title: items => items[0]?.label || '',
+ label: context => `评论 ${context.raw} 次`
+ }
+ }
+ },
+ onHover: (event, activeElements) => {
+ canvas.style.cursor = activeElements.length ? 'pointer' : 'default';
+ }
+ }
+ });
+
+ return [chart];
+}
+
+export function renderTopArticles(container, articles, softColors, strongColors) {
+ const chartArea = createSection(
+ container,
+ '热门文章 Top10',
+ '按访问量排序的热门文章'
+ );
+ if (!articles?.length) {
+ chartArea.innerHTML = '暂无热门文章数据
';
+ return [];
+ }
+
+ const normalized = articles
+ .map(item => ({
+ name: item.name || '未命名文章',
+ views: Number(item.views ?? item.count ?? 0)
+ }))
+ .filter(item => item.views > 0)
+ .sort((a, b) => b.views - a.views)
+ .slice(0, 10);
+
+ if (!normalized.length) {
+ chartArea.innerHTML = '暂无热门文章数据
';
+ return [];
+ }
+
+ const canvas = buildCanvasCard(chartArea, `热门文章 Top ${normalized.length}`);
+ const card = canvas.closest('.xhhaocom-chartboard-card');
+ if (card) {
+ card.classList.add('xhhaocom-chartboard-card--animated');
+ }
+
+ const articleColors = createBarColors(normalized.length, softColors, strongColors);
+
+ const isMobile = window.innerWidth <= 768;
+
+ const chart = new Chart(canvas, {
+ type: 'bar',
+ data: {
+ labels: normalized.map(item => item.name.length > 16 ? item.name.slice(0, 16) + '…' : item.name),
+ datasets: [{
+ label: '访问量',
+ data: normalized.map(item => item.views),
+ backgroundColor: articleColors.map(item => item.background),
+ borderColor: articleColors.map(item => item.border),
+ borderWidth: 1.5,
+ borderRadius: {
+ topLeft: 14,
+ topRight: 14,
+ bottomLeft: 14,
+ bottomRight: 14
+ },
+ barPercentage: 0.65,
+ categoryPercentage: 0.6
+ }]
+ },
+ options: {
+ maintainAspectRatio: false,
+ animation: {
+ duration: 1500,
+ easing: 'easeOutQuart'
+ },
+ interaction: {
+ mode: 'index',
+ intersect: false
+ },
+ scales: {
+ y: {
+ beginAtZero: true,
+ grid: {
+ color: 'rgba(148, 163, 184, 0.18)',
+ drawBorder: false,
+ borderDash: [4, 4]
+ },
+ ticks: {
+ callback: value => {
+ const val = Number(value) || 0;
+ if (val >= 1_000_000) return (val / 1_000_000).toFixed(1) + 'M';
+ if (val >= 1_000) return (val / 1_000).toFixed(1) + 'K';
+ return val.toString();
+ },
+ font: { size: 12 }
+ }
+ },
+ x: {
+ grid: {
+ drawBorder: false
+ },
+ ticks: {
+ display: !isMobile,
+ font: { size: 12 },
+ autoSkip: false
+ }
+ }
+ },
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ backgroundColor: 'rgba(15, 23, 42, 0.88)',
+ cornerRadius: 8,
+ padding: 12,
+ displayColors: false,
+ callbacks: {
+ title: items => items[0]?.label || '',
+ label: context => {
+ const val = Number(context.raw) || 0;
+ if (val >= 1_000_000) return `访问量 ${(val / 1_000_000).toFixed(1)}M`;
+ if (val >= 1_000) return `访问量 ${(val / 1_000).toFixed(1)}K`;
+ return `访问量 ${val}`;
+ }
+ }
+ }
+ },
+ onHover: (event, activeElements) => {
+ canvas.style.cursor = activeElements.length ? 'pointer' : 'default';
+ }
+ }
+ });
+
+ return [chart];
+}
diff --git a/src/main/resources/static/js/modules/siteCharts/constants.js b/src/main/resources/static/js/modules/siteCharts/constants.js
new file mode 100644
index 0000000..c96ccca
--- /dev/null
+++ b/src/main/resources/static/js/modules/siteCharts/constants.js
@@ -0,0 +1,40 @@
+export const API_ENDPOINT = '/apis/api.data.statistics.xhhao.com/v1alpha1/chart/data';
+
+export const COLOR_PALETTE = [
+ '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
+ '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#0ea5e9'
+];
+
+export const BAR_SOFT_COLORS = [
+ 'rgba(255, 99, 132, 0.22)',
+ 'rgba(255, 159, 64, 0.22)',
+ 'rgba(255, 205, 86, 0.22)',
+ 'rgba(75, 192, 192, 0.22)',
+ 'rgba(54, 162, 235, 0.22)',
+ 'rgba(153, 102, 255, 0.22)',
+ 'rgba(201, 203, 207, 0.22)',
+ 'rgba(236, 72, 153, 0.22)',
+ 'rgba(16, 185, 129, 0.22)',
+ 'rgba(14, 165, 233, 0.22)'
+];
+
+export const BAR_STRONG_COLORS = [
+ 'rgb(255, 99, 132)',
+ 'rgb(255, 159, 64)',
+ 'rgb(255, 205, 86)',
+ 'rgb(75, 192, 192)',
+ 'rgb(54, 162, 235)',
+ 'rgb(153, 102, 255)',
+ 'rgb(201, 203, 207)',
+ 'rgb(236, 72, 153)',
+ 'rgb(16, 185, 129)',
+ 'rgb(14, 165, 233)'
+];
+
+export const DAY_IN_MS = 24 * 60 * 60 * 1000;
+
+export const WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日'];
+
+export const MONTH_NAMES = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
+
+export const DEFAULT_CHART_TYPES = ['tags', 'categories', 'articles', 'comments', 'topArticles'];
diff --git a/src/main/resources/static/js/modules/siteCharts/domUtils.js b/src/main/resources/static/js/modules/siteCharts/domUtils.js
new file mode 100644
index 0000000..f7bdeaf
--- /dev/null
+++ b/src/main/resources/static/js/modules/siteCharts/domUtils.js
@@ -0,0 +1,41 @@
+export function createSection(container, title, subtitle) {
+ const section = document.createElement('section');
+ section.className = 'xhhaocom-chartboard-section';
+
+ const header = document.createElement('header');
+ header.className = 'xhhaocom-chartboard-section__header';
+ header.innerHTML = `
+ ${title}
+ ${subtitle ? `${subtitle}
` : ''}
+ `;
+ section.appendChild(header);
+
+ const body = document.createElement('div');
+ body.className = 'xhhaocom-chartboard-section__body';
+ section.appendChild(body);
+
+ container.appendChild(section);
+ return body;
+}
+
+export function buildCanvasCard(body, hint) {
+ const card = document.createElement('div');
+ card.className = 'xhhaocom-chartboard-card';
+
+ const canvasWrapper = document.createElement('div');
+ canvasWrapper.className = 'xhhaocom-chartboard-card__canvas';
+ const canvas = document.createElement('canvas');
+ canvasWrapper.appendChild(canvas);
+
+ card.appendChild(canvasWrapper);
+
+ if (hint) {
+ const footer = document.createElement('footer');
+ footer.className = 'xhhaocom-chartboard-card__footer';
+ footer.textContent = hint;
+ card.appendChild(footer);
+ }
+
+ body.appendChild(card);
+ return canvas;
+}
diff --git a/src/main/resources/static/js/modules/siteCharts/init.js b/src/main/resources/static/js/modules/siteCharts/init.js
new file mode 100644
index 0000000..89d2316
--- /dev/null
+++ b/src/main/resources/static/js/modules/siteCharts/init.js
@@ -0,0 +1,86 @@
+import { DEFAULT_CHART_TYPES, BAR_SOFT_COLORS, BAR_STRONG_COLORS } from './constants.js';
+import { renderTaxonomyCharts, renderArticleHeatmap, renderCommentChart, renderTopArticles } from './chartRenderers.js';
+import { fetchChartData } from './api.js';
+import { disposeCharts, registerCharts } from './chartManager.js';
+
+export function renderCharts(container, data) {
+ disposeCharts(container);
+ container.innerHTML = '';
+
+ const dataTypes = container.getAttribute('data-types');
+ const enabledTypes = dataTypes ? dataTypes.split(',').map(t => t.trim()).filter(Boolean) :
+ DEFAULT_CHART_TYPES;
+
+ const charts = [];
+
+ if (enabledTypes.includes('tags') || enabledTypes.includes('categories')) {
+ const tags = enabledTypes.includes('tags') ? data.tags : null;
+ const categories = enabledTypes.includes('categories') ? data.categories : null;
+ charts.push(...renderTaxonomyCharts(container, tags, categories));
+ }
+
+ if (enabledTypes.includes('articles')) {
+ charts.push(...renderArticleHeatmap(container, data.articles));
+ }
+
+ if (enabledTypes.includes('comments')) {
+ charts.push(...renderCommentChart(container, data.comments, BAR_SOFT_COLORS, BAR_STRONG_COLORS));
+ }
+
+ if (enabledTypes.includes('topArticles')) {
+ charts.push(...renderTopArticles(container, data.top10Articles, BAR_SOFT_COLORS, BAR_STRONG_COLORS));
+ }
+
+ registerCharts(container, charts);
+
+ if (container.children.length === 0) {
+ container.innerHTML = '暂无可展示的数据
';
+ }
+}
+
+export async function fetchAndRender(container) {
+ container.classList.add('xhhaocom-chartboard');
+ container.innerHTML = '数据加载中…
';
+
+ try {
+ const data = await fetchChartData();
+ renderCharts(container, data || {});
+ } catch (error) {
+ console.error('[ChartBoard] fetch error:', error);
+ container.innerHTML = `获取图表数据失败:${error.message}
`;
+ }
+}
+
+export function waitForChart(callback, maxAttempts = 50) {
+ if (typeof Chart !== 'undefined') {
+ callback();
+ return;
+ }
+
+ if (maxAttempts <= 0) {
+ console.error('[ChartBoard] Chart.js 加载超时');
+ return;
+ }
+
+ setTimeout(() => waitForChart(callback, maxAttempts - 1), 100);
+}
+
+export function init() {
+ waitForChart(() => {
+ document.querySelectorAll('.xhhaocom-chartboard').forEach(container => {
+ if (!container.hasAttribute('data-initialized')) {
+ container.setAttribute('data-initialized', 'true');
+ fetchAndRender(container);
+ }
+ });
+ });
+}
+
+export function setupMutationObserver() {
+ if (typeof MutationObserver !== 'undefined') {
+ const observer = new MutationObserver(() => {
+ init();
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
+ }
+}
diff --git a/src/main/resources/static/js/modules/siteCharts/utils.js b/src/main/resources/static/js/modules/siteCharts/utils.js
new file mode 100644
index 0000000..68206c6
--- /dev/null
+++ b/src/main/resources/static/js/modules/siteCharts/utils.js
@@ -0,0 +1,34 @@
+export function ready(fn) {
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', fn);
+ } else {
+ fn();
+ }
+}
+
+export function formatNumber(num) {
+ const value = Number(num) || 0;
+ if (value >= 1_000_000) return (value / 1_000_000).toFixed(1) + 'M';
+ if (value >= 1_000) return (value / 1_000).toFixed(1) + 'K';
+ return value.toString();
+}
+
+export function safeGet(target, path, fallback = undefined) {
+ return path.split('.').reduce((acc, key) => {
+ if (acc && Object.prototype.hasOwnProperty.call(acc, key)) {
+ return acc[key];
+ }
+ return undefined;
+ }, target) ?? fallback;
+}
+
+export function formatDateYMD(date) {
+ return date.toISOString().slice(0, 10);
+}
+
+export function createBarColors(length, softColors, strongColors) {
+ return Array.from({ length }, (_, index) => ({
+ background: softColors[index % softColors.length],
+ border: strongColors[index % strongColors.length]
+ }));
+}
diff --git a/src/main/resources/static/js/siteCharts.js b/src/main/resources/static/js/siteCharts.js
index f7325c3..2ad9e7d 100644
--- a/src/main/resources/static/js/siteCharts.js
+++ b/src/main/resources/static/js/siteCharts.js
@@ -1,970 +1,10 @@
+import { ready } from './modules/siteCharts/modules/siteCharts/utils.js';
+import { init, setupMutationObserver } from './modules/siteCharts/init.js';
+
(function () {
'use strict';
- const API_ENDPOINT = '/apis/api.data.statistics.xhhao.com/v1alpha1/chart/data';
- const COLOR_PALETTE = [
- '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
- '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#0ea5e9'
- ];
- const DAY_IN_MS = 24 * 60 * 60 * 1000;
-
- const chartRegistry = new Map();
-
- function ready(fn) {
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', fn);
- } else {
- fn();
- }
- }
-
- function formatNumber(num) {
- const value = Number(num) || 0;
- if (value >= 1_000_000) return (value / 1_000_000).toFixed(1) + 'M';
- if (value >= 1_000) return (value / 1_000).toFixed(1) + 'K';
- return value.toString();
- }
-
- function safeGet(target, path, fallback = undefined) {
- return path.split('.').reduce((acc, key) => {
- if (acc && Object.prototype.hasOwnProperty.call(acc, key)) {
- return acc[key];
- }
- return undefined;
- }, target) ?? fallback;
- }
-
- function disposeCharts(container) {
- const charts = chartRegistry.get(container);
- if (charts) {
- charts.forEach(instance => {
- if (instance?.destroy) {
- instance.destroy();
- }
- });
- chartRegistry.delete(container);
- }
- }
-
- function createSection(container, title, subtitle) {
- const section = document.createElement('section');
- section.className = 'xhhaocom-chartboard-section';
-
- const header = document.createElement('header');
- header.className = 'xhhaocom-chartboard-section__header';
- header.innerHTML = `
- ${title}
- ${subtitle ? `${subtitle}
` : ''}
- `;
- section.appendChild(header);
-
- const body = document.createElement('div');
- body.className = 'xhhaocom-chartboard-section__body';
- section.appendChild(body);
-
- container.appendChild(section);
- return body;
- }
-
- function buildCanvasCard(body, hint) {
- const card = document.createElement('div');
- card.className = 'xhhaocom-chartboard-card';
-
- const canvasWrapper = document.createElement('div');
- canvasWrapper.className = 'xhhaocom-chartboard-card__canvas';
- const canvas = document.createElement('canvas');
- canvasWrapper.appendChild(canvas);
-
- card.appendChild(canvasWrapper);
-
- if (hint) {
- const footer = document.createElement('footer');
- footer.className = 'xhhaocom-chartboard-card__footer';
- footer.textContent = hint;
- card.appendChild(footer);
- }
-
- body.appendChild(card);
- return canvas;
- }
-
- function formatDateYMD(date) {
- return date.toISOString().slice(0, 10);
- }
-
- function renderTaxonomyCharts(container, tags, categories) {
- const section = createSection(
- container,
- '标签与分类统计',
- '展示全部标签和分类的文章数量占比'
- );
-
- const cards = [];
-
- const tagData = (tags || [])
- .map(tag => ({
- name:
- tag?.name ??
- safeGet(tag, 'spec.displayName') ??
- safeGet(tag, 'metadata.name') ??
- '未命名标签',
- count: Number(
- tag?.count ??
- tag?.total ??
- safeGet(tag, 'status.visiblePostCount', 0)
- )
- }))
- .filter(item => item.count > 0)
- .sort((a, b) => b.count - a.count);
-
- const categoryData = (categories || [])
- .map(category => ({
- name:
- category?.name ??
- safeGet(category, 'spec.displayName') ??
- safeGet(category, 'metadata.name') ??
- '未命名分类',
- count: Number(
- category?.total ??
- category?.count ??
- safeGet(category, 'status.visiblePostCount', 0)
- )
- }))
- .filter(item => item.count > 0)
- .sort((a, b) => b.count - a.count);
-
- if (!tagData.length && !categoryData.length) {
- section.innerHTML = '暂无标签或分类数据
';
- return [];
- }
-
- const charts = [];
-
- if (tagData.length) {
- const unusedTags = (tags?.length || 0) - tagData.length;
- const tagFootnote = unusedTags > 0
- ? `已使用标签 ${tagData.length} 个(另有 ${unusedTags} 个未使用)`
- : `已使用标签 ${tagData.length} 个`;
- const canvas = buildCanvasCard(section, tagFootnote);
- const card = canvas.closest('.xhhaocom-chartboard-card');
- if (card) {
- card.classList.add('xhhaocom-chartboard-card--animated');
- }
- const chart = new Chart(canvas, {
- type: 'doughnut',
- data: {
- labels: tagData.map(item => item.name),
- datasets: [{
- data: tagData.map(item => item.count),
- backgroundColor: tagData.map((_, index) => COLOR_PALETTE[index % COLOR_PALETTE.length]),
- borderWidth: 2,
- borderColor: '#ffffff',
- cutout: '55%',
- hoverOffset: 8,
- hoverBorderWidth: 3
- }]
- },
- options: {
- maintainAspectRatio: false,
- animation: {
- animateRotate: true,
- animateScale: true,
- duration: 1200,
- easing: 'easeOutQuart'
- },
- interaction: {
- intersect: false,
- mode: 'point'
- },
- plugins: {
- legend: { display: false },
- tooltip: {
- enabled: true,
- backgroundColor: 'rgba(0, 0, 0, 0.8)',
- padding: 12,
- cornerRadius: 8,
- displayColors: true,
- callbacks: {
- label: context => `${context.label}: ${context.raw} 篇文章`
- }
- }
- },
- onHover: (event, activeElements) => {
- canvas.style.cursor = activeElements.length > 0 ? 'pointer' : 'default';
- }
- }
- });
-
- chart.canvas.style.height = '220px';
- chart.canvas.style.maxHeight = '220px';
- chart.resize();
-
- charts.push(chart);
- if (card) {
- cards.push(card);
- }
- }
-
- if (categoryData.length) {
- const unusedCategories = (categories?.length || 0) - categoryData.length;
- const categoryFootnote = unusedCategories > 0
- ? `已使用分类 ${categoryData.length} 个(另有 ${unusedCategories} 个未使用)`
- : `已使用分类 ${categoryData.length} 个`;
- const canvas = buildCanvasCard(section, categoryFootnote);
- const card = canvas.closest('.xhhaocom-chartboard-card');
- if (card) {
- card.classList.add('xhhaocom-chartboard-card--animated');
- }
-
- // 按数量排序用于折线图显示
- const sortedData = [...categoryData].sort((a, b) => a.count - b.count);
-
- const chart = new Chart(canvas, {
- type: 'line',
- data: {
- labels: sortedData.map(item => item.name),
- datasets: [{
- label: '文章数量',
- data: sortedData.map(item => item.count),
- borderColor: COLOR_PALETTE[0],
- backgroundColor: COLOR_PALETTE[0] + '20',
- borderWidth: 3,
- fill: true,
- tension: 0.4,
- pointRadius: 5,
- pointHoverRadius: 8,
- pointBackgroundColor: COLOR_PALETTE[0],
- pointBorderColor: '#ffffff',
- pointBorderWidth: 2,
- pointHoverBackgroundColor: COLOR_PALETTE[0],
- pointHoverBorderColor: '#ffffff',
- pointHoverBorderWidth: 3
- }]
- },
- options: {
- maintainAspectRatio: false,
- animation: {
- duration: 1500,
- easing: 'easeOutQuart'
- },
- interaction: {
- intersect: false,
- mode: 'index'
- },
- scales: {
- x: {
- beginAtZero: false,
- grid: {
- display: false
- },
- ticks: {
- font: {
- size: 11
- },
- maxRotation: 45,
- minRotation: 0
- }
- },
- y: {
- beginAtZero: true,
- grid: {
- color: 'rgba(0, 0, 0, 0.05)',
- drawBorder: false
- },
- ticks: {
- font: {
- size: 11
- },
- callback: value => Number(value)
- }
- }
- },
- plugins: {
- legend: { display: false },
- tooltip: {
- enabled: true,
- backgroundColor: 'rgba(0, 0, 0, 0.8)',
- padding: 12,
- cornerRadius: 8,
- displayColors: true,
- callbacks: {
- label: context => `${context.label}: ${context.raw} 篇文章`
- }
- }
- },
- onHover: (event, activeElements) => {
- canvas.style.cursor = activeElements.length > 0 ? 'pointer' : 'default';
- }
- }
- });
- charts.push(chart);
- if (card) {
- cards.push(card);
- }
- }
-
- if (cards.length === 1) {
- cards[0].style.gridColumn = 'span 2';
- }
-
- return charts;
- }
-
- function renderArticleHeatmap(container, articles) {
- const chartArea = createSection(
- container,
- '文章发布趋势',
- '按日期统计文章发布数量'
- );
-
- const dataMap = new Map();
- (articles || []).forEach(article => {
- const rawDate = article.date || article.name;
- if (!rawDate) {
- return;
- }
- const date = new Date(rawDate);
- if (Number.isNaN(date.valueOf())) {
- return;
- }
- date.setHours(0, 0, 0, 0);
- const key = formatDateYMD(date);
- const total = Number(article.total ?? article.count ?? 0);
- dataMap.set(key, (dataMap.get(key) || 0) + total);
- });
-
- if (!dataMap.size) {
- chartArea.innerHTML = '暂无文章数据
';
- return [];
- }
-
- const now = new Date();
- now.setHours(0, 0, 0, 0);
- const end = new Date(now);
- const start = new Date(end.getTime() - 364 * DAY_IN_MS);
-
- const firstMonday = new Date(start);
- const offsetToMonday = (firstMonday.getDay() + 6) % 7;
- firstMonday.setDate(firstMonday.getDate() - offsetToMonday);
-
- const totalDays = Math.floor((end - firstMonday) / DAY_IN_MS) + 1;
- const weeksCount = Math.ceil(totalDays / 7);
- const weeks = Array.from({ length: weeksCount }, (_, index) =>
- new Date(firstMonday.getTime() + index * 7 * DAY_IN_MS)
- );
-
- const maxValue = Math.max(...dataMap.values(), 0);
-
- const card = document.createElement('div');
- card.className = 'xhhaocom-chartboard-card xhhaocom-chartboard-card--heatmap';
- card.style.gridColumn = '1 / -1';
-
- const heatmap = document.createElement('div');
- heatmap.className = 'xhhaocom-chartboard-heatmap';
-
- const tooltip = document.createElement('div');
- tooltip.className = 'xhhaocom-chartboard-heatmap__tooltip';
- tooltip.style.display = 'none';
- card.appendChild(tooltip);
-
- const monthsRow = document.createElement('div');
- monthsRow.className = 'xhhaocom-chartboard-heatmap__months';
-
- const weekdaysCol = document.createElement('div');
- weekdaysCol.className = 'xhhaocom-chartboard-heatmap__weekdays';
- const weekdayLabels = ['一', '二', '三', '四', '五', '六', '日'];
- weekdayLabels.forEach(day => {
- const label = document.createElement('div');
- label.className = 'xhhaocom-chartboard-heatmap__weekday';
- label.textContent = day;
- weekdaysCol.appendChild(label);
- });
-
- const grid = document.createElement('div');
- grid.className = 'xhhaocom-chartboard-heatmap__grid';
- const updateCellSize = () => {
- const cardRect = card.getBoundingClientRect();
- if (cardRect.width === 0) {
- requestAnimationFrame(updateCellSize);
- return;
- }
-
- const isMobile = window.innerWidth <= 768;
- if (isMobile) {
- const cellSize = '12px';
- monthsRow.style.gridTemplateColumns = `repeat(${weeksCount}, ${cellSize})`;
- grid.style.gridTemplateColumns = `repeat(${weeksCount}, ${cellSize})`;
- document.documentElement.style.setProperty('--chartboard-heatmap-cell', cellSize);
- document.documentElement.style.setProperty('--chartboard-heatmap-cell-width', cellSize);
- return;
- }
- const padding = 40;
- const weekdayWidth = 30;
- const weekdayGap = 10;
- const availableWidth = cardRect.width - padding - weekdayWidth - weekdayGap;
- const cellGap = 4;
- const cellWidth = Math.max(8, Math.floor((availableWidth - (weeksCount - 1) * cellGap) / weeksCount));
-
- const cellSize = `${cellWidth}px`;
- monthsRow.style.gridTemplateColumns = `repeat(${weeksCount}, ${cellSize})`;
- grid.style.gridTemplateColumns = `repeat(${weeksCount}, ${cellSize})`;
- document.documentElement.style.setProperty('--chartboard-heatmap-cell', cellSize);
- document.documentElement.style.setProperty('--chartboard-heatmap-cell-width', cellSize);
- };
- const resizeObserver = new ResizeObserver(() => {
- updateCellSize();
- });
-
- // 监听窗口大小变化(用于设备旋转等情况)
- let resizeTimeout;
- const handleWindowResize = () => {
- clearTimeout(resizeTimeout);
- resizeTimeout = setTimeout(() => {
- updateCellSize();
- }, 150);
- };
-
- const initSize = () => {
- resizeObserver.observe(card);
- window.addEventListener('resize', handleWindowResize);
- window.addEventListener('orientationchange', handleWindowResize);
- updateCellSize();
- };
-
- const computeLevel = value => {
- if (!value || !maxValue) {
- return 0;
- }
- if (maxValue <= 1) {
- return value > 0 ? 1 : 0;
- }
- const q1 = Math.max(1, Math.ceil(maxValue * 0.25));
- const q2 = Math.max(q1 + 1, Math.ceil(maxValue * 0.5));
- const q3 = Math.max(q2 + 1, Math.ceil(maxValue * 0.75));
- if (value >= q3) return 4;
- if (value >= q2) return 3;
- if (value >= q1) return 2;
- return 1;
- };
-
- const showTooltip = (event, dateKey, value) => {
- tooltip.innerHTML = `${dateKey}${value ? `发布 ${value} 篇文章` : '无文章发布'}`;
- tooltip.style.display = 'flex';
-
- const cardRect = card.getBoundingClientRect();
- const tooltipRect = tooltip.getBoundingClientRect();
-
- let left = event.clientX - cardRect.left + 12;
- let top = event.clientY - cardRect.top - tooltipRect.height - 10;
-
- if (left + tooltipRect.width > cardRect.width) {
- left = cardRect.width - tooltipRect.width - 8;
- }
- if (top < 0) {
- top = event.clientY - cardRect.top + 12;
- }
-
- tooltip.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;
- };
-
- const hideTooltip = () => {
- tooltip.style.display = 'none';
- tooltip.style.transform = 'translate(-9999px, -9999px)';
- };
-
- const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'];
- let monthSegments = [];
- {
- const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
- const lastMonth = new Date(end.getFullYear(), end.getMonth(), 1);
-
- while (cursor <= lastMonth) {
- const monthStart = new Date(cursor);
- const monthEnd = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0);
-
- const effectiveStart = monthStart < start ? new Date(start) : monthStart;
- const effectiveEnd = monthEnd > end ? new Date(end) : monthEnd;
-
- const startOffsetDays = Math.floor((effectiveStart - firstMonday) / DAY_IN_MS);
- const endOffsetDays = Math.floor((effectiveEnd - firstMonday) / DAY_IN_MS);
- const startWeek = Math.max(0, Math.min(weeksCount - 1, Math.floor(startOffsetDays / 7)));
- const endWeekExclusive = Math.max(startWeek + 1, Math.min(weeksCount, Math.floor(endOffsetDays / 7) + 1));
-
- monthSegments.push({
- label: monthNames[cursor.getMonth()],
- start: startWeek,
- end: endWeekExclusive
- });
-
- cursor.setMonth(cursor.getMonth() + 1);
- }
- }
-
- if (monthSegments.length) {
- const normalized = [];
- let cursorIndex = 0;
- monthSegments.forEach(segment => {
- let start = Math.max(cursorIndex, segment.start);
- let end = Math.max(start + 1, segment.end);
-
- start = Math.min(start, weeksCount - 1);
- end = Math.min(end, weeksCount);
- if (start >= weeksCount) {
- return;
- }
-
- normalized.push({
- label: segment.label,
- start,
- end
- });
- cursorIndex = end;
- });
- monthSegments = normalized;
- }
-
- weeks.forEach((weekStart, weekIndex) => {
- const column = document.createElement('div');
- column.className = 'xhhaocom-chartboard-heatmap__column';
-
- for (let dayIndex = 0; dayIndex < 7; dayIndex++) {
- const day = new Date(weekStart.getTime() + dayIndex * DAY_IN_MS);
- const cell = document.createElement('div');
- cell.className = 'xhhaocom-chartboard-heatmap__day';
-
- const dayKey = formatDateYMD(day);
- const isWithinRange = day >= start && day <= end;
-
- if (!isWithinRange) {
- cell.classList.add('is-outside');
- } else {
- const value = dataMap.get(dayKey) || 0;
- const level = computeLevel(value);
- cell.dataset.level = level.toString();
- cell.dataset.value = value.toString();
- cell.dataset.date = dayKey;
-
- const handleMouseMove = event => showTooltip(event, dayKey, value);
- cell.addEventListener('mouseenter', handleMouseMove);
- cell.addEventListener('mousemove', handleMouseMove);
- cell.addEventListener('mouseleave', hideTooltip);
- }
-
- column.appendChild(cell);
- }
-
- grid.appendChild(column);
- });
-
- card.addEventListener('mouseleave', hideTooltip);
-
- let cursor = 0;
- monthSegments.forEach(segment => {
- if (segment.start > cursor) {
- const filler = document.createElement('div');
- filler.className = 'xhhaocom-chartboard-heatmap__month is-placeholder';
- filler.style.gridColumn = `span ${segment.start - cursor}`;
- monthsRow.appendChild(filler);
- }
-
- const span = Math.max(1, segment.end - segment.start);
- const label = document.createElement('div');
- label.className = 'xhhaocom-chartboard-heatmap__month';
- label.textContent = segment.label;
- label.style.gridColumn = `span ${span}`;
- monthsRow.appendChild(label);
-
- cursor = segment.end;
- });
-
- if (cursor < weeksCount) {
- const filler = document.createElement('div');
- filler.className = 'xhhaocom-chartboard-heatmap__month is-placeholder';
- filler.style.gridColumn = `span ${weeksCount - cursor}`;
- monthsRow.appendChild(filler);
- }
-
- heatmap.appendChild(weekdaysCol);
- heatmap.appendChild(monthsRow);
- heatmap.appendChild(grid);
-
- const footerRow = document.createElement('div');
- footerRow.className = 'xhhaocom-chartboard-heatmap__footer';
-
- const dateRange = document.createElement('div');
- dateRange.className = 'xhhaocom-chartboard-heatmap__date-range';
- dateRange.textContent = `${formatDateYMD(start)} 至 ${formatDateYMD(end)}`;
- footerRow.appendChild(dateRange);
-
- const legend = document.createElement('div');
- legend.className = 'xhhaocom-chartboard-heatmap__legend';
- const legendLabelMin = document.createElement('span');
- legendLabelMin.textContent = '较少';
- legend.appendChild(legendLabelMin);
- [0, 1, 2, 3, 4].forEach(level => {
- const dot = document.createElement('span');
- dot.className = 'xhhaocom-chartboard-heatmap__legend-dot';
- dot.dataset.level = level.toString();
- legend.appendChild(dot);
- });
- const legendLabelMax = document.createElement('span');
- legendLabelMax.textContent = '较多';
- legend.appendChild(legendLabelMax);
- footerRow.appendChild(legend);
-
- heatmap.appendChild(footerRow);
- card.appendChild(heatmap);
-
- chartArea.appendChild(card);
-
- // 在添加到DOM后初始化大小计算
- initSize();
- return [{ type: 'heatmap' }];
- }
-
- const BAR_SOFT_COLORS = [
- 'rgba(255, 99, 132, 0.22)',
- 'rgba(255, 159, 64, 0.22)',
- 'rgba(255, 205, 86, 0.22)',
- 'rgba(75, 192, 192, 0.22)',
- 'rgba(54, 162, 235, 0.22)',
- 'rgba(153, 102, 255, 0.22)',
- 'rgba(201, 203, 207, 0.22)',
- 'rgba(236, 72, 153, 0.22)',
- 'rgba(16, 185, 129, 0.22)',
- 'rgba(14, 165, 233, 0.22)'
- ];
-
- const BAR_STRONG_COLORS = [
- 'rgb(255, 99, 132)',
- 'rgb(255, 159, 64)',
- 'rgb(255, 205, 86)',
- 'rgb(75, 192, 192)',
- 'rgb(54, 162, 235)',
- 'rgb(153, 102, 255)',
- 'rgb(201, 203, 207)',
- 'rgb(236, 72, 153)',
- 'rgb(16, 185, 129)',
- 'rgb(14, 165, 233)'
- ];
-
- function createBarColors(length) {
- return Array.from({ length }, (_, index) => ({
- background: BAR_SOFT_COLORS[index % BAR_SOFT_COLORS.length],
- border: BAR_STRONG_COLORS[index % BAR_STRONG_COLORS.length]
- }));
- }
-
- function renderCommentChart(container, comments) {
- const chartArea = createSection(
- container,
- '评论活跃用户',
- '按评论作者统计评论数量'
- );
- if (!comments?.length) {
- chartArea.innerHTML = '暂无评论数据
';
- return [];
- }
-
- const normalized = comments
- .map(comment => ({
- name: comment?.username || comment?.name || comment?.email || '匿名',
- count: Number(comment?.count ?? 0)
- }))
- .filter(item => item.count > 0)
- .sort((a, b) => b.count - a.count)
- .slice(0, 10);
-
- if (!normalized.length) {
- chartArea.innerHTML = '暂无评论数据
';
- return [];
- }
-
- const canvas = buildCanvasCard(chartArea, `活跃评论用户 Top ${normalized.length}`);
- const card = canvas.closest('.xhhaocom-chartboard-card');
- if (card) {
- card.classList.add('xhhaocom-chartboard-card--animated');
- }
-
- const barColors = createBarColors(normalized.length);
-
-
- const chart = new Chart(canvas, {
- type: 'bar',
- data: {
- labels: normalized.map(item => item.name),
- datasets: [{
- label: '评论数量',
- data: normalized.map(item => item.count),
- backgroundColor: barColors.map(item => item.background),
- borderColor: barColors.map(item => item.border),
- borderWidth: 1.5,
- borderRadius: {
- topLeft: 14,
- topRight: 14,
- bottomLeft: 14,
- bottomRight: 14
- },
- barPercentage: 0.65,
- categoryPercentage: 0.6
- }]
- },
- options: {
- maintainAspectRatio: false,
- animation: {
- duration: 1400,
- easing: 'easeOutQuart'
- },
- interaction: {
- mode: 'index',
- intersect: false
- },
- scales: {
- y: {
- beginAtZero: true,
- grid: {
- color: 'rgba(148, 163, 184, 0.18)',
- drawBorder: false,
- borderDash: [4, 4]
- },
- ticks: {
- precision: 0,
- font: { size: 12 }
- }
- },
- x: {
- grid: {
- drawBorder: false
- },
- ticks: {
- font: { size: 12 },
- autoSkip: false
- }
- }
- },
- plugins: {
- legend: { display: false },
- tooltip: {
- backgroundColor: 'rgba(15, 23, 42, 0.88)',
- cornerRadius: 8,
- padding: 12,
- displayColors: false,
- callbacks: {
- title: items => items[0]?.label || '',
- label: context => `评论 ${context.raw} 次`
- }
- }
- },
- onHover: (event, activeElements) => {
- canvas.style.cursor = activeElements.length ? 'pointer' : 'default';
- }
- }
- });
-
- return [chart];
- }
-
- function renderTopArticles(container, articles) {
- const chartArea = createSection(
- container,
- '热门文章 Top10',
- '按访问量排序的热门文章'
- );
- if (!articles?.length) {
- chartArea.innerHTML = '暂无热门文章数据
';
- return [];
- }
-
- const normalized = articles
- .map(item => ({
- name: item.name || '未命名文章',
- views: Number(item.views ?? item.count ?? 0)
- }))
- .filter(item => item.views > 0)
- .sort((a, b) => b.views - a.views)
- .slice(0, 10);
-
- if (!normalized.length) {
- chartArea.innerHTML = '暂无热门文章数据
';
- return [];
- }
-
- const canvas = buildCanvasCard(chartArea, `热门文章 Top ${normalized.length}`);
- const card = canvas.closest('.xhhaocom-chartboard-card');
- if (card) {
- card.classList.add('xhhaocom-chartboard-card--animated');
- }
-
- const articleColors = createBarColors(normalized.length);
-
- // 检测是否为移动端
- const isMobile = window.innerWidth <= 768;
-
- const chart = new Chart(canvas, {
- type: 'bar',
- data: {
- labels: normalized.map(item => item.name.length > 16 ? item.name.slice(0, 16) + '…' : item.name),
- datasets: [{
- label: '访问量',
- data: normalized.map(item => item.views),
- backgroundColor: articleColors.map(item => item.background),
- borderColor: articleColors.map(item => item.border),
- borderWidth: 1.5,
- borderRadius: {
- topLeft: 14,
- topRight: 14,
- bottomLeft: 14,
- bottomRight: 14
- },
- barPercentage: 0.65,
- categoryPercentage: 0.6
- }]
- },
- options: {
- maintainAspectRatio: false,
- animation: {
- duration: 1500,
- easing: 'easeOutQuart'
- },
- interaction: {
- mode: 'index',
- intersect: false
- },
- scales: {
- y: {
- beginAtZero: true,
- grid: {
- color: 'rgba(148, 163, 184, 0.18)',
- drawBorder: false,
- borderDash: [4, 4]
- },
- ticks: {
- callback: value => formatNumber(value),
- font: { size: 12 }
- }
- },
- x: {
- grid: {
- drawBorder: false
- },
- ticks: {
- display: !isMobile, // 移动端隐藏标签
- font: { size: 12 },
- autoSkip: false
- }
- }
- },
- plugins: {
- legend: { display: false },
- tooltip: {
- backgroundColor: 'rgba(15, 23, 42, 0.88)',
- cornerRadius: 8,
- padding: 12,
- displayColors: false,
- callbacks: {
- title: items => items[0]?.label || '',
- label: context => `访问量 ${formatNumber(context.raw)}`
- }
- }
- },
- onHover: (event, activeElements) => {
- canvas.style.cursor = activeElements.length ? 'pointer' : 'default';
- }
- }
- });
-
- return [chart];
- }
-
- function renderCharts(container, data) {
- disposeCharts(container);
- container.innerHTML = '';
-
- // 获取要渲染的图表类型
- const dataTypes = container.getAttribute('data-types');
- const enabledTypes = dataTypes ? dataTypes.split(',').map(t => t.trim()).filter(Boolean) :
- ['tags', 'categories', 'articles', 'comments', 'topArticles'];
-
- const charts = [];
-
- // 根据选择的类型渲染对应的图表
- if (enabledTypes.includes('tags') || enabledTypes.includes('categories')) {
- const tags = enabledTypes.includes('tags') ? data.tags : null;
- const categories = enabledTypes.includes('categories') ? data.categories : null;
- charts.push(...renderTaxonomyCharts(container, tags, categories));
- }
-
- if (enabledTypes.includes('articles')) {
- charts.push(...renderArticleHeatmap(container, data.articles));
- }
-
- if (enabledTypes.includes('comments')) {
- charts.push(...renderCommentChart(container, data.comments));
- }
-
- if (enabledTypes.includes('topArticles')) {
- charts.push(...renderTopArticles(container, data.top10Articles));
- }
-
- const actualCharts = charts.filter(chart => chart && typeof chart.destroy === 'function');
- if (actualCharts.length > 0) {
- chartRegistry.set(container, actualCharts);
- }
- if (container.children.length === 0) {
- container.innerHTML = '暂无可展示的数据
';
- }
- }
-
- function fetchAndRender(container) {
- container.classList.add('xhhaocom-chartboard');
- container.innerHTML = '数据加载中…
';
-
- fetch(API_ENDPOINT)
- .then(response => {
- if (!response.ok) {
- throw new Error(`HTTP ${response.status}`);
- }
- return response.json();
- })
- .then(data => renderCharts(container, data || {}))
- .catch(error => {
- console.error('[ChartBoard] fetch error:', error);
- container.innerHTML = `获取图表数据失败:${error.message}
`;
- });
- }
-
- function waitForChart(callback, maxAttempts = 50) {
- if (typeof Chart !== 'undefined') {
- callback();
- return;
- }
-
- if (maxAttempts <= 0) {
- console.error('[ChartBoard] Chart.js 加载超时');
- return;
- }
-
- setTimeout(() => waitForChart(callback, maxAttempts - 1), 100);
- }
-
- function init() {
- waitForChart(() => {
- document.querySelectorAll('.xhhaocom-chartboard').forEach(container => {
- if (!container.hasAttribute('data-initialized')) {
- container.setAttribute('data-initialized', 'true');
- fetchAndRender(container);
- }
- });
- });
- }
-
ready(init);
+ setupMutationObserver();
- if (typeof MutationObserver !== 'undefined') {
- const observer = new MutationObserver(() => {
- init();
- });
- observer.observe(document.body, { childList: true, subtree: true });
- }
})();
-