refactor: 弹框架构重构 + 审核规则seed API + LDAP默认配置 + 全量初始数据

弹框架构:
- Layout header/sidebar z-[200] 隔离弹框层,弹框不再遮挡面包屑
- dialog.tsx 简化: 去掉 border/backdrop-blur,统一暗色主题变量
- ModalOverlay z-[100] 对齐 Radix Dialog overlay 层级
- EditInstanceDialog 同步 z-index 修复
- 修复 InstanceGroupPage/InstancePage/LdapConfigPage 缺失 import
- 修复 InstanceGroupPage 缺失 X icon import
- RedisDetailPage ModalOverlay 迁移为 FormDialog

审核规则:
- POST /audit-rules/seed/:dbType 初始化默认规则 (mysql 11/pg 7/redis 4/mongo 4)
- POST /audit-rules/:id/toggle 开关规则
- PUT /audit-rules/:id 编辑规则
- seed.go 启动时自动创建 26 条默认审核规则

LDAP 默认配置:
- seed.go 自动写入 9 条 LDAP 配置 (8.217.205.92:389, dc=opsx,dc=vip)
- 全新 DB 启动即就位,无需手动配置
This commit is contained in:
logan committed 2026-05-29 22:48:45 +08:00
1 parent 47431f127c
commit 889cce140b
24 files changed
+494 -252

No files matched your search

+103
View File
@@ -15,6 +15,8 @@ func seedAll(db *gorm.DB) {
seedDefaultUsers(db) seedDefaultUsers(db)
seedDefaultWorkflow(db) seedDefaultWorkflow(db)
seedDefaultSettings(db) seedDefaultSettings(db)
seedLDAPConfig(db)
seedAuditRules(db)
log.Println("[seed] Default data initialized") log.Println("[seed] Default data initialized")
} }
@@ -114,3 +116,104 @@ func seedDefaultWorkflow(db *gorm.DB) {
} }
func intPtr(v int) *int { return &v } func intPtr(v int) *int { return &v }
// seedLDAPConfig inserts default LDAP settings if not present.
func seedLDAPConfig(db *gorm.DB) {
ldapDefaults := []model.SystemSetting{
{Key: "ldap_host", Value: "8.217.205.92", ValueType: "string", Group: "ldap", Label: "LDAP 服务器地址", Desc: strPtr("LDAP 服务器 IP 或域名"), DefaultValue: strPtr("8.217.205.92"), SortOrder: 1},
{Key: "ldap_port", Value: "389", ValueType: "number", Group: "ldap", Label: "LDAP 端口", Desc: strPtr("默认 389,LDAPS 用 636"), DefaultValue: strPtr("389"), SortOrder: 2},
{Key: "ldap_use_ssl", Value: "false", ValueType: "boolean", Group: "ldap", Label: "启用 LDAPS (SSL/TLS)", Desc: strPtr("使用 SSL/TLS 加密连接"), DefaultValue: strPtr("false"), SortOrder: 3},
{Key: "ldap_bind_dn", Value: "cn=admin,dc=opsx,dc=vip", ValueType: "string", Group: "ldap", Label: "管理员 DN", Desc: strPtr("LDAP 管理员绑定 DN"), DefaultValue: strPtr("cn=admin,dc=opsx,dc=vip"), SortOrder: 4},
{Key: "ldap_base_dn", Value: "dc=opsx,dc=vip", ValueType: "string", Group: "ldap", Label: "Base DN", Desc: strPtr("用户搜索的基础 DN"), DefaultValue: strPtr("dc=opsx,dc=vip"), SortOrder: 5},
{Key: "ldap_user_filter", Value: "(objectClass=inetOrgPerson)", ValueType: "string", Group: "ldap", Label: "用户过滤器", Desc: strPtr("LDAP 用户搜索过滤器"), DefaultValue: strPtr("(objectClass=inetOrgPerson)"), SortOrder: 6},
{Key: "ldap_uid_attr", Value: "uid", ValueType: "string", Group: "ldap", Label: "UID 属性", Desc: strPtr("用户唯一标识属性"), DefaultValue: strPtr("uid"), SortOrder: 7},
{Key: "ldap_mail_attr", Value: "mail", ValueType: "string", Group: "ldap", Label: "邮箱属性", Desc: strPtr("用户邮箱属性名"), DefaultValue: strPtr("mail"), SortOrder: 8},
{Key: "ldap_cn_attr", Value: "cn", ValueType: "string", Group: "ldap", Label: "显示名属性", Desc: strPtr("用户显示名属性名"), DefaultValue: strPtr("cn"), SortOrder: 9},
}
for _, s := range ldapDefaults {
var existing model.SystemSetting
if db.Where("`key` = ?", s.Key).First(&existing).Error != nil {
db.Create(&s)
}
}
log.Println("[seed] LDAP default config seeded")
}
// seedAuditRules creates default audit rules for all DB types.
func seedAuditRules(db *gorm.DB) {
var count int64
db.Model(&model.AuditRule{}).Count(&count)
if count > 0 {
return // Already has rules
}
enabled := true
type seedRule struct {
Name, Desc, RuleType, Severity, Pattern, Message, Value string
}
allRules := map[string][]seedRule{
"mysql": {
{"禁止 DROP TABLE", "生产环境不允许删除表", "DDL", "error", `(?i)^\s*DROP\s+TABLE`, "禁止执行 DROP TABLE 操作,可能导致数据永久丢失", "reject"},
{"禁止 DROP DATABASE", "不允许删除数据库", "DDL", "error", `(?i)^\s*DROP\s+DATABASE`, "禁止执行 DROP DATABASE 操作", "reject"},
{"禁止 TRUNCATE TABLE", "清空表数据需走审批", "DDL", "error", `(?i)^\s*TRUNCATE\s+TABLE?`, "禁止执行 TRUNCATE 操作,请使用 DELETE 逐批删除", "reject"},
{"ALTER TABLE 需审核", "表结构变更需审批", "DDL", "warning", `(?i)^\s*ALTER\s+TABLE`, "ALTER TABLE 操作需要审批后执行", "review"},
{"禁止 DELETE 无 WHERE", "删除操作必须带条件", "DML", "error", `(?i)^\s*DELETE\s+FROM?\s+\S+\s*$`, "DELETE 语句必须包含 WHERE 条件", "reject"},
{"禁止 UPDATE 无 WHERE", "更新操作必须带条件", "DML", "error", `(?i)^\s*UPDATE\s+\S+\s+SET\s+.*\s*$`, "UPDATE 语句必须包含 WHERE 条件", "reject"},
{"SELECT * 建议", "避免使用 SELECT *", "DQL", "info", `(?i)^\s*SELECT\s+\*\s+FROM`, "建议明确指定查询字段,避免使用 SELECT *", "warn"},
{"INSERT 需指定列", "INSERT 需指定列名", "DML", "warning", `(?i)^\s*INSERT\s+INTO\s+\S+\s+VALUES`, "INSERT 语句应明确指定列名", "review"},
{"禁止 GRANT ALL", "不允许授予全部权限", "DCL", "error", `(?i)^\s*GRANT\s+ALL`, "禁止使用 GRANT ALL PRIVILEGES", "reject"},
{"大表变更警告", "影响行数超过阈值需注意", "performance", "warning", "", "操作影响行数可能较大,建议在低峰期执行", "warn"},
{"禁止不带 LIMIT 的 DELETE", "批量删除建议带 LIMIT", "DML", "warning", `(?i)^\s*DELETE\s+FROM\s+\S+\s+WHERE`, "建议 DELETE 语句添加 LIMIT 子句分批删除", "warn"},
},
"postgresql": {
{"禁止 DROP TABLE", "生产环境不允许删除表", "DDL", "error", `(?i)^\s*DROP\s+TABLE`, "禁止执行 DROP TABLE 操作", "reject"},
{"禁止 DROP DATABASE", "不允许删除数据库", "DDL", "error", `(?i)^\s*DROP\s+DATABASE`, "禁止执行 DROP DATABASE 操作", "reject"},
{"禁止 DELETE 无 WHERE", "删除操作必须带条件", "DML", "error", `(?i)^\s*DELETE\s+FROM?\s+\S+\s*$`, "DELETE 语句必须包含 WHERE 条件", "reject"},
{"禁止 UPDATE 无 WHERE", "更新操作必须带条件", "DML", "error", `(?i)^\s*UPDATE\s+\S+\s+SET\s+.*\s*$`, "UPDATE 语句必须包含 WHERE 条件", "reject"},
{"ALTER TABLE 需审核", "表结构变更需审批", "DDL", "warning", `(?i)^\s*ALTER\s+TABLE`, "ALTER TABLE 操作需要审批后执行", "review"},
{"SELECT * 建议", "避免使用 SELECT *", "DQL", "info", `(?i)^\s*SELECT\s+\*\s+FROM`, "建议明确指定查询字段", "warn"},
{"禁止 GRANT ALL", "不允许授予全部权限", "DCL", "error", `(?i)^\s*GRANT\s+ALL`, "禁止使用 GRANT ALL", "reject"},
},
"redis": {
{"禁止 FLUSHALL", "不允许清空所有数据", "DDL", "error", `(?i)^FLUSHALL`, "禁止执行 FLUSHALL 操作", "reject"},
{"禁止 FLUSHDB", "清空数据库需审批", "DDL", "error", `(?i)^FLUSHDB`, "禁止执行 FLUSHDB 操作", "reject"},
{"禁止 CONFIG", "配置修改需走审批", "security", "error", `(?i)^CONFIG\s`, "CONFIG 命令需要审批后执行", "reject"},
{"KEYS 命令警告", "生产环境禁止 KEYS", "performance", "error", `(?i)^KEYS\s`, "生产环境禁止使用 KEYS 命令,请使用 SCAN", "reject"},
},
"mongodb": {
{"禁止 dropDatabase", "不允许删除数据库", "DDL", "error", `dropDatabase`, "禁止执行 dropDatabase 操作", "reject"},
{"禁止 drop 集合", "不允许删除集合", "DDL", "error", `(?i)\.drop\(\)`, "禁止执行集合 drop 操作", "reject"},
{"remove 需带条件", "删除操作必须带条件", "DML", "error", `(?i)remove\(\s*\)`, "remove 操作必须带查询条件", "reject"},
{"updateMany 需审核", "批量更新需审批", "DML", "warning", `(?i)updateMany`, "updateMany 操作需要审批后执行", "review"},
},
}
total := 0
for dbType, rules := range allRules {
for _, r := range rules {
desc := r.Desc
pattern := r.Pattern
severity := r.Severity
value := r.Value
rule := model.AuditRule{
Name: r.Name,
Desc: &desc,
RuleType: r.RuleType,
Severity: &severity,
DBType: &dbType,
Pattern: &pattern,
IsEnabled: &enabled,
Message: r.Message,
Value: &value,
}
if err := db.Create(&rule).Error; err != nil {
log.Printf("[seed] Failed to create audit rule %s/%s: %v", dbType, r.Name, err)
} else {
total++
}
}
}
log.Printf("[seed] Created %d default audit rules across 4 DB types", total)
}
+149
View File
@@ -528,3 +528,152 @@ func TestNotification(c *gin.Context) {
_ = time.Now() _ = time.Now()
c.JSON(http.StatusOK, gin.H{"message": "Notification sent (placeholder)"}) c.JSON(http.StatusOK, gin.H{"message": "Notification sent (placeholder)"})
} }
// ==================== Audit Rules ====================
// defaultAuditRules defines seed rules per db_type
var defaultAuditRules = map[string][]struct {
Name string
Desc string
RuleType string
Severity string
Pattern string
Message string
Value string
}{
"mysql": {
{"禁止 DROP TABLE", "生产环境不允许删除表", "DDL", "error", `(?i)^\s*DROP\s+TABLE`, "禁止执行 DROP TABLE 操作,可能导致数据永久丢失", "reject"},
{"禁止 DROP DATABASE", "不允许删除数据库", "DDL", "error", `(?i)^\s*DROP\s+DATABASE`, "禁止执行 DROP DATABASE 操作", "reject"},
{"禁止 TRUNCATE TABLE", "清空表数据需走审批", "DDL", "error", `(?i)^\s*TRUNCATE\s+TABLE?`, "禁止执行 TRUNCATE 操作,请使用 DELETE 逐批删除", "reject"},
{"ALTER TABLE 需审核", "表结构变更需审批", "DDL", "warning", `(?i)^\s*ALTER\s+TABLE`, "ALTER TABLE 操作需要审批后执行", "review"},
{"禁止 DELETE 无 WHERE", "删除操作必须带条件", "DML", "error", `(?i)^\s*DELETE\s+FROM?\s+\S+\s*$`, "DELETE 语句必须包含 WHERE 条件", "reject"},
{"禁止 UPDATE 无 WHERE", "更新操作必须带条件", "DML", "error", `(?i)^\s*UPDATE\s+\S+\s+SET\s+.*\s*$`, "UPDATE 语句必须包含 WHERE 条件", "reject"},
{"SELECT * 建议", "避免使用 SELECT *", "DQL", "info", `(?i)^\s*SELECT\s+\*\s+FROM`, "建议明确指定查询字段,避免使用 SELECT *", "warn"},
{"INSERT 需指定列", "INSERT 需指定列名", "DML", "warning", `(?i)^\s*INSERT\s+INTO\s+\S+\s+VALUES`, "INSERT 语句应明确指定列名", "review"},
{"禁止 GRANT ALL", "不允许授予全部权限", "DCL", "error", `(?i)^\s*GRANT\s+ALL`, "禁止使用 GRANT ALL PRIVILEGES", "reject"},
{"大表变更警告", "影响行数超过阈值需注意", "performance", "warning", "", "操作影响行数可能较大,建议在低峰期执行", "warn"},
{"禁止不带 LIMIT 的 DELETE", "批量删除建议带 LIMIT", "DML", "warning", `(?i)^\s*DELETE\s+FROM\s+\S+\s+WHERE`, "建议 DELETE 语句添加 LIMIT 子句分批删除", "warn"},
},
"postgresql": {
{"禁止 DROP TABLE", "生产环境不允许删除表", "DDL", "error", `(?i)^\s*DROP\s+TABLE`, "禁止执行 DROP TABLE 操作", "reject"},
{"禁止 DROP DATABASE", "不允许删除数据库", "DDL", "error", `(?i)^\s*DROP\s+DATABASE`, "禁止执行 DROP DATABASE 操作", "reject"},
{"禁止 DELETE 无 WHERE", "删除操作必须带条件", "DML", "error", `(?i)^\s*DELETE\s+FROM?\s+\S+\s*$`, "DELETE 语句必须包含 WHERE 条件", "reject"},
{"禁止 UPDATE 无 WHERE", "更新操作必须带条件", "DML", "error", `(?i)^\s*UPDATE\s+\S+\s+SET\s+.*\s*$`, "UPDATE 语句必须包含 WHERE 条件", "reject"},
{"ALTER TABLE 需审核", "表结构变更需审批", "DDL", "warning", `(?i)^\s*ALTER\s+TABLE`, "ALTER TABLE 操作需要审批后执行", "review"},
{"SELECT * 建议", "避免使用 SELECT *", "DQL", "info", `(?i)^\s*SELECT\s+\*\s+FROM`, "建议明确指定查询字段", "warn"},
{"禁止 GRANT ALL", "不允许授予全部权限", "DCL", "error", `(?i)^\s*GRANT\s+ALL`, "禁止使用 GRANT ALL", "reject"},
},
"redis": {
{"禁止 FLUSHALL", "不允许清空所有数据", "DDL", "error", `(?i)^FLUSHALL`, "禁止执行 FLUSHALL 操作", "reject"},
{"禁止 FLUSHDB", "清空数据库需审批", "DDL", "error", `(?i)^FLUSHDB`, "禁止执行 FLUSHDB 操作", "reject"},
{"禁止 CONFIG", "配置修改需走审批", "security", "error", `(?i)^CONFIG\s`, "CONFIG 命令需要审批后执行", "reject"},
{"KEYS 命令警告", "生产环境禁止 KEYS", "performance", "error", `(?i)^KEYS\s`, "生产环境禁止使用 KEYS 命令,请使用 SCAN", "reject"},
},
"mongodb": {
{"禁止 dropDatabase", "不允许删除数据库", "DDL", "error", `dropDatabase`, "禁止执行 dropDatabase 操作", "reject"},
{"禁止 drop 集合", "不允许删除集合", "DDL", "error", `(?i)\.drop\(\)`, "禁止执行集合 drop 操作", "reject"},
{"remove 需带条件", "删除操作必须带条件", "DML", "error", `(?i)remove\(\s*\)`, "remove 操作必须带查询条件", "reject"},
{"updateMany 需审核", "批量更新需审批", "DML", "warning", `(?i)updateMany`, "updateMany 操作需要审批后执行", "review"},
},
}
// POST /api/v1/audit-rules/seed/:dbType
func SeedAuditRules(c *gin.Context) {
dbType := c.Param("dbType")
rules, ok := defaultAuditRules[dbType]
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("不支持的数据库类型: %s", dbType)})
return
}
enabled := true
count := 0
for _, r := range rules {
// Idempotent: skip if rule with same name+db_type exists
var existing int64
model.DB.Model(&model.AuditRule{}).Where("name = ? AND db_type = ?", r.Name, dbType).Count(&existing)
if existing > 0 {
continue
}
desc := r.Desc
pattern := r.Pattern
severity := r.Severity
value := r.Value
rule := model.AuditRule{
Name: r.Name,
Desc: &desc,
RuleType: r.RuleType,
Severity: &severity,
DBType: &dbType,
Pattern: &pattern,
IsEnabled: &enabled,
Message: r.Message,
Value: &value,
}
if err := model.DB.Create(&rule).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("创建规则失败: %s", err.Error())})
return
}
count++
}
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("成功初始化 %d 条规则", count), "count": count})
}
// POST /api/v1/audit-rules/:id/toggle
func ToggleAuditRule(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的规则 ID"})
return
}
var rule model.AuditRule
if err := model.DB.First(&rule, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "规则不存在"})
return
}
newEnabled := !*rule.IsEnabled
model.DB.Model(&rule).Update("is_enabled", newEnabled)
c.JSON(http.StatusOK, gin.H{"id": id, "is_enabled": newEnabled})
}
// PUT /api/v1/audit-rules/:id
func UpdateAuditRule(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的规则 ID"})
return
}
var rule model.AuditRule
if err := model.DB.First(&rule, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "规则不存在"})
return
}
var body struct {
Value *string `json:"value"`
Pattern *string `json:"pattern"`
Message *string `json:"message"`
Severity *string `json:"severity"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
updates := map[string]interface{}{}
if body.Value != nil {
updates["value"] = *body.Value
}
if body.Pattern != nil {
updates["pattern"] = *body.Pattern
}
if body.Message != nil {
updates["message"] = *body.Message
}
if body.Severity != nil {
updates["severity"] = *body.Severity
}
if len(updates) > 0 {
model.DB.Model(&rule).Updates(updates)
}
c.JSON(http.StatusOK, rule)
}
+3
View File
@@ -181,6 +181,9 @@ func Setup(r *gin.Engine) {
// Read-only resources // Read-only resources
auth.GET("/audit-rules", handler.ListAuditRules) auth.GET("/audit-rules", handler.ListAuditRules)
auth.POST("/audit-rules/seed/:dbType", handler.SeedAuditRules)
auth.POST("/audit-rules/:id/toggle", handler.ToggleAuditRule)
auth.PUT("/audit-rules/:id", handler.UpdateAuditRule)
auth.GET("/announcements", handler.ListAnnouncements) auth.GET("/announcements", handler.ListAnnouncements)
auth.GET("/query-histories", handler.ListQueryHistories) auth.GET("/query-histories", handler.ListQueryHistories)
// Notifications CRUD (replaces old channels/templates endpoints) // Notifications CRUD (replaces old channels/templates endpoints)
@@ -137,7 +137,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
const labelCls = 'block text-xs font-medium text-[var(--dg-text-secondary)] mb-1'; const labelCls = 'block text-xs font-medium text-[var(--dg-text-secondary)] mb-1';
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"> <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40">
<div <div
className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-lg max-h-[85vh] overflow-y-auto" className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-lg max-h-[85vh] overflow-y-auto"
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
+4 -4
View File
@@ -52,8 +52,8 @@ export default function Layout() {
return ( return (
<div className="flex h-screen bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] overflow-hidden"> <div className="flex h-screen bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] overflow-hidden">
{/* 侧边栏 */} {/* 侧边栏 — z-[200] 永远在弹框之上 */}
<aside className="flex flex-col bg-[var(--dg-bg-secondary)] border-r border-[var(--dg-border)] w-[220px] min-w-[220px]"> <aside className="relative z-[200] flex flex-col bg-[var(--dg-bg-secondary)] border-r border-[var(--dg-border)] w-[220px] min-w-[220px]">
{/* Logo */} {/* Logo */}
<div className="h-14 flex items-center px-4"> <div className="h-14 flex items-center px-4">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
@@ -123,8 +123,8 @@ export default function Layout() {
{/* 主内容区 */} {/* 主内容区 */}
<main id="app-main" className="relative flex-1 flex flex-col min-w-0 overflow-hidden"> <main id="app-main" className="relative flex-1 flex flex-col min-w-0 overflow-hidden">
{/* 顶部栏 */} {/* 顶部栏 — z-[200] 永远在弹框之上 */}
<header className="h-14 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)] flex items-center justify-between px-5 flex-shrink-0"> <header className="relative z-[200] h-14 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)] flex items-center justify-between px-5 flex-shrink-0">
{/* Breadcrumb area */} {/* Breadcrumb area */}
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<span className="text-[var(--dg-text-muted)] font-medium">OpsxSQL</span> <span className="text-[var(--dg-text-muted)] font-medium">OpsxSQL</span>
+7 -1
View File
@@ -8,6 +8,12 @@ interface ModalOverlayProps {
children: React.ReactNode; children: React.ReactNode;
} }
/**
* 通用弹框组件
* - overlay z-[100],低于 header/sidebar 的 z-[200],不遮挡面包屑
* - 点击遮罩层不关闭
* - ESC 键关闭
*/
export function ModalOverlay({ open, onClose, width = 'max-w-lg', className = '', children }: ModalOverlayProps) { export function ModalOverlay({ open, onClose, width = 'max-w-lg', className = '', children }: ModalOverlayProps) {
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
@@ -28,7 +34,7 @@ export function ModalOverlay({ open, onClose, width = 'max-w-lg', className = ''
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"> <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40">
<div <div
className={`bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full ${width} max-h-[85vh] overflow-y-auto ${className}`} className={`bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full ${width} max-h-[85vh] overflow-y-auto ${className}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
+3 -3
View File
@@ -1,4 +1,4 @@
import React, { useRef, useEffect, useCallback, useImperativeHandle, forwardRef } from 'react'; import { useRef, useEffect, useCallback, useImperativeHandle, forwardRef } from 'react';
import { EditorState, Compartment } from '@codemirror/state'; import { EditorState, Compartment } from '@codemirror/state';
import type { Extension } from '@codemirror/state'; import type { Extension } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, placeholder as cmPlaceholder } from '@codemirror/view'; import { EditorView, keymap, lineNumbers, placeholder as cmPlaceholder } from '@codemirror/view';
@@ -125,8 +125,8 @@ function getSQLExtension(language: SqlEditorProps['language'], schema?: SqlEdito
const dialect = SQLDialect.define({ const dialect = SQLDialect.define({
keywords: 'select from where order by group having limit offset join inner left right full outer cross on and or not in is null like between as asc desc distinct all case when then else end exists union intersect except insert into values update set delete create alter drop table index view primary key foreign references unique check default grant revoke commit rollback begin transaction count sum avg min max show describe use database explain analyze', keywords: 'select from where order by group having limit offset join inner left right full outer cross on and or not in is null like between as asc desc distinct all case when then else end exists union intersect except insert into values update set delete create alter drop table index view primary key foreign references unique check default grant revoke commit rollback begin transaction count sum avg min max show describe use database explain analyze',
schema: tables, ...(tables ? { schema: tables } : {}),
}); } as any);
return sql({ dialect }); return sql({ dialect });
} }
+1 -1
View File
@@ -68,7 +68,7 @@ const STARS = [
{ left: '60%', top: '30%', size: 1, delay: 2.2, duration: 4.2 }, { left: '60%', top: '30%', size: 1, delay: 2.2, duration: 4.2 },
]; ];
export default function AuroraView(): JSX.Element { export default function AuroraView() {
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin(); const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
return ( return (
+1 -1
View File
@@ -66,7 +66,7 @@ interface Particle {
} }
/* ── 组件 ── */ /* ── 组件 ── */
export default function CosmosView(): JSX.Element { export default function CosmosView() {
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin(); const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
/* 生成静态星星数据 */ /* 生成静态星星数据 */
@@ -43,7 +43,7 @@ const KEYFRAMES = `
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Component */ /* Component */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
export default function InkWashView(): JSX.Element { export default function InkWashView() {
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin(); const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
return ( return (
@@ -31,7 +31,7 @@ const KEYFRAMES = `
} }
`; `;
export default function NeonCityView(): JSX.Element { export default function NeonCityView() {
const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin(); const { username, password, loading, error, setUsername, setPassword, handleLogin } = useLogin();
const [focus, setFocus] = useState<string | null>(null); const [focus, setFocus] = useState<string | null>(null);
const isDisabled = !username || !password || loading; const isDisabled = !username || !password || loading;
+1 -1
View File
@@ -26,7 +26,7 @@ export default function SidePanel() {
onClick={toggleTheme} 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" 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 === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />} {(theme as string) === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button> </button>
{/* 左侧 */} {/* 左侧 */}
+26 -59
View File
@@ -1,42 +1,30 @@
import * as React from "react" import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Dialog({ function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />
} }
function DialogTrigger({ function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
} }
function DialogPortal({ function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
} }
function DialogClose({ function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
} }
function DialogOverlay({ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return ( return (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"fixed inset-0 z-[100] bg-black/50", "fixed inset-0 z-[100] bg-black/40",
"data-[state=open]:animate-in data-[state=closed]:animate-out", "data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className
@@ -51,28 +39,24 @@ function DialogContent({
children, children,
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & { showCloseButton?: boolean }) {
showCloseButton?: boolean
}) {
return ( return (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
onPointerDownOutside={(e) => { onPointerDownOutside={(e) => e.preventDefault()}
e.preventDefault() onInteractOutside={(e) => e.preventDefault()}
}}
onInteractOutside={(e) => {
e.preventDefault()
}}
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out", "bg-[var(--dg-bg-secondary)] text-[var(--dg-text-primary)]",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"fixed z-[101] grid w-full max-w-[calc(100%-2rem)]", "fixed z-[101] top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2",
"top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]", "w-full max-w-[calc(100%-2rem)] sm:max-w-lg",
"gap-4 rounded-lg border p-6 shadow-lg duration-200", "rounded-xl shadow-2xl",
"outline-none sm:max-w-lg", "max-h-[85vh] flex flex-col",
"outline-none duration-200",
className className
)} )}
{...props} {...props}
@@ -81,9 +65,9 @@ function DialogContent({
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close <DialogPrimitive.Close
data-slot="dialog-close" data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" className="absolute top-3.5 right-3.5 rounded-md p-1 text-[var(--dg-text-muted)] opacity-70 transition-opacity hover:opacity-100 focus:outline-none"
> >
<XIcon /> <XIcon className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
@@ -94,48 +78,31 @@ function DialogContent({
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div data-slot="dialog-header" className={cn("flex items-center justify-between", className)} {...props} />
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
) )
} }
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div data-slot="dialog-footer" className={cn("flex justify-end gap-2 pt-2", className)} {...props} />
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
) )
} }
function DialogTitle({ function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)} className={cn("text-base font-semibold", className)}
{...props} {...props}
/> />
) )
} }
function DialogDescription({ function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)} className={cn("text-sm text-[var(--dg-text-muted)]", className)}
{...props} {...props}
/> />
) )
@@ -148,8 +115,8 @@ export {
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
DialogOverlay,
DialogPortal,
} }
+1 -1
View File
@@ -32,7 +32,7 @@ export function useLogin(): LoginState {
if (ok) { if (ok) {
// Apply user preferences from backend (cross-browser sync) // Apply user preferences from backend (cross-browser sync)
try { try {
const prefs = await api.get<{ theme?: string; login_style?: string }>('/user/preferences'); const { data: prefs } = await api.get<{ theme?: string; login_style?: string }>('/user/preferences');
if (prefs?.theme) { if (prefs?.theme) {
const t = prefs.theme; const t = prefs.theme;
if (t === 'dark' || t === 'light') { if (t === 'dark' || t === 'light') {
+75 -86
View File
@@ -1,8 +1,8 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import request from '@/api/request'; import request from '@/api/request';
import { Megaphone, Plus, Edit2, Trash2, Eye, Clock, CheckCircle, X, Save } from 'lucide-react'; import { Megaphone, Plus, Edit2, Trash2, Eye, Clock, CheckCircle, Save } from 'lucide-react';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ModalOverlay } from '@/components/ModalOverlay'; import { FormDialog } from '@/components/FormDialog';
/* ── types ── */ /* ── types ── */
interface Announcement { interface Announcement {
@@ -231,94 +231,35 @@ export default function AnnouncementPage() {
{/* ── View Preview Modal ── */} {/* ── View Preview Modal ── */}
{viewAnn && ( {viewAnn && (
<ModalOverlay open={!!viewAnn} onClose={() => setViewAnn(null)} width="max-w-2xl"> <FormDialog open={!!viewAnn} onClose={() => setViewAnn(null)} title={viewAnn.title} width="max-w-2xl">
<div className="p-6"> <div className="flex items-center gap-2 mb-3">
<div className="flex items-center justify-between mb-4"> <span
<div className="flex items-center gap-2"> className={`text-[11px] px-2 py-0.5 rounded-full ${
<h3 className="text-lg font-semibold text-[var(--dg-text-primary)]">{viewAnn.title}</h3> viewAnn.is_published ? 'bg-green-500/15 text-green-400' : 'bg-yellow-500/15 text-yellow-400'
<span }`}
className={`text-[11px] px-2 py-0.5 rounded-full ${ >
viewAnn.is_published ? 'bg-green-500/15 text-green-400' : 'bg-yellow-500/15 text-yellow-400' {viewAnn.is_published ? '已发布' : '草稿'}
}`} </span>
>
{viewAnn.is_published ? '已发布' : '草稿'}
</span>
</div>
<button onClick={() => setViewAnn(null)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)]">
<X className="w-5 h-5" />
</button>
</div>
<div className="text-xs text-[var(--dg-text-muted)] flex items-center gap-3 mb-4 pb-3 border-b border-[var(--dg-border)]">
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {viewAnn.created_at}</span>
</div>
<div
className="text-sm text-[var(--dg-text-secondary)] leading-relaxed prose-sm"
dangerouslySetInnerHTML={{ __html: simpleMarkdown(viewAnn.content) }}
/>
</div> </div>
</ModalOverlay> <div className="text-xs text-[var(--dg-text-muted)] flex items-center gap-3 mb-4 pb-3 border-b border-[var(--dg-border)]">
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {viewAnn.created_at}</span>
</div>
<div
className="text-sm text-[var(--dg-text-secondary)] leading-relaxed prose-sm"
dangerouslySetInnerHTML={{ __html: simpleMarkdown(viewAnn.content) }}
/>
</FormDialog>
)} )}
{/* ── Create / Edit Modal ── */} {/* ── Create / Edit Modal ── */}
{showModal && ( {showModal && (
<ModalOverlay open={showModal} onClose={() => setShowModal(false)} width="max-w-3xl"> <FormDialog
<div className="p-6"> open={showModal}
<div className="flex items-center justify-between mb-6"> onClose={() => setShowModal(false)}
<h3 className="text-xl font-semibold text-[var(--dg-text-primary)]">{editingId ? '编辑公告' : '创建公告'}</h3> title={editingId ? '编辑公告' : '创建公告'}
<button onClick={() => setShowModal(false)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)]"> width="max-w-3xl"
<X className="w-5 h-5" /> footer={
</button> <>
</div>
<div className="space-y-4">
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">
标题 <span className="text-red-400">*</span>
</label>
<input
type="text"
value={formTitle}
onChange={e => setFormTitle(e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
placeholder="输入公告标题"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs text-[var(--dg-text-muted)]">
内容 <span className="text-red-400">*</span> <span className="text-[var(--dg-text-muted)]">(支持 Markdown)</span>
</label>
<button
onClick={() => setShowPreview(!showPreview)}
className={`text-xs px-2 py-1 rounded transition-colors ${
showPreview
? 'bg-[var(--dg-blue)] text-white'
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
{showPreview ? '编辑' : '预览'}
</button>
</div>
{showPreview ? (
<div
className="w-full min-h-[200px] px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-secondary)] leading-relaxed overflow-auto"
dangerouslySetInnerHTML={{ __html: simpleMarkdown(formContent) }}
/>
) : (
<textarea
value={formContent}
onChange={e => setFormContent(e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)] resize-none font-mono"
rows={12}
placeholder="输入公告内容,支持 Markdown 语法...&#10;&#10;**粗体** *斜体* `代码`&#10;## 二级标题&#10;- 列表项&#10;1. 有序列表"
/>
)}
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-[var(--dg-border)]">
<button <button
onClick={() => setShowModal(false)} onClick={() => setShowModal(false)}
className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors" className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
@@ -332,9 +273,57 @@ export default function AnnouncementPage() {
> >
<Save className="w-4 h-4" /> {saving ? '保存中…' : editingId ? '保存修改' : '创建'} <Save className="w-4 h-4" /> {saving ? '保存中…' : editingId ? '保存修改' : '创建'}
</button> </button>
</>
}
>
<div className="space-y-4 pt-2">
<div>
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">
标题 <span className="text-red-400">*</span>
</label>
<input
type="text"
value={formTitle}
onChange={e => setFormTitle(e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)]"
placeholder="输入公告标题"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs text-[var(--dg-text-muted)]">
内容 <span className="text-red-400">*</span> <span className="text-[var(--dg-text-muted)]">(支持 Markdown)</span>
</label>
<button
onClick={() => setShowPreview(!showPreview)}
className={`text-xs px-2 py-1 rounded transition-colors ${
showPreview
? 'bg-[var(--dg-blue)] text-white'
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
{showPreview ? '编辑' : '预览'}
</button>
</div>
{showPreview ? (
<div
className="w-full min-h-[200px] px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-secondary)] leading-relaxed overflow-auto"
dangerouslySetInnerHTML={{ __html: simpleMarkdown(formContent) }}
/>
) : (
<textarea
value={formContent}
onChange={e => setFormContent(e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-[var(--dg-blue)] resize-none font-mono"
rows={12}
placeholder="输入公告内容,支持 Markdown 语法...&#10;&#10;**粗体** *斜体* `代码`&#10;## 二级标题&#10;- 列表项&#10;1. 有序列表"
/>
)}
</div> </div>
</div> </div>
</ModalOverlay> </FormDialog>
)} )}
<ConfirmDialog <ConfirmDialog
open={!!deleteConfirm} open={!!deleteConfirm}
+2 -1
View File
@@ -28,7 +28,8 @@ export default function AppearanceSettings() {
useEffect(() => { useEffect(() => {
api.get<{ theme?: string; login_style?: string }>('/user/preferences') api.get<{ theme?: string; login_style?: string }>('/user/preferences')
.then(prefs => { .then(res => {
const prefs = res.data;
if (prefs?.theme) { if (prefs?.theme) {
const t = prefs.theme; const t = prefs.theme;
if (t === 'dark' || t === 'light') { if (t === 'dark' || t === 'light') {
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
Clock, XCircle, CheckCircle, AlertTriangle, Clock, AlertTriangle,
Database, Zap, Loader2, Calendar, Ban, Database, Zap, Loader2, Calendar, Ban,
} from 'lucide-react'; } from 'lucide-react';
import api from '@/api/request'; import api from '@/api/request';
+2 -9
View File
@@ -16,13 +16,6 @@ import type { DashboardStats } from '@/types';
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Types */ /* Types */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
interface QuickAction {
label: string;
icon: any;
onClick: () => void;
color: string;
bg: string;
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Helpers */ /* Helpers */
@@ -75,8 +68,8 @@ function StatCardSkeleton() {
/* Main Dashboard */ /* Main Dashboard */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
export default function Dashboard() { export default function Dashboard() {
const theme = useTheme(); useTheme();
const { user } = useAuth(); useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set()); const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
+1 -1
View File
@@ -241,7 +241,7 @@ export default function DataDictionaryPage() {
return tables.filter(t => t.toLowerCase().includes(searchFilter.toLowerCase())); return tables.filter(t => t.toLowerCase().includes(searchFilter.toLowerCase()));
}, [tables, searchFilter]); }, [tables, searchFilter]);
const relations = useMemo(() => { const relations = useMemo<TableRelation[]>(() => {
if (!selectedTable) return []; if (!selectedTable) return [];
return []; return [];
}, [selectedTable]); }, [selectedTable]);
+1
View File
@@ -9,6 +9,7 @@ import { instanceApi } from '@/api/instance';
import { userApi } from '@/api/user'; import { userApi } from '@/api/user';
import { useActionPermission } from '@/hooks/useAuth'; import { useActionPermission } from '@/hooks/useAuth';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { FormDialog } from '@/components/FormDialog';
import { ModalOverlay } from '@/components/ModalOverlay'; import { ModalOverlay } from '@/components/ModalOverlay';
/* ───────── Constants ───────── */ /* ───────── Constants ───────── */
+1
View File
@@ -13,6 +13,7 @@ import EmptyState from '@/components/EmptyState';
import { instanceApi } from '@/api/instance'; import { instanceApi } from '@/api/instance';
import EditInstanceDialog from '@/components/EditInstanceDialog'; import EditInstanceDialog from '@/components/EditInstanceDialog';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { FormDialog } from '@/components/FormDialog';
import { ModalOverlay } from '@/components/ModalOverlay'; import { ModalOverlay } from '@/components/ModalOverlay';
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
+90 -48
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import api from '@/api/request'; import api from '@/api/request';
import { FormDialog } from '@/components/FormDialog';
import { ModalOverlay } from '@/components/ModalOverlay'; import { ModalOverlay } from '@/components/ModalOverlay';
import { toast } from '@/hooks/useToast'; import { toast } from '@/hooks/useToast';
import { import {
@@ -743,66 +744,107 @@ export default function LdapConfigPage() {
{/* ── 账号测试弹框 ── */} {/* ── 账号测试弹框 ── */}
{showAccountTest && ( {showAccountTest && (
<ModalOverlay open={true} onClose={() => setShowAccountTest(false)} width="w-[420px]"> <ModalOverlay open={true} onClose={() => { setShowAccountTest(false); setAccountResult(null); }} width="w-[440px]">
<div className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl"> {/* 标题 */}
<div className="flex items-center justify-between px-5 py-4"> <div className="flex items-center justify-between px-5 py-3.5">
<h3 className="text-sm font-semibold">LDAP 账号验证测试</h3> <div className="flex items-center gap-2">
<button onClick={() => setShowAccountTest(false)} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"> <KeyRound className="w-4 h-4 text-blue-400" />
✕ <h3 className="text-sm font-semibold text-[var(--dg-text-primary)]">账号验证测试</h3>
</button>
</div> </div>
<div className="px-5 space-y-4"> <button onClick={() => { setShowAccountTest(false); setAccountResult(null); }} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors">
<div> ✕
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">测试用户名</label> </button>
<input </div>
value={testUser}
onChange={e => setTestUser(e.target.value)} {/* 表单区 */}
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400" <div className="px-5 pb-4 space-y-3">
placeholder="LDAP 用户名" <div>
/> <label className="text-xs text-[var(--dg-text-muted)] mb-1 block">用户名</label>
</div> <input
<div> value={testUser}
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">测试密码</label> onChange={e => { setTestUser(e.target.value); setAccountResult(null); }}
<input className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
type="password" placeholder="LDAP 用户名"
value={testPass} />
onChange={e => setTestPass(e.target.value)} </div>
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400" <div>
placeholder="密码" <label className="text-xs text-[var(--dg-text-muted)] mb-1 block">密码</label>
/> <input
</div> type="password"
{accountResult && ( value={testPass}
<div className={`px-3 py-2 rounded-lg text-sm ${accountResult.ok ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}> onChange={e => { setTestPass(e.target.value); setAccountResult(null); }}
{accountResult.ok ? '✅ ' : '❌ '}{accountResult.msg} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-400 transition-colors"
placeholder="密码"
onKeyDown={e => { if (e.key === 'Enter' && testUser && testPass) handleTestAccount(); }}
/>
</div>
</div>
{/* 结果区 — 独立视觉层级 */}
{accountResult && (
<div className="mx-5 mb-4">
{accountResult.ok ? (
/* 成功 */
<div className="rounded-lg bg-emerald-500/12 p-4">
<div className="flex items-center gap-2 mb-3">
<div className="w-6 h-6 rounded-full bg-emerald-500/25 flex items-center justify-center">
<CheckCircle className="w-4 h-4 text-emerald-400" />
</div>
<span className="text-sm font-medium text-emerald-400">验证通过</span>
</div>
{accountResult.info && ( {accountResult.info && (
<div className="mt-2 text-xs space-y-1 font-mono"> <div className="space-y-1.5 pl-8">
{Object.entries(accountResult.info).map(([k, v]) => ( {Object.entries(accountResult.info)
<div key={k}> .filter(([, v]) => v && String(v).length > 0)
<span className="text-[var(--dg-text-muted)]">{k}:</span>{' '} .map(([k, v]) => (
<div key={k} className="flex text-xs gap-2">
<span className="text-emerald-400/60 min-w-[48px] shrink-0">{
k === 'Display Name' ? '姓名' :
k === 'Email' ? '邮箱' :
k === 'User DN' ? 'DN' :
k === 'User ID' ? '用户名' : k
}</span>
{Array.isArray(v) ? ( {Array.isArray(v) ? (
v.length > 0 ? ( v.length > 0 ? (
<span className="block ml-4 space-y-0.5"> <span className="text-emerald-300">
{v.map((g, i) => <div key={i}>- {g}</div>)} {v.join(', ')}
</span> </span>
) : <span>(none)</span> ) : null
) : String(v)} ) : <span className="text-emerald-300">{String(v)}</span>}
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </div>
) : (
/* 失败 */
<div className="rounded-lg bg-red-500/12 p-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-red-500/25 flex items-center justify-center">
<XCircle className="w-4 h-4 text-red-400" />
</div>
<span className="text-sm font-medium text-red-400">验证失败</span>
</div>
<p className="text-xs text-red-300/80 mt-2 pl-8">{accountResult.msg}</p>
</div>
)} )}
</div> </div>
<div className="flex justify-end gap-3 px-5 py-4"> )}
<button onClick={() => setShowAccountTest(false)} className="px-4 py-2 text-sm text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">取消</button>
<button {/* 操作栏 */}
onClick={handleTestAccount} <div className="flex justify-end gap-3 px-5 py-3.5">
disabled={testingAccount || !testUser || !testPass} <button onClick={() => { setShowAccountTest(false); setAccountResult(null); }} className="px-4 py-2 text-sm text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors">关闭</button>
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50" <button
> onClick={handleTestAccount}
{testingAccount ? '测试中…' : '测试验证'} disabled={testingAccount || !testUser || !testPass}
</button> className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50 transition-colors"
</div> >
{testingAccount ? (
<><RefreshCw className="w-3.5 h-3.5 animate-spin" />验证中…</>
) : (
<><Shield className="w-3.5 h-3.5" />测试验证</>
)}
</button>
</div> </div>
</ModalOverlay> </ModalOverlay>
)} )}
+2 -1
View File
@@ -1,7 +1,8 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import request from '@/api/request'; import request from '@/api/request';
import { Plus, Trash2, Send, Bell, Mail, Settings, X } from 'lucide-react'; import { Plus, Trash2, Send, Bell, Mail, Settings } from 'lucide-react';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { FormDialog } from '@/components/FormDialog';
import { ModalOverlay } from '@/components/ModalOverlay'; import { ModalOverlay } from '@/components/ModalOverlay';
/* ── types ── */ /* ── types ── */
+17 -31
View File
@@ -3,7 +3,7 @@ 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 } from 'lucide-react';
import { redisApi } from '@/api/redis'; import { redisApi } from '@/api/redis';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ModalOverlay } from '@/components/ModalOverlay'; import { FormDialog } from '@/components/FormDialog';
type TabKey = 'keys' | 'monitor' | 'cli' | 'perf'; type TabKey = 'keys' | 'monitor' | 'cli' | 'perf';
@@ -266,12 +266,7 @@ export default function RedisDetailPage() {
{/* 键详情弹窗 */} {/* 键详情弹窗 */}
{selectedKey && ( {selectedKey && (
<ModalOverlay open={!!selectedKey} onClose={function () { setSelectedKey(null); }} width="max-w-lg"> <FormDialog open={!!selectedKey} onClose={function () { setSelectedKey(null); }} title="键详情" width="max-w-lg">
<div className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-h-[70vh] overflow-y-auto p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold">键详情</h3>
<button onClick={function () { setSelectedKey(null); }} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">✕</button>
</div>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex gap-2 text-sm"><span className="text-[var(--dg-text-muted)]">键名:</span><span className="font-mono">{selectedKey.key || selectedKey.name}</span></div> <div className="flex gap-2 text-sm"><span className="text-[var(--dg-text-muted)]">键名:</span><span className="font-mono">{selectedKey.key || selectedKey.name}</span></div>
<div className="flex gap-2 text-sm"><span className="text-[var(--dg-text-muted)]">类型:</span><span>{selectedKey.type}</span></div> <div className="flex gap-2 text-sm"><span className="text-[var(--dg-text-muted)]">类型:</span><span>{selectedKey.type}</span></div>
@@ -282,33 +277,24 @@ export default function RedisDetailPage() {
</pre> </pre>
</div> </div>
</div> </div>
</div> </FormDialog>
</ModalOverlay>
)} )}
{/* 新增键弹窗 */} {/* 新增键弹窗 */}
{showAddKey && ( <FormDialog open={showAddKey} onClose={() => setShowAddKey(false)} title="新增键" width="max-w-md"
<ModalOverlay open={showAddKey} onClose={function () { setShowAddKey(false); }} width="max-w-md"> footer={<>
<div className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full p-6"> <button onClick={() => setShowAddKey(false)} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg text-sm">取消</button>
<div className="flex items-center justify-between mb-4"> <button onClick={handleAddKey} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">添加</button>
<h3 className="font-semibold">新增键</h3> </>}>
<button onClick={function () { setShowAddKey(false); }} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">✕</button> <div className="space-y-3">
</div> <div><label className="text-sm text-[var(--dg-text-muted)]">类型</label><select value={newKey.type} onChange={function (e) { setNewKey(Object.assign({}, newKey, { type: e.target.value })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
<div className="space-y-3"> <option value="string">String</option><option value="hash">Hash</option><option value="list">List</option><option value="set">Set</option><option value="zset">ZSet</option>
<div><label className="text-sm text-[var(--dg-text-muted)]">类型</label><select value={newKey.type} onChange={function (e) { setNewKey(Object.assign({}, newKey, { type: e.target.value })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm"> </select></div>
<option value="string">String</option><option value="hash">Hash</option><option value="list">List</option><option value="set">Set</option><option value="zset">ZSet</option> <div><label className="text-sm text-[var(--dg-text-muted)]">键名</label><input value={newKey.key} onChange={function (e) { setNewKey(Object.assign({}, newKey, { key: e.target.value })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" placeholder="key" /></div>
</select></div> <div><label className="text-sm text-[var(--dg-text-muted)]">值</label><textarea value={newKey.value} onChange={function (e) { setNewKey(Object.assign({}, newKey, { value: e.target.value })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono min-h-[80px]" placeholder="value" /></div>
<div><label className="text-sm text-[var(--dg-text-muted)]">键名</label><input value={newKey.key} onChange={function (e) { setNewKey(Object.assign({}, newKey, { key: e.target.value })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" placeholder="key" /></div> <div><label className="text-sm text-[var(--dg-text-muted)]">TTL (秒, -1 永不过期)</label><input type="number" value={newKey.ttl} onChange={function (e) { setNewKey(Object.assign({}, newKey, { ttl: Number(e.target.value) })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" /></div>
<div><label className="text-sm text-[var(--dg-text-muted)]">值</label><textarea value={newKey.value} onChange={function (e) { setNewKey(Object.assign({}, newKey, { value: e.target.value })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono min-h-[80px]" placeholder="value" /></div> </div>
<div><label className="text-sm text-[var(--dg-text-muted)]">TTL (秒, -1 永不过期)</label><input type="number" value={newKey.ttl} onChange={function (e) { setNewKey(Object.assign({}, newKey, { ttl: Number(e.target.value) })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" /></div> </FormDialog>
</div>
<div className="flex justify-end gap-3 mt-4">
<button onClick={function () { setShowAddKey(false); }} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg text-sm">取消</button>
<button onClick={handleAddKey} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">添加</button>
</div>
</div>
</ModalOverlay>
)}
<ConfirmDialog <ConfirmDialog
open={!!deleteConfirm} open={!!deleteConfirm}
title={deleteConfirm?.msg || '确认操作'} title={deleteConfirm?.msg || '确认操作'}