feat: add finance module with yearly income/expense tracking
- FinanceEntry model (income/expense x fixed/extra) - CRUD API endpoints for finance entries - FinancePage with summary cards and drill-down - Updated navigation with Finance tab - Updated todos default filter to active
This commit is contained in:
+10
-2
@@ -36,14 +36,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 自动建表
|
// 自动建表
|
||||||
db.AutoMigrate(&model.Todo{}, &model.DailyLog{})
|
db.AutoMigrate(&model.Todo{}, &model.DailyLog{}, &model.FinanceEntry{})
|
||||||
|
|
||||||
// 初始化各层
|
// 初始化各层
|
||||||
todoRepo := repository.NewTodoRepository(db)
|
todoRepo := repository.NewTodoRepository(db)
|
||||||
dailyLogRepo := repository.NewDailyLogRepository(db)
|
dailyLogRepo := repository.NewDailyLogRepository(db)
|
||||||
|
financeRepo := repository.NewFinanceRepository(db)
|
||||||
todoSvc := service.NewTodoService(todoRepo)
|
todoSvc := service.NewTodoService(todoRepo)
|
||||||
dailyLogSvc := service.NewDailyLogService(dailyLogRepo)
|
dailyLogSvc := service.NewDailyLogService(dailyLogRepo)
|
||||||
h := handler.NewHandler(todoSvc, dailyLogSvc)
|
financeSvc := service.NewFinanceService(financeRepo)
|
||||||
|
h := handler.NewHandler(todoSvc, dailyLogSvc, financeSvc)
|
||||||
|
|
||||||
// Gin 路由
|
// Gin 路由
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
@@ -70,6 +72,12 @@ func main() {
|
|||||||
api.GET("/logs/:date", h.GetDailyLog)
|
api.GET("/logs/:date", h.GetDailyLog)
|
||||||
api.GET("/logs", h.GetRecentLogs)
|
api.GET("/logs", h.GetRecentLogs)
|
||||||
api.PUT("/logs/:date", h.SaveDailyLog)
|
api.PUT("/logs/:date", h.SaveDailyLog)
|
||||||
|
|
||||||
|
// 经济记录
|
||||||
|
api.GET("/finance/:year", h.GetFinanceByYear)
|
||||||
|
api.POST("/finance", h.CreateFinanceEntry)
|
||||||
|
api.PUT("/finance/:id", h.UpdateFinanceEntry)
|
||||||
|
api.DELETE("/finance/:id", h.DeleteFinanceEntry)
|
||||||
}
|
}
|
||||||
|
|
||||||
port := getEnv("PORT", "17010")
|
port := getEnv("PORT", "17010")
|
||||||
|
|||||||
@@ -5,18 +5,20 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"lifelog/internal/model"
|
||||||
"lifelog/internal/service"
|
"lifelog/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
todoSvc *service.TodoService
|
todoSvc *service.TodoService
|
||||||
dailyLogSvc *service.DailyLogService
|
dailyLogSvc *service.DailyLogService
|
||||||
|
financeSvc *service.FinanceService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(todoSvc *service.TodoService, dailyLogSvc *service.DailyLogService) *Handler {
|
func NewHandler(todoSvc *service.TodoService, dailyLogSvc *service.DailyLogService, financeSvc *service.FinanceService) *Handler {
|
||||||
return &Handler{todoSvc: todoSvc, dailyLogSvc: dailyLogSvc}
|
return &Handler{todoSvc: todoSvc, dailyLogSvc: dailyLogSvc, financeSvc: financeSvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Todo handlers
|
// Todo handlers
|
||||||
@@ -165,3 +167,95 @@ func (h *Handler) SaveDailyLog(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"data": log})
|
c.JSON(http.StatusOK, gin.H{"data": log})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Finance handlers
|
||||||
|
|
||||||
|
func (h *Handler) GetFinanceByYear(c *gin.Context) {
|
||||||
|
yearStr := c.Param("year")
|
||||||
|
year, err := strconv.Atoi(yearStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid year"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := h.financeSvc.GetByYear(year)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": entries})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateFinanceEntry(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Year int `json:"year" binding:"required"`
|
||||||
|
Month int `json:"month"`
|
||||||
|
Type string `json:"type" binding:"required"` // income / expense
|
||||||
|
Category string `json:"category" binding:"required"` // fixed / extra
|
||||||
|
Description string `json:"description"`
|
||||||
|
Amount float64 `json:"amount" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry := &model.FinanceEntry{
|
||||||
|
Year: req.Year,
|
||||||
|
Month: req.Month,
|
||||||
|
Type: req.Type,
|
||||||
|
Category: req.Category,
|
||||||
|
Description: req.Description,
|
||||||
|
Amount: req.Amount,
|
||||||
|
}
|
||||||
|
if err := h.financeSvc.Create(entry); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"data": entry})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateFinanceEntry(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Year int `json:"year"`
|
||||||
|
Month int `json:"month"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = h.financeSvc.Update(&model.FinanceEntry{
|
||||||
|
ID: id,
|
||||||
|
Year: req.Year,
|
||||||
|
Month: req.Month,
|
||||||
|
Type: req.Type,
|
||||||
|
Category: req.Category,
|
||||||
|
Description: req.Description,
|
||||||
|
Amount: req.Amount,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteFinanceEntry(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.financeSvc.Delete(id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,3 +31,22 @@ type DailyLog struct {
|
|||||||
func (DailyLog) TableName() string {
|
func (DailyLog) TableName() string {
|
||||||
return "daily_logs"
|
return "daily_logs"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FinanceEntry 收支条目
|
||||||
|
// Type: income(收入) / expense(支出)
|
||||||
|
// Category: fixed(固定) / extra(额外)
|
||||||
|
type FinanceEntry struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
Year int `gorm:"type:int;not null;index" json:"year"` // 年份
|
||||||
|
Month int `gorm:"type:int;not null;default:0" json:"month"` // 月份(0表示全年)
|
||||||
|
Type string `gorm:"type:varchar(10);not null" json:"type"` // income / expense
|
||||||
|
Category string `gorm:"type:varchar(10);not null" json:"category"` // fixed / extra
|
||||||
|
Description string `gorm:"type:varchar(255)" json:"description"` // 条目描述
|
||||||
|
Amount float64 `gorm:"type:decimal(12,2);not null" json:"amount"` // 金额(正数)
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (FinanceEntry) TableName() string {
|
||||||
|
return "finance_entries"
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,3 +76,35 @@ func (r *DailyLogRepository) Upsert(log *model.DailyLog) error {
|
|||||||
existing.Evening = log.Evening
|
existing.Evening = log.Evening
|
||||||
return r.db.Save(&existing).Error
|
return r.db.Save(&existing).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FinanceRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFinanceRepository(db *gorm.DB) *FinanceRepository {
|
||||||
|
return &FinanceRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *FinanceRepository) GetByYear(year int) ([]model.FinanceEntry, error) {
|
||||||
|
var entries []model.FinanceEntry
|
||||||
|
err := r.db.Where("year = ?", year).Order("month ASC, id ASC").Find(&entries).Error
|
||||||
|
return entries, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *FinanceRepository) Create(entry *model.FinanceEntry) error {
|
||||||
|
return r.db.Create(entry).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *FinanceRepository) Update(entry *model.FinanceEntry) error {
|
||||||
|
return r.db.Save(entry).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *FinanceRepository) Delete(id uint64) error {
|
||||||
|
return r.db.Delete(&model.FinanceEntry{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *FinanceRepository) GetByID(id uint64) (*model.FinanceEntry, error) {
|
||||||
|
var entry model.FinanceEntry
|
||||||
|
err := r.db.First(&entry, id).Error
|
||||||
|
return &entry, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,3 +97,27 @@ func (s *DailyLogService) Save(date, morning, afternoon, evening string) (*model
|
|||||||
err := s.repo.Upsert(log)
|
err := s.repo.Upsert(log)
|
||||||
return log, err
|
return log, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FinanceService struct {
|
||||||
|
repo *repository.FinanceRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFinanceService(repo *repository.FinanceRepository) *FinanceService {
|
||||||
|
return &FinanceService{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FinanceService) GetByYear(year int) ([]model.FinanceEntry, error) {
|
||||||
|
return s.repo.GetByYear(year)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FinanceService) Create(entry *model.FinanceEntry) error {
|
||||||
|
return s.repo.Create(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FinanceService) Update(entry *model.FinanceEntry) error {
|
||||||
|
return s.repo.Update(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FinanceService) Delete(id uint64) error {
|
||||||
|
return s.repo.Delete(id)
|
||||||
|
}
|
||||||
|
|||||||
+30
-9
@@ -1,22 +1,43 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import HomePage from './pages/HomePage'
|
import HomePage from './pages/HomePage'
|
||||||
|
import FinancePage from './pages/FinancePage'
|
||||||
|
|
||||||
|
type Tab = 'home' | 'finance'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [tab, setTab] = useState<Tab>('home')
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50">
|
<div className="min-h-screen bg-slate-50">
|
||||||
<nav className="bg-white border-b border-slate-200 mb-8">
|
<nav className="bg-white border-b border-slate-200 mb-0 sticky top-0 z-10">
|
||||||
<div className="max-w-5xl mx-auto px-4">
|
<div className="max-w-5xl mx-auto px-4">
|
||||||
<div className="flex items-center gap-2 py-4">
|
<div className="flex items-center gap-1 py-3">
|
||||||
<span className="text-xl">📒</span>
|
<span className="text-xl mr-3">📒</span>
|
||||||
<span className="font-bold text-lg text-slate-800">LifeLog</span>
|
<span className="font-bold text-lg text-slate-800 mr-6">LifeLog</span>
|
||||||
<span className="text-slate-400 text-sm ml-2">我的每日工作台</span>
|
|
||||||
|
<button
|
||||||
|
onClick={() => setTab('home')}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition ${
|
||||||
|
tab === 'home' ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
📋 {tab === 'home' ? '工作台' : '工作台'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTab('finance')}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition ${
|
||||||
|
tab === 'finance' ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
💰 {tab === 'finance' ? '经济' : '经济'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<main className="px-4 pb-12">
|
<main className="px-4 pb-12">
|
||||||
<HomePage />
|
{tab === 'home' && <HomePage />}
|
||||||
|
{tab === 'finance' && <FinancePage />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
|
||||||
|
|||||||
@@ -41,3 +41,23 @@ export const logApi = {
|
|||||||
getRecent: (limit = 30) =>
|
getRecent: (limit = 30) =>
|
||||||
api.get<{ data: DailyLog[] }>(`/logs?limit=${limit}`).then(r => r.data.data),
|
api.get<{ data: DailyLog[] }>(`/logs?limit=${limit}`).then(r => r.data.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FinanceEntry {
|
||||||
|
id: number
|
||||||
|
year: number
|
||||||
|
month: number
|
||||||
|
type: 'income' | 'expense'
|
||||||
|
category: 'fixed' | 'extra'
|
||||||
|
description: string
|
||||||
|
amount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const financeApi = {
|
||||||
|
getByYear: (year: number) =>
|
||||||
|
api.get<{ data: FinanceEntry[] }>(`/finance/${year}`).then(r => r.data.data),
|
||||||
|
create: (data: Omit<FinanceEntry, 'id'>) =>
|
||||||
|
api.post<{ data: FinanceEntry }>('/finance', data).then(r => r.data.data),
|
||||||
|
update: (id: number, data: Omit<FinanceEntry, 'id'>) =>
|
||||||
|
api.put(`/finance/${id}`, data),
|
||||||
|
delete: (id: number) => api.delete(`/finance/${id}`),
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { financeApi, type FinanceEntry } from '../api'
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
|
||||||
|
type EntryType = 'income' | 'expense'
|
||||||
|
type Category = 'fixed' | 'extra'
|
||||||
|
|
||||||
|
interface Summary {
|
||||||
|
incomeFixed: number
|
||||||
|
incomeExtra: number
|
||||||
|
expenseFixed: number
|
||||||
|
expenseExtra: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FinancePage() {
|
||||||
|
const [year, setYear] = useState(currentYear)
|
||||||
|
const [entries, setEntries] = useState<FinanceEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [expandedType, setExpandedType] = useState<EntryType | null>(null)
|
||||||
|
const [expandedCat, setExpandedCat] = useState<'fixed' | 'extra' | null>(null)
|
||||||
|
|
||||||
|
// 新增表单
|
||||||
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
|
const [newEntry, setNewEntry] = useState({
|
||||||
|
month: 0,
|
||||||
|
type: 'expense' as EntryType,
|
||||||
|
category: 'fixed' as Category,
|
||||||
|
description: '',
|
||||||
|
amount: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => { loadData(year) }, [year])
|
||||||
|
|
||||||
|
const loadData = async (y: number) => {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await financeApi.getByYear(y)
|
||||||
|
setEntries(data)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const computeSummary = (): Summary => {
|
||||||
|
const s = { incomeFixed: 0, incomeExtra: 0, expenseFixed: 0, expenseExtra: 0 }
|
||||||
|
entries.forEach(e => {
|
||||||
|
if (e.type === 'income' && e.category === 'fixed') s.incomeFixed += e.amount
|
||||||
|
if (e.type === 'income' && e.category === 'extra') s.incomeExtra += e.amount
|
||||||
|
if (e.type === 'expense' && e.category === 'fixed') s.expenseFixed += e.amount
|
||||||
|
if (e.type === 'expense' && e.category === 'extra') s.expenseExtra += e.amount
|
||||||
|
})
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (n: number) => n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
|
||||||
|
const summary = computeSummary()
|
||||||
|
const years = Array.from({ length: 5 }, (_, i) => currentYear - i)
|
||||||
|
|
||||||
|
const filteredEntries = entries.filter(e =>
|
||||||
|
expandedType === null || e.type === expandedType
|
||||||
|
).filter(e =>
|
||||||
|
expandedCat === null || e.category === expandedCat
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!newEntry.description || !newEntry.amount) return
|
||||||
|
const entry = await financeApi.create({
|
||||||
|
year,
|
||||||
|
month: newEntry.month,
|
||||||
|
type: newEntry.type,
|
||||||
|
category: newEntry.category,
|
||||||
|
description: newEntry.description,
|
||||||
|
amount: parseFloat(newEntry.amount),
|
||||||
|
})
|
||||||
|
setEntries([...entries, entry])
|
||||||
|
setShowAdd(false)
|
||||||
|
setNewEntry({ month: 0, type: 'expense', category: 'fixed', description: '', amount: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
await financeApi.delete(id)
|
||||||
|
setEntries(entries.filter(e => e.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthLabel = (m: number) => m === 0 ? '全年' : `${m}月`
|
||||||
|
|
||||||
|
const Card = ({ label, amount, color, onClick }: {
|
||||||
|
label: string
|
||||||
|
amount: number
|
||||||
|
color: string
|
||||||
|
onClick: () => void
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`flex-1 min-w-0 p-4 rounded-2xl border border-slate-100 text-left hover:shadow-md transition ${color}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs text-slate-500 mb-1">{label}</div>
|
||||||
|
<div className="text-lg font-bold text-slate-800 truncate">¥{fmt(amount)}</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* 顶部:年份选择 */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800">💰 经济</h1>
|
||||||
|
<select
|
||||||
|
value={year}
|
||||||
|
onChange={e => setYear(Number(e.target.value))}
|
||||||
|
className="px-3 py-1.5 border border-slate-200 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
{years.map(y => <option key={y} value={y}>{y}年</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 收支总览卡片 */}
|
||||||
|
<div className="mb-6 space-y-3">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Card
|
||||||
|
label="💼 固定收入" amount={summary.incomeFixed}
|
||||||
|
color="bg-blue-50"
|
||||||
|
onClick={() => {
|
||||||
|
setExpandedType(expandedType === 'income' ? null : 'income')
|
||||||
|
setExpandedCat(expandedType === 'income' ? (expandedCat === 'fixed' ? null : 'fixed') : 'fixed')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Card
|
||||||
|
label="💵 额外收入" amount={summary.incomeExtra}
|
||||||
|
color="bg-green-50"
|
||||||
|
onClick={() => {
|
||||||
|
setExpandedType(expandedType === 'income' ? null : 'income')
|
||||||
|
setExpandedCat(expandedType === 'income' ? (expandedCat === 'extra' ? null : 'extra') : 'extra')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Card
|
||||||
|
label="🏠 固定支出" amount={summary.expenseFixed}
|
||||||
|
color="bg-red-50"
|
||||||
|
onClick={() => {
|
||||||
|
setExpandedType(expandedType === 'expense' ? null : 'expense')
|
||||||
|
setExpandedCat(expandedType === 'expense' ? (expandedCat === 'fixed' ? null : 'fixed') : 'fixed')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Card
|
||||||
|
label="🛒 额外支出" amount={summary.expenseExtra}
|
||||||
|
color="bg-orange-50"
|
||||||
|
onClick={() => {
|
||||||
|
setExpandedType(expandedType === 'expense' ? null : 'expense')
|
||||||
|
setExpandedCat(expandedType === 'expense' ? (expandedCat === 'extra' ? null : 'extra') : 'extra')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 结算 */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-slate-100 rounded-2xl">
|
||||||
|
<span className="text-sm text-slate-600">年结余</span>
|
||||||
|
<span className={`text-xl font-bold ${summary.incomeFixed + summary.incomeExtra - summary.expenseFixed - summary.expenseExtra >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
¥{fmt(summary.incomeFixed + summary.incomeExtra - summary.expenseFixed - summary.expenseExtra)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 明细列表 */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 p-4 mb-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="font-bold text-slate-800">明细 {filteredEntries.length}条</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAdd(!showAdd)}
|
||||||
|
className="text-sm text-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{showAdd ? '取消添加' : '+ 添加条目'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAdd && (
|
||||||
|
<div className="p-3 bg-slate-50 rounded-xl mb-3 space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={newEntry.type}
|
||||||
|
onChange={e => setNewEntry({ ...newEntry, type: e.target.value as EntryType })}
|
||||||
|
className="px-2 py-1.5 border border-slate-200 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="expense">支出</option>
|
||||||
|
<option value="income">收入</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={newEntry.category}
|
||||||
|
onChange={e => setNewEntry({ ...newEntry, category: e.target.value as Category })}
|
||||||
|
className="px-2 py-1.5 border border-slate-200 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="fixed">固定</option>
|
||||||
|
<option value="extra">额外</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={newEntry.month}
|
||||||
|
onChange={e => setNewEntry({ ...newEntry, month: Number(e.target.value) })}
|
||||||
|
className="px-2 py-1.5 border border-slate-200 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value={0}>全年</option>
|
||||||
|
{Array.from({ length: 12 }, (_, i) => i + 1).map(m => (
|
||||||
|
<option key={m} value={m}>{m}月</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newEntry.description}
|
||||||
|
onChange={e => setNewEntry({ ...newEntry, description: e.target.value })}
|
||||||
|
placeholder="描述(如:工资、房租)"
|
||||||
|
className="w-full px-3 py-1.5 border border-slate-200 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={newEntry.amount}
|
||||||
|
onChange={e => setNewEntry({ ...newEntry, amount: e.target.value })}
|
||||||
|
placeholder="金额"
|
||||||
|
className="flex-1 px-3 py-1.5 border border-slate-200 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="px-4 py-1.5 bg-blue-500 text-white rounded-lg text-sm hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center text-slate-400 text-sm py-6">加载中...</div>
|
||||||
|
) : filteredEntries.length === 0 ? (
|
||||||
|
<div className="text-center text-slate-400 text-sm py-6">暂无记录</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 max-h-96 overflow-y-auto">
|
||||||
|
{filteredEntries.map(entry => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 group ${
|
||||||
|
entry.type === 'income' ? 'bg-green-50/50' : 'bg-red-50/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||||
|
entry.type === 'income' ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'
|
||||||
|
}`}>
|
||||||
|
{entry.type === 'income' ? '收' : '支'}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||||
|
entry.category === 'fixed' ? 'bg-blue-50 text-blue-500' : 'bg-orange-50 text-orange-500'
|
||||||
|
}`}>
|
||||||
|
{entry.category === 'fixed' ? '固定' : '额外'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400 w-12">{monthLabel(entry.month)}</span>
|
||||||
|
<span className="flex-1 text-sm text-slate-700 truncate">{entry.description}</span>
|
||||||
|
<span className={`text-sm font-medium ${entry.type === 'income' ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{entry.type === 'income' ? '+' : '-'}¥{fmt(entry.amount)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(entry.id)}
|
||||||
|
className="text-slate-300 hover:text-red-500 text-xs opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ export default function HomePage() {
|
|||||||
const [newTitle, setNewTitle] = useState('')
|
const [newTitle, setNewTitle] = useState('')
|
||||||
const [newPriority, setNewPriority] = useState(2)
|
const [newPriority, setNewPriority] = useState(2)
|
||||||
const [newDueDate, setNewDueDate] = useState('')
|
const [newDueDate, setNewDueDate] = useState('')
|
||||||
const [filter, setFilter] = useState<Filter>('all')
|
const [filter, setFilter] = useState<Filter>('active')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
|
|||||||
Reference in New Issue
Block a user