叠加显示

This commit is contained in:
曾志威
2026-05-11 01:31:28 +08:00
parent 94a59392c9
commit 080664e63c
13 changed files with 768 additions and 103 deletions
+18 -10
View File
@@ -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
+28
View File
@@ -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})
}
+7 -1
View File
@@ -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)
+66 -53
View File
@@ -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"` // 涨跌幅(%)
}
+45 -10
View File
@@ -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
}
+15 -8
View File
@@ -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" {
+53 -6
View File
@@ -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
}
+12
View File
@@ -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: <DashboardOutlined />, label: '市场概览' },
{ key: '/index', icon: <FundOutlined />, label: '指数行情' },
{ key: '/overlay', icon: <StockOutlined />, label: 'K线叠加' },
{ key: '/high100', icon: <RiseOutlined />, label: '百日新高' },
{ key: '/consecutive', icon: <ThunderboltOutlined />, label: '连板高度' },
{ key: '/trend', icon: <LineChartOutlined />, label: '每日趋势' },
];
/** 布局组件: 顶部导航 + 内容区 */
function AppLayout() {
const navigate = useNavigate();
const location = useLocation();
@@ -48,6 +58,7 @@ function AppLayout() {
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/index" element={<IndexPage />} />
<Route path="/overlay" element={<OverlayPage />} />
<Route path="/high100" element={<High100Page />} />
<Route path="/consecutive" element={<ConsecutivePage />} />
<Route path="/trend" element={<TrendPage />} />
@@ -57,6 +68,7 @@ function AppLayout() {
);
}
/** 根组件: Ant Design 中文国际化 + BrowserRouter */
function App() {
return (
<ConfigProvider locale={zhCN}>
+41 -15
View File
@@ -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<DashboardSummary> {
const res = await api.get('/dashboard/summary');
return res.data.data;
}
/** 获取近 N 天每日市场统计 */
export async function getDailySummary(days = 30): Promise<DailySummary[]> {
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<ConsecutiveLimit[]> {
const res = await api.get('/dashboard/consecutive');
return res.data.data;
}
/** 获取百日新高股票列表 */
export async function getHigh100(): Promise<StockRank[]> {
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<IndexKline[]> {
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<StockKlineResult> {
const res = await api.get('/dashboard/stock-kline', { params: { code } });
return res.data.data;
}
+124
View File
@@ -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<HTMLDivElement>(null);
const chartRef = useRef<echarts.ECharts | null>(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 <div ref={ref} style={{ height: 400 }} />;
}
+162
View File
@@ -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<HTMLDivElement>(null);
const chartRef = useRef<echarts.ECharts | null>(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<string>();
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 (
<div ref={ref} style={{ width: '100%', height: '100%' }}>
{series.length === 0 && (
<div style={{ textAlign: 'center', padding: 60, color: '#999', position: 'absolute', width: '100%' }}>
</div>
)}
</div>
);
}
+92
View File
@@ -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<IndexKline[]>([]);
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 (
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
<Title level={2} style={{ textAlign: 'center', marginBottom: 24 }}>
</Title>
<div style={{ display: 'grid', gridTemplateColumns: '160px 1fr', gap: 16 }}>
{/* 左侧指数列表 */}
<Card size="small" style={{ padding: 0 }}>
<List
size="small"
dataSource={INDICES}
renderItem={(item) => (
<List.Item
onClick={() => setSelected(item.code)}
style={{
cursor: 'pointer',
padding: '8px 12px',
background: item.code === selected ? '#e6f7ff' : undefined,
borderRadius: 4,
}}
>
<Text strong={item.code === selected}>{item.name}</Text>
</List.Item>
)}
/>
</Card>
{/* 右侧 K 线图 */}
<Card
title={INDICES.find((i) => i.code === selected)?.name}
size="small"
>
{/* 最新收盘价和涨跌幅 */}
{latest && (
<div style={{ marginBottom: 8, fontSize: 16 }}>
<Text strong style={{ fontSize: 22 }}>{latest.close.toFixed(2)}</Text>
<Text
style={{ marginLeft: 12, color: latest.pct_change >= 0 ? '#cf1322' : '#3f8600' }}
>
{latest.pct_change >= 0 ? '+' : ''}{latest.pct_change.toFixed(2)}%
</Text>
</div>
)}
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}>
<Spin size="large" />
</div>
) : (
<IndexKlineChart data={kline} />
)}
</Card>
</div>
</div>
);
}
+105
View File
@@ -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<StockEntry[]>([]);
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 (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* 输入栏 + 股票标签 */}
<Card size="small" style={{ marginBottom: 8, flexShrink: 0 }}>
<Input
placeholder="输入股票代码,回车添加(如 000001)"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
style={{ width: 300, marginRight: 16 }}
/>
{stocks.map((s) => (
<Tag
key={s.code}
closable
onClose={() => removeStock(s.code)}
color={s.loading ? 'processing' : 'blue'}
style={{ marginBottom: 4 }}
>
{s.name}({s.code}) {s.loading && <Spin size="small" />}
</Tag>
))}
</Card>
{/* K 线叠加图表,直接填满剩余空间 */}
<div style={{ flex: 1, minHeight: 0 }}>
<OverlayKlineChart series={readyStocks} />
</div>
</div>
);
}