From 4d2fdee8eddc5e0ec6a15a2c798c893f49d216d7 Mon Sep 17 00:00:00 2001 From: AR-26710 Date: Wed, 21 Jan 2026 01:38:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor(js):=20=E6=A8=A1=E5=9D=97=E5=8C=96=20j?= =?UTF-8?q?s=20=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/static/js/dataStatistics.js | 829 +-------------- src/main/resources/static/js/index.js | 98 ++ .../js/modules/dataStatistics/activity.js | 119 +++ .../js/modules/dataStatistics/constants.js | 34 + .../static/js/modules/dataStatistics/dom.js | 70 ++ .../js/modules/dataStatistics/formatters.js | 70 ++ .../js/modules/dataStatistics/github.js | 221 ++++ .../static/js/modules/dataStatistics/i18n.js | 41 + .../js/modules/dataStatistics/traffic.js | 116 +++ .../js/modules/dataStatistics/uptime.js | 73 ++ .../static/js/modules/dataStatistics/utils.js | 59 ++ .../static/js/modules/siteCharts/api.js | 9 + .../js/modules/siteCharts/chartManager.js | 24 + .../js/modules/siteCharts/chartRenderers.js | 758 ++++++++++++++ .../static/js/modules/siteCharts/constants.js | 40 + .../static/js/modules/siteCharts/domUtils.js | 41 + .../static/js/modules/siteCharts/init.js | 86 ++ .../static/js/modules/siteCharts/utils.js | 34 + src/main/resources/static/js/siteCharts.js | 968 +----------------- 19 files changed, 1926 insertions(+), 1764 deletions(-) create mode 100644 src/main/resources/static/js/index.js create mode 100644 src/main/resources/static/js/modules/dataStatistics/activity.js create mode 100644 src/main/resources/static/js/modules/dataStatistics/constants.js create mode 100644 src/main/resources/static/js/modules/dataStatistics/dom.js create mode 100644 src/main/resources/static/js/modules/dataStatistics/formatters.js create mode 100644 src/main/resources/static/js/modules/dataStatistics/github.js create mode 100644 src/main/resources/static/js/modules/dataStatistics/i18n.js create mode 100644 src/main/resources/static/js/modules/dataStatistics/traffic.js create mode 100644 src/main/resources/static/js/modules/dataStatistics/uptime.js create mode 100644 src/main/resources/static/js/modules/dataStatistics/utils.js create mode 100644 src/main/resources/static/js/modules/siteCharts/api.js create mode 100644 src/main/resources/static/js/modules/siteCharts/chartManager.js create mode 100644 src/main/resources/static/js/modules/siteCharts/chartRenderers.js create mode 100644 src/main/resources/static/js/modules/siteCharts/constants.js create mode 100644 src/main/resources/static/js/modules/siteCharts/domUtils.js create mode 100644 src/main/resources/static/js/modules/siteCharts/init.js create mode 100644 src/main/resources/static/js/modules/siteCharts/utils.js 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 }); - } })(); -