diff --git a/server/config/config.go b/server/config/config.go index 0ecbc40..4e8cc61 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -1,3 +1,4 @@ +// Package config 读取应用配置,支持 YAML 配置文件和环境变量覆盖 package config import ( @@ -7,29 +8,34 @@ import ( "gopkg.in/yaml.v3" ) +// DBConfig MySQL 数据库连接配置 type DBConfig struct { - Host string `yaml:"host"` - Port string `yaml:"port"` - User string `yaml:"user"` - Password string `yaml:"password"` - DBName string `yaml:"database"` - Charset string `yaml:"charset"` + Host string `yaml:"host"` // 数据库主机地址 + Port string `yaml:"port"` // 数据库端口 + User string `yaml:"user"` // 用户名 + Password string `yaml:"password"` // 密码 + DBName string `yaml:"database"` // 数据库名 + Charset string `yaml:"charset"` // 字符集,默认 utf8mb4 } +// ServerConfig HTTP 服务器配置 type ServerConfig struct { - Port string `yaml:"port"` + Port string `yaml:"port"` // 监听端口,默认 8080 } +// Config 应用总配置 type Config struct { - DB DBConfig `yaml:"mysql"` - Server ServerConfig `yaml:"server"` + DB DBConfig `yaml:"mysql"` // MySQL 配置段 + Server ServerConfig `yaml:"server"` // 服务器配置段 } +// Load 加载配置,优先级: 环境变量 > config.yaml > 默认值 func Load() *Config { cfg := &Config{} data, err := os.ReadFile("config.yaml") if err != nil { + // 配置文件不存在时使用环境变量或默认值 fmt.Println("config.yaml not found, using env defaults") cfg.DB.Host = getEnv("DB_HOST", "127.0.0.1") cfg.DB.Port = getEnv("DB_PORT", "3306") @@ -53,7 +59,7 @@ func Load() *Config { return cfg } - // Environment variables override config file + // 环境变量覆盖配置文件中的值 if v := os.Getenv("DB_HOST"); v != "" { cfg.DB.Host = v } @@ -76,6 +82,7 @@ func Load() *Config { return cfg } +// DSN 生成 MySQL 连接字符串 func (c *DBConfig) DSN() string { charset := c.Charset if charset == "" { @@ -85,6 +92,7 @@ func (c *DBConfig) DSN() string { c.User, c.Password, c.Host, c.Port, c.DBName, charset) } +// getEnv 获取环境变量,不存在时返回默认值 func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v diff --git a/server/handler/dashboard.go b/server/handler/dashboard.go index 05964ed..d09edee 100644 --- a/server/handler/dashboard.go +++ b/server/handler/dashboard.go @@ -1,3 +1,4 @@ +// Package handler HTTP 请求处理层,负责参数解析、调用 Service、返回 JSON 响应 package handler import ( @@ -8,6 +9,7 @@ import ( "github.com/gin-gonic/gin" ) +// DashboardHandler 市场概览相关的 HTTP handler type DashboardHandler struct { svc *service.DashboardService } @@ -16,6 +18,8 @@ func NewDashboardHandler(svc *service.DashboardService) *DashboardHandler { return &DashboardHandler{svc: svc} } +// GetSummary 获取最新交易日的市场概览数据 +// GET /api/dashboard/summary func (h *DashboardHandler) GetSummary(c *gin.Context) { summary, err := h.svc.GetSummary() if err != nil { @@ -25,6 +29,8 @@ func (h *DashboardHandler) GetSummary(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": summary}) } +// GetDailySummary 获取近 N 天的每日市场统计数据 +// GET /api/dashboard/daily?days=30 func (h *DashboardHandler) GetDailySummary(c *gin.Context) { days, _ := strconv.Atoi(c.DefaultQuery("days", "30")) if days <= 0 || days > 365 { @@ -38,6 +44,8 @@ func (h *DashboardHandler) GetDailySummary(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": data}) } +// GetConsecutiveLimits 获取最新交易日的连板股票统计 +// GET /api/dashboard/consecutive func (h *DashboardHandler) GetConsecutiveLimits(c *gin.Context) { data, err := h.svc.GetConsecutiveLimits() if err != nil { @@ -47,6 +55,8 @@ func (h *DashboardHandler) GetConsecutiveLimits(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": data}) } +// GetHigh100 获取百日新高股票列表 +// GET /api/dashboard/high100 func (h *DashboardHandler) GetHigh100(c *gin.Context) { data, err := h.svc.GetHigh100() if err != nil { @@ -56,6 +66,8 @@ func (h *DashboardHandler) GetHigh100(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": data}) } +// GetIndexKline 获取指数 K 线数据 +// GET /api/dashboard/index-kline?code=000001&days=60 func (h *DashboardHandler) GetIndexKline(c *gin.Context) { code := c.DefaultQuery("code", "000001") days, _ := strconv.Atoi(c.DefaultQuery("days", "60")) @@ -69,3 +81,19 @@ func (h *DashboardHandler) GetIndexKline(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"data": data}) } + +// GetStockKline 获取个股 K 线数据(全部历史) +// GET /api/dashboard/stock-kline?code=600498 +func (h *DashboardHandler) GetStockKline(c *gin.Context) { + code := c.Query("code") + if code == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "code is required"}) + return + } + data, err := h.svc.GetStockKline(code) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": data}) +} diff --git a/server/main.go b/server/main.go index 3e7552f..e41b3e9 100644 --- a/server/main.go +++ b/server/main.go @@ -1,3 +1,5 @@ +// AShareView 服务端入口 +// A股市场数据可视化平台后端,提供市场概览、指数行情、K线叠加对比等 API package main import ( @@ -16,15 +18,17 @@ import ( ) func main() { + // 加载配置 cfg := config.Load() + // 连接 MySQL db, err := gorm.Open(mysql.Open(cfg.DB.DSN()), &gorm.Config{}) if err != nil { log.Fatalf("failed to connect database: %v", err) } fmt.Println("Database connected successfully") - // Ensure indexes exist (skip if already exist) + // 异步创建常用索引,避免首次查询慢 go func() { time.Sleep(3 * time.Second) indexes := []string{ @@ -40,10 +44,12 @@ func main() { } }() + // 分层初始化: Repository -> Service -> Handler stockRepo := repository.NewStockRepo(db) dashboardSvc := service.NewDashboardService(stockRepo) dashboardHandler := handler.NewDashboardHandler(dashboardSvc) + // 启动 HTTP 服务 r := gin.Default() router.Setup(r, dashboardHandler) diff --git a/server/model/stock.go b/server/model/stock.go index 4dba6bb..bdfb49b 100644 --- a/server/model/stock.go +++ b/server/model/stock.go @@ -1,84 +1,97 @@ +// Package model 定义了A股数据相关的数据模型 +// +// 数据源: +// - stock_daily 表: 个股每日行情数据(开盘、收盘、最高、最低、成交量、成交额、涨跌幅等) +// - stock_info 表: 个股基本信息(代码、名称、上市日期) +// - index_daily 表: 指数每日行情数据 package model +// StockDaily 个股每日行情记录 type StockDaily struct { ID int `gorm:"primaryKey" json:"id"` - Code string `gorm:"column:code;type:varchar(10)" json:"code"` - Date string `gorm:"column:date;type:date" json:"date"` - Open float64 `gorm:"column:open" json:"open"` - Close float64 `gorm:"column:close" json:"close"` - High float64 `gorm:"column:high" json:"high"` - Low float64 `gorm:"column:low" json:"low"` - Volume float64 `gorm:"column:volume" json:"volume"` - Turnover float64 `gorm:"column:turnover" json:"turnover"` - Amplitude float64 `gorm:"column:amplitude" json:"amplitude"` - PctChange float64 `gorm:"column:pct_change" json:"pct_change"` - Change float64 `gorm:"column:change" json:"change"` - TurnoverRate float64 `gorm:"column:turnover_rate" json:"turnover_rate"` + Code string `gorm:"column:code;type:varchar(10)" json:"code"` // 股票代码,如 "600498" + Date string `gorm:"column:date;type:date" json:"date"` // 交易日期 + Open float64 `gorm:"column:open" json:"open"` // 开盘价 + Close float64 `gorm:"column:close" json:"close"` // 收盘价 + High float64 `gorm:"column:high" json:"high"` // 最高价 + Low float64 `gorm:"column:low" json:"low"` // 最低价 + Volume float64 `gorm:"column:volume" json:"volume"` // 成交量(股) + Turnover float64 `gorm:"column:turnover" json:"turnover"` // 成交额(元) + Amplitude float64 `gorm:"column:amplitude" json:"amplitude"` // 振幅(%) + PctChange float64 `gorm:"column:pct_change" json:"pct_change"` // 涨跌幅(%) + Change float64 `gorm:"column:change" json:"change"` // 涨跌额 + TurnoverRate float64 `gorm:"column:turnover_rate" json:"turnover_rate"` // 换手率(%) } func (StockDaily) TableName() string { return "stock_daily" } +// StockInfo 个股基本信息 type StockInfo struct { - Code string `gorm:"primaryKey;column:code;type:varchar(10)" json:"code"` - Name string `gorm:"column:name;type:varchar(50)" json:"name"` - IpoDate string `gorm:"column:ipo_date;type:date" json:"ipo_date"` + Code string `gorm:"primaryKey;column:code;type:varchar(10)" json:"code"` // 股票代码 + Name string `gorm:"column:name;type:varchar(50)" json:"name"` // 股票名称 + IpoDate string `gorm:"column:ipo_date;type:date" json:"ipo_date"` // 上市日期 } func (StockInfo) TableName() string { return "stock_info" } +// StockRank 涨停/跌停/百日新高排行 type StockRank struct { - Code string `json:"code"` - Name string `json:"name"` - PctChange float64 `json:"pct_change"` - Close float64 `json:"close"` + Code string `json:"code"` // 股票代码 + Name string `json:"name"` // 股票名称 + PctChange float64 `json:"pct_change"` // 涨跌幅(%) + Close float64 `json:"close"` // 收盘价 } +// DailySummary 每日市场统计汇总 type DailySummary struct { - Date string `gorm:"column:date" json:"date"` - TotalStocks int `gorm:"column:total_stocks" json:"total_stocks"` - UpCount int `gorm:"column:up_count" json:"up_count"` - DownCount int `gorm:"column:down_count" json:"down_count"` - FlatCount int `gorm:"column:flat_count" json:"flat_count"` - TotalTurnover float64 `gorm:"column:total_turnover" json:"total_turnover"` - AvgPctChange float64 `gorm:"column:avg_pct_change" json:"avg_pct_change"` - LimitUp10 int `gorm:"column:limit_up_10" json:"limit_up_10"` - LimitDown10 int `gorm:"column:limit_down_10" json:"limit_down_10"` - LimitUp20 int `gorm:"column:limit_up_20" json:"limit_up_20"` - LimitDown20 int `gorm:"column:limit_down_20" json:"limit_down_20"` - ConsecutiveLimitCount int `gorm:"column:consecutive_limit_count" json:"consecutive_limit_count"` + Date string `gorm:"column:date" json:"date"` // 交易日期 + TotalStocks int `gorm:"column:total_stocks" json:"total_stocks"` // 当日交易股票总数 + UpCount int `gorm:"column:up_count" json:"up_count"` // 上涨数量 + DownCount int `gorm:"column:down_count" json:"down_count"` // 下跌数量 + FlatCount int `gorm:"column:flat_count" json:"flat_count"` // 平盘数量 + TotalTurnover float64 `gorm:"column:total_turnover" json:"total_turnover"` // 总成交额 + AvgPctChange float64 `gorm:"column:avg_pct_change" json:"avg_pct_change"` // 平均涨跌幅(%) + LimitUp10 int `gorm:"column:limit_up_10" json:"limit_up_10"` // 主板涨停数量(涨幅>=9.5%) + LimitDown10 int `gorm:"column:limit_down_10" json:"limit_down_10"` // 主板跌停数量(跌幅<=-9.5%) + LimitUp20 int `gorm:"column:limit_up_20" json:"limit_up_20"` // 创业板/科创板涨停数量(涨幅>=19.5%) + LimitDown20 int `gorm:"column:limit_down_20" json:"limit_down_20"` // 创业板/科创板跌停数量(跌幅<=-19.5%) + ConsecutiveLimitCount int `gorm:"column:consecutive_limit_count" json:"consecutive_limit_count"` // 连板股票数量(当日涨停且前一日也涨停) } +// DashboardSummary 市场概览数据 type DashboardSummary struct { - TotalStocks int64 `json:"total_stocks"` - UpCount int64 `json:"up_count"` - DownCount int64 `json:"down_count"` - FlatCount int64 `json:"flat_count"` - TotalTurnover float64 `json:"total_turnover"` - AvgPctChange float64 `json:"avg_pct_change"` - LimitUpList []StockRank `json:"limit_up_list"` - LimitDownList []StockRank `json:"limit_down_list"` - High100List []StockRank `json:"high_100_list"` + TotalStocks int64 `json:"total_stocks"` // 当日交易股票总数 + UpCount int64 `json:"up_count"` // 上涨数量 + DownCount int64 `json:"down_count"` // 下跌数量 + FlatCount int64 `json:"flat_count"` // 平盘数量 + TotalTurnover float64 `json:"total_turnover"` // 总成交额 + AvgPctChange float64 `json:"avg_pct_change"` // 平均涨跌幅(%) + LimitUpList []StockRank `json:"limit_up_list"` // 涨停股票列表 + LimitDownList []StockRank `json:"limit_down_list"` // 跌停股票列表 + High100List []StockRank `json:"high_100_list"` // 百日新高股票列表 } +// ConsecutiveLimit 连板统计 type ConsecutiveLimit struct { - Code string `json:"code"` - Name string `json:"name"` - Streak int `json:"streak"` - Close float64 `json:"close"` - PctChange float64 `json:"pct_change"` + Code string `json:"code"` // 股票代码 + Name string `json:"name"` // 股票名称 + Streak int `json:"streak"` // 连板天数(连续涨停天数) + Close float64 `json:"close"` // 收盘价 + PctChange float64 `json:"pct_change"` // 当日涨跌幅(%) } +// IndexKline K线数据(通用结构,同时用于指数和个股) type IndexKline struct { - Date string `json:"date"` - Open float64 `json:"open"` - Close float64 `json:"close"` - High float64 `json:"high"` - Low float64 `json:"low"` - Volume float64 `json:"volume"` - Amount float64 `json:"amount"` - PctChange float64 `json:"pct_change"` + Date string `json:"date"` // 交易日期 + Open float64 `json:"open"` // 开盘价 + Close float64 `json:"close"` // 收盘价 + High float64 `json:"high"` // 最高价 + Low float64 `json:"low"` // 最低价 + Volume float64 `json:"volume"` // 成交量 + Amount float64 `json:"amount"` // 成交额 + PctChange float64 `json:"pct_change"` // 涨跌幅(%) } diff --git a/server/repository/stock_repo.go b/server/repository/stock_repo.go index 3fabc50..2059aba 100644 --- a/server/repository/stock_repo.go +++ b/server/repository/stock_repo.go @@ -1,3 +1,4 @@ +// Package repository 数据访问层,直接操作 MySQL 数据库执行 SQL 查询 package repository import ( @@ -8,9 +9,10 @@ import ( "gorm.io/gorm" ) +// StockRepo 个股与指数数据的数据访问对象 type StockRepo struct { db *gorm.DB - dateCache struct { + dateCache struct { // 最新交易日期缓存,避免频繁查询 mu sync.RWMutex date string expires time.Time @@ -21,7 +23,7 @@ func NewStockRepo(db *gorm.DB) *StockRepo { return &StockRepo{db: db} } -// getLatestDate returns the latest trade date, cached for 5 minutes +// getLatestDate 获取最新交易日期,结果缓存 5 分钟 func (r *StockRepo) getLatestDate() (string, error) { r.dateCache.mu.RLock() if r.dateCache.date != "" && time.Now().Before(r.dateCache.expires) { @@ -47,14 +49,18 @@ func (r *StockRepo) getLatestDate() (string, error) { return result.MaxDate, nil } +// GetLatestTradeDate 获取最新交易日期(公开方法) func (r *StockRepo) GetLatestTradeDate() (string, error) { return r.getLatestDate() } +// GetSummaryByDate 获取指定日期的市场概览数据 +// 包括: 涨跌统计、涨停跌停列表、百日新高列表 +// 使用单条聚合查询替代多次 COUNT 查询,提升性能 func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, error) { summary := &model.DashboardSummary{} - // Single aggregation query instead of 6 separate queries + // 聚合统计: 总数、上涨、下跌、平盘、总成交额、平均涨跌幅 var agg struct { Total int64 `gorm:"column:total"` Up int64 `gorm:"column:up"` @@ -81,6 +87,7 @@ func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, erro summary.TotalTurnover = agg.Turnover summary.AvgPctChange = agg.AvgPct + // 涨停列表: 涨幅 >= 9.5%(覆盖主板10%和创业板/科创板20%) r.db.Raw(` SELECT sd.code, si.name, sd.pct_change, sd.close FROM stock_daily sd @@ -89,6 +96,7 @@ func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, erro ORDER BY sd.pct_change DESC `, date).Scan(&summary.LimitUpList) + // 跌停列表: 跌幅 <= -9.5% r.db.Raw(` SELECT sd.code, si.name, sd.pct_change, sd.close FROM stock_daily sd @@ -97,7 +105,7 @@ func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, erro ORDER BY sd.pct_change ASC `, date).Scan(&summary.LimitDownList) - // 100-day new high: pre-aggregate max_close with covering index + // 百日新高: 当日收盘价等于近 150 个交易日最高收盘价的股票 r.db.Raw(` SELECT t.code, si.name, t.pct_change, t.close FROM ( @@ -118,6 +126,7 @@ func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, erro return summary, nil } +// GetHigh100 获取最新交易日创百日新高的股票列表 func (r *StockRepo) GetHigh100() ([]model.StockRank, error) { date, err := r.getLatestDate() if err != nil { @@ -143,6 +152,8 @@ func (r *StockRepo) GetHigh100() ([]model.StockRank, error) { return list, nil } +// GetDailySummary 获取近 N 天的每日市场统计 +// 包含: 涨跌数量、成交额、涨停跌停数量、连板数量 func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) { latest, err := r.getLatestDate() if err != nil { @@ -169,7 +180,7 @@ func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) { ORDER BY date ASC `, latest, days-1).Scan(&result) - // Consecutive limit-up: get trading date pairs, then batch query + // 计算连板数量: 获取相邻交易日对,批量查询当日和前一日都涨停的股票数量 type datePair struct { CurDate string `gorm:"column:cur_date"` PrevDate string `gorm:"column:prev_date"` @@ -188,7 +199,7 @@ func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) { } var consecResults []consecRow if len(pairs) > 0 { - // Use UNION ALL for better index utilization than OR + // 使用 UNION ALL 批量查询,利用索引优于 OR 条件 unionSQL := "" args := []interface{}{} for _, p := range pairs { @@ -201,6 +212,7 @@ func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) { r.db.Raw(unionSQL, args...).Scan(&consecResults) } + // 将连板数量映射到对应的日期 consecMap := make(map[string]int) for _, row := range consecResults { consecMap[row.Date] = row.Cnt @@ -214,6 +226,8 @@ func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) { return result, nil } +// GetConsecutiveLimits 获取最新交易日连板股票列表 +// 查询近 30 天的涨停记录,计算当日涨停股票的连续涨停天数 func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) { latest, err := r.getLatestDate() if err != nil { @@ -223,7 +237,7 @@ func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) { return nil, nil } - // Get recent trading dates with date range to use index + // 获取近 20 个交易日(覆盖 30 天范围) var dates []string r.db.Raw(`SELECT date FROM stock_daily WHERE date BETWEEN DATE_SUB(?, INTERVAL 30 DAY) AND ? GROUP BY date ORDER BY date DESC LIMIT 20`, latest, latest).Scan(&dates) if len(dates) == 0 { @@ -231,7 +245,7 @@ func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) { } earliest := dates[len(dates)-1] - // Get all limit-up records for recent dates + // 加载近期的全部涨停记录到内存,用 set 快速判断 type limitRow struct { Code string `gorm:"column:code"` D string `gorm:"column:date"` @@ -244,7 +258,7 @@ func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) { limitSet[row.Code+"_"+row.D[:10]] = true } - // Get today's limit-up stocks with info + // 获取当日涨停股票详情 type stockInfo struct { Code string `gorm:"column:code"` Name string `gorm:"column:name"` @@ -260,6 +274,7 @@ func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) { ORDER BY sd.pct_change DESC `, latest).Scan(&todayLimit) + // 逐只股票从最近交易日往前计算连续涨停天数 var result []model.ConsecutiveLimit for _, s := range todayLimit { streak := 0 @@ -280,7 +295,7 @@ func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) { }) } - // Sort by streak desc + // 按连板天数降序排列 for i := 0; i < len(result); i++ { for j := i + 1; j < len(result); j++ { if result[j].Streak > result[i].Streak || (result[j].Streak == result[i].Streak && result[j].PctChange > result[i].PctChange) { @@ -292,6 +307,7 @@ func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) { return result, nil } +// GetIndexKline 获取指数 K 线数据(全部历史,来自 index_daily 表) func (r *StockRepo) GetIndexKline(code string, days int) ([]model.IndexKline, error) { var result []model.IndexKline r.db.Raw(` @@ -302,3 +318,22 @@ func (r *StockRepo) GetIndexKline(code string, days int) ([]model.IndexKline, er `, code).Scan(&result) return result, nil } + +// GetStockKline 获取个股 K 线数据(全部历史,来自 stock_daily 表) +func (r *StockRepo) GetStockKline(code string) ([]model.IndexKline, error) { + var result []model.IndexKline + r.db.Raw(` + SELECT date, open, close, high, low, volume, turnover as amount, pct_change + FROM stock_daily + WHERE code = ? + ORDER BY date ASC + `, code).Scan(&result) + return result, nil +} + +// GetStockName 根据 code 查询股票名称 +func (r *StockRepo) GetStockName(code string) (string, error) { + var name string + r.db.Raw(`SELECT name FROM stock_info WHERE code = ?`, code).Scan(&name) + return name, nil +} diff --git a/server/router/router.go b/server/router/router.go index 7de3343..0122629 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -1,3 +1,4 @@ +// Package router 路由配置,注册 API 路由和前端静态文件服务 package router import ( @@ -9,20 +10,26 @@ import ( "github.com/gin-gonic/gin" ) +// Setup 注册所有路由 +// - /api/* : 后端 API 接口 +// - /assets/*, /vite.svg : 前端静态资源 +// - 其余路径: SPA fallback,返回 index.html func Setup(r *gin.Engine, dh *handler.DashboardHandler) { + // 后端 API 路由组 api := r.Group("/api") { - api.GET("/dashboard/summary", dh.GetSummary) - api.GET("/dashboard/daily", dh.GetDailySummary) - api.GET("/dashboard/consecutive", dh.GetConsecutiveLimits) - api.GET("/dashboard/high100", dh.GetHigh100) - api.GET("/dashboard/index-kline", dh.GetIndexKline) + api.GET("/dashboard/summary", dh.GetSummary) // 市场概览 + api.GET("/dashboard/daily", dh.GetDailySummary) // 每日统计 + api.GET("/dashboard/consecutive", dh.GetConsecutiveLimits) // 连板统计 + api.GET("/dashboard/high100", dh.GetHigh100) // 百日新高 + api.GET("/dashboard/index-kline", dh.GetIndexKline) // 指数K线 + api.GET("/dashboard/stock-kline", dh.GetStockKline) // 个股K线 } - // Serve frontend static files + // 前端静态文件服务 staticDir := "static" if _, err := os.Stat(staticDir); os.IsNotExist(err) { - // Try relative to executable + // 当前目录不存在时,尝试可执行文件所在目录 exePath, _ := os.Executable() staticDir = filepath.Join(filepath.Dir(exePath), "static") } @@ -30,7 +37,7 @@ func Setup(r *gin.Engine, dh *handler.DashboardHandler) { r.Static("/assets", filepath.Join(staticDir, "assets")) r.StaticFile("/vite.svg", filepath.Join(staticDir, "vite.svg")) - // SPA fallback: all non-API routes serve index.html + // SPA fallback: 非 API 路径全部返回 index.html,支持前端路由 r.NoRoute(func(c *gin.Context) { path := c.Request.URL.Path if len(path) < 4 || path[:4] != "/api" { diff --git a/server/service/dashboard_svc.go b/server/service/dashboard_svc.go index 4410a56..776931f 100644 --- a/server/service/dashboard_svc.go +++ b/server/service/dashboard_svc.go @@ -1,3 +1,8 @@ +// Package service 业务逻辑层,封装数据查询并提供带 TTL 的内存缓存 +// +// 缓存策略: 使用读写锁 + 泛型缓存条目,所有查询先检查缓存是否过期, +// 未过期则直接返回缓存数据,否则从 Repository 层重新加载。 +// 缓存过期时间为 3 分钟,适合A股数据的更新频率(每日收盘后更新)。 package service import ( @@ -7,19 +12,21 @@ import ( "time" ) +// cacheEntry 带过期时间的泛型缓存条目 type cacheEntry[T any] struct { data T expires time.Time } +// DashboardService 市场数据服务,包含各查询的独立缓存 type DashboardService struct { repo *repository.StockRepo - mu sync.RWMutex - summary *cacheEntry[*model.DashboardSummary] - daily map[int]*cacheEntry[[]model.DailySummary] - consec *cacheEntry[[]model.ConsecutiveLimit] - high100 *cacheEntry[[]model.StockRank] - indexKline map[string]*cacheEntry[[]model.IndexKline] + mu sync.RWMutex // 保护所有缓存字段的读写锁 + summary *cacheEntry[*model.DashboardSummary] // 市场概览缓存 + daily map[int]*cacheEntry[[]model.DailySummary] // 每日统计缓存,按天数分组 + consec *cacheEntry[[]model.ConsecutiveLimit] // 连板统计缓存 + high100 *cacheEntry[[]model.StockRank] // 百日新高缓存 + indexKline map[string]*cacheEntry[[]model.IndexKline] // K线缓存,指数用 code,个股用 "stock:"+code } func NewDashboardService(repo *repository.StockRepo) *DashboardService { @@ -28,6 +35,7 @@ func NewDashboardService(repo *repository.StockRepo) *DashboardService { const cacheTTL = 3 * time.Minute +// GetSummary 获取最新交易日市场概览(涨跌数量、涨停跌停、百日新高等) func (s *DashboardService) GetSummary() (*model.DashboardSummary, error) { s.mu.RLock() if s.summary != nil && time.Now().Before(s.summary.expires) { @@ -53,6 +61,7 @@ func (s *DashboardService) GetSummary() (*model.DashboardSummary, error) { return summary, nil } +// GetDailySummary 获取近 N 天每日市场统计 func (s *DashboardService) GetDailySummary(days int) ([]model.DailySummary, error) { s.mu.RLock() if e, ok := s.daily[days]; ok && time.Now().Before(e.expires) { @@ -74,6 +83,7 @@ func (s *DashboardService) GetDailySummary(days int) ([]model.DailySummary, erro return data, nil } +// GetConsecutiveLimits 获取最新交易日连板股票(连续多日涨停) func (s *DashboardService) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) { s.mu.RLock() if s.consec != nil && time.Now().Before(s.consec.expires) { @@ -95,6 +105,7 @@ func (s *DashboardService) GetConsecutiveLimits() ([]model.ConsecutiveLimit, err return data, nil } +// GetHigh100 获取最新交易日创百日新高的股票列表 func (s *DashboardService) GetHigh100() ([]model.StockRank, error) { s.mu.RLock() if s.high100 != nil && time.Now().Before(s.high100.expires) { @@ -116,6 +127,7 @@ func (s *DashboardService) GetHigh100() ([]model.StockRank, error) { return data, nil } +// GetIndexKline 获取指数 K 线数据(全部历史) func (s *DashboardService) GetIndexKline(code string, days int) ([]model.IndexKline, error) { s.mu.RLock() if e, ok := s.indexKline[code]; ok && time.Now().Before(e.expires) { @@ -139,3 +151,38 @@ func (s *DashboardService) GetIndexKline(code string, days int) ([]model.IndexKl return data, nil } + +// StockKlineResult 个股 K 线查询结果,包含股票名称和 K 线数据 +type StockKlineResult struct { + Name string `json:"name"` // 股票名称 + Data []model.IndexKline `json:"data"` // K 线数据列表 +} + +// GetStockKline 获取个股 K 线数据(全部历史),缓存 key 为 "stock:"+code +func (s *DashboardService) GetStockKline(code string) (*StockKlineResult, error) { + cacheKey := "stock:" + code + + s.mu.RLock() + if e, ok := s.indexKline[cacheKey]; ok && time.Now().Before(e.expires) { + data := e.data + s.mu.RUnlock() + name, _ := s.repo.GetStockName(code) + return &StockKlineResult{Name: name, Data: data}, nil + } + s.mu.RUnlock() + + data, err := s.repo.GetStockKline(code) + if err != nil { + return nil, err + } + + s.mu.Lock() + if s.indexKline == nil { + s.indexKline = make(map[string]*cacheEntry[[]model.IndexKline]) + } + s.indexKline[cacheKey] = &cacheEntry[[]model.IndexKline]{data: data, expires: time.Now().Add(cacheTTL)} + s.mu.Unlock() + + name, _ := s.repo.GetStockName(code) + return &StockKlineResult{Name: name, Data: data}, nil +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 5eaf476..762d9e1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,3 +1,8 @@ +/** + * 应用入口组件 + * 使用 Ant Design Layout + react-router-dom 构建单页应用 + * 顶部导航栏切换各功能页面 + */ import { ConfigProvider, Layout, Menu } from 'antd'; import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom'; @@ -7,6 +12,7 @@ import { FundOutlined, LineChartOutlined, RiseOutlined, + StockOutlined, ThunderboltOutlined, } from '@ant-design/icons'; import Dashboard from './pages/Dashboard'; @@ -14,17 +20,21 @@ import TrendPage from './pages/TrendPage'; import ConsecutivePage from './pages/ConsecutivePage'; import High100Page from './pages/High100Page'; import IndexPage from './pages/IndexPage'; +import OverlayPage from './pages/OverlayPage'; const { Header, Content } = Layout; +/** 顶部导航菜单配置 */ const menuItems = [ { key: '/', icon: , label: '市场概览' }, { key: '/index', icon: , label: '指数行情' }, + { key: '/overlay', icon: , label: 'K线叠加' }, { key: '/high100', icon: , label: '百日新高' }, { key: '/consecutive', icon: , label: '连板高度' }, { key: '/trend', icon: , label: '每日趋势' }, ]; +/** 布局组件: 顶部导航 + 内容区 */ function AppLayout() { const navigate = useNavigate(); const location = useLocation(); @@ -48,6 +58,7 @@ function AppLayout() { } /> } /> + } /> } /> } /> } /> @@ -57,6 +68,7 @@ function AppLayout() { ); } +/** 根组件: Ant Design 中文国际化 + BrowserRouter */ function App() { return ( diff --git a/web/src/api/dashboard.ts b/web/src/api/dashboard.ts index 48c418d..ad2fca0 100644 --- a/web/src/api/dashboard.ts +++ b/web/src/api/dashboard.ts @@ -1,9 +1,14 @@ +/** + * 后端 API 接口封装 + * 所有接口基于 /api 前缀,返回数据格式为 { data: T } + */ import axios from 'axios'; const api = axios.create({ baseURL: '/api', }); +/** 股票排行项(涨停、跌停、百日新高共用) */ export interface StockRank { code: string; name: string; @@ -11,18 +16,20 @@ export interface StockRank { close: number; } +/** 市场概览数据 */ export interface DashboardSummary { - total_stocks: number; - up_count: number; - down_count: number; - flat_count: number; - total_turnover: number; - avg_pct_change: number; - limit_up_list: StockRank[]; - limit_down_list: StockRank[]; - high_100_list: StockRank[]; + total_stocks: number; // 当日交易股票总数 + up_count: number; // 上涨数量 + down_count: number; // 下跌数量 + flat_count: number; // 平盘数量 + total_turnover: number; // 总成交额 + avg_pct_change: number; // 平均涨跌幅 + limit_up_list: StockRank[]; // 涨停股票列表 + limit_down_list: StockRank[]; // 跌停股票列表 + high_100_list: StockRank[]; // 百日新高股票列表 } +/** 每日市场统计 */ export interface DailySummary { date: string; total_stocks: number; @@ -31,41 +38,47 @@ export interface DailySummary { flat_count: number; total_turnover: number; avg_pct_change: number; - limit_up_10: number; - limit_down_10: number; - limit_up_20: number; - limit_down_20: number; - consecutive_limit_count: number; + limit_up_10: number; // 主板涨停数量 + limit_down_10: number; // 主板跌停数量 + limit_up_20: number; // 创业板/科创板涨停数量 + limit_down_20: number; // 创业板/科创板跌停数量 + consecutive_limit_count: number; // 连板股票数量 } +/** 获取市场概览数据 */ export async function getDashboardSummary(): Promise { const res = await api.get('/dashboard/summary'); return res.data.data; } +/** 获取近 N 天每日市场统计 */ export async function getDailySummary(days = 30): Promise { const res = await api.get('/dashboard/daily', { params: { days } }); return res.data.data; } +/** 连板统计项 */ export interface ConsecutiveLimit { code: string; name: string; - streak: number; + streak: number; // 连续涨停天数 close: number; pct_change: number; } +/** 获取连板股票统计 */ export async function getConsecutiveLimits(): Promise { const res = await api.get('/dashboard/consecutive'); return res.data.data; } +/** 获取百日新高股票列表 */ export async function getHigh100(): Promise { const res = await api.get('/dashboard/high100'); return res.data.data; } +/** K 线数据项(指数和个股通用) */ export interface IndexKline { date: string; open: number; @@ -77,7 +90,20 @@ export interface IndexKline { pct_change: number; } +/** 获取指数 K 线数据 */ export async function getIndexKline(code = '000001', days = 60): Promise { const res = await api.get('/dashboard/index-kline', { params: { code, days } }); return res.data.data; } + +/** 个股 K 线查询结果 */ +export interface StockKlineResult { + name: string; + data: IndexKline[]; +} + +/** 获取个股 K 线数据(全部历史) */ +export async function getStockKline(code: string): Promise { + const res = await api.get('/dashboard/stock-kline', { params: { code } }); + return res.data.data; +} diff --git a/web/src/components/IndexKlineChart.tsx b/web/src/components/IndexKlineChart.tsx new file mode 100644 index 0000000..25cf33e --- /dev/null +++ b/web/src/components/IndexKlineChart.tsx @@ -0,0 +1,124 @@ +/** + * 指数 K 线图组件 + * 使用 ECharts 渲染蜡烛图 + 成交量柱状图 + MA 均线 + * 支持鼠标滚轮缩放和拖拽平移 + */ +import { useEffect, useRef } from 'react'; +import * as echarts from 'echarts'; +import type { IndexKline } from '../api/dashboard'; + +interface Props { + data: IndexKline[]; + title?: string; +} + +/** 计算 N 日简单移动平均线 */ +function ma(data: number[], n: number): (number | null)[] { + return data.map((_, i) => (i >= n - 1 ? data.slice(i - n + 1, i + 1).reduce((a, b) => a + b, 0) / n : null)); +} + +export default function IndexKlineChart({ data, title = '上证指数' }: Props) { + const ref = useRef(null); + const chartRef = useRef(null); + + useEffect(() => { + if (!ref.current || data.length === 0) return; + if (!chartRef.current) chartRef.current = echarts.init(ref.current); + const chart = chartRef.current; + + // 准备数据 + const dates = data.map((d) => d.date.slice(0, 10)); + const ohlc = data.map((d) => [d.open, d.close, d.low, d.high]); + const volumes = data.map((d) => d.volume); + const closes = data.map((d) => d.close); + const ma5 = ma(closes, 5); + const ma10 = ma(closes, 10); + const ma20 = ma(closes, 20); + + // 双面板布局: 上方 K 线图(55%),下方成交量(18%) + chart.setOption({ + tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, + legend: { top: 0, data: ['K线', 'MA5', 'MA10', 'MA20'] }, + grid: [ + { left: 70, right: 30, top: 40, height: '55%' }, // K 线面板 + { left: 70, right: 30, top: '74%', height: '18%' }, // 成交量面板 + ], + xAxis: [ + { type: 'category', data: dates, gridIndex: 0, boundaryGap: true, axisLabel: { show: false } }, + { type: 'category', data: dates, gridIndex: 1, boundaryGap: true }, + ], + yAxis: [ + { type: 'value', gridIndex: 0, scale: true, splitArea: { show: true } }, + { type: 'value', gridIndex: 1, splitLine: { show: false } }, + ], + // 默认显示最近 90 天,支持滚轮缩放 + dataZoom: [ + { type: 'inside', xAxisIndex: [0, 1], startValue: dates.length - 90, endValue: dates.length - 1 }, + ], + series: [ + // K 线蜡烛图: 红(涨) 绿(跌) + { + name: 'K线', + type: 'candlestick', + xAxisIndex: 0, + yAxisIndex: 0, + data: ohlc, + itemStyle: { + color: '#ef5350', + color0: '#26a69a', + borderColor: '#ef5350', + borderColor0: '#26a69a', + }, + }, + // MA 均线叠加在 K 线面板上 + { + name: 'MA5', + type: 'line', + xAxisIndex: 0, + yAxisIndex: 0, + data: ma5, + smooth: true, + lineStyle: { width: 1 }, + symbol: 'none', + }, + { + name: 'MA10', + type: 'line', + xAxisIndex: 0, + yAxisIndex: 0, + data: ma10, + smooth: true, + lineStyle: { width: 1 }, + symbol: 'none', + }, + { + name: 'MA20', + type: 'line', + xAxisIndex: 0, + yAxisIndex: 0, + data: ma20, + smooth: true, + lineStyle: { width: 1 }, + symbol: 'none', + }, + // 成交量柱状图,颜色跟随涨跌 + { + name: '成交量', + type: 'bar', + xAxisIndex: 1, + yAxisIndex: 1, + data: volumes.map((v, i) => ({ + value: v, + itemStyle: { color: data[i].close >= data[i].open ? '#ef5350' : '#26a69a' }, + })), + }, + ], + }, true); + + const handleResize = () => chart.resize(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [data, title]); + + return
; +} diff --git a/web/src/components/OverlayKlineChart.tsx b/web/src/components/OverlayKlineChart.tsx new file mode 100644 index 0000000..b2a1a65 --- /dev/null +++ b/web/src/components/OverlayKlineChart.tsx @@ -0,0 +1,162 @@ +/** + * K 线叠加对比图组件 + * 多只股票的 K 线蜡烛图叠加在同一个面板上,共享 x 轴 + * 每只股票有独立的对数 y 轴,支持通过图例切换显示/隐藏 + */ +import { useEffect, useRef } from 'react'; +import * as echarts from 'echarts'; +import type { IndexKline } from '../api/dashboard'; + +/** 单只股票的数据系列 */ +interface StockSeries { + code: string; + name: string; + data: IndexKline[]; +} + +interface Props { + series: StockSeries[]; +} + +// 不同股票使用不同颜色区分 +const COLOR_PAIRS = [ + { up: '#ef5350', down: '#26a69a' }, + { up: '#5470c6', down: '#73c0de' }, + { up: '#91cc75', down: '#b8d4a3' }, + { up: '#fac858', down: '#ffd97d' }, + { up: '#fc8452', down: '#ffa97d' }, + { up: '#9a60b4', down: '#b88cc8' }, + { up: '#ea7ccc', down: '#f0a0dd' }, + { up: '#3ba272', down: '#6dc7a0' }, +]; + +export default function OverlayKlineChart({ series }: Props) { + const ref = useRef(null); + const chartRef = useRef(null); + + useEffect(() => { + if (!ref.current) return; + + if (series.length === 0) { + if (chartRef.current) { + chartRef.current.clear(); + } + return; + } + + if (!chartRef.current) { + chartRef.current = echarts.init(ref.current); + } + const chart = chartRef.current; + + // 取所有股票日期的交集 + let startDate = series[0].data[0]?.date.slice(0, 10) || ''; + let endDate = series[0].data[series[0].data.length - 1]?.date.slice(0, 10) || ''; + for (const s of series) { + const first = s.data[0]?.date.slice(0, 10) || ''; + const last = s.data[s.data.length - 1]?.date.slice(0, 10) || ''; + if (first > startDate) startDate = first; + if (last < endDate) endDate = last; + } + + const dateSet = new Set(); + for (const s of series) { + for (const d of s.data) { + const ds = d.date.slice(0, 10); + if (ds >= startDate && ds <= endDate) dateSet.add(ds); + } + } + const baseDates = [...dateSet].sort(); + + const yAxes: any[] = []; + const echartsSeries: any[] = []; + + // 只用最近 90 天的数据计算 y 轴范围,让 K 线更饱满 + const visibleStart = baseDates.length > 90 ? baseDates[baseDates.length - 90] : baseDates[0]; + + series.forEach((s, idx) => { + const name = `${s.name}(${s.code})`; + const colors = COLOR_PAIRS[idx % COLOR_PAIRS.length]; + + // 对数 y 轴: 第一只在左侧,其余在右侧,范围取可见区间 + let min = Infinity, max = -Infinity; + for (const d of s.data) { + const ds = d.date.slice(0, 10); + if (ds >= visibleStart) { + if (d.low < min) min = d.low; + if (d.high > max) max = d.high; + } + } + yAxes.push({ + type: 'log', + min: min * 0.95, + max: max * 1.05, + logBase: 10, + position: idx === 0 ? 'left' : 'right', + offset: idx <= 1 ? 0 : (idx - 1) * 50, + name: name, + nameTextStyle: { fontSize: 11 }, + axisLabel: { fontSize: 11 }, + splitLine: { show: idx === 0 }, + show: idx < 4, + }); + + // 将股票数据映射到基准日期 + const dateMap = new Map(s.data.map((d) => [d.date.slice(0, 10), d])); + const klineData = baseDates.map((date) => { + const d = dateMap.get(date); + if (!d) return '-'; + return [d.open, d.close, d.low, d.high]; + }); + + echartsSeries.push({ + name, + type: 'candlestick', + data: klineData, + yAxisIndex: idx, + itemStyle: { + color: colors.up, + color0: colors.down, + borderColor: colors.up, + borderColor0: colors.down, + }, + }); + }); + + // 右侧预留空间随股票数量增加 + const rightSpace = series.length <= 1 ? 30 : Math.min(30 + (series.length - 1) * 50, 250); + + chart.setOption({ + tooltip: { + trigger: 'axis', + axisPointer: { type: 'cross' }, + }, + legend: { + top: 0, + data: series.map((s) => `${s.name}(${s.code})`), + }, + grid: { left: 70, right: rightSpace, top: 30, bottom: 70 }, + xAxis: { type: 'category', data: baseDates, boundaryGap: true }, + yAxis: yAxes, + dataZoom: [ + { type: 'inside', startValue: baseDates.length - 90, endValue: baseDates.length - 1 }, + { type: 'slider', startValue: baseDates.length - 90, endValue: baseDates.length - 1, bottom: 10 }, + ], + series: echartsSeries, + }, true); + + const handleResize = () => chart.resize(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [series]); + + return ( +
+ {series.length === 0 && ( +
+ 请输入股票代码添加对比 +
+ )} +
+ ); +} diff --git a/web/src/pages/IndexPage.tsx b/web/src/pages/IndexPage.tsx new file mode 100644 index 0000000..419d7ff --- /dev/null +++ b/web/src/pages/IndexPage.tsx @@ -0,0 +1,92 @@ +/** + * 指数行情页面 + * 左侧显示指数列表(上证指数、深证成指等),右侧显示选中指数的 K 线图 + */ +import { useEffect, useState } from 'react'; +import { Card, List, Spin, Typography } from 'antd'; +import { getIndexKline, type IndexKline } from '../api/dashboard'; +import IndexKlineChart from '../components/IndexKlineChart'; + +const { Title, Text } = Typography; + +/** 支持的指数列表 */ +const INDICES = [ + { code: '000001', name: '上证指数' }, + { code: '399001', name: '深证成指' }, + { code: '000300', name: '沪深300' }, + { code: '000905', name: '中证500' }, + { code: '000852', name: '中证1000' }, + { code: '399006', name: '创业板指' }, +]; + +export default function IndexPage() { + const [selected, setSelected] = useState('000001'); + const [kline, setKline] = useState([]); + const [loading, setLoading] = useState(true); + + // 切换指数时重新加载 K 线数据 + useEffect(() => { + setLoading(true); + getIndexKline(selected, 120) + .then(setKline) + .finally(() => setLoading(false)); + }, [selected]); + + const latest = kline.length > 0 ? kline[kline.length - 1] : null; + + return ( +
+ + 指数行情 + + +
+ {/* 左侧指数列表 */} + + ( + setSelected(item.code)} + style={{ + cursor: 'pointer', + padding: '8px 12px', + background: item.code === selected ? '#e6f7ff' : undefined, + borderRadius: 4, + }} + > + {item.name} + + )} + /> + + + {/* 右侧 K 线图 */} + i.code === selected)?.name} + size="small" + > + {/* 最新收盘价和涨跌幅 */} + {latest && ( +
+ {latest.close.toFixed(2)} + = 0 ? '#cf1322' : '#3f8600' }} + > + {latest.pct_change >= 0 ? '+' : ''}{latest.pct_change.toFixed(2)}% + +
+ )} + {loading ? ( +
+ +
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/web/src/pages/OverlayPage.tsx b/web/src/pages/OverlayPage.tsx new file mode 100644 index 0000000..af3a5f4 --- /dev/null +++ b/web/src/pages/OverlayPage.tsx @@ -0,0 +1,105 @@ +/** + * K 线叠加对比页面 + * 用户输入股票代码,添加多只股票到图表中叠加对比 + * 最多支持 8 只股票,每只股票以独立面板显示 K 线蜡烛图 + */ +import { useState, useCallback } from 'react'; +import { Card, Input, Tag, Spin, message } from 'antd'; +import { getStockKline, type IndexKline } from '../api/dashboard'; +import OverlayKlineChart from '../components/OverlayKlineChart'; + +/** 单只股票的加载状态和数据 */ +interface StockEntry { + code: string; + name: string; + data: IndexKline[]; + loading: boolean; +} + +export default function OverlayPage() { + const [stocks, setStocks] = useState([]); + const [input, setInput] = useState(''); + + /** 添加股票: 输入代码回车后触发,先加入 loading 状态,异步加载数据后更新 */ + const addStock = useCallback(async (code: string) => { + const trimmed = code.trim(); + if (!trimmed) return; + if (stocks.some((s) => s.code === trimmed)) { + message.warning(`${trimmed} 已添加`); + return; + } + if (stocks.length >= 8) { + message.warning('最多叠加8只'); + return; + } + + // 先加入 loading 占位 + const entry: StockEntry = { code: trimmed, name: trimmed, data: [], loading: true }; + setStocks((prev) => [...prev, entry]); + + try { + const res = await getStockKline(trimmed); + if (!res.data || res.data.length === 0) { + // 无数据时移除该条目 + setStocks((prev) => prev.filter((s) => s.code !== trimmed)); + message.error(trimmed + ' 无数据'); + return; + } + // 更新为已加载状态 + setStocks((prev) => + prev.map((s) => (s.code === trimmed ? { ...s, name: res.name || trimmed, data: res.data, loading: false } : s)) + ); + } catch { + // 查询失败时移除该条目 + setStocks((prev) => prev.filter((s) => s.code !== trimmed)); + message.error(`${trimmed} 查询失败`); + } + }, [stocks]); + + /** 移除股票 */ + const removeStock = useCallback((code: string) => { + setStocks((prev) => prev.filter((s) => s.code !== code)); + }, []); + + /** 回车添加 */ + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + addStock(input); + setInput(''); + } + }; + + // 过滤掉仍在加载中的股票,只传已就绪的数据给图表 + const readyStocks = stocks.filter((s) => !s.loading && s.data.length > 0); + + return ( +
+ {/* 输入栏 + 股票标签 */} + + setInput(e.target.value)} + onKeyDown={handleKeyDown} + style={{ width: 300, marginRight: 16 }} + /> + {stocks.map((s) => ( + removeStock(s.code)} + color={s.loading ? 'processing' : 'blue'} + style={{ marginBottom: 4 }} + > + {s.name}({s.code}) {s.loading && } + + ))} + + + {/* K 线叠加图表,直接填满剩余空间 */} +
+ +
+
+ ); +}