feat: 在首页新增每日洞察卡,集成AI服务提供个性化财务分析。

This commit is contained in:
2026-01-29 00:11:14 +08:00
parent f116998226
commit c80f64346e
4 changed files with 213 additions and 17 deletions

View File

@@ -159,4 +159,81 @@
.week-diff-badge.red {
background: rgba(239, 68, 68, 0.1);
color: #EF4444;
}
/* Emoji badge in header */
.daily-insight__emoji {
font-size: 1.2rem;
margin-left: auto;
animation: bounce 1s ease-in-out;
}
@keyframes bounce {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.2);
}
}
/* Tip section at bottom */
.daily-insight__tip {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: linear-gradient(135deg, rgba(var(--color-primary-rgb), 0.08), rgba(var(--color-primary-rgb), 0.02));
border-radius: var(--radius-lg);
font-size: 0.85rem;
color: var(--color-text-secondary);
grid-column: 1 / -1;
}
.daily-insight__tip svg {
color: var(--color-primary);
flex-shrink: 0;
}
/* Trend Chart */
.trend-chart {
display: flex;
align-items: flex-end;
justify-content: space-between;
height: 60px;
padding-top: 10px;
gap: 4px;
}
.trend-bar-wrapper {
flex: 1;
height: 100%;
display: flex;
align-items: flex-end;
justify-content: center;
}
.trend-bar {
width: 60%;
background: rgba(var(--color-text-rgb), 0.1);
border-radius: 4px;
transition: all 0.3s ease;
min-height: 4px;
}
.trend-bar--today {
background: var(--color-primary);
box-shadow: 0 0 8px rgba(var(--color-primary-rgb), 0.4);
}
.trend-bar--max {
background: var(--color-warning, #F59E0B);
}
/* If today is also max, give it a special gradient */
.trend-bar--today.trend-bar--max {
background: linear-gradient(to top, var(--color-primary), var(--color-warning, #F59E0B));
}

View File

@@ -13,6 +13,14 @@ interface DailyInsightCardProps {
maxTransaction?: { note?: string; amount: number };
lastWeekSpend?: number;
streakDays?: number;
// 新增字段
budgetRemaining?: number;
daysRemaining?: number;
weeklyTotal?: number;
avgDailySpend?: number;
top3Categories?: { name: string; amount: number }[];
todayTransactionCount?: number;
last7DaysSpend?: number[]; // 最近7天每日支出
}
export const DailyInsightCard: React.FC<DailyInsightCardProps> = ({
@@ -23,9 +31,16 @@ export const DailyInsightCard: React.FC<DailyInsightCardProps> = ({
topCategory,
maxTransaction,
lastWeekSpend,
streakDays
streakDays,
budgetRemaining,
daysRemaining,
weeklyTotal,
avgDailySpend,
top3Categories,
todayTransactionCount,
last7DaysSpend,
}) => {
const [aiData, setAiData] = useState<{ spending: string; budget: string } | null>(null);
const [aiData, setAiData] = useState<{ spending: string; budget: string; emoji?: string; tip?: string } | null>(null);
const [isAiLoading, setIsAiLoading] = useState(false);
useEffect(() => {
@@ -50,7 +65,15 @@ export const DailyInsightCard: React.FC<DailyInsightCardProps> = ({
amount: maxTransaction.amount
} : undefined,
lastWeekSpend,
streakDays
streakDays,
// 新增数据
budgetRemaining,
daysRemaining: daysRemaining ?? (daysInMonth - today),
weeklyTotal,
avgDailySpend,
top3Categories,
todayTransactionCount,
last7DaysSpend,
});
setAiData(result);
} catch (e) {
@@ -62,7 +85,7 @@ export const DailyInsightCard: React.FC<DailyInsightCardProps> = ({
const timer = setTimeout(fetchAI, 500);
return () => clearTimeout(timer);
}, [todaySpend, yesterdaySpend, monthlyBudget, monthlySpent, topCategory, maxTransaction, lastWeekSpend, streakDays]);
}, [todaySpend, yesterdaySpend, monthlyBudget, monthlySpent, topCategory, maxTransaction, lastWeekSpend, streakDays, budgetRemaining, daysRemaining, weeklyTotal, avgDailySpend, top3Categories, todayTransactionCount, last7DaysSpend]);
const getSpendingInsight = (today: number, yesterday: number) => {
if (aiData) return { text: <span>{aiData.spending}</span>, type: 'ai' };
@@ -114,6 +137,7 @@ export const DailyInsightCard: React.FC<DailyInsightCardProps> = ({
<div className="daily-insight__header">
<Icon icon={aiData ? "solar:magic-stick-3-bold-duotone" : "solar:stars-minimalistic-bold-duotone"} width="20" />
<span>{aiData ? 'AI 每日简报' : '每日简报'}</span>
{aiData?.emoji && <span className="daily-insight__emoji">{aiData.emoji}</span>}
{isAiLoading && !aiData && <span className="daily-insight__loading-badge">AI ...</span>}
</div>
@@ -130,12 +154,46 @@ export const DailyInsightCard: React.FC<DailyInsightCardProps> = ({
<p className="daily-insight__text animate-fade-in">{spendingInsight.text}</p>
</div>
{/* 7-Day Trend Chart */}
{last7DaysSpend && last7DaysSpend.length > 0 && (
<div className="daily-insight__section">
<span className="daily-insight__title">7</span>
<div className="trend-chart">
{last7DaysSpend.map((amount, index) => {
const maxVal = Math.max(...last7DaysSpend, 1);
const heightPercent = Math.max((amount / maxVal) * 100, 10); // Min 10% height
const isToday = index === last7DaysSpend.length - 1;
const isMax = amount === maxVal && amount > 0;
return (
<div key={index} className="trend-bar-wrapper" title={`Day ${index - 6}: ¥${amount}`}>
<div
className={`trend-bar ${isToday ? 'trend-bar--today' : ''} ${isMax ? 'trend-bar--max' : ''}`}
style={{ height: `${heightPercent}%` }}
/>
</div>
);
})}
</div>
</div>
)}
<div className="daily-insight__divider" />
<div className="daily-insight__section">
<span className="daily-insight__title"></span>
<p className="daily-insight__text animate-fade-in">{budgetInsight.text}</p>
</div>
{aiData?.tip && (
<>
<div className="daily-insight__divider" />
<div className="daily-insight__tip">
<Icon icon="solar:lightbulb-bolt-bold-duotone" width="16" />
<span>{aiData.tip}</span>
</div>
</>
)}
</div>
</div>
);

View File

@@ -56,7 +56,8 @@ function Home() {
const [monthlyBudgetTotal, setMonthlyBudgetTotal] = useState(0);
const [monthlyBudgetSpentTotal, setMonthlyBudgetSpentTotal] = useState(0);
const [todayTransactions, setTodayTransactions] = useState<Transaction[]>([]);
const [lastWeekSpend, setLastWeekSpend] = useState(0); // New State
const [lastWeekSpend, setLastWeekSpend] = useState(0);
const [last7DaysSpend, setLast7DaysSpend] = useState<number[]>([]); // New: 最近7天支出
useEffect(() => {
@@ -87,8 +88,16 @@ function Home() {
const yesterdayStr = toLocalDate(yesterday);
const lastWeekStr = toLocalDate(lastWeek);
// Load accounts, recent transactions, today/yesterday stats... AND last week
const [accountsData, transactionsData, categoriesData, ledgersData, settingsData, budgetsData, todayData, yesterdayData, lastWeekData, streakData] = await Promise.all([
// Generate last 7 days date strings (from 6 days ago to today)
const last7Days: string[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
last7Days.push(toLocalDate(d));
}
// Load accounts, recent transactions, today/yesterday stats... AND last 7 days
const [accountsData, transactionsData, categoriesData, ledgersData, settingsData, budgetsData, todayData, yesterdayData, lastWeekData, streakData, ...last7DaysData] = await Promise.all([
getAccounts(),
getTransactions({ page: 1, pageSize: 5 }), // Recent
getCategories(),
@@ -99,6 +108,10 @@ function Home() {
getTransactions({ startDate: yesterdayStr, endDate: yesterdayStr, type: 'expense', pageSize: 100 }),
getTransactions({ startDate: lastWeekStr, endDate: lastWeekStr, type: 'expense', pageSize: 100 }), // Last week same day
getStreakInfo().catch(() => null),
// Fetch last 7 days data
...last7Days.map(dateStr =>
getTransactions({ startDate: dateStr, endDate: dateStr, type: 'expense', pageSize: 100 })
),
]);
setAccounts(accountsData || []);
@@ -133,6 +146,12 @@ function Home() {
// Set streak info
setStreakInfo(streakData);
// Calculate last 7 days spending array
const last7DaysSpendArray = last7DaysData.map(dayData =>
calculateTotalExpense(dayData?.items || [])
);
setLast7DaysSpend(last7DaysSpendArray);
} catch (err) {
setError(err instanceof Error ? err.message : '加载数据失败');
console.error('Failed to load home page data:', err);
@@ -141,34 +160,53 @@ function Home() {
}
};
const { maxTransaction, topCategory } = useMemo(() => {
if (todayTransactions.length === 0) return { maxTransaction: undefined, topCategory: undefined };
const { maxTransaction, topCategory, top3Categories, todayTransactionCount, avgDailySpend } = useMemo(() => {
if (todayTransactions.length === 0) return {
maxTransaction: undefined,
topCategory: undefined,
top3Categories: undefined,
todayTransactionCount: 0,
avgDailySpend: 0
};
// Max Transaction
const maxTx = [...todayTransactions].sort((a, b) => b.amount - a.amount)[0];
// Top Category
// Category aggregation
const catMap: Record<number, number> = {};
todayTransactions.forEach((t) => {
catMap[t.categoryId] = (catMap[t.categoryId] || 0) + t.amount;
});
// Sort categories
// Sort categories by amount
const sortedCatIds = Object.keys(catMap).sort((a, b) => catMap[Number(b)] - catMap[Number(a)]);
// Find top one
// Top category
let topCatName = undefined;
if (sortedCatIds.length > 0) {
const topCatId = Number(sortedCatIds[0]);
topCatName = categories.find((c) => c.id === topCatId)?.name;
// Optimization: if category name not found, try to look up in transaction's populated data if available or fallback
}
// Top 3 categories
const top3 = sortedCatIds.slice(0, 3).map(idStr => {
const id = Number(idStr);
const name = categories.find((c) => c.id === id)?.name || '未知';
return { name, amount: catMap[id] };
});
// Avg daily spend (简单计算: 本月已花 / 已过天数)
const dayOfMonth = new Date().getDate();
const avgDaily = dayOfMonth > 0 ? monthlyBudgetSpentTotal / dayOfMonth : 0;
return {
maxTransaction: maxTx,
topCategory: topCatName ? { name: topCatName, amount: catMap[Number(sortedCatIds[0])] } : undefined
topCategory: topCatName ? { name: topCatName, amount: catMap[Number(sortedCatIds[0])] } : undefined,
top3Categories: top3.length > 0 ? top3 : undefined,
todayTransactionCount: todayTransactions.length,
avgDailySpend: avgDaily
};
}, [todayTransactions, categories]);
}, [todayTransactions, categories, monthlyBudgetSpentTotal]);
const handleQuickTransaction = () => {
navigate('/transactions?action=new');
@@ -475,6 +513,11 @@ function Home() {
topCategory={topCategory}
lastWeekSpend={lastWeekSpend}
streakDays={streakInfo?.currentStreak || 0}
budgetRemaining={monthlyBudgetTotal - monthlyBudgetSpentTotal}
avgDailySpend={avgDailySpend}
top3Categories={top3Categories}
todayTransactionCount={todayTransactionCount}
last7DaysSpend={last7DaysSpend}
/>
</div>

View File

@@ -299,21 +299,39 @@ let dailyInsightCache: {
} | null = null;
export interface DailyInsightContext {
// 今日数据
todaySpend: number;
yesterdaySpend: number;
// 预算数据
monthlyBudget: number;
monthlySpent: number;
monthProgress: number; // 0-1
budgetRemaining?: number; // 剩余预算
daysRemaining?: number; // 剩余天数
// 历史数据
lastWeekSpend?: number; // 上周同日
weeklyTotal?: number; // 本周累计
last7DaysSpend?: number[];// 最近7天支出数组
avgDailySpend?: number; // 日均支出
// 分类分析
topCategory?: { name: string; amount: number };
top3Categories?: { name: string; amount: number }[]; // 本月前三分类
// 交易详情
maxTransaction?: { note: string; amount: number };
lastWeekSpend?: number;
todayTransactionCount?: number; // 今日交易笔数
// 用户习惯
streakDays?: number;
}
/**
* Get AI-powered daily insight
*/
export async function getDailyInsight(context: DailyInsightContext): Promise<{ spending: string; budget: string }> {
export async function getDailyInsight(context: DailyInsightContext): Promise<{ spending: string; budget: string; emoji?: string; tip?: string }> {
// Hash needs to include new fields
const currentHash = JSON.stringify(context);
const NOW = Date.now();