管理员增加数据同步功能,支持初始化股票列表
This commit is contained in:
@@ -26,6 +26,10 @@ func main() {
|
|||||||
log.Fatalf("migrate database: %v", err)
|
log.Fatalf("migrate database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := models.AutoMigrateStocks(database); err != nil {
|
||||||
|
log.Fatalf("migrate stocks table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := models.SeedRoles(database); err != nil {
|
if err := models.SeedRoles(database); err != nil {
|
||||||
log.Fatalf("seed roles: %v", err)
|
log.Fatalf("seed roles: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type Config struct {
|
|||||||
JWTExpirationHours int
|
JWTExpirationHours int
|
||||||
Port string
|
Port string
|
||||||
AllowedOrigins []string
|
AllowedOrigins []string
|
||||||
|
TushareToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -50,12 +51,15 @@ func Load() (*Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tushareToken := os.Getenv("TUSHARE_TOKEN")
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
DatabaseURL: databaseURL,
|
DatabaseURL: databaseURL,
|
||||||
JWTSecret: jwtSecret,
|
JWTSecret: jwtSecret,
|
||||||
JWTExpirationHours: expHours,
|
JWTExpirationHours: expHours,
|
||||||
Port: port,
|
Port: port,
|
||||||
AllowedOrigins: allowedOrigins,
|
AllowedOrigins: allowedOrigins,
|
||||||
|
TushareToken: tushareToken,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package datasource
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"stock-user-system/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tushareBaseURL = "http://api.tushare.pro"
|
||||||
|
defaultTimeout = 60 * time.Second
|
||||||
|
maxRetries = 3
|
||||||
|
retryBaseDelay = 1 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// StockBasic 表示股票基础信息。
|
||||||
|
type StockBasic struct {
|
||||||
|
TsCode string
|
||||||
|
Symbol string
|
||||||
|
Name string
|
||||||
|
Area string
|
||||||
|
Industry string
|
||||||
|
Market string
|
||||||
|
Exchange string
|
||||||
|
ListStatus string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client 封装 Tushare Pro HTTP API 调用。
|
||||||
|
type Client struct {
|
||||||
|
token string
|
||||||
|
baseURL string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient 从配置创建 Tushare 客户端。
|
||||||
|
func NewClient(cfg *config.Config) *Client {
|
||||||
|
return &Client{
|
||||||
|
token: cfg.TushareToken,
|
||||||
|
baseURL: tushareBaseURL,
|
||||||
|
client: &http.Client{Timeout: defaultTimeout},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListStocks 通过 stock_basic 接口获取全部上市股票基础信息。
|
||||||
|
func (c *Client) ListStocks() ([]StockBasic, error) {
|
||||||
|
params := map[string]any{
|
||||||
|
"list_status": "L",
|
||||||
|
"fields": "ts_code,symbol,name,area,industry,market,exchange,list_status",
|
||||||
|
}
|
||||||
|
fields, items, err := c.call("stock_basic", params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stock_basic: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
col := buildColumnMap(fields)
|
||||||
|
idxTsCode := col["ts_code"]
|
||||||
|
idxSymbol := col["symbol"]
|
||||||
|
idxName := col["name"]
|
||||||
|
idxArea := col["area"]
|
||||||
|
idxIndustry := col["industry"]
|
||||||
|
idxMarket := col["market"]
|
||||||
|
idxExchange := col["exchange"]
|
||||||
|
idxListStatus := col["list_status"]
|
||||||
|
|
||||||
|
stocks := make([]StockBasic, 0, len(items))
|
||||||
|
for _, row := range items {
|
||||||
|
code := stringAt(row, idxTsCode)
|
||||||
|
if code == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stocks = append(stocks, StockBasic{
|
||||||
|
TsCode: code,
|
||||||
|
Symbol: stringAt(row, idxSymbol),
|
||||||
|
Name: stringAt(row, idxName),
|
||||||
|
Area: stringAt(row, idxArea),
|
||||||
|
Industry: stringAt(row, idxIndustry),
|
||||||
|
Market: stringAt(row, idxMarket),
|
||||||
|
Exchange: stringAt(row, idxExchange),
|
||||||
|
ListStatus: stringAt(row, idxListStatus),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return stocks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// call 调用 Tushare Pro API,返回字段名与数据行。
|
||||||
|
func (c *Client) call(apiName string, params map[string]any) ([]string, [][]any, error) {
|
||||||
|
reqBody := map[string]any{
|
||||||
|
"api_name": apiName,
|
||||||
|
"token": c.token,
|
||||||
|
"params": params,
|
||||||
|
"fields": "",
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
time.Sleep(retryBaseDelay * time.Duration(1<<(attempt-1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", c.baseURL, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
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("tushare %s returned %d: %s", apiName, resp.StatusCode, string(respBody))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, nil, fmt.Errorf("tushare %s returned %d: %s", apiName, resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var wrapper struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data *struct {
|
||||||
|
Fields []string `json:"fields"`
|
||||||
|
Items [][]any `json:"items"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &wrapper); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("decode tushare response: %w", err)
|
||||||
|
}
|
||||||
|
if wrapper.Code != 0 {
|
||||||
|
return nil, nil, fmt.Errorf("tushare %s error %d: %s", apiName, wrapper.Code, wrapper.Msg)
|
||||||
|
}
|
||||||
|
if wrapper.Data == nil {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
return wrapper.Data.Fields, wrapper.Data.Items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr != nil {
|
||||||
|
return nil, nil, fmt.Errorf("tushare %s failed after %d retries: %w", apiName, maxRetries, lastErr)
|
||||||
|
}
|
||||||
|
return nil, nil, fmt.Errorf("tushare %s request failed", apiName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildColumnMap(fields []string) map[string]int {
|
||||||
|
m := make(map[string]int, len(fields))
|
||||||
|
for i, f := range fields {
|
||||||
|
m[f] = i
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringAt(row []any, idx int) string {
|
||||||
|
if idx < 0 || idx >= len(row) || row[idx] == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch v := row[idx].(type) {
|
||||||
|
case string:
|
||||||
|
return v
|
||||||
|
case []byte:
|
||||||
|
return string(v)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func floatAt(row []any, idx int) float64 {
|
||||||
|
if idx < 0 || idx >= len(row) || row[idx] == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch v := row[idx].(type) {
|
||||||
|
case float64:
|
||||||
|
return v
|
||||||
|
case float32:
|
||||||
|
return float64(v)
|
||||||
|
case int:
|
||||||
|
return float64(v)
|
||||||
|
case int64:
|
||||||
|
return float64(v)
|
||||||
|
case string:
|
||||||
|
f, _ := strconv.ParseFloat(v, 64)
|
||||||
|
return f
|
||||||
|
default:
|
||||||
|
f, _ := strconv.ParseFloat(fmt.Sprintf("%v", v), 64)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"stock-user-system/internal/config"
|
||||||
|
"stock-user-system/internal/datasource"
|
||||||
|
"stock-user-system/internal/models"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DataSyncHandler 处理数据同步相关接口。
|
||||||
|
type DataSyncHandler struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
CFG *config.Config
|
||||||
|
client *datasource.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDataSyncHandler 创建数据同步处理器。
|
||||||
|
func NewDataSyncHandler(db *gorm.DB, cfg *config.Config) *DataSyncHandler {
|
||||||
|
return &DataSyncHandler{
|
||||||
|
DB: db,
|
||||||
|
CFG: cfg,
|
||||||
|
client: datasource.NewClient(cfg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type initStocksResponse struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitStocks 从 Tushare 拉取全部上市股票列表并写入数据库。
|
||||||
|
func (h *DataSyncHandler) InitStocks(c *gin.Context) {
|
||||||
|
if h.CFG.TushareToken == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "未配置 TUSHARE_TOKEN"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stocks, err := h.client.ListStocks()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
records := make([]models.Stock, 0, len(stocks))
|
||||||
|
for _, s := range stocks {
|
||||||
|
records = append(records, models.Stock{
|
||||||
|
TsCode: s.TsCode,
|
||||||
|
Symbol: s.Symbol,
|
||||||
|
Name: s.Name,
|
||||||
|
Area: s.Area,
|
||||||
|
Industry: s.Industry,
|
||||||
|
Market: s.Market,
|
||||||
|
Exchange: s.Exchange,
|
||||||
|
ListStatus: s.ListStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.DB.Exec("TRUNCATE TABLE stocks RESTART IDENTITY").Error; err != nil {
|
||||||
|
if !strings.Contains(err.Error(), "does not exist") {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "清空旧数据失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.DB.CreateInBatches(records, 500).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "写入股票列表失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"data": initStocksResponse{
|
||||||
|
Count: len(records),
|
||||||
|
Message: "股票列表初始化完成",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stock 存储股票基础信息。
|
||||||
|
type Stock struct {
|
||||||
|
ID string `json:"id" gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
|
||||||
|
TsCode string `json:"ts_code" gorm:"size:32;not null;uniqueIndex"`
|
||||||
|
Symbol string `json:"symbol" gorm:"size:32;not null;index"`
|
||||||
|
Name string `json:"name" gorm:"size:128"`
|
||||||
|
Area string `json:"area" gorm:"size:64"`
|
||||||
|
Industry string `json:"industry" gorm:"size:64"`
|
||||||
|
Market string `json:"market" gorm:"size:16"`
|
||||||
|
Exchange string `json:"exchange" gorm:"size:16"`
|
||||||
|
ListStatus string `json:"list_status" gorm:"size:8"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoMigrateStocks 迁移股票基础信息表。
|
||||||
|
func AutoMigrateStocks(db *gorm.DB) error {
|
||||||
|
return db.AutoMigrate(&Stock{})
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
func Setup(cfg *config.Config, db *gorm.DB) *gin.Engine {
|
func Setup(cfg *config.Config, db *gorm.DB) *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}
|
||||||
|
dataSyncHandler := handlers.NewDataSyncHandler(db, cfg)
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ 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("/data-sync/init-stocks", dataSyncHandler.InitStocks)
|
||||||
}
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
|||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
User,
|
User,
|
||||||
|
Database,
|
||||||
LogOut,
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
Sun,
|
Sun,
|
||||||
@@ -46,6 +47,7 @@ export function Layout() {
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
...(user && canManageUsers(user.role) ? [{ to: '/users', label: '用户管理', icon: Users }] : []),
|
...(user && canManageUsers(user.role) ? [{ to: '/users', label: '用户管理', icon: Users }] : []),
|
||||||
|
...(user && canManageUsers(user.role) ? [{ to: '/data-sync', label: '数据同步', icon: Database }] : []),
|
||||||
{ to: '/profile', label: '个人中心', icon: User },
|
{ to: '/profile', label: '个人中心', icon: User },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ export interface AuthResponse {
|
|||||||
user: UserInfo
|
user: UserInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InitStocksResponse {
|
||||||
|
success: boolean
|
||||||
|
data: {
|
||||||
|
count: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem('access_token')
|
return localStorage.getItem('access_token')
|
||||||
@@ -107,4 +115,7 @@ 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'),
|
||||||
|
|
||||||
|
initStocks: () =>
|
||||||
|
request<InitStocksResponse>('/api/admin/data-sync/init-stocks', { method: 'POST' }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Database, RefreshCw, CheckCircle, AlertCircle } from 'lucide-react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
|
||||||
|
export function DataSync() {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [result, setResult] = useState<{ count: number; message: string } | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleInitStocks = async () => {
|
||||||
|
if (!confirm('确定要从 Tushare 初始化股票列表?这会清空现有股票数据。')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
const res = await api.initStocks()
|
||||||
|
setResult(res.data)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || '初始化失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-btn bg-accent/10 text-accent">
|
||||||
|
<Database className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-foreground">数据同步</h1>
|
||||||
|
<p className="text-sm text-secondary">从 Tushare 拉取基础数据</p>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="flex items-start gap-2 rounded-btn border border-success/30 bg-success/10 px-3 py-2.5 text-xs text-success">
|
||||||
|
<CheckCircle className="h-3.5 w-3.5 mt-px shrink-0" />
|
||||||
|
<span>{result.message},共 {result.count} 条记录</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-card border border-border bg-surface p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">初始化股票列表</h2>
|
||||||
|
<p className="text-xs text-secondary mt-1">调用 Tushare stock_basic 接口,获取全部 A 股基础信息</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleInitStocks}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-btn bg-accent text-white text-sm font-medium hover:bg-accent/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{loading ? '同步中...' : '开始同步'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-card border border-border bg-surface p-5">
|
||||||
|
<h2 className="text-sm font-semibold text-foreground mb-3">说明</h2>
|
||||||
|
<ul className="space-y-2 text-sm text-secondary">
|
||||||
|
<li>• 需要配置 TUSHARE_TOKEN 环境变量</li>
|
||||||
|
<li>• 每次同步会清空 stocks 表并重新写入</li>
|
||||||
|
<li>• 仅获取基础字段:代码、名称、交易所、行业等</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { AuthGuard } from './components/AuthGuard'
|
|||||||
import { Login } from './pages/Login'
|
import { Login } from './pages/Login'
|
||||||
import { Users } from './pages/Users'
|
import { Users } from './pages/Users'
|
||||||
import { Profile } from './pages/Profile'
|
import { Profile } from './pages/Profile'
|
||||||
|
import { DataSync } from './pages/DataSync'
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{ path: '/login', element: <Login /> },
|
{ path: '/login', element: <Login /> },
|
||||||
@@ -24,6 +25,14 @@ export const router = createBrowserRouter([
|
|||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'data-sync',
|
||||||
|
element: (
|
||||||
|
<AuthGuard allowedRoles={['admin', 'system_admin']} requireAuth>
|
||||||
|
<DataSync />
|
||||||
|
</AuthGuard>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'profile',
|
path: 'profile',
|
||||||
element: <Profile />,
|
element: <Profile />,
|
||||||
|
|||||||
Reference in New Issue
Block a user