新增:连接管理、数据查询等功能
This commit is contained in:
@@ -4,12 +4,19 @@
|
||||
<div class="header-content">
|
||||
<h2>Go Desk</h2>
|
||||
<a-tabs v-model:active-key="activeTab" class="header-tabs">
|
||||
<a-tab-pane key="user" title="用户查询" />
|
||||
<a-tab-pane key="device" title="设备调用测试" />
|
||||
<a-tab-pane key="db-cli" title="数据库客户端"/>
|
||||
<a-tab-pane key="user" title="用户查询"/>
|
||||
<a-tab-pane key="device" title="设备调用测试"/>
|
||||
</a-tabs>
|
||||
<div class="header-actions">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
<a-layout-content class="content">
|
||||
<!-- 数据库客户端 -->
|
||||
<DbCli v-if="activeTab === 'db-cli'"/>
|
||||
|
||||
<!-- 用户查询页面 -->
|
||||
<div v-if="activeTab === 'user'">
|
||||
<!-- 查询表单 -->
|
||||
@@ -17,17 +24,17 @@
|
||||
<a-form :model="formModel" layout="inline">
|
||||
<a-form-item label="关键字">
|
||||
<a-input
|
||||
v-model="formModel.keyword"
|
||||
placeholder="姓名、账号、电话"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
v-model="formModel.keyword"
|
||||
placeholder="姓名、账号、电话"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select
|
||||
v-model="formModel.status"
|
||||
placeholder="选择状态"
|
||||
style="width: 120px"
|
||||
v-model="formModel.status"
|
||||
placeholder="选择状态"
|
||||
style="width: 120px"
|
||||
>
|
||||
<a-option :value="0">全部</a-option>
|
||||
<a-option :value="1">正常</a-option>
|
||||
@@ -39,13 +46,13 @@
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<template #icon>
|
||||
<icon-search />
|
||||
<icon-search/>
|
||||
</template>
|
||||
查询
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<template #icon>
|
||||
<icon-refresh />
|
||||
<icon-refresh/>
|
||||
</template>
|
||||
重置
|
||||
</a-button>
|
||||
@@ -57,12 +64,12 @@
|
||||
<!-- 数据表格 -->
|
||||
<a-card class="table-card">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data="tableData"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
:columns="columns"
|
||||
:data="tableData"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
>
|
||||
<template #status="{ record }">
|
||||
<a-tag v-if="record.status === 1" color="green">正常</a-tag>
|
||||
@@ -74,17 +81,19 @@
|
||||
</div>
|
||||
|
||||
<!-- 设备调用测试页面 -->
|
||||
<DeviceTest v-if="activeTab === 'device'" />
|
||||
<DeviceTest v-if="activeTab === 'device'"/>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import {onMounted, ref} from 'vue'
|
||||
import {Message} from '@arco-design/web-vue'
|
||||
import DeviceTest from './components/DeviceTest.vue'
|
||||
import DbCli from './views/db-cli/index.vue'
|
||||
import ThemeToggle from './components/ThemeToggle.vue'
|
||||
|
||||
const activeTab = ref('user')
|
||||
const activeTab = ref('db-cli')
|
||||
const loading = ref(false)
|
||||
const formModel = ref({
|
||||
keyword: '',
|
||||
@@ -102,14 +111,14 @@ const pagination = ref({
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '编号', dataIndex: 'memberid', width: 80 },
|
||||
{ title: '姓名', dataIndex: 'membername', width: 120 },
|
||||
{ title: '账号', dataIndex: 'account', width: 150 },
|
||||
{ title: '联系电话', dataIndex: 'contactphone', width: 130 },
|
||||
{ title: '机构ID', dataIndex: 'organid', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 80 },
|
||||
{ title: '创建时间', dataIndex: 'createtime', width: 180 },
|
||||
{ title: '修改时间', dataIndex: 'updatetime', width: 180 }
|
||||
{title: '编号', dataIndex: 'memberid', width: 80},
|
||||
{title: '姓名', dataIndex: 'membername', width: 120},
|
||||
{title: '账号', dataIndex: 'account', width: 150},
|
||||
{title: '联系电话', dataIndex: 'contactphone', width: 130},
|
||||
{title: '机构ID', dataIndex: 'organid', width: 100},
|
||||
{title: '状态', dataIndex: 'status', slotName: 'status', width: 80},
|
||||
{title: '创建时间', dataIndex: 'createtime', width: 180},
|
||||
{title: '修改时间', dataIndex: 'updatetime', width: 180}
|
||||
]
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -122,14 +131,14 @@ const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await window.go.main.App.QueryUsers(
|
||||
formModel.value.keyword || '',
|
||||
formModel.value.status || 0,
|
||||
formModel.value.role || 0,
|
||||
formModel.value.organid || 0,
|
||||
pagination.value.current,
|
||||
pagination.value.pageSize,
|
||||
'createtime',
|
||||
'descend'
|
||||
formModel.value.keyword || '',
|
||||
formModel.value.status || 0,
|
||||
formModel.value.role || 0,
|
||||
formModel.value.organid || 0,
|
||||
pagination.value.current,
|
||||
pagination.value.pageSize,
|
||||
'createtime',
|
||||
'descend'
|
||||
)
|
||||
|
||||
if (result && result.rows) {
|
||||
@@ -200,6 +209,7 @@ onMounted(() => {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.header-tabs {
|
||||
@@ -207,6 +217,12 @@ onMounted(() => {
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 20px;
|
||||
flex: 1;
|
||||
|
||||
25
web/src/api/connection.ts
Normal file
25
web/src/api/connection.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 连接相关 API
|
||||
*/
|
||||
|
||||
import type { Connection } from './types'
|
||||
|
||||
/**
|
||||
* 获取连接列表
|
||||
*/
|
||||
export async function listConnections(): Promise<Connection[]> {
|
||||
if (!window.go?.main?.App?.ListDbConnections) {
|
||||
throw new Error('ListDbConnections API 不可用')
|
||||
}
|
||||
return await window.go.main.App.ListDbConnections()
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除连接
|
||||
*/
|
||||
export async function deleteConnection(id: number): Promise<void> {
|
||||
if (!window.go?.main?.App?.DeleteDbConnection) {
|
||||
throw new Error('DeleteDbConnection API 不可用')
|
||||
}
|
||||
await window.go.main.App.DeleteDbConnection(id)
|
||||
}
|
||||
25
web/src/api/database.ts
Normal file
25
web/src/api/database.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 数据库和表相关 API
|
||||
*/
|
||||
|
||||
import type { Database, Table } from './types'
|
||||
|
||||
/**
|
||||
* 获取数据库列表
|
||||
*/
|
||||
export async function getDatabases(connectionId: number): Promise<Database[]> {
|
||||
if (!window.go?.main?.App?.GetDatabases) {
|
||||
throw new Error('GetDatabases API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetDatabases(connectionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表列表
|
||||
*/
|
||||
export async function getTables(connectionId: number, database: string): Promise<Table[]> {
|
||||
if (!window.go?.main?.App?.GetTables) {
|
||||
throw new Error('GetTables API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetTables(connectionId, database)
|
||||
}
|
||||
11
web/src/api/index.ts
Normal file
11
web/src/api/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* API 统一导出
|
||||
*/
|
||||
|
||||
export * from './types'
|
||||
export * from './connection'
|
||||
export * from './database'
|
||||
export * from './structure'
|
||||
export * from './query'
|
||||
export * from './tab'
|
||||
export * from './system'
|
||||
21
web/src/api/query.ts
Normal file
21
web/src/api/query.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* SQL 查询相关 API
|
||||
*/
|
||||
|
||||
import type { QueryResult } from './types'
|
||||
|
||||
/**
|
||||
* 执行 SQL 查询
|
||||
*/
|
||||
export async function executeQuery(
|
||||
connectionId: number,
|
||||
sql: string,
|
||||
database?: string,
|
||||
page?: number,
|
||||
pageSize?: number
|
||||
): Promise<QueryResult> {
|
||||
if (!window.go?.main?.App?.ExecuteSQL) {
|
||||
throw new Error('ExecuteSQL API 不可用')
|
||||
}
|
||||
return await window.go.main.App.ExecuteSQL(connectionId, sql, database, page, pageSize)
|
||||
}
|
||||
19
web/src/api/structure.ts
Normal file
19
web/src/api/structure.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 表结构相关 API
|
||||
*/
|
||||
|
||||
import type { Structure } from './types'
|
||||
|
||||
/**
|
||||
* 获取表结构
|
||||
*/
|
||||
export async function getTableStructure(
|
||||
connectionId: number,
|
||||
database: string,
|
||||
table: string
|
||||
): Promise<Structure> {
|
||||
if (!window.go?.main?.App?.GetTableStructure) {
|
||||
throw new Error('GetTableStructure API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetTableStructure(connectionId, database, table)
|
||||
}
|
||||
95
web/src/api/system.ts
Normal file
95
web/src/api/system.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 系统信息相关 API
|
||||
*/
|
||||
|
||||
import type { SystemInfo, CPU, Memory, Disk, File } from './types'
|
||||
|
||||
/**
|
||||
* 获取系统信息
|
||||
*/
|
||||
export async function getSystemInfo(): Promise<SystemInfo> {
|
||||
if (!window.go?.main?.App?.GetSystemInfo) {
|
||||
throw new Error('GetSystemInfo API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetSystemInfo()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 CPU 信息
|
||||
*/
|
||||
export async function getCPUInfo(): Promise<CPU> {
|
||||
if (!window.go?.main?.App?.GetCPUInfo) {
|
||||
throw new Error('GetCPUInfo API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetCPUInfo()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内存信息
|
||||
*/
|
||||
export async function getMemoryInfo(): Promise<Memory> {
|
||||
if (!window.go?.main?.App?.GetMemoryInfo) {
|
||||
throw new Error('GetMemoryInfo API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetMemoryInfo()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取磁盘信息
|
||||
*/
|
||||
export async function getDiskInfo(): Promise<Disk> {
|
||||
if (!window.go?.main?.App?.GetDiskInfo) {
|
||||
throw new Error('GetDiskInfo API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetDiskInfo()
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出目录文件
|
||||
*/
|
||||
export async function listDir(path: string): Promise<File[]> {
|
||||
if (!window.go?.main?.App?.ListDir) {
|
||||
throw new Error('ListDir API 不可用')
|
||||
}
|
||||
return await window.go.main.App.ListDir(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件
|
||||
*/
|
||||
export async function readFile(path: string): Promise<string> {
|
||||
if (!window.go?.main?.App?.ReadFile) {
|
||||
throw new Error('ReadFile API 不可用')
|
||||
}
|
||||
return await window.go.main.App.ReadFile(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入文件
|
||||
*/
|
||||
export async function writeFile(path: string, content: string): Promise<void> {
|
||||
if (!window.go?.main?.App?.WriteFile) {
|
||||
throw new Error('WriteFile API 不可用')
|
||||
}
|
||||
await window.go.main.App.WriteFile(path, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件或目录
|
||||
*/
|
||||
export async function deletePath(path: string): Promise<void> {
|
||||
if (!window.go?.main?.App?.DeletePath) {
|
||||
throw new Error('DeletePath API 不可用')
|
||||
}
|
||||
await window.go.main.App.DeletePath(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取环境变量
|
||||
*/
|
||||
export async function getEnvVars(): Promise<Record<string, string>> {
|
||||
if (!window.go?.main?.App?.GetEnvVars) {
|
||||
throw new Error('GetEnvVars API 不可用')
|
||||
}
|
||||
return await window.go.main.App.GetEnvVars()
|
||||
}
|
||||
25
web/src/api/tab.ts
Normal file
25
web/src/api/tab.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 标签页相关 API
|
||||
*/
|
||||
|
||||
import type { Tab } from './types'
|
||||
|
||||
/**
|
||||
* 保存标签页
|
||||
*/
|
||||
export async function saveTabs(tabs: Tab[]): Promise<void> {
|
||||
if (!window.go?.main?.App?.SaveSqlTabs) {
|
||||
throw new Error('SaveSqlTabs API 不可用')
|
||||
}
|
||||
await window.go.main.App.SaveSqlTabs(tabs)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取标签页列表
|
||||
*/
|
||||
export async function listTabs(): Promise<Tab[]> {
|
||||
if (!window.go?.main?.App?.ListSqlTabs) {
|
||||
throw new Error('ListSqlTabs API 不可用')
|
||||
}
|
||||
return await window.go.main.App.ListSqlTabs()
|
||||
}
|
||||
108
web/src/api/types.ts
Normal file
108
web/src/api/types.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* API 类型定义
|
||||
*/
|
||||
|
||||
// 连接
|
||||
export interface Connection {
|
||||
id: number
|
||||
name: string
|
||||
dbType: string
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
database?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
// 数据库和表
|
||||
export interface Database {
|
||||
name: string
|
||||
tableCount?: number
|
||||
}
|
||||
|
||||
export interface Table {
|
||||
name: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
// 表结构
|
||||
export interface Column {
|
||||
Field: string
|
||||
Type: string
|
||||
Null: string
|
||||
Key: string
|
||||
Default: string | null
|
||||
Comment: string
|
||||
Extra?: string
|
||||
}
|
||||
|
||||
export interface Index {
|
||||
Key_name: string
|
||||
Column_name: string
|
||||
Non_unique: number
|
||||
Seq_in_index: number
|
||||
Index_type: string
|
||||
}
|
||||
|
||||
export interface Structure {
|
||||
database: string
|
||||
table: string
|
||||
type: 'mysql' | 'mongo' | 'redis'
|
||||
columns?: Column[]
|
||||
indexes?: Index[]
|
||||
structure?: any
|
||||
info?: any
|
||||
}
|
||||
|
||||
// SQL 查询
|
||||
export interface QueryResult {
|
||||
columns: string[]
|
||||
data: any[]
|
||||
rowsAffected: number
|
||||
executionTime: number
|
||||
}
|
||||
|
||||
// 标签页
|
||||
export interface Tab {
|
||||
id?: number
|
||||
title: string
|
||||
content: string
|
||||
connectionId?: number | null
|
||||
order?: number
|
||||
}
|
||||
|
||||
// 系统信息
|
||||
export interface SystemInfo {
|
||||
os: string
|
||||
arch: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface CPU {
|
||||
model: string
|
||||
cores: number
|
||||
usage: number
|
||||
}
|
||||
|
||||
export interface Memory {
|
||||
total: number
|
||||
used: number
|
||||
free: number
|
||||
usage: number
|
||||
}
|
||||
|
||||
export interface Disk {
|
||||
path: string
|
||||
total: number
|
||||
used: number
|
||||
free: number
|
||||
usage: number
|
||||
}
|
||||
|
||||
export interface File {
|
||||
name: string
|
||||
path: string
|
||||
size: number
|
||||
isDir: boolean
|
||||
modified?: string
|
||||
}
|
||||
@@ -40,10 +40,10 @@
|
||||
</a-row>
|
||||
<a-card size="small" title="磁盘信息" v-if="diskInfo && diskInfo.length > 0">
|
||||
<a-table
|
||||
:columns="diskColumns"
|
||||
:data="diskInfo"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
:columns="diskColumns"
|
||||
:data="diskInfo"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
/>
|
||||
</a-card>
|
||||
</a-space>
|
||||
@@ -54,9 +54,9 @@
|
||||
<a-space direction="vertical" :size="16" style="width: 100%">
|
||||
<a-input-group>
|
||||
<a-input
|
||||
v-model="filePath"
|
||||
placeholder="输入文件或目录路径"
|
||||
style="flex: 1"
|
||||
v-model="filePath"
|
||||
placeholder="输入文件或目录路径"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<a-button @click="browseDirectory">浏览</a-button>
|
||||
<a-button type="primary" @click="listDirectory">列出目录</a-button>
|
||||
@@ -65,9 +65,9 @@
|
||||
<a-col :span="12">
|
||||
<a-card size="small" title="文件列表">
|
||||
<a-list
|
||||
:data="fileList"
|
||||
:loading="fileLoading"
|
||||
style="max-height: 300px; overflow-y: auto"
|
||||
:data="fileList"
|
||||
:loading="fileLoading"
|
||||
style="max-height: 300px; overflow-y: auto"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<a-list-item>
|
||||
@@ -92,9 +92,9 @@
|
||||
<a-card size="small" title="文件内容">
|
||||
<a-space direction="vertical" :size="8" style="width: 100%">
|
||||
<a-textarea
|
||||
v-model="fileContent"
|
||||
:rows="10"
|
||||
placeholder="文件内容将显示在这里"
|
||||
v-model="fileContent"
|
||||
:rows="10"
|
||||
placeholder="文件内容将显示在这里"
|
||||
/>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="readFile" :loading="fileLoading">读取文件</a-button>
|
||||
@@ -112,20 +112,31 @@
|
||||
<a-card class="test-card" title="环境变量">
|
||||
<a-button @click="loadEnvVars" :loading="envLoading">加载环境变量</a-button>
|
||||
<a-table
|
||||
v-if="envVars"
|
||||
:columns="envColumns"
|
||||
:data="envTableData"
|
||||
:pagination="{ pageSize: 20 }"
|
||||
style="margin-top: 16px"
|
||||
size="small"
|
||||
v-if="envVars"
|
||||
:columns="envColumns"
|
||||
:data="envTableData"
|
||||
:pagination="{ pageSize: 20 }"
|
||||
style="margin-top: 16px"
|
||||
size="small"
|
||||
/>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import {computed, onMounted, ref} from 'vue'
|
||||
import {Message} from '@arco-design/web-vue'
|
||||
import {
|
||||
getSystemInfo,
|
||||
getCPUInfo,
|
||||
getMemoryInfo,
|
||||
getDiskInfo,
|
||||
listDir,
|
||||
readFile as readFileApi,
|
||||
writeFile as writeFileApi,
|
||||
deletePath,
|
||||
getEnvVars
|
||||
} from '@/api'
|
||||
|
||||
const systemInfo = ref(null)
|
||||
const cpuInfo = ref(null)
|
||||
@@ -139,17 +150,17 @@ const envVars = ref(null)
|
||||
const envLoading = ref(false)
|
||||
|
||||
const diskColumns = [
|
||||
{ title: '设备', dataIndex: 'device', width: 120 },
|
||||
{ title: '挂载点', dataIndex: 'mountpoint', width: 200 },
|
||||
{ title: '总容量', dataIndex: 'total_str', width: 100 },
|
||||
{ title: '已用', dataIndex: 'used_str', width: 100 },
|
||||
{ title: '可用', dataIndex: 'free_str', width: 100 },
|
||||
{ title: '使用率', dataIndex: 'usage', width: 80 }
|
||||
{title: '设备', dataIndex: 'device', width: 120},
|
||||
{title: '挂载点', dataIndex: 'mountpoint', width: 200},
|
||||
{title: '总容量', dataIndex: 'total_str', width: 100},
|
||||
{title: '已用', dataIndex: 'used_str', width: 100},
|
||||
{title: '可用', dataIndex: 'free_str', width: 100},
|
||||
{title: '使用率', dataIndex: 'usage', width: 80}
|
||||
]
|
||||
|
||||
const envColumns = [
|
||||
{ title: '变量名', dataIndex: 'key', width: 200 },
|
||||
{ title: '值', dataIndex: 'value' }
|
||||
{title: '变量名', dataIndex: 'key', width: 200},
|
||||
{title: '值', dataIndex: 'value'}
|
||||
]
|
||||
|
||||
const envTableData = computed(() => {
|
||||
@@ -162,12 +173,10 @@ const envTableData = computed(() => {
|
||||
|
||||
const refreshSystemInfo = async () => {
|
||||
try {
|
||||
if (window.go?.main?.App) {
|
||||
systemInfo.value = await window.go.main.App.GetSystemInfo()
|
||||
cpuInfo.value = await window.go.main.App.GetCPUInfo()
|
||||
memoryInfo.value = await window.go.main.App.GetMemoryInfo()
|
||||
diskInfo.value = await window.go.main.App.GetDiskInfo()
|
||||
}
|
||||
systemInfo.value = await getSystemInfo()
|
||||
cpuInfo.value = await getCPUInfo()
|
||||
memoryInfo.value = await getMemoryInfo()
|
||||
diskInfo.value = await getDiskInfo()
|
||||
} catch (error) {
|
||||
console.error('获取系统信息失败:', error)
|
||||
Message.error('获取系统信息失败: ' + (error.message || error))
|
||||
@@ -181,9 +190,7 @@ const listDirectory = async () => {
|
||||
}
|
||||
fileLoading.value = true
|
||||
try {
|
||||
if (window.go?.main?.App) {
|
||||
fileList.value = await window.go.main.App.ListDir(filePath.value)
|
||||
}
|
||||
fileList.value = await listDir(filePath.value)
|
||||
} catch (error) {
|
||||
console.error('列出目录失败:', error)
|
||||
Message.error('列出目录失败: ' + (error.message || error))
|
||||
@@ -199,9 +206,7 @@ const readFile = async () => {
|
||||
}
|
||||
fileLoading.value = true
|
||||
try {
|
||||
if (window.go?.main?.App) {
|
||||
fileContent.value = await window.go.main.App.ReadFile(filePath.value)
|
||||
}
|
||||
fileContent.value = await readFileApi(filePath.value)
|
||||
} catch (error) {
|
||||
console.error('读取文件失败:', error)
|
||||
Message.error('读取文件失败: ' + (error.message || error))
|
||||
@@ -217,10 +222,8 @@ const writeFile = async () => {
|
||||
}
|
||||
fileLoading.value = true
|
||||
try {
|
||||
if (window.go?.main?.App) {
|
||||
await window.go.main.App.WriteFile(filePath.value, fileContent.value)
|
||||
Message.success('文件写入成功')
|
||||
}
|
||||
await writeFileApi(filePath.value, fileContent.value)
|
||||
Message.success('文件写入成功')
|
||||
} catch (error) {
|
||||
console.error('写入文件失败:', error)
|
||||
Message.error('写入文件失败: ' + (error.message || error))
|
||||
@@ -239,13 +242,11 @@ const deleteFile = async () => {
|
||||
}
|
||||
fileLoading.value = true
|
||||
try {
|
||||
if (window.go?.main?.App) {
|
||||
await window.go.main.App.DeletePath(filePath.value)
|
||||
Message.success('删除成功')
|
||||
filePath.value = ''
|
||||
fileContent.value = ''
|
||||
fileList.value = []
|
||||
}
|
||||
await deletePath(filePath.value)
|
||||
Message.success('删除成功')
|
||||
filePath.value = ''
|
||||
fileContent.value = ''
|
||||
fileList.value = []
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
Message.error('删除失败: ' + (error.message || error))
|
||||
@@ -269,9 +270,7 @@ const browseDirectory = () => {
|
||||
const loadEnvVars = async () => {
|
||||
envLoading.value = true
|
||||
try {
|
||||
if (window.go?.main?.App) {
|
||||
envVars.value = await window.go.main.App.GetEnvVars()
|
||||
}
|
||||
envVars.value = await getEnvVars()
|
||||
} catch (error) {
|
||||
console.error('加载环境变量失败:', error)
|
||||
Message.error('加载环境变量失败: ' + (error.message || error))
|
||||
|
||||
50
web/src/components/ThemeToggle.vue
Normal file
50
web/src/components/ThemeToggle.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<a-tooltip :content="tooltipText" position="bottom">
|
||||
<div
|
||||
class="theme-toggle-btn"
|
||||
@click="handleToggle"
|
||||
>
|
||||
{{ isDark ? '🌙' : '☀️' }}
|
||||
</div>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
|
||||
const { isDark, toggleTheme } = useTheme()
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
return isDark.value ? '切换到亮色主题' : '切换到夜间主题'
|
||||
})
|
||||
|
||||
const handleToggle = () => {
|
||||
toggleTheme()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.theme-toggle-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-small, 2px);
|
||||
user-select: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.theme-toggle-btn:hover {
|
||||
background: var(--color-bg-2);
|
||||
}
|
||||
|
||||
.theme-toggle-btn:active {
|
||||
background: var(--color-bg-3);
|
||||
}
|
||||
</style>
|
||||
8
web/src/composables/index.ts
Normal file
8
web/src/composables/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 全局 Composables 导出
|
||||
*/
|
||||
|
||||
export * from './useLocalStorage'
|
||||
export * from './useDebounce'
|
||||
export * from './useTablePage'
|
||||
export * from './useApiError'
|
||||
61
web/src/composables/useApiError.ts
Normal file
61
web/src/composables/useApiError.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* API Error handling composable
|
||||
* 统一的 API 错误处理
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
export interface ApiErrorState {
|
||||
hasError: boolean
|
||||
message: string
|
||||
code?: string | number
|
||||
}
|
||||
|
||||
export function useApiError() {
|
||||
const error = ref<ApiErrorState>({
|
||||
hasError: false,
|
||||
message: ''
|
||||
})
|
||||
|
||||
const setError = (err: Error | string | unknown, defaultMessage: string = '操作失败') => {
|
||||
let message = defaultMessage
|
||||
let code: string | number | undefined
|
||||
|
||||
if (err instanceof Error) {
|
||||
message = err.message || defaultMessage
|
||||
} else if (typeof err === 'string') {
|
||||
message = err
|
||||
} else if (err && typeof err === 'object' && 'message' in err) {
|
||||
message = (err as any).message || defaultMessage
|
||||
if ('code' in err) code = (err as any).code
|
||||
}
|
||||
|
||||
error.value = {
|
||||
hasError: true,
|
||||
message,
|
||||
code
|
||||
}
|
||||
|
||||
return { message, code }
|
||||
}
|
||||
|
||||
const showError = (err: Error | string | unknown, defaultMessage: string = '操作失败') => {
|
||||
const { message } = setError(err, defaultMessage)
|
||||
Message.error(message)
|
||||
}
|
||||
|
||||
const clearError = () => {
|
||||
error.value = {
|
||||
hasError: false,
|
||||
message: ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error,
|
||||
setError,
|
||||
showError,
|
||||
clearError
|
||||
}
|
||||
}
|
||||
34
web/src/composables/useDebounce.ts
Normal file
34
web/src/composables/useDebounce.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Debounce composable
|
||||
* 防抖函数
|
||||
*/
|
||||
|
||||
import { ref, watch, type Ref, type ComputedRef } from 'vue'
|
||||
|
||||
export function useDebounce<T>(value: Ref<T> | ComputedRef<T>, delay: number = 300): Ref<T> {
|
||||
const debouncedValue = ref<T>(value.value) as Ref<T>
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(value, (newValue) => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
debouncedValue.value = newValue
|
||||
}, delay)
|
||||
})
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
|
||||
export function debounceFn<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
delay: number = 300
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
fn(...args)
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
34
web/src/composables/useLocalStorage.ts
Normal file
34
web/src/composables/useLocalStorage.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* LocalStorage composable
|
||||
* 通用的 localStorage 操作
|
||||
*/
|
||||
|
||||
import { watch, type Ref } from 'vue'
|
||||
|
||||
export function useLocalStorage<T>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
storage: Storage = localStorage
|
||||
): [Ref<T>, (value: T) => void, () => void] {
|
||||
const stored = storage.getItem(key)
|
||||
const value = ref<T>(stored ? JSON.parse(stored) : defaultValue)
|
||||
|
||||
const setValue = (newValue: T) => {
|
||||
value.value = newValue
|
||||
}
|
||||
|
||||
const clearValue = () => {
|
||||
value.value = defaultValue
|
||||
storage.removeItem(key)
|
||||
}
|
||||
|
||||
watch(value, (newValue) => {
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify(newValue))
|
||||
} catch (e) {
|
||||
console.warn(`Failed to save ${key} to localStorage:`, e)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
return [value, setValue, clearValue]
|
||||
}
|
||||
60
web/src/composables/useTablePage.ts
Normal file
60
web/src/composables/useTablePage.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Table Pagination composable
|
||||
* 表格分页逻辑
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export interface PaginationOptions {
|
||||
pageSize?: number
|
||||
initialPage?: number
|
||||
}
|
||||
|
||||
export function useTablePage(options: PaginationOptions = {}) {
|
||||
const { pageSize = 10, initialPage = 1 } = options
|
||||
|
||||
const currentPage = ref(initialPage)
|
||||
const currentPageSize = ref(pageSize)
|
||||
|
||||
const canGoPrev = computed(() => currentPage.value > 1)
|
||||
const canGoNext = computed(() => currentPage.value * currentPageSize.value < totalItems.value)
|
||||
|
||||
const totalItems = ref(0)
|
||||
const totalPages = computed(() => Math.ceil(totalItems.value / currentPageSize.value))
|
||||
|
||||
const nextPage = () => {
|
||||
if (canGoNext.value) currentPage.value++
|
||||
}
|
||||
|
||||
const prevPage = () => {
|
||||
if (canGoPrev.value) currentPage.value--
|
||||
}
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
currentPage.value = page
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
currentPage.value = initialPage
|
||||
}
|
||||
|
||||
const setTotalItems = (total: number) => {
|
||||
totalItems.value = total
|
||||
}
|
||||
|
||||
return {
|
||||
currentPage,
|
||||
currentPageSize,
|
||||
canGoPrev,
|
||||
canGoNext,
|
||||
totalItems,
|
||||
totalPages,
|
||||
nextPage,
|
||||
prevPage,
|
||||
goToPage,
|
||||
reset,
|
||||
setTotalItems
|
||||
}
|
||||
}
|
||||
78
web/src/composables/useTheme.ts
Normal file
78
web/src/composables/useTheme.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
const THEME_STORAGE_KEY = 'app-theme'
|
||||
|
||||
// 单例模式:全局共享主题状态
|
||||
const theme = ref<Theme>('light')
|
||||
let systemThemeListener: (() => void) | null = null
|
||||
|
||||
// 应用主题到 DOM
|
||||
const applyTheme = (newTheme: Theme) => {
|
||||
theme.value = newTheme
|
||||
if (newTheme === 'dark') {
|
||||
document.body.setAttribute('arco-theme', 'dark')
|
||||
} else {
|
||||
document.body.removeAttribute('arco-theme')
|
||||
}
|
||||
localStorage.setItem(THEME_STORAGE_KEY, newTheme)
|
||||
}
|
||||
|
||||
// 初始化主题(只调用一次)
|
||||
const initTheme = () => {
|
||||
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY) as Theme
|
||||
if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark')) {
|
||||
applyTheme(savedTheme)
|
||||
} else {
|
||||
// 检测系统偏好
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
applyTheme('dark')
|
||||
} else {
|
||||
applyTheme('light')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听系统主题变化
|
||||
if (window.matchMedia) {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
// 如果用户没有手动设置过主题,则跟随系统
|
||||
if (!localStorage.getItem(THEME_STORAGE_KEY)) {
|
||||
applyTheme(e.matches ? 'dark' : 'light')
|
||||
}
|
||||
}
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
systemThemeListener = () => mediaQuery.removeEventListener('change', handleChange)
|
||||
}
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
// 切换主题
|
||||
const toggleTheme = () => {
|
||||
const newTheme: Theme = theme.value === 'light' ? 'dark' : 'light'
|
||||
applyTheme(newTheme)
|
||||
}
|
||||
|
||||
// 设置为亮色主题
|
||||
const setLightTheme = () => {
|
||||
applyTheme('light')
|
||||
}
|
||||
|
||||
// 设置为暗色主题
|
||||
const setDarkTheme = () => {
|
||||
applyTheme('dark')
|
||||
}
|
||||
|
||||
return {
|
||||
theme: computed(() => theme.value),
|
||||
isDark: computed(() => theme.value === 'dark'),
|
||||
toggleTheme,
|
||||
setLightTheme,
|
||||
setDarkTheme,
|
||||
initTheme
|
||||
}
|
||||
}
|
||||
|
||||
// 导出初始化函数(在 main.js 中使用)
|
||||
export { initTheme }
|
||||
@@ -1,10 +1,15 @@
|
||||
import { createApp } from 'vue'
|
||||
import {createApp} from 'vue'
|
||||
import ArcoVue from '@arco-design/web-vue'
|
||||
import '@arco-design/web-vue/dist/arco.css'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import {initTheme} from './composables/useTheme'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(ArcoVue)
|
||||
|
||||
// 在应用挂载前初始化主题
|
||||
initTheme()
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
|
||||
@@ -1,17 +1,51 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* 滚动条样式优化 */
|
||||
/* Webkit浏览器 (Chrome, Safari, Edge) */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-2, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-border-3, rgba(0, 0, 0, 0.2));
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border-2, rgba(0, 0, 0, 0.1)) transparent;
|
||||
}
|
||||
22
web/src/types/window.d.ts
vendored
Normal file
22
web/src/types/window.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 全局 Window 类型声明
|
||||
* 扩展 Window 接口以支持 Wails 的 window.go
|
||||
*
|
||||
* 注意:这是过渡方案,最终应该使用 Wails 生成的包装函数(App.js)
|
||||
* 而不是直接访问 window.go.main.App
|
||||
*/
|
||||
|
||||
import type * as App from '../wailsjs/wailsjs/go/main/App'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
go: {
|
||||
main: {
|
||||
App: typeof App
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
511
web/src/views/db-cli/components/ConnectionForm.vue
Normal file
511
web/src/views/db-cli/components/ConnectionForm.vue
Normal file
@@ -0,0 +1,511 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:visible="visible"
|
||||
title="数据库连接配置"
|
||||
width="560px"
|
||||
:body-style="{ padding: '16px 20px' }"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<!-- 错误提示区域 -->
|
||||
<a-alert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
closable
|
||||
@close="errorMessage = ''"
|
||||
class="error-alert"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</a-alert>
|
||||
|
||||
<a-form :model="form" :rules="rules" ref="formRef" layout="horizontal" :label-col-props="{ span: 6 }"
|
||||
:wrapper-col-props="{ span: 18 }" size="small">
|
||||
<a-form-item label="连接名称" field="name">
|
||||
<a-input v-model="form.name" placeholder="请输入连接名称" size="small"/>
|
||||
</a-form-item>
|
||||
<a-form-item label="数据库类型" field="type">
|
||||
<a-select v-model="form.type" placeholder="请选择数据库类型" @change="handleTypeChange" size="small">
|
||||
<a-option value="mysql">MySQL</a-option>
|
||||
<a-option value="redis">Redis</a-option>
|
||||
<a-option value="mongo">MongoDB</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="主机地址" field="host">
|
||||
<a-input v-model="form.host" placeholder="请输入主机地址" size="small"/>
|
||||
</a-form-item>
|
||||
<a-form-item label="端口" field="port">
|
||||
<a-input-number v-model="form.port" :min="1" :max="65535" placeholder="请输入端口" style="width: 100%"
|
||||
size="small"/>
|
||||
</a-form-item>
|
||||
<a-form-item label="用户名" field="username" v-if="form.type !== 'redis'">
|
||||
<a-input v-model="form.username" placeholder="请输入用户名" size="small"/>
|
||||
</a-form-item>
|
||||
<a-form-item label="密码" field="password">
|
||||
<div v-if="props.connectionId && !isPasswordChanged" class="password-display">
|
||||
<a-input
|
||||
value="已保存的密码"
|
||||
disabled
|
||||
class="password-input"
|
||||
size="small"
|
||||
/>
|
||||
<a-button type="text" size="mini" @click="isPasswordChanged = true">
|
||||
修改密码
|
||||
</a-button>
|
||||
</div>
|
||||
<a-input-password
|
||||
v-else
|
||||
v-model="form.password"
|
||||
:placeholder="getPasswordPlaceholder()"
|
||||
size="small"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item :label="form.type === 'redis' ? '数据库编号' : '数据库名'" field="database">
|
||||
<a-input v-model="form.database"
|
||||
:placeholder="form.type === 'redis' ? 'Redis DB 编号 (0-15,默认为0)' : '可选,留空则连接所有数据库'"
|
||||
:max-length="100"
|
||||
size="small"/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- MongoDB 专用选项 -->
|
||||
<template v-if="form.type === 'mongo'">
|
||||
<a-form-item label="认证数据库" field="options.authSource">
|
||||
<a-input v-model="optionsForm.authSource" placeholder="留空则使用 admin" size="small"/>
|
||||
<template #extra>
|
||||
<span class="form-item-extra">MongoDB 用户所在的数据库,通常为 admin(可选)</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
<template #footer>
|
||||
<a-space size="small">
|
||||
<a-button @click="handleTest" :loading="testing" size="small">测试连接</a-button>
|
||||
<a-button @click="handleCancel" size="small">取消</a-button>
|
||||
<a-button type="primary" @click="handleSubmit" :loading="saving" size="small">保存</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {reactive, ref, watch} from 'vue'
|
||||
import {Message} from '@arco-design/web-vue'
|
||||
import {
|
||||
ListDbConnections,
|
||||
SaveDbConnection
|
||||
} from '../../../wailsjs/wailsjs/go/main/App'
|
||||
|
||||
// 使用 defineModel 简化 v-model:visible 双向绑定(Vue 3.5+)
|
||||
const visible = defineModel('visible', { type: Boolean, default: false })
|
||||
|
||||
// 使用 TypeScript 泛型语法(Vue 3.5+)
|
||||
const props = defineProps<{
|
||||
connectionId?: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const formRef = ref<any>(null)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const errorMessage = ref('')
|
||||
// 是否修改密码(编辑模式下)
|
||||
const isPasswordChanged = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
type: 'mysql',
|
||||
host: '127.0.0.1',
|
||||
port: 3306,
|
||||
username: '',
|
||||
password: '',
|
||||
database: '',
|
||||
options: ''
|
||||
})
|
||||
|
||||
// 选项表单(用于表单输入)
|
||||
const optionsForm = reactive({
|
||||
authSource: ''
|
||||
})
|
||||
|
||||
// 将 options JSON 字符串解析为 optionsForm
|
||||
const parseOptionsToForm = (optionsStr: string) => {
|
||||
if (!optionsStr || optionsStr.trim() === '') {
|
||||
optionsForm.authSource = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const opts = JSON.parse(optionsStr)
|
||||
optionsForm.authSource = opts.authSource || ''
|
||||
// 认证机制使用自动检测,不需要从选项读取
|
||||
} catch (error) {
|
||||
console.warn('解析 Options JSON 失败:', error)
|
||||
// 解析失败时,清空表单选项
|
||||
optionsForm.authSource = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 将 optionsForm 和 form.options 合并为 JSON 字符串
|
||||
const mergeOptionsToJson = (): string => {
|
||||
let customOptions: any = {}
|
||||
|
||||
// 先解析已有的 JSON(可能包含其他自定义选项)
|
||||
if (form.options && form.options.trim() !== '') {
|
||||
try {
|
||||
customOptions = JSON.parse(form.options)
|
||||
} catch (error) {
|
||||
console.warn('解析自定义 Options JSON 失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据数据库类型合并表单选项(仅 MongoDB)
|
||||
if (form.type === 'mongo') {
|
||||
// 只有认证数据库不为空时才添加
|
||||
if (optionsForm.authSource && optionsForm.authSource.trim() !== '') {
|
||||
customOptions.authSource = optionsForm.authSource.trim()
|
||||
}
|
||||
// 认证机制使用自动检测,不需要添加到选项
|
||||
}
|
||||
|
||||
// 如果没有任何选项,返回空字符串
|
||||
if (Object.keys(customOptions).length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return JSON.stringify(customOptions)
|
||||
}
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
name: [
|
||||
{required: true, message: '请输入连接名称'},
|
||||
{maxLength: 100, message: '连接名称长度不能超过100个字符'}
|
||||
],
|
||||
type: [{required: true, message: '请选择数据库类型'}],
|
||||
host: [
|
||||
{required: true, message: '请输入主机地址'},
|
||||
{maxLength: 255, message: '主机地址长度不能超过255个字符'}
|
||||
],
|
||||
port: [
|
||||
{required: true, message: '请输入端口'},
|
||||
{
|
||||
validator: (value, callback) => {
|
||||
if (!value || value < 1 || value > 65535) {
|
||||
callback('端口号必须在1-65535之间')
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
database: [
|
||||
{
|
||||
validator: (value, callback) => {
|
||||
// MySQL 类型时数据库名为可选(允许为空)
|
||||
// MongoDB 和 Redis 也为可选
|
||||
callback()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 获取密码输入框的占位符
|
||||
const getPasswordPlaceholder = () => {
|
||||
if (props.connectionId) {
|
||||
return '请输入新密码'
|
||||
}
|
||||
switch (form.type) {
|
||||
case 'redis':
|
||||
return '可选,留空则无密码连接'
|
||||
case 'mongo':
|
||||
return '可选,留空则无认证连接'
|
||||
default:
|
||||
return '请输入密码'
|
||||
}
|
||||
}
|
||||
|
||||
// 监听类型变化,设置默认端口、主机和用户名
|
||||
const handleTypeChange = (type) => {
|
||||
// 如果主机为空,设置默认值
|
||||
if (!form.host || form.host.trim() === '') {
|
||||
form.host = '127.0.0.1'
|
||||
}
|
||||
|
||||
// 根据类型设置默认端口和用户名
|
||||
switch (type) {
|
||||
case 'mysql':
|
||||
form.port = 3306
|
||||
if (!form.username) {
|
||||
form.username = 'root'
|
||||
}
|
||||
// 清空 MongoDB 专用选项
|
||||
optionsForm.authSource = ''
|
||||
form.options = ''
|
||||
break
|
||||
case 'redis':
|
||||
form.port = 6379
|
||||
form.username = '' // Redis 不需要用户名
|
||||
if (!form.database) {
|
||||
form.database = '0' // Redis 默认 DB 0
|
||||
}
|
||||
// 清空其他数据库的选项
|
||||
optionsForm.authSource = ''
|
||||
form.options = ''
|
||||
break
|
||||
case 'mongo':
|
||||
case 'mongodb':
|
||||
form.port = 27017
|
||||
if (!form.username) {
|
||||
form.username = 'admin' // MongoDB 常用默认用户名
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 类型变化时,同步更新 options JSON
|
||||
form.options = mergeOptionsToJson()
|
||||
}
|
||||
|
||||
// 加载连接详情
|
||||
const loadConnection = async () => {
|
||||
if (!props.connectionId) {
|
||||
resetForm()
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
if (!(window as any).go?.main?.App?.ListDbConnections) {
|
||||
return
|
||||
}
|
||||
|
||||
const connections = await (window as any).go.main.App.ListDbConnections()
|
||||
const conn = connections.find(c => c.id === props.connectionId)
|
||||
if (conn) {
|
||||
form.name = conn.name
|
||||
form.type = conn.type
|
||||
form.host = conn.host || '127.0.0.1'
|
||||
form.port = conn.port || (conn.type === 'mysql' ? 3306 : conn.type === 'redis' ? 6379 : 27017)
|
||||
form.username = conn.username || ''
|
||||
form.database = conn.database || ''
|
||||
// 先解析 options 到表单
|
||||
parseOptionsToForm(conn.options || '')
|
||||
// 然后设置 form.options(这样不会触发 watch)
|
||||
form.options = conn.options || ''
|
||||
// 编辑模式下,默认不修改密码
|
||||
form.password = ''
|
||||
isPasswordChanged.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载连接详情失败:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
form.name = ''
|
||||
form.type = 'mysql'
|
||||
form.host = '127.0.0.1'
|
||||
form.port = 3306
|
||||
form.username = 'root' // MySQL 默认用户名
|
||||
form.password = ''
|
||||
form.database = ''
|
||||
form.options = ''
|
||||
optionsForm.authSource = ''
|
||||
isPasswordChanged.value = false
|
||||
}
|
||||
|
||||
// 测试连接(不保存数据)
|
||||
const handleTest = async () => {
|
||||
if (!formRef.value) {
|
||||
console.error('formRef 未初始化')
|
||||
return
|
||||
}
|
||||
|
||||
// 清除之前的错误信息
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
// 验证通过,继续执行
|
||||
} catch (error) {
|
||||
// 表单验证失败
|
||||
const errorFields = error?.fields || {}
|
||||
const firstError = Object.values(errorFields)[0]
|
||||
const errorMsg = firstError?.[0]?.message || '请检查表单填写是否正确'
|
||||
errorMessage.value = errorMsg
|
||||
Message.warning(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 Go 后端是否可用
|
||||
if (!(window as any).go?.main?.App) {
|
||||
errorMessage.value = 'Go 后端未就绪,请确保应用已启动'
|
||||
Message.error('Go 后端未就绪,请确保应用已启动')
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
try {
|
||||
// 编辑模式下,如果未修改密码,传递空字符串(后端会获取已保存的密码)
|
||||
const passwordToTest = (props.connectionId && !isPasswordChanged.value) ? '' : (form.password || '')
|
||||
|
||||
// 合并选项为 JSON
|
||||
const optionsJson = mergeOptionsToJson()
|
||||
|
||||
// 直接测试连接,不保存数据
|
||||
await (window as any).go.main.App.TestDbConnectionWithParams({
|
||||
id: props.connectionId || 0, // 编辑模式下传递ID,用于获取已保存的密码
|
||||
type: form.type,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username || '',
|
||||
password: passwordToTest,
|
||||
database: form.database || '',
|
||||
options: optionsJson
|
||||
})
|
||||
|
||||
Message.success('连接测试成功')
|
||||
errorMessage.value = ''
|
||||
} catch (error) {
|
||||
console.error('连接测试失败:', error)
|
||||
const errorMsg = error.message || error.toString() || '未知错误'
|
||||
errorMessage.value = '连接测试失败: ' + errorMsg
|
||||
Message.error('连接测试失败: ' + errorMsg)
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) {
|
||||
console.error('formRef 未初始化')
|
||||
return
|
||||
}
|
||||
|
||||
// 清除之前的错误信息
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
// 验证通过,继续执行
|
||||
} catch (error) {
|
||||
// 表单验证失败
|
||||
const errorFields = error?.fields || {}
|
||||
const firstError = Object.values(errorFields)[0]
|
||||
const errorMsg = firstError?.[0]?.message || '请检查表单填写是否正确'
|
||||
errorMessage.value = errorMsg
|
||||
Message.warning(errorMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 Go 后端是否可用
|
||||
if (!(window as any).go?.main?.App) {
|
||||
errorMessage.value = 'Go 后端未就绪,请确保应用已启动'
|
||||
Message.error('Go 后端未就绪,请确保应用已启动')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
// 编辑模式下,如果未修改密码,传递空字符串(后端会保留原密码)
|
||||
const passwordToSave = (props.connectionId && !isPasswordChanged.value) ? '' : (form.password || '')
|
||||
|
||||
// 合并选项为 JSON
|
||||
const optionsJson = mergeOptionsToJson()
|
||||
|
||||
await (window as any).go.main.App.SaveDbConnection({
|
||||
id: props.connectionId || 0,
|
||||
name: form.name,
|
||||
type: form.type,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username || '',
|
||||
password: passwordToSave,
|
||||
database: form.database || '',
|
||||
options: optionsJson
|
||||
})
|
||||
|
||||
Message.success(props.connectionId ? '更新成功' : '保存成功')
|
||||
errorMessage.value = ''
|
||||
emit('success')
|
||||
visible.value = false
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
const errorMsg = error.message || error.toString() || '未知错误'
|
||||
errorMessage.value = '保存失败: ' + errorMsg
|
||||
Message.error('保存失败: ' + errorMsg)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消
|
||||
const handleCancel = () => {
|
||||
errorMessage.value = ''
|
||||
visible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 是否正在加载连接(用于避免加载时触发 watch)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// 监听 optionsForm 变化,自动同步到 form.options(仅 MongoDB)
|
||||
watch(
|
||||
() => [optionsForm.authSource, form.type],
|
||||
() => {
|
||||
// 如果正在加载,不触发更新
|
||||
if (isLoading.value) {
|
||||
return
|
||||
}
|
||||
// 仅 MongoDB 需要同步选项
|
||||
if (visible.value && form.type === 'mongo') {
|
||||
form.options = mergeOptionsToJson()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(visible, (val) => {
|
||||
if (val) {
|
||||
errorMessage.value = ''
|
||||
loadConnection()
|
||||
} else {
|
||||
errorMessage.value = ''
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.password-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.password-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.options-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-item-extra {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
1129
web/src/views/db-cli/components/ConnectionTree.vue
Normal file
1129
web/src/views/db-cli/components/ConnectionTree.vue
Normal file
File diff suppressed because it is too large
Load Diff
183
web/src/views/db-cli/components/ContextMenu.vue
Normal file
183
web/src/views/db-cli/components/ContextMenu.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="context-menu-overlay"
|
||||
@click="handleOverlayClick"
|
||||
@contextmenu.prevent="handleOverlayClick"
|
||||
>
|
||||
<div
|
||||
class="context-menu"
|
||||
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<template v-for="(item, index) in processedItems" :key="item.key || index">
|
||||
<div
|
||||
v-if="item.divider"
|
||||
class="context-menu-divider"
|
||||
></div>
|
||||
<div
|
||||
v-else
|
||||
class="context-menu-item"
|
||||
:class="{ disabled: item.disabled }"
|
||||
@click="handleMenuItemClick(item)"
|
||||
>
|
||||
<span v-if="item.icon" class="context-menu-item-icon">
|
||||
<component :is="item.icon"/>
|
||||
</span>
|
||||
<span class="context-menu-item-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
/**
|
||||
* 菜单项配置
|
||||
*/
|
||||
export interface MenuItem {
|
||||
key: string
|
||||
label: string
|
||||
icon?: Component
|
||||
disabled?: boolean
|
||||
divider?: boolean
|
||||
handler?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 defineModel 简化 v-model:visible 双向绑定(Vue 3.5+)
|
||||
*/
|
||||
const visible = defineModel<boolean>('visible', { default: false })
|
||||
|
||||
/**
|
||||
* Props
|
||||
*/
|
||||
const props = defineProps<{
|
||||
position: { x: number; y: number }
|
||||
items: MenuItem[]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* Emits
|
||||
*/
|
||||
const emit = defineEmits<{
|
||||
'menu-item-click': [item: MenuItem]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* 点击遮罩层关闭菜单
|
||||
*/
|
||||
const handleOverlayClick = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理菜单项点击
|
||||
*/
|
||||
const handleMenuItemClick = (item: MenuItem) => {
|
||||
if (item.disabled) return
|
||||
|
||||
emit('menu-item-click', item)
|
||||
|
||||
if (item.handler) {
|
||||
item.handler()
|
||||
}
|
||||
|
||||
// 点击后关闭菜单
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理菜单项(处理分隔线)
|
||||
* divider: true 表示在该菜单项之后添加分隔线
|
||||
*/
|
||||
const processedItems = computed(() => {
|
||||
const result: MenuItem[] = []
|
||||
|
||||
props.items.forEach((item, index) => {
|
||||
// 添加菜单项本身(不包含 divider 标记)
|
||||
const menuItem = { ...item }
|
||||
const hasDivider = menuItem.divider
|
||||
delete menuItem.divider // 移除 divider 标记,避免在渲染时被当作分隔线
|
||||
|
||||
result.push(menuItem)
|
||||
|
||||
// 如果该项标记了 divider,在其后添加分隔线
|
||||
if (hasDivider) {
|
||||
result.push({
|
||||
key: `divider-${index}`,
|
||||
label: '',
|
||||
divider: true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.context-menu-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
min-width: 160px;
|
||||
padding: 4px 0;
|
||||
background: var(--color-bg-popup, #fff);
|
||||
border: 1px solid var(--color-border-2, #e5e6eb);
|
||||
border-radius: var(--border-radius-medium, 4px);
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-1, #1d2129);
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.context-menu-item:hover:not(.disabled) {
|
||||
background: var(--color-fill-2, #f2f3f5);
|
||||
}
|
||||
|
||||
.context-menu-item.disabled {
|
||||
color: var(--color-text-4, #c9cdd4);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.context-menu-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.context-menu-item-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
height: 1px;
|
||||
margin: 4px 12px;
|
||||
background: var(--color-border-2, #e5e6eb);
|
||||
}
|
||||
</style>
|
||||
529
web/src/views/db-cli/components/MySQLCreate.vue
Normal file
529
web/src/views/db-cli/components/MySQLCreate.vue
Normal file
@@ -0,0 +1,529 @@
|
||||
<template>
|
||||
<div class="mysql-create">
|
||||
<a-tabs
|
||||
v-model:active-key="activeTab"
|
||||
type="line"
|
||||
class="create-tabs"
|
||||
>
|
||||
<!-- 基本信息 -->
|
||||
<a-tab-pane key="basic" title="基本信息">
|
||||
<div class="tab-content basic-info-content">
|
||||
<a-form :model="formData" layout="vertical" :label-col-props="{ span: 6 }">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="数据库" field="database">
|
||||
<a-input v-model="formData.database" disabled />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="表名" field="tableName" :rules="[{ required: true, message: '请输入表名' }]">
|
||||
<a-input v-model="formData.tableName" placeholder="请输入表名" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="字符集" field="charset">
|
||||
<a-select v-model="formData.charset" placeholder="选择字符集">
|
||||
<a-option value="utf8mb4">utf8mb4</a-option>
|
||||
<a-option value="utf8">utf8</a-option>
|
||||
<a-option value="latin1">latin1</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="排序规则" field="collation">
|
||||
<a-select v-model="formData.collation" placeholder="选择排序规则">
|
||||
<a-option value="utf8mb4_general_ci">utf8mb4_general_ci</a-option>
|
||||
<a-option value="utf8mb4_unicode_ci">utf8mb4_unicode_ci</a-option>
|
||||
<a-option value="utf8_general_ci">utf8_general_ci</a-option>
|
||||
<a-option value="utf8_unicode_ci">utf8_unicode_ci</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 字段列表 -->
|
||||
<a-tab-pane key="fields" title="字段列表">
|
||||
<div class="tab-content fields-content">
|
||||
<MySQLFieldList
|
||||
mode="create"
|
||||
:fields="formData.fields"
|
||||
@add-field="handleAddField"
|
||||
@remove-field="handleRemoveField"
|
||||
@move-field="handleMoveField"
|
||||
@update-field="handleUpdateField"
|
||||
/>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 索引列表 -->
|
||||
<a-tab-pane key="indexes" title="索引列表">
|
||||
<div class="tab-content indexes-content">
|
||||
<div class="section-header">
|
||||
<a-button type="primary" size="small" @click="showIndexDialog">
|
||||
<template #icon>
|
||||
<icon-plus />
|
||||
</template>
|
||||
添加索引
|
||||
</a-button>
|
||||
</div>
|
||||
<div v-if="formData.indexes.length === 0" class="empty-tip">
|
||||
<a-empty description="暂无索引(可选)" :image="false" />
|
||||
</div>
|
||||
<a-table
|
||||
v-else
|
||||
:columns="indexColumns"
|
||||
:data="formData.indexes"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
:bordered="true"
|
||||
>
|
||||
<template #unique="{ record }">
|
||||
<a-tag :color="record.unique ? 'blue' : 'default'">
|
||||
{{ record.unique ? '是' : '否' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template #fields="{ record }">
|
||||
{{ record.fields.map((f: any) => f.name).join(', ') }}
|
||||
</template>
|
||||
<template #operations="{ record, rowIndex }">
|
||||
<a-button type="text" size="small" status="danger" @click="removeIndex(rowIndex)">
|
||||
<template #icon>
|
||||
<icon-delete />
|
||||
</template>
|
||||
</a-button>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- SQL预览 -->
|
||||
<a-tab-pane key="sql" title="SQL预览">
|
||||
<div class="tab-content sql-preview-content">
|
||||
<div class="sql-preview-header">
|
||||
<a-button type="text" size="small" @click="copySQL">
|
||||
<template #icon>
|
||||
<icon-copy />
|
||||
</template>
|
||||
复制
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="sql-preview-wrapper">
|
||||
<pre class="sql-code">{{ sqlPreview }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<!-- 索引定义对话框 -->
|
||||
<a-modal
|
||||
v-model:visible="indexDialogVisible"
|
||||
title="添加索引"
|
||||
:width="600"
|
||||
@ok="handleIndexDialogOk"
|
||||
@cancel="handleIndexDialogCancel"
|
||||
>
|
||||
<a-form :model="indexForm" layout="vertical" ref="indexFormRef">
|
||||
<a-form-item label="索引名" field="name" :rules="[{ required: true, message: '请输入索引名' }]">
|
||||
<a-input v-model="indexForm.name" placeholder="请输入索引名" />
|
||||
</a-form-item>
|
||||
<a-form-item label="唯一索引" field="unique">
|
||||
<a-checkbox v-model="indexForm.unique">唯一索引</a-checkbox>
|
||||
</a-form-item>
|
||||
<a-form-item label="索引字段" field="fields" :rules="[{ required: true, message: '请至少选择一个字段' }]">
|
||||
<a-select
|
||||
v-model="indexForm.fields"
|
||||
mode="multiple"
|
||||
placeholder="选择索引字段"
|
||||
:max-tag-count="3"
|
||||
>
|
||||
<a-option
|
||||
v-for="field in formData.fields"
|
||||
:key="field.name"
|
||||
:value="field.name"
|
||||
>
|
||||
{{ field.name }} ({{ field.type }}{{ field.length ? `(${field.length})` : '' }})
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import {
|
||||
IconCopy
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
import MySQLFieldList from './MySQLFieldList.vue'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
connectionId: number
|
||||
database: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancel'): void
|
||||
(e: 'create', data: any): void
|
||||
}>()
|
||||
|
||||
// 当前激活的 tab
|
||||
const activeTab = ref<string>('basic')
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
database: props.database,
|
||||
tableName: '',
|
||||
charset: 'utf8mb4',
|
||||
collation: 'utf8mb4_general_ci',
|
||||
fields: [] as any[],
|
||||
indexes: [] as any[]
|
||||
})
|
||||
|
||||
// SQL 预览
|
||||
const sqlPreview = computed(() => {
|
||||
if (formData.fields.length === 0) {
|
||||
return '-- 请先添加字段'
|
||||
}
|
||||
return generateSQL()
|
||||
})
|
||||
|
||||
// 索引表格列
|
||||
const indexColumns = [
|
||||
{ title: '索引名', dataIndex: 'name', width: 150 },
|
||||
{ title: '唯一', dataIndex: 'unique', slotName: 'unique', width: 80 },
|
||||
{ title: '字段', slotName: 'fields', width: 200 },
|
||||
{ title: '操作', slotName: 'operations', width: 100, fixed: 'right' }
|
||||
]
|
||||
|
||||
// 索引对话框
|
||||
const indexDialogVisible = ref(false)
|
||||
const indexFormRef = ref()
|
||||
const indexForm = reactive({
|
||||
name: '',
|
||||
unique: false,
|
||||
fields: [] as string[]
|
||||
})
|
||||
|
||||
// 字段列表事件处理
|
||||
const handleAddField = (field: any) => {
|
||||
formData.fields.push(field)
|
||||
// 自动切换到字段列表 tab
|
||||
if (activeTab.value !== 'fields') {
|
||||
activeTab.value = 'fields'
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateField = (index: number, field: string, value: any) => {
|
||||
if (formData.fields[index]) {
|
||||
formData.fields[index] = { ...formData.fields[index], [field]: value }
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveField = (index: number) => {
|
||||
formData.fields.splice(index, 1)
|
||||
// 同时移除相关索引
|
||||
formData.indexes = formData.indexes.filter((idx: any) => {
|
||||
return idx.fields.every((f: any) => {
|
||||
const fieldName = typeof f === 'string' ? f : f.name
|
||||
return fieldName !== formData.fields[index]?.name
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleMoveField = (index: number, direction: 'up' | 'down') => {
|
||||
if (direction === 'up' && index > 0) {
|
||||
const temp = formData.fields[index]
|
||||
formData.fields[index] = formData.fields[index - 1]
|
||||
formData.fields[index - 1] = temp
|
||||
} else if (direction === 'down' && index < formData.fields.length - 1) {
|
||||
const temp = formData.fields[index]
|
||||
formData.fields[index] = formData.fields[index + 1]
|
||||
formData.fields[index + 1] = temp
|
||||
}
|
||||
}
|
||||
|
||||
const showIndexDialog = () => {
|
||||
if (formData.fields.length === 0) {
|
||||
Message.warning('请先添加字段')
|
||||
return
|
||||
}
|
||||
// 重置表单
|
||||
Object.assign(indexForm, {
|
||||
name: '',
|
||||
unique: false,
|
||||
fields: []
|
||||
})
|
||||
indexDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleIndexDialogOk = async () => {
|
||||
try {
|
||||
// 验证表单
|
||||
await indexFormRef.value?.validate()
|
||||
|
||||
// 检查索引名是否重复
|
||||
if (formData.indexes.some((idx: any) => idx.name === indexForm.name)) {
|
||||
Message.error('索引名已存在')
|
||||
return false // 阻止对话框关闭
|
||||
}
|
||||
|
||||
// 添加索引(深拷贝避免引用问题)
|
||||
const newIndex = {
|
||||
name: indexForm.name,
|
||||
unique: indexForm.unique,
|
||||
fields: indexForm.fields.map((name: string) => ({ name, order: 'ASC' }))
|
||||
}
|
||||
|
||||
formData.indexes.push(newIndex)
|
||||
|
||||
Message.success('索引添加成功')
|
||||
indexDialogVisible.value = false
|
||||
|
||||
// 自动切换到索引列表 tab
|
||||
if (activeTab.value !== 'indexes') {
|
||||
activeTab.value = 'indexes'
|
||||
}
|
||||
} catch (error) {
|
||||
// 表单验证失败时会抛出错误
|
||||
console.error('索引表单验证失败:', error)
|
||||
return false // 阻止对话框关闭
|
||||
}
|
||||
}
|
||||
|
||||
const handleIndexDialogCancel = () => {
|
||||
indexDialogVisible.value = false
|
||||
}
|
||||
|
||||
const removeIndex = (index: number) => {
|
||||
formData.indexes.splice(index, 1)
|
||||
}
|
||||
|
||||
// 复制 SQL
|
||||
const copySQL = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(sqlPreview.value)
|
||||
Message.success('SQL已复制到剪贴板')
|
||||
} catch (error) {
|
||||
Message.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 验证表单
|
||||
const validate = (): boolean => {
|
||||
if (!formData.tableName) {
|
||||
Message.error('请输入表名')
|
||||
return false
|
||||
}
|
||||
|
||||
if (formData.fields.length === 0) {
|
||||
Message.error('请至少添加一个字段')
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否有主键
|
||||
const hasPrimaryKey = formData.fields.some((f: any) => f.primaryKey)
|
||||
if (!hasPrimaryKey) {
|
||||
Message.warning('建议设置主键')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 转义 SQL 字符串(转义单引号)
|
||||
const escapeSQLString = (str: string): string => {
|
||||
return str.replace(/'/g, "''")
|
||||
}
|
||||
|
||||
// 生成 SQL
|
||||
const generateSQL = (): string => {
|
||||
const fieldsSQL = formData.fields.map((field: any) => {
|
||||
let sql = `\`${field.name}\` ${field.type}`
|
||||
if (field.length) {
|
||||
sql += `(${field.length})`
|
||||
}
|
||||
if (!field.nullable) {
|
||||
sql += ' NOT NULL'
|
||||
}
|
||||
// 处理默认值
|
||||
if (field.defaultValue !== null && field.defaultValue !== undefined) {
|
||||
if (field.defaultValue === '') {
|
||||
// 空字符串默认值
|
||||
sql += ` DEFAULT ''`
|
||||
} else {
|
||||
// 转义单引号
|
||||
const escapedDefault = escapeSQLString(String(field.defaultValue))
|
||||
sql += ` DEFAULT '${escapedDefault}'`
|
||||
}
|
||||
}
|
||||
if (field.autoIncrement) {
|
||||
sql += ' AUTO_INCREMENT'
|
||||
}
|
||||
if (field.comment) {
|
||||
// 转义注释中的单引号
|
||||
const escapedComment = escapeSQLString(field.comment)
|
||||
sql += ` COMMENT '${escapedComment}'`
|
||||
}
|
||||
return sql
|
||||
}).join(',\n ')
|
||||
|
||||
// 主键
|
||||
const primaryKeys = formData.fields.filter((f: any) => f.primaryKey).map((f: any) => `\`${f.name}\``)
|
||||
let primaryKeySQL = ''
|
||||
if (primaryKeys.length > 0) {
|
||||
primaryKeySQL = `,\n PRIMARY KEY (${primaryKeys.join(', ')})`
|
||||
}
|
||||
|
||||
// 索引
|
||||
const indexesSQL = formData.indexes.map((idx: any) => {
|
||||
const fields = idx.fields.map((f: any) => `\`${typeof f === 'string' ? f : f.name}\``).join(', ')
|
||||
const unique = idx.unique ? 'UNIQUE ' : ''
|
||||
return ` ${unique}KEY \`${idx.name}\` (${fields})`
|
||||
}).join(',\n')
|
||||
|
||||
const sql = `CREATE TABLE \`${formData.database}\`.\`${formData.tableName}\` (
|
||||
${fieldsSQL}${primaryKeySQL}${indexesSQL ? ',\n' + indexesSQL : ''}
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=${formData.charset} COLLATE=${formData.collation};`
|
||||
|
||||
return sql
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
validate,
|
||||
generateSQL,
|
||||
getFormData: () => formData
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mysql-create {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Tabs 容器 */
|
||||
.create-tabs {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.create-tabs :deep(.arco-tabs) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.create-tabs :deep(.arco-tabs-content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.create-tabs :deep(.arco-tabs-content-list) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.create-tabs :deep(.arco-tabs-content-item) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Tab 内容通用样式 */
|
||||
.tab-content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--spacing-md, 12px);
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 基本信息内容 */
|
||||
.basic-info-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.basic-info-content :deep(.arco-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 字段列表和索引列表内容 */
|
||||
.fields-content,
|
||||
.indexes-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fields-content :deep(.arco-table),
|
||||
.indexes-content :deep(.arco-table) {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* SQL预览内容 */
|
||||
.sql-preview-content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sql-preview-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sql-preview-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: var(--color-fill-2, #f2f3f5);
|
||||
border: 1px solid var(--color-border-2, #e5e6eb);
|
||||
border-radius: var(--border-radius-small, 2px);
|
||||
padding: var(--spacing-md, 12px);
|
||||
}
|
||||
|
||||
.sql-code {
|
||||
margin: 0;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-1, #1d2129);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
446
web/src/views/db-cli/components/MySQLFieldList.vue
Normal file
446
web/src/views/db-cli/components/MySQLFieldList.vue
Normal file
@@ -0,0 +1,446 @@
|
||||
<template>
|
||||
<div class="mysql-field-list">
|
||||
<!-- 创建模式:可编辑表格 + 添加按钮 -->
|
||||
<template v-if="mode === 'create'">
|
||||
<div class="section-header">
|
||||
<a-button type="primary" size="small" @click="handleAddField">
|
||||
<template #icon>
|
||||
<icon-plus />
|
||||
</template>
|
||||
添加字段
|
||||
</a-button>
|
||||
</div>
|
||||
<div v-if="fields.length === 0" class="empty-tip">
|
||||
<a-empty description="暂无字段,请添加字段" :image="false" />
|
||||
</div>
|
||||
<a-table
|
||||
v-else
|
||||
:columns="createModeColumns"
|
||||
:data="fields"
|
||||
:pagination="false"
|
||||
size="mini"
|
||||
:bordered="true"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
/>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- 编辑模式:可编辑表格 -->
|
||||
<template v-else-if="mode === 'edit'">
|
||||
<a-table
|
||||
:columns="editModeColumns"
|
||||
:data="fields"
|
||||
:pagination="false"
|
||||
size="mini"
|
||||
:bordered="true"
|
||||
:scroll="{ y: scrollHeight, x: 'max-content' }"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import {
|
||||
IconPlus,
|
||||
IconUp,
|
||||
IconDown,
|
||||
IconDelete
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
import { Input, Select, Option, Optgroup, InputGroup, Checkbox, Button } from '@arco-design/web-vue'
|
||||
import { mysqlDataTypeOptions, typesNeedLength, parseType, formatType } from '../utils/mysqlFieldUtils'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
mode: 'create' | 'edit'
|
||||
fields: any[]
|
||||
scrollHeight?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
scrollHeight: 400
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'update:fields': [fields: any[]]
|
||||
'add-field': [field: any]
|
||||
'remove-field': [index: number]
|
||||
'move-field': [index: number, direction: 'up' | 'down']
|
||||
'update-field': [index: number, field: string, value: any]
|
||||
}>()
|
||||
|
||||
// 更新字段值
|
||||
const updateFieldValue = (rowIndex: number, field: string, value: any) => {
|
||||
emit('update-field', rowIndex, field, value)
|
||||
}
|
||||
|
||||
// 创建模式:表格列定义(可编辑)
|
||||
const createModeColumns = computed(() => [
|
||||
{
|
||||
title: '序号',
|
||||
width: 80,
|
||||
fixed: 'left',
|
||||
render: ({ rowIndex }: { rowIndex: number }) => rowIndex + 1
|
||||
},
|
||||
{
|
||||
title: '字段名',
|
||||
dataIndex: 'name',
|
||||
width: 150,
|
||||
fixed: 'left',
|
||||
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
|
||||
return h(Input, {
|
||||
modelValue: record.name || '',
|
||||
'onUpdate:modelValue': (val: string) => updateFieldValue(rowIndex, 'name', val),
|
||||
size: 'mini',
|
||||
placeholder: '字段名',
|
||||
style: { width: '100%' }
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
width: 250,
|
||||
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
|
||||
const currentType = record.type || ''
|
||||
const typeStr = currentType + (record.length ? `(${record.length})` : '')
|
||||
const { baseType, length } = parseType(typeStr)
|
||||
|
||||
// 判断当前类型是否需要长度参数
|
||||
const needsLen = baseType && typesNeedLength.includes(baseType.toUpperCase())
|
||||
|
||||
// 检查是否是自定义输入
|
||||
const isCustomInput = typeStr && /[()]/.test(typeStr) && !mysqlDataTypeOptions.some(group =>
|
||||
group.options.some(opt => {
|
||||
const parsed = parseType(typeStr)
|
||||
return parsed.baseType.toUpperCase() === opt.value.toUpperCase()
|
||||
})
|
||||
)
|
||||
|
||||
return h('div', {
|
||||
style: {
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
width: '100%',
|
||||
alignItems: 'center'
|
||||
}
|
||||
}, [
|
||||
// 类型选择下拉框
|
||||
h(Select, {
|
||||
modelValue: baseType || currentType,
|
||||
'onUpdate:modelValue': (val: string) => {
|
||||
if (val) {
|
||||
const isCustom = /[()]/.test(val) || !mysqlDataTypeOptions.some(group =>
|
||||
group.options.some(opt => opt.value.toUpperCase() === val.toUpperCase())
|
||||
)
|
||||
|
||||
if (isCustom) {
|
||||
const parsed = parseType(val)
|
||||
updateFieldValue(rowIndex, 'type', parsed.baseType)
|
||||
if (parsed.length) {
|
||||
updateFieldValue(rowIndex, 'length', parsed.length)
|
||||
}
|
||||
} else {
|
||||
const upperVal = val.toUpperCase()
|
||||
const needsLenParam = typesNeedLength.includes(upperVal)
|
||||
if (needsLenParam) {
|
||||
const keepLength = baseType.toUpperCase() === upperVal && length
|
||||
const newType = keepLength ? formatType(upperVal, length) : upperVal
|
||||
const parsed = parseType(newType)
|
||||
updateFieldValue(rowIndex, 'type', parsed.baseType)
|
||||
if (parsed.length) {
|
||||
updateFieldValue(rowIndex, 'length', parsed.length)
|
||||
} else {
|
||||
updateFieldValue(rowIndex, 'length', undefined)
|
||||
}
|
||||
} else {
|
||||
updateFieldValue(rowIndex, 'type', upperVal)
|
||||
updateFieldValue(rowIndex, 'length', undefined)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updateFieldValue(rowIndex, 'type', '')
|
||||
updateFieldValue(rowIndex, 'length', undefined)
|
||||
}
|
||||
},
|
||||
allowSearch: true,
|
||||
allowCreate: true,
|
||||
size: 'mini',
|
||||
placeholder: '选择类型',
|
||||
style: { flex: needsLen ? '1' : '1 1 auto', minWidth: '100px' },
|
||||
filterOption: (inputValue: string, option: any) => {
|
||||
return option.label?.toLowerCase().includes(inputValue.toLowerCase()) || false
|
||||
}
|
||||
}, {
|
||||
default: () => mysqlDataTypeOptions.map(group =>
|
||||
h(Optgroup, { label: group.label, key: group.label }, {
|
||||
default: () => group.options.map(opt =>
|
||||
h(Option, {
|
||||
label: opt.label,
|
||||
value: opt.value,
|
||||
key: opt.value
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
}),
|
||||
// 长度输入框(仅当类型需要长度参数时显示)
|
||||
needsLen && !isCustomInput ? h(InputGroup, {
|
||||
style: { flex: '0 0 auto', width: '100px' }
|
||||
}, {
|
||||
prepend: () => h('span', {
|
||||
style: {
|
||||
padding: '0 2px',
|
||||
color: 'var(--color-text-2)',
|
||||
fontSize: '12px'
|
||||
}
|
||||
}, '('),
|
||||
default: () => h(Input, {
|
||||
modelValue: length || '',
|
||||
'onUpdate:modelValue': (val: string) => {
|
||||
const trimmedVal = val.trim()
|
||||
updateFieldValue(rowIndex, 'length', trimmedVal || undefined)
|
||||
},
|
||||
size: 'mini',
|
||||
placeholder: '32',
|
||||
style: {
|
||||
textAlign: 'center',
|
||||
padding: '0 2px',
|
||||
width: '60px'
|
||||
}
|
||||
}),
|
||||
append: () => h('span', {
|
||||
style: {
|
||||
padding: '0 2px',
|
||||
color: 'var(--color-text-2)',
|
||||
fontSize: '12px'
|
||||
}
|
||||
}, ')')
|
||||
}) : null
|
||||
])
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '允许NULL',
|
||||
dataIndex: 'nullable',
|
||||
width: 100,
|
||||
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
|
||||
return h(Checkbox, {
|
||||
modelValue: record.nullable !== false,
|
||||
'onUpdate:modelValue': (checked: boolean) => {
|
||||
updateFieldValue(rowIndex, 'nullable', checked)
|
||||
},
|
||||
style: { display: 'flex', justifyContent: 'center' }
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '默认值',
|
||||
dataIndex: 'defaultValue',
|
||||
width: 200,
|
||||
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
|
||||
const defaultValue = record.defaultValue
|
||||
const isNull = defaultValue === null || defaultValue === undefined
|
||||
const isEmptyString = defaultValue === ''
|
||||
|
||||
let currentType: 'NULL' | 'EMPTY' | 'VALUE' = 'VALUE'
|
||||
if (isNull) {
|
||||
currentType = 'NULL'
|
||||
} else if (isEmptyString) {
|
||||
currentType = 'EMPTY'
|
||||
}
|
||||
|
||||
return h('div', { style: { display: 'flex', gap: '4px', width: '100%', alignItems: 'center' } }, [
|
||||
h(Select, {
|
||||
modelValue: currentType,
|
||||
'onUpdate:modelValue': (val: 'NULL' | 'EMPTY' | 'VALUE') => {
|
||||
if (val === 'NULL') {
|
||||
updateFieldValue(rowIndex, 'defaultValue', null)
|
||||
} else if (val === 'EMPTY') {
|
||||
updateFieldValue(rowIndex, 'defaultValue', '')
|
||||
} else if (val === 'VALUE') {
|
||||
if (currentType === 'NULL' || currentType === 'EMPTY') {
|
||||
updateFieldValue(rowIndex, 'defaultValue', '')
|
||||
}
|
||||
}
|
||||
},
|
||||
size: 'mini',
|
||||
style: { width: '70px', flexShrink: 0 },
|
||||
options: [
|
||||
{ label: 'NULL', value: 'NULL' },
|
||||
{ label: "''", value: 'EMPTY' },
|
||||
{ label: '值', value: 'VALUE' }
|
||||
]
|
||||
}),
|
||||
currentType === 'NULL' ? null : h(Input, {
|
||||
modelValue: currentType === 'EMPTY' ? '' : String(defaultValue || ''),
|
||||
'onUpdate:modelValue': (val: string) => {
|
||||
if (currentType === 'EMPTY') {
|
||||
if (val !== '') {
|
||||
updateFieldValue(rowIndex, 'defaultValue', val)
|
||||
}
|
||||
} else {
|
||||
updateFieldValue(rowIndex, 'defaultValue', val)
|
||||
}
|
||||
},
|
||||
size: 'mini',
|
||||
placeholder: currentType === 'EMPTY' ? "空字符串(不可编辑)" : '输入默认值',
|
||||
style: { flex: 1 },
|
||||
disabled: currentType === 'EMPTY'
|
||||
})
|
||||
])
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '主键',
|
||||
dataIndex: 'primaryKey',
|
||||
width: 80,
|
||||
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
|
||||
return h(Checkbox, {
|
||||
modelValue: record.primaryKey || false,
|
||||
'onUpdate:modelValue': (checked: boolean) => {
|
||||
updateFieldValue(rowIndex, 'primaryKey', checked)
|
||||
// 如果取消主键,同时取消自增
|
||||
if (!checked && record.autoIncrement) {
|
||||
updateFieldValue(rowIndex, 'autoIncrement', false)
|
||||
}
|
||||
},
|
||||
style: { display: 'flex', justifyContent: 'center' }
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '自增',
|
||||
dataIndex: 'autoIncrement',
|
||||
width: 80,
|
||||
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
|
||||
const isIntegerType = ['TINYINT', 'SMALLINT', 'MEDIUMINT', 'INT', 'BIGINT'].includes(record.type?.toUpperCase())
|
||||
return h(Checkbox, {
|
||||
modelValue: record.autoIncrement || false,
|
||||
disabled: !isIntegerType,
|
||||
'onUpdate:modelValue': (checked: boolean) => {
|
||||
if (checked && !record.primaryKey) {
|
||||
Message.warning('自增字段必须设置为主键')
|
||||
updateFieldValue(rowIndex, 'primaryKey', true)
|
||||
}
|
||||
updateFieldValue(rowIndex, 'autoIncrement', checked)
|
||||
},
|
||||
style: { display: 'flex', justifyContent: 'center' }
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '注释',
|
||||
dataIndex: 'comment',
|
||||
width: 200,
|
||||
render: ({ record, rowIndex }: { record: any, rowIndex: number }) => {
|
||||
return h(Input, {
|
||||
modelValue: record.comment || '',
|
||||
'onUpdate:modelValue': (val: string) => updateFieldValue(rowIndex, 'comment', val),
|
||||
size: 'mini',
|
||||
placeholder: '字段注释',
|
||||
style: { width: '100%' }
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
render: ({ rowIndex }: { rowIndex: number }) => {
|
||||
const totalRows = props.fields.length
|
||||
return h('div', { style: { display: 'flex', gap: '4px', alignItems: 'center' } }, [
|
||||
h(Button, {
|
||||
type: 'text',
|
||||
size: 'mini',
|
||||
disabled: rowIndex === 0,
|
||||
onClick: () => handleMoveField(rowIndex, 'up'),
|
||||
style: { padding: '0 4px' }
|
||||
}, {
|
||||
default: () => h(IconUp, { style: { fontSize: '12px' } })
|
||||
}),
|
||||
h(Button, {
|
||||
type: 'text',
|
||||
size: 'mini',
|
||||
disabled: rowIndex === totalRows - 1,
|
||||
onClick: () => handleMoveField(rowIndex, 'down'),
|
||||
style: { padding: '0 4px' }
|
||||
}, {
|
||||
default: () => h(IconDown, { style: { fontSize: '12px' } })
|
||||
}),
|
||||
h(Button, {
|
||||
type: 'text',
|
||||
size: 'mini',
|
||||
status: 'danger',
|
||||
onClick: () => handleRemoveField(rowIndex),
|
||||
style: { padding: '0 4px' }
|
||||
}, {
|
||||
default: () => h(IconDelete, { style: { fontSize: '12px' } })
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
// 编辑模式:表格列定义(需要从 ResultPanel 中提取相关逻辑)
|
||||
// 这里先简化,后续可以完善
|
||||
const editModeColumns = computed(() => {
|
||||
// TODO: 从 ResultPanel 中提取可编辑列定义
|
||||
// 暂时返回基本列
|
||||
return [
|
||||
{ title: '字段名', dataIndex: 'Field', width: 150 },
|
||||
{ title: '类型', dataIndex: 'Type', width: 200 },
|
||||
{ title: '允许NULL', dataIndex: 'Null', width: 100 },
|
||||
{ title: '默认值', dataIndex: 'Default', width: 150 },
|
||||
{ title: '注释', dataIndex: 'Comment', width: 200 }
|
||||
]
|
||||
})
|
||||
|
||||
// 创建模式:添加字段
|
||||
const handleAddField = () => {
|
||||
const newField = {
|
||||
name: '',
|
||||
type: 'VARCHAR',
|
||||
length: 50,
|
||||
nullable: true,
|
||||
defaultValue: null,
|
||||
primaryKey: false,
|
||||
autoIncrement: false,
|
||||
comment: ''
|
||||
}
|
||||
emit('add-field', newField)
|
||||
}
|
||||
|
||||
const handleRemoveField = (index: number) => {
|
||||
emit('remove-field', index)
|
||||
}
|
||||
|
||||
const handleMoveField = (index: number, direction: 'up' | 'down') => {
|
||||
emit('move-field', index, direction)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mysql-field-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
2437
web/src/views/db-cli/components/ResultPanel.vue
Normal file
2437
web/src/views/db-cli/components/ResultPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
460
web/src/views/db-cli/components/SqlEditor.vue
Normal file
460
web/src/views/db-cli/components/SqlEditor.vue
Normal file
@@ -0,0 +1,460 @@
|
||||
<template>
|
||||
<div class="sql-editor-wrapper">
|
||||
<div class="editor-toolbar">
|
||||
<a-space>
|
||||
<a-button type="outline" @click="handleExecute">
|
||||
<template #icon>
|
||||
<icon-play-arrow/>
|
||||
</template>
|
||||
{{ getExecuteButtonText() }} (F5)
|
||||
</a-button>
|
||||
<a-button type="outline" @click="handleExecuteSelected">
|
||||
<template #icon>
|
||||
<icon-code/>
|
||||
</template>
|
||||
执行选中 (Ctrl+Enter)
|
||||
</a-button>
|
||||
</a-space>
|
||||
<a-space v-if="currentConnection">
|
||||
<a-tag color="blue" size="small">
|
||||
<template #icon>
|
||||
<icon-storage/>
|
||||
</template>
|
||||
{{ currentConnection.name }}
|
||||
</a-tag>
|
||||
<span class="connection-info">
|
||||
{{ currentConnection.host }}:{{ currentConnection.port }}
|
||||
<span v-if="currentConnection.database" class="database-name">
|
||||
/ {{ currentConnection.database }}
|
||||
</span>
|
||||
</span>
|
||||
</a-space>
|
||||
<span v-else class="connection-info-empty">
|
||||
未选择连接
|
||||
</span>
|
||||
</div>
|
||||
<div class="editor-container">
|
||||
<div class="code-editor" ref="editorContainerRef"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue'
|
||||
import {Message} from '@arco-design/web-vue'
|
||||
import {IconPlayArrow, IconStorage, IconCode} from '@arco-design/web-vue/es/icon'
|
||||
import {EditorView, keymap, lineNumbers} from '@codemirror/view'
|
||||
import {EditorState} from '@codemirror/state'
|
||||
import {sql} from '@codemirror/lang-sql'
|
||||
import {javascript} from '@codemirror/lang-javascript'
|
||||
import {defaultKeymap, history, historyKeymap} from '@codemirror/commands'
|
||||
import {defaultHighlightStyle, syntaxHighlighting} from '@codemirror/language'
|
||||
import {useTabPersistence} from '../composables/useTabPersistence'
|
||||
|
||||
// ==================== Props & Events ====================
|
||||
const props = defineProps({
|
||||
currentConnection: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['execute', 'execute-selected', 'format'])
|
||||
|
||||
// 常量配置
|
||||
const STORAGE_KEY_EDITOR_CONTENT = 'db-cli:editor-content'
|
||||
|
||||
// 标签页持久化
|
||||
const tabPersistence = useTabPersistence()
|
||||
|
||||
// 数据库类型配置
|
||||
const DB_CONFIG = {
|
||||
mysql: {
|
||||
language: () => sql(),
|
||||
defaultContent: 'select 1;',
|
||||
executeText: '执行'
|
||||
},
|
||||
redis: {
|
||||
language: () => javascript({ jsx: false, typescript: false }),
|
||||
defaultContent: 'GET key\nSET key value\nHGET hash field',
|
||||
executeText: '执行命令'
|
||||
},
|
||||
mongo: {
|
||||
language: () => javascript({ jsx: false, typescript: false }),
|
||||
defaultContent: 'db.collection.find({})\n// 示例:db.users.find({name: "John"})',
|
||||
executeText: '执行查询'
|
||||
},
|
||||
mongodb: {
|
||||
language: () => javascript({ jsx: false, typescript: false }),
|
||||
defaultContent: 'db.collection.find({})\n// 示例:db.users.find({name: "John"})',
|
||||
executeText: '执行查询'
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
const getDbType = () => props.currentConnection?.type?.toLowerCase() || 'mysql'
|
||||
const getDbConfig = (dbType = null) => DB_CONFIG[dbType || getDbType()] || DB_CONFIG.mysql
|
||||
const getLanguageMode = (dbType = null) => getDbConfig(dbType).language()
|
||||
const getDefaultContent = (dbType = null) => getDbConfig(dbType).defaultContent
|
||||
const getExecuteButtonText = () => getDbConfig().executeText
|
||||
|
||||
// ==================== 编辑器管理 ====================
|
||||
const editorContainerRef = ref(null)
|
||||
let editorView = null
|
||||
let saveTimer = null
|
||||
|
||||
// 创建编辑器扩展
|
||||
const createEditorExtensions = () => {
|
||||
const dbType = getDbType()
|
||||
const languageMode = getLanguageMode(dbType)
|
||||
|
||||
return [
|
||||
EditorState.lineSeparator.of('\n'),
|
||||
lineNumbers(),
|
||||
history(),
|
||||
languageMode,
|
||||
syntaxHighlighting(defaultHighlightStyle),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
const content = update.state.doc.toString()
|
||||
localStorage.setItem(STORAGE_KEY_EDITOR_CONTENT, content)
|
||||
}
|
||||
}),
|
||||
keymap.of([
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
{
|
||||
key: 'Mod-Enter',
|
||||
run: () => {
|
||||
handleExecuteSelected()
|
||||
return true
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'F5',
|
||||
run: () => {
|
||||
handleExecute()
|
||||
return true
|
||||
}
|
||||
}
|
||||
]),
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
fontSize: '13px',
|
||||
fontFamily: "'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'Courier New', monospace",
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--color-bg-1)',
|
||||
color: 'var(--color-text-1)'
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '12px',
|
||||
backgroundColor: 'var(--color-bg-1)',
|
||||
color: 'var(--color-text-1)',
|
||||
caretColor: 'var(--color-text-1)'
|
||||
},
|
||||
'.cm-editor': {
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
backgroundColor: 'var(--color-bg-1)'
|
||||
},
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'&.cm-focused .cm-cursor': {
|
||||
borderLeftColor: 'var(--color-text-1)',
|
||||
borderLeftWidth: '2px'
|
||||
},
|
||||
'&.cm-focused .cm-cursor-primary': {
|
||||
borderLeftColor: 'var(--color-text-1)',
|
||||
borderLeftWidth: '2px'
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
maxHeight: '100%',
|
||||
backgroundColor: 'var(--color-bg-1)'
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--color-bg-2)',
|
||||
border: 'none',
|
||||
color: 'var(--color-text-3)'
|
||||
},
|
||||
'.cm-lineNumbers': { color: 'var(--color-text-3)' },
|
||||
'.cm-line': { color: 'var(--color-text-1)' },
|
||||
'.cm-activeLine': { backgroundColor: 'var(--color-fill-2)' },
|
||||
'.cm-selectionMatch': { backgroundColor: 'var(--color-primary-light-4)' },
|
||||
'.cm-dropCursor': {
|
||||
borderLeftColor: 'var(--color-text-1)',
|
||||
borderLeftWidth: '2px'
|
||||
}
|
||||
}, { dark: false }),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.contentAttributes.of({contenteditable: 'true'}),
|
||||
]
|
||||
}
|
||||
|
||||
// 初始化编辑器
|
||||
const initEditor = async () => {
|
||||
if (!editorContainerRef.value) return false
|
||||
|
||||
// 销毁旧编辑器
|
||||
if (editorView) {
|
||||
editorView.destroy()
|
||||
editorView = null
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
await new Promise(resolve => requestAnimationFrame(resolve))
|
||||
|
||||
const container = editorContainerRef.value
|
||||
if (!container) return false
|
||||
|
||||
const savedContent = localStorage.getItem(STORAGE_KEY_EDITOR_CONTENT)
|
||||
const initialContent = savedContent || getDefaultContent()
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: initialContent,
|
||||
extensions: createEditorExtensions()
|
||||
})
|
||||
|
||||
editorView = new EditorView({
|
||||
state,
|
||||
parent: container
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 获取编辑器实例
|
||||
const getEditor = () => editorView
|
||||
|
||||
// ==================== 事件处理 ====================
|
||||
const validateEditor = () => {
|
||||
if (!props.currentConnection) {
|
||||
Message.warning('请先选择数据库连接')
|
||||
return null
|
||||
}
|
||||
if (!editorView) {
|
||||
Message.warning('编辑器未初始化')
|
||||
return null
|
||||
}
|
||||
return editorView
|
||||
}
|
||||
|
||||
const handleExecute = () => {
|
||||
const editor = validateEditor()
|
||||
if (!editor) return
|
||||
|
||||
const content = editor.state.doc.toString().trim()
|
||||
if (!content) {
|
||||
Message.warning('SQL 语句不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
emit('execute', content)
|
||||
}
|
||||
|
||||
const handleExecuteSelected = () => {
|
||||
const editor = validateEditor()
|
||||
if (!editor) return
|
||||
|
||||
const selection = editor.state.selection.main
|
||||
if (!selection || selection.empty) {
|
||||
Message.warning('请先选中要执行的 SQL 语句')
|
||||
return
|
||||
}
|
||||
|
||||
const content = editor.state.doc.sliceString(selection.from, selection.to).trim()
|
||||
if (!content) {
|
||||
Message.warning('选中的内容为空')
|
||||
return
|
||||
}
|
||||
|
||||
emit('execute-selected', content)
|
||||
}
|
||||
|
||||
const insertSQL = async (sql) => {
|
||||
const editor = getEditor()
|
||||
if (!editor) {
|
||||
await nextTick()
|
||||
await new Promise(resolve => requestAnimationFrame(resolve))
|
||||
const retryEditor = getEditor()
|
||||
if (!retryEditor) {
|
||||
await initEditor()
|
||||
const newEditor = getEditor()
|
||||
if (newEditor) {
|
||||
insertSQL(sql)
|
||||
}
|
||||
return
|
||||
}
|
||||
insertSQL(sql)
|
||||
return
|
||||
}
|
||||
|
||||
const transaction = editor.state.update({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: editor.state.doc.length,
|
||||
insert: sql
|
||||
}
|
||||
})
|
||||
editor.dispatch(transaction)
|
||||
editor.focus()
|
||||
}
|
||||
|
||||
// ==================== 监听器 ====================
|
||||
watch(() => props.currentConnection, async (newConn, oldConn) => {
|
||||
// 只有数据库类型改变时才重新初始化编辑器
|
||||
if (oldConn && newConn && oldConn.type !== newConn.type) {
|
||||
const currentContent = editorView ? editorView.state.doc.toString() : ''
|
||||
await initEditor()
|
||||
// 恢复内容
|
||||
if (editorView && currentContent) {
|
||||
const transaction = editorView.state.update({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: editorView.state.doc.length,
|
||||
insert: currentContent
|
||||
}
|
||||
})
|
||||
editorView.dispatch(transaction)
|
||||
}
|
||||
}
|
||||
}, {deep: true})
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
onBeforeUnmount(() => {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = null
|
||||
}
|
||||
if (editorView) {
|
||||
editorView.destroy()
|
||||
editorView = null
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
await new Promise(resolve => requestAnimationFrame(resolve))
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
await initEditor()
|
||||
})
|
||||
|
||||
// ==================== 暴露方法 ====================
|
||||
defineExpose({
|
||||
insertSQL,
|
||||
getTabs: () => [], // 兼容父组件,但不再支持多标签页
|
||||
|
||||
/**
|
||||
* 保存当前编辑器状态为单个标签页
|
||||
* 可用于应用关闭前保存状态
|
||||
*/
|
||||
saveCurrentTab: async () => {
|
||||
if (!editorView) return null
|
||||
const content = editorView.state.doc.toString()
|
||||
const tabData = [{
|
||||
id: 0, // 新标签页
|
||||
title: props.currentConnection?.name ? `${props.currentConnection.name} - 查询` : '未命名查询',
|
||||
content: content,
|
||||
connectionId: props.currentConnection?.id || null,
|
||||
order: 0
|
||||
}]
|
||||
const success = await tabPersistence.saveTabs(tabData)
|
||||
return success ? tabData[0] : null
|
||||
},
|
||||
|
||||
/**
|
||||
* 加载保存的标签页
|
||||
* 可用于应用启动时恢复状态
|
||||
*/
|
||||
loadSavedTabs: async () => {
|
||||
const savedTabs = await tabPersistence.loadTabs()
|
||||
if (savedTabs && savedTabs.length > 0) {
|
||||
// 恢复第一个标签页的内容
|
||||
const firstTab = savedTabs[0]
|
||||
if (firstTab.content && editorView) {
|
||||
const transaction = editorView.state.update({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: editorView.state.doc.length,
|
||||
insert: firstTab.content
|
||||
}
|
||||
})
|
||||
editorView.dispatch(transaction)
|
||||
}
|
||||
}
|
||||
return savedTabs
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sql-editor-wrapper {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--spacing-md, 12px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.code-editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: var(--border-radius-medium, 4px);
|
||||
}
|
||||
|
||||
/* CodeMirror 编辑器滚动支持 */
|
||||
.code-editor :deep(.cm-editor) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.code-editor :deep(.cm-scroller) {
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing-sm, 8px) var(--spacing-md, 12px);
|
||||
border-bottom: 1px solid var(--color-border-2);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md, 12px);
|
||||
background: var(--color-bg-1);
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.connection-info {
|
||||
font-size: var(--font-size-xs, 12px);
|
||||
font-family: var(--font-family-mono, monospace);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.database-name {
|
||||
margin-left: var(--spacing-sm, 8px);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
.connection-info-empty {
|
||||
font-size: var(--font-size-xs, 12px);
|
||||
color: var(--color-text-3);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
182
web/src/views/db-cli/components/SqlPreviewDialog.vue
Normal file
182
web/src/views/db-cli/components/SqlPreviewDialog.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div class="sql-preview-dialog">
|
||||
<div class="sql-preview-header">
|
||||
<span class="sql-preview-title">将执行 {{ statements.length }} 条{{ dbType === 'mysql' ? 'SQL' : 'MongoDB' }}语句:</span>
|
||||
<a-button type="text" size="small" @click="handleCopy">
|
||||
<template #icon>
|
||||
<icon-copy />
|
||||
</template>
|
||||
复制
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="sql-preview-content" ref="editorContainerRef"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconCopy } from '@arco-design/web-vue/es/icon'
|
||||
import { EditorView, lineNumbers } from '@codemirror/view'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { sql } from '@codemirror/lang-sql'
|
||||
import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
|
||||
interface Props {
|
||||
statements: string[]
|
||||
dbType?: 'mysql' | 'mongo' | 'redis'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
dbType: 'mysql'
|
||||
})
|
||||
|
||||
const editorContainerRef = ref<HTMLElement | null>(null)
|
||||
let editorView: EditorView | null = null
|
||||
|
||||
// 格式化 SQL 语句(添加分号,分离编号)
|
||||
const formatStatements = (statements: string[]): string => {
|
||||
return statements.map((stmt, index) => {
|
||||
// 确保语句末尾有分号
|
||||
const trimmedStmt = stmt.trim()
|
||||
const sql = trimmedStmt.endsWith(';') ? trimmedStmt : trimmedStmt + ';'
|
||||
return sql
|
||||
}).join('\n\n')
|
||||
}
|
||||
|
||||
// 获取所有 SQL(用于复制)
|
||||
const getAllSQL = (): string => {
|
||||
return formatStatements(props.statements)
|
||||
}
|
||||
|
||||
// 初始化编辑器
|
||||
const initEditor = async () => {
|
||||
if (!editorContainerRef.value) return
|
||||
|
||||
// 销毁旧编辑器
|
||||
if (editorView) {
|
||||
editorView.destroy()
|
||||
editorView = null
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const sqlText = formatStatements(props.statements)
|
||||
|
||||
// 检测是否为暗色主题
|
||||
const isDark = document.body.hasAttribute('arco-theme')
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: sqlText,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
sql(),
|
||||
syntaxHighlighting(defaultHighlightStyle),
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
fontSize: '13px',
|
||||
fontFamily: "'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace",
|
||||
height: '100%'
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '12px',
|
||||
backgroundColor: 'var(--color-bg-1)',
|
||||
color: 'var(--color-text-1)'
|
||||
},
|
||||
'.cm-editor': {
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--color-bg-1)'
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto'
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--color-bg-2)',
|
||||
border: 'none',
|
||||
color: 'var(--color-text-3)'
|
||||
},
|
||||
'.cm-lineNumbers': {
|
||||
color: 'var(--color-text-3)'
|
||||
},
|
||||
'&.cm-focused': {
|
||||
outline: 'none'
|
||||
}
|
||||
}, { dark: isDark }),
|
||||
EditorView.editable.of(false), // 只读
|
||||
EditorView.lineWrapping
|
||||
]
|
||||
})
|
||||
|
||||
editorView = new EditorView({
|
||||
state,
|
||||
parent: editorContainerRef.value
|
||||
})
|
||||
}
|
||||
|
||||
// 复制 SQL
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
const sqlText = getAllSQL()
|
||||
await navigator.clipboard.writeText(sqlText)
|
||||
Message.success('SQL已复制到剪贴板')
|
||||
} catch (error) {
|
||||
Message.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.statements, () => {
|
||||
initEditor()
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initEditor()
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (editorView) {
|
||||
editorView.destroy()
|
||||
editorView = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sql-preview-dialog {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sql-preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.sql-preview-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sql-preview-content {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
max-height: 400px;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-1);
|
||||
}
|
||||
|
||||
.sql-preview-content :deep(.cm-editor) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sql-preview-content :deep(.cm-scroller) {
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
39
web/src/views/db-cli/components/result/MessageLog.vue
Normal file
39
web/src/views/db-cli/components/result/MessageLog.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="messages-content">
|
||||
<div v-for="(msg, index) in messages" :key="index" class="message-item">
|
||||
<a-tag :color="msg.type === 'error' ? 'red' : 'blue'">{{ msg.time }}</a-tag>
|
||||
{{ msg.content }}
|
||||
</div>
|
||||
<div v-if="messages.length === 0" class="messages-empty">
|
||||
<a-empty description="暂无消息"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
messages: Array<{ type?: string; time: string; content: string }>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.messages-content {
|
||||
flex: 1;
|
||||
padding: var(--spacing-md, 12px);
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.messages-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
margin-bottom: var(--spacing-sm, 8px);
|
||||
font-size: var(--font-size-xs, 12px);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
</style>
|
||||
77
web/src/views/db-cli/components/result/README.md
Normal file
77
web/src/views/db-cli/components/result/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Result 组件重构
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
result/
|
||||
├── ResultTab.vue # 结果标签页容器(组合 Stats + Table/Json)
|
||||
├── ResultStats.vue # 统计信息栏
|
||||
├── ResultTable.vue # 表格视图(含分页)
|
||||
├── ResultJson.vue # JSON 视图
|
||||
├── MessageLog.vue # 消息日志
|
||||
├── types.ts # 类型定义
|
||||
└── index.ts # 导出
|
||||
```
|
||||
|
||||
## 组件职责
|
||||
|
||||
### ResultTab.vue
|
||||
- 组合 ResultStats、ResultTable、ResultJson
|
||||
- 管理视图模式切换(表格/JSON)
|
||||
- 处理加载和错误状态
|
||||
|
||||
### ResultStats.vue
|
||||
- 显示行数、执行时间
|
||||
- 视图模式切换按钮
|
||||
|
||||
### ResultTable.vue
|
||||
- 表格展示
|
||||
- 分页控制
|
||||
- 高度自适应
|
||||
- 单元格格式化和提示
|
||||
|
||||
### ResultJson.vue
|
||||
- JSON 格式展示
|
||||
- 语法高亮
|
||||
|
||||
### MessageLog.vue
|
||||
- 消息列表展示
|
||||
- 消息类型标识
|
||||
|
||||
## 使用示例
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<ResultTab
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:data="resultData"
|
||||
:stats="stats"
|
||||
:columns="columns"
|
||||
:page="currentPage"
|
||||
@re-execute-sql="handleReExecute"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ResultTab } from './result'
|
||||
</script>
|
||||
```
|
||||
|
||||
## 迁移计划
|
||||
|
||||
### 阶段 1:测试新组件
|
||||
- 在 ResultPanel.vue 中引入并测试 ResultTab
|
||||
- 验证功能完整性
|
||||
|
||||
### 阶段 2:替换旧代码
|
||||
- 用 ResultTab 替换 ResultPanel.vue 中的结果展示部分
|
||||
- 用 MessageLog 替换消息日志部分
|
||||
|
||||
### 阶段 3:拆分其他功能
|
||||
- 将表结构相关功能拆分为 StructureTab 组件
|
||||
- 将查询历史拆分为 QueryHistory 组件
|
||||
|
||||
### 阶段 4:简化 ResultPanel.vue
|
||||
- ResultPanel.vue 变成轻量的标签页容器
|
||||
- 只负责标签切换和状态管理
|
||||
73
web/src/views/db-cli/components/result/ResultJson.vue
Normal file
73
web/src/views/db-cli/components/result/ResultJson.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="result-json-container">
|
||||
<pre class="result-json" v-html="highlightedJson"></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
data: any[]
|
||||
}>()
|
||||
|
||||
// 转义 HTML
|
||||
const escapeHtml = (str: string) => str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// JSON 高亮
|
||||
const highlightedJson = computed(() => {
|
||||
const json = JSON.stringify(props.data, null, 2)
|
||||
if (!json) return ''
|
||||
|
||||
return escapeHtml(json)
|
||||
.replace(/: ("(?:[^"\\]|\\.)*")/g, ': <span class="json-string">$1</span>')
|
||||
.replace(/: (-?\d+\.?\d*(?:e[+-]?\d+)?)/gi, ': <span class="json-number">$1</span>')
|
||||
.replace(/: (true|false|null)/g, ': <span class="json-boolean">$1</span>')
|
||||
.replace(/"([^"]+)":/g, '<span class="json-key">"$1"</span>:')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.result-json-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
padding: var(--spacing-sm, 8px);
|
||||
}
|
||||
|
||||
.result-json {
|
||||
margin: 0;
|
||||
padding: var(--spacing-md, 16px);
|
||||
border-radius: var(--border-radius-medium, 6px);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
background: linear-gradient(135deg, var(--color-bg-3) 0%, var(--color-bg-2) 100%);
|
||||
color: var(--color-text-2);
|
||||
border: 1px solid var(--color-border-2);
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.result-json :deep(.json-key) {
|
||||
color: rgb(var(--arcoblue-6, 22, 93, 255));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-json :deep(.json-string) {
|
||||
color: rgb(var(--green-6, 0, 180, 42));
|
||||
}
|
||||
|
||||
.result-json :deep(.json-number) {
|
||||
color: rgb(var(--orange-6, 255, 125, 0));
|
||||
}
|
||||
|
||||
.result-json :deep(.json-boolean) {
|
||||
color: rgb(var(--purple-6, 114, 46, 209));
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
41
web/src/views/db-cli/components/result/ResultStats.vue
Normal file
41
web/src/views/db-cli/components/result/ResultStats.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="result-stats">
|
||||
<a-space>
|
||||
<span>{{ rowsLabel }}: {{ rowsAffected }}</span>
|
||||
<a-divider type="vertical"/>
|
||||
<span>执行时间: {{ executionTime }}ms</span>
|
||||
<a-divider type="vertical"/>
|
||||
<a-radio-group :model-value="viewMode" type="button" size="mini" @update:model-value="$emit('update:viewMode', $event)">
|
||||
<a-radio value="table">表格</a-radio>
|
||||
<a-radio value="json">JSON</a-radio>
|
||||
</a-radio-group>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
rowsLabel: string
|
||||
rowsAffected: number
|
||||
executionTime: number
|
||||
viewMode: 'table' | 'json'
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:viewMode': [mode: 'table' | 'json']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.result-stats {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: var(--spacing-xs, 4px);
|
||||
padding: var(--spacing-xs, 4px) var(--spacing-md, 12px);
|
||||
font-size: var(--font-size-xs, 12px);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.result-stats span {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
</style>
|
||||
126
web/src/views/db-cli/components/result/ResultTab.vue
Normal file
126
web/src/views/db-cli/components/result/ResultTab.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="result-content">
|
||||
<div v-if="loading" class="result-loading">
|
||||
<a-spin/>
|
||||
<span>执行中...</span>
|
||||
</div>
|
||||
<div v-else-if="error">
|
||||
<a-alert type="error" show-icon>
|
||||
{{ error }}
|
||||
</a-alert>
|
||||
</div>
|
||||
<div v-else-if="data !== null" class="result-data-wrapper">
|
||||
<ResultStats
|
||||
v-if="stats"
|
||||
:rows-label="rowsLabel"
|
||||
:rows-affected="stats.rowsAffected"
|
||||
:execution-time="stats.executionTime"
|
||||
:view-mode="viewMode"
|
||||
@update:viewMode="viewMode = $event"
|
||||
/>
|
||||
<ResultTable
|
||||
v-if="viewMode === 'table' && data.length > 0"
|
||||
:columns="tableColumns"
|
||||
:data="pagedData"
|
||||
:loading="loading"
|
||||
:page="page"
|
||||
:can-go-next="canGoNext"
|
||||
@page-change="$emit('re-execute-sql', { page: $event, pageSize: 10 })"
|
||||
/>
|
||||
<div v-else-if="viewMode === 'table' && data.length === 0" class="result-empty-table">
|
||||
<a-empty description="查询结果为空" :image="false"/>
|
||||
</div>
|
||||
<ResultJson v-else-if="viewMode === 'json'" :data="data" />
|
||||
</div>
|
||||
<div v-else class="result-empty">
|
||||
<a-empty description="暂无执行结果"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, type ComputedRef } from 'vue'
|
||||
import { Tooltip } from '@arco-design/web-vue'
|
||||
import ResultStats from './ResultStats.vue'
|
||||
import ResultTable from './ResultTable.vue'
|
||||
import ResultJson from './ResultJson.vue'
|
||||
import type { TableColumn } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
loading: boolean
|
||||
error: string
|
||||
data: any[] | null
|
||||
stats?: { rowsAffected: number; executionTime: number }
|
||||
columns: string[]
|
||||
page: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
're-execute-sql': [params: { page: number; pageSize: number }]
|
||||
}>()
|
||||
|
||||
const viewMode = ref<'table' | 'json'>('table')
|
||||
|
||||
const rowsLabel = computed(() => {
|
||||
if (!props.data || props.data.length === 0) return '影响行数'
|
||||
return '返回行数'
|
||||
})
|
||||
|
||||
// 表格列定义
|
||||
const tableColumns: ComputedRef<TableColumn[]> = computed(() => {
|
||||
if (props.columns?.length > 0) {
|
||||
return props.columns.map(key => ({
|
||||
title: key,
|
||||
dataIndex: key,
|
||||
width: 150
|
||||
}))
|
||||
}
|
||||
if (!props.data?.length) return []
|
||||
const firstRow = props.data[0] as Record<string, any>
|
||||
return Object.keys(firstRow).map(key => ({
|
||||
title: key,
|
||||
dataIndex: key,
|
||||
width: 150
|
||||
}))
|
||||
})
|
||||
|
||||
const pagedData = computed(() => props.data || [])
|
||||
const canGoNext = computed(() => {
|
||||
if (!props.data || props.data.length === 0) return false
|
||||
return props.data.length >= 10
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.result-content {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--spacing-md, 12px);
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.result-loading,
|
||||
.result-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.result-data-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result-empty-table {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
227
web/src/views/db-cli/components/result/ResultTable.vue
Normal file
227
web/src/views/db-cli/components/result/ResultTable.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="result-table-container" ref="containerRef">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:pagination="false"
|
||||
:loading="loading"
|
||||
size="mini"
|
||||
:scroll="{ x: 'max-content', y: tableScrollHeight }"
|
||||
:bordered="true"
|
||||
class="result-table"
|
||||
column-resizable
|
||||
/>
|
||||
<div class="custom-pagination">
|
||||
<a-space>
|
||||
<a-button
|
||||
size="small"
|
||||
:disabled="page <= 1 || loading"
|
||||
@click="$emit('page-change', page - 1)"
|
||||
>
|
||||
上一页
|
||||
</a-button>
|
||||
<span style="color: var(--color-text-3); font-size: 12px;">
|
||||
第 {{ page }} 页,{{ data.length }} 条
|
||||
<span v-if="!canGoNext && !loading" style="color: var(--color-text-4);">(已到最后一页)</span>
|
||||
</span>
|
||||
<a-button
|
||||
size="small"
|
||||
:disabled="!canGoNext || loading"
|
||||
@click="$emit('page-change', page + 1)"
|
||||
>
|
||||
下一页
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted, h } from 'vue'
|
||||
import { Tooltip } from '@arco-design/web-vue'
|
||||
import type { TableColumn } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
columns: TableColumn[]
|
||||
data: any[]
|
||||
loading: boolean
|
||||
page: number
|
||||
canGoNext: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'page-change': [page: number]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const tableScrollHeight = ref(400)
|
||||
|
||||
// 格式化单元格值
|
||||
const formatCellValue = (value: unknown): string => {
|
||||
if (value === null) return 'null'
|
||||
if (value === undefined) return ''
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// 渲染表格列
|
||||
const renderedColumns = computed(() => {
|
||||
return props.columns.map(col => ({
|
||||
...col,
|
||||
render: ({ record }: { record: Record<string, unknown> }) => {
|
||||
const value = record[col.dataIndex]
|
||||
const formattedValue = formatCellValue(value)
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const jsonStr = JSON.stringify(value, null, 2)
|
||||
return h(Tooltip, { content: jsonStr }, {
|
||||
default: () => h('span', { class: 'cell-json cell-content' }, formattedValue)
|
||||
})
|
||||
}
|
||||
|
||||
return h(Tooltip, {
|
||||
content: formattedValue,
|
||||
disabled: !formattedValue
|
||||
}, {
|
||||
default: () => h('span', { class: 'cell-content' }, formattedValue)
|
||||
})
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
// 更新表格高度
|
||||
const updateTableHeight = () => {
|
||||
setTimeout(() => {
|
||||
if (!containerRef.value) return
|
||||
|
||||
const container = containerRef.value
|
||||
const containerHeight = container.offsetHeight
|
||||
const paginationEl = container.querySelector('.custom-pagination') as HTMLElement
|
||||
const paginationHeight = paginationEl ? paginationEl.offsetHeight : 40
|
||||
const tableHeaderEl = container.querySelector('.arco-table-header') as HTMLElement
|
||||
const tableHeaderHeight = tableHeaderEl ? tableHeaderEl.offsetHeight : 40
|
||||
|
||||
const availableHeight = containerHeight - paginationHeight - tableHeaderHeight - 8
|
||||
tableScrollHeight.value = Math.max(100, availableHeight > 0 ? availableHeight : 400)
|
||||
}, 150)
|
||||
}
|
||||
|
||||
// 监听数据变化
|
||||
watch(() => props.data, () => {
|
||||
nextTick(updateTableHeight)
|
||||
})
|
||||
|
||||
// 窗口调整
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const handleResize = () => {
|
||||
if (resizeTimer) clearTimeout(resizeTimer)
|
||||
resizeTimer = setTimeout(updateTableHeight, 100)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(updateTableHeight)
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
updateHeight: updateTableHeight
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.result-table-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.result-table-container :deep(.arco-table) {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result-table-container :deep(.arco-table-body) {
|
||||
overflow-y: auto !important;
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
|
||||
.custom-pagination {
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing-sm, 8px);
|
||||
border-top: 1px solid var(--color-border-2);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.result-table-container :deep(.result-table) {
|
||||
font-size: var(--font-size-xs, 12px);
|
||||
}
|
||||
|
||||
.result-table-container :deep(.result-table .arco-table-th) {
|
||||
padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.result-table-container :deep(.result-table .arco-table-td) {
|
||||
padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px);
|
||||
max-width: 0;
|
||||
}
|
||||
|
||||
.result-table-container :deep(.result-table .arco-table-td .cell-content),
|
||||
.result-table-container :deep(.result-table .arco-table-td .cell-json) {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-table-container :deep(.result-table .arco-table-tr) {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.result-table-container :deep(.result-table .arco-table-tbody .arco-table-tr) {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.result-table-container :deep(.cell-json) {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
background: var(--color-fill-2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.result-table-container :deep(.cell-json:hover) {
|
||||
background: var(--color-fill-3);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.result-table-container :deep(.cell-content) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
10
web/src/views/db-cli/components/result/index.ts
Normal file
10
web/src/views/db-cli/components/result/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 结果展示组件导出
|
||||
*/
|
||||
|
||||
export { default as ResultTab } from './ResultTab.vue'
|
||||
export { default as ResultStats } from './ResultStats.vue'
|
||||
export { default as ResultTable } from './ResultTable.vue'
|
||||
export { default as ResultJson } from './ResultJson.vue'
|
||||
export { default as MessageLog } from './MessageLog.vue'
|
||||
export * from './types'
|
||||
21
web/src/views/db-cli/components/result/types.ts
Normal file
21
web/src/views/db-cli/components/result/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 结果展示组件类型定义
|
||||
*/
|
||||
|
||||
export interface TableColumn {
|
||||
title: string
|
||||
dataIndex: string
|
||||
width?: number
|
||||
render?: (params: { record: Record<string, unknown> }) => unknown
|
||||
}
|
||||
|
||||
export interface ResultStats {
|
||||
rowsAffected: number
|
||||
executionTime: number
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
type?: string
|
||||
time: string
|
||||
content: string
|
||||
}
|
||||
118
web/src/views/db-cli/composables/MIGRATION.md
Normal file
118
web/src/views/db-cli/composables/MIGRATION.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# 架构迁移指南
|
||||
|
||||
## 新架构:事件驱动 + 单例 Store
|
||||
|
||||
### 核心改进
|
||||
|
||||
1. **事件总线 (`useEventBus.ts`)**
|
||||
- 解耦组件通信
|
||||
- 提供可追踪的事件流
|
||||
- 支持类型安全的事件定义
|
||||
|
||||
2. **单例 Store (`useStructureStore.ts`)**
|
||||
- 全局共享状态
|
||||
- 统一状态管理
|
||||
- 自动事件通知
|
||||
|
||||
3. **调试友好**
|
||||
- 所有状态变化都有日志
|
||||
- 事件触发可追踪
|
||||
- 清晰的数据流
|
||||
|
||||
### 迁移步骤
|
||||
|
||||
#### 1. 旧方式(问题多多)
|
||||
|
||||
```ts
|
||||
// ❌ 问题:状态分散,难以追踪
|
||||
const structureState = useStructureState()
|
||||
const { structureData, loadStructure } = structureState
|
||||
|
||||
// ❌ 问题:响应式传递复杂,容易丢失
|
||||
<ResultPanel :structure-data="computedStructureData" />
|
||||
|
||||
// ❌ 问题:调试困难,不知道数据在哪里丢失
|
||||
console.log('structureData:', structureData.value)
|
||||
```
|
||||
|
||||
#### 2. 新方式(事件驱动)
|
||||
|
||||
```ts
|
||||
// ✅ 优点:单例,全局共享
|
||||
const structureStore = useStructureStore()
|
||||
|
||||
// ✅ 优点:直接访问,无需计算属性
|
||||
<ResultPanel :structure-data="structureStore.data" />
|
||||
|
||||
// ✅ 优点:事件可追踪
|
||||
structureStore.on('structure:data', ({ data, info }) => {
|
||||
console.log('收到结构数据:', data, info)
|
||||
})
|
||||
```
|
||||
|
||||
#### 3. 组件中使用
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useStructureStore } from '../composables/useStructureStore'
|
||||
|
||||
const store = useStructureStore()
|
||||
|
||||
// 直接使用 store 的状态
|
||||
console.log('当前数据:', store.data.value)
|
||||
console.log('当前信息:', store.info.value)
|
||||
console.log('加载状态:', store.loading.value)
|
||||
|
||||
// 订阅事件变化(可选)
|
||||
store.eventBus.on('structure:data', ({ data }) => {
|
||||
console.log('数据已更新:', data)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 直接传递 store,无需计算属性 -->
|
||||
<ResultPanel
|
||||
:structure-data="store.data"
|
||||
:structure-info="store.info"
|
||||
:structure-loading="store.loading"
|
||||
:structure-error="store.error"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 对比
|
||||
|
||||
| 特性 | 旧方式 | 新方式 |
|
||||
|------|--------------------------|--------------------------|
|
||||
| 状态共享 | Composable 实例 | 单例 Store |
|
||||
| 组件通信 | props/emit | 事件总线 |
|
||||
| 响应式传递 | computed + props | 直接访问 ref |
|
||||
| 调试 | 困难,日志分散 | 清晰,所有变化有日志 |
|
||||
| 类型安全 | 部分 | 完全类型安全 |
|
||||
| 可追踪性 | 低 | 高(事件流) |
|
||||
| 解耦 | 低(依赖 props) | 高(事件驱动) |
|
||||
|
||||
### 优势
|
||||
|
||||
1. **确定性**:单例确保全局只有一个实例,状态不会丢失
|
||||
2. **可追踪**:所有状态变化都有日志,事件流清晰
|
||||
3. **可调试**:事件总线提供完整的通信链路
|
||||
4. **解耦**:组件通过事件通信,不依赖具体实现
|
||||
5. **类型安全**:事件和状态都有完整的类型定义
|
||||
|
||||
### 适用场景
|
||||
|
||||
- ✅ 跨组件状态共享
|
||||
- ✅ 复杂状态管理
|
||||
- ✅ 需要调试的状态
|
||||
- ✅ 频繁更新的状态
|
||||
- ❌ 简单的本地状态(无需事件总线)
|
||||
|
||||
### 后续改进
|
||||
|
||||
1. 添加状态持久化(localStorage)
|
||||
2. 添加状态回滚/撤销
|
||||
3. 添加状态快照
|
||||
4. 添加状态变更中间件
|
||||
|
||||
**时间:** 2026-01-03
|
||||
116
web/src/views/db-cli/composables/useContextMenu.ts
Normal file
116
web/src/views/db-cli/composables/useContextMenu.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import type { MenuItem } from '../components/ContextMenu.vue'
|
||||
|
||||
/**
|
||||
* 右键菜单状态管理 Composable
|
||||
*/
|
||||
export function useContextMenu() {
|
||||
const menuVisible = ref(false)
|
||||
const menuPosition = ref({ x: 0, y: 0 })
|
||||
const menuItems = ref<MenuItem[]>([])
|
||||
const currentNodeData = ref<any>(null)
|
||||
|
||||
/**
|
||||
* 显示菜单
|
||||
*/
|
||||
const showMenu = (event: MouseEvent, nodeData: any, items: MenuItem[]) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
menuPosition.value = {
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
}
|
||||
menuItems.value = items
|
||||
currentNodeData.value = nodeData
|
||||
menuVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏菜单
|
||||
*/
|
||||
const hideMenu = () => {
|
||||
menuVisible.value = false
|
||||
menuItems.value = []
|
||||
currentNodeData.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理菜单项点击
|
||||
*/
|
||||
const handleMenuItemClick = (item: MenuItem, emit: (event: string, data: any) => void) => {
|
||||
if (item.disabled || !currentNodeData.value) return
|
||||
|
||||
// 根据菜单项key触发相应事件
|
||||
switch (item.key) {
|
||||
case 'view-structure':
|
||||
emit('table-structure', {
|
||||
connectionId: currentNodeData.value.connectionId,
|
||||
database: currentNodeData.value.database || '',
|
||||
tableName: currentNodeData.value.tableName || currentNodeData.value.keyName || currentNodeData.value.title || '',
|
||||
dbType: currentNodeData.value.dbType || 'mysql',
|
||||
nodeType: currentNodeData.value.type
|
||||
})
|
||||
break
|
||||
case 'edit':
|
||||
emit('connection-edit', {
|
||||
connectionId: currentNodeData.value.connectionId
|
||||
})
|
||||
break
|
||||
case 'delete':
|
||||
emit('connection-delete', {
|
||||
connectionId: currentNodeData.value.connectionId
|
||||
})
|
||||
break
|
||||
case 'generate-sql':
|
||||
emit('table-select', {
|
||||
connectionId: currentNodeData.value.connectionId,
|
||||
database: currentNodeData.value.database || '',
|
||||
tableName: currentNodeData.value.tableName || currentNodeData.value.keyName || currentNodeData.value.title || '',
|
||||
dbType: currentNodeData.value.dbType || 'mysql'
|
||||
})
|
||||
break
|
||||
case 'copy-name':
|
||||
// 复制名称到剪贴板
|
||||
const name = currentNodeData.value.tableName || currentNodeData.value.keyName || currentNodeData.value.title || ''
|
||||
navigator.clipboard.writeText(name)
|
||||
break
|
||||
case 'refresh':
|
||||
// 刷新节点(通过重新加载实现)
|
||||
emit('connection-refresh', {
|
||||
connectionId: currentNodeData.value.connectionId,
|
||||
nodeType: currentNodeData.value.type,
|
||||
database: currentNodeData.value.database
|
||||
})
|
||||
break
|
||||
case 'test':
|
||||
// 测试连接
|
||||
emit('connection-test', {
|
||||
connectionId: currentNodeData.value.connectionId
|
||||
})
|
||||
break
|
||||
case 'create-table':
|
||||
// 创建表/集合/Key
|
||||
emit('create-table', {
|
||||
connectionId: currentNodeData.value.connectionId,
|
||||
database: currentNodeData.value.database || '',
|
||||
dbType: currentNodeData.value.dbType || 'mysql'
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
hideMenu()
|
||||
}
|
||||
|
||||
return {
|
||||
menuVisible,
|
||||
menuPosition,
|
||||
menuItems,
|
||||
currentNodeData,
|
||||
showMenu,
|
||||
hideMenu,
|
||||
handleMenuItemClick
|
||||
}
|
||||
}
|
||||
|
||||
36
web/src/views/db-cli/composables/useCreateState.ts
Normal file
36
web/src/views/db-cli/composables/useCreateState.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export function useCreateState() {
|
||||
const createLoading = ref(false)
|
||||
const createError = ref('')
|
||||
const createInfo = ref<{
|
||||
connectionId: number
|
||||
database: string
|
||||
dbType: 'mysql' | 'mongo' | 'redis'
|
||||
} | null>(null)
|
||||
|
||||
const startCreate = (connectionId: number, database: string, dbType: 'mysql' | 'mongo' | 'redis') => {
|
||||
createInfo.value = { connectionId, database, dbType }
|
||||
createError.value = ''
|
||||
}
|
||||
|
||||
const cancelCreate = () => {
|
||||
createInfo.value = null
|
||||
createError.value = ''
|
||||
}
|
||||
|
||||
const clearCreate = () => {
|
||||
createInfo.value = null
|
||||
createError.value = ''
|
||||
createLoading.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
createLoading,
|
||||
createError,
|
||||
createInfo,
|
||||
startCreate,
|
||||
cancelCreate,
|
||||
clearCreate
|
||||
}
|
||||
}
|
||||
62
web/src/views/db-cli/composables/useDbConnection.ts
Normal file
62
web/src/views/db-cli/composables/useDbConnection.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { STORAGE_KEYS } from '../constants/storage'
|
||||
|
||||
/**
|
||||
* 数据库连接管理 Composable
|
||||
*/
|
||||
export function useDbConnection() {
|
||||
const currentConnection = ref<any>(null)
|
||||
const selectedDatabase = ref('')
|
||||
const showConnectionForm = ref(false)
|
||||
const editingConnectionId = ref<number | null>(null)
|
||||
|
||||
const selectConnection = (conn: any, database?: string) => {
|
||||
if (!conn?.id) return
|
||||
currentConnection.value = conn
|
||||
const dbName = database ?? conn.database ?? ''
|
||||
selectedDatabase.value = dbName
|
||||
localStorage.setItem(STORAGE_KEYS.CURRENT_CONNECTION, String(conn.id))
|
||||
localStorage[dbName ? 'setItem' : 'removeItem'](STORAGE_KEYS.SELECTED_DATABASE, dbName)
|
||||
}
|
||||
|
||||
const editConnection = (connectionId: number) => {
|
||||
editingConnectionId.value = connectionId
|
||||
showConnectionForm.value = true
|
||||
}
|
||||
|
||||
const deleteConnection = (connectionId: number): boolean => {
|
||||
const isCurrent = currentConnection.value?.id === connectionId
|
||||
if (isCurrent) {
|
||||
currentConnection.value = null
|
||||
selectedDatabase.value = ''
|
||||
}
|
||||
return isCurrent
|
||||
}
|
||||
|
||||
const newConnection = () => {
|
||||
editingConnectionId.value = null
|
||||
showConnectionForm.value = true
|
||||
}
|
||||
|
||||
const onConnectionSuccess = (editedId: number | null) => {
|
||||
showConnectionForm.value = false
|
||||
editingConnectionId.value = null
|
||||
if (editedId && currentConnection.value?.id === editedId) {
|
||||
Message.info('连接已更新,请重新选择连接')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentConnection,
|
||||
selectedDatabase,
|
||||
showConnectionForm,
|
||||
editingConnectionId,
|
||||
selectConnection,
|
||||
editConnection,
|
||||
deleteConnection,
|
||||
newConnection,
|
||||
onConnectionSuccess
|
||||
}
|
||||
}
|
||||
|
||||
19
web/src/views/db-cli/composables/useEditorState.ts
Normal file
19
web/src/views/db-cli/composables/useEditorState.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ref } from 'vue'
|
||||
import { STORAGE_KEYS } from '../constants/storage'
|
||||
|
||||
/**
|
||||
* 编辑器状态管理 Composable
|
||||
*/
|
||||
export function useEditorState() {
|
||||
const editorVisible = ref(
|
||||
localStorage.getItem(STORAGE_KEYS.EDITOR_VISIBLE) !== 'false'
|
||||
)
|
||||
|
||||
const toggleEditor = () => {
|
||||
editorVisible.value = !editorVisible.value
|
||||
localStorage.setItem(STORAGE_KEYS.EDITOR_VISIBLE, String(editorVisible.value))
|
||||
}
|
||||
|
||||
return { editorVisible, toggleEditor }
|
||||
}
|
||||
|
||||
81
web/src/views/db-cli/composables/useEventBus.ts
Normal file
81
web/src/views/db-cli/composables/useEventBus.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { type Ref, type UnwrapRef } from 'vue'
|
||||
|
||||
export interface DbCliEvents {
|
||||
'structure:loading': { loading: boolean }
|
||||
'structure:data': { data: any; info: StructureInfo }
|
||||
'structure:error': { error: string }
|
||||
'structure:clear': {}
|
||||
}
|
||||
|
||||
export interface StructureInfo {
|
||||
connectionId: number
|
||||
database: string
|
||||
tableName: string
|
||||
dbType: 'mysql' | 'mongo' | 'redis'
|
||||
nodeType: string
|
||||
}
|
||||
|
||||
type EventListener<T> = (payload: T) => void
|
||||
|
||||
class EventBus<T extends Record<string, any>> {
|
||||
private listeners: Map<keyof T, Set<EventListener<any>>> = new Map()
|
||||
|
||||
on<K extends keyof T>(event: K, listener: EventListener<UnwrapRef<T[K]>>): () => void {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, new Set())
|
||||
}
|
||||
this.listeners.get(event)!.add(listener)
|
||||
|
||||
return () => {
|
||||
this.listeners.get(event)?.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
once<K extends keyof T>(event: K, listener: EventListener<UnwrapRef<T[K]>>): () => void {
|
||||
const onceWrapper: EventListener<any> = (payload) => {
|
||||
listener(payload)
|
||||
this.off(event, onceWrapper)
|
||||
}
|
||||
return this.on(event, onceWrapper)
|
||||
}
|
||||
|
||||
off<K extends keyof T>(event: K, listener?: EventListener<UnwrapRef<T[K]>>): void {
|
||||
if (listener) {
|
||||
this.listeners.get(event)?.delete(listener)
|
||||
} else {
|
||||
this.listeners.delete(event)
|
||||
}
|
||||
}
|
||||
|
||||
emit<K extends keyof T>(event: K, payload: UnwrapRef<T[K]>): void {
|
||||
this.listeners.get(event)?.forEach(listener => {
|
||||
try {
|
||||
listener(payload)
|
||||
} catch (error) {
|
||||
console.error(`事件处理错误 [${String(event)}]:`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.listeners.clear()
|
||||
}
|
||||
}
|
||||
|
||||
const eventBus = new EventBus<DbCliEvents>()
|
||||
|
||||
export function useEventBus() {
|
||||
return {
|
||||
on: <K extends keyof DbCliEvents>(event: K, listener: EventListener<UnwrapRef<DbCliEvents[K]>>) =>
|
||||
eventBus.on(event, listener),
|
||||
once: <K extends keyof DbCliEvents>(event: K, listener: EventListener<UnwrapRef<DbCliEvents[K]>>) =>
|
||||
eventBus.once(event, listener),
|
||||
off: <K extends keyof DbCliEvents>(event: K, listener?: EventListener<UnwrapRef<DbCliEvents[K]>>) =>
|
||||
eventBus.off(event, listener),
|
||||
emit: <K extends keyof DbCliEvents>(event: K, payload: UnwrapRef<DbCliEvents[K]>) =>
|
||||
eventBus.emit(event, payload)
|
||||
}
|
||||
}
|
||||
|
||||
export { eventBus }
|
||||
export type { EventBus }
|
||||
100
web/src/views/db-cli/composables/useMenuRegistry.ts
Normal file
100
web/src/views/db-cli/composables/useMenuRegistry.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { Component } from 'vue'
|
||||
import type { MenuItem } from '../components/ContextMenu.vue'
|
||||
import { IconEye, IconEdit, IconDelete, IconRefresh, IconCheck, IconCode, IconCopy, IconPlus } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
/**
|
||||
* 菜单项注册表
|
||||
* 根据节点类型返回对应的菜单项配置
|
||||
*/
|
||||
export function useMenuRegistry() {
|
||||
/**
|
||||
* 获取连接节点菜单项
|
||||
*/
|
||||
const getConnectionMenuItems = (): MenuItem[] => {
|
||||
return [
|
||||
{ key: 'view-structure', label: '查看结构', icon: IconEye },
|
||||
{ key: 'edit', label: '编辑连接', icon: IconEdit },
|
||||
{ key: 'delete', label: '删除连接', icon: IconDelete, divider: true },
|
||||
{ key: 'refresh', label: '刷新', icon: IconRefresh },
|
||||
{ key: 'test', label: '测试连接', icon: IconCheck }
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库节点菜单项
|
||||
*/
|
||||
const getDatabaseMenuItems = (dbType: string): MenuItem[] => {
|
||||
const items: MenuItem[] = []
|
||||
|
||||
// 新建表/集合/Key
|
||||
if (dbType === 'mysql') {
|
||||
items.push({ key: 'create-table', label: '新建表', icon: IconPlus })
|
||||
} else if (dbType === 'mongo') {
|
||||
items.push({ key: 'create-table', label: '新建集合', icon: IconPlus })
|
||||
} else if (dbType === 'redis') {
|
||||
items.push({ key: 'create-table', label: '新建Key', icon: IconPlus })
|
||||
}
|
||||
|
||||
items.push({ key: 'view-structure', label: '查看结构', icon: IconEye, divider: true })
|
||||
|
||||
if (dbType === 'mysql' || dbType === 'mongo') {
|
||||
items.push({ key: 'generate-sql', label: dbType === 'mysql' ? '生成SELECT语句' : '生成find语句', icon: IconCode })
|
||||
} else if (dbType === 'redis') {
|
||||
items.push({ key: 'generate-sql', label: '生成KEYS命令', icon: IconCode })
|
||||
}
|
||||
|
||||
items.push({ key: 'refresh', label: '刷新', icon: IconRefresh, divider: true })
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表节点菜单项
|
||||
*/
|
||||
const getTableMenuItems = (dbType: string): MenuItem[] => {
|
||||
const items: MenuItem[] = [
|
||||
{ key: 'view-structure', label: '查看结构', icon: IconEye }
|
||||
]
|
||||
|
||||
if (dbType === 'mysql') {
|
||||
items.push({ key: 'generate-sql', label: '生成SELECT语句', icon: IconCode })
|
||||
items.push({ key: 'copy-name', label: '复制表名', icon: IconCopy, divider: true })
|
||||
} else if (dbType === 'mongo') {
|
||||
items.push({ key: 'generate-sql', label: '生成find语句', icon: IconCode })
|
||||
items.push({ key: 'copy-name', label: '复制集合名', icon: IconCopy, divider: true })
|
||||
} else if (dbType === 'redis') {
|
||||
items.push({ key: 'generate-sql', label: '生成GET命令', icon: IconCode })
|
||||
items.push({ key: 'copy-name', label: '复制Key名', icon: IconCopy, divider: true })
|
||||
}
|
||||
|
||||
items.push({ key: 'refresh', label: '刷新', icon: IconRefresh })
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据节点类型获取菜单项
|
||||
*/
|
||||
const getMenuItems = (nodeType: string, dbType?: string): MenuItem[] => {
|
||||
switch (nodeType) {
|
||||
case 'connection':
|
||||
return getConnectionMenuItems()
|
||||
case 'database':
|
||||
return getDatabaseMenuItems(dbType || 'mysql')
|
||||
case 'table':
|
||||
case 'collection':
|
||||
case 'key':
|
||||
return getTableMenuItems(dbType || 'mysql')
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getMenuItems,
|
||||
getConnectionMenuItems,
|
||||
getDatabaseMenuItems,
|
||||
getTableMenuItems
|
||||
}
|
||||
}
|
||||
|
||||
34
web/src/views/db-cli/composables/useMessageLog.ts
Normal file
34
web/src/views/db-cli/composables/useMessageLog.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const MAX_MESSAGES = 100
|
||||
|
||||
export interface MessageItem {
|
||||
type: 'info' | 'success' | 'error' | 'warning'
|
||||
content: string
|
||||
time: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息日志管理 Composable
|
||||
*/
|
||||
export function useMessageLog() {
|
||||
const messages = ref<MessageItem[]>([])
|
||||
|
||||
const addMessage = (type: MessageItem['type'], content: string) => {
|
||||
messages.value.unshift({
|
||||
type,
|
||||
content,
|
||||
time: new Date().toLocaleTimeString()
|
||||
})
|
||||
if (messages.value.length > MAX_MESSAGES) {
|
||||
messages.value = messages.value.slice(0, MAX_MESSAGES)
|
||||
}
|
||||
}
|
||||
|
||||
const clearMessages = () => {
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
return { messages, addMessage, clearMessages }
|
||||
}
|
||||
|
||||
92
web/src/views/db-cli/composables/useResultHistory.ts
Normal file
92
web/src/views/db-cli/composables/useResultHistory.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
export interface ResultHistoryItem {
|
||||
id: number
|
||||
connection_id: number
|
||||
database: string
|
||||
sql: string
|
||||
type: string
|
||||
data?: any
|
||||
columns?: string[]
|
||||
rows_affected: number
|
||||
execution_time: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ResultHistorySearchParams {
|
||||
connectionId?: number
|
||||
keyword?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
const handleApiError = (error: unknown, action: string): never => {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error) || '操作失败'
|
||||
Message.error(`${action}失败: ${errorMsg}`)
|
||||
throw error
|
||||
}
|
||||
|
||||
export function useResultHistory() {
|
||||
const loading = ref(false)
|
||||
const histories = ref<ResultHistoryItem[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const searchHistory = async (params: ResultHistorySearchParams = {}) => {
|
||||
if (!(window as any).go?.main?.App?.GetResultHistory) {
|
||||
throw new Error('Go 后端未就绪')
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await (window as any).go.main.App.GetResultHistory(
|
||||
params.connectionId || null,
|
||||
params.keyword || '',
|
||||
params.limit || 20,
|
||||
params.offset || 0
|
||||
)
|
||||
histories.value = result.items || []
|
||||
total.value = result.total || 0
|
||||
} catch (error: unknown) {
|
||||
handleApiError(error, '查询历史记录')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getHistoryById = async (id: number): Promise<ResultHistoryItem | null> => {
|
||||
if (!(window as any).go?.main?.App?.GetResultHistoryByID) {
|
||||
throw new Error('Go 后端未就绪')
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await (window as any).go.main.App.GetResultHistoryByID(id)
|
||||
return result || null
|
||||
} catch (error: unknown) {
|
||||
handleApiError(error, '查询历史记录详情')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteHistory = async (id: number): Promise<boolean> => {
|
||||
if (!(window as any).go?.main?.App?.DeleteResultHistory) {
|
||||
throw new Error('Go 后端未就绪')
|
||||
}
|
||||
|
||||
try {
|
||||
await (window as any).go.main.App.DeleteResultHistory(id)
|
||||
Message.success('删除成功')
|
||||
return true
|
||||
} catch (error: unknown) {
|
||||
handleApiError(error, '删除历史记录')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
histories,
|
||||
total,
|
||||
searchHistory,
|
||||
getHistoryById,
|
||||
deleteHistory
|
||||
}
|
||||
}
|
||||
112
web/src/views/db-cli/composables/useResultState.ts
Normal file
112
web/src/views/db-cli/composables/useResultState.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface ResultStats {
|
||||
rowsAffected: number
|
||||
executionTime: number
|
||||
}
|
||||
|
||||
interface Column {
|
||||
title: string
|
||||
dataIndex: string
|
||||
width: number
|
||||
tooltip?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 结果状态管理 Composable
|
||||
*/
|
||||
export function useResultState() {
|
||||
const resultLoading = ref(false)
|
||||
const resultError = ref('')
|
||||
const resultData = ref<unknown>(null)
|
||||
const resultMode = ref<'table' | 'json'>('table')
|
||||
const resultStats = ref<ResultStats | null>(null)
|
||||
const resultColumns = ref<Column[]>([])
|
||||
|
||||
const buildColumn = (key: string): Column => ({
|
||||
title: key,
|
||||
dataIndex: key,
|
||||
width: 120,
|
||||
tooltip: true
|
||||
})
|
||||
|
||||
const clearResults = () => {
|
||||
resultData.value = null
|
||||
resultError.value = ''
|
||||
resultStats.value = null
|
||||
resultColumns.value = []
|
||||
}
|
||||
|
||||
const setQueryResult = (data: unknown[], stats: ResultStats, columns?: string[]) => {
|
||||
const dataArray = data ?? []
|
||||
resultData.value = dataArray
|
||||
resultMode.value = 'table'
|
||||
resultStats.value = stats
|
||||
|
||||
if (columns?.length) {
|
||||
resultColumns.value = columns.map(buildColumn)
|
||||
} else if (dataArray.length) {
|
||||
resultColumns.value = Object.keys(dataArray[0] as Record<string, any>).map(buildColumn)
|
||||
} else {
|
||||
resultColumns.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const setUpdateResult = (stats: ResultStats) => {
|
||||
resultData.value = null
|
||||
resultMode.value = 'table'
|
||||
resultStats.value = stats
|
||||
resultColumns.value = []
|
||||
}
|
||||
|
||||
const setCommandResult = (data: unknown, stats: ResultStats) => {
|
||||
resultData.value = data
|
||||
resultMode.value = 'json'
|
||||
resultStats.value = stats
|
||||
resultColumns.value = []
|
||||
}
|
||||
|
||||
const setError = (error: string) => {
|
||||
resultError.value = error
|
||||
resultData.value = null
|
||||
resultStats.value = null
|
||||
resultColumns.value = []
|
||||
}
|
||||
|
||||
// 开始加载(清空数据,用于新查询)
|
||||
const startLoading = () => {
|
||||
resultLoading.value = true
|
||||
resultError.value = ''
|
||||
resultData.value = null
|
||||
resultStats.value = null
|
||||
resultColumns.value = []
|
||||
}
|
||||
|
||||
// 开始加载但保留数据(用于翻页,避免闪烁)
|
||||
const startLoadingKeepData = () => {
|
||||
resultLoading.value = true
|
||||
resultError.value = ''
|
||||
}
|
||||
|
||||
const stopLoading = () => {
|
||||
resultLoading.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
resultLoading,
|
||||
resultError,
|
||||
resultData,
|
||||
resultMode,
|
||||
resultStats,
|
||||
resultColumns,
|
||||
clearResults,
|
||||
setQueryResult,
|
||||
setUpdateResult,
|
||||
setCommandResult,
|
||||
setError,
|
||||
startLoading,
|
||||
startLoadingKeepData,
|
||||
stopLoading
|
||||
}
|
||||
}
|
||||
|
||||
151
web/src/views/db-cli/composables/useSqlExecution.ts
Normal file
151
web/src/views/db-cli/composables/useSqlExecution.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { inject } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { useResultState } from './useResultState'
|
||||
import type { useMessageLog } from './useMessageLog'
|
||||
|
||||
const RESULT_STATE_KEY = Symbol('resultState')
|
||||
const MESSAGE_LOG_KEY = Symbol('messageLog')
|
||||
|
||||
export const DbCliKeys = {
|
||||
resultState: RESULT_STATE_KEY,
|
||||
messageLog: MESSAGE_LOG_KEY
|
||||
}
|
||||
|
||||
export function useSqlExecution(
|
||||
resultState?: ReturnType<typeof useResultState>,
|
||||
messageLog?: ReturnType<typeof useMessageLog>
|
||||
) {
|
||||
const injectedResultState = inject<ReturnType<typeof useResultState>>(RESULT_STATE_KEY)
|
||||
const injectedMessageLog = inject<ReturnType<typeof useMessageLog>>(MESSAGE_LOG_KEY)
|
||||
|
||||
const finalResultState = resultState ?? injectedResultState
|
||||
const finalMessageLog = messageLog ?? injectedMessageLog
|
||||
|
||||
if (!finalResultState || !finalMessageLog) {
|
||||
throw new Error('useSqlExecution: 缺少必需的依赖')
|
||||
}
|
||||
|
||||
const parseResultData = (data: any): any[] => {
|
||||
if (data == null) return []
|
||||
if (Array.isArray(data)) return data
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
return Array.isArray(parsed) ? parsed : (parsed ? [parsed] : [])
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
if (typeof data === 'object') {
|
||||
if (Array.isArray(data.rows)) return data.rows
|
||||
if (Array.isArray(data.data)) return data.data
|
||||
return [data]
|
||||
}
|
||||
return [data]
|
||||
}
|
||||
|
||||
const truncateSql = (sql: string): string =>
|
||||
sql.length > 100 ? sql.slice(0, 100) + '...' : sql
|
||||
|
||||
// 为 SQL 添加分页(仅对查询语句)
|
||||
// page=1 且 SQL 已有 LIMIT 时保留用户的 LIMIT;翻页时才覆盖
|
||||
const addPaginationToSQL = (sql: string, page: number, pageSize: number): string => {
|
||||
if (page <= 0 || pageSize <= 0) {
|
||||
return sql
|
||||
}
|
||||
|
||||
const sqlUpper = sql.trim().toUpperCase()
|
||||
// 只对 SELECT、SHOW、DESCRIBE、DESC、EXPLAIN 查询添加分页
|
||||
if (!sqlUpper.startsWith('SELECT') &&
|
||||
!sqlUpper.startsWith('SHOW') &&
|
||||
!sqlUpper.startsWith('DESCRIBE') &&
|
||||
!sqlUpper.startsWith('DESC') &&
|
||||
!sqlUpper.startsWith('EXPLAIN')) {
|
||||
return sql
|
||||
}
|
||||
|
||||
const hasLimit = /\s+LIMIT\s+\d+/i.test(sql)
|
||||
|
||||
// 第一页且用户已写 LIMIT,保留用户的 SQL 不修改
|
||||
if (page === 1 && hasLimit) {
|
||||
return sql
|
||||
}
|
||||
|
||||
// 移除已有 LIMIT(支持 LIMIT n、LIMIT n OFFSET m、LIMIT m,n)
|
||||
const strippedSql = sql.replace(/\s+LIMIT\s+\d+(?:\s*,\s*\d+)?(?:\s+OFFSET\s+\d+)?\s*;?\s*$/i, '').trim()
|
||||
|
||||
// 添加 LIMIT 和 OFFSET
|
||||
const offset = (page - 1) * pageSize
|
||||
return `${strippedSql} LIMIT ${pageSize} OFFSET ${offset}`
|
||||
}
|
||||
|
||||
const executeSQL = async (sql: string, connection: any, database: string = '', page: number = 0, pageSize: number = 0) => {
|
||||
if (!connection) {
|
||||
Message.warning('请先选择数据库连接')
|
||||
return
|
||||
}
|
||||
|
||||
if (!(window as any).go?.main?.App?.ExecuteSQL) {
|
||||
throw new Error('Go 后端未就绪')
|
||||
}
|
||||
|
||||
// 翻页时保留数据避免闪烁,新查询时清空
|
||||
if (page > 1) {
|
||||
finalResultState.startLoadingKeepData()
|
||||
} else {
|
||||
finalResultState.startLoading()
|
||||
}
|
||||
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
const dbParam = connection.type === 'mysql' ? database : ''
|
||||
|
||||
// 如果是查询且需要分页,自动添加 LIMIT 和 OFFSET
|
||||
const finalSQL = addPaginationToSQL(sql, page, pageSize)
|
||||
|
||||
const result = await (window as any).go.main.App.ExecuteSQL(
|
||||
connection.id,
|
||||
finalSQL,
|
||||
dbParam
|
||||
)
|
||||
const executionTime = Date.now() - startTime
|
||||
|
||||
if (result.type === 'query') {
|
||||
const data = parseResultData(result.data)
|
||||
const stats = {
|
||||
rowsAffected: data.length || result.rowsAffected || 0,
|
||||
executionTime: result.executionTime ?? executionTime
|
||||
}
|
||||
// 统一使用表格展示,避免大数据量 JSON 渲染性能问题
|
||||
finalResultState.setQueryResult(data, stats, result.columns)
|
||||
Message.success(`查询成功,返回 ${stats.rowsAffected} 行数据`)
|
||||
finalMessageLog.addMessage('success', `执行成功: ${stats.rowsAffected} 行,耗时 ${stats.executionTime}ms - ${truncateSql(sql)}`)
|
||||
} else if (result.type === 'update') {
|
||||
const stats = {
|
||||
rowsAffected: result.rowsAffected ?? 0,
|
||||
executionTime: result.executionTime ?? executionTime
|
||||
}
|
||||
finalResultState.setUpdateResult(stats)
|
||||
Message.success(`执行成功,影响 ${stats.rowsAffected} 行`)
|
||||
finalMessageLog.addMessage('success', `执行成功: 影响 ${stats.rowsAffected} 行,耗时 ${stats.executionTime}ms - ${truncateSql(sql)}`)
|
||||
} else if (result.type === 'command') {
|
||||
const stats = {
|
||||
rowsAffected: 1,
|
||||
executionTime: result.executionTime ?? executionTime
|
||||
}
|
||||
finalResultState.setCommandResult(result.data, stats)
|
||||
Message.success('命令执行成功')
|
||||
finalMessageLog.addMessage('success', `执行成功,耗时 ${stats.executionTime}ms - ${truncateSql(sql)}`)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
finalResultState.setError(errorMsg)
|
||||
Message.error('执行失败: ' + errorMsg)
|
||||
finalMessageLog.addMessage('error', errorMsg)
|
||||
} finally {
|
||||
finalResultState.stopLoading()
|
||||
}
|
||||
}
|
||||
|
||||
return { executeSQL }
|
||||
}
|
||||
154
web/src/views/db-cli/composables/useStructureEdit.ts
Normal file
154
web/src/views/db-cli/composables/useStructureEdit.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
/**
|
||||
* 表结构编辑状态管理 Composable
|
||||
* 负责管理表结构编辑相关的状态和逻辑
|
||||
*/
|
||||
export function useStructureEdit() {
|
||||
const isEditing = ref(false)
|
||||
const editMode = ref<'view' | 'edit'>('view')
|
||||
const editedColumns = ref<any[]>([])
|
||||
const editedIndexes = ref<any[]>([])
|
||||
|
||||
const hasUnsavedChanges = computed(() => false)
|
||||
|
||||
const switchToViewMode = () => {
|
||||
editMode.value = 'view'
|
||||
isEditing.value = false
|
||||
editedColumns.value = []
|
||||
editedIndexes.value = []
|
||||
}
|
||||
|
||||
const switchToEditMode = (originalColumns?: any[], originalIndexes?: any[]) => {
|
||||
editMode.value = 'edit'
|
||||
isEditing.value = true
|
||||
editedColumns.value = originalColumns ? JSON.parse(JSON.stringify(originalColumns)) : []
|
||||
editedIndexes.value = originalIndexes ? JSON.parse(JSON.stringify(originalIndexes)) : []
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新表结构
|
||||
*/
|
||||
const updateTableStructure = async (
|
||||
connectionId: number,
|
||||
database: string,
|
||||
tableName: string,
|
||||
dbType: 'mysql' | 'mongo' | 'redis'
|
||||
): Promise<string[]> => {
|
||||
if (!(window as any).go?.main?.App?.UpdateTableStructure) {
|
||||
throw new Error('Go 后端未就绪')
|
||||
}
|
||||
|
||||
if (dbType === 'redis') {
|
||||
throw new Error('Redis 不支持表结构修改')
|
||||
}
|
||||
|
||||
const structure = dbType === 'mysql'
|
||||
? { columns: editedColumns.value, indexes: editedIndexes.value }
|
||||
: { indexes: editedIndexes.value }
|
||||
|
||||
return await (window as any).go.main.App.UpdateTableStructure(
|
||||
connectionId, database, tableName, structure
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览表结构变更
|
||||
*/
|
||||
const previewTableStructure = async (
|
||||
connectionId: number,
|
||||
database: string,
|
||||
tableName: string,
|
||||
dbType: 'mysql' | 'mongo' | 'redis'
|
||||
): Promise<string[]> => {
|
||||
if (!(window as any).go?.main?.App?.PreviewTableStructure) {
|
||||
throw new Error('Go 后端未就绪')
|
||||
}
|
||||
|
||||
if (dbType === 'redis') {
|
||||
throw new Error('Redis 不支持表结构预览')
|
||||
}
|
||||
|
||||
const structure = dbType === 'mysql'
|
||||
? { columns: editedColumns.value, indexes: editedIndexes.value }
|
||||
: { indexes: editedIndexes.value }
|
||||
|
||||
return await (window as any).go.main.App.PreviewTableStructure(
|
||||
connectionId, database, tableName, structure
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存结构修改
|
||||
*/
|
||||
const saveStructure = async (
|
||||
connectionId: number,
|
||||
database: string,
|
||||
tableName: string,
|
||||
dbType: 'mysql' | 'mongo'
|
||||
) => {
|
||||
try {
|
||||
const sqlStatements = await updateTableStructure(connectionId, database, tableName, dbType)
|
||||
Message.success('结构保存成功')
|
||||
switchToViewMode()
|
||||
return { success: true, sqlStatements }
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
Message.error('保存表结构失败: ' + errorMessage)
|
||||
return { success: false, sqlStatements: [] }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消编辑
|
||||
*/
|
||||
const cancelEdit = () => switchToViewMode()
|
||||
|
||||
const addColumn = () => {
|
||||
editedColumns.value.push({
|
||||
Field: '',
|
||||
Type: 'varchar(255)',
|
||||
Null: 'YES',
|
||||
Key: '',
|
||||
Default: null,
|
||||
Extra: '',
|
||||
Comment: ''
|
||||
})
|
||||
}
|
||||
|
||||
const removeColumn = (index: number) => editedColumns.value.splice(index, 1)
|
||||
|
||||
const addIndex = () => {
|
||||
editedIndexes.value.push({
|
||||
Key_name: '',
|
||||
Column_name: '',
|
||||
Non_unique: 0,
|
||||
Index_type: 'BTREE'
|
||||
})
|
||||
}
|
||||
|
||||
const removeIndex = (index: number) => editedIndexes.value.splice(index, 1)
|
||||
|
||||
return {
|
||||
isEditing,
|
||||
editMode,
|
||||
editedColumns,
|
||||
editedIndexes,
|
||||
hasUnsavedChanges,
|
||||
switchToViewMode,
|
||||
switchToEditMode,
|
||||
previewTableStructure,
|
||||
saveStructure,
|
||||
cancelEdit,
|
||||
addColumn,
|
||||
removeColumn,
|
||||
addIndex,
|
||||
removeIndex
|
||||
}
|
||||
}
|
||||
|
||||
export interface SaveStructureResult {
|
||||
success: boolean
|
||||
sqlStatements: string[]
|
||||
}
|
||||
159
web/src/views/db-cli/composables/useStructureState.ts
Normal file
159
web/src/views/db-cli/composables/useStructureState.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { GetTableStructure } from '../../wailsjs/wailsjs/go/main/App'
|
||||
|
||||
/**
|
||||
* 表结构状态管理 Composable
|
||||
* 负责管理表结构查看相关的状态和数据
|
||||
*/
|
||||
export function useStructureState() {
|
||||
// 状态
|
||||
const structureLoading = ref(false)
|
||||
const structureError = ref('')
|
||||
const structureData = ref<any>(null)
|
||||
const structureInfo = ref<{
|
||||
connectionId: number
|
||||
database: string
|
||||
tableName: string
|
||||
dbType: 'mysql' | 'mongo' | 'redis'
|
||||
nodeType: string
|
||||
} | null>(null)
|
||||
|
||||
/**
|
||||
* 加载表结构
|
||||
* @param connectionId 连接ID
|
||||
* @param database 数据库名
|
||||
* @param tableName 表名/集合名/Key名
|
||||
* @param dbType 数据库类型
|
||||
* @param nodeType 节点类型
|
||||
*/
|
||||
const loadStructure = async (
|
||||
connectionId: number,
|
||||
database: string,
|
||||
tableName: string,
|
||||
dbType: 'mysql' | 'mongo' | 'redis',
|
||||
nodeType: string
|
||||
) => {
|
||||
console.log('🟢 loadStructure 开始:', { connectionId, database, tableName, dbType, nodeType })
|
||||
|
||||
// 对于连接和数据库节点,不需要加载结构
|
||||
if (nodeType === 'connection' || nodeType === 'database') {
|
||||
console.log('🟡 跳过:节点类型为连接或数据库')
|
||||
structureInfo.value = {
|
||||
connectionId,
|
||||
database,
|
||||
tableName: '',
|
||||
dbType,
|
||||
nodeType
|
||||
}
|
||||
structureData.value = null
|
||||
return
|
||||
}
|
||||
|
||||
// 如果没有表名,不加载(但保留 structureInfo 用于显示提示)
|
||||
if (!tableName) {
|
||||
console.log('🟡 跳过:表名为空')
|
||||
structureInfo.value = {
|
||||
connectionId,
|
||||
database,
|
||||
tableName: '',
|
||||
dbType,
|
||||
nodeType
|
||||
}
|
||||
structureData.value = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
structureLoading.value = true
|
||||
structureError.value = ''
|
||||
|
||||
if (!window.go?.main?.App?.GetTableStructure) {
|
||||
throw new Error('Go 后端未就绪')
|
||||
}
|
||||
|
||||
const result = await window.go.main.App.GetTableStructure(
|
||||
connectionId,
|
||||
database,
|
||||
tableName
|
||||
)
|
||||
|
||||
console.log('表结构加载成功:', { connectionId, database, tableName, result })
|
||||
console.log('返回数据类型:', typeof result)
|
||||
console.log('返回数据 keys:', result ? Object.keys(result) : 'null')
|
||||
console.log('返回数据 type 字段:', result?.type)
|
||||
console.log('返回数据 columns 字段:', result?.columns)
|
||||
|
||||
structureData.value = result
|
||||
|
||||
// 确保 structureInfo 也设置了
|
||||
structureInfo.value = {
|
||||
connectionId,
|
||||
database,
|
||||
tableName,
|
||||
dbType,
|
||||
nodeType
|
||||
}
|
||||
|
||||
// 确保 structureInfo 也设置了
|
||||
structureInfo.value = {
|
||||
connectionId,
|
||||
database,
|
||||
tableName,
|
||||
dbType,
|
||||
nodeType
|
||||
}
|
||||
|
||||
console.log('✅ 设置完成 - structureData:', structureData.value)
|
||||
console.log('✅ 设置完成 - structureInfo:', structureInfo.value)
|
||||
console.log('✅ structureData 是否为 null:', structureData.value === null)
|
||||
console.log('✅ structureInfo 是否为 null:', structureInfo.value === null)
|
||||
} catch (error: unknown) {
|
||||
console.error('加载表结构失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '加载表结构失败'
|
||||
structureError.value = errorMessage
|
||||
Message.error('加载表结构失败: ' + errorMessage)
|
||||
structureData.value = null
|
||||
structureInfo.value = null
|
||||
} finally {
|
||||
structureLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空结构数据
|
||||
*/
|
||||
const clearStructure = () => {
|
||||
structureData.value = null
|
||||
structureInfo.value = null
|
||||
structureError.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新结构数据
|
||||
*/
|
||||
const refreshStructure = async () => {
|
||||
if (!structureInfo.value) return
|
||||
|
||||
await loadStructure(
|
||||
structureInfo.value.connectionId,
|
||||
structureInfo.value.database,
|
||||
structureInfo.value.tableName,
|
||||
structureInfo.value.dbType,
|
||||
structureInfo.value.nodeType
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
structureLoading,
|
||||
structureError,
|
||||
structureData,
|
||||
structureInfo,
|
||||
// 方法
|
||||
loadStructure,
|
||||
clearStructure,
|
||||
refreshStructure
|
||||
}
|
||||
}
|
||||
|
||||
123
web/src/views/db-cli/composables/useStructureStore.ts
Normal file
123
web/src/views/db-cli/composables/useStructureStore.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ref } from 'vue'
|
||||
import { useEventBus } from './useEventBus'
|
||||
import type { StructureInfo } from './useEventBus'
|
||||
import { STORAGE_KEYS } from '../constants/storage'
|
||||
import { getTableStructure } from '@/api'
|
||||
|
||||
class StructureStore {
|
||||
public readonly loading = ref(false)
|
||||
public readonly error = ref('')
|
||||
public readonly data = ref<any>(null)
|
||||
public readonly info = ref<StructureInfo | null>(null)
|
||||
|
||||
private eventBus = useEventBus()
|
||||
|
||||
setLoading(loading: boolean): void {
|
||||
this.loading.value = loading
|
||||
this.eventBus.emit('structure:loading', { loading })
|
||||
}
|
||||
|
||||
setError(error: string): void {
|
||||
this.error.value = error
|
||||
this.eventBus.emit('structure:error', { error })
|
||||
}
|
||||
|
||||
setData(data: any, info: StructureInfo): void {
|
||||
this.data.value = data
|
||||
this.info.value = info
|
||||
this.error.value = ''
|
||||
this.loading.value = false
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.STRUCTURE_INFO, JSON.stringify(info))
|
||||
} catch {}
|
||||
this.eventBus.emit('structure:data', { data, info })
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.data.value = null
|
||||
this.info.value = null
|
||||
this.error.value = ''
|
||||
this.loading.value = false
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEYS.STRUCTURE_INFO)
|
||||
} catch {}
|
||||
this.eventBus.emit('structure:clear', {})
|
||||
}
|
||||
|
||||
restoreStructureInfo(): StructureInfo | null {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.STRUCTURE_INFO)
|
||||
return saved ? JSON.parse(saved) as StructureInfo : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async loadStructure(
|
||||
connectionId: number,
|
||||
database: string,
|
||||
tableName: string,
|
||||
dbType: 'mysql' | 'mongo' | 'redis',
|
||||
nodeType: string
|
||||
): Promise<void> {
|
||||
// 跳过非表节点
|
||||
if (nodeType === 'connection' || nodeType === 'database' || !tableName) {
|
||||
this.info.value = { connectionId, database, tableName: '', dbType, nodeType }
|
||||
this.data.value = null
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否切换到不同的表
|
||||
const currentInfo = this.info.value
|
||||
const isDifferentTable = !currentInfo ||
|
||||
currentInfo.connectionId !== connectionId ||
|
||||
currentInfo.database !== database ||
|
||||
currentInfo.tableName !== tableName
|
||||
|
||||
if (isDifferentTable) {
|
||||
this.data.value = null
|
||||
this.error.value = ''
|
||||
}
|
||||
|
||||
try {
|
||||
this.setLoading(true)
|
||||
|
||||
const result = await getTableStructure(
|
||||
connectionId,
|
||||
database,
|
||||
tableName
|
||||
)
|
||||
|
||||
this.setData(result, { connectionId, database, tableName, dbType, nodeType })
|
||||
} catch (error: unknown) {
|
||||
const errorMsg = error instanceof Error ? error.message : '加载表结构失败'
|
||||
this.setError(errorMsg)
|
||||
this.data.value = null
|
||||
this.info.value = null
|
||||
} finally {
|
||||
this.setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async refreshStructure(): Promise<void> {
|
||||
if (!this.info.value) return
|
||||
await this.loadStructure(
|
||||
this.info.value.connectionId,
|
||||
this.info.value.database,
|
||||
this.info.value.tableName,
|
||||
this.info.value.dbType,
|
||||
this.info.value.nodeType
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let structureStoreInstance: StructureStore | null = null
|
||||
|
||||
export function useStructureStore(): StructureStore {
|
||||
if (!structureStoreInstance) {
|
||||
structureStoreInstance = new StructureStore()
|
||||
}
|
||||
return structureStoreInstance
|
||||
}
|
||||
|
||||
export type { StructureInfo }
|
||||
81
web/src/views/db-cli/composables/useStructureStoreLegacy.ts
Normal file
81
web/src/views/db-cli/composables/useStructureStoreLegacy.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @deprecated 请使用 useStructureStore
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { GetTableStructure } from '../../wailsjs/wailsjs/go/main/App'
|
||||
|
||||
export function useStructureState() {
|
||||
const structureLoading = ref(false)
|
||||
const structureError = ref('')
|
||||
const structureData = ref<any>(null)
|
||||
const structureInfo = ref<{
|
||||
connectionId: number
|
||||
database: string
|
||||
tableName: string
|
||||
dbType: 'mysql' | 'mongo' | 'redis'
|
||||
nodeType: string
|
||||
} | null>(null)
|
||||
|
||||
const loadStructure = async (
|
||||
connectionId: number,
|
||||
database: string,
|
||||
tableName: string,
|
||||
dbType: 'mysql' | 'mongo' | 'redis',
|
||||
nodeType: string
|
||||
) => {
|
||||
if (nodeType === 'connection' || nodeType === 'database' || !tableName) {
|
||||
structureInfo.value = { connectionId, database, tableName: '', dbType, nodeType }
|
||||
structureData.value = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
structureLoading.value = true
|
||||
structureError.value = ''
|
||||
|
||||
if (!window.go?.main?.App?.GetTableStructure) {
|
||||
throw new Error('Go 后端未就绪')
|
||||
}
|
||||
|
||||
const result = await window.go.main.App.GetTableStructure(connectionId, database, tableName)
|
||||
structureData.value = result
|
||||
structureInfo.value = { connectionId, database, tableName, dbType, nodeType }
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : '加载表结构失败'
|
||||
structureError.value = errorMessage
|
||||
Message.error('加载表结构失败: ' + errorMessage)
|
||||
structureData.value = null
|
||||
structureInfo.value = null
|
||||
} finally {
|
||||
structureLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const clearStructure = () => {
|
||||
structureData.value = null
|
||||
structureInfo.value = null
|
||||
structureError.value = ''
|
||||
}
|
||||
|
||||
const refreshStructure = async () => {
|
||||
if (!structureInfo.value) return
|
||||
await loadStructure(
|
||||
structureInfo.value.connectionId,
|
||||
structureInfo.value.database,
|
||||
structureInfo.value.tableName,
|
||||
structureInfo.value.dbType,
|
||||
structureInfo.value.nodeType
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
structureLoading,
|
||||
structureError,
|
||||
structureData,
|
||||
structureInfo,
|
||||
loadStructure,
|
||||
clearStructure,
|
||||
refreshStructure
|
||||
}
|
||||
}
|
||||
139
web/src/views/db-cli/composables/useTabEditor.ts
Normal file
139
web/src/views/db-cli/composables/useTabEditor.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
|
||||
export interface TabEditorTab {
|
||||
id?: number
|
||||
key: string
|
||||
title: string
|
||||
content: string
|
||||
connectionId?: number
|
||||
}
|
||||
|
||||
export interface TabEditorOptions {
|
||||
findContainer: (tabKey: string, retryCount?: number) => Promise<{ container: HTMLElement; pane?: HTMLElement } | null>
|
||||
checkContainerSize: (container: HTMLElement) => Promise<void>
|
||||
createExtensions: (tab: TabEditorTab) => any[]
|
||||
getInitialContent: (tab: TabEditorTab) => string
|
||||
onContentChange?: (tabKey: string, content: string) => void
|
||||
onEditorReady?: (tabKey: string, editor: EditorView) => void
|
||||
}
|
||||
|
||||
const INIT_DELAY = 200
|
||||
|
||||
export function useTabEditor(options: TabEditorOptions) {
|
||||
const { findContainer, checkContainerSize, createExtensions, getInitialContent, onContentChange, onEditorReady } = options
|
||||
|
||||
const editorViews = ref<Map<string, EditorView>>(new Map())
|
||||
|
||||
const getEditor = (tabKey: string): EditorView | null => {
|
||||
return editorViews.value.get(tabKey) as EditorView || null
|
||||
}
|
||||
|
||||
const destroyEditor = (tabKey: string): void => {
|
||||
const editor = editorViews.value.get(tabKey)
|
||||
if (!editor) return
|
||||
|
||||
if (onContentChange) {
|
||||
onContentChange(tabKey, editor.state.doc.toString())
|
||||
}
|
||||
editor.destroy()
|
||||
editorViews.value.delete(tabKey)
|
||||
}
|
||||
|
||||
const focusEditor = (editor: EditorView, delay = 0): void => {
|
||||
if (!editor) return
|
||||
|
||||
const focus = () => {
|
||||
editor.requestMeasure?.()
|
||||
editor.dispatch({ effects: [] })
|
||||
requestAnimationFrame(() => editor.focus())
|
||||
}
|
||||
|
||||
delay > 0 ? setTimeout(focus, delay) : requestAnimationFrame(focus)
|
||||
}
|
||||
|
||||
const initEditor = async (tabKey: string, tab: any, isActive: boolean, forceInit = false): Promise<boolean> => {
|
||||
if (!isActive && !forceInit) return false
|
||||
|
||||
const existingEditor = editorViews.value.get(tabKey)
|
||||
if (existingEditor instanceof EditorView) {
|
||||
if (isActive) focusEditor(existingEditor, 100)
|
||||
return true
|
||||
}
|
||||
|
||||
destroyEditor(tabKey)
|
||||
await nextTick()
|
||||
|
||||
const containerResult = await findContainer(tabKey)
|
||||
if (!containerResult) return false
|
||||
|
||||
const { container } = containerResult
|
||||
await checkContainerSize(container)
|
||||
|
||||
const rect = container.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0) {
|
||||
if (isActive) {
|
||||
setTimeout(() => initEditor(tabKey, tab, isActive, forceInit), 100)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: getInitialContent(tab),
|
||||
extensions: createExtensions(tab)
|
||||
})
|
||||
|
||||
container.innerHTML = ''
|
||||
const editorView = new EditorView({ state, parent: container })
|
||||
editorViews.value.set(tabKey, editorView)
|
||||
|
||||
if (onEditorReady) onEditorReady(tabKey, editorView)
|
||||
if (isActive) focusEditor(editorView, INIT_DELAY)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const destroyAll = (): void => {
|
||||
editorViews.value.forEach((_, tabKey) => destroyEditor(tabKey))
|
||||
editorViews.value.clear()
|
||||
}
|
||||
|
||||
const updateEditorContent = (tabKey: string, content: string): boolean => {
|
||||
const editor = editorViews.value.get(tabKey)
|
||||
if (!editor) return false
|
||||
|
||||
const update = () => {
|
||||
const state = editor.state
|
||||
if (!state?.doc) return false
|
||||
if (state.doc.toString() === content) return true
|
||||
|
||||
try {
|
||||
editor.dispatch(state.update({ changes: { from: 0, to: state.doc.length, insert: content } }))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return update() || update()
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : ''
|
||||
if (errorMessage?.includes('doesn\'t start from the previous state')) {
|
||||
try { return update() } catch {}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
editorViews,
|
||||
getEditor,
|
||||
destroyEditor,
|
||||
initEditor,
|
||||
focusEditor,
|
||||
destroyAll,
|
||||
updateEditorContent
|
||||
}
|
||||
}
|
||||
67
web/src/views/db-cli/composables/useTabPersistence.js
Normal file
67
web/src/views/db-cli/composables/useTabPersistence.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { saveTabs as saveTabsApi, listTabs } from '@/api'
|
||||
|
||||
/**
|
||||
* SQL 标签页持久化 Composable
|
||||
*/
|
||||
export function useTabPersistence() {
|
||||
const loading = ref(false)
|
||||
const tabs = ref([])
|
||||
|
||||
/**
|
||||
* 保存标签页
|
||||
*/
|
||||
const saveTabs = async (tabsData) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const formattedTabs = tabsData.map(tab => ({
|
||||
id: tab.id || 0,
|
||||
title: tab.title || '未命名查询',
|
||||
content: tab.content || '',
|
||||
connectionId: tab.connectionId || null,
|
||||
order: tab.order || 0
|
||||
}))
|
||||
|
||||
await saveTabsApi(formattedTabs)
|
||||
tabs.value = tabsData
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('保存标签页失败:', error)
|
||||
Message.error('保存标签页失败: ' + (error.message || error))
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载标签页
|
||||
*/
|
||||
const loadTabs = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const result = await listTabs()
|
||||
tabs.value = result || []
|
||||
return result || []
|
||||
} catch (error) {
|
||||
console.error('加载标签页失败:', error)
|
||||
Message.error('加载标签页失败: ' + (error.message || error))
|
||||
return []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const clearTabs = () => {
|
||||
tabs.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
tabs,
|
||||
saveTabs,
|
||||
loadTabs,
|
||||
clearTabs
|
||||
}
|
||||
}
|
||||
24
web/src/views/db-cli/constants/storage.ts
Normal file
24
web/src/views/db-cli/constants/storage.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* localStorage 键常量
|
||||
* 统一管理所有 localStorage 键,避免重复定义
|
||||
*/
|
||||
export const STORAGE_KEYS = {
|
||||
// SQL编辑器
|
||||
ACTIVE_TAB: 'db-cli-sql-editor-active-tab',
|
||||
// 数据库连接
|
||||
CURRENT_CONNECTION: 'db-cli-current-connection',
|
||||
SELECTED_DATABASE: 'db-cli-selected-database',
|
||||
TREE_EXPANDED_KEYS: 'db-cli-tree-expanded-keys',
|
||||
TREE_SELECTED_KEYS: 'db-cli-tree-selected-keys',
|
||||
// 编辑器状态
|
||||
EDITOR_VISIBLE: 'db-cli-editor-visible',
|
||||
EDITOR_AREA_HEIGHT: 'db-cli-editor-area-height',
|
||||
// 结果面板
|
||||
RESULT_TAB: 'db-cli-result-tab',
|
||||
// 表结构状态
|
||||
STRUCTURE_INFO: 'db-cli-structure-info',
|
||||
// 搜索历史
|
||||
TABLE_SEARCH_HISTORY: 'db-cli-table-search-history',
|
||||
TABLE_SEARCH_TEXT: 'db-cli-table-search-text'
|
||||
} as const
|
||||
|
||||
966
web/src/views/db-cli/index.vue
Normal file
966
web/src/views/db-cli/index.vue
Normal file
@@ -0,0 +1,966 @@
|
||||
<template>
|
||||
<a-layout class="db-cli-layout">
|
||||
<!-- 左侧:数据库列表视图 -->
|
||||
<a-layout-sider :width="280" class="sidebar">
|
||||
<div class="sidebar-container">
|
||||
<ConnectionTree
|
||||
:current-connection-id="currentConnection?.id"
|
||||
@connection-select="handleConnectionSelect"
|
||||
@connection-edit="handleConnectionEdit"
|
||||
@connection-delete="handleConnectionDelete"
|
||||
@connection-refresh="handleConnectionRefresh"
|
||||
@connection-test="handleConnectionTest"
|
||||
@table-select="handleTableSelect"
|
||||
@table-structure="handleTableStructure"
|
||||
@create-table="handleCreateTable"
|
||||
@new-connection="handleNewConnection"
|
||||
ref="connectionTreeRef"
|
||||
/>
|
||||
</div>
|
||||
</a-layout-sider>
|
||||
|
||||
<!-- 右侧:编辑器区域和结果区域 -->
|
||||
<a-layout ref="mainLayoutRef" class="main-layout">
|
||||
<!-- SQL编辑器区域 -->
|
||||
<a-layout-content
|
||||
v-if="editorVisible"
|
||||
ref="editorAreaRef"
|
||||
class="editor-area"
|
||||
:style="editorAreaStyle"
|
||||
>
|
||||
<SqlEditor
|
||||
:current-connection="currentConnection"
|
||||
@execute="handleExecuteSQL"
|
||||
@execute-selected="handleExecuteSQL"
|
||||
ref="sqlEditorRef"
|
||||
/>
|
||||
</a-layout-content>
|
||||
|
||||
<!-- 编辑器/结果分隔条 -->
|
||||
<div v-if="editorVisible" class="editor-result-divider" @mousedown="handleEditorResultDividerMouseDown">
|
||||
<a-button
|
||||
type="text"
|
||||
size="mini"
|
||||
class="divider-toggle-btn"
|
||||
@click.stop="toggleEditor"
|
||||
@mousedown.stop
|
||||
title="隐藏编辑器"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-down/>
|
||||
</template>
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 编辑器隐藏时的展开按钮 -->
|
||||
<div v-if="!editorVisible" class="editor-result-divider collapsed">
|
||||
<a-button type="text" size="mini" class="divider-toggle-btn" @click="toggleEditor" title="显示编辑器">
|
||||
<template #icon>
|
||||
<icon-up/>
|
||||
</template>
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 结果展示区域 -->
|
||||
<a-layout-content class="result-area">
|
||||
<ResultPanel
|
||||
ref="resultPanelRef"
|
||||
:loading="resultLoading"
|
||||
:error="resultError"
|
||||
:data="(resultData as unknown[] | undefined)"
|
||||
:mode="resultMode"
|
||||
@re-execute-sql="handleReExecuteSQL"
|
||||
:stats="(resultStats as { rowsAffected: number; executionTime: number } | undefined)"
|
||||
:columns="resultColumns"
|
||||
:messages="messages"
|
||||
:editor-visible="editorVisible"
|
||||
:structure-loading="structureLoading"
|
||||
:structure-error="structureError"
|
||||
:structure-data="structureData"
|
||||
:structure-info="structureInfo || undefined"
|
||||
:edit-mode="structureEditMode"
|
||||
:edited-columns="editedColumns"
|
||||
:edited-indexes="editedIndexes"
|
||||
@toggle-editor="toggleEditor"
|
||||
@update-columns="handleUpdateColumns"
|
||||
@update-indexes="handleUpdateIndexes"
|
||||
@refresh-structure="structureStore.refreshStructure"
|
||||
@switch-to-edit-mode="handleSwitchToEditMode"
|
||||
@switch-to-view-mode="handleSwitchToViewMode"
|
||||
@save-structure="handleSaveStructure"
|
||||
@cancel-edit="handleCancelEdit"
|
||||
@add-column="handleAddColumn"
|
||||
:create-info="createInfo"
|
||||
:create-loading="createLoading"
|
||||
@cancel-create="handleCancelCreate"
|
||||
@create-table="handleCreateTableSubmit"
|
||||
@tab-change="handleTabChange"
|
||||
@view-history="handleViewHistory"
|
||||
/>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
<!-- 连接管理表单 -->
|
||||
<ConnectionForm
|
||||
v-model:visible="showConnectionForm"
|
||||
:connection-id="editingConnectionId || undefined"
|
||||
@success="handleConnectionSuccess"
|
||||
/>
|
||||
|
||||
<!-- SQL 预览确认对话框 -->
|
||||
<a-modal
|
||||
v-model:visible="showSqlPreviewModal"
|
||||
title="确认执行表结构变更"
|
||||
:width="800"
|
||||
:mask-closable="false"
|
||||
@cancel="showSqlPreviewModal = false"
|
||||
@ok="handleConfirmSqlExecute"
|
||||
okText="确定执行"
|
||||
cancelText="取消"
|
||||
>
|
||||
<SqlPreviewDialog
|
||||
v-if="sqlPreviewStatements.length > 0"
|
||||
:statements="sqlPreviewStatements"
|
||||
:db-type="sqlPreviewDbType"
|
||||
/>
|
||||
</a-modal>
|
||||
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, provide, computed, nextTick, onMounted, onUnmounted, h, onBeforeUpdate } from 'vue'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconUp, IconDown, IconCopy } from '@arco-design/web-vue/es/icon'
|
||||
import SqlPreviewDialog from './components/SqlPreviewDialog.vue'
|
||||
import ConnectionTree from './components/ConnectionTree.vue'
|
||||
import SqlEditor from './components/SqlEditor.vue'
|
||||
import ResultPanel from './components/ResultPanel.vue'
|
||||
import ConnectionForm from './components/ConnectionForm.vue'
|
||||
import { useDbConnection } from './composables/useDbConnection'
|
||||
import { useEditorState } from './composables/useEditorState'
|
||||
import { useResultState } from './composables/useResultState'
|
||||
import { useMessageLog } from './composables/useMessageLog'
|
||||
import { useSqlExecution, DbCliKeys } from './composables/useSqlExecution'
|
||||
import { useStructureStore } from './composables/useStructureStore'
|
||||
import { useStructureEdit, type SaveStructureResult } from './composables/useStructureEdit'
|
||||
import { useCreateState } from './composables/useCreateState'
|
||||
import { createResizeHandler } from './utils/resize'
|
||||
import { STORAGE_KEYS } from './constants/storage'
|
||||
import { executeQuery } from '@/api'
|
||||
|
||||
// 类型声明
|
||||
declare global {
|
||||
interface Window {
|
||||
go?: {
|
||||
main?: {
|
||||
App?: {
|
||||
GetTableStructure?: (connectionId: number, database: string, tableName: string) => Promise<any>
|
||||
TestDbConnection?: (connectionId: number) => Promise<void>
|
||||
ExecuteSQL?: (connectionId: number, sql: string, database?: string) => Promise<any>
|
||||
}
|
||||
}
|
||||
}
|
||||
runtime?: {
|
||||
EventsOn?: (event: string, callback: () => void) => void
|
||||
EventsOff?: (event: string) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 Composables
|
||||
const {
|
||||
currentConnection,
|
||||
selectedDatabase,
|
||||
showConnectionForm,
|
||||
editingConnectionId,
|
||||
selectConnection,
|
||||
editConnection,
|
||||
deleteConnection: deleteConnectionAction,
|
||||
newConnection,
|
||||
onConnectionSuccess
|
||||
} = useDbConnection()
|
||||
|
||||
const { editorVisible, toggleEditor } = useEditorState()
|
||||
|
||||
const resultState = useResultState()
|
||||
const {
|
||||
resultLoading,
|
||||
resultError,
|
||||
resultData,
|
||||
resultMode,
|
||||
resultStats,
|
||||
resultColumns,
|
||||
clearResults
|
||||
} = resultState
|
||||
|
||||
const messageLog = useMessageLog()
|
||||
const { messages, addMessage } = messageLog
|
||||
|
||||
// 提供依赖注入(供子组件使用)
|
||||
provide(DbCliKeys.resultState, resultState)
|
||||
provide(DbCliKeys.messageLog, messageLog)
|
||||
|
||||
// 在当前组件中直接传递参数(provide/inject 用于子组件,当前组件直接传参)
|
||||
const { executeSQL } = useSqlExecution(resultState, messageLog)
|
||||
|
||||
// 新架构:使用单例 Store(事件驱动)
|
||||
const structureStore = useStructureStore()
|
||||
// 直接使用 Store 的状态(Store 暴露的是 ref,在模板中自动解包)
|
||||
// 为了类型安全,使用 computed 包装
|
||||
const structureLoading = computed(() => structureStore.loading.value)
|
||||
const structureError = computed(() => structureStore.error.value)
|
||||
const structureData = computed(() => structureStore.data.value)
|
||||
const structureInfo = computed(() => structureStore.info.value)
|
||||
|
||||
// 表结构编辑状态
|
||||
const structureEdit = useStructureEdit()
|
||||
const {
|
||||
editMode: structureEditMode,
|
||||
editedColumns,
|
||||
editedIndexes,
|
||||
switchToEditMode,
|
||||
switchToViewMode,
|
||||
previewTableStructure,
|
||||
saveStructure: saveStructureEdit,
|
||||
addColumn,
|
||||
removeColumn
|
||||
} = structureEdit
|
||||
|
||||
// 表创建状态
|
||||
const createState = useCreateState()
|
||||
const {
|
||||
createInfo,
|
||||
createLoading,
|
||||
startCreate,
|
||||
cancelCreate
|
||||
} = createState
|
||||
|
||||
// 组件引用
|
||||
const connectionTreeRef = ref<any>(null)
|
||||
const sqlEditorRef = ref<any>(null)
|
||||
const resultPanelRef = ref<any>(null)
|
||||
const mainLayoutRef = ref<any>(null)
|
||||
const editorAreaRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// SQL 预览对话框状态
|
||||
const showSqlPreviewModal = ref(false)
|
||||
const sqlPreviewStatements = ref<string[]>([])
|
||||
const sqlPreviewDbType = ref<'mysql' | 'mongo' | 'redis'>('mysql')
|
||||
const sqlPreviewInfo = ref<{
|
||||
connectionId: number
|
||||
database: string
|
||||
tableName: string
|
||||
dbType: 'mysql' | 'mongo' | 'redis'
|
||||
} | null>(null)
|
||||
|
||||
|
||||
// 编辑器/结果区域高度调整
|
||||
const loadEditorAreaHeight = (): number => {
|
||||
const saved = localStorage.getItem(STORAGE_KEYS.EDITOR_AREA_HEIGHT)
|
||||
return saved ? Number(saved) : 50
|
||||
}
|
||||
const editorAreaHeight = ref(loadEditorAreaHeight())
|
||||
const editorAreaPixelHeight = ref<number | null>(null)
|
||||
|
||||
// 计算编辑器区域的样式
|
||||
const editorAreaStyle = computed(() => {
|
||||
if (!editorVisible.value) return {}
|
||||
|
||||
// 优先使用像素高度,否则使用百分比
|
||||
if (editorAreaPixelHeight.value !== null) {
|
||||
return { height: `${editorAreaPixelHeight.value}px` }
|
||||
}
|
||||
return { height: `${editorAreaHeight.value}%` }
|
||||
})
|
||||
|
||||
// 更新编辑器区域的像素高度
|
||||
const updateEditorPixelHeight = () => {
|
||||
if (!mainLayoutRef.value || !editorVisible.value) {
|
||||
editorAreaPixelHeight.value = null
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
const mainLayoutEl = (mainLayoutRef.value as any)?.$el || mainLayoutRef.value
|
||||
if (mainLayoutEl instanceof HTMLElement) {
|
||||
const containerHeight = mainLayoutEl.getBoundingClientRect().height
|
||||
if (containerHeight > 0) {
|
||||
editorAreaPixelHeight.value = (containerHeight * editorAreaHeight.value) / 100
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 监听编辑器高度和可见性变化
|
||||
watch(() => editorAreaHeight.value, updateEditorPixelHeight)
|
||||
watch(() => editorVisible.value, (visible) => {
|
||||
if (visible) {
|
||||
updateEditorPixelHeight()
|
||||
} else {
|
||||
editorAreaPixelHeight.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const handleEditorResultDividerMouseDown = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('.divider-toggle-btn')) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const mainLayoutEl = mainLayoutRef.value
|
||||
? ((mainLayoutRef.value as any)?.$el || mainLayoutRef.value)
|
||||
: (e.currentTarget as HTMLElement).closest('.main-layout')
|
||||
|
||||
if (!(mainLayoutEl instanceof HTMLElement)) return
|
||||
|
||||
const resizeHandler = createResizeHandler(mainLayoutEl, () => editorAreaHeight.value, {
|
||||
minPercent: 20,
|
||||
maxPercent: 80,
|
||||
minPixels: 150,
|
||||
onResize: (percentage) => {
|
||||
editorAreaHeight.value = percentage
|
||||
localStorage.setItem(STORAGE_KEYS.EDITOR_AREA_HEIGHT, String(percentage))
|
||||
|
||||
const containerHeight = mainLayoutEl.getBoundingClientRect().height
|
||||
if (containerHeight > 0) {
|
||||
editorAreaPixelHeight.value = (containerHeight * percentage) / 100
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
resizeHandler(e)
|
||||
}
|
||||
|
||||
// 导入事件类型
|
||||
import type {
|
||||
ConnectionSelectEvent,
|
||||
ConnectionEditEvent,
|
||||
ConnectionDeleteEvent,
|
||||
ConnectionTestEvent,
|
||||
ConnectionRefreshEvent,
|
||||
TableSelectEvent,
|
||||
TableStructureEvent
|
||||
} from './types/events'
|
||||
|
||||
// 恢复表结构状态(用于页面刷新或重新进入时的状态恢复)
|
||||
const restoreStructureState = async () => {
|
||||
const savedInfo = structureStore.restoreStructureInfo()
|
||||
if (!savedInfo?.tableName) return
|
||||
|
||||
// 检查连接是否匹配
|
||||
if (!currentConnection.value || currentConnection.value.id !== savedInfo.connectionId) return
|
||||
|
||||
// 避免重复加载
|
||||
if (structureStore.loading.value) return
|
||||
|
||||
// 如果当前已经有不同表的信息,不恢复
|
||||
const currentInfo = structureStore.info.value
|
||||
if (currentInfo?.tableName && currentInfo.tableName !== savedInfo.tableName) return
|
||||
|
||||
const currentTab = resultPanelRef.value?.getCurrentTab() || 'result'
|
||||
|
||||
// 如果当前不是结果Tab,需要切换到结构Tab
|
||||
if (currentTab !== 'result' && resultPanelRef.value) {
|
||||
(resultPanelRef.value as any).switchToStructureTab()
|
||||
await nextTick()
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
// 再次检查加载状态(切换Tab可能触发其他加载)
|
||||
if (structureStore.loading.value) return
|
||||
|
||||
// 如果当前是结果Tab,不加载结构(保持用户在结果Tab查看数据)
|
||||
if (currentTab === 'result') return
|
||||
|
||||
// 重新加载表结构
|
||||
await structureStore.loadStructure(
|
||||
savedInfo.connectionId,
|
||||
savedInfo.database,
|
||||
savedInfo.tableName,
|
||||
savedInfo.dbType,
|
||||
savedInfo.nodeType
|
||||
)
|
||||
}
|
||||
|
||||
// 连接选择
|
||||
const handleConnectionSelect = async (data: ConnectionSelectEvent) => {
|
||||
selectConnection(data.connection, data.database)
|
||||
clearResults()
|
||||
addMessage('info', `切换到连接: ${data.connection.name}${data.database ? ` (${data.database})` : ''}`)
|
||||
|
||||
// 连接切换后延迟恢复表结构状态(给 table-structure 事件处理时间)
|
||||
await nextTick()
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
await restoreStructureState()
|
||||
}
|
||||
|
||||
// 连接编辑
|
||||
const handleConnectionEdit = (data: ConnectionEditEvent) => {
|
||||
editConnection(data.connectionId)
|
||||
}
|
||||
|
||||
const handleConnectionDelete = async (data: ConnectionDeleteEvent) => {
|
||||
const isCurrent = deleteConnectionAction(data.connectionId)
|
||||
if (isCurrent) clearResults()
|
||||
await connectionTreeRef.value?.refresh?.()
|
||||
}
|
||||
|
||||
const handleNewConnection = () => newConnection()
|
||||
|
||||
const handleConnectionRefresh = async (data: ConnectionRefreshEvent) => {
|
||||
await connectionTreeRef.value?.refreshNode?.(data.connectionId, data.nodeType, data.database)
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
const handleConnectionTest = async (data: ConnectionTestEvent) => {
|
||||
try {
|
||||
await window.go?.main?.App?.TestDbConnection?.(data.connectionId)
|
||||
Message.success('连接测试成功')
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
Message.error('连接测试失败: ' + errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成数据库查询命令
|
||||
const generateQueryCommand = (dbType: string, database: string, tableName: string, pretty: boolean = false): string => {
|
||||
if (dbType === 'mongo') {
|
||||
const command = {
|
||||
op: "find",
|
||||
collection: tableName,
|
||||
filter: {},
|
||||
limit: 100
|
||||
}
|
||||
return pretty ? JSON.stringify(command, null, 2) : JSON.stringify(command)
|
||||
} else if (dbType === 'redis') {
|
||||
return `GET "${tableName}"`
|
||||
} else {
|
||||
return `SELECT * FROM \`${database}\`.\`${tableName}\` LIMIT 10;`
|
||||
}
|
||||
}
|
||||
|
||||
// 表选择(生成SQL/命令)
|
||||
const handleTableSelect = (data: TableSelectEvent) => {
|
||||
const dbType = data.dbType || currentConnection.value?.type || 'mysql'
|
||||
const sql = generateQueryCommand(dbType, data.database, data.tableName, true)
|
||||
sqlEditorRef.value?.insertSQL?.(sql)
|
||||
}
|
||||
|
||||
// 查询表数据(用于表节点点击时自动查询数据)
|
||||
const queryTableData = async (connectionId: number, database: string, tableName: string, dbType: 'mysql' | 'mongo' | 'redis' = 'mysql', nodeType: string = 'table') => {
|
||||
if (!currentConnection.value || currentConnection.value.id !== connectionId) return
|
||||
|
||||
// 保存表信息到 structureStore,以便切换到"结构"Tab时能自动加载
|
||||
structureStore.info.value = { connectionId, database, tableName, dbType, nodeType }
|
||||
|
||||
const sql = generateQueryCommand(dbType, database, tableName)
|
||||
await handleExecuteSQL(sql) // 用 handleExecuteSQL 保存原始 SQL,支持翻页
|
||||
}
|
||||
|
||||
const handleTableStructure = async (data: TableStructureEvent) => {
|
||||
if (!editorVisible.value) toggleEditor()
|
||||
|
||||
const currentTab = resultPanelRef.value?.getCurrentTab() || 'result'
|
||||
|
||||
if (currentTab === 'result') {
|
||||
await queryTableData(data.connectionId, data.database, data.tableName, data.dbType, data.nodeType)
|
||||
} else if (currentTab === 'structure') {
|
||||
const currentInfo = structureStore.info.value
|
||||
const isDifferentTable = !currentInfo ||
|
||||
currentInfo.connectionId !== data.connectionId ||
|
||||
currentInfo.database !== data.database ||
|
||||
currentInfo.tableName !== data.tableName
|
||||
|
||||
if (isDifferentTable && structureEditMode.value === 'edit') switchToViewMode()
|
||||
|
||||
await structureStore.loadStructure(
|
||||
data.connectionId,
|
||||
data.database,
|
||||
data.tableName,
|
||||
data.dbType,
|
||||
data.nodeType
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 查看历史记录(将历史记录加载到结果面板显示)
|
||||
const handleViewHistory = (historyItem: any) => {
|
||||
if (!historyItem) return
|
||||
|
||||
// 根据历史记录类型设置结果数据
|
||||
if (historyItem.type === 'query') {
|
||||
resultState.setQueryResult(
|
||||
historyItem.data || [],
|
||||
{
|
||||
rowsAffected: historyItem.rows_affected || 0,
|
||||
executionTime: historyItem.execution_time || 0
|
||||
},
|
||||
historyItem.columns || []
|
||||
)
|
||||
} else if (historyItem.type === 'update') {
|
||||
resultState.setUpdateResult({
|
||||
rowsAffected: historyItem.rows_affected || 0,
|
||||
executionTime: historyItem.execution_time || 0
|
||||
})
|
||||
} else {
|
||||
resultState.setCommandResult(
|
||||
historyItem.data,
|
||||
{
|
||||
rowsAffected: historyItem.rows_affected || 0,
|
||||
executionTime: historyItem.execution_time || 0
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTabChange = async (newTab: string, oldTab: string) => {
|
||||
const structureInfo = structureStore.info.value
|
||||
if (!structureInfo?.tableName) return
|
||||
if (!currentConnection.value || currentConnection.value.id !== structureInfo.connectionId) return
|
||||
|
||||
if (newTab === 'result' && oldTab !== 'result') {
|
||||
await queryTableData(
|
||||
structureInfo.connectionId,
|
||||
structureInfo.database,
|
||||
structureInfo.tableName,
|
||||
structureInfo.dbType
|
||||
)
|
||||
} else if (newTab === 'structure' && oldTab !== 'structure') {
|
||||
const currentData = structureStore.data.value
|
||||
if (!currentData || (currentData.type === 'mysql' && currentData.table !== structureInfo.tableName)) {
|
||||
await structureStore.loadStructure(
|
||||
structureInfo.connectionId,
|
||||
structureInfo.database,
|
||||
structureInfo.tableName,
|
||||
structureInfo.dbType,
|
||||
structureInfo.nodeType
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 开始创建表
|
||||
const handleCreateTable = (data: { connectionId: number; database: string; dbType: 'mysql' | 'mongo' | 'redis' }) => {
|
||||
// 如果结果面板隐藏,自动显示编辑器(这样结果面板也会显示)
|
||||
if (!editorVisible.value) {
|
||||
toggleEditor()
|
||||
}
|
||||
|
||||
startCreate(data.connectionId, data.database, data.dbType)
|
||||
}
|
||||
|
||||
// 取消创建
|
||||
const handleCancelCreate = () => {
|
||||
cancelCreate()
|
||||
}
|
||||
|
||||
// 提交创建表
|
||||
const handleCreateTableSubmit = async (data: { connectionId: number; database: string; tableName: string; sql: string }) => {
|
||||
try {
|
||||
createLoading.value = true
|
||||
|
||||
// 执行 CREATE TABLE SQL
|
||||
const result = await executeQuery(
|
||||
data.connectionId,
|
||||
data.sql,
|
||||
data.database
|
||||
)
|
||||
|
||||
Message.success(`表 ${data.tableName} 创建成功`)
|
||||
addMessage('success', `表 ${data.tableName} 创建成功`)
|
||||
|
||||
// 取消创建状态
|
||||
cancelCreate()
|
||||
|
||||
// 刷新连接树(刷新表列表)
|
||||
if (connectionTreeRef.value) {
|
||||
await connectionTreeRef.value.refresh()
|
||||
}
|
||||
|
||||
// 切换到结构 Tab 并加载新创建的表结构
|
||||
if (resultPanelRef.value) {
|
||||
(resultPanelRef.value as any).switchToStructureTab()
|
||||
}
|
||||
|
||||
// 等待一下确保Tab切换完成
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// 加载新创建的表结构
|
||||
await structureStore.loadStructure(
|
||||
data.connectionId,
|
||||
data.database,
|
||||
data.tableName,
|
||||
'mysql',
|
||||
'table'
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
Message.error('创建表失败: ' + errorMessage)
|
||||
addMessage('error', '创建表失败: ' + errorMessage)
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存当前执行的 SQL(用于分页)
|
||||
const currentExecutedSQL = ref('')
|
||||
|
||||
// 执行SQL
|
||||
const handleExecuteSQL = async (sql: string, page?: number, pageSize?: number) => {
|
||||
// 保存原始 SQL(不包含分页信息)
|
||||
if (page == null && pageSize == null) {
|
||||
currentExecutedSQL.value = sql
|
||||
}
|
||||
|
||||
const resolvedPage = page ?? 1
|
||||
const resolvedPageSize = pageSize ?? 10
|
||||
await executeSQL(sql, currentConnection.value, selectedDatabase.value, resolvedPage, resolvedPageSize)
|
||||
// 执行完成后,等待一下确保结果已经设置,然后切换到结果 tab
|
||||
await nextTick()
|
||||
setTimeout(() => {
|
||||
if (resultPanelRef.value && (resultData.value !== null || resultStats.value !== null)) {
|
||||
resultPanelRef.value.switchToResultTab()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// 处理分页重新执行 SQL
|
||||
const handleReExecuteSQL = async (pagination: { page: number; pageSize: number }) => {
|
||||
if (!currentExecutedSQL.value) {
|
||||
Message.warning('无法翻页:缺少原始 SQL 语句')
|
||||
return
|
||||
}
|
||||
await handleExecuteSQL(currentExecutedSQL.value, pagination.page, pagination.pageSize)
|
||||
}
|
||||
|
||||
// 连接表单成功回调
|
||||
const handleConnectionSuccess = async () => {
|
||||
const editedId = editingConnectionId.value
|
||||
// 刷新连接列表
|
||||
if (connectionTreeRef.value) {
|
||||
await connectionTreeRef.value?.refresh()
|
||||
}
|
||||
onConnectionSuccess(editedId)
|
||||
}
|
||||
|
||||
// 表结构编辑相关处理
|
||||
const handleSwitchToEditMode = () => {
|
||||
const data = structureStore.data.value
|
||||
const info = structureStore.info.value
|
||||
if (!data || !info) {
|
||||
console.warn('切换到编辑模式失败:缺少数据或信息', { data, info })
|
||||
return
|
||||
}
|
||||
if (info.dbType === 'mysql' && (data.type === 'mysql' || !data.type)) {
|
||||
const columns = data.columns || []
|
||||
const indexes = data.indexes || []
|
||||
if (columns.length === 0) {
|
||||
console.warn('切换到编辑模式失败:字段列表为空', data)
|
||||
return
|
||||
}
|
||||
switchToEditMode(columns, indexes)
|
||||
} else if (info.dbType === 'mongo' && data.type === 'mongo') {
|
||||
switchToEditMode([], data.structure?.indexes || [])
|
||||
}
|
||||
}
|
||||
|
||||
const handleSwitchToViewMode = () => {
|
||||
switchToViewMode()
|
||||
}
|
||||
|
||||
// 表结构保存处理(包含预览和用户确认流程)
|
||||
const handleSaveStructure = async () => {
|
||||
const info = structureStore.info.value
|
||||
if (!info) return
|
||||
|
||||
try {
|
||||
// 第一步:预览生成 SQL 语句
|
||||
const previewStatements = await previewTableStructure(
|
||||
info.connectionId,
|
||||
info.database,
|
||||
info.tableName,
|
||||
info.dbType
|
||||
)
|
||||
|
||||
// 如果没有变更,直接返回
|
||||
if (previewStatements.length === 0) {
|
||||
Message.info('表结构未发生变化')
|
||||
return
|
||||
}
|
||||
|
||||
// 第二步:显示确认对话框,让用户确认执行
|
||||
sqlPreviewStatements.value = previewStatements
|
||||
sqlPreviewDbType.value = info.dbType
|
||||
sqlPreviewInfo.value = {
|
||||
connectionId: info.connectionId,
|
||||
database: info.database,
|
||||
tableName: info.tableName,
|
||||
dbType: info.dbType
|
||||
}
|
||||
showSqlPreviewModal.value = true
|
||||
} catch (error: unknown) {
|
||||
console.error('预览表结构变更失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
Message.error('预览表结构变更失败: ' + errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
switchToViewMode()
|
||||
}
|
||||
|
||||
// 确认执行 SQL
|
||||
const handleConfirmSqlExecute = async () => {
|
||||
if (!sqlPreviewInfo.value) return
|
||||
|
||||
const info = sqlPreviewInfo.value
|
||||
showSqlPreviewModal.value = false
|
||||
|
||||
if (info.dbType === 'redis') {
|
||||
Message.error('Redis 不支持表结构修改')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await saveStructureEdit(
|
||||
info.connectionId,
|
||||
info.database,
|
||||
info.tableName,
|
||||
info.dbType as 'mysql' | 'mongo'
|
||||
)
|
||||
|
||||
if (result && result.success) {
|
||||
// 保存成功后刷新结构数据
|
||||
await structureStore.refreshStructure()
|
||||
|
||||
// 在消息面板中展示生成的 SQL 语句
|
||||
if (result.sqlStatements && result.sqlStatements.length > 0) {
|
||||
addMessage('success', `表结构变更成功,执行了 ${result.sqlStatements.length} 条语句`)
|
||||
// 为每条 SQL 语句添加消息
|
||||
result.sqlStatements.forEach((sql: string, index: number) => {
|
||||
addMessage('info', `[${index + 1}] ${sql}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新编辑数据
|
||||
const handleUpdateColumns = (columns: any[]) => {
|
||||
editedColumns.value = columns
|
||||
}
|
||||
|
||||
const handleUpdateIndexes = (indexes: any[]) => {
|
||||
editedIndexes.value = indexes
|
||||
}
|
||||
|
||||
// 添加字段
|
||||
const handleAddColumn = () => {
|
||||
addColumn()
|
||||
}
|
||||
|
||||
// 清理本地缓存
|
||||
const handleClearCache = () => {
|
||||
Modal.confirm({
|
||||
title: '清理本地缓存',
|
||||
content: '确定要清理所有本地缓存数据吗?这将清除编辑器状态、连接状态、展开状态等所有缓存信息。',
|
||||
onOk: () => {
|
||||
try {
|
||||
// 清理所有 localStorage 缓存
|
||||
Object.values(STORAGE_KEYS).forEach(key => {
|
||||
localStorage.removeItem(key)
|
||||
})
|
||||
Message.success('本地缓存已清理')
|
||||
|
||||
// 重置连接树状态
|
||||
if (connectionTreeRef.value) {
|
||||
connectionTreeRef.value.refresh()
|
||||
}
|
||||
|
||||
// 重置编辑器状态
|
||||
clearResults()
|
||||
} catch (error) {
|
||||
Message.error('清理缓存失败: ' + (error.message || error))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 监听容器大小变化,更新编辑器区域高度
|
||||
let mainLayoutResizeObserver: ResizeObserver | null = null
|
||||
|
||||
// 组件挂载时的初始化工作
|
||||
onMounted(async () => {
|
||||
// 监听 Wails 事件(来自窗口菜单的清理缓存功能)
|
||||
if (window.runtime?.EventsOn) {
|
||||
window.runtime.EventsOn('clear-cache', () => {
|
||||
handleClearCache()
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化编辑器像素高度并监听容器大小变化
|
||||
nextTick(() => {
|
||||
updateEditorPixelHeight()
|
||||
|
||||
const mainLayoutEl = mainLayoutRef.value
|
||||
? ((mainLayoutRef.value as any)?.$el || mainLayoutRef.value)
|
||||
: null
|
||||
|
||||
if (mainLayoutEl instanceof HTMLElement) {
|
||||
mainLayoutResizeObserver = new ResizeObserver(updateEditorPixelHeight)
|
||||
mainLayoutResizeObserver.observe(mainLayoutEl)
|
||||
}
|
||||
})
|
||||
|
||||
// 加载保存的标签页内容
|
||||
await nextTick()
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
if (sqlEditorRef.value?.loadSavedTabs) {
|
||||
try {
|
||||
await sqlEditorRef.value.loadSavedTabs()
|
||||
} catch (error) {
|
||||
console.warn('加载保存的标签页失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时的清理工作
|
||||
onUnmounted(() => {
|
||||
// 取消 Wails 事件监听
|
||||
if (window.runtime?.EventsOff) {
|
||||
window.runtime.EventsOff('clear-cache')
|
||||
}
|
||||
|
||||
// 清理 ResizeObserver 避免内存泄漏
|
||||
if (mainLayoutResizeObserver) {
|
||||
mainLayoutResizeObserver.disconnect()
|
||||
mainLayoutResizeObserver = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 主布局容器 */
|
||||
.db-cli-layout {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 侧边栏 - 使用 Arco 设计令牌 */
|
||||
.sidebar {
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
border-right: 1px solid var(--color-border-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 主布局容器 - 使用 Arco Layout */
|
||||
.main-layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 编辑器区域 - 使用 Arco Layout Content */
|
||||
.editor-area {
|
||||
flex: 0 0 auto !important; /* 覆盖 Arco 的 flex: auto,使用固定高度 */
|
||||
min-height: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.editor-area :deep(.sql-editor-wrapper) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 编辑器/结果分隔条 - 使用 Arco 设计令牌 */
|
||||
.editor-result-divider {
|
||||
flex-shrink: 0;
|
||||
height: 4px;
|
||||
background: var(--color-border-2);
|
||||
cursor: row-resize;
|
||||
position: relative;
|
||||
transition: background-color var(--transition-duration-2) var(--transition-timing-function-ease-out);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.editor-result-divider:hover {
|
||||
background: var(--color-border-3);
|
||||
}
|
||||
|
||||
.editor-result-divider.collapsed {
|
||||
cursor: pointer;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.editor-result-divider.collapsed:hover {
|
||||
background: var(--color-primary-light-4);
|
||||
}
|
||||
|
||||
.divider-toggle-btn {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
background: var(--color-bg-1);
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: var(--border-radius-small);
|
||||
box-shadow: var(--shadow-1-down);
|
||||
transition: all var(--transition-duration-2) var(--transition-timing-function-ease-out);
|
||||
padding: 0;
|
||||
min-width: 30px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.divider-toggle-btn:hover {
|
||||
background: var(--color-bg-2);
|
||||
border-color: var(--color-primary-light-2);
|
||||
box-shadow: var(--shadow-2-down);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.divider-toggle-btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow-1-down);
|
||||
}
|
||||
|
||||
/* 结果区域 - 使用 Arco Layout Content */
|
||||
.result-area {
|
||||
flex: 1;
|
||||
min-height: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--color-border-2);
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.result-area :deep(.result-panel-wrapper) {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
6
web/src/views/db-cli/index.vue.tmp
Normal file
6
web/src/views/db-cli/index.vue.tmp
Normal file
@@ -0,0 +1,6 @@
|
||||
// 新架构:使用单例 Store(事件驱动)
|
||||
const structureStore = useStructureStore()
|
||||
// 直接使用 Store 的状态(无需计算属性,无需 watch)
|
||||
// 状态是只读的,通过 Store 方法修改
|
||||
|
||||
// 表结构编辑状态
|
||||
149
web/src/views/db-cli/types/events.ts
Normal file
149
web/src/views/db-cli/types/events.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 数据库客户端事件类型定义
|
||||
* 所有事件参数使用对象格式,确保类型安全和易于扩展
|
||||
*/
|
||||
|
||||
// ==================== 连接相关事件 ====================
|
||||
|
||||
/**
|
||||
* 连接选择事件
|
||||
*/
|
||||
export interface ConnectionSelectEvent {
|
||||
connection: {
|
||||
id: number
|
||||
name: string
|
||||
type: 'mysql' | 'mongo' | 'redis'
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
database?: string
|
||||
[key: string]: any
|
||||
}
|
||||
database?: string // 可选,选中的数据库
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接编辑事件
|
||||
*/
|
||||
export interface ConnectionEditEvent {
|
||||
connectionId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接删除事件
|
||||
*/
|
||||
export interface ConnectionDeleteEvent {
|
||||
connectionId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接刷新事件
|
||||
*/
|
||||
export interface ConnectionRefreshEvent {
|
||||
connectionId: number
|
||||
nodeType?: 'connection' | 'database' | 'table' | 'collection' | 'key' // 节点类型
|
||||
database?: string // 数据库名(如果是数据库或表节点)
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接测试事件
|
||||
*/
|
||||
export interface ConnectionTestEvent {
|
||||
connectionId: number
|
||||
}
|
||||
|
||||
// ==================== 表结构相关事件 ====================
|
||||
|
||||
/**
|
||||
* 查看表结构事件
|
||||
*/
|
||||
export interface TableStructureEvent {
|
||||
connectionId: number
|
||||
database: string
|
||||
tableName: string // 表名/集合名/Key名,对于连接和数据库节点可能为空
|
||||
dbType: 'mysql' | 'mongo' | 'redis'
|
||||
nodeType: 'table' | 'collection' | 'key' | 'database' | 'connection'
|
||||
}
|
||||
|
||||
/**
|
||||
* 表选择事件(用于生成SQL)
|
||||
*/
|
||||
export interface TableSelectEvent {
|
||||
connectionId: number
|
||||
database: string
|
||||
tableName: string
|
||||
dbType?: 'mysql' | 'mongo' | 'redis'
|
||||
sql?: string // 可选,预生成的SQL
|
||||
}
|
||||
|
||||
// ==================== SQL执行相关事件 ====================
|
||||
|
||||
/**
|
||||
* SQL执行事件
|
||||
*/
|
||||
export interface SqlExecuteEvent {
|
||||
sql: string
|
||||
connectionId: number
|
||||
database?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL执行完成事件
|
||||
*/
|
||||
export interface SqlExecuteCompleteEvent {
|
||||
result?: any
|
||||
error?: string
|
||||
}
|
||||
|
||||
// ==================== 编辑器相关事件 ====================
|
||||
|
||||
/**
|
||||
* SQL插入事件
|
||||
*/
|
||||
export interface SqlInsertEvent {
|
||||
sql: string
|
||||
tabKey?: string // 可选,指定Tab
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab切换事件
|
||||
*/
|
||||
export interface TabSwitchEvent {
|
||||
tabKey: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab关闭事件
|
||||
*/
|
||||
export interface TabCloseEvent {
|
||||
tabKey: string
|
||||
}
|
||||
|
||||
// ==================== 组件事件映射 ====================
|
||||
|
||||
/**
|
||||
* ConnectionTree 组件事件
|
||||
*/
|
||||
export interface ConnectionTreeEvents {
|
||||
'connection-select': ConnectionSelectEvent
|
||||
'connection-edit': ConnectionEditEvent
|
||||
'connection-delete': ConnectionDeleteEvent
|
||||
'connection-refresh': ConnectionRefreshEvent
|
||||
'connection-test': ConnectionTestEvent
|
||||
'table-select': TableSelectEvent
|
||||
'table-structure': TableStructureEvent
|
||||
'new-connection': void
|
||||
}
|
||||
|
||||
/**
|
||||
* SqlEditor 组件事件
|
||||
*/
|
||||
export interface SqlEditorEvents {
|
||||
'execute': { sql: string }
|
||||
'execute-selected': { sql: string }
|
||||
'sql-insert': SqlInsertEvent
|
||||
'tab-switch': TabSwitchEvent
|
||||
'tab-close': TabCloseEvent
|
||||
'toggle-editor': void
|
||||
}
|
||||
|
||||
88
web/src/views/db-cli/utils/mysqlFieldUtils.ts
Normal file
88
web/src/views/db-cli/utils/mysqlFieldUtils.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// MySQL 数据类型选项
|
||||
export const mysqlDataTypeOptions = [
|
||||
{
|
||||
label: '整数类型',
|
||||
options: [
|
||||
{ label: 'TINYINT', value: 'TINYINT' },
|
||||
{ label: 'SMALLINT', value: 'SMALLINT' },
|
||||
{ label: 'MEDIUMINT', value: 'MEDIUMINT' },
|
||||
{ label: 'INT', value: 'INT' },
|
||||
{ label: 'BIGINT', value: 'BIGINT' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '浮点类型',
|
||||
options: [
|
||||
{ label: 'FLOAT', value: 'FLOAT' },
|
||||
{ label: 'DOUBLE', value: 'DOUBLE' },
|
||||
{ label: 'DECIMAL', value: 'DECIMAL' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '字符串类型',
|
||||
options: [
|
||||
{ label: 'CHAR', value: 'CHAR' },
|
||||
{ label: 'VARCHAR', value: 'VARCHAR' },
|
||||
{ label: 'TEXT', value: 'TEXT' },
|
||||
{ label: 'TINYTEXT', value: 'TINYTEXT' },
|
||||
{ label: 'MEDIUMTEXT', value: 'MEDIUMTEXT' },
|
||||
{ label: 'LONGTEXT', value: 'LONGTEXT' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '日期时间类型',
|
||||
options: [
|
||||
{ label: 'DATE', value: 'DATE' },
|
||||
{ label: 'TIME', value: 'TIME' },
|
||||
{ label: 'DATETIME', value: 'DATETIME' },
|
||||
{ label: 'TIMESTAMP', value: 'TIMESTAMP' },
|
||||
{ label: 'YEAR', value: 'YEAR' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '其他类型',
|
||||
options: [
|
||||
{ label: 'BLOB', value: 'BLOB' },
|
||||
{ label: 'JSON', value: 'JSON' },
|
||||
{ label: 'ENUM', value: 'ENUM' },
|
||||
{ label: 'SET', value: 'SET' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// 需要长度参数的类型
|
||||
export const typesNeedLength = ['VARCHAR', 'CHAR', 'DECIMAL', 'FLOAT', 'DOUBLE']
|
||||
|
||||
// 解析类型字符串,提取基础类型和长度参数
|
||||
export const parseType = (typeStr: string): { baseType: string; length: string | null } => {
|
||||
if (!typeStr) return { baseType: '', length: null }
|
||||
|
||||
const match = typeStr.match(/^(\w+)(?:\((.+?)\))?$/i)
|
||||
if (match) {
|
||||
return {
|
||||
baseType: match[1].toUpperCase(),
|
||||
length: match[2] || null
|
||||
}
|
||||
}
|
||||
return { baseType: typeStr.toUpperCase(), length: null }
|
||||
}
|
||||
|
||||
// 格式化类型字符串
|
||||
export const formatType = (baseType: string, length: string | null): string => {
|
||||
if (!baseType) return ''
|
||||
if (length) {
|
||||
return `${baseType}(${length})`
|
||||
}
|
||||
return baseType
|
||||
}
|
||||
|
||||
// 获取类型的默认长度
|
||||
export const getDefaultLength = (baseType: string): string | null => {
|
||||
const upperType = baseType.toUpperCase()
|
||||
if (upperType === 'VARCHAR') return '255'
|
||||
if (upperType === 'CHAR') return '10'
|
||||
if (upperType === 'DECIMAL') return '10,2'
|
||||
if (upperType === 'FLOAT') return ''
|
||||
if (upperType === 'DOUBLE') return ''
|
||||
return null
|
||||
}
|
||||
35
web/src/views/db-cli/utils/resize.ts
Normal file
35
web/src/views/db-cli/utils/resize.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export interface ResizeOptions {
|
||||
minPercent?: number
|
||||
maxPercent?: number
|
||||
minPixels?: number
|
||||
onResize?: (percentage: number) => void
|
||||
}
|
||||
|
||||
export function createResizeHandler(
|
||||
container: HTMLElement | null,
|
||||
getInitialPercentage: () => number,
|
||||
options: ResizeOptions = {}
|
||||
): (e: MouseEvent) => void {
|
||||
const { minPercent = 20, maxPercent = 80, minPixels = 150, onResize } = options
|
||||
|
||||
return (e: MouseEvent) => {
|
||||
if (!container) return
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!container) return
|
||||
const rect = container.getBoundingClientRect()
|
||||
const percentage = ((moveEvent.clientY - rect.top) / rect.height) * 100
|
||||
const minPercentFromPixels = (minPixels / rect.height) * 100
|
||||
const clamped = Math.max(Math.max(minPercent, minPercentFromPixels), Math.min(maxPercent, percentage))
|
||||
onResize?.(clamped)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user