diff --git a/cmd/server/main.go b/cmd/server/main.go index d05c9b0..aab750a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -36,14 +36,16 @@ func main() { } // 自动建表 - db.AutoMigrate(&model.Todo{}, &model.DailyLog{}) + db.AutoMigrate(&model.Todo{}, &model.DailyLog{}, &model.FinanceEntry{}) // 初始化各层 todoRepo := repository.NewTodoRepository(db) dailyLogRepo := repository.NewDailyLogRepository(db) + financeRepo := repository.NewFinanceRepository(db) todoSvc := service.NewTodoService(todoRepo) dailyLogSvc := service.NewDailyLogService(dailyLogRepo) - h := handler.NewHandler(todoSvc, dailyLogSvc) + financeSvc := service.NewFinanceService(financeRepo) + h := handler.NewHandler(todoSvc, dailyLogSvc, financeSvc) // Gin 路由 r := gin.New() @@ -70,6 +72,12 @@ func main() { api.GET("/logs/:date", h.GetDailyLog) api.GET("/logs", h.GetRecentLogs) 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") diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 96522f9..3de4f10 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -5,18 +5,20 @@ import ( "strconv" "time" + "lifelog/internal/model" "lifelog/internal/service" "github.com/gin-gonic/gin" ) type Handler struct { - todoSvc *service.TodoService - dailyLogSvc *service.DailyLogService + todoSvc *service.TodoService + dailyLogSvc *service.DailyLogService + financeSvc *service.FinanceService } -func NewHandler(todoSvc *service.TodoService, dailyLogSvc *service.DailyLogService) *Handler { - return &Handler{todoSvc: todoSvc, dailyLogSvc: dailyLogSvc} +func NewHandler(todoSvc *service.TodoService, dailyLogSvc *service.DailyLogService, financeSvc *service.FinanceService) *Handler { + return &Handler{todoSvc: todoSvc, dailyLogSvc: dailyLogSvc, financeSvc: financeSvc} } // Todo handlers @@ -165,3 +167,95 @@ func (h *Handler) SaveDailyLog(c *gin.Context) { } 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"}) +} diff --git a/internal/model/model.go b/internal/model/model.go index 9276935..62da598 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -31,3 +31,22 @@ type DailyLog struct { func (DailyLog) TableName() string { 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" +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index da15bca..b9d5893 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -76,3 +76,35 @@ func (r *DailyLogRepository) Upsert(log *model.DailyLog) error { existing.Evening = log.Evening 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 +} diff --git a/internal/service/service.go b/internal/service/service.go index 21bc513..a7489bc 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -97,3 +97,27 @@ func (s *DailyLogService) Save(date, morning, afternoon, evening string) (*model err := s.repo.Upsert(log) 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) +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 947ed4e..252a71e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,22 +1,43 @@ +import { useState } from 'react' import HomePage from './pages/HomePage' +import FinancePage from './pages/FinancePage' + +type Tab = 'home' | 'finance' + +export default function App() { + const [tab, setTab] = useState('home') -function App() { return (
-
) } - -export default App diff --git a/web/src/api.ts b/web/src/api.ts index c94db24..7bc4a53 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -41,3 +41,23 @@ export const logApi = { getRecent: (limit = 30) => 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) => + api.post<{ data: FinanceEntry }>('/finance', data).then(r => r.data.data), + update: (id: number, data: Omit) => + api.put(`/finance/${id}`, data), + delete: (id: number) => api.delete(`/finance/${id}`), +} diff --git a/web/src/pages/FinancePage.tsx b/web/src/pages/FinancePage.tsx new file mode 100644 index 0000000..559970c --- /dev/null +++ b/web/src/pages/FinancePage.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [expandedType, setExpandedType] = useState(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 + }) => ( + + ) + + return ( +
+ {/* 顶部:年份选择 */} +
+

💰 经济

+ +
+ + {/* 收支总览卡片 */} +
+
+ { + setExpandedType(expandedType === 'income' ? null : 'income') + setExpandedCat(expandedType === 'income' ? (expandedCat === 'fixed' ? null : 'fixed') : 'fixed') + }} + /> + { + setExpandedType(expandedType === 'income' ? null : 'income') + setExpandedCat(expandedType === 'income' ? (expandedCat === 'extra' ? null : 'extra') : 'extra') + }} + /> +
+
+ { + setExpandedType(expandedType === 'expense' ? null : 'expense') + setExpandedCat(expandedType === 'expense' ? (expandedCat === 'fixed' ? null : 'fixed') : 'fixed') + }} + /> + { + setExpandedType(expandedType === 'expense' ? null : 'expense') + setExpandedCat(expandedType === 'expense' ? (expandedCat === 'extra' ? null : 'extra') : 'extra') + }} + /> +
+ + {/* 结算 */} +
+ 年结余 + = 0 ? 'text-green-600' : 'text-red-600'}`}> + ¥{fmt(summary.incomeFixed + summary.incomeExtra - summary.expenseFixed - summary.expenseExtra)} + +
+
+ + {/* 明细列表 */} +
+
+

明细 {filteredEntries.length}条

+ +
+ + {showAdd && ( +
+
+ + + +
+ setNewEntry({ ...newEntry, description: e.target.value })} + placeholder="描述(如:工资、房租)" + className="w-full px-3 py-1.5 border border-slate-200 rounded-lg text-sm" + /> +
+ setNewEntry({ ...newEntry, amount: e.target.value })} + placeholder="金额" + className="flex-1 px-3 py-1.5 border border-slate-200 rounded-lg text-sm" + /> + +
+
+ )} + + {loading ? ( +
加载中...
+ ) : filteredEntries.length === 0 ? ( +
暂无记录
+ ) : ( +
+ {filteredEntries.map(entry => ( +
+ + {entry.type === 'income' ? '收' : '支'} + + + {entry.category === 'fixed' ? '固定' : '额外'} + + {monthLabel(entry.month)} + {entry.description} + + {entry.type === 'income' ? '+' : '-'}¥{fmt(entry.amount)} + + +
+ ))} +
+ )} +
+
+ ) +} diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index 087e255..bb341ef 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -25,7 +25,7 @@ export default function HomePage() { const [newTitle, setNewTitle] = useState('') const [newPriority, setNewPriority] = useState(2) const [newDueDate, setNewDueDate] = useState('') - const [filter, setFilter] = useState('all') + const [filter, setFilter] = useState('active') const [loading, setLoading] = useState(true) const today = new Date()