From e0cd9028e733bcfbf332fa887a8eafaf417ea8a7 Mon Sep 17 00:00:00 2001 From: admin <1297598740@qq.com> Date: Wed, 28 Jan 2026 10:33:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=BF=9E=E7=BB=AD?= =?UTF-8?q?=E8=AE=B0=E8=B4=A6=E5=A4=A9=E6=95=B0=E7=BB=9F=E8=AE=A1=E4=B8=8E?= =?UTF-8?q?=E7=83=AD=E5=8A=9B=E5=9B=BE=E5=B1=95=E7=A4=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ContributionGraph/ContributionModal.css | 221 ++++++++++++++++++ .../ContributionGraph/ContributionModal.tsx | 169 ++++++++++++++ src/pages/Home/Home.css | 43 ++++ src/pages/Home/Home.tsx | 31 ++- src/services/streakService.ts | 96 ++++++++ 5 files changed, 556 insertions(+), 4 deletions(-) create mode 100644 src/components/common/ContributionGraph/ContributionModal.css create mode 100644 src/components/common/ContributionGraph/ContributionModal.tsx create mode 100644 src/services/streakService.ts 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>(new Map()); + const [loading, setLoading] = useState(false); + const [graphData, setGraphData] = useState([]); // Array of weeks, each week adds days + + useEffect(() => { + if (isOpen) { + loadData(); + } + }, [isOpen]); + + const loadData = async () => { + setLoading(true); + try { + const data = await getContributionData(); + const map = new Map(); + data.forEach(item => map.set(item.date, item.count)); + setContributions(map); + generateGraphData(map); + } catch (err) { + console.error('Failed to load contribution data', err); + } finally { + setLoading(false); + } + }; + + const getLevel = (count: number) => { + if (count === 0) return 0; + if (count <= 2) return 1; + if (count <= 5) return 2; + if (count <= 9) return 3; + return 4; + }; + + const generateGraphData = (dataMap: Map) => { + const weeks: DayData[][] = []; + const today = new Date(); + // Go back 52 weeks (approx 1 year) + const startDate = new Date(today); + startDate.setDate(today.getDate() - 365); + + // Adjust start date to previous Sunday to align grid properly if we want strict Sunday start + // GitHub actually shifts rows based on day of week. + // Let's stick to GitHub style: Columns are weeks. Rows are Days (Sun -> Sat) + + // We need to find the Sunday before or equal to startDate + const dayOfWeek = startDate.getDay(); // 0 (Sun) - 6 (Sat) + const offset = dayOfWeek; + startDate.setDate(startDate.getDate() - offset); + + let currentDate = new Date(startDate); + const endDate = new Date(today); + + let currentWeek: DayData[] = []; + + while (currentDate <= endDate) { + const dateStr = currentDate.toISOString().split('T')[0]; + const count = dataMap.get(dateStr) || 0; + + currentWeek.push({ + date: dateStr, + count: count, + level: getLevel(count) + }); + + if (currentWeek.length === 7) { + weeks.push(currentWeek); + currentWeek = []; + } + + // Move to next day + currentDate.setDate(currentDate.getDate() + 1); + } + + // Push last partial week if any + if (currentWeek.length > 0) { + weeks.push(currentWeek); + } + + setGraphData(weeks); + }; + + if (!isOpen) return null; + + // Calculate total contributions + const totalContributions = Array.from(contributions.values()).reduce((a, b) => a + b, 0); + + return ( +
+
e.stopPropagation()}> +
+

+ + 记账足迹 +

+ +
+ +
+
+ {totalContributions} + 过去一年记账 (笔) +
+
+ {streakInfo?.longestStreak || 0} + 最长连续 (天) +
+
+ {streakInfo?.currentStreak || 0} + 当前连续 (天) +
+
+ + {loading ? ( +
加载中...
+ ) : ( + <> +
+
+ {graphData.map((week, wIndex) => ( +
+ {week.map((day) => ( +
+ ))} +
+ ))} +
+
+ +
+ Less +
+
+
+
+
+ More +
+ + )} + +
+
+ ); +}; diff --git a/src/pages/Home/Home.css b/src/pages/Home/Home.css index ac313ee..b681990 100644 --- a/src/pages/Home/Home.css +++ b/src/pages/Home/Home.css @@ -86,6 +86,49 @@ color: var(--accent-primary); } +/* Streak Badge - 连续记账天数徽章 */ +.streak-badge { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + background: linear-gradient(135deg, #ff6b6b, #ee5a5a); + border-radius: var(--radius-full); + font-weight: 700; + font-size: 0.875rem; + color: white; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + box-shadow: 0 2px 8px rgba(238, 90, 90, 0.35); +} + +.streak-badge:hover { + transform: scale(1.08); + box-shadow: 0 4px 16px rgba(238, 90, 90, 0.5); +} + +.streak-icon { + color: white; + animation: heartbeat 1.5s ease-in-out infinite; +} + +.streak-count { + font-family: 'Outfit', sans-serif; + letter-spacing: -0.5px; +} + +@keyframes heartbeat { + + 0%, + 100% { + transform: scale(1); + } + + 50% { + transform: scale(1.15); + } +} + /* Header Actions & Health Score */ .header-actions { display: flex; diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index a574ec1..b3cb49d 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -6,6 +6,7 @@ import { getAccounts, calculateTotalAssets, calculateTotalLiabilities } from '.. import { getTransactions, calculateTotalExpense } from '../../services/transactionService'; import { getCategories } from '../../services/categoryService'; import { getLedgers, reorderLedgers } from '../../services/ledgerService'; +import { getStreakInfo, type StreakInfoFormatted } from '../../services/streakService'; import { getSettings, updateSettings } from '../../services/settingsService'; import { Icon } from '@iconify/react'; import { SpendingTrendChart } from '../../components/charts/SpendingTrendChart'; @@ -16,8 +17,8 @@ import { CreateFirstAccountModal } from '../../components/account/CreateFirstAcc import { AccountForm } from '../../components/account/AccountForm/AccountForm'; import { createAccount } from '../../services/accountService'; import { Confetti } from '../../components/common/Confetti'; -import { LikeButton } from '../../components/common/MicroInteraction/LikeButton'; import { HealthScoreModal } from '../../components/home/HealthScoreModal/HealthScoreModal'; +import { ContributionModal } from '../../components/common/ContributionGraph/ContributionModal'; // Import Component import type { Account, Transaction, Category, Ledger, UserSettings } from '../../types'; @@ -42,8 +43,10 @@ function Home() { const [showAccountForm, setShowAccountForm] = useState(false); const [showConfetti, setShowConfetti] = useState(false); const [showHealthModal, setShowHealthModal] = useState(false); + const [showContributionModal, setShowContributionModal] = useState(false); // Add State const [todaySpend, setTodaySpend] = useState(0); const [yesterdaySpend, setYesterdaySpend] = useState(0); + const [streakInfo, setStreakInfo] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -66,7 +69,7 @@ function Home() { const yesterdayStr = yesterday.toISOString().split('T')[0]; // Load accounts, recent transactions, today/yesterday stats - const [accountsData, transactionsData, categoriesData, ledgersData, settingsData, todayData, yesterdayData] = await Promise.all([ + const [accountsData, transactionsData, categoriesData, ledgersData, settingsData, todayData, yesterdayData, streakData] = await Promise.all([ getAccounts(), getTransactions({ page: 1, pageSize: 5 }), // Recent transactions getCategories(), @@ -74,6 +77,7 @@ function Home() { getSettings().catch(() => null), getTransactions({ startDate: todayStr, endDate: todayStr, type: 'expense', pageSize: 100 }), getTransactions({ startDate: yesterdayStr, endDate: yesterdayStr, type: 'expense', pageSize: 100 }), + getStreakInfo().catch(() => null), ]); setAccounts(accountsData || []); @@ -85,6 +89,9 @@ function Home() { // Calculate daily spends setTodaySpend(calculateTotalExpense(todayData.items)); setYesterdaySpend(calculateTotalExpense(yesterdayData.items)); + + // Set streak info + setStreakInfo(streakData); } catch (err) { setError(err instanceof Error ? err.message : '加载数据失败'); console.error('Failed to load home page data:', err); @@ -304,9 +311,18 @@ function Home() {

- {insight} + {streakInfo?.message || insight}

- + {streakInfo && streakInfo.currentStreak > 0 && ( +
setShowContributionModal(true)} + title={`最长连续: ${streakInfo.longestStreak} 天\n累计记账: ${streakInfo.totalRecordDays} 天\n点击查看详情`} + > + + {streakInfo.currentStreak} +
+ )}

@@ -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, +};