init
continuous-integration/drone Build is failing

This commit is contained in:
曾志威
2026-05-10 19:23:04 +08:00
parent 1d3f0bda32
commit 9f9dd1275a
27 changed files with 1298 additions and 146 deletions
+18
View File
@@ -0,0 +1,18 @@
kind: pipeline
name: default
steps:
# docker 打包
- name: docker
image: plugins/docker
settings:
context: ./
dockerfile: ./Dockerfile
username:
from_secret: docker_username
password:
from_secret: docker_password
registry: https://registry.cn-hangzhou.aliyuncs.com
repo: registry.cn-hangzhou.aliyuncs.com/hub_z/ashareview
tags:
- latest
+10
View File
@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ClaudeCodeTabState">
<option name="tabSessions">
<map>
<entry key="0">
<value>
<TabSessionState>
<option name="provider" value="claude" />
<option name="sessionId" value="2695427f-f87e-45f6-80a4-bd1cdbcb850a" />
<option name="cwd" value="$PROJECT_DIR$" />
<option name="model" value="claude-opus-4-7[1m]" />
<option name="permissionMode" value="bypassPermissions" />
<option name="reasoningEffort" value="high" />
</TabSessionState>
</value>
</entry>
</map>
</option>
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/AShareView.iml" filepath="$PROJECT_DIR$/.idea/AShareView.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+26
View File
@@ -0,0 +1,26 @@
# ---- 前端构建 ----
FROM node:20-alpine AS web-builder
WORKDIR /app/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
# ---- 后端构建 ----
FROM golang:1.24-alpine AS go-builder
WORKDIR /app/server
COPY server/go.mod server/go.sum ./
RUN go mod download
COPY server/ ./
COPY --from=web-builder /app/web/dist ./static/
RUN CGO_ENABLED=0 GOOS=linux go build -o ashareview .
# ---- 运行镜像 ----
FROM alpine:3.20
RUN apk add --no-cache tzdata && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
WORKDIR /app
COPY --from=go-builder /app/server/ashareview .
COPY --from=go-builder /app/server/static ./static/
COPY server/config.yaml ./config.yaml
EXPOSE 8080
CMD ["./ashareview"]
+11
View File
@@ -0,0 +1,11 @@
services:
ashareview:
image: registry.cn-hangzhou.aliyuncs.com/hub_z/ashareview:latest
container_name: ashareview
restart: always
ports:
- "8080:8080"
volumes:
- ./server/config.yaml:/app/config.yaml
environment:
- TZ=Asia/Shanghai
+40 -21
View File
@@ -9,32 +9,51 @@ import (
)
func main() {
db, err := sql.Open("mysql", "root:ttx2011@tcp(db.freeicu.top:32000)/ashare?charset=utf8mb4")
db, err := sql.Open("mysql", "root:ttx2011@tcp(db.freeicu.top:32000)/ashare?charset=utf8mb4&parseTime=True&loc=Local")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Direct query to check limit counts
var lu10, ld10, lu20, ld20 int
db.QueryRow(`
SELECT
SUM(CASE WHEN pct_change >= 9.5 AND pct_change < 19.5 THEN 1 ELSE 0 END),
SUM(CASE WHEN pct_change <= -9.5 AND pct_change > -19.5 THEN 1 ELSE 0 END),
SUM(CASE WHEN pct_change >= 19.5 THEN 1 ELSE 0 END),
SUM(CASE WHEN pct_change <= -19.5 THEN 1 ELSE 0 END)
FROM stock_daily WHERE date = '2026-05-08'
`).Scan(&lu10, &ld10, &lu20, &ld20)
fmt.Printf("Direct SQL: limit_up_10=%d, limit_down_10=%d, limit_up_20=%d, limit_down_20=%d\n", lu10, ld10, lu20, ld20)
// Test 1: dates from trading_day
var dates []string
rows, _ := db.Query("SELECT DATE(date) as d FROM trading_day ORDER BY date DESC LIMIT 3")
for rows.Next() {
var d string
rows.Scan(&d)
dates = append(dates, d)
fmt.Printf("date: [%s]\n", d)
}
rows.Close()
// Count with broader threshold
if len(dates) == 0 {
fmt.Println("NO DATES!")
return
}
latest := dates[0]
fmt.Printf("latest: [%s]\n", latest)
// Test 2: today's limit-up count
var cnt int
db.QueryRow(`SELECT COUNT(*) FROM stock_daily WHERE date = '2026-05-08' AND pct_change >= 9.0`).Scan(&cnt)
fmt.Printf("pct >= 9.0: %d\n", cnt)
db.QueryRow(`SELECT COUNT(*) FROM stock_daily WHERE date = '2026-05-08' AND pct_change >= 19.0`).Scan(&cnt)
fmt.Printf("pct >= 19.0: %d\n", cnt)
db.QueryRow(`SELECT COUNT(*) FROM stock_daily WHERE date = '2026-05-08' AND pct_change <= -9.0`).Scan(&cnt)
fmt.Printf("pct <= -9.0: %d\n", cnt)
db.QueryRow(`SELECT COUNT(*) FROM stock_daily WHERE date = '2026-05-08' AND pct_change <= -19.0`).Scan(&cnt)
fmt.Printf("pct <= -19.0: %d\n", cnt)
db.QueryRow("SELECT COUNT(*) FROM stock_daily WHERE date = ? AND pct_change >= 9.5", latest).Scan(&cnt)
fmt.Printf("limit-up count on %s: %d\n", latest, cnt)
// Test 3: with JOIN
var cnt2 int
db.QueryRow(`
SELECT COUNT(*)
FROM stock_daily sd
LEFT JOIN stock_info si ON sd.code = si.code
WHERE sd.date = ? AND sd.pct_change >= 9.5
`, latest).Scan(&cnt2)
fmt.Printf("limit-up with JOIN: %d\n", cnt2)
// Test 4: limit-up rows
rows2, _ := db.Query("SELECT code, DATE(date) as d FROM stock_daily WHERE pct_change >= 9.5 AND date >= ? LIMIT 5", dates[len(dates)-1])
for rows2.Next() {
var code, d string
rows2.Scan(&code, &d)
fmt.Printf(" limitRow: code=%s date=[%s]\n", code, d)
}
rows2.Close()
}
+12
View File
@@ -0,0 +1,12 @@
# MySQL 数据库连接配置
mysql:
host: "db.freeicu.top"
port: 32000
user: "root"
password: "ttx2011"
database: "ashare"
charset: "utf8mb4"
# 服务器配置
server:
port: "8080"
+64 -17
View File
@@ -3,39 +3,86 @@ package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type DBConfig struct {
Host string
Port string
User string
Password string
DBName string
Host string `yaml:"host"`
Port string `yaml:"port"`
User string `yaml:"user"`
Password string `yaml:"password"`
DBName string `yaml:"database"`
Charset string `yaml:"charset"`
}
type ServerConfig struct {
Port string `yaml:"port"`
}
type Config struct {
DB DBConfig
Server struct {
Port string
}
DB DBConfig `yaml:"mysql"`
Server ServerConfig `yaml:"server"`
}
func Load() *Config {
cfg := &Config{}
cfg.DB.Host = getEnv("DB_HOST", "db.freeicu.top")
cfg.DB.Port = getEnv("DB_PORT", "32000")
cfg.DB.User = getEnv("DB_USER", "root")
cfg.DB.Password = getEnv("DB_PASSWORD", "ttx2011")
cfg.DB.DBName = getEnv("DB_NAME", "ashare")
cfg.Server.Port = getEnv("SERVER_PORT", "8080")
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")
cfg.DB.User = getEnv("DB_USER", "root")
cfg.DB.Password = getEnv("DB_PASSWORD", "")
cfg.DB.DBName = getEnv("DB_NAME", "ashare")
cfg.DB.Charset = "utf8mb4"
cfg.Server.Port = getEnv("SERVER_PORT", "8080")
return cfg
}
if err := yaml.Unmarshal(data, cfg); err != nil {
fmt.Printf("parse config.yaml failed: %v, using env defaults\n", err)
cfg.DB.Host = getEnv("DB_HOST", "127.0.0.1")
cfg.DB.Port = getEnv("DB_PORT", "3306")
cfg.DB.User = getEnv("DB_USER", "root")
cfg.DB.Password = getEnv("DB_PASSWORD", "")
cfg.DB.DBName = getEnv("DB_NAME", "ashare")
cfg.DB.Charset = "utf8mb4"
cfg.Server.Port = getEnv("SERVER_PORT", "8080")
return cfg
}
// Environment variables override config file
if v := os.Getenv("DB_HOST"); v != "" {
cfg.DB.Host = v
}
if v := os.Getenv("DB_PORT"); v != "" {
cfg.DB.Port = v
}
if v := os.Getenv("DB_USER"); v != "" {
cfg.DB.User = v
}
if v := os.Getenv("DB_PASSWORD"); v != "" {
cfg.DB.Password = v
}
if v := os.Getenv("DB_NAME"); v != "" {
cfg.DB.DBName = v
}
if v := os.Getenv("SERVER_PORT"); v != "" {
cfg.Server.Port = v
}
return cfg
}
func (c *DBConfig) DSN() string {
return fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
c.User, c.Password, c.Host, c.Port, c.DBName)
charset := c.Charset
if charset == "" {
charset = "utf8mb4"
}
return fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=True&loc=Local",
c.User, c.Password, c.Host, c.Port, c.DBName, charset)
}
func getEnv(key, fallback string) string {
+2 -1
View File
@@ -4,6 +4,8 @@ go 1.26.2
require (
github.com/gin-gonic/gin v1.12.0
github.com/go-sql-driver/mysql v1.8.1
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.1
)
@@ -19,7 +21,6 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
+8
View File
@@ -42,6 +42,10 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -59,6 +63,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -92,6 +98,8 @@ golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+9
View File
@@ -37,3 +37,12 @@ func (h *DashboardHandler) GetDailySummary(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"data": data})
}
func (h *DashboardHandler) GetConsecutiveLimits(c *gin.Context) {
data, err := h.svc.GetConsecutiveLimits()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": data})
}
+11 -2
View File
@@ -58,6 +58,15 @@ type DashboardSummary struct {
FlatCount int64 `json:"flat_count"`
TotalTurnover float64 `json:"total_turnover"`
AvgPctChange float64 `json:"avg_pct_change"`
TopGainers []StockRank `json:"top_gainers"`
TopLosers []StockRank `json:"top_losers"`
LimitUpList []StockRank `json:"limit_up_list"`
LimitDownList []StockRank `json:"limit_down_list"`
High100List []StockRank `json:"high_100_list"`
}
type ConsecutiveLimit struct {
Code string `json:"code"`
Name string `json:"name"`
Streak int `json:"streak"`
Close float64 `json:"close"`
PctChange float64 `json:"pct_change"`
}
+102 -6
View File
@@ -57,18 +57,32 @@ func (r *StockRepo) GetSummaryByDate(date string) (*model.DashboardSummary, erro
r.db.Table("stock_daily as sd").
Select("sd.code, si.name, sd.pct_change, sd.close").
Joins("LEFT JOIN stock_info si ON sd.code = si.code").
Where("sd.date = ? AND sd.pct_change IS NOT NULL", date).
Where("sd.date = ? AND sd.pct_change >= 9.5", date).
Order("sd.pct_change DESC").
Limit(5).
Find(&summary.TopGainers)
Find(&summary.LimitUpList)
r.db.Table("stock_daily as sd").
Select("sd.code, si.name, sd.pct_change, sd.close").
Joins("LEFT JOIN stock_info si ON sd.code = si.code").
Where("sd.date = ? AND sd.pct_change IS NOT NULL", date).
Where("sd.date = ? AND sd.pct_change <= -9.5", date).
Order("sd.pct_change ASC").
Limit(5).
Find(&summary.TopLosers)
Find(&summary.LimitDownList)
r.db.Raw(`
SELECT sd.code, si.name, sd.pct_change, sd.close
FROM stock_daily sd
LEFT JOIN stock_info si ON sd.code = si.code
WHERE sd.date = ?
AND sd.close = (
SELECT MAX(sd2.close)
FROM stock_daily sd2
WHERE sd2.code = sd.code
AND sd2.date <= sd.date
AND sd2.date >= DATE_SUB(sd.date, INTERVAL 150 DAY)
)
AND sd.close > 0
ORDER BY sd.pct_change DESC
`, date).Scan(&summary.High100List)
return summary, nil
}
@@ -97,3 +111,85 @@ func (r *StockRepo) GetDailySummary(days int) ([]model.DailySummary, error) {
return result, err
}
func (r *StockRepo) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
// Get latest trade date from stock_daily
type dateResult struct {
D string `gorm:"column:d"`
}
var dr dateResult
r.db.Raw("SELECT MAX(DATE(date)) as d FROM stock_daily").Scan(&dr)
latest := dr.D
if latest == "" {
return nil, nil
}
// Get recent trading dates up to latest
var dates []string
r.db.Raw(`SELECT DATE(date) as d FROM stock_daily WHERE date <= ? GROUP BY date ORDER BY date DESC LIMIT 20`, latest).Scan(&dates)
if len(dates) == 0 {
return nil, nil
}
earliest := dates[len(dates)-1]
// Get all limit-up records for recent dates
type limitRow struct {
Code string
D string
}
var rows []limitRow
r.db.Raw(`SELECT code, DATE(date) as d FROM stock_daily WHERE pct_change >= 9.5 AND date >= ?`, earliest).Scan(&rows)
// Build set of (code, date) for limit-up
limitSet := make(map[string]bool)
for _, row := range rows {
limitSet[row.Code+"_"+row.D] = true
}
// Get today's limit-up stocks with info
type stockInfo struct {
Code string `gorm:"column:code"`
Name string `gorm:"column:name"`
Close float64 `gorm:"column:close"`
PctChange float64 `gorm:"column:pct_change"`
}
var todayLimit []stockInfo
r.db.Raw(`
SELECT sd.code, si.name, sd.close, sd.pct_change
FROM stock_daily sd
LEFT JOIN stock_info si ON sd.code = si.code
WHERE sd.date = ? AND sd.pct_change >= 9.5
ORDER BY sd.pct_change DESC
`, latest).Scan(&todayLimit)
// For each stock, count consecutive limit-up days backwards from latest
var result []model.ConsecutiveLimit
for _, s := range todayLimit {
streak := 0
for _, d := range dates {
if limitSet[s.Code+"_"+d] {
streak++
} else {
break
}
}
result = append(result, model.ConsecutiveLimit{
Code: s.Code,
Name: s.Name,
Streak: streak,
Close: s.Close,
PctChange: s.PctChange,
})
}
// 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) {
result[i], result[j] = result[j], result[i]
}
}
}
return result, nil
}
+1
View File
@@ -14,6 +14,7 @@ func Setup(r *gin.Engine, dh *handler.DashboardHandler) {
{
api.GET("/dashboard/summary", dh.GetSummary)
api.GET("/dashboard/daily", dh.GetDailySummary)
api.GET("/dashboard/consecutive", dh.GetConsecutiveLimits)
}
// Serve frontend static files
+4
View File
@@ -24,3 +24,7 @@ func (s *DashboardService) GetSummary() (*model.DashboardSummary, error) {
func (s *DashboardService) GetDailySummary(days int) ([]model.DailySummary, error) {
return s.repo.GetDailySummary(days)
}
func (s *DashboardService) GetConsecutiveLimits() ([]model.ConsecutiveLimit, error) {
return s.repo.GetConsecutiveLimits()
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
<script type="module" crossorigin src="/assets/index-B76CqnWZ.js"></script>
<script type="module" crossorigin src="/assets/index-hDKBor3F.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DGNrK5qb.css">
</head>
<body>
+5 -1
View File
@@ -5,14 +5,17 @@ import zhCN from 'antd/locale/zh_CN';
import {
DashboardOutlined,
LineChartOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import Dashboard from './pages/Dashboard';
import TrendPage from './pages/TrendPage';
import ConsecutivePage from './pages/ConsecutivePage';
const { Header, Content } = Layout;
const menuItems = [
{ key: '/', icon: <DashboardOutlined />, label: '市场概览' },
{ key: '/consecutive', icon: <ThunderboltOutlined />, label: '连板高度' },
{ key: '/trend', icon: <LineChartOutlined />, label: '每日趋势' },
];
@@ -35,9 +38,10 @@ function AppLayout() {
style={{ flex: 1 }}
/>
</Header>
<Content style={{ padding: '24px', background: '#f5f5f5' }}>
<Content style={{ padding: 24, background: '#f5f5f5' }}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/consecutive" element={<ConsecutivePage />} />
<Route path="/trend" element={<TrendPage />} />
</Routes>
</Content>
+16 -2
View File
@@ -18,8 +18,9 @@ export interface DashboardSummary {
flat_count: number;
total_turnover: number;
avg_pct_change: number;
top_gainers: StockRank[];
top_losers: StockRank[];
limit_up_list: StockRank[];
limit_down_list: StockRank[];
high_100_list: StockRank[];
}
export interface DailySummary {
@@ -45,3 +46,16 @@ 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;
close: number;
pct_change: number;
}
export async function getConsecutiveLimits(): Promise<ConsecutiveLimit[]> {
const res = await api.get('/dashboard/consecutive');
return res.data.data;
}
+61 -77
View File
@@ -22,43 +22,35 @@ export default function DailyChart({ data }: Props) {
}
const chart = chartRef.current;
const dates = data.map((d) => d.date);
const dates = data.map((d) => d.date.slice(0, 10));
chart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
},
legend: [
{
top: 0,
data: ['上涨数', '下跌数', '平均涨跌幅(%)'],
},
{
top: 25,
data: ['涨停(10%)', '跌停(10%)', '涨停(20%)', '跌停(20%)'],
},
],
legend: {
top: 0,
data: ['上涨数', '下跌数', '成交额(亿)', '涨停10%', '跌停10%', '涨停20%', '跌停20%'],
},
grid: [
{ left: 60, right: 20, top: 70, height: '25%' },
{ left: 60, right: 20, top: '48%', height: '20%' },
{ left: 60, right: 20, top: '76%', height: '18%' },
{ left: 60, right: 70, top: 60, height: '30%' },
{ left: 60, right: 70, top: '50%', height: '44%' },
],
xAxis: [
{ type: 'category', data: dates, gridIndex: 0, boundaryGap: false },
{ type: 'category', data: dates, gridIndex: 1, boundaryGap: false, show: false },
{ type: 'category', data: dates, gridIndex: 2, boundaryGap: false },
{ type: 'category', data: dates, gridIndex: 1, boundaryGap: false },
],
yAxis: [
{ type: 'value', name: '股票数', gridIndex: 0 },
{ type: 'value', name: '', gridIndex: 1 },
{ type: 'value', name: '亿', gridIndex: 2 },
{ type: 'value', name: '亿', gridIndex: 0, position: 'right', splitLine: { show: false } },
{ type: 'value', name: '10%家', gridIndex: 1, position: 'left' },
{ type: 'value', name: '20%家', gridIndex: 1, position: 'right', splitLine: { show: false } },
],
dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1, 2], start: 0, end: 100 },
{ type: 'inside', xAxisIndex: [0, 1], start: 0, end: 100 },
],
series: [
// 涨跌概览
{
name: '上涨数',
type: 'line',
@@ -79,69 +71,61 @@ export default function DailyChart({ data }: Props) {
lineStyle: { width: 2 },
symbol: 'none',
},
{
name: '平均涨跌幅(%)',
type: 'line',
xAxisIndex: 0,
yAxisIndex: 0,
data: data.map((d) => +d.avg_pct_change.toFixed(2)),
itemStyle: { color: '#1677ff' },
lineStyle: { width: 2, type: 'dashed' },
symbol: 'none',
},
// 涨停跌停
{
name: '涨停(10%)',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
stack: 'limit',
data: data.map((d) => d.limit_up_10),
itemStyle: { color: '#ff4d4f' },
barMaxWidth: 12,
},
{
name: '涨停(20%)',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
stack: 'limit',
data: data.map((d) => d.limit_up_20),
itemStyle: { color: '#ff7875' },
barMaxWidth: 12,
},
{
name: '跌停(10%)',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
stack: 'limit_down',
data: data.map((d) => -d.limit_down_10),
itemStyle: { color: '#52c41a' },
barMaxWidth: 12,
},
{
name: '跌停(20%)',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
stack: 'limit_down',
data: data.map((d) => -d.limit_down_20),
itemStyle: { color: '#95de64' },
barMaxWidth: 12,
},
// 成交额
{
name: '成交额(亿)',
type: 'line',
xAxisIndex: 2,
yAxisIndex: 2,
xAxisIndex: 0,
yAxisIndex: 1,
data: data.map((d) => fmtYi(d.total_turnover)),
areaStyle: { color: 'rgba(22,119,255,0.15)' },
itemStyle: { color: '#1677ff' },
itemStyle: { color: 'rgba(114,46,209,0.6)' },
lineStyle: { width: 2 },
areaStyle: { color: 'rgba(114,46,209,0.15)' },
symbol: 'none',
},
{
name: '涨停10%',
type: 'line',
xAxisIndex: 1,
yAxisIndex: 2,
data: data.map((d) => d.limit_up_10),
itemStyle: { color: '#ff4d4f' },
lineStyle: { width: 2 },
symbol: 'circle',
symbolSize: 4,
},
{
name: '跌停10%',
type: 'line',
xAxisIndex: 1,
yAxisIndex: 2,
data: data.map((d) => -d.limit_down_10),
itemStyle: { color: '#52c41a' },
lineStyle: { width: 2 },
symbol: 'circle',
symbolSize: 4,
},
{
name: '涨停20%',
type: 'line',
xAxisIndex: 1,
yAxisIndex: 3,
data: data.map((d) => d.limit_up_20),
itemStyle: { color: '#ff7875' },
lineStyle: { width: 2, type: 'dashed' },
symbol: 'circle',
symbolSize: 5,
},
{
name: '跌停20%',
type: 'line',
xAxisIndex: 1,
yAxisIndex: 3,
data: data.map((d) => -d.limit_down_20),
itemStyle: { color: '#95de64' },
lineStyle: { width: 2, type: 'dashed' },
symbol: 'circle',
symbolSize: 5,
},
],
}, true);
@@ -150,5 +134,5 @@ export default function DailyChart({ data }: Props) {
return () => window.removeEventListener('resize', handleResize);
}, [data]);
return <div ref={ref} style={{ height: 640 }} />;
return <div ref={ref} style={{ height: 600 }} />;
}
+99
View File
@@ -0,0 +1,99 @@
import { useEffect, useState } from 'react';
import { Card, Spin, Table, Tag, Typography } from 'antd';
import { getConsecutiveLimits, type ConsecutiveLimit } from '../api/dashboard';
const { Title } = Typography;
const columns = [
{
title: '连板',
dataIndex: 'streak',
key: 'streak',
width: 70,
render: (v: number) => (
<Tag color={v >= 5 ? '#722ed1' : v >= 3 ? '#cf1322' : '#fa541c'} style={{ fontWeight: 'bold', fontSize: 14 }}>
{v}
</Tag>
),
},
{ title: '代码', dataIndex: 'code', key: 'code', width: 80 },
{ title: '名称', dataIndex: 'name', key: 'name', width: 100 },
{
title: '最新价',
dataIndex: 'close',
key: 'close',
width: 80,
render: (v: number) => v?.toFixed(2),
},
{
title: '涨跌幅',
dataIndex: 'pct_change',
key: 'pct_change',
width: 90,
render: (v: number) => (
<Tag color="red">{v >= 0 ? '+' : ''}{v?.toFixed(2)}%</Tag>
),
},
];
export default function ConsecutivePage() {
const [data, setData] = useState<ConsecutiveLimit[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getConsecutiveLimits()
.then(setData)
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}>
<Spin size="large" />
</div>
);
}
// Group by streak
const grouped = data.reduce<Record<number, ConsecutiveLimit[]>>((acc, item) => {
if (!acc[item.streak]) acc[item.streak] = [];
acc[item.streak].push(item);
return acc;
}, {});
const maxStreak = data.length > 0 ? data[0].streak : 0;
return (
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
<Title level={2} style={{ textAlign: 'center', marginBottom: 32 }}>
</Title>
<Card style={{ marginBottom: 16, textAlign: 'center' }}>
<Title level={4} style={{ margin: 0 }}>
<span style={{ color: '#cf1322' }}>{data.length}</span>
<span style={{ color: '#722ed1', fontSize: 28 }}>{maxStreak}</span>
</Title>
</Card>
{Object.keys(grouped)
.sort((a, b) => Number(b) - Number(a))
.map((streak) => (
<Card
key={streak}
title={`${streak}连板 (${grouped[Number(streak)].length}只)`}
size="small"
style={{ marginBottom: 12 }}
>
<Table
columns={columns}
dataSource={grouped[Number(streak)]}
rowKey="code"
pagination={false}
size="small"
/>
</Card>
))}
</div>
);
}
+34 -18
View File
@@ -8,26 +8,29 @@ import StatCards from '../components/StatCard';
const { Title } = Typography;
const rankColumns = [
{ title: '代码', dataIndex: 'code', key: 'code', width: 90 },
{ title: '名称', dataIndex: 'name', key: 'name', width: 110 },
const columns = [
{ title: '代码', dataIndex: 'code', key: 'code', width: 80 },
{ title: '名称', dataIndex: 'name', key: 'name', width: 100 },
{
title: '最新价',
dataIndex: 'close',
key: 'close',
width: 90,
width: 80,
render: (v: number) => v?.toFixed(2),
},
{
title: '涨跌幅',
dataIndex: 'pct_change',
key: 'pct_change',
width: 100,
render: (v: number) => (
<Tag color={v >= 0 ? 'red' : 'green'}>
{v >= 0 ? '+' : ''}{v?.toFixed(2)}%
</Tag>
),
width: 90,
render: (v: number) => {
const is20 = Math.abs(v) >= 19.5;
return (
<Tag color={v >= 0 ? 'red' : 'green'} style={is20 ? { fontWeight: 'bold' } : undefined}>
{v >= 0 ? '+' : ''}{v?.toFixed(2)}%
</Tag>
);
},
},
];
@@ -60,25 +63,38 @@ export default function Dashboard() {
<StatCards data={summary} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginTop: 24 }}>
<Card title="涨幅前5" size="small">
<Card title={`涨停 (${summary.limit_up_list.length}只)`} size="small">
<Table
columns={rankColumns}
dataSource={summary.top_gainers}
columns={columns}
dataSource={summary.limit_up_list}
rowKey="code"
pagination={false}
pagination={{ pageSize: 50, size: 'small', showSizeChanger: false }}
size="small"
scroll={{ y: 400 }}
/>
</Card>
<Card title="跌幅前5" size="small">
<Card title={`跌停 (${summary.limit_down_list.length}只)`} size="small">
<Table
columns={rankColumns}
dataSource={summary.top_losers}
columns={columns}
dataSource={summary.limit_down_list}
rowKey="code"
pagination={false}
pagination={{ pageSize: 50, size: 'small', showSizeChanger: false }}
size="small"
scroll={{ y: 400 }}
/>
</Card>
</div>
<Card title={`百日新高 (${summary.high_100_list.length}只)`} size="small" style={{ marginTop: 16 }}>
<Table
columns={columns}
dataSource={summary.high_100_list}
rowKey="code"
pagination={{ pageSize: 50, size: 'small', showSizeChanger: false }}
size="small"
scroll={{ y: 400 }}
/>
</Card>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { Card, Segmented, Spin, Typography } from 'antd';
import { getDailySummary, type DailySummary } from '../api/dashboard';
import DailyChart from '../components/DailyChart';
const { Title } = Typography;
export default function TrendPage() {
const [daily, setDaily] = useState<DailySummary[]>([]);
const [loading, setLoading] = useState(true);
const [days, setDays] = useState<number>(30);
useEffect(() => {
setLoading(true);
getDailySummary(days)
.then(setDaily)
.finally(() => setLoading(false));
}, [days]);
return (
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
<Title level={2} style={{ textAlign: 'center', marginBottom: 32 }}>
</Title>
<Card
extra={
<Segmented
value={days}
onChange={(v) => setDays(v as number)}
options={[
{ label: '7天', value: 7 },
{ label: '30天', value: 30 },
{ label: '90天', value: 90 },
{ label: '180天', value: 180 },
]}
/>
}
>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 60 }}>
<Spin size="large" />
</div>
) : (
<DailyChart data={daily} />
)}
</Card>
</div>
);
}