diff --git a/docs/docs.go b/docs/docs.go index d2c7ff91..8c626301 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": { diff --git a/docs/swagger.json b/docs/swagger.json index e9f59861..4c21fb50 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index ab184116..6b2a98c0 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 获取更早日志 diff --git a/frontend/app/(main)/admin/database/page.tsx b/frontend/app/(main)/admin/database/page.tsx new file mode 100644 index 00000000..d1fde8ae --- /dev/null +++ b/frontend/app/(main)/admin/database/page.tsx @@ -0,0 +1,8 @@ +"use client" + +import {DatabaseManager} from "@/components/common/admin/database-manage" + +/* 数据库管理页面 */ +export default function DatabasePage() { + return +} diff --git a/frontend/components/common/admin/database-manage.tsx b/frontend/components/common/admin/database-manage.tsx new file mode 100644 index 00000000..dba664de --- /dev/null +++ b/frontend/components/common/admin/database-manage.tsx @@ -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(null) + const [tables, setTables] = useState([]) + const [selectedTable, setSelectedTable] = useState("") + const [tableData, setTableData] = useState(null) + + // 分页状态 + const [page, setPage] = useState(1) + const pageSize = 10 + + // 加载状态 + const [loadingOverview, setLoadingOverview] = useState(true) + const [loadingTables, setLoadingTables] = useState(true) + const [loadingData, setLoadingData] = useState(false) + const [exporting, setExporting] = useState(false) + + // SQL 控制台状态 + const [showConsole, setShowConsole] = useState(false) + const [sqlQuery, setSqlQuery] = useState("") + const [executingSQL, setExecutingSQL] = useState(false) + const [sqlResult, setSqlResult] = useState(null) + const [sqlError, setSqlError] = useState(null) + + // 拖拽与主题状态 + const containerRef = useRef(null) + const [editorHeight, setEditorHeight] = useState(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 = () => ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + + + + + + + + ))} +
+ ) + + // SQL 查询控台渲染 + if (showConsole) { + return ( +
+ {/* 顶部控制与标题 */} +
+
+ +
+

SQL 查询终端

+

执行自定义 SQL 查询,支持数据读取与写入操作

+
+
+
+ {overview?.type === "postgres" ? "PostgreSQL Connected" : "SQLite Connected"} +
+
+ + {/* 类似于 VS Code 的单个整体编辑器+结果区域,固定高度,有分水岭拖拽调整大小 */} +
+ {/* 顶部编辑工具栏 */} +
+
+ + SQL 编辑器 +
+ +
+ {/* 快速模板 */} +
+ 快速模板: + +
+ + {/* 控制按钮 */} +
+ + +
+
+
+ + {/* 上半区:编辑器输入框 */} +
+ setSqlQuery(value)} + className="h-full text-xs font-mono" + basicSetup={{ + lineNumbers: true, + foldGutter: true, + dropCursor: true, + allowMultipleSelections: false, + indentOnInput: true, + }} + /> +
+ + {/* 拖动分割线 */} +
+
+
+ + {/* 下半区:查看结果 (可滑动查看) */} +
+ {/* 结果栏工具提示 */} +
+ 执行输出 + {sqlResult && ( + + 类型: {sqlResult.type.toUpperCase()} | 耗时: {sqlResult.execution_time_ms} ms + + )} +
+ + {/* 滚动结果内容 */} +
+ {executingSQL && ( +
+ + 正在在数据库执行查询,请稍候... +
+ )} + + {sqlError && ( +
+

SQL 执行报错 (Error):

+
{sqlError}
+
+ )} + + {sqlResult && ( +
+ {sqlResult.type === "select" && sqlResult.columns && sqlResult.columns.length > 0 && ( +
+ + + + {sqlResult.columns.map((col) => ( + {col} + ))} + + + + {sqlResult.results && sqlResult.results.length > 0 ? ( + sqlResult.results.map((row, rIndex) => ( + + {sqlResult.columns!.map((col) => ( + + + {formatCellValue(row[col])} + + + ))} + + )) + ) : ( + + + 查询结果为空 + + + )} + +
+
+ )} + + {sqlResult.type === "exec" && ( +
+

SQL 执行成功

+

+ 受影响行数: {sqlResult.affected_rows} 行,耗时 {sqlResult.execution_time_ms} 毫秒。 +

+
+ )} +
+ )} + + {!executingSQL && !sqlResult && !sqlError && ( +
+ + 编辑器就绪,请在上方编写 SQL 并运行查看结果 +
+ )} +
+
+
+
+ ) + } + + // 正常数据概览及表分页浏览器渲染 + return ( +
+ {/* 顶部控制与标题 */} +
+
+

数据管理

+

查看物理数据库运行运维数据,进行表级数据浏览与 SQL 交互查询

+
+ +
+ + {/* 1. 顶部概览卡片 */} + {loadingOverview ? ( + renderOverviewSkeleton() + ) : ( + overview && ( +
+ {/* 卡片1: 数据库类型 */} + + + 数据库类型 + + + +
{overview.type}
+
+
+ + {/* 卡片2: 数据库版本 */} + + + 版本信息 + + + +
+ {overview.version.split(" ").slice(0, 2).join(" ")} +
+
+
+ + {/* 卡片3: 数据库名称 */} + + + 名称/路径 + + + +
+ {overview.name.substring(overview.name.lastIndexOf("/") + 1)} +
+
+
+ + {/* 卡片4: 数据库大小 */} + + + 数据库大小 + + + +
{overview.size}
+
+
+ + {/* 卡片5: 表数量 */} + + + 物理数据表 + + + +
{formatNumber(overview.table_count)}
+
+
+ + {/* 卡片6: 连接数 */} + + + 活跃连接数 + + + +
{formatNumber(overview.connections)}
+
+
+
+ ) + )} + + {/* 2. 中部数据表浏览器 */} + + +
+ 数据表浏览器 + 浏览数据库中的物理数据表详情及内容 +
+ +
+ {loadingTables ? ( + + ) : ( + + )} +
+
+ + {loadingData && !tableData ? ( +
+ + + + +
+ ) : tableData && tableData.columns.length > 0 ? ( +
+ {/* 数据表格区域 */} +
+ {loadingData && ( +
+ +
+ )} + + + + {tableData.columns.map((col) => ( + {col} + ))} + + + + {tableData.results && tableData.results.length > 0 ? ( + tableData.results.map((row, rIndex) => ( + + {tableData.columns.map((col) => ( + + + {formatCellValue(row[col])} + + + ))} + + )) + ) : ( + + + 表中无记录数据 + + + )} + +
+
+ + {/* 分页控制 */} +
+
+ 总行数: {formatNumber(tableData.total)} 条记录 +
+
+ + + 第 {page} / {Math.max(1, Math.ceil(tableData.total / pageSize))} 页 + + +
+
+
+ ) : ( +
+ + 未选择数据表或表结构无法加载 +
+ )} +
+
+ + {/* 3. 底部功能区 */} + + + 功能区 + 数据库导出及自定义高级 SQL 执行终端 + + +
+ {/* 功能一: 数据库导出 */} +
+
+

+ + 数据库备份导出 +

+

+ 直接导出并下载物理数据库镜像文件(SQLite 导出 .db,PostgreSQL 导出为打包的 .sql 文本文件) +

+
+ +
+ + {/* 功能二: SQL 查询终端 */} +
+
+

+ + SQL 查询控台 +

+

+ 打开在线 SQL 控制终端,执行原生的 SQL 进行数据筛选、更新、调试或性能优化 +

+
+ +
+
+
+
+
+ ) +} diff --git a/frontend/components/common/settings/info-tab.tsx b/frontend/components/common/settings/info-tab.tsx index be229cf0..b3d3eb37 100644 --- a/frontend/components/common/settings/info-tab.tsx +++ b/frontend/components/common/settings/info-tab.tsx @@ -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 (
@@ -99,69 +63,6 @@ export function InfoTab({ systemConfigsLength, authSourcesLength }: InfoTabProps - - {/* 数据库信息卡片 */} - - -
-
-
- -
-
- 数据库信息 - 当前使用的数据库类型及连接信息 -
-
- -
-
- - {dbInfoQuery.isLoading ? ( -
- - 加载数据库信息中... -
- ) : dbInfoQuery.isError ? ( -

获取数据库信息失败

- ) : ( - <> -
- 数据库类型 - - {dbInfo?.type === "postgres" ? "PostgreSQL" : "SQLite"} - -
- - - - )} -
-
) } diff --git a/frontend/components/common/settings/other-tab.tsx b/frontend/components/common/settings/other-tab.tsx index fb34dd49..57c64f1b 100644 --- a/frontend/components/common/settings/other-tab.tsx +++ b/frontend/components/common/settings/other-tab.tsx @@ -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 }, ] diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx index 4679b48a..6535982e 100644 --- a/frontend/components/layout/sidebar.tsx +++ b/frontend/components/layout/sidebar.tsx @@ -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 }, diff --git a/frontend/lib/services/db-manage/db-manage.service.ts b/frontend/lib/services/db-manage/db-manage.service.ts new file mode 100644 index 00000000..1774fcd1 --- /dev/null +++ b/frontend/lib/services/db-manage/db-manage.service.ts @@ -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 { + return this.get('/overview'); + } + + /** + * 获取数据库所有物理表名 + */ + static async listTables(): Promise { + return this.get('/tables'); + } + + /** + * 获取某张数据表的数据(分页) + */ + static async getTableData(params: { + table: string; + page: number; + pageSize: number; + }): Promise { + return this.get('/table-data', params as unknown as Record); + } + + /** + * 在数据库中执行自定义 SQL 语句 + */ + static async executeSQL(sql: string): Promise { + return this.post('/query', { sql }); + } +} diff --git a/frontend/lib/services/db-manage/index.ts b/frontend/lib/services/db-manage/index.ts new file mode 100644 index 00000000..9470a772 --- /dev/null +++ b/frontend/lib/services/db-manage/index.ts @@ -0,0 +1,2 @@ +export { DbManageService } from './db-manage.service'; +export type { DBOverview, TableDataResponse, ExecuteSQLResponse } from './types'; diff --git a/frontend/lib/services/db-manage/types.ts b/frontend/lib/services/db-manage/types.ts new file mode 100644 index 00000000..5ed54106 --- /dev/null +++ b/frontend/lib/services/db-manage/types.ts @@ -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[]; +} + +/** + * 执行自定义 SQL 的响应 + */ +export interface ExecuteSQLResponse { + type: "select" | "exec"; + columns?: string[]; + results?: Record[]; + affected_rows: number; + execution_time_ms: number; +} diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts index 8ed3c61b..026b116a 100644 --- a/frontend/lib/services/index.ts +++ b/frontend/lib/services/index.ts @@ -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'; diff --git a/frontend/package.json b/frontend/package.json index 931159bd..66d3ea5e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index ac650446..42c39195 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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 diff --git a/internal/apps/admin/db_manage/routers.go b/internal/apps/admin/db_manage/routers.go new file mode 100644 index 00000000..01f24a9f --- /dev/null +++ b/internal/apps/admin/db_manage/routers.go @@ -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)) +} diff --git a/internal/router/router.go b/internal/router/router.go index 48edf444..7816feb7 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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)