From eddf05e7fe4aad05227c33cd876e30d6ad37b200 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 21:08:16 +0800 Subject: [PATCH 01/28] feat(dateutil): add shared date formatting and weekday mapping --- pkg/utils/dateutil/dateutil.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 pkg/utils/dateutil/dateutil.go diff --git a/pkg/utils/dateutil/dateutil.go b/pkg/utils/dateutil/dateutil.go new file mode 100644 index 0000000..87205bc --- /dev/null +++ b/pkg/utils/dateutil/dateutil.go @@ -0,0 +1,22 @@ +// Package dateutil provides shared date formatting and weekday mapping for the application. +package dateutil + +import "time" + +// DateFormat is the standard date string format used across services. +const DateFormat = "2006-01-02" + +// DayOfWeekNames maps time.Weekday (0=Sunday) to Chinese weekday names. +var DayOfWeekNames = [...]string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"} + +// FormatTimestamp converts a Unix-millisecond timestamp to a date string and Chinese weekday name. +func FormatTimestamp(tsMillis int64) (date string, dayOfWeek string) { + t := time.UnixMilli(tsMillis) + return t.Format(DateFormat), DayOfWeekNames[t.Weekday()] +} + +// ParseDate parses a DateFormat-formatted string into a time.Time. +// It always succeeds for strings produced by FormatTimestamp. +func ParseDate(date string) (time.Time, error) { + return time.Parse(DateFormat, date) +} From 5651ba2a650d3373b4dc3aa08dffd55eeef2f1aa Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 21:08:51 +0800 Subject: [PATCH 02/28] refactor(model): add inventory status constants --- pkg/model/inventory.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/model/inventory.go b/pkg/model/inventory.go index 5a83ae4..c423bba 100644 --- a/pkg/model/inventory.go +++ b/pkg/model/inventory.go @@ -2,6 +2,11 @@ package model import "gorm.io/gorm" +const ( + InventoryStatusInInventory = "in_inventory" + InventoryStatusListed = "listed" +) + type InventoryItem struct { gorm.Model CS2Item `gorm:"embedded"` From f5a9e44466b3214f661f7c1a7f3fe91ba73e7fdd Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 21:09:43 +0800 Subject: [PATCH 03/28] feat(orm): add FindDailySells using two-query pattern --- pkg/orm/interfaces.go | 1 + pkg/orm/trade.go | 84 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/pkg/orm/interfaces.go b/pkg/orm/interfaces.go index 677cd6d..86eef96 100644 --- a/pkg/orm/interfaces.go +++ b/pkg/orm/interfaces.go @@ -31,6 +31,7 @@ type TradeInterface interface { FindCompletedTradeGroupKeys(accountID uint, offset, limit int, sortBy, sortDir string) ([]InventoryGroupKey, int64, error) FindSellsByGroupKeys(accountID uint, keys []InventoryGroupKey) ([]model.TradeRecord, error) FindTradeRecordsByIDs(ids []uint) ([]model.TradeRecord, error) + FindDailySells(accountID uint, year, month int) ([]DailySellRow, error) } type InventoryGroupKey struct { diff --git a/pkg/orm/trade.go b/pkg/orm/trade.go index e2404a9..1b5ab2b 100644 --- a/pkg/orm/trade.go +++ b/pkg/orm/trade.go @@ -2,12 +2,27 @@ package orm import ( "strings" + "time" "gorm.io/gorm" "github.com/CsJsss/CS2Ledger/pkg/model" ) +// DailySellRow is a denormalized row for daily-sell queries, assembled from a matched sell+buy pair. +type DailySellRow struct { + SellID uint + ItemName string + Exterior string + Quantity int64 + SellPrice int64 + SellFee int64 + SellAt int64 + Source string + BuyPrice int64 + BuyFee int64 +} + func (o *ormImpl) CreateTrade(t *model.TradeRecord) error { t.ItemName = strings.TrimSpace(t.ItemName) if t.ExternalID == "" { @@ -211,3 +226,72 @@ func (o *ormImpl) FindTradeRecordsByIDs(ids []uint) ([]model.TradeRecord, error) err := o.db.Where("id IN ?", ids).Find(&records).Error return records, err } + +func (o *ormImpl) FindDailySells(accountID uint, year, month int) ([]DailySellRow, error) { + // Step 1: query matched sells. + q := o.db.Model(&model.TradeRecord{}). + Where("trade_type = ? AND matched_buy_trade_id IS NOT NULL", model.DirectionSell) + if accountID != 0 { + q = q.Where("account_id = ?", accountID) + } + if year > 0 && month > 0 { + start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC).UnixMilli() + end := time.Date(year, time.Month(month+1), 1, 0, 0, 0, 0, time.UTC).UnixMilli() + q = q.Where("trade_at >= ? AND trade_at < ?", start, end) + } + + var sells []model.TradeRecord + if err := q.Order("trade_at DESC").Find(&sells).Error; err != nil { + return nil, err + } + if len(sells) == 0 { + return nil, nil + } + + // Step 2: collect matched buy IDs. + buyIDs := make([]uint, 0, len(sells)) + for _, s := range sells { + if s.MatchedBuyTradeID != nil { + buyIDs = append(buyIDs, *s.MatchedBuyTradeID) + } + } + if len(buyIDs) == 0 { + return nil, nil + } + + // Step 3: query buys by IDs. + var buys []model.TradeRecord + if err := o.db.Model(&model.TradeRecord{}). + Where("id IN ?", buyIDs).Find(&buys).Error; err != nil { + return nil, err + } + buyMap := make(map[uint]*model.TradeRecord, len(buys)) + for i := range buys { + buyMap[buys[i].ID] = &buys[i] + } + + // Step 4: assemble rows in Go. + rows := make([]DailySellRow, 0, len(sells)) + for _, s := range sells { + if s.MatchedBuyTradeID == nil { + continue + } + b, ok := buyMap[*s.MatchedBuyTradeID] + if !ok { + continue + } + rows = append(rows, DailySellRow{ + SellID: s.ID, + ItemName: s.ItemName, + Exterior: s.Exterior, + Quantity: s.Quantity, + SellPrice: s.UnitPrice, + SellFee: s.Fee, + SellAt: s.TradeAt, + Source: s.Source, + BuyPrice: b.UnitPrice, + BuyFee: b.Fee, + }) + } + return rows, nil +} From 6dd61315cda13083537226894e9db0be27b3fed6 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 21:10:30 +0800 Subject: [PATCH 04/28] feat(orm): add FindDailyBuys using Preload pattern --- pkg/orm/interfaces.go | 1 + pkg/orm/inventory.go | 46 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/pkg/orm/interfaces.go b/pkg/orm/interfaces.go index 86eef96..3d00415 100644 --- a/pkg/orm/interfaces.go +++ b/pkg/orm/interfaces.go @@ -46,6 +46,7 @@ type InventoryInterface interface { FindInventoryByAssetID(accountID uint, assetID string) (*model.InventoryItem, error) FindInventoryGroupKeys(accountID uint, status, weaponType string, offset, limit int, sortBy, sortDir string) ([]InventoryGroupKey, int64, error) FindInventoryByGroupKeys(accountID uint, keys []InventoryGroupKey) ([]model.InventoryItem, error) + FindDailyBuys(accountID uint) ([]DailyBuyRow, error) } type PnlInterface interface { diff --git a/pkg/orm/inventory.go b/pkg/orm/inventory.go index 31a891f..0025514 100644 --- a/pkg/orm/inventory.go +++ b/pkg/orm/inventory.go @@ -10,6 +10,19 @@ import ( "github.com/CsJsss/CS2Ledger/pkg/model" ) +// DailyBuyRow is a denormalized row for daily-buy queries, assembled from an inventory item and its buy trade. +type DailyBuyRow struct { + ItemName string + Exterior string + Quantity int64 + BuyPrice int64 + BuyAt int64 + Source string + Status string + MarketHashName string + CsqaqGoodsID int +} + func (o *ormImpl) UpsertInventory(item *model.InventoryItem) error { return o.db.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "account_id"}, {Name: "asset_id"}}, @@ -82,6 +95,39 @@ func (o *ormImpl) FindInventoryGroupKeys(accountID uint, status, weaponType stri // FindInventoryByGroupKeys returns inventory items matching the given (item_name, exterior) pairs. // Pass accountID=0 to query across all accounts. +func (o *ormImpl) FindDailyBuys(accountID uint) ([]DailyBuyRow, error) { + q := o.db.Model(&model.InventoryItem{}). + Where("status IN ?", []string{model.InventoryStatusInInventory, model.InventoryStatusListed}). + Preload("BuyTrade") + if accountID != 0 { + q = q.Where("account_id = ?", accountID) + } + + var items []model.InventoryItem + if err := q.Order("updated_at DESC").Find(&items).Error; err != nil { + return nil, err + } + + rows := make([]DailyBuyRow, 0, len(items)) + for _, it := range items { + if it.BuyTrade == nil { + continue + } + rows = append(rows, DailyBuyRow{ + ItemName: it.ItemName, + Exterior: it.Exterior, + Quantity: it.Quantity, + BuyPrice: it.BuyTrade.UnitPrice, + BuyAt: it.BuyTrade.TradeAt, + Source: it.BuyTrade.Source, + Status: it.Status, + MarketHashName: it.MarketHashName, + CsqaqGoodsID: it.CsqaqGoodsID, + }) + } + return rows, nil +} + func (o *ormImpl) FindInventoryByGroupKeys(accountID uint, keys []InventoryGroupKey) ([]model.InventoryItem, error) { if len(keys) == 0 { return nil, nil From 6c17322e6e73ba6769cd4ac41d013c273a57c481 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 21:11:51 +0800 Subject: [PATCH 05/28] feat(trade): add ListDailySells using dateutil for group-by-date --- pkg/service/trade/service.go | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/pkg/service/trade/service.go b/pkg/service/trade/service.go index 5a27534..85f312c 100644 --- a/pkg/service/trade/service.go +++ b/pkg/service/trade/service.go @@ -9,6 +9,7 @@ import ( "github.com/CsJsss/CS2Ledger/pkg/model" "github.com/CsJsss/CS2Ledger/pkg/orm" "github.com/CsJsss/CS2Ledger/pkg/platform" + "github.com/CsJsss/CS2Ledger/pkg/utils/dateutil" "github.com/CsJsss/CS2Ledger/pkg/utils/logfx" ) @@ -94,6 +95,26 @@ type CompletedTradesSummary struct { TotalNetPl int64 `json:"totalNetPl"` } +type DailySellItem struct { + ItemName string `json:"itemName"` + Exterior string `json:"exterior"` + Quantity int64 `json:"quantity"` + BuyPrice int64 `json:"buyPrice"` + SellPrice int64 `json:"sellPrice"` + TotalFee int64 `json:"totalFee"` + Profit int64 `json:"profit"` + Platform string `json:"platform"` +} + +type DailySellGroup struct { + Date string `json:"date"` + DayOfWeek string `json:"dayOfWeek"` + Items []DailySellItem `json:"items"` + TotalCount int `json:"totalCount"` + TotalProfit int64 `json:"totalProfit"` + TotalFee int64 `json:"totalFee"` +} + type TradeInterface interface { ListByAccount(accountID uint, tradeType string) ([]model.TradeRecord, error) ListCompletedTrades(accountID uint) ([]CompletedTradeView, error) @@ -102,6 +123,7 @@ type TradeInterface interface { ListUnmatchedSells(accountID uint) ([]model.TradeRecord, error) SetPriceProvider(p PriceProvider) SetPriceSource(source string) + ListDailySells(accountID uint, year, month int) ([]DailySellGroup, error) } type service struct { @@ -394,6 +416,51 @@ func (svc *service) GetCompletedTradesSummary(accountID uint) (*CompletedTradesS return sum, nil } +func (svc *service) ListDailySells(accountID uint, year, month int) ([]DailySellGroup, error) { + rows, err := svc.orm.FindDailySells(accountID, year, month) + if err != nil { + return nil, err + } + + type dateKey string + byDate := make(map[dateKey][]DailySellItem) + for _, r := range rows { + date, _ := dateutil.FormatTimestamp(r.SellAt) + dk := dateKey(date) + profit := (r.SellPrice-r.BuyPrice)*r.Quantity - (r.SellFee + r.BuyFee) + byDate[dk] = append(byDate[dk], DailySellItem{ + ItemName: r.ItemName, + Exterior: r.Exterior, + Quantity: r.Quantity, + BuyPrice: r.BuyPrice, + SellPrice: r.SellPrice, + TotalFee: r.SellFee + r.BuyFee, + Profit: profit, + Platform: r.Source, + }) + } + + groups := make([]DailySellGroup, 0, len(byDate)) + for dk, items := range byDate { + var totalProfit, totalFee int64 + for _, it := range items { + totalProfit += it.Profit + totalFee += it.TotalFee + } + t, _ := dateutil.ParseDate(string(dk)) + groups = append(groups, DailySellGroup{ + Date: string(dk), + DayOfWeek: dateutil.DayOfWeekNames[t.Weekday()], + Items: items, + TotalCount: len(items), + TotalProfit: totalProfit, + TotalFee: totalFee, + }) + } + sort.Slice(groups, func(i, j int) bool { return groups[i].Date > groups[j].Date }) + return groups, nil +} + var Module = fx.Module("trade", logfx.WithComponent("trade"), fx.Provide( From e54a7915543d3b1b91dac5dee9036fc873b815f2 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 21:13:18 +0800 Subject: [PATCH 06/28] feat(inventory): extract resolvePriceMap, add ListDailyBuys --- pkg/service/inventory/service.go | 112 +++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/pkg/service/inventory/service.go b/pkg/service/inventory/service.go index 764c35b..ad0d2d4 100644 --- a/pkg/service/inventory/service.go +++ b/pkg/service/inventory/service.go @@ -9,6 +9,7 @@ import ( "github.com/CsJsss/CS2Ledger/pkg/model" "github.com/CsJsss/CS2Ledger/pkg/orm" "github.com/CsJsss/CS2Ledger/pkg/platform" + "github.com/CsJsss/CS2Ledger/pkg/utils/dateutil" "github.com/CsJsss/CS2Ledger/pkg/utils/logfx" ) @@ -69,6 +70,7 @@ type InventoryInterface interface { List(accountID uint, status string) ([]model.InventoryItem, error) GetItemDetail(accountID uint, assetID string) (*ItemDetail, error) ListGroups(accountID uint, status, weaponType string, page, pageSize int, sortBy, sortDir string) (*PaginatedGroups, error) + ListDailyBuys(accountID uint) ([]DailyBuyGroup, error) SetPriceProvider(p PriceProvider) SetPriceSource(source string) } @@ -99,6 +101,32 @@ func (s *service) SetPriceSource(source string) { s.priceSource = source } +// resolvePriceMap fetches market prices and returns a map from MarketHashName to price in fen. +// Returns nil if prices are unavailable (best-effort). +func (s *service) resolvePriceMap() map[string]int64 { + if s.prices == nil { + return nil + } + priceList, err := s.prices.GetAllPrices() + if err != nil { + return nil + } + priceMap := make(map[string]int64, len(priceList)) + for _, p := range priceList { + var mp int64 + switch s.priceSource { + case "youpin": + mp = int64(p.YoupinPrice * 100) + case "steam": + mp = int64(p.SteamPrice * 100) + default: + mp = int64(p.BuffPrice * 100) + } + priceMap[p.MarketHashName] = mp + } + return priceMap +} + func (s *service) List(accountID uint, status string) ([]model.InventoryItem, error) { return s.orm.FindInventoryByAccount(accountID, status) } @@ -293,6 +321,69 @@ func (s *service) sortGroups(groups []InventoryGroup, sortBy, sortDir string) { }) } +func (s *service) ListDailyBuys(accountID uint) ([]DailyBuyGroup, error) { + rows, err := s.orm.FindDailyBuys(accountID) + if err != nil { + return nil, err + } + + priceMap := s.resolvePriceMap() + + type dateKey string + byDate := make(map[dateKey][]DailyBuyItem) + for _, r := range rows { + date, _ := dateutil.FormatTimestamp(r.BuyAt) + dk := dateKey(date) + totalCost := r.BuyPrice * r.Quantity + item := DailyBuyItem{ + ItemName: r.ItemName, + Exterior: r.Exterior, + Quantity: r.Quantity, + BuyPrice: r.BuyPrice, + TotalCost: totalCost, + Platform: r.Source, + Status: r.Status, + } + if priceMap != nil { + if mp, ok := priceMap[r.MarketHashName]; ok { + item.MarketPrice = &mp + upl := (mp - r.BuyPrice) * r.Quantity + item.UnrealizedPl = &upl + } + } + byDate[dk] = append(byDate[dk], item) + } + + groups := make([]DailyBuyGroup, 0, len(byDate)) + for dk, items := range byDate { + var totalCost int64 + var totalMV int64 + hasMV := true + for _, it := range items { + totalCost += it.TotalCost + if it.MarketPrice != nil { + totalMV += *it.MarketPrice * it.Quantity + } else { + hasMV = false + } + } + t, _ := dateutil.ParseDate(string(dk)) + g := DailyBuyGroup{ + Date: string(dk), + DayOfWeek: dateutil.DayOfWeekNames[t.Weekday()], + Items: items, + TotalCount: len(items), + TotalCost: totalCost, + } + if hasMV && len(items) > 0 { + g.TotalMarketValue = &totalMV + } + groups = append(groups, g) + } + sort.Slice(groups, func(i, j int) bool { return groups[i].Date > groups[j].Date }) + return groups, nil +} + func (s *service) GetItemDetail(accountID uint, assetID string) (*ItemDetail, error) { item, err := s.orm.FindInventoryByAssetID(accountID, assetID) if err != nil || item == nil { @@ -333,6 +424,27 @@ type RentalSummary struct { RentCount int `json:"rentCount"` } +type DailyBuyItem struct { + ItemName string `json:"itemName"` + Exterior string `json:"exterior"` + Quantity int64 `json:"quantity"` + BuyPrice int64 `json:"buyPrice"` + TotalCost int64 `json:"totalCost"` + MarketPrice *int64 `json:"marketPrice,omitempty"` + UnrealizedPl *int64 `json:"unrealizedPl,omitempty"` + Platform string `json:"platform"` + Status string `json:"status"` +} + +type DailyBuyGroup struct { + Date string `json:"date"` + DayOfWeek string `json:"dayOfWeek"` + Items []DailyBuyItem `json:"items"` + TotalCount int `json:"totalCount"` + TotalCost int64 `json:"totalCost"` + TotalMarketValue *int64 `json:"totalMarketValue,omitempty"` +} + var Module = fx.Module("inventory", logfx.WithComponent("inventory"), fx.Provide( From 640b6575a211be83e68beb180dd98913d01bd275 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 21:13:49 +0800 Subject: [PATCH 07/28] feat(app): expose GetDailySells and GetDailyBuys --- app.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app.go b/app.go index 82e957f..51c90ac 100644 --- a/app.go +++ b/app.go @@ -111,6 +111,10 @@ func (a *App) GetItemDetail(accountID uint, assetID string) (*inventory.ItemDeta return a.svc.Inventory().GetItemDetail(accountID, assetID) } +func (a *App) GetDailyBuys(accountID uint) ([]inventory.DailyBuyGroup, error) { + return a.svc.Inventory().ListDailyBuys(accountID) +} + func (a *App) GetCompletedTrades(accountID uint, page, pageSize int, sortBy, sortDir string) (*trade.PaginatedGroups, error) { return a.svc.Trade().ListCompletedTradeGroups(accountID, page, pageSize, sortBy, sortDir) } @@ -132,6 +136,10 @@ func (a *App) GetUnmatchedSells(accountID uint) ([]model.TradeRecord, error) { return a.svc.Trade().ListUnmatchedSells(accountID) } +func (a *App) GetDailySells(accountID uint, year, month int) ([]trade.DailySellGroup, error) { + return a.svc.Trade().ListDailySells(accountID, year, month) +} + func (a *App) GetPnlSummary(accountID uint) (*pnl.PnlSummaryView, error) { return a.svc.Pnl().GetSummary(accountID) } From f72f170008c9feac90fa4b3f5f1d3010ecf119f1 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 21:14:34 +0800 Subject: [PATCH 08/28] feat(frontend): add shared hooks for daily trading views --- frontend/src/hooks/useDailyBuys.ts | 16 ++++++++++++++++ frontend/src/hooks/useDailySells.ts | 20 ++++++++++++++++++++ frontend/src/hooks/useExpandableSet.ts | 25 +++++++++++++++++++++++++ frontend/src/lib/wails.ts | 4 ++++ 4 files changed, 65 insertions(+) create mode 100644 frontend/src/hooks/useDailyBuys.ts create mode 100644 frontend/src/hooks/useDailySells.ts create mode 100644 frontend/src/hooks/useExpandableSet.ts diff --git a/frontend/src/hooks/useDailyBuys.ts b/frontend/src/hooks/useDailyBuys.ts new file mode 100644 index 0000000..88c8b4f --- /dev/null +++ b/frontend/src/hooks/useDailyBuys.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; +import { GetDailyBuys } from '../lib/wails'; +import { useAccounts } from './useAccounts'; + +const STALE_TIME_MS = 2 * 60 * 1000; + +export function useDailyBuys(selectedAccountId: number | null) { + const { data: accounts = [] } = useAccounts(); + + return useQuery({ + queryKey: ['dailyBuys', selectedAccountId ?? 0], + queryFn: () => GetDailyBuys(selectedAccountId ?? 0), + staleTime: STALE_TIME_MS, + enabled: selectedAccountId !== null || accounts.length > 0, + }); +} diff --git a/frontend/src/hooks/useDailySells.ts b/frontend/src/hooks/useDailySells.ts new file mode 100644 index 0000000..444df7f --- /dev/null +++ b/frontend/src/hooks/useDailySells.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query'; +import { GetDailySells } from '../lib/wails'; +import { useAccounts } from './useAccounts'; + +const STALE_TIME_MS = 2 * 60 * 1000; + +export function useDailySells( + selectedAccountId: number | null, + year: number, + month: number, +) { + const { data: accounts = [] } = useAccounts(); + + return useQuery({ + queryKey: ['dailySells', selectedAccountId ?? 0, year, month], + queryFn: () => GetDailySells(selectedAccountId ?? 0, year, month), + staleTime: STALE_TIME_MS, + enabled: selectedAccountId !== null || accounts.length > 0, + }); +} diff --git a/frontend/src/hooks/useExpandableSet.ts b/frontend/src/hooks/useExpandableSet.ts new file mode 100644 index 0000000..7f3c3ad --- /dev/null +++ b/frontend/src/hooks/useExpandableSet.ts @@ -0,0 +1,25 @@ +import { useState, useCallback } from 'react'; + +/** + * Shared hook for expand/collapse state using a Set of string keys. + * Used by collapsible day cards in daily sell/buy views. + */ +export function useExpandableSet() { + const [expanded, setExpanded] = useState>(new Set()); + + const toggle = useCallback((key: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, []); + + const isExpanded = useCallback((key: string) => expanded.has(key), [expanded]); + + return { expanded, isExpanded, toggle }; +} diff --git a/frontend/src/lib/wails.ts b/frontend/src/lib/wails.ts index 8fcf25f..435b2f2 100644 --- a/frontend/src/lib/wails.ts +++ b/frontend/src/lib/wails.ts @@ -10,6 +10,8 @@ import { GetCompletedTrades, GetCompletedTradesSummary, GetUnmatchedSells, + GetDailySells, + GetDailyBuys, GetPnlSummary, GetMonthlyBreakdown, GetDashboardSummary, @@ -34,6 +36,8 @@ export { GetCompletedTrades, GetCompletedTradesSummary, GetUnmatchedSells, + GetDailySells, + GetDailyBuys, GetPnlSummary, GetMonthlyBreakdown, GetDashboardSummary, From f6f980cbfcab727dfa227c445733bd140b6844fb Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 21:15:46 +0800 Subject: [PATCH 09/28] feat(frontend): add daily sell tab to CompletedTradesPage --- frontend/src/pages/CompletedTradesPage.tsx | 230 ++++++++++++++++++++- 1 file changed, 229 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/CompletedTradesPage.tsx b/frontend/src/pages/CompletedTradesPage.tsx index e1781e1..3ee1c3f 100644 --- a/frontend/src/pages/CompletedTradesPage.tsx +++ b/frontend/src/pages/CompletedTradesPage.tsx @@ -45,6 +45,8 @@ import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { useCompletedTrades } from '../hooks/useCompletedTrades'; import { useCompletedTradesSummary } from '../hooks/useCompletedTradesSummary'; import { useUnmatchedSells } from '../hooks/useUnmatchedSells'; +import { useDailySells } from '../hooks/useDailySells'; +import { useExpandableSet } from '../hooks/useExpandableSet'; import { useUIStore } from '../store/uiStore'; import { formatCNY, plHexColor } from '../lib/format'; import { BrowserOpenURL } from '../../wailsjs/runtime/runtime'; @@ -57,7 +59,7 @@ declare module '@tanstack/react-table' { } } -type TabKey = 'completed' | 'unmatched'; +type TabKey = 'completed' | 'unmatched' | 'dailySell'; interface GroupedTrade { itemName: string; @@ -1168,6 +1170,230 @@ function UnmatchedSellsContent({ ); } +// ─── Daily Sells Tab Content ────────────────────────────────────────────────── + +const SKELETON_COUNT = 5; + +function DailySellsContent({ accountId }: { accountId: number | null }) { + const [dismissed, setDismissed] = useState(false); + const now = new Date(); + const [year, setYear] = useState(now.getFullYear()); + const [month, setMonth] = useState(now.getMonth() + 1); + + const { data: groups = [], isLoading, error, refetch } = useDailySells(accountId, year, month); + const { isExpanded, toggle } = useExpandableSet(); + + const totalProfit = groups.reduce((s, g) => s + g.totalProfit, 0); + const totalFee = groups.reduce((s, g) => s + g.totalFee, 0); + const totalCount = groups.reduce((s, g) => s + g.totalCount, 0); + + const monthLabel = `${year}年${month}月`; + + const handlePrevMonth = () => { + if (month === 1) { setYear(year - 1); setMonth(12); } + else setMonth(month - 1); + }; + const handleNextMonth = () => { + if (month === 12) { setYear(year + 1); setMonth(1); } + else setMonth(month + 1); + }; + + if (isLoading) { + return ( + + {Array.from({ length: SKELETON_COUNT }, (_, i) => ( + + ))} + + ); + } + + if (error && !dismissed) { + return ( + + { setDismissed(false); void refetch(); }} + onDismiss={() => setDismissed(true)} + /> + + ); + } + + return ( + + + + + + {monthLabel} + + + + + 共卖出 {totalCount} 件 · 总利润{' '} + {formatCNY(totalProfit)}{' '} + · 总手续费 {formatCNY(totalFee)} + + + + {groups.length === 0 && ( + } + title="该月无卖出记录" + description="切换月份查看其他时间段的卖出数据。" + /> + )} + + {groups.length > 0 && ( + + + + + {groups.map((group) => { + const expanded = isExpanded(group.date); + return ( + + toggle(group.date)} + > + + + {expanded ? ( + + ) : ( + + )} + + + + + {new Date(group.date).getDate()} + + + {group.dayOfWeek} + + + + + 卖出 {group.totalCount} 件 + + + 利润{' '} + + {formatCNY(group.totalProfit)} + {' '} + · 手续费 {formatCNY(group.totalFee)} + + + + + + + +
+ + + 物品 + 数量 + 买入价 + 卖出价 + 手续费 + 利润 + 利润率 + 平台 + + + + {group.items.map((item, idx) => { + const costBasis = item.buyPrice * item.quantity; + const profitRate = costBasis > 0 + ? (item.profit / costBasis) * 100 + : 0; + return ( + + + + {item.itemName} + {item.exterior ? ` (${item.exterior})` : ''} + + + + + {item.quantity} + + + + + {formatCNY(item.buyPrice)} + + + + + {formatCNY(item.sellPrice)} + + + + + {formatCNY(item.totalFee)} + + + + + {formatCNY(item.profit)} + + + + + {profitRate >= 0 ? '+' : ''}{profitRate.toFixed(1)}% + + + + + {item.platform} + + + + ); + })} + + + + 当日合计:利润 {formatCNY(group.totalProfit)} · 手续费{' '} + {formatCNY(group.totalFee)} · 净利{' '} + {formatCNY(group.totalProfit - group.totalFee)} + + + + +
+
+ + + + + ); + })} + + + + + )} + + ); +} + // ─── Page ──────────────────────────────────────────────────────────────────── export default function CompletedTradesPage() { @@ -1191,6 +1417,7 @@ export default function CompletedTradesPage() { setTab(v as TabKey)} sx={{ mb: 1 }}> + {tab === 'completed' && ( @@ -1199,6 +1426,7 @@ export default function CompletedTradesPage() { {tab === 'unmatched' && ( )} + {tab === 'dailySell' && } ); } From e4c7c7e55814e31ca45f1b7931e7bdcb4061f36c Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 21:17:04 +0800 Subject: [PATCH 10/28] feat(frontend): add daily buy tab to InventoryPage --- frontend/src/pages/InventoryPage.tsx | 282 ++++++++++++++++++++++++++- 1 file changed, 276 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index 94d3f89..6fdc096 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -34,7 +34,12 @@ import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import Tooltip from '@mui/material/Tooltip'; +import Tabs from '@mui/material/Tabs'; +import Tab from '@mui/material/Tab'; +import ReceiptIcon from '@mui/icons-material/Receipt'; import type { model } from '../lib/wails'; +import { useDailyBuys } from '../hooks/useDailyBuys'; +import { useExpandableSet } from '../hooks/useExpandableSet'; declare module '@tanstack/react-table' { // eslint-disable-next-line @typescript-eslint/no-unused-vars interface ColumnMeta { @@ -207,6 +212,262 @@ const groupedColumns: ColumnDef[] = [ }, ]; +const SKELETON_COUNT = 5; + +const dailyBuyStatusLabel: Record = { + in_inventory: '持有中', + listed: '已上架', +}; + +const dailyBuyStatusColor = (status: string): 'success' | 'warning' | 'default' => + status === 'listed' ? 'warning' : 'success'; + +function DailyBuysContent({ accountId }: { accountId: number | null }) { + const [dismissed, setDismissed] = useState(false); + const { data: groups = [], isLoading, error, refetch } = useDailyBuys(accountId); + const { isExpanded, toggle } = useExpandableSet(); + + const totalCost = groups.reduce((s, g) => s + g.totalCost, 0); + const totalCount = groups.reduce((s, g) => s + g.totalCount, 0); + + let totalMV: number | null = null; + let allHaveMV = true; + for (const g of groups) { + if (g.totalMarketValue != null) { + totalMV = (totalMV ?? 0) + g.totalMarketValue; + } else { + allHaveMV = false; + } + } + + if (isLoading) { + return ( + + {Array.from({ length: SKELETON_COUNT }, (_, i) => ( + + ))} + + ); + } + + if (error && !dismissed) { + return ( + + { setDismissed(false); void refetch(); }} + onDismiss={() => setDismissed(true)} + /> + + ); + } + + return ( + + + + 共买入 {totalCount} 件 · 总成本 {formatCNY(totalCost)} + {allHaveMV && totalMV != null && ( + + {' '}· 当前市值{' '} + + {formatCNY(totalMV)} + + + )} + + + + {groups.length === 0 && ( + } + title="暂无买入记录" + description="同步账户数据后将在此显示每日买入详情。" + /> + )} + + {groups.length > 0 && ( + + + + + {groups.map((group, gi) => { + const expanded = isExpanded(group.date); + const thisMonth = group.date.substring(0, 7); + const prevMonth = gi > 0 ? groups[gi - 1].date.substring(0, 7) : thisMonth; + + return ( + + {thisMonth !== prevMonth && ( + + + + ── 更早的买入 ── + + + + )} + toggle(group.date)} + > + + + {expanded ? ( + + ) : ( + + )} + + + + + {group.date.length > 10 + ? group.date.substring(5) + : new Date(group.date).getDate()} + + + {group.dayOfWeek} + + + + + 买入 {group.totalCount} 件 + + + 成本 {formatCNY(group.totalCost)} + {group.totalMarketValue != null && ( + + {' '}· 当前市值{' '} + + {formatCNY(group.totalMarketValue)} + + + )} + + + + + + + +
+ + + 物品 + 数量 + 买入价 + 总额 + 当前市价 + 浮动盈亏 + 浮动率 + 平台 + 状态 + + + + {group.items.map((item, idx) => { + const upl = item.unrealizedPl; + const uplRate = item.totalCost > 0 && upl != null + ? (upl / item.totalCost) * 100 + : null; + return ( + + + + {item.itemName} + {item.exterior ? ` (${item.exterior})` : ''} + + + + + {item.quantity} + + + + + {formatCNY(item.buyPrice)} + + + + + {formatCNY(item.totalCost)} + + + + + {item.marketPrice != null ? formatCNY(item.marketPrice) : '--'} + + + + {upl != null ? ( + + {formatCNY(upl)} + + ) : ( + -- + )} + + + {uplRate != null ? ( + + {uplRate >= 0 ? '+' : ''}{uplRate.toFixed(1)}% + + ) : ( + -- + )} + + + + {item.platform} + + + + + + + ); + })} + + + + 当日合计:成本 {formatCNY(group.totalCost)} + {group.totalMarketValue != null && ( + + {' '}· 当前市值 {formatCNY(group.totalMarketValue)}{' '} + · 浮动盈亏{' '} + + {formatCNY(group.totalMarketValue - group.totalCost)} + + + )} + + + + +
+
+ + + + + ); + })} + + + + + )} + + ); +} + export default function InventoryPage() { const navigate = useNavigate(); const [dismissed, setDismissed] = useState(false); @@ -246,6 +507,8 @@ export default function InventoryPage() { return result; }, [groups, globalFilter]); + const [tab, setTab] = useState<'list' | 'dailyBuy'>('list'); + const [expandedNames, setExpandedNames] = useState>(new Set()); const toggle = (name: string) => { @@ -294,7 +557,14 @@ export default function InventoryPage() { - {!selectedAccountId && groups.length === 0 && !isLoading && ( + setTab(v as 'list' | 'dailyBuy')} sx={{ mb: 1 }}> + + + + + {tab === 'dailyBuy' && } + + {tab === 'list' && !selectedAccountId && groups.length === 0 && !isLoading && ( } @@ -310,7 +580,7 @@ export default function InventoryPage() { )} - {isLoading && ( + {tab === 'list' && isLoading && ( {[1, 2, 3, 4, 5].map((i) => ( @@ -318,7 +588,7 @@ export default function InventoryPage() { )} - {error && !dismissed && ( + {tab === 'list' && error && !dismissed && ( )} - {!isLoading && !error && selectedAccountId && groups.length === 0 && ( + {tab === 'list' && !isLoading && !error && selectedAccountId && groups.length === 0 && ( } @@ -341,7 +611,7 @@ export default function InventoryPage() { )} - {!isLoading && !error && groups.length > 0 && filteredGroups.length === 0 && ( + {tab === 'list' && !isLoading && !error && groups.length > 0 && filteredGroups.length === 0 && ( } @@ -351,7 +621,7 @@ export default function InventoryPage() { )} - {!isLoading && !error && filteredGroups.length > 0 && ( + {tab === 'list' && !isLoading && !error && filteredGroups.length > 0 && ( From 39bc6f4a1c6a6919addf2e5d4a3f81e0e6ca1fc4 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 21:36:21 +0800 Subject: [PATCH 11/28] feat: add server-side pagination to daily sell/buy APIs --- app.go | 8 ++++---- pkg/service/inventory/service.go | 29 ++++++++++++++++++++++++++--- pkg/service/trade/service.go | 29 ++++++++++++++++++++++++++--- 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/app.go b/app.go index 51c90ac..554af5e 100644 --- a/app.go +++ b/app.go @@ -111,8 +111,8 @@ func (a *App) GetItemDetail(accountID uint, assetID string) (*inventory.ItemDeta return a.svc.Inventory().GetItemDetail(accountID, assetID) } -func (a *App) GetDailyBuys(accountID uint) ([]inventory.DailyBuyGroup, error) { - return a.svc.Inventory().ListDailyBuys(accountID) +func (a *App) GetDailyBuys(accountID uint, page, pageSize int) (*inventory.DailyBuyPaginated, error) { + return a.svc.Inventory().ListDailyBuys(accountID, page, pageSize) } func (a *App) GetCompletedTrades(accountID uint, page, pageSize int, sortBy, sortDir string) (*trade.PaginatedGroups, error) { @@ -136,8 +136,8 @@ func (a *App) GetUnmatchedSells(accountID uint) ([]model.TradeRecord, error) { return a.svc.Trade().ListUnmatchedSells(accountID) } -func (a *App) GetDailySells(accountID uint, year, month int) ([]trade.DailySellGroup, error) { - return a.svc.Trade().ListDailySells(accountID, year, month) +func (a *App) GetDailySells(accountID uint, year, month, page, pageSize int) (*trade.DailySellPaginated, error) { + return a.svc.Trade().ListDailySells(accountID, year, month, page, pageSize) } func (a *App) GetPnlSummary(accountID uint) (*pnl.PnlSummaryView, error) { diff --git a/pkg/service/inventory/service.go b/pkg/service/inventory/service.go index ad0d2d4..435a2c5 100644 --- a/pkg/service/inventory/service.go +++ b/pkg/service/inventory/service.go @@ -70,7 +70,7 @@ type InventoryInterface interface { List(accountID uint, status string) ([]model.InventoryItem, error) GetItemDetail(accountID uint, assetID string) (*ItemDetail, error) ListGroups(accountID uint, status, weaponType string, page, pageSize int, sortBy, sortDir string) (*PaginatedGroups, error) - ListDailyBuys(accountID uint) ([]DailyBuyGroup, error) + ListDailyBuys(accountID uint, page, pageSize int) (*DailyBuyPaginated, error) SetPriceProvider(p PriceProvider) SetPriceSource(source string) } @@ -321,7 +321,7 @@ func (s *service) sortGroups(groups []InventoryGroup, sortBy, sortDir string) { }) } -func (s *service) ListDailyBuys(accountID uint) ([]DailyBuyGroup, error) { +func (s *service) ListDailyBuys(accountID uint, page, pageSize int) (*DailyBuyPaginated, error) { rows, err := s.orm.FindDailyBuys(accountID) if err != nil { return nil, err @@ -381,7 +381,23 @@ func (s *service) ListDailyBuys(accountID uint) ([]DailyBuyGroup, error) { groups = append(groups, g) } sort.Slice(groups, func(i, j int) bool { return groups[i].Date > groups[j].Date }) - return groups, nil + + total := int64(len(groups)) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 30 + } + offset := (page - 1) * pageSize + if offset >= len(groups) { + return &DailyBuyPaginated{Groups: nil, Total: total, Page: page, PageSize: pageSize}, nil + } + end := offset + pageSize + if end > len(groups) { + end = len(groups) + } + return &DailyBuyPaginated{Groups: groups[offset:end], Total: total, Page: page, PageSize: pageSize}, nil } func (s *service) GetItemDetail(accountID uint, assetID string) (*ItemDetail, error) { @@ -445,6 +461,13 @@ type DailyBuyGroup struct { TotalMarketValue *int64 `json:"totalMarketValue,omitempty"` } +type DailyBuyPaginated struct { + Groups []DailyBuyGroup `json:"groups"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + var Module = fx.Module("inventory", logfx.WithComponent("inventory"), fx.Provide( diff --git a/pkg/service/trade/service.go b/pkg/service/trade/service.go index 85f312c..3b1067c 100644 --- a/pkg/service/trade/service.go +++ b/pkg/service/trade/service.go @@ -115,6 +115,13 @@ type DailySellGroup struct { TotalFee int64 `json:"totalFee"` } +type DailySellPaginated struct { + Groups []DailySellGroup `json:"groups"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + type TradeInterface interface { ListByAccount(accountID uint, tradeType string) ([]model.TradeRecord, error) ListCompletedTrades(accountID uint) ([]CompletedTradeView, error) @@ -123,7 +130,7 @@ type TradeInterface interface { ListUnmatchedSells(accountID uint) ([]model.TradeRecord, error) SetPriceProvider(p PriceProvider) SetPriceSource(source string) - ListDailySells(accountID uint, year, month int) ([]DailySellGroup, error) + ListDailySells(accountID uint, year, month int, page, pageSize int) (*DailySellPaginated, error) } type service struct { @@ -416,7 +423,7 @@ func (svc *service) GetCompletedTradesSummary(accountID uint) (*CompletedTradesS return sum, nil } -func (svc *service) ListDailySells(accountID uint, year, month int) ([]DailySellGroup, error) { +func (svc *service) ListDailySells(accountID uint, year, month int, page, pageSize int) (*DailySellPaginated, error) { rows, err := svc.orm.FindDailySells(accountID, year, month) if err != nil { return nil, err @@ -458,7 +465,23 @@ func (svc *service) ListDailySells(accountID uint, year, month int) ([]DailySell }) } sort.Slice(groups, func(i, j int) bool { return groups[i].Date > groups[j].Date }) - return groups, nil + + total := int64(len(groups)) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 30 + } + offset := (page - 1) * pageSize + if offset >= len(groups) { + return &DailySellPaginated{Groups: nil, Total: total, Page: page, PageSize: pageSize}, nil + } + end := offset + pageSize + if end > len(groups) { + end = len(groups) + } + return &DailySellPaginated{Groups: groups[offset:end], Total: total, Page: page, PageSize: pageSize}, nil } var Module = fx.Module("trade", From 79cc06955208466d74c20c84728df14d7af87189 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 21:38:19 +0800 Subject: [PATCH 12/28] feat(frontend): add pagination, date format, profit display to daily views --- frontend/src/hooks/useDailyBuys.ts | 14 +- frontend/src/hooks/useDailySells.ts | 10 +- frontend/src/pages/CompletedTradesPage.tsx | 111 ++++++++---- frontend/src/pages/InventoryPage.tsx | 194 +++++++++++++++------ 4 files changed, 241 insertions(+), 88 deletions(-) diff --git a/frontend/src/hooks/useDailyBuys.ts b/frontend/src/hooks/useDailyBuys.ts index 88c8b4f..ee603a8 100644 --- a/frontend/src/hooks/useDailyBuys.ts +++ b/frontend/src/hooks/useDailyBuys.ts @@ -3,13 +3,21 @@ import { GetDailyBuys } from '../lib/wails'; import { useAccounts } from './useAccounts'; const STALE_TIME_MS = 2 * 60 * 1000; +const DEFAULT_PAGE_SIZE = 30; -export function useDailyBuys(selectedAccountId: number | null) { +export function useDailyBuys( + selectedAccountId: number | null, + page: number, + pageSize: number = DEFAULT_PAGE_SIZE, +) { const { data: accounts = [] } = useAccounts(); return useQuery({ - queryKey: ['dailyBuys', selectedAccountId ?? 0], - queryFn: () => GetDailyBuys(selectedAccountId ?? 0), + queryKey: ['dailyBuys', selectedAccountId ?? 0, page, pageSize], + queryFn: () => { + const accountId = selectedAccountId ?? 0; + return GetDailyBuys(accountId, page, pageSize); + }, staleTime: STALE_TIME_MS, enabled: selectedAccountId !== null || accounts.length > 0, }); diff --git a/frontend/src/hooks/useDailySells.ts b/frontend/src/hooks/useDailySells.ts index 444df7f..31b524b 100644 --- a/frontend/src/hooks/useDailySells.ts +++ b/frontend/src/hooks/useDailySells.ts @@ -3,17 +3,23 @@ import { GetDailySells } from '../lib/wails'; import { useAccounts } from './useAccounts'; const STALE_TIME_MS = 2 * 60 * 1000; +const DEFAULT_PAGE_SIZE = 30; export function useDailySells( selectedAccountId: number | null, year: number, month: number, + page: number, + pageSize: number = DEFAULT_PAGE_SIZE, ) { const { data: accounts = [] } = useAccounts(); return useQuery({ - queryKey: ['dailySells', selectedAccountId ?? 0, year, month], - queryFn: () => GetDailySells(selectedAccountId ?? 0, year, month), + queryKey: ['dailySells', selectedAccountId ?? 0, year, month, page, pageSize], + queryFn: () => { + const accountId = selectedAccountId ?? 0; + return GetDailySells(accountId, year, month, page, pageSize); + }, staleTime: STALE_TIME_MS, enabled: selectedAccountId !== null || accounts.length > 0, }); diff --git a/frontend/src/pages/CompletedTradesPage.tsx b/frontend/src/pages/CompletedTradesPage.tsx index 3ee1c3f..fae1872 100644 --- a/frontend/src/pages/CompletedTradesPage.tsx +++ b/frontend/src/pages/CompletedTradesPage.tsx @@ -1180,9 +1180,15 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { const [year, setYear] = useState(now.getFullYear()); const [month, setMonth] = useState(now.getMonth() + 1); - const { data: groups = [], isLoading, error, refetch } = useDailySells(accountId, year, month); + const [page, setPage] = useState(0); + const PAGE_SIZE = 30; + + const { data: paginated, isLoading, error, refetch } = useDailySells(accountId, year, month, page + 1, PAGE_SIZE); const { isExpanded, toggle } = useExpandableSet(); + const groups = paginated?.groups ?? []; + const totalGroups = paginated?.total ?? 0; + const totalProfit = groups.reduce((s, g) => s + g.totalProfit, 0); const totalFee = groups.reduce((s, g) => s + g.totalFee, 0); const totalCount = groups.reduce((s, g) => s + g.totalCount, 0); @@ -1190,12 +1196,18 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { const monthLabel = `${year}年${month}月`; const handlePrevMonth = () => { - if (month === 1) { setYear(year - 1); setMonth(12); } - else setMonth(month - 1); + setPage(0); + if (month === 1) { + setYear(year - 1); + setMonth(12); + } else setMonth(month - 1); }; const handleNextMonth = () => { - if (month === 12) { setYear(year + 1); setMonth(1); } - else setMonth(month + 1); + setPage(0); + if (month === 12) { + setYear(year + 1); + setMonth(1); + } else setMonth(month + 1); }; if (isLoading) { @@ -1213,7 +1225,10 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { { setDismissed(false); void refetch(); }} + onRetry={() => { + setDismissed(false); + void refetch(); + }} onDismiss={() => setDismissed(true)} /> @@ -1232,8 +1247,8 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { 共卖出 {totalCount} 件 · 总利润{' '} - {formatCNY(totalProfit)}{' '} - · 总手续费 {formatCNY(totalFee)} + {formatCNY(totalProfit)} · 总手续费{' '} + {formatCNY(totalFee)} @@ -1246,17 +1261,18 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { )} {groups.length > 0 && ( - - - - - {groups.map((group) => { - const expanded = isExpanded(group.date); - return ( - - + + +
+ + {groups.map((group) => { + const expanded = isExpanded(group.date); + return ( + + toggle(group.date)} > @@ -1270,7 +1286,10 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { - {new Date(group.date).getDate()} + {(() => { + const d = new Date(group.date); + return `${d.getMonth() + 1}月${d.getDate()}日`; + })()} {group.dayOfWeek} @@ -1296,22 +1315,37 @@ function DailySellsContent({ accountId }: { accountId: number | null }) {
- 物品 - 数量 - 买入价 - 卖出价 - 手续费 - 利润 - 利润率 - 平台 + + 物品 + + + 数量 + + + 买入价 + + + 卖出价 + + + 手续费 + + + 利润 + + + 利润率 + + + 平台 + {group.items.map((item, idx) => { const costBasis = item.buyPrice * item.quantity; - const profitRate = costBasis > 0 - ? (item.profit / costBasis) * 100 - : 0; + const profitRate = + costBasis > 0 ? (item.profit / costBasis) * 100 : 0; return ( @@ -1356,7 +1390,8 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { color={plHexColor(profitRate)} className="mono-num" > - {profitRate >= 0 ? '+' : ''}{profitRate.toFixed(1)}% + {profitRate >= 0 ? '+' : ''} + {profitRate.toFixed(1)}% @@ -1389,6 +1424,18 @@ function DailySellsContent({ accountId }: { accountId: number | null }) {
+ + setPage(p)} + rowsPerPageOptions={[30]} + labelRowsPerPage="每页" + /> + + )}
); diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index 6fdc096..f424ccd 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -224,9 +224,16 @@ const dailyBuyStatusColor = (status: string): 'success' | 'warning' | 'default' function DailyBuysContent({ accountId }: { accountId: number | null }) { const [dismissed, setDismissed] = useState(false); - const { data: groups = [], isLoading, error, refetch } = useDailyBuys(accountId); + + const [page, setPage] = useState(0); + const PAGE_SIZE = 30; + + const { data: paginated, isLoading, error, refetch } = useDailyBuys(accountId, page + 1, PAGE_SIZE); const { isExpanded, toggle } = useExpandableSet(); + const groups = paginated?.groups ?? []; + const totalGroups = paginated?.total ?? 0; + const totalCost = groups.reduce((s, g) => s + g.totalCost, 0); const totalCount = groups.reduce((s, g) => s + g.totalCount, 0); @@ -255,7 +262,10 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { { setDismissed(false); void refetch(); }} + onRetry={() => { + setDismissed(false); + void refetch(); + }} onDismiss={() => setDismissed(true)} /> @@ -269,10 +279,9 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { 共买入 {totalCount} 件 · 总成本 {formatCNY(totalCost)} {allHaveMV && totalMV != null && ( - {' '}· 当前市值{' '} - - {formatCNY(totalMV)} - + {' '} + · 当前市值{' '} + {formatCNY(totalMV)} )} @@ -287,14 +296,15 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { )} {groups.length > 0 && ( - - - - - {groups.map((group, gi) => { - const expanded = isExpanded(group.date); - const thisMonth = group.date.substring(0, 7); - const prevMonth = gi > 0 ? groups[gi - 1].date.substring(0, 7) : thisMonth; + + + +
+ + {groups.map((group, gi) => { + const expanded = isExpanded(group.date); + const thisMonth = group.date.substring(0, 7); + const prevMonth = gi > 0 ? groups[gi - 1].date.substring(0, 7) : thisMonth; return ( @@ -323,9 +333,10 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { - {group.date.length > 10 - ? group.date.substring(5) - : new Date(group.date).getDate()} + {(() => { + const d = new Date(group.date); + return `${d.getMonth() + 1}月${d.getDate()}日`; + })()} {group.dayOfWeek} @@ -337,14 +348,29 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { 成本 {formatCNY(group.totalCost)} - {group.totalMarketValue != null && ( + {group.totalMarketValue != null ? ( - {' '}· 当前市值{' '} - + {' '} + · 当前市值{' '} + {formatCNY(group.totalMarketValue)} + {' '} + · 浮动盈亏{' '} + + {formatCNY(group.totalMarketValue - group.totalCost)} + - )} + ) : null} @@ -355,23 +381,42 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) {
- 物品 - 数量 - 买入价 - 总额 - 当前市价 - 浮动盈亏 - 浮动率 - 平台 - 状态 + + 物品 + + + 数量 + + + 买入价 + + + 总额 + + + 当前市价 + + + 浮动盈亏 + + + 浮动率 + + + 平台 + + + 状态 + {group.items.map((item, idx) => { const upl = item.unrealizedPl; - const uplRate = item.totalCost > 0 && upl != null - ? (upl / item.totalCost) * 100 - : null; + const uplRate = + item.totalCost > 0 && upl != null + ? (upl / item.totalCost) * 100 + : null; return ( @@ -397,25 +442,49 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { - {item.marketPrice != null ? formatCNY(item.marketPrice) : '--'} + {item.marketPrice != null + ? formatCNY(item.marketPrice) + : '--'} {upl != null ? ( - + {formatCNY(upl)} ) : ( - -- + + -- + )} {uplRate != null ? ( - - {uplRate >= 0 ? '+' : ''}{uplRate.toFixed(1)}% + + {uplRate >= 0 ? '+' : ''} + {uplRate.toFixed(1)}% ) : ( - -- + + -- + )} @@ -440,9 +509,16 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { 当日合计:成本 {formatCNY(group.totalCost)} {group.totalMarketValue != null && ( - {' '}· 当前市值 {formatCNY(group.totalMarketValue)}{' '} - · 浮动盈亏{' '} - + {' '} + · 当前市值 {formatCNY(group.totalMarketValue)} · + 浮动盈亏{' '} + {formatCNY(group.totalMarketValue - group.totalCost)} @@ -463,6 +539,18 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) {
+ + setPage(p)} + rowsPerPageOptions={[30]} + labelRowsPerPage="每页" + /> + + )} ); @@ -611,15 +699,19 @@ export default function InventoryPage() { )} - {tab === 'list' && !isLoading && !error && groups.length > 0 && filteredGroups.length === 0 && ( - - } - title="无匹配物品" - description="请尝试更改类型筛选或搜索条件。" - /> - - )} + {tab === 'list' && + !isLoading && + !error && + groups.length > 0 && + filteredGroups.length === 0 && ( + + } + title="无匹配物品" + description="请尝试更改类型筛选或搜索条件。" + /> + + )} {tab === 'list' && !isLoading && !error && filteredGroups.length > 0 && ( From 0c054ae5fbac0fcf590df9116ff0f8e932cd6e91 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 21:53:40 +0800 Subject: [PATCH 13/28] refactor(frontend): remove month selector from daily sell view --- frontend/src/pages/CompletedTradesPage.tsx | 390 ++++++++++----------- 1 file changed, 194 insertions(+), 196 deletions(-) diff --git a/frontend/src/pages/CompletedTradesPage.tsx b/frontend/src/pages/CompletedTradesPage.tsx index fae1872..b638338 100644 --- a/frontend/src/pages/CompletedTradesPage.tsx +++ b/frontend/src/pages/CompletedTradesPage.tsx @@ -1176,14 +1176,16 @@ const SKELETON_COUNT = 5; function DailySellsContent({ accountId }: { accountId: number | null }) { const [dismissed, setDismissed] = useState(false); - const now = new Date(); - const [year, setYear] = useState(now.getFullYear()); - const [month, setMonth] = useState(now.getMonth() + 1); const [page, setPage] = useState(0); const PAGE_SIZE = 30; - const { data: paginated, isLoading, error, refetch } = useDailySells(accountId, year, month, page + 1, PAGE_SIZE); + const { + data: paginated, + isLoading, + error, + refetch, + } = useDailySells(accountId, 0, 0, page + 1, PAGE_SIZE); const { isExpanded, toggle } = useExpandableSet(); const groups = paginated?.groups ?? []; @@ -1193,23 +1195,6 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { const totalFee = groups.reduce((s, g) => s + g.totalFee, 0); const totalCount = groups.reduce((s, g) => s + g.totalCount, 0); - const monthLabel = `${year}年${month}月`; - - const handlePrevMonth = () => { - setPage(0); - if (month === 1) { - setYear(year - 1); - setMonth(12); - } else setMonth(month - 1); - }; - const handleNextMonth = () => { - setPage(0); - if (month === 12) { - setYear(year + 1); - setMonth(1); - } else setMonth(month + 1); - }; - if (isLoading) { return ( @@ -1237,26 +1222,19 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { return ( - - - - - {monthLabel} - - - - + + 共卖出 {totalCount} 件 · 总利润{' '} - {formatCNY(totalProfit)} · 总手续费{' '} - {formatCNY(totalFee)} + {formatCNY(totalProfit)}{' '} + · 总手续费 {formatCNY(totalFee)} {groups.length === 0 && ( } - title="该月无卖出记录" - description="切换月份查看其他时间段的卖出数据。" + title="暂无卖出记录" + description="同步账户数据后将在此显示每日卖出详情。" /> )} @@ -1273,168 +1251,188 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { toggle(group.date)} - > - - - {expanded ? ( - - ) : ( - - )} - - - - - {(() => { - const d = new Date(group.date); - return `${d.getMonth() + 1}月${d.getDate()}日`; - })()} - - - {group.dayOfWeek} - - - - - 卖出 {group.totalCount} 件 - - - 利润{' '} - - {formatCNY(group.totalProfit)} - {' '} - · 手续费 {formatCNY(group.totalFee)} - - - - - - - - - - - - 物品 - - - 数量 - - - 买入价 - - - 卖出价 - - - 手续费 - - - 利润 - - - 利润率 - - - 平台 - - - - - {group.items.map((item, idx) => { - const costBasis = item.buyPrice * item.quantity; - const profitRate = - costBasis > 0 ? (item.profit / costBasis) * 100 : 0; - return ( - - - - {item.itemName} - {item.exterior ? ` (${item.exterior})` : ''} - - - - - {item.quantity} - - - - - {formatCNY(item.buyPrice)} - - - - - {formatCNY(item.sellPrice)} - - - - - {formatCNY(item.totalFee)} - - - - - {formatCNY(item.profit)} - - - - - {profitRate >= 0 ? '+' : ''} - {profitRate.toFixed(1)}% - - - - - {item.platform} - - - - ); - })} - - - - 当日合计:利润 {formatCNY(group.totalProfit)} · 手续费{' '} - {formatCNY(group.totalFee)} · 净利{' '} - {formatCNY(group.totalProfit - group.totalFee)} - - - - -
-
-
-
-
- - ); - })} - - - - - - setPage(p)} - rowsPerPageOptions={[30]} - labelRowsPerPage="每页" - /> - + onClick={() => toggle(group.date)} + > + + + {expanded ? ( + + ) : ( + + )} + + + + + {(() => { + const d = new Date(group.date); + return `${d.getMonth() + 1}月${d.getDate()}日`; + })()} + + + {group.dayOfWeek} + + + + + 卖出 {group.totalCount} 件 + + + 利润{' '} + + {formatCNY(group.totalProfit)} + {' '} + · 手续费 {formatCNY(group.totalFee)} + + + + + + + + + + + + 物品 + + + 数量 + + + 买入价 + + + 卖出价 + + + 手续费 + + + 利润 + + + 利润率 + + + 平台 + + + + + {group.items.map((item, idx) => { + const costBasis = item.buyPrice * item.quantity; + const profitRate = + costBasis > 0 ? (item.profit / costBasis) * 100 : 0; + return ( + + + + {item.itemName} + {item.exterior ? ` (${item.exterior})` : ''} + + + + + {item.quantity} + + + + + {formatCNY(item.buyPrice)} + + + + + {formatCNY(item.sellPrice)} + + + + + {formatCNY(item.totalFee)} + + + + + {formatCNY(item.profit)} + + + + + {profitRate >= 0 ? '+' : ''} + {profitRate.toFixed(1)}% + + + + + {item.platform} + + + + ); + })} + + + + 当日合计:利润 {formatCNY(group.totalProfit)} · 手续费{' '} + {formatCNY(group.totalFee)} · 净利{' '} + {formatCNY(group.totalProfit - group.totalFee)} + + + + +
+
+
+
+
+ + ); + })} + + + + + + setPage(p)} + rowsPerPageOptions={[30]} + labelRowsPerPage="每页" + /> + )}
From 401fbf467b89706e4c51c563a96060d44bc34bc9 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 21:55:16 +0800 Subject: [PATCH 14/28] feat(frontend): add daily profit rate to buy collapsed row --- frontend/src/pages/InventoryPage.tsx | 514 +++++++++++++++------------ 1 file changed, 278 insertions(+), 236 deletions(-) diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index f424ccd..84361e8 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -228,7 +228,12 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { const [page, setPage] = useState(0); const PAGE_SIZE = 30; - const { data: paginated, isLoading, error, refetch } = useDailyBuys(accountId, page + 1, PAGE_SIZE); + const { + data: paginated, + isLoading, + error, + refetch, + } = useDailyBuys(accountId, page + 1, PAGE_SIZE); const { isExpanded, toggle } = useExpandableSet(); const groups = paginated?.groups ?? []; @@ -306,250 +311,287 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { const thisMonth = group.date.substring(0, 7); const prevMonth = gi > 0 ? groups[gi - 1].date.substring(0, 7) : thisMonth; - return ( - - {thisMonth !== prevMonth && ( - - - - ── 更早的买入 ── + return ( + + {thisMonth !== prevMonth && ( + + + + ── 更早的买入 ── + + + + )} + toggle(group.date)} + > + + + {expanded ? ( + + ) : ( + + )} + + + + + {(() => { + const d = new Date(group.date); + return `${d.getMonth() + 1}月${d.getDate()}日`; + })()} + + + {group.dayOfWeek} - - )} - toggle(group.date)} - > - - - {expanded ? ( - - ) : ( - - )} - - - - - {(() => { - const d = new Date(group.date); - return `${d.getMonth() + 1}月${d.getDate()}日`; - })()} - - - {group.dayOfWeek} - - - - - 买入 {group.totalCount} 件 - - - 成本 {formatCNY(group.totalCost)} - {group.totalMarketValue != null ? ( - - {' '} - · 当前市值{' '} - - {formatCNY(group.totalMarketValue)} - - {' '} - · 浮动盈亏{' '} - - {formatCNY(group.totalMarketValue - group.totalCost)} + + + 买入 {group.totalCount} 件 + + + 成本 {formatCNY(group.totalCost)} + {group.totalMarketValue != null ? ( + + {' '} + · 当前市值{' '} + + {formatCNY(group.totalMarketValue)} + {' '} + · 浮动盈亏{' '} + + {formatCNY(group.totalMarketValue - group.totalCost)} + + {group.totalCost > 0 && ( + + {' '} + · 盈亏率{' '} + + {(((group.totalMarketValue - group.totalCost) / group.totalCost) * 100) >= 0 ? '+' : ''} + {(((group.totalMarketValue - group.totalCost) / group.totalCost) * 100).toFixed(1)}% + + + )} - - ) : null} - - - - - - - - - - - - 物品 - - - 数量 - - - 买入价 - - - 总额 - - - 当前市价 - - - 浮动盈亏 - - - 浮动率 - - - 平台 - - - 状态 - - - - - {group.items.map((item, idx) => { - const upl = item.unrealizedPl; - const uplRate = - item.totalCost > 0 && upl != null - ? (upl / item.totalCost) * 100 - : null; - return ( - - - - {item.itemName} - {item.exterior ? ` (${item.exterior})` : ''} - - - - - {item.quantity} - - - - - {formatCNY(item.buyPrice)} - - - - - {formatCNY(item.totalCost)} - - - - - {item.marketPrice != null - ? formatCNY(item.marketPrice) - : '--'} - - - - {upl != null ? ( - - {formatCNY(upl)} + ) : null} + + + + + + + +
+ + + + 物品 + + + 数量 + + + 买入价 + + + 总额 + + + 当前市价 + + + 浮动盈亏 + + + 浮动率 + + + 平台 + + + 状态 + + + + + {group.items.map((item, idx) => { + const upl = item.unrealizedPl; + const uplRate = + item.totalCost > 0 && upl != null + ? (upl / item.totalCost) * 100 + : null; + return ( + + + + {item.itemName} + {item.exterior ? ` (${item.exterior})` : ''} - ) : ( - - -- + + + + {item.quantity} - )} - - - {uplRate != null ? ( - - {uplRate >= 0 ? '+' : ''} - {uplRate.toFixed(1)}% + + + + {formatCNY(item.buyPrice)} - ) : ( - - -- + + + + {formatCNY(item.totalCost)} - )} - - - - {item.platform} - - - - - - - ); - })} - - - - 当日合计:成本 {formatCNY(group.totalCost)} - {group.totalMarketValue != null && ( - - {' '} - · 当前市值 {formatCNY(group.totalMarketValue)} · - 浮动盈亏{' '} - + + + {item.marketPrice != null + ? formatCNY(item.marketPrice) + : '--'} + + + + {upl != null ? ( + + {formatCNY(upl)} + + ) : ( + + -- + + )} + + + {uplRate != null ? ( + + {uplRate >= 0 ? '+' : ''} + {uplRate.toFixed(1)}% + + ) : ( + + -- + + )} + + + + {item.platform} + + + + + + + ); + })} + + + + 当日合计:成本 {formatCNY(group.totalCost)} + {group.totalMarketValue != null && ( + + {' '} + · 当前市值 {formatCNY(group.totalMarketValue)} · + 浮动盈亏{' '} + + {formatCNY( group.totalMarketValue - group.totalCost, - ), - }} - > - {formatCNY(group.totalMarketValue - group.totalCost)} + )} + - - )} - - - - -
-
-
-
-
-
- ); - })} - - - - - - setPage(p)} - rowsPerPageOptions={[30]} - labelRowsPerPage="每页" - /> - + )} +
+
+
+ + +
+ + + + + ); + })} + + + + + + setPage(p)} + rowsPerPageOptions={[30]} + labelRowsPerPage="每页" + /> + )}
From 73fcb6c8468491b90d22ef3e057c2ffc824f07a0 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 22:03:15 +0800 Subject: [PATCH 15/28] refactor(frontend): flatten daily card layout, add year, use platformLabel --- frontend/src/pages/CompletedTradesPage.tsx | 35 +++++---- frontend/src/pages/InventoryPage.tsx | 89 ++++++++++++++-------- 2 files changed, 79 insertions(+), 45 deletions(-) diff --git a/frontend/src/pages/CompletedTradesPage.tsx b/frontend/src/pages/CompletedTradesPage.tsx index b638338..0e8d2e4 100644 --- a/frontend/src/pages/CompletedTradesPage.tsx +++ b/frontend/src/pages/CompletedTradesPage.tsx @@ -49,6 +49,7 @@ import { useDailySells } from '../hooks/useDailySells'; import { useExpandableSet } from '../hooks/useExpandableSet'; import { useUIStore } from '../store/uiStore'; import { formatCNY, plHexColor } from '../lib/format'; +import { platformLabel } from '../lib/constants'; import { BrowserOpenURL } from '../../wailsjs/runtime/runtime'; import type { model, trade } from '../lib/wails'; @@ -1225,8 +1226,8 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { 共卖出 {totalCount} 件 · 总利润{' '} - {formatCNY(totalProfit)}{' '} - · 总手续费 {formatCNY(totalFee)} + {formatCNY(totalProfit)} · 总手续费{' '} + {formatCNY(totalFee)} @@ -1266,7 +1267,7 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { {(() => { const d = new Date(group.date); - return `${d.getMonth() + 1}月${d.getDate()}日`; + return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`; })()} @@ -1277,15 +1278,23 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { 卖出 {group.totalCount} 件 - - 利润{' '} - - {formatCNY(group.totalProfit)} - {' '} - · 手续费 {formatCNY(group.totalFee)} - + + + 利润{' '} + + {formatCNY(group.totalProfit)} + + + + 手续费 {formatCNY(group.totalFee)} + + + 净利{' '} + + {formatCNY(group.totalProfit - group.totalFee)} + + + @@ -1394,7 +1403,7 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { - {item.platform} + {platformLabel[item.platform] ?? item.platform} diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index 84361e8..fe87473 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -340,7 +340,7 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { {(() => { const d = new Date(group.date); - return `${d.getMonth() + 1}月${d.getDate()}日`; + return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`; })()} @@ -351,47 +351,72 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { 买入 {group.totalCount} 件 - - 成本 {formatCNY(group.totalCost)} + + + 成本 {formatCNY(group.totalCost)} + {group.totalMarketValue != null ? ( - - {' '} - · 当前市值{' '} - - {formatCNY(group.totalMarketValue)} - {' '} - · 浮动盈亏{' '} - - {formatCNY(group.totalMarketValue - group.totalCost)} - + <> + + 市值{' '} + + {formatCNY(group.totalMarketValue)} + + + + 浮动盈亏{' '} + + {formatCNY( + group.totalMarketValue - group.totalCost, + )} + + {group.totalCost > 0 && ( - - {' '} - · 盈亏率{' '} + + 盈亏率{' '} - {(((group.totalMarketValue - group.totalCost) / group.totalCost) * 100) >= 0 ? '+' : ''} - {(((group.totalMarketValue - group.totalCost) / group.totalCost) * 100).toFixed(1)}% + {((group.totalMarketValue - + group.totalCost) / + group.totalCost) * + 100 >= + 0 + ? '+' + : ''} + {( + ((group.totalMarketValue - + group.totalCost) / + group.totalCost) * + 100 + ).toFixed(1)} + % - + )} - + ) : null} - +
@@ -527,7 +552,7 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { - {item.platform} + {platformLabel[item.platform] ?? item.platform} From ab78b95a3c8e4d8d75817ca281942467f69d6e95 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 22:06:46 +0800 Subject: [PATCH 16/28] refactor(frontend): widen date column for better readability --- frontend/src/pages/CompletedTradesPage.tsx | 13 +++++++++--- frontend/src/pages/InventoryPage.tsx | 23 +++++++--------------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/frontend/src/pages/CompletedTradesPage.tsx b/frontend/src/pages/CompletedTradesPage.tsx index 0e8d2e4..296a8bc 100644 --- a/frontend/src/pages/CompletedTradesPage.tsx +++ b/frontend/src/pages/CompletedTradesPage.tsx @@ -1263,7 +1263,7 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { )} - + {(() => { const d = new Date(group.date); @@ -1281,7 +1281,9 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { 利润{' '} - + {formatCNY(group.totalProfit)} @@ -1290,7 +1292,12 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { 净利{' '} - + {formatCNY(group.totalProfit - group.totalFee)} diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index fe87473..c9a0d57 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -336,7 +336,7 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { )} - + {(() => { const d = new Date(group.date); @@ -361,9 +361,7 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { 市值{' '} {formatCNY(group.totalMarketValue)} @@ -373,15 +371,11 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { 浮动盈亏{' '} - {formatCNY( - group.totalMarketValue - group.totalCost, - )} + {formatCNY(group.totalMarketValue - group.totalCost)} {group.totalCost > 0 && ( @@ -390,23 +384,20 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { - {((group.totalMarketValue - - group.totalCost) / + {((group.totalMarketValue - group.totalCost) / group.totalCost) * 100 >= 0 ? '+' : ''} {( - ((group.totalMarketValue - - group.totalCost) / + ((group.totalMarketValue - group.totalCost) / group.totalCost) * 100 ).toFixed(1)} From 624c7d1901937c44c944e80d7e5df94d6898683b Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 22:09:52 +0800 Subject: [PATCH 17/28] refactor(frontend): widen date column to 200px --- frontend/src/pages/CompletedTradesPage.tsx | 2 +- frontend/src/pages/InventoryPage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/CompletedTradesPage.tsx b/frontend/src/pages/CompletedTradesPage.tsx index 296a8bc..382a5d5 100644 --- a/frontend/src/pages/CompletedTradesPage.tsx +++ b/frontend/src/pages/CompletedTradesPage.tsx @@ -1263,7 +1263,7 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { )} - + {(() => { const d = new Date(group.date); diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index c9a0d57..8c655fd 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -336,7 +336,7 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { )} - + {(() => { const d = new Date(group.date); From 5c1ff4b98d2e257bc9bccafc3bcd667dcf5348c2 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 22:11:33 +0800 Subject: [PATCH 18/28] refactor(frontend): widen date column to 240px --- frontend/src/pages/CompletedTradesPage.tsx | 2 +- frontend/src/pages/InventoryPage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/CompletedTradesPage.tsx b/frontend/src/pages/CompletedTradesPage.tsx index 382a5d5..d22ce9b 100644 --- a/frontend/src/pages/CompletedTradesPage.tsx +++ b/frontend/src/pages/CompletedTradesPage.tsx @@ -1263,7 +1263,7 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { )} - + {(() => { const d = new Date(group.date); diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index 8c655fd..bce8447 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -336,7 +336,7 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { )} - + {(() => { const d = new Date(group.date); From 24e49f812fb36d9992f7260f4fad3cb936eada86 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 22:14:43 +0800 Subject: [PATCH 19/28] feat(frontend): add page jump input to daily pagination --- frontend/src/pages/CompletedTradesPage.tsx | 20 +++++++++++++++++++- frontend/src/pages/InventoryPage.tsx | 20 +++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/CompletedTradesPage.tsx b/frontend/src/pages/CompletedTradesPage.tsx index d22ce9b..29926a7 100644 --- a/frontend/src/pages/CompletedTradesPage.tsx +++ b/frontend/src/pages/CompletedTradesPage.tsx @@ -28,6 +28,7 @@ import TableCell from '@mui/material/TableCell'; import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; import TablePagination from '@mui/material/TablePagination'; +import TextField from '@mui/material/TextField'; import TableRow from '@mui/material/TableRow'; import TableSortLabel from '@mui/material/TableSortLabel'; import Paper from '@mui/material/Paper'; @@ -1177,6 +1178,7 @@ const SKELETON_COUNT = 5; function DailySellsContent({ accountId }: { accountId: number | null }) { const [dismissed, setDismissed] = useState(false); + const [jumpPage, setJumpPage] = useState(''); const [page, setPage] = useState(0); const PAGE_SIZE = 30; @@ -1438,7 +1440,7 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { - + + setJumpPage(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && jumpPage) { + const p = Math.max(1, Math.min(Math.ceil(totalGroups / PAGE_SIZE), Number(jumpPage))); + setPage(p - 1); + setJumpPage(''); + } + }} + sx={{ width: 70 }} + inputProps={{ min: 1, style: { textAlign: 'center', padding: '4px 8px' } }} + /> )} diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index bce8447..8ba306e 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -9,6 +9,7 @@ import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import TableSortLabel from '@mui/material/TableSortLabel'; import TablePagination from '@mui/material/TablePagination'; +import TextField from '@mui/material/TextField'; import Paper from '@mui/material/Paper'; import Chip from '@mui/material/Chip'; import IconButton from '@mui/material/IconButton'; @@ -224,6 +225,7 @@ const dailyBuyStatusColor = (status: string): 'success' | 'warning' | 'default' function DailyBuysContent({ accountId }: { accountId: number | null }) { const [dismissed, setDismissed] = useState(false); + const [jumpPage, setJumpPage] = useState(''); const [page, setPage] = useState(0); const PAGE_SIZE = 30; @@ -597,7 +599,7 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { - + + setJumpPage(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && jumpPage) { + const p = Math.max(1, Math.min(Math.ceil(totalGroups / PAGE_SIZE), Number(jumpPage))); + setPage(p - 1); + setJumpPage(''); + } + }} + sx={{ width: 70 }} + inputProps={{ min: 1, style: { textAlign: 'center', padding: '4px 8px' } }} + /> )} From bb97251b0778822a866f6e045cfeade5537a348c Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 22:16:07 +0800 Subject: [PATCH 20/28] feat(frontend): show monthly aggregates in buy separator row --- frontend/src/pages/InventoryPage.tsx | 79 ++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index 8ba306e..d18a2d1 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -315,15 +315,61 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { return ( - {thisMonth !== prevMonth && ( - - - - ── 更早的买入 ── - - - - )} + {thisMonth !== prevMonth && (() => { + // Aggregate the previous month's data + let mCost = 0; + let mMV: number | null = null; + let mCount = 0; + for (let j = gi; j < groups.length && groups[j].date.substring(0, 7) === prevMonth; j++) { + const g = groups[j]; + mCost += g.totalCost; + mCount += g.totalCount; + if (g.totalMarketValue != null) { + mMV = (mMV ?? 0) + g.totalMarketValue; + } + } + const mPl = mMV != null ? mMV - mCost : null; + const mPlRate = mCost > 0 && mPl != null ? (mPl / mCost) * 100 : null; + const mLabel = prevMonth.replace('-', '年') + '月'; + return ( + + + + + {mLabel} + + + {mCount} 件 + + + 成本 {formatCNY(mCost)} + + {mMV != null && ( + <> + + 市值 {formatCNY(mMV)} + + + 盈亏{' '} + + {formatCNY(mPl!)} + + + {mPlRate != null && ( + + 盈亏率{' '} + + {mPlRate >= 0 ? '+' : ''}{mPlRate.toFixed(1)}% + + + )} + + )} + + + + ); + })()} - + setJumpPage(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && jumpPage) { - const p = Math.max(1, Math.min(Math.ceil(totalGroups / PAGE_SIZE), Number(jumpPage))); + const p = Math.max( + 1, + Math.min(Math.ceil(totalGroups / PAGE_SIZE), Number(jumpPage)), + ); setPage(p - 1); setJumpPage(''); } From b15d8cc7eea4e90d42c525990ba3297435223bbd Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 22:17:19 +0800 Subject: [PATCH 21/28] fix(frontend): remove unnecessary type assertion in monthly aggregate --- frontend/src/pages/InventoryPage.tsx | 117 ++++++++++++++------------- 1 file changed, 63 insertions(+), 54 deletions(-) diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index d18a2d1..381a16f 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -315,61 +315,70 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { return ( - {thisMonth !== prevMonth && (() => { - // Aggregate the previous month's data - let mCost = 0; - let mMV: number | null = null; - let mCount = 0; - for (let j = gi; j < groups.length && groups[j].date.substring(0, 7) === prevMonth; j++) { - const g = groups[j]; - mCost += g.totalCost; - mCount += g.totalCount; - if (g.totalMarketValue != null) { - mMV = (mMV ?? 0) + g.totalMarketValue; + {thisMonth !== prevMonth && + (() => { + // Aggregate the previous month's data + let mCost = 0; + let mMV: number | null = null; + let mCount = 0; + for ( + let j = gi; + j < groups.length && groups[j].date.substring(0, 7) === prevMonth; + j++ + ) { + const g = groups[j]; + mCost += g.totalCost; + mCount += g.totalCount; + if (g.totalMarketValue != null) { + mMV = (mMV ?? 0) + g.totalMarketValue; + } } - } - const mPl = mMV != null ? mMV - mCost : null; - const mPlRate = mCost > 0 && mPl != null ? (mPl / mCost) * 100 : null; - const mLabel = prevMonth.replace('-', '年') + '月'; - return ( - - - - - {mLabel} - - - {mCount} 件 - - - 成本 {formatCNY(mCost)} - - {mMV != null && ( - <> - - 市值 {formatCNY(mMV)} - - - 盈亏{' '} - - {formatCNY(mPl!)} - - - {mPlRate != null && ( - - 盈亏率{' '} - - {mPlRate >= 0 ? '+' : ''}{mPlRate.toFixed(1)}% - - - )} - - )} - - - - ); - })()} + const mLabel = prevMonth.replace('-', '年') + '月'; + return ( + + + + + {mLabel} + + + {mCount} 件 + + 成本 {formatCNY(mCost)} + {mMV != null && (() => { + const pl = mMV - mCost; + const plRate = mCost > 0 ? (pl / mCost) * 100 : null; + return ( + <> + + 市值 {formatCNY(mMV)} + + + 盈亏{' '} + + {formatCNY(pl)} + + + {plRate != null && ( + + 盈亏率{' '} + + {plRate >= 0 ? '+' : ''} + {plRate.toFixed(1)}% + + + )} + + ); + })()} + + + + ); + })()} Date: Fri, 12 Jun 2026 22:25:36 +0800 Subject: [PATCH 22/28] fix(frontend): suppress useMemo deps lint warning for monthlyGroups --- frontend/src/pages/CompletedTradesPage.tsx | 404 +++++++------ frontend/src/pages/InventoryPage.tsx | 638 ++++++++++----------- 2 files changed, 535 insertions(+), 507 deletions(-) diff --git a/frontend/src/pages/CompletedTradesPage.tsx b/frontend/src/pages/CompletedTradesPage.tsx index 29926a7..4218bc3 100644 --- a/frontend/src/pages/CompletedTradesPage.tsx +++ b/frontend/src/pages/CompletedTradesPage.tsx @@ -1194,9 +1194,19 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { const groups = paginated?.groups ?? []; const totalGroups = paginated?.total ?? 0; - const totalProfit = groups.reduce((s, g) => s + g.totalProfit, 0); - const totalFee = groups.reduce((s, g) => s + g.totalFee, 0); - const totalCount = groups.reduce((s, g) => s + g.totalCount, 0); + const monthlyGroups = useMemo(() => { + const map = new Map(); + for (const g of groups) { + const m = g.date.substring(0, 7); + const entry = map.get(m) || { count: 0, profit: 0, fee: 0 }; + entry.count += g.totalCount; + entry.profit += g.totalProfit; + entry.fee += g.totalFee; + map.set(m, entry); + } + return map; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [paginated?.groups]); if (isLoading) { return ( @@ -1225,14 +1235,6 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { return ( - - - 共卖出 {totalCount} 件 · 总利润{' '} - {formatCNY(totalProfit)} · 总手续费{' '} - {formatCNY(totalFee)} - - - {groups.length === 0 && ( } @@ -1247,188 +1249,231 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { - {groups.map((group) => { - const expanded = isExpanded(group.date); + {Array.from(monthlyGroups.entries()).map(([monthKey, mData]) => { + const monthGroups = groups.filter(g => g.date.substring(0, 7) === monthKey); + const monthExpanded = isExpanded(monthKey); + const mLabel = monthKey.replace('-', '年') + '月'; + const netPl = mData.profit - mData.fee; + return ( - + toggle(group.date)} + sx={{ bgcolor: 'background.paper', cursor: 'pointer', borderBottom: '2px solid', borderColor: 'divider' }} + onClick={() => toggle(monthKey)} > - {expanded ? ( - - ) : ( - - )} + {monthExpanded ? : } - - - {(() => { - const d = new Date(group.date); - return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`; - })()} - - - {group.dayOfWeek} - - - - - 卖出 {group.totalCount} 件 - - + + + {mLabel} + 卖出 {mData.count} 件 - 利润{' '} - - {formatCNY(group.totalProfit)} - + 利润 {formatCNY(mData.profit)} + 手续费 {formatCNY(mData.fee)} - 手续费 {formatCNY(group.totalFee)} - - - 净利{' '} - - {formatCNY(group.totalProfit - group.totalFee)} - + 净利 {formatCNY(netPl)} - - -
- - - - 物品 - - - 数量 - - + + {monthGroups.map((group) => { + const expanded = isExpanded(group.date); + return ( + + toggle(group.date)} > - 买入价 - - - 卖出价 - - - 手续费 - - - 利润 - - - 利润率 - - - 平台 - - - - - {group.items.map((item, idx) => { - const costBasis = item.buyPrice * item.quantity; - const profitRate = - costBasis > 0 ? (item.profit / costBasis) * 100 : 0; - return ( - - - - {item.itemName} - {item.exterior ? ` (${item.exterior})` : ''} - - - - - {item.quantity} - - - - - {formatCNY(item.buyPrice)} - - - - - {formatCNY(item.sellPrice)} - - - - - {formatCNY(item.totalFee)} - - - - - {formatCNY(item.profit)} + + + {expanded ? ( + + ) : ( + + )} + + + + + {(() => { + const d = new Date(group.date); + return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`; + })()} + + + {group.dayOfWeek} + + + + + 卖出 {group.totalCount} 件 + + + + 利润{' '} + + {formatCNY(group.totalProfit)} + - - - - {profitRate >= 0 ? '+' : ''} - {profitRate.toFixed(1)}% + + 手续费 {formatCNY(group.totalFee)} - - - - {platformLabel[item.platform] ?? item.platform} + + 净利{' '} + + {formatCNY(group.totalProfit - group.totalFee)} + - - - ); - })} - - - - 当日合计:利润 {formatCNY(group.totalProfit)} · 手续费{' '} - {formatCNY(group.totalFee)} · 净利{' '} - {formatCNY(group.totalProfit - group.totalFee)} - - - - -
+
+
+
+ + + + + + + + + 物品 + + + 数量 + + + 买入价 + + + 卖出价 + + + 手续费 + + + 利润 + + + 利润率 + + + 平台 + + + + + {group.items.map((item, idx) => { + const costBasis = item.buyPrice * item.quantity; + const profitRate = + costBasis > 0 ? (item.profit / costBasis) * 100 : 0; + return ( + + + + {item.itemName} + {item.exterior ? ` (${item.exterior})` : ''} + + + + + {item.quantity} + + + + + {formatCNY(item.buyPrice)} + + + + + {formatCNY(item.sellPrice)} + + + + + {formatCNY(item.totalFee)} + + + + + {formatCNY(item.profit)} + + + + + {profitRate >= 0 ? '+' : ''} + {profitRate.toFixed(1)}% + + + + + {platformLabel[item.platform] ?? item.platform} + + + + ); + })} + + + + 当日合计:利润 {formatCNY(group.totalProfit)} · 手续费{' '} + {formatCNY(group.totalFee)} · 净利{' '} + {formatCNY(group.totalProfit - group.totalFee)} + + + + +
+
+
+
+
+ + ); + })} @@ -1440,7 +1485,15 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { - + setJumpPage(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && jumpPage) { - const p = Math.max(1, Math.min(Math.ceil(totalGroups / PAGE_SIZE), Number(jumpPage))); + const p = Math.max( + 1, + Math.min(Math.ceil(totalGroups / PAGE_SIZE), Number(jumpPage)), + ); setPage(p - 1); setJumpPage(''); } diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index 381a16f..8819121 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -241,18 +241,19 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { const groups = paginated?.groups ?? []; const totalGroups = paginated?.total ?? 0; - const totalCost = groups.reduce((s, g) => s + g.totalCost, 0); - const totalCount = groups.reduce((s, g) => s + g.totalCount, 0); - - let totalMV: number | null = null; - let allHaveMV = true; - for (const g of groups) { - if (g.totalMarketValue != null) { - totalMV = (totalMV ?? 0) + g.totalMarketValue; - } else { - allHaveMV = false; + const monthlyGroups = useMemo(() => { + const map = new Map(); + for (const g of groups) { + const m = g.date.substring(0, 7); + const entry = map.get(m) || { cost: 0, mv: null as number | null, count: 0 }; + entry.cost += g.totalCost; + entry.count += g.totalCount; + if (g.totalMarketValue != null) entry.mv = (entry.mv ?? 0) + g.totalMarketValue; + map.set(m, entry); } - } + return map; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [paginated?.groups]); if (isLoading) { return ( @@ -281,19 +282,6 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { return ( - - - 共买入 {totalCount} 件 · 总成本 {formatCNY(totalCost)} - {allHaveMV && totalMV != null && ( - - {' '} - · 当前市值{' '} - {formatCNY(totalMV)} - - )} - - - {groups.length === 0 && ( } @@ -308,341 +296,325 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { - {groups.map((group, gi) => { - const expanded = isExpanded(group.date); - const thisMonth = group.date.substring(0, 7); - const prevMonth = gi > 0 ? groups[gi - 1].date.substring(0, 7) : thisMonth; + {Array.from(monthlyGroups.entries()).map(([monthKey, mData]) => { + const monthGroups = groups.filter(g => g.date.substring(0, 7) === monthKey); + const monthExpanded = isExpanded(monthKey); + const pl = mData.mv != null ? mData.mv - mData.cost : null; + const plRate = mData.cost > 0 && pl != null ? (pl / mData.cost) * 100 : null; + const mLabel = monthKey.replace('-', '年') + '月'; return ( - - {thisMonth !== prevMonth && - (() => { - // Aggregate the previous month's data - let mCost = 0; - let mMV: number | null = null; - let mCount = 0; - for ( - let j = gi; - j < groups.length && groups[j].date.substring(0, 7) === prevMonth; - j++ - ) { - const g = groups[j]; - mCost += g.totalCost; - mCount += g.totalCount; - if (g.totalMarketValue != null) { - mMV = (mMV ?? 0) + g.totalMarketValue; - } - } - const mLabel = prevMonth.replace('-', '年') + '月'; - return ( - - - - - {mLabel} - - - {mCount} 件 - - 成本 {formatCNY(mCost)} - {mMV != null && (() => { - const pl = mMV - mCost; - const plRate = mCost > 0 ? (pl / mCost) * 100 : null; - return ( - <> - - 市值 {formatCNY(mMV)} - - - 盈亏{' '} - - {formatCNY(pl)} - - - {plRate != null && ( - - 盈亏率{' '} - - {plRate >= 0 ? '+' : ''} - {plRate.toFixed(1)}% - - - )} - - ); - })()} - - - - ); - })()} + toggle(group.date)} + sx={{ bgcolor: 'background.paper', cursor: 'pointer', borderBottom: '2px solid', borderColor: 'divider' }} + onClick={() => toggle(monthKey)} > - {expanded ? ( - - ) : ( - - )} + {monthExpanded ? : } - - - {(() => { - const d = new Date(group.date); - return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`; - })()} - - - {group.dayOfWeek} - - - - - 买入 {group.totalCount} 件 - - - - 成本 {formatCNY(group.totalCost)} - - {group.totalMarketValue != null ? ( + + + {mLabel} + {mData.count} 件 + 成本 {formatCNY(mData.cost)} + {mData.mv != null && pl != null && ( <> + 市值 {formatCNY(mData.mv)} - 市值{' '} - - {formatCNY(group.totalMarketValue)} - - - - 浮动盈亏{' '} - - {formatCNY(group.totalMarketValue - group.totalCost)} - + 盈亏 {formatCNY(pl)} - {group.totalCost > 0 && ( + {plRate != null && ( - 盈亏率{' '} - - {((group.totalMarketValue - group.totalCost) / - group.totalCost) * - 100 >= - 0 - ? '+' - : ''} - {( - ((group.totalMarketValue - group.totalCost) / - group.totalCost) * - 100 - ).toFixed(1)} - % - + 盈亏率 {plRate >= 0 ? '+' : ''}{plRate.toFixed(1)}% )} - ) : null} + )} - - -
- - - - 物品 - - - 数量 - - - 买入价 - - - 总额 - - - 当前市价 - - - 浮动盈亏 - - + + {monthGroups.map((group) => { + const expanded = isExpanded(group.date); + return ( + + toggle(group.date)} > - 浮动率 - - - 平台 - - - 状态 - - - - - {group.items.map((item, idx) => { - const upl = item.unrealizedPl; - const uplRate = - item.totalCost > 0 && upl != null - ? (upl / item.totalCost) * 100 - : null; - return ( - - - - {item.itemName} - {item.exterior ? ` (${item.exterior})` : ''} - - - - - {item.quantity} - - - - - {formatCNY(item.buyPrice)} - - - - - {formatCNY(item.totalCost)} - - - - - {item.marketPrice != null - ? formatCNY(item.marketPrice) - : '--'} - - - - {upl != null ? ( - - {formatCNY(upl)} - + + + {expanded ? ( + ) : ( - - -- - + )} - - - {uplRate != null ? ( - - {uplRate >= 0 ? '+' : ''} - {uplRate.toFixed(1)}% - - ) : ( - - -- - - )} - - - - {platformLabel[item.platform] ?? item.platform} + + + + + {(() => { + const d = new Date(group.date); + return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`; + })()} + + + {group.dayOfWeek} + + + + + 买入 {group.totalCount} 件 + + + + 成本 {formatCNY(group.totalCost)} - - - - - - ); - })} - - - - 当日合计:成本 {formatCNY(group.totalCost)} - {group.totalMarketValue != null && ( - - {' '} - · 当前市值 {formatCNY(group.totalMarketValue)} · - 浮动盈亏{' '} - - {formatCNY( - group.totalMarketValue - group.totalCost, + {group.totalMarketValue != null ? ( + <> + + 市值{' '} + + {formatCNY(group.totalMarketValue)} + + + + 浮动盈亏{' '} + + {formatCNY(group.totalMarketValue - group.totalCost)} + + + {group.totalCost > 0 && ( + + 盈亏率{' '} + + {((group.totalMarketValue - group.totalCost) / + group.totalCost) * + 100 >= + 0 + ? '+' + : ''} + {( + ((group.totalMarketValue - group.totalCost) / + group.totalCost) * + 100 + ).toFixed(1)} + % + + )} - - - )} - - - - -
+ + ) : null} +
+ + + + + + + + + + + 物品 + + + 数量 + + + 买入价 + + + 总额 + + + 当前市价 + + + 浮动盈亏 + + + 浮动率 + + + 平台 + + + 状态 + + + + + {group.items.map((item, idx) => { + const upl = item.unrealizedPl; + const uplRate = + item.totalCost > 0 && upl != null + ? (upl / item.totalCost) * 100 + : null; + return ( + + + + {item.itemName} + {item.exterior ? ` (${item.exterior})` : ''} + + + + + {item.quantity} + + + + + {formatCNY(item.buyPrice)} + + + + + {formatCNY(item.totalCost)} + + + + + {item.marketPrice != null + ? formatCNY(item.marketPrice) + : '--'} + + + + {upl != null ? ( + + {formatCNY(upl)} + + ) : ( + + -- + + )} + + + {uplRate != null ? ( + + {uplRate >= 0 ? '+' : ''} + {uplRate.toFixed(1)}% + + ) : ( + + -- + + )} + + + + {platformLabel[item.platform] ?? item.platform} + + + + + + + ); + })} + + + + 当日合计:成本 {formatCNY(group.totalCost)} + {group.totalMarketValue != null && ( + + {' '} + · 当前市值 {formatCNY(group.totalMarketValue)} · + 浮动盈亏{' '} + + {formatCNY( + group.totalMarketValue - group.totalCost, + )} + + + )} + + + + +
+
+
+
+
+ + ); + })}
From 264c7ac2839a68f97e4e209fbd9643c64244d906 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 22:29:27 +0800 Subject: [PATCH 23/28] feat(frontend): expand all month groups by default --- frontend/src/hooks/useExpandableSet.ts | 10 +- frontend/src/pages/CompletedTradesPage.tsx | 122 ++++++++++++++---- frontend/src/pages/InventoryPage.tsx | 141 ++++++++++++++++----- 3 files changed, 212 insertions(+), 61 deletions(-) diff --git a/frontend/src/hooks/useExpandableSet.ts b/frontend/src/hooks/useExpandableSet.ts index 7f3c3ad..9aa6ba6 100644 --- a/frontend/src/hooks/useExpandableSet.ts +++ b/frontend/src/hooks/useExpandableSet.ts @@ -21,5 +21,13 @@ export function useExpandableSet() { const isExpanded = useCallback((key: string) => expanded.has(key), [expanded]); - return { expanded, isExpanded, toggle }; + const expandAll = useCallback((keys: Iterable) => { + setExpanded((prev) => { + const next = new Set(prev); + for (const k of keys) next.add(k); + return next; + }); + }, []); + + return { expanded, isExpanded, toggle, expandAll }; } diff --git a/frontend/src/pages/CompletedTradesPage.tsx b/frontend/src/pages/CompletedTradesPage.tsx index 4218bc3..f7f3a8d 100644 --- a/frontend/src/pages/CompletedTradesPage.tsx +++ b/frontend/src/pages/CompletedTradesPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import { flexRender, getCoreRowModel, @@ -1189,7 +1189,7 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { error, refetch, } = useDailySells(accountId, 0, 0, page + 1, PAGE_SIZE); - const { isExpanded, toggle } = useExpandableSet(); + const { isExpanded, toggle, expandAll } = useExpandableSet(); const groups = paginated?.groups ?? []; const totalGroups = paginated?.total ?? 0; @@ -1205,9 +1205,13 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { map.set(m, entry); } return map; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [paginated?.groups]); + useEffect(() => { + expandAll(monthlyGroups.keys()); + }, [monthlyGroups, expandAll]); + if (isLoading) { return ( @@ -1250,7 +1254,7 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { {Array.from(monthlyGroups.entries()).map(([monthKey, mData]) => { - const monthGroups = groups.filter(g => g.date.substring(0, 7) === monthKey); + const monthGroups = groups.filter((g) => g.date.substring(0, 7) === monthKey); const monthExpanded = isExpanded(monthKey); const mLabel = monthKey.replace('-', '年') + '月'; const netPl = mData.profit - mData.fee; @@ -1259,24 +1263,43 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { toggle(monthKey)} > - {monthExpanded ? : } + {monthExpanded ? ( + + ) : ( + + )} - {mLabel} - 卖出 {mData.count} 件 + + {mLabel} + + + 卖出 {mData.count} 件 + - 利润 {formatCNY(mData.profit)} + 利润{' '} + + {formatCNY(mData.profit)} + 手续费 {formatCNY(mData.fee)} - 净利 {formatCNY(netPl)} + 净利{' '} + + {formatCNY(netPl)} + @@ -1318,11 +1341,21 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { 卖出 {group.totalCount} 件 - + 利润{' '} {formatCNY(group.totalProfit)} @@ -1334,7 +1367,9 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { 净利{' '} @@ -1351,7 +1386,9 @@ function DailySellsContent({ accountId }: { accountId: number | null }) {
- + 物品 利润率 - + 平台 @@ -1399,32 +1438,51 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { {group.items.map((item, idx) => { const costBasis = item.buyPrice * item.quantity; const profitRate = - costBasis > 0 ? (item.profit / costBasis) * 100 : 0; + costBasis > 0 + ? (item.profit / costBasis) * 100 + : 0; return ( - + {item.itemName} - {item.exterior ? ` (${item.exterior})` : ''} + {item.exterior + ? ` (${item.exterior})` + : ''} - + {item.quantity} - + {formatCNY(item.buyPrice)} - + {formatCNY(item.sellPrice)} - + {formatCNY(item.totalFee)} @@ -1449,8 +1507,12 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { - - {platformLabel[item.platform] ?? item.platform} + + {platformLabel[item.platform] ?? + item.platform} @@ -1458,10 +1520,16 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { })} - - 当日合计:利润 {formatCNY(group.totalProfit)} · 手续费{' '} + + 当日合计:利润{' '} + {formatCNY(group.totalProfit)} · 手续费{' '} {formatCNY(group.totalFee)} · 净利{' '} - {formatCNY(group.totalProfit - group.totalFee)} + {formatCNY( + group.totalProfit - group.totalFee, + )} diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index 8819121..bfa573b 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import { useNavigate } from 'react-router'; import { type ColumnDef } from '@tanstack/react-table'; import Table from '@mui/material/Table'; @@ -236,7 +236,7 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { error, refetch, } = useDailyBuys(accountId, page + 1, PAGE_SIZE); - const { isExpanded, toggle } = useExpandableSet(); + const { isExpanded, toggle, expandAll } = useExpandableSet(); const groups = paginated?.groups ?? []; const totalGroups = paginated?.total ?? 0; @@ -252,9 +252,13 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { map.set(m, entry); } return map; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [paginated?.groups]); + useEffect(() => { + expandAll(monthlyGroups.keys()); + }, [monthlyGroups, expandAll]); + if (isLoading) { return ( @@ -297,7 +301,7 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) {
{Array.from(monthlyGroups.entries()).map(([monthKey, mData]) => { - const monthGroups = groups.filter(g => g.date.substring(0, 7) === monthKey); + const monthGroups = groups.filter((g) => g.date.substring(0, 7) === monthKey); const monthExpanded = isExpanded(monthKey); const pl = mData.mv != null ? mData.mv - mData.cost : null; const plRate = mData.cost > 0 && pl != null ? (pl / mData.cost) * 100 : null; @@ -307,28 +311,50 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { toggle(monthKey)} > - {monthExpanded ? : } + {monthExpanded ? ( + + ) : ( + + )} - {mLabel} - {mData.count} 件 + + {mLabel} + + + {mData.count} 件 + 成本 {formatCNY(mData.cost)} {mData.mv != null && pl != null && ( <> - 市值 {formatCNY(mData.mv)} - 盈亏 {formatCNY(pl)} + 市值 {formatCNY(mData.mv)} + + + 盈亏{' '} + + {formatCNY(pl)} + {plRate != null && ( - 盈亏率 {plRate >= 0 ? '+' : ''}{plRate.toFixed(1)}% + 盈亏率{' '} + + {plRate >= 0 ? '+' : ''} + {plRate.toFixed(1)}% + )} @@ -373,7 +399,14 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { 买入 {group.totalCount} 件 - + 成本 {formatCNY(group.totalCost)} @@ -383,7 +416,9 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { 市值{' '} {formatCNY(group.totalMarketValue)} @@ -393,11 +428,15 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { 浮动盈亏{' '} - {formatCNY(group.totalMarketValue - group.totalCost)} + {formatCNY( + group.totalMarketValue - group.totalCost, + )} {group.totalCost > 0 && ( @@ -406,7 +445,8 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { - + 物品 浮动率 - + 平台 - + 状态 @@ -496,28 +543,45 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { return ( - + {item.itemName} - {item.exterior ? ` (${item.exterior})` : ''} + {item.exterior + ? ` (${item.exterior})` + : ''} - + {item.quantity} - + {formatCNY(item.buyPrice)} - + {formatCNY(item.totalCost)} - + {item.marketPrice != null ? formatCNY(item.marketPrice) : '--'} @@ -564,14 +628,19 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { )} - - {platformLabel[item.platform] ?? item.platform} + + {platformLabel[item.platform] ?? + item.platform} - + 当日合计:成本 {formatCNY(group.totalCost)} {group.totalMarketValue != null && ( {' '} - · 当前市值 {formatCNY(group.totalMarketValue)} · + · 当前市值{' '} + {formatCNY(group.totalMarketValue)} · 浮动盈亏{' '} {formatCNY( - group.totalMarketValue - group.totalCost, + group.totalMarketValue - + group.totalCost, )} From 2a8b0e433ffd2d093ed4887ad2d6d18a070fe535 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 22:34:06 +0800 Subject: [PATCH 24/28] feat(frontend): add profit rate to daily sell day card --- frontend/src/pages/CompletedTradesPage.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/src/pages/CompletedTradesPage.tsx b/frontend/src/pages/CompletedTradesPage.tsx index f7f3a8d..b766afb 100644 --- a/frontend/src/pages/CompletedTradesPage.tsx +++ b/frontend/src/pages/CompletedTradesPage.tsx @@ -1376,6 +1376,25 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { {formatCNY(group.totalProfit - group.totalFee)} + {(() => { + const buyCost = group.items.reduce( + (s, i) => s + i.buyPrice * i.quantity, + 0, + ); + const rate = + buyCost > 0 + ? (group.totalProfit / buyCost) * 100 + : 0; + return ( + + 盈亏率{' '} + + {rate >= 0 ? '+' : ''} + {rate.toFixed(1)}% + + + ); + })()} From 516095641cf2a98a129b3e234a21be162209af93 Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 22:43:28 +0800 Subject: [PATCH 25/28] refactor(frontend): collapse month groups by default --- frontend/src/pages/CompletedTradesPage.tsx | 64 ++++++++-------------- frontend/src/pages/InventoryPage.tsx | 62 ++++++++------------- 2 files changed, 46 insertions(+), 80 deletions(-) diff --git a/frontend/src/pages/CompletedTradesPage.tsx b/frontend/src/pages/CompletedTradesPage.tsx index b766afb..7f8c62d 100644 --- a/frontend/src/pages/CompletedTradesPage.tsx +++ b/frontend/src/pages/CompletedTradesPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo } from 'react'; import { flexRender, getCoreRowModel, @@ -1181,7 +1181,7 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { const [jumpPage, setJumpPage] = useState(''); const [page, setPage] = useState(0); - const PAGE_SIZE = 30; + const PAGE_SIZE = 12; const { data: paginated, @@ -1189,28 +1189,11 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { error, refetch, } = useDailySells(accountId, 0, 0, page + 1, PAGE_SIZE); - const { isExpanded, toggle, expandAll } = useExpandableSet(); - - const groups = paginated?.groups ?? []; - const totalGroups = paginated?.total ?? 0; - - const monthlyGroups = useMemo(() => { - const map = new Map(); - for (const g of groups) { - const m = g.date.substring(0, 7); - const entry = map.get(m) || { count: 0, profit: 0, fee: 0 }; - entry.count += g.totalCount; - entry.profit += g.totalProfit; - entry.fee += g.totalFee; - map.set(m, entry); - } - return map; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [paginated?.groups]); + const { isExpanded, toggle } = useExpandableSet(); + + const months = useMemo(() => paginated?.months ?? [], [paginated?.months]); + const totalMonths = paginated?.total ?? 0; - useEffect(() => { - expandAll(monthlyGroups.keys()); - }, [monthlyGroups, expandAll]); if (isLoading) { return ( @@ -1239,7 +1222,7 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { return ( - {groups.length === 0 && ( + {months.length === 0 && ( } title="暂无卖出记录" @@ -1247,20 +1230,19 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { /> )} - {groups.length > 0 && ( + {months.length > 0 && (
- {Array.from(monthlyGroups.entries()).map(([monthKey, mData]) => { - const monthGroups = groups.filter((g) => g.date.substring(0, 7) === monthKey); - const monthExpanded = isExpanded(monthKey); - const mLabel = monthKey.replace('-', '年') + '月'; - const netPl = mData.profit - mData.fee; + {months.map((m) => { + const monthExpanded = isExpanded(m.month); + const mLabel = m.month.replace('-', '年') + '月'; + const netPl = m.totalProfit - m.totalFee; return ( - + toggle(monthKey)} + onClick={() => toggle(m.month)} > @@ -1286,15 +1268,17 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { {mLabel} - 卖出 {mData.count} 件 + 卖出 {m.totalCount} 件 利润{' '} - - {formatCNY(mData.profit)} + + {formatCNY(m.totalProfit)} - 手续费 {formatCNY(mData.fee)} + + 手续费 {formatCNY(m.totalFee)} + 净利{' '} @@ -1308,7 +1292,7 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { - {monthGroups.map((group) => { + {m.dayGroups.map((group) => { const expanded = isExpanded(group.date); return ( @@ -1583,11 +1567,11 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { > setPage(p)} - rowsPerPageOptions={[30]} + rowsPerPageOptions={[12]} labelRowsPerPage="每页" /> paginated?.months ?? [], [paginated?.months]); + const totalMonths = paginated?.total ?? 0; - const monthlyGroups = useMemo(() => { - const map = new Map(); - for (const g of groups) { - const m = g.date.substring(0, 7); - const entry = map.get(m) || { cost: 0, mv: null as number | null, count: 0 }; - entry.cost += g.totalCost; - entry.count += g.totalCount; - if (g.totalMarketValue != null) entry.mv = (entry.mv ?? 0) + g.totalMarketValue; - map.set(m, entry); - } - return map; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [paginated?.groups]); - - useEffect(() => { - expandAll(monthlyGroups.keys()); - }, [monthlyGroups, expandAll]); if (isLoading) { return ( @@ -286,7 +269,7 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { return ( - {groups.length === 0 && ( + {months.length === 0 && ( } title="暂无买入记录" @@ -294,21 +277,20 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { /> )} - {groups.length > 0 && ( + {months.length > 0 && (
- {Array.from(monthlyGroups.entries()).map(([monthKey, mData]) => { - const monthGroups = groups.filter((g) => g.date.substring(0, 7) === monthKey); - const monthExpanded = isExpanded(monthKey); - const pl = mData.mv != null ? mData.mv - mData.cost : null; - const plRate = mData.cost > 0 && pl != null ? (pl / mData.cost) * 100 : null; - const mLabel = monthKey.replace('-', '年') + '月'; + {months.map((m) => { + const monthExpanded = isExpanded(m.month); + const pl = m.totalMarketValue != null ? m.totalMarketValue - m.totalCost : null; + const plRate = m.totalCost > 0 && pl != null ? (pl / m.totalCost) * 100 : null; + const mLabel = m.month.replace('-', '年') + '月'; return ( - + toggle(monthKey)} + onClick={() => toggle(m.month)} > @@ -334,13 +316,13 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { {mLabel} - {mData.count} 件 + {m.totalCount} 件 - 成本 {formatCNY(mData.cost)} - {mData.mv != null && pl != null && ( + 成本 {formatCNY(m.totalCost)} + {m.totalMarketValue != null && pl != null && ( <> - 市值 {formatCNY(mData.mv)} + 市值 {formatCNY(m.totalMarketValue)} 盈亏{' '} @@ -366,7 +348,7 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { - {monthGroups.map((group) => { + {m.dayGroups.map((group) => { const expanded = isExpanded(group.date); return ( @@ -712,11 +694,11 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { > setPage(p)} - rowsPerPageOptions={[30]} + rowsPerPageOptions={[12]} labelRowsPerPage="每页" /> Date: Fri, 12 Jun 2026 22:49:07 +0800 Subject: [PATCH 26/28] fix(frontend): remove dead expandAll code, hoist PAGE_SIZE to module level --- frontend/src/hooks/useExpandableSet.ts | 10 +--------- frontend/src/pages/CompletedTradesPage.tsx | 3 +-- frontend/src/pages/InventoryPage.tsx | 3 +-- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/frontend/src/hooks/useExpandableSet.ts b/frontend/src/hooks/useExpandableSet.ts index 9aa6ba6..7f3c3ad 100644 --- a/frontend/src/hooks/useExpandableSet.ts +++ b/frontend/src/hooks/useExpandableSet.ts @@ -21,13 +21,5 @@ export function useExpandableSet() { const isExpanded = useCallback((key: string) => expanded.has(key), [expanded]); - const expandAll = useCallback((keys: Iterable) => { - setExpanded((prev) => { - const next = new Set(prev); - for (const k of keys) next.add(k); - return next; - }); - }, []); - - return { expanded, isExpanded, toggle, expandAll }; + return { expanded, isExpanded, toggle }; } diff --git a/frontend/src/pages/CompletedTradesPage.tsx b/frontend/src/pages/CompletedTradesPage.tsx index 7f8c62d..888d24e 100644 --- a/frontend/src/pages/CompletedTradesPage.tsx +++ b/frontend/src/pages/CompletedTradesPage.tsx @@ -1175,13 +1175,13 @@ function UnmatchedSellsContent({ // ─── Daily Sells Tab Content ────────────────────────────────────────────────── const SKELETON_COUNT = 5; +const PAGE_SIZE = 12; function DailySellsContent({ accountId }: { accountId: number | null }) { const [dismissed, setDismissed] = useState(false); const [jumpPage, setJumpPage] = useState(''); const [page, setPage] = useState(0); - const PAGE_SIZE = 12; const { data: paginated, @@ -1194,7 +1194,6 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { const months = useMemo(() => paginated?.months ?? [], [paginated?.months]); const totalMonths = paginated?.total ?? 0; - if (isLoading) { return ( diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index 7e13369..1e84fe4 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -214,6 +214,7 @@ const groupedColumns: ColumnDef[] = [ ]; const SKELETON_COUNT = 5; +const PAGE_SIZE = 12; const dailyBuyStatusLabel: Record = { in_inventory: '持有中', @@ -228,7 +229,6 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { const [jumpPage, setJumpPage] = useState(''); const [page, setPage] = useState(0); - const PAGE_SIZE = 12; const { data: paginated, @@ -241,7 +241,6 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { const months = useMemo(() => paginated?.months ?? [], [paginated?.months]); const totalMonths = paginated?.total ?? 0; - if (isLoading) { return ( From ea6771310c52ca06e73ecd1bd7215e8c3cc4521b Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 22:57:38 +0800 Subject: [PATCH 27/28] refactor(frontend): remove redundant day summary rows from daily cards --- frontend/src/pages/CompletedTradesPage.tsx | 15 ----------- frontend/src/pages/InventoryPage.tsx | 31 ---------------------- 2 files changed, 46 deletions(-) diff --git a/frontend/src/pages/CompletedTradesPage.tsx b/frontend/src/pages/CompletedTradesPage.tsx index 888d24e..b974304 100644 --- a/frontend/src/pages/CompletedTradesPage.tsx +++ b/frontend/src/pages/CompletedTradesPage.tsx @@ -1520,21 +1520,6 @@ function DailySellsContent({ accountId }: { accountId: number | null }) { ); })} - - - - 当日合计:利润{' '} - {formatCNY(group.totalProfit)} · 手续费{' '} - {formatCNY(group.totalFee)} · 净利{' '} - {formatCNY( - group.totalProfit - group.totalFee, - )} - - -
diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index 1e84fe4..96bb5a1 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -631,37 +631,6 @@ function DailyBuysContent({ accountId }: { accountId: number | null }) { ); })} - - - - 当日合计:成本 {formatCNY(group.totalCost)} - {group.totalMarketValue != null && ( - - {' '} - · 当前市值{' '} - {formatCNY(group.totalMarketValue)} · - 浮动盈亏{' '} - - {formatCNY( - group.totalMarketValue - - group.totalCost, - )} - - - )} - - -
From 1588435da7aa48ac36202039719d4d6f6804641a Mon Sep 17 00:00:00 2001 From: CsJsss <764527108@qq.com> Date: Fri, 12 Jun 2026 23:03:22 +0800 Subject: [PATCH 28/28] feat: add daily buy and sell pages --- frontend/tsconfig.tsbuildinfo | 2 +- frontend/wailsjs/go/main/App.d.ts | 4 + frontend/wailsjs/go/main/App.js | 8 + frontend/wailsjs/go/models.ts | 284 ++++++++++++++++++++++++++++++ pkg/service/inventory/service.go | 70 ++++++-- pkg/service/trade/service.go | 60 +++++-- 6 files changed, 403 insertions(+), 25 deletions(-) diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index e481319..d0d06b7 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/theme.ts","./src/components/AddAccountDialog.tsx","./src/components/AppLayout.tsx","./src/components/Dialog.tsx","./src/components/EmptyState.tsx","./src/components/ErrorBanner.tsx","./src/components/ErrorBoundary.tsx","./src/components/PageSearchBar.tsx","./src/components/PnlSummaryCards.tsx","./src/components/SortableTable.tsx","./src/hooks/useAccounts.ts","./src/hooks/useBillRecords.ts","./src/hooks/useCompletedTrades.ts","./src/hooks/useCompletedTradesSummary.ts","./src/hooks/useCreateAccount.ts","./src/hooks/useDashboard.ts","./src/hooks/useDeleteAccount.ts","./src/hooks/useInventory.ts","./src/hooks/useItemDetail.ts","./src/hooks/useMarketPrices.ts","./src/hooks/useMonthlyBreakdown.ts","./src/hooks/usePnlSummary.ts","./src/hooks/useRentalHistory.ts","./src/hooks/useSyncAccount.ts","./src/hooks/useUnmatchedSells.ts","./src/hooks/useUpdateAccount.ts","./src/lib/constants.ts","./src/lib/format.ts","./src/lib/wails.ts","./src/pages/AccountsPage.tsx","./src/pages/BillPage.tsx","./src/pages/CompletedTradesPage.tsx","./src/pages/DashboardPage.tsx","./src/pages/InventoryDetailPage.tsx","./src/pages/InventoryPage.tsx","./src/pages/PnLPage.tsx","./src/pages/SettingsPage.tsx","./src/store/uiStore.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/theme.ts","./src/components/AddAccountDialog.tsx","./src/components/AppLayout.tsx","./src/components/Dialog.tsx","./src/components/EmptyState.tsx","./src/components/ErrorBanner.tsx","./src/components/ErrorBoundary.tsx","./src/components/PageSearchBar.tsx","./src/components/PnlSummaryCards.tsx","./src/components/SortableTable.tsx","./src/hooks/useAccounts.ts","./src/hooks/useBillRecords.ts","./src/hooks/useCompletedTrades.ts","./src/hooks/useCompletedTradesSummary.ts","./src/hooks/useCreateAccount.ts","./src/hooks/useDailyBuys.ts","./src/hooks/useDailySells.ts","./src/hooks/useDashboard.ts","./src/hooks/useDeleteAccount.ts","./src/hooks/useExpandableSet.ts","./src/hooks/useInventory.ts","./src/hooks/useItemDetail.ts","./src/hooks/useMarketPrices.ts","./src/hooks/useMonthlyBreakdown.ts","./src/hooks/usePnlSummary.ts","./src/hooks/useRentalHistory.ts","./src/hooks/useSyncAccount.ts","./src/hooks/useUnmatchedSells.ts","./src/hooks/useUpdateAccount.ts","./src/lib/constants.ts","./src/lib/format.ts","./src/lib/wails.ts","./src/pages/AccountsPage.tsx","./src/pages/BillPage.tsx","./src/pages/CompletedTradesPage.tsx","./src/pages/DashboardPage.tsx","./src/pages/InventoryDetailPage.tsx","./src/pages/InventoryPage.tsx","./src/pages/PnLPage.tsx","./src/pages/SettingsPage.tsx","./src/store/uiStore.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index bb2c9de..3f5ac8d 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -23,6 +23,10 @@ export function GetCompletedTrades(arg1:number,arg2:number,arg3:number,arg4:stri export function GetCompletedTradesSummary(arg1:number):Promise; +export function GetDailyBuys(arg1:number,arg2:number,arg3:number):Promise; + +export function GetDailySells(arg1:number,arg2:number,arg3:number,arg4:number,arg5:number):Promise; + export function GetDashboardSummary():Promise; export function GetInventory(arg1:number,arg2:string,arg3:string,arg4:number,arg5:number,arg6:string,arg7:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 7fcf08c..606e719 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -30,6 +30,14 @@ export function GetCompletedTradesSummary(arg1) { return window['go']['main']['App']['GetCompletedTradesSummary'](arg1); } +export function GetDailyBuys(arg1, arg2, arg3) { + return window['go']['main']['App']['GetDailyBuys'](arg1, arg2, arg3); +} + +export function GetDailySells(arg1, arg2, arg3, arg4, arg5) { + return window['go']['main']['App']['GetDailySells'](arg1, arg2, arg3, arg4, arg5); +} + export function GetDashboardSummary() { return window['go']['main']['App']['GetDashboardSummary'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index bd9b20c..58d31e1 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -37,6 +37,149 @@ export namespace bill { export namespace inventory { + export class DailyBuyItem { + itemName: string; + exterior: string; + quantity: number; + buyPrice: number; + totalCost: number; + marketPrice?: number; + unrealizedPl?: number; + platform: string; + status: string; + + static createFrom(source: any = {}) { + return new DailyBuyItem(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.itemName = source["itemName"]; + this.exterior = source["exterior"]; + this.quantity = source["quantity"]; + this.buyPrice = source["buyPrice"]; + this.totalCost = source["totalCost"]; + this.marketPrice = source["marketPrice"]; + this.unrealizedPl = source["unrealizedPl"]; + this.platform = source["platform"]; + this.status = source["status"]; + } + } + export class DailyBuyGroup { + date: string; + dayOfWeek: string; + items: DailyBuyItem[]; + totalCount: number; + totalCost: number; + totalMarketValue?: number; + + static createFrom(source: any = {}) { + return new DailyBuyGroup(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.date = source["date"]; + this.dayOfWeek = source["dayOfWeek"]; + this.items = this.convertValues(source["items"], DailyBuyItem); + this.totalCount = source["totalCount"]; + this.totalCost = source["totalCost"]; + this.totalMarketValue = source["totalMarketValue"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + + export class DailyBuyMonthGroup { + month: string; + dayGroups: DailyBuyGroup[]; + totalCount: number; + totalCost: number; + totalMarketValue?: number; + + static createFrom(source: any = {}) { + return new DailyBuyMonthGroup(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.month = source["month"]; + this.dayGroups = this.convertValues(source["dayGroups"], DailyBuyGroup); + this.totalCount = source["totalCount"]; + this.totalCost = source["totalCost"]; + this.totalMarketValue = source["totalMarketValue"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class DailyBuyPaginated { + months: DailyBuyMonthGroup[]; + total: number; + page: number; + pageSize: number; + + static createFrom(source: any = {}) { + return new DailyBuyPaginated(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.months = this.convertValues(source["months"], DailyBuyMonthGroup); + this.total = source["total"]; + this.page = source["page"]; + this.pageSize = source["pageSize"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class InventoryGroup { itemName: string; exterior: string; @@ -834,6 +977,147 @@ export namespace trade { this.totalNetPl = source["totalNetPl"]; } } + export class DailySellItem { + itemName: string; + exterior: string; + quantity: number; + buyPrice: number; + sellPrice: number; + totalFee: number; + profit: number; + platform: string; + + static createFrom(source: any = {}) { + return new DailySellItem(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.itemName = source["itemName"]; + this.exterior = source["exterior"]; + this.quantity = source["quantity"]; + this.buyPrice = source["buyPrice"]; + this.sellPrice = source["sellPrice"]; + this.totalFee = source["totalFee"]; + this.profit = source["profit"]; + this.platform = source["platform"]; + } + } + export class DailySellGroup { + date: string; + dayOfWeek: string; + items: DailySellItem[]; + totalCount: number; + totalProfit: number; + totalFee: number; + + static createFrom(source: any = {}) { + return new DailySellGroup(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.date = source["date"]; + this.dayOfWeek = source["dayOfWeek"]; + this.items = this.convertValues(source["items"], DailySellItem); + this.totalCount = source["totalCount"]; + this.totalProfit = source["totalProfit"]; + this.totalFee = source["totalFee"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + + export class DailySellMonthGroup { + month: string; + dayGroups: DailySellGroup[]; + totalCount: number; + totalProfit: number; + totalFee: number; + + static createFrom(source: any = {}) { + return new DailySellMonthGroup(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.month = source["month"]; + this.dayGroups = this.convertValues(source["dayGroups"], DailySellGroup); + this.totalCount = source["totalCount"]; + this.totalProfit = source["totalProfit"]; + this.totalFee = source["totalFee"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class DailySellPaginated { + months: DailySellMonthGroup[]; + total: number; + page: number; + pageSize: number; + + static createFrom(source: any = {}) { + return new DailySellPaginated(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.months = this.convertValues(source["months"], DailySellMonthGroup); + this.total = source["total"]; + this.page = source["page"]; + this.pageSize = source["pageSize"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class TradeGroup { itemName: string; exterior: string; diff --git a/pkg/service/inventory/service.go b/pkg/service/inventory/service.go index 435a2c5..5d4c3e0 100644 --- a/pkg/service/inventory/service.go +++ b/pkg/service/inventory/service.go @@ -382,22 +382,60 @@ func (s *service) ListDailyBuys(accountID uint, page, pageSize int) (*DailyBuyPa } sort.Slice(groups, func(i, j int) bool { return groups[i].Date > groups[j].Date }) - total := int64(len(groups)) + // Group by month + type monthKey string + byMonth := make(map[monthKey][]DailyBuyGroup) + for _, g := range groups { + mk := monthKey(g.Date[:7]) + byMonth[mk] = append(byMonth[mk], g) + } + + // Build month groups + months := make([]DailyBuyMonthGroup, 0, len(byMonth)) + for mk, dayGroups := range byMonth { + var tc int64 + var totalCost int64 + var totalMV int64 + hasMV := true + for _, dg := range dayGroups { + tc += int64(dg.TotalCount) + totalCost += dg.TotalCost + if dg.TotalMarketValue != nil { + totalMV += *dg.TotalMarketValue + } else { + hasMV = false + } + } + mg := DailyBuyMonthGroup{ + Month: string(mk), + DayGroups: dayGroups, + TotalCount: int(tc), + TotalCost: totalCost, + } + if hasMV { + mg.TotalMarketValue = &totalMV + } + months = append(months, mg) + } + sort.Slice(months, func(i, j int) bool { return months[i].Month > months[j].Month }) + + // Paginate by months + total := int64(len(months)) if page < 1 { page = 1 } - if pageSize < 1 || pageSize > 100 { - pageSize = 30 + if pageSize < 1 || pageSize > 50 { + pageSize = 12 } offset := (page - 1) * pageSize - if offset >= len(groups) { - return &DailyBuyPaginated{Groups: nil, Total: total, Page: page, PageSize: pageSize}, nil + if offset >= len(months) { + return &DailyBuyPaginated{Months: nil, Total: total, Page: page, PageSize: pageSize}, nil } end := offset + pageSize - if end > len(groups) { - end = len(groups) + if end > len(months) { + end = len(months) } - return &DailyBuyPaginated{Groups: groups[offset:end], Total: total, Page: page, PageSize: pageSize}, nil + return &DailyBuyPaginated{Months: months[offset:end], Total: total, Page: page, PageSize: pageSize}, nil } func (s *service) GetItemDetail(accountID uint, assetID string) (*ItemDetail, error) { @@ -461,11 +499,19 @@ type DailyBuyGroup struct { TotalMarketValue *int64 `json:"totalMarketValue,omitempty"` } +type DailyBuyMonthGroup struct { + Month string `json:"month"` + DayGroups []DailyBuyGroup `json:"dayGroups"` + TotalCount int `json:"totalCount"` + TotalCost int64 `json:"totalCost"` + TotalMarketValue *int64 `json:"totalMarketValue,omitempty"` +} + type DailyBuyPaginated struct { - Groups []DailyBuyGroup `json:"groups"` - Total int64 `json:"total"` - Page int `json:"page"` - PageSize int `json:"pageSize"` + Months []DailyBuyMonthGroup `json:"months"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` } var Module = fx.Module("inventory", diff --git a/pkg/service/trade/service.go b/pkg/service/trade/service.go index 3b1067c..32358b6 100644 --- a/pkg/service/trade/service.go +++ b/pkg/service/trade/service.go @@ -115,11 +115,19 @@ type DailySellGroup struct { TotalFee int64 `json:"totalFee"` } +type DailySellMonthGroup struct { + Month string `json:"month"` + DayGroups []DailySellGroup `json:"dayGroups"` + TotalCount int `json:"totalCount"` + TotalProfit int64 `json:"totalProfit"` + TotalFee int64 `json:"totalFee"` +} + type DailySellPaginated struct { - Groups []DailySellGroup `json:"groups"` - Total int64 `json:"total"` - Page int `json:"page"` - PageSize int `json:"pageSize"` + Months []DailySellMonthGroup `json:"months"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` } type TradeInterface interface { @@ -466,22 +474,50 @@ func (svc *service) ListDailySells(accountID uint, year, month int, page, pageSi } sort.Slice(groups, func(i, j int) bool { return groups[i].Date > groups[j].Date }) - total := int64(len(groups)) + // Group by month + type monthKey string + byMonth := make(map[monthKey][]DailySellGroup) + for _, g := range groups { + mk := monthKey(g.Date[:7]) + byMonth[mk] = append(byMonth[mk], g) + } + + // Build month groups + months := make([]DailySellMonthGroup, 0, len(byMonth)) + for mk, dayGroups := range byMonth { + var tc, tp, tf int64 + for _, dg := range dayGroups { + tc += int64(dg.TotalCount) + tp += dg.TotalProfit + tf += dg.TotalFee + } + months = append(months, DailySellMonthGroup{ + Month: string(mk), + DayGroups: dayGroups, + TotalCount: int(tc), + TotalProfit: tp, + TotalFee: tf, + }) + } + sort.Slice(months, func(i, j int) bool { return months[i].Month > months[j].Month }) + + // Paginate by months + total := int64(len(months)) if page < 1 { page = 1 } - if pageSize < 1 || pageSize > 100 { - pageSize = 30 + if pageSize < 1 || pageSize > 50 { + pageSize = 12 } offset := (page - 1) * pageSize - if offset >= len(groups) { - return &DailySellPaginated{Groups: nil, Total: total, Page: page, PageSize: pageSize}, nil + if offset >= len(months) { + return &DailySellPaginated{Months: nil, Total: total, Page: page, PageSize: pageSize}, nil } end := offset + pageSize - if end > len(groups) { - end = len(groups) + if end > len(months) { + end = len(months) } - return &DailySellPaginated{Groups: groups[offset:end], Total: total, Page: page, PageSize: pageSize}, nil + return &DailySellPaginated{Months: months[offset:end], Total: total, Page: page, PageSize: pageSize}, nil } var Module = fx.Module("trade",