新增盘后 A 股数据同步与看板市场概览

This commit is contained in:
2026-07-04 10:36:59 +08:00
parent 1fd9e97fe1
commit d18aa79a47
13 changed files with 968 additions and 2 deletions
+9
View File
@@ -13,3 +13,12 @@ RUST_LOG=info
# 端口
BACKEND_PORT=3019
FRONTEND_PORT=3018
# TickFlow 数据源配置(付费 key 必填,空 key 将无法启动同步)
TICKFLOW_API_KEY=
TICKFLOW_BASE_URL=https://api.tickflow.org
# 盘后数据同步配置
DATA_SYNC_ENABLED=true
DATA_SYNC_TIME=16:30
DATA_SYNC_WEEKDAYS_ONLY=true
+10 -1
View File
@@ -7,8 +7,10 @@ import (
"stock-user-system/internal/config"
"stock-user-system/internal/db"
"stock-user-system/internal/jobs"
"stock-user-system/internal/models"
"stock-user-system/internal/routes"
"stock-user-system/internal/services"
)
func main() {
@@ -34,7 +36,14 @@ func main() {
log.Fatalf("seed system admin: %v", err)
}
r := routes.Setup(cfg, database)
if err := models.AutoMigrateStock(database); err != nil {
log.Fatalf("migrate stock tables: %v", err)
}
stockSyncSvc := services.NewStockSyncService(cfg, database)
jobs.StartStockSyncScheduler(cfg, stockSyncSvc)
r := routes.Setup(cfg, database, stockSyncSvc)
addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port)
log.Printf("backend listening on %s", addr)
+24
View File
@@ -16,6 +16,13 @@ type Config struct {
JWTExpirationHours int
Port string
AllowedOrigins []string
// 股票数据源
TickFlowAPIKey string
TickFlowBaseURL string
DataSyncEnabled bool
DataSyncTime string // HH:MM
DataSyncWeekdays bool
}
func Load() (*Config, error) {
@@ -50,12 +57,29 @@ func Load() (*Config, error) {
}
}
tickFlowBaseURL := os.Getenv("TICKFLOW_BASE_URL")
if tickFlowBaseURL == "" {
tickFlowBaseURL = "https://api.tickflow.org"
}
dataSyncEnabled := strings.ToLower(os.Getenv("DATA_SYNC_ENABLED")) != "false"
dataSyncTime := os.Getenv("DATA_SYNC_TIME")
if dataSyncTime == "" {
dataSyncTime = "16:30"
}
dataSyncWeekdays := strings.ToLower(os.Getenv("DATA_SYNC_WEEKDAYS_ONLY")) != "false"
return &Config{
DatabaseURL: databaseURL,
JWTSecret: jwtSecret,
JWTExpirationHours: expHours,
Port: port,
AllowedOrigins: allowedOrigins,
TickFlowAPIKey: os.Getenv("TICKFLOW_API_KEY"),
TickFlowBaseURL: tickFlowBaseURL,
DataSyncEnabled: dataSyncEnabled,
DataSyncTime: dataSyncTime,
DataSyncWeekdays: dataSyncWeekdays,
}, nil
}
+199
View File
@@ -0,0 +1,199 @@
package datasource
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"stock-user-system/internal/config"
)
const (
defaultTimeout = 60 * time.Second
maxRetries = 3
retryBaseDelay = 1 * time.Second
)
// Quote 对应 tickflow /v1/quotes 返回的单条行情数据。
type Quote struct {
Symbol string `json:"symbol"`
Name string `json:"name"`
Open float64 `json:"open"`
High float64 `json:"high"`
Low float64 `json:"low"`
Close float64 `json:"last_price"`
PrevClose float64 `json:"prev_close"`
Volume int64 `json:"volume"`
Amount float64 `json:"amount"`
Timestamp int64 `json:"timestamp"`
Region string `json:"region"`
Ext struct {
ChangePct float64 `json:"change_pct"`
TurnoverRate float64 `json:"turnover_rate"`
Name string `json:"name"`
} `json:"ext"`
}
// ChangePct 优先使用 ext.change_pct,否则根据 close/prev_close 计算。
func (q *Quote) ChangePct() float64 {
if q.Ext.ChangePct != 0 {
return q.Ext.ChangePct
}
if q.PrevClose != 0 {
return (q.Close - q.PrevClose) / q.PrevClose * 100
}
return 0
}
// TurnoverRate 返回 ext.turnover_rate(仅 A 股有效)。
func (q *Quote) TurnoverRate() float64 {
return q.Ext.TurnoverRate
}
// DisplayName 优先使用 ext.name,否则使用 symbol。
func (q *Quote) DisplayName() string {
if q.Ext.Name != "" {
return q.Ext.Name
}
if q.Name != "" {
return q.Name
}
return q.Symbol
}
// UniverseDetail 对应 /v1/universes/:id 返回的标的池详情。
type UniverseDetail struct {
ID string `json:"id"`
Name string `json:"name"`
SymbolCount int `json:"symbol_count"`
Symbols []string `json:"symbols"`
}
// Client 封装 tickflow HTTP API 调用。
type Client struct {
baseURL string
apiKey string
client *http.Client
}
// NewClient 从配置创建 tickflow 客户端。
func NewClient(cfg *config.Config) *Client {
return &Client{
baseURL: strings.TrimRight(cfg.TickFlowBaseURL, "/"),
apiKey: cfg.TickFlowAPIKey,
client: &http.Client{Timeout: defaultTimeout},
}
}
// GetUniverse 获取指定标的池的完整代码列表。
func (c *Client) GetUniverse(id string) (*UniverseDetail, error) {
path := fmt.Sprintf("/v1/universes/%s", id)
var detail UniverseDetail
if err := c.request("GET", path, nil, nil, &detail); err != nil {
return nil, err
}
return &detail, nil
}
// GetQuotesByUniverses 按标的池批量获取行情快照。
func (c *Client) GetQuotesByUniverses(universes []string) ([]Quote, error) {
body := map[string]any{"universes": universes}
var quotes []Quote
if err := c.request("POST", "/v1/quotes", nil, body, &quotes); err != nil {
return nil, err
}
return quotes, nil
}
// GetQuotesBySymbols 按代码列表批量获取行情快照。
func (c *Client) GetQuotesBySymbols(symbols []string) ([]Quote, error) {
body := map[string]any{"symbols": symbols}
var quotes []Quote
if err := c.request("POST", "/v1/quotes", nil, body, &quotes); err != nil {
return nil, err
}
return quotes, nil
}
func (c *Client) request(method, path string, params, body map[string]any, result any) error {
url := c.baseURL + path
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
time.Sleep(retryBaseDelay * time.Duration(1<<(attempt-1)))
}
var bodyReader io.Reader
if len(body) > 0 && (method == "POST" || method == "PUT") {
data, err := json.Marshal(body)
if err != nil {
return err
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return err
}
if c.apiKey != "" {
req.Header.Set("x-api-key", c.apiKey)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
if len(params) > 0 {
q := req.URL.Query()
for k, v := range params {
q.Set(k, fmt.Sprintf("%v", v))
}
req.URL.RawQuery = q.Encode()
}
resp, err := c.client.Do(req)
if err != nil {
lastErr = err
continue
}
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = err
continue
}
if resp.StatusCode >= 500 || resp.StatusCode == 429 {
lastErr = fmt.Errorf("tickflow %s %s returned %d: %s", method, path, resp.StatusCode, string(respBody))
continue
}
if resp.StatusCode >= 400 {
return fmt.Errorf("tickflow %s %s returned %d: %s", method, path, resp.StatusCode, string(respBody))
}
var wrapper struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(respBody, &wrapper); err != nil {
return fmt.Errorf("decode tickflow response: %w", err)
}
if result != nil {
if err := json.Unmarshal(wrapper.Data, result); err != nil {
return fmt.Errorf("decode tickflow data: %w", err)
}
}
return nil
}
if lastErr != nil {
return fmt.Errorf("tickflow request failed after %d retries: %w", maxRetries, lastErr)
}
return fmt.Errorf("tickflow request failed")
}
+161
View File
@@ -0,0 +1,161 @@
package handlers
import (
"context"
"net/http"
"time"
"stock-user-system/internal/models"
"stock-user-system/internal/services"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
var coreIndexSymbols = map[string]bool{
"000001.SH": true,
"399001.SZ": true,
"399006.SZ": true,
"000688.SH": true,
}
// StockHandler 处理股票看板与同步接口。
type StockHandler struct {
DB *gorm.DB
Svc *services.StockSyncService
}
type stockRow struct {
Symbol string `json:"symbol"`
Name string `json:"name"`
Close float64 `json:"close"`
ChangePct float64 `json:"change_pct"`
Amount float64 `json:"amount,omitempty"`
}
type overviewResponse struct {
LatestTradeDate string `json:"latest_trade_date"`
Counts marketCounts `json:"counts"`
AvgChangePct float64 `json:"avg_change_pct"`
Indices []stockRow `json:"indices"`
TopGainers []stockRow `json:"top_gainers"`
TopLosers []stockRow `json:"top_losers"`
TurnoverLeaders []stockRow `json:"turnover_leaders"`
}
type marketCounts struct {
Up int64 `json:"up"`
Down int64 `json:"down"`
Flat int64 `json:"flat"`
Total int64 `json:"total"`
}
// Overview 返回最新交易日的市场概览。
func (h *StockHandler) Overview(c *gin.Context) {
ctx := context.Background()
var latestDate time.Time
if err := h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
Select("MAX(trade_date)").Scan(&latestDate).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "查询最新交易日失败"})
return
}
if latestDate.IsZero() {
c.JSON(http.StatusOK, overviewResponse{})
return
}
var counts marketCounts
if err := h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
Select(`
COUNT(*) AS total,
SUM(CASE WHEN change_pct > 0 THEN 1 ELSE 0 END) AS up,
SUM(CASE WHEN change_pct < 0 THEN 1 ELSE 0 END) AS down,
SUM(CASE WHEN change_pct = 0 THEN 1 ELSE 0 END) AS flat
`).
Where("trade_date = ? AND symbol NOT IN ?", latestDate, indexSymbolsList()).
Scan(&counts).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "统计涨跌失败"})
return
}
var avgChange float64
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
Select("COALESCE(AVG(change_pct), 0)").
Where("trade_date = ? AND symbol NOT IN ?", latestDate, indexSymbolsList()).
Scan(&avgChange)
indices := h.topRows(ctx, latestDate, "symbol IN ?", indexSymbolsList(), 10, false)
gainers := h.topRows(ctx, latestDate, "symbol NOT IN ?", indexSymbolsList(), 10, true)
losers := h.topRows(ctx, latestDate, "symbol NOT IN ?", indexSymbolsList(), 10, false)
turnover := h.topRowsByAmount(ctx, latestDate)
c.JSON(http.StatusOK, overviewResponse{
LatestTradeDate: latestDate.Format("2006-01-02"),
Counts: counts,
AvgChangePct: avgChange,
Indices: indices,
TopGainers: gainers,
TopLosers: losers,
TurnoverLeaders: turnover,
})
}
func (h *StockHandler) topRows(ctx context.Context, tradeDate time.Time, condition string, args any, limit int, desc bool) []stockRow {
var rows []stockRow
order := "change_pct ASC"
if desc {
order = "change_pct DESC"
}
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
Select("symbol, name, close, change_pct").
Where("trade_date = ?", tradeDate).
Where(condition, args).
Order(order).
Limit(limit).
Scan(&rows)
return rows
}
func (h *StockHandler) topRowsByAmount(ctx context.Context, tradeDate time.Time) []stockRow {
var rows []stockRow
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
Select("symbol, name, close, change_pct, amount").
Where("trade_date = ? AND symbol NOT IN ?", tradeDate, indexSymbolsList()).
Order("amount DESC").
Limit(10).
Scan(&rows)
return rows
}
// SyncStatus 返回最近一次同步任务状态。
func (h *StockHandler) SyncStatus(c *gin.Context) {
job, err := h.Svc.LatestJob(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "查询同步状态失败"})
return
}
if job == nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": nil})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": job})
}
// TriggerSync 手动触发同步(管理员)。
func (h *StockHandler) TriggerSync(c *gin.Context) {
count, err := h.Svc.Sync(c.Request.Context(), "manual")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "records_count": count})
}
func indexSymbolsList() []string {
list := make([]string, 0, len(coreIndexSymbols))
for s := range coreIndexSymbols {
list = append(list, s)
}
return list
}
@@ -0,0 +1,81 @@
package jobs
import (
"context"
"fmt"
"log"
"strconv"
"strings"
"time"
"stock-user-system/internal/config"
"stock-user-system/internal/services"
)
var chinaLoc, _ = time.LoadLocation("Asia/Shanghai")
// StartStockSyncScheduler 启动盘后数据同步调度器。
func StartStockSyncScheduler(cfg *config.Config, svc *services.StockSyncService) {
if !cfg.DataSyncEnabled {
log.Println("stock sync scheduler disabled")
return
}
hour, minute, err := parseSyncTime(cfg.DataSyncTime)
if err != nil {
log.Printf("invalid DATA_SYNC_TIME %q, scheduler not started: %v", cfg.DataSyncTime, err)
return
}
go func() {
for {
next := nextSyncTime(hour, minute, cfg.DataSyncWeekdays)
wait := time.Until(next)
log.Printf("next stock sync scheduled at %s (in %v)", next.Format(time.RFC3339), wait)
time.Sleep(wait)
if _, err := svc.Sync(context.Background(), "schedule"); err != nil {
log.Printf("scheduled stock sync failed: %v", err)
} else {
log.Println("scheduled stock sync completed")
}
}
}()
}
func parseSyncTime(s string) (int, int, error) {
parts := strings.Split(s, ":")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("expected HH:MM")
}
h, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, err
}
m, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, err
}
if h < 0 || h > 23 || m < 0 || m > 59 {
return 0, 0, fmt.Errorf("invalid time")
}
return h, m, nil
}
func nextSyncTime(hour, minute int, weekdaysOnly bool) time.Time {
now := time.Now().In(chinaLoc)
today := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, chinaLoc)
candidate := today
if now.Equal(candidate) || now.After(candidate) {
candidate = candidate.Add(24 * time.Hour)
}
if weekdaysOnly {
for candidate.Weekday() == time.Saturday || candidate.Weekday() == time.Sunday {
candidate = candidate.Add(24 * time.Hour)
}
}
return candidate
}
+44
View File
@@ -0,0 +1,44 @@
package models
import (
"time"
"gorm.io/gorm"
)
// StockDailyQuote 存储每日行情快照(盘后同步)。
type StockDailyQuote struct {
ID string `json:"id" gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
Symbol string `json:"symbol" gorm:"size:32;not null;uniqueIndex:idx_stock_daily_symbol_date"`
Name string `json:"name" gorm:"size:128"`
TradeDate time.Time `json:"trade_date" gorm:"type:date;not null;uniqueIndex:idx_stock_daily_symbol_date"`
Open float64 `json:"open"`
High float64 `json:"high"`
Low float64 `json:"low"`
Close float64 `json:"close"`
PrevClose float64 `json:"prev_close"`
Volume int64 `json:"volume"`
Amount float64 `json:"amount"`
ChangePct float64 `json:"change_pct"`
TurnoverRate float64 `json:"turnover_rate"`
Region string `json:"region" gorm:"size:16"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// StockSyncJob 记录每次盘后同步任务的状态。
type StockSyncJob struct {
ID string `json:"id" gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
JobDate time.Time `json:"job_date" gorm:"type:date;not null"`
Status string `json:"status" gorm:"size:16;not null"` // running / success / failed
RecordsCount int `json:"records_count"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
ErrorMessage string `json:"error_message"`
TriggerBy string `json:"trigger_by" gorm:"size:16"` // schedule / manual
}
// AutoMigrateStock 迁移股票数据表。
func AutoMigrateStock(db *gorm.DB) error {
return db.AutoMigrate(&StockDailyQuote{}, &StockSyncJob{})
}
+12 -1
View File
@@ -5,14 +5,16 @@ import (
"stock-user-system/internal/handlers"
"stock-user-system/internal/middleware"
"stock-user-system/internal/models"
"stock-user-system/internal/services"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func Setup(cfg *config.Config, db *gorm.DB) *gin.Engine {
func Setup(cfg *config.Config, db *gorm.DB, stockSyncSvc *services.StockSyncService) *gin.Engine {
authHandler := &handlers.AuthHandler{DB: db, CFG: cfg}
adminHandler := &handlers.AdminHandler{DB: db, CFG: cfg}
stockHandler := &handlers.StockHandler{DB: db, Svc: stockSyncSvc}
r := gin.Default()
@@ -41,6 +43,14 @@ func Setup(cfg *config.Config, db *gorm.DB) *gin.Engine {
auth.POST("/logout", authHandler.Logout)
}
// 受保护的股票看板接口
stocks := r.Group("/api/stocks")
stocks.Use(middleware.RequireAuth())
{
stocks.GET("/overview", stockHandler.Overview)
stocks.GET("/sync/status", stockHandler.SyncStatus)
}
// 管理员接口
admin := r.Group("/api/admin")
admin.Use(middleware.RequireAuth(), middleware.RequireRoles(models.RoleAdmin, models.RoleSystemAdmin))
@@ -50,6 +60,7 @@ func Setup(cfg *config.Config, db *gorm.DB) *gin.Engine {
admin.PUT("/users/:id", adminHandler.UpdateUser)
admin.DELETE("/users/:id", adminHandler.DeleteUser)
admin.GET("/roles", adminHandler.ListRoles)
admin.POST("/stocks/sync", stockHandler.TriggerSync)
}
return r
+138
View File
@@ -0,0 +1,138 @@
package services
import (
"context"
"fmt"
"sync"
"time"
"stock-user-system/internal/config"
"stock-user-system/internal/datasource"
"stock-user-system/internal/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var (
coreIndexSymbols = []string{"000001.SH", "399001.SZ", "399006.SZ", "000688.SH"}
chinaLoc, _ = time.LoadLocation("Asia/Shanghai")
)
// StockSyncService 负责盘后行情同步。
type StockSyncService struct {
cfg *config.Config
db *gorm.DB
client *datasource.Client
mu sync.Mutex
}
// NewStockSyncService 创建同步服务。
func NewStockSyncService(cfg *config.Config, db *gorm.DB) *StockSyncService {
return &StockSyncService{
cfg: cfg,
db: db,
client: datasource.NewClient(cfg),
}
}
// Sync 执行一次盘后同步,返回写入的记录数。
func (s *StockSyncService) Sync(ctx context.Context, triggerBy string) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
job := models.StockSyncJob{
JobDate: today(),
Status: "running",
StartedAt: time.Now().UTC(),
TriggerBy: triggerBy,
}
if err := s.db.WithContext(ctx).Create(&job).Error; err != nil {
return 0, fmt.Errorf("create sync job: %w", err)
}
recordsCount, err := s.doSync(ctx)
now := time.Now().UTC()
updates := map[string]any{
"records_count": recordsCount,
"finished_at": &now,
}
if err != nil {
updates["status"] = "failed"
updates["error_message"] = err.Error()
} else {
updates["status"] = "success"
}
s.db.WithContext(ctx).Model(&job).Updates(updates)
return recordsCount, err
}
func (s *StockSyncService) doSync(ctx context.Context) (int, error) {
// 1. 拉取全 A 股行情快照
stockQuotes, err := s.client.GetQuotesByUniverses([]string{"CN_Equity_A"})
if err != nil {
return 0, fmt.Errorf("fetch stock quotes: %w", err)
}
// 2. 拉取核心指数行情
indexQuotes, err := s.client.GetQuotesBySymbols(coreIndexSymbols)
if err != nil {
return 0, fmt.Errorf("fetch index quotes: %w", err)
}
quotes := append(stockQuotes, indexQuotes...)
if len(quotes) == 0 {
return 0, fmt.Errorf("no quotes returned")
}
// 3. 归一化并写入 DB
records := make([]models.StockDailyQuote, 0, len(quotes))
for _, q := range quotes {
tradeDate := msToDate(q.Timestamp)
records = append(records, models.StockDailyQuote{
Symbol: q.Symbol,
Name: q.DisplayName(),
TradeDate: tradeDate,
Open: q.Open,
High: q.High,
Low: q.Low,
Close: q.Close,
PrevClose: q.PrevClose,
Volume: q.Volume,
Amount: q.Amount,
ChangePct: q.ChangePct(),
TurnoverRate: q.TurnoverRate(),
Region: q.Region,
})
}
if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "symbol"}, {Name: "trade_date"}},
UpdateAll: true,
}).CreateInBatches(records, 500).Error; err != nil {
return 0, fmt.Errorf("upsert daily quotes: %w", err)
}
return len(records), nil
}
func (s *StockSyncService) LatestJob(ctx context.Context) (*models.StockSyncJob, error) {
var job models.StockSyncJob
if err := s.db.WithContext(ctx).Order("started_at DESC").First(&job).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &job, nil
}
func today() time.Time {
return time.Now().In(chinaLoc).Truncate(24 * time.Hour)
}
func msToDate(ms int64) time.Time {
return time.UnixMilli(ms).In(chinaLoc).Truncate(24 * time.Hour)
}
+5
View File
@@ -29,6 +29,11 @@ services:
JWT_EXPIRATION_HOURS: ${JWT_EXPIRATION_HOURS:-168}
GIN_MODE: ${GIN_MODE:-release}
PORT: 3019
TICKFLOW_API_KEY: ${TICKFLOW_API_KEY:-}
TICKFLOW_BASE_URL: ${TICKFLOW_BASE_URL:-https://api.tickflow.org}
DATA_SYNC_ENABLED: ${DATA_SYNC_ENABLED:-true}
DATA_SYNC_TIME: ${DATA_SYNC_TIME:-16:30}
DATA_SYNC_WEEKDAYS_ONLY: ${DATA_SYNC_WEEKDAYS_ONLY:-true}
ports:
- "3019:3019"
depends_on:
+241
View File
@@ -0,0 +1,241 @@
import { useEffect, useState } from 'react'
import {
BarChart3,
TrendingUp,
TrendingDown,
Minus,
RefreshCw,
Loader2,
AlertCircle,
Calendar,
Activity,
} from 'lucide-react'
import { api, MarketOverview, SyncJob, StockRow } from '@/lib/api'
import { canManageUsers } from '@/lib/auth'
import { cn } from '@/lib/cn'
interface StockOverviewProps {
role: 'system_admin' | 'admin' | 'user'
}
export function StockOverview({ role }: StockOverviewProps) {
const [overview, setOverview] = useState<MarketOverview | null>(null)
const [job, setJob] = useState<SyncJob | null>(null)
const [loading, setLoading] = useState(true)
const [syncing, setSyncing] = useState(false)
const [error, setError] = useState('')
const canSync = canManageUsers(role)
const fetchAll = async () => {
setLoading(true)
setError('')
try {
const [o, j] = await Promise.all([api.stockOverview(), api.stockSyncStatus()])
setOverview(o)
setJob(j)
} catch (err: any) {
setError(err?.message || '加载市场数据失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchAll()
}, [])
const handleSync = async () => {
setSyncing(true)
setError('')
try {
const res = await api.triggerStockSync()
await fetchAll()
// eslint-disable-next-line no-console
console.log('synced', res.records_count, 'records')
} catch (err: any) {
setError(err?.message || '同步失败')
} finally {
setSyncing(false)
}
}
if (loading) {
return (
<div className="rounded-card border border-border bg-surface p-6 flex items-center justify-center gap-2 text-sm text-muted">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)
}
if (!overview?.latest_trade_date) {
return (
<div className="rounded-card border border-border bg-surface p-6 text-center">
<div className="text-sm text-secondary"></div>
{canSync && (
<button
onClick={handleSync}
disabled={syncing}
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 disabled:opacity-60"
>
{syncing ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
</button>
)}
</div>
)
}
const { counts, avg_change_pct, indices, top_gainers, top_losers, turnover_leaders } = overview
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-card border border-border bg-surface/80 px-4 py-3">
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-accent" />
<h2 className="text-base font-semibold text-foreground"></h2>
<span className="text-xs text-muted flex items-center gap-1">
<Calendar className="h-3 w-3" />
{overview.latest_trade_date}
</span>
</div>
<div className="flex items-center gap-3">
{job && (
<span className="text-xs text-muted">
{formatSyncStatus(job)}
</span>
)}
{canSync && (
<button
onClick={handleSync}
disabled={syncing}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 disabled:opacity-60 transition-colors"
>
{syncing ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
</button>
)}
</div>
</div>
{error && (
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>{error}</span>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<KpiCard icon={<TrendingUp className="h-4 w-4 text-bull" />} label="上涨" value={counts.up} />
<KpiCard icon={<TrendingDown className="h-4 w-4 text-bear" />} label="下跌" value={counts.down} />
<KpiCard icon={<Minus className="h-4 w-4 text-muted" />} label="平盘" value={counts.flat} />
<KpiCard
icon={<Activity className="h-4 w-4 text-accent" />}
label="平均涨跌"
value={`${avg_change_pct >= 0 ? '+' : ''}${avg_change_pct.toFixed(2)}%`}
valueClass={avg_change_pct >= 0 ? 'text-bull' : 'text-bear'}
/>
</div>
{indices.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{indices.map((item) => (
<IndexCard key={item.symbol} item={item} />
))}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StockList title="涨幅榜" rows={top_gainers} valueKey="change_pct" />
<StockList title="跌幅榜" rows={top_losers} valueKey="change_pct" />
<StockList title="成交额榜" rows={turnover_leaders} valueKey="amount" />
</div>
</div>
)
}
function KpiCard({
icon,
label,
value,
valueClass,
}: {
icon: React.ReactNode
label: string
value: React.ReactNode
valueClass?: string
}) {
return (
<div className="rounded-card border border-border bg-surface p-3 flex items-center gap-3">
<div className="p-1.5 rounded-btn bg-elevated">{icon}</div>
<div>
<div className={cn('text-lg font-bold text-foreground', valueClass)}>{value}</div>
<div className="text-xs text-secondary">{label}</div>
</div>
</div>
)
}
function IndexCard({ item }: { item: StockRow }) {
const positive = item.change_pct >= 0
return (
<div className="rounded-card border border-border bg-surface p-3">
<div className="text-xs text-secondary truncate">{item.name || item.symbol}</div>
<div className={cn('text-base font-semibold mt-1', positive ? 'text-bull' : 'text-bear')}>
{item.close.toFixed(2)}
</div>
<div className={cn('text-xs font-medium', positive ? 'text-bull' : 'text-bear')}>
{positive ? '+' : ''}{item.change_pct.toFixed(2)}%
</div>
</div>
)
}
function StockList({
title,
rows,
valueKey,
}: {
title: string
rows: StockRow[]
valueKey: 'change_pct' | 'amount'
}) {
return (
<div className="rounded-card border border-border bg-surface p-3">
<h3 className="text-xs font-semibold text-secondary uppercase tracking-wider mb-2">{title}</h3>
<div className="space-y-1">
{rows.length === 0 ? (
<div className="text-xs text-muted py-2"></div>
) : (
rows.map((item, idx) => {
const val = valueKey === 'amount' ? (item.amount ?? 0) / 1e8 : item.change_pct
const isPositive = valueKey === 'amount' ? true : item.change_pct >= 0
return (
<div key={item.symbol} className="flex items-center justify-between text-sm py-1">
<div className="flex items-center gap-2 min-w-0">
<span className="text-[10px] text-muted w-4">{idx + 1}</span>
<span className="text-foreground font-medium truncate">{item.name || item.symbol}</span>
</div>
<div className={cn('text-xs font-mono shrink-0', valueKey === 'change_pct' ? (isPositive ? 'text-bull' : 'text-bear') : 'text-foreground')}>
{valueKey === 'amount'
? `${val.toFixed(2)}亿`
: `${isPositive ? '+' : ''}${val.toFixed(2)}%`}
</div>
</div>
)
})
)}
</div>
</div>
)
}
function formatSyncStatus(job: SyncJob): string {
if (job.status === 'running') return '同步中…'
const timeStr = job.finished_at
? new Date(job.finished_at).toLocaleString()
: new Date(job.started_at).toLocaleString()
const statusText = job.status === 'success' ? '成功' : '失败'
return `${statusText} · ${timeStr}`
}
+41
View File
@@ -28,6 +28,40 @@ export interface AuthResponse {
user: UserInfo
}
export interface StockRow {
symbol: string
name: string
close: number
change_pct: number
amount?: number
}
export interface MarketOverview {
latest_trade_date: string
counts: {
up: number
down: number
flat: number
total: number
}
avg_change_pct: number
indices: StockRow[]
top_gainers: StockRow[]
top_losers: StockRow[]
turnover_leaders: StockRow[]
}
export interface SyncJob {
id: string
job_date: string
status: 'running' | 'success' | 'failed'
records_count: number
started_at: string
finished_at?: string
error_message: string
trigger_by: string
}
export function getToken(): string | null {
try {
return localStorage.getItem('access_token')
@@ -107,4 +141,11 @@ export const api = {
request<{ message: string }>(`/api/admin/users/${id}`, { method: 'DELETE' }),
listRoles: () => request<Role[]>('/api/admin/roles'),
stockOverview: () => request<MarketOverview>('/api/stocks/overview'),
stockSyncStatus: () => request<SyncJob | null>('/api/stocks/sync/status'),
triggerStockSync: () =>
request<{ records_count: number }>('/api/admin/stocks/sync', { method: 'POST' }),
}
+3
View File
@@ -1,5 +1,6 @@
import { LayoutDashboard, ShieldCheck } from 'lucide-react'
import { getStoredUser, roleLabel } from '@/lib/auth'
import { StockOverview } from '@/components/StockOverview'
export function Dashboard() {
const user = getStoredUser()
@@ -16,6 +17,8 @@ export function Dashboard() {
</div>
</div>
<StockOverview role={user?.role || 'user'} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Card
icon={<ShieldCheck className="h-5 w-5 text-accent" />}