From 58bbc394e9221b087705aed878a016a7cb581948 Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Tue, 7 Jul 2026 10:39:33 +0800 Subject: [PATCH] feat: enhance redis and production IA --- backend/internal/handler/connection_tester.go | 21 +- backend/internal/handler/preferences.go | 2 +- backend/internal/handler/redis.go | 188 +++++-- backend/internal/model/model.go | 2 +- backend/router/router.go | 10 +- frontend/src/App.tsx | 6 +- frontend/src/api/auth.ts | 35 +- frontend/src/api/auth/auth.ts | 34 ++ frontend/src/api/auth/index.ts | 2 + frontend/src/api/dashboard.ts | 6 +- frontend/src/api/dashboard/dashboard.ts | 5 + frontend/src/api/dashboard/index.ts | 2 + frontend/src/api/datasource/db-types.ts | 51 ++ frontend/src/api/datasource/dictionary.ts | 22 + frontend/src/api/datasource/index.ts | 8 + .../src/api/datasource/instance-detail.ts | 89 ++++ frontend/src/api/datasource/instance-group.ts | 38 ++ frontend/src/api/datasource/instance.ts | 15 + frontend/src/api/datasource/mongo.ts | 24 + frontend/src/api/datasource/redis.ts | 47 ++ frontend/src/api/dbTypes.ts | 51 +- frontend/src/api/dictionary.ts | 22 +- frontend/src/api/index.ts | 8 + frontend/src/api/instance.ts | 15 +- frontend/src/api/instanceDetail.ts | 89 +--- frontend/src/api/instanceGroup.ts | 38 +- frontend/src/api/mongo.ts | 24 +- frontend/src/api/notification.ts | 9 +- frontend/src/api/query.ts | 16 +- frontend/src/api/query/index.ts | 2 + frontend/src/api/query/query.ts | 15 + frontend/src/api/redis.ts | 43 +- frontend/src/api/system/index.ts | 4 + frontend/src/api/system/notification.ts | 9 + frontend/src/api/system/user.ts | 25 + frontend/src/api/system/workflow.ts | 11 + frontend/src/api/ticket.ts | 38 +- frontend/src/api/ticket/index.ts | 2 + frontend/src/api/ticket/ticket.ts | 37 ++ frontend/src/api/user.ts | 25 +- frontend/src/api/workflow.ts | 11 +- .../src/components/DataDictionaryPanel.tsx | 474 ++++++++++++++++++ .../src/components/EditInstanceDialog.tsx | 14 +- frontend/src/components/Layout.tsx | 7 +- frontend/src/components/PageHeader.tsx | 8 +- frontend/src/components/login/AuroraView.tsx | 2 +- frontend/src/components/login/CosmosView.tsx | 4 +- .../src/components/login/GradientMeshView.tsx | 2 +- frontend/src/components/login/InkWashView.tsx | 2 +- frontend/src/components/login/MinimalDark.tsx | 2 +- .../src/components/login/NeonCityView.tsx | 2 +- frontend/src/components/login/SidePanel.tsx | 6 +- frontend/src/constants/menu.ts | 47 +- frontend/src/hooks/useAuth.tsx | 28 +- frontend/src/hooks/useLogin.tsx | 4 +- frontend/src/hooks/useTheme.tsx | 31 +- frontend/src/index.css | 36 +- frontend/src/lib/constants.ts | 52 +- frontend/src/pages/AppearanceSettings.tsx | 16 +- frontend/src/pages/AuditLogsPage.tsx | 4 +- frontend/src/pages/AuditRulesPage.tsx | 6 +- frontend/src/pages/ConfigPlatformPage.tsx | 14 +- frontend/src/pages/Dashboard.tsx | 4 +- frontend/src/pages/DataDictionaryPage.tsx | 10 +- frontend/src/pages/InstanceDetailPage.tsx | 48 +- frontend/src/pages/InstancePage.tsx | 14 +- frontend/src/pages/LoginPage.tsx | 12 +- frontend/src/pages/MongoDBDetailPage.tsx | 2 +- frontend/src/pages/RedisDetailPage.tsx | 406 ++++++++++++--- frontend/src/pages/RolesPage.tsx | 16 +- frontend/src/pages/TicketCreatePage.tsx | 2 +- frontend/src/pages/TicketDetailPage.tsx | 12 +- frontend/src/pages/TicketsPage.tsx | 10 +- frontend/src/pages/UsersPage.tsx | 6 +- frontend/src/pages/WorkflowPage.tsx | 6 +- run.sh | 13 +- 76 files changed, 1702 insertions(+), 721 deletions(-) create mode 100644 frontend/src/api/auth/auth.ts create mode 100644 frontend/src/api/auth/index.ts create mode 100644 frontend/src/api/dashboard/dashboard.ts create mode 100644 frontend/src/api/dashboard/index.ts create mode 100644 frontend/src/api/datasource/db-types.ts create mode 100644 frontend/src/api/datasource/dictionary.ts create mode 100644 frontend/src/api/datasource/index.ts create mode 100644 frontend/src/api/datasource/instance-detail.ts create mode 100644 frontend/src/api/datasource/instance-group.ts create mode 100644 frontend/src/api/datasource/instance.ts create mode 100644 frontend/src/api/datasource/mongo.ts create mode 100644 frontend/src/api/datasource/redis.ts create mode 100644 frontend/src/api/index.ts create mode 100644 frontend/src/api/query/index.ts create mode 100644 frontend/src/api/query/query.ts create mode 100644 frontend/src/api/system/index.ts create mode 100644 frontend/src/api/system/notification.ts create mode 100644 frontend/src/api/system/user.ts create mode 100644 frontend/src/api/system/workflow.ts create mode 100644 frontend/src/api/ticket/index.ts create mode 100644 frontend/src/api/ticket/ticket.ts create mode 100644 frontend/src/components/DataDictionaryPanel.tsx diff --git a/backend/internal/handler/connection_tester.go b/backend/internal/handler/connection_tester.go index 5abb406..5b81b20 100644 --- a/backend/internal/handler/connection_tester.go +++ b/backend/internal/handler/connection_tester.go @@ -141,12 +141,13 @@ func testRedis(ctx context.Context, host string, port int, username, pass string redisUsername = "" } rdb := redis.NewClient(&redis.Options{ - Addr: addr, - Username: redisUsername, - Password: pass, - DB: 0, - DialTimeout: 10 * time.Second, - ReadTimeout: 10 * time.Second, + Addr: addr, + Username: redisUsername, + Password: pass, + DB: 0, + DialTimeout: 10 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, }) defer rdb.Close() @@ -169,6 +170,14 @@ func testRedis(ctx context.Context, host string, port int, username, pass string } } } + if clusterInfo, err := rdb.Do(ctx, "CLUSTER", "INFO").Text(); err == nil { + for _, line := range strings.Split(clusterInfo, "\n") { + line = strings.TrimRight(line, "\r") + if strings.HasPrefix(line, "cluster_state:") { + isCluster = strings.TrimSpace(strings.TrimPrefix(line, "cluster_state:")) == "ok" + } + } + } mode := "standalone" if isCluster { diff --git a/backend/internal/handler/preferences.go b/backend/internal/handler/preferences.go index bf2cb66..48d1f94 100644 --- a/backend/internal/handler/preferences.go +++ b/backend/internal/handler/preferences.go @@ -69,7 +69,7 @@ func UpdateUserPreferences(c *gin.Context) { // GET /api/v1/public/login-style (no auth required) func GetPublicLoginStyle(c *gin.Context) { style := "split" - theme := "dark" + theme := "system" var s model.SystemSetting if model.DB.Where("`key` = ?", "login_style").Take(&s).Error == nil && s.Value != "" { diff --git a/backend/internal/handler/redis.go b/backend/internal/handler/redis.go index 285f864..d0042c7 100644 --- a/backend/internal/handler/redis.go +++ b/backend/internal/handler/redis.go @@ -184,6 +184,13 @@ func (w *redisClientWrapper) HSet(ctx context.Context, key string, values ...int return w.Client.HSet(ctx, key, values...) } +func (w *redisClientWrapper) ZAdd(ctx context.Context, key string, members ...redis.Z) *redis.IntCmd { + if w.isCluster && w.ClusterClient != nil { + return w.ClusterClient.ZAdd(ctx, key, members...) + } + return w.Client.ZAdd(ctx, key, members...) +} + func (w *redisClientWrapper) Expire(ctx context.Context, key string, expiration time.Duration) *redis.BoolCmd { if w.isCluster && w.ClusterClient != nil { return w.ClusterClient.Expire(ctx, key, expiration) @@ -219,6 +226,79 @@ func (w *redisClientWrapper) SScan(ctx context.Context, key string, cursor uint6 return w.Client.SScan(ctx, key, cursor, match, count) } +func writeRedisKeyValue(ctx context.Context, client *redisClientWrapper, key string, keyType string, value interface{}) error { + switch keyType { + case "string": + return client.Set(ctx, key, fmt.Sprintf("%v", value), 0).Err() + case "list": + values, ok := value.([]interface{}) + if !ok { + return fmt.Errorf("list value must be an array") + } + if len(values) == 0 { + return nil + } + items := make([]interface{}, 0, len(values)) + for _, v := range values { + items = append(items, fmt.Sprintf("%v", v)) + } + return client.RPush(ctx, key, items...).Err() + case "set": + values, ok := value.([]interface{}) + if !ok { + return fmt.Errorf("set value must be an array") + } + if len(values) == 0 { + return nil + } + items := make([]interface{}, 0, len(values)) + for _, v := range values { + items = append(items, fmt.Sprintf("%v", v)) + } + return client.SAdd(ctx, key, items...).Err() + case "hash": + values, ok := value.(map[string]interface{}) + if !ok { + return fmt.Errorf("hash value must be an object") + } + if len(values) == 0 { + return nil + } + return client.HSet(ctx, key, values).Err() + case "zset": + values, ok := value.([]interface{}) + if !ok { + return fmt.Errorf("zset value must be an array") + } + members := make([]redis.Z, 0, len(values)) + for _, row := range values { + m, ok := row.(map[string]interface{}) + if !ok { + return fmt.Errorf("zset rows must be objects with member and score") + } + member, ok := m["member"] + if !ok { + member = m["Member"] + } + scoreRaw, ok := m["score"] + if !ok { + scoreRaw = m["Score"] + } + score, err := strconv.ParseFloat(fmt.Sprintf("%v", scoreRaw), 64) + if err != nil { + return fmt.Errorf("invalid zset score: %v", scoreRaw) + } + members = append(members, redis.Z{Member: fmt.Sprintf("%v", member), Score: score}) + } + if len(members) == 0 { + return nil + } + return client.ZAdd(ctx, key, members...).Err() + default: + return fmt.Errorf("unsupported redis type: %s", keyType) + } +} + func getRedisClient(instance *model.Instance, db int) (*redisClientWrapper, error) { password := instance.Password // Decrypt if encrypted @@ -237,12 +317,16 @@ func getRedisClient(instance *model.Instance, db int) (*redisClientWrapper, erro } client := redis.NewClient(&redis.Options{ - Addr: addr, - Username: redisUsername, - Password: password, - DB: db, + Addr: addr, + Username: redisUsername, + Password: password, + DB: db, + DialTimeout: 5 * time.Second, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, }) - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() if err := client.Ping(ctx).Err(); err != nil { client.Close() return nil, fmt.Errorf("Redis connection failed: %v", err) @@ -250,16 +334,9 @@ func getRedisClient(instance *model.Instance, db int) (*redisClientWrapper, erro // Check cluster mode flag for wrapper behavior (ScanAllKeys needs to know) isCluster := instance.ClusterMode != nil && *instance.ClusterMode - wrapper := &redisClientWrapper{Client: client, isCluster: isCluster} - if !isCluster { - return wrapper, nil - } - - // Redis Cluster routes key commands by slot. Build the address list from - // CLUSTER SLOTS, but replace advertised internal node IPs with the configured - // host so Docker/NAT deployments can still reach the mapped node ports. addrs := []string{addr} if slots, err := client.ClusterSlots(ctx).Result(); err == nil && len(slots) > 0 { + isCluster = true seen := map[string]bool{} addrs = addrs[:0] for _, slot := range slots { @@ -280,10 +357,18 @@ func getRedisClient(instance *model.Instance, db int) (*redisClientWrapper, erro } } + wrapper := &redisClientWrapper{Client: client, isCluster: isCluster} + if !isCluster { + return wrapper, nil + } + clusterClient := redis.NewClusterClient(&redis.ClusterOptions{ - Addrs: addrs, - Username: redisUsername, - Password: password, + Addrs: addrs, + Username: redisUsername, + Password: password, + DialTimeout: 5 * time.Second, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, }) if err := clusterClient.Ping(ctx).Err(); err != nil { clusterClient.Close() @@ -710,25 +795,9 @@ func RedisCreateKey(c *gin.Context) { ctx := context.Background() - switch input.Type { - case "string": - client.Set(ctx, input.Key, input.Value, 0) - case "list": - if vals, ok := input.Value.([]interface{}); ok { - for _, v := range vals { - client.RPush(ctx, input.Key, fmt.Sprintf("%v", v)) - } - } - case "set": - if vals, ok := input.Value.([]interface{}); ok { - for _, v := range vals { - client.SAdd(ctx, input.Key, fmt.Sprintf("%v", v)) - } - } - case "hash": - if vals, ok := input.Value.(map[string]interface{}); ok { - client.HSet(ctx, input.Key, vals) - } + if err := writeRedisKeyValue(ctx, client, input.Key, input.Type, input.Value); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return } if input.TTL > 0 { @@ -738,6 +807,55 @@ func RedisCreateKey(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Key created"}) } +// PUT /api/v1/redis/:id/keys/:key +func RedisUpdateKey(c *gin.Context) { + id := c.Param("id") + key := c.Param("key") + dbParam := c.Query("db") + db := 0 + if dbParam != "" { + db, _ = strconv.Atoi(dbParam) + } + + var input struct { + Type string `json:"type" binding:"required"` + Value interface{} `json:"value"` + TTL int `json:"ttl"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + var instance model.Instance + if err := model.DB.First(&instance, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + return + } + + client, err := getRedisClient(&instance, db) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + defer client.Close() + + ctx := context.Background() + if err := client.Del(ctx, key).Err(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + if err := writeRedisKeyValue(ctx, client, key, input.Type, input.Value); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + if input.TTL > 0 { + client.Expire(ctx, key, time.Duration(input.TTL)*time.Second) + } + + c.JSON(http.StatusOK, gin.H{"message": "Key updated"}) +} + // DELETE /api/v1/redis/:id/keys/:key func RedisDeleteKey(c *gin.Context) { id := c.Param("id") diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index 6d42621..6ae0e01 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -19,7 +19,7 @@ type User struct { AuthSource string `gorm:"size:20;default:'local'" json:"auth_source"` // local / ldap Role string `gorm:"size:20;not null" json:"role"` // admin, dba, developer, auditor IsActive *bool `gorm:"default:true" json:"is_active"` - Preferences *string `gorm:"type:text" json:"preferences"` // JSON: {"theme":"dark","login_style":"split"} + Preferences *string `gorm:"type:text" json:"preferences"` // JSON: {"theme":"system","login_style":"split"} LastLogin *time.Time `json:"last_login"` FailedLoginAttempts int `gorm:"default:0" json:"failed_login_attempts"` LockedUntil *time.Time `json:"locked_until"` diff --git a/backend/router/router.go b/backend/router/router.go index 5df20e8..28fbe87 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -47,11 +47,11 @@ func Setup(r *gin.Engine) { auth.GET("/instances/:id", handler.GetInstance) auth.GET("/instances/:id/databases", handler.ListDatabases) auth.GET("/instances/:id/databases/:db/tables", handler.ListTables) - auth.POST("/instances/:id/test-connection", handler.TestConnection) - auth.POST("/instances/test-connection", handler.TestNewConnection) - auth.GET("/instances/:id/metrics", handler.InstanceMetrics) + auth.POST("/instances/:id/test-connection", handler.TestConnection) + auth.POST("/instances/test-connection", handler.TestNewConnection) + auth.GET("/instances/:id/metrics", handler.InstanceMetrics) - // Instance Detail (databases/tables/schema/processlist/status/users) + // Instance Detail (databases/tables/schema/processlist/status/users) auth.GET("/instances/:id/detail/databases", handler.DetailListDatabases) auth.GET("/instances/:id/detail/tables", handler.DetailListTables) auth.GET("/instances/:id/detail/db-schema", handler.DetailDbSchema) @@ -97,6 +97,7 @@ func Setup(r *gin.Engine) { auth.GET("/redis/:id/clients", handler.RedisClients) auth.POST("/redis/:id/command", handler.RedisExecuteCommand) auth.POST("/redis/:id/keys", handler.RedisCreateKey) + auth.PUT("/redis/:id/keys/:key", handler.RedisUpdateKey) auth.DELETE("/redis/:id/keys/:key", handler.RedisDeleteKey) // Users (admin only) @@ -162,7 +163,6 @@ func Setup(r *gin.Engine) { queryGroup.POST("/query/export", handler.ExportQueryCSV) } - // SQL Assistant auth.POST("/sql-assistant/format", handler.SQLFormat) auth.POST("/sql-assistant/analyze", handler.SQLAnalyze) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 278f917..0c5deb9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,7 +17,8 @@ const WorkflowPage = lazy(() => import('@/pages/WorkflowPage')); const AutoTaskPage = lazy(() => import('@/pages/AutoTaskPage')); const SettingsPage = lazy(() => import('@/pages/SettingsPage')); const AuditLogsPage = lazy(() => import('@/pages/AuditLogsPage')); -const DataDictionaryPage = lazy(() => import('@/pages/DataDictionaryPage')); +const AnnouncementPage = lazy(() => import('@/pages/AnnouncementPage')); +const NotificationPage = lazy(() => import('@/pages/NotificationPage')); const InstanceDetailPage = lazy(() => import('@/pages/InstanceDetailPage')); const RedisDetailPage = lazy(() => import('@/pages/RedisDetailPage')); const MongoDBDetailPage = lazy(() => import('@/pages/MongoDBDetailPage')); @@ -73,7 +74,6 @@ function AppRoutes() { } /> } /> } /> - } /> } /> } /> } /> @@ -84,6 +84,8 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index f3a5e87..f68792e 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,34 +1 @@ -import request from './request'; - -export interface LoginResponse { - access_token: string; - token_type: string; - user: { - id: number; - username: string; - display_name: string; - role: string; - email: string | null; - data_source_permissions?: string; - custom_permissions?: string; - group_ids?: number[]; - }; -} - -export interface MeResponse { - id: number; - username: string; - display_name: string; - role: string; - email: string | null; - data_source_permissions?: string; - custom_permissions?: string; - group_ids?: number[]; -} - -export const authApi = { - login: (username: string, password: string) => - request.post('/auth/login', { username, password }), - getMe: () => request.get('/auth/me'), - logout: () => request.post('/auth/logout'), -}; +export * from './auth/auth'; diff --git a/frontend/src/api/auth/auth.ts b/frontend/src/api/auth/auth.ts new file mode 100644 index 0000000..6326c8f --- /dev/null +++ b/frontend/src/api/auth/auth.ts @@ -0,0 +1,34 @@ +import request from '../request'; + +export interface LoginResponse { + access_token: string; + token_type: string; + user: { + id: number; + username: string; + display_name: string; + role: string; + email: string | null; + data_source_permissions?: string; + custom_permissions?: string; + group_ids?: number[]; + }; +} + +export interface MeResponse { + id: number; + username: string; + display_name: string; + role: string; + email: string | null; + data_source_permissions?: string; + custom_permissions?: string; + group_ids?: number[]; +} + +export const authApi = { + login: (username: string, password: string) => + request.post('/auth/login', { username, password }), + getMe: () => request.get('/auth/me'), + logout: () => request.post('/auth/logout'), +}; diff --git a/frontend/src/api/auth/index.ts b/frontend/src/api/auth/index.ts new file mode 100644 index 0000000..e632c6f --- /dev/null +++ b/frontend/src/api/auth/index.ts @@ -0,0 +1,2 @@ +export * from './auth'; + diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 7e8fd42..bc27bc4 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -1,5 +1 @@ -import request from './request'; - -export const dashboardApi = { - getStats: () => request.get('/dashboard/stats'), -}; +export * from './dashboard/dashboard'; diff --git a/frontend/src/api/dashboard/dashboard.ts b/frontend/src/api/dashboard/dashboard.ts new file mode 100644 index 0000000..49c0f8c --- /dev/null +++ b/frontend/src/api/dashboard/dashboard.ts @@ -0,0 +1,5 @@ +import request from '../request'; + +export const dashboardApi = { + getStats: () => request.get('/dashboard/stats'), +}; diff --git a/frontend/src/api/dashboard/index.ts b/frontend/src/api/dashboard/index.ts new file mode 100644 index 0000000..b56c17a --- /dev/null +++ b/frontend/src/api/dashboard/index.ts @@ -0,0 +1,2 @@ +export * from './dashboard'; + diff --git a/frontend/src/api/datasource/db-types.ts b/frontend/src/api/datasource/db-types.ts new file mode 100644 index 0000000..e2d22d6 --- /dev/null +++ b/frontend/src/api/datasource/db-types.ts @@ -0,0 +1,51 @@ +import request from '../request'; + +export interface DBTypeOverview { + type: string; + name: string; + icon: string; + description: string; + category: string; + version: string; + supported_operations: number; + security_rules_count: number; +} + +export interface DBTypeQueryType { + type: string; + description: string; + example: string; +} + +export interface DBTypeDetail { + type: string; + name: string; + icon: string; + description: string; + version: string; + category: string; + supported_query_types: DBTypeQueryType[]; + security_rules: string[]; + tips: string[]; + common_commands: Record; + limitations: string[]; +} + +export interface DBTypeCommand { + name: string; + command: string; +} + +export const dbTypesApi = { + list: () => + request.get('/db-types'), + + getDetail: (dbType: string) => + request.get('/db-types/' + dbType), + + getCommands: (dbType: string) => + request.get('/db-types/' + dbType + '/commands'), + + getTips: (dbType: string) => + request.get('/db-types/' + dbType + '/tips'), +}; diff --git a/frontend/src/api/datasource/dictionary.ts b/frontend/src/api/datasource/dictionary.ts new file mode 100644 index 0000000..8ff27e2 --- /dev/null +++ b/frontend/src/api/datasource/dictionary.ts @@ -0,0 +1,22 @@ +import request from '../request'; + +export var dictionaryApi = { + listDatabases: function (instanceId: number) { + return request.get('/dictionary/instances/' + instanceId + '/databases'); + }, + listTables: function (instanceId: number, database: string) { + return request.get('/dictionary/instances/' + instanceId + '/tables', { + params: { database: database }, + }); + }, + getTableDetail: function (instanceId: number, tableName: string, database: string) { + return request.get('/dictionary/instances/' + instanceId + '/tables/' + tableName, { + params: { database: database }, + }); + }, + exportDictionary: function (instanceId: number, database: string) { + return request.get('/dictionary/instances/' + instanceId + '/export', { + params: { database: database }, + }); + }, +}; diff --git a/frontend/src/api/datasource/index.ts b/frontend/src/api/datasource/index.ts new file mode 100644 index 0000000..bfa6ee0 --- /dev/null +++ b/frontend/src/api/datasource/index.ts @@ -0,0 +1,8 @@ +export * from './db-types'; +export * from './dictionary'; +export * from './instance'; +export * from './instance-detail'; +export * from './instance-group'; +export * from './mongo'; +export * from './redis'; + diff --git a/frontend/src/api/datasource/instance-detail.ts b/frontend/src/api/datasource/instance-detail.ts new file mode 100644 index 0000000..fc25fcc --- /dev/null +++ b/frontend/src/api/datasource/instance-detail.ts @@ -0,0 +1,89 @@ +import request from '../request'; + +/** 实例详情 API — 数据库/表/字段/索引/Redis Key */ +export const instanceDetailApi = { + /** 获取 MySQL 数据库列表 */ + listDatabases: (instanceId: number) => + request.get(`/instances/${instanceId}/detail/databases`), + + /** 获取表列表(含引擎、行数等) */ + listTables: (instanceId: number, database: string) => + request.get(`/instances/${instanceId}/detail/tables`, { + params: { database }, + }), + + /** 获取整库表+列结构(用于 SQL 自动补全) */ + getDbSchema: (instanceId: number, database: string) => + request.get>(`/instances/${instanceId}/detail/db-schema`, { + params: { database }, + }), + + /** 获取表详情(DDL + 字段 + 索引) */ + getTableDetail: (instanceId: number, database: string, table: string) => + request.get(`/instances/${instanceId}/detail/table-detail`, { + params: { database, table }, + }), + + /** SHOW PROCESSLIST */ + getProcesslist: (instanceId: number) => + request.get(`/instances/${instanceId}/detail/processlist`), + + /** SHOW GLOBAL STATUS */ + getStatus: (instanceId: number) => + request.get; all_status: any[] }>( + `/instances/${instanceId}/detail/status` + ), + + /** 终止进程 */ + killProcess: (instanceId: number, pid: number) => + request.post(`/instances/${instanceId}/detail/processlist/${pid}/kill`), + + /** 获取数据库用户列表 */ + listUsers: (instanceId: number) => + request.get(`/instances/${instanceId}/detail/users`), +}; + +/** 表元信息 */ +export interface TableMeta { + name: string; + engine: string; + rows: number; + data_length: number; + index_length: number; + comment: string; + collation: string; + create_time: string; + update_time: string; +} + +/** 表详情 */ +export interface TableDetail { + ddl: string; + columns: ColumnInfo[]; + indexes: IndexInfo[]; +} + +export interface ColumnInfo { + Field: string; + Type: string; + Null: string; + Key: string; + Default: string | null; + Extra: string; +} + +export interface IndexInfo { + Table: string; + Non_unique: number; + Key_name: string; + Seq_in_index: number; + Column_name: string; + Collation: string; + Cardinality: number; + Sub_part: string | null; + Packed: string | null; + Null: string; + Index_type: string; + Comment: string; + Index_comment: string; +} diff --git a/frontend/src/api/datasource/instance-group.ts b/frontend/src/api/datasource/instance-group.ts new file mode 100644 index 0000000..b153677 --- /dev/null +++ b/frontend/src/api/datasource/instance-group.ts @@ -0,0 +1,38 @@ +import request from '../request'; + +export interface InstanceGroup { + id: number; + name: string; + description?: string; + db_type: string; + permissions?: string[]; + allowed_databases?: string[]; + is_active: boolean; + created_at?: string; + updated_at?: string; + readonly_instance_ids?: number[]; + write_instance_ids?: number[]; + readonly_instance_count: number; + write_instance_count: number; + user_count: number; + readonly_instances?: { id: number; name: string; db_type: string; host: string; port: number; env: string; status: string }[]; + write_instances?: { id: number; name: string; db_type: string; host: string; port: number; env: string; status: string }[]; + users?: { id: number; username: string; display_name: string; role: string }[]; +} + +export const instanceGroupApi = { + list: (params?: any) => request.get('/instance-groups', { params }), + get: (id: number) => request.get(`/instance-groups/${id}`), + create: (data: any) => request.post('/instance-groups', data), + update: (id: number, data: any) => request.put(`/instance-groups/${id}`, data), + delete: (id: number) => request.delete(`/instance-groups/${id}`), + assignInstances: (groupId: number, instanceIds: number[], accessType: string = 'readonly') => + request.post(`/instance-groups/${groupId}/instances`, { instance_ids: instanceIds, access_type: accessType }), + removeInstance: (groupId: number, instanceId: number, accessType?: string) => + request.delete(`/instance-groups/${groupId}/instances/${instanceId}`, { params: accessType ? { access_type: accessType } : undefined }), + listUsers: (groupId: number) => request.get(`/instance-groups/${groupId}/users`), + assignUsers: (groupId: number, userIds: number[], permissions?: string[]) => + request.post(`/instance-groups/${groupId}/users`, { user_ids: userIds, permissions }), + updateUserGroups: (userId: number, groupIds: number[]) => + request.put(`/instance-groups/users/${userId}/groups`, { group_ids: groupIds }), +}; diff --git a/frontend/src/api/datasource/instance.ts b/frontend/src/api/datasource/instance.ts new file mode 100644 index 0000000..b3a935a --- /dev/null +++ b/frontend/src/api/datasource/instance.ts @@ -0,0 +1,15 @@ +import request from '../request'; + +export const instanceApi = { + list: (params?: any) => request.get('/instances', { params }), + accessible: (accessType: 'readonly' | 'write') => request.get('/instances/accessible', { params: { access_type: accessType } }), + get: (id: number) => request.get(`/instances/${id}`), + create: (data: any) => request.post('/instances', data), + update: (id: number, data: any) => request.put(`/instances/${id}`, data), + delete: (id: number) => request.delete(`/instances/${id}`), + testConnection: (id: number) => request.post(`/instances/${id}/test-connection`), + testNewConnection: (data: any) => request.post('/instances/test-connection', data), + listDatabases: (id: number) => request.get(`/instances/${id}/databases`), + listTables: (id: number, db: string) => request.get(`/instances/${id}/databases/${db}/tables`), + metrics: (id: number) => request.get(`/instances/${id}/metrics`), +}; diff --git a/frontend/src/api/datasource/mongo.ts b/frontend/src/api/datasource/mongo.ts new file mode 100644 index 0000000..4d0fb21 --- /dev/null +++ b/frontend/src/api/datasource/mongo.ts @@ -0,0 +1,24 @@ +import request from '../request'; + +export var mongoApi = { + getDatabases: function (id: number) { + return request.get('/mongo/' + id + '/databases'); + }, + getCollections: function (id: number, database: string) { + return request.get('/mongo/' + id + '/databases/' + encodeURIComponent(database) + '/collections'); + }, + getCollectionStats: function (id: number, collection: string, database?: string) { + var params: Record = {}; + if (database) { params.database = database; } + return request.get('/mongo/' + id + '/collections/' + encodeURIComponent(collection) + '/stats', { params: params }); + }, + query: function (id: number, data: Record) { + return request.post('/mongo/' + id + '/query', data); + }, + execute: function (id: number, data: Record) { + return request.post('/mongo/' + id + '/execute', data); + }, + testConnection: function (id: number) { + return request.post('/mongo/' + id + '/test-connection'); + }, +}; diff --git a/frontend/src/api/datasource/redis.ts b/frontend/src/api/datasource/redis.ts new file mode 100644 index 0000000..960c7df --- /dev/null +++ b/frontend/src/api/datasource/redis.ts @@ -0,0 +1,47 @@ +import request from '../request'; + +export const redisApi = { + // Redis 信息概览 + getInfo: (instanceId: number) => + request.get('/redis/' + instanceId + '/info'), + + // DBSIZE + getDbsize: (instanceId: number, db?: number) => + request.get('/redis/' + instanceId + '/dbsize', { params: { db: db ?? 0 } }), + + // 键列表 + scanKeys: (instanceId: number, params: { db?: number; pattern?: string; cursor?: number; count?: number; type?: string }) => + request.get('/redis/' + instanceId + '/keys', { params }), + + // 键详情 + getKeyDetail: (instanceId: number, key: string, db?: number) => + request.get('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), { params: { db: db ?? 0 } }), + + // 慢日志 + getSlowlog: (instanceId: number, count?: number) => + request.get('/redis/' + instanceId + '/slowlog', { params: { count: count ?? 10 } }), + + // 客户端列表 + getClients: (instanceId: number) => + request.get('/redis/' + instanceId + '/clients'), + + // 执行命令 + executeCommand: (instanceId: number, data: { db: number; command: string }) => + request.post('/redis/' + instanceId + '/command', data), + + // 获取键列表(兼容旧接口) + getKeys: (instanceId: number, params?: { db?: number; pattern?: string; count?: number; type?: string; cursor?: number }) => + request.get('/redis/' + instanceId + '/keys', { params }), + + // 创建键 + createKey: (instanceId: number, data: { db?: number; key: string; type?: string; value: any; ttl?: number }) => + request.post('/redis/' + instanceId + '/keys', data), + + // 更新键值(整体替换 value,并可设置 TTL) + updateKey: (instanceId: number, key: string, data: { db?: number; type: string; value: any; ttl?: number }) => + request.put('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), data, { params: { db: data.db ?? 0 } }), + + // 删除键 + deleteKey: (instanceId: number, key: string, db?: number) => + request.delete('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), { params: { db: db ?? 0 } }), +}; diff --git a/frontend/src/api/dbTypes.ts b/frontend/src/api/dbTypes.ts index f8143ad..8d91908 100644 --- a/frontend/src/api/dbTypes.ts +++ b/frontend/src/api/dbTypes.ts @@ -1,51 +1,2 @@ -import request from './request'; +export * from './datasource/db-types'; -export interface DBTypeOverview { - type: string; - name: string; - icon: string; - description: string; - category: string; - version: string; - supported_operations: number; - security_rules_count: number; -} - -export interface DBTypeQueryType { - type: string; - description: string; - example: string; -} - -export interface DBTypeDetail { - type: string; - name: string; - icon: string; - description: string; - version: string; - category: string; - supported_query_types: DBTypeQueryType[]; - security_rules: string[]; - tips: string[]; - common_commands: Record; - limitations: string[]; -} - -export interface DBTypeCommand { - name: string; - command: string; -} - -export const dbTypesApi = { - list: () => - request.get('/db-types'), - - getDetail: (dbType: string) => - request.get('/db-types/' + dbType), - - getCommands: (dbType: string) => - request.get('/db-types/' + dbType + '/commands'), - - getTips: (dbType: string) => - request.get('/db-types/' + dbType + '/tips'), -}; diff --git a/frontend/src/api/dictionary.ts b/frontend/src/api/dictionary.ts index 4105404..b4fe7c4 100644 --- a/frontend/src/api/dictionary.ts +++ b/frontend/src/api/dictionary.ts @@ -1,22 +1,2 @@ -import request from './request'; +export * from './datasource/dictionary'; -export var dictionaryApi = { - listDatabases: function (instanceId: number) { - return request.get('/dictionary/instances/' + instanceId + '/databases'); - }, - listTables: function (instanceId: number, database: string) { - return request.get('/dictionary/instances/' + instanceId + '/tables', { - params: { database: database }, - }); - }, - getTableDetail: function (instanceId: number, tableName: string, database: string) { - return request.get('/dictionary/instances/' + instanceId + '/tables/' + tableName, { - params: { database: database }, - }); - }, - exportDictionary: function (instanceId: number, database: string) { - return request.get('/dictionary/instances/' + instanceId + '/export', { - params: { database: database }, - }); - }, -}; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..b731b4b --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,8 @@ +// API unified exports. Prefer importing domain APIs from "@/api" or "@/api/". +export * from './auth'; +export * from './dashboard'; +export * from './datasource'; +export * from './query'; +export * from './ticket'; +export * from './system'; + diff --git a/frontend/src/api/instance.ts b/frontend/src/api/instance.ts index 7aafae6..d1c7499 100644 --- a/frontend/src/api/instance.ts +++ b/frontend/src/api/instance.ts @@ -1,15 +1,2 @@ -import request from './request'; +export * from './datasource/instance'; -export const instanceApi = { - list: (params?: any) => request.get('/instances', { params }), - accessible: (accessType: 'readonly' | 'write') => request.get('/instances/accessible', { params: { access_type: accessType } }), - get: (id: number) => request.get(`/instances/${id}`), - create: (data: any) => request.post('/instances', data), - update: (id: number, data: any) => request.put(`/instances/${id}`, data), - delete: (id: number) => request.delete(`/instances/${id}`), - testConnection: (id: number) => request.post(`/instances/${id}/test-connection`), - testNewConnection: (data: any) => request.post('/instances/test-connection', data), - listDatabases: (id: number) => request.get(`/instances/${id}/databases`), - listTables: (id: number, db: string) => request.get(`/instances/${id}/databases/${db}/tables`), - metrics: (id: number) => request.get(`/instances/${id}/metrics`), -}; diff --git a/frontend/src/api/instanceDetail.ts b/frontend/src/api/instanceDetail.ts index 8f8ddea..ff508f3 100644 --- a/frontend/src/api/instanceDetail.ts +++ b/frontend/src/api/instanceDetail.ts @@ -1,89 +1,2 @@ -import request from './request'; +export * from './datasource/instance-detail'; -/** 实例详情 API — 数据库/表/字段/索引/Redis Key */ -export const instanceDetailApi = { - /** 获取 MySQL 数据库列表 */ - listDatabases: (instanceId: number) => - request.get(`/instances/${instanceId}/detail/databases`), - - /** 获取表列表(含引擎、行数等) */ - listTables: (instanceId: number, database: string) => - request.get(`/instances/${instanceId}/detail/tables`, { - params: { database }, - }), - - /** 获取整库表+列结构(用于 SQL 自动补全) */ - getDbSchema: (instanceId: number, database: string) => - request.get>(`/instances/${instanceId}/detail/db-schema`, { - params: { database }, - }), - - /** 获取表详情(DDL + 字段 + 索引) */ - getTableDetail: (instanceId: number, database: string, table: string) => - request.get(`/instances/${instanceId}/detail/table-detail`, { - params: { database, table }, - }), - - /** SHOW PROCESSLIST */ - getProcesslist: (instanceId: number) => - request.get(`/instances/${instanceId}/detail/processlist`), - - /** SHOW GLOBAL STATUS */ - getStatus: (instanceId: number) => - request.get; all_status: any[] }>( - `/instances/${instanceId}/detail/status` - ), - - /** 终止进程 */ - killProcess: (instanceId: number, pid: number) => - request.post(`/instances/${instanceId}/detail/processlist/${pid}/kill`), - - /** 获取数据库用户列表 */ - listUsers: (instanceId: number) => - request.get(`/instances/${instanceId}/detail/users`), -}; - -/** 表元信息 */ -export interface TableMeta { - name: string; - engine: string; - rows: number; - data_length: number; - index_length: number; - comment: string; - collation: string; - create_time: string; - update_time: string; -} - -/** 表详情 */ -export interface TableDetail { - ddl: string; - columns: ColumnInfo[]; - indexes: IndexInfo[]; -} - -export interface ColumnInfo { - Field: string; - Type: string; - Null: string; - Key: string; - Default: string | null; - Extra: string; -} - -export interface IndexInfo { - Table: string; - Non_unique: number; - Key_name: string; - Seq_in_index: number; - Column_name: string; - Collation: string; - Cardinality: number; - Sub_part: string | null; - Packed: string | null; - Null: string; - Index_type: string; - Comment: string; - Index_comment: string; -} diff --git a/frontend/src/api/instanceGroup.ts b/frontend/src/api/instanceGroup.ts index baccea7..06ba681 100644 --- a/frontend/src/api/instanceGroup.ts +++ b/frontend/src/api/instanceGroup.ts @@ -1,38 +1,2 @@ -import request from './request'; +export * from './datasource/instance-group'; -export interface InstanceGroup { - id: number; - name: string; - description?: string; - db_type: string; - permissions?: string[]; - allowed_databases?: string[]; - is_active: boolean; - created_at?: string; - updated_at?: string; - readonly_instance_ids?: number[]; - write_instance_ids?: number[]; - readonly_instance_count: number; - write_instance_count: number; - user_count: number; - readonly_instances?: { id: number; name: string; db_type: string; host: string; port: number; env: string; status: string }[]; - write_instances?: { id: number; name: string; db_type: string; host: string; port: number; env: string; status: string }[]; - users?: { id: number; username: string; display_name: string; role: string }[]; -} - -export const instanceGroupApi = { - list: (params?: any) => request.get('/instance-groups', { params }), - get: (id: number) => request.get(`/instance-groups/${id}`), - create: (data: any) => request.post('/instance-groups', data), - update: (id: number, data: any) => request.put(`/instance-groups/${id}`, data), - delete: (id: number) => request.delete(`/instance-groups/${id}`), - assignInstances: (groupId: number, instanceIds: number[], accessType: string = 'readonly') => - request.post(`/instance-groups/${groupId}/instances`, { instance_ids: instanceIds, access_type: accessType }), - removeInstance: (groupId: number, instanceId: number, accessType?: string) => - request.delete(`/instance-groups/${groupId}/instances/${instanceId}`, { params: accessType ? { access_type: accessType } : undefined }), - listUsers: (groupId: number) => request.get(`/instance-groups/${groupId}/users`), - assignUsers: (groupId: number, userIds: number[], permissions?: string[]) => - request.post(`/instance-groups/${groupId}/users`, { user_ids: userIds, permissions }), - updateUserGroups: (userId: number, groupIds: number[]) => - request.put(`/instance-groups/users/${userId}/groups`, { group_ids: groupIds }), -}; diff --git a/frontend/src/api/mongo.ts b/frontend/src/api/mongo.ts index 7448fe4..018e6ca 100644 --- a/frontend/src/api/mongo.ts +++ b/frontend/src/api/mongo.ts @@ -1,24 +1,2 @@ -import request from './request'; +export * from './datasource/mongo'; -export var mongoApi = { - getDatabases: function (id: number) { - return request.get('/mongo/' + id + '/databases'); - }, - getCollections: function (id: number, database: string) { - return request.get('/mongo/' + id + '/databases/' + encodeURIComponent(database) + '/collections'); - }, - getCollectionStats: function (id: number, collection: string, database?: string) { - var params: Record = {}; - if (database) { params.database = database; } - return request.get('/mongo/' + id + '/collections/' + encodeURIComponent(collection) + '/stats', { params: params }); - }, - query: function (id: number, data: Record) { - return request.post('/mongo/' + id + '/query', data); - }, - execute: function (id: number, data: Record) { - return request.post('/mongo/' + id + '/execute', data); - }, - testConnection: function (id: number) { - return request.post('/mongo/' + id + '/test-connection'); - }, -}; diff --git a/frontend/src/api/notification.ts b/frontend/src/api/notification.ts index 36918f5..24d9928 100644 --- a/frontend/src/api/notification.ts +++ b/frontend/src/api/notification.ts @@ -1,9 +1,2 @@ -import request from './request'; +export * from './system/notification'; -export const notificationApi = { - list: () => request.get('/notifications'), - create: (data: any) => request.post('/notifications', data), - update: (id: number, data: any) => request.put(`/notifications/${id}`, data), - delete: (id: number) => request.delete(`/notifications/${id}`), - test: (id: number) => request.post(`/notifications/${id}/test`), -}; diff --git a/frontend/src/api/query.ts b/frontend/src/api/query.ts index 16d772f..9f9abc0 100644 --- a/frontend/src/api/query.ts +++ b/frontend/src/api/query.ts @@ -1,15 +1 @@ -import request from './request'; - -export const queryApi = { - execute: (data: { instance_id: number; database?: string; sql: string; limit?: number; offset?: number }) => - request.post('/query', data), - - formatSQL: (sql: string) => - request.post('/sql-assistant/format', { sql }), - - analyzeSQL: (sql: string, db_type: string) => - request.post('/sql-assistant/analyze', { sql, db_type }), - - getDBTypeDetail: (dbType: string) => - request.get('/db-types/' + dbType), -}; +export * from './query/query'; diff --git a/frontend/src/api/query/index.ts b/frontend/src/api/query/index.ts new file mode 100644 index 0000000..1082fc1 --- /dev/null +++ b/frontend/src/api/query/index.ts @@ -0,0 +1,2 @@ +export * from './query'; + diff --git a/frontend/src/api/query/query.ts b/frontend/src/api/query/query.ts new file mode 100644 index 0000000..afe9738 --- /dev/null +++ b/frontend/src/api/query/query.ts @@ -0,0 +1,15 @@ +import request from '../request'; + +export const queryApi = { + execute: (data: { instance_id: number; database?: string; sql: string; limit?: number; offset?: number }) => + request.post('/query', data), + + formatSQL: (sql: string) => + request.post('/sql-assistant/format', { sql }), + + analyzeSQL: (sql: string, db_type: string) => + request.post('/sql-assistant/analyze', { sql, db_type }), + + getDBTypeDetail: (dbType: string) => + request.get('/db-types/' + dbType), +}; diff --git a/frontend/src/api/redis.ts b/frontend/src/api/redis.ts index 71f8eb2..1bd99d3 100644 --- a/frontend/src/api/redis.ts +++ b/frontend/src/api/redis.ts @@ -1,43 +1,2 @@ -import request from './request'; +export * from './datasource/redis'; -export const redisApi = { - // Redis 信息概览 - getInfo: (instanceId: number) => - request.get('/redis/' + instanceId + '/info'), - - // DBSIZE - getDbsize: (instanceId: number, db?: number) => - request.get('/redis/' + instanceId + '/dbsize', { params: { db: db ?? 0 } }), - - // 键列表 - scanKeys: (instanceId: number, params: { db?: number; pattern?: string; cursor?: number; count?: number; type?: string }) => - request.get('/redis/' + instanceId + '/keys', { params }), - - // 键详情 - getKeyDetail: (instanceId: number, key: string, db?: number) => - request.get('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), { params: { db: db ?? 0 } }), - - // 慢日志 - getSlowlog: (instanceId: number, count?: number) => - request.get('/redis/' + instanceId + '/slowlog', { params: { count: count ?? 10 } }), - - // 客户端列表 - getClients: (instanceId: number) => - request.get('/redis/' + instanceId + '/clients'), - - // 执行命令 - executeCommand: (instanceId: number, data: { db: number; command: string }) => - request.post('/redis/' + instanceId + '/command', data), - - // 获取键列表(兼容旧接口) - getKeys: (instanceId: number, params?: { db?: number; pattern?: string; count?: number; type?: string; cursor?: number }) => - request.get('/redis/' + instanceId + '/keys', { params }), - - // 创建键 - createKey: (instanceId: number, data: { db?: number; key: string; type?: string; value: any; ttl?: number }) => - request.post('/redis/' + instanceId + '/keys', data), - - // 删除键 - deleteKey: (instanceId: number, key: string, db?: number) => - request.delete('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), { params: { db: db ?? 0 } }), -}; diff --git a/frontend/src/api/system/index.ts b/frontend/src/api/system/index.ts new file mode 100644 index 0000000..e0b4306 --- /dev/null +++ b/frontend/src/api/system/index.ts @@ -0,0 +1,4 @@ +export * from './notification'; +export * from './user'; +export * from './workflow'; + diff --git a/frontend/src/api/system/notification.ts b/frontend/src/api/system/notification.ts new file mode 100644 index 0000000..afcd664 --- /dev/null +++ b/frontend/src/api/system/notification.ts @@ -0,0 +1,9 @@ +import request from '../request'; + +export const notificationApi = { + list: () => request.get('/notifications'), + create: (data: any) => request.post('/notifications', data), + update: (id: number, data: any) => request.put(`/notifications/${id}`, data), + delete: (id: number) => request.delete(`/notifications/${id}`), + test: (id: number) => request.post(`/notifications/${id}/test`), +}; diff --git a/frontend/src/api/system/user.ts b/frontend/src/api/system/user.ts new file mode 100644 index 0000000..5471a34 --- /dev/null +++ b/frontend/src/api/system/user.ts @@ -0,0 +1,25 @@ +import request from '../request'; + +export const userApi = { + list: (params?: any) => request.get('/users', { params }), + get: (id: number) => request.get(`/users/${id}`), + create: (data: any) => request.post('/users', data), + update: (id: number, data: any) => request.put(`/users/${id}`, data), + delete: (id: number) => request.delete(`/users/${id}`), + resetPassword: (id: number, data: { new_password: string }) => + request.post(`/users/${id}/reset-password`, data), + batchImport: (data: any) => request.post('/users/batch-import', data), + batchImportLdap: (data: any) => request.post('/users/batch-import-ldap', data), + downloadTemplate: () => request.get('/users/import-template', { responseType: 'blob' }), +}; + +export const ldapApi = { + getConfig: () => request.get('/ldap-config'), + updateConfig: (data: any) => request.put('/ldap-config', data), + testConnection: (data: any) => request.post('/ldap-config/test', data), + listOus: () => request.post('/ldap-config/ou-list'), + listUsers: (data?: any) => request.post('/ldap-config/user-list', data || {}), + createOu: (data: any) => request.post('/ldap-config/create-ou', data), + createUser: (data: any) => request.post('/ldap-config/create-user', data), + batchImportToLdap: (data: any) => request.post('/ldap-config/batch-import-ldap', data), +}; diff --git a/frontend/src/api/system/workflow.ts b/frontend/src/api/system/workflow.ts new file mode 100644 index 0000000..54d925f --- /dev/null +++ b/frontend/src/api/system/workflow.ts @@ -0,0 +1,11 @@ +import request from '../request'; + +export const workflowApi = { + list: () => request.get('/workflows'), + get: (id: number) => request.get(`/workflows/${id}`), + create: (data: any) => request.post('/workflows', data), + update: (id: number, data: any) => request.put(`/workflows/${id}`, data), + delete: (id: number) => request.delete(`/workflows/${id}`), + setDefault: (id: number) => request.post(`/workflows/${id}/set-default`), + clone: (id: number) => request.post(`/workflows/${id}/clone`), +}; diff --git a/frontend/src/api/ticket.ts b/frontend/src/api/ticket.ts index 84776cc..0ffc24c 100644 --- a/frontend/src/api/ticket.ts +++ b/frontend/src/api/ticket.ts @@ -1,37 +1 @@ -import request from './request'; - -export var ticketApi = { - list: function (params?: any) { - return request.get('/tickets', { params: params }); - }, - get: function (id: number) { - return request.get('/tickets/' + id); - }, - create: function (data: any) { - return request.post('/tickets', data); - }, - review: function (id: number, data: { action: 'approve' | 'reject'; comment: string }) { - return request.post('/tickets/' + id + '/review', data); - }, - execute: function (id: number) { - return request.post('/tickets/' + id + '/execute'); - }, - statistics: function () { - return request.get('/tickets/statistics'); - }, - timeline: function (id: number) { - return request.get('/tickets/' + id + '/timeline'); - }, - approvals: function (id: number) { - return request.get('/tickets/' + id + '/approvals'); - }, - cancel: function (id: number) { - return request.post('/tickets/' + id + '/cancel'); - }, - rollback: function (id: number) { - return request.post('/tickets/' + id + '/rollback'); - }, - audit: function (data: { sql_content: string; instance_id: number; database: string }) { - return request.post('/tickets/audit', data); - }, -}; +export * from './ticket/ticket'; diff --git a/frontend/src/api/ticket/index.ts b/frontend/src/api/ticket/index.ts new file mode 100644 index 0000000..dcfb11d --- /dev/null +++ b/frontend/src/api/ticket/index.ts @@ -0,0 +1,2 @@ +export * from './ticket'; + diff --git a/frontend/src/api/ticket/ticket.ts b/frontend/src/api/ticket/ticket.ts new file mode 100644 index 0000000..16d4152 --- /dev/null +++ b/frontend/src/api/ticket/ticket.ts @@ -0,0 +1,37 @@ +import request from '../request'; + +export var ticketApi = { + list: function (params?: any) { + return request.get('/tickets', { params: params }); + }, + get: function (id: number) { + return request.get('/tickets/' + id); + }, + create: function (data: any) { + return request.post('/tickets', data); + }, + review: function (id: number, data: { action: 'approve' | 'reject'; comment: string }) { + return request.post('/tickets/' + id + '/review', data); + }, + execute: function (id: number) { + return request.post('/tickets/' + id + '/execute'); + }, + statistics: function () { + return request.get('/tickets/statistics'); + }, + timeline: function (id: number) { + return request.get('/tickets/' + id + '/timeline'); + }, + approvals: function (id: number) { + return request.get('/tickets/' + id + '/approvals'); + }, + cancel: function (id: number) { + return request.post('/tickets/' + id + '/cancel'); + }, + rollback: function (id: number) { + return request.post('/tickets/' + id + '/rollback'); + }, + audit: function (data: { sql_content: string; instance_id: number; database: string }) { + return request.post('/tickets/audit', data); + }, +}; diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index ddbe4fe..af15242 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -1,25 +1,2 @@ -import request from './request'; +export * from './system/user'; -export const userApi = { - list: (params?: any) => request.get('/users', { params }), - get: (id: number) => request.get(`/users/${id}`), - create: (data: any) => request.post('/users', data), - update: (id: number, data: any) => request.put(`/users/${id}`, data), - delete: (id: number) => request.delete(`/users/${id}`), - resetPassword: (id: number, data: { new_password: string }) => - request.post(`/users/${id}/reset-password`, data), - batchImport: (data: any) => request.post('/users/batch-import', data), - batchImportLdap: (data: any) => request.post('/users/batch-import-ldap', data), - downloadTemplate: () => request.get('/users/import-template', { responseType: 'blob' }), -}; - -export const ldapApi = { - getConfig: () => request.get('/ldap-config'), - updateConfig: (data: any) => request.put('/ldap-config', data), - testConnection: (data: any) => request.post('/ldap-config/test', data), - listOus: () => request.post('/ldap-config/ou-list'), - listUsers: (data?: any) => request.post('/ldap-config/user-list', data || {}), - createOu: (data: any) => request.post('/ldap-config/create-ou', data), - createUser: (data: any) => request.post('/ldap-config/create-user', data), - batchImportToLdap: (data: any) => request.post('/ldap-config/batch-import-ldap', data), -}; diff --git a/frontend/src/api/workflow.ts b/frontend/src/api/workflow.ts index 1b5b830..1a37897 100644 --- a/frontend/src/api/workflow.ts +++ b/frontend/src/api/workflow.ts @@ -1,11 +1,2 @@ -import request from './request'; +export * from './system/workflow'; -export const workflowApi = { - list: () => request.get('/workflows'), - get: (id: number) => request.get(`/workflows/${id}`), - create: (data: any) => request.post('/workflows', data), - update: (id: number, data: any) => request.put(`/workflows/${id}`, data), - delete: (id: number) => request.delete(`/workflows/${id}`), - setDefault: (id: number) => request.post(`/workflows/${id}/set-default`), - clone: (id: number) => request.post(`/workflows/${id}/clone`), -}; diff --git a/frontend/src/components/DataDictionaryPanel.tsx b/frontend/src/components/DataDictionaryPanel.tsx new file mode 100644 index 0000000..7faa518 --- /dev/null +++ b/frontend/src/components/DataDictionaryPanel.tsx @@ -0,0 +1,474 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + AlignLeft, + Code2, + Columns3, + Database, + FileJson, + Gauge, + Hash, + Info, + Key, + RefreshCw, + Search, + Table2, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { instanceDetailApi, type IndexInfo, type TableMeta } from '@/api/datasource/instance-detail'; + +interface ColumnInfo { + Field: string; + Type: string; + Null: string; + Key: string; + Default: string | null; + Extra: string; + Comment?: string; +} + +interface NormalizedTableDetail { + columns: ColumnInfo[]; + indexes: IndexInfo[]; + ddl: string; +} + +type DetailTab = 'columns' | 'indexes' | 'ddl'; + +function getTypeIcon(type: string): { icon: React.ElementType; color: string } { + const lower = type.toLowerCase(); + if (lower.includes('int') || lower.includes('float') || lower.includes('decimal') || lower.includes('double')) { + return { icon: Hash, color: 'text-blue-400' }; + } + if (lower.includes('char') || lower.includes('text') || lower.includes('blob')) { + return { icon: AlignLeft, color: 'text-green-400' }; + } + if (lower.includes('date') || lower.includes('time') || lower.includes('timestamp')) { + return { icon: Info, color: 'text-cyan-400' }; + } + if (lower.includes('json')) { + return { icon: FileJson, color: 'text-yellow-400' }; + } + return { icon: Hash, color: 'text-[var(--dg-text-muted)]' }; +} + +function formatBytes(value?: number): string { + if (!value || value <= 0) return '-'; + if (value >= 1024 * 1024 * 1024) return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`; + if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(2)} MB`; + if (value >= 1024) return `${(value / 1024).toFixed(2)} KB`; + return `${value} B`; +} + +function highlightDDL(ddl: string): string { + const keywords = [ + 'CREATE', 'TABLE', 'IF', 'NOT', 'EXISTS', 'PRIMARY', 'KEY', 'UNIQUE', 'INDEX', + 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'NULL', 'AUTO_INCREMENT', + 'CHARSET', 'COLLATE', 'ENGINE', 'COMMENT', 'INT', 'VARCHAR', 'TEXT', 'DATETIME', + 'TIMESTAMP', 'BOOLEAN', 'DECIMAL', 'BIGINT', 'UNSIGNED', 'SIGNED', 'ON', 'DELETE', + 'UPDATE', 'CASCADE', 'SET', 'DROP', 'ALTER', 'ADD', 'COLUMN', 'MODIFY', 'CHANGE', + ]; + let highlighted = ddl + .replace(/&/g, '&') + .replace(//g, '>'); + + keywords.forEach(kw => { + highlighted = highlighted.replace(new RegExp(`\\b${kw}\\b`, 'gi'), `${kw}`); + }); + highlighted = highlighted.replace(/'[^']*'/g, '$&'); + highlighted = highlighted.replace(/`[^`]*`/g, '$&'); + highlighted = highlighted.replace(/\b\d+\b/g, '$&'); + return highlighted; +} + +function normalizeTableName(table: TableMeta | string): string { + return typeof table === 'string' ? table : table.name || ''; +} + +function groupIndexes(indexes: IndexInfo[]) { + const grouped = new Map(); + indexes.forEach(index => { + const key = index.Key_name || 'UNKNOWN'; + grouped.set(key, [...(grouped.get(key) || []), index]); + }); + return Array.from(grouped.entries()).map(([name, rows]) => ({ + name, + columns: rows + .sort((a, b) => (a.Seq_in_index || 0) - (b.Seq_in_index || 0)) + .map(row => row.Column_name) + .filter(Boolean), + nonUnique: rows[0]?.Non_unique, + type: rows[0]?.Index_type || '-', + comment: rows[0]?.Index_comment || rows[0]?.Comment || '-', + })); +} + +export default function DataDictionaryPanel({ instanceId }: { instanceId: number }) { + const [databases, setDatabases] = useState([]); + const [currentDb, setCurrentDb] = useState(''); + const [tables, setTables] = useState([]); + const [selectedTable, setSelectedTable] = useState(''); + const [tableDetail, setTableDetail] = useState(null); + const [searchFilter, setSearchFilter] = useState(''); + const [activeTab, setActiveTab] = useState('columns'); + const [loadingDatabases, setLoadingDatabases] = useState(false); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingDetail, setLoadingDetail] = useState(false); + + const loadDatabases = useCallback(() => { + setLoadingDatabases(true); + instanceDetailApi.listDatabases(instanceId) + .then((res: any) => { + const list = Array.isArray(res) ? res : (res?.databases || []); + setDatabases(list); + setCurrentDb(prev => prev && list.includes(prev) ? prev : (list[0] || '')); + }) + .catch(() => { + setDatabases([]); + setCurrentDb(''); + }) + .finally(() => setLoadingDatabases(false)); + }, [instanceId]); + + const loadTables = useCallback(() => { + if (!currentDb) { + setTables([]); + setSelectedTable(''); + return; + } + setLoadingTables(true); + instanceDetailApi.listTables(instanceId, currentDb) + .then((res: any) => { + const list = Array.isArray(res) ? res : (res?.tables || []); + setTables(list); + const tableNames = list.map(normalizeTableName).filter(Boolean); + setSelectedTable(prev => prev && tableNames.includes(prev) ? prev : (tableNames[0] || '')); + }) + .catch(() => { + setTables([]); + setSelectedTable(''); + }) + .finally(() => setLoadingTables(false)); + }, [currentDb, instanceId]); + + const loadTableDetail = useCallback(() => { + if (!currentDb || !selectedTable) { + setTableDetail(null); + return; + } + setLoadingDetail(true); + instanceDetailApi.getTableDetail(instanceId, currentDb, selectedTable) + .then((res: any) => { + setTableDetail({ + columns: res?.columns || [], + indexes: res?.indexes || [], + ddl: res?.ddl || res?.create_ddl || '', + }); + }) + .catch(() => setTableDetail(null)) + .finally(() => setLoadingDetail(false)); + }, [currentDb, instanceId, selectedTable]); + + useEffect(() => { + loadDatabases(); + }, [loadDatabases]); + + useEffect(() => { + loadTables(); + }, [loadTables]); + + useEffect(() => { + loadTableDetail(); + }, [loadTableDetail]); + + const filteredTables = useMemo(() => { + const keyword = searchFilter.trim().toLowerCase(); + if (!keyword) return tables; + return tables.filter(table => normalizeTableName(table).toLowerCase().includes(keyword)); + }, [searchFilter, tables]); + + const selectedMeta = useMemo( + () => tables.find(table => normalizeTableName(table) === selectedTable), + [selectedTable, tables] + ); + + const indexes = useMemo(() => groupIndexes(tableDetail?.indexes || []), [tableDetail?.indexes]); + + const tabs: { key: DetailTab; label: string; icon: React.ElementType }[] = [ + { key: 'columns', label: '字段', icon: Columns3 }, + { key: 'indexes', label: '索引', icon: Key }, + { key: 'ddl', label: 'DDL', icon: Code2 }, + ]; + + return ( +
+ + +
+ {!selectedTable ? ( + + + +

选择一个表查看字段、索引和 DDL

+
+
+ ) : ( + + +
+
+ {selectedTable} + {selectedMeta?.comment && ( +

{selectedMeta.comment}

+ )} +
+
+ + {Number(selectedMeta?.rows || 0).toLocaleString()} 行 + + + + {formatBytes((selectedMeta?.data_length || 0) + (selectedMeta?.index_length || 0))} + + {selectedMeta?.engine && ( + + {selectedMeta.engine} + + )} + +
+
+
+ +
+ {tabs.map(tab => { + const Icon = tab.icon; + const isActive = activeTab === tab.key; + return ( + + ); + })} +
+ + + {loadingDetail ? ( +
+ + 加载结构... +
+ ) : activeTab === 'columns' ? ( +
+ + + + + + + + + + + + + + {(tableDetail?.columns || []).map(col => { + const { icon: TypeIcon, color } = getTypeIcon(col.Type || ''); + return ( + + + + + + + + + + ); + })} + {(!tableDetail?.columns || tableDetail.columns.length === 0) && ( + + + + )} + +
字段类型可空键默认值额外注释
+ + + {col.Field} + + {col.Type} + + {col.Null === 'YES' ? 'YES' : 'NO'} + + {col.Key || '-'}{col.Default ?? 'NULL'}{col.Extra || '-'}{col.Comment || '-'}
暂无字段信息
+
+ ) : activeTab === 'indexes' ? ( +
+ + + + + + + + + + + + {indexes.map(index => ( + + + + + + + + ))} + {indexes.length === 0 && ( + + + + )} + +
索引名字段唯一类型备注
{index.name}{index.columns.join(', ') || '-'} + + {index.nonUnique === 0 ? '唯一' : '普通'} + + {index.type}{index.comment}
暂无索引信息
+
+ ) : ( +
+
+ + + CREATE TABLE 语句 + + +
+
+ {tableDetail?.ddl ? ( +
+                    ) : (
+                      
-- 暂无 DDL 信息
+ )} +
+
+ )} +
+
+ )} +
+ +
+ ); +} diff --git a/frontend/src/components/EditInstanceDialog.tsx b/frontend/src/components/EditInstanceDialog.tsx index 420df95..0d247d5 100644 --- a/frontend/src/components/EditInstanceDialog.tsx +++ b/frontend/src/components/EditInstanceDialog.tsx @@ -38,6 +38,10 @@ interface Props { mode?: 'edit' | 'clone'; } +function toBool(value: unknown): boolean { + return value === true || value === 'true' || value === 1 || value === '1'; +} + /* ------------------------------------------------------------------ */ /* Reusable Edit Instance Dialog */ /* ------------------------------------------------------------------ */ @@ -50,7 +54,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m const [database, setDatabase] = useState(instance.database || ''); const [env, setEnv] = useState(instance.env || 'development'); const [accessMode, setAccessMode] = useState(instance.access_mode || 'readwrite'); - const [clusterMode, setClusterMode] = useState(instance.cluster_mode === true); + const [clusterMode, setClusterMode] = useState(toBool(instance.cluster_mode)); const [workflowId, setWorkflowId] = useState(instance.workflow_id || null); const [workflows, setWorkflows] = useState([]); // excluded_databases: comma-separated input @@ -74,7 +78,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m setDatabase(instance.database || ''); setEnv(instance.env || 'development'); setAccessMode(instance.access_mode || 'readwrite'); - setClusterMode(instance.cluster_mode === true); + setClusterMode(toBool(instance.cluster_mode)); setWorkflowId(instance.workflow_id || null); setExcludedDbs( Array.isArray(instance.excluded_databases) ? (instance.excluded_databases as string[]).join(', ') : (instance.excluded_databases || '') @@ -155,7 +159,11 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m const res = mode === 'edit' && !password ? await instanceApi.testConnection(instance.id) : await instanceApi.testNewConnection(payload); - setTestResult(res as { success: boolean; message: string; version?: string; cluster_mode?: boolean }); + const result = res as { success: boolean; message: string; version?: string; cluster_mode?: boolean }; + if (instance.db_type === 'redis' && result.success) { + setClusterMode(toBool(result.cluster_mode)); + } + setTestResult(result); } catch (e: unknown) { const msg = (e as { response?: { data?: { detail?: string; message?: string } } })?.response?.data?.detail || (e as { response?: { data?: { message?: string } } })?.response?.data?.message diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index cc56ee2..773d4ce 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -47,6 +47,7 @@ export default function Layout() { const themeOption = useMemo(() => THEME_OPTIONS.find(o => o.id === theme), [theme]); const themeIcon = useMemo(() => { if (!themeOption) return ; + if (themeOption.isDark === 'system') return ; if (themeOption.isDark) return ; return ; }, [themeOption]); @@ -94,13 +95,13 @@ export default function Layout() { + 'hover:ml-0.5') } > - {/* Active indicator bar - gradient with glow */} + {/* Active indicator bar */} {isActive && (
)} diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx index 1038e13..cf6dc02 100644 --- a/frontend/src/components/PageHeader.tsx +++ b/frontend/src/components/PageHeader.tsx @@ -1,4 +1,4 @@ -import { useAuth, actionPermissionMap } from '@/hooks/useAuth'; +import { useAuth } from '@/hooks/useAuth'; import type { ReactNode } from 'react'; interface ActionConfig { @@ -17,11 +17,11 @@ interface PageHeaderProps { } export default function PageHeader({ title, description, action, secondaryAction, children }: PageHeaderProps) { - const { user, hasPermission } = useAuth(); + const { hasPermission, hasActionPermission } = useAuth(); const canShowAction = !action?.permission || hasPermission(action.permission) - || (actionPermissionMap[action.permission] || []).includes(user.role); + || hasActionPermission(action.permission); return (
@@ -43,7 +43,7 @@ export default function PageHeader({ title, description, action, secondaryAction {canShowAction && action && (
diff --git a/frontend/src/components/login/CosmosView.tsx b/frontend/src/components/login/CosmosView.tsx index e9b9b59..969c9b2 100644 --- a/frontend/src/components/login/CosmosView.tsx +++ b/frontend/src/components/login/CosmosView.tsx @@ -373,7 +373,7 @@ export default function CosmosView() { letterSpacing: '0.3px', }} > - 企业级数据库审核与合规管理 + 企业级数据审核与变更管理平台

@@ -527,7 +527,7 @@ export default function CosmosView() { letterSpacing: '0.3px', }} > - © 2025 OpsxSQL — 企业级数据库安全合规 + © 2025 OpsxSQL — 企业级数据审核与变更管理平台

diff --git a/frontend/src/components/login/GradientMeshView.tsx b/frontend/src/components/login/GradientMeshView.tsx index 087da06..df6a652 100644 --- a/frontend/src/components/login/GradientMeshView.tsx +++ b/frontend/src/components/login/GradientMeshView.tsx @@ -117,7 +117,7 @@ export default function GradientMeshView(): React.JSX.Element { OpsxSQL

- 企业级数据库审核与合规管理 + 企业级数据审核与变更管理平台

diff --git a/frontend/src/components/login/InkWashView.tsx b/frontend/src/components/login/InkWashView.tsx index fe1295e..398c65d 100644 --- a/frontend/src/components/login/InkWashView.tsx +++ b/frontend/src/components/login/InkWashView.tsx @@ -288,7 +288,7 @@ export default function InkWashView() { letterSpacing: 0.5, }} > - 企业级数据库审核与合规管理 + 企业级数据审核与变更管理平台

diff --git a/frontend/src/components/login/MinimalDark.tsx b/frontend/src/components/login/MinimalDark.tsx index a431b34..2ecc962 100644 --- a/frontend/src/components/login/MinimalDark.tsx +++ b/frontend/src/components/login/MinimalDark.tsx @@ -195,7 +195,7 @@ export default function MinimalDark() { OpsxSQL

- 企业级数据库审核与合规管理 + 企业级数据审核与变更管理平台

{/* Form */} diff --git a/frontend/src/components/login/NeonCityView.tsx b/frontend/src/components/login/NeonCityView.tsx index e5d6a78..3e3240c 100644 --- a/frontend/src/components/login/NeonCityView.tsx +++ b/frontend/src/components/login/NeonCityView.tsx @@ -79,7 +79,7 @@ export default function NeonCityView() { OpsxSQL

- 企业级数据库审核与合规管理 + 企业级数据审核与变更管理平台

diff --git a/frontend/src/components/login/SidePanel.tsx b/frontend/src/components/login/SidePanel.tsx index 0994fb9..525d688 100644 --- a/frontend/src/components/login/SidePanel.tsx +++ b/frontend/src/components/login/SidePanel.tsx @@ -1,6 +1,6 @@ import { useLogin } from '@/hooks/useLogin'; import { useTheme } from '@/hooks/useTheme'; -import { Shield, Database, Code, GitBranch, Bell, History, UserCog, Sparkles, Sun, Moon } from 'lucide-react'; +import { Shield, Database, Code, GitBranch, Bell, History, UserCog, Sparkles, Sun, Moon, Monitor } from 'lucide-react'; export default function SidePanel() { const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin(); @@ -11,7 +11,7 @@ export default function SidePanel() { { icon: Code, title: '自助查询', desc: '在线SQL编辑器,语法高亮,查询历史' }, { icon: GitBranch, title: '数据变更审批', desc: '工单提交、多级审核、执行确认' }, { icon: Bell, title: '审核规则引擎', desc: '语法/性能/安全/命名,9大类规则' }, - { icon: History, title: '完整审计追踪', desc: '操作可追溯,查询历史、慢日志分析' }, + { icon: History, title: '完整审计追踪', desc: '操作可追溯,查询历史、执行记录' }, { icon: UserCog, title: '角色权限管理', desc: 'RBAC 权限体系,admin/DBA/开发者/审计' }, ]; @@ -26,7 +26,7 @@ export default function SidePanel() { onClick={toggleTheme} className="absolute top-5 right-5 z-20 w-10 h-10 rounded-xl bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors" > - {(theme as string) === 'dark' ? : } + {theme === 'system' ? : theme === 'ocean-deep' || theme === 'pine-green' ? : } {/* 左侧 */} diff --git a/frontend/src/constants/menu.ts b/frontend/src/constants/menu.ts index 588bd34..56e9053 100644 --- a/frontend/src/constants/menu.ts +++ b/frontend/src/constants/menu.ts @@ -1,24 +1,39 @@ export const menuSections = [ { id: 'workspace', label: '工作台' }, - { id: 'admin', label: '管理' }, - { id: 'system', label: '系统' }, + { id: 'change', label: '数据变更' }, + { id: 'query', label: '数据查询' }, + { id: 'asset', label: '数据资产' }, + { id: 'governance', label: '审批治理' }, + { id: 'access', label: '权限与组织' }, + { id: 'system', label: '系统治理' }, ]; export const menuItems = [ - // 工作台 — 日常操作 - { id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'workspace', roles: ['admin', 'dba', 'developer', 'auditor'], permission: 'dashboard.view' }, - { id: 'queries', label: '查询数据', icon: 'Code2', section: 'workspace', roles: ['admin', 'dba', 'developer'], permission: 'queries.execute' }, - { id: 'tickets', label: '数据变更', icon: 'FileEdit', section: 'workspace', roles: ['admin', 'dba', 'developer'], permission: 'tickets.manage' }, + // 工作台 + { id: 'dashboard', label: '运营总览', icon: 'LayoutDashboard', section: 'workspace', roles: ['admin', 'dba', 'developer', 'auditor'], permission: 'dashboard' }, - // 管理 — 数据源 + 权限 - { id: 'instances', label: '数据源', icon: 'Database', section: 'admin', roles: ['admin', 'dba'], permission: 'instances.manage' }, - { id: 'workflow', label: '审批流程', icon: 'GitBranch', section: 'admin', roles: ['admin', 'dba'], permission: 'workflow.manage' }, - { id: 'users', label: '用户管理', icon: 'Users', section: 'admin', roles: ['admin'], permission: 'users.manage' }, - { id: 'roles', label: '角色权限', icon: 'ShieldCheck', section: 'admin', roles: ['admin'], permission: 'roles.manage' }, - { id: 'instance-groups', label: '权限组', icon: 'FolderLock', section: 'admin', roles: ['admin'], permission: 'instance-groups.manage' }, + // 数据变更 + { id: 'tickets', label: '变更工单', icon: 'FileEdit', section: 'change', roles: ['admin', 'dba', 'developer'], permission: 'tickets' }, + { id: 'auto-task', label: '自动执行', icon: 'Timer', section: 'change', roles: ['admin'], permission: 'auto-task' }, - // 系统 - { id: 'audit-logs', label: '审计日志', icon: 'ScrollText', section: 'system', roles: ['admin', 'dba', 'auditor'], permission: 'audit-logs.view' }, - { id: 'auto-task', label: '自动任务', icon: 'Timer', section: 'system', roles: ['admin'], permission: 'auto-task.manage' }, - { id: 'settings', label: '系统设置', icon: 'Settings', section: 'system', roles: ['admin'], permission: 'settings.manage' }, + // 数据查询 + { id: 'queries', label: 'SQL 查询', icon: 'Code2', section: 'query', roles: ['admin', 'dba', 'developer'], permission: 'queries' }, + + // 数据资产 + { id: 'instances', label: '数据源实例', icon: 'Database', section: 'asset', roles: ['admin', 'dba'], permission: 'instances' }, + + // 审批治理 + { id: 'workflow', label: '审批流程', icon: 'GitBranch', section: 'governance', roles: ['admin', 'dba', 'developer', 'auditor'], permission: 'workflow' }, + { id: 'audit-rules', label: '审核规则', icon: 'ShieldCheck', section: 'governance', roles: ['admin', 'dba', 'auditor'], permission: 'audit-rules' }, + + // 权限与组织 + { id: 'users', label: '用户管理', icon: 'Users', section: 'access', roles: ['admin'], permission: 'users' }, + { id: 'roles', label: '角色权限', icon: 'ShieldCheck', section: 'access', roles: ['admin'], permission: 'roles' }, + { id: 'instance-groups', label: '权限组', icon: 'FolderLock', section: 'access', roles: ['admin'], permission: 'instance-groups' }, + + // 系统治理 + { id: 'audit-logs', label: '审计日志', icon: 'ScrollText', section: 'system', roles: ['admin', 'dba', 'auditor'], permission: 'audit-logs' }, + { id: 'notifications', label: '通知渠道', icon: 'Bell', section: 'system', roles: ['admin'], permission: 'notifications' }, + { id: 'announcement', label: '公告管理', icon: 'Megaphone', section: 'system', roles: ['admin'], permission: 'announcement' }, + { id: 'settings', label: '系统设置', icon: 'Settings', section: 'system', roles: ['admin'], permission: 'settings' }, ]; diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx index 3165dbc..2992e01 100644 --- a/frontend/src/hooks/useAuth.tsx +++ b/frontend/src/hooks/useAuth.tsx @@ -113,17 +113,24 @@ const routePermissionMap: Record = { dashboard: ['admin', 'dba', 'developer', 'auditor'], instances: ['admin', 'dba'], 'instance-groups': ['admin'], - 'data-dictionary': ['admin', 'dba', 'developer'], tickets: ['admin', 'dba', 'developer'], queries: ['admin', 'dba', 'developer'], roles: ['admin'], users: ['admin'], + 'audit-rules': ['admin', 'dba', 'auditor'], 'audit-logs': ['admin', 'dba', 'auditor'], workflow: ['admin', 'dba', 'developer', 'auditor'], 'auto-task': ['admin'], + notifications: ['admin'], + announcement: ['admin'], settings: ['admin'], }; +const permissionAliasMap: Record = { + instances: ['datasource'], + settings: ['config-platform'], +}; + export const actionPermissionMap: Record = { 'ticket.create': ['admin', 'dba', 'developer'], 'ticket.approve': ['admin', 'dba', 'developer'], @@ -314,17 +321,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return cache.menu.get(menuId)!; } - const allowedRoles = routePermissionMap[menuId]; + const menuItem = menuItems.find(item => item.id === menuId || item.permission === menuId); + const allowedRoles = menuItem?.roles || routePermissionMap[menuId]; if (!allowedRoles) { cache.menu.set(menuId, false); return false; } - const result = allowedRoles.includes(user.role); + const permissionKeys = [ + menuId, + menuItem?.permission, + ...((permissionAliasMap[menuId] || [])), + ...((menuItem?.permission && permissionAliasMap[menuItem.permission]) || []), + ].filter(Boolean) as string[]; + const customPermission = permissionKeys.find(key => user.customPermissions && key in user.customPermissions); + const result = allowedRoles.includes(user.role) + && (customPermission ? Boolean(user.customPermissions?.[customPermission]) : true); cache.menu.set(menuId, result); cache.timestamp = Date.now(); return result; - }, [user.role, isLoggedIn, isCacheValid]); + }, [user.role, user.customPermissions, isLoggedIn, isCacheValid]); /** 操作权限检查(带缓存,支持自定义权限覆盖) */ const hasActionPermission = useCallback((action: PermissionAction | string): boolean => { @@ -446,8 +462,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }, [user, clearPermissionCache, emitPermissionChange]); const visibleMenuItems = useMemo(() => { - return menuItems.filter(item => item.roles.includes(user.role)); - }, [user.role]); + return menuItems.filter(item => item.roles.includes(user.role) && hasPermission(item.id)); + }, [user.role, hasPermission]); /** 检查是否拥有指定角色或以上级别权限 */ const hasRoleLevel = useCallback((minRole: UserRole): boolean => { diff --git a/frontend/src/hooks/useLogin.tsx b/frontend/src/hooks/useLogin.tsx index e0806d6..66e42d5 100644 --- a/frontend/src/hooks/useLogin.tsx +++ b/frontend/src/hooks/useLogin.tsx @@ -35,7 +35,9 @@ export function useLogin(): LoginState { const { data: prefs } = await api.get<{ theme?: string; login_style?: string }>('/user/preferences'); if (prefs?.theme) { const t = prefs.theme; - if (t === 'dark' || t === 'light') { + if (t === 'system') { + setTheme('system'); + } else if (t === 'dark' || t === 'light') { setTheme(t === 'dark' ? 'ocean-deep' : 'light-professional'); } else if (THEME_OPTIONS.some(o => o.id === t)) { setTheme(t as Theme); diff --git a/frontend/src/hooks/useTheme.tsx b/frontend/src/hooks/useTheme.tsx index 5848369..7ad42ad 100644 --- a/frontend/src/hooks/useTheme.tsx +++ b/frontend/src/hooks/useTheme.tsx @@ -1,30 +1,30 @@ import { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react'; -export type Theme = 'ocean-deep' | 'light-professional' | 'midnight-purple' | 'pine-green' | 'orange-vitality'; +export type Theme = 'system' | 'light-professional' | 'ocean-deep' | 'pine-green'; export interface ThemeOption { id: Theme; name: string; description: string; - isDark: boolean; + isDark: boolean | 'system'; primaryColor: string; } export const THEME_OPTIONS: ThemeOption[] = [ - { id: 'ocean-deep', name: '深海蓝', description: '专业稳重的深蓝色调', isDark: true, primaryColor: '#3b82f6' }, - { id: 'light-professional', name: '明亮白', description: '清爽干净的浅色主题', isDark: false, primaryColor: '#2563eb' }, - { id: 'midnight-purple', name: '午夜紫', description: '神秘优雅的紫色调', isDark: true, primaryColor: '#a855f7' }, - { id: 'pine-green', name: '松林绿', description: '自然清新的绿色主题', isDark: true, primaryColor: '#22c55e' }, - { id: 'orange-vitality', name: '活力橙', description: '温暖活力的橙色主题', isDark: true, primaryColor: '#f97316' }, + { id: 'system', name: '跟随系统', description: '自动匹配系统深色或灰白浅色', isDark: 'system', primaryColor: '#2563eb' }, + { id: 'light-professional', name: '灰白', description: '克制清爽的生产浅色主题', isDark: false, primaryColor: '#2563eb' }, + { id: 'ocean-deep', name: '深色', description: '低亮度、高对比的专业暗色主题', isDark: true, primaryColor: '#3b82f6' }, + { id: 'pine-green', name: '墨绿', description: '偏运维场景的深色备用主题', isDark: true, primaryColor: '#22c55e' }, ]; const THEME_ORDER: Theme[] = THEME_OPTIONS.map(o => o.id); -const DEFAULT_THEME: Theme = 'ocean-deep'; +const DEFAULT_THEME: Theme = 'system'; // Backward compatibility: map old theme names to new ones function migrateLegacyTheme(value: string | null): Theme | null { if (value === 'dark') return 'ocean-deep'; if (value === 'light') return 'light-professional'; + if (value === 'midnight-purple' || value === 'orange-vitality') return 'ocean-deep'; return null; } @@ -51,8 +51,15 @@ function getInitialTheme(): Theme { return DEFAULT_THEME; } +function resolveSystemTheme(): Exclude { + if (typeof window === 'undefined') return 'ocean-deep'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'ocean-deep' : 'light-professional'; +} + function applyTheme(theme: Theme) { - document.documentElement.setAttribute('data-theme', theme); + const resolvedTheme = theme === 'system' ? resolveSystemTheme() : theme; + document.documentElement.setAttribute('data-theme', resolvedTheme); + document.documentElement.setAttribute('data-theme-choice', theme); } export function ThemeProvider({ children }: { children: React.ReactNode }) { @@ -60,6 +67,12 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { useEffect(() => { applyTheme(theme); + if (theme !== 'system') return; + + const media = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = () => applyTheme('system'); + media.addEventListener('change', handleChange); + return () => media.removeEventListener('change', handleChange); }, [theme]); const toggleTheme = useCallback(() => { diff --git a/frontend/src/index.css b/frontend/src/index.css index b28ccb4..76ba3d8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -46,14 +46,14 @@ --dg-green: #3fb950; --dg-red: #f85149; --dg-orange: #f0883e; - --dg-purple: #8957e5; + --dg-purple: #7c8aa5; --dg-cyan: #39c5cf; --dg-yellow: #d29922; --dg-card-hover-border: rgba(88, 166, 255, 0.5); --dg-gradient-start: #2563eb; - --dg-gradient-end: #9333ea; + --dg-gradient-end: #0ea5e9; --dg-gradient-text-start: #60a5fa; - --dg-gradient-text-end: #c084fc; + --dg-gradient-text-end: #93c5fd; --dg-input-focus-ring: rgba(96, 165, 250, 0.2); } @@ -99,21 +99,21 @@ --dg-green: #3fb950; --dg-red: #f85149; --dg-orange: #f0883e; - --dg-purple: #8957e5; + --dg-purple: #7c8aa5; --dg-cyan: #39c5cf; --dg-yellow: #d29922; --dg-card-hover-border: rgba(88, 166, 255, 0.5); --dg-gradient-start: #2563eb; - --dg-gradient-end: #9333ea; + --dg-gradient-end: #0ea5e9; --dg-gradient-text-start: #60a5fa; - --dg-gradient-text-end: #c084fc; + --dg-gradient-text-end: #93c5fd; --dg-input-focus-ring: rgba(96, 165, 250, 0.2); } /* ===== Theme 2: Light Professional (浅色商务) ===== */ [data-theme="light-professional"], [data-theme="light"] { - --background: 0 0% 100%; + --background: 220 14% 97%; --foreground: 220 14% 10%; --card: 0 0% 100%; --card-foreground: 220 14% 10%; @@ -133,7 +133,7 @@ --input: 220 13% 91%; --ring: 217 91% 60%; --radius: 0.625rem; - --sidebar-background: 0 0% 98%; + --sidebar-background: 0 0% 100%; --sidebar-foreground: 220 14% 20%; --sidebar-primary: 217 91% 60%; --sidebar-primary-foreground: 0 0% 100%; @@ -141,12 +141,12 @@ --sidebar-accent-foreground: 220 14% 10%; --sidebar-border: 220 13% 91%; --sidebar-ring: 217 91% 60%; - --dg-bg-primary: #ffffff; - --dg-bg-secondary: #f8f9fb; - --dg-bg-tertiary: #f0f1f5; - --dg-bg-hover: #e8eaef; - --dg-border: #e2e5eb; - --dg-border-light: #eaecf0; + --dg-bg-primary: #f5f6f8; + --dg-bg-secondary: #ffffff; + --dg-bg-tertiary: #eef1f5; + --dg-bg-hover: #e6e9ef; + --dg-border: #dfe3ea; + --dg-border-light: #edf0f4; --dg-text-primary: #1a1d23; --dg-text-secondary: #5c6370; --dg-text-muted: #9ca3af; @@ -154,14 +154,14 @@ --dg-green: #16a34a; --dg-red: #dc2626; --dg-orange: #ea580c; - --dg-purple: #7c3aed; + --dg-purple: #64748b; --dg-cyan: #0891b2; --dg-yellow: #ca8a04; --dg-card-hover-border: rgba(37, 99, 235, 0.3); --dg-gradient-start: #2563eb; - --dg-gradient-end: #7c3aed; + --dg-gradient-end: #0ea5e9; --dg-gradient-text-start: #1d4ed8; - --dg-gradient-text-end: #6d28d9; + --dg-gradient-text-end: #0369a1; --dg-input-focus-ring: rgba(37, 99, 235, 0.15); } @@ -455,7 +455,7 @@ @apply dg-badge bg-yellow-500/10 text-yellow-400; } .dg-badge-purple { - @apply dg-badge bg-purple-500/10 text-purple-400; + @apply dg-badge bg-cyan-500/10 text-cyan-400; } .dg-badge-orange { @apply dg-badge bg-orange-500/10 text-orange-400; diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts index 060ba5d..b78e1b2 100644 --- a/frontend/src/lib/constants.ts +++ b/frontend/src/lib/constants.ts @@ -1,39 +1,45 @@ import type { PermissionNode } from '@/types'; export const permissionTreeData: PermissionNode[] = [ - { id: 'dashboard', label: '仪表盘(查看统计、图表)' }, - { id: 'datasource', label: '数据源管理', children: [ + { id: 'workspace', label: '工作台', children: [ + { id: 'dashboard', label: '运营总览(查看统计、待办、风险趋势)' }, + ]}, + { id: 'change', label: '数据变更', children: [ + { id: 'tickets', label: '变更工单(创建、查看、审批、执行)' }, + { id: 'ticket_create', label: '创建工单' }, + { id: 'ticket_approve', label: '审批工单' }, + { id: 'ticket_execute', label: '执行工单' }, + { id: 'ticket_rollback', label: '回滚工单' }, + { id: 'auto-task', label: '自动执行任务' }, + ]}, + { id: 'query', label: '数据查询', children: [ + { id: 'queries', label: 'SQL 查询(查看日志、新建查询、导出)' }, + { id: 'query_create', label: '执行查询' }, + { id: 'query_export', label: '导出结果' }, + { id: 'query_view_all', label: '查看全部用户查询' }, + ]}, + { id: 'datasource', label: '数据资产', children: [ + { id: 'instances', label: '数据源实例(查看、添加、编辑、删除)' }, { id: 'mysql', label: 'MySQL 实例(查看、添加、编辑、删除)' }, { id: 'postgres', label: 'PostgreSQL 实例(查看、添加、编辑、删除)' }, { id: 'redis', label: 'Redis 实例(查看、添加、编辑、删除)' }, { id: 'mongo', label: 'MongoDB 实例(查看、添加、编辑、删除)' }, ]}, - { id: 'tickets', label: '数据变更工单(创建、查看、审批、执行)', children: [ - { id: 'ticket_create', label: '创建工单' }, - { id: 'ticket_approve', label: '审批工单' }, - { id: 'ticket_execute', label: '执行工单' }, - { id: 'ticket_rollback', label: '回滚工单' }, + { id: 'governance', label: '审批治理', children: [ + { id: 'workflow', label: '审批流程(创建、编辑流程模板)' }, + { id: 'audit-rules', label: '审核规则(风险规则、禁用语句、影响行数)' }, ]}, - { id: 'queries', label: '查询数据(查看日志、新建查询、导出)', children: [ - { id: 'query_create', label: '执行查询' }, - { id: 'query_export', label: '导出结果' }, - { id: 'query_view_all', label: '查看全部用户查询' }, - ]}, - { id: 'permission', label: '权限管理', children: [ - { id: 'roles', label: '角色权限(创建、编辑、分配权限)' }, + { id: 'permission', label: '权限与组织', children: [ { id: 'users', label: '用户管理(创建、编辑、禁用)' }, + { id: 'roles', label: '角色权限(创建、编辑、分配权限)' }, { id: 'instance-groups', label: '权限组管理(创建、编辑、分配)' }, ]}, - { id: 'system', label: '系统配置', children: [ - { id: 'audit-rules', label: '审核规则(启用/禁用、添加规则)' }, + { id: 'system', label: '系统治理', children: [ { id: 'audit-logs', label: '审计日志(查看操作记录)' }, - { id: 'workflow', label: '审批流程(创建、编辑流程模板)' }, - { id: 'auto-task', label: '自动任务' }, - { id: 'announcement', label: '公告管理' }, - ]}, - { id: 'config', label: '配置平台', children: [ - { id: 'config-platform', label: '全局配置(消息推送/LDAP/查询限制/工单规则)' }, { id: 'notifications', label: '通知渠道(配置 Webhook、测试)' }, + { id: 'announcement', label: '公告管理' }, + { id: 'settings', label: '系统设置(外观、LDAP、平台参数)' }, + { id: 'config-platform', label: '全局配置(消息推送/LDAP/查询限制/工单规则)' }, ]}, ]; @@ -53,7 +59,7 @@ export const statusColorMap: Record = { reviewing: 'bg-blue-500/15 text-blue-400', approved: 'bg-green-500/15 text-green-400', rejected: 'bg-red-500/15 text-red-400', - executing: 'bg-purple-500/15 text-purple-400', + executing: 'bg-cyan-500/15 text-cyan-400', success: 'bg-green-500/15 text-green-400', failed: 'bg-red-500/15 text-red-400', rollback: 'bg-orange-500/15 text-orange-400', diff --git a/frontend/src/pages/AppearanceSettings.tsx b/frontend/src/pages/AppearanceSettings.tsx index 948ea68..92ba75c 100644 --- a/frontend/src/pages/AppearanceSettings.tsx +++ b/frontend/src/pages/AppearanceSettings.tsx @@ -32,7 +32,9 @@ export default function AppearanceSettings() { const prefs = res.data; if (prefs?.theme) { const t = prefs.theme; - if (t === 'dark' || t === 'light') { + if (t === 'system') { + setTheme('system'); + } else if (t === 'dark' || t === 'light') { setTheme(t === 'dark' ? 'ocean-deep' : 'light-professional'); } else if (THEME_OPTIONS.some(o => o.id === t)) { setTheme(t as Theme); @@ -87,8 +89,8 @@ export default function AppearanceSettings() { {/* ===== Theme Mode Section ===== */}

界面配色

-

选择界面配色方案,即时预览效果

-
+

推荐使用跟随系统;浅色为灰白基调,暗色降低眩光,适合长期值守

+
{THEME_OPTIONS.map(opt => (
- - {tables.length === 0 ?
选择数据库查看表列表
: ( -
- - - - - - {tables.map(function (t: any, i: number) { - return - - - - - - ; - })} - -
表名引擎行数大小注释
{t.Name || t.name}{t.Engine || t.engine || '-'}{t.Rows || t.rows || '-'}{t.Data_length ? (t.Data_length / 1024 / 1024).toFixed(2) + ' MB' : '-'}{t.Comment || t.comment || '-'}
-
- )} - + )} {/* SQL查询 */} diff --git a/frontend/src/pages/InstancePage.tsx b/frontend/src/pages/InstancePage.tsx index a698244..5820add 100644 --- a/frontend/src/pages/InstancePage.tsx +++ b/frontend/src/pages/InstancePage.tsx @@ -66,7 +66,7 @@ const envColor: Record = { staging: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', development: 'bg-green-500/10 text-green-400 border-green-500/20', test: 'bg-blue-500/10 text-blue-400 border-blue-500/20', - uat: 'bg-purple-500/10 text-purple-400 border-purple-500/20', + uat: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20', }; const statusLabel: Record = { @@ -1074,7 +1074,7 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete { label: '连接数', value: metrics?.connections ?? 0, unit: '', icon: Users, color: 'text-blue-400', bg: 'bg-blue-400/10' }, { label: 'QPS', value: metrics?.qps ?? 0, unit: '/s', icon: Activity, color: 'text-green-400', bg: 'bg-green-400/10' }, { label: '延迟', value: metrics?.latency ?? 0, unit: 'ms', icon: Signal, color: 'text-orange-400', bg: 'bg-orange-400/10' }, - { label: '运行时间', value: metrics?.uptime ?? '-', unit: '', icon: HardDrive, color: 'text-purple-400', bg: 'bg-purple-400/10' }, + { label: '运行时间', value: metrics?.uptime ?? '-', unit: '', icon: HardDrive, color: 'text-cyan-400', bg: 'bg-cyan-400/10' }, ].map(m => (
@@ -1164,6 +1164,10 @@ const formSteps = [ { label: '高级配置', icon: Activity }, ]; +function toBool(value: unknown): boolean { + return value === true || value === 'true' || value === 1 || value === '1'; +} + function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType: string; onCancel: () => void; onCreated: () => void; }) { @@ -1244,7 +1248,11 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { cluster_mode: isRedis ? redisClusterMode : undefined, env: (finalEnv || 'development') as string, access_mode: accessMode, }); - setTestResult(res as { success: boolean; message: string; version?: string; cluster_mode?: boolean }); + const result = res as { success: boolean; message: string; version?: string; cluster_mode?: boolean }; + if (isRedis && result.success) { + setRedisClusterMode(toBool(result.cluster_mode)); + } + setTestResult(result); } catch (e: unknown) { const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '连接失败'; setTestResult({ success: false, message: msg }); diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 3455ee4..2f3bf63 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -9,6 +9,7 @@ import CosmosView from '@/components/login/CosmosView'; import InkWashView from '@/components/login/InkWashView'; import NeonCityView from '@/components/login/NeonCityView'; import GradientMeshView from '@/components/login/GradientMeshView'; +import { THEME_OPTIONS, type Theme, useTheme } from '@/hooks/useTheme'; export type LoginStyle = 'sidepanel' | 'split' | 'centered' | 'fullimage' | 'minimal' | 'aurora' | 'cosmos' | 'inkwash' | 'neoncity' | 'gradientmesh'; @@ -41,6 +42,7 @@ export const LOGIN_STYLE_OPTIONS: { id: LoginStyle; name: string; desc: string } export default function LoginPage() { const [style, setStyle] = useState('sidepanel'); const [ready, setReady] = useState(false); + const { setTheme } = useTheme(); useEffect(() => { // Try backend first (cross-browser), fallback to localStorage @@ -48,6 +50,14 @@ export default function LoginPage() { .then(r => r.json()) .then(data => { const s = data?.login_style as LoginStyle; + const t = data?.theme as string | undefined; + if (t === 'system') { + setTheme('system'); + } else if (t === 'dark' || t === 'light') { + setTheme(t === 'dark' ? 'ocean-deep' : 'light-professional'); + } else if (t && THEME_OPTIONS.some(option => option.id === t)) { + setTheme(t as Theme); + } if (s && s in LOGIN_STYLES) { setStyle(s); } else { @@ -60,7 +70,7 @@ export default function LoginPage() { if (stored && stored in LOGIN_STYLES) setStyle(stored); }) .finally(() => setReady(true)); - }, []); + }, [setTheme]); if (!ready) return null; // avoid flash diff --git a/frontend/src/pages/MongoDBDetailPage.tsx b/frontend/src/pages/MongoDBDetailPage.tsx index 54ea5db..ef8114e 100644 --- a/frontend/src/pages/MongoDBDetailPage.tsx +++ b/frontend/src/pages/MongoDBDetailPage.tsx @@ -204,7 +204,7 @@ export default function MongoDBDetailPage() { + + (colType === 'view' ? 'bg-cyan-500/10 text-cyan-400' : 'bg-blue-500/10 text-blue-400')}> {colType} diff --git a/frontend/src/pages/RedisDetailPage.tsx b/frontend/src/pages/RedisDetailPage.tsx index e9e0d6c..d0b10bf 100644 --- a/frontend/src/pages/RedisDetailPage.tsx +++ b/frontend/src/pages/RedisDetailPage.tsx @@ -1,11 +1,68 @@ import { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { ArrowLeft, Key, Activity, Terminal, Gauge, Plus, RefreshCw, Search } from 'lucide-react'; +import { ArrowLeft, Key, Activity, Terminal, Gauge, Plus, RefreshCw, Search, Save, Copy, Code2, Table2, Trash2, FileText } from 'lucide-react'; import { redisApi } from '@/api/redis'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { FormDialog } from '@/components/FormDialog'; +import { toast } from '@/hooks/useToast'; type TabKey = 'keys' | 'monitor' | 'cli' | 'perf'; +type ValueMode = 'table' | 'json' | 'raw'; + +interface RedisKeyDetail { + key: string; + name?: string; + type: string; + ttl: number; + value: any; +} + +function ttlMsToSeconds(ttl: number | undefined): number { + if (ttl == null || ttl < 0) return -1; + return Math.max(1, Math.ceil(ttl / 1000)); +} + +function formatTTL(ttl: number | undefined): string { + if (ttl == null || ttl < 0) return '永不过期'; + if (ttl === 0) return '即将过期'; + var seconds = Math.ceil(ttl / 1000); + if (seconds < 60) return seconds + 's'; + if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's'; + return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm'; +} + +function normalizeZSetRows(value: any): { member: string; score: number }[] { + if (!Array.isArray(value)) return []; + return value.map(function (row: any) { + return { + member: String(row?.member ?? row?.Member ?? ''), + score: Number(row?.score ?? row?.Score ?? 0), + }; + }); +} + +function normalizeValueForEdit(type: string, value: any): any { + if (type === 'zset') return normalizeZSetRows(value); + return value; +} + +function stringifyValue(type: string, value: any): string { + if (type === 'string') return typeof value === 'string' ? value : String(value ?? ''); + return JSON.stringify(normalizeValueForEdit(type, value), null, 2); +} + +function parseEditorValue(type: string, text: string): any { + if (type === 'string') return text; + return JSON.parse(text || (type === 'hash' ? '{}' : '[]')); +} + +function tryFormatJsonText(text: string): string | null { + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return null; + } +} export default function RedisDetailPage() { var params = useParams<{ id: string }>(); @@ -18,7 +75,13 @@ export default function RedisDetailPage() { var [keyPattern, setKeyPattern] = useState('*'); var [keyType, setKeyType] = useState(''); var [keys, setKeys] = useState([]); - var [selectedKey, setSelectedKey] = useState(null); + var [selectedKey, setSelectedKey] = useState(null); + var [detailLoading, setDetailLoading] = useState(false); + var [valueMode, setValueMode] = useState('table'); + var [valueSearch, setValueSearch] = useState(''); + var [editValue, setEditValue] = useState(''); + var [editTtl, setEditTtl] = useState(-1); + var [savingKey, setSavingKey] = useState(false); var [showAddKey, setShowAddKey] = useState(false); var [newKey, setNewKey] = useState({ key: '', value: '', type: 'string', ttl: -1 }); var [cmdInput, setCmdInput] = useState(''); @@ -48,21 +111,83 @@ export default function RedisDetailPage() { }).catch(function () { setKeys([]); }).finally(function () { setLoading(false); }); }; + var loadKeyDetail = function (keyName: string) { + setDetailLoading(true); + redisApi.getKeyDetail(instanceId, keyName, selectedDb).then(function (res: any) { + var detail = res as RedisKeyDetail; + setSelectedKey(detail); + setEditValue(stringifyValue(detail.type, detail.value)); + setEditTtl(ttlMsToSeconds(detail.ttl)); + setValueMode(detail.type === 'string' ? 'json' : 'table'); + setValueSearch(''); + }).catch(function () { + toast('error', '加载 Redis Key 详情失败'); + }).finally(function () { + setDetailLoading(false); + }); + }; + var handleAddKey = function () { - redisApi.createKey(instanceId, { db: selectedDb, key: newKey.key, value: newKey.value, type: newKey.type, ttl: newKey.ttl > 0 ? newKey.ttl : undefined }).then(function () { + var parsedValue: any; + try { + parsedValue = parseEditorValue(newKey.type, newKey.value); + } catch { + toast('error', newKey.type === 'hash' ? 'Hash 值需要 JSON Object' : '该类型的值需要 JSON Array'); + return; + } + redisApi.createKey(instanceId, { db: selectedDb, key: newKey.key, value: parsedValue, type: newKey.type, ttl: newKey.ttl > 0 ? newKey.ttl : undefined }).then(function () { setShowAddKey(false); setNewKey({ key: '', value: '', type: 'string', ttl: -1 }); fetchKeys(); + toast('success', 'Key 已新增'); }).catch(function () {}); }; var handleDeleteKey = function (keyName: string) { setDeleteConfirm({msg: '确定删除该键?', onConfirm: async () => { - redisApi.deleteKey(instanceId, keyName, selectedDb).then(function () { fetchKeys(); }).catch(function () {}); + redisApi.deleteKey(instanceId, keyName, selectedDb).then(function () { + if ((selectedKey?.key || selectedKey?.name) === keyName) setSelectedKey(null); + fetchKeys(); + toast('success', 'Key 已删除'); + }).catch(function () {}); }}); return; }; + var handleCopyValue = function () { + if (!selectedKey) return; + navigator.clipboard?.writeText(editValue).then(function () { + toast('success', '已复制当前值'); + }).catch(function () { + toast('warning', '复制失败,请手动复制'); + }); + }; + + var handleSaveKey = function () { + if (!selectedKey) return; + var keyName = selectedKey.key || selectedKey.name || ''; + var parsedValue: any; + try { + parsedValue = parseEditorValue(selectedKey.type, editValue); + } catch (e: any) { + toast('error', '值不是合法的 ' + (selectedKey.type === 'hash' ? 'JSON Object' : 'JSON Array')); + return; + } + setSavingKey(true); + redisApi.updateKey(instanceId, keyName, { + db: selectedDb, + type: selectedKey.type, + value: parsedValue, + ttl: editTtl > 0 ? editTtl : undefined, + }).then(function () { + toast('success', 'Key 已保存'); + loadKeyDetail(keyName); + fetchKeys(); + }).catch(function () {}).finally(function () { + setSavingKey(false); + }); + }; + var handleCommand = function () { if (!cmdInput.trim()) return; var cmd = cmdInput; @@ -82,6 +207,106 @@ export default function RedisDetailPage() { } }, [activeTab, instanceId]); + var renderStructuredValue = function () { + if (!selectedKey) return null; + var type = selectedKey.type; + var value = normalizeValueForEdit(type, selectedKey.value); + var search = valueSearch.trim().toLowerCase(); + + if (type === 'hash') { + var entries = Object.entries(value || {}).filter(function (entry) { + return !search || entry[0].toLowerCase().includes(search) || String(entry[1]).toLowerCase().includes(search); + }); + return ( + + + + + + + + + + {entries.map(function (entry, index) { + var pretty = typeof entry[1] === 'string' ? (tryFormatJsonText(entry[1]) || entry[1]) : JSON.stringify(entry[1], null, 2); + return ( + + + + + + ); + })} + +
#FieldValue
{index + 1}{entry[0]} +
{pretty}
+
+ ); + } + + if (type === 'list' || type === 'set') { + var list = Array.isArray(value) ? value.filter(function (item) { + return !search || String(item).toLowerCase().includes(search); + }) : []; + return ( + + + + + + + + + {list.map(function (item, index) { + var pretty = typeof item === 'string' ? (tryFormatJsonText(item) || item) : JSON.stringify(item, null, 2); + return ( + + + + + ); + })} + +
IndexValue
{index}
{pretty}
+ ); + } + + if (type === 'zset') { + var zRows = normalizeZSetRows(value).filter(function (row) { + return !search || row.member.toLowerCase().includes(search) || String(row.score).includes(search); + }); + return ( + + + + + + + + + + {zRows.map(function (row, index) { + return ( + + + + + + ); + })} + +
#MemberScore
{index + 1}{row.member}{row.score}
+ ); + } + + var formatted = tryFormatJsonText(String(value ?? '')); + return ( +
+        {formatted || String(value ?? '')}
+      
+ ); + }; + var infoSections = Object.entries(info).filter(function (e) { return typeof e[1] === 'object' && e[1] !== null; }); var infoScalars = Object.entries(info).filter(function (e) { return typeof e[1] !== 'object'; }); @@ -123,60 +348,119 @@ export default function RedisDetailPage() { {/* 键管理 */} {activeTab === 'keys' && ( -
-
- -
- - +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ Keys + {keys.length} 条 +
+ +
+ {loading ? ( +
+ {Array.from({ length: 8 }, function (_, i) { + return
; + })} +
+ ) : keys.length === 0 ? ( +
点击刷新加载键列表
+ ) : keys.map(function (k: any, i: number) { + var keyName = k.key || k.name || k; + var active = (selectedKey?.key || selectedKey?.name) === keyName; + return ( + + ); + })}
- - -
- {/* 键列表 */} -
- {loading ? ( -
- {Array.from({ length: 5 }, function (_, i) { - return
-
-
-
-
-
; - })} -
加载中...
+
+ {!selectedKey ? ( +
+ +
选择一个 Key 查看详情
+
支持结构化查看、格式化 JSON、编辑值和 TTL
- ) : keys.length === 0 ? ( -
点击刷新加载键列表
) : ( - - - - - - {keys.map(function (k: any, i: number) { - return - - - - - ; - })} - -
键名类型TTL操作
{k.key || k.name || k}{k.type || '-'}{k.ttl != null ? k.ttl : '-'} - - -
+
+
+
+ {selectedKey.type} + +
+ TTL + +
+ + + +
+
+ +
+
+ {[ + { key: 'table' as ValueMode, label: '结构', icon: Table2 }, + { key: 'json' as ValueMode, label: '格式化', icon: Code2 }, + { key: 'raw' as ValueMode, label: '编辑', icon: FileText }, + ].map(function (mode) { + var Icon = mode.icon; + return ; + })} +
+
+ + +
+
+ +
+ {detailLoading ? ( +
+ ) : valueMode === 'raw' ? ( +