diff --git a/src/components/common/ContributionGraph/ContributionModal.css b/src/components/common/ContributionGraph/ContributionModal.css
new file mode 100644
index 0000000..be83ea1
--- /dev/null
+++ b/src/components/common/ContributionGraph/ContributionModal.css
@@ -0,0 +1,221 @@
+/**
+ * Contribution Heatmap Graph Style
+ */
+
+.contribution-modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.6);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1200;
+ backdrop-filter: blur(8px);
+ animation: fadeIn 0.3s ease;
+}
+
+.contribution-modal-content {
+ background: var(--glass-panel-bg);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-xl);
+ padding: var(--spacing-xl);
+ width: 90%;
+ max-width: 800px;
+ box-shadow: var(--shadow-2xl);
+ animation: scaleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+ position: relative;
+ color: var(--text-primary);
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: var(--spacing-lg);
+}
+
+.modal-title {
+ font-size: 1.25rem;
+ font-weight: 700;
+ margin: 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.close-btn {
+ background: none;
+ border: none;
+ color: var(--text-tertiary);
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 50%;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.close-btn:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+}
+
+.contribution-stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: var(--spacing-md);
+ margin-bottom: var(--spacing-xl);
+}
+
+.stat-card {
+ background: var(--bg-secondary);
+ padding: var(--spacing-md);
+ border-radius: var(--radius-lg);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+}
+
+.stat-value {
+ font-family: 'Outfit', sans-serif;
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin-bottom: 4px;
+}
+
+.stat-label {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+
+.graph-container {
+ overflow-x: auto;
+ padding-bottom: var(--spacing-md);
+}
+
+.contribution-graph {
+ display: flex;
+ gap: 4px;
+ min-width: fit-content;
+}
+
+.week-column {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.day-cell {
+ width: 12px;
+ height: 12px;
+ border-radius: 2px;
+ background-color: var(--bg-tertiary);
+ transition: all 0.2s ease;
+ cursor: pointer;
+ position: relative;
+}
+
+.day-cell:hover {
+ transform: scale(1.4);
+ z-index: 10;
+ border: 1px solid rgba(0, 0, 0, 0.1);
+}
+
+/* Tooltip */
+.day-cell[data-tooltip]:hover::after {
+ content: attr(data-tooltip);
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ background: rgba(0, 0, 0, 0.8);
+ color: white;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ white-space: nowrap;
+ z-index: 20;
+ margin-bottom: 6px;
+ pointer-events: none;
+}
+
+/* Legend */
+.graph-legend {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 4px;
+ margin-top: var(--spacing-md);
+ font-size: 0.75rem;
+ color: var(--text-tertiary);
+}
+
+.legend-cell {
+ width: 12px;
+ height: 12px;
+ border-radius: 2px;
+}
+
+/* Levels */
+.level-0 {
+ background-color: var(--bg-tertiary);
+}
+
+.level-1 {
+ background-color: #fca5a5;
+}
+
+/* Light Red */
+.level-2 {
+ background-color: #f87171;
+}
+
+.level-3 {
+ background-color: #ef4444;
+}
+
+.level-4 {
+ background-color: #dc2626;
+}
+
+/* Deep Red */
+
+@media (prefers-color-scheme: dark) {
+ .level-0 {
+ background-color: #2d2d2d;
+ }
+
+ .level-1 {
+ background-color: #451a1a;
+ }
+
+ .level-2 {
+ background-color: #7f1d1d;
+ }
+
+ .level-3 {
+ background-color: #b91c1c;
+ }
+
+ .level-4 {
+ background-color: #ef4444;
+ }
+}
+
+@keyframes scaleIn {
+ from {
+ transform: scale(0.95);
+ opacity: 0;
+ }
+
+ to {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
\ No newline at end of file
diff --git a/src/components/common/ContributionGraph/ContributionModal.tsx b/src/components/common/ContributionGraph/ContributionModal.tsx
new file mode 100644
index 0000000..6149bcb
--- /dev/null
+++ b/src/components/common/ContributionGraph/ContributionModal.tsx
@@ -0,0 +1,169 @@
+import React, { useEffect, useState } from 'react';
+import { Icon } from '@iconify/react';
+import { getContributionData, type StreakInfoFormatted } from '../../../services/streakService';
+import './ContributionModal.css';
+
+interface ContributionModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ streakInfo: StreakInfoFormatted | null;
+}
+
+interface DayData {
+ date: string;
+ count: number;
+ level: number;
+}
+
+export const ContributionModal: React.FC = ({ isOpen, onClose, streakInfo }) => {
+ const [contributions, setContributions] = useState
@@ -518,6 +534,13 @@ function Home() {
todaySpend={todaySpend}
yesterdaySpend={yesterdaySpend}
/>
+
+ {/* Contribution Heatmap Modal */}
+ setShowContributionModal(false)}
+ streakInfo={streakInfo}
+ />
);
}
diff --git a/src/services/streakService.ts b/src/services/streakService.ts
new file mode 100644
index 0000000..4eb16c6
--- /dev/null
+++ b/src/services/streakService.ts
@@ -0,0 +1,96 @@
+/**
+ * Streak Service - API calls for user streak management
+ * 连续记账功能服务
+ */
+
+import api from './api';
+import type { ApiResponse } from '../types';
+
+/**
+ * Streak info returned from backend
+ */
+export interface StreakInfo {
+ current_streak: number; // 当前连续天数
+ longest_streak: number; // 最长连续记录
+ total_record_days: number; // 累计记账天数
+ has_record_today: boolean; // 今天是否已记账
+ message: string; // 提示信息
+}
+
+/**
+ * Daily contribution data
+ */
+export interface DailyContribution {
+ date: string;
+ count: number;
+}
+
+/**
+ * Streak info in camelCase for frontend
+ */
+export interface StreakInfoFormatted {
+ currentStreak: number;
+ longestStreak: number;
+ totalRecordDays: number;
+ hasRecordToday: boolean;
+ message: string;
+}
+
+/**
+ * Map streak data from API (snake_case) to frontend (camelCase)
+ */
+function mapStreakFromApi(data: StreakInfo): StreakInfoFormatted {
+ return {
+ currentStreak: data.current_streak,
+ longestStreak: data.longest_streak,
+ totalRecordDays: data.total_record_days,
+ hasRecordToday: data.has_record_today,
+ message: data.message,
+ };
+}
+
+/**
+ * Get current user's streak info
+ * 获取当前用户的连续记账信息
+ */
+export async function getStreakInfo(): Promise {
+ const response = await api.get>('/user/streak');
+ if (!response.data) {
+ // Return default values if no streak data
+ return {
+ currentStreak: 0,
+ longestStreak: 0,
+ totalRecordDays: 0,
+ hasRecordToday: false,
+ message: '开始记录你的第一笔账吧!',
+ };
+ }
+ return mapStreakFromApi(response.data);
+}
+
+/**
+ * Recalculate streak from transaction history
+ * 重新计算连续记账天数(基于交易历史)
+ */
+export async function recalculateStreak(): Promise {
+ const response = await api.post>('/user/streak/recalculate');
+ if (!response.data) {
+ throw new Error(response.error || 'Failed to recalculate streak');
+ }
+ return mapStreakFromApi(response.data);
+}
+
+/**
+ * Get daily contribution data for heatmap
+ * 获取热力图数据
+ */
+export async function getContributionData(): Promise {
+ const response = await api.get>('/user/streak/contribution');
+ return response.data || [];
+}
+
+export default {
+ getStreakInfo,
+ recalculateStreak,
+ getContributionData,
+};