feat: 添加Neo4j图数据库支持及前端代码重构
- 新增 Neo4j 图数据库 handler、service、model - 后端添加 SaveGraph API 接口 - 前端 Database.vue 重构,拆分为独立组件 - 新增 web/src/views/database/ 组件目录 - 删除临时文件 (temp_*.go) - 添加 Neo4j 相关 API 需求文档 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
86
server/internal/handler/neo4j_handler.go
Normal file
86
server/internal/handler/neo4j_handler.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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"` // 关系属性
|
||||
}
|
||||
|
||||
39
server/internal/model/neo4j_info.go
Normal file
39
server/internal/model/neo4j_info.go
Normal file
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
// 从数据库中获取该数据库下所有子表的字段映射
|
||||
|
||||
850
server/internal/service/neo4j_service.go
Normal file
850
server/internal/service/neo4j_service.go
Normal file
@@ -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
|
||||
}
|
||||
BIN
server/server.exe
Normal file
BIN
server/server.exe
Normal file
Binary file not shown.
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 用户")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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("完成!")
|
||||
}
|
||||
@@ -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("完成!")
|
||||
}
|
||||
@@ -9,6 +9,10 @@
|
||||
- [获取数据库列表](database-list.md)
|
||||
- [获取子表列表](subtable-list.md)
|
||||
|
||||
### Neo4j 相关
|
||||
|
||||
- [Neo4j 连接测试](neo4j-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 并获取图谱概览数据(标签、关系类型、属性定义)
|
||||
- 用于前端图可视化展示
|
||||
|
||||
265
team-require/api/neo4j-check.md
Normal file
265
team-require/api/neo4j-check.md
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
75
team-require/api/neo4j-graph-save.md
Normal file
75
team-require/api/neo4j-graph-save.md
Normal file
@@ -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',
|
||||
}),
|
||||
})
|
||||
```
|
||||
110
team-require/web/neo4j-api-requirement.md
Normal file
110
team-require/web/neo4j-api-requirement.md
Normal file
@@ -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
|
||||
})
|
||||
})
|
||||
```
|
||||
99
team-require/web/neo4j-check-return-id.md
Normal file
99
team-require/web/neo4j-check-return-id.md
Normal file
@@ -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` 都可以被正确识别。
|
||||
|
||||
196
team-require/web/neo4j-graphs.md
Normal file
196
team-require/web/neo4j-graphs.md
Normal file
@@ -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. 如果关系类型少于节点数,可以创建额外连接让图谱更美观
|
||||
|
||||
## 前端缓存策略
|
||||
|
||||
### 方案设计
|
||||
- **首次加载**:获取数据并缓存
|
||||
- **第二次展示**:直接使用缓存,秒开
|
||||
- **刷新按钮**:用户手动点击刷新获取最新数据
|
||||
|
||||
### 实现说明
|
||||
前端会缓存图谱数据,第二次进入时直接展示缓存数据,提升用户体验。同时提供"刷新"按钮供用户手动刷新。
|
||||
42
team-require/web/neo4j-support.md
Normal file
42
team-require/web/neo4j-support.md
Normal file
@@ -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` 方法
|
||||
|
||||
## 优先级
|
||||
|
||||
中 - 扩展数据库类型支持
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
> 需求完成后请完成者打 ✔
|
||||
69
web/src/assets/styles/base/reset.css
Normal file
69
web/src/assets/styles/base/reset.css
Normal file
@@ -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;
|
||||
}
|
||||
65
web/src/assets/styles/base/variables.css
Normal file
65
web/src/assets/styles/base/variables.css
Normal file
@@ -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;
|
||||
}
|
||||
127
web/src/assets/styles/components/button.css
Normal file
127
web/src/assets/styles/components/button.css
Normal file
@@ -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;
|
||||
}
|
||||
102
web/src/assets/styles/components/form.css
Normal file
102
web/src/assets/styles/components/form.css
Normal file
@@ -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);
|
||||
}
|
||||
88
web/src/assets/styles/components/modal.css
Normal file
88
web/src/assets/styles/components/modal.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
91
web/src/assets/styles/components/table.css
Normal file
91
web/src/assets/styles/components/table.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
198
web/src/assets/styles/element/index.css
Normal file
198
web/src/assets/styles/element/index.css
Normal file
@@ -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;
|
||||
}
|
||||
295
web/src/assets/styles/index.css
Normal file
295
web/src/assets/styles/index.css
Normal file
@@ -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;
|
||||
}
|
||||
125
web/src/composables/useApi.ts
Normal file
125
web/src/composables/useApi.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
/**
|
||||
* API 请求封装
|
||||
* @param apiFunc - API 请求函数
|
||||
* @param options - 配置选项
|
||||
*/
|
||||
export function useApi<T = any>(
|
||||
apiFunc: (...args: any[]) => Promise<Response>,
|
||||
options: {
|
||||
showError?: boolean
|
||||
showSuccess?: boolean
|
||||
successMessage?: string
|
||||
errorMessage?: string
|
||||
} = {}
|
||||
) {
|
||||
const { showError = true, showSuccess = false, successMessage, errorMessage } = options
|
||||
|
||||
const loading = ref(false)
|
||||
const data = ref<T | null>(null)
|
||||
const error = ref<Error | null>(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<T = any>(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<T>) => {
|
||||
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<T>) => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
95
web/src/utils/format.ts
Normal file
95
web/src/utils/format.ts
Normal file
@@ -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(' ')
|
||||
}
|
||||
128
web/src/utils/validate.ts
Normal file
128
web/src/utils/validate.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,679 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
interface Database {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
db_type: string
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password: string
|
||||
database: string
|
||||
table_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 表信息
|
||||
interface TableInfo {
|
||||
name: string
|
||||
ddl: string
|
||||
columns: ColumnInfo[]
|
||||
table_comment?: string
|
||||
}
|
||||
|
||||
// 字段信息
|
||||
interface ColumnInfo {
|
||||
name: string
|
||||
type: string
|
||||
comment: string
|
||||
mapped_name: string
|
||||
column_type?: string
|
||||
is_nullable?: string
|
||||
default_value?: string
|
||||
column_key?: string
|
||||
}
|
||||
|
||||
// 解析 DDL 获取列信息
|
||||
function parseDDLColumns(ddl: string): ColumnInfo[] {
|
||||
const columns: ColumnInfo[] = []
|
||||
if (!ddl) return columns
|
||||
|
||||
// 移除 CREATE TABLE 语句,只保留括号内的内容
|
||||
const match = ddl.match(/\(([\s\S]*)\)\s*.*$/m)
|
||||
if (!match) return columns
|
||||
|
||||
const body = match[1]
|
||||
|
||||
// 按换行或逗号分割列定义(处理多行的情况)
|
||||
const lines = body.split(/,\s*(?=`\w+`|\s*$)/)
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('PRIMARY KEY') || trimmed.startsWith('KEY ') ||
|
||||
trimmed.startsWith('UNIQUE KEY') || trimmed.startsWith('FULLTEXT') ||
|
||||
trimmed.startsWith('CONSTRAINT') || trimmed.startsWith('FOREIGN KEY') ||
|
||||
trimmed.startsWith('ENGINE') || trimmed.startsWith('CHARSET') || trimmed.startsWith(')')) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析列名(支持反引号和单引号)
|
||||
const colNameMatch = trimmed.match(/^`?(\w+)`?\s+/)
|
||||
if (!colNameMatch) continue
|
||||
|
||||
const name = colNameMatch[1]
|
||||
|
||||
// 提取列定义剩余部分
|
||||
const rest = trimmed.substring(colNameMatch[0].length)
|
||||
|
||||
// 提取数据类型(到 NOT NULL / DEFAULT / COMMENT 之前)
|
||||
const typeMatch = rest.match(/^([^\s]+(?:\s*\([^\)]+\))?)/)
|
||||
const type = typeMatch ? typeMatch[1] : ''
|
||||
|
||||
// 提取 COMMENT
|
||||
const commentMatch = trimmed.match(/COMMENT\s+['"]([^'"]*)['"]/i)
|
||||
const comment = commentMatch ? commentMatch[1] : ''
|
||||
|
||||
// 提取默认值
|
||||
const defaultMatch = trimmed.match(/DEFAULT\s+([^\s,]+)/i)
|
||||
const defaultValue = defaultMatch ? defaultMatch[1] : ''
|
||||
|
||||
// 判断是否可空
|
||||
const isNullable = trimmed.includes('NOT NULL') ? 'NO' : 'YES'
|
||||
|
||||
// 判断是否是主键
|
||||
const isPrimaryKey = trimmed.includes('PRIMARY KEY')
|
||||
|
||||
columns.push({
|
||||
name,
|
||||
type,
|
||||
comment,
|
||||
mapped_name: '',
|
||||
column_type: type,
|
||||
is_nullable: isNullable,
|
||||
default_value: defaultValue,
|
||||
column_key: isPrimaryKey ? 'PRI' : '',
|
||||
})
|
||||
}
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
// 数据库数据
|
||||
const databases = ref<Database[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 编辑状态
|
||||
const editingDb = ref<Database | null>(null)
|
||||
const isEditing = ref(false)
|
||||
const isCreating = ref(false)
|
||||
const searchQuery = ref('')
|
||||
|
||||
// 表映射弹窗状态
|
||||
const isMapping = ref(false)
|
||||
const mappingDb = ref<Database | null>(null)
|
||||
const tables = ref<TableInfo[]>([])
|
||||
const tableLoading = ref(false)
|
||||
const tableSearchQuery = ref('')
|
||||
const selectedTables = ref<TableInfo[]>([]) // 选中的表列表
|
||||
const tablePage = ref(1)
|
||||
const tablePageSize = ref(10)
|
||||
const currentTableIndex = ref(0)
|
||||
|
||||
// 搜索时重置分页
|
||||
watch(tableSearchQuery, () => {
|
||||
tablePage.value = 1
|
||||
})
|
||||
|
||||
// 映射步骤:1-选择表, 2-DDl和映射
|
||||
const mappingStep = ref(1)
|
||||
|
||||
// 当前正在编辑的表
|
||||
const selectedTable = computed(() => {
|
||||
if (selectedTables.value.length === 0) return null
|
||||
return selectedTables.value[currentTableIndex.value] || null
|
||||
})
|
||||
|
||||
// 检查表是否被选中
|
||||
const isTableSelected = (tableName: string) => {
|
||||
return selectedTables.value.some(t => t.name === tableName)
|
||||
}
|
||||
|
||||
// 切换表的选择状态
|
||||
const toggleTableSelection = (table: TableInfo) => {
|
||||
const index = selectedTables.value.findIndex(t => t.name === table.name)
|
||||
if (index >= 0) {
|
||||
selectedTables.value.splice(index, 1)
|
||||
// 调整当前索引
|
||||
if (currentTableIndex.value >= selectedTables.value.length) {
|
||||
currentTableIndex.value = Math.max(0, selectedTables.value.length - 1)
|
||||
}
|
||||
} else {
|
||||
selectedTables.value.push(table)
|
||||
}
|
||||
}
|
||||
|
||||
// 全选所有表
|
||||
const selectAllTables = () => {
|
||||
selectedTables.value = [...filteredTables()]
|
||||
currentTableIndex.value = 0
|
||||
}
|
||||
|
||||
// 清除所有选择
|
||||
const clearAllTables = () => {
|
||||
selectedTables.value = []
|
||||
currentTableIndex.value = 0
|
||||
}
|
||||
|
||||
// 上一张表
|
||||
const prevTable = () => {
|
||||
if (currentTableIndex.value > 0) {
|
||||
currentTableIndex.value--
|
||||
}
|
||||
}
|
||||
|
||||
// 下一张表
|
||||
const nextTable = () => {
|
||||
if (currentTableIndex.value < selectedTables.value.length - 1) {
|
||||
currentTableIndex.value++
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤后的表
|
||||
const filteredTables = () => {
|
||||
let result = tables.value
|
||||
if (tableSearchQuery.value) {
|
||||
result = result.filter(t => t.name.toLowerCase().includes(tableSearchQuery.value.toLowerCase()))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const paginatedTables = computed(() => {
|
||||
const start = (tablePage.value - 1) * tablePageSize.value
|
||||
const end = start + tablePageSize.value
|
||||
return filteredTables().slice(start, end)
|
||||
})
|
||||
|
||||
const totalFilteredTables = computed(() => filteredTables().length)
|
||||
|
||||
// 关闭映射弹窗
|
||||
const closeMapping = () => {
|
||||
isMapping.value = false
|
||||
mappingDb.value = null
|
||||
tables.value = []
|
||||
selectedTables.value = []
|
||||
currentTableIndex.value = 0
|
||||
mappingStep.value = 1
|
||||
tableSearchQuery.value = ''
|
||||
tablePage.value = 1
|
||||
}
|
||||
|
||||
// 打开映射弹窗(从列表页面点击映射按钮)
|
||||
const openMapping = async (db: Database) => {
|
||||
mappingDb.value = db
|
||||
tables.value = []
|
||||
selectedTables.value = []
|
||||
currentTableIndex.value = 0
|
||||
mappingStep.value = 1
|
||||
tableLoading.value = true
|
||||
isMapping.value = true
|
||||
|
||||
try {
|
||||
// 并行获取实时表结构和已保存的映射数据
|
||||
const [checkRes, mappingRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/database/check`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
db_type: db.db_type,
|
||||
host: db.host,
|
||||
port: db.port,
|
||||
username: db.username,
|
||||
password: db.password,
|
||||
database: db.database,
|
||||
charset: 'utf8mb4',
|
||||
}),
|
||||
}),
|
||||
// 如果是已存在的数据库,获取已保存的映射
|
||||
db.id ? fetch(`${API_BASE}/sub-table/database/${db.id}`) : Promise.resolve(null),
|
||||
])
|
||||
|
||||
if (!checkRes.ok) {
|
||||
throw new Error(`HTTP ${checkRes.status}`)
|
||||
}
|
||||
|
||||
const result = await checkRes.json()
|
||||
|
||||
// 获取已保存的映射数据
|
||||
let savedMappings: any = {}
|
||||
let savedTableNames: string[] = [] // 已保存的表名列表
|
||||
if (mappingRes && mappingRes.ok) {
|
||||
const mappingResult = await mappingRes.json()
|
||||
// 后端返回的是 list 字段
|
||||
const tablesList = mappingResult.list || mappingResult.tables || []
|
||||
if (tablesList.length > 0) {
|
||||
// 构建映射表: parent_table -> fields[]
|
||||
for (const table of tablesList) {
|
||||
savedMappings[table.parent_table] = table.fields || []
|
||||
savedTableNames.push(table.parent_table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.success && result.tables) {
|
||||
tables.value = result.tables.map((table: any) => {
|
||||
const ddl = table.ddl || ''
|
||||
// 获取该表已保存的字段映射
|
||||
const savedFields = savedMappings[table.table_name] || []
|
||||
|
||||
// 如果有 columns 数据则使用,否则尝试从 DDL 解析
|
||||
let columns: ColumnInfo[]
|
||||
if (table.columns && table.columns.length > 0) {
|
||||
columns = table.columns.map((col: any) => {
|
||||
// 查找已保存的映射
|
||||
const savedField = savedFields.find((f: any) => f.column_name === col.column_name)
|
||||
return {
|
||||
name: col.column_name,
|
||||
type: col.data_type || col.column_type || '',
|
||||
column_type: col.column_type || '',
|
||||
comment: col.column_comment || '',
|
||||
is_nullable: col.is_nullable || '',
|
||||
default_value: col.default_value || '',
|
||||
column_key: col.column_key || '',
|
||||
mapped_name: savedField?.mapped_name || col.mapped_name || '',
|
||||
}
|
||||
})
|
||||
} else if (ddl) {
|
||||
// 从 DDL 解析列信息,并合并已保存的映射
|
||||
columns = parseDDLColumns(ddl).map(col => {
|
||||
const savedField = savedFields.find((f: any) => f.column_name === col.name)
|
||||
return {
|
||||
...col,
|
||||
mapped_name: savedField?.mapped_name || '',
|
||||
}
|
||||
})
|
||||
} else {
|
||||
columns = []
|
||||
}
|
||||
|
||||
return {
|
||||
name: table.table_name,
|
||||
table_comment: table.table_comment || '',
|
||||
ddl,
|
||||
columns,
|
||||
}
|
||||
})
|
||||
|
||||
// 恢复已选择的表
|
||||
if (savedTableNames.length > 0) {
|
||||
selectedTables.value = tables.value.filter(t => savedTableNames.includes(t.name))
|
||||
} else {
|
||||
selectedTables.value = []
|
||||
}
|
||||
currentTableIndex.value = 0
|
||||
} else {
|
||||
ElMessage.warning(result.message || '获取表结构失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取表结构失败:', error)
|
||||
ElMessage.error('获取表结构失败: ' + error.message)
|
||||
} finally {
|
||||
tableLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 进入下一步(选择表后)
|
||||
const goToStep2 = () => {
|
||||
if (selectedTables.value.length > 0) {
|
||||
mappingStep.value = 2
|
||||
currentTableIndex.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 返回上一步
|
||||
const goToStep1 = () => {
|
||||
mappingStep.value = 1
|
||||
}
|
||||
|
||||
// 保存映射(创建或更新数据库 + 保存子表)
|
||||
const saveMapping = async () => {
|
||||
if (!mappingDb.value) {
|
||||
ElMessage.warning('数据库信息不存在')
|
||||
return
|
||||
}
|
||||
|
||||
const isEditing = !!mappingDb.value.id
|
||||
const subTablesData = selectedTables.value.map(table => ({
|
||||
parent_table: table.name,
|
||||
sub_table_name: table.table_comment || table.name,
|
||||
sub_table_comment: table.table_comment || '',
|
||||
fields: (table.columns || []).map(col => ({
|
||||
column_name: col.name,
|
||||
mapped_name: col.mapped_name || '', // 这里实际存的是 comment
|
||||
})),
|
||||
}))
|
||||
|
||||
try {
|
||||
let response: Response
|
||||
|
||||
if (isEditing) {
|
||||
// 编辑模式:调用更新接口
|
||||
response = await fetch(`${API_BASE}/database/${mappingDb.value.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: mappingDb.value.name,
|
||||
description: mappingDb.value.description,
|
||||
db_type: mappingDb.value.db_type,
|
||||
host: mappingDb.value.host,
|
||||
port: mappingDb.value.port,
|
||||
username: mappingDb.value.username,
|
||||
password: mappingDb.value.password,
|
||||
database: mappingDb.value.database,
|
||||
sub_tables: subTablesData,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
// 新建模式:调用创建接口
|
||||
response = await fetch(`${API_BASE}/database/add`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: mappingDb.value.name,
|
||||
description: mappingDb.value.description,
|
||||
db_type: mappingDb.value.db_type,
|
||||
host: mappingDb.value.host,
|
||||
port: mappingDb.value.port,
|
||||
username: mappingDb.value.username,
|
||||
password: mappingDb.value.password,
|
||||
database: mappingDb.value.database,
|
||||
sub_tables: subTablesData,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
if (!isEditing) {
|
||||
databases.value.push(result)
|
||||
}
|
||||
closeMapping()
|
||||
ElMessage.success(isEditing ? 'Mapping updated successfully' : 'Database created successfully')
|
||||
// 刷新列表
|
||||
fetchDatabases()
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
ElMessage.error(errorData.error || (isEditing ? 'Failed to update mapping' : 'Failed to create database'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('保存失败:', error)
|
||||
ElMessage.error('保存失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
const dbTypes = ['MySQL', 'PostgreSQL', 'MongoDB', 'Redis']
|
||||
|
||||
// API 基础 URL
|
||||
const API_BASE = 'http://localhost:8082'
|
||||
|
||||
// 获取列表数据
|
||||
const fetchDatabases = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/database/list`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch databases')
|
||||
}
|
||||
const data = await response.json()
|
||||
databases.value = data.list || []
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch databases:', error)
|
||||
ElMessage.error('Failed to load databases')
|
||||
databases.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDatabases()
|
||||
})
|
||||
|
||||
// 新建 Database 表单
|
||||
const newDbForm = ref({
|
||||
name: '',
|
||||
db_type: 'MySQL',
|
||||
description: '',
|
||||
host: 'localhost',
|
||||
port: 3306,
|
||||
username: 'root',
|
||||
password: '',
|
||||
database: '',
|
||||
})
|
||||
|
||||
// 打开新建弹窗
|
||||
const openCreate = () => {
|
||||
newDbForm.value = {
|
||||
name: '',
|
||||
db_type: 'MySQL',
|
||||
description: '',
|
||||
host: 'localhost',
|
||||
port: 3306,
|
||||
username: 'root',
|
||||
password: '',
|
||||
database: '',
|
||||
}
|
||||
isCreating.value = true
|
||||
}
|
||||
|
||||
// 关闭新建弹窗
|
||||
const closeCreate = () => {
|
||||
isCreating.value = false
|
||||
}
|
||||
|
||||
// 测试连接并获取表列表
|
||||
const testConnect = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/database/check`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
db_type: newDbForm.value.db_type,
|
||||
host: newDbForm.value.host,
|
||||
port: newDbForm.value.port,
|
||||
username: newDbForm.value.username,
|
||||
password: newDbForm.value.password,
|
||||
database: newDbForm.value.database,
|
||||
charset: 'utf8mb4',
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
// 连接成功,显示表映射弹窗
|
||||
const dbInfo: Database = {
|
||||
id: '',
|
||||
name: newDbForm.value.name || 'Untitled Database',
|
||||
description: newDbForm.value.description,
|
||||
db_type: newDbForm.value.db_type,
|
||||
host: newDbForm.value.host,
|
||||
port: newDbForm.value.port,
|
||||
username: newDbForm.value.username,
|
||||
password: newDbForm.value.password,
|
||||
database: newDbForm.value.database,
|
||||
table_count: 0,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
}
|
||||
|
||||
// 将后端返回的表数据映射到前端格式
|
||||
if (result.tables && result.tables.length > 0) {
|
||||
tables.value = result.tables.map((table: any) => {
|
||||
const ddl = table.ddl || ''
|
||||
// 如果有 columns 数据则使用,否则尝试从 DDL 解析
|
||||
let columns: ColumnInfo[]
|
||||
if (table.columns && table.columns.length > 0) {
|
||||
columns = table.columns.map((col: any) => ({
|
||||
name: col.column_name,
|
||||
type: col.data_type || col.column_type || '',
|
||||
column_type: col.column_type || '',
|
||||
comment: col.column_comment || '',
|
||||
is_nullable: col.is_nullable || '',
|
||||
default_value: col.default_value || '',
|
||||
column_key: col.column_key || '',
|
||||
mapped_name: '',
|
||||
}))
|
||||
} else if (ddl) {
|
||||
// 从 DDL 解析列信息
|
||||
columns = parseDDLColumns(ddl)
|
||||
} else {
|
||||
columns = []
|
||||
}
|
||||
|
||||
return {
|
||||
name: table.table_name,
|
||||
table_comment: table.table_comment || '',
|
||||
ddl,
|
||||
columns,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 默认不选中任何表
|
||||
selectedTables.value = []
|
||||
currentTableIndex.value = 0
|
||||
mappingStep.value = 1
|
||||
mappingDb.value = dbInfo
|
||||
isMapping.value = true
|
||||
isCreating.value = false // 关闭创建弹窗
|
||||
ElMessage.success('Connection successful! Please select tables to map.')
|
||||
} else {
|
||||
ElMessage.error(result.message || 'Connection failed')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Connection test failed:', error)
|
||||
ElMessage.error('Connection test failed: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑表单数据
|
||||
const editForm = ref({
|
||||
name: '',
|
||||
db_type: '',
|
||||
description: '',
|
||||
host: '',
|
||||
port: 0,
|
||||
username: '',
|
||||
password: '',
|
||||
database: '',
|
||||
})
|
||||
|
||||
// 打开编辑弹窗
|
||||
const openEdit = (db: Database) => {
|
||||
editingDb.value = db
|
||||
editForm.value = {
|
||||
name: db.name,
|
||||
db_type: db.db_type,
|
||||
description: db.description,
|
||||
host: db.host,
|
||||
port: db.port,
|
||||
username: db.username,
|
||||
password: db.password,
|
||||
database: db.database,
|
||||
}
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
// 保存编辑
|
||||
const saveEdit = async () => {
|
||||
if (editingDb.value) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/database/${editingDb.value.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: editForm.value.name,
|
||||
description: editForm.value.description,
|
||||
db_type: editForm.value.db_type,
|
||||
host: editForm.value.host,
|
||||
port: editForm.value.port,
|
||||
username: editForm.value.username,
|
||||
password: editForm.value.password,
|
||||
database: editForm.value.database,
|
||||
}),
|
||||
})
|
||||
if (response.ok) {
|
||||
const updatedDb = await response.json()
|
||||
const index = databases.value.findIndex(d => d.id === editingDb.value!.id)
|
||||
if (index !== -1) {
|
||||
databases.value[index] = updatedDb
|
||||
}
|
||||
ElMessage.success('Database updated successfully')
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
ElMessage.error(errorData.error || 'Failed to update database')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update database:', error)
|
||||
ElMessage.error('Failed to update database')
|
||||
}
|
||||
}
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
// 取消编辑
|
||||
const cancelEdit = () => {
|
||||
isEditing.value = false
|
||||
editingDb.value = null
|
||||
}
|
||||
|
||||
// 删除 Database
|
||||
const deleteDb = async (id: string) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/database/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
if (response.ok) {
|
||||
databases.value = databases.value.filter(d => d.id !== id)
|
||||
ElMessage.success('Database deleted successfully')
|
||||
} else {
|
||||
ElMessage.error('Failed to delete database')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete database:', error)
|
||||
ElMessage.error('Failed to delete database')
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤后的 Databases
|
||||
const filteredDatabases = () => {
|
||||
return databases.value.filter(db => {
|
||||
const matchSearch = !searchQuery.value ||
|
||||
db.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
db.db_type.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
db.host.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
return matchSearch
|
||||
})
|
||||
}
|
||||
|
||||
import { useDatabase } from './database/useDatabase'
|
||||
import './database/database.css'
|
||||
|
||||
const {
|
||||
// State
|
||||
databases,
|
||||
loading,
|
||||
editingDb,
|
||||
isEditing,
|
||||
isCreating,
|
||||
searchQuery,
|
||||
isMapping,
|
||||
mappingDb,
|
||||
tables,
|
||||
tableLoading,
|
||||
tableSearchQuery,
|
||||
selectedTables,
|
||||
tablePage,
|
||||
tablePageSize,
|
||||
currentTableIndex,
|
||||
mappingStep,
|
||||
newDbForm,
|
||||
editForm,
|
||||
dbTypes,
|
||||
// Computed
|
||||
selectedTable,
|
||||
filteredDatabases,
|
||||
paginatedTables,
|
||||
totalFilteredTables,
|
||||
// Methods
|
||||
isTableSelected,
|
||||
toggleTableSelection,
|
||||
selectAllTables,
|
||||
clearAllTables,
|
||||
prevTable,
|
||||
nextTable,
|
||||
fetchDatabases,
|
||||
openCreate,
|
||||
closeCreate,
|
||||
closeMapping,
|
||||
testConnect,
|
||||
openMapping,
|
||||
goToStep2,
|
||||
goToStep1,
|
||||
saveMapping,
|
||||
openEdit,
|
||||
saveEdit,
|
||||
cancelEdit,
|
||||
deleteDb,
|
||||
} = useDatabase()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -979,6 +354,57 @@ const filteredDatabases = () => {
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 连接凭据 - 当选择Neo4j时显示 -->
|
||||
<div v-if="newDbForm.db_type === 'Neo4j'" class="space-y-4 pt-2">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Host</label>
|
||||
<input
|
||||
v-model="newDbForm.host"
|
||||
type="text"
|
||||
placeholder="localhost"
|
||||
class="input-field"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Port</label>
|
||||
<input
|
||||
v-model="newDbForm.port"
|
||||
type="number"
|
||||
placeholder="7687"
|
||||
class="input-field"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Database</label>
|
||||
<input
|
||||
v-model="newDbForm.database"
|
||||
type="text"
|
||||
placeholder="neo4j (default)"
|
||||
class="input-field"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">User</label>
|
||||
<input
|
||||
v-model="newDbForm.username"
|
||||
type="text"
|
||||
placeholder="neo4j"
|
||||
class="input-field"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Password</label>
|
||||
<input
|
||||
v-model="newDbForm.password"
|
||||
type="password"
|
||||
placeholder="Enter password..."
|
||||
class="input-field"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
|
||||
75
web/src/views/database/database.css
Normal file
75
web/src/views/database/database.css
Normal file
@@ -0,0 +1,75 @@
|
||||
/* Database View Styles */
|
||||
|
||||
/* 搜索输入框 */
|
||||
.search-input {
|
||||
@apply bg-dark-700 border border-dark-500 rounded-lg px-4 py-2.5 pl-10 text-white placeholder-gray-500 focus:outline-none focus:border-primary-cyan focus:ring-1 focus:ring-primary-cyan/30 transition-all;
|
||||
}
|
||||
|
||||
/* 输入框 */
|
||||
.input-field {
|
||||
@apply w-full bg-dark-700 border border-dark-500 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-primary-cyan focus:ring-1 focus:ring-primary-cyan/30 transition-all;
|
||||
}
|
||||
|
||||
/* 主要按钮 */
|
||||
.btn-primary {
|
||||
@apply px-4 py-2 rounded-lg bg-gradient-to-r from-primary-cyan to-blue-500 text-white hover:from-cyan-500 hover:to-blue-600 transition-all flex items-center gap-2;
|
||||
}
|
||||
|
||||
/* 次要按钮 */
|
||||
.btn-secondary {
|
||||
@apply px-4 py-2 rounded-lg bg-dark-600 text-gray-300 hover:bg-dark-500 transition-all flex items-center gap-2;
|
||||
}
|
||||
|
||||
/* 图标按钮 */
|
||||
.btn-icon {
|
||||
@apply p-2 rounded-lg hover:bg-dark-600 transition-colors;
|
||||
}
|
||||
|
||||
/* 表格行 */
|
||||
.table-row {
|
||||
@apply border-b border-dark-600 hover:bg-dark-600/50 transition-colors;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
@apply py-16 text-center text-gray-500;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
@apply text-4xl mb-4 block;
|
||||
}
|
||||
|
||||
/* 表格复选框 */
|
||||
.table-checkbox {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
/* 弹窗遮罩 */
|
||||
.modal-overlay {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* 弹窗内容 */
|
||||
.modal-content {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
45
web/src/views/database/types.ts
Normal file
45
web/src/views/database/types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// Database 相关类型定义
|
||||
|
||||
export interface Database {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
db_type: string
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password: string
|
||||
database: string
|
||||
table_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface TableInfo {
|
||||
name: string
|
||||
ddl: string
|
||||
columns: ColumnInfo[]
|
||||
table_comment?: string
|
||||
}
|
||||
|
||||
export interface ColumnInfo {
|
||||
name: string
|
||||
type: string
|
||||
comment: string
|
||||
mapped_name: string
|
||||
column_type?: string
|
||||
is_nullable?: string
|
||||
default_value?: string
|
||||
column_key?: string
|
||||
}
|
||||
|
||||
export interface DbForm {
|
||||
name: string
|
||||
db_type: string
|
||||
description: string
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password: string
|
||||
database: string
|
||||
}
|
||||
683
web/src/views/database/useDatabase.ts
Normal file
683
web/src/views/database/useDatabase.ts
Normal file
@@ -0,0 +1,683 @@
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { Database, TableInfo, ColumnInfo, DbForm } from './types'
|
||||
|
||||
// API 基础 URL
|
||||
const API_BASE = 'http://localhost:8082'
|
||||
|
||||
// 解析 DDL 获取列信息
|
||||
function parseDDLColumns(ddl: string): ColumnInfo[] {
|
||||
const columns: ColumnInfo[] = []
|
||||
if (!ddl) return columns
|
||||
|
||||
// 移除 CREATE TABLE 语句,只保留括号内的内容
|
||||
const match = ddl.match(/\(([\s\S]*)\)\s*.*$/m)
|
||||
if (!match) return columns
|
||||
|
||||
const body = match[1]
|
||||
|
||||
// 按换行或逗号分割列定义(处理多行的情况)
|
||||
const lines = body.split(/,\s*(?=`\w+`|\s*$)/)
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('PRIMARY KEY') || trimmed.startsWith('KEY ') ||
|
||||
trimmed.startsWith('UNIQUE KEY') || trimmed.startsWith('FULLTEXT') ||
|
||||
trimmed.startsWith('CONSTRAINT') || trimmed.startsWith('FOREIGN KEY') ||
|
||||
trimmed.startsWith('ENGINE') || trimmed.startsWith('CHARSET') || trimmed.startsWith(')')) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析列名(支持反引号和单引号)
|
||||
const colNameMatch = trimmed.match(/^`?(\w+)`?\s+/)
|
||||
if (!colNameMatch) continue
|
||||
|
||||
const name = colNameMatch[1]
|
||||
|
||||
// 提取列定义剩余部分
|
||||
const rest = trimmed.substring(colNameMatch[0].length)
|
||||
|
||||
// 提取数据类型(到 NOT NULL / DEFAULT / COMMENT 之前)
|
||||
const typeMatch = rest.match(/^([^\s]+(?:\s*\([^\)]+\))?)/)
|
||||
const type = typeMatch ? typeMatch[1] : ''
|
||||
|
||||
// 提取 COMMENT
|
||||
const commentMatch = trimmed.match(/COMMENT\s+['"]([^'"]*)['"]/i)
|
||||
const comment = commentMatch ? commentMatch[1] : ''
|
||||
|
||||
// 提取默认值
|
||||
const defaultMatch = trimmed.match(/DEFAULT\s+([^\s,]+)/i)
|
||||
const defaultValue = defaultMatch ? defaultMatch[1] : ''
|
||||
|
||||
// 判断是否可空
|
||||
const isNullable = trimmed.includes('NOT NULL') ? 'NO' : 'YES'
|
||||
|
||||
// 判断是否是主键
|
||||
const isPrimaryKey = trimmed.includes('PRIMARY KEY')
|
||||
|
||||
columns.push({
|
||||
name,
|
||||
type,
|
||||
comment,
|
||||
mapped_name: '',
|
||||
column_type: type,
|
||||
is_nullable: isNullable,
|
||||
default_value: defaultValue,
|
||||
column_key: isPrimaryKey ? 'PRI' : '',
|
||||
})
|
||||
}
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
export function useDatabase() {
|
||||
// 数据库数据
|
||||
const databases = ref<Database[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 编辑状态
|
||||
const editingDb = ref<Database | null>(null)
|
||||
const isEditing = ref(false)
|
||||
const isCreating = ref(false)
|
||||
const searchQuery = ref('')
|
||||
|
||||
// 表映射弹窗状态
|
||||
const isMapping = ref(false)
|
||||
const mappingDb = ref<Database | null>(null)
|
||||
const tables = ref<TableInfo[]>([])
|
||||
const tableLoading = ref(false)
|
||||
const tableSearchQuery = ref('')
|
||||
const selectedTables = ref<TableInfo[]>([])
|
||||
const tablePage = ref(1)
|
||||
const tablePageSize = ref(10)
|
||||
const currentTableIndex = ref(0)
|
||||
|
||||
// 映射步骤:1-选择表, 2-DDl和映射
|
||||
const mappingStep = ref(1)
|
||||
|
||||
// 新建表单
|
||||
const newDbForm = ref<DbForm>({
|
||||
name: '',
|
||||
db_type: 'MySQL',
|
||||
description: '',
|
||||
host: 'localhost',
|
||||
port: 3306,
|
||||
username: 'root',
|
||||
password: '',
|
||||
database: '',
|
||||
})
|
||||
|
||||
// 编辑表单
|
||||
const editForm = ref<DbForm>({
|
||||
name: '',
|
||||
db_type: '',
|
||||
description: '',
|
||||
host: '',
|
||||
port: 0,
|
||||
username: '',
|
||||
password: '',
|
||||
database: '',
|
||||
})
|
||||
|
||||
const dbTypes = ['MySQL', 'Neo4j']
|
||||
|
||||
// 监听数据库类型变化,自动设置默认端口
|
||||
watch(() => newDbForm.value.db_type, (newType) => {
|
||||
if (newType === 'Neo4j') {
|
||||
newDbForm.value.port = 7687
|
||||
newDbForm.value.username = 'neo4j'
|
||||
} else if (newType === 'MySQL') {
|
||||
newDbForm.value.port = 3306
|
||||
newDbForm.value.username = 'root'
|
||||
}
|
||||
})
|
||||
|
||||
// 搜索时重置分页
|
||||
watch(tableSearchQuery, () => {
|
||||
tablePage.value = 1
|
||||
})
|
||||
|
||||
// 当前正在编辑的表
|
||||
const selectedTable = computed(() => {
|
||||
if (selectedTables.value.length === 0) return null
|
||||
return selectedTables.value[currentTableIndex.value] || null
|
||||
})
|
||||
|
||||
// 过滤后的表
|
||||
const filteredTables = () => {
|
||||
let result = tables.value
|
||||
if (tableSearchQuery.value) {
|
||||
result = result.filter(t => t.name.toLowerCase().includes(tableSearchQuery.value.toLowerCase()))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const paginatedTables = computed(() => {
|
||||
const start = (tablePage.value - 1) * tablePageSize.value
|
||||
const end = start + tablePageSize.value
|
||||
return filteredTables().slice(start, end)
|
||||
})
|
||||
|
||||
const totalFilteredTables = computed(() => filteredTables().length)
|
||||
|
||||
// 过滤后的 Databases
|
||||
const filteredDatabases = () => {
|
||||
return databases.value.filter(db => {
|
||||
const matchSearch = !searchQuery.value ||
|
||||
db.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
db.db_type.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
db.host.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
return matchSearch
|
||||
})
|
||||
}
|
||||
|
||||
// 检查表是否被选中
|
||||
const isTableSelected = (tableName: string) => {
|
||||
return selectedTables.value.some(t => t.name === tableName)
|
||||
}
|
||||
|
||||
// 切换表的选择状态
|
||||
const toggleTableSelection = (table: TableInfo) => {
|
||||
const index = selectedTables.value.findIndex(t => t.name === table.name)
|
||||
if (index >= 0) {
|
||||
selectedTables.value.splice(index, 1)
|
||||
if (currentTableIndex.value >= selectedTables.value.length) {
|
||||
currentTableIndex.value = Math.max(0, selectedTables.value.length - 1)
|
||||
}
|
||||
} else {
|
||||
selectedTables.value.push(table)
|
||||
}
|
||||
}
|
||||
|
||||
// 全选所有表
|
||||
const selectAllTables = () => {
|
||||
selectedTables.value = [...filteredTables()]
|
||||
currentTableIndex.value = 0
|
||||
}
|
||||
|
||||
// 清除所有选择
|
||||
const clearAllTables = () => {
|
||||
selectedTables.value = []
|
||||
currentTableIndex.value = 0
|
||||
}
|
||||
|
||||
// 上一张表
|
||||
const prevTable = () => {
|
||||
if (currentTableIndex.value > 0) {
|
||||
currentTableIndex.value--
|
||||
}
|
||||
}
|
||||
|
||||
// 下一张表
|
||||
const nextTable = () => {
|
||||
if (currentTableIndex.value < selectedTables.value.length - 1) {
|
||||
currentTableIndex.value++
|
||||
}
|
||||
}
|
||||
|
||||
// 获取列表数据
|
||||
const fetchDatabases = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/database/list`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch databases')
|
||||
}
|
||||
const data = await response.json()
|
||||
databases.value = data.list || []
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch databases:', error)
|
||||
ElMessage.error('Failed to load databases')
|
||||
databases.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开新建弹窗
|
||||
const openCreate = () => {
|
||||
newDbForm.value = {
|
||||
name: '',
|
||||
db_type: 'MySQL',
|
||||
description: '',
|
||||
host: 'localhost',
|
||||
port: 3306,
|
||||
username: 'root',
|
||||
password: '',
|
||||
database: '',
|
||||
}
|
||||
isCreating.value = true
|
||||
}
|
||||
|
||||
// 关闭新建弹窗
|
||||
const closeCreate = () => {
|
||||
isCreating.value = false
|
||||
}
|
||||
|
||||
// 关闭映射弹窗
|
||||
const closeMapping = () => {
|
||||
isMapping.value = false
|
||||
mappingDb.value = null
|
||||
tables.value = []
|
||||
selectedTables.value = []
|
||||
currentTableIndex.value = 0
|
||||
mappingStep.value = 1
|
||||
tableSearchQuery.value = ''
|
||||
tablePage.value = 1
|
||||
}
|
||||
|
||||
// 测试连接并获取表列表
|
||||
const testConnect = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/database/check`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
db_type: newDbForm.value.db_type,
|
||||
host: newDbForm.value.host,
|
||||
port: newDbForm.value.port,
|
||||
username: newDbForm.value.username,
|
||||
password: newDbForm.value.password,
|
||||
database: newDbForm.value.database,
|
||||
charset: 'utf8mb4',
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
const dbInfo: Database = {
|
||||
id: '',
|
||||
name: newDbForm.value.name || 'Untitled Database',
|
||||
description: newDbForm.value.description,
|
||||
db_type: newDbForm.value.db_type,
|
||||
host: newDbForm.value.host,
|
||||
port: newDbForm.value.port,
|
||||
username: newDbForm.value.username,
|
||||
password: newDbForm.value.password,
|
||||
database: newDbForm.value.database,
|
||||
table_count: 0,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
}
|
||||
|
||||
if (result.tables && result.tables.length > 0) {
|
||||
tables.value = result.tables.map((table: any) => {
|
||||
const ddl = table.ddl || ''
|
||||
let columns: ColumnInfo[]
|
||||
if (table.columns && table.columns.length > 0) {
|
||||
columns = table.columns.map((col: any) => ({
|
||||
name: col.column_name,
|
||||
type: col.data_type || col.column_type || '',
|
||||
column_type: col.column_type || '',
|
||||
comment: col.column_comment || '',
|
||||
is_nullable: col.is_nullable || '',
|
||||
default_value: col.default_value || '',
|
||||
column_key: col.column_key || '',
|
||||
mapped_name: '',
|
||||
}))
|
||||
} else if (ddl) {
|
||||
columns = parseDDLColumns(ddl)
|
||||
} else {
|
||||
columns = []
|
||||
}
|
||||
|
||||
return {
|
||||
name: table.table_name,
|
||||
table_comment: table.table_comment || '',
|
||||
ddl,
|
||||
columns,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
selectedTables.value = []
|
||||
currentTableIndex.value = 0
|
||||
mappingStep.value = 1
|
||||
mappingDb.value = dbInfo
|
||||
isMapping.value = true
|
||||
isCreating.value = false
|
||||
ElMessage.success('Connection successful! Please select tables to map.')
|
||||
} else {
|
||||
ElMessage.error(result.message || 'Connection failed')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Connection test failed:', error)
|
||||
ElMessage.error('Connection test failed: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开映射弹窗
|
||||
const openMapping = async (db: Database) => {
|
||||
mappingDb.value = db
|
||||
tables.value = []
|
||||
selectedTables.value = []
|
||||
currentTableIndex.value = 0
|
||||
mappingStep.value = 1
|
||||
tableLoading.value = true
|
||||
isMapping.value = true
|
||||
|
||||
try {
|
||||
const [checkRes, mappingRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/database/check`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
db_type: db.db_type,
|
||||
host: db.host,
|
||||
port: db.port,
|
||||
username: db.username,
|
||||
password: db.password,
|
||||
database: db.database,
|
||||
charset: 'utf8mb4',
|
||||
}),
|
||||
}),
|
||||
db.id ? fetch(`${API_BASE}/sub-table/database/${db.id}`) : Promise.resolve(null),
|
||||
])
|
||||
|
||||
if (!checkRes.ok) {
|
||||
throw new Error(`HTTP ${checkRes.status}`)
|
||||
}
|
||||
|
||||
const result = await checkRes.json()
|
||||
|
||||
let savedMappings: any = {}
|
||||
let savedTableNames: string[] = []
|
||||
if (mappingRes && mappingRes.ok) {
|
||||
const mappingResult = await mappingRes.json()
|
||||
const tablesList = mappingResult.list || mappingResult.tables || []
|
||||
for (const table of tablesList) {
|
||||
savedMappings[table.parent_table] = table.fields || []
|
||||
savedTableNames.push(table.parent_table)
|
||||
}
|
||||
}
|
||||
|
||||
if (result.success && result.tables) {
|
||||
tables.value = result.tables.map((table: any) => {
|
||||
const ddl = table.ddl || ''
|
||||
const savedFields = savedMappings[table.table_name] || []
|
||||
|
||||
let columns: ColumnInfo[]
|
||||
if (table.columns && table.columns.length > 0) {
|
||||
columns = table.columns.map((col: any) => {
|
||||
const savedField = savedFields.find((f: any) => f.column_name === col.column_name)
|
||||
return {
|
||||
name: col.column_name,
|
||||
type: col.data_type || col.column_type || '',
|
||||
column_type: col.column_type || '',
|
||||
comment: col.column_comment || '',
|
||||
is_nullable: col.is_nullable || '',
|
||||
default_value: col.default_value || '',
|
||||
column_key: col.column_key || '',
|
||||
mapped_name: savedField?.mapped_name || col.mapped_name || '',
|
||||
}
|
||||
})
|
||||
} else if (ddl) {
|
||||
columns = parseDDLColumns(ddl).map(col => {
|
||||
const savedField = savedFields.find((f: any) => f.column_name === col.name)
|
||||
return {
|
||||
...col,
|
||||
mapped_name: savedField?.mapped_name || '',
|
||||
}
|
||||
})
|
||||
} else {
|
||||
columns = []
|
||||
}
|
||||
|
||||
return {
|
||||
name: table.table_name,
|
||||
table_comment: table.table_comment || '',
|
||||
ddl,
|
||||
columns,
|
||||
}
|
||||
})
|
||||
|
||||
if (savedTableNames.length > 0) {
|
||||
selectedTables.value = tables.value.filter(t => savedTableNames.includes(t.name))
|
||||
} else {
|
||||
selectedTables.value = []
|
||||
}
|
||||
currentTableIndex.value = 0
|
||||
} else {
|
||||
ElMessage.warning(result.message || '获取表结构失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取表结构失败:', error)
|
||||
ElMessage.error('获取表结构失败: ' + error.message)
|
||||
} finally {
|
||||
tableLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 进入下一步
|
||||
const goToStep2 = () => {
|
||||
if (selectedTables.value.length > 0) {
|
||||
mappingStep.value = 2
|
||||
currentTableIndex.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 返回上一步
|
||||
const goToStep1 = () => {
|
||||
mappingStep.value = 1
|
||||
}
|
||||
|
||||
// 保存映射
|
||||
const saveMapping = async () => {
|
||||
if (!mappingDb.value) {
|
||||
ElMessage.warning('数据库信息不存在')
|
||||
return
|
||||
}
|
||||
|
||||
const isEditing = !!mappingDb.value.id
|
||||
const subTablesData = selectedTables.value.map(table => ({
|
||||
parent_table: table.name,
|
||||
sub_table_name: table.table_comment || table.name,
|
||||
sub_table_comment: table.table_comment || '',
|
||||
fields: (table.columns || []).map(col => ({
|
||||
column_name: col.name,
|
||||
mapped_name: col.mapped_name || '',
|
||||
})),
|
||||
}))
|
||||
|
||||
try {
|
||||
let response: Response
|
||||
|
||||
if (isEditing) {
|
||||
response = await fetch(`${API_BASE}/database/${mappingDb.value.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: mappingDb.value.name,
|
||||
description: mappingDb.value.description,
|
||||
db_type: mappingDb.value.db_type,
|
||||
host: mappingDb.value.host,
|
||||
port: mappingDb.value.port,
|
||||
username: mappingDb.value.username,
|
||||
password: mappingDb.value.password,
|
||||
database: mappingDb.value.database,
|
||||
sub_tables: subTablesData,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
response = await fetch(`${API_BASE}/database/add`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: mappingDb.value.name,
|
||||
description: mappingDb.value.description,
|
||||
db_type: mappingDb.value.db_type,
|
||||
host: mappingDb.value.host,
|
||||
port: mappingDb.value.port,
|
||||
username: mappingDb.value.username,
|
||||
password: mappingDb.value.password,
|
||||
database: mappingDb.value.database,
|
||||
sub_tables: subTablesData,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
if (!isEditing) {
|
||||
databases.value.push(result)
|
||||
}
|
||||
closeMapping()
|
||||
ElMessage.success(isEditing ? 'Mapping updated successfully' : 'Database created successfully')
|
||||
fetchDatabases()
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
ElMessage.error(errorData.error || (isEditing ? 'Failed to update mapping' : 'Failed to create database'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('保存失败:', error)
|
||||
ElMessage.error('保存失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑弹窗
|
||||
const openEdit = (db: Database) => {
|
||||
editingDb.value = db
|
||||
editForm.value = {
|
||||
name: db.name,
|
||||
db_type: db.db_type,
|
||||
description: db.description,
|
||||
host: db.host,
|
||||
port: db.port,
|
||||
username: db.username,
|
||||
password: db.password,
|
||||
database: db.database,
|
||||
}
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
// 保存编辑
|
||||
const saveEdit = async () => {
|
||||
if (editingDb.value) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/database/${editingDb.value.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: editForm.value.name,
|
||||
description: editForm.value.description,
|
||||
db_type: editForm.value.db_type,
|
||||
host: editForm.value.host,
|
||||
port: editForm.value.port,
|
||||
username: editForm.value.username,
|
||||
password: editForm.value.password,
|
||||
database: editForm.value.database,
|
||||
}),
|
||||
})
|
||||
if (response.ok) {
|
||||
const updatedDb = await response.json()
|
||||
const index = databases.value.findIndex(d => d.id === editingDb.value!.id)
|
||||
if (index !== -1) {
|
||||
databases.value[index] = updatedDb
|
||||
}
|
||||
ElMessage.success('Database updated successfully')
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
ElMessage.error(errorData.error || 'Failed to update database')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update database:', error)
|
||||
ElMessage.error('Failed to update database')
|
||||
}
|
||||
}
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
// 取消编辑
|
||||
const cancelEdit = () => {
|
||||
isEditing.value = false
|
||||
editingDb.value = null
|
||||
}
|
||||
|
||||
// 删除 Database
|
||||
const deleteDb = async (id: string) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/database/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
if (response.ok) {
|
||||
databases.value = databases.value.filter(d => d.id !== id)
|
||||
ElMessage.success('Database deleted successfully')
|
||||
} else {
|
||||
ElMessage.error('Failed to delete database')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete database:', error)
|
||||
ElMessage.error('Failed to delete database')
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchDatabases()
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
databases,
|
||||
loading,
|
||||
editingDb,
|
||||
isEditing,
|
||||
isCreating,
|
||||
searchQuery,
|
||||
isMapping,
|
||||
mappingDb,
|
||||
tables,
|
||||
tableLoading,
|
||||
tableSearchQuery,
|
||||
selectedTables,
|
||||
tablePage,
|
||||
tablePageSize,
|
||||
currentTableIndex,
|
||||
mappingStep,
|
||||
newDbForm,
|
||||
editForm,
|
||||
dbTypes,
|
||||
|
||||
// Computed
|
||||
selectedTable,
|
||||
filteredDatabases,
|
||||
paginatedTables,
|
||||
totalFilteredTables,
|
||||
|
||||
// Methods
|
||||
isTableSelected,
|
||||
toggleTableSelection,
|
||||
selectAllTables,
|
||||
clearAllTables,
|
||||
prevTable,
|
||||
nextTable,
|
||||
fetchDatabases,
|
||||
openCreate,
|
||||
closeCreate,
|
||||
closeMapping,
|
||||
testConnect,
|
||||
openMapping,
|
||||
goToStep2,
|
||||
goToStep1,
|
||||
saveMapping,
|
||||
openEdit,
|
||||
saveEdit,
|
||||
cancelEdit,
|
||||
deleteDb,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user