├── screenshots
├── 1.png
└── 2.png
├── fonts
└── Hubot-Sans.woff2
├── .gitignore
├── images
└── placeholder-avatar.svg
├── LICENSE
├── README.md
├── debug.js
├── monthly_activity.js
├── index.html
├── heatmap.js
├── yearly_contributions.js
└── styles.css
/screenshots/1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/IICSC/github-contributions/HEAD/screenshots/1.png
--------------------------------------------------------------------------------
/screenshots/2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/IICSC/github-contributions/HEAD/screenshots/2.png
--------------------------------------------------------------------------------
/fonts/Hubot-Sans.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/IICSC/github-contributions/HEAD/fonts/Hubot-Sans.woff2
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | workspace.xml
2 | junitvmwatcher*.properties
3 | build.properties
4 |
5 | # generated java classes and java source files
6 | # manually add any custom artifacts that can't be generated from the models
7 | # http://confluence.jetbrains.com/display/MPSD25/HowTo+--+MPS+and+Git
8 | classes_gen
9 | source_gen
10 | source_gen.caches
11 |
12 | # generated test code and test results
13 | test_gen
14 | test_gen.caches
15 | TEST-*.xml
16 | junit*.properties
17 |
--------------------------------------------------------------------------------
/images/placeholder-avatar.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 IICSC
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # GitHub 贡献统计
2 |
3 | 一个优雅的 GitHub 贡献数据可视化工具,展示用户的贡献热力图、年度统计和连续贡献等数据。
4 |
5 | 
6 |
7 | 
8 |
9 | **🌐 体验最新Commit:https://iicsc.damon233.top/github-contributions**
10 |
11 | ## ✨ 特性
12 |
13 | - 📊 贡献热力图展示
14 | - 📈 年度贡献统计图表
15 | - 🔥 连续贡献天数统计
16 | - 🎯 总贡献数据统计
17 | - 💫 流畅的动画效果
18 | - 🌓 简洁现代的界面设计
19 |
20 | ## 🚀 快速开始
21 |
22 | 1. 克隆仓库:
23 | ```bash
24 | git clone https://github.com/IICSC/github-contributions.git
25 | cd github-contributions
26 | ```
27 |
28 | 2. 使用 HTTP 服务器运行项目(例如使用 Python):
29 | ```bash
30 | # Python 3
31 | python -m http.server 8000
32 |
33 | # Python 2
34 | python -m SimpleHTTPServer 8000
35 | ```
36 |
37 | _**注:**`8000` **为端口,可以修改**_
38 |
39 | 3. 在浏览器中访问:`http://localhost:8000`
40 |
41 | _**注:您也可以直接打开** `index.html`_
42 |
43 | ## 🛠️ 项目结构
44 |
45 | ```
46 | github-contributions/
47 | ├── fonts/
48 | │ └── Hubot-Sans.woff2 # 自定义字体
49 | │
50 | ├── images/
51 | │ └── placeholder-avatar.svg # 头像占位
52 | │
53 | ├── index.html # 主页面HTML结构
54 | │ ├── 导航栏
55 | │ ├── 用户输入区
56 | │ ├── 统计卡片
57 | │ ├── 图表区域
58 | │ └── 页脚
59 | │
60 | ├── styles.css # 样式文件
61 | │ ├── 主题变量
62 | │ ├── 布局样式
63 | │ ├── 组件样式
64 | │ └── 动画效果
65 | │
66 | ├── yearly_contributions.js # 年度统计相关
67 | │ ├── 数据处理
68 | │ ├── 图表生成
69 | │ ├── 动画效果
70 | │ └── 事件处理
71 | │
72 | ├── heatmap.js # 贡献热力图相关
73 | │ ├── 热力图生成
74 | │ ├── 日期处理
75 | │ ├── 动画效果
76 | │ └── 交互处理
77 | │
78 | └── monthly_activity.js # 月度活动统计
79 | ├── 数据处理
80 | └── 图表生成
81 | ```
82 |
83 | ### 核心文件说明
84 |
85 | #### 📄 index.html
86 | - 定义页面结构和布局
87 | - 引入所需的样式和脚本
88 | - 包含主要的HTML组件
89 |
90 | #### 📄 styles.css
91 | - 定义自定义WOFF字体
92 | - 定义全局主题变量
93 | - 设置响应式布局
94 | - 实现组件样式
95 | - 定义动画效果
96 |
97 | #### 📄 yearly_contributions.js
98 | - 处理年度贡献数据
99 | - 生成年度统计图表
100 | - 计算连续贡献天数
101 | - 处理数据刷新逻辑
102 |
103 | #### 📄 heatmap.js
104 | - 生成贡献热力图
105 | - 处理日期和贡献数据
106 | - 实现交互效果
107 | - 管理动画效果
108 |
109 | #### 📄 monthly_activity.js
110 | - 处理月度活动数据
111 | - 生成月度统计图表
112 |
113 | ### 修改指南
114 |
115 | 1. 修改主题样式
116 | - 在 `styles.css` 中更改主题变量
117 | - 调整组件样式和动画效果
118 |
119 | 2. 更新页面结构
120 | - 在 `index.html` 中修改HTML结构
121 | - 添加或移除组件
122 |
123 | 3. 调整数据处理
124 | - 在对应的 JS 文件中修改数据处理逻辑
125 | - 更新图表配置和动画效果
126 |
127 | 4. 添加新功能
128 | - 创建新的 JS 文件
129 | - 在 `index.html` 中引入
130 | - 添加相应的样式和交互
131 |
132 | ## ⚖️ 许可证
133 |
134 | 本项目使用 [MIT 许可证](https://github.com/IICSC/github-contributions/blob/main/LICENSE) 开源
135 |
136 | ### 使用到的开源项目及资源
137 |
138 | - [Chart.js](https://github.com/chartjs/Chart.js)(MIT 许可证)
139 | - [Hubot Sans](https://github.com/github/hubot-sans)(OFL-1.1 许可证)
140 |
--------------------------------------------------------------------------------
/debug.js:
--------------------------------------------------------------------------------
1 | // 调试脚本 - 可通过浏览器控制台运行
2 | // 使用方法: 打开浏览器控制台,复制粘贴此脚本并执行
3 |
4 | (function() {
5 | console.log("=== GitHub贡献统计调试工具 ===");
6 | console.log("浏览器时区信息:", Intl.DateTimeFormat().resolvedOptions().timeZone);
7 | console.log("时区偏移量(小时):", -new Date().getTimezoneOffset() / 60);
8 |
9 | // 测试日期
10 | const testDate = new Date();
11 | console.log("当前日期:", testDate.toISOString());
12 | console.log("本地日期格式:", testDate.toLocaleDateString());
13 |
14 | // 测试API请求
15 | const testUsername = document.getElementById('username').value.trim() || 'WuXiaoMuer';
16 | console.log("测试用户名:", testUsername);
17 |
18 | fetch(`https://github-contributions-api.jogruber.de/v4/${testUsername}`)
19 | .then(response => {
20 | console.log("API响应状态:", response.status, response.statusText);
21 | return response.json();
22 | })
23 | .then(data => {
24 | console.log("API返回数据:", data);
25 |
26 | if (data && data.contributions && data.contributions.length > 0) {
27 | const firstItem = data.contributions[0];
28 | const lastItem = data.contributions[data.contributions.length - 1];
29 |
30 | console.log("第一条贡献记录:", firstItem);
31 | console.log("最后一条贡献记录:", lastItem);
32 |
33 | // 检查日期范围
34 | const startDate = new Date(data.contributions[0].date);
35 | const endDate = new Date(data.contributions[data.contributions.length - 1].date);
36 |
37 | console.log("贡献日期范围:", startDate.toISOString(), "至", endDate.toISOString());
38 | console.log("总贡献天数:", data.contributions.length);
39 |
40 | // 按年份统计
41 | const yearCounts = {};
42 | data.contributions.forEach(item => {
43 | const year = new Date(item.date).getFullYear();
44 | if (!yearCounts[year]) {
45 | yearCounts[year] = 0;
46 | }
47 | yearCounts[year]++;
48 | });
49 |
50 | console.log("各年份贡献天数:", yearCounts);
51 |
52 | // 检查是否有无效日期
53 | const invalidDates = data.contributions.filter(item =>
54 | !item.date || isNaN(new Date(item.date).getTime())
55 | );
56 |
57 | if (invalidDates.length > 0) {
58 | console.error("发现无效日期:", invalidDates);
59 | } else {
60 | console.log("未发现无效日期");
61 | }
62 |
63 | // 显示热力图中的日期匹配情况
64 | if (typeof isLeapYear === 'function') {
65 | // 检查当前年份
66 | const currentYear = new Date().getFullYear();
67 | console.log(`${currentYear}是${isLeapYear(currentYear) ? '闰年' : '平年'}`);
68 |
69 | // 检查某个特定年份的日期
70 | const targetYear = 2024;
71 | const daysInYear = isLeapYear(targetYear) ? 366 : 365;
72 | console.log(`${targetYear}年共${daysInYear}天`);
73 |
74 | // 检查API返回的2024年数据
75 | const year2024Data = data.contributions.filter(item =>
76 | item.date.startsWith('2024-')
77 | );
78 |
79 | console.log(`API返回的${targetYear}年数据:`, year2024Data.length, "条");
80 | }
81 | } else {
82 | console.error("API返回的数据格式不正确或为空");
83 | }
84 | })
85 | .catch(error => {
86 | console.error("API请求失败:", error);
87 | });
88 |
89 | // 检查DOM元素
90 | console.log("热力图容器:", document.getElementById('heatmap'));
91 | console.log("月度图表容器:", document.getElementById('monthlyChart'));
92 | console.log("年度图表容器:", document.getElementById('yearlyChart'));
93 | })();
--------------------------------------------------------------------------------
/monthly_activity.js:
--------------------------------------------------------------------------------
1 | const processMonthlyData = (contributions) => {
2 | const monthlyData = {};
3 |
4 | // 添加调试信息
5 | console.log('开始处理月度数据,总条目数:', contributions?.length || 0);
6 |
7 | // 检查数据是否有效
8 | if (!contributions || !Array.isArray(contributions) || contributions.length === 0) {
9 | console.warn('月度数据为空或无效');
10 | return {};
11 | }
12 |
13 | // 获取当前日期,用于筛选未来日期
14 | const now = new Date();
15 | const currentYear = now.getFullYear();
16 | const currentMonth = now.getMonth() + 1;
17 | const currentDay = now.getDate();
18 | const currentDateStr = `${currentYear}-${String(currentMonth).padStart(2, '0')}-${String(currentDay).padStart(2, '0')}`;
19 |
20 | console.log('月度图表当前日期:', currentDateStr);
21 |
22 | // 按日期排序
23 | const sortedContributions = [...contributions].sort((a, b) => a.date.localeCompare(b.date));
24 |
25 | sortedContributions.forEach(day => {
26 | try {
27 | // 检查是否为未来日期
28 | if (day.date > currentDateStr) {
29 | // 跳过未来日期
30 | return;
31 | }
32 |
33 | // 检查日期有效性
34 | const dateObj = new Date(day.date);
35 | if (isNaN(dateObj.getTime())) {
36 | console.warn('月度图表忽略无效日期:', day);
37 | return;
38 | }
39 |
40 | // 提取年月
41 | const [yearStr, monthStr] = day.date.split('-');
42 | const monthKey = `${yearStr}-${monthStr}`;
43 |
44 | if (!monthlyData[monthKey]) {
45 | monthlyData[monthKey] = 0;
46 | }
47 | monthlyData[monthKey] += day.count;
48 | } catch (e) {
49 | console.error('处理月度数据时出错:', e, day);
50 | }
51 | });
52 |
53 | // 按时间顺序排序月份
54 | const sortedData = {};
55 | Object.keys(monthlyData)
56 | .sort()
57 | .forEach(key => {
58 | sortedData[key] = monthlyData[key];
59 | });
60 |
61 | console.log('月度数据处理完成,月份数:', Object.keys(sortedData).length);
62 | return sortedData;
63 | };
64 |
65 | document.addEventListener('DOMContentLoaded', () => {
66 | // monthly_activity.js中的图表将通过refreshData函数触发创建
67 | // 此处不需要直接初始化
68 | console.log('月度活动图表模块已加载');
69 | });
70 |
71 | window.createMonthlyChart = (data) => {
72 | if (!data || !data.contributions || !Array.isArray(data.contributions)) {
73 | console.error('月度图表创建失败: 无效数据格式', data);
74 | return;
75 | }
76 |
77 | console.log('开始创建月度图表');
78 | const ctx = document.getElementById('monthlyChart')?.getContext('2d');
79 | if (!ctx) {
80 | console.error('找不到月度图表canvas元素');
81 | return;
82 | }
83 |
84 | try {
85 | const monthlyData = processMonthlyData(data.contributions);
86 |
87 | if (window.monthlyChart) {
88 | window.monthlyChart.destroy();
89 | }
90 |
91 | const labels = Object.keys(monthlyData);
92 | const values = Object.values(monthlyData);
93 |
94 | if (labels.length === 0) {
95 | console.warn('没有月度数据可显示');
96 | return;
97 | }
98 |
99 | // 格式化标签,将YYYY-MM转换为YYYY年MM月
100 | const formattedLabels = labels.map(label => {
101 | const [year, month] = label.split('-');
102 | return `${year}年${parseInt(month)}月`;
103 | });
104 |
105 | // 生成背景颜色渐变
106 | const backgroundColors = values.map((value, index) => {
107 | // 根据贡献值计算颜色深浅
108 | const alpha = Math.min(Math.max(value / 100, 0.1), 0.5);
109 | return `rgba(75, 192, 192, ${alpha})`;
110 | });
111 |
112 | window.monthlyChart = new Chart(ctx, {
113 | type: 'line',
114 | data: {
115 | labels: formattedLabels,
116 | datasets: [{
117 | label: '月度贡献',
118 | data: values,
119 | borderColor: 'rgb(75, 192, 192)',
120 | backgroundColor: backgroundColors,
121 | tension: 0.2,
122 | fill: true,
123 | pointBackgroundColor: 'rgb(75, 192, 192)',
124 | pointRadius: 4,
125 | pointHoverRadius: 6
126 | }]
127 | },
128 | options: {
129 | responsive: true,
130 | animation: {
131 | duration: 2000,
132 | easing: 'easeOutQuart'
133 | },
134 | scales: {
135 | y: {
136 | beginAtZero: true,
137 | grid: {
138 | display: false
139 | },
140 | ticks: {
141 | precision: 0
142 | }
143 | },
144 | x: {
145 | grid: {
146 | display: false
147 | }
148 | }
149 | },
150 | plugins: {
151 | tooltip: {
152 | callbacks: {
153 | title: function(context) {
154 | return context[0].label;
155 | },
156 | label: function(context) {
157 | return `贡献数: ${context.raw}`;
158 | }
159 | }
160 | }
161 | }
162 | }
163 | });
164 |
165 | console.log('月度图表创建成功');
166 | } catch (e) {
167 | console.error('创建月度图表时出错:', e);
168 | }
169 | };
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | GitHub 贡献统计
6 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
26 |
27 |
45 |
46 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
--------------------------------------------------------------------------------
/heatmap.js:
--------------------------------------------------------------------------------
1 | const createHeatmap = (data) => {
2 | const heatmapContainer = document.getElementById('heatmap');
3 | if (!heatmapContainer) {
4 | console.error('找不到热力图容器');
5 | return;
6 | }
7 |
8 | // 清空容器但保留已有内容的引用
9 | const oldHeader = heatmapContainer.querySelector('.heatmap-header');
10 | const oldYearSelector = heatmapContainer.querySelector('.year-selector');
11 |
12 | heatmapContainer.innerHTML = '';
13 |
14 | // 创建标题和图标
15 | const titleContainer = document.createElement('div');
16 | titleContainer.className = 'heatmap-header';
17 |
18 | const icon = document.createElement('span');
19 | icon.className = 'contribution-icon';
20 | icon.innerHTML = ``;
23 |
24 | const title = document.createElement('h3');
25 | title.textContent = '贡献日历';
26 | title.className = 'heatmap-title';
27 |
28 | titleContainer.appendChild(icon);
29 | titleContainer.appendChild(title);
30 | heatmapContainer.appendChild(titleContainer);
31 |
32 | // 检查数据是否有效
33 | if (!data || !data.contributions || data.contributions.length === 0) {
34 | console.error('热力图数据无效或为空');
35 |
36 | // 创建空的贡献日历
37 | const emptyCalendar = document.createElement('div');
38 | emptyCalendar.className = 'contribution-calendar';
39 | emptyCalendar.innerHTML = '暂无贡献数据
';
40 | heatmapContainer.appendChild(emptyCalendar);
41 |
42 | return;
43 | }
44 |
45 | console.log('开始创建热力图,数据点数量:', data.contributions.length);
46 |
47 | // 预处理所有数据,创建全局的日期贡献映射
48 | const allContributionsMap = new Map();
49 | data.contributions.forEach(day => {
50 | try {
51 | const date = new Date(day.date);
52 | if (isNaN(date.getTime())) {
53 | console.warn('忽略无效日期:', day.date);
54 | return;
55 | }
56 | allContributionsMap.set(day.date, day.count);
57 | } catch (e) {
58 | console.error('处理贡献数据时出错:', e);
59 | }
60 | });
61 |
62 | // 按年份分组数据 - 主要用于年份选择器
63 | const yearlyData = {};
64 | data.contributions.forEach(day => {
65 | try {
66 | const date = new Date(day.date);
67 | if (isNaN(date.getTime())) {
68 | return;
69 | }
70 |
71 | const year = date.getFullYear();
72 | if (!yearlyData[year]) {
73 | yearlyData[year] = [];
74 | }
75 | yearlyData[year].push(day);
76 | } catch (e) {
77 | console.error('处理贡献数据时出错:', e);
78 | }
79 | });
80 |
81 | // 检查是否有有效的年份数据
82 | if (Object.keys(yearlyData).length === 0) {
83 | console.error('没有有效的年份数据');
84 |
85 | const emptyCalendar = document.createElement('div');
86 | emptyCalendar.className = 'contribution-calendar';
87 | emptyCalendar.innerHTML = '无法解析贡献数据
';
88 | heatmapContainer.appendChild(emptyCalendar);
89 |
90 | return;
91 | }
92 |
93 | // 创建年份选择器
94 | const yearSelector = document.createElement('div');
95 | yearSelector.className = 'year-selector';
96 |
97 | const years = Object.keys(yearlyData).sort().reverse();
98 | console.log('可用年份:', years);
99 |
100 | if (years.length === 0) {
101 | console.error('未找到任何年份数据');
102 | return;
103 | }
104 |
105 | const latestYear = years[0];
106 |
107 | years.forEach((year, index) => {
108 | const yearBtn = document.createElement('button');
109 | yearBtn.textContent = year;
110 | yearBtn.className = 'year-btn';
111 | yearBtn.style.animationDelay = `${index * 0.1}s`;
112 |
113 | if (year === latestYear) {
114 | yearBtn.classList.add('active');
115 | }
116 |
117 | yearBtn.onclick = () => {
118 | document.querySelectorAll('.year-btn').forEach(btn => {
119 | btn.classList.remove('active');
120 | });
121 | yearBtn.classList.add('active');
122 | showYearContributions(year, yearlyData[year], allContributionsMap);
123 | };
124 | yearSelector.appendChild(yearBtn);
125 | });
126 | heatmapContainer.appendChild(yearSelector);
127 |
128 | // 创建日历容器
129 | const calendarContainer = document.createElement('div');
130 | calendarContainer.className = 'contribution-calendar';
131 | heatmapContainer.appendChild(calendarContainer);
132 |
133 | // 添加图例
134 | addLegend(heatmapContainer);
135 |
136 | // 立即显示最新年份的数据
137 | try {
138 | showYearContributions(latestYear, yearlyData[latestYear], allContributionsMap);
139 | } catch (e) {
140 | console.error('显示年度贡献时出错:', e);
141 | calendarContainer.innerHTML = '显示贡献数据时出错
';
142 | }
143 |
144 | console.log('热力图创建完成');
145 | };
146 |
147 | const showYearContributions = (year, contributions, allContributionsMap) => {
148 | const calendarContainer = document.querySelector('.contribution-calendar');
149 | calendarContainer.classList.add('fade-out');
150 |
151 | setTimeout(() => {
152 | calendarContainer.innerHTML = '';
153 |
154 | // 创建月份标签
155 | const monthLabels = document.createElement('div');
156 | monthLabels.className = 'month-labels';
157 | ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'].forEach((month, index) => {
158 | const monthLabel = document.createElement('div');
159 | monthLabel.className = 'month-label';
160 | monthLabel.textContent = month + '月';
161 | monthLabel.style.left = `${index * 4.2 + 3}rem`;
162 | monthLabels.appendChild(monthLabel);
163 | });
164 | calendarContainer.appendChild(monthLabels);
165 |
166 | // 创建周标签
167 | const weekLabels = document.createElement('div');
168 | weekLabels.className = 'week-labels';
169 | ['日', '一', '二', '三', '四', '五', '六'].forEach(label => {
170 | const weekLabel = document.createElement('div');
171 | weekLabel.className = 'week-label';
172 | weekLabel.textContent = label;
173 | weekLabels.appendChild(weekLabel);
174 | });
175 | calendarContainer.appendChild(weekLabels);
176 |
177 | // 创建网格容器
178 | const gridContainer = document.createElement('div');
179 | gridContainer.className = 'contribution-grid';
180 |
181 | // 调试日期格式问题
182 | console.log('贡献数据第一项:', contributions[0]);
183 | console.log('处理的年份:', year);
184 |
185 | // 检查是否有未来日期
186 | const now = new Date();
187 | const currentYear = now.getFullYear();
188 | const currentMonth = now.getMonth() + 1;
189 | const currentDay = now.getDate();
190 | const currentDateStr = `${currentYear}-${String(currentMonth).padStart(2, '0')}-${String(currentDay).padStart(2, '0')}`;
191 |
192 | console.log('当前日期:', currentDateStr);
193 |
194 | // 生成当年所有日期并构建日历
195 | const yearNum = parseInt(year);
196 | const isLeapYearVal = isLeapYear(yearNum);
197 | const daysInYear = isLeapYearVal ? 366 : 365;
198 |
199 | console.log(`${year}年天数:`, daysInYear, isLeapYearVal ? '(闰年)' : '');
200 |
201 | // 获取该年第一天的星期
202 | const firstDayOfYear = new Date(yearNum, 0, 1);
203 | const firstDayWeekday = firstDayOfYear.getDay(); // 0 是星期日,1是星期一...
204 |
205 | console.log(`${year}年第一天是星期:`, firstDayWeekday);
206 |
207 | // 生成该年所有日期
208 | const allDates = [];
209 |
210 | // 添加上一年的日期以填充第一周
211 | for (let i = 0; i < firstDayWeekday; i++) {
212 | // 修正计算逻辑,确保日期正确
213 | const daysToSubtract = firstDayWeekday - i;
214 | const prevYearDate = new Date(yearNum, 0, 1);
215 | prevYearDate.setDate(prevYearDate.getDate() - daysToSubtract);
216 | allDates.push(prevYearDate);
217 | }
218 |
219 | // 添加当年所有日期
220 | for (let i = 0; i < daysInYear; i++) {
221 | const currentDate = new Date(yearNum, 0, 1);
222 | currentDate.setDate(currentDate.getDate() + i);
223 | allDates.push(currentDate);
224 | }
225 |
226 | // 如果需要,添加下一年的日期以填充最后一周
227 | const lastDayOfYear = new Date(yearNum, 11, 31);
228 | const lastDayWeekday = lastDayOfYear.getDay();
229 | if (lastDayWeekday < 6) {
230 | for (let i = 1; i <= 6 - lastDayWeekday; i++) {
231 | const nextYearDate = new Date(yearNum, 11, 31);
232 | nextYearDate.setDate(nextYearDate.getDate() + i);
233 | allDates.push(nextYearDate);
234 | }
235 | }
236 |
237 | console.log('日历总日期数:', allDates.length);
238 |
239 | // 创建日历网格
240 | const weeksCount = Math.ceil(allDates.length / 7);
241 | console.log('日历总周数:', weeksCount);
242 |
243 | // 以7x53的网格显示
244 | const grid = Array(7).fill().map(() => Array(weeksCount).fill(null));
245 |
246 | // 填充日期到网格
247 | allDates.forEach((date, index) => {
248 | const col = Math.floor(index / 7);
249 | const row = index % 7;
250 | grid[row][col] = date;
251 | });
252 |
253 | // 创建日历单元格
254 | grid.forEach((row, rowIndex) => {
255 | row.forEach((date, colIndex) => {
256 | if (!date) return;
257 |
258 | const cell = document.createElement('div');
259 | cell.className = 'contribution-cell';
260 |
261 | // 格式化日期为YYYY-MM-DD,与API返回格式一致
262 | const formattedYear = date.getFullYear();
263 | const formattedMonth = String(date.getMonth() + 1).padStart(2, '0');
264 | const formattedDay = String(date.getDate()).padStart(2, '0');
265 | const formattedDate = `${formattedYear}-${formattedMonth}-${formattedDay}`;
266 |
267 | // 检查是否为未来日期
268 | const isFutureDate = formattedDate > currentDateStr;
269 |
270 | // 获取贡献数 - 使用全局贡献映射,无论日期属于哪一年
271 | const count = isFutureDate ? 0 : (allContributionsMap.get(formattedDate) || 0);
272 |
273 | // 为调试添加日期属性
274 | cell.setAttribute('data-date', formattedDate);
275 | if (isFutureDate) {
276 | cell.setAttribute('data-future', 'true');
277 | }
278 |
279 | // 设置贡献等级
280 | if (isFutureDate) {
281 | cell.classList.add('future-date');
282 | cell.classList.add('level-0');
283 | } else {
284 | if (count === 0) cell.classList.add('level-0');
285 | else if (count <= 3) cell.classList.add('level-1');
286 | else if (count <= 6) cell.classList.add('level-2');
287 | else if (count <= 9) cell.classList.add('level-3');
288 | else cell.classList.add('level-4');
289 | }
290 |
291 | // 添加提示信息
292 | const displayDate = `${formattedYear}年${date.getMonth() + 1}月${date.getDate()}日`;
293 | cell.title = isFutureDate ? `${displayDate}: 未来日期` : `${displayDate}: ${count} 次贡献`;
294 |
295 | // 如果日期不在当前年份内,添加淡化效果
296 | if (formattedYear !== yearNum) {
297 | cell.classList.add('outside-month');
298 | }
299 |
300 | // 添加动画延迟
301 | const delay = Math.min((rowIndex * weeksCount + colIndex) * 0.001, 0.3);
302 | cell.style.animationDelay = `${delay}s`;
303 |
304 | gridContainer.appendChild(cell);
305 | });
306 | });
307 |
308 | calendarContainer.appendChild(gridContainer);
309 |
310 | calendarContainer.classList.remove('fade-out');
311 | calendarContainer.classList.add('fade-in');
312 |
313 | setTimeout(() => {
314 | calendarContainer.classList.remove('fade-in');
315 | }, 300);
316 | }, 300);
317 | };
318 |
319 | // 辅助函数:判断是否为闰年
320 | function isLeapYear(year) {
321 | return ((year % 4 === 0) && (year % 100 !== 0)) || (year % 400 === 0);
322 | }
323 |
324 | const addLegend = (container) => {
325 | const legend = document.createElement('div');
326 | legend.className = 'contribution-legend';
327 | legend.innerHTML = `
328 | 贡献度:
329 |
333 |
337 |
341 |
345 |
349 | `;
350 | container.appendChild(legend);
351 | };
352 |
353 | // 假设我们从API获取数据后调用这个函数
354 | fetch('https://github-contributions-api.jogruber.de/v4/WuXiaoMuer')
355 | .then(response => response.json())
356 | .then(data => createHeatmap(data))
357 | .catch(error => console.error('Error:', error));
--------------------------------------------------------------------------------
/yearly_contributions.js:
--------------------------------------------------------------------------------
1 | let yearlyChart = null;
2 |
3 | const createYearlyChart = (data) => {
4 | // 获取当前日期,用于筛选未来日期
5 | const now = new Date();
6 | const currentDateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
7 |
8 | console.log('年度图表当前日期:', currentDateStr);
9 |
10 | // 过滤掉未来日期和无效日期
11 | const validContributions = data.contributions.filter(day => {
12 | // 检查是否为有效日期
13 | const dateObj = new Date(day.date);
14 | if (isNaN(dateObj.getTime())) {
15 | console.warn('年度图表忽略无效日期:', day);
16 | return false;
17 | }
18 |
19 | // 检查是否为未来日期
20 | return day.date <= currentDateStr;
21 | });
22 |
23 | console.log('有效贡献数据条目数:', validContributions.length);
24 |
25 | // 计算总贡献(所有年份的总和)
26 | const totalContributions = validContributions.reduce((sum, day) => sum + day.count, 0);
27 | animateNumber('totalContributions', totalContributions);
28 |
29 | // 计算连续贡献
30 | calculateStreaks(validContributions);
31 |
32 | // 处理年度数据
33 | const yearlyData = {};
34 | validContributions.forEach(day => {
35 | // 从日期字符串中提取年份
36 | const year = day.date.split('-')[0];
37 | if (!yearlyData[year]) {
38 | yearlyData[year] = 0;
39 | }
40 | yearlyData[year] += day.count;
41 | });
42 |
43 | const years = Object.keys(yearlyData).sort();
44 | const contributions = years.map(year => yearlyData[year]);
45 |
46 | const ctx = document.getElementById('yearlyChart').getContext('2d');
47 |
48 | if (yearlyChart) {
49 | yearlyChart.destroy();
50 | }
51 |
52 | // 生成背景颜色渐变
53 | const backgroundColors = contributions.map(value => {
54 | // 根据贡献值计算颜色深浅
55 | const intensity = Math.min(Math.max(value / 500, 0.3), 0.9);
56 | return `rgba(46, 164, 79, ${intensity})`;
57 | });
58 |
59 | yearlyChart = new Chart(ctx, {
60 | type: 'bar',
61 | data: {
62 | labels: years,
63 | datasets: [{
64 | label: '年度贡献数',
65 | data: contributions,
66 | backgroundColor: backgroundColors,
67 | borderColor: 'rgba(46, 164, 79, 1)',
68 | borderWidth: 1,
69 | borderRadius: 8,
70 | barThickness: 40
71 | }]
72 | },
73 | options: {
74 | responsive: true,
75 | animation: {
76 | duration: 2000,
77 | easing: 'easeOutQuart'
78 | },
79 | scales: {
80 | y: {
81 | beginAtZero: true,
82 | grid: {
83 | display: false
84 | },
85 | ticks: {
86 | precision: 0
87 | }
88 | },
89 | x: {
90 | grid: {
91 | display: false
92 | }
93 | }
94 | },
95 | plugins: {
96 | legend: {
97 | display: false
98 | },
99 | tooltip: {
100 | callbacks: {
101 | title: function(context) {
102 | return context[0].label + '年';
103 | },
104 | label: function(context) {
105 | return `贡献数: ${context.raw}`;
106 | }
107 | }
108 | }
109 | }
110 | }
111 | });
112 | };
113 |
114 | // 计算连续贡献天数
115 | const calculateStreaks = (contributions) => {
116 | // 确保数据非空
117 | if (!contributions || contributions.length === 0) {
118 | console.warn('计算连续贡献时发现空数据');
119 | animateNumber('currentStreak', 0);
120 | animateNumber('longestStreak', 0);
121 | return;
122 | }
123 |
124 | // 获取当前日期(不含时分秒)
125 | const today = new Date();
126 | today.setHours(0, 0, 0, 0);
127 |
128 | // 确保按日期排序,从最近到最早
129 | const sortedContributions = [...contributions].sort((a, b) =>
130 | new Date(b.date).getTime() - new Date(a.date).getTime()
131 | );
132 |
133 | console.log('计算连续贡献,共有数据点:', sortedContributions.length);
134 | console.log('最近的贡献日期:', sortedContributions[0]?.date);
135 |
136 | // 创建日期到贡献的映射
137 | const contributionMap = new Map();
138 | sortedContributions.forEach(item => {
139 | contributionMap.set(item.date, item.count);
140 | });
141 |
142 | // 计算当前连续贡献
143 | let currentStreak = 0;
144 | let checkDate = new Date(today);
145 |
146 | // 检查最近一次贡献是否是今天或昨天
147 | const latestContribution = sortedContributions[0];
148 | const latestDate = latestContribution ? new Date(latestContribution.date) : null;
149 | latestDate?.setHours(0, 0, 0, 0);
150 |
151 | const oneDayMs = 24 * 60 * 60 * 1000;
152 | const daysSinceLatest = latestDate ? Math.round((today - latestDate) / oneDayMs) : null;
153 |
154 | console.log('最新贡献距今天数:', daysSinceLatest);
155 |
156 | // 如果最近的贡献不是今天或昨天,当前连续贡献为0
157 | if (daysSinceLatest === null || daysSinceLatest > 1) {
158 | console.log('最新贡献不是今天或昨天,当前连续为0');
159 | currentStreak = 0;
160 | } else {
161 | // 从最近的贡献日期开始往前检查
162 | let streakEndDate = new Date(latestDate);
163 |
164 | // 如果最新贡献是昨天而不是今天,那么从昨天开始检查
165 | if (daysSinceLatest === 1) {
166 | console.log('最新贡献是昨天');
167 | streakEndDate = new Date(latestDate);
168 | } else {
169 | console.log('最新贡献是今天');
170 | }
171 |
172 | let keepCounting = true;
173 |
174 | while (keepCounting) {
175 | // 格式化日期为YYYY-MM-DD (使用UTC避免时区问题)
176 | const year = streakEndDate.getFullYear();
177 | const month = String(streakEndDate.getMonth() + 1).padStart(2, '0');
178 | const day = String(streakEndDate.getDate()).padStart(2, '0');
179 | const dateStr = `${year}-${month}-${day}`;
180 |
181 | const count = contributionMap.get(dateStr) || 0;
182 |
183 | if (count > 0) {
184 | currentStreak++;
185 | console.log(`${dateStr} 有贡献,当前连续: ${currentStreak}`);
186 |
187 | // 前一天 (使用setDate以正确处理月份和年份边界)
188 | streakEndDate.setDate(streakEndDate.getDate() - 1);
189 | } else {
190 | console.log(`${dateStr} 无贡献,当前连续中断`);
191 | keepCounting = false;
192 | }
193 | }
194 | }
195 |
196 | // 计算最长连续贡献
197 | let longestStreak = 0;
198 | let currentLength = 0;
199 | let previousDate = null;
200 |
201 | // 按日期排序,从最早到最近
202 | const chronologicalContributions = [...contributions].sort((a, b) =>
203 | new Date(a.date).getTime() - new Date(b.date).getTime()
204 | );
205 |
206 | for (let i = 0; i < chronologicalContributions.length; i++) {
207 | const day = chronologicalContributions[i];
208 | const currentDate = new Date(day.date);
209 | currentDate.setHours(0, 0, 0, 0);
210 |
211 | // 检查是否是连续的日期
212 | if (previousDate) {
213 | const diffDays = Math.round((currentDate - previousDate) / oneDayMs);
214 |
215 | if (diffDays > 1) {
216 | // 连续中断,重置当前记录
217 | currentLength = 0;
218 | }
219 | }
220 |
221 | if (day.count > 0) {
222 | currentLength++;
223 | longestStreak = Math.max(longestStreak, currentLength);
224 | } else {
225 | currentLength = 0;
226 | }
227 |
228 | previousDate = currentDate;
229 | }
230 |
231 | // 确保最长连续贡献至少与当前连续贡献一样多
232 | longestStreak = Math.max(longestStreak, currentStreak);
233 |
234 | // 更新统计数据
235 | animateNumber('currentStreak', currentStreak);
236 | animateNumber('longestStreak', longestStreak);
237 |
238 | console.log(`当前连续贡献: ${currentStreak}天, 最长连续贡献: ${longestStreak}天`);
239 | };
240 |
241 | // 数字动画函数
242 | const animateNumber = (elementId, target) => {
243 | const element = document.getElementById(elementId);
244 | const duration = 1500;
245 | const start = parseInt(element.textContent) || 0;
246 | const increment = (target - start) / (duration / 16);
247 | let current = start;
248 |
249 | const animate = () => {
250 | current += increment;
251 | if ((increment > 0 && current >= target) ||
252 | (increment < 0 && current <= target)) {
253 | element.textContent = target;
254 | return;
255 | }
256 | element.textContent = Math.round(current);
257 | requestAnimationFrame(animate);
258 | };
259 |
260 | animate();
261 | };
262 |
263 | // 重置统计数据
264 | const resetStats = () => {
265 | // 清空所有统计数据
266 | ['totalContributions', 'currentStreak', 'longestStreak'].forEach(id => {
267 | const element = document.getElementById(id);
268 | if (element) {
269 | element.style.animation = 'none';
270 | element.offsetHeight; // 触发重排
271 | element.style.animation = null;
272 | element.textContent = '0';
273 | }
274 | });
275 |
276 | // 清空图表
277 | if (yearlyChart) {
278 | yearlyChart.destroy();
279 | yearlyChart = null;
280 | }
281 |
282 | // 清空月度图表
283 | if (window.monthlyChart) {
284 | window.monthlyChart.destroy();
285 | window.monthlyChart = null;
286 | }
287 |
288 | console.log('所有统计数据已重置');
289 | };
290 |
291 | // 刷新数据的函数
292 | const refreshData = () => {
293 | const username = document.getElementById('username').value.trim();
294 | if (!username) {
295 | showError('请输入用户名');
296 | return;
297 | }
298 |
299 | // 更新用户头像
300 | const userAvatar = document.getElementById('userAvatar');
301 | userAvatar.src = `https://github.com/${username}.png`;
302 | userAvatar.style.animation = 'none';
303 | userAvatar.offsetHeight; // 触发重排
304 | userAvatar.style.animation = 'scaleIn 0.3s ease';
305 |
306 | const refreshBtn = document.getElementById('refreshBtn');
307 | const spinner = refreshBtn.querySelector('.loading-spinner');
308 | const buttonText = refreshBtn.querySelector('.button-text');
309 |
310 | refreshBtn.disabled = true;
311 | spinner.style.display = 'block';
312 | buttonText.textContent = '加载中...';
313 |
314 | // 先重置统计数字,但不清空图表
315 | ['totalContributions', 'currentStreak', 'longestStreak'].forEach(id => {
316 | const element = document.getElementById(id);
317 | if (element) {
318 | element.textContent = '0';
319 | }
320 | });
321 |
322 | // 添加调试信息
323 | console.log('开始请求GitHub贡献数据:', username);
324 |
325 | // 更新用户访问计数
326 | const userCounter = document.querySelector('.user-counter .counter-image');
327 | if (userCounter) {
328 | userCounter.src = `https://profile-counter.glitch.me/${username}/count.svg`;
329 | }
330 |
331 | fetch(`https://github-contributions-api.jogruber.de/v4/${username}`)
332 | .then(response => {
333 | if (!response.ok) {
334 | console.error('API响应错误:', response.status, response.statusText);
335 | throw new Error(`API响应错误: ${response.status}`);
336 | }
337 | return response.json();
338 | })
339 | .then(data => {
340 | console.log('已获得数据:', data);
341 |
342 | // 检查数据格式是否正确
343 | if (!data || !data.contributions || !Array.isArray(data.contributions) || data.contributions.length === 0) {
344 | console.error('数据格式错误或为空:', data);
345 | throw new Error('获取的数据格式不正确或为空');
346 | }
347 |
348 | // 检查是否有invalid date
349 | if (data.contributions.some(item => !item.date || isNaN(new Date(item.date).getTime()))) {
350 | console.error('数据中包含无效日期');
351 | // 修复日期格式
352 | data.contributions = data.contributions.filter(item => {
353 | if (!item.date || isNaN(new Date(item.date).getTime())) {
354 | console.warn('跳过无效日期:', item);
355 | return false;
356 | }
357 | return true;
358 | });
359 |
360 | if (data.contributions.length === 0) {
361 | throw new Error('过滤无效日期后没有可用数据');
362 | }
363 | }
364 |
365 | // 更新图表和热力图
366 | createYearlyChart(data);
367 | createHeatmap(data);
368 |
369 | // 调用月度图表创建函数
370 | if (window.createMonthlyChart) {
371 | window.createMonthlyChart(data);
372 | }
373 | })
374 | .catch(error => {
375 | console.error('Error:', error);
376 | showError('获取数据时出错: ' + (error.message || '请检查用户名并稍后重试'));
377 | resetStats();
378 | })
379 | .finally(() => {
380 | refreshBtn.disabled = false;
381 | spinner.style.display = 'none';
382 | buttonText.textContent = '刷新数据';
383 | });
384 | };
385 |
386 | // 修改错误提示
387 | const showError = (message) => {
388 | const errorDiv = document.createElement('div');
389 | errorDiv.className = 'error-message';
390 | errorDiv.innerHTML = `
391 |
394 | ${message}
395 | `;
396 |
397 | const existingError = document.querySelector('.error-message');
398 | if (existingError) {
399 | existingError.remove();
400 | }
401 |
402 | const inputSection = document.querySelector('.input-section');
403 | inputSection.appendChild(errorDiv);
404 |
405 | setTimeout(() => {
406 | errorDiv.classList.add('fade-out');
407 | setTimeout(() => errorDiv.remove(), 300);
408 | }, 3000);
409 | };
410 |
411 | // 添加事件监听
412 | document.addEventListener('DOMContentLoaded', () => {
413 | // 刷新按钮点击事件
414 | const refreshBtn = document.getElementById('refreshBtn');
415 | if (refreshBtn) {
416 | refreshBtn.addEventListener('click', refreshData);
417 | }
418 |
419 | // 用户名输入框回车键提交
420 | const usernameInput = document.getElementById('username');
421 | if (usernameInput) {
422 | usernameInput.addEventListener('keypress', (event) => {
423 | if (event.key === 'Enter') {
424 | event.preventDefault();
425 | refreshData();
426 | }
427 | });
428 | }
429 |
430 | // 页面加载时获取默认用户数据
431 | setTimeout(() => {
432 | refreshData();
433 | }, 100);
434 |
435 | console.log('事件监听器已设置');
436 | });
437 |
438 | // 添加导航栏自动隐藏
439 | let lastScrollY = window.scrollY;
440 | const navbar = document.querySelector('.navbar');
441 |
442 | window.addEventListener('scroll', () => {
443 | const currentScrollY = window.scrollY;
444 |
445 | if (currentScrollY > lastScrollY) {
446 | navbar?.classList.add('hidden');
447 | } else {
448 | navbar?.classList.remove('hidden');
449 | }
450 |
451 | lastScrollY = currentScrollY;
452 | });
--------------------------------------------------------------------------------
/styles.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'Hubot Sans';
3 | src:
4 | url('./fonts/Hubot-Sans.woff2') format('woff2 supports variations'),
5 | url('./fonts/Hubot-Sans.woff2') format('woff2-variations');
6 | font-weight: 200 500;
7 | font-stretch: 400% 125%;
8 | }
9 |
10 | html {
11 | font-family: 'Hubot Sans';
12 | }
13 |
14 | :root {
15 | --primary-color: #2ea44f;
16 | --primary-hover: #2c974b;
17 | --bg-color: #f6f8fa;
18 | --card-bg: #ffffff;
19 | --text-primary: #24292e;
20 | --text-secondary: #586069;
21 | --border-color: #e1e4e8;
22 | --animation-duration: 0.6s;
23 | }
24 |
25 | * {
26 | margin: 0;
27 | padding: 0;
28 | box-sizing: border-box;
29 | }
30 |
31 | body {
32 | font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
33 | background-color: var(--bg-color);
34 | color: var(--text-primary);
35 | line-height: 1.5;
36 | }
37 |
38 | .container {
39 | max-width: 1200px;
40 | margin: 0 auto;
41 | padding: 2rem;
42 | padding-top: 1rem;
43 | margin-top: 80px;
44 | }
45 |
46 | .header {
47 | text-align: center;
48 | margin-bottom: 2rem;
49 | font-family: 'Hubot Sans';
50 | }
51 |
52 | .header h1 {
53 | font-size: 2.5rem;
54 | margin-bottom: 1.5rem;
55 | background: linear-gradient(120deg, #2ea44f, #238636);
56 | -webkit-background-clip: text;
57 | background-clip: text;
58 | -webkit-text-fill-color: transparent;
59 | animation: fadeInDown var(--animation-duration) ease;
60 | }
61 |
62 | .input-section {
63 | display: flex;
64 | gap: 1rem;
65 | justify-content: center;
66 | margin-bottom: 2rem;
67 | animation: fadeInUp var(--animation-duration) ease;
68 | }
69 |
70 | /* 输入框组样式 */
71 | .input-group {
72 | position: relative;
73 | flex: 1;
74 | max-width: 300px;
75 | }
76 |
77 | .input-group label {
78 | display: block;
79 | color: var(--text-secondary);
80 | font-size: 0.875rem;
81 | margin-bottom: 0.5rem;
82 | }
83 |
84 | .input-wrapper {
85 | position: relative;
86 | display: flex;
87 | align-items: center;
88 | gap: 0.75rem;
89 | }
90 |
91 | .user-avatar {
92 | width: 48px;
93 | height: 48px;
94 | border-radius: 50%;
95 | border: 2px solid var(--primary-color);
96 | transition: all 0.3s ease;
97 | animation: scaleIn 0.3s ease;
98 | }
99 |
100 | #username {
101 | flex: 1;
102 | padding: 0.75rem 1rem;
103 | font-size: 1rem;
104 | background: transparent;
105 | border: none;
106 | border-bottom: 2px solid var(--border-color);
107 | transition: all 0.3s ease;
108 | }
109 |
110 | #username:hover {
111 | border-bottom-color: var(--text-secondary);
112 | }
113 |
114 | #username:focus {
115 | outline: none;
116 | border-bottom-color: var(--primary-color);
117 | }
118 |
119 | /* 头像动画 */
120 | @keyframes scaleIn {
121 | from {
122 | transform: scale(0.8);
123 | opacity: 0;
124 | }
125 |
126 | to {
127 | transform: scale(1);
128 | opacity: 1;
129 | }
130 | }
131 |
132 | /* 年份选择器样式 */
133 | .year-selector {
134 | display: flex;
135 | gap: 0.5rem;
136 | margin-bottom: 1.5rem;
137 | padding: 0.5rem;
138 | background: var(--bg-color);
139 | border-radius: 8px;
140 | overflow-x: auto;
141 | -webkit-overflow-scrolling: touch;
142 | }
143 |
144 | .year-btn {
145 | padding: 0.5rem 1.25rem;
146 | border: none;
147 | background: transparent;
148 | color: var(--text-secondary);
149 | font-size: 0.9rem;
150 | font-weight: 500;
151 | cursor: pointer;
152 | border-radius: 6px;
153 | transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
154 | position: relative;
155 | overflow: hidden;
156 | }
157 |
158 | .year-btn::before {
159 | content: '';
160 | position: absolute;
161 | top: 0;
162 | left: 0;
163 | width: 100%;
164 | height: 100%;
165 | background: var(--primary-color);
166 | border-radius: 6px;
167 | transform: scaleX(0);
168 | transform-origin: left;
169 | transition: transform 0.3s ease;
170 | z-index: -1;
171 | }
172 |
173 | .year-btn:hover {
174 | color: var(--primary-color);
175 | }
176 |
177 | .year-btn.active {
178 | color: white;
179 | transform: scale(1.05);
180 | }
181 |
182 | .year-btn.active::before {
183 | transform: scaleX(1);
184 | }
185 |
186 | /* 刷新按钮样式优化 */
187 | #refreshBtn {
188 | padding: 0.75rem 1.5rem;
189 | background: linear-gradient(45deg, var(--primary-color), var(--primary-hover));
190 | color: white;
191 | border: none;
192 | border-radius: 8px;
193 | font-size: 1rem;
194 | cursor: pointer;
195 | transition: all 0.3s ease;
196 | display: flex;
197 | align-items: center;
198 | gap: 0.5rem;
199 | position: relative;
200 | overflow: hidden;
201 | box-shadow: 0 2px 4px rgba(46, 164, 79, 0.2);
202 | }
203 |
204 | #refreshBtn:hover {
205 | transform: translateY(-2px);
206 | box-shadow: 0 4px 8px rgba(46, 164, 79, 0.3);
207 | }
208 |
209 | #refreshBtn:active {
210 | transform: translateY(0);
211 | box-shadow: 0 2px 4px rgba(46, 164, 79, 0.2);
212 | }
213 |
214 | .loading-spinner {
215 | display: none;
216 | width: 16px;
217 | height: 16px;
218 | border: 2px solid #ffffff;
219 | border-top: 2px solid transparent;
220 | border-radius: 50%;
221 | animation: spin 1s linear infinite;
222 | }
223 |
224 | .stats-container {
225 | display: grid;
226 | grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
227 | gap: 1.5rem;
228 | margin-bottom: 2rem;
229 | font-family: 'Hubot Sans';
230 | }
231 |
232 | .stats-card {
233 | background: var(--card-bg);
234 | padding: 1.5rem;
235 | border-radius: 12px;
236 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
237 | text-align: center;
238 | animation: fadeInUp var(--animation-duration) ease;
239 | transition: transform 0.3s ease;
240 | position: relative;
241 | overflow: hidden;
242 | }
243 |
244 | .stats-card::before {
245 | content: '';
246 | position: absolute;
247 | top: 0;
248 | left: 0;
249 | width: 100%;
250 | height: 4px;
251 | background: linear-gradient(90deg, var(--primary-color), var(--primary-hover));
252 | opacity: 0;
253 | transition: opacity 0.3s ease;
254 | }
255 |
256 | .stats-card:hover::before {
257 | opacity: 1;
258 | }
259 |
260 | .stats-card h3 {
261 | color: var(--text-secondary);
262 | font-size: 1rem;
263 | margin-bottom: 0.5rem;
264 | }
265 |
266 | .stats-card .number {
267 | font-size: 2.5rem;
268 | font-weight: 600;
269 | color: var(--primary-color);
270 | background: linear-gradient(120deg, var(--primary-color), var(--primary-hover));
271 | -webkit-background-clip: text;
272 | background-clip: text;
273 | -webkit-text-fill-color: transparent;
274 | transition: all 0.3s ease;
275 | }
276 |
277 | .chart-card {
278 | background: var(--card-bg);
279 | padding: 1.5rem;
280 | border-radius: 12px;
281 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
282 | margin-bottom: 1.5rem;
283 | animation: fadeInUp var(--animation-duration) ease;
284 | transition: all 0.3s ease;
285 | }
286 |
287 | .chart-card:hover {
288 | transform: translateY(-2px);
289 | box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
290 | }
291 |
292 | /* 热力图样式 */
293 | .contribution-calendar {
294 | position: relative;
295 | padding: 3.5rem 2.5rem 2rem;
296 | background: var(--card-bg);
297 | border-radius: 12px;
298 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
299 | overflow: hidden;
300 | }
301 |
302 | .month-labels {
303 | position: absolute;
304 | top: 1.2rem;
305 | left: 5rem;
306 | right: 1rem;
307 | height: 20px;
308 | }
309 |
310 | .month-label {
311 | position: absolute;
312 | font-size: 0.85rem;
313 | color: var(--text-secondary);
314 | transform-origin: left;
315 | white-space: nowrap;
316 | }
317 |
318 | .week-labels {
319 | position: absolute;
320 | left: 1.2rem;
321 | top: 3.6rem;
322 | display: flex;
323 | flex-direction: column;
324 | gap: 0;
325 | height: calc(7 * 14px + 6 * 2px);
326 | justify-content: space-between;
327 | }
328 |
329 | .week-label {
330 | font-size: 0.8rem;
331 | color: var(--text-secondary);
332 | text-align: center;
333 | width: 1.5rem;
334 | height: 14px;
335 | line-height: 14px;
336 | opacity: 0.8;
337 | }
338 |
339 | .contribution-grid {
340 | margin-left: 3rem;
341 | display: grid;
342 | grid-template-columns: repeat(53, 14px);
343 | grid-template-rows: repeat(7, 14px);
344 | gap: 2px;
345 | }
346 |
347 | .contribution-cell {
348 | width: 14px;
349 | height: 14px;
350 | border-radius: 2px;
351 | transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
352 | animation: scaleIn 0.15s ease forwards;
353 | transform: scale(0);
354 | opacity: 0;
355 | cursor: pointer;
356 | }
357 |
358 | .contribution-cell:hover {
359 | transform: scale(1.2);
360 | }
361 |
362 | .contribution-cell.outside-month {
363 | opacity: 0.7;
364 | border: 1px solid rgba(0, 0, 0, 0.03);
365 | }
366 |
367 | @keyframes scaleIn {
368 | from {
369 | transform: scale(0);
370 | opacity: 0;
371 | }
372 |
373 | to {
374 | transform: scale(1);
375 | opacity: 1;
376 | }
377 | }
378 |
379 | /* 贡献等级颜色 */
380 | .level-0 {
381 | background-color: #ebedf0;
382 | }
383 |
384 | .level-1 {
385 | background-color: #9be9a8;
386 | }
387 |
388 | .level-2 {
389 | background-color: #40c463;
390 | }
391 |
392 | .level-3 {
393 | background-color: #30a14e;
394 | }
395 |
396 | .level-4 {
397 | background-color: #216e39;
398 | }
399 |
400 | @keyframes fadeIn {
401 | from {
402 | opacity: 0;
403 | }
404 |
405 | to {
406 | opacity: 1;
407 | }
408 | }
409 |
410 | /* 动画关键帧 */
411 | @keyframes fadeInDown {
412 | from {
413 | opacity: 0;
414 | transform: translateY(-20px);
415 | }
416 |
417 | to {
418 | opacity: 1;
419 | transform: translateY(0);
420 | }
421 | }
422 |
423 | @keyframes fadeInUp {
424 | from {
425 | opacity: 0;
426 | transform: translateY(20px);
427 | }
428 |
429 | to {
430 | opacity: 1;
431 | transform: translateY(0);
432 | }
433 | }
434 |
435 | @keyframes spin {
436 | to {
437 | transform: rotate(360deg);
438 | }
439 | }
440 |
441 | /* 响应式设计 */
442 | @media (max-width: 768px) {
443 | .container {
444 | padding: 1rem;
445 | }
446 |
447 | .header h1 {
448 | font-size: 2rem;
449 | }
450 |
451 | .input-section {
452 | flex-direction: column;
453 | }
454 |
455 | #username {
456 | width: 100%;
457 | }
458 |
459 | .navbar {
460 | padding: 1rem;
461 | flex-direction: column;
462 | gap: 1rem;
463 | }
464 |
465 | .nav-links {
466 | gap: 1rem;
467 | }
468 |
469 | .contribution-calendar {
470 | padding: 3rem 1rem 1.5rem;
471 | }
472 |
473 | .week-labels {
474 | left: 0.5rem;
475 | }
476 |
477 | .contribution-grid {
478 | margin-left: 2rem;
479 | }
480 |
481 | .month-labels {
482 | left: 2rem;
483 | }
484 | }
485 |
486 | /* 图例 */
487 | .contribution-legend {
488 | display: flex;
489 | align-items: center;
490 | padding-top: 1rem;
491 | margin-top: 1rem;
492 | border-top: 1px solid var(--border-color);
493 | justify-content: center;
494 | gap: 0.75rem;
495 | color: var(--text-secondary);
496 | font-size: 0.875rem;
497 | }
498 |
499 | .legend-item {
500 | display: flex;
501 | align-items: center;
502 | padding: 4px 8px;
503 | border-radius: 4px;
504 | transition: all 0.2s ease;
505 | gap: 4px;
506 | }
507 |
508 | .legend-color {
509 | width: 12px;
510 | height: 12px;
511 | border-radius: 2px;
512 | }
513 |
514 | /* 更新热力图容器样式 */
515 | #heatmap {
516 | margin: 20px 0;
517 | background: var(--card-bg);
518 | border-radius: 12px;
519 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
520 | padding: 1.5rem;
521 | overflow-x: auto;
522 | }
523 |
524 | .heatmap-title {
525 | font-size: 1.2rem;
526 | color: var(--text-primary);
527 | margin: 0;
528 | line-height: 1.4;
529 | }
530 |
531 | .heatmap-header {
532 | display: flex;
533 | align-items: center;
534 | gap: 0.5rem;
535 | margin-bottom: 1rem;
536 | }
537 |
538 | .contribution-icon {
539 | color: var(--primary-color);
540 | display: flex;
541 | align-items: center;
542 | animation: rotate-in 0.6s ease;
543 | margin-top: 2px;
544 | /* 微调图标位置 */
545 | }
546 |
547 | /* 新增动画关键帧 */
548 | @keyframes rotate-in {
549 | from {
550 | transform: rotate(-180deg);
551 | opacity: 0;
552 | }
553 |
554 | to {
555 | transform: rotate(0);
556 | opacity: 1;
557 | }
558 | }
559 |
560 | @keyframes slide-in-right {
561 | from {
562 | transform: translateX(20px);
563 | opacity: 0;
564 | }
565 |
566 | to {
567 | transform: translateX(0);
568 | opacity: 1;
569 | }
570 | }
571 |
572 | @keyframes pulse {
573 | 0% {
574 | transform: scale(1);
575 | }
576 |
577 | 50% {
578 | transform: scale(1.05);
579 | }
580 |
581 | 100% {
582 | transform: scale(1);
583 | }
584 | }
585 |
586 | .fade-out {
587 | animation: fadeOut 0.3s ease forwards;
588 | }
589 |
590 | @keyframes fadeOut {
591 | from {
592 | opacity: 1;
593 | transform: translateY(0);
594 | }
595 |
596 | to {
597 | opacity: 0;
598 | transform: translateY(-10px);
599 | }
600 | }
601 |
602 | /* 错误消息样式 */
603 | .error-message {
604 | display: flex;
605 | align-items: center;
606 | justify-content: center;
607 | gap: 0.5rem;
608 | color: #cf222e;
609 | background-color: #ffebe9;
610 | border: 1px solid #ff818266;
611 | border-radius: 6px;
612 | padding: 0.75rem;
613 | margin-top: 0.5rem;
614 | font-size: 0.875rem;
615 | width: 100%;
616 | animation: slideInDown 0.3s ease;
617 | }
618 |
619 | .error-icon {
620 | fill: currentColor;
621 | }
622 |
623 | /* 加载状态样式 */
624 | #refreshBtn:disabled {
625 | opacity: 0.7;
626 | cursor: not-allowed;
627 | }
628 |
629 | /* 新增动画 */
630 | @keyframes slideInDown {
631 | from {
632 | transform: translateY(-10px);
633 | opacity: 0;
634 | }
635 |
636 | to {
637 | transform: translateY(0);
638 | opacity: 1;
639 | }
640 | }
641 |
642 | /* 页脚样式 */
643 | .footer {
644 | margin-top: 2rem;
645 | padding: 2rem;
646 | border-top: 1px solid var(--border-color);
647 | display: flex;
648 | flex-direction: column;
649 | align-items: center;
650 | gap: 1rem;
651 | text-align: center;
652 | }
653 |
654 | .author {
655 | display: flex;
656 | align-items: center;
657 | }
658 |
659 | .author-link {
660 | display: flex;
661 | align-items: center;
662 | gap: 0.75rem;
663 | text-decoration: none;
664 | color: var(--text-primary);
665 | font-weight: 500;
666 | transition: opacity 0.2s ease;
667 | margin-right: 10px;
668 | }
669 |
670 | .author-link:hover {
671 | opacity: 0.8;
672 | }
673 |
674 | .author-avatar {
675 | width: 40px;
676 | height: 40px;
677 | border-radius: 50%;
678 | border: 2px solid var(--border-color);
679 | }
680 |
681 | .repo-info {
682 | display: flex;
683 | flex-direction: column;
684 | align-items: center;
685 | gap: 0.5rem;
686 | }
687 |
688 | .repo-link {
689 | display: flex;
690 | align-items: center;
691 | gap: 0.5rem;
692 | text-decoration: none;
693 | color: var(--text-secondary);
694 | font-size: 0.875rem;
695 | transition: color 0.2s ease;
696 | }
697 |
698 | .repo-link:hover {
699 | color: var(--primary-color);
700 | }
701 |
702 | .license {
703 | font-size: 0.75rem;
704 | color: var(--text-secondary);
705 | }
706 |
707 | .license a {
708 | color: inherit;
709 | text-decoration: none;
710 | }
711 |
712 | .license a:hover {
713 | text-decoration: underline;
714 | }
715 |
716 | /* 导航栏样式 */
717 | .navbar {
718 | background: var(--card-bg);
719 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
720 | padding: 1rem 2rem;
721 | position: fixed;
722 | top: 0;
723 | left: 0;
724 | right: 0;
725 | z-index: 100;
726 | display: flex;
727 | justify-content: space-between;
728 | align-items: center;
729 | transform: translateY(0);
730 | transition: transform 0.3s ease;
731 | }
732 |
733 | .navbar.hidden {
734 | transform: translateY(-100%);
735 | }
736 |
737 | .nav-brand {
738 | display: flex;
739 | align-items: center;
740 | gap: 1rem;
741 | }
742 |
743 | .nav-avatar {
744 | width: 32px;
745 | height: 32px;
746 | border-radius: 50%;
747 | border: 2px solid var(--primary-color);
748 | }
749 |
750 | .nav-title {
751 | font-size: 1.25rem;
752 | font-weight: 600;
753 | color: var(--text-primary);
754 | background: linear-gradient(120deg, var(--primary-color), var(--primary-hover));
755 | -webkit-background-clip: text;
756 | background-clip: text;
757 | -webkit-text-fill-color: transparent;
758 | font-family: 'Hubot Sans';
759 | }
760 |
761 | .nav-links {
762 | display: flex;
763 | gap: 2rem;
764 | }
765 |
766 | .nav-link {
767 | text-decoration: none;
768 | color: var(--text-secondary);
769 | font-size: 0.95rem;
770 | font-weight: 500;
771 | padding: 0.5rem 0;
772 | position: relative;
773 | transition: color 0.3s ease;
774 | }
775 |
776 | .nav-link::after {
777 | content: '';
778 | position: absolute;
779 | bottom: 0;
780 | left: 0;
781 | width: 100%;
782 | height: 2px;
783 | background: var(--primary-color);
784 | transform: scaleX(0);
785 | transform-origin: right;
786 | transition: transform 0.3s ease;
787 | }
788 |
789 | .nav-link:hover {
790 | color: var(--primary-color);
791 | }
792 |
793 | .nav-link:hover::after {
794 | transform: scaleX(1);
795 | transform-origin: left;
796 | }
797 |
798 | .nav-link.active {
799 | color: var(--primary-color);
800 | }
801 |
802 | /* 添加GitHub图标容器样式 */
803 | .github-icon-wrapper {
804 | display: flex;
805 | align-items: center;
806 | color: var(--text-secondary);
807 | margin-right: 8px;
808 | order: -1;
809 | }
810 |
811 | /* 访问量统计容器样式 */
812 | .visit-counter {
813 | margin-top: 0;
814 | margin-bottom: 2rem;
815 | text-align: center;
816 | display: flex;
817 | flex-direction: row;
818 | justify-content: center;
819 | align-items: center;
820 | gap: 3rem;
821 | padding: 1.5rem;
822 | background: var(--card-bg);
823 | border-radius: 12px;
824 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
825 | }
826 |
827 | .counter-label {
828 | color: var(--text-secondary);
829 | font-size: 0.9rem;
830 | margin-bottom: 0.5rem;
831 | }
832 |
833 | .counter-image {
834 | height: 24px;
835 | border-radius: 4px;
836 | }
837 |
838 | .user-counter {
839 | margin-top: 0;
840 | }
841 |
842 | /* 添加计数器项容器样式 */
843 | .visit-counter > div {
844 | display: flex;
845 | flex-direction: column;
846 | align-items: center;
847 | min-width: 200px;
848 | }
849 |
850 | /* 热力图贡献等级 */
851 | .contribution-cell.level-0 {
852 | background-color: #ebedf0;
853 | }
854 |
855 | .contribution-cell.level-1 {
856 | background-color: #9be9a8;
857 | }
858 |
859 | .contribution-cell.level-2 {
860 | background-color: #40c463;
861 | }
862 |
863 | .contribution-cell.level-3 {
864 | background-color: #30a14e;
865 | }
866 |
867 | .contribution-cell.level-4 {
868 | background-color: #216e39;
869 | }
870 |
871 | /* 未来日期样式 */
872 | .contribution-cell.future-date {
873 | background-color: #f0f0f0;
874 | border: 1px dashed #ccc;
875 | opacity: 0.7;
876 | }
877 |
878 | .dark-mode .contribution-cell.future-date {
879 | background-color: #2d333b;
880 | border: 1px dashed #444c56;
881 | }
882 |
883 | /* 无数据时的提示样式 */
884 | .empty-message {
885 | display: flex;
886 | align-items: center;
887 | justify-content: center;
888 | height: 200px;
889 | color: var(--text-secondary);
890 | font-size: 1.1rem;
891 | text-align: center;
892 | background-color: rgba(0, 0, 0, 0.02);
893 | border-radius: 8px;
894 | border: 1px dashed var(--border-color);
895 | margin: 1rem 0;
896 | }
897 |
898 | .fade-in {
899 | animation: fadeIn 0.3s ease forwards;
900 | }
--------------------------------------------------------------------------------