feat: 新增连续记账天数统计与热力图展示功能
This commit is contained in:
221
src/components/common/ContributionGraph/ContributionModal.css
Normal file
221
src/components/common/ContributionGraph/ContributionModal.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
169
src/components/common/ContributionGraph/ContributionModal.tsx
Normal file
169
src/components/common/ContributionGraph/ContributionModal.tsx
Normal file
@@ -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<ContributionModalProps> = ({ isOpen, onClose, streakInfo }) => {
|
||||
const [contributions, setContributions] = useState<Map<string, number>>(new Map());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [graphData, setGraphData] = useState<DayData[][]>([]); // 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<string, number>();
|
||||
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<string, number>) => {
|
||||
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 (
|
||||
<div className="contribution-modal-overlay" onClick={onClose}>
|
||||
<div className="contribution-modal-content" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">
|
||||
<Icon icon="solar:calendar-date-bold-duotone" width="24" color="var(--accent-primary)" />
|
||||
记账足迹
|
||||
</h2>
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
<Icon icon="solar:close-circle-bold" width="24" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="contribution-stats">
|
||||
<div className="stat-card">
|
||||
<span className="stat-value">{totalContributions}</span>
|
||||
<span className="stat-label">过去一年记账 (笔)</span>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="stat-value">{streakInfo?.longestStreak || 0}</span>
|
||||
<span className="stat-label">最长连续 (天)</span>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="stat-value">{streakInfo?.currentStreak || 0}</span>
|
||||
<span className="stat-label">当前连续 (天)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-tertiary)' }}>加载中...</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="graph-container">
|
||||
<div className="contribution-graph">
|
||||
{graphData.map((week, wIndex) => (
|
||||
<div key={wIndex} className="week-column">
|
||||
{week.map((day) => (
|
||||
<div
|
||||
key={day.date}
|
||||
className={`day-cell level-${day.level}`}
|
||||
data-tooltip={`${day.date}: ${day.count} 笔交易`}
|
||||
title={`${day.date}: ${day.count} 笔交易`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="graph-legend">
|
||||
<span>Less</span>
|
||||
<div className="legend-cell level-0"></div>
|
||||
<div className="legend-cell level-1"></div>
|
||||
<div className="legend-cell level-2"></div>
|
||||
<div className="legend-cell level-3"></div>
|
||||
<div className="legend-cell level-4"></div>
|
||||
<span>More</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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<StreakInfoFormatted | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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() {
|
||||
</h1>
|
||||
<p className="greeting-insight animate-slide-up delay-100">
|
||||
<Icon icon="solar:bell-bing-bold-duotone" width="16" className="insight-icon" />
|
||||
{insight}
|
||||
{streakInfo?.message || insight}
|
||||
<div className="insight-actions" style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center' }}>
|
||||
<LikeButton size="sm" initialCount={Math.floor(Math.random() * 20)} />
|
||||
{streakInfo && streakInfo.currentStreak > 0 && (
|
||||
<div
|
||||
className="streak-badge"
|
||||
onClick={() => setShowContributionModal(true)}
|
||||
title={`最长连续: ${streakInfo.longestStreak} 天\n累计记账: ${streakInfo.totalRecordDays} 天\n点击查看详情`}
|
||||
>
|
||||
<Icon icon="solar:heart-bold" width="16" className="streak-icon" />
|
||||
<span className="streak-count">{streakInfo.currentStreak}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
@@ -518,6 +534,13 @@ function Home() {
|
||||
todaySpend={todaySpend}
|
||||
yesterdaySpend={yesterdaySpend}
|
||||
/>
|
||||
|
||||
{/* Contribution Heatmap Modal */}
|
||||
<ContributionModal
|
||||
isOpen={showContributionModal}
|
||||
onClose={() => setShowContributionModal(false)}
|
||||
streakInfo={streakInfo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
96
src/services/streakService.ts
Normal file
96
src/services/streakService.ts
Normal file
@@ -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<StreakInfoFormatted> {
|
||||
const response = await api.get<ApiResponse<StreakInfo>>('/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<StreakInfoFormatted> {
|
||||
const response = await api.post<ApiResponse<StreakInfo>>('/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<DailyContribution[]> {
|
||||
const response = await api.get<ApiResponse<DailyContribution[]>>('/user/streak/contribution');
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
export default {
|
||||
getStreakInfo,
|
||||
recalculateStreak,
|
||||
getContributionData,
|
||||
};
|
||||
Reference in New Issue
Block a user