diff --git a/server/Dockerfile b/server/Dockerfile deleted file mode 100644 index 9f14488..0000000 --- a/server/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -# 构建阶段 -FROM golang:1.21-alpine AS builder - -WORKDIR /app - -# 安装依赖 -RUN apk add --no-cache git - -# 复制 go.mod 和 go.sum -COPY go.mod go.sum ./ -RUN go mod download - -# 复制源代码 -COPY . . - -# 构建 -RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/api - -# 运行阶段 -FROM alpine:latest - -RUN apk --no-cache add ca-certificates - -WORKDIR /app - -# 复制构建产物 -COPY --from=builder /server . -COPY config/ ./config/ - -EXPOSE 8080 - -CMD ["./server"] diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 7caef09..963f4b0 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -79,10 +79,12 @@ func main() { // 5. 初始化 Service dbService := service.NewDatabaseService(dbRepo, subTableRepo) subTableService := service.NewSubTableService(subTableRepo, dbRepo) + neo4jService := service.NewNeo4jService(dbRepo) // 6. 初始化 Handler dbHandler := handler.NewDatabaseHandler(dbService) subTableHandler := handler.NewSubTableHandler(subTableService) + neo4jHandler := handler.NewNeo4jHandler(neo4jService) systemHandler := handler.NewSystemHandler() // 7. 设置路由 @@ -137,6 +139,7 @@ func main() { databaseGroup.POST("/add", dbHandler.Create) databaseGroup.PUT("/:id", dbHandler.Update) databaseGroup.DELETE("/:id", dbHandler.Delete) + databaseGroup.POST("/graph/save", dbHandler.SaveGraph) } // 子表映射管理模块 @@ -151,6 +154,15 @@ func main() { subTableGroup.DELETE("/:id", subTableHandler.Delete) } + // Neo4j 连接管理模块 + neo4jGroup := r.Group("/neo4j") + { + neo4jGroup.POST("/check", neo4jHandler.Check) + neo4jGroup.POST("/graphs", neo4jHandler.GetGraphs) + neo4jGroup.POST("/nodes", neo4jHandler.GetNodes) + neo4jGroup.POST("/relationships", neo4jHandler.GetRelationships) + } + // 系统信息模块 r.GET("/system/info", systemHandler.GetSystemInfo) diff --git a/server/internal/handler/database_handler.go b/server/internal/handler/database_handler.go index 5290016..eafc401 100644 --- a/server/internal/handler/database_handler.go +++ b/server/internal/handler/database_handler.go @@ -98,6 +98,23 @@ func (h *DatabaseHandler) Update(c *gin.Context) { c.JSON(http.StatusOK, info) } +// SaveGraph 保存图谱信息 +func (h *DatabaseHandler) SaveGraph(c *gin.Context) { + var req model.SaveGraphRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result, err := h.service.SaveGraph(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + // Delete 删除 func (h *DatabaseHandler) Delete(c *gin.Context) { id := c.Param("id") diff --git a/server/internal/handler/neo4j_handler.go b/server/internal/handler/neo4j_handler.go new file mode 100644 index 0000000..c74a45d --- /dev/null +++ b/server/internal/handler/neo4j_handler.go @@ -0,0 +1,86 @@ +package handler + +import ( + "net/http" + + "x-agents/server/internal/model" + "x-agents/server/internal/service" + + "github.com/gin-gonic/gin" +) + +type Neo4jHandler struct { + service *service.Neo4jService +} + +func NewNeo4jHandler(svc *service.Neo4jService) *Neo4jHandler { + return &Neo4jHandler{service: svc} +} + +// Check 检查 Neo4j 连接 +func (h *Neo4jHandler) Check(c *gin.Context) { + var req model.Neo4jCheckRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result, err := h.service.Check(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// GetGraphs 获取图谱概览数据 +func (h *Neo4jHandler) GetGraphs(c *gin.Context) { + var req model.Neo4jGraphRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result, err := h.service.GetGraphs(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// GetNodes 获取节点详情 +func (h *Neo4jHandler) GetNodes(c *gin.Context) { + var req model.Neo4jNodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result, err := h.service.GetNodes(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// GetRelationships 获取关系详情 +func (h *Neo4jHandler) GetRelationships(c *gin.Context) { + var req model.Neo4jRelRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result, err := h.service.GetRelationships(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} diff --git a/server/internal/model/database_info.go b/server/internal/model/database_info.go index 61ad2c1..c614237 100644 --- a/server/internal/model/database_info.go +++ b/server/internal/model/database_info.go @@ -9,7 +9,7 @@ type DatabaseInfo struct { ID string `json:"id" gorm:"primaryKey;size:36"` // UUID Name string `json:"name" gorm:"size:100;not null"` // 数据库名称 Description string `json:"description" gorm:"size:500"` // 描述 - DBType string `json:"db_type" gorm:"size:20;not null"` // 数据库类型: mysql, postgres, mongodb等 + DBType string `json:"db_type" gorm:"size:20;not null"` // 数据库类型: mysql, postgres, mongodb, neo4j等 Host string `json:"host" gorm:"size:255;not null"` // 主机地址 Port int `json:"port" gorm:"not null"` // 端口 Username string `json:"username" gorm:"size:100;not null"` // 用户名 @@ -17,6 +17,12 @@ type DatabaseInfo struct { Database string `json:"database" gorm:"size:100"` // 数据库名 TableCount int `json:"table_count" gorm:"default:0"` // 子表数量 + // Neo4j 专用字段 + URI string `json:"uri" gorm:"size:255"` // Neo4j 连接地址 (bolt://host:7687) + GraphLabels string `json:"graph_labels" gorm:"size:1000"` // Neo4j 标签列表 (JSON 格式) + GraphRelationship string `json:"graph_relationship" gorm:"size:1000"` // Neo4j 关系类型列表 (JSON 格式) + SelectedLabel string `json:"selected_label" gorm:"size:100"` // 当前选中的标签 + // 连接选项 Charset string `json:"charset" gorm:"size:20;default:utf8mb4"` // 字符集 SSLMode string `json:"ssl_mode" gorm:"size:20"` // SSL模式 @@ -48,17 +54,38 @@ type CreateDatabaseRequest struct { // UpdateRequest 更新数据库信息请求 type UpdateDatabaseRequest struct { - Name string `json:"name"` - Description string `json:"description"` - DBType string `json:"db_type"` - Host string `json:"host"` - Port int `json:"port"` - Username string `json:"username"` - Password string `json:"password"` - Database string `json:"database"` - TableCount int `json:"table_count"` - Charset string `json:"charset"` - SSLMode string `json:"ssl_mode"` + Name string `json:"name"` + Description string `json:"description"` + DBType string `json:"db_type"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + Database string `json:"database"` + TableCount int `json:"table_count"` + Charset string `json:"charset"` + SSLMode string `json:"ssl_mode"` + URI string `json:"uri"` // Neo4j 连接地址 + GraphLabels string `json:"graph_labels"` // Neo4j 标签列表 (JSON) + GraphRelationship string `json:"graph_relationship"` // Neo4j 关系类型列表 (JSON) + SelectedLabel string `json:"selected_label"` // 当前选中的标签 +} + +// SaveGraphRequest 保存图谱信息请求 +type SaveGraphRequest struct { + DatabaseID string `json:"databaseId" binding:"required"` + DatabaseName string `json:"databaseName" binding:"required"` + URI string `json:"uri" binding:"required"` + Username string `json:"username" binding:"required"` + Labels []string `json:"labels" binding:"required"` + RelationshipTypes []string `json:"relationshipTypes" binding:"required"` + SelectedLabel string `json:"selectedLabel"` +} + +// SaveGraphResponse 保存图谱信息响应 +type SaveGraphResponse struct { + Success bool `json:"success"` + Message string `json:"message"` } // CheckRequest 检查连接请求 @@ -72,12 +99,97 @@ type CheckRequest struct { Charset string `json:"charset"` SSLMode string `json:"ssl_mode"` DatabaseID string `json:"database_id"` // 可选,用于获取已保存的字段映射 + URI string `json:"uri"` // Neo4j 连接地址,如 bolt://localhost:7687 } // CheckResponse 检查连接响应 type CheckResponse struct { - Success bool `json:"success"` // 是否连接成功 - Message string `json:"message"` // 消息 - Tables []TableDDLInfo `json:"tables,omitempty"` // 表列表(连接成功时返回) - Database string `json:"database"` // 数据库名 + Success bool `json:"success"` // 是否连接成功 + Message string `json:"message"` // 消息 + Tables []TableDDLInfo `json:"tables,omitempty"` // 表列表(关系型数据库) + Database string `json:"database"` // 数据库名 + Graphs *GraphOverview `json:"graphs,omitempty"` // 图谱概览(Neo4j) +} + +// GraphOverview 图谱概览数据 +type GraphOverview struct { + Labels []LabelCount `json:"labels"` // 标签列表 + RelationshipTypes []RelTypeCount `json:"relationshipTypes"` // 关系类型列表 + Nodes []NodeProperty `json:"nodes"` // 节点属性定义 + Relationships []RelProperty `json:"relationships"` // 关系属性定义 +} + +// LabelCount 标签数量统计 +type LabelCount struct { + Name string `json:"name"` // 标签名 + Count int `json:"count"` // 节点数量 +} + +// RelTypeCount 关系类型数量统计 +type RelTypeCount struct { + Name string `json:"name"` // 关系类型名 + Count int `json:"count"` // 关系数量 +} + +// NodeProperty 节点属性定义 +type NodeProperty struct { + Label string `json:"label"` // 节点标签名 + Properties []PropertyInfo `json:"properties"` // 属性列表 +} + +// PropertyInfo 属性信息 +type PropertyInfo struct { + Name string `json:"name"` // 属性名 + Type string `json:"type"` // 属性类型 +} + +// RelProperty 关系属性定义 +type RelProperty struct { + Type string `json:"type"` // 关系类型名 + StartLabel string `json:"startLabel"` // 起始节点标签 + EndLabel string `json:"endLabel"` // 目标节点标签 + Properties []PropertyInfo `json:"properties"` // 属性列表 +} + +// Neo4jNodeRequest 获取节点详情请求 +type Neo4jNodeRequest struct { + URI string `json:"uri" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Database string `json:"database"` + Label string `json:"label" binding:"required"` + Limit int `json:"limit"` // 默认 10 +} + +// Neo4jNodeResponse 获取节点详情响应 +type Neo4jNodeResponse struct { + Success bool `json:"success"` // 是否成功 + Message string `json:"message"` // 消息 + Nodes []map[string]interface{} `json:"nodes"` // 节点数据 + Properties []PropertyInfo `json:"properties"` // 属性定义列表 +} + +// Neo4jRelRequest 获取关系详情请求 +type Neo4jRelRequest struct { + URI string `json:"uri" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Database string `json:"database"` + RelationshipType string `json:"relationship_type" binding:"required"` + Limit int `json:"limit"` // 默认 10 +} + +// Neo4jRelResponse 获取关系详情响应 +type Neo4jRelResponse struct { + Success bool `json:"success"` // 是否成功 + Message string `json:"message"` // 消息 + Relationships []Neo4jRelationship `json:"relationships"` // 关系数据 +} + +// Neo4jRelationship 关系数据 +type Neo4jRelationship struct { + ID string `json:"id"` // 关系唯一标识 + Source string `json:"source"` // 起始节点ID + Target string `json:"target"` // 目标节点ID + Properties map[string]interface{} `json:"properties"` // 关系属性 } diff --git a/server/internal/model/neo4j_info.go b/server/internal/model/neo4j_info.go new file mode 100644 index 0000000..f1c8888 --- /dev/null +++ b/server/internal/model/neo4j_info.go @@ -0,0 +1,39 @@ +package model + +// Neo4jCheckRequest Neo4j 连接测试请求 +type Neo4jCheckRequest struct { + Name string `json:"name"` // 数据库名称 + Host string `json:"host" binding:"required"` + Port int `json:"port" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Database string `json:"database"` // 可选,默认 neo4j + URI string `json:"uri"` // 可选,Neo4j 连接地址 (bolt://host:7687) + Description string `json:"description"` // 可选,数据库描述 +} + +// Neo4jCheckResponse Neo4j 连接测试响应 +type Neo4jCheckResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Version string `json:"version,omitempty"` + Databases []string `json:"databases,omitempty"` + DatabaseID string `json:"databaseId,omitempty"` // 数据库记录 ID + Name string `json:"name,omitempty"` // 数据库名称 + Description string `json:"description,omitempty"` // 数据库描述 +} + +// Neo4jGraphRequest 获取图谱概览请求 +type Neo4jGraphRequest struct { + URI string `json:"uri" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Database string `json:"database"` // 可选,默认 neo4j +} + +// Neo4jGraphResponse 获取图谱概览响应 +type Neo4jGraphResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Graphs *GraphOverview `json:"graphs,omitempty"` +} diff --git a/server/internal/repository/database_repo.go b/server/internal/repository/database_repo.go index 552b305..3c865ba 100644 --- a/server/internal/repository/database_repo.go +++ b/server/internal/repository/database_repo.go @@ -41,6 +41,11 @@ func (r *DatabaseRepository) Update(id string, info *model.DatabaseInfo) error { return r.db.Model(&model.DatabaseInfo{}).Where("id = ?", id).Updates(info).Error } +// UpdateFields 更新指定字段 +func (r *DatabaseRepository) UpdateFields(id string, fields map[string]interface{}) error { + return r.db.Model(&model.DatabaseInfo{}).Where("id = ?", id).Updates(fields).Error +} + // Delete 删除 func (r *DatabaseRepository) Delete(id string) error { return r.db.Delete(&model.DatabaseInfo{}, "id = ?", id).Error diff --git a/server/internal/service/database_service.go b/server/internal/service/database_service.go index 18ee629..aa4a390 100644 --- a/server/internal/service/database_service.go +++ b/server/internal/service/database_service.go @@ -259,6 +259,27 @@ func (s *DatabaseService) Check(req model.CheckRequest) (*model.CheckResponse, e var db *sql.DB var err error + // Neo4j 处理 + if dbType == "neo4j" { + log.Printf("[Check] 检测到 Neo4j 类型,使用图数据库连接...") + neo4jService := NewNeo4jService(s.repo) + graph, err := neo4jService.GetGraphOverview(req) + if err != nil { + log.Printf("[Check] Neo4j 连接失败: %v", err) + return &model.CheckResponse{ + Success: false, + Message: fmt.Sprintf("neo4j connection failed: %v", err), + }, nil + } + log.Printf("[Check] Neo4j 连接成功,获取到 %d 个标签", len(graph.Labels)) + return &model.CheckResponse{ + Success: true, + Message: "connection successful", + Graphs: graph, + Database: req.Database, + }, nil + } + switch dbType { case "mysql": db, err = sql.Open("mysql", dsn) @@ -684,6 +705,92 @@ func (s *DatabaseService) Update(id string, req model.UpdateDatabaseRequest) (*m return s.repo.FindByID(id) } +// SaveGraph 保存图谱信息 +func (s *DatabaseService) SaveGraph(req model.SaveGraphRequest) (*model.SaveGraphResponse, error) { + log.Printf("[SaveGraph] 保存图谱信息, databaseId=%s, databaseName=%s", req.DatabaseID, req.DatabaseName) + + // 检查数据库是否存在 + _, err := s.repo.FindByID(req.DatabaseID) + if err != nil { + // 如果不存在,创建一个新的 + if err == ErrDatabaseNotFound { + // 创建新的数据库记录 + dbType := "neo4j" + // 从 URI 解析 host 和 port + host := "localhost" + port := 7687 + if req.URI != "" { + uri := strings.TrimPrefix(req.URI, "bolt://") + uri = strings.TrimPrefix(uri, "neo4j://") + if idx := strings.Index(uri, ":"); idx > 0 { + host = uri[:idx] + fmt.Sscanf(uri[idx+1:], "%d", &port) + } + } + + // 将 labels 和 relationshipTypes 转为 JSON 字符串 + labelsJSON, _ := json.Marshal(req.Labels) + relJSON, _ := json.Marshal(req.RelationshipTypes) + + info := &model.DatabaseInfo{ + ID: req.DatabaseID, + Name: req.DatabaseName, + DBType: dbType, + Host: host, + Port: port, + Username: req.Username, + URI: req.URI, + GraphLabels: string(labelsJSON), + GraphRelationship: string(relJSON), + SelectedLabel: req.SelectedLabel, + } + + if err := s.repo.Create(info); err != nil { + log.Printf("[SaveGraph] 创建失败: %v", err) + return &model.SaveGraphResponse{ + Success: false, + Message: fmt.Sprintf("创建失败: %v", err), + }, err + } + + return &model.SaveGraphResponse{ + Success: true, + Message: "保存成功", + }, nil + } + log.Printf("[SaveGraph] 查询失败: %v", err) + return &model.SaveGraphResponse{ + Success: false, + Message: fmt.Sprintf("查询失败: %v", err), + }, err + } + + // 更新现有记录 + labelsJSON, _ := json.Marshal(req.Labels) + relJSON, _ := json.Marshal(req.RelationshipTypes) + + updates := map[string]interface{}{ + "uri": req.URI, + "username": req.Username, + "graph_labels": string(labelsJSON), + "graph_relationship": string(relJSON), + "selected_label": req.SelectedLabel, + } + + if err := s.repo.UpdateFields(req.DatabaseID, updates); err != nil { + log.Printf("[SaveGraph] 更新失败: %v", err) + return &model.SaveGraphResponse{ + Success: false, + Message: fmt.Sprintf("更新失败: %v", err), + }, err + } + + return &model.SaveGraphResponse{ + Success: true, + Message: "保存成功", + }, nil +} + // fillFieldMappings 填充字段映射到表结构中 func (s *DatabaseService) fillFieldMappings(databaseID string, tables []model.TableDDLInfo) { // 从数据库中获取该数据库下所有子表的字段映射 diff --git a/server/internal/service/neo4j_service.go b/server/internal/service/neo4j_service.go new file mode 100644 index 0000000..c8ee70d --- /dev/null +++ b/server/internal/service/neo4j_service.go @@ -0,0 +1,850 @@ +package service + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + + "x-agents/server/internal/model" + "x-agents/server/internal/repository" + + "github.com/google/uuid" +) + +// Neo4jService Neo4j 服务 +type Neo4jService struct { + client *http.Client + databaseRepo *repository.DatabaseRepository +} + +func NewNeo4jService(dbRepo *repository.DatabaseRepository) *Neo4jService { + return &Neo4jService{ + client: &http.Client{}, + databaseRepo: dbRepo, + } +} + +// GetGraphs 获取图谱概览数据(新增接口) +func (s *Neo4jService) GetGraphs(req model.Neo4jGraphRequest) (*model.Neo4jGraphResponse, error) { + host := "localhost" + port := 7687 + if req.URI != "" { + // 解析 URI + uri := strings.TrimPrefix(req.URI, "bolt://") + uri = strings.TrimPrefix(uri, "neo4j://") + if idx := strings.Index(uri, ":"); idx > 0 { + host = uri[:idx] + fmt.Sscanf(uri[idx+1:], "%d", &port) + } + } + + db := req.Database + if db == "" { + db = "neo4j" + } + + auth := fmt.Sprintf("%s:%s", req.Username, req.Password) + encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth)) + + // 尝试多个端口 + ports := []int{port - 1000, 7474, port} + checkedPorts := make(map[int]bool) + + for _, p := range ports { + if checkedPorts[p] { + continue + } + checkedPorts[p] = true + + url := fmt.Sprintf("http://%s:%d/db/%s/tx/commit", host, p, db) + graph, err := s.getGraphOverviewWithURL(url, encodedAuth, db) + if err == nil && graph != nil { + return &model.Neo4jGraphResponse{ + Success: true, + Message: "success", + Graphs: graph, + }, nil + } + } + + return &model.Neo4jGraphResponse{ + Success: false, + Message: "failed to connect to Neo4j", + }, nil +} + +// Check 测试 Neo4j 连接 +func (s *Neo4jService) Check(req model.Neo4jCheckRequest) (*model.Neo4jCheckResponse, error) { + db := req.Database + if db == "" { + db = "neo4j" + } + + auth := fmt.Sprintf("%s:%s", req.Username, req.Password) + encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth)) + + httpPort := req.Port - 1000 + if httpPort <= 0 { + httpPort = 7474 + } + + ports := []int{httpPort, 7474, req.Port} + checkedPorts := make(map[int]bool) + + var version string + for _, port := range ports { + if checkedPorts[port] { + continue + } + checkedPorts[port] = true + + url := fmt.Sprintf("http://%s:%d/db/%s/tx/commit", req.Host, port, db) + resp, err := s.checkWithURL(url, encodedAuth, db) + if err == nil && resp.Success { + version = resp.Version + // 连接成功,检查或创建数据库记录 + log.Printf("[Check] Neo4j 连接成功,准备获取/创建数据库记录, host=%s, port=%d, db=%s", req.Host, req.Port, db) + dbInfo, err := s.ensureNeo4jDatabase(req, db) + if err != nil { + log.Printf("[Check] 确保数据库记录失败: %v", err) + } + log.Printf("[Check] 数据库记录ID: %s, Name: %s", dbInfo.ID, dbInfo.Name) + return &model.Neo4jCheckResponse{ + Success: true, + Message: "connection successful", + Version: version, + Databases: []string{db}, + DatabaseID: dbInfo.ID, + Name: dbInfo.Name, + Description: dbInfo.Description, + }, nil + } + } + + return &model.Neo4jCheckResponse{ + Success: false, + Message: fmt.Sprintf("connection failed on all ports (7474, %d)", req.Port), + }, nil +} + +// Neo4jDatabaseInfo Neo4j 数据库记录信息 +type Neo4jDatabaseInfo struct { + ID string + Name string + Description string +} + +// ensureNeo4jDatabase 确保 Neo4j 数据库记录存在 +func (s *Neo4jService) ensureNeo4jDatabase(req model.Neo4jCheckRequest, dbName string) (*Neo4jDatabaseInfo, error) { + log.Printf("[ensureNeo4jDatabase] 开始处理, host=%s, port=%d, username=%s, dbName=%s, uri=%s", req.Host, req.Port, req.Username, dbName, req.URI) + + // 根据 host, port, username, database 查找是否已存在 + databases, err := s.databaseRepo.FindAll() + if err != nil { + log.Printf("[ensureNeo4jDatabase] FindAll 失败: %v", err) + return nil, err + } + log.Printf("[ensureNeo4jDatabase] 找到 %d 条数据库记录", len(databases)) + + // 构建 URI + uri := req.URI + if uri == "" { + uri = fmt.Sprintf("bolt://%s:%d", req.Host, req.Port) + } + log.Printf("[ensureNeo4jDatabase] 使用 URI: %s", uri) + + // 查找已存在的记录 + for _, d := range databases { + log.Printf("[ensureNeo4jDatabase] 对比: URI=%s, Username=%s, Database=%s", d.URI, d.Username, d.Database) + if d.URI == uri && d.Username == req.Username && d.Database == dbName { + log.Printf("[ensureNeo4jDatabase] 找到已存在的记录, id=%s, name=%s", d.ID, d.Name) + return &Neo4jDatabaseInfo{ + ID: d.ID, + Name: d.Name, + Description: d.Description, + }, nil + } + } + + // 不存在,创建新记录 + log.Printf("[ensureNeo4jDatabase] 未找到匹配记录,创建新记录") + dbType := "neo4j" + name := req.Name + if name == "" { + name = fmt.Sprintf("Neo4j-%s", dbName) + } + description := req.Description + if description == "" { + description = fmt.Sprintf("Neo4j %s@%s:%d", dbName, req.Host, req.Port) + } + + newDB := &model.DatabaseInfo{ + ID: uuid.New().String(), + Name: name, + DBType: dbType, + Host: req.Host, + Port: req.Port, + Username: req.Username, + Password: req.Password, + Database: dbName, + URI: uri, + Description: description, + } + + log.Printf("[ensureNeo4jDatabase] 创建新数据库: ID=%s, Name=%s, Host=%s, Port=%d, URI=%s", newDB.ID, newDB.Name, newDB.Host, newDB.Port, newDB.URI) + + if err := s.databaseRepo.Create(newDB); err != nil { + log.Printf("[ensureNeo4jDatabase] Create 失败: %v", err) + return nil, err + } + + log.Printf("[ensureNeo4jDatabase] 创建成功, 返回 ID=%s", newDB.ID) + return &Neo4jDatabaseInfo{ + ID: newDB.ID, + Name: newDB.Name, + Description: newDB.Description, + }, nil +} + +// GetGraphOverview 获取图谱概览数据 +func (s *Neo4jService) GetGraphOverview(req model.CheckRequest) (*model.GraphOverview, error) { + // 从 CheckRequest 获取连接信息 + // URI 可能是 bolt://host:7687 格式 + host := req.Host + port := req.Port + if req.URI != "" { + // 解析 URI + uri := strings.TrimPrefix(req.URI, "bolt://") + uri = strings.TrimPrefix(uri, "neo4j://") + if idx := strings.Index(uri, ":"); idx > 0 { + host = uri[:idx] + fmt.Sscanf(uri[idx+1:], "%d", &port) + } + } + + db := req.Database + if db == "" { + db = "neo4j" + } + + auth := fmt.Sprintf("%s:%s", req.Username, req.Password) + encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth)) + + // 尝试多个端口 + ports := []int{port - 1000, 7474, port} + checkedPorts := make(map[int]bool) + + var graph *model.GraphOverview + for _, p := range ports { + if checkedPorts[p] { + continue + } + checkedPorts[p] = true + + url := fmt.Sprintf("http://%s:%d/db/%s/tx/commit", host, p, db) + var err error + graph, err = s.getGraphOverviewWithURL(url, encodedAuth, db) + if err == nil && graph != nil { + return graph, nil + } + } + + return nil, fmt.Errorf("failed to connect to Neo4j") +} + +func (s *Neo4jService) getGraphOverviewWithURL(url, encodedAuth, db string) (*model.GraphOverview, error) { + // 查询所有标签及其数量 + labelsQuery := `CALL db.labels() YIELD label RETURN label` + relTypesQuery := `CALL db.relationshipTypes() YIELD relationshipType RETURN relationshipType` + + // 构建复合查询 + query := fmt.Sprintf(`{"statements": [ + {"statement": "%s", "resultDataContents": ["row"]}, + {"statement": "%s", "resultDataContents": ["row"]}, + {"statement": "MATCH (n) RETURN labels(n) as nodeLabels, count(*) as count", "resultDataContents": ["row"]}, + {"statement": "MATCH ()-[r]->() RETURN type(r) as relType, count(*) as count", "resultDataContents": ["row"]} + ]}`, labelsQuery, relTypesQuery) + + reqBody := strings.NewReader(query) + httpReq, err := http.NewRequest("POST", url, reqBody) + if err != nil { + return nil, err + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Basic "+encodedAuth) + + resp, err := s.client.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + + // 检查错误 + if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 { + if errMap, ok := errors[0].(map[string]interface{}); ok { + if msg, ok := errMap["message"].(string); ok { + return nil, fmt.Errorf("%s", msg) + } + } + return nil, fmt.Errorf("query error") + } + + // 解析结果 + results, ok := result["results"].([]interface{}) + if !ok || len(results) < 4 { + return nil, fmt.Errorf("invalid response format") + } + + graph := &model.GraphOverview{ + Labels: []model.LabelCount{}, + RelationshipTypes: []model.RelTypeCount{}, + Nodes: []model.NodeProperty{}, + Relationships: []model.RelProperty{}, + } + + // 解析标签 + if len(results) > 0 { + if res0, ok := results[0].(map[string]interface{}); ok { + if data, ok := res0["data"].([]interface{}); ok { + for _, item := range data { + if row, ok := item.(map[string]interface{}); ok { + if label, ok := row["row"].([]interface{}); ok && len(label) > 0 { + graph.Labels = append(graph.Labels, model.LabelCount{ + Name: fmt.Sprintf("%v", label[0]), + Count: 0, + }) + } + } + } + } + } + } + + // 解析关系类型 + if len(results) > 1 { + if res1, ok := results[1].(map[string]interface{}); ok { + if data, ok := res1["data"].([]interface{}); ok { + for _, item := range data { + if row, ok := item.(map[string]interface{}); ok { + if relType, ok := row["row"].([]interface{}); ok && len(relType) > 0 { + graph.RelationshipTypes = append(graph.RelationshipTypes, model.RelTypeCount{ + Name: fmt.Sprintf("%v", relType[0]), + Count: 0, + }) + } + } + } + } + } + } + + // 解析节点统计 + if len(results) > 2 { + if res2, ok := results[2].(map[string]interface{}); ok { + if data, ok := res2["data"].([]interface{}); ok { + for _, item := range data { + if row, ok := item.(map[string]interface{}); ok { + if rowData, ok := row["row"].([]interface{}); ok && len(rowData) >= 2 { + if labels, ok := rowData[0].([]interface{}); ok && len(labels) > 0 { + labelName := fmt.Sprintf("%v", labels[0]) + count := int(rowData[1].(float64)) + + // 更新标签数量 + for i := range graph.Labels { + if graph.Labels[i].Name == labelName { + graph.Labels[i].Count = count + break + } + } + } + } + } + } + } + } + } + + // 解析关系统计 + if len(results) > 3 { + if res3, ok := results[3].(map[string]interface{}); ok { + if data, ok := res3["data"].([]interface{}); ok { + for _, item := range data { + if row, ok := item.(map[string]interface{}); ok { + if rowData, ok := row["row"].([]interface{}); ok && len(rowData) >= 2 { + relTypeName := fmt.Sprintf("%v", rowData[0]) + count := int(rowData[1].(float64)) + + // 更新关系类型数量 + for i := range graph.RelationshipTypes { + if graph.RelationshipTypes[i].Name == relTypeName { + graph.RelationshipTypes[i].Count = count + break + } + } + } + } + } + } + } + } + + // 获取节点属性定义 + s.fillNodeProperties(url, encodedAuth, db, graph) + // 获取关系属性定义 + s.fillRelProperties(url, encodedAuth, db, graph) + + return graph, nil +} + +func (s *Neo4jService) fillNodeProperties(url, encodedAuth, db string, graph *model.GraphOverview) { + // 获取每个标签的属性 + for i, label := range graph.Labels { + query := fmt.Sprintf(`MATCH (n:%s) RETURN properties(n) as props LIMIT 1`, label.Name) + body := fmt.Sprintf(`{"statements": [{"statement": "%s", "resultDataContents": ["row"]}]}`, query) + + reqBody := strings.NewReader(body) + httpReq, err := http.NewRequest("POST", url, reqBody) + if err != nil { + continue + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Basic "+encodedAuth) + + resp, err := s.client.Do(httpReq) + if err != nil { + continue + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + continue + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + continue + } + + results, ok := result["results"].([]interface{}) + if !ok || len(results) == 0 { + continue + } + + if res0, ok := results[0].(map[string]interface{}); ok { + if data, ok := res0["data"].([]interface{}); ok && len(data) > 0 { + if item, ok := data[0].(map[string]interface{}); ok { + if row, ok := item["row"].([]interface{}); ok && len(row) > 0 { + if props, ok := row[0].(map[string]interface{}); ok { + nodeProp := model.NodeProperty{ + Label: label.Name, + Properties: []model.PropertyInfo{}, + } + for name, value := range props { + nodeProp.Properties = append(nodeProp.Properties, model.PropertyInfo{ + Name: name, + Type: fmt.Sprintf("%T", value), + }) + } + graph.Nodes = append(graph.Nodes, nodeProp) + graph.Labels[i].Count = label.Count + } + } + } + } + } + } +} + +func (s *Neo4jService) fillRelProperties(url, encodedAuth, db string, graph *model.GraphOverview) { + // 获取每个关系类型的属性 + for _, relType := range graph.RelationshipTypes { + query := fmt.Sprintf(`MATCH ()-[r:%s]->() RETURN properties(r) as props LIMIT 1`, relType.Name) + body := fmt.Sprintf(`{"statements": [{"statement": "%s", "resultDataContents": ["row"]}]}`, query) + + reqBody := strings.NewReader(body) + httpReq, err := http.NewRequest("POST", url, reqBody) + if err != nil { + continue + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Basic "+encodedAuth) + + resp, err := s.client.Do(httpReq) + if err != nil { + continue + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + continue + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + continue + } + + results, ok := result["results"].([]interface{}) + if !ok || len(results) == 0 { + continue + } + + if res0, ok := results[0].(map[string]interface{}); ok { + if data, ok := res0["data"].([]interface{}); ok && len(data) > 0 { + if item, ok := data[0].(map[string]interface{}); ok { + if row, ok := item["row"].([]interface{}); ok && len(row) > 0 { + if props, ok := row[0].(map[string]interface{}); ok { + relProp := model.RelProperty{ + Type: relType.Name, + Properties: []model.PropertyInfo{}, + } + for name, value := range props { + relProp.Properties = append(relProp.Properties, model.PropertyInfo{ + Name: name, + Type: fmt.Sprintf("%T", value), + }) + } + graph.Relationships = append(graph.Relationships, relProp) + } + } + } + } + } + } +} + +// GetNodes 获取节点详情 +func (s *Neo4jService) GetNodes(req model.Neo4jNodeRequest) (*model.Neo4jNodeResponse, error) { + host := "localhost" + port := 7687 + if req.URI != "" { + uri := strings.TrimPrefix(req.URI, "bolt://") + uri = strings.TrimPrefix(uri, "neo4j://") + if idx := strings.Index(uri, ":"); idx > 0 { + host = uri[:idx] + fmt.Sscanf(uri[idx+1:], "%d", &port) + } + } + + db := req.Database + if db == "" { + db = "neo4j" + } + + limit := req.Limit + if limit <= 0 { + limit = 10 + } + + auth := fmt.Sprintf("%s:%s", req.Username, req.Password) + encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth)) + + ports := []int{port - 1000, 7474, port} + checkedPorts := make(map[int]bool) + + for _, p := range ports { + if checkedPorts[p] { + continue + } + checkedPorts[p] = true + + url := fmt.Sprintf("http://%s:%d/db/%s/tx/commit", host, p, db) + nodes, props, err := s.getNodesWithURL(url, encodedAuth, req.Label, limit) + if err == nil { + return &model.Neo4jNodeResponse{ + Success: true, + Message: "success", + Nodes: nodes, + Properties: props, + }, nil + } + } + + return &model.Neo4jNodeResponse{ + Success: false, + Message: "failed to connect to Neo4j", + }, nil +} + +func (s *Neo4jService) getNodesWithURL(url, encodedAuth, label string, limit int) ([]map[string]interface{}, []model.PropertyInfo, error) { + query := fmt.Sprintf(`MATCH (n:%s) RETURN properties(n) as props, elementId(n) as id LIMIT %d`, label, limit) + body := fmt.Sprintf(`{"statements": [{"statement": "%s", "resultDataContents": ["row"]}]}`, query) + + reqBody := strings.NewReader(body) + httpReq, err := http.NewRequest("POST", url, reqBody) + if err != nil { + return nil, nil, err + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Basic "+encodedAuth) + + resp, err := s.client.Do(httpReq) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, err + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, nil, err + } + + if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 { + if errMap, ok := errors[0].(map[string]interface{}); ok { + if msg, ok := errMap["message"].(string); ok { + return nil, nil, fmt.Errorf("%s", msg) + } + } + return nil, nil, fmt.Errorf("query error") + } + + results, ok := result["results"].([]interface{}) + if !ok || len(results) == 0 { + return []map[string]interface{}{}, []model.PropertyInfo{}, nil + } + + var nodes []map[string]interface{} + propsMap := make(map[string]string) // 用于去重属性 + + if res0, ok := results[0].(map[string]interface{}); ok { + if data, ok := res0["data"].([]interface{}); ok { + for _, item := range data { + if row, ok := item.(map[string]interface{}); ok { + if rowData, ok := row["row"].([]interface{}); ok && len(rowData) >= 2 { + node := map[string]interface{}{ + "id": rowData[1], + } + if props, ok := rowData[0].(map[string]interface{}); ok { + for k, v := range props { + node[k] = v + // 收集属性类型 + if _, exists := propsMap[k]; !exists { + propsMap[k] = fmt.Sprintf("%T", v) + } + } + } + nodes = append(nodes, node) + } + } + } + } + } + + // 构建属性列表 + var properties []model.PropertyInfo + for name, propType := range propsMap { + properties = append(properties, model.PropertyInfo{ + Name: name, + Type: propType, + }) + } + + return nodes, properties, nil +} + +// GetRelationships 获取关系详情 +func (s *Neo4jService) GetRelationships(req model.Neo4jRelRequest) (*model.Neo4jRelResponse, error) { + host := "localhost" + port := 7687 + if req.URI != "" { + uri := strings.TrimPrefix(req.URI, "bolt://") + uri = strings.TrimPrefix(uri, "neo4j://") + if idx := strings.Index(uri, ":"); idx > 0 { + host = uri[:idx] + fmt.Sscanf(uri[idx+1:], "%d", &port) + } + } + + db := req.Database + if db == "" { + db = "neo4j" + } + + limit := req.Limit + if limit <= 0 { + limit = 10 + } + + auth := fmt.Sprintf("%s:%s", req.Username, req.Password) + encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth)) + + ports := []int{port - 1000, 7474, port} + checkedPorts := make(map[int]bool) + + for _, p := range ports { + if checkedPorts[p] { + continue + } + checkedPorts[p] = true + + url := fmt.Sprintf("http://%s:%d/db/%s/tx/commit", host, p, db) + rels, err := s.getRelationshipsWithURL(url, encodedAuth, req.RelationshipType, limit) + if err == nil { + return &model.Neo4jRelResponse{ + Success: true, + Message: "success", + Relationships: rels, + }, nil + } + } + + return &model.Neo4jRelResponse{ + Success: false, + Message: "failed to connect to Neo4j", + }, nil +} + +func (s *Neo4jService) getRelationshipsWithURL(url, encodedAuth, relType string, limit int) ([]model.Neo4jRelationship, error) { + query := fmt.Sprintf(`MATCH (a)-[r:%s]->(b) RETURN properties(r) as props, elementId(a) as startId, elementId(b) as endId, labels(a) as startLabels, labels(b) as endLabels, elementId(r) as relId LIMIT %d`, relType, limit) + body := fmt.Sprintf(`{"statements": [{"statement": "%s", "resultDataContents": ["row"]}]}`, query) + + reqBody := strings.NewReader(body) + httpReq, err := http.NewRequest("POST", url, reqBody) + if err != nil { + return nil, err + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Basic "+encodedAuth) + + resp, err := s.client.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, err + } + + if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 { + if errMap, ok := errors[0].(map[string]interface{}); ok { + if msg, ok := errMap["message"].(string); ok { + return nil, fmt.Errorf("%s", msg) + } + } + return nil, fmt.Errorf("query error") + } + + results, ok := result["results"].([]interface{}) + if !ok || len(results) == 0 { + return []model.Neo4jRelationship{}, nil + } + + var rels []model.Neo4jRelationship + if res0, ok := results[0].(map[string]interface{}); ok { + if data, ok := res0["data"].([]interface{}); ok { + for _, item := range data { + if row, ok := item.(map[string]interface{}); ok { + if rowData, ok := row["row"].([]interface{}); ok && len(rowData) >= 6 { + rel := model.Neo4jRelationship{ + ID: fmt.Sprintf("%v", rowData[5]), + Source: fmt.Sprintf("%v", rowData[1]), + Target: fmt.Sprintf("%v", rowData[2]), + } + if props, ok := rowData[0].(map[string]interface{}); ok { + rel.Properties = props + } + rels = append(rels, rel) + } + } + } + } + } + + return rels, nil +} + +func (s *Neo4jService) checkWithURL(url, encodedAuth, db string) (*model.Neo4jCheckResponse, error) { + query := `{"statements": [{"statement": "RETURN 1 AS num"}]}` + reqBody := strings.NewReader(query) + + httpReq, err := http.NewRequest("POST", url, reqBody) + if err != nil { + return &model.Neo4jCheckResponse{ + Success: false, + Message: fmt.Sprintf("failed to create request: %v", err), + }, nil + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Basic "+encodedAuth) + + resp, err := s.client.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return &model.Neo4jCheckResponse{ + Success: false, + Message: fmt.Sprintf("failed to read response: %v", err), + }, nil + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return &model.Neo4jCheckResponse{ + Success: false, + Message: fmt.Sprintf("failed to parse response: %v", err), + }, nil + } + + if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 { + msg := "connection failed" + if errMap, ok := errors[0].(map[string]interface{}); ok { + if m, ok := errMap["message"].(string); ok { + msg = m + } + } + return &model.Neo4jCheckResponse{ + Success: false, + Message: msg, + }, nil + } + + version := "unknown" + if resp.Header.Get("X-Neo4j-Version") != "" { + version = resp.Header.Get("X-Neo4j-Version") + } + + return &model.Neo4jCheckResponse{ + Success: true, + Message: "connection successful", + Version: version, + Databases: []string{db}, + }, nil +} diff --git a/server/server.exe b/server/server.exe new file mode 100644 index 0000000..c9ab2af Binary files /dev/null and b/server/server.exe differ diff --git a/server/temp_add_data2.go b/server/temp_add_data2.go deleted file mode 100644 index 5ad9c84..0000000 --- a/server/temp_add_data2.go +++ /dev/null @@ -1,122 +0,0 @@ -package main - -import ( - "fmt" - "math/rand" - "time" - - "gorm.io/driver/mysql" - "gorm.io/gorm" -) - -type Teacher struct { - ID uint `gorm:"primaryKey"` - Name string `gorm:"size:50;charset=utf8mb4"` - Subject string `gorm:"size:50;charset=utf8mb4"` - Phone string `gorm:"size:20"` - CreatedAt time.Time -} - -type Student struct { - ID uint `gorm:"primaryKey"` - Name string `gorm:"size:50;charset=utf8mb4"` - Age int - Gender string `gorm:"size:10;charset=utf8mb4"` - Class string `gorm:"size:50;charset=utf8mb4"` - Phone string `gorm:"size:20"` - CreatedAt time.Time -} - -type Score struct { - ID uint `gorm:"primaryKey"` - StudentID uint - Subject string `gorm:"size:50;charset=utf8mb4"` - Score float64 - TeacherID uint - ExamDate time.Time - CreatedAt time.Time -} - -func main() { - dsn := "root:881116142@tcp(10.10.10.189:3306)/students?charset=utf8mb4&parseTime=True&loc=Local" - db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) - if err != nil { - panic("连接数据库失败: " + err.Error()) - } - - // 自动迁移表 - db.AutoMigrate(&Teacher{}, &Student{}, &Score{}) - - // 清理旧数据 - db.Exec("DELETE FROM scores") - db.Exec("DELETE FROM students") - db.Exec("DELETE FROM teachers") - - rand.Seed(time.Now().UnixNano()) - - // 创建教师 - teachers := []Teacher{ - {Name: "张老师", Subject: "数学", Phone: "13800001001"}, - {Name: "李老师", Subject: "语文", Phone: "13800001002"}, - {Name: "王老师", Subject: "英语", Phone: "13800001003"}, - {Name: "刘老师", Subject: "物理", Phone: "13800001004"}, - {Name: "陈老师", Subject: "化学", Phone: "13800001005"}, - {Name: "杨老师", Subject: "生物", Phone: "13800001006"}, - {Name: "赵老师", Subject: "历史", Phone: "13800001007"}, - {Name: "周老师", Subject: "地理", Phone: "13800001008"}, - } - db.Create(&teachers) - - // 创建30个学生 - names := []string{"张三", "李四", "王五", "刘六", "陈七", "杨八", "赵九", "钱十", - "孙一", "周二", "吴三", "郑四", "冯五", "褚六", "卫七", "蒋八", - "沈九", "韩十", "朱十一", "秦十二", "许十三", "何十四", "吕十五", "施十六", - "张十七", "孔十八", "曹十九", "严二十", "华二十一", "金二十二"} - genders := []string{"男", "女"} - classes := []string{"高一(1)班", "高一(2)班", "高一(3)班", "高二(1)班", "高二(2)班"} - - students := make([]Student, 30) - for i := 0; i < 30; i++ { - students[i] = Student{ - Name: names[i], - Age: 15 + rand.Intn(3), - Gender: genders[rand.Intn(len(genders))], - Class: classes[rand.Intn(len(classes))], - Phone: fmt.Sprintf("139%08d", 10000000+rand.Intn(90000000)), - } - } - db.Create(&students) - - // 为每个学生创建成绩记录 - subjects := []string{"数学", "语文", "英语", "物理", "化学", "生物", "历史", "地理"} - scores := make([]Score, 0) - - for i := 0; i < 30; i++ { - numSubjects := 4 + rand.Intn(3) - selectedSubjects := make(map[string]bool) - for len(selectedSubjects) < numSubjects { - subj := subjects[rand.Intn(len(subjects))] - if !selectedSubjects[subj] { - selectedSubjects[subj] = true - - teacherID := uint(1 + rand.Intn(len(teachers))) - examDate := time.Now().AddDate(0, -rand.Intn(6), -rand.Intn(30)) - - score := Score{ - StudentID: students[i].ID, - Subject: subj, - Score: 60 + rand.Float64()*40, - TeacherID: teacherID, - ExamDate: examDate, - } - scores = append(scores, score) - } - } - } - db.Create(&scores) - - fmt.Println("数据创建成功!") - fmt.Printf("教师: %d 条\n", len(teachers)) - fmt.Printf("学生: %d 条\n", len(students)) - fmt.Printf("成绩: %d 条\n", len(scores)) -} diff --git a/server/temp_check.go b/server/temp_check.go deleted file mode 100644 index 4df8904..0000000 --- a/server/temp_check.go +++ /dev/null @@ -1,27 +0,0 @@ -package main - -import ( - "gorm.io/driver/mysql" - "gorm.io/gorm" -) - -func main() { - dsn := "root:881116142@tcp(10.10.10.189:3306)/mysql?charset=utf8mb4&parseTime=True&loc=Local" - db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) - if err != nil { - panic("连接失败: " + err.Error()) - } - - type User struct { - User string - Host string - } - - var users []User - db.Raw("SELECT User, Host FROM mysql.user WHERE User='root'").Scan(&users) - - println("Root 用户列表:") - for _, u := range users { - println("- User: " + u.User + ", Host: " + u.Host) - } -} diff --git a/server/temp_native.go b/server/temp_native.go deleted file mode 100644 index aec8853..0000000 --- a/server/temp_native.go +++ /dev/null @@ -1,32 +0,0 @@ -package main - -import ( - "gorm.io/driver/mysql" - "gorm.io/gorm" -) - -func main() { - dsn := "root:881116142@tcp(10.10.10.189:3306)/mysql?charset=utf8mb4&parseTime=True&loc=Local" - db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) - if err != nil { - panic("连接失败: " + err.Error()) - } - - // 使用 mysql_native_password 插件重建用户 - sqls := []string{ - "DROP USER IF EXISTS 'root'@'%'", - "CREATE USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY '881116142'", - "GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION", - "FLUSH PRIVILEGES", - } - - for _, sql := range sqls { - if err := db.Exec(sql).Error; err != nil { - println("执行: " + sql + " - 错误: " + err.Error()) - } else { - println("成功: " + sql) - } - } - - println("完成! 用 mysql_native_password 插件重建了 root 用户") -} diff --git a/server/temp_newuser.go b/server/temp_newuser.go deleted file mode 100644 index 2abfefa..0000000 --- a/server/temp_newuser.go +++ /dev/null @@ -1,41 +0,0 @@ -package main - -import ( - "gorm.io/driver/mysql" - "gorm.io/gorm" -) - -func main() { - // 尝试用 root 用户连接,但指定 IP - dsn := "root:881116142@tcp(127.0.0.1:3306)/mysql?charset=utf8mb4&parseTime=True&loc=Local&multiStatements=true" - db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) - if err != nil { - // 尝试其他方式 - dsn2 := "root:881116142@tcp(localhost:3306)/mysql?charset=utf8mb4&parseTime=True&loc=Local&multiStatements=true" - db, err = gorm.Open(mysql.Open(dsn2), &gorm.Config{}) - if err != nil { - println("无法连接,请通过其他方式在 MySQL 服务器上执行:") - println("CREATE USER IF NOT EXISTS 'root'@'%' IDENTIFIED BY '881116142';") - println("GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION;") - println("FLUSH PRIVILEGES;") - panic("连接失败: " + err.Error()) - } - } - - // 创建新用户 - sqls := []string{ - "CREATE USER IF NOT EXISTS 'admin'@'%' IDENTIFIED BY 'admin123'", - "GRANT ALL PRIVILEGES ON *.* TO 'admin'@'%' WITH GRANT OPTION", - "FLUSH PRIVILEGES", - } - - for _, sql := range sqls { - if err := db.Exec(sql).Error; err != nil { - println("执行: " + sql + " - 错误: " + err.Error()) - } else { - println("执行成功: " + sql) - } - } - - println("创建了新用户 admin,可以用这个连接 Navicat") -} diff --git a/server/temp_regrant.go b/server/temp_regrant.go deleted file mode 100644 index 42c1abe..0000000 --- a/server/temp_regrant.go +++ /dev/null @@ -1,32 +0,0 @@ -package main - -import ( - "gorm.io/driver/mysql" - "gorm.io/gorm" -) - -func main() { - dsn := "root:881116142@tcp(10.10.10.189:3306)/mysql?charset=utf8mb4&parseTime=True&loc=Local&multiStatements=true" - db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) - if err != nil { - panic("连接失败: " + err.Error()) - } - - // 重建 root@% 用户并设置密码 - sqls := []string{ - "DROP USER IF EXISTS 'root'@'%'", - "CREATE USER 'root'@'%' IDENTIFIED BY '881116142'", - "GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION", - "FLUSH PRIVILEGES", - } - - for _, sql := range sqls { - if err := db.Exec(sql).Error; err != nil { - println("执行: " + sql + " - 错误: " + err.Error()) - } else { - println("执行成功: " + sql) - } - } - - println("完成!") -} diff --git a/server/temp_reset.go b/server/temp_reset.go deleted file mode 100644 index 2ab68fd..0000000 --- a/server/temp_reset.go +++ /dev/null @@ -1,35 +0,0 @@ -package main - -import ( - "gorm.io/driver/mysql" - "gorm.io/gorm" -) - -func main() { - dsn := "root:881116142@tcp(10.10.10.189:3306)/mysql?charset=utf8mb4&parseTime=True&loc=Local&allowOldStrings=true" - db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) - if err != nil { - panic("连接失败: " + err.Error()) - } - - // 删除所有 root 用户 - sqls := []string{ - "DROP USER IF EXISTS 'root'@'%'", - "DROP USER IF EXISTS 'root'@'10.10.10.122'", - "DROP USER IF EXISTS 'root'@'localhost'", - "DROP USER IF EXISTS 'root'@'127.0.0.1'", - "CREATE USER 'root'@'%' IDENTIFIED BY '881116142'", - "GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION", - "FLUSH PRIVILEGES", - } - - for _, sql := range sqls { - if err := db.Exec(sql).Error; err != nil { - println("执行: " + sql + " - 错误: " + err.Error()) - } else { - println("成功: " + sql) - } - } - - println("完成!") -} diff --git a/team-require/api/README.md b/team-require/api/README.md index 8ffaea0..bd76b18 100644 --- a/team-require/api/README.md +++ b/team-require/api/README.md @@ -9,6 +9,10 @@ - [获取数据库列表](database-list.md) - [获取子表列表](subtable-list.md) +### Neo4j 相关 + +- [Neo4j 连接测试](neo4j-check.md) + --- > 接口如有更新,请同步更新此文档 diff --git a/team-require/api/database-check.md b/team-require/api/database-check.md index 68da395..8370434 100644 --- a/team-require/api/database-check.md +++ b/team-require/api/database-check.md @@ -10,7 +10,7 @@ POST /database/check | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| -| db_type | string | 是 | 数据库类型:`mysql`、`postgres` | +| db_type | string | 是 | 数据库类型:`mysql`、`postgres`、`neo4j` | | host | string | 是 | 数据库主机 | | port | int | 是 | 数据库端口 | | username | string | 是 | 用户名 | @@ -19,8 +19,9 @@ POST /database/check | charset | string | 否 | 字符集,默认 `utf8mb4` | | ssl_mode | string | 否 | SSL 模式 | | database_id | string | 否 | 已存在的数据库ID,用于恢复字段映射 | +| uri | string | 否 | Neo4j 连接地址(如 bolt://localhost:7687),Neo4j 类型必填 | -## 请求示例 +## 请求示例(MySQL/PostgreSQL) ```json { @@ -31,7 +32,19 @@ POST /database/check "password": "root", "database": "students", "charset": "utf8mb4", - "database_id": "xxx-xxx-xxx" // 可选,用于恢复字段映射 + "database_id": "xxx-xxx-xxx" +} +``` + +## 请求示例(Neo4j) + +```json +{ + "db_type": "neo4j", + "uri": "bolt://localhost:7687", + "username": "neo4j", + "password": "password", + "database": "neo4j" } ``` @@ -42,9 +55,10 @@ POST /database/check | success | bool | 是否连接成功 | | message | string | 消息 | | database | string | 数据库名 | -| tables | array | 表结构列表 | +| tables | array | 表结构列表(MySQL/PostgreSQL) | +| graphs | object | 图谱数据(Neo4j) | -### tables[] 详情 +### tables[] 详情(关系型数据库) | 参数 | 类型 | 说明 | |------|------|------| @@ -67,7 +81,46 @@ POST /database/check | column_comment | string | 列注释 | | mapped_name | string | 字段中文映射名(已保存的映射) | -## 返回示例 +### graphs 详情(Neo4j) + +| 参数 |类型| 说明 | +|------|------|------| +| labels | array | 标签列表 | +| relationshipTypes | array | 关系类型列表 | +| nodes | array | 节点属性定义 | +| relationships | array | 关系属性定义 | + +### graphs.labels[] + +| 参数 | 类型 | 说明 | +|------|------|------| +| name | string | 标签名称 | +| count | int | 节点数量 | + +### graphs.relationshipTypes[] + +| 参数 | 类型 | 说明 | +|------|------|------| +| name | string | 关系类型名称 | +| count | int | 关系数量 | + +### graphs.nodes[] + +| 参数 | 类型 | 说明 | +|------|------|------| +| label | string | 节点标签名 | +| properties | array | 属性列表 | + +### graphs.relationships[] + +| 参数 | 类型 | 说明 | +|------|------|------| +| type | string | 关系类型名 | +| startLabel | string | 起始节点标签 | +| endLabel | string | 目标节点标签 | +| properties | array | 属性列表 | + +## 返回示例(MySQL/PostgreSQL) ```json { @@ -97,7 +150,51 @@ POST /database/check } ``` +## 返回示例(Neo4j) + +```json +{ + "success": true, + "message": "connection successful", + "database": "neo4j", + "graphs": { + "labels": [ + {"name": "User", "count": 100}, + {"name": "Order", "count": 50} + ], + "relationshipTypes": [ + {"name": "KNOWS", "count": 30}, + {"name": "OWNS", "count": 20} + ], + "nodes": [ + { + "label": "User", + "properties": [ + {"name": "id", "type": "string"}, + {"name": "name", "type": "string"} + ] + } + ], + "relationships": [ + { + "type": "KNOWS", + "startLabel": "User", + "endLabel": "User", + "properties": [ + {"name": "since", "type": "date"} + ] + } + ] + } +} +``` + ## 使用场景 -1. **首次连接**:不传 `database_id`,获取实时表结构 -2. **恢复映射**:传入 `database_id`,返回已保存的 `mapped_name` 和 `ddl` +1. **关系型数据库**: + - 首次连接:不传 `database_id`,获取实时表结构 + - 恢复映射:传入 `database_id`,返回已保存的 `mapped_name` 和 `ddl` + +2. **Neo4j 图数据库**: + - 连接 Neo4j 并获取图谱概览数据(标签、关系类型、属性定义) + - 用于前端图可视化展示 diff --git a/team-require/api/neo4j-check.md b/team-require/api/neo4j-check.md new file mode 100644 index 0000000..a75f4c5 --- /dev/null +++ b/team-require/api/neo4j-check.md @@ -0,0 +1,265 @@ +# Neo4j 连接测试 + +## 接口地址 + +``` +POST /neo4j/check +``` + +## 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| host | string | 是 | Neo4j 主机 | +| port | int | 是 | Neo4j 端口(默认 7687) | +| username | string | 是 | 用户名(默认 neo4j) | +| password | string | 是 | 密码 | +| database | string | 否 | 数据库名(默认 neo4j) | +| name | string | 否 | 数据库名称,用于保存记录 | +| uri | string | 否 | Neo4j 连接地址(bolt://host:port) | +| description | string | 否 | 数据库描述 | + +## 请求示例 + +```json +{ + "host": "localhost", + "port": 7687, + "username": "neo4j", + "password": "password", + "database": "neo4j", + "name": "My Neo4j Database" +} +``` + +## 返回参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| success | bool | 是否连接成功 | +| message | string | 消息 | +| version | string | Neo4j 版本 | +| databases | array | 数据库列表 | +| databaseId | string | 数据库记录 ID(新增) | +| name | string | 数据库名称(新增) | +| description | string | 数据库描述(新增) | + +## 返回示例 + +```json +{ + "success": true, + "message": "connection successful", + "version": "5.14.0", + "databases": ["neo4j", "system"], + "databaseId": "abc-123-def", + "name": "Neo4j-neo4j", + "description": "Neo4j neo4j@localhost:7687" +} +``` + +> **说明**:连接成功时,后端会自动检查数据库记录是否存在,不存在则创建并返回 `databaseId`、`name` 和 `description`。前端可使用这些信息进行后续保存图谱操作。 + +--- + +# Neo4j 获取图谱概览数据(核心接口) + +获取所有标签(Labels)和关系类型(Relationship Types)的统计信息,用于前端图谱可视化。 + +## 接口地址 + +``` +POST /neo4j/graphs +``` + +## 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| uri | string | 是 | Neo4j 连接地址,如 bolt://localhost:7687 | +| username | string | 是 | 用户名 | +| password | string | 是 | 密码 | +| database | string | 否 | 数据库名(默认 neo4j) | + +## 请求示例 + +```json +{ + "uri": "bolt://localhost:7687", + "username": "neo4j", + "password": "password", + "database": "neo4j" +} +``` + +## 返回参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| success | bool | 是否成功 | +| graphs | object | 图谱数据 | + +### graphs 对象 + +| 参数 | 类型 | 说明 | +|------| +| labels------|------| | array | 标签列表 | +| relationshipTypes | array | 关系类型列表 | + +### labels 数组项 + +| 参数 | 类型 | 说明 | +|------|------|------| +| name | string | 标签名称 | +| count | int | 该标签的节点数量 | + +### relationshipTypes 数组项 + +| 参数 | 类型 | 说明 | +|------|------|------| +| name | string | 关系类型名称 | +| count | int | 该关系的数量 | + +## 返回示例 + +```json +{ + "success": true, + "graphs": { + "labels": [ + {"name": "User", "count": 1523}, + {"name": "Order", "count": 856}, + {"name": "Product", "count": 2341}, + {"name": "Category", "count": 45}, + {"name": "Review", "count": 5678} + ], + "relationshipTypes": [ + {"name": "KNOWS", "count": 2341}, + {"name": "BOUGHT", "count": 5678}, + {"name": "BELONGS_TO", "count": 2341}, + {"name": "HAS_REVIEW", "count": 5678}, + {"name": "LOCATED_IN", "count": 1523} + ] + } +} +``` + +## 前端使用说明 + +前端使用 ECharts 力导向图谱展示: +- `labels` 生成图谱节点,count 决定节点大小 +- `relationshipTypes` 生成图谱边 +- 建议返回至少 5-10 个关系类型以便生成丰富图谱 + +--- + +# Neo4j 获取节点详情 + +## 接口地址 + +``` +POST /neo4j/nodes +``` + +## 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| uri | string | 是 | Neo4j 连接地址,如 bolt://localhost:7687 | +| username | string | 是 | 用户名 | +| password | string | 是 | 密码 | +| database | string | 否 | 数据库名 | +| label | string | 是 | 节点标签名 | +| limit | int | 否 | 返回数量限制,默认 10 | + +## 请求示例 + +```json +{ + "uri": "bolt://localhost:7687", + "username": "neo4j", + "password": "password", + "label": "User", + "limit": 10 +} +``` + +## 返回参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| success | bool | 是否成功 | +| message | string | 消息 | +| nodes | array | 节点数据列表 | + +## 返回示例 + +```json +{ + "success": true, + "message": "success", + "nodes": [ + {"id": "1", "name": "张三", "email": "zhangsan@example.com"}, + {"id": "2", "name": "李四", "email": "lisi@example.com"} + ] +} +``` + +--- + +# Neo4j 获取关系详情 + +## 接口地址 + +``` +POST /neo4j/relationships +``` + +## 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| uri | string | 是 | Neo4j 连接地址 | +| username | string | 是 | 用户名 | +| password | string | 是 | 密码 | +| database | string | 否 | 数据库名 | +| relationship_type | string | 是 | 关系类型名 | +| limit | int | 否 | 返回数量限制,默认 10 | + +## 请求示例 + +```json +{ + "uri": "bolt://localhost:7687", + "username": "neo4j", + "password": "password", + "relationship_type": "KNOWS", + "limit": 10 +} +``` + +## 返回参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| success | bool | 是否成功 | +| message | string | 消息 | +| relationships | array | 关系数据列表 | + +## 返回示例 + +```json +{ + "success": true, + "message": "success", + "relationships": [ + { + "startId": "1", + "endId": "2", + "startLabels": ["User"], + "endLabels": ["User"], + "since": "2020-01-01" + } + ] +} +``` diff --git a/team-require/api/neo4j-graph-save.md b/team-require/api/neo4j-graph-save.md new file mode 100644 index 0000000..89d69e8 --- /dev/null +++ b/team-require/api/neo4j-graph-save.md @@ -0,0 +1,75 @@ +# Neo4j 图谱保存接口需求 + +## 需求说明 + +前端需要保存 Neo4j 图谱的连接信息,以便后续快速加载和查看。 + +--- + +## 接口地址 + +``` +POST /database/graph/save +``` + +## 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| databaseId | string | 是 | 数据库 ID | +| databaseName | string | 是 | 数据库名称 | +| uri | string | 是 | Neo4j 连接地址,如 bolt://localhost:7687 | +| username | string | 是 | 用户名 | +| labels | array | 是 | 标签列表 | +| relationshipTypes | array | 是 | 关系类型列表 | +| selectedLabel | string | 否 | 当前选中的标签 | + +## 请求示例 + +```json +{ + "databaseId": "123", + "databaseName": "neo4j", + "uri": "bolt://10.10.10.189:7687", + "username": "neo4j", + "labels": ["User", "Order", "Product"], + "relationshipTypes": ["KNOWS", "BOUGHT", "BELONGS_TO"], + "selectedLabel": "User" +} +``` + +## 返回参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| success | bool | 是否成功 | +| message | string | 消息 | + +## 返回示例 + +```json +{ + "success": true, + "message": "保存成功" +} +``` + +--- + +## 前端调用示例 + +```javascript +fetch('/database/graph/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + databaseId: '123', + databaseName: 'neo4j', + uri: 'bolt://10.10.10.189:7687', + username: 'neo4j', + labels: ['User', 'Order', 'Product'], + relationshipTypes: ['KNOWS', 'BOUGHT', 'BELONGS_TO'], + selectedLabel: 'User', + }), +}) +``` diff --git a/team-require/web/neo4j-api-requirement.md b/team-require/web/neo4j-api-requirement.md new file mode 100644 index 0000000..44325a5 --- /dev/null +++ b/team-require/web/neo4j-api-requirement.md @@ -0,0 +1,110 @@ +# Neo4j 接口后端需求 + +## 需求说明 + +前端 Neo4j 图谱功能已完成,后端接口需要匹配前端调用。 + +--- + +## 1. 新增 `/neo4j/graphs` 接口 + +### 接口地址 +``` +POST /neo4j/graphs +``` + +### 请求参数 +```json +{ + "uri": "bolt://localhost:7687", + "username": "neo4j", + "password": "password", + "database": "neo4j" +} +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| uri | string | 是 | Neo4j 连接地址,如 bolt://localhost:7687 | +| username | string | 是 | 用户名 | +| password | string | 是 | 密码 | +| database | string | 否 | 数据库名(默认 neo4j) | + +### 返回参数 +```json +{ + "success": true, + "graphs": { + "labels": [ + {"name": "User", "count": 1523}, + {"name": "Order", "count": 856} + ], + "relationshipTypes": [ + {"name": "KNOWS", "count": 2341}, + {"name": "BOUGHT", "count": 5678} + ] + } +} +``` + +--- + +## 2. 修改路由路径 + +### 当前状态 +- `/database/neo4j/nodes` → 需要改为 → `/neo4j/nodes` +- `/database/neo4j/relationships` → 需要改为 → `/neo4j/relationships` + +--- + +## 总结 + +后端需要修改以下内容: + +1. **新增** `/neo4j/graphs` 接口 +2. **修改** `/database/neo4j/nodes` → `/neo4j/nodes` +3. **修改** `/database/neo4j/relationships` → `/neo4j/relationships` + +--- + +## 附:前端 API 调用示例 + +```javascript +// 获取图谱概览 +fetch('/neo4j/graphs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + uri: 'bolt://10.10.10.189:7687', + username: 'neo4j', + password: 'neo4j', + database: 'neo4j' + }) +}) + +// 获取节点详情 +fetch('/neo4j/nodes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + uri: 'bolt://10.10.10.189:7687', + username: 'neo4j', + password: 'neo4j', + label: 'User', + limit: 10 + }) +}) + +// 获取关系详情 +fetch('/neo4j/relationships', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + uri: 'bolt://10.10.10.189:7687', + username: 'neo4j', + password: 'neo4j', + relationship_type: 'KNOWS', + limit: 10 + }) +}) +``` diff --git a/team-require/web/neo4j-check-return-id.md b/team-require/web/neo4j-check-return-id.md new file mode 100644 index 0000000..fab67a6 --- /dev/null +++ b/team-require/web/neo4j-check-return-id.md @@ -0,0 +1,99 @@ +# Neo4j 连接成功后返回数据库 ID + +## 需求说明 + +当前端 Connect 测试 Neo4j 连接成功后,后端需要返回数据库的 ID,以便前端保存图谱配置。 + +--- + +## 问题 + +当前 `/neo4j/check` 接口返回: +```json +{ + "success": true, + "message": "connection successful", + "version": "5.14.0", + "databases": ["neo4j", "system"] +} +``` + +**没有返回 `databaseId`**,导致后续保存图谱时缺少 `databaseId`。 + +--- + +## 需求内容 + +修改 `/neo4j/check` 接口,在连接成功时: + +1. **检查数据库是否已存在** - 根据 URI(bolt://host:port)、username、database 查询 +2. **如果存在** - 返回已有的 `databaseId` +3. **如果不存在** - 自动创建一条数据库记录,并返回新的 `databaseId` + +### 返回格式 + +```json +{ + "success": true, + "message": "connection successful", + "version": "5.14.0", + "databases": ["neo4j", "system"], + "databaseId": "xxx-xxx-xxx", + "name": "Neo4j-neo4j", + "description": "Neo4j neo4j@10.10.10.189:7687" +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| success | bool | 是否成功 | +| message | string | 消息 | +| version | string | Neo4j 版本 | +| databases | array | 数据库列表 | +| databaseId | string | 数据库记录 ID | +| name | string | 数据库名称 | +| description | string | 数据库描述 | + +### 请求参数 + +当前 `/neo4j/check` 请求: +```json +{ + "db_type": "Neo4j", + "host": "10.10.10.189", + "port": 7687, + "username": "neo4j", + "password": "neo4j", + "database": "neo4j" +} +``` + +后端需要增加 `name` 字段用于数据库名称(可选): +```json +{ + "name": "My Neo4j Database" +} +``` + +--- + +## 涉及文件 + +- `server/internal/service/neo4j_service.go` + - 函数:`Check()` - 第 81-128 行 + - 函数:`ensureNeo4jDatabase()` - 第 131-175 行(已有代码但可能有问题) + +- `server/internal/model/neo4j_info.go` + - 结构体:`Neo4jCheckResponse` - 需要确保 `databaseId` 字段正确返回 + +--- + +## 前端使用 + +前端代码已实现兼容处理: +```javascript +const dbId = result.databaseId || result.id || result.database_id || '' +``` + +所以后端返回 `databaseId`、`id` 或 `database_id` 都可以被正确识别。 + diff --git a/team-require/web/neo4j-graphs.md b/team-require/web/neo4j-graphs.md new file mode 100644 index 0000000..17427e8 --- /dev/null +++ b/team-require/web/neo4j-graphs.md @@ -0,0 +1,196 @@ +# 后端需求 - Neo4j 图谱数据获取(完善版) + +## 需求描述 + +Neo4j 连接成功后,需要获取图谱数据供前端可视化展示。前端使用 ECharts 力导向图谱展示科幻风格效果。 + +## Neo4j 图谱核心概念 + +Neo4j 是图数据库,与关系型数据库概念不同: +- **Node(节点)** - 类似于表,但不需要固定结构 +- **Label(标签)** - 类似于表的类型名(如 User, Order) +- **Relationship(关系)** - 节点之间的边 +- **Relationship Type(关系类型)** - 关系的类型(如 KNOWS, OWNS) + +## 后端需要提供的接口 + +### 1. 获取图谱概览数据(核心接口) + +返回所有 Label(标签)和 Relationship Type(关系类型)的统计信息。这是前端图谱可视化的核心数据来源。 + +**接口地址:** `POST /database/check` (复用现有接口,在 db_type 为 Neo4j 时返回图谱数据) + +**请求参数:** +```json +{ + "db_type": "Neo4j", + "uri": "bolt://localhost:7687", + "username": "neo4j", + "password": "password", + "database": "neo4j" +} +``` + +**返回参数:** +```json +{ + "success": true, + "graphs": { + "labels": [ + {"name": "User", "count": 1523}, + {"name": "Order", "count": 856}, + {"name": "Product", "count": 2341}, + {"name": "Category", "count": 45}, + {"name": "Review", "count": 5678} + ], + "relationshipTypes": [ + {"name": "KNOWS", "count": 2341}, + {"name": "BOUGHT", "count": 5678}, + {"name": "BELONGS_TO", "count": 2341}, + {"name": "HAS_REVIEW", "count": 5678}, + {"name": "LOCATED_IN", "count": 1523} + ] + } +} +``` + +### 2. 获取节点详情(可选,用于点击显示) + +点击某个 Label 节点时,获取该类型节点的样本数据用于详情展示。 + +**接口地址:** `POST /database/neo4j/nodes` + +**请求参数:** +```json +{ + "uri": "bolt://localhost:7687", + "username": "neo4j", + "password": "password", + "database": "neo4j", + "label": "User", + "limit": 5 +} +``` + +**返回参数:** +```json +{ + "success": true, + "nodes": [ + {"id": "1", "name": "张三", "email": "zhangsan@example.com", "created_at": "2024-01-01"}, + {"id": "2", "name": "李四", "email": "lisi@example.com", "created_at": "2024-01-02"} + ], + "properties": [ + {"name": "id", "type": "string"}, + {"name": "name", "type": "string"}, + {"name": "email", "type": "string"}, + {"name": "created_at", "type": "datetime"} + ] +} +``` + +### 3. 获取关系详情(可选) + +获取两个节点之间的关系数据。 + +**接口地址:** `POST /database/neo4j/relationships` + +**请求参数:** +```json +{ + "uri": "bolt://localhost:7687", + "username": "neo4j", + "password": "password", + "database": "neo4j", + "relationshipType": "KNOWS", + "limit": 10 +} +``` + +**返回参数:** +```json +{ + "success": true, + "relationships": [ + { + "id": "rel-1", + "source": "1", + "target": "2", + "properties": {"since": "2020-01-01"} + } + ] +} +``` + +## 数据结构说明 + +### graphs.labels[] - 标签列表(前端图谱节点) +| 字段 | 类型 | 说明 | 用途 | +|------|------|------|------| +| name | string | 标签名称(如 User, Order) | 作为图谱节点显示 | +| count | int | 该标签的节点数量 | 计算节点大小 symbolSize | + +### graphs.relationshipTypes[] - 关系类型列表(前端图谱边) +| 字段 | 类型 | 说明 | 用途 | +|------|------|------|------| +| name | string | 关系类型(如 KNOWS, OWNS) | 作为图谱边的标签 | +| count | int | 该关系的数量 | 可能影响边的粗细 | + +### nodes[] - 节点详情 +| 字段 | 类型 | 说明 | +|------|------|------| +| id | string | 节点唯一标识 | +| (其他) | any | 节点的其他属性 | + +### relationships[] - 关系详情 +| 字段 | 类型 | 说明 | +|------|------|------| +| id | string | 关系唯一标识 | +| source | string | 起始节点ID | +| target | string | 目标节点ID | +| properties | object | 关系属性 | + +## 前端图谱展示逻辑 + +前端使用 ECharts 力导向图谱(force-directed graph),展示方式如下: + +1. **节点生成**: + - 根据 `graphs.labels` 数组生成节点 + - `name` 作为节点显示名称 + - `count` 决定节点大小(symbolSize = log2(count+1) * 12) + - 节点颜色按索引分配科幻配色(紫、蓝、绿、橙、粉、青) + - 节点带发光效果(shadowBlur: 20) + +2. **边生成**: + - 根据 `graphs.relationshipTypes` 生成边 + - 边 label 显示关系类型名称 + - 曲线连接(curveness: 0.2) + - 带箭头 + +3. **交互效果**: + - 弹簧物理效果(force layout) + - 节点可拖拽 + - 滚轮缩放 + - hover 相邻节点高亮 + +## 优先级 + +高 - Neo4j 可视化的核心数据 + +## 注意事项 + +1. Neo4j 连接使用官方 Go 驱动:`github.com/neo4j/neo4j-go-driver` +2. 注意处理连接超时和认证失败的情况 +3. 大数据量时需要限制返回数量(limit 参数) +4. **建议返回足够多的关系类型**(建议至少 5-10 个),以便前端生成丰富的图谱连接 +5. 如果关系类型少于节点数,可以创建额外连接让图谱更美观 + +## 前端缓存策略 + +### 方案设计 +- **首次加载**:获取数据并缓存 +- **第二次展示**:直接使用缓存,秒开 +- **刷新按钮**:用户手动点击刷新获取最新数据 + +### 实现说明 +前端会缓存图谱数据,第二次进入时直接展示缓存数据,提升用户体验。同时提供"刷新"按钮供用户手动刷新。 diff --git a/team-require/web/neo4j-support.md b/team-require/web/neo4j-support.md new file mode 100644 index 0000000..ddec242 --- /dev/null +++ b/team-require/web/neo4j-support.md @@ -0,0 +1,42 @@ +# 后端需求 - 支持 Neo4j 图数据库 + +## 需求描述 + +添加 Neo4j 图数据库类型支持。 + +## Neo4j 连接参数 + +Neo4j 连接需要以下参数: + +| 参数 | 类型 | 必填 | 说明 | 默认值 | +|------|------|------|------|--------| +| uri | string | 是 | 连接地址 | bolt://localhost:7687 | +| username | string | 是 | 用户名 | neo4j | +| password | string | 是 | 密码 | - | +| database | string | 否 | 数据库名 | neo4j(默认数据库) | + +### 连接示例 +- `bolt://localhost:7687` +- `neo4j://localhost:7687` +- `bolt://192.168.1.100:7687` + +## 需要修改的地方 + +### 1. 数据库类型列表 +在前端和后端添加 "Neo4j" 选项 + +### 2. 连接表单 +Neo4j 只需要 3-4 个字段: +- URI(连接地址) +- Username(用户名) +- Password(密码) +- Database(数据库名,可选) + +### 3. 数据库服务 +- `server/internal/service/database_service.go` +- 新增 `connectNeo4j` 方法 +- 新增 `getNeo4jTables` 方法 + +## 优先级 + +中 - 扩展数据库类型支持 diff --git a/team-require/web/todo-2026-3-6.md b/team-require/web/todo-2026-3-6.md index cc2547f..428b3e3 100644 --- a/team-require/web/todo-2026-3-6.md +++ b/team-require/web/todo-2026-3-6.md @@ -22,6 +22,31 @@ - 相关文件:`server/internal/service/database_service.go`, `server/internal/model/` - 详细需求:[mapping-state.md](./mapping-state.md) +- [x] **Neo4j 图谱数据获取** - 前端已完成 ECharts 科幻风格图谱,后端需提供图谱数据接口 ✔ + - 前端:使用 ECharts force-directed graph,力导向弹簧效果,可拖拽,hover 高亮 + - 详细需求:[neo4j-graphs.md](./neo4j-graphs.md), [neo4j-support.md](./neo4j-support.md) + +--- + +- [x] **Neo4j 接口路由修改** - 后端已完成 ✔ + - 新增 `/neo4j/graphs` 接口 + - 修改 `/database/neo4j/nodes` → `/neo4j/nodes` + - 修改 `/database/neo4j/relationships` → `/neo4j/relationships` + - 详细需求:[neo4j-api-requirement.md](./neo4j-api-requirement.md) + +--- + +### 2026-03-07 + +- [x] **Neo4j 图谱保存接口** - 后端已完成 ✔ + - 接口地址:`POST /database/graph/save` + - 详细需求:[neo4j-graph-save.md](./neo4j-graph-save.md) + +- [x] **Neo4j 连接成功后返回 databaseId** - 后端已完成 ✔ + - 问题:Connect 测试连接成功后没有保存数据库记录,导致后续保存图谱时缺少 databaseId + - 解决方案:/neo4j/check 成功时检查数据库是否已存在,不存在则自动创建并返回 databaseId + - 详细需求:[neo4j-check-return-id.md](./neo4j-check-return-id.md) + --- > 需求完成后请完成者打 ✔ \ No newline at end of file diff --git a/web/src/assets/styles/base/reset.css b/web/src/assets/styles/base/reset.css new file mode 100644 index 0000000..2a7d891 --- /dev/null +++ b/web/src/assets/styles/base/reset.css @@ -0,0 +1,69 @@ +/* Reset - 样式重置 */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +html.dark { + color-scheme: dark; +} + +body { + font-family: var(--font-system, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif); + font-size: var(--font-size-sm, 14px); + line-height: 1.5; + color: #f3f4f6; + background-color: #0f1117; +} + +a { + color: inherit; + text-decoration: none; +} + +button { + border: none; + background: none; + cursor: pointer; + font: inherit; +} + +input, +textarea, +select { + font: inherit; +} + +ul, +ol { + list-style: none; +} + +img { + max-width: 100%; + display: block; +} + +/* 滚动条隐藏工具类 */ +.scrollbar-hide::-webkit-scrollbar { + display: none; +} + +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} + +/* 内容自动可见性 */ +.content-auto { + content-visibility: auto; +} diff --git a/web/src/assets/styles/base/variables.css b/web/src/assets/styles/base/variables.css new file mode 100644 index 0000000..bb179d9 --- /dev/null +++ b/web/src/assets/styles/base/variables.css @@ -0,0 +1,65 @@ +/* CSS Variables - 全局变量 */ +:root { + /* 主题色 */ + --color-primary: #ff9500; + --color-primary-light: #ffb732; + --color-primary-dark: #cc7700; + + /* 功能色 */ + --color-success: #22c55e; + --color-warning: #eab308; + --color-danger: #ef4444; + --color-info: #3b82f6; + + /* 文字色 */ + --color-text-primary: #ffffff; + --color-text-regular: #a1a1aa; + --color-text-secondary: #71717a; + --color-text-placeholder: #6b7280; + + /* 背景色 - Dark 主题 */ + --color-bg-base: #0f1117; + --color-bg-dark: #171922; + --color-bg-light: #1a1c25; + --color-bg-lighter: #1f2230; + + /* 边框色 */ + --color-border: #2a2c36; + --color-border-light: #3a3c46; + --color-border-lighter: #4a4c56; + + /* 圆角 */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-2xl: 24px; + + /* 间距 */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 12px; + --spacing-lg: 16px; + --spacing-xl: 24px; + --spacing-2xl: 32px; + + /* 字体-family: -apple */ + --font-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-size-xs: 12px; + --font-size-sm: 14px; + --font-size-md: 16px; + --font-size-lg: 18px; + --font-size-xl: 20px; + --font-size-2xl: 24px; + + /* 阴影 */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.4); + --shadow-xl: 0 16px 32px rgba(0, 0, 0, 0.5); + + /* 动画 */ + --transition-fast: 150ms ease; + --transition-base: 200ms ease; + --transition-slow: 300ms ease; +} diff --git a/web/src/assets/styles/components/button.css b/web/src/assets/styles/components/button.css new file mode 100644 index 0000000..ba6ceba --- /dev/null +++ b/web/src/assets/styles/components/button.css @@ -0,0 +1,127 @@ +/* Button - 按钮样式 */ + +/* 主要按钮 */ +.btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-lg); + font-size: var(--font-size-sm); + font-weight: 500; + color: white; + background: linear-gradient(to right, var(--color-primary), #ef4444); + border-radius: var(--radius-md); + transition: all var(--transition-base); + cursor: pointer; + border: none; +} + +.btn-primary:hover { + background: linear-gradient(to right, #ffb732, #dc2626); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(255, 107, 53, 0.3); +} + +.btn-primary:active { + transform: translateY(0); + box-shadow: 0 2px 8px rgba(255, 107, 53, 0.2); +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +/* 次要按钮 */ +.btn-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-lg); + font-size: var(--font-size-sm); + color: #d1d5db; + background-color: #374151; + border: 1px solid #4b5563; + border-radius: var(--radius-md); + transition: all var(--transition-base); + cursor: pointer; +} + +.btn-secondary:hover { + background-color: #4b5563; + border-color: #6b7280; +} + +.btn-secondary:active { + transform: scale(0.98); +} + +/* 图标按钮 */ +.btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--spacing-sm); + border-radius: var(--radius-md); + transition: all var(--transition-fast); + cursor: pointer; + background: transparent; + border: none; + color: #9ca3af; +} + +.btn-icon:hover { + background-color: #374151; + transform: scale(1.05); +} + +.btn-icon:active { + transform: scale(0.95); +} + +/* 危险按钮 */ +.btn-danger { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-lg); + font-size: var(--font-size-sm); + font-weight: 500; + color: white; + background-color: var(--color-danger); + border-radius: var(--radius-md); + transition: all var(--transition-base); + cursor: pointer; + border: none; +} + +.btn-danger:hover { + background-color: #dc2626; + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3); +} + +/* 幽灵按钮 */ +.btn-ghost { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-lg); + font-size: var(--font-size-sm); + color: #9ca3af; + background: transparent; + border-radius: var(--radius-md); + transition: all var(--transition-base); + cursor: pointer; + border: none; +} + +.btn-ghost:hover { + background-color: #374151; + color: white; +} diff --git a/web/src/assets/styles/components/form.css b/web/src/assets/styles/components/form.css new file mode 100644 index 0000000..ecd6dc0 --- /dev/null +++ b/web/src/assets/styles/components/form.css @@ -0,0 +1,102 @@ +/* Form - 表单样式 */ + +/* 输入框 */ +.input-field { + width: 100%; + padding: 10px 16px; + font-size: var(--font-size-sm); + color: white; + background-color: #171922; + border: 1px solid #2a2c36; + border-radius: var(--radius-md); + transition: all var(--transition-base); + outline: none; +} + +.input-field::placeholder { + color: var(--color-text-placeholder); +} + +.input-field:hover:not(:focus) { + border-color: #3a3c46; +} + +.input-field:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(255, 149, 0, 0.15); +} + +/* 搜索输入框 */ +.search-input { + width: 100%; + padding: 10px 16px 10px 40px; + font-size: var(--font-size-sm); + color: white; + background-color: #171922; + border: 1px solid #2a2c36; + border-radius: var(--radius-md); + transition: all var(--transition-base); + outline: none; +} + +.search-input::placeholder { + color: var(--color-text-placeholder); +} + +.search-input:hover:not(:focus) { + border-color: #3a3c46; +} + +.search-input:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(255, 149, 0, 0.15); +} + +/* 文本域 */ +.textarea-field { + width: 100%; + padding: 10px 16px; + font-size: var(--font-size-sm); + color: white; + background-color: #171922; + border: 1px solid #2a2c36; + border-radius: var(--radius-md); + transition: all var(--transition-base); + outline: none; + resize: vertical; + min-height: 80px; +} + +.textarea-field::placeholder { + color: var(--color-text-placeholder); +} + +.textarea-field:hover:not(:focus) { + border-color: #3a3c46; +} + +.textarea-field:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(255, 149, 0, 0.15); +} + +/* 表单标签 */ +.form-label { + display: block; + font-size: var(--font-size-sm); + font-weight: 500; + color: #d1d5db; + margin-bottom: var(--spacing-sm); +} + +/* 表单组 */ +.form-group { + margin-bottom: var(--spacing-lg); +} + +/* 表单错误提示 */ +.form-error { + font-size: var(--font-size-xs); + color: var(--color-danger); + margin-top: var(--spacing-xs); +} diff --git a/web/src/assets/styles/components/modal.css b/web/src/assets/styles/components/modal.css new file mode 100644 index 0000000..3eeabd7 --- /dev/null +++ b/web/src/assets/styles/components/modal.css @@ -0,0 +1,88 @@ +/* Modal - 弹窗样式 */ + +/* 弹窗遮罩 */ +.modal-overlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; + padding: var(--spacing-lg); + animation: modal-fade-in 0.2s ease-out forwards; +} + +/* 弹窗内容 */ +.modal-content { + background-color: #1f2230; + border-radius: var(--radius-xl); + border: 1px solid #2a2c36; + box-shadow: var(--shadow-xl); + overflow: hidden; + animation: modal-scale-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +/* 弹窗头部 */ +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-lg); + border-bottom: 1px solid #2a2c36; + background-color: rgba(31, 34, 48, 0.5); +} + +.modal-title { + font-size: var(--font-size-lg); + font-weight: 600; + color: white; +} + +/* 弹窗主体 */ +.modal-body { + padding: var(--spacing-lg); +} + +/* 弹窗底部 */ +.modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--spacing-md); + padding: var(--spacing-lg); + border-top: 1px solid #2a2c36; + background-color: rgba(31, 34, 48, 0.5); +} + +/* 弹窗动画 */ +@keyframes modal-fade-in { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +@keyframes modal-scale-in { + 0% { + opacity: 0; + transform: scale(0.95) translateY(10px); + } + 100% { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes modal-slide-up { + 0% { + opacity: 0; + transform: translateY(20px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} diff --git a/web/src/assets/styles/components/table.css b/web/src/assets/styles/components/table.css new file mode 100644 index 0000000..be5ec0a --- /dev/null +++ b/web/src/assets/styles/components/table.css @@ -0,0 +1,91 @@ +/* Table - 表格样式 */ + +/* 表格容器 */ +.table-container { + background-color: #1f2230; + border-radius: var(--radius-lg); + overflow: hidden; +} + +/* 表格 */ +.table { + width: 100%; + border-collapse: collapse; +} + +/* 表格头部 */ +.table th { + text-align: left; + padding: var(--spacing-md) var(--spacing-lg); + font-size: var(--font-size-sm); + font-weight: 500; + color: #9ca3af; + background-color: #171922; + border-bottom: 1px solid #2a2c36; +} + +/* 表格单元格 */ +.table td { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid #2a2c36; +} + +/* 表格行 */ +.table-row { + transition: all var(--transition-base); +} + +.table-row:hover { + background-color: rgba(55, 65, 81, 0.5); +} + +.table-row:active { + background-color: #374151; +} + +/* 表格行 - 斑马纹 */ +.table-row-striped:nth-child(even) { + background-color: rgba(31, 34, 48, 0.5); +} + +/* 表格操作按钮组 */ +.table-actions { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); +} + +/* 表格空状态 */ +.table-empty { + padding: var(--spacing-2xl); + text-align: center; + color: #6b7280; +} + +.table-empty-icon { + font-size: var(--font-size-2xl); + margin-bottom: var(--spacing-md); + color: #4b5563; +} + +/* 表格加载状态 */ +.table-loading { + padding: var(--spacing-2xl); + text-center: center; + color: #6b7280; +} + +.table-loading-icon { + font-size: var(--font-size-2xl); + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/web/src/assets/styles/element/index.css b/web/src/assets/styles/element/index.css new file mode 100644 index 0000000..362a857 --- /dev/null +++ b/web/src/assets/styles/element/index.css @@ -0,0 +1,198 @@ +/* Element Plus - Dark Theme Overrides */ + +/* 基础变量覆盖 */ +html.dark { + --el-bg-color: #171922; + --el-bg-color-overlay: #171922; + --el-text-color-primary: #ffffff; + --el-text-color-regular: #a1a1aa; + --el-border-color: #2a2c36; + --el-border-color-light: #2a2c36; + --el-fill-color-blank: #171922; + --el-color-primary: #ff9500; +} + +/* el-select 统一样式 */ +html.dark .el-select { + --el-select-input-focus-border-color: #ff9500; +} + +html.dark .el-select .el-input__wrapper, +html.dark .el-select .el-select__wrapper { + background-color: #1a1c25 !important; + border: 1px solid #2a2c36; + border-radius: 8px; + transition: all 0.2s ease; + padding: 2px 11px; + min-height: 42px; + box-shadow: none !important; +} + +html.dark .el-select:hover .el-input__wrapper, +html.dark .el-select:hover .el-select__wrapper { + background-color: #1a1c25 !important; + border-color: #ff9500; +} + +html.dark .el-select .el-input__wrapper.is-focus, +html.dark .el-select .el-select__wrapper.is-focus { + border-color: #ff9500; +} + +html.dark .el-select .el-input__inner { + color: #ffffff; + line-height: 1.5; +} + +html.dark .el-select .el-input__inner::placeholder { + color: #6b7280; +} + +/* 下拉箭头 */ +html.dark .el-select .el-input__suffix .el-select__caret { + color: #71717a; + transition: transform 0.3s; +} + +html.dark .el-select .el-input__suffix .el-select__caret.is-reverse { + transform: rotate(180deg); +} + +/* 下拉菜单 */ +.el-select-dropdown.el-popper, +html.dark .el-select-dropdown { + background-color: #1a1c25 !important; + border: 1px solid #2a2c36 !important; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +html.dark .el-select-dropdown__list { + background-color: #1a1c25 !important; +} + +html.dark .el-select-dropdown__item { + color: #ffffff !important; + padding: 8px 12px; + transition: all 0.2s; +} + +html.dark .el-select-dropdown__item:hover, +html.dark .el-select-dropdown__item.hover { + background-color: #1a1c25 !important; +} + +html.dark .el-select-dropdown__item.is-selected { + color: #ff9500 !important; + font-weight: 600; + background-color: transparent; +} + +html.dark .el-select-dropdown__item.is-selected::before { + content: ''; + display: inline-block; + width: 4px; + height: 4px; + background-color: #ff9500; + border-radius: 50%; + margin-right: 8px; +} + +html.dark .el-select-dropdown__empty { + color: #71717a; + padding: 20px 0; +} + +/* 选中项文字居中 */ +html.dark .el-select .el-select__wrapper { + display: flex; + align-items: center; +} + +/* 多选标签 */ +html.dark .el-select .el-tag { + background-color: #2a2c36; + border-color: transparent; + color: #ffffff; + border-radius: 4px; +} + +/* el-checkbox 暗色主题 - 金黄色选中 */ +html.dark .el-checkbox { + --el-checkbox-checked-text-color: #ffb700; + --el-checkbox-checked-bg-color: #ffb700; + --el-checkbox-checked-border-color: #ffb700; + --el-checkbox-input-border-color-hover: #ffb700; +} + +html.dark .el-checkbox .el-checkbox__input.is-checked .el-checkbox__inner { + background-color: #ffb700; + border-color: #ffb700; +} + +html.dark .el-checkbox .el-checkbox__input.is-checked .el-checkbox__inner::after { + border-color: #1f2937; +} + +html.dark .el-checkbox .el-checkbox__label { + color: #e5e7eb; +} + +html.dark .el-checkbox:hover .el-checkbox__inner { + border-color: #ffb700; +} + +/* popper 箭头 */ +.el-popper.is-light, +html.dark .el-popper.is-light { + background: #1a1c25 !important; + border: 1px solid #2a2c36 !important; +} + +.el-popper.is-light .el-popper__arrow::before, +html.dark .el-popper.is-light .el-popper__arrow::before { + background: #1a1c25 !important; + border-color: #2a2c36 !important; +} + +/* el-pagination */ +html.dark .el-pagination { + --el-pagination-bg-color: #374151; + --el-pagination-text-color: #ffffff; + --el-pagination-button-disabled-bg-color: #1f2230; +} + +html.dark .el-pagination .el-pager li { + background-color: #374151; + color: #ffffff; +} + +html.dark .el-pagination .el-pager li:hover { + color: #ff9500; +} + +html.dark .el-pagination .el-pager li.is-active { + background-color: #ff9500; + color: #ffffff; +} + +/* 自定义下拉菜单类 */ +.dark-select-dropdown { + background-color: #1a1c25 !important; + border: 1px solid #2a2c36 !important; +} + +.dark-select-dropdown .el-select-dropdown__item { + color: #ffffff !important; + background-color: transparent !important; +} + +.dark-select-dropdown .el-select-dropdown__item:hover, +.dark-select-dropdown .el-select-dropdown__item.hover { + background-color: #1a1c25 !important; +} + +.dark-select-dropdown .el-select-dropdown__item.is-selected { + color: #ff9500 !important; + background-color: transparent !important; +} diff --git a/web/src/assets/styles/index.css b/web/src/assets/styles/index.css new file mode 100644 index 0000000..08c883e --- /dev/null +++ b/web/src/assets/styles/index.css @@ -0,0 +1,295 @@ +/* Main Entry - 全局样式入口 */ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Force body styles */ +body { + color: #f3f4f6; + background-color: #0f1115; +} + +/* Ensure main content has dark background */ +#app, .page-content { + background-color: #0f1115 !important; + color: #f3f4f6; +} + +/* Base styles */ +@import './base/variables.css'; +@import './base/reset.css'; + +/* Component styles */ +@import './components/button.css'; +@import './components/form.css'; +@import './components/modal.css'; +@import './components/table.css'; + +/* Animations */ +@keyframes bar-grow { + from { + height: 0; + opacity: 1; + } + to { + opacity: 1; + } +} + +@keyframes progress-grow { + from { + width: 0; + opacity: 1; + } + to { + width: var(--target-width); + opacity: 1; + } +} + +@keyframes pulse-dot { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +@keyframes loading-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes loading-dots { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; } + 40% { transform: scale(1); opacity: 1; } +} + +@keyframes loading-skeleton { + 0% { opacity: 0.4; } + 50% { opacity: 0.7; } + 100% { opacity: 0.4; } +} + +/* Animation classes */ +.chart-bar { + height: 0; + opacity: 0; + animation: bar-grow 2.4s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +.progress-bar { + width: 0; + opacity: 0; + animation: progress-grow 2.4s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +.loading-pulse { + animation: pulse-dot 1.5s ease-in-out infinite; +} + +.loading-spin { + animation: loading-spin 1s linear infinite; +} + +.loading-skeleton { + animation: loading-skeleton 1.5s ease-in-out infinite; +} + +/* Utility classes */ +.text-gradient { + background: linear-gradient(to right, var(--color-primary), #ef4444); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.glass { + background-color: rgba(31, 34, 48, 0.8); + backdrop-filter: blur(12px); + border: 1px solid rgba(42, 44, 54, 0.5); +} + +.focus-ring:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(255, 149, 0, 0.5); +} + +/* 滚动条美化 */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #1f2230; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: #374151; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #4b5563; +} + +/* Badge styles */ +.badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + font-size: var(--font-size-xs); + font-weight: 500; + border-radius: var(--radius-sm); + transition: all var(--transition-base); +} + +.badge-success { + background-color: rgba(34, 197, 94, 0.2); + color: #22c55e; +} + +.badge-warning { + background-color: rgba(234, 179, 8, 0.2); + color: #eab308; +} + +.badge-error { + background-color: rgba(239, 68, 68, 0.2); + color: #ef4444; +} + +.badge-info { + background-color: rgba(59, 130, 246, 0.2); + color: #3b82f6; +} + +.badge-default { + background-color: rgba(107, 114, 128, 0.2); + color: #6b7280; +} + +/* Status dot */ +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + transition: all var(--transition-base); +} + +.status-dot-active { + background-color: #22c55e; + box-shadow: 0 0 8px rgba(34, 197, 94, 0.5); +} + +.status-dot-inactive { + background-color: #6b7280; +} + +.status-dot-error { + background-color: #ef4444; + box-shadow: 0 0 8px rgba(239, 68, 68, 0.5); +} + +/* Empty state */ +.empty-state { + padding: var(--spacing-2xl); + text-align: center; + transition: all var(--transition-slow); +} + +.empty-state-icon { + color: #4b5563; + font-size: var(--font-size-2xl); + margin-bottom: var(--spacing-md); + transition: transform var(--transition-slow); +} + +.empty-state:hover .empty-state-icon { + color: #6b7280; + transform: scale(1.1); +} + +/* Card hover effect */ +.card-hover { + transition: all var(--transition-base); +} + +.card-hover:hover { + box-shadow: var(--shadow-lg); + transform: translateY(-2px); +} + +/* Element Plus Dark Theme - Must be last */ +html.dark { + --el-bg-color: #171922; + --el-bg-color-overlay: #171922; + --el-text-color-primary: #ffffff; + --el-text-color-regular: #a1a1aa; + --el-border-color: #2a2c36; + --el-border-color-light: #2a2c36; + --el-fill-color-blank: #171922; + --el-color-primary: #ff9500; +} + +/* el-select */ +html.dark .el-select .el-input__wrapper, +html.dark .el-select .el-select__wrapper { + background-color: #171922 !important; + border: 1px solid #2a2c36; + box-shadow: none !important; +} + +html.dark .el-select:hover .el-input__wrapper, +html.dark .el-select:hover .el-select__wrapper { + background-color: #1a1c25 !important; + border-color: #ff9500; +} + +html.dark .el-select .el-input__wrapper.is-focus, +html.dark .el-select .el-select__wrapper.is-focus { + border-color: #ff9500; +} + +html.dark .el-select .el-input__inner { + color: #ffffff; +} + +/* el-select dropdown */ +.el-select-dropdown.el-popper, +html.dark .el-select-dropdown { + background-color: #171922 !important; + border: 1px solid #2a2c36 !important; +} + +html.dark .el-select-dropdown__list { + background-color: #171922 !important; +} + +html.dark .el-select-dropdown__item { + color: #ffffff !important; + background-color: transparent !important; +} + +html.dark .el-select-dropdown__item:hover, +html.dark .el-select-dropdown__item.hover { + background-color: #1a1c25 !important; +} + +html.dark .el-select-dropdown__item.is-selected { + color: #ff9500 !important; +} + +/* el-checkbox */ +html.dark .el-checkbox .el-checkbox__input.is-checked .el-checkbox__inner { + background-color: #ffb700; + border-color: #ffb700; +} + +html.dark .el-checkbox .el-checkbox__label { + color: #e5e7eb; +} diff --git a/web/src/composables/useApi.ts b/web/src/composables/useApi.ts new file mode 100644 index 0000000..6d3e77b --- /dev/null +++ b/web/src/composables/useApi.ts @@ -0,0 +1,125 @@ +import { ref, computed } from 'vue' +import { ElMessage } from 'element-plus' + +/** + * API 请求封装 + * @param apiFunc - API 请求函数 + * @param options - 配置选项 + */ +export function useApi( + apiFunc: (...args: any[]) => Promise, + options: { + showError?: boolean + showSuccess?: boolean + successMessage?: string + errorMessage?: string + } = {} +) { + const { showError = true, showSuccess = false, successMessage, errorMessage } = options + + const loading = ref(false) + const data = ref(null) + const error = ref(null) + + const execute = async (...args: any[]) => { + loading.value = true + error.value = null + + try { + const response = await apiFunc(...args) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + const result = await response.json() + + if (result.success || response.ok) { + data.value = result + + if (showSuccess) { + ElMessage.success(successMessage || result.message || '操作成功') + } + + return result + } else { + if (showError) { + ElMessage.error(errorMessage || result.message || '操作失败') + } + throw new Error(result.message || '操作失败') + } + } catch (err: any) { + error.value = err + + if (showError) { + ElMessage.error(errorMessage || err.message || '请求失败') + } + + throw err + } finally { + loading.value = false + } + } + + return { + loading: computed(() => loading.value), + data: computed(() => data.value), + error: computed(() => error.value), + execute, + } +} + +/** + * 简单的 CRUD 操作封装 + */ +export function useCrud(baseUrl: string) { + const API_BASE = baseUrl + + const fetchList = async (params?: any) => { + const query = params ? '?' + new URLSearchParams(params).toString() : '' + const response = await fetch(`${API_BASE}${query}`) + const result = await response.json() + return result + } + + const fetchById = async (id: string) => { + const response = await fetch(`${API_BASE}/${id}`) + const result = await response.json() + return result + } + + const create = async (data: Partial) => { + const response = await fetch(API_BASE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + const result = await response.json() + return result + } + + const update = async (id: string, data: Partial) => { + const response = await fetch(`${API_BASE}/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + const result = await response.json() + return result + } + + const remove = async (id: string) => { + const response = await fetch(`${API_BASE}/${id}`, { + method: 'DELETE', + }) + return response.ok + } + + return { + fetchList, + fetchById, + create, + update, + remove, + } +} diff --git a/web/src/main.ts b/web/src/main.ts index 59300b5..4fd5cd6 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -3,7 +3,7 @@ import { createPinia } from 'pinia' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import router from './router' -import './style.css' +import './assets/styles/index.css' import App from './App.vue' const app = createApp(App) diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts new file mode 100644 index 0000000..488b288 --- /dev/null +++ b/web/src/utils/format.ts @@ -0,0 +1,95 @@ +/** + * 格式化工具函数 + */ + +/** + * 格式化日期 + * @param date - 日期字符串或 Date 对象 + * @param format - 格式化模板,默认 'YYYY-MM-DD' + */ +export function formatDate(date: string | Date, format: string = 'YYYY-MM-DD'): string { + if (!date) return '' + + const d = typeof date === 'string' ? new Date(date) : date + + if (isNaN(d.getTime())) return '' + + const year = d.getFullYear() + const month = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + const hours = String(d.getHours()).padStart(2, '0') + const minutes = String(d.getMinutes()).padStart(2, '0') + const seconds = String(d.getSeconds()).padStart(2, '0') + + return format + .replace('YYYY', String(year)) + .replace('MM', month) + .replace('DD', day) + .replace('HH', hours) + .replace('mm', minutes) + .replace('ss', seconds) +} + +/** + * 格式化数字(千分位) + * @param num - 数字 + */ +export function formatNumber(num: number | string): string { + if (num === null || num === undefined) return '' + const n = typeof num === 'string' ? parseFloat(num) : num + if (isNaN(n)) return '' + return n.toLocaleString() +} + +/** + * 格式化文件大小 + * @param bytes - 字节数 + */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B' + + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + const k = 1024 + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return `${(bytes / Math.pow(k, i)).toFixed(2)} ${units[i]}` +} + +/** + * 截断文本 + * @param text - 文本 + * @param maxLength - 最大长度 + * @param suffix - 后缀,默认 '...' + */ +export function truncate(text: string, maxLength: number, suffix: string = '...'): string { + if (!text || text.length <= maxLength) return text + return text.slice(0, maxLength) + suffix +} + +/** + * 首字母大写 + * @param text - 文本 + */ +export function capitalize(text: string): string { + if (!text) return '' + return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase() +} + +/** + * 格式化时长(秒) + * @param seconds - 秒数 + */ +export function formatDuration(seconds: number): string { + if (!seconds || seconds < 0) return '0s' + + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + const s = Math.floor(seconds % 60) + + const parts: string[] = [] + if (h > 0) parts.push(`${h}h`) + if (m > 0) parts.push(`${m}m`) + if (s > 0 || parts.length === 0) parts.push(`${s}s`) + + return parts.join(' ') +} diff --git a/web/src/utils/validate.ts b/web/src/utils/validate.ts new file mode 100644 index 0000000..c812874 --- /dev/null +++ b/web/src/utils/validate.ts @@ -0,0 +1,128 @@ +/** + * 校验工具函数 + */ + +/** + * 校验邮箱 + */ +export function isEmail(value: string): boolean { + const reg = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return reg.test(value) +} + +/** + * 校验手机号(中国大陆) + */ +export function isPhone(value: string): boolean { + const reg = /^1[3-9]\d{9}$/ + return reg.test(value) +} + +/** + * 校验 URL + */ +export function isUrl(value: string): boolean { + try { + new URL(value) + return true + } catch { + return false + } +} + +/** + * 校验 IP 地址 + */ +export function isIP(value: string): boolean { + const reg = /^(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])$/ + return reg.test(value) +} + +/** + * 校验端口号 + */ +export function isPort(value: string | number): boolean { + const port = typeof value === 'string' ? parseInt(value) : value + return !isNaN(port) && port >= 1 && port <= 65535 +} + +/** + * 校验必填 + */ +export function isRequired(value: any): boolean { + if (value === null || value === undefined) return false + if (typeof value === 'string') return value.trim().length > 0 + if (Array.isArray(value)) return value.length > 0 + return true +} + +/** + * 校验最小长度 + */ +export function minLength(value: string, min: number): boolean { + return value.length >= min +} + +/** + * 校验最大长度 + */ +export function maxLength(value: string, max: number): boolean { + return value.length <= max +} + +/** + * 校验数值范围 + */ +export function inRange(value: number, min: number, max: number): boolean { + return value >= min && value <= max +} + +/** + * 校验密码强度 + * @returns 0-4, 0=无, 1=弱, 2=中, 3=强, 4=很强 + */ +export function passwordStrength(password: string): number { + if (!password) return 0 + + let strength = 0 + + // 长度 + if (password.length >= 8) strength++ + if (password.length >= 12) strength++ + + // 字符类型 + if (/[a-z]/.test(password)) strength++ + if (/[A-Z]/.test(password)) strength++ + if (/[0-9]/.test(password)) strength++ + if (/[^a-zA-Z0-9]/.test(password)) strength++ + + return Math.min(strength, 4) +} + +/** + * 校验对象 + */ +export interface ValidationRule { + validator: (value: any) => boolean + message: string +} + +export interface ValidationResult { + valid: boolean + errors: string[] +} + +export function validate(value: any, rules: ValidationRule[]): ValidationResult { + const errors: string[] = [] + + for (const rule of rules) { + if (!rule.validator(value)) { + errors.push(rule.message) + } + } + + return { + valid: errors.length === 0, + errors, + } +} diff --git a/web/src/views/Database.vue b/web/src/views/Database.vue index c9b2a24..de5e5e0 100644 --- a/web/src/views/Database.vue +++ b/web/src/views/Database.vue @@ -1,679 +1,54 @@