feat: 在首页新增每日洞察卡,集成AI服务提供个性化财务分析。
This commit is contained in:
@@ -160,3 +160,80 @@
|
||||
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));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user