Compare commits
19 Commits
24630654a2
...
46013a2b2f
| Author | SHA1 | Date | |
|---|---|---|---|
| 46013a2b2f | |||
| ab14eb9c30 | |||
| aa37a93a35 | |||
| 63687723ab | |||
| 716761b3d9 | |||
| 01da4eb370 | |||
| b8b88e11df | |||
| 42b2270839 | |||
| 346d3c5555 | |||
| 05c38ca8e3 | |||
| 1453088c0b | |||
| 2d967f21ec | |||
| 5bf4949999 | |||
| d18aa79a47 | |||
| 1fd9e97fe1 | |||
| 54369243b6 | |||
| acd335c435 | |||
| 5a50af66bf | |||
| 323f50bfc0 |
@@ -13,3 +13,12 @@ RUST_LOG=info
|
|||||||
# 端口
|
# 端口
|
||||||
BACKEND_PORT=3019
|
BACKEND_PORT=3019
|
||||||
FRONTEND_PORT=3018
|
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
|
||||||
|
|||||||
@@ -38,9 +38,14 @@ node_modules/
|
|||||||
# ===== Vite =====
|
# ===== Vite =====
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/.vite/
|
frontend/.vite/
|
||||||
|
frontend/vite.config.js
|
||||||
|
frontend/vite.config.d.ts
|
||||||
backend/static/
|
backend/static/
|
||||||
backend/app/static/
|
backend/app/static/
|
||||||
|
|
||||||
|
# ===== npm lockfile (不纳入版本控制) =====
|
||||||
|
frontend/package-lock.json
|
||||||
|
|
||||||
# ===== IDE =====
|
# ===== IDE =====
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
@@ -97,3 +102,5 @@ data/strategies/custom/
|
|||||||
# ===== 临时 / 调试产物 =====
|
# ===== 临时 / 调试产物 =====
|
||||||
backend._recheck*.py
|
backend._recheck*.py
|
||||||
backend/_verify*.py
|
backend/_verify*.py
|
||||||
|
refer/data
|
||||||
|
.claude/
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# A股工具 · 用户体系
|
# A股工具 · 用户体系
|
||||||
|
|
||||||
基于 **Go + PostgreSQL + Docker Compose** 的用户权限管理模块,角色覆盖:系统管理员、管理员、用户、游客。前端界面复用 `refer/` 目录下的暗色主题设计语言。
|
基于 **Go + PostgreSQL + Docker Compose** 的用户权限管理模块,角色覆盖:系统管理员、管理员、用户。前端界面复用 `refer/` 目录下的暗色主题设计语言。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
@@ -32,16 +32,15 @@ open http://localhost:3018
|
|||||||
| 后端 API | 3019 |
|
| 后端 API | 3019 |
|
||||||
| PostgreSQL | 5432 |
|
| PostgreSQL | 5432 |
|
||||||
|
|
||||||
## 创建首个系统管理员
|
## 默认系统管理员
|
||||||
|
|
||||||
系统不内置默认账号。启动后,先注册一个普通用户,再通过 SQL 将其提升为系统管理员:
|
系统启动时会自动创建一个系统管理员账号(仅当不存在时):
|
||||||
|
|
||||||
```bash
|
| 用户名 | 密码 |
|
||||||
docker compose exec db psql -U stock -d stock -c "
|
|---|---|
|
||||||
UPDATE users SET role_id = (SELECT id FROM roles WHERE name = 'system_admin')
|
| `system_admin` | `system_admin` |
|
||||||
WHERE username = '你的用户名';
|
|
||||||
"
|
整个系统只能有一个系统管理员账号,无法通过管理后台再创建或提升其他系统管理员。
|
||||||
```
|
|
||||||
|
|
||||||
## 角色说明
|
## 角色说明
|
||||||
|
|
||||||
@@ -50,14 +49,11 @@ WHERE username = '你的用户名';
|
|||||||
| 系统管理员 | 管理管理员、用户、系统配置 |
|
| 系统管理员 | 管理管理员、用户、系统配置 |
|
||||||
| 管理员 | 管理普通用户 |
|
| 管理员 | 管理普通用户 |
|
||||||
| 用户 | 访问业务功能、修改个人资料 |
|
| 用户 | 访问业务功能、修改个人资料 |
|
||||||
| 游客 | 仅查看公开内容,不可操作 |
|
|
||||||
|
|
||||||
## 主要 API
|
## 主要 API
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 | 权限 |
|
| 方法 | 路径 | 说明 | 权限 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | /api/public | 公开信息 | 公开 |
|
|
||||||
| POST | /api/auth/register | 注册(默认 user) | 公开 |
|
|
||||||
| POST | /api/auth/login | 登录 | 公开 |
|
| POST | /api/auth/login | 登录 | 公开 |
|
||||||
| GET | /api/auth/me | 当前用户 | 需登录 |
|
| GET | /api/auth/me | 当前用户 | 需登录 |
|
||||||
| POST | /api/auth/logout | 登出 | 需登录 |
|
| POST | /api/auth/logout | 登出 | 需登录 |
|
||||||
@@ -107,5 +103,4 @@ npm run dev
|
|||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
- 生产环境请务必修改 `JWT_SECRET`。
|
- 生产环境请务必修改 `JWT_SECRET`。
|
||||||
- 游客通过首页直接访问,无需登录。
|
|
||||||
- 首次启动时 GORM 会自动创建表并插入默认角色。
|
- 首次启动时 GORM 会自动创建表并插入默认角色。
|
||||||
|
|||||||
+14
-1
@@ -7,8 +7,10 @@ import (
|
|||||||
|
|
||||||
"stock-user-system/internal/config"
|
"stock-user-system/internal/config"
|
||||||
"stock-user-system/internal/db"
|
"stock-user-system/internal/db"
|
||||||
|
"stock-user-system/internal/jobs"
|
||||||
"stock-user-system/internal/models"
|
"stock-user-system/internal/models"
|
||||||
"stock-user-system/internal/routes"
|
"stock-user-system/internal/routes"
|
||||||
|
"stock-user-system/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -30,7 +32,18 @@ func main() {
|
|||||||
log.Fatalf("seed roles: %v", err)
|
log.Fatalf("seed roles: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
r := routes.Setup(cfg, database)
|
if err := models.SeedSystemAdmin(database); err != nil {
|
||||||
|
log.Fatalf("seed system admin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port)
|
||||||
log.Printf("backend listening on %s", addr)
|
log.Printf("backend listening on %s", addr)
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ type Config struct {
|
|||||||
JWTExpirationHours int
|
JWTExpirationHours int
|
||||||
Port string
|
Port string
|
||||||
AllowedOrigins []string
|
AllowedOrigins []string
|
||||||
|
|
||||||
|
// 股票数据源
|
||||||
|
TickFlowAPIKey string
|
||||||
|
TickFlowBaseURL string
|
||||||
|
DataSyncEnabled bool
|
||||||
|
DataSyncTime string // HH:MM
|
||||||
|
DataSyncWeekdays bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -50,12 +57,34 @@ func Load() (*Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tickFlowAPIKey := os.Getenv("TICKFLOW_API_KEY")
|
||||||
|
if tickFlowAPIKey == "" {
|
||||||
|
tickFlowAPIKey = "tk_94a20304993f45b5b0e376b9767597cc"
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
return &Config{
|
||||||
DatabaseURL: databaseURL,
|
DatabaseURL: databaseURL,
|
||||||
JWTSecret: jwtSecret,
|
JWTSecret: jwtSecret,
|
||||||
JWTExpirationHours: expHours,
|
JWTExpirationHours: expHours,
|
||||||
Port: port,
|
Port: port,
|
||||||
AllowedOrigins: allowedOrigins,
|
AllowedOrigins: allowedOrigins,
|
||||||
|
TickFlowAPIKey: tickFlowAPIKey,
|
||||||
|
TickFlowBaseURL: tickFlowBaseURL,
|
||||||
|
DataSyncEnabled: dataSyncEnabled,
|
||||||
|
DataSyncTime: dataSyncTime,
|
||||||
|
DataSyncWeekdays: dataSyncWeekdays,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
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(tickflow 返回小数,如 0.30 = 30%),
|
||||||
|
// 否则根据 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
|
||||||
|
}
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KlineData 对应 /v1/klines/batch 返回的单标的历史 K 线(紧凑列式)。
|
||||||
|
type KlineData struct {
|
||||||
|
Symbol string `json:"-"`
|
||||||
|
Name string `json:"-"`
|
||||||
|
Timestamp []int64 `json:"timestamp"`
|
||||||
|
Open []float64 `json:"open"`
|
||||||
|
High []float64 `json:"high"`
|
||||||
|
Low []float64 `json:"low"`
|
||||||
|
Close []float64 `json:"close"`
|
||||||
|
Volume []int64 `json:"volume"`
|
||||||
|
Amount []float64 `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, "es); 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, "es); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return quotes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetKlinesBatch 批量获取多只股票的历史日 K 线。
|
||||||
|
// symbols 最多 100 只(tickflow 限制)。
|
||||||
|
func (c *Client) GetKlinesBatch(symbols []string, period string, startMs, endMs int64) (map[string]KlineData, error) {
|
||||||
|
if len(symbols) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if len(symbols) > 100 {
|
||||||
|
return nil, fmt.Errorf("GetKlinesBatch supports up to 100 symbols, got %d", len(symbols))
|
||||||
|
}
|
||||||
|
params := map[string]any{
|
||||||
|
"symbols": strings.Join(symbols, ","),
|
||||||
|
"period": period,
|
||||||
|
"adjust": "none",
|
||||||
|
"start_time": startMs,
|
||||||
|
"end_time": endMs,
|
||||||
|
"count": 10000,
|
||||||
|
}
|
||||||
|
var result map[string]KlineData
|
||||||
|
if err := c.request("GET", "/v1/klines/batch", params, nil, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for sym, data := range result {
|
||||||
|
data.Symbol = sym
|
||||||
|
result[sym] = data
|
||||||
|
}
|
||||||
|
return result, 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")
|
||||||
|
}
|
||||||
@@ -20,9 +20,9 @@ type AdminHandler struct {
|
|||||||
func allowedRolesForCreation(actor models.RoleName) []models.RoleName {
|
func allowedRolesForCreation(actor models.RoleName) []models.RoleName {
|
||||||
switch actor {
|
switch actor {
|
||||||
case models.RoleSystemAdmin:
|
case models.RoleSystemAdmin:
|
||||||
return []models.RoleName{models.RoleAdmin, models.RoleUser, models.RoleGuest}
|
return []models.RoleName{models.RoleAdmin, models.RoleUser}
|
||||||
case models.RoleAdmin:
|
case models.RoleAdmin:
|
||||||
return []models.RoleName{models.RoleUser, models.RoleGuest}
|
return []models.RoleName{models.RoleUser}
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -161,6 +161,10 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.Status != "" {
|
if req.Status != "" {
|
||||||
|
if target.Role.NameEnum() == models.RoleSystemAdmin && req.Status != "active" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "不能禁用系统管理员账号"})
|
||||||
|
return
|
||||||
|
}
|
||||||
updates["status"] = req.Status
|
updates["status"] = req.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +174,10 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "无效的角色"})
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "无效的角色"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if target.Role.NameEnum() == models.RoleSystemAdmin && newRole != models.RoleSystemAdmin {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "不能修改系统管理员账号的角色"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if !containsRole(allowedRolesForCreation(current.Role), newRole) || !current.Role.CanManage(newRole) {
|
if !containsRole(allowedRolesForCreation(current.Role), newRole) || !current.Role.CanManage(newRole) {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "权限不足"})
|
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "权限不足"})
|
||||||
return
|
return
|
||||||
@@ -211,6 +219,11 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if target.Role.NameEnum() == models.RoleSystemAdmin {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "不能删除系统管理员账号"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.DB.Delete(&target).Error; err != nil {
|
if err := h.DB.Delete(&target).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -19,80 +19,6 @@ type AuthHandler struct {
|
|||||||
CFG *config.Config
|
CFG *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) Register(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Username string `json:"username" binding:"required"`
|
|
||||||
Email *string `json:"email"`
|
|
||||||
Password string `json:"password" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求参数错误"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := validateUsername(req.Username); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(req.Password) < 6 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "密码长度至少 6 位"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
username := strings.ToLower(strings.TrimSpace(req.Username))
|
|
||||||
|
|
||||||
var role models.Role
|
|
||||||
if err := h.DB.Where("name = ?", models.RoleUser).First(&role).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hash, err := hashPassword(req.Password)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var email *string
|
|
||||||
if req.Email != nil && *req.Email != "" {
|
|
||||||
e := strings.ToLower(strings.TrimSpace(*req.Email))
|
|
||||||
email = &e
|
|
||||||
}
|
|
||||||
|
|
||||||
user := models.User{
|
|
||||||
Username: username,
|
|
||||||
Email: email,
|
|
||||||
PasswordHash: hash,
|
|
||||||
RoleID: role.ID,
|
|
||||||
Status: "active",
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.DB.Create(&user).Error; err != nil {
|
|
||||||
if strings.Contains(err.Error(), "duplicate key") {
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"success": false, "error": "用户名或邮箱已存在"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.DB.Preload("Role").First(&user, "id = ?", user.ID).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := middleware.GenerateToken(user.ID, models.RoleUser, h.CFG)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"token": token,
|
|
||||||
"user": user.ToPublicInfo(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AuthHandler) Login(c *gin.Context) {
|
func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Username string `json:"username" binding:"required"`
|
Username string `json:"username" binding:"required"`
|
||||||
@@ -153,13 +79,6 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "登出成功"})
|
c.JSON(http.StatusOK, gin.H{"message": "登出成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) PublicInfo(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"message": "欢迎访问 A股工具公开信息",
|
|
||||||
"guest_allowed": true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func hashPassword(password string) (string, error) {
|
func hashPassword(password string) (string, error) {
|
||||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
return string(bytes), err
|
return string(bytes), err
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"stock-user-system/internal/middleware"
|
||||||
|
"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"`
|
||||||
|
PrevClose float64 `json:"prev_close,omitempty"`
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
baseWhere := "trade_date = ? AND symbol NOT IN ? AND NOT (volume = 0 AND change_pct = 0)"
|
||||||
|
|
||||||
|
var counts marketCounts
|
||||||
|
if err := h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
|
||||||
|
Select(`
|
||||||
|
COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN close > prev_close THEN 1 ELSE 0 END) AS up,
|
||||||
|
SUM(CASE WHEN close < prev_close THEN 1 ELSE 0 END) AS down,
|
||||||
|
SUM(CASE WHEN close = prev_close THEN 1 ELSE 0 END) AS flat
|
||||||
|
`).
|
||||||
|
Where(baseWhere, 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(baseWhere, latestDate, indexSymbolsList()).
|
||||||
|
Scan(&avgChange)
|
||||||
|
|
||||||
|
indices := h.topRows(ctx, latestDate, "symbol IN ?", indexSymbolsList(), 10, false)
|
||||||
|
gainers := h.topRows(ctx, latestDate, "symbol NOT IN ? AND NOT (volume = 0 AND change_pct = 0)", indexSymbolsList(), 10, true)
|
||||||
|
losers := h.topRows(ctx, latestDate, "symbol NOT IN ? AND NOT (volume = 0 AND change_pct = 0)", 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 ? AND NOT (volume = 0 AND change_pct = 0)", 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) {
|
||||||
|
user, ok := middleware.GetCurrentUser(c)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "未登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var job *models.StockSyncJob
|
||||||
|
var err error
|
||||||
|
if user.Role == models.RoleSystemAdmin {
|
||||||
|
job, err = h.Svc.StartSyncHistory(c.Request.Context(), "manual", 6)
|
||||||
|
} else {
|
||||||
|
job, err = h.Svc.StartSync(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, "job": job})
|
||||||
|
}
|
||||||
|
|
||||||
|
type distributionRow struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type distributionScan struct {
|
||||||
|
SH int64 `gorm:"column:sh"`
|
||||||
|
SZ int64 `gorm:"column:sz"`
|
||||||
|
BJ int64 `gorm:"column:bj"`
|
||||||
|
Up0_3 int64 `gorm:"column:up_0_3"`
|
||||||
|
Up3_5 int64 `gorm:"column:up_3_5"`
|
||||||
|
Up5_7 int64 `gorm:"column:up_5_7"`
|
||||||
|
Up7_10 int64 `gorm:"column:up_7_10"`
|
||||||
|
Up10 int64 `gorm:"column:up_10"`
|
||||||
|
Down0_3 int64 `gorm:"column:down_0_3"`
|
||||||
|
Down3_5 int64 `gorm:"column:down_3_5"`
|
||||||
|
Down5_7 int64 `gorm:"column:down_5_7"`
|
||||||
|
Down7_10 int64 `gorm:"column:down_7_10"`
|
||||||
|
Down10 int64 `gorm:"column:down_10"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type debugStatsResponse struct {
|
||||||
|
TradeDate string `json:"trade_date"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
IndexCount int64 `json:"index_count"`
|
||||||
|
SuspendedCount int64 `json:"suspended_count"`
|
||||||
|
UpByPrice int64 `json:"up_by_price"`
|
||||||
|
DownByPrice int64 `json:"down_by_price"`
|
||||||
|
FlatByPrice int64 `json:"flat_by_price"`
|
||||||
|
UpByPct int64 `json:"up_by_pct"`
|
||||||
|
DownByPct int64 `json:"down_by_pct"`
|
||||||
|
FlatByPct int64 `json:"flat_by_pct"`
|
||||||
|
ExchangeCounts map[string]int64 `json:"exchange_counts"`
|
||||||
|
UpDistribution []distributionRow `json:"up_distribution"`
|
||||||
|
DownDistribution []distributionRow `json:"down_distribution"`
|
||||||
|
FlatSamples []stockRow `json:"flat_samples"`
|
||||||
|
BoundarySamples []stockRow `json:"boundary_samples"`
|
||||||
|
UpBoundarySamples []stockRow `json:"up_boundary_samples"`
|
||||||
|
DownBoundarySamples []stockRow `json:"down_boundary_samples"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebugStats 返回最新交易日的详细统计与边界样本,便于对齐第三方口径。
|
||||||
|
func (h *StockHandler) DebugStats(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, gin.H{"success": true, "data": debugStatsResponse{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idxList := indexSymbolsList()
|
||||||
|
baseWhere := "trade_date = ? AND symbol NOT IN ? AND NOT (volume = 0 AND change_pct = 0)"
|
||||||
|
|
||||||
|
var stats debugStatsResponse
|
||||||
|
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
|
||||||
|
Select(`
|
||||||
|
COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN close > prev_close THEN 1 ELSE 0 END) AS up_by_price,
|
||||||
|
SUM(CASE WHEN close < prev_close THEN 1 ELSE 0 END) AS down_by_price,
|
||||||
|
SUM(CASE WHEN close = prev_close THEN 1 ELSE 0 END) AS flat_by_price,
|
||||||
|
SUM(CASE WHEN change_pct > 0 THEN 1 ELSE 0 END) AS up_by_pct,
|
||||||
|
SUM(CASE WHEN change_pct < 0 THEN 1 ELSE 0 END) AS down_by_pct,
|
||||||
|
SUM(CASE WHEN change_pct = 0 THEN 1 ELSE 0 END) AS flat_by_pct
|
||||||
|
`).
|
||||||
|
Where(baseWhere, latestDate, idxList).
|
||||||
|
Scan(&stats)
|
||||||
|
|
||||||
|
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
|
||||||
|
Where("trade_date = ? AND symbol IN ?", latestDate, idxList).
|
||||||
|
Count(&stats.IndexCount)
|
||||||
|
|
||||||
|
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
|
||||||
|
Where("trade_date = ? AND symbol NOT IN ? AND volume = 0 AND change_pct = 0", latestDate, idxList).
|
||||||
|
Count(&stats.SuspendedCount)
|
||||||
|
|
||||||
|
stats.TradeDate = latestDate.Format("2006-01-02")
|
||||||
|
|
||||||
|
var dist distributionScan
|
||||||
|
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
|
||||||
|
Select(`
|
||||||
|
SUM(CASE WHEN symbol LIKE '%.SH' THEN 1 ELSE 0 END) AS sh,
|
||||||
|
SUM(CASE WHEN symbol LIKE '%.SZ' THEN 1 ELSE 0 END) AS sz,
|
||||||
|
SUM(CASE WHEN symbol LIKE '%.BJ' THEN 1 ELSE 0 END) AS bj,
|
||||||
|
SUM(CASE WHEN change_pct > 0 AND change_pct <= 0.03 THEN 1 ELSE 0 END) AS up_0_3,
|
||||||
|
SUM(CASE WHEN change_pct > 0.03 AND change_pct <= 0.05 THEN 1 ELSE 0 END) AS up_3_5,
|
||||||
|
SUM(CASE WHEN change_pct > 0.05 AND change_pct <= 0.07 THEN 1 ELSE 0 END) AS up_5_7,
|
||||||
|
SUM(CASE WHEN change_pct > 0.07 AND change_pct <= 0.10 THEN 1 ELSE 0 END) AS up_7_10,
|
||||||
|
SUM(CASE WHEN change_pct > 0.10 THEN 1 ELSE 0 END) AS up_10,
|
||||||
|
SUM(CASE WHEN change_pct < 0 AND change_pct >= -0.03 THEN 1 ELSE 0 END) AS down_0_3,
|
||||||
|
SUM(CASE WHEN change_pct < -0.03 AND change_pct >= -0.05 THEN 1 ELSE 0 END) AS down_3_5,
|
||||||
|
SUM(CASE WHEN change_pct < -0.05 AND change_pct >= -0.07 THEN 1 ELSE 0 END) AS down_5_7,
|
||||||
|
SUM(CASE WHEN change_pct < -0.07 AND change_pct >= -0.10 THEN 1 ELSE 0 END) AS down_7_10,
|
||||||
|
SUM(CASE WHEN change_pct < -0.10 THEN 1 ELSE 0 END) AS down_10
|
||||||
|
`).
|
||||||
|
Where(baseWhere, latestDate, idxList).
|
||||||
|
Scan(&dist)
|
||||||
|
|
||||||
|
stats.ExchangeCounts = map[string]int64{
|
||||||
|
"sh": dist.SH,
|
||||||
|
"sz": dist.SZ,
|
||||||
|
"bj": dist.BJ,
|
||||||
|
}
|
||||||
|
stats.UpDistribution = []distributionRow{
|
||||||
|
{Label: "0~3%", Count: dist.Up0_3},
|
||||||
|
{Label: "3~5%", Count: dist.Up3_5},
|
||||||
|
{Label: "5~7%", Count: dist.Up5_7},
|
||||||
|
{Label: "7~10%", Count: dist.Up7_10},
|
||||||
|
{Label: ">10%", Count: dist.Up10},
|
||||||
|
}
|
||||||
|
stats.DownDistribution = []distributionRow{
|
||||||
|
{Label: "3~0%", Count: dist.Down0_3},
|
||||||
|
{Label: "5~3%", Count: dist.Down3_5},
|
||||||
|
{Label: "7~5%", Count: dist.Down5_7},
|
||||||
|
{Label: "10~7%", Count: dist.Down7_10},
|
||||||
|
{Label: ">10%", Count: dist.Down10},
|
||||||
|
}
|
||||||
|
|
||||||
|
var flatSamples []stockRow
|
||||||
|
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
|
||||||
|
Select("symbol, name, close, prev_close, change_pct").
|
||||||
|
Where("trade_date = ? AND symbol NOT IN ? AND close = prev_close", latestDate, idxList).
|
||||||
|
Order("amount DESC").
|
||||||
|
Limit(20).
|
||||||
|
Scan(&flatSamples)
|
||||||
|
stats.FlatSamples = flatSamples
|
||||||
|
|
||||||
|
var boundarySamples []stockRow
|
||||||
|
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
|
||||||
|
Select("symbol, name, close, prev_close, change_pct").
|
||||||
|
Where("trade_date = ? AND symbol NOT IN ?", latestDate, idxList).
|
||||||
|
Order("ABS(change_pct) ASC").
|
||||||
|
Limit(20).
|
||||||
|
Scan(&boundarySamples)
|
||||||
|
stats.BoundarySamples = boundarySamples
|
||||||
|
|
||||||
|
var upBoundarySamples []stockRow
|
||||||
|
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
|
||||||
|
Select("symbol, name, close, prev_close, change_pct").
|
||||||
|
Where("trade_date = ? AND symbol NOT IN ? AND close > prev_close", latestDate, idxList).
|
||||||
|
Order("change_pct ASC").
|
||||||
|
Limit(50).
|
||||||
|
Scan(&upBoundarySamples)
|
||||||
|
stats.UpBoundarySamples = upBoundarySamples
|
||||||
|
|
||||||
|
var downBoundarySamples []stockRow
|
||||||
|
h.DB.WithContext(ctx).Model(&models.StockDailyQuote{}).
|
||||||
|
Select("symbol, name, close, prev_close, change_pct").
|
||||||
|
Where("trade_date = ? AND symbol NOT IN ? AND close < prev_close", latestDate, idxList).
|
||||||
|
Order("change_pct DESC").
|
||||||
|
Limit(50).
|
||||||
|
Scan(&downBoundarySamples)
|
||||||
|
stats.DownBoundarySamples = downBoundarySamples
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": stats})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@ type CurrentUser struct {
|
|||||||
Role models.RoleName
|
Role models.RoleName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var publicPaths = map[string]struct{}{
|
||||||
|
"/api/auth/login": {},
|
||||||
|
}
|
||||||
|
|
||||||
func AuthMiddleware(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
|
func AuthMiddleware(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
authHeader := c.GetHeader("Authorization")
|
authHeader := c.GetHeader("Authorization")
|
||||||
@@ -41,6 +45,17 @@ func AuthMiddleware(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, exists := c.Get("currentUser"); !exists {
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := publicPaths[c.Request.URL.Path]; !ok {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "error": "访问令牌无效或已过期"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{})
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,7 +14,6 @@ const (
|
|||||||
RoleSystemAdmin RoleName = "system_admin"
|
RoleSystemAdmin RoleName = "system_admin"
|
||||||
RoleAdmin RoleName = "admin"
|
RoleAdmin RoleName = "admin"
|
||||||
RoleUser RoleName = "user"
|
RoleUser RoleName = "user"
|
||||||
RoleGuest RoleName = "guest"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r RoleName) String() string { return string(r) }
|
func (r RoleName) String() string { return string(r) }
|
||||||
@@ -43,10 +43,8 @@ func ParseRoleName(s string) (RoleName, error) {
|
|||||||
return RoleAdmin, nil
|
return RoleAdmin, nil
|
||||||
case "user":
|
case "user":
|
||||||
return RoleUser, nil
|
return RoleUser, nil
|
||||||
case "guest":
|
|
||||||
return RoleGuest, nil
|
|
||||||
default:
|
default:
|
||||||
return RoleGuest, fmt.Errorf("unknown role: %s", s)
|
return "", fmt.Errorf("unknown role: %s", s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +103,6 @@ func SeedRoles(db *gorm.DB) error {
|
|||||||
{Name: string(RoleSystemAdmin), Description: strPtr("系统管理员,可管理管理员与系统配置"), Permissions: `["*"]`},
|
{Name: string(RoleSystemAdmin), Description: strPtr("系统管理员,可管理管理员与系统配置"), Permissions: `["*"]`},
|
||||||
{Name: string(RoleAdmin), Description: strPtr("管理员,可管理普通用户"), Permissions: `["users.read", "users.write", "users.create"]`},
|
{Name: string(RoleAdmin), Description: strPtr("管理员,可管理普通用户"), Permissions: `["users.read", "users.write", "users.create"]`},
|
||||||
{Name: string(RoleUser), Description: strPtr("普通用户,可访问业务功能"), Permissions: `["dashboard.read", "profile.write"]`},
|
{Name: string(RoleUser), Description: strPtr("普通用户,可访问业务功能"), Permissions: `["dashboard.read", "profile.write"]`},
|
||||||
{Name: string(RoleGuest), Description: strPtr("游客,仅可查看公开内容"), Permissions: `["public.read"]`},
|
|
||||||
}
|
}
|
||||||
for _, role := range roles {
|
for _, role := range roles {
|
||||||
var existing Role
|
var existing Role
|
||||||
@@ -122,6 +119,33 @@ func SeedRoles(db *gorm.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SeedSystemAdmin 在没有 system_admin 用户时创建默认系统管理员账号。
|
||||||
|
func SeedSystemAdmin(db *gorm.DB) error {
|
||||||
|
var existing User
|
||||||
|
if err := db.Where("username = ?", "system_admin").First(&existing).Error; err == nil {
|
||||||
|
return nil
|
||||||
|
} else if err != gorm.ErrRecordNotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var role Role
|
||||||
|
if err := db.Where("name = ?", RoleSystemAdmin).First(&role).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte("system_admin"), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.Create(&User{
|
||||||
|
Username: "system_admin",
|
||||||
|
PasswordHash: string(hash),
|
||||||
|
RoleID: role.ID,
|
||||||
|
Status: "active",
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
func strPtr(s string) *string {
|
func strPtr(s string) *string {
|
||||||
return &s
|
return &s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ import (
|
|||||||
"stock-user-system/internal/handlers"
|
"stock-user-system/internal/handlers"
|
||||||
"stock-user-system/internal/middleware"
|
"stock-user-system/internal/middleware"
|
||||||
"stock-user-system/internal/models"
|
"stock-user-system/internal/models"
|
||||||
|
"stock-user-system/internal/services"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"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}
|
authHandler := &handlers.AuthHandler{DB: db, CFG: cfg}
|
||||||
adminHandler := &handlers.AdminHandler{DB: db, CFG: cfg}
|
adminHandler := &handlers.AdminHandler{DB: db, CFG: cfg}
|
||||||
|
stockHandler := &handlers.StockHandler{DB: db, Svc: stockSyncSvc}
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
@@ -30,9 +32,7 @@ func Setup(cfg *config.Config, db *gorm.DB) *gin.Engine {
|
|||||||
|
|
||||||
r.Use(middleware.AuthMiddleware(cfg, db))
|
r.Use(middleware.AuthMiddleware(cfg, db))
|
||||||
|
|
||||||
// 公开接口
|
// 公开接口(仅登录)
|
||||||
r.GET("/api/public", authHandler.PublicInfo)
|
|
||||||
r.POST("/api/auth/register", authHandler.Register)
|
|
||||||
r.POST("/api/auth/login", authHandler.Login)
|
r.POST("/api/auth/login", authHandler.Login)
|
||||||
|
|
||||||
// 受保护接口
|
// 受保护接口
|
||||||
@@ -43,6 +43,14 @@ func Setup(cfg *config.Config, db *gorm.DB) *gin.Engine {
|
|||||||
auth.POST("/logout", authHandler.Logout)
|
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 := r.Group("/api/admin")
|
||||||
admin.Use(middleware.RequireAuth(), middleware.RequireRoles(models.RoleAdmin, models.RoleSystemAdmin))
|
admin.Use(middleware.RequireAuth(), middleware.RequireRoles(models.RoleAdmin, models.RoleSystemAdmin))
|
||||||
@@ -52,6 +60,8 @@ func Setup(cfg *config.Config, db *gorm.DB) *gin.Engine {
|
|||||||
admin.PUT("/users/:id", adminHandler.UpdateUser)
|
admin.PUT("/users/:id", adminHandler.UpdateUser)
|
||||||
admin.DELETE("/users/:id", adminHandler.DeleteUser)
|
admin.DELETE("/users/:id", adminHandler.DeleteUser)
|
||||||
admin.GET("/roles", adminHandler.ListRoles)
|
admin.GET("/roles", adminHandler.ListRoles)
|
||||||
|
admin.POST("/stocks/sync", stockHandler.TriggerSync)
|
||||||
|
admin.GET("/stocks/debug-stats", stockHandler.DebugStats)
|
||||||
}
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"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) {
|
||||||
|
return s.runSyncJob(ctx, triggerBy, func() (int, error) {
|
||||||
|
return s.doSync(ctx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncHistory 同步近 N 个月的历史日 K 数据,返回写入的记录数。
|
||||||
|
func (s *StockSyncService) SyncHistory(ctx context.Context, triggerBy string, months int) (int, error) {
|
||||||
|
return s.runSyncJob(ctx, triggerBy, func() (int, error) {
|
||||||
|
return s.doSyncHistory(ctx, months)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartSync 异步启动一次盘后同步,返回已创建的 job。
|
||||||
|
func (s *StockSyncService) StartSync(ctx context.Context, triggerBy string) (*models.StockSyncJob, error) {
|
||||||
|
job, err := s.createJob(ctx, triggerBy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
go s.runSyncJobByID(context.Background(), job.ID, func(ctx context.Context) (int, error) {
|
||||||
|
return s.doSync(ctx)
|
||||||
|
})
|
||||||
|
return job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartSyncHistory 异步启动近 N 个月历史同步,返回已创建的 job。
|
||||||
|
func (s *StockSyncService) StartSyncHistory(ctx context.Context, triggerBy string, months int) (*models.StockSyncJob, error) {
|
||||||
|
job, err := s.createJob(ctx, triggerBy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
go s.runSyncJobByID(context.Background(), job.ID, func(ctx context.Context) (int, error) {
|
||||||
|
return s.doSyncHistory(ctx, months)
|
||||||
|
})
|
||||||
|
return job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StockSyncService) createJob(ctx context.Context, triggerBy string) (*models.StockSyncJob, error) {
|
||||||
|
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 nil, fmt.Errorf("create sync job: %w", err)
|
||||||
|
}
|
||||||
|
return &job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StockSyncService) runSyncJob(ctx context.Context, triggerBy string, fn func() (int, error)) (int, error) {
|
||||||
|
job, err := s.createJob(ctx, triggerBy)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
recordsCount, err := fn()
|
||||||
|
s.finishJob(ctx, job.ID, recordsCount, err)
|
||||||
|
return recordsCount, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StockSyncService) runSyncJobByID(ctx context.Context, jobID string, fn func(context.Context) (int, error)) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
recordsCount, err := fn(ctx)
|
||||||
|
s.finishJob(ctx, jobID, recordsCount, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StockSyncService) finishJob(ctx context.Context, jobID string, recordsCount int, jobErr error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
updates := map[string]any{
|
||||||
|
"records_count": recordsCount,
|
||||||
|
"finished_at": &now,
|
||||||
|
}
|
||||||
|
if jobErr != nil {
|
||||||
|
updates["status"] = "failed"
|
||||||
|
updates["error_message"] = jobErr.Error()
|
||||||
|
log.Printf("[stock sync] job %s failed: %v", jobID, jobErr)
|
||||||
|
} else {
|
||||||
|
updates["status"] = "success"
|
||||||
|
log.Printf("[stock sync] job %s success, records=%d", jobID, recordsCount)
|
||||||
|
}
|
||||||
|
if err := s.db.WithContext(ctx).Model(&models.StockSyncJob{}).Where("id = ?", jobID).Updates(updates).Error; err != nil {
|
||||||
|
log.Printf("[stock sync] update job %s failed: %v", jobID, 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[stock sync] fetched %d stock quotes from CN_Equity_A, %d index quotes, total %d",
|
||||||
|
len(stockQuotes), len(indexQuotes), len(quotes))
|
||||||
|
|
||||||
|
// 3. 归一化并写入 DB
|
||||||
|
records := make([]models.StockDailyQuote, 0, len(quotes))
|
||||||
|
for _, q := range quotes {
|
||||||
|
tradeDate := msToDate(q.Timestamp)
|
||||||
|
open := round2(q.Open)
|
||||||
|
high := round2(q.High)
|
||||||
|
low := round2(q.Low)
|
||||||
|
close := round2(q.Close)
|
||||||
|
prevClose := round2(q.PrevClose)
|
||||||
|
var changePct float64
|
||||||
|
if prevClose != 0 {
|
||||||
|
changePct = (close - prevClose) / prevClose
|
||||||
|
}
|
||||||
|
records = append(records, models.StockDailyQuote{
|
||||||
|
Symbol: q.Symbol,
|
||||||
|
Name: q.DisplayName(),
|
||||||
|
TradeDate: tradeDate,
|
||||||
|
Open: open,
|
||||||
|
High: high,
|
||||||
|
Low: low,
|
||||||
|
Close: close,
|
||||||
|
PrevClose: prevClose,
|
||||||
|
Volume: q.Volume,
|
||||||
|
Amount: q.Amount,
|
||||||
|
ChangePct: 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).
|
||||||
|
Where("started_at > ?", time.Time{}).
|
||||||
|
Order("started_at DESC").
|
||||||
|
First(&job).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StockSyncService) doSyncHistory(ctx context.Context, months int) (int, error) {
|
||||||
|
// 1. 获取全 A 股代码列表
|
||||||
|
universe, err := s.client.GetUniverse("CN_Equity_A")
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("fetch universe: %w", err)
|
||||||
|
}
|
||||||
|
if universe == nil || len(universe.Symbols) == 0 {
|
||||||
|
return 0, fmt.Errorf("empty universe")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取核心指数列表
|
||||||
|
allSymbols := append([]string{}, universe.Symbols...)
|
||||||
|
allSymbols = append(allSymbols, coreIndexSymbols...)
|
||||||
|
|
||||||
|
// 3. 计算时间范围(近 N 个月)
|
||||||
|
endMs := time.Now().In(chinaLoc).UnixMilli()
|
||||||
|
startMs := time.Now().In(chinaLoc).AddDate(0, -months, 0).UnixMilli()
|
||||||
|
|
||||||
|
log.Printf("[stock sync] start history sync for %d months, symbols=%d, range=%d-%d",
|
||||||
|
months, len(allSymbols), startMs, endMs)
|
||||||
|
|
||||||
|
// 4. 分批拉取并写入
|
||||||
|
const batchSize = 50
|
||||||
|
totalRecords := 0
|
||||||
|
for i := 0; i < len(allSymbols); i += batchSize {
|
||||||
|
batch := allSymbols[i:min(i+batchSize, len(allSymbols))]
|
||||||
|
klinesMap, err := s.client.GetKlinesBatch(batch, "1d", startMs, endMs)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[stock sync] klines batch %d-%d failed: %v", i+1, min(i+batchSize, len(allSymbols)), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
records := make([]models.StockDailyQuote, 0)
|
||||||
|
for sym, data := range klinesMap {
|
||||||
|
name := sym
|
||||||
|
records = append(records, klineDataToRecords(sym, name, data)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(records) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
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 totalRecords, fmt.Errorf("upsert history batch %d: %w", i/batchSize, err)
|
||||||
|
}
|
||||||
|
totalRecords += len(records)
|
||||||
|
log.Printf("[stock sync] history batch %d/%d done, records=%d, total=%d",
|
||||||
|
(i/batchSize)+1, (len(allSymbols)+batchSize-1)/batchSize, len(records), totalRecords)
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalRecords, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func klineDataToRecords(symbol, name string, data datasource.KlineData) []models.StockDailyQuote {
|
||||||
|
n := len(data.Timestamp)
|
||||||
|
if n == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(data.Open) < n { n = len(data.Open) }
|
||||||
|
if len(data.High) < n { n = len(data.High) }
|
||||||
|
if len(data.Low) < n { n = len(data.Low) }
|
||||||
|
if len(data.Close) < n { n = len(data.Close) }
|
||||||
|
if len(data.Volume) < n { n = len(data.Volume) }
|
||||||
|
if len(data.Amount) < n { n = len(data.Amount) }
|
||||||
|
|
||||||
|
records := make([]models.StockDailyQuote, 0, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
open := round2(data.Open[i])
|
||||||
|
high := round2(data.High[i])
|
||||||
|
low := round2(data.Low[i])
|
||||||
|
close := round2(data.Close[i])
|
||||||
|
var prevClose float64
|
||||||
|
if i > 0 {
|
||||||
|
prevClose = round2(data.Close[i-1])
|
||||||
|
}
|
||||||
|
var changePct float64
|
||||||
|
if prevClose != 0 {
|
||||||
|
changePct = (close - prevClose) / prevClose
|
||||||
|
}
|
||||||
|
records = append(records, models.StockDailyQuote{
|
||||||
|
Symbol: symbol,
|
||||||
|
Name: name,
|
||||||
|
TradeDate: msToDate(data.Timestamp[i]),
|
||||||
|
Open: open,
|
||||||
|
High: high,
|
||||||
|
Low: low,
|
||||||
|
Close: close,
|
||||||
|
PrevClose: prevClose,
|
||||||
|
Volume: data.Volume[i],
|
||||||
|
Amount: data.Amount[i],
|
||||||
|
ChangePct: changePct,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func round2(v float64) float64 {
|
||||||
|
return math.Round(v*100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -29,6 +29,11 @@ services:
|
|||||||
JWT_EXPIRATION_HOURS: ${JWT_EXPIRATION_HOURS:-168}
|
JWT_EXPIRATION_HOURS: ${JWT_EXPIRATION_HOURS:-168}
|
||||||
GIN_MODE: ${GIN_MODE:-release}
|
GIN_MODE: ${GIN_MODE:-release}
|
||||||
PORT: 3019
|
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:
|
ports:
|
||||||
- "3019:3019"
|
- "3019:3019"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { X, UserPlus, AlertCircle, Loader2 } from 'lucide-react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { RoleName, roleLabel } from '@/lib/auth'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
|
interface CreateUserDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSuccess: () => void
|
||||||
|
allowedRoles: RoleName[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateUserDialog({ open, onClose, onSuccess, allowedRoles }: CreateUserDialogProps) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
role: (allowedRoles[0] || 'user') as RoleName,
|
||||||
|
})
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (!form.username.trim()) {
|
||||||
|
setError('请输入用户名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.password.length < 6) {
|
||||||
|
setError('密码长度至少 6 位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.password !== form.confirmPassword) {
|
||||||
|
setError('两次输入的密码不一致')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!allowedRoles.includes(form.role as RoleName)) {
|
||||||
|
setError('没有权限分配该角色')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await api.createUser({
|
||||||
|
username: form.username.trim(),
|
||||||
|
email: form.email.trim() || undefined,
|
||||||
|
password: form.password,
|
||||||
|
role: form.role,
|
||||||
|
})
|
||||||
|
setForm({ username: '', email: '', password: '', confirmPassword: '', role: (allowedRoles[0] || 'user') as RoleName })
|
||||||
|
onSuccess()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || '创建失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||||
|
<div className="relative w-full max-w-md rounded-card border border-border bg-surface p-6 shadow-xl">
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 rounded-btn bg-accent/10 text-accent">
|
||||||
|
<UserPlus className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-base font-semibold text-foreground">创建用户</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-btn text-secondary hover:bg-elevated hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</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 mb-4">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-secondary mb-1">用户名</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
placeholder="用户名"
|
||||||
|
value={form.username}
|
||||||
|
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||||
|
className="w-full rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-secondary mb-1">邮箱(可选)</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="邮箱"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
className="w-full rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-secondary mb-1">密码</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
placeholder="密码"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
className="w-full rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-secondary mb-1">确认密码</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
placeholder="确认密码"
|
||||||
|
value={form.confirmPassword}
|
||||||
|
onChange={(e) => setForm({ ...form, confirmPassword: e.target.value })}
|
||||||
|
className="w-full rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-secondary mb-1">角色</label>
|
||||||
|
<select
|
||||||
|
value={form.role}
|
||||||
|
onChange={(e) => setForm({ ...form, role: e.target.value as RoleName })}
|
||||||
|
className="w-full rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{allowedRoles.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{roleLabel(r)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded-btn text-sm text-secondary hover:bg-elevated hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2 px-4 py-2 rounded-btn bg-accent text-white text-sm font-medium hover:bg-accent/90 transition-colors',
|
||||||
|
loading && 'opacity-60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||||
|
创建
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -74,11 +74,8 @@ export function Layout() {
|
|||||||
<div>工作台</div>
|
<div>工作台</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2.5 text-[10px] uppercase tracking-[0.22em] text-secondary">
|
|
||||||
量化工具
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
className="mt-3 h-px"
|
className="mt-4 h-px"
|
||||||
style={{ background: `linear-gradient(90deg, ${BRAND}88, transparent 80%)` }}
|
style={{ background: `linear-gradient(90deg, ${BRAND}88, transparent 80%)` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Shield, Lock, AlertCircle, Loader2, UserPlus, LogIn } from 'lucide-react'
|
import { Shield, Lock, AlertCircle, Loader2, LogIn } from 'lucide-react'
|
||||||
import { api, setToken } from '@/lib/api'
|
import { api, setToken } from '@/lib/api'
|
||||||
import { setStoredUser } from '@/lib/auth'
|
import { setStoredUser } from '@/lib/auth'
|
||||||
|
|
||||||
@@ -8,9 +8,7 @@ interface LoginFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LoginForm({ onSuccess }: LoginFormProps) {
|
export function LoginForm({ onSuccess }: LoginFormProps) {
|
||||||
const [mode, setMode] = useState<'login' | 'register'>('login')
|
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [email, setEmail] = useState('')
|
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -22,25 +20,14 @@ export function LoginForm({ onSuccess }: LoginFormProps) {
|
|||||||
setError('请输入用户名和密码')
|
setError('请输入用户名和密码')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (mode === 'register' && password.length < 6) {
|
|
||||||
setError('密码长度至少 6 位')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res =
|
const res = await api.login({ username: username.trim(), password })
|
||||||
mode === 'login'
|
|
||||||
? await api.login({ username: username.trim(), password })
|
|
||||||
: await api.register({
|
|
||||||
username: username.trim(),
|
|
||||||
email: email.trim() || undefined,
|
|
||||||
password,
|
|
||||||
})
|
|
||||||
setToken(res.token)
|
setToken(res.token)
|
||||||
setStoredUser(res.user)
|
setStoredUser(res.user)
|
||||||
onSuccess()
|
onSuccess()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.message || '操作失败,请稍后重试')
|
setError(err?.message || '登录失败,请稍后重试')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -61,13 +48,9 @@ export function LoginForm({ onSuccess }: LoginFormProps) {
|
|||||||
>
|
>
|
||||||
<Shield className="h-8 w-8" style={{ color: '#8B5CF6' }} />
|
<Shield className="h-8 w-8" style={{ color: '#8B5CF6' }} />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-5 text-2xl font-bold text-foreground tracking-tight">
|
<h1 className="mt-5 text-2xl font-bold text-foreground tracking-tight">登录账号</h1>
|
||||||
{mode === 'login' ? '登录账号' : '注册账号'}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-sm text-secondary leading-relaxed">
|
<p className="mt-2 text-sm text-secondary leading-relaxed">
|
||||||
{mode === 'login'
|
请输入用户名和密码进入系统。
|
||||||
? '请输入用户名和密码进入系统。'
|
|
||||||
: '注册后默认获得用户权限。'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -86,29 +69,13 @@ export function LoginForm({ onSuccess }: LoginFormProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === 'register' && (
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted">
|
|
||||||
<Shield className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
placeholder="邮箱(可选)"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="w-full pl-9 pr-3 py-2.5 rounded-input bg-base border border-border text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted">
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted">
|
||||||
<Lock className="h-4 w-4" />
|
<Lock className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
autoComplete="current-password"
|
||||||
placeholder="密码"
|
placeholder="密码"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
@@ -130,26 +97,15 @@ export function LoginForm({ onSuccess }: LoginFormProps) {
|
|||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : mode === 'login' ? (
|
|
||||||
<LogIn className="h-4 w-4" />
|
|
||||||
) : (
|
) : (
|
||||||
<UserPlus className="h-4 w-4" />
|
<LogIn className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{loading ? '处理中…' : mode === 'login' ? '登录' : '注册'}
|
{loading ? '处理中…' : '登录'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-5 text-center">
|
<div className="mt-5 text-center">
|
||||||
<button
|
<span className="text-xs text-secondary">没有账号?请联系管理员创建</span>
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setMode(mode === 'login' ? 'register' : 'login')
|
|
||||||
setError('')
|
|
||||||
}}
|
|
||||||
className="text-xs text-secondary hover:text-accent transition-colors"
|
|
||||||
>
|
|
||||||
{mode === 'login' ? '没有账号?立即注册' : '已有账号?直接登录'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { useEffect, useRef, 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 pollRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
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()
|
||||||
|
return () => {
|
||||||
|
if (pollRef.current) {
|
||||||
|
window.clearInterval(pollRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startPolling = () => {
|
||||||
|
if (pollRef.current) {
|
||||||
|
window.clearInterval(pollRef.current)
|
||||||
|
}
|
||||||
|
pollRef.current = window.setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const j = await api.stockSyncStatus()
|
||||||
|
setJob(j)
|
||||||
|
if (!j || j.status !== 'running') {
|
||||||
|
if (pollRef.current) {
|
||||||
|
window.clearInterval(pollRef.current)
|
||||||
|
pollRef.current = null
|
||||||
|
}
|
||||||
|
setSyncing(false)
|
||||||
|
await fetchAll()
|
||||||
|
if (j?.status === 'failed') {
|
||||||
|
setError(j.error_message || '同步失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (pollRef.current) {
|
||||||
|
window.clearInterval(pollRef.current)
|
||||||
|
pollRef.current = null
|
||||||
|
}
|
||||||
|
setSyncing(false)
|
||||||
|
setError(err?.message || '轮询同步状态失败')
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
setSyncing(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.triggerStockSync()
|
||||||
|
startPolling()
|
||||||
|
} catch (err: any) {
|
||||||
|
setSyncing(false)
|
||||||
|
setError(err?.message || '同步失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 * 100).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 * 100).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 * 100
|
||||||
|
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 ts = job.finished_at || job.started_at
|
||||||
|
let timeStr = '时间未知'
|
||||||
|
if (ts && ts !== '0001-01-01T00:00:00Z') {
|
||||||
|
const d = new Date(ts)
|
||||||
|
if (!isNaN(d.getTime())) {
|
||||||
|
timeStr = d.toLocaleString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const statusText = job.status === 'success' ? '成功' : '失败'
|
||||||
|
return `${statusText} · ${timeStr}`
|
||||||
|
}
|
||||||
+43
-11
@@ -10,14 +10,14 @@ export interface UserInfo {
|
|||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
email?: string
|
email?: string
|
||||||
role: 'system_admin' | 'admin' | 'user' | 'guest'
|
role: 'system_admin' | 'admin' | 'user'
|
||||||
status: string
|
status: string
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Role {
|
export interface Role {
|
||||||
id: number
|
id: number
|
||||||
name: 'system_admin' | 'admin' | 'user' | 'guest'
|
name: 'system_admin' | 'admin' | 'user'
|
||||||
description?: string
|
description?: string
|
||||||
permissions: string[]
|
permissions: string[]
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -28,6 +28,40 @@ export interface AuthResponse {
|
|||||||
user: UserInfo
|
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 {
|
export function getToken(): string | null {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem('access_token')
|
return localStorage.getItem('access_token')
|
||||||
@@ -79,15 +113,6 @@ async function request<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
publicInfo: () =>
|
|
||||||
request<{ message: string; guest_allowed: boolean }>('/api/public'),
|
|
||||||
|
|
||||||
register: (body: { username: string; email?: string; password: string }) =>
|
|
||||||
request<AuthResponse>('/api/auth/register', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}),
|
|
||||||
|
|
||||||
login: (body: { username: string; password: string }) =>
|
login: (body: { username: string; password: string }) =>
|
||||||
request<AuthResponse>('/api/auth/login', {
|
request<AuthResponse>('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -116,4 +141,11 @@ export const api = {
|
|||||||
request<{ message: string }>(`/api/admin/users/${id}`, { method: 'DELETE' }),
|
request<{ message: string }>(`/api/admin/users/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
listRoles: () => request<Role[]>('/api/admin/roles'),
|
listRoles: () => request<Role[]>('/api/admin/roles'),
|
||||||
|
|
||||||
|
stockOverview: () => request<MarketOverview>('/api/stocks/overview'),
|
||||||
|
|
||||||
|
stockSyncStatus: () => request<SyncJob | null>('/api/stocks/sync/status'),
|
||||||
|
|
||||||
|
triggerStockSync: () =>
|
||||||
|
request<{ job: SyncJob }>('/api/admin/stocks/sync', { method: 'POST' }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,13 +30,12 @@ export function clearAuth() {
|
|||||||
localStorage.removeItem('user')
|
localStorage.removeItem('user')
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RoleName = 'system_admin' | 'admin' | 'user' | 'guest'
|
export type RoleName = 'system_admin' | 'admin' | 'user'
|
||||||
|
|
||||||
const ROLE_RANK: Record<RoleName, number> = {
|
const ROLE_RANK: Record<RoleName, number> = {
|
||||||
system_admin: 3,
|
system_admin: 3,
|
||||||
admin: 2,
|
admin: 2,
|
||||||
user: 1,
|
user: 1,
|
||||||
guest: 0,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function roleRank(role: RoleName): number {
|
export function roleRank(role: RoleName): number {
|
||||||
@@ -51,8 +50,6 @@ export function roleLabel(role: RoleName): string {
|
|||||||
return '管理员'
|
return '管理员'
|
||||||
case 'user':
|
case 'user':
|
||||||
return '用户'
|
return '用户'
|
||||||
case 'guest':
|
|
||||||
return '游客'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { LayoutDashboard, ShieldCheck } from 'lucide-react'
|
||||||
import { LayoutDashboard, Globe, ShieldCheck } from 'lucide-react'
|
|
||||||
import { api } from '@/lib/api'
|
|
||||||
import { getStoredUser, roleLabel } from '@/lib/auth'
|
import { getStoredUser, roleLabel } from '@/lib/auth'
|
||||||
|
import { StockOverview } from '@/components/StockOverview'
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const [publicInfo, setPublicInfo] = useState<{ message: string } | null>(null)
|
|
||||||
const user = getStoredUser()
|
const user = getStoredUser()
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.publicInfo().then(setPublicInfo).catch(() => null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -19,22 +13,18 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-foreground">看板</h1>
|
<h1 className="text-xl font-bold text-foreground">看板</h1>
|
||||||
<p className="text-sm text-secondary">欢迎回来,{user?.username || '游客'}</p>
|
<p className="text-sm text-secondary">欢迎回来,{user?.username || '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<StockOverview role={user?.role || 'user'} />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<Card
|
<Card
|
||||||
icon={<ShieldCheck className="h-5 w-5 text-accent" />}
|
icon={<ShieldCheck className="h-5 w-5 text-accent" />}
|
||||||
title="当前身份"
|
title="当前身份"
|
||||||
value={user ? roleLabel(user.role) : '游客'}
|
value={user ? roleLabel(user.role) : '—'}
|
||||||
desc={user ? `用户:${user.username}` : '未登录,仅可查看公开信息'}
|
desc={user ? `用户:${user.username}` : '加载中…'}
|
||||||
/>
|
|
||||||
<Card
|
|
||||||
icon={<Globe className="h-5 w-5 text-bear" />}
|
|
||||||
title="公开信息"
|
|
||||||
value="已接入"
|
|
||||||
desc={publicInfo?.message || '加载中…'}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -44,7 +34,6 @@ export function Dashboard() {
|
|||||||
<li><span className="text-accent font-medium">系统管理员</span>:可管理管理员、用户、系统配置</li>
|
<li><span className="text-accent font-medium">系统管理员</span>:可管理管理员、用户、系统配置</li>
|
||||||
<li><span className="text-accent font-medium">管理员</span>:可管理普通用户</li>
|
<li><span className="text-accent font-medium">管理员</span>:可管理普通用户</li>
|
||||||
<li><span className="text-accent font-medium">用户</span>:可访问业务功能、修改个人资料</li>
|
<li><span className="text-accent font-medium">用户</span>:可访问业务功能、修改个人资料</li>
|
||||||
<li><span className="text-accent font-medium">游客</span>:仅查看公开内容,不可操作</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Loader2, Plus, Trash2, Users as UsersIcon, AlertCircle } from 'lucide-react'
|
import { Plus, Trash2, Users as UsersIcon, AlertCircle, Loader2 } from 'lucide-react'
|
||||||
import { api, Role, UserInfo } from '@/lib/api'
|
import { api, UserInfo } from '@/lib/api'
|
||||||
import { canDeleteUsers, roleLabel } from '@/lib/auth'
|
import { canDeleteUsers, getStoredUser, roleLabel, RoleName } from '@/lib/auth'
|
||||||
|
import { CreateUserDialog } from '@/components/CreateUserDialog'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
|
const creatableRoles: Record<RoleName, RoleName[]> = {
|
||||||
|
system_admin: ['admin', 'user'],
|
||||||
|
admin: ['user'],
|
||||||
|
user: [],
|
||||||
|
}
|
||||||
|
|
||||||
export function Users() {
|
export function Users() {
|
||||||
const [users, setUsers] = useState<UserInfo[]>([])
|
const [users, setUsers] = useState<UserInfo[]>([])
|
||||||
const [roles, setRoles] = useState<Role[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [formOpen, setFormOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [form, setForm] = useState({ username: '', email: '', password: '', role: 'user' })
|
|
||||||
|
const currentUser = getStoredUser()
|
||||||
|
const allowedRoles = currentUser ? creatableRoles[currentUser.role] : []
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [u, r] = await Promise.all([api.listUsers(), api.listRoles()])
|
const u = await api.listUsers()
|
||||||
setUsers(u)
|
setUsers(u)
|
||||||
setRoles(r)
|
|
||||||
setError('')
|
setError('')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.message || '加载失败')
|
setError(err?.message || '加载失败')
|
||||||
@@ -30,23 +37,6 @@ export function Users() {
|
|||||||
fetchData()
|
fetchData()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleCreate = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
try {
|
|
||||||
await api.createUser({
|
|
||||||
username: form.username,
|
|
||||||
email: form.email || undefined,
|
|
||||||
password: form.password,
|
|
||||||
role: form.role,
|
|
||||||
})
|
|
||||||
setForm({ username: '', email: '', password: '', role: 'user' })
|
|
||||||
setFormOpen(false)
|
|
||||||
fetchData()
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.message || '创建失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!confirm('确定删除该用户?')) return
|
if (!confirm('确定删除该用户?')) return
|
||||||
try {
|
try {
|
||||||
@@ -66,7 +56,7 @@ export function Users() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentUser = users.find((u) => u.username === JSON.parse(localStorage.getItem('user') || '{}')?.username)
|
const current = users.find((u) => u.username === currentUser?.username)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -78,7 +68,7 @@ export function Users() {
|
|||||||
<h1 className="text-xl font-bold text-foreground">用户管理</h1>
|
<h1 className="text-xl font-bold text-foreground">用户管理</h1>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setFormOpen(!formOpen)}
|
onClick={() => setDialogOpen(true)}
|
||||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-btn bg-accent text-white text-sm font-medium hover:bg-accent/90 transition-colors"
|
className="inline-flex items-center gap-2 px-3 py-2 rounded-btn bg-accent text-white text-sm font-medium hover:bg-accent/90 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -93,51 +83,6 @@ export function Users() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{formOpen && (
|
|
||||||
<form
|
|
||||||
onSubmit={handleCreate}
|
|
||||||
className="rounded-card border border-border bg-surface p-4 grid grid-cols-1 md:grid-cols-5 gap-3"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
placeholder="用户名"
|
|
||||||
value={form.username}
|
|
||||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
|
||||||
className="rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
placeholder="邮箱"
|
|
||||||
value={form.email}
|
|
||||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
|
||||||
className="rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
type="password"
|
|
||||||
placeholder="密码"
|
|
||||||
value={form.password}
|
|
||||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
|
||||||
className="rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={form.role}
|
|
||||||
onChange={(e) => setForm({ ...form, role: e.target.value })}
|
|
||||||
className="rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
|
||||||
>
|
|
||||||
{roles.map((r) => (
|
|
||||||
<option key={r.id} value={r.name}>{roleLabel(r.name)}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="md:col-span-5 inline-flex items-center justify-center gap-2 px-4 py-2 rounded-btn bg-accent text-white text-sm font-medium hover:bg-accent/90 transition-colors"
|
|
||||||
>
|
|
||||||
创建
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="rounded-card border border-border bg-surface overflow-hidden">
|
<div className="rounded-card border border-border bg-surface overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-elevated/50 text-secondary">
|
<thead className="bg-elevated/50 text-secondary">
|
||||||
@@ -173,7 +118,7 @@ export function Users() {
|
|||||||
<select
|
<select
|
||||||
value={u.status}
|
value={u.status}
|
||||||
onChange={(e) => handleStatusChange(u, e.target.value)}
|
onChange={(e) => handleStatusChange(u, e.target.value)}
|
||||||
disabled={u.id === currentUser?.id}
|
disabled={u.id === current?.id}
|
||||||
className="bg-base border border-border rounded-input px-2 py-1 text-xs disabled:opacity-50"
|
className="bg-base border border-border rounded-input px-2 py-1 text-xs disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value="active">启用</option>
|
<option value="active">启用</option>
|
||||||
@@ -184,7 +129,7 @@ export function Users() {
|
|||||||
{new Date(u.created_at).toLocaleString()}
|
{new Date(u.created_at).toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
{canDeleteUsers(currentUser?.role || 'user') && u.id !== currentUser?.id && (
|
{canDeleteUsers(current?.role || 'user') && u.id !== current?.id && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(u.id)}
|
onClick={() => handleDelete(u.id)}
|
||||||
className="p-1.5 rounded-btn text-danger hover:bg-danger/10 transition-colors"
|
className="p-1.5 rounded-btn text-danger hover:bg-danger/10 transition-colors"
|
||||||
@@ -199,6 +144,16 @@ export function Users() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CreateUserDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onClose={() => setDialogOpen(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setDialogOpen(false)
|
||||||
|
fetchData()
|
||||||
|
}}
|
||||||
|
allowedRoles={allowedRoles}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ export const router = createBrowserRouter([
|
|||||||
{ path: '/login', element: <Login /> },
|
{ path: '/login', element: <Login /> },
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
element: <Layout />,
|
element: (
|
||||||
|
<AuthGuard requireAuth>
|
||||||
|
<Layout />
|
||||||
|
</AuthGuard>
|
||||||
|
),
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <Dashboard /> },
|
{ index: true, element: <Dashboard /> },
|
||||||
{
|
{
|
||||||
@@ -23,11 +27,7 @@ export const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'profile',
|
path: 'profile',
|
||||||
element: (
|
element: <Profile />,
|
||||||
<AuthGuard requireAuth>
|
|
||||||
<Profile />
|
|
||||||
</AuthGuard>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user