mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
db manager
This commit is contained in:
+353
@@ -547,6 +547,276 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/db-manage/overview": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "获取数据库类型、版本、名称、文件大小、表数量及当前连接数,需要管理员权限",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "获取数据库运行概览",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "获取成功",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/db_manage.DBOverviewResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/db-manage/query": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "在当前数据库中执行任意自定义 SQL,如果是查询语句将返回格式化后的列与数据集,否则返回受影响行数,需要管理员权限",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "执行 SQL 查询",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "SQL 请求参数",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/db_manage.ExecuteSQLRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "执行完毕",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/db_manage.ExecuteSQLResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "SQL 语句错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/db-manage/table-data": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "根据传入的数据表名称进行分页数据查询,返回表结构列名及动态行数据,需要管理员权限",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "获取数据表数据",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "表名称",
|
||||
"name": "table",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "页码,默认 1",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "每页大小,默认 10",
|
||||
"name": "pageSize",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "获取成功",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/db_manage.TableDataResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "参数错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/db-manage/tables": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "返回当前数据库的所有用户自定义表名称列表,需要管理员权限",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "获取数据库所有表名",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "获取成功",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/logs": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -3833,6 +4103,89 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"db_manage.DBOverviewResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"connections": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "string"
|
||||
},
|
||||
"table_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"db_manage.ExecuteSQLRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"sql"
|
||||
],
|
||||
"properties": {
|
||||
"sql": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"db_manage.ExecuteSQLResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"affected_rows": {
|
||||
"type": "integer"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"execution_time_ms": {
|
||||
"type": "integer"
|
||||
},
|
||||
"results": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"description": "\"select\" 或 \"exec\"",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"db_manage.TableDataResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"results": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"total": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logger.LogEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -540,6 +540,276 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/db-manage/overview": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "获取数据库类型、版本、名称、文件大小、表数量及当前连接数,需要管理员权限",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "获取数据库运行概览",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "获取成功",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/db_manage.DBOverviewResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/db-manage/query": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "在当前数据库中执行任意自定义 SQL,如果是查询语句将返回格式化后的列与数据集,否则返回受影响行数,需要管理员权限",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "执行 SQL 查询",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "SQL 请求参数",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/db_manage.ExecuteSQLRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "执行完毕",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/db_manage.ExecuteSQLResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "SQL 语句错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/db-manage/table-data": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "根据传入的数据表名称进行分页数据查询,返回表结构列名及动态行数据,需要管理员权限",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "获取数据表数据",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "表名称",
|
||||
"name": "table",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "页码,默认 1",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "每页大小,默认 10",
|
||||
"name": "pageSize",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "获取成功",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/db_manage.TableDataResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "参数错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/db-manage/tables": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"SessionCookie": []
|
||||
}
|
||||
],
|
||||
"description": "返回当前数据库的所有用户自定义表名称列表,需要管理员权限",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "获取数据库所有表名",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "获取成功",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "未登录",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "无管理员权限",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "内部错误",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/util.ResponseAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/logs": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -3826,6 +4096,89 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"db_manage.DBOverviewResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"connections": {
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "string"
|
||||
},
|
||||
"table_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"db_manage.ExecuteSQLRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"sql"
|
||||
],
|
||||
"properties": {
|
||||
"sql": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"db_manage.ExecuteSQLResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"affected_rows": {
|
||||
"type": "integer"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"execution_time_ms": {
|
||||
"type": "integer"
|
||||
},
|
||||
"results": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"description": "\"select\" 或 \"exec\"",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"db_manage.TableDataResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"results": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"total": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logger.LogEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -73,6 +73,61 @@ definitions:
|
||||
- solutions
|
||||
- token
|
||||
type: object
|
||||
db_manage.DBOverviewResponse:
|
||||
properties:
|
||||
connections:
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
size:
|
||||
type: string
|
||||
table_count:
|
||||
type: integer
|
||||
type:
|
||||
type: string
|
||||
version:
|
||||
type: string
|
||||
type: object
|
||||
db_manage.ExecuteSQLRequest:
|
||||
properties:
|
||||
sql:
|
||||
type: string
|
||||
required:
|
||||
- sql
|
||||
type: object
|
||||
db_manage.ExecuteSQLResponse:
|
||||
properties:
|
||||
affected_rows:
|
||||
type: integer
|
||||
columns:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
execution_time_ms:
|
||||
type: integer
|
||||
results:
|
||||
items:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
type: array
|
||||
type:
|
||||
description: '"select" 或 "exec"'
|
||||
type: string
|
||||
type: object
|
||||
db_manage.TableDataResponse:
|
||||
properties:
|
||||
columns:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
results:
|
||||
items:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
type: array
|
||||
total:
|
||||
type: integer
|
||||
type: object
|
||||
logger.LogEntry:
|
||||
properties:
|
||||
data:
|
||||
@@ -1233,6 +1288,167 @@ paths:
|
||||
summary: 获取数据库信息
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/db-manage/overview:
|
||||
get:
|
||||
description: 获取数据库类型、版本、名称、文件大小、表数量及当前连接数,需要管理员权限
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: 获取成功
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/util.ResponseAny'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/db_manage.DBOverviewResponse'
|
||||
type: object
|
||||
"401":
|
||||
description: 未登录
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"403":
|
||||
description: 无管理员权限
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"500":
|
||||
description: 内部错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 获取数据库运行概览
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/db-manage/query:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 在当前数据库中执行任意自定义 SQL,如果是查询语句将返回格式化后的列与数据集,否则返回受影响行数,需要管理员权限
|
||||
parameters:
|
||||
- description: SQL 请求参数
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/db_manage.ExecuteSQLRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: 执行完毕
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/util.ResponseAny'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/db_manage.ExecuteSQLResponse'
|
||||
type: object
|
||||
"400":
|
||||
description: SQL 语句错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"401":
|
||||
description: 未登录
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"403":
|
||||
description: 无管理员权限
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"500":
|
||||
description: 内部错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 执行 SQL 查询
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/db-manage/table-data:
|
||||
get:
|
||||
description: 根据传入的数据表名称进行分页数据查询,返回表结构列名及动态行数据,需要管理员权限
|
||||
parameters:
|
||||
- description: 表名称
|
||||
in: query
|
||||
name: table
|
||||
required: true
|
||||
type: string
|
||||
- description: 页码,默认 1
|
||||
in: query
|
||||
name: page
|
||||
type: integer
|
||||
- description: 每页大小,默认 10
|
||||
in: query
|
||||
name: pageSize
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: 获取成功
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/util.ResponseAny'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/db_manage.TableDataResponse'
|
||||
type: object
|
||||
"400":
|
||||
description: 参数错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"401":
|
||||
description: 未登录
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"403":
|
||||
description: 无管理员权限
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"500":
|
||||
description: 内部错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 获取数据表数据
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/db-manage/tables:
|
||||
get:
|
||||
description: 返回当前数据库的所有用户自定义表名称列表,需要管理员权限
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: 获取成功
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/util.ResponseAny'
|
||||
- properties:
|
||||
data:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type: object
|
||||
"401":
|
||||
description: 未登录
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"403":
|
||||
description: 无管理员权限
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
"500":
|
||||
description: 内部错误
|
||||
schema:
|
||||
$ref: '#/definitions/util.ResponseAny'
|
||||
security:
|
||||
- SessionCookie: []
|
||||
summary: 获取数据库所有表名
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/admin/logs:
|
||||
get:
|
||||
description: 分页获取系统历史日志,cursor=0 获取最新日志,cursor>0 获取更早日志
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import {DatabaseManager} from "@/components/common/admin/database-manage"
|
||||
|
||||
/* 数据库管理页面 */
|
||||
export default function DatabasePage() {
|
||||
return <DatabaseManager />
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useCallback, useEffect, useRef, useState} from "react"
|
||||
import {useTheme} from "next-themes"
|
||||
import CodeMirror from "@uiw/react-codemirror"
|
||||
import {sql} from "@codemirror/lang-sql"
|
||||
import {toast} from "sonner"
|
||||
import {
|
||||
Activity,
|
||||
ArrowLeft,
|
||||
Cpu,
|
||||
Database,
|
||||
Download,
|
||||
FileText,
|
||||
HardDrive,
|
||||
Layers,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Terminal,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Skeleton} from "@/components/ui/skeleton"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import type {DBOverview, ExecuteSQLResponse, TableDataResponse} from "@/lib/services"
|
||||
import services, {AdminService} from "@/lib/services"
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
if (num === undefined || num === null) return "0"
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ",")
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化表格单元格内容
|
||||
*/
|
||||
const formatCellValue = (val: unknown): string => {
|
||||
if (val === null || val === undefined) return "-"
|
||||
if (typeof val === "boolean") return val ? "true" : "false"
|
||||
if (typeof val === "object") {
|
||||
try {
|
||||
return JSON.stringify(val)
|
||||
} catch {
|
||||
return "[Object]"
|
||||
}
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
|
||||
export function DatabaseManager() {
|
||||
// 基础状态
|
||||
const [overview, setOverview] = useState<DBOverview | null>(null)
|
||||
const [tables, setTables] = useState<string[]>([])
|
||||
const [selectedTable, setSelectedTable] = useState<string>("")
|
||||
const [tableData, setTableData] = useState<TableDataResponse | null>(null)
|
||||
|
||||
// 分页状态
|
||||
const [page, setPage] = useState<number>(1)
|
||||
const pageSize = 10
|
||||
|
||||
// 加载状态
|
||||
const [loadingOverview, setLoadingOverview] = useState<boolean>(true)
|
||||
const [loadingTables, setLoadingTables] = useState<boolean>(true)
|
||||
const [loadingData, setLoadingData] = useState<boolean>(false)
|
||||
const [exporting, setExporting] = useState<boolean>(false)
|
||||
|
||||
// SQL 控制台状态
|
||||
const [showConsole, setShowConsole] = useState<boolean>(false)
|
||||
const [sqlQuery, setSqlQuery] = useState<string>("")
|
||||
const [executingSQL, setExecutingSQL] = useState<boolean>(false)
|
||||
const [sqlResult, setSqlResult] = useState<ExecuteSQLResponse | null>(null)
|
||||
const [sqlError, setSqlError] = useState<string | null>(null)
|
||||
|
||||
// 拖拽与主题状态
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [editorHeight, setEditorHeight] = useState<number>(240)
|
||||
const { resolvedTheme } = useTheme()
|
||||
const cmTheme = resolvedTheme === "dark" ? "dark" : "light"
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const startY = e.clientY
|
||||
const startHeight = editorHeight
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!containerRef.current) return
|
||||
const deltaY = moveEvent.clientY - startY
|
||||
const containerHeight = containerRef.current.getBoundingClientRect().height
|
||||
const newHeight = startHeight + deltaY
|
||||
|
||||
// Limit editor height between 80px and containerHeight - 80px
|
||||
if (newHeight > 80 && newHeight < containerHeight - 80) {
|
||||
setEditorHeight(newHeight)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
// 1. 获取运行概览
|
||||
const fetchOverview = useCallback(async (isSilent = false) => {
|
||||
if (!isSilent) setLoadingOverview(true)
|
||||
try {
|
||||
const data = await services.dbManage.getOverview()
|
||||
setOverview(data)
|
||||
} catch (err) {
|
||||
toast.error("获取数据库概览失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setLoadingOverview(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 2. 获取表列表
|
||||
const fetchTables = useCallback(async () => {
|
||||
setLoadingTables(true)
|
||||
try {
|
||||
const data = await services.dbManage.listTables()
|
||||
setTables(data)
|
||||
// 默认选择第一张表
|
||||
if (data.length > 0 && !selectedTable) {
|
||||
setSelectedTable(data[0])
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("获取数据库数据表列表失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setLoadingTables(false)
|
||||
}
|
||||
}, [selectedTable])
|
||||
|
||||
// 3. 获取具体表的数据
|
||||
const fetchTableData = useCallback(async (tableName: string, targetPage: number, size: number) => {
|
||||
if (!tableName) return
|
||||
setLoadingData(true)
|
||||
try {
|
||||
const data = await services.dbManage.getTableData({
|
||||
table: tableName,
|
||||
page: targetPage,
|
||||
pageSize: size,
|
||||
})
|
||||
setTableData(data)
|
||||
} catch (err) {
|
||||
toast.error(`获取数据表 ${tableName} 数据失败`, {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setLoadingData(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 4. 导出数据库
|
||||
const handleExport = async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const { blob, filename } = await AdminService.exportDatabase()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success("数据库导出成功", { description: `已下载归档文件: ${filename}` })
|
||||
} catch (err) {
|
||||
toast.error("数据库导出失败", {
|
||||
description: err instanceof Error ? err.message : "导出异常",
|
||||
})
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 执行 SQL 查询
|
||||
const handleExecuteSQL = async () => {
|
||||
if (!sqlQuery.trim()) return
|
||||
setExecutingSQL(true)
|
||||
setSqlResult(null)
|
||||
setSqlError(null)
|
||||
try {
|
||||
const result = await services.dbManage.executeSQL(sqlQuery)
|
||||
setSqlResult(result)
|
||||
toast.success("SQL 执行成功")
|
||||
} catch (err) {
|
||||
setSqlError(err instanceof Error ? err.message : "未知执行错误")
|
||||
toast.error("SQL 执行失败")
|
||||
} finally {
|
||||
setExecutingSQL(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 页面初始化加载
|
||||
useEffect(() => {
|
||||
fetchOverview()
|
||||
fetchTables()
|
||||
}, [fetchOverview, fetchTables])
|
||||
|
||||
// 选择数据表或页码变化时,拉取对应的数据
|
||||
useEffect(() => {
|
||||
if (selectedTable) {
|
||||
fetchTableData(selectedTable, page, pageSize)
|
||||
}
|
||||
}, [selectedTable, page, pageSize, fetchTableData])
|
||||
|
||||
// 处理页码改变
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage)
|
||||
}
|
||||
|
||||
// 处理表切换
|
||||
const handleTableChange = (value: string) => {
|
||||
setSelectedTable(value)
|
||||
setPage(1) // 切换表重置为第一页
|
||||
}
|
||||
|
||||
// Preset SQL 选择器
|
||||
const handlePresetSQLChange = (query: string) => {
|
||||
setSqlQuery(query)
|
||||
}
|
||||
|
||||
// 渲染概览骨架
|
||||
const renderOverviewSkeleton = () => (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} className="border-border/40 bg-card/50">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// SQL 查询控台渲染
|
||||
if (showConsole) {
|
||||
return (
|
||||
<div className="space-y-3 p-1 w-full">
|
||||
{/* 顶部控制与标题 */}
|
||||
<div className="flex items-center justify-between border-b border-border/30 pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => {
|
||||
setShowConsole(false)
|
||||
setSqlResult(null)
|
||||
setSqlError(null)
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight">SQL 查询终端</h2>
|
||||
<p className="text-xs text-muted-foreground">执行自定义 SQL 查询,支持数据读取与写入操作</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{overview?.type === "postgres" ? "PostgreSQL Connected" : "SQLite Connected"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 类似于 VS Code 的单个整体编辑器+结果区域,固定高度,有分水岭拖拽调整大小 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full border border-border/40 bg-card/60 backdrop-blur-md rounded-lg overflow-hidden flex flex-col shadow-sm h-[calc(100vh-140px)] min-h-[500px]"
|
||||
>
|
||||
{/* 顶部编辑工具栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/40 shrink-0 gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="size-4 text-primary" />
|
||||
<span className="text-xs font-semibold">SQL 编辑器</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* 快速模板 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-muted-foreground">快速模板:</span>
|
||||
<Select onValueChange={handlePresetSQLChange}>
|
||||
<SelectTrigger className="h-7 w-[180px] text-[11px] bg-background">
|
||||
<SelectValue placeholder="选择预设 SQL" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SELECT * FROM users LIMIT 10;">查询用户 (SELECT users)</SelectItem>
|
||||
<SelectItem value="SELECT * FROM uploads LIMIT 10;">查询文件 (SELECT uploads)</SelectItem>
|
||||
<SelectItem value="SELECT * FROM task_executions ORDER BY created_at DESC LIMIT 10;">查询任务流水 (SELECT task_executions)</SelectItem>
|
||||
<SelectItem value="SELECT sqlite_version();">SQLite 版本 (SQLite only)</SelectItem>
|
||||
<SelectItem value="SELECT version();">PostgreSQL 版本 (Postgres only)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 控制按钮 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSqlQuery("")}
|
||||
title="清空编辑器"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-3 gap-1 text-[11px]"
|
||||
onClick={handleExecuteSQL}
|
||||
disabled={executingSQL || !sqlQuery.trim()}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
{executingSQL ? "运行中..." : "运行"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上半区:编辑器输入框 */}
|
||||
<div
|
||||
style={{ height: editorHeight }}
|
||||
className="w-full overflow-hidden relative min-h-[100px] bg-background"
|
||||
>
|
||||
<CodeMirror
|
||||
value={sqlQuery}
|
||||
height="100%"
|
||||
extensions={[sql()]}
|
||||
theme={cmTheme}
|
||||
onChange={(value) => setSqlQuery(value)}
|
||||
className="h-full text-xs font-mono"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
dropCursor: true,
|
||||
allowMultipleSelections: false,
|
||||
indentOnInput: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 拖动分割线 */}
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className="h-1.5 bg-border/60 hover:bg-primary/50 cursor-row-resize transition-colors flex items-center justify-center shrink-0 select-none z-10"
|
||||
title="拖动调整大小"
|
||||
>
|
||||
<div className="w-8 h-1 rounded bg-muted-foreground/30" />
|
||||
</div>
|
||||
|
||||
{/* 下半区:查看结果 (可滑动查看) */}
|
||||
<div className="flex-1 min-h-[100px] flex flex-col overflow-hidden bg-muted/10">
|
||||
{/* 结果栏工具提示 */}
|
||||
<div className="flex items-center justify-between px-4 py-1.5 border-b bg-muted/20 shrink-0 text-[11px] text-muted-foreground font-mono">
|
||||
<span className="font-semibold">执行输出</span>
|
||||
{sqlResult && (
|
||||
<span>
|
||||
类型: {sqlResult.type.toUpperCase()} | 耗时: {sqlResult.execution_time_ms} ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 滚动结果内容 */}
|
||||
<div className="flex-1 overflow-auto p-4 min-h-0">
|
||||
{executingSQL && (
|
||||
<div className="flex flex-col items-center justify-center h-full py-10 space-y-2">
|
||||
<RefreshCw className="size-5 text-primary animate-spin" />
|
||||
<span className="text-xs text-muted-foreground">正在在数据库执行查询,请稍候...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sqlError && (
|
||||
<div className="bg-destructive/10 border border-destructive/20 text-destructive font-mono text-xs p-4 rounded-lg overflow-auto h-full max-h-[300px]">
|
||||
<p className="font-semibold mb-1">SQL 执行报错 (Error):</p>
|
||||
<pre className="whitespace-pre-wrap">{sqlError}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sqlResult && (
|
||||
<div className="h-full flex flex-col">
|
||||
{sqlResult.type === "select" && sqlResult.columns && sqlResult.columns.length > 0 && (
|
||||
<div className="border rounded-md overflow-auto max-w-full max-h-full bg-background relative flex-1 min-h-0">
|
||||
<Table className="text-xs">
|
||||
<TableHeader className="bg-muted/40 font-semibold sticky top-0 z-10">
|
||||
<TableRow>
|
||||
{sqlResult.columns.map((col) => (
|
||||
<TableHead key={col} className="font-semibold text-foreground py-2">{col}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sqlResult.results && sqlResult.results.length > 0 ? (
|
||||
sqlResult.results.map((row, rIndex) => (
|
||||
<TableRow key={rIndex} className="hover:bg-muted/10">
|
||||
{sqlResult.columns!.map((col) => (
|
||||
<TableCell key={col} className="font-mono py-1.5">
|
||||
<span className="truncate max-w-[240px] block" title={formatCellValue(row[col])}>
|
||||
{formatCellValue(row[col])}
|
||||
</span>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={sqlResult.columns.length} className="text-center py-10 text-muted-foreground">
|
||||
查询结果为空
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sqlResult.type === "exec" && (
|
||||
<div className="bg-primary/5 border border-primary/10 text-primary-foreground font-mono text-xs p-6 rounded-lg text-center my-auto">
|
||||
<p className="text-foreground text-sm font-semibold">SQL 执行成功</p>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
受影响行数: {sqlResult.affected_rows} 行,耗时 {sqlResult.execution_time_ms} 毫秒。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!executingSQL && !sqlResult && !sqlError && (
|
||||
<div className="flex flex-col items-center justify-center h-full py-10 text-muted-foreground opacity-60">
|
||||
<Terminal className="size-8 mb-2" />
|
||||
<span className="text-xs">编辑器就绪,请在上方编写 SQL 并运行查看结果</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 正常数据概览及表分页浏览器渲染
|
||||
return (
|
||||
<div className="space-y-6 p-1 w-full">
|
||||
{/* 顶部控制与标题 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between border-b border-border/30 pb-4 gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight">数据管理</h2>
|
||||
<p className="text-xs text-muted-foreground">查看物理数据库运行运维数据,进行表级数据浏览与 SQL 交互查询</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 gap-1.5 text-xs self-start sm:self-auto"
|
||||
onClick={() => fetchOverview()}
|
||||
disabled={loadingOverview}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${loadingOverview ? "animate-spin" : ""}`} />
|
||||
刷新数据
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 1. 顶部概览卡片 */}
|
||||
{loadingOverview ? (
|
||||
renderOverviewSkeleton()
|
||||
) : (
|
||||
overview && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{/* 卡片1: 数据库类型 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">数据库类型</CardDescription>
|
||||
<Server className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold uppercase">{overview.type}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片2: 数据库版本 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300 col-span-1 md:col-span-2 lg:col-span-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">版本信息</CardDescription>
|
||||
<Cpu className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xs font-semibold truncate" title={overview.version}>
|
||||
{overview.version.split(" ").slice(0, 2).join(" ")}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片3: 数据库名称 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">名称/路径</CardDescription>
|
||||
<Database className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xs font-semibold truncate" title={overview.name}>
|
||||
{overview.name.substring(overview.name.lastIndexOf("/") + 1)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片4: 数据库大小 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">数据库大小</CardDescription>
|
||||
<HardDrive className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold">{overview.size}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片5: 表数量 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">物理数据表</CardDescription>
|
||||
<Layers className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold">{formatNumber(overview.table_count)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 卡片6: 连接数 */}
|
||||
<Card className="shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardDescription className="text-[10px] font-medium">活跃连接数</CardDescription>
|
||||
<Activity className="size-3.5 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm font-bold">{formatNumber(overview.connections)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 2. 中部数据表浏览器 */}
|
||||
<Card className="border-border/40 bg-card/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader className="pb-3 border-b border-dashed flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="text-sm font-semibold">数据表浏览器</CardTitle>
|
||||
<CardDescription className="text-[11px]">浏览数据库中的物理数据表详情及内容</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{loadingTables ? (
|
||||
<Skeleton className="h-8 w-48" />
|
||||
) : (
|
||||
<Select value={selectedTable} onValueChange={handleTableChange}>
|
||||
<SelectTrigger className="h-8 w-[200px] text-xs bg-background border-border/40">
|
||||
<SelectValue placeholder="选择数据表" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-xs font-mono">{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
{loadingData && !tableData ? (
|
||||
<div className="space-y-3 py-6">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : tableData && tableData.columns.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{/* 数据表格区域 */}
|
||||
<div className="border rounded-md overflow-x-auto max-w-full bg-background/50 relative">
|
||||
{loadingData && (
|
||||
<div className="absolute inset-0 bg-background/40 backdrop-blur-[1px] flex items-center justify-center z-10">
|
||||
<RefreshCw className="size-5 text-primary animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<Table className="text-xs">
|
||||
<TableHeader className="bg-muted/40 font-semibold sticky top-0">
|
||||
<TableRow>
|
||||
{tableData.columns.map((col) => (
|
||||
<TableHead key={col} className="font-semibold text-foreground py-2.5">{col}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tableData.results && tableData.results.length > 0 ? (
|
||||
tableData.results.map((row, rIndex) => (
|
||||
<TableRow key={rIndex} className="hover:bg-muted/10">
|
||||
{tableData.columns.map((col) => (
|
||||
<TableCell key={col} className="font-mono py-2">
|
||||
<span className="truncate max-w-[200px] block" title={formatCellValue(row[col])}>
|
||||
{formatCellValue(row[col])}
|
||||
</span>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={tableData.columns.length} className="text-center py-10 text-muted-foreground">
|
||||
表中无记录数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分页控制 */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground flex-wrap gap-2 pt-2 border-t border-dashed">
|
||||
<div>
|
||||
总行数: <span className="font-mono text-foreground font-semibold">{formatNumber(tableData.total)}</span> 条记录
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[11px]"
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={page <= 1 || loadingData}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-xs px-2 font-mono">
|
||||
第 {page} / {Math.max(1, Math.ceil(tableData.total / pageSize))} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[11px]"
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={page >= Math.ceil(tableData.total / pageSize) || loadingData}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
||||
<Layers className="size-8 opacity-45 mb-2" />
|
||||
<span className="text-xs">未选择数据表或表结构无法加载</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3. 底部功能区 */}
|
||||
<Card className="border-border/40 bg-card/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader className="pb-3 border-b border-dashed">
|
||||
<CardTitle className="text-sm font-semibold">功能区</CardTitle>
|
||||
<CardDescription className="text-[11px]">数据库导出及自定义高级 SQL 执行终端</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 功能一: 数据库导出 */}
|
||||
<div className="flex items-center justify-between p-4 border border-dashed rounded-lg hover:bg-muted/20 transition-colors duration-150">
|
||||
<div className="space-y-1 pr-4">
|
||||
<p className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Download className="size-4 text-primary" />
|
||||
数据库备份导出
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
直接导出并下载物理数据库镜像文件(SQLite 导出 .db,PostgreSQL 导出为打包的 .sql 文本文件)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs gap-1"
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${exporting ? "animate-spin" : ""}`} />
|
||||
{exporting ? "正在准备..." : "开始导出"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 功能二: SQL 查询终端 */}
|
||||
<div className="flex items-center justify-between p-4 border border-dashed rounded-lg hover:bg-muted/20 transition-colors duration-150">
|
||||
<div className="space-y-1 pr-4">
|
||||
<p className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Terminal className="size-4 text-primary" />
|
||||
SQL 查询控台
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
打开在线 SQL 控制终端,执行原生的 SQL 进行数据筛选、更新、调试或性能优化
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-xs gap-1 bg-primary text-primary-foreground hover:bg-primary/95"
|
||||
onClick={() => {
|
||||
setShowConsole(true)
|
||||
setSqlQuery("SELECT * FROM users LIMIT 10;")
|
||||
}}
|
||||
>
|
||||
<FileText className="size-3.5" />
|
||||
进入控台
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import {useState} from "react"
|
||||
import {Database, Download, Info, Loader2, Server} from "lucide-react"
|
||||
import {Info, Server} from "lucide-react"
|
||||
import packageJson from "../../../package.json"
|
||||
import {AdminService, apiConfig} from "@/lib/services"
|
||||
import {apiConfig} from "@/lib/services"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {useQuery} from "@tanstack/react-query"
|
||||
import {toast} from "sonner"
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
@@ -25,37 +20,6 @@ interface InfoTabProps {
|
||||
}
|
||||
|
||||
export function InfoTab({ systemConfigsLength, authSourcesLength }: InfoTabProps) {
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
const dbInfoQuery = useQuery({
|
||||
queryKey: ["admin", "db-info"],
|
||||
queryFn: () => AdminService.getDatabaseInfo(),
|
||||
})
|
||||
|
||||
const dbInfo = dbInfoQuery.data
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const { blob, filename } = await AdminService.exportDatabase()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success("数据库导出成功", { description: `文件已下载:${filename}` })
|
||||
} catch (err) {
|
||||
toast.error("数据库导出失败", {
|
||||
description: err instanceof Error ? err.message : "请求时发生未知错误",
|
||||
})
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
@@ -99,69 +63,6 @@ export function InfoTab({ systemConfigsLength, authSourcesLength }: InfoTabProps
|
||||
<InfoRow label="认证源数量" value={`${authSourcesLength} 个`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 数据库信息卡片 */}
|
||||
<Card className="border border-dashed shadow-sm lg:col-span-2">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-muted text-muted-foreground">
|
||||
<Database className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">数据库信息</CardTitle>
|
||||
<CardDescription className="text-xs">当前使用的数据库类型及连接信息</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
id="db-export-btn"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs gap-1.5"
|
||||
onClick={handleExport}
|
||||
disabled={exporting || dbInfoQuery.isLoading}
|
||||
>
|
||||
{exporting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-3" />
|
||||
)}
|
||||
{exporting ? "导出中..." : "数据导出"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
{dbInfoQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
加载数据库信息中...
|
||||
</div>
|
||||
) : dbInfoQuery.isError ? (
|
||||
<p className="text-xs text-destructive py-2">获取数据库信息失败</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-4 border-b border-dashed py-2">
|
||||
<span className="text-xs text-muted-foreground">数据库类型</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
dbInfo?.type === "postgres"
|
||||
? "bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400 border-none text-[11px]"
|
||||
: "bg-amber-500/10 text-amber-600 dark:bg-amber-500/20 dark:text-amber-400 border-none text-[11px]"
|
||||
}
|
||||
>
|
||||
{dbInfo?.type === "postgres" ? "PostgreSQL" : "SQLite"}
|
||||
</Badge>
|
||||
</div>
|
||||
<InfoRow
|
||||
label={dbInfo?.type === "postgres" ? "数据库名称" : "数据库文件"}
|
||||
value={dbInfo?.name}
|
||||
/>
|
||||
<InfoRow label="版本信息" value={dbInfo?.version} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {useMemo} from "react"
|
||||
import {useMutation, useQueryClient} from "@tanstack/react-query"
|
||||
import {
|
||||
CreditCard,
|
||||
Database,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Home,
|
||||
@@ -37,6 +38,7 @@ const MENU_GROUPS = [
|
||||
{ path: "/admin/files", label: "文件管理", description: "查看用户上传文件并清理多余文件", icon: FolderOpen },
|
||||
{ path: "/admin/tasks", label: "任务管理", description: "查看和调度系统异步及定时任务", icon: Layers },
|
||||
{ path: "/admin/system", label: "系统配置", description: "管理和维护系统基础键值对配置", icon: ShieldCheck },
|
||||
{ path: "/admin/database", label: "数据管理", description: "监控物理数据库状态、分页浏览表数据及交互式 SQL 查询", icon: Database },
|
||||
{ path: "/admin/logs", label: "系统日志", description: "查看异步任务执行日志和系统运行情况", icon: Terminal },
|
||||
{ path: "/admin/settings", label: "系统设置", description: "配置安全验证、邮箱服务及目录显示", icon: Settings, readOnly: true },
|
||||
]
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
CreditCard,
|
||||
Database,
|
||||
FileQuestionMark,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
@@ -73,9 +74,9 @@ const data = {
|
||||
{ title: "文件管理", url: "/admin/files", icon: FolderOpen },
|
||||
{ title: "任务管理", url: "/admin/tasks", icon: Layers },
|
||||
{ title: "系统配置", url: "/admin/system", icon: ShieldCheck },
|
||||
{ title: "数据管理", url: "/admin/database", icon: Database },
|
||||
{ title: "系统日志", url: "/admin/logs", icon: Terminal },
|
||||
{ title: "系统设置", url: "/admin/settings", icon: Settings },
|
||||
|
||||
],
|
||||
document: [
|
||||
{ title: "组件库", url: "/components", icon: Palette },
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {BaseService} from '@/lib/services';
|
||||
import type {DBOverview, ExecuteSQLResponse, TableDataResponse} from './types';
|
||||
|
||||
/**
|
||||
* 数据库管理服务类
|
||||
*/
|
||||
export class DbManageService extends BaseService {
|
||||
protected static readonly basePath = '/api/v1/admin/db-manage';
|
||||
|
||||
/**
|
||||
* 获取数据库运行概览
|
||||
*/
|
||||
static async getOverview(): Promise<DBOverview> {
|
||||
return this.get<DBOverview>('/overview');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库所有物理表名
|
||||
*/
|
||||
static async listTables(): Promise<string[]> {
|
||||
return this.get<string[]>('/tables');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某张数据表的数据(分页)
|
||||
*/
|
||||
static async getTableData(params: {
|
||||
table: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}): Promise<TableDataResponse> {
|
||||
return this.get<TableDataResponse>('/table-data', params as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在数据库中执行自定义 SQL 语句
|
||||
*/
|
||||
static async executeSQL(sql: string): Promise<ExecuteSQLResponse> {
|
||||
return this.post<ExecuteSQLResponse>('/query', { sql });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DbManageService } from './db-manage.service';
|
||||
export type { DBOverview, TableDataResponse, ExecuteSQLResponse } from './types';
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 数据库概览信息
|
||||
*/
|
||||
export interface DBOverview {
|
||||
type: string;
|
||||
version: string;
|
||||
name: string;
|
||||
size: string;
|
||||
table_count: number;
|
||||
connections: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态数据表分页数据响应
|
||||
*/
|
||||
export interface TableDataResponse {
|
||||
columns: string[];
|
||||
total: number;
|
||||
results: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行自定义 SQL 的响应
|
||||
*/
|
||||
export interface ExecuteSQLResponse {
|
||||
type: "select" | "exec";
|
||||
columns?: string[];
|
||||
results?: Record<string, unknown>[];
|
||||
affected_rows: number;
|
||||
execution_time_ms: number;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {AdminService} from './admin';
|
||||
import {UserService} from './user';
|
||||
import {ConfigService} from './config';
|
||||
import {UploadService} from './upload';
|
||||
import {DbManageService} from './db-manage';
|
||||
|
||||
/**
|
||||
* 服务对象
|
||||
@@ -44,6 +45,8 @@ const services = {
|
||||
config: ConfigService,
|
||||
/** 上传服务 */
|
||||
upload: UploadService,
|
||||
/** 数据库管理服务 */
|
||||
dbManage: DbManageService,
|
||||
} as const;
|
||||
|
||||
export default services;
|
||||
@@ -116,3 +119,7 @@ export type { AccessToken, CreateTokenResponse } from './user';
|
||||
// 上传服务
|
||||
export { UploadService } from './upload';
|
||||
export type { UploadImageResponse } from './upload';
|
||||
|
||||
// 数据库管理服务
|
||||
export { DbManageService } from './db-manage';
|
||||
export type { DBOverview, TableDataResponse, ExecuteSQLResponse } from './db-manage';
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -43,6 +44,7 @@
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.16",
|
||||
"@uiw/react-codemirror": "^4.25.10",
|
||||
"axios": "^1.15.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
Generated
+196
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@codemirror/lang-sql':
|
||||
specifier: ^6.10.0
|
||||
version: 6.10.0
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -98,6 +101,9 @@ importers:
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.13.16
|
||||
version: 3.13.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@uiw/react-codemirror':
|
||||
specifier: ^4.25.10
|
||||
version: 4.25.10(@babel/runtime@7.28.4)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.0)(codemirror@6.0.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
axios:
|
||||
specifier: ^1.15.0
|
||||
version: 1.15.0
|
||||
@@ -248,6 +254,33 @@ packages:
|
||||
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@codemirror/autocomplete@6.20.3':
|
||||
resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
|
||||
|
||||
'@codemirror/commands@6.10.3':
|
||||
resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==}
|
||||
|
||||
'@codemirror/lang-sql@6.10.0':
|
||||
resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==}
|
||||
|
||||
'@codemirror/language@6.12.3':
|
||||
resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==}
|
||||
|
||||
'@codemirror/lint@6.9.6':
|
||||
resolution: {integrity: sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==}
|
||||
|
||||
'@codemirror/search@6.7.0':
|
||||
resolution: {integrity: sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==}
|
||||
|
||||
'@codemirror/state@6.6.0':
|
||||
resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==}
|
||||
|
||||
'@codemirror/theme-one-dark@6.1.3':
|
||||
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
|
||||
|
||||
'@codemirror/view@6.43.0':
|
||||
resolution: {integrity: sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==}
|
||||
|
||||
'@date-fns/tz@1.4.1':
|
||||
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
|
||||
|
||||
@@ -521,6 +554,18 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@lezer/common@1.5.2':
|
||||
resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==}
|
||||
|
||||
'@lezer/highlight@1.2.3':
|
||||
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
|
||||
|
||||
'@lezer/lr@1.4.10':
|
||||
resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==}
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2':
|
||||
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
@@ -1671,6 +1716,28 @@ packages:
|
||||
resolution: {integrity: sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@uiw/codemirror-extensions-basic-setup@4.25.10':
|
||||
resolution: {integrity: sha512-P3vytLlpE62KYSWrMUnwDCv2lvaQDuDZzyj03mHntuHo5bSl34fRZpjTY3kQTPGuXHxkGSYpoPFFj+hMTqaaMQ==}
|
||||
peerDependencies:
|
||||
'@codemirror/autocomplete': '>=6.0.0'
|
||||
'@codemirror/commands': '>=6.0.0'
|
||||
'@codemirror/language': '>=6.0.0'
|
||||
'@codemirror/lint': '>=6.0.0'
|
||||
'@codemirror/search': '>=6.0.0'
|
||||
'@codemirror/state': '>=6.0.0'
|
||||
'@codemirror/view': '>=6.0.0'
|
||||
|
||||
'@uiw/react-codemirror@4.25.10':
|
||||
resolution: {integrity: sha512-DzgSMwM5qzB7v1FIb4gEeriYt67iiay756/HIOM9mAbeOVK0MO7rqefHf0O5c0269pJKMW7AH9FjclExD23V9w==}
|
||||
peerDependencies:
|
||||
'@babel/runtime': '>=7.11.0'
|
||||
'@codemirror/state': '>=6.0.0'
|
||||
'@codemirror/theme-one-dark': '>=6.0.0'
|
||||
'@codemirror/view': '>=6.0.0'
|
||||
codemirror: '>=6.0.0'
|
||||
react: '>=17.0.0'
|
||||
react-dom: '>=17.0.0'
|
||||
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
|
||||
@@ -1948,6 +2015,9 @@ packages:
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
codemirror@6.0.2:
|
||||
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -1968,6 +2038,9 @@ packages:
|
||||
core-js@3.47.0:
|
||||
resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==}
|
||||
|
||||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -3495,6 +3568,9 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
style-mod@4.1.3:
|
||||
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
|
||||
|
||||
style-to-js@1.1.21:
|
||||
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
|
||||
|
||||
@@ -3676,6 +3752,9 @@ packages:
|
||||
victory-vendor@36.9.2:
|
||||
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
|
||||
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3726,6 +3805,68 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@codemirror/autocomplete@6.20.3':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.0
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@codemirror/commands@6.10.3':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.0
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@codemirror/lang-sql@6.10.0':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@codemirror/language@6.12.3':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.0
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
style-mod: 4.1.3
|
||||
|
||||
'@codemirror/lint@6.9.6':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.0
|
||||
crelt: 1.0.6
|
||||
|
||||
'@codemirror/search@6.7.0':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.0
|
||||
crelt: 1.0.6
|
||||
|
||||
'@codemirror/state@6.6.0':
|
||||
dependencies:
|
||||
'@marijn/find-cluster-break': 1.0.2
|
||||
|
||||
'@codemirror/theme-one-dark@6.1.3':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.0
|
||||
'@lezer/highlight': 1.2.3
|
||||
|
||||
'@codemirror/view@6.43.0':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.6.0
|
||||
crelt: 1.0.6
|
||||
style-mod: 4.1.3
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
'@date-fns/tz@1.4.1': {}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.2.3)':
|
||||
@@ -3965,6 +4106,18 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@lezer/common@1.5.2': {}
|
||||
|
||||
'@lezer/highlight@1.2.3':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@lezer/lr@1.4.10':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2': {}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.7.0
|
||||
@@ -5121,6 +5274,33 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.46.3
|
||||
eslint-visitor-keys: 4.2.1
|
||||
|
||||
'@uiw/codemirror-extensions-basic-setup@4.25.10(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/commands': 6.10.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/lint': 6.9.6
|
||||
'@codemirror/search': 6.7.0
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.0
|
||||
|
||||
'@uiw/react-codemirror@4.25.10(@babel/runtime@7.28.4)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.0)(codemirror@6.0.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@codemirror/commands': 6.10.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/theme-one-dark': 6.1.3
|
||||
'@codemirror/view': 6.43.0
|
||||
'@uiw/codemirror-extensions-basic-setup': 4.25.10(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.3)(@codemirror/language@6.12.3)(@codemirror/lint@6.9.6)(@codemirror/search@6.7.0)(@codemirror/state@6.6.0)(@codemirror/view@6.43.0)
|
||||
codemirror: 6.0.2
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
transitivePeerDependencies:
|
||||
- '@codemirror/autocomplete'
|
||||
- '@codemirror/language'
|
||||
- '@codemirror/lint'
|
||||
- '@codemirror/search'
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
|
||||
@@ -5390,6 +5570,16 @@ snapshots:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
codemirror@6.0.2:
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/commands': 6.10.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/lint': 6.9.6
|
||||
'@codemirror/search': 6.7.0
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.0
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@@ -5407,6 +5597,8 @@ snapshots:
|
||||
core-js@3.47.0:
|
||||
optional: true
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -7461,6 +7653,8 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
style-mod@4.1.3: {}
|
||||
|
||||
style-to-js@1.1.21:
|
||||
dependencies:
|
||||
style-to-object: 1.0.14
|
||||
@@ -7700,6 +7894,8 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
dependencies:
|
||||
is-bigint: 1.1.0
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
// Copyright 2025 linux.do
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package db_manage provides router handlers for managing database tables,
|
||||
// overview information, and executing custom SQL queries.
|
||||
package db_manage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/config"
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
binaryKB = 0
|
||||
binaryMB = 1
|
||||
binaryGB = 2
|
||||
valueThreshold = 10
|
||||
maxStringLength = 200
|
||||
)
|
||||
|
||||
// DBOverviewResponse 数据库运行概览响应结构体
|
||||
type DBOverviewResponse struct {
|
||||
Type string `json:"type"`
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Size string `json:"size"`
|
||||
TableCount int64 `json:"table_count"`
|
||||
Connections int64 `json:"connections"`
|
||||
}
|
||||
|
||||
// GetTableDataRequest 分页拉取表数据请求结构体
|
||||
type GetTableDataRequest struct {
|
||||
Table string `form:"table" binding:"required"`
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"pageSize,default=10"`
|
||||
}
|
||||
|
||||
// TableDataResponse 动态数据表响应结构体
|
||||
type TableDataResponse struct {
|
||||
Columns []string `json:"columns"`
|
||||
Total int64 `json:"total"`
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
}
|
||||
|
||||
// ExecuteSQLRequest 执行自定义 SQL 请求结构体
|
||||
type ExecuteSQLRequest struct {
|
||||
SQL string `json:"sql" binding:"required"`
|
||||
}
|
||||
|
||||
// ExecuteSQLResponse 执行自定义 SQL 响应结构体
|
||||
type ExecuteSQLResponse struct {
|
||||
Type string `json:"type"` // "select" 或 "exec"
|
||||
Columns []string `json:"columns,omitempty"`
|
||||
Results []map[string]interface{} `json:"results,omitempty"`
|
||||
AffectedRows int64 `json:"affected_rows"`
|
||||
ExecutionTimeMs int64 `json:"execution_time_ms"`
|
||||
}
|
||||
|
||||
// formatBytes 格式化字节大小为可读字符串
|
||||
func formatBytes(bytes uint64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
value := float64(bytes) / float64(div)
|
||||
var suffix string
|
||||
switch exp {
|
||||
case binaryKB:
|
||||
suffix = "KiB"
|
||||
case binaryMB:
|
||||
suffix = "MiB"
|
||||
case binaryGB:
|
||||
suffix = "GiB"
|
||||
default:
|
||||
suffix = "TiB"
|
||||
}
|
||||
|
||||
if value == math.Trunc(value) {
|
||||
if value >= valueThreshold {
|
||||
return fmt.Sprintf("%.0f %s", value, suffix)
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", value, suffix)
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", value, suffix)
|
||||
}
|
||||
|
||||
// getSQLiteOverview 获取 SQLite 数据库概览信息
|
||||
func getSQLiteOverview(gormDB *gorm.DB) (DBOverviewResponse, error) {
|
||||
name := config.Config.Database.SQLitePath
|
||||
if name == "" {
|
||||
name = "./data/wavelet.db"
|
||||
}
|
||||
|
||||
var version string
|
||||
var ver string
|
||||
if err := gormDB.Raw("SELECT sqlite_version()").Scan(&ver).Error; err == nil {
|
||||
version = "SQLite " + ver
|
||||
} else {
|
||||
version = "SQLite"
|
||||
}
|
||||
|
||||
var sizeStr string
|
||||
if fi, err := os.Stat(name); err == nil {
|
||||
sizeStr = formatBytes(uint64(fi.Size()))
|
||||
} else {
|
||||
sizeStr = "0 B"
|
||||
}
|
||||
|
||||
var tableCount int64
|
||||
if err := gormDB.Raw("SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Scan(&tableCount).Error; err != nil {
|
||||
tableCount = 0
|
||||
}
|
||||
|
||||
var connCount int64
|
||||
if sqlDB, err := gormDB.DB(); err == nil {
|
||||
connCount = int64(sqlDB.Stats().OpenConnections)
|
||||
} else {
|
||||
connCount = 1
|
||||
}
|
||||
|
||||
return DBOverviewResponse{
|
||||
Type: "sqlite",
|
||||
Version: version,
|
||||
Name: name,
|
||||
Size: sizeStr,
|
||||
TableCount: tableCount,
|
||||
Connections: connCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getPostgresOverview 获取 PostgreSQL 数据库概览信息
|
||||
func getPostgresOverview(gormDB *gorm.DB) (DBOverviewResponse, error) {
|
||||
name := config.Config.Database.Database
|
||||
|
||||
var version string
|
||||
var ver string
|
||||
if err := gormDB.Raw("SELECT version()").Scan(&ver).Error; err == nil {
|
||||
version = ver
|
||||
} else {
|
||||
version = "PostgreSQL"
|
||||
}
|
||||
|
||||
var sizeStr string
|
||||
var sizeBytes sql.NullInt64
|
||||
if err := gormDB.Raw("SELECT pg_database_size(current_database())").Scan(&sizeBytes).Error; err == nil && sizeBytes.Valid {
|
||||
sizeStr = formatBytes(uint64(sizeBytes.Int64))
|
||||
} else {
|
||||
sizeStr = "0 B"
|
||||
}
|
||||
|
||||
var tableCount int64
|
||||
if err := gormDB.Raw("SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema()").Scan(&tableCount).Error; err != nil {
|
||||
tableCount = 0
|
||||
}
|
||||
|
||||
var connCount int64
|
||||
var pgc sql.NullInt64
|
||||
if err := gormDB.Raw("SELECT count(*) FROM pg_stat_activity WHERE datname = current_database()").Scan(&pgc).Error; err == nil && pgc.Valid {
|
||||
connCount = pgc.Int64
|
||||
} else {
|
||||
if sqlDB, err := gormDB.DB(); err == nil {
|
||||
connCount = int64(sqlDB.Stats().OpenConnections)
|
||||
} else {
|
||||
connCount = 1
|
||||
}
|
||||
}
|
||||
|
||||
return DBOverviewResponse{
|
||||
Type: "postgres",
|
||||
Version: version,
|
||||
Name: name,
|
||||
Size: sizeStr,
|
||||
TableCount: tableCount,
|
||||
Connections: connCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDBOverview 获取数据库运行概览
|
||||
// @Summary 获取数据库运行概览
|
||||
// @Description 获取数据库类型、版本、名称、文件大小、表数量及当前连接数,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Success 200 {object} util.ResponseAny{data=db_manage.DBOverviewResponse} "获取成功"
|
||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||
// @Failure 500 {object} util.ResponseAny "内部错误"
|
||||
// @Router /api/v1/admin/db-manage/overview [get]
|
||||
func GetDBOverview(c *gin.Context) {
|
||||
gormDB := db.DB(c.Request.Context())
|
||||
if gormDB == nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err("数据库未初始化"))
|
||||
return
|
||||
}
|
||||
|
||||
var overview DBOverviewResponse
|
||||
var err error
|
||||
|
||||
if !config.Config.Database.Enabled {
|
||||
overview, err = getSQLiteOverview(gormDB)
|
||||
} else {
|
||||
overview, err = getPostgresOverview(gormDB)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(overview))
|
||||
}
|
||||
|
||||
// ListDBTables 获取数据库所有表名
|
||||
// @Summary 获取数据库所有表名
|
||||
// @Description 返回当前数据库的所有用户自定义表名称列表,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Success 200 {object} util.ResponseAny{data=[]string} "获取成功"
|
||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||
// @Failure 500 {object} util.ResponseAny "内部错误"
|
||||
// @Router /api/v1/admin/db-manage/tables [get]
|
||||
func ListDBTables(c *gin.Context) {
|
||||
gormDB := db.DB(c.Request.Context())
|
||||
if gormDB == nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err("数据库未初始化"))
|
||||
return
|
||||
}
|
||||
|
||||
var tables []string
|
||||
var err error
|
||||
|
||||
if !config.Config.Database.Enabled {
|
||||
err = gormDB.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").Scan(&tables).Error
|
||||
} else {
|
||||
err = gormDB.Raw("SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() ORDER BY table_name").Scan(&tables).Error
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(tables))
|
||||
}
|
||||
|
||||
// GetDBTableData 获取数据表 data
|
||||
func GetDBTableData(c *gin.Context) {
|
||||
var req GetTableDataRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
gormDB := db.DB(c.Request.Context())
|
||||
if gormDB == nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err("数据库未初始化"))
|
||||
return
|
||||
}
|
||||
|
||||
// 安全转义表名并拼接
|
||||
quotedTable := `"` + strings.ReplaceAll(req.Table, `"`, `""`) + `"`
|
||||
|
||||
var total int64
|
||||
if err := gormDB.Raw("SELECT count(*) FROM " + quotedTable).Scan(&total).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := req.PageSize
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
rows, err := gormDB.Raw("SELECT * FROM "+quotedTable+" LIMIT ? OFFSET ?", limit, offset).Rows()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = rows.Close()
|
||||
}()
|
||||
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
results, err := scanTableRows(rows, cols)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(TableDataResponse{
|
||||
Columns: cols,
|
||||
Total: total,
|
||||
Results: results,
|
||||
}))
|
||||
}
|
||||
|
||||
// scanTableRows 扫描并提取数据表行数据,做截断处理
|
||||
func scanTableRows(rows *sql.Rows, cols []string) ([]map[string]interface{}, error) {
|
||||
results := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
columns := make([]interface{}, len(cols))
|
||||
columnPointers := make([]interface{}, len(cols))
|
||||
for i := range columns {
|
||||
columnPointers[i] = &columns[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(columnPointers...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rowMap := make(map[string]interface{})
|
||||
for i, colName := range cols {
|
||||
val := columns[i]
|
||||
if b, ok := val.([]byte); ok {
|
||||
strVal := string(b)
|
||||
runes := []rune(strVal)
|
||||
if len(runes) > maxStringLength {
|
||||
strVal = string(runes[:maxStringLength]) + "..."
|
||||
}
|
||||
rowMap[colName] = strVal
|
||||
} else if str, ok := val.(string); ok {
|
||||
runes := []rune(str)
|
||||
if len(runes) > maxStringLength {
|
||||
str = string(runes[:maxStringLength]) + "..."
|
||||
}
|
||||
rowMap[colName] = str
|
||||
} else {
|
||||
rowMap[colName] = val
|
||||
}
|
||||
}
|
||||
results = append(results, rowMap)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// executeSQLQuery 执行并解析查询类 SQL 语句
|
||||
func executeSQLQuery(gormDB *gorm.DB, sqlStr string, startTime time.Time) (ExecuteSQLResponse, error) {
|
||||
rows, err := gormDB.Raw(sqlStr).Rows()
|
||||
if err != nil {
|
||||
return ExecuteSQLResponse{}, err
|
||||
}
|
||||
defer func() {
|
||||
_ = rows.Close()
|
||||
}()
|
||||
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
return ExecuteSQLResponse{}, err
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
columns := make([]interface{}, len(cols))
|
||||
columnPointers := make([]interface{}, len(cols))
|
||||
for i := range columns {
|
||||
columnPointers[i] = &columns[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(columnPointers...); err != nil {
|
||||
return ExecuteSQLResponse{}, err
|
||||
}
|
||||
|
||||
rowMap := make(map[string]interface{})
|
||||
for i, colName := range cols {
|
||||
val := columns[i]
|
||||
if b, ok := val.([]byte); ok {
|
||||
rowMap[colName] = string(b)
|
||||
} else {
|
||||
rowMap[colName] = val
|
||||
}
|
||||
}
|
||||
results = append(results, rowMap)
|
||||
}
|
||||
|
||||
executionTime := time.Since(startTime).Milliseconds()
|
||||
return ExecuteSQLResponse{
|
||||
Type: "select",
|
||||
Columns: cols,
|
||||
Results: results,
|
||||
AffectedRows: int64(len(results)),
|
||||
ExecutionTimeMs: executionTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// executeSQLMutation 执行修改/更新类 SQL 语句
|
||||
func executeSQLMutation(gormDB *gorm.DB, sqlStr string, startTime time.Time) (ExecuteSQLResponse, error) {
|
||||
tx := gormDB.Exec(sqlStr)
|
||||
if tx.Error != nil {
|
||||
return ExecuteSQLResponse{}, tx.Error
|
||||
}
|
||||
|
||||
executionTime := time.Since(startTime).Milliseconds()
|
||||
return ExecuteSQLResponse{
|
||||
Type: "exec",
|
||||
AffectedRows: tx.RowsAffected,
|
||||
ExecutionTimeMs: executionTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExecuteSQL 执行 SQL 查询
|
||||
// @Summary 执行 SQL 查询
|
||||
// @Description 在当前数据库中执行任意自定义 SQL,如果是查询语句将返回格式化后的列与数据集,否则返回受影响行数,需要管理员权限
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param request body db_manage.ExecuteSQLRequest true "SQL 请求参数"
|
||||
// @Success 200 {object} util.ResponseAny{data=db_manage.ExecuteSQLResponse} "执行完毕"
|
||||
// @Failure 400 {object} util.ResponseAny "SQL 语句错误"
|
||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||
// @Failure 500 {object} util.ResponseAny "内部错误"
|
||||
// @Router /api/v1/admin/db-manage/query [post]
|
||||
func ExecuteSQL(c *gin.Context) {
|
||||
var req ExecuteSQLRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
gormDB := db.DB(c.Request.Context())
|
||||
if gormDB == nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err("数据库未初始化"))
|
||||
return
|
||||
}
|
||||
|
||||
trimmedSQL := strings.TrimSpace(req.SQL)
|
||||
if trimmedSQL == "" {
|
||||
c.JSON(http.StatusBadRequest, util.Err("SQL 语句不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// 识别是否是查询语句(SELECT, SHOW, EXPLAIN 等)
|
||||
isQuery := false
|
||||
lowerSQL := strings.ToLower(trimmedSQL)
|
||||
queryKeywords := []string{"select", "show", "explain", "describe", "pragma"}
|
||||
for _, kw := range queryKeywords {
|
||||
if strings.HasPrefix(lowerSQL, kw) {
|
||||
isQuery = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var resp ExecuteSQLResponse
|
||||
var err error
|
||||
|
||||
if isQuery {
|
||||
resp, err = executeSQLQuery(gormDB, trimmedSQL, startTime)
|
||||
} else {
|
||||
resp, err = executeSQLMutation(gormDB, trimmedSQL, startTime)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, util.OK(resp))
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/admin"
|
||||
admin_auth_source "github.com/Rain-kl/Wavelet/internal/apps/admin/auth_source"
|
||||
admin_db_manage "github.com/Rain-kl/Wavelet/internal/apps/admin/db_manage"
|
||||
admin_logs "github.com/Rain-kl/Wavelet/internal/apps/admin/logs"
|
||||
admin_status "github.com/Rain-kl/Wavelet/internal/apps/admin/status"
|
||||
admin_task "github.com/Rain-kl/Wavelet/internal/apps/admin/task"
|
||||
@@ -188,6 +189,12 @@ func Serve() {
|
||||
adminRouter.GET("/db-info", admin_status.GetDatabaseInfo)
|
||||
adminRouter.GET("/db-export", admin_status.ExportDatabase)
|
||||
|
||||
// Database management
|
||||
adminRouter.GET("/db-manage/overview", admin_db_manage.GetDBOverview)
|
||||
adminRouter.GET("/db-manage/tables", admin_db_manage.ListDBTables)
|
||||
adminRouter.GET("/db-manage/table-data", admin_db_manage.GetDBTableData)
|
||||
adminRouter.POST("/db-manage/query", admin_db_manage.ExecuteSQL)
|
||||
|
||||
// System logs
|
||||
adminRouter.GET("/logs", admin_logs.GetLogs)
|
||||
adminRouter.GET("/logs/access", admin_logs.GetAccessLogs)
|
||||
|
||||
Reference in New Issue
Block a user