63 Commits

Author SHA1 Message Date
meilin.huang
57361d8241 feat: 支持关联多标签、计划任务立即执行、标签相关操作优化 2023-12-05 23:03:51 +08:00
meilin.huang
b347bd7ef5 feat: 数据库超时时间设置 2023-11-30 15:02:48 +08:00
meilin.huang
070c8ac0da fix: 排序导致条件丢失 2023-11-29 20:13:29 +08:00
meilin.huang
e221c2f42e feat: 新增系统全局分页size配置,可根据屏幕大小自行设置 2023-11-29 17:34:54 +08:00
Coder慌
c7bab3a71b !62 fix:gauss驱动支持ssh
Merge pull request !62 from zongyangleo/dev_1128
2023-11-29 08:40:25 +00:00
刘宗洋
82c17a51a2 fix:libpq驱动支持gaussdb sha256加密登录 2023-11-28 22:49:42 +08:00
meilin.huang
e4447e6bc2 refactor: code review 2023-11-27 17:40:47 +08:00
Coder慌
b9570d9a5f !61 fix: 1、pgsql驱动换成高斯db驱动 2、sql格式化支持传数据库类型
Merge pull request !61 from zongyangleo/dev_1127
2023-11-27 08:58:32 +00:00
刘宗洋
01e8a2c14d fix:
1、pgsql驱动换成高斯db驱动
2、sql格式化支持传数据库类型
2023-11-27 16:55:00 +08:00
meilin.huang
64bd51c3b0 refactor: code review 2023-11-26 21:21:35 +08:00
Coder慌
54ab34df3f !60 feat: 支持pgsql编辑表、索引
Merge pull request !60 from zongyangleo/dev_1126
2023-11-26 02:32:27 +00:00
刘宗洋
206490ba3e feat: 支持pgsql编辑表、索引 2023-11-26 01:47:49 +08:00
meilin.huang
16612d2c9c refactor: 多tab结果集调整 2023-11-24 17:03:08 +08:00
meilin.huang
6b65605360 feat: sql查询支持多tab结果集 2023-11-24 12:12:47 +08:00
meilin.huang
bb37ed3b95 refactor: 数据库操作界面小优化 2023-11-22 12:19:07 +08:00
meilin.huang
d102cc8c08 feat: 数据库表操作支持复制单元格数据菜单 2023-11-20 12:21:27 +08:00
meilin.huang
a6df74d63d refactor: 虚拟表格优化 2023-11-18 21:15:33 +08:00
meilin.huang
f79760943e refactor: 虚拟表格与contextmenu菜单优化 2023-11-18 15:22:25 +08:00
meilin.huang
a40ec21a05 refactor: 数据库表使用虚拟表替换,提升数据量较大时的渲染速度 2023-11-17 13:31:28 +08:00
meilin.huang
43230267b6 refactor: 界面小调整 2023-11-15 12:28:49 +08:00
meilin.huang
0ae99cdaf9 refactor: contextmenu组件优化、标签&资源替换为contextmenu操作 2023-11-14 17:36:51 +08:00
meilin.huang
f234c72514 refactor: DB-数据操作优化 2023-11-13 17:41:03 +08:00
meilin.huang
76527d95bd refactor: 机器相关配置迁移至系统配置、pgsql数据操作完善、新增context-path 2023-11-12 20:14:44 +08:00
meilin.huang
27c53385f2 refactor: 列表操作按钮调整 2023-11-09 12:11:11 +08:00
Coder慌
a1b25e9766 !59 fix: sql代码提示修复:支持跨schema提示
Merge pull request !59 from zongyangleo/dev_1109
2023-11-09 02:13:31 +00:00
刘宗洋
abad0ed481 fix: sql代码提示修复:支持跨schema提示 2023-11-09 09:48:58 +08:00
meilin.huang
eddda41291 feat: 机器列表新增运行状态 & refactor: 登录账号信息存储与context 2023-11-07 21:05:21 +08:00
meilin.huang
d9adf0fd25 fix: 表问题修复 2023-11-03 22:29:01 +08:00
meilin.huang
0ce82b41ba refactor: 标签树展示调整 2023-11-03 17:09:20 +08:00
meilin.huang
37026f3269 feat: 数据库表操作显示表size&其他小优化 2023-11-02 12:46:21 +08:00
meilin.huang
3155380f16 refactor: tooltip延迟显示等 2023-10-31 12:36:04 +08:00
meilin.huang
f2b0f294d8 refactor: machine包调整 2023-10-30 17:34:56 +08:00
meilin.huang
12f63ef3dd refactor: db/redis/mongo连接代码包独立 2023-10-27 17:41:45 +08:00
meilin.huang
a1303b52eb refactor: 新增base.Repo与base.App,重构repo与app层代码 2023-10-26 17:15:49 +08:00
meilin.huang
10f6b03fb5 refactor: code review 2023-10-20 21:31:46 +08:00
may-fly
45d2449221 Merge pull request #74 from kanzihuang/fix-showcreatetable
fix: show create table for postgres
2023-10-20 21:15:54 +08:00
wanli
9e5f146e05 fix: show create table for postgres 2023-10-20 17:37:09 +08:00
meilin.huang
2b91bbe185 refactor: websocket支持单用户多连接 2023-10-19 19:00:23 +08:00
may-fly
747ea6404d Merge pull request #73 from kanzihuang/feat-notify
feature: 每个客户端独立处理后端发送的系统消息
2023-10-18 08:36:21 -05:00
wanli
ccfc6bd1df feature: 每个客户端独立处理后端发送的系统消息 2023-10-18 20:31:27 +08:00
kanzihuang
361eafedae feature: 执行 SQL 脚本时显示执行进度 2023-10-18 15:41:57 +08:00
meilin.huang
a64b894b08 refactor: sqlite依赖替换 2023-10-17 12:32:59 +08:00
may-fly
0ad805c170 Merge pull request #72 from kanzihuang/refactor-dbtype
refactor: 实现 DbType 类型,集中处理部分差异化的数据库操作
2023-10-15 19:41:19 -05:00
kanzihuang
ba82b5b516 refactor: 实现 DbType 类型,集中处理部分差异化的数据库操作 2023-10-15 11:39:42 +08:00
may-fly
f04b82c933 Merge pull request #71 from kanzihuang/fix-exec-postgres-sql-pullrequest
fix: 执行或导入 SQL 脚本支持 PostgreSQL
2023-10-14 09:57:12 -05:00
kanzihuang
23b137ab9b fix: 执行或导入 SQL 脚本支持 PostgreSQL 2023-10-14 21:10:53 +08:00
meilin.huang
a4d3a4627a refactor: 系统水印优化等 2023-10-14 16:00:16 +08:00
meilin.huang
77ae6e3bab refactor: 系统水印重构 2023-10-14 00:38:51 +08:00
meilin.huang
e0f1f40ba0 fix: 缓存使用redis无法set问题修复&admin账号默认有所有菜单 2023-10-12 21:50:55 +08:00
meilin.huang
d300f604f1 review 2023-10-12 12:14:56 +08:00
may-fly
2c2c0ff40b Merge pull request #69 from kanzihuang/feat-progress-notify-pullrequest
feat: 显示 SQL 文件执行进度
2023-10-10 20:52:24 -05:00
kanzihuang
b4ddbbd38f fix: 使用最新版 vitess sqlparser 解析 SQL 语句
解决 xwb1989/sqlparser 不支持 current_timestamp() 的问题
2023-10-10 23:56:01 +08:00
wanli
7544288451 feat: 前端显示 SQL 文件执行进度 2023-10-10 23:28:25 +08:00
meilin.huang
41443dccc0 feat: 支持sqlite存储数据 2023-10-10 23:21:29 +08:00
meilin.huang
22e218fc5f refactor: 系统消息调整 2023-10-10 17:39:46 +08:00
meilin.huang
4da0d1abaa refactor: form表单label统一去除':' 2023-10-09 17:29:52 +08:00
meilin.huang
6563b53436 refactor: 包依赖升级等 2023-10-08 12:14:19 +08:00
meilin.huang
fac71a4794 fix: 前端代理默认端口调整&水印开关不生效 2023-09-27 17:19:58 +08:00
meilin.huang
92dff6fdc3 refactor: review 2023-09-26 17:38:52 +08:00
meilin.huang
a1eca3d691 refactor: 登录页调整 2023-09-23 22:52:05 +08:00
meilin.huang
6681dc1057 refactor: 数据库sql提示优化&机器终端支持全屏 2023-09-20 20:42:23 +08:00
meilin.huang
829a68feaa fix: 数据库多库切换关键字提示错误修复&sql编辑器组件统一 2023-09-19 23:00:32 +08:00
meilin.huang
72677e270d feat: 前端用户信息迁移至localstorage 2023-09-16 17:07:48 +08:00
380 changed files with 13056 additions and 9216 deletions

View File

@@ -18,7 +18,7 @@ WORKDIR /mayfly
# Copy the go source for building server
COPY server .
RUN go mod download
RUN go mod tidy && go mod download
COPY --from=fe-builder /mayfly/dist /mayfly/static/static

View File

@@ -1,19 +1,19 @@
# 🌈mayfly-go
<p align="center">
<a href="https://gitee.com/objs/mayfly-go" target="_blank">
<img src="https://gitee.com/objs/mayfly-go/badge/star.svg?theme=white" alt="star"/>
<img src="https://gitee.com/objs/mayfly-go/badge/fork.svg" alt="fork"/>
<a href="https://gitee.com/dromara/mayfly-go" target="_blank">
<img src="https://gitee.com/dromara/mayfly-go/badge/star.svg?theme=white" alt="star"/>
<img src="https://gitee.com/dromara/mayfly-go/badge/fork.svg" alt="fork"/>
</a>
<a href="https://github.com/may-fly/mayfly-go" target="_blank">
<img src="https://img.shields.io/github/stars/may-fly/mayfly-go.svg?style=social" alt="github star"/>
<img src="https://img.shields.io/github/forks/may-fly/mayfly-go.svg?style=social" alt="github fork"/>
<a href="https://github.com/dromara/mayfly-go" target="_blank">
<img src="https://img.shields.io/github/stars/dromara/mayfly-go.svg?style=social" alt="github star"/>
<img src="https://img.shields.io/github/forks/dromara/mayfly-go.svg?style=social" alt="github fork"/>
</a>
<a href="https://hub.docker.com/r/mayflygo/mayfly-go/tags" target="_blank">
<img src="https://img.shields.io/docker/pulls/mayflygo/mayfly-go.svg?label=docker%20pulls&color=fac858" alt="docker pulls"/>
</a>
<a href="https://github.com/golang/go" target="_blank">
<img src="https://img.shields.io/badge/Golang-1.20%2B-yellow.svg" alt="golang"/>
<img src="https://img.shields.io/badge/Golang-1.21%2B-yellow.svg" alt="golang"/>
</a>
<a href="https://cn.vuejs.org" target="_blank">
<img src="https://img.shields.io/badge/Vue-3.x-green.svg" alt="vue">
@@ -22,7 +22,7 @@
### 介绍
web 版 **linux(终端[终端回放] 文件 脚本 进程)、数据库mysql postgres、redis(单机 哨兵 集群)、mongo 统一管理操作平台**
web 版 **linux(终端[终端回放] 文件 脚本 进程 计划任务)、数据库mysql postgres、redis(单机 哨兵 集群)、mongo 统一管理操作平台**
### 开发语言与主要框架
@@ -100,4 +100,4 @@ http://go.mayfly.run
#### 💌 支持作者
如果觉得项目不错,或者已经在使用了,希望你可以去 <a target="_blank" href="https://github.com/may-fly/mayfly-go">Github</a> 或者 <a target="_blank" href="https://gitee.com/objs/mayfly-go">Gitee</a> 帮我点个 ⭐ Star这将是对我极大的鼓励与支持。
如果觉得项目不错,或者已经在使用了,希望你可以去 <a target="_blank" href="https://github.com/dromara/mayfly-go">Github</a> 或者 <a target="_blank" href="https://gitee.com/dromara/mayfly-go">Gitee</a> 帮我点个 ⭐ Star这将是对我极大的鼓励与支持。

View File

@@ -74,12 +74,13 @@ function build() {
# fi
if [ "${copyDocScript}" == "1" ] ; then
echo_green "拷贝脚本等资源文件[config.yml.example、mayfly-go.sql、readme.txt、startup.sh、shutdown.sh]"
echo_green "拷贝脚本等资源文件[config.yml.example、mayfly-go.sql、mayfly-go.sqlite、readme.txt、startup.sh、shutdown.sh]"
cp ${server_folder}/config.yml.example ${toFolder}
cp ${server_folder}/mayfly-go.sql ${toFolder}
cp ${server_folder}/readme.txt ${toFolder}
cp ${server_folder}/startup.sh ${toFolder}
cp ${server_folder}/shutdown.sh ${toFolder}
cp ${server_folder}/resources/script/startup.sh ${toFolder}
cp ${server_folder}/resources/script/shutdown.sh ${toFolder}
cp ${server_folder}/resources/script/sql/mayfly-go.sql ${toFolder}
cp ${server_folder}/resources/data/mayfly-go.sqlite ${toFolder}
fi
echo_yellow ">>>>>>>>>>>>>>>>>>>${os}-${arch}打包构建完成<<<<<<<<<<<<<<<<<<<<\n"

View File

@@ -2,4 +2,8 @@
ENV = 'development'
# 本地环境接口地址
VITE_API_URL = '/api'
VITE_API_URL = '/api'
# 路由模式
# Optional: hash | history
VITE_ROUTER_MODE = hash

View File

@@ -2,4 +2,8 @@
ENV = 'production'
# 线上环境接口地址
VITE_API_URL = '/api'
VITE_API_URL = '/api'
# 路由模式
# Optional: hash | history
VITE_ROUTER_MODE = hash

View File

@@ -1,76 +1,76 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
parser: 'vue-eslint-parser',
parserOptions: {
ecmaVersion: 12,
parser: '@typescript-eslint/parser',
sourceType: 'module',
},
extends: ['plugin:vue/vue3-essential', 'plugin:vue/essential', 'eslint:recommended'],
plugins: ['vue', '@typescript-eslint'],
overrides: [
{
files: ['*.ts', '*.tsx', '*.vue'],
rules: {
'no-undef': 'off',
},
},
],
rules: {
// http://eslint.cn/docs/rules/
// https://eslint.vuejs.org/rules/
// https://typescript-eslint.io/rules/no-unused-vars/
'@typescript-eslint/ban-ts-ignore': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-redeclare': 'error',
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
'@typescript-eslint/no-unused-vars': [2],
'vue/custom-event-name-casing': 'off',
'vue/attributes-order': 'off',
'vue/one-component-per-file': 'off',
'vue/html-closing-bracket-newline': 'off',
'vue/max-attributes-per-line': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/attribute-hyphenation': 'off',
'vue/html-self-closing': 'off',
'vue/no-multiple-template-root': 'off',
'vue/require-default-prop': 'off',
'vue/no-v-model-argument': 'off',
'vue/no-arrow-functions-in-watch': 'off',
'vue/no-template-key': 'off',
'vue/no-v-html': 'off',
'vue/comment-directive': 'off',
'vue/no-parsing-error': 'off',
'vue/no-deprecated-v-on-native-modifier': 'off',
'vue/multi-word-component-names': 'off',
'no-useless-escape': 'off',
'no-sparse-arrays': 'off',
'no-prototype-builtins': 'off',
'no-constant-condition': 'off',
'no-use-before-define': 'off',
'no-restricted-globals': 'off',
'no-restricted-syntax': 'off',
'generator-star-spacing': 'off',
'no-unreachable': 'off',
'no-multiple-template-root': 'off',
'no-unused-vars': 'error',
'no-v-model-argument': 'off',
'no-case-declarations': 'off',
'no-console': 'error',
'no-redeclare': 'off',
},
};
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
parser: 'vue-eslint-parser',
parserOptions: {
ecmaVersion: 12,
parser: '@typescript-eslint/parser',
sourceType: 'module',
},
extends: ['plugin:vue/vue3-essential', 'plugin:vue/essential', 'eslint:recommended'],
plugins: ['vue', '@typescript-eslint'],
overrides: [
{
files: ['*.ts', '*.tsx', '*.vue'],
rules: {
'no-undef': 'off',
},
},
],
rules: {
// http://eslint.cn/docs/rules/
// https://eslint.vuejs.org/rules/
// https://typescript-eslint.io/rules/no-unused-vars/
'@typescript-eslint/ban-ts-ignore': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-redeclare': 'error',
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
'@typescript-eslint/no-unused-vars': [2],
'vue/custom-event-name-casing': 'off',
'vue/attributes-order': 'off',
'vue/one-component-per-file': 'off',
'vue/html-closing-bracket-newline': 'off',
'vue/max-attributes-per-line': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/attribute-hyphenation': 'off',
'vue/html-self-closing': 'off',
'vue/no-multiple-template-root': 'off',
'vue/require-default-prop': 'off',
'vue/no-v-model-argument': 'off',
'vue/no-arrow-functions-in-watch': 'off',
'vue/no-template-key': 'off',
'vue/no-v-html': 'off',
'vue/comment-directive': 'off',
'vue/no-parsing-error': 'off',
'vue/no-deprecated-v-on-native-modifier': 'off',
'vue/multi-word-component-names': 'off',
'no-useless-escape': 'off',
'no-sparse-arrays': 'off',
'no-prototype-builtins': 'off',
'no-constant-condition': 'off',
'no-use-before-define': 'off',
'no-restricted-globals': 'off',
'no-restricted-syntax': 'off',
'generator-star-spacing': 'off',
'no-unreachable': 'off',
'no-multiple-template-root': 'off',
'no-unused-vars': 'error',
'no-v-model-argument': 'off',
'no-case-declarations': 'off',
// 'no-console': 'error',
'no-redeclare': 'off',
},
};

View File

@@ -10,27 +10,28 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.1.0",
"asciinema-player": "^3.5.0",
"axios": "^1.5.0",
"asciinema-player": "^3.6.2",
"axios": "^1.6.2",
"clipboard": "^2.0.11",
"countup.js": "^2.7.0",
"cropperjs": "^1.5.11",
"echarts": "^5.4.0",
"element-plus": "^2.3.12",
"jsencrypt": "^3.3.1",
"echarts": "^5.4.3",
"element-plus": "^2.4.3",
"jsencrypt": "^3.3.2",
"lodash": "^4.17.21",
"mitt": "^3.0.1",
"monaco-editor": "^0.43.0",
"monaco-editor": "^0.44.0",
"monaco-sql-languages": "^0.11.0",
"monaco-themes": "^0.4.4",
"nprogress": "^0.2.0",
"pinia": "^2.1.6",
"qrcode.vue": "^3.4.0",
"pinia": "^2.1.7",
"qrcode.vue": "^3.4.1",
"screenfull": "^6.0.2",
"sortablejs": "^1.13.0",
"sql-formatter": "^12.1.2",
"vue": "^3.3.4",
"vue-clipboard3": "^1.0.1",
"vue-router": "^4.2.4",
"sortablejs": "^1.15.0",
"sql-formatter": "^14.0.0",
"uuid": "^9.0.1",
"vue": "^3.3.10",
"vue-router": "^4.2.5",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-search": "^0.13.0",
@@ -40,20 +41,19 @@
"@types/lodash": "^4.14.178",
"@types/node": "^15.6.0",
"@types/nprogress": "^0.2.0",
"@types/sortablejs": "^1.10.6",
"@typescript-eslint/eslint-plugin": "^4.23.0",
"@typescript-eslint/parser": "^4.23.0",
"@vitejs/plugin-vue": "^4.0.0",
"@vue/compiler-sfc": "^3.0.11",
"dotenv": "^10.0.0",
"@types/sortablejs": "^1.15.3",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"@vitejs/plugin-vue": "^4.4.0",
"@vue/compiler-sfc": "^3.3.4",
"dotenv": "^16.3.1",
"eslint": "^8.35.0",
"eslint-plugin-vue": "^8.2.0",
"prettier": "^2.3.0",
"sass": "^1.62.0",
"sass-loader": "^13.2.0",
"typescript": "^5.0.2",
"vite": "^4.4.9",
"vue-eslint-parser": "^9.1.1"
"eslint-plugin-vue": "^9.17.0",
"prettier": "^3.0.3",
"sass": "^1.69.0",
"typescript": "^5.3.2",
"vite": "^5.0.5",
"vue-eslint-parser": "^9.3.1"
},
"browserslist": [
"> 1%",

View File

@@ -1,20 +1,33 @@
<template>
<router-view v-show="themeConfig.lockScreenTime !== 0" />
<LockScreen v-if="themeConfig.isLockScreen" />
<Setings ref="setingsRef" v-show="themeConfig.lockScreenTime !== 0" />
<div class="h100">
<el-watermark
:zIndex="10000000"
:width="210"
v-if="themeConfig.isWatermark"
:font="{ color: 'rgba(180, 180, 180, 0.5)' }"
:content="themeConfig.watermarkText"
class="h100"
>
<router-view v-show="themeConfig.lockScreenTime !== 0" />
</el-watermark>
<router-view v-if="!themeConfig.isWatermark" v-show="themeConfig.lockScreenTime !== 0" />
<LockScreen v-if="themeConfig.isLockScreen" />
<Setings ref="setingsRef" v-show="themeConfig.lockScreenTime !== 0" />
</div>
</template>
<script setup lang="ts" name="app">
import { ref, onBeforeMount, onMounted, onUnmounted, nextTick, watch } from 'vue';
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue';
import { useRoute } from 'vue-router';
// import { useTagsViewRoutes } from '@/store/tagsViewRoutes';
import { storeToRefs } from 'pinia';
import { useThemeConfig } from '@/store/themeConfig';
import { getLocal } from '@/common/utils/storage';
import LockScreen from '@/views/layout/lockScreen/index.vue';
import Setings from '@/views/layout/navBars/breadcrumb/setings.vue';
import Watermark from '@/common/utils/wartermark';
import LockScreen from '@/layout/lockScreen/index.vue';
import Setings from '@/layout/navBars/breadcrumb/setings.vue';
import mittBus from '@/common/utils/mitt';
import { getThemeConfig } from './common/utils/storage';
import { useWatermark } from '@/common/sysconfig';
const setingsRef = ref();
const route = useRoute();
@@ -27,13 +40,11 @@ const openSetingsDrawer = () => {
setingsRef.value.openDrawer();
};
// 设置初始化,防止刷新时恢复默认
onBeforeMount(() => {
// 设置批量第三方 icon 图标
// setIntroduction.cssCdn();
// // 设置批量第三方 js
// setIntroduction.jsCdn();
});
const prefers = matchMedia('(prefers-color-scheme: dark)');
const switchDarkFollowOS = () => {
// 跟随系统主题
themeConfigStores.switchDark(prefers.matches);
};
// 页面加载时
onMounted(() => {
@@ -42,16 +53,60 @@ onMounted(() => {
mittBus.on('openSetingsDrawer', () => {
openSetingsDrawer();
});
// 获取缓存中的布局配置
if (getLocal('themeConfig')) {
themeConfigStores.setThemeConfig({ themeConfig: getLocal('themeConfig') });
const tc = getThemeConfig();
if (tc) {
themeConfigStores.setThemeConfig({ themeConfig: tc });
document.documentElement.style.cssText = getLocal('themeConfigStyle');
}
switchDarkFollowOS();
// 是否开启水印
useWatermark().then((res) => {
themeConfigStores.setWatermarkConfig(res);
});
});
});
// 监听 themeConfig isWartermark配置文件的变化
watch(
() => themeConfig.value.isWatermark,
(val) => {
if (val) {
setTimeout(() => {
setWatermarkContent();
refreshWatermarkTime();
}, 500);
}
}
);
const setWatermarkContent = () => {
themeConfigStores.setWatermarkUser();
themeConfigStores.setWatermarkNowTime();
};
let refreshWatermarkTimeInterval: any = null;
/**
* 刷新水印时间
*/
const refreshWatermarkTime = () => {
if (refreshWatermarkTimeInterval) {
clearInterval(refreshWatermarkTimeInterval);
}
refreshWatermarkTimeInterval = setInterval(() => {
if (themeConfig.value.isWatermark) {
themeConfigStores.setWatermarkNowTime();
} else {
clearInterval(refreshWatermarkTimeInterval);
}
}, 60000);
};
// 页面销毁时,关闭监听布局配置
onUnmounted(() => {
clearInterval(refreshWatermarkTimeInterval);
mittBus.off('openSetingsDrawer', () => {});
});
@@ -60,8 +115,6 @@ watch(
() => route.path,
() => {
nextTick(() => {
// 路由变化更新水印
Watermark.use();
document.title = `${route.meta.title} - ${themeConfig.value.globalTitle}` || themeConfig.value.globalTitle;
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -0,0 +1,9 @@
import EnumValue from './Enum';
// 标签关联的资源类型
export const TagResourceTypeEnum = {
Machine: EnumValue.of(1, '机器'),
Db: EnumValue.of(2, '数据库'),
Redis: EnumValue.of(3, 'redis'),
Mongo: EnumValue.of(4, 'mongo'),
};

View File

@@ -3,6 +3,10 @@ function getBaseApiUrl() {
if (path == '/') {
return window.location.host;
}
if (path.endsWith('/')) {
// 去除最后一个/
return window.location.host + path.replace(/\/$/, '');
}
return window.location.host + path;
}
@@ -11,7 +15,7 @@ const config = {
baseWsUrl: `${(window as any).globalConfig.BaseWsUrl || `${location.protocol == 'https:' ? 'wss:' : 'ws:'}//${getBaseApiUrl()}`}/api`,
// 系统版本
version: 'v1.5.2',
version: 'v1.6.0',
};
export default config;

View File

@@ -1,7 +1,7 @@
import router from '../router';
import Axios from 'axios';
import config from './config';
import { getSession } from './utils/storage';
import { getClientId, getToken } from './utils/storage';
import { templateResolve } from './utils/string';
import { ElMessage } from 'element-plus';
@@ -29,7 +29,7 @@ enum ResultEnum {
}
const baseUrl: string = config.baseApiUrl;
const baseWsUrl: string = config.baseWsUrl;
// const baseWsUrl: string = config.baseWsUrl;
/**
* 通知错误消息
@@ -43,17 +43,18 @@ function notifyErrorMsg(msg: string) {
// create an axios instance
const service = Axios.create({
baseURL: baseUrl, // url = base url + request url
timeout: 20000, // request timeout
timeout: 60000, // request timeout
});
// request interceptor
service.interceptors.request.use(
(config: any) => {
// do something before request is sent
const token = getSession('token');
const token = getToken();
if (token) {
// 设置token
config.headers['Authorization'] = token;
config.headers['ClientId'] = getClientId();
}
return config;
},
@@ -143,8 +144,8 @@ function request(method: string, url: string, params: any = null, headers: any =
.request(query)
.then((res) => res)
.catch((e) => {
// 如果返回的code不为成功则会返回对应的错误msg则直接统一通知即可
if (e.msg) {
// 如果返回的code不为成功则会返回对应的错误msg则直接统一通知即可。忽略登录超时或没有权限的提示直接跳转至401页面
if (e.msg && e?.code != ResultEnum.NO_PERMISSION) {
notifyErrorMsg(e.msg);
}
return Promise.reject(e);
@@ -176,7 +177,12 @@ function del(url: string, params: any = null, headers: any = null, options: any
function getApiUrl(url: string) {
// 只是返回api地址而不做请求用在上传组件之类的
return baseUrl + url + '?token=' + getSession('token');
return baseUrl + url + '?' + joinClientParams();
}
// 组装客户端参数,包括 token 和 clientId
export function joinClientParams(): string {
return `token=${getToken()}&clientId=${getClientId()}`;
}
export default {

View File

@@ -1,45 +0,0 @@
import Config from './config';
import { ElNotification } from 'element-plus';
import SocketBuilder from './SocketBuilder';
import { getSession } from '@/common/utils/storage';
export default {
/**
* 全局系统消息websocket
*/
sysMsgSocket() {
const token = getSession('token');
if (!token) {
return null;
}
return SocketBuilder.builder(`${Config.baseWsUrl}/sysmsg?token=${token}`)
.message((event: { data: string }) => {
const message = JSON.parse(event.data);
let mtype: string;
switch (message.type) {
case 0:
mtype = 'error';
break;
case 2:
mtype = 'info';
break;
case 1:
mtype = 'success';
break;
default:
mtype = 'info';
}
if (mtype == undefined) {
return;
}
ElNotification({
duration: 0,
title: message.title,
message: message.msg,
type: mtype as any,
});
})
.open((event: any) => console.log(event))
.build();
},
};

View File

@@ -3,7 +3,7 @@ import openApi from './openApi';
// 登录是否使用验证码配置key
const AccountLoginSecurity = 'AccountLoginSecurity';
const UseLoginCaptchaConfigKey = 'UseLoginCaptcha';
const UseWartermarkConfigKey = 'UseWartermark';
const UseWatermarkConfigKey = 'UseWatermark';
/**
* 获取系统配置值
@@ -53,12 +53,26 @@ export async function useLoginCaptcha(): Promise<boolean> {
}
/**
* 是否启用水印
* 是否启用水印信息配置
*
* @returns
*/
export async function useWartermark(): Promise<boolean> {
return await getBoolConfigValue(UseWartermarkConfigKey, true);
export async function useWatermark(): Promise<any> {
const value = await getConfigValue(UseWatermarkConfigKey);
const defaultValue = {
isUse: true,
};
if (!value) {
return defaultValue;
}
try {
const jsonValue = JSON.parse(value);
// 将字符串转为bool
jsonValue.isUse = convertBool(jsonValue.isUse, true);
return jsonValue;
} catch (e) {
return defaultValue;
}
}
function convertBool(value: string, defaultValue: boolean) {
@@ -77,4 +91,3 @@ export async function getLdapEnabled(): Promise<any> {
const value = await openApi.getLdapEnabled();
return convertBool(value, false);
}

View File

@@ -0,0 +1,100 @@
import Config from './config';
import { ElNotification } from 'element-plus';
import SocketBuilder from './SocketBuilder';
import { getToken } from '@/common/utils/storage';
import { joinClientParams } from './request';
class SysSocket {
/**
* socket连接
*/
socket: any;
/**
* key -> 消息类别value -> 消息对应的处理器函数
*/
categoryHandlers: Map<string, any> = new Map();
/**
* 消息类型
*/
messageTypes = {
0: 'error',
1: 'success',
2: 'info',
};
/**
* 初始化全局系统消息websocket
*/
init() {
// 存在则不需要重新建立连接
if (this.socket) {
return;
}
const token = getToken();
if (!token) {
return null;
}
console.log('init system ws');
const sysMsgUrl = `${Config.baseWsUrl}/sysmsg?${joinClientParams()}`;
this.socket = SocketBuilder.builder(sysMsgUrl)
.message((event: { data: string }) => {
const message = JSON.parse(event.data);
// 存在消息类别对应的处理器,则进行处理,否则进行默认通知处理
const handler = this.categoryHandlers.get(message.category);
if (handler) {
handler(message);
return;
}
const type = this.getMsgType(message.type);
ElNotification({
duration: 0,
title: message.title,
message: message.msg,
type: type,
});
})
.open((event: any) => console.log(event))
.close(() => {
console.log('close sys socket');
this.socket = null;
})
.build();
}
destory() {
this.socket.close();
this.socket = null;
this.categoryHandlers.clear();
}
/**
* 注册消息处理函数
*
* @param category 消息类别
* @param handlerFunc 消息处理函数
*/
registerMsgHandler(category: any, handlerFunc: any) {
this.init();
if (this.categoryHandlers.has(category)) {
console.log(`${category}该类别消息处理器已存在...`);
return;
}
if (typeof handlerFunc != 'function') {
throw new Error('message handler需为函数');
}
this.categoryHandlers.set(category, handlerFunc);
}
getMsgType(msgType: any) {
return this.messageTypes[msgType];
}
}
// 全局系统消息websocket;
const sysSocket = new SysSocket();
export default sysSocket;

View File

@@ -0,0 +1,17 @@
import { ref } from 'vue';
const vw = ref(document.documentElement.clientWidth);
const vh = ref(document.documentElement.clientHeight);
window.addEventListener('resize', () => {
vw.value = document.documentElement.clientWidth;
vh.value = document.documentElement.clientHeight;
});
/**
* 获取视图宽高
* @returns 视图宽高
*/
export function useViewport() {
return { vw, vh };
}

View File

@@ -29,15 +29,19 @@ export function exportCsv(filename: string, columns: string[], datas: []) {
cvsData.push(dataValueArr);
}
const csvString = cvsData.map((e) => e.join(',')).join('\n');
exportFile(`${filename}.csv`, csvString);
}
export function exportFile(filename: string, content: string) {
// 导出
let link = document.createElement('a');
let exportContent = '\uFEFF';
let blob = new Blob([exportContent + csvString], {
let blob = new Blob([exportContent + content], {
type: 'text/plain;charset=utf-8',
});
link.id = 'download-csv';
link.id = 'download-file';
link.setAttribute('href', URL.createObjectURL(blob));
link.setAttribute('download', `${filename}.csv`);
link.setAttribute('download', `${filename}`);
document.body.appendChild(link);
link.click();
}

View File

@@ -3,21 +3,16 @@
* @param size byte size
* @returns
*/
export function formatByteSize(size: any) {
const value = Number(size);
if (size && !isNaN(value)) {
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'BB'];
let index = 0;
let k = value;
if (value >= 1024) {
while (k > 1024) {
k = k / 1024;
index++;
}
}
return `${k.toFixed(2)}${units[index]}`;
export function formatByteSize(size: number, fixed = 2) {
if (size === 0) {
return '0B';
}
return '-';
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const base = 1024;
const exponent = Math.floor(Math.log(size) / Math.log(base));
return parseFloat((size / Math.pow(base, exponent)).toFixed(fixed)) + units[exponent];
}
/**

View File

@@ -1,46 +1,46 @@
import { nextTick } from 'vue';
import loadingCss from '@/theme/loading.scss?inline';
import '@/theme/loading.scss';
// 定义方法
/**
* 页面全局 Loading
* @method start 创建 loading
* @method done 移除 loading
*/
export const NextLoading = {
// 载入 css
setCss: () => {
let link = document.createElement('link');
link.rel = 'stylesheet';
link.href = loadingCss;
link.crossOrigin = 'anonymous';
document.getElementsByTagName('head')[0].appendChild(link);
},
// 创建 loading
start: () => {
const bodys: any = document.body;
const div = document.createElement('div');
const bodys: Element = document.body;
const div = <HTMLElement>document.createElement('div');
div.setAttribute('class', 'loading-next');
const htmls = `
<div class="loading-next-box">
<div class="loading-next-box-warp">
<div class="loading-next-box-item"></div>
<div class="loading-next-box-item"></div>
<div class="loading-next-box-item"></div>
<div class="loading-next-box-item"></div>
<div class="loading-next-box-item"></div>
<div class="loading-next-box-item"></div>
<div class="loading-next-box-item"></div>
<div class="loading-next-box-item"></div>
<div class="loading-next-box-item"></div>
<div class="loading-next-box-warp">
<div class="loading-next-box-item"></div>
<div class="loading-next-box-item"></div>
<div class="loading-next-box-item"></div>
<div class="loading-next-box-item"></div>
<div class="loading-next-box-item"></div>
<div class="loading-next-box-item"></div>
<div class="loading-next-box-item"></div>
<div class="loading-next-box-item"></div>
<div class="loading-next-box-item"></div>
</div>
</div>
</div>
`;
div.innerHTML = htmls;
bodys.insertBefore(div, bodys.childNodes[0]);
},
// 移除 loading
done: () => {
done: (time: number = 1000) => {
nextTick(() => {
setTimeout(() => {
const el = document.querySelector('.loading-next');
el && el.parentNode?.removeChild(el);
}, 1000);
const el = <HTMLElement>document.querySelector('.loading-next');
el?.parentNode?.removeChild(el);
}, time);
});
},
};
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -1,17 +1,90 @@
import { randomUuid } from './string';
const TokenKey = 'm-token';
const UserKey = 'm-user';
const TagViewsKey = 'm-tagViews';
const ClientIdKey = 'm-clientId';
// 获取请求token
export function getToken(): string {
return getLocal(TokenKey);
}
// 保存用户访问token
export function saveToken(token: string) {
setLocal(TokenKey, token);
}
// 获取登录用户基础信息
export function getUser() {
return getLocal(UserKey);
}
// 保存用户信息
export function saveUser(userinfo: any) {
setLocal(UserKey, userinfo);
}
export function saveThemeConfig(themeConfig: any) {
setLocal('themeConfig', themeConfig);
}
export function getThemeConfig() {
return getLocal('themeConfig');
}
// 清除用户相关的用户信息
export function clearUser() {
removeLocal(TokenKey);
removeLocal(UserKey);
}
export function getTagViews() {
return getSession(TagViewsKey);
}
export function setTagViews(tagViews: Array<object>) {
setSession(TagViewsKey, tagViews);
}
export function removeTagViews() {
removeSession(TagViewsKey);
}
// 获取客户端UUID
export function getClientId(): string {
let uuid = getSession(ClientIdKey);
if (uuid == null) {
uuid = randomUuid();
setSession(ClientIdKey, uuid);
}
return uuid;
}
// 1. localStorage
// 设置永久缓存
export function setLocal(key: string, val: any) {
window.localStorage.setItem(key, JSON.stringify(val));
if (typeof val == 'object') {
val = JSON.stringify(val);
}
window.localStorage.setItem(key, val);
}
// 获取永久缓存
export function getLocal(key: string) {
let json: any = window.localStorage.getItem(key);
return JSON.parse(json);
let val: any = window.localStorage.getItem(key);
try {
return JSON.parse(val);
} catch (e) {
return val;
}
}
// 移除永久缓存
export function removeLocal(key: string) {
window.localStorage.removeItem(key);
}
// 移除全部永久缓存
export function clearLocal() {
window.localStorage.clear();
@@ -20,35 +93,29 @@ export function clearLocal() {
// 2. sessionStorage
// 设置临时缓存
export function setSession(key: string, val: any) {
window.sessionStorage.setItem(key, JSON.stringify(val));
if (typeof val == 'object') {
val = JSON.stringify(val);
}
window.sessionStorage.setItem(key, val);
}
// 获取临时缓存
export function getSession(key: string) {
let json: any = window.sessionStorage.getItem(key);
return JSON.parse(json);
let val: any = window.sessionStorage.getItem(key);
try {
return JSON.parse(val);
} catch (e) {
return val;
}
}
// 移除临时缓存
export function removeSession(key: string) {
window.sessionStorage.removeItem(key);
}
// 移除全部临时缓存
export function clearSession() {
clearUser();
window.sessionStorage.clear();
}
export function getUserInfo4Session() {
return getSession('userInfo');
}
export function setUserInfo2Session(userinfo: any) {
setSession('userInfo', userinfo);
}
// 获取是否开启水印
export function getUseWatermark4Session() {
return getSession('useWatermark');
}
export function setUseWatermark2Session(useWatermark: boolean) {
setSession('useWatermark', useWatermark);
}

View File

@@ -1,3 +1,7 @@
import { v1 as uuidv1 } from 'uuid';
import Clipboard from 'clipboard';
import { ElMessage } from 'element-plus';
/**
* 模板字符串解析template = 'hahaha{name}_{id}' ,param = {name: 'hh', id: 1}
* 解析后为 hahahahh_1
@@ -129,3 +133,49 @@ export function getContentWidth(content: any): number {
// }
return flexWidth;
}
/**
*
* @returns uuid
*/
export function randomUuid() {
return uuidv1();
}
/**
* 拷贝文本至剪贴板
* @param txt 需要拷贝到剪贴板的文本
* @param selector click事件对应的元素selector默认为 #copyValue
* @returns
*/
export async function copyToClipboard(txt: string, selector: string = '#copyValue') {
// navigator clipboard 需要https等安全上下文
if (navigator.clipboard && window.isSecureContext) {
// navigator clipboard 向剪贴板写文本
try {
// 拷贝单元格数据
await navigator.clipboard.writeText(txt);
ElMessage.success('复制成功');
} catch (e: any) {
ElMessage.error('复制失败');
}
return;
}
let clipboard = new Clipboard(selector, {
text: function () {
return txt;
},
});
clipboard.on('success', () => {
ElMessage.success('复制成功');
// 释放内存
clipboard.destroy();
});
clipboard.on('error', () => {
// 不支持复制
ElMessage.error('该浏览器不支持自动复制');
// 释放内存
clipboard.destroy();
});
}

View File

@@ -0,0 +1,13 @@
const mode = import.meta.env.VITE_ROUTER_MODE;
/**
* @description 获取不同路由模式所对应的 url
* @returns {String}
*/
export function getNowUrl() {
const url = {
hash: location.hash.substring(1),
history: location.pathname + location.search,
};
return url[mode];
}

View File

@@ -1,65 +0,0 @@
import { getUseWatermark4Session, getUserInfo4Session } from '@/common/utils/storage';
import { dateFormat2 } from '@/common/utils/date';
// 页面添加水印效果
const setWatermark = (str: any) => {
const id = '1.23452384164.123412416';
if (document.getElementById(id) !== null) document.body.removeChild(document.getElementById(id) as any);
const can = document.createElement('canvas');
can.width = 400;
can.height = 250;
const cans: any = can.getContext('2d');
cans.rotate((-20 * Math.PI) / 180);
cans.font = '14px Vedana';
cans.fillStyle = 'rgba(200, 200, 200, 0.35)';
cans.textAlign = 'left';
cans.textBaseline = 'Middle';
// cans.fillText('mayfly go', can.width / 4, can.height )
cans.fillText(str, can.width / 8, can.height / 2);
const div = document.createElement('div');
div.id = id;
div.style.pointerEvents = 'none';
div.style.top = '30px';
div.style.left = '0px';
div.style.position = 'fixed';
div.style.zIndex = '10000000';
div.style.width = document.documentElement.clientWidth + 'px';
div.style.height = document.documentElement.clientHeight + 'px';
div.style.background = `url(${can.toDataURL('image/png')}) left top repeat`;
document.body.appendChild(div);
return id;
};
function set(str: any) {
let id = setWatermark(str);
if (document.getElementById(id) === null) id = setWatermark(str);
}
function del() {
let id = '1.23452384164.123412416';
if (document.getElementById(id) !== null) document.body.removeChild(document.getElementById(id) as any);
}
const watermark = {
use: () => {
setTimeout(() => {
const userinfo = getUserInfo4Session();
if (userinfo && getUseWatermark4Session()) {
set(`${userinfo.username} ${dateFormat2('yyyy-MM-dd HH:mm:ss', new Date())}`);
} else {
del();
}
}, 1500);
},
// 设置水印
set: (str: any) => {
set(str);
},
// 删除水印
del: () => {
del();
},
};
export default watermark;

View File

@@ -6,6 +6,9 @@ import { useUserInfo } from '@/store/userInfo';
* @returns
*/
export function hasPerm(code: string) {
if (!code) {
return true;
}
return useUserInfo().userInfo.permissions.some((v: any) => v === code);
}

View File

@@ -0,0 +1,59 @@
import Contextmenu from './index.vue';
class ContextmenuItem {
clickId: any;
txt: string;
icon: string;
affix: boolean;
permission: string;
/**
* 是否隐藏回调函数
*/
hideFunc: (data: any) => boolean;
onClickFunc: (data: any) => void;
constructor(clickId: any, txt: string) {
this.clickId = clickId;
this.txt = txt;
}
withIcon(icon: string) {
this.icon = icon;
return this;
}
withPermission(permission: string) {
this.permission = permission;
return this;
}
withHideFunc(func: (data: any) => boolean) {
this.hideFunc = func;
return this;
}
withOnClick(func: (data: any) => void) {
this.onClickFunc = func;
return this;
}
/**
* 是否隐藏
* @param data 点击数据项
* @returns
*/
isHide(data: any) {
if (this.hideFunc) {
return this.hideFunc(data);
}
return false;
}
}
export { Contextmenu, ContextmenuItem };

View File

@@ -1,36 +1,41 @@
<template>
<transition name="el-zoom-in-center">
<transition @enter="onEnter" name="el-zoom-in-center">
<div
aria-hidden="true"
class="el-dropdown__popper el-popper is-light is-pure custom-contextmenu"
role="tooltip"
data-popper-placement="bottom"
:style="`top: ${dropdowns.y + 5}px;left: ${dropdowns.x}px;`"
:style="`top: ${state.dropdown.y + 5}px;left: ${state.dropdown.x}px;`"
:key="Math.random()"
v-show="state.isShow"
v-show="state.isShow && !allHide"
>
<ul class="el-dropdown-menu">
<template v-for="(v, k) in state.dropdownList">
<li
:id="v.clickId"
v-auth="v.permission"
class="el-dropdown-menu__item"
aria-disabled="false"
tabindex="-1"
:key="k"
v-if="!v.affix"
@click="onCurrentContextmenuClick(v.contextMenuClickId)"
v-if="!v.affix && !v.isHide(state.item)"
@click="onCurrentContextmenuClick(v)"
>
<SvgIcon :name="v.icon" />
<span>{{ v.txt }}</span>
</li>
</template>
</ul>
<div class="el-popper__arrow" :style="{ left: `${state.arrowLeft}px` }"></div>
<div v-if="state.arrowLeft > 0" class="el-popper__arrow" :style="{ left: `${state.arrowLeft}px` }"></div>
</div>
</transition>
</template>
<script setup lang="ts" name="layoutTagsViewContextmenu">
import { computed, reactive, onMounted, onUnmounted, watch } from 'vue';
import { ContextmenuItem } from './index';
import { useViewport } from '@/common/use';
import SvgIcon from '@/components/svgIcon/index.vue';
// 定义父组件传过来的值
const props = defineProps({
@@ -44,38 +49,82 @@ const props = defineProps({
},
},
items: {
type: Array,
default: [],
type: Array<ContextmenuItem>,
default: () => [],
},
});
// 定义子组件向父组件传值/事件
const emit = defineEmits(['currentContextmenuClick']);
const { vw, vh } = useViewport();
// 定义变量内容
const state = reactive({
isShow: false,
dropdownList: [{ contextMenuClickId: 0, txt: '刷新', affix: false, icon: 'RefreshRight' }],
dropdownList: [] as ContextmenuItem[],
item: {} as any,
arrowLeft: 10,
dropdown: {
x: 0,
y: 0,
},
});
// 父级传过来的坐标 x,y 值
const dropdowns = computed(() => {
// 117 为 `Dropdown 下拉菜单` 的宽度
if (props.dropdown.x + 117 > document.documentElement.clientWidth) {
return {
x: document.documentElement.clientWidth - 117 - 5,
y: props.dropdown.y,
};
} else {
return props.dropdown;
// 下拉菜单宽高
let contextmenuWidth = 117;
let contextmenuHeight = 117;
// 下拉菜单元素
let ele = null as any;
const onEnter = (el: any) => {
if (ele || el.offsetHeight == 0) {
return;
}
});
// 当前项菜单点击
const onCurrentContextmenuClick = (contextMenuClickId: number) => {
emit('currentContextmenuClick', { id: contextMenuClickId, item: state.item });
ele = el;
contextmenuHeight = el.offsetHeight;
contextmenuWidth = el.offsetWidth;
setDropdowns(props.dropdown);
};
const setDropdowns = (dropdown: any) => {
let { x, y } = dropdown;
state.arrowLeft = 10;
// `Dropdown 下拉菜单` 的宽度
if (x + contextmenuWidth > vw.value) {
state.arrowLeft = contextmenuWidth - (vw.value - x);
x = vw.value - contextmenuWidth - 5;
}
if (y + contextmenuHeight > vh.value) {
y = vh.value - contextmenuHeight - 5;
state.arrowLeft = 0;
}
state.dropdown.x = x;
state.dropdown.y = y;
};
const allHide = computed(() => {
for (let item of state.dropdownList) {
if (!item.isHide(state.item)) {
return false;
}
}
return true;
});
// 当前项菜单点击
const onCurrentContextmenuClick = (ci: ContextmenuItem) => {
// 存在点击事件,则触发该事件函数
if (ci.onClickFunc) {
ci.onClickFunc(state.item);
}
emit('currentContextmenuClick', { id: ci.clickId, item: state.item });
};
// 打开右键菜单:判断是否固定,固定则不显示关闭按钮
const openContextmenu = (item: any) => {
state.item = item;
@@ -84,29 +133,34 @@ const openContextmenu = (item: any) => {
state.isShow = true;
}, 10);
};
// 关闭右键菜单
const closeContextmenu = () => {
state.isShow = false;
};
// 监听页面监听进行右键菜单的关闭
onMounted(() => {
document.body.addEventListener('click', closeContextmenu);
state.dropdownList = props.items;
});
// 页面卸载时,移除右键菜单监听事件
onUnmounted(() => {
document.body.removeEventListener('click', closeContextmenu);
});
// 监听下拉菜单位置
watch(
() => props.dropdown,
({ x }) => {
if (x + 117 > document.documentElement.clientWidth) state.arrowLeft = 117 - (document.documentElement.clientWidth - x);
else state.arrowLeft = 10;
() => {
// 元素置为空重新在onEnter赋值元素否则会造成堆栈溢出
ele = null;
},
{
deep: true,
}
);
watch(
() => props.items,
(x: any) => {
@@ -140,3 +194,4 @@ defineExpose({
}
}
</style>
.

View File

@@ -9,7 +9,6 @@
<script lang="ts" setup>
import { ref, watch, toRefs, reactive, onMounted, onBeforeUnmount } from 'vue';
// import * as monaco from 'monaco-editor';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
// 相关语言
import 'monaco-editor/esm/vs/basic-languages/shell/shell.contribution.js';
@@ -133,7 +132,7 @@ const languageArr = [
},
];
const options = {
const defaultOptions = {
language: 'shell',
theme: 'SolarizedLight',
automaticLayout: true, //自适应宽高布局
@@ -155,6 +154,20 @@ const options = {
},
};
const monacoTextarea: any = ref();
let monacoEditorIns: editor.IStandaloneCodeEditor = null as any;
let completionItemProvider: any = null;
self.MonacoEnvironment = {
getWorker(_: any, label: string) {
if (label === 'json') {
return new JsonWorker();
}
return new EditorWorker();
},
};
const state = reactive({
languageMode: 'shell',
});
@@ -173,6 +186,7 @@ onBeforeUnmount(() => {
monacoEditorIns.dispose();
}
if (completionItemProvider) {
console.log('unmount=> dispose completion item provider');
completionItemProvider.dispose();
}
});
@@ -203,28 +217,14 @@ watch(
}
);
const monacoTextarea: any = ref(null);
let monacoEditorIns: editor.IStandaloneCodeEditor = null as any;
let completionItemProvider: any = null;
self.MonacoEnvironment = {
getWorker(_: any, label: string) {
if (label === 'json') {
return new JsonWorker();
}
return new EditorWorker();
},
};
const initMonacoEditorIns = () => {
console.log('初始化monaco编辑器');
// options参数参考 https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.IStandaloneEditorConstructionOptions.html#language
// 初始化一些主题
monaco.editor.defineTheme('SolarizedLight', SolarizedLight);
options.language = state.languageMode;
options.theme = themeConfig.value.editorTheme;
monacoEditorIns = monaco.editor.create(monacoTextarea.value, Object.assign(options, props.options as any));
defaultOptions.language = state.languageMode;
defaultOptions.theme = themeConfig.value.editorTheme;
monacoEditorIns = monaco.editor.create(monacoTextarea.value, Object.assign(defaultOptions, props.options as any));
// 监听内容改变,双向绑定
monacoEditorIns.onDidChangeModelContent(() => {
@@ -260,6 +260,7 @@ const setEditorValue = (value: any) => {
*/
const registerCompletionItemProvider = () => {
if (completionItemProvider) {
console.log('exist competion item provider, dispose now');
completionItemProvider.dispose();
}
if (state.languageMode == 'shell') {
@@ -299,7 +300,11 @@ const format = () => {
monacoEditorIns.trigger('', 'editor.action.formatDocument', '');
};
defineExpose({ format });
const getEditor = () => {
return monacoEditorIns;
};
defineExpose({ getEditor, format });
</script>
<style lang="scss">

View File

@@ -0,0 +1,25 @@
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
/**
* key: language, value: CompletionItemProvider
*/
const completionItemProviders: Map<string, any> = new Map();
export function registerCompletionItemProvider(language: string, completionItemProvider: any, replace: boolean = true) {
const exist = completionItemProviders.get(language);
if (exist) {
if (!replace) {
return;
}
exist.dispose();
}
completionItemProviders.set(language, monaco.languages.registerCompletionItemProvider(language, completionItemProvider));
}
export function dispposeCompletionItemProvider(language: string) {
const exist = completionItemProviders.get(language);
if (exist) {
exist.dispose();
completionItemProviders.delete(language);
}
}

View File

@@ -26,14 +26,14 @@
>
<!-- 这里只获取指定个数的筛选条件 -->
<el-input
v-model="queryForm[item.prop]"
v-model="queryForm_[item.prop]"
:placeholder="'输入' + item.label + '关键字'"
clearable
v-if="item.type == 'text'"
></el-input>
<el-select-v2
v-model="queryForm[item.prop]"
v-model="queryForm_[item.prop]"
:options="item.options"
clearable
:placeholder="'选择' + item.label + '关键字'"
@@ -41,7 +41,7 @@
/>
<el-date-picker
v-model="queryForm[item.prop]"
v-model="queryForm_[item.prop]"
clearable
type="datetimerange"
format="YYYY-MM-DD hh:mm:ss"
@@ -185,8 +185,10 @@
import { toRefs, watch, reactive, onMounted } from 'vue';
import { TableColumn, TableQuery } from './index';
import EnumTag from '@/components/enumtag/EnumTag.vue';
import { useThemeConfig } from '@/store/themeConfig';
import { storeToRefs } from 'pinia';
const emit = defineEmits(['update:queryForm', 'update:pageNum', 'update:pageSize', 'update:selectionData', 'pageChange'])
const emit = defineEmits(['update:queryForm', 'update:pageNum', 'update:pageSize', 'update:selectionData', 'pageChange']);
const props = defineProps({
size: {
@@ -195,7 +197,7 @@ const props = defineProps({
},
inputWidth: {
type: [Number, String],
default: 0,
default: '200px',
},
// 是否显示选择列
showSelection: {
@@ -204,7 +206,7 @@ const props = defineProps({
},
// 当前选择的数据
selectionData: {
type: Array<any>
type: Array<any>,
},
// 列信息
columns: {
@@ -236,16 +238,18 @@ const props = defineProps({
type: Array<TableQuery>,
default: function () {
return [];
}
},
},
// 绑定的查询表单
queryForm: {
type: Object,
default: function () {
return {};
}
},
},
})
});
const { themeConfig } = storeToRefs(useThemeConfig());
const state = reactive({
pageSizes: [] as any, // 可选每页显示的数据量
@@ -253,140 +257,153 @@ const state = reactive({
pageNum: 1,
isOpenMoreQuery: false,
defaultQueryCount: 2, // 默认显示的查询参数个数展开后每行显示查询条件个数为该值加1。第一行用最后一列来占用按钮
queryForm: {} as any,
queryForm_: {} as any,
loadingData: false,
// 输入框宽度
inputWidth: "200px" as any,
inputWidth_: '200px' as any,
formatVal: '', // 格式化后的值
tableMaxHeight: window.innerHeight - 240 + 'px',
})
});
const {
pageSizes,
isOpenMoreQuery,
defaultQueryCount,
queryForm,
loadingData,
inputWidth,
formatVal,
tableMaxHeight,
} = toRefs(state)
const { pageSizes, isOpenMoreQuery, defaultQueryCount, queryForm_, inputWidth_, formatVal, loadingData, tableMaxHeight } = toRefs(state);
watch(() => props.queryForm, (newValue: any) => {
state.queryForm = newValue;
})
watch(() => props.pageNum, (newValue: any) => {
state.pageNum = newValue;
})
watch(() => props.pageSize, (newValue: any) => {
state.pageSize = newValue;
})
watch(() => props.data, (newValue: any) => {
if (newValue && newValue.length > 0) {
props.columns.forEach(item => {
if (item.autoWidth && item.show) {
item.autoCalculateMinWidth(props.data);
}
})
watch(
() => props.queryForm,
(newValue: any) => {
state.queryForm_ = newValue;
}
})
);
watch(
() => props.pageNum,
(newValue: any) => {
state.pageNum = newValue;
}
);
watch(
() => props.pageSize,
(newValue: any) => {
state.pageSize = newValue;
}
);
watch(
() => props.data,
(newValue: any) => {
if (newValue && newValue.length > 0) {
props.columns.forEach((item) => {
if (item.autoWidth && item.show) {
item.autoCalculateMinWidth(props.data);
}
});
}
}
);
onMounted(() => {
const pageSize = props.pageSize;
let pageSize = props.pageSize;
// 如果pageSize设为0则使用系统全局配置的pageSize
if (!pageSize) {
pageSize = themeConfig.value.defaultListPageSize;
// 可能storage已经存在配置json则可能没值需要清storage重试
if (!pageSize) {
pageSize = 10;
}
emit('update:pageSize', pageSize);
}
state.pageNum = props.pageNum;
state.pageSize = pageSize;
state.queryForm = props.queryForm;
state.queryForm_ = props.queryForm;
state.pageSizes = [pageSize, pageSize * 2, pageSize * 3, pageSize * 4, pageSize * 5];
// 如果没传输入框宽度则根据组件size设置默认宽度
if (!props.inputWidth) {
state.inputWidth = props.size == 'small' ? '150px' : '200px';
state.inputWidth_ = props.size == 'small' ? '150px' : '200px';
} else {
state.inputWidth = props.inputWidth;
state.inputWidth_ = props.inputWidth;
}
window.addEventListener('resize', () => {
calcuTableHeight();
});
})
});
const calcuTableHeight = () => {
state.tableMaxHeight = window.innerHeight - 240 + 'px';
}
};
const formatText = (data: any)=> {
const formatText = (data: any) => {
state.formatVal = '';
try {
state.formatVal = JSON.stringify(JSON.parse(data), null, 4);
} catch (e) {
} catch (e) {
state.formatVal = data;
}
}
};
const getRowQueryItem = (row: number) => {
// 第一行需要加个查询等按钮列
if (row === 1) {
const res = props.query.slice(row - 1, defaultQueryCount.value);
// 查询等按钮列
res.push(TableQuery.slot("", "", "queryBtns"));
return res
res.push(TableQuery.slot('', '', 'queryBtns'));
return res;
}
const columnCount = defaultQueryCount.value + 1;
return props.query.slice((row - 1) * columnCount - 1, row * columnCount - 1);
}
};
const handleSelectionChange = (val: any) => {
emit('update:selectionData', val);
}
};
const handlePageChange = () => {
emit('update:pageNum', state.pageNum);
execQuery();
}
};
const handleSizeChange = () => {
changePageNum(1);
emit('update:pageSize', state.pageSize);
execQuery();
}
};
const queryData = () => {
changePageNum(1);
execQuery();
}
};
const reset = () => {
// 将查询参数绑定的值置空,并重新粗发查询接口
for (let qi of props.query) {
state.queryForm[qi.prop] = null;
state.queryForm_[qi.prop] = null;
}
changePageNum(1);
emit('update:queryForm', state.queryForm);
emit('update:queryForm', state.queryForm_);
execQuery();
}
};
const changePageNum = (pageNum: number) => {
state.pageNum = pageNum;
emit('update:pageNum', state.pageNum);
}
};
const execQuery = () => {
emit('pageChange');
}
};
/**
* 是否正在加载数据
*/
const loading = (loading: boolean) => {
state.loadingData = loading;
}
};
defineExpose({ loading })
defineExpose({ loading });
</script>
<style scoped lang="scss">
.page-table {
@@ -420,15 +437,15 @@ defineExpose({ loading })
}
.el-select-v2 {
width: v-bind(inputWidth);
width: v-bind(inputWidth_);
}
.el-input {
width: v-bind(inputWidth);
width: v-bind(inputWidth_);
}
.el-select {
width: v-bind(inputWidth);
width: v-bind(inputWidth_);
}
.el-date-editor {

View File

@@ -0,0 +1,12 @@
export const buildProgressProps = (): any => {
return {
progress: {
title: {
type: String,
},
executedStatements: {
type: Number,
},
},
};
};

View File

@@ -0,0 +1,34 @@
<template>
<el-descriptions border size="small" :title="`${progress.title}`">
<el-descriptions-item label="时间">{{ state.elapsedTime }}</el-descriptions-item>
<el-descriptions-item label="已处理">{{ progress.executedStatements }}</el-descriptions-item>
</el-descriptions>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, reactive } from 'vue';
import { formatTime } from 'element-plus/es/components/countdown/src/utils';
import { buildProgressProps } from './progress-notify';
const props = defineProps(buildProgressProps());
const state = reactive({
elapsedTime: '00:00:00',
});
let timer: any = undefined;
const startTime = Date.now();
onMounted(async () => {
timer = setInterval(() => {
const elapsed = Date.now() - startTime;
state.elapsedTime = formatTime(elapsed, 'HH:mm:ss');
}, 1000);
});
onUnmounted(async () => {
if (timer != undefined) {
clearInterval(timer); // 在Vue实例销毁前清除我们的定时器
timer = undefined;
}
});
</script>

View File

@@ -52,13 +52,13 @@
title="最小化"
/>
<!-- <SvgIcon name="FullScreen" @click="handlerFullScreen(openTerminal)" :size="20" class="pointer-icon mr10" title="全屏|退出全屏" /> -->
<SvgIcon name="FullScreen" @click="handlerFullScreen(openTerminal)" :size="20" class="pointer-icon mr10" title="全屏|退出全屏" />
<SvgIcon name="Close" class="pointer-icon" @click="close(openTerminal.terminalId)" title="关闭" :size="20" />
</div>
</div>
</template>
<div class="terminal-wrapper" style="height: calc(100vh - 215px)">
<div class="terminal-wrapper" :style="{ height: `calc(100vh - ${openTerminal.fullscreen ? '47px' : '200px'})` }">
<TerminalBody
@status-change="terminalStatusChange(openTerminal.terminalId, $event)"
:ref="(el) => setTerminalRef(el, openTerminal.terminalId)"
@@ -78,7 +78,7 @@
size="small"
@click="maximize(minimizeTerminal.terminalId)"
>
<el-tooltip effect="customized" :content="minimizeTerminal.desc" placement="top">
<el-tooltip :content="minimizeTerminal.desc" placement="top">
<span>
{{ minimizeTerminal.title }}
</span>
@@ -230,6 +230,16 @@ function maximize(terminalId: any) {
}, 250);
}
const handlerFullScreen = (terminal: any) => {
terminal.fullscreen = !terminal.fullscreen;
const terminalRef = openTerminalRefs[terminal.terminalId];
// fit
setTimeout(() => {
terminalRef?.fitTerminal();
terminalRef?.focus();
}, 250);
};
const closeMinimizeTerminal = (terminalId: any) => {
delete state.minimizeTerminals[terminalId];
close(terminalId);
@@ -249,9 +259,11 @@ defineExpose({
padding: 10px;
}
// .terminal-dialog {
// height: calc(100vh - 200px) !important;
// }
// 取消body最大高度否则全屏有问题
.el-dialog__body {
max-height: 100% !important;
overflow: hidden !important;
}
.el-overlay .el-overlay-dialog .el-dialog .el-dialog__body {
padding: 0px !important;

View File

@@ -21,8 +21,8 @@ import pinia from '@/store/index';
import { storeToRefs } from 'pinia';
import { useThemeConfig } from '@/store/themeConfig';
import { useRoutesList } from '@/store/routesList';
import Logo from '@/views/layout/logo/index.vue';
import Vertical from '@/views/layout/navMenu/vertical.vue';
import Logo from '@/layout/logo/index.vue';
import Vertical from '@/layout/navMenu/vertical.vue';
import mittBus from '@/common/utils/mitt';
const { proxy } = getCurrentInstance() as any;
@@ -38,8 +38,7 @@ const state: any = reactive({
// /
const setCollapseWidth = computed(() => {
let { layout, isCollapse, menuBar } = themeConfig.value;
let asideBrColor =
menuBar === '#FFFFFF' || menuBar === '#FFF' || menuBar === '#fff' || menuBar === '#ffffff' ? 'layout-el-aside-br-color' : '';
let asideBrColor = menuBar === '#FFFFFF' || menuBar === '#FFF' || menuBar === '#fff' || menuBar === '#ffffff' ? 'layout-el-aside-br-color' : '';
if (layout === 'columns') {
// 1px
if (isCollapse) {

View File

@@ -6,7 +6,7 @@
<script lang="ts">
import { computed } from 'vue';
import NavBarsIndex from '@/views/layout/navBars/index.vue';
import NavBarsIndex from '@/layout/navBars/index.vue';
import { useThemeConfig } from '@/store/themeConfig';
export default {
name: 'layoutHeader',

View File

@@ -1,16 +1,25 @@
<template>
<el-main class="layout-main">
<el-scrollbar class="layout-scrollbar" ref="layoutScrollbarRef"
<el-scrollbar
class="layout-scrollbar"
ref="layoutScrollbarRef"
v-show="!state.currentRouteMeta.link && state.currentRouteMeta.linkType != 1"
:style="{ minHeight: `calc(100vh - ${state.headerHeight}` }">
:style="{ minHeight: `calc(100vh - ${state.headerHeight}` }"
>
<LayoutParentView />
<Footer v-if="themeConfig.isFooter" />
</el-scrollbar>
<Link :style="{ height: `calc(100vh - ${state.headerHeight}` }" :meta="state.currentRouteMeta"
v-if="state.currentRouteMeta.link && state.currentRouteMeta.linkType == 2" />
<Iframes :style="{ height: `calc(100vh - ${state.headerHeight}` }" :meta="state.currentRouteMeta"
<Link
:style="{ height: `calc(100vh - ${state.headerHeight}` }"
:meta="state.currentRouteMeta"
v-if="state.currentRouteMeta.link && state.currentRouteMeta.linkType == 2"
/>
<Iframes
:style="{ height: `calc(100vh - ${state.headerHeight}` }"
:meta="state.currentRouteMeta"
v-if="state.currentRouteMeta.link && state.currentRouteMeta.linkType == 1 && state.isShowLink"
@getCurrentRouteMeta="onGetCurrentRouteMeta" />
@getCurrentRouteMeta="onGetCurrentRouteMeta"
/>
</el-main>
</template>
@@ -19,10 +28,10 @@ import { reactive, getCurrentInstance, watch, onBeforeMount } from 'vue';
import { useRoute } from 'vue-router';
import { storeToRefs } from 'pinia';
import { useThemeConfig } from '@/store/themeConfig';
import LayoutParentView from '@/views/layout/routerView/parent.vue';
import Footer from '@/views/layout/footer/index.vue';
import Link from '@/views/layout/routerView/link.vue';
import Iframes from '@/views/layout/routerView/iframes.vue';
import LayoutParentView from '@/layout/routerView/parent.vue';
import Footer from '@/layout/footer/index.vue';
import Link from '@/layout/routerView/link.vue';
import Iframes from '@/layout/routerView/iframes.vue';
const { proxy } = getCurrentInstance() as any;
const { themeConfig } = storeToRefs(useThemeConfig());

View File

@@ -10,10 +10,10 @@ import { onBeforeMount, onUnmounted } from 'vue';
import { getLocal, setLocal } from '@/common/utils/storage';
import { storeToRefs } from 'pinia';
import { useThemeConfig } from '@/store/themeConfig';
import Defaults from '@/views/layout/main/defaults.vue';
import Classic from '@/views/layout/main/classic.vue';
import Transverse from '@/views/layout/main/transverse.vue';
import Columns from '@/views/layout/main/columns.vue';
import Defaults from '@/layout/main/defaults.vue';
import Classic from '@/layout/main/classic.vue';
import Transverse from '@/layout/main/transverse.vue';
import Columns from '@/layout/main/columns.vue';
import mittBus from '@/common/utils/mitt';
const { themeConfig } = storeToRefs(useThemeConfig());

View File

@@ -213,7 +213,7 @@ onUnmounted(() => {
}
.layout-lock-screen-img {
@extend .layout-lock-screen-fixed;
background: url('@/assets/image/bg-login.png') no-repeat;
background: url('@/assets/image/login-bg-main.svg') no-repeat;
background-size: 100% 100%;
z-index: 9999991;
}

View File

@@ -15,10 +15,10 @@
<script lang="ts" setup name="layoutClassic">
import { storeToRefs } from 'pinia';
import { useThemeConfig } from '@/store/themeConfig';
import Aside from '@/views/layout/component/aside.vue';
import Header from '@/views/layout/component/header.vue';
import Main from '@/views/layout/component/main.vue';
import TagsView from '@/views/layout/navBars/tagsView/tagsView.vue';
import Aside from '@/layout/component/aside.vue';
import Header from '@/layout/component/header.vue';
import Main from '@/layout/component/main.vue';
import TagsView from '@/layout/navBars/tagsView/tagsView.vue';
const { themeConfig } = storeToRefs(useThemeConfig());
</script>

View File

@@ -17,10 +17,10 @@
<script lang="ts">
import { computed } from 'vue';
import Aside from '@/views/layout/component/aside.vue';
import Header from '@/views/layout/component/header.vue';
import Main from '@/views/layout/component/main.vue';
import ColumnsAside from '@/views/layout/component/columnsAside.vue';
import Aside from '@/layout/component/aside.vue';
import Header from '@/layout/component/header.vue';
import Main from '@/layout/component/main.vue';
import ColumnsAside from '@/layout/component/columnsAside.vue';
import { useThemeConfig } from '@/store/themeConfig';
export default {
name: 'layoutColumns',

View File

@@ -15,9 +15,9 @@
<script lang="ts">
import { computed, getCurrentInstance, watch } from 'vue';
import { useRoute } from 'vue-router';
import Aside from '@/views/layout/component/aside.vue';
import Header from '@/views/layout/component/header.vue';
import Main from '@/views/layout/component/main.vue';
import Aside from '@/layout/component/aside.vue';
import Header from '@/layout/component/header.vue';
import Main from '@/layout/component/main.vue';
import { useThemeConfig } from '@/store/themeConfig';
export default {
name: 'layoutDefaults',

View File

@@ -7,8 +7,8 @@
</template>
<script lang="ts">
import Header from '@/views/layout/component/header.vue';
import Main from '@/views/layout/component/main.vue';
import Header from '@/layout/component/header.vue';
import Main from '@/layout/component/main.vue';
export default {
name: 'layoutTransverse',
components: { Header, Main },

View File

@@ -14,10 +14,10 @@ import pinia from '@/store/index';
import { storeToRefs } from 'pinia';
import { useThemeConfig } from '@/store/themeConfig';
import { useRoutesList } from '@/store/routesList';
import Breadcrumb from '@/views/layout/navBars/breadcrumb/breadcrumb.vue';
import User from '@/views/layout/navBars/breadcrumb/user.vue';
import Logo from '@/views/layout/logo/index.vue';
import Horizontal from '@/views/layout/navMenu/horizontal.vue';
import Breadcrumb from '@/layout/navBars/breadcrumb/breadcrumb.vue';
import User from '@/layout/navBars/breadcrumb/user.vue';
import Logo from '@/layout/logo/index.vue';
import Horizontal from '@/layout/navMenu/horizontal.vue';
import mittBus from '@/common/utils/mitt';
const { themeConfig } = storeToRefs(useThemeConfig());

View File

@@ -61,6 +61,24 @@
</div>
</div>
<!-- 全局设置 -->
<el-divider content-position="left">全局设置</el-divider>
<div class="layout-breadcrumb-seting-bar-flex mt15">
<div class="layout-breadcrumb-seting-bar-flex-label">分页size</div>
<div class="layout-breadcrumb-seting-bar-flex-value">
<el-input-number
v-model="themeConfig.defaultListPageSize"
controls-position="right"
:min="10"
:max="50"
@change="setLocalThemeConfig"
size="small"
style="width: 90px"
>
</el-input-number>
</div>
</div>
<!-- 全局主题 -->
<el-divider content-position="left">全局主题</el-divider>
<div class="layout-breadcrumb-seting-bar-flex">
@@ -374,7 +392,7 @@
</div>
</div>
</div>
<div class="copy-config">
<!-- <div class="copy-config">
<el-alert title="点击下方按钮,复制布局配置去 /src/store/modules/themeConfig.ts中修改" type="warning" :closable="false"> </el-alert>
<el-button
size="small"
@@ -385,7 +403,7 @@
@click="onCopyConfigClick($event.target)"
>一键复制配置
</el-button>
</div>
</div> -->
</el-scrollbar>
</el-drawer>
</div>

View File

@@ -56,7 +56,7 @@
<crop />
</el-icon>
</div>
<el-dropdown :show-timeout="70" :hide-timeout="50" @command="onHandleCommandClick">
<el-dropdown trigger="click" :show-timeout="70" :hide-timeout="50" @command="onHandleCommandClick">
<span class="layout-navbars-breadcrumb-user-link" style="cursor: pointer">
<img :src="userInfo.photo" class="layout-navbars-breadcrumb-user-link-photo mr5" />
{{ userInfo.name || userInfo.username }}
@@ -75,7 +75,7 @@
</template>
<script setup lang="ts" name="layoutBreadcrumbUser">
import { ref, computed, reactive, onMounted, nextTick } from 'vue';
import { ref, computed, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessageBox, ElMessage } from 'element-plus';
import screenfull from 'screenfull';
@@ -83,11 +83,12 @@ import { resetRoute } from '@/router/index';
import { storeToRefs } from 'pinia';
import { useUserInfo } from '@/store/userInfo';
import { useThemeConfig } from '@/store/themeConfig';
import { clearSession, setLocal, getLocal, removeLocal } from '@/common/utils/storage';
import UserNews from '@/views/layout/navBars/breadcrumb/userNews.vue';
import SearchMenu from '@/views/layout/navBars/breadcrumb/search.vue';
import { clearSession, removeLocal } from '@/common/utils/storage';
import UserNews from '@/layout/navBars/breadcrumb/userNews.vue';
import SearchMenu from '@/layout/navBars/breadcrumb/search.vue';
import mittBus from '@/common/utils/mitt';
import openApi from '@/common/openApi';
import { saveThemeConfig, getThemeConfig } from '@/common/utils/storage';
const router = useRouter();
const searchRef = ref();
@@ -99,7 +100,8 @@ const state = reactive({
disabledSize: '',
});
const { userInfo } = storeToRefs(useUserInfo());
const { themeConfig } = storeToRefs(useThemeConfig());
const themeConfigStore = useThemeConfig();
const { themeConfig } = storeToRefs(themeConfigStore);
//
const layoutUserFlexNum = computed(() => {
@@ -164,16 +166,8 @@ const onHandleCommandClick = (path: string) => {
};
const switchDark = (isDark: boolean) => {
themeConfig.value.isDark = isDark;
setLocal('themeConfig', themeConfig.value);
const body = document.documentElement as HTMLElement;
if (isDark) {
body.setAttribute('class', 'dark');
themeConfig.value.editorTheme = 'vs-dark';
} else {
body.setAttribute('class', '');
themeConfig.value.editorTheme = 'SolarizedLight';
}
themeConfigStore.switchDark(isDark);
saveThemeConfig(themeConfig.value);
};
// //
@@ -185,7 +179,7 @@ const onSearchClick = () => {
const onComponentSizeChange = (size: string) => {
removeLocal('themeConfig');
themeConfig.value.globalComponentSize = size;
setLocal('themeConfig', themeConfig.value);
saveThemeConfig(themeConfig.value);
// proxy.$ELEMENT.size = size;
initComponentSize();
window.location.reload();
@@ -193,7 +187,7 @@ const onComponentSizeChange = (size: string) => {
//
const initComponentSize = () => {
switch (getLocal('themeConfig').globalComponentSize) {
switch (getThemeConfig().globalComponentSize) {
case '':
state.disabledSize = '';
break;
@@ -211,12 +205,10 @@ const initComponentSize = () => {
//
onMounted(() => {
if (getLocal('themeConfig')) {
const isDark = themeConfig.value.isDark;
state.isDark = isDark;
switchDark(isDark);
const themeConfig = getThemeConfig();
if (themeConfig) {
initComponentSize();
state.isDark = themeConfig.isDark;
}
});
</script>

View File

@@ -8,8 +8,8 @@
<script lang="ts">
import { computed } from 'vue';
import { useThemeConfig } from '@/store/themeConfig';
import BreadcrumbIndex from '@/views/layout/navBars/breadcrumb/index.vue';
import TagsView from '@/views/layout/navBars/tagsView/tagsView.vue';
import BreadcrumbIndex from '@/layout/navBars/breadcrumb/index.vue';
import TagsView from '@/layout/navBars/tagsView/tagsView.vue';
export default {
name: 'layoutNavBars',
components: { BreadcrumbIndex, TagsView },

View File

@@ -3,7 +3,7 @@
<el-scrollbar ref="scrollbarRef" @wheel.prevent="onHandleScroll">
<ul class="layout-navbars-tagsview-ul" :class="setTagsStyle" ref="tagsUlRef">
<li
v-for="(v, k) in state.tagsViewList"
v-for="(v, k) in tagsViews"
:key="k"
class="layout-navbars-tagsview-ul-li"
:data-name="v.name"
@@ -17,8 +17,8 @@
"
>
<SvgIcon name="iconfont icon-tag-view-active" class="layout-navbars-tagsview-ul-li-iconfont font14" v-if="isActive(v)" />
<SvgIcon :name="v.meta.icon" class="layout-navbars-tagsview-ul-li-iconfont" v-if="!isActive(v) && themeConfig.isTagsviewIcon" />
<span>{{ v.meta.title }}</span>
<SvgIcon :name="v.icon" class="layout-navbars-tagsview-ul-li-iconfont" v-if="!isActive(v) && themeConfig.isTagsviewIcon" />
<span>{{ v.title }}</span>
<template v-if="isActive(v)">
<SvgIcon
name="RefreshRight"
@@ -28,7 +28,7 @@
<SvgIcon
name="Close"
class="font14 layout-navbars-tagsview-ul-li-icon layout-icon-active"
v-if="!v.meta.isAffix"
v-if="!v.isAffix"
@click.stop="closeCurrentTagsView(themeConfig.isShareTagsView ? v.path : v.path)"
/>
</template>
@@ -36,42 +36,75 @@
<SvgIcon
name="Close"
class="font14 layout-navbars-tagsview-ul-li-icon layout-icon-three"
v-if="!v.meta.isAffix"
v-if="!v.isAffix"
@click.stop="closeCurrentTagsView(themeConfig.isShareTagsView ? v.path : v.path)"
/>
</li>
</ul>
</el-scrollbar>
<Contextmenu :dropdown="state.dropdown" ref="contextmenuRef" @currentContextmenuClick="onCurrentContextmenuClick" />
<Contextmenu :items="state.contextmenu.items" :dropdown="state.contextmenu.dropdown" ref="contextmenuRef" />
</div>
</template>
<script lang="ts" setup name="layoutTagsView">
import { reactive, onMounted, computed, ref, nextTick, onBeforeUpdate, onBeforeMount, onUnmounted, getCurrentInstance, watch } from 'vue';
import { reactive, onMounted, computed, ref, nextTick, onBeforeUpdate, onBeforeMount, onUnmounted, getCurrentInstance } from 'vue';
import { useRoute, useRouter, onBeforeRouteUpdate } from 'vue-router';
import screenfull from 'screenfull';
import { storeToRefs } from 'pinia';
import { useThemeConfig } from '@/store/themeConfig';
import { getSession, setSession, removeSession } from '@/common/utils/storage';
import mittBus from '@/common/utils/mitt';
import Sortable from 'sortablejs';
import Contextmenu from '@/views/layout/navBars/tagsView/contextmenu.vue';
import { Contextmenu, ContextmenuItem } from '@/components/contextmenu';
import { getTagViews, setTagViews, removeTagViews } from '@/common/utils/storage';
import { useTagsViews } from '@/store/tagsViews';
import { useKeepALiveNames } from '@/store/keepAliveNames';
const { proxy } = getCurrentInstance() as any;
const tagsRefs = ref([]) as any;
const scrollbarRef = ref();
const contextmenuRef = ref();
const tagsUlRef = ref();
const { themeConfig } = storeToRefs(useThemeConfig());
const { tagsViews } = storeToRefs(useTagsViews());
const keepAliveNamesStores = useKeepALiveNames();
const route = useRoute();
const router = useRouter();
const contextmenuItems = [
new ContextmenuItem(0, '刷新').withIcon('RefreshRight').withOnClick((data: any) => {
// pathfullPath
let { path } = data;
let currentTag = tagsViews.value.find((v: any) => v.path === path);
refreshCurrentTagsView(path);
router.push({ path, query: currentTag?.query });
}),
new ContextmenuItem(1, '关闭').withIcon('Close').withOnClick((data: any) => closeCurrentTagsView(data.path)),
new ContextmenuItem(2, '关闭其他').withIcon('CircleClose').withOnClick((data: any) => {
let { path } = data;
let currentTag = tagsViews.value.find((v: any) => v.path === path);
router.push({ path, query: currentTag?.query });
closeOtherTagsView(path);
}),
new ContextmenuItem(3, '关闭所有').withIcon('FolderDelete').withOnClick((data: any) => closeAllTagsView(data.path)),
new ContextmenuItem(4, '当前页全屏').withIcon('full-screen').withOnClick((data: any) => openCurrenFullscreen(data.path)),
];
const state = reactive({
routePath: route.fullPath,
dropdown: { x: '', y: '' },
// dropdown: { x: '', y: '' },
tagsRefsIndex: 0,
tagsViewList: [] as any,
sortable: '' as any,
contextmenu: {
items: contextmenuItems,
dropdown: { x: '', y: '' },
},
});
// tagsView
@@ -81,163 +114,173 @@ const setTagsStyle = computed(() => {
// tagsViewList
const addBrowserSetSession = (tagsViewList: Array<object>) => {
setSession('tagsViewList', tagsViewList);
setTagViews(tagsViewList);
};
// vuex tagsViewRoutes
// tagsViewRoutes
const getTagsViewRoutes = () => {
state.routePath = route.fullPath;
state.tagsViewList = [];
if (!themeConfig.value.isCacheTagsView) removeSession('tagsViewList');
tagsViews.value = [];
if (!themeConfig.value.isCacheTagsView) {
removeTagViews();
}
initTagsView();
};
// vuex isAffix
// isAffix
const initTagsView = () => {
if (getSession('tagsViewList') && themeConfig.value.isCacheTagsView) {
state.tagsViewList = getSession('tagsViewList');
const tagViews = getTagViews();
if (tagViews && themeConfig.value.isCacheTagsView) {
tagsViews.value = tagViews;
} else {
state.tagsViewList?.map((v: any) => {
if (v.meta.isAffix && !v.meta.isHide) state.tagsViewList.push({ ...v });
tagsViews.value?.map((v: any) => {
if (v.isAffix && !v.isHide) {
tagsViews.value.push({ ...v });
keepAliveNamesStores.setCacheKeepAlive(v);
}
});
addTagsView(route.fullPath);
}
// (li)
getTagsRefsIndex(route.fullPath);
setTagsRefsIndex(route.fullPath);
//
tagsViewmoveToCurrentTag();
};
// 1 tagsViewisHide tagsView
// pathfullPath
const addTagsView = (path: string, to: any = null) => {
if (!to) {
to = route;
}
path = decodeURI(path);
for (let tv of state.tagsViewList) {
if (tv.fullPath === path) {
return false;
const addTagsView = (path: string, to: any = null, tagViewIndex: number = -1) => {
nextTick(async () => {
if (!to) {
to = route;
}
}
const tagView = { ...to };
// Converting circular structure to JSON
tagView.matched = null;
tagView.redirectedFrom = null;
state.tagsViewList.push(tagView);
addBrowserSetSession(state.tagsViewList);
for (let tv of tagsViews.value) {
if (tv.path === path) {
return false;
}
}
const tagView = {
path: path,
name: to.name,
query: to.query,
title: to.meta.title,
icon: to.meta.icon,
isAffix: to.meta.isAffix,
isKeepAlive: to.meta.isKeepAlive,
};
if (tagViewIndex != -1) {
tagsViews.value.splice(tagViewIndex + 1, 0, tagView);
} else {
tagsViews.value.push(tagView);
}
await keepAliveNamesStores.addCachedView(tagView);
addBrowserSetSession(tagsViews.value);
});
};
// 2 tagsView
// pathfullPath
const refreshCurrentTagsView = (path: string) => {
const refreshCurrentTagsView = async (path: string) => {
const item = getTagsView(path);
await keepAliveNamesStores.delCachedView(item);
keepAliveNamesStores.addCachedView(item);
mittBus.emit('onTagsViewRefreshRouterView', path);
};
const getTagsView = (path: string) => {
return tagsViews.value.find((v: any) => v.path === path);
};
// 3 tagsViewisAffix
// pathfullPath
const closeCurrentTagsView = (path: string) => {
state.tagsViewList.map((v: any, k: number, arr: any) => {
if (!v.meta.isAffix) {
if (v.fullPath === path) {
state.tagsViewList.splice(k, 1);
tagsViews.value.map((v: TagsView, k: number, arr: any) => {
if (!v.isAffix) {
if (v.path === path) {
keepAliveNamesStores.delCachedView(v);
tagsViews.value.splice(k, 1);
setTimeout(() => {
if (state.routePath !== path) {
return;
}
let next;
let next: TagsView;
//
if (state.tagsViewList.length === k) {
if (tagsViews.value.length === k) {
next = k !== arr.length ? arr[k] : arr[arr.length - 1];
} else {
next = arr[k];
}
if (next.meta.isDynamic) {
router.push({ name: next.name, params: next.params });
} else {
if (next) {
router.push({ path: next.path, query: next.query });
} else {
router.push({ path: '/' });
}
}, 0);
}
}
});
addBrowserSetSession(state.tagsViewList);
addBrowserSetSession(tagsViews.value);
};
// 4 tagsViewisAffix
const closeOtherTagsView = (path: string) => {
const oldTagViews = state.tagsViewList;
state.tagsViewList = [];
oldTagViews.map((v: any) => {
if (v.meta.isAffix && !v.meta.isHide) state.tagsViewList.push({ ...v });
const oldTagViews = tagsViews.value;
tagsViews.value = [];
oldTagViews.map((v: TagsView) => {
if (v.isAffix && !v.isHide) {
keepAliveNamesStores.delOthersCachedViews(v);
tagsViews.value.push({ ...v });
}
});
addTagsView(path);
};
// 5 tagsViewisAffix
const closeAllTagsView = (path: string) => {
const oldTagViews = state.tagsViewList;
state.tagsViewList = [];
keepAliveNamesStores.delAllCachedViews();
const oldTagViews = tagsViews.value;
tagsViews.value = [];
oldTagViews.map((v: any) => {
if (v.meta.isAffix && !v.meta.isHide) {
state.tagsViewList.push({ ...v });
if (state.tagsViewList.some((v: any) => v.path === path)) router.push({ path, query: route.query });
else router.push({ path: v.path, query: route.query });
if (v.isAffix && !v.isHide) {
tagsViews.value.push({ ...v });
if (tagsViews.value.some((v: any) => v.path === path)) {
router.push({ path, query: route.query });
}
}
});
addBrowserSetSession(state.tagsViewList);
if (tagsViews.value) {
router.push({ path: '/' });
}
addBrowserSetSession(tagsViews.value);
};
// 6
const openCurrenFullscreen = (path: string) => {
const item = state.tagsViewList.find((v: any) => v.fullPath === path);
const item = tagsViews.value.find((v: any) => v.path === path);
nextTick(() => {
router.push({ path, query: item.query });
router.push({ path, query: item?.query });
const element = document.querySelector('.layout-main');
const screenfulls: any = screenfull;
screenfulls.request(element);
});
};
//
const onCurrentContextmenuClick = (data: any) => {
// pathfullPath
let { id, path } = data;
let currentTag = state.tagsViewList.find((v: any) => v.fullPath === path);
switch (id) {
case 0:
refreshCurrentTagsView(path);
router.push({ path, query: currentTag.query });
break;
case 1:
closeCurrentTagsView(path);
break;
case 2:
router.push({ path, query: currentTag.query });
closeOtherTagsView(path);
break;
case 3:
closeAllTagsView(path);
break;
case 4:
openCurrenFullscreen(path);
break;
}
};
//
const isActive = (route: any) => {
return route.fullPath === state.routePath;
const isActive = (tagView: TagsView) => {
return tagView.path === state.routePath;
};
// x,y props
const onContextmenu = (v: any, e: any) => {
const { clientX, clientY } = e;
state.dropdown.x = clientX;
state.dropdown.y = clientY;
state.contextmenu.dropdown.x = clientX;
state.contextmenu.dropdown.y = clientY;
contextmenuRef.value.openContextmenu(v);
};
// tagsView
const onTagsClick = (v: any, k: number) => {
state.routePath = decodeURI(v.fullPath);
state.routePath = decodeURI(v.path);
state.tagsRefsIndex = k;
router.push(v);
};
@@ -302,9 +345,9 @@ const tagsViewmoveToCurrentTag = () => {
});
};
// tagsView tagsView
const getTagsRefsIndex = (path: string) => {
if (state.tagsViewList.length > 0) {
state.tagsRefsIndex = state.tagsViewList.findIndex((item: any) => item.fullPath === path);
const setTagsRefsIndex = (path: string) => {
if (tagsViews.value.length > 0) {
state.tagsRefsIndex = tagsViews.value.findIndex((item: any) => item.path === path);
}
};
// tagsView
@@ -319,7 +362,7 @@ const initSortable = () => {
onEnd: () => {
const sortEndList: any = [];
state.sortable.toArray().map((val: any) => {
state.tagsViewList.map((v: any) => {
tagsViews.value.map((v: any) => {
if (v.name === val) sortEndList.push({ ...v });
});
});
@@ -329,24 +372,8 @@ const initSortable = () => {
}
};
// tagsView
// watch(
// pinia.state,
// (val) => {
// if (val.tagsViewRoutes.tagsViewRoutes.length === state.tagsViewRoutesList.length) return false;
// getTagsViewRoutes();
// },
// {
// deep: true,
// }
// );
//
onBeforeMount(() => {
// 0 1 2 3 4
mittBus.on('onCurrentContextmenuClick', (data: object) => {
onCurrentContextmenuClick(data);
});
// /
mittBus.on('openOrCloseSortable', () => {
initSortable();
@@ -354,8 +381,6 @@ onBeforeMount(() => {
});
//
onUnmounted(() => {
//
mittBus.off('onCurrentContextmenuClick');
// /
mittBus.off('openOrCloseSortable');
});
@@ -371,9 +396,10 @@ onMounted(() => {
});
//
onBeforeRouteUpdate((to) => {
state.routePath = decodeURI(to.fullPath);
addTagsView(to.fullPath, to);
getTagsRefsIndex(to.fullPath);
const path = decodeURI(to.fullPath);
state.routePath = path;
addTagsView(path, to, state.tagsRefsIndex);
setTagsRefsIndex(path);
tagsViewmoveToCurrentTag();
});
</script>
@@ -494,8 +520,14 @@ onBeforeRouteUpdate((to) => {
-webkit-mask-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzAiIGhlaWdodD0iNzAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgZmlsbD0ibm9uZSI+CgogPGc+CiAgPHRpdGxlPkxheWVyIDE8L3RpdGxlPgogIDxwYXRoIHRyYW5zZm9ybT0icm90YXRlKC0wLjEzMzUwNiA1MC4xMTkyIDUwKSIgaWQ9InN2Z18xIiBkPSJtMTAwLjExOTE5LDEwMGMtNTUuMjI4LDAgLTEwMCwtNDQuNzcyIC0xMDAsLTEwMGwwLDEwMGwxMDAsMHoiIG9wYWNpdHk9InVuZGVmaW5lZCIgc3Ryb2tlPSJudWxsIiBmaWxsPSIjRjhFQUU3Ii8+CiAgPHBhdGggZD0ibS0wLjYzNzY2LDcuMzEyMjhjMC4xMTkxOSwwIDAuMjE3MzcsMC4wNTc5NiAwLjQ3Njc2LDAuMTE5MTljMC4yMzIsMC4wNTQ3NyAwLjI3MzI5LDAuMDM0OTEgMC4zNTc1NywwLjExOTE5YzAuMDg0MjgsMC4wODQyOCAwLjM1NzU3LDAgMC40NzY3NiwwbDAuMTE5MTksMGwwLjIzODM4LDAiIGlkPSJzdmdfMiIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHBhdGggZD0ibTI4LjkyMTM0LDY5LjA1MjQ0YzAsMC4xMTkxOSAwLDAuMjM4MzggMCwwLjM1NzU3bDAsMC4xMTkxOWwwLDAuMTE5MTkiIGlkPSJzdmdfMyIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z180IiBoZWlnaHQ9IjAiIHdpZHRoPSIxLjMxMTA4IiB5PSI2LjgzNTUyIiB4PSItMC4wNDE3MSIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z181IiBoZWlnaHQ9IjEuNzg3ODQiIHdpZHRoPSIwLjExOTE5IiB5PSI2OC40NTY1IiB4PSIyOC45MjEzNCIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiAgPHJlY3QgaWQ9InN2Z182IiBoZWlnaHQ9IjQuODg2NzciIHdpZHRoPSIxOS4wNzAzMiIgeT0iNTEuMjkzMjEiIHg9IjM2LjY2ODY2IiBzdHJva2U9Im51bGwiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+'),
url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNzAiIGhlaWdodD0iNzAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgZmlsbD0ibm9uZSI+CiA8Zz4KICA8dGl0bGU+TGF5ZXIgMTwvdGl0bGU+CiAgPHBhdGggdHJhbnNmb3JtPSJyb3RhdGUoLTg5Ljc2MjQgNy4zMzAxNCA1NS4xMjUyKSIgc3Ryb2tlPSJudWxsIiBpZD0ic3ZnXzEiIGZpbGw9IiNGOEVBRTciIGQ9Im02Mi41NzQ0OSwxMTcuNTIwODZjLTU1LjIyOCwwIC0xMDAsLTQ0Ljc3MiAtMTAwLC0xMDBsMCwxMDBsMTAwLDB6IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPgogIDxwYXRoIGQ9Im0tMC42Mzc2Niw3LjMxMjI4YzAuMTE5MTksMCAwLjIxNzM3LDAuMDU3OTYgMC40NzY3NiwwLjExOTE5YzAuMjMyLDAuMDU0NzcgMC4yNzMyOSwwLjAzNDkxIDAuMzU3NTcsMC4xMTkxOWMwLjA4NDI4LDAuMDg0MjggMC4zNTc1NywwIDAuNDc2NzYsMGwwLjExOTE5LDBsMC4yMzgzOCwwIiBpZD0ic3ZnXzIiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxwYXRoIGQ9Im0yOC45MjEzNCw2OS4wNTI0NGMwLDAuMTE5MTkgMCwwLjIzODM4IDAsMC4zNTc1N2wwLDAuMTE5MTlsMCwwLjExOTE5IiBpZD0ic3ZnXzMiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNCIgaGVpZ2h0PSIwIiB3aWR0aD0iMS4zMTEwOCIgeT0iNi44MzU1MiIgeD0iLTAuMDQxNzEiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNSIgaGVpZ2h0PSIxLjc4Nzg0IiB3aWR0aD0iMC4xMTkxOSIgeT0iNjguNDU2NSIgeD0iMjguOTIxMzQiIHN0cm9rZT0ibnVsbCIgZmlsbD0ibm9uZSIvPgogIDxyZWN0IGlkPSJzdmdfNiIgaGVpZ2h0PSI0Ljg4Njc3IiB3aWR0aD0iMTkuMDcwMzIiIHk9IjUxLjI5MzIxIiB4PSIzNi42Njg2NiIgc3Ryb2tlPSJudWxsIiBmaWxsPSJub25lIi8+CiA8L2c+Cjwvc3ZnPg=='),
url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><rect rx='8' width='100%' height='100%' fill='%23F8EAE7'/></svg>");
-webkit-mask-size: 18px 30px, 20px 30px, calc(100% - 30px) calc(100% + 17px);
-webkit-mask-position: right bottom, left bottom, center top;
-webkit-mask-size:
18px 30px,
20px 30px,
calc(100% - 30px) calc(100% + 17px);
-webkit-mask-position:
right bottom,
left bottom,
center top;
-webkit-mask-repeat: no-repeat;
}
@@ -516,14 +548,14 @@ onBeforeRouteUpdate((to) => {
&:hover {
@extend .tgs-style-three-svg;
background: var(--el-color-primary-light-9);
background: var(--tagsview3-active-background-color);
color: unset;
}
}
.is-active {
@extend .tgs-style-three-svg;
background: var(--el-color-primary-light-9) !important;
background: var(--tagsview3-active-background-color) !important;
color: var(--el-color-primary) !important;
z-index: 1;
}

View File

@@ -1,24 +1,23 @@
<template>
<div class="el-menu-horizontal-warp">
<el-scrollbar @wheel.prevent="onElMenuHorizontalScroll" ref="elMenuHorizontalScrollRef">
<el-menu router :default-active="state.defaultActive" background-color="transparent" mode="horizontal"
@select="onHorizontalSelect">
<el-menu router :default-active="state.defaultActive" background-color="transparent" mode="horizontal" @select="onHorizontalSelect">
<template v-for="val in menuLists">
<el-sub-menu :index="val.path" v-if="val.children && val.children.length > 0" :key="val.path">
<template #title>
<SvgIcon :name="val.meta.icon"/>
<SvgIcon :name="val.meta.icon" />
<span>{{ val.meta.title }}</span>
</template>
<SubItem :chil="val.children" />
</el-sub-menu>
<el-menu-item :index="val.path" :key="val?.path" v-else>
<template #title v-if="!val.meta.link || (val.meta.link && val.meta.linkType == 1)">
<SvgIcon :name="val.meta.icon"/>
<SvgIcon :name="val.meta.icon" />
{{ val.meta.title }}
</template>
<template #title v-else>
<a :href="val.meta.link" target="_blank">
<SvgIcon :name="val.meta.icon"/>
<SvgIcon :name="val.meta.icon" />
{{ val.meta.title }}
</a>
</template>
@@ -32,7 +31,7 @@
<script lang="ts" setup name="navMenuHorizontal">
import { reactive, computed, getCurrentInstance, onMounted, nextTick } from 'vue';
import { useRoute, onBeforeRouteUpdate } from 'vue-router';
import SubItem from '@/views/layout/navMenu/subItem.vue';
import SubItem from '@/layout/navMenu/subItem.vue';
import { useRoutesList } from '@/store/routesList';
import { useThemeConfig } from '@/store/themeConfig';
import mittBus from '@/common/utils/mitt';

View File

@@ -1,21 +1,28 @@
<template>
<el-menu router :default-active="state.defaultActive" background-color="transparent" :collapse="setIsCollapse"
:unique-opened="themeConfig.isUniqueOpened" :collapse-transition="false">
<el-menu
router
:default-active="state.defaultActive"
background-color="transparent"
:collapse="setIsCollapse"
:unique-opened="themeConfig.isUniqueOpened"
:collapse-transition="false"
>
<template v-for="val in menuLists">
<el-sub-menu :index="val.path" v-if="val.children && val.children.length > 0" :key="val.path">
<template #title>
<SvgIcon :name="val.meta.icon"/>
<SvgIcon :name="val.meta.icon" />
<span>{{ val.meta.title }}</span>
</template>
<SubItem :chil="val.children" />
</el-sub-menu>
<el-menu-item :index="val.path" :key="val?.path" v-else>
<SvgIcon :name="val.meta.icon"/>
<SvgIcon :name="val.meta.icon" />
<template #title v-if="!val.meta.link || (val.meta.link && val.meta.linkType == 1)">
<span>{{ val.meta.title }}</span>
</template>
<template #title v-else>
<a :href="val.meta.link" target="_blank">{{ val.meta.title }}</a></template>
<a :href="val.meta.link" target="_blank">{{ val.meta.title }}</a></template
>
</el-menu-item>
</template>
</el-menu>
@@ -26,7 +33,7 @@ import { reactive, computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useThemeConfig } from '@/store/themeConfig';
import { useRoute, onBeforeRouteUpdate } from 'vue-router';
import SubItem from '@/views/layout/navMenu/subItem.vue';
import SubItem from '@/layout/navMenu/subItem.vue';
import mittBus from '@/common/utils/mitt';
//

View File

@@ -2,7 +2,7 @@
<div class="h100">
<router-view v-slot="{ Component }">
<transition :name="setTransitionName" mode="out-in">
<keep-alive :include="state.keepAliveNameList">
<keep-alive :include="getKeepAliveNames">
<component :is="Component" :key="state.refreshRouterViewKey" class="w100" />
</keep-alive>
</transition>
@@ -11,38 +11,62 @@
</template>
<script lang="ts" setup name="layoutParentView">
import { computed, reactive, onBeforeMount, onUnmounted, nextTick } from 'vue';
import { computed, watch, reactive, onBeforeMount, onMounted, onUnmounted, nextTick } from 'vue';
import { useRoute } from 'vue-router';
import { storeToRefs } from 'pinia';
import { useThemeConfig } from '@/store/themeConfig';
import { useKeepALiveNames } from '@/store/keepAliveNames';
import mittBus from '@/common/utils/mitt';
import { getTagViews } from '@/common/utils/storage';
const route = useRoute();
const { themeConfig } = storeToRefs(useThemeConfig());
const { keepAliveNames } = storeToRefs(useKeepALiveNames());
const { keepAliveNames, cachedViews } = storeToRefs(useKeepALiveNames());
const state: any = reactive({
refreshRouterViewKey: null,
keepAliveNameList: [],
keepAliveNameNewList: [],
});
// refreshRouterViewKey
// onBeforeRouteUpdate((to: any) => {
// state.refreshRouterViewKey = decodeURI(to.fullPath);
// });
// (name)
const getKeepAliveNames = computed(() => {
return themeConfig.value.isTagsview ? cachedViews.value : state.keepAliveNameList;
});
//
onBeforeMount(() => {
state.keepAliveNameList = keepAliveNames.value;
mittBus.on('onTagsViewRefreshRouterView', (path: string) => {
if (decodeURI(route.fullPath) !== path) return false;
state.keepAliveNameList = keepAliveNames.value.filter((name: string) => route.name !== name);
state.refreshRouterViewKey = route.path;
state.refreshRouterViewKey = '';
nextTick(() => {
state.refreshRouterViewKey = null;
state.refreshRouterViewKey = path;
state.keepAliveNameList = keepAliveNames.value;
});
});
});
//
onMounted(() => {
nextTick(() => {
setTimeout(() => {
if (themeConfig.value.isCacheTagsView) {
let tagsViewArr: any = getTagViews() || [];
cachedViews.value = tagsViewArr.filter((item: any) => item?.isKeepAlive).map((item: any) => item.name as string);
}
}, 0);
});
});
// tagsView
watch(
() => route.fullPath,
() => {
state.refreshRouterViewKey = decodeURI(route.fullPath);
},
{
immediate: true,
}
);
//
const setTransitionName = computed(() => {
return themeConfig.value.animation;

View File

@@ -1,12 +1,12 @@
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import { getSession, clearSession } from '@/common/utils/storage';
import { clearSession, getToken } from '@/common/utils/storage';
import { templateResolve } from '@/common/utils/string';
import { NextLoading } from '@/common/utils/loading';
import { dynamicRoutes, staticRoutes, pathMatch } from './route';
import openApi from '@/common/openApi';
import sockets from '@/common/sockets';
import syssocket from '@/common/syssocket';
import pinia from '@/store/index';
import { useThemeConfig } from '@/store/themeConfig';
import { useUserInfo } from '@/store/userInfo';
@@ -18,7 +18,7 @@ import { useKeepALiveNames } from '@/store/keepAliveNames';
* @method import.meta.glob
* @link 参考https://cn.vitejs.dev/guide/features.html#json
*/
const viewsModules: any = import.meta.glob(['../views/**/*.{vue,tsx}', '!../views/layout/**/*.{vue,tsx}']);
const viewsModules: any = import.meta.glob(['../views/**/*.{vue,tsx}']);
const dynamicViewsModules: Record<string, Function> = Object.assign({}, { ...viewsModules });
// 添加静态路由
@@ -29,8 +29,7 @@ const router = createRouter({
// 前端控制路由:初始化方法,防止刷新时丢失
export function initAllFun() {
NextLoading.start(); // 界面 loading 动画开始执行
const token = getSession('token'); // 获取浏览器缓存 token 值
const token = getToken(); // 获取浏览器缓存 token 值
if (!token) {
// 无 token 停止执行下一步
return false;
@@ -38,18 +37,15 @@ export function initAllFun() {
useUserInfo().setUserInfo({});
router.addRoute(pathMatch); // 添加404界面
resetRoute(); // 删除/重置路由
// 添加动态路由
setFilterRouteEnd().forEach((route: any) => {
router.addRoute(route as unknown as RouteRecordRaw);
});
router.addRoute(dynamicRoutes[0]);
// 过滤权限菜单
useRoutesList().setRoutesList(setFilterMenuFun(dynamicRoutes[0].children, useUserInfo().userInfo.menus));
useRoutesList().setRoutesList(dynamicRoutes[0].children);
}
// 后端控制路由:执行路由数据初始化
export async function initBackEndControlRoutesFun() {
NextLoading.start(); // 界面 loading 动画开始执行
const token = getSession('token'); // 获取浏览器缓存 token 值
const token = getToken(); // 获取浏览器缓存 token 值
if (!token) {
// 无 token 停止执行下一步
return false;
@@ -57,14 +53,25 @@ export async function initBackEndControlRoutesFun() {
useUserInfo().setUserInfo({});
// 获取路由
let menuRoute = await getBackEndControlRoutes();
dynamicRoutes[0].children = backEndRouterConverter(menuRoute); // 处理路由component
const cacheList: Array<string> = [];
// 处理路由component
dynamicRoutes[0].children = backEndRouterConverter(menuRoute, (router: any) => {
// 可能为false时不存在isKeepAlive属性
if (!router.meta.isKeepAlive) {
router.meta.isKeepAlive = false;
}
if (router.meta.isKeepAlive) {
cacheList.push(router.name);
}
});
useKeepALiveNames().setCacheKeepAlive(cacheList);
// 添加404界面
router.addRoute(pathMatch);
resetRoute(); // 删除/重置路由
// 添加动态路由
formatTwoStageRoutes(formatFlatteningRoutes(dynamicRoutes)).forEach((route: any) => {
router.addRoute(route as unknown as RouteRecordRaw);
});
router.addRoute(dynamicRoutes[0] as unknown as RouteRecordRaw);
useRoutesList().setRoutesList(dynamicRoutes[0].children);
}
@@ -81,8 +88,10 @@ export async function getBackEndControlRoutes() {
}
}
type RouterConvCallbackFunc = (router: any) => void;
// 后端控制路由,后端返回路由 转换为vue route
export function backEndRouterConverter(routes: any, parentPath: string = '/') {
export function backEndRouterConverter(routes: any, callbackFunc: RouterConvCallbackFunc = null as any, parentPath: string = '/') {
if (!routes) return;
return routes.map((item: any) => {
if (!item.meta) {
@@ -117,7 +126,9 @@ export function backEndRouterConverter(routes: any, parentPath: string = '/') {
item.redirect = item.meta.redirect;
delete item.meta['redirect'];
}
item.children && backEndRouterConverter(item.children, item.path);
// 存在回调,则执行回调
callbackFunc && callbackFunc(item);
item.children && backEndRouterConverter(item.children, callbackFunc, item.path);
return item;
});
}
@@ -143,86 +154,6 @@ export function dynamicImport(dynamicViewsModules: Record<string, Function>, com
}
}
// 多级嵌套数组处理成一维数组
export function formatFlatteningRoutes(arr: any) {
if (arr.length <= 0) return false;
for (let i = 0; i < arr.length; i++) {
if (arr[i].children) {
arr = arr.slice(0, i + 1).concat(arr[i].children, arr.slice(i + 1));
}
}
return arr;
}
// 多级嵌套数组处理后的一维数组,再处理成 `定义动态路由` 的格式
// 只保留二级也就是二级以上全部处理成只有二级keep-alive 支持二级缓存
// isKeepAlive 处理 `name` 值,进行缓存。顶级关闭,全部不缓存
export function formatTwoStageRoutes(arr: any) {
if (arr.length <= 0) return false;
const newArr: any = [];
const cacheList: Array<string> = [];
arr.forEach((v: any) => {
if (v.path === '/') {
newArr.push({ component: v.component, name: v.name, path: v.path, redirect: v.redirect, meta: v.meta, children: [] });
} else {
newArr[0].children.push({ ...v });
if (newArr[0].meta.isKeepAlive && v.meta.isKeepAlive) {
cacheList.push(v.name);
}
}
});
useKeepALiveNames().setCacheKeepAlive(cacheList);
return newArr;
}
// 判断路由code 是否包含当前登录用户menus字段中menus为字符串code数组
export function hasAnth(menus: any, route: any) {
if (route.meta && route.meta.code) {
return menus.includes(route.meta.code);
}
return true;
}
// 递归过滤有权限的路由
export function setFilterMenuFun(routes: any, menus: any) {
const menu: any = [];
routes.forEach((route: any) => {
const item = { ...route };
if (hasAnth(menus, item)) {
if (item.children) {
item.children = setFilterMenuFun(item.children, menus);
}
menu.push(item);
}
});
return menu;
}
// 获取当前用户的权限去比对路由表,用于动态路由的添加
export function setFilterRoute(chil: any) {
let filterRoute: any = [];
chil.forEach((route: any) => {
// 如果路由需要拥有指定code才可访问则校验该用户菜单是否存在该code
if (route.meta.code) {
useUserInfo().userInfo.menus.forEach((m: any) => {
if (route.meta.code == m) {
filterRoute.push({ ...route });
}
});
} else {
filterRoute.push({ ...route });
}
});
return filterRoute;
}
// 比对后的路由表,进行重新赋值
export function setFilterRouteEnd() {
let filterRouteEnd: any = formatTwoStageRoutes(formatFlatteningRoutes(dynamicRoutes));
filterRouteEnd[0].children = setFilterRoute(filterRouteEnd[0].children);
return filterRouteEnd;
}
// 删除/重置路由
export function resetRoute() {
useRoutesList().routesList.forEach((route: any) => {
@@ -232,18 +163,22 @@ export function resetRoute() {
}
export async function initRouter() {
// 初始化方法执行
const { isRequestRoutes } = useThemeConfig(pinia).themeConfig;
if (!isRequestRoutes) {
// 未开启后端控制路由
initAllFun();
} else if (isRequestRoutes) {
// 后端控制路由isRequestRoutes 为 true则开启后端控制路由
await initBackEndControlRoutesFun();
NextLoading.start(); // 界面 loading 动画开始执行
try {
// 初始化方法执行
const { isRequestRoutes } = useThemeConfig(pinia).themeConfig;
if (!isRequestRoutes) {
// 未开启后端控制路由
initAllFun();
} else if (isRequestRoutes) {
// 后端控制路由isRequestRoutes 为 true则开启后端控制路由
await initBackEndControlRoutesFun();
}
} finally {
NextLoading.done();
}
}
let SysWs: any;
let loadRouter = false;
// 路由加载前
@@ -252,11 +187,11 @@ router.beforeEach(async (to, from, next) => {
if (to.meta.title) NProgress.start();
// 如果有标题参数,则再原标题后加上参数来区别
if (to.meta.titleRename) {
if (to.meta.titleRename && to.meta.title) {
to.meta.title = templateResolve(to.meta.title as string, to.query);
}
const token = getSession('token');
const token = getToken();
if ((to.path === '/login' || to.path == '/oauth2/callback') && !token) {
next();
NProgress.done();
@@ -268,10 +203,7 @@ router.beforeEach(async (to, from, next) => {
resetRoute();
NProgress.done();
if (SysWs) {
SysWs.close();
SysWs = null;
}
syssocket.destory();
return;
}
if (token && to.path === '/login') {
@@ -281,9 +213,10 @@ router.beforeEach(async (to, from, next) => {
}
// 终端不需要连接系统websocket消息
if (!SysWs && to.path != '/machine/terminal') {
SysWs = sockets.sysMsgSocket();
if (to.path != '/machine/terminal') {
syssocket.init();
}
// 不存在路由避免刷新页面找不到路由并且未加载过避免token过期导致获取权限接口报权限不足无限获取则重新初始化路由
if (useRoutesList().routesList.length == 0 && !loadRouter) {
await initRouter();
@@ -297,7 +230,6 @@ router.beforeEach(async (to, from, next) => {
// 路由加载后
router.afterEach(() => {
NProgress.done();
NextLoading.done();
});
// 导出路由

View File

@@ -1,5 +1,5 @@
import { RouteRecordRaw } from 'vue-router';
import Layout from '@/views/layout/index.vue';
import Layout from '@/layout/index.vue';
// 定义动态路由
export const dynamicRoutes = [

View File

@@ -18,14 +18,16 @@ export const useKeepALiveNames = defineStore('keepALiveNames', {
this.keepAliveNames = data;
},
async addCachedView(view: any) {
if (view.meta.isKeepAlive) this.cachedViews?.push(view.name);
if (view.isKeepAlive) {
this.cachedViews?.push(view.name);
}
},
async delCachedView(view: any) {
const index = this.cachedViews.indexOf(view.name);
index > -1 && this.cachedViews.splice(index, 1);
},
async delOthersCachedViews(view: any) {
if (view.meta.isKeepAlive) this.cachedViews = [view.name];
if (view.isKeepAlive) this.cachedViews = [view.name];
else this.cachedViews = [];
},
async delAllCachedViews() {

View File

@@ -0,0 +1,25 @@
import { getNowUrl } from '@/common/utils/url';
import { defineStore } from 'pinia';
/**
* tags view
*/
export const useTagsViews = defineStore('tagsViews', {
state: (): TagsViewsState => ({
tagsViews: [],
}),
actions: {
setTagsViews(data: Array<TagsView>) {
this.tagsViews = data;
},
// 设置当前页面的tags view title
setNowTitle(title: string) {
this.tagsViews.forEach((item) => {
// console.log(getNowUrl(), item.path);
if (item.path == getNowUrl()) {
item.title = title;
}
});
},
},
});

View File

@@ -1,4 +1,6 @@
import { defineStore } from 'pinia';
import { dateFormat2 } from '@/common/utils/date';
import { useUserInfo } from '@/store/userInfo';
export const useThemeConfig = defineStore('themeConfig', {
state: (): ThemeConfigState => ({
@@ -88,9 +90,9 @@ export const useThemeConfig = defineStore('themeConfig', {
// 是否开启色弱模式
isInvert: false,
// 是否开启水印
isWartermark: false,
// 水印文案
wartermarkText: 'mayfly',
isWatermark: false,
// 水印文案数组0->用户信息 1->当前时间 2->额外信息
watermarkText: ['', '', ''],
/* 其它设置
------------------------------- */
@@ -133,6 +135,10 @@ export const useThemeConfig = defineStore('themeConfig', {
globalI18n: 'zh-cn',
// 默认全局组件大小,可选值"<|large|default|small>",默认 ''
globalComponentSize: '',
/** 全局设置 */
// 默认列表页的分页大小
defaultListPageSize: 15,
},
}),
actions: {
@@ -140,5 +146,40 @@ export const useThemeConfig = defineStore('themeConfig', {
setThemeConfig(data: ThemeConfigState) {
this.themeConfig = data.themeConfig;
},
// 切换暗模式
switchDark(isDark: boolean) {
this.themeConfig.isDark = isDark;
const body = document.documentElement as HTMLElement;
if (isDark) {
body.setAttribute('class', 'dark');
this.themeConfig.editorTheme = 'vs-dark';
} else {
body.setAttribute('class', '');
this.themeConfig.editorTheme = 'vs';
}
},
// 设置水印配置信息
setWatermarkConfig(useWatermarkConfig: any) {
this.themeConfig.watermarkText = [];
this.themeConfig.isWatermark = useWatermarkConfig.isUse;
if (!useWatermarkConfig.isUse) {
return;
}
// 索引2为用户自定义水印信息
this.themeConfig.watermarkText[2] = useWatermarkConfig.content;
},
// 设置水印用户信息
setWatermarkUser(del: boolean = false) {
const userinfo = useUserInfo().userInfo;
let desc = '';
if (!del && userinfo && userinfo.username) {
desc = `${userinfo.username}(${userinfo.name})`;
}
this.themeConfig.watermarkText[0] = desc;
},
// 设置水印时间为当前时间
setWatermarkNowTime() {
this.themeConfig.watermarkText[1] = dateFormat2('yyyy-MM-dd HH:mm:ss', new Date());
},
},
});

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia';
import { getSession } from '@/common/utils/storage';
import { getUser } from '@/common/utils/storage';
export const useUserInfo = defineStore('userInfo', {
state: (): UserInfoState => ({
@@ -8,7 +8,7 @@ export const useUserInfo = defineStore('userInfo', {
actions: {
// 设置用户信息
async setUserInfo(data: object) {
const ui = getSession('userInfo');
const ui = getUser();
if (ui) {
this.userInfo = ui;
} else {

View File

@@ -23,6 +23,8 @@
--color-seting-main: #e9eef3;
--color-seting-aside: #d3dce6;
--color-seting-header: #b3c0d1;
--tagsview3-active-background-color: var(--el-color-primary-light-9);
}
html,

View File

@@ -83,3 +83,42 @@
opacity: 1;
}
}
/* 登录页动画
------------------------------- */
@keyframes loginLeft {
0% {
left: -100%;
}
50%,
100% {
left: 100%;
}
}
@keyframes loginTop {
0% {
top: -100%;
}
50%,
100% {
top: 100%;
}
}
@keyframes loginRight {
0% {
right: -100%;
}
50%,
100% {
right: 100%;
}
}
@keyframes loginBottom {
0% {
bottom: -100%;
}
50%,
100% {
bottom: 100%;
}
}

View File

@@ -24,4 +24,6 @@ html.dark {
--bg-menuBarActiveColor: var(--next-color-hover-rgba) !important;
--bg-columnsMenuBar: var(--next-color-disabled) !important;
--bg-columnsMenuBarColor: var(--next-color-bar) !important;
--tagsview3-active-background-color: var(--next-color-hover);
}

View File

@@ -335,8 +335,15 @@
/* Set padding to ensure the height is 32px */
// padding: 6px 12px;
background: linear-gradient(90deg, rgb(159, 229, 151), rgb(204, 229, 129));
}
.el-popper.is-customized .el-popper__arrow::before {
}
.el-popper.is-customized .el-popper__arrow::before {
background: linear-gradient(45deg, #b2e68d, #bce689);
right: 0;
}
}
.el-dialog {
border-radius: 6px; /* 设置圆角 */
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); /* 添加轻微阴影效果 */
border: 1px solid var(--el-border-color-lighter);
}

View File

@@ -48,4 +48,4 @@
35% {
transform: scale3D(0, 0, 1);
}
}
}

View File

@@ -1,5 +1,5 @@
declare interface UserInfoState<T = any> {
userInfo: any;
userInfo: T;
}
declare interface ThemeConfigState {
@@ -40,8 +40,8 @@ declare interface ThemeConfigState {
isDark: boolean;
isGrayscale: boolean;
isInvert: boolean;
isWartermark: boolean;
wartermarkText: string;
isWatermark: boolean;
watermarkText: Array<string>;
tagsStyle: string;
animation: string;
columnsAsideStyle: string;
@@ -57,13 +57,45 @@ declare interface ThemeConfigState {
terminalFontSize: number;
terminalFontWeight: string | any;
editorTheme: string;
defaultListPageSize: number;
};
}
declare interface TagsView {
/**
* 路径
*/
path: string;
/**
* 标题
*/
title: string;
/**
* router name
*/
name: string;
/**
* router query
*/
query: any;
/**
* 图标
*/
icon: string;
isAffix: boolean;
isKeepAlive: boolean;
isHide?: boolean;
}
// TagsView 路由列表
declare interface TagsViewRoutesState<T = any> {
tagsViewRoutes: T[];
isTagsViewCurrenFull: Boolean;
declare interface TagsViewsState<> {
tagsViews: TagsView[];
}
// 路由列表

View File

@@ -1,14 +1,5 @@
/* eslint-disable */
import {IDisposable} from 'monaco-editor';
declare global {
interface Window {
completionItemProvider?: IDisposable | undefined;
}
}
// 申明外部 npm 插件模块
declare module 'sql-formatter';
declare module 'jsoneditor';
declare module 'asciinema-player';
declare module 'monaco-editor';
declare module 'vue-grid-layout';
declare module 'vue-grid-layout';

View File

@@ -12,7 +12,7 @@
</div>
</div>
<div class="right">
<img src="@/assets/image/401.png" />
<img src="@/assets/image/401.svg" />
</div>
</div>
</div>
@@ -20,7 +20,7 @@
<script lang="ts">
import { useRouter } from 'vue-router';
import { clearSession } from '@/common/utils/storage.ts';
import { clearSession } from '@/common/utils/storage';
export default {
name: '401',
setup() {
@@ -39,7 +39,7 @@ export default {
<style scoped lang="scss">
.error {
height: 100%;
background-color: white;
background-color: var(--bg-main-color);
display: flex;
.error-flex {
margin: auto;
@@ -64,7 +64,7 @@ export default {
}
.left-item-title {
font-size: 20px;
color: #333333;
// color: #333333;
margin: 15px 0 5px 0;
animation-delay: 0.1s;
}

View File

@@ -12,7 +12,7 @@
</div>
</div>
<div class="right">
<img src="@/assets/image/404.png" />
<img src="@/assets/image/404.svg" />
</div>
</div>
</div>
@@ -37,7 +37,7 @@ export default {
<style scoped lang="scss">
.error {
height: 100%;
background-color: white;
background-color: var(--bg-main-color);
display: flex;
.error-flex {
margin: auto;
@@ -62,7 +62,7 @@ export default {
}
.left-item-title {
font-size: 20px;
color: #333333;
// color: #333333;
margin: 15px 0 5px 0;
animation-delay: 0.1s;
}

View File

@@ -1,138 +0,0 @@
<template>
<transition name="el-zoom-in-center">
<div
aria-hidden="true"
class="el-dropdown__popper el-popper is-light is-pure custom-contextmenu"
role="tooltip"
data-popper-placement="bottom"
:style="`top: ${dropdowns.y + 5}px;left: ${dropdowns.x}px;`"
:key="Math.random()"
v-show="state.isShow"
>
<ul class="el-dropdown-menu">
<template v-for="(v, k) in state.dropdownList">
<li
class="el-dropdown-menu__item"
aria-disabled="false"
tabindex="-1"
:key="k"
v-if="!v.affix"
@click="onCurrentContextmenuClick(v.contextMenuClickId)"
>
<SvgIcon :name="v.icon" />
<span>{{ v.txt }}</span>
</li>
</template>
</ul>
<div class="el-popper__arrow" :style="{ left: `${state.arrowLeft}px` }"></div>
</div>
</transition>
</template>
<script setup lang="ts" name="layoutTagsViewContextmenu">
import { computed, reactive, onMounted, onUnmounted, watch } from 'vue';
// 定义父组件传过来的值
const props = defineProps({
dropdown: {
type: Object,
default: () => {
return {
x: 0,
y: 0,
};
},
},
});
// 定义子组件向父组件传值/事件
const emit = defineEmits(['currentContextmenuClick']);
// 定义变量内容
const state = reactive({
isShow: false,
dropdownList: [
{ contextMenuClickId: 0, txt: '刷新', affix: false, icon: 'RefreshRight' },
{ contextMenuClickId: 1, txt: '关闭', affix: false, icon: 'Close' },
{ contextMenuClickId: 2, txt: '关闭其他', affix: false, icon: 'CircleClose' },
{ contextMenuClickId: 3, txt: '关闭所有', affix: false, icon: 'FolderDelete' },
{
contextMenuClickId: 4,
txt: '当前页全屏',
affix: false,
icon: 'full-screen',
},
],
item: {} as any,
arrowLeft: 10,
});
// 父级传过来的坐标 x,y 值
const dropdowns = computed(() => {
// 117 为 `Dropdown 下拉菜单` 的宽度
if (props.dropdown.x + 117 > document.documentElement.clientWidth) {
return {
x: document.documentElement.clientWidth - 117 - 5,
y: props.dropdown.y,
};
} else {
return props.dropdown;
}
});
// 当前项菜单点击
const onCurrentContextmenuClick = (contextMenuClickId: number) => {
emit('currentContextmenuClick', { id: contextMenuClickId, path: state.item.fullPath });
};
// 打开右键菜单:判断是否固定,固定则不显示关闭按钮
const openContextmenu = (item: any) => {
state.item = item;
item.meta?.isAffix ? (state.dropdownList[1].affix = true) : (state.dropdownList[1].affix = false);
closeContextmenu();
setTimeout(() => {
state.isShow = true;
}, 10);
};
// 关闭右键菜单
const closeContextmenu = () => {
state.isShow = false;
};
// 监听页面监听进行右键菜单的关闭
onMounted(() => {
document.body.addEventListener('click', closeContextmenu);
});
// 页面卸载时,移除右键菜单监听事件
onUnmounted(() => {
document.body.removeEventListener('click', closeContextmenu);
});
// 监听下拉菜单位置
watch(
() => props.dropdown,
({ x }) => {
if (x + 117 > document.documentElement.clientWidth) state.arrowLeft = 117 - (document.documentElement.clientWidth - x);
else state.arrowLeft = 10;
},
{
deep: true,
}
);
// 暴露变量
defineExpose({
openContextmenu,
});
</script>
<style scoped lang="scss">
.custom-contextmenu {
transform-origin: center top;
z-index: 2190;
position: fixed;
.el-dropdown-menu__item {
font-size: 12px !important;
white-space: nowrap;
i {
font-size: 12px !important;
}
}
}
</style>

View File

@@ -132,16 +132,18 @@ import { nextTick, onMounted, ref, toRefs, reactive, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { initRouter } from '@/router/index';
import { getSession, setSession, setUserInfo2Session, setUseWatermark2Session } from '@/common/utils/storage';
import { saveToken, saveUser } from '@/common/utils/storage';
import { formatAxis } from '@/common/utils/format';
import openApi from '@/common/openApi';
import { RsaEncrypt } from '@/common/rsa';
import { getAccountLoginSecurity, getLdapEnabled, useWartermark } from '@/common/sysconfig';
import { getAccountLoginSecurity, getLdapEnabled } from '@/common/sysconfig';
import { letterAvatar } from '@/common/utils/string';
import { useUserInfo } from '@/store/userInfo';
import QrcodeVue from 'qrcode.vue';
import { personApi } from '@/views/personal/api';
import { AccountUsernamePattern } from '@/common/pattern';
import { getToken } from '@/common/utils/storage';
import { useThemeConfig } from '@/store/themeConfig';
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
@@ -149,6 +151,9 @@ const rules = {
captcha: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
};
// 定义变量内容
const storesThemeConfig = useThemeConfig();
const route = useRoute();
const router = useRouter();
const loginFormRef: any = ref(null);
@@ -159,7 +164,7 @@ const baseInfoFormRef: any = ref(null);
const state = reactive({
accountLoginSecurity: {
useCaptcha: true,
useCaptcha: false,
useOtp: false,
loginFailCount: 5,
loginFailMin: 10,
@@ -354,12 +359,12 @@ const loginResDeal = (loginRes: any) => {
};
// 存储用户信息到浏览器缓存
setUserInfo2Session(userInfos);
saveUser(userInfos);
// 1、请注意执行顺序(存储用户信息到vuex)
useUserInfo().setUserInfo(userInfos);
const token = loginRes.token;
// 如果不需要otp校验则该token即为accessToken否则为otp校验token
// 如果不需要 otp校验则该token即为accessToken否则为otp校验token
if (loginRes.otp == -1) {
signInSuccess(token);
return;
@@ -376,10 +381,11 @@ const loginResDeal = (loginRes: any) => {
// 登录成功后的跳转
const signInSuccess = async (accessToken: string = '') => {
if (!accessToken) {
accessToken = getSession('token');
accessToken = getToken();
}
// 存储 token 到浏览器缓存
setSession('token', accessToken);
saveToken(accessToken);
// 初始化路由
await initRouter();
@@ -404,9 +410,8 @@ const toIndex = async () => {
// 关闭 loading
state.loading.signIn = true;
ElMessage.success(`${currentTimeInfo},欢迎回来!`);
if (await useWartermark()) {
setUseWatermark2Session(true);
}
// 水印设置用户信息
storesThemeConfig.setWatermarkUser();
}, 300);
};

View File

@@ -1,70 +1,77 @@
<template>
<div class="login-container">
<div class="login-logo">
<span>{{ themeConfig.globalViceTitle }}</span>
<div class="login-container flex">
<div class="login-left">
<div class="login-left-logo">
<img :src="logoMini" />
<div class="login-left-logo-text">
<span>mayfly-go</span>
<!-- <span class="login-left-logo-text-msg">mayfly-go</span> -->
</div>
</div>
<div class="login-left-img">
<img :src="loginBgImg" />
</div>
<img :src="loginBgSplitImg" class="login-left-waves" />
</div>
<div class="login-content" :class="{ 'login-content-mobile': tabsActiveName === 'mobile' }">
<div class="login-content-main">
<h4 class="login-content-title">mayfly-go</h4>
<el-tabs v-model="tabsActiveName" @tab-click="onTabsClick">
<el-tab-pane label="账号密码登录" name="account" :disabled="tabsActiveName === 'account'">
<transition name="el-zoom-in-center">
<Account v-show="isTabPaneShow" ref="loginForm" />
</transition>
</el-tab-pane>
<!-- <el-tab-pane label="手机号登录" name="mobile" :disabled="tabsActiveName === 'mobile'">
<transition name="el-zoom-in-center">
<Mobile v-show="!isTabPaneShow" />
</transition>
</el-tab-pane> -->
</el-tabs>
<div class="mt20" v-show="oauth2LoginConfig.enable">
<el-button link size="small">第三方登录: </el-button>
<el-tooltip :content="oauth2LoginConfig.name" placement="top-start">
<el-button link size="small" type="primary" @click="oauth2Login">
<el-icon :size="18">
<Link />
</el-icon>
</el-button>
</el-tooltip>
<div class="login-right flex">
<div class="login-right-warp flex-margin">
<span class="login-right-warp-one"></span>
<span class="login-right-warp-two"></span>
<div class="login-right-warp-mian">
<div class="login-right-warp-main-title">mayfly-go</div>
<div class="login-right-warp-main-form">
<div v-if="!state.isScan">
<el-tabs v-model="state.tabsActiveName">
<el-tab-pane label="账号密码登录" name="account">
<Account ref="loginForm" />
</el-tab-pane>
</el-tabs>
</div>
<div class="mt20" v-show="state.oauth2LoginConfig.enable">
<el-button link size="small">第三方登录: </el-button>
<el-tooltip :content="state.oauth2LoginConfig.name" placement="top-start">
<el-button link size="small" type="primary" @click="oauth2Login">
<el-icon :size="18">
<Link />
</el-icon>
</el-button>
</el-tooltip>
</div>
</div>
</div>
</div>
</div>
<!-- <div class="login-copyright">
<div class="mb5 login-copyright-company">mayfly</div>
<div class="login-copyright-msg">mayfly</div>
</div> -->
</div>
</template>
<script lang="ts" setup>
import { toRefs, reactive, onMounted, h, ref } from 'vue';
import Account from '@/views/login/component/AccountLogin.vue';
import { storeToRefs } from 'pinia';
<script setup lang="ts" name="loginIndex">
import { ref, defineAsyncComponent, onMounted, reactive } from 'vue';
import { useThemeConfig } from '@/store/themeConfig';
import logoMini from '@/assets/image/logo.svg';
import loginBgImg from '@/assets/image/login-bg-main.svg';
import loginBgSplitImg from '@/assets/image/login-bg-split.svg';
import openApi from '@/common/openApi';
import config from '@/common/config';
const { themeConfig } = storeToRefs(useThemeConfig());
// 引入组件
const Account = defineAsyncComponent(() => import('./component/AccountLogin.vue'));
const loginForm = ref<{ loginResDeal: (data: any) => void } | null>(null);
// 定义变量内容
const storesThemeConfig = useThemeConfig();
const state = reactive({
tabsActiveName: 'account',
isTabPaneShow: true,
isScan: false,
oauth2LoginConfig: {
name: 'OAuth2登录',
enable: false,
},
});
const loginForm = ref<{ loginResDeal: (data: any) => void } | null>(null);
const { isTabPaneShow, tabsActiveName, oauth2LoginConfig: oauth2LoginConfig } = toRefs(state);
// 切换密码、手机登录
const onTabsClick = () => {
state.isTabPaneShow = !state.isTabPaneShow;
};
onMounted(async () => {
storesThemeConfig.setWatermarkUser(true);
state.oauth2LoginConfig = await openApi.oauth2LoginConfig();
});
@@ -94,76 +101,178 @@ const oauth2Login = () => {
<style scoped lang="scss">
.login-container {
width: 100%;
height: 100%;
background: url('@/assets/image/bg-login.png') no-repeat;
background-size: 100% 100%;
.login-logo {
position: absolute;
top: 30px;
left: 50%;
height: 50px;
display: flex;
align-items: center;
font-size: 20px;
color: var(--el-color-primary);
letter-spacing: 2px;
width: 90%;
transform: translateX(-50%);
}
.login-content {
width: 500px;
padding: 20px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) translate3d(0, 0, 0);
background-color: rgba(255, 255, 255, 0.99);
box-shadow: 0 2px 12px 0 var(--el-color-primary-light-5);
border-radius: 4px;
transition: height 0.2s linear;
height: 490px;
overflow: hidden;
z-index: 1;
.login-content-main {
margin: 0 auto;
width: 80%;
.login-content-title {
color: #333;
font-weight: 500;
font-size: 22px;
text-align: center;
letter-spacing: 4px;
margin: 15px 0 30px;
white-space: nowrap;
background: var(--bg-main-color);
.login-left {
flex: 1;
position: relative;
background-color: rgba(211, 239, 255, 1);
margin-right: 100px;
.login-left-logo {
display: flex;
align-items: center;
position: absolute;
top: 50px;
left: 80px;
z-index: 1;
animation: logoAnimation 0.3s ease;
img {
width: 52px;
height: 52px;
}
.login-left-logo-text {
display: flex;
flex-direction: column;
span {
margin-left: 10px;
font-size: 28px;
color: #26a59a;
}
.login-left-logo-text-msg {
font-size: 12px;
color: #32a99e;
}
}
}
}
.login-content-mobile {
height: 418px;
}
.login-copyright {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 30px;
text-align: center;
color: white;
font-size: 12px;
opacity: 0.8;
.login-copyright-company {
white-space: nowrap;
.login-left-img {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 52%;
img {
width: 100%;
height: 100%;
animation: error-num 0.6s ease;
}
}
.login-copyright-msg {
@extend .login-copyright-company;
.login-left-waves {
position: absolute;
top: 0;
right: -100px;
}
}
.login-right {
width: 700px;
.login-right-warp {
border: 1px solid var(--el-color-primary-light-3);
border-radius: 3px;
width: 500px;
height: 500px;
position: relative;
overflow: hidden;
background-color: var(--bg-main-color);
.login-right-warp-one,
.login-right-warp-two {
position: absolute;
display: block;
width: inherit;
height: inherit;
&::before,
&::after {
content: '';
position: absolute;
z-index: 1;
}
}
.login-right-warp-one {
&::before {
filter: hue-rotate(0deg);
top: 0px;
left: 0;
width: 100%;
height: 3px;
background: linear-gradient(90deg, transparent, var(--el-color-primary));
animation: loginLeft 3s linear infinite;
}
&::after {
filter: hue-rotate(60deg);
top: -100%;
right: 2px;
width: 3px;
height: 100%;
background: linear-gradient(180deg, transparent, var(--el-color-primary));
animation: loginTop 3s linear infinite;
animation-delay: 0.7s;
}
}
.login-right-warp-two {
&::before {
filter: hue-rotate(120deg);
bottom: 2px;
right: -100%;
width: 100%;
height: 3px;
background: linear-gradient(270deg, transparent, var(--el-color-primary));
animation: loginRight 3s linear infinite;
animation-delay: 1.4s;
}
&::after {
filter: hue-rotate(300deg);
bottom: -100%;
left: 0px;
width: 3px;
height: 100%;
background: linear-gradient(360deg, transparent, var(--el-color-primary));
animation: loginBottom 3s linear infinite;
animation-delay: 2.1s;
}
}
.login-right-warp-mian {
display: flex;
flex-direction: column;
height: 100%;
.login-right-warp-main-title {
height: 110px;
line-height: 110px;
font-size: 27px;
text-align: center;
letter-spacing: 3px;
animation: logoAnimation 0.3s ease;
animation-delay: 0.3s;
color: var(--el-text-color-primary);
}
.login-right-warp-main-form {
flex: 1;
padding: 0 50px 50px;
.login-content-main-sacn {
position: absolute;
top: 0;
right: 0;
width: 50px;
height: 50px;
overflow: hidden;
cursor: pointer;
transition: all ease 0.3s;
color: var(--el-color-primary);
&-delta {
position: absolute;
width: 35px;
height: 70px;
z-index: 2;
top: 2px;
right: 21px;
background: var(--el-color-white);
transform: rotate(-45deg);
}
&:hover {
opacity: 1;
transition: all ease 0.3s;
color: var(--el-color-primary) !important;
}
i {
width: 47px;
height: 50px;
display: inline-block;
font-size: 48px;
position: absolute;
right: 1px;
top: 0px;
}
}
}
}
}
}
}

View File

@@ -0,0 +1,45 @@
<template>
<div style="display: inline-flex; justify-content: center; align-items: center; cursor: pointer; vertical-align: middle">
<el-popover :show-after="500" @show="getTags" placement="top-start" width="230" trigger="hover">
<template #reference>
<div>
<!-- <el-button type="primary" link size="small">标签</el-button> -->
<SvgIcon name="view" :size="16" color="var(--el-color-primary)" />
</div>
</template>
<el-tag effect="plain" v-for="tag in tags" :key="tag" class="ml5" type="success" size="small">{{ tag.tagPath }}</el-tag>
</el-popover>
</div>
</template>
<script lang="ts" setup>
import { reactive, toRefs } from 'vue';
import { tagApi } from '../tag/api';
import SvgIcon from '@/components/svgIcon/index.vue';
const props = defineProps({
resourceCode: {
type: [String],
required: true,
},
resourceType: {
type: [Number],
required: true,
},
});
const state = reactive({
tags: [] as any,
});
const { tags } = toRefs(state);
const getTags = async () => {
state.tags = await tagApi.getTagResources.request({
resourceCode: props.resourceCode,
resourceType: props.resourceType,
});
};
</script>
<style lang="scss"></style>

View File

@@ -1,13 +1,13 @@
<template>
<div style="display: inline-flex; justify-content: center; align-items: center; cursor: pointer; vertical-align: middle">
<el-popover @show="showTagInfo" placement="top-start" title="标签信息" :width="300" trigger="hover">
<el-popover :show-after="500" @show="showTagInfo" placement="top-start" title="标签信息" :width="300" trigger="hover">
<template #reference>
<el-icon>
<InfoFilled />
</el-icon>
</template>
<span v-for="(v, i) in tags" :key="i">
<el-tooltip effect="customized" :content="v.remark" placement="top">
<el-tooltip :content="v.remark" placement="top">
<span class="color-success">{{ v.name }}</span>
</el-tooltip>
<span v-if="i != state.tags.length - 1" class="color-primary"> / </span>

View File

@@ -8,7 +8,7 @@
ref="treeRef"
:style="{ maxHeight: state.height, height: state.height, overflow: 'auto' }"
:highlight-current="true"
:indent="7"
:indent="10"
:load="loadNode"
:props="treeProps"
lazy
@@ -21,7 +21,7 @@
>
<template #default="{ node, data }">
<span>
<span v-if="data.type == TagTreeNode.TagPath">
<span v-if="data.type.value == TagTreeNode.TagPath">
<tag-info :tag-path="data.label" />
</span>
@@ -30,6 +30,8 @@
<span class="ml3">
<slot name="label" :data="data"> {{ data.label }}</slot>
</span>
<slot :node="node" :data="data" name="suffix"></slot>
</span>
</template>
</el-tree>
@@ -40,19 +42,25 @@
</template>
<script lang="ts" setup>
import { onMounted, reactive, ref, watch, toRefs } from 'vue';
import { TagTreeNode } from './tag';
import { onMounted, reactive, ref, watch, toRefs, onUnmounted } from 'vue';
import { NodeType, TagTreeNode } from './tag';
import TagInfo from './TagInfo.vue';
import Contextmenu from '@/components/contextmenu/index.vue';
import { Contextmenu } from '@/components/contextmenu';
import { useViewport } from '@/common/use';
import { tagApi } from '../tag/api';
const props = defineProps({
height: {
type: [Number, String],
default: 0,
resourceType: {
type: [Number],
required: true,
},
tagPathNodeType: {
type: [NodeType],
required: true,
},
load: {
type: Function,
required: true,
required: false,
},
loadContextmenuItems: {
type: Function,
@@ -70,6 +78,8 @@ const emit = defineEmits(['nodeClick', 'currentContextmenuClick']);
const treeRef: any = ref(null);
const contextmenuRef = ref();
const { vh } = useViewport();
const state = reactive({
height: 600 as any,
filterText: '',
@@ -83,18 +93,18 @@ const state = reactive({
const { filterText } = toRefs(state);
onMounted(async () => {
if (!props.height) {
setHeight();
window.onresize = () => setHeight();
} else {
state.height = props.height;
}
setHeight();
window.addEventListener('resize', setHeight);
});
const setHeight = () => {
state.height = window.innerHeight - 157 + 'px';
state.height = vh.value - 148 + 'px';
};
onUnmounted(() => {
window.removeEventListener('resize', setHeight);
});
watch(filterText, (val) => {
treeRef.value?.filter(val);
});
@@ -104,6 +114,18 @@ const filterNode = (value: string, data: any) => {
return data.label.includes(value);
};
/**
* 加载标签树节点
*/
const loadTags = async () => {
const tags = await tagApi.getResourceTagPaths.request({ resourceType: props.resourceType });
const tagNodes = [];
for (let tagPath of tags) {
tagNodes.push(new TagTreeNode(tagPath, tagPath, props.tagPathNodeType));
}
return tagNodes;
};
/**
* 加载树节点
* @param { Object } node
@@ -115,7 +137,13 @@ const loadNode = async (node: any, resolve: any) => {
}
let nodes = [];
try {
nodes = await props.load(node);
if (node.level == 0) {
nodes = await loadTags();
} else if (props.load) {
nodes = await props.load(node);
} else {
nodes = await node.data.loadChildren();
}
} catch (e: any) {
console.error(e);
}
@@ -124,18 +152,23 @@ const loadNode = async (node: any, resolve: any) => {
const treeNodeClick = (data: any) => {
emit('nodeClick', data);
if (data.type.nodeClickFunc) {
data.type.nodeClickFunc(data);
}
// 关闭可能存在的右击菜单
contextmenuRef.value.closeContextmenu();
};
// 树节点右击事件
const nodeContextmenu = (event: any, data: any) => {
if (!props.loadContextmenuItems) {
return;
}
// 加载当前节点是否需要显示右击菜单
const items = props.loadContextmenuItems(data);
let items = data.type.contextMenuItems;
if (!items || items.length == 0) {
if (props.loadContextmenuItems) {
items = props.loadContextmenuItems(data);
}
}
if (!items) {
return;
}
state.contextmenuItems = items;
@@ -173,6 +206,7 @@ defineExpose({
overflow: 'auto';
position: relative;
border-radius: var(--el-input-border-radius, var(--el-border-radius-base));
border: 1px solid var(--el-border-color-light, #ebeef5);
.el-tree {

View File

@@ -2,14 +2,14 @@
<div>
<el-tree-select
v-bind="$attrs"
@check="changeTag"
v-model="selectTags"
@change="changeTag"
style="width: 100%"
:data="tags"
placeholder="请选择关联标签"
:render-after-expand="true"
:default-expanded-keys="[selectTags]"
show-checkbox
check-strictly
node-key="id"
:props="{
value: 'id',
@@ -33,35 +33,46 @@
</template>
<script lang="ts" setup>
import { useAttrs, toRefs, reactive, onMounted } from 'vue';
import { toRefs, reactive, onMounted } from 'vue';
import { tagApi } from '../tag/api';
const attrs = useAttrs();
//
const emit = defineEmits(['changeTag', 'update:tagPath']);
const emit = defineEmits(['update:modelValue', 'changeTag', 'input']);
const props = defineProps({
resourceCode: {
type: [String],
required: true,
},
resourceType: {
type: [Number],
required: true,
},
});
const state = reactive({
tags: [],
// idid
selectTags: null as any,
selectTags: [],
});
const { tags, selectTags } = toRefs(state);
onMounted(async () => {
if (attrs.modelValue) {
state.selectTags = attrs.modelValue;
if (props.resourceCode) {
const resourceTags = await tagApi.getTagResources.request({
resourceCode: props.resourceCode,
resourceType: props.resourceType,
});
state.selectTags = resourceTags.map((x: any) => x.tagId);
changeTag();
}
state.tags = await tagApi.getTagTrees.request(null);
});
const changeTag = (tag: any, checkInfo: any) => {
if (checkInfo.checkedNodes.length > 0) {
emit('update:tagPath', tag.codePath);
emit('changeTag', tag);
} else {
emit('update:tagPath', null);
}
const changeTag = () => {
emit('changeTag', state.selectTags);
};
</script>
<style lang="scss"></style>

View File

@@ -1,3 +1,5 @@
import { ContextmenuItem } from '@/components/contextmenu';
export class TagTreeNode {
/**
* 节点id
@@ -12,18 +14,26 @@ export class TagTreeNode {
/**
* 树节点类型
*/
type: any;
type: NodeType;
/**
* 是否为叶子节点
*/
isLeaf: boolean = false;
/**
* 额外需要传递的参数
*/
params: any;
icon: any;
static TagPath = -1;
constructor(key: any, label: string, type?: any) {
constructor(key: any, label: string, type?: NodeType) {
this.key = key;
this.label = label;
this.type = type || TagTreeNode.TagPath;
this.type = type || new NodeType(TagTreeNode.TagPath);
}
withIsLeaf(isLeaf: boolean) {
@@ -35,4 +45,73 @@ export class TagTreeNode {
this.params = params;
return this;
}
withIcon(icon: any) {
this.icon = icon;
return this;
}
/**
* 加载子节点使用节点类型的loadNodesFunc去加载子节点
* @returns 子节点信息
*/
async loadChildren() {
if (this.isLeaf) {
return [];
}
if (this.type && this.type.loadNodesFunc) {
return await this.type.loadNodesFunc(this);
}
return [];
}
}
/**
* 节点类型,用于加载子节点及点击事件等
*/
export class NodeType {
/**
* 节点类型值
*/
value: number;
contextMenuItems: ContextmenuItem[];
loadNodesFunc: (parentNode: TagTreeNode) => Promise<TagTreeNode[]>;
nodeClickFunc: (node: TagTreeNode) => void;
constructor(value: number) {
this.value = value;
}
/**
* 赋值加载子节点回调函数
* @param func 加载子节点回调函数
* @returns this
*/
withLoadNodesFunc(func: (parentNode: TagTreeNode) => Promise<TagTreeNode[]>) {
this.loadNodesFunc = func;
return this;
}
/**
* 赋值节点点击事件回调函数
* @param func 节点点击事件回调函数
* @returns this
*/
withNodeClickFunc(func: (node: TagTreeNode) => void) {
this.nodeClickFunc = func;
return this;
}
/**
* 赋值右击菜单按钮选项
* @param contextMenuItems 右击菜单按钮选项
* @returns this
*/
withContextMenuItems(contextMenuItems: ContextmenuItem[]) {
this.contextMenuItems = contextMenuItems;
return this;
}
}

View File

@@ -10,16 +10,27 @@
width="38%"
>
<el-form :model="form" ref="dbForm" :rules="rules" label-width="auto">
<el-form-item prop="tagId" label="标签:" required>
<tag-select v-model="form.tagId" v-model:tag-path="form.tagPath" style="width: 100%" />
<el-form-item ref="tagSelectRef" prop="tagId" label="标签" required>
<tag-tree-select
@change-tag="
(tagIds) => {
form.tagId = tagIds;
tagSelectRef.validate();
}
"
multiple
:resource-code="form.code"
:resource-type="TagResourceTypeEnum.Db.value"
style="width: 100%"
/>
</el-form-item>
<el-form-item prop="instanceId" label="数据库实例:" required>
<el-form-item prop="instanceId" label="数据库实例" required>
<el-select
:disabled="form.id !== undefined"
remote
:remote-method="getInstances"
@change="getAllDatabase"
@change="changeInstance"
v-model="form.instanceId"
placeholder="请输入实例名称搜索并选择实例"
filterable
@@ -37,11 +48,11 @@
</el-select>
</el-form-item>
<el-form-item prop="name" label="别名:" required>
<el-form-item prop="name" label="别名" required>
<el-input v-model.trim="form.name" placeholder="请输入数据库别名" auto-complete="off"></el-input>
</el-form-item>
<el-form-item prop="database" label="数据库名:" required>
<el-form-item prop="database" label="数据库名" required>
<el-select
@change="changeDatabase"
v-model="databaseList"
@@ -58,7 +69,7 @@
</el-select>
</el-form-item>
<el-form-item prop="remark" label="备注:">
<el-form-item prop="remark" label="备注">
<el-input v-model.trim="form.remark" auto-complete="off" type="textarea"></el-input>
</el-form-item>
</el-form>
@@ -77,7 +88,8 @@
import { toRefs, reactive, watch, ref } from 'vue';
import { dbApi } from './api';
import { ElMessage } from 'element-plus';
import TagSelect from '../component/TagSelect.vue';
import TagTreeSelect from '../component/TagTreeSelect.vue';
import { TagResourceTypeEnum } from '@/common/commonEnum';
const props = defineProps({
visible: {
@@ -128,6 +140,7 @@ const rules = {
};
const dbForm: any = ref(null);
const tagSelectRef: any = ref(null);
const state = reactive({
dialogVisible: false,
@@ -135,9 +148,9 @@ const state = reactive({
databaseList: [] as any,
form: {
id: null,
tagId: null as any,
tagPath: null as any,
tagId: [],
name: null,
code: '',
database: '',
remark: '',
instanceId: null as any,
@@ -148,13 +161,14 @@ const state = reactive({
const { dialogVisible, allDatabases, databaseList, form, btnLoading } = toRefs(state);
watch(props, (newValue: any) => {
watch(props, async (newValue: any) => {
state.dialogVisible = newValue.visible;
if (!state.dialogVisible) {
return;
}
if (newValue.db) {
state.form = { ...newValue.db };
// 将数据库名使用空格切割,获取所有数据库列表
state.databaseList = newValue.db.database.split(' ');
} else {
@@ -163,6 +177,11 @@ watch(props, (newValue: any) => {
}
});
const changeInstance = () => {
state.databaseList = [];
getAllDatabase();
};
/**
* 改变表单中的数据库字段,方便表单错误提示。如全部删光,可提示请添加数据库
*/

View File

@@ -46,54 +46,43 @@
</template>
<template #tagPath="{ data }">
<tag-info :tag-path="data.tagPath" />
<span class="ml5">
{{ data.tagPath }}
</span>
<resource-tag :resource-code="data.code" :resource-type="TagResourceTypeEnum.Db.value" />
</template>
<template #database="{ data }">
<el-popover placement="right" trigger="click" :width="300">
<template #reference>
<el-link type="primary" :underline="false" plain @click="selectDb(data.dbs)">查看 </el-link>
</template>
<el-input v-model="filterDb.param" @keyup="filterSchema" class="w-50 m-2" placeholder="搜索" size="small">
<template #prefix>
<el-icon class="el-input__icon">
<search-icon />
</el-icon>
</template>
</el-input>
<div
class="el-tag--plain el-tag--success"
v-for="db in filterDb.list"
:key="db"
style="border: 1px var(--color-success-light-3) solid; margin-top: 3px; border-radius: 5px; padding: 2px; position: relative"
>
<el-link type="success" plain size="small" :underline="false">{{ db }}</el-link>
<el-link type="primary" plain size="small" :underline="false" @click="showTableInfo(data, db)" style="position: absolute; right: 4px"
>操作
</el-link>
</div>
</el-popover>
</template>
<template #more="{ data }">
<el-button @click="showInfo(data)" link>详情</el-button>
<el-button class="ml5" type="primary" @click="onShowSqlExec(data)" link>SQL执行记录</el-button>
<template #host="{ data }">
{{ `${data.host}:${data.port}` }}
</template>
<template #action="{ data }">
<el-button v-if="actionBtns[perms.saveDb]" @click="editDb(data)" type="primary" link>编辑</el-button>
<el-button v-if="data.type == 'mysql'" class="ml5" type="primary" @click="onDumpDbs(data)" link>导出</el-button>
<span v-if="actionBtns[perms.saveDb]">
<el-button type="primary" @click="editDb(data)" link>编辑</el-button>
<el-divider direction="vertical" border-style="dashed" />
</span>
<el-button type="primary" @click="onShowSqlExec(data)" link>SQL记录</el-button>
<el-divider direction="vertical" border-style="dashed" />
<el-dropdown @command="handleMoreActionCommand">
<span class="el-dropdown-link-more">
更多
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :command="{ type: 'detail', data }"> 详情 </el-dropdown-item>
<!-- <el-dropdown-item :command="{ type: 'edit', data }" v-if="actionBtns[perms.saveDb]"> 编辑 </el-dropdown-item> -->
<el-dropdown-item :command="{ type: 'dumpDb', data }" v-if="data.type == DbType.mysql"> 导出 </el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</page-table>
<el-dialog width="80%" :title="`${db} 表信息`" :before-close="closeTableInfo" v-model="tableInfoDialog.visible">
<db-table-list :db-id="dbId" :db="db" :db-type="state.row.type" />
</el-dialog>
<el-dialog width="620" :title="`${db} 数据库导出`" v-model="exportDialog.visible">
<el-dialog width="720px" :title="`${db} 数据库导出`" v-model="exportDialog.visible">
<el-row justify="space-between">
<el-col :span="9">
<el-form-item label="导出内容: ">
@@ -172,18 +161,20 @@ import { ref, toRefs, reactive, onMounted, defineAsyncComponent } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { dbApi } from './api';
import config from '@/common/config';
import { getSession } from '@/common/utils/storage';
import { joinClientParams } from '@/common/request';
import { isTrue } from '@/common/assert';
import { Search as SearchIcon } from '@element-plus/icons-vue';
import { dateFormat } from '@/common/utils/date';
import TagInfo from '../component/TagInfo.vue';
import ResourceTag from '../component/ResourceTag.vue';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn, TableQuery } from '@/components/pagetable';
import { hasPerms } from '@/components/auth/auth';
import DbSqlExecLog from './DbSqlExecLog.vue';
import { DbType } from './dialect';
import { tagApi } from '../tag/api';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { useRoute } from 'vue-router';
const DbEdit = defineAsyncComponent(() => import('./DbEdit.vue'));
const DbTableList = defineAsyncComponent(() => import('./table/DbTableList.vue'));
const perms = {
base: 'db',
@@ -194,17 +185,20 @@ const perms = {
const queryConfig = [TableQuery.slot('tagPath', '标签', 'tagPathSelect'), TableQuery.slot('instanceId', '实例', 'instanceSelect')];
const columns = ref([
TableColumn.new('tagPath', '标签路径').isSlot().setAddWidth(20),
TableColumn.new('instanceName', '实例名'),
TableColumn.new('type', '类型'),
TableColumn.new('host', 'ip:port').isSlot().setAddWidth(40),
TableColumn.new('username', 'username'),
TableColumn.new('name', '名称'),
TableColumn.new('database', '数据库').isSlot().setMinWidth(70),
TableColumn.new('tagPath', '关联标签').isSlot().setAddWidth(10).alignCenter(),
TableColumn.new('remark', '备注'),
TableColumn.new('more', '更多').isSlot().setMinWidth(180).fixedRight(),
]);
// 该用户拥有的的操作列按钮权限
const actionBtns = hasPerms([perms.base, perms.saveDb]);
const actionColumn = TableColumn.new('action', '操作').isSlot().setMinWidth(150).fixedRight().alignCenter();
const actionColumn = TableColumn.new('action', '操作').isSlot().setMinWidth(220).fixedRight().alignCenter();
const route = useRoute();
const pageTableRef: any = ref(null);
const state = reactive({
@@ -221,10 +215,10 @@ const state = reactive({
* 查询条件
*/
query: {
tagPath: null,
tagPath: '',
instanceId: null,
pageNum: 1,
pageSize: 10,
pageSize: 0,
},
datas: [],
total: 0,
@@ -236,13 +230,6 @@ const state = reactive({
instanceId: 0,
},
},
showDumpInfo: false,
dumpInfo: {
id: 0,
db: '',
type: 3,
tables: [],
},
// sql执行记录弹框
sqlExecLogDialog: {
title: '',
@@ -250,10 +237,6 @@ const state = reactive({
dbs: [],
dbId: 0,
},
chooseTableName: '',
tableInfoDialog: {
visible: false,
},
exportDialog: {
visible: false,
dbId: 0,
@@ -275,8 +258,7 @@ const state = reactive({
},
});
const { dbId, db, tags, selectionData, query, datas, total, infoDialog, sqlExecLogDialog, tableInfoDialog, exportDialog, dbEditDialog, filterDb } =
toRefs(state);
const { db, tags, selectionData, query, datas, total, infoDialog, sqlExecLogDialog, exportDialog, dbEditDialog } = toRefs(state);
onMounted(async () => {
if (Object.keys(actionBtns).length > 0) {
@@ -288,6 +270,10 @@ onMounted(async () => {
const search = async () => {
try {
pageTableRef.value.loading(true);
if (route.query.tagPath) {
state.query.tagPath = route.query.tagPath as string;
}
let res: any = await dbApi.dbs.request(state.query);
// 切割数据库
res.list?.forEach((e: any) => {
@@ -316,7 +302,7 @@ const onBeforeCloseInfoDialog = () => {
};
const getTags = async () => {
state.tags = await dbApi.dbTags.request(null);
state.tags = await tagApi.getResourceTagPaths.request({ resourceType: TagResourceTypeEnum.Db.value });
};
const getInstances = async (instanceName = '') => {
@@ -330,6 +316,24 @@ const getInstances = async (instanceName = '') => {
}
};
const handleMoreActionCommand = (commond: any) => {
const data = commond.data;
const type = commond.type;
switch (type) {
case 'detail': {
showInfo(data);
return;
}
case 'edit': {
editDb(data);
return;
}
case 'dumpDb': {
onDumpDbs(data);
}
}
};
const editDb = async (data: any) => {
if (!data) {
state.dbEditDialog.data = null;
@@ -355,7 +359,9 @@ const deleteDb = async () => {
await dbApi.deleteDb.request({ id: state.selectionData.map((x: any) => x.id).join(',') });
ElMessage.success('删除成功');
search();
} catch (err) {}
} catch (err) {
//
}
};
const onShowSqlExec = async (row: any) => {
@@ -406,40 +412,23 @@ const dumpDbs = () => {
'href',
`${config.baseApiUrl}/dbs/${state.exportDialog.dbId}/dump?db=${state.exportDialog.value.join(',')}&type=${type}&extName=${
state.exportDialog.extName
}&token=${getSession('token')}`
}&${joinClientParams()}`
);
a.click();
state.exportDialog.visible = false;
};
const showTableInfo = async (row: any, db: string) => {
state.dbId = row.id;
state.row = row;
state.db = db;
state.tableInfoDialog.visible = true;
};
const closeTableInfo = () => {
state.showDumpInfo = false;
state.tableInfoDialog.visible = false;
};
// 点击查看时初始化数据
const selectDb = (row: any) => {
state.filterDb.param = '';
state.filterDb.cache = row;
state.filterDb.list = row;
};
// 输入字符过滤schema
const filterSchema = () => {
if (state.filterDb.param) {
state.filterDb.list = state.filterDb.cache.filter((a) => {
return String(a).toLowerCase().indexOf(state.filterDb.param) > -1;
});
} else {
state.filterDb.list = state.filterDb.cache;
}
};
</script>
<style lang="scss"></style>
<style lang="scss">
.db-list {
.el-transfer-panel {
width: 250px;
}
}
.el-dropdown-link-more {
cursor: pointer;
color: var(--el-color-primary);
display: flex;
align-items: center;
margin-top: 6px;
}
</style>

View File

@@ -39,7 +39,7 @@
</template>
<script lang="ts" setup>
import { ref, toRefs,watch, reactive, computed, onMounted, defineAsyncComponent } from 'vue';
import { toRefs, watch, reactive, onMounted } from 'vue';
import { dbApi } from './api';
import { DbSqlExecTypeEnum } from './enums';
import PageTable from '@/components/pagetable/PageTable.vue';
@@ -103,13 +103,12 @@ onMounted(async () => {
searchSqlExecLog();
});
watch(props, async (newValue: any) => {
watch(props, async () => {
await searchSqlExecLog();
});
const searchSqlExecLog = async () => {
state.query.dbId = props.dbId
state.query.dbId = props.dbId;
const res = await dbApi.getSqlExecs.request(state.query);
state.data = res.list;
state.total = res.total;

View File

@@ -4,16 +4,16 @@
<el-form :model="form" ref="dbForm" :rules="rules" label-width="auto">
<el-tabs v-model="tabActiveName">
<el-tab-pane label="基础信息" name="basic">
<el-form-item prop="name" label="别名:" required>
<el-form-item prop="name" label="别名" required>
<el-input v-model.trim="form.name" placeholder="请输入数据库别名" auto-complete="off"></el-input>
</el-form-item>
<el-form-item prop="type" label="类型:" required>
<el-form-item prop="type" label="类型" required>
<el-select style="width: 100%" v-model="form.type" placeholder="请选择数据库类型">
<el-option key="item.id" label="mysql" value="mysql"> </el-option>
<el-option key="item.id" label="postgres" value="postgres"> </el-option>
</el-select>
</el-form-item>
<el-form-item prop="host" label="host:" required>
<el-form-item prop="host" label="host" required>
<el-col :span="18">
<el-input :disabled="form.id !== undefined" v-model.trim="form.host" placeholder="请输入主机ip" auto-complete="off"></el-input>
</el-col>
@@ -22,10 +22,10 @@
<el-input type="number" v-model.number="form.port" placeholder="请输入端口"></el-input>
</el-col>
</el-form-item>
<el-form-item prop="username" label="用户名:" required>
<el-form-item prop="username" label="用户名" required>
<el-input v-model.trim="form.username" placeholder="请输入用户名"></el-input>
</el-form-item>
<el-form-item prop="password" label="密码:">
<el-form-item prop="password" label="密码">
<el-input type="password" show-password v-model.trim="form.password" placeholder="请输入密码" autocomplete="new-password">
<template v-if="form.id && form.id != 0" #suffix>
<el-popover @hide="pwd = ''" placement="right" title="原密码" :width="200" trigger="click" :content="pwd">
@@ -39,13 +39,13 @@
</el-input>
</el-form-item>
<el-form-item prop="remark" label="备注:">
<el-input v-model.trim="form.remark" auto-complete="off" type="textarea"></el-input>
<el-form-item prop="remark" label="备注">
<el-input v-model="form.remark" auto-complete="off" type="textarea"></el-input>
</el-form-item>
</el-tab-pane>
<el-tab-pane label="其他配置" name="other">
<el-form-item prop="params" label="连接参数:">
<el-form-item prop="params" label="连接参数">
<el-input v-model.trim="form.params" placeholder="其他连接参数,形如: key1=value1&key2=value2">
<template #suffix>
<el-link
@@ -60,7 +60,7 @@
</el-input>
</el-form-item>
<el-form-item prop="sshTunnelMachineId" label="SSH隧道:">
<el-form-item prop="sshTunnelMachineId" label="SSH隧道">
<ssh-tunnel-select v-model="form.sshTunnelMachineId" />
</el-form-item>
</el-tab-pane>
@@ -69,6 +69,7 @@
<template #footer>
<div class="dialog-footer">
<el-button @click="testConn" :loading="state.testConnBtnLoading" type="success">测试连接</el-button>
<el-button @click="cancel()"> </el-button>
<el-button type="primary" :loading="btnLoading" @click="btnOk"> </el-button>
</div>
@@ -153,6 +154,7 @@ const state = reactive({
// 原用户名
oldUserName: null,
btnLoading: false,
testConnBtnLoading: false,
});
const { dialogVisible, tabActiveName, form, pwd, btnLoading } = toRefs(state);
@@ -176,6 +178,32 @@ const getDbPwd = async () => {
state.pwd = await dbApi.getInstancePwd.request({ id: state.form.id });
};
const getReqForm = async () => {
const reqForm = { ...state.form };
reqForm.password = await RsaEncrypt(reqForm.password);
if (!state.form.sshTunnelMachineId) {
reqForm.sshTunnelMachineId = -1;
}
return reqForm;
};
const testConn = async () => {
dbForm.value.validate(async (valid: boolean) => {
if (valid) {
state.testConnBtnLoading = true;
try {
await dbApi.testConn.request(await getReqForm());
ElMessage.success('连接成功');
} finally {
state.testConnBtnLoading = false;
}
} else {
ElMessage.error('请正确填写信息');
return false;
}
});
};
const btnOk = async () => {
if (!state.form.id) {
notBlank(state.form.password, '新增操作,密码不可为空');
@@ -185,12 +213,7 @@ const btnOk = async () => {
dbForm.value.validate(async (valid: boolean) => {
if (valid) {
const reqForm = { ...state.form };
reqForm.password = await RsaEncrypt(reqForm.password);
if (!state.form.sshTunnelMachineId) {
reqForm.sshTunnelMachineId = -1;
}
dbApi.saveInstance.request(reqForm).then(() => {
dbApi.saveInstance.request(await getReqForm()).then(() => {
ElMessage.success('保存成功');
emit('val-change', state.form);
state.btnLoading = true;

View File

@@ -20,11 +20,8 @@
>
</template>
<template #more="{ data }">
<el-button @click="showInfo(data)" link>详情</el-button>
</template>
<template #action="{ data }">
<el-button @click="showInfo(data)" link>详情</el-button>
<el-button v-if="actionBtns[perms.saveInstance]" @click="editInstance(data)" type="primary" link>编辑</el-button>
</template>
</page-table>
@@ -81,16 +78,16 @@ const queryConfig = [TableQuery.text('name', '名称')];
const columns = ref([
TableColumn.new('name', '名称'),
TableColumn.new('host', 'host:port').setFormatFunc((data: any, _prop: string) => `${data.host}:${data.port}`),
TableColumn.new('type', '类型'),
TableColumn.new('host', 'host:port').setFormatFunc((data: any) => `${data.host}:${data.port}`),
TableColumn.new('username', '用户名'),
TableColumn.new('params', '连接参数'),
TableColumn.new('remark', '备注'),
TableColumn.new('more', '更多').isSlot().setMinWidth(50).fixedRight(),
]);
// 该用户拥有的的操作列按钮权限
const actionBtns = hasPerms([perms.saveInstance]);
const actionColumn = TableColumn.new('action', '操作').isSlot().setMinWidth(65).fixedRight().alignCenter();
const actionColumn = TableColumn.new('action', '操作').isSlot().setMinWidth(110).fixedRight().alignCenter();
const pageTableRef: any = ref(null);
@@ -108,7 +105,7 @@ const state = reactive({
query: {
name: null,
pageNum: 1,
pageSize: 10,
pageSize: 0,
},
datas: [],
total: 0,
@@ -173,7 +170,9 @@ const deleteInstance = async () => {
await dbApi.deleteInstance.request({ id: state.selectionData.map((x: any) => x.id).join(',') });
ElMessage.success('删除成功');
search();
} catch (err) {}
} catch (err) {
//
}
};
</script>
<style lang="scss"></style>

View File

@@ -1,124 +1,369 @@
<template>
<div>
<el-row class="mb5">
<el-col :span="4">
<el-button type="primary" icon="plus" @click="addQueryTab({ id: nowDbInst.id, dbs: nowDbInst.databases.split(' ') }, state.db)" size="small"
>新建查询</el-button
>
</el-col>
<el-col :span="20" v-if="state.db">
<el-descriptions :column="4" size="small" border style="height: 10px" class="ml5">
<el-descriptions-item label-align="right" label="tag">{{ nowDbInst.tagPath }}</el-descriptions-item>
<el-descriptions-item label="实例" label-align="right">
{{ nowDbInst.id }}
<el-divider direction="vertical" border-style="dashed" />
{{ nowDbInst.type }}
<el-divider direction="vertical" border-style="dashed" />
{{ nowDbInst.name }}
</el-descriptions-item>
<el-descriptions-item label="库名" label-align="right">{{ state.db }}</el-descriptions-item>
</el-descriptions>
</el-col>
</el-row>
<el-row type="flex">
<el-col :span="4">
<tag-tree
ref="tagTreeRef"
@node-click="nodeClick"
:load="loadNode"
:load-contextmenu-items="getContextmenuItems"
@current-contextmenu-click="onCurrentContextmenuClick"
:height="state.tagTreeHeight"
>
<div class="db-sql-exec">
<el-row>
<el-col :span="5">
<tag-tree :resource-type="TagResourceTypeEnum.Db.value" :tag-path-node-type="NodeTypeTagPath" ref="tagTreeRef">
<template #prefix="{ data }">
<span v-if="data.type == NodeType.DbInst">
<el-popover placement="right-start" title="数据库实例信息" trigger="hover" :width="210">
<span v-if="data.type.value == SqlExecNodeType.DbInst">
<el-popover :show-after="500" placement="right-start" title="数据库实例信息" trigger="hover" :width="250">
<template #reference>
<SvgIcon v-if="data.params.type === 'mysql'" name="iconfont icon-op-mysql" :size="18" />
<SvgIcon v-if="data.params.type === 'postgres'" name="iconfont icon-op-postgres" :size="18" />
<SvgIcon name="InfoFilled" v-else />
<SvgIcon :name="getDbDialect(data.params.type).getIcon()" :size="18" />
</template>
<template #default>
<el-form class="instances-pop-form" label-width="55px" :size="'small'">
<el-form-item label="类型:">{{ data.params.type }}</el-form-item>
<el-form-item label="名称:">{{ data.params.name }}</el-form-item>
<el-form-item v-if="data.params.remark" label="备注:">{{ data.params.remark }}</el-form-item>
</el-form>
<el-descriptions :column="1" size="small">
<el-descriptions-item label="名称">
{{ data.params.name }}
</el-descriptions-item>
<el-descriptions-item label="host">
{{ `${data.params.host}:${data.params.port}` }}
</el-descriptions-item>
<el-descriptions-item label="user">
{{ data.params.username }}
</el-descriptions-item>
<el-descriptions-item label="备注">
{{ data.params.remark }}
</el-descriptions-item>
</el-descriptions>
</template>
</el-popover>
</span>
<SvgIcon v-if="data.type == NodeType.Db" name="Coin" color="#67c23a" />
<SvgIcon v-if="data.icon" :name="data.icon.name" :color="data.icon.color" />
</template>
<SvgIcon name="Calendar" v-if="data.type == NodeType.TableMenu" color="#409eff" />
<el-tooltip v-if="data.type == NodeType.Table" effect="customized" :content="data.params.tableComment" placement="top-end">
<SvgIcon name="Calendar" color="#409eff" />
<template #label="{ data }">
<el-tooltip placement="left" :show-after="1000" v-if="data.type.value == SqlExecNodeType.Table" :content="data.params.tableComment">
{{ data.label }}
</el-tooltip>
</template>
<SvgIcon name="Files" v-if="data.type == NodeType.SqlMenu || data.type == NodeType.Sql" color="#f56c6c" />
<template #suffix="{ data }">
<span class="db-table-size" v-if="data.type.value == SqlExecNodeType.Table && data.params.size">{{ ` ${data.params.size}` }}</span>
<span class="db-table-size" v-if="data.type.value == SqlExecNodeType.TableMenu && data.params.dbTableSize">{{
` ${data.params.dbTableSize}`
}}</span>
</template>
</tag-tree>
</el-col>
<el-col :span="20">
<el-container id="data-exec" class="mt5 ml5">
<el-tabs @tab-remove="onRemoveTab" @tab-change="onTabChange" style="width: 100%" v-model="state.activeName">
<el-tab-pane closable v-for="dt in state.tabs.values()" :key="dt.key" :label="dt.key" :name="dt.key">
<table-data
v-if="dt.type === TabType.TableData"
@gen-insert-sql="onGenerateInsertSql"
:data="dt"
:table-height="state.dataTabsTableHeight"
></table-data>
<query
v-else
<el-col :span="19">
<el-row>
<el-col :span="24" v-if="state.db">
<el-descriptions :column="4" size="small" border class="ml5">
<el-descriptions-item label-align="right" label="操作"
><el-button
:disabled="!state.db || !nowDbInst.id"
type="primary"
icon="Search"
@click="addQueryTab({ id: nowDbInst.id, dbs: nowDbInst.databases?.split(' ') }, state.db)"
size="small"
>新建查询</el-button
></el-descriptions-item
>
<el-descriptions-item label-align="right" label="tag">{{ nowDbInst.tagPath }}</el-descriptions-item>
<el-descriptions-item label-align="right">
<template #label>
<div>
<SvgIcon :name="getDbDialect(nowDbInst.type).getIcon()" :size="18" />
实例
</div>
</template>
{{ nowDbInst.id }}
<el-divider direction="vertical" border-style="dashed" />
{{ nowDbInst.name }}
<el-divider direction="vertical" border-style="dashed" />
{{ nowDbInst.host }}
</el-descriptions-item>
<el-descriptions-item label="库名" label-align="right">{{ state.db }}</el-descriptions-item>
</el-descriptions>
</el-col>
</el-row>
<div id="data-exec" class="mt5 ml5">
<el-tabs
v-if="state.tabs.size > 0"
type="card"
@tab-remove="onRemoveTab"
@tab-change="onTabChange"
style="width: 100%"
v-model="state.activeName"
>
<el-tab-pane closable v-for="dt in state.tabs.values()" :label="dt.label" :name="dt.key" :key="dt.key">
<template #label>
<el-popover :show-after="1000" placement="bottom-start" trigger="hover" :width="250">
<template #reference> {{ dt.label }} </template>
<template #default>
<el-descriptions :column="1" size="small">
<el-descriptions-item label="tagPath">
{{ dt.params.tagPath }}
</el-descriptions-item>
<el-descriptions-item label="名称">
{{ dt.params.name }}
</el-descriptions-item>
<el-descriptions-item label="host">
<SvgIcon :name="getDbDialect(dt.params.type).getIcon()" :size="18" />
{{ dt.params.host }}
</el-descriptions-item>
<el-descriptions-item label="库名">
{{ dt.params.dbName }}
</el-descriptions-item>
</el-descriptions>
</template>
</el-popover>
</template>
<db-table-data-op
v-if="dt.type === TabType.TableData"
:db-id="dt.dbId"
:db-name="dt.db"
:table-name="dt.params.table"
:table-height="state.dataTabsTableHeight"
></db-table-data-op>
<db-sql-editor
v-if="dt.type === TabType.Query"
:db-id="dt.dbId"
:db-name="dt.db"
:sql-name="dt.params.sqlName"
@save-sql-success="reloadSqls"
@delete-sql-success="deleteSqlScript(dt)"
:data="dt"
:editor-height="state.editorHeight"
>
</query>
</db-sql-editor>
<db-tables-op
v-if="dt.type == TabType.TablesOp"
:db-id="dt.params.id"
:db="dt.params.db"
:db-type="dt.params.type"
:height="state.tablesOpHeight"
/>
</el-tab-pane>
</el-tabs>
</el-container>
</div>
</el-col>
</el-row>
<el-dialog @close="state.genSqlDialog.visible = false" v-model="state.genSqlDialog.visible" title="SQL" width="1000px">
<el-input v-model="state.genSqlDialog.sql" type="textarea" rows="20" />
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, onMounted, reactive, ref, toRefs } from 'vue';
import { ElMessage } from 'element-plus';
import { DbInst, TabInfo, TabType } from './db';
import { TagTreeNode } from '../component/tag';
import { defineAsyncComponent, onMounted, reactive, ref, toRefs, onBeforeUnmount } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { formatByteSize } from '@/common/utils/format';
import { DbInst, TabInfo, TabType, registerDbCompletionItemProvider } from './db';
import { TagTreeNode, NodeType } from '../component/tag';
import TagTree from '../component/TagTree.vue';
import { dbApi } from './api';
import { dispposeCompletionItemProvider } from '@/components/monaco/completionItemProvider';
import SvgIcon from '@/components/svgIcon/index.vue';
import { ContextmenuItem } from '@/components/contextmenu';
import { getDbDialect } from './dialect/index';
import { sleep } from '@/common/utils/loading';
import { TagResourceTypeEnum } from '@/common/commonEnum';
const DbSqlEditor = defineAsyncComponent(() => import('./component/sqleditor/DbSqlEditor.vue'));
const DbTableDataOp = defineAsyncComponent(() => import('./component/table/DbTableDataOp.vue'));
const DbTablesOp = defineAsyncComponent(() => import('./component/table/DbTablesOp.vue'));
const Query = defineAsyncComponent(() => import('./component/tab/Query.vue'));
const TableData = defineAsyncComponent(() => import('./component/tab/TableData.vue'));
/**
* 树节点类型
*/
class NodeType {
class SqlExecNodeType {
static DbInst = 1;
static Db = 2;
static TableMenu = 3;
static SqlMenu = 4;
static Table = 5;
static Sql = 6;
static PgSchemaMenu = 7;
static PgSchema = 8;
}
class ContextmenuClickId {
static ReloadTable = 0;
}
const DbIcon = {
name: 'Coin',
color: '#67c23a',
};
// pgsql schema icon
const SchemaIcon = {
name: 'List',
color: '#67c23a',
};
const TableIcon = {
name: 'Calendar',
color: '#409eff',
};
const SqlIcon = {
name: 'Files',
color: '#f56c6c',
};
// node节点点击时触发改变db事件
const nodeClickChangeDb = (nodeData: TagTreeNode) => {
const params = nodeData.params;
if (params.db) {
changeDb(
{ id: params.id, host: `${params.host}`, name: params.name, type: params.type, tagPath: params.tagPath, databases: params.database },
params.db
);
}
};
// tagpath 节点类型
const NodeTypeTagPath = new NodeType(TagTreeNode.TagPath).withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const dbInfoRes = await dbApi.dbs.request({ tagPath: parentNode.key });
const dbInfos = dbInfoRes.list;
if (!dbInfos) {
return [];
}
// 防止过快加载会出现一闪而过,对眼睛不好
await sleep(100);
return dbInfos?.map((x: any) => {
x.tagPath = parentNode.key;
return new TagTreeNode(`${parentNode.key}.${x.id}`, x.name, NodeTypeDbInst).withParams(x);
});
});
// 数据库实例节点类型
const NodeTypeDbInst = new NodeType(SqlExecNodeType.DbInst).withLoadNodesFunc((parentNode: TagTreeNode) => {
const params = parentNode.params;
const dbs = params.database.split(' ')?.sort();
return dbs.map((x: any) => {
return new TagTreeNode(`${parentNode.key}.${x}`, x, NodeTypeDb)
.withParams({
tagPath: params.tagPath,
id: params.id,
name: params.name,
type: params.type,
host: `${params.host}:${params.port}`,
dbs: dbs,
db: x,
})
.withIcon(DbIcon);
});
});
// 数据库节点
const NodeTypeDb = new NodeType(SqlExecNodeType.Db)
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
if (params.type == 'postgres') {
return [new TagTreeNode(`${params.id}.${params.db}.schema-menu`, 'schema', NodeTypePostgresScheamMenu).withParams(params).withIcon(SchemaIcon)];
}
return [
new TagTreeNode(`${params.id}.${params.db}.table-menu`, '表', NodeTypeTableMenu).withParams(params).withIcon(TableIcon),
new TagTreeNode(getSqlMenuNodeKey(params.id, params.db), 'SQL', NodeTypeSqlMenu).withParams(params).withIcon(SqlIcon),
];
})
.withNodeClickFunc(nodeClickChangeDb);
// postgres schema模式菜单
const NodeTypePostgresScheamMenu = new NodeType(SqlExecNodeType.PgSchemaMenu)
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
const { id, db } = params;
const schemaNames = await dbApi.pgSchemas.request({ id, db });
return schemaNames.map((sn: any) => {
// 将db变更为 db/schema;
const nParams = { ...params };
nParams.schema = sn;
nParams.db = nParams.db + '/' + sn;
return new TagTreeNode(`${params.id}.${params.db}.schema.${sn}`, sn, NodeTypePostgresScheam).withParams(nParams).withIcon(SchemaIcon);
});
})
.withNodeClickFunc(nodeClickChangeDb);
// postgres schema模式
const NodeTypePostgresScheam = new NodeType(SqlExecNodeType.PgSchema)
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
return [
new TagTreeNode(`${params.id}.${params.db}.table-menu`, '表', NodeTypeTableMenu).withParams(params).withIcon(TableIcon),
new TagTreeNode(getSqlMenuNodeKey(params.id, params.db), 'SQL', NodeTypeSqlMenu).withParams(params).withIcon(SqlIcon),
];
})
.withNodeClickFunc(nodeClickChangeDb);
// 数据库表菜单节点
const NodeTypeTableMenu = new NodeType(SqlExecNodeType.TableMenu)
.withContextMenuItems([
new ContextmenuItem('reloadTables', '刷新').withIcon('RefreshRight').withOnClick((data: any) => reloadTables(data.key)),
new ContextmenuItem('tablesOp', '表操作').withIcon('Setting').withOnClick((data: any) => {
const params = data.params;
addTablesOpTab({ id: params.id, db: params.db, type: params.type, nodeKey: data.key });
}),
])
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
let { id, db } = params;
// 获取当前库的所有表信息
let tables = await DbInst.getInst(id).loadTables(db, state.reloadStatus);
state.reloadStatus = false;
let dbTableSize = 0;
const tablesNode = tables.map((x: any) => {
dbTableSize += x.dataLength + x.indexLength;
return new TagTreeNode(`${id}.${db}.${x.tableName}`, x.tableName, NodeTypeTable)
.withIsLeaf(true)
.withParams({
id,
db,
tableName: x.tableName,
tableComment: x.tableComment,
size: formatByteSize(x.dataLength + x.indexLength, 1),
})
.withIcon(TableIcon);
});
// 设置父节点参数的表大小
parentNode.params.dbTableSize = formatByteSize(dbTableSize);
return tablesNode;
})
.withNodeClickFunc(nodeClickChangeDb);
// 数据库sql模板菜单节点
const NodeTypeSqlMenu = new NodeType(SqlExecNodeType.SqlMenu)
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
const id = params.id;
const db = params.db;
const dbs = params.dbs;
// 加载用户保存的sql脚本
const sqls = await dbApi.getSqlNames.request({ id: id, db: db });
return sqls.map((x: any) => {
return new TagTreeNode(`${id}.${db}.${x.name}`, x.name, NodeTypeSql)
.withIsLeaf(true)
.withParams({
id,
db,
dbs,
sqlName: x.name,
})
.withIcon(SqlIcon);
});
})
.withNodeClickFunc(nodeClickChangeDb);
// 表节点类型
const NodeTypeTable = new NodeType(SqlExecNodeType.Table).withNodeClickFunc((nodeData: TagTreeNode) => {
const params = nodeData.params;
loadTableData({ id: params.id, nodeKey: nodeData.key }, params.db, params.tableName);
});
// sql模板节点类型
const NodeTypeSql = new NodeType(SqlExecNodeType.Sql)
.withNodeClickFunc((nodeData: TagTreeNode) => {
const params = nodeData.params;
addQueryTab({ id: params.id, nodeKey: nodeData.key, dbs: params.dbs }, params.db, params.sqlName);
})
.withContextMenuItems([
new ContextmenuItem('delSql', '删除').withIcon('delete').withOnClick((data: any) => deleteSql(data.params.id, data.params.db, data.params.sqlName)),
]);
const tagTreeRef: any = ref(null);
@@ -132,228 +377,81 @@ const state = reactive({
activeName: '',
reloadStatus: false,
tabs,
dataTabsTableHeight: '600',
dataTabsTableHeight: 600,
editorHeight: '600',
tagTreeHeight: window.innerHeight - 178 + 'px',
genSqlDialog: {
visible: false,
sql: '',
},
tablesOpHeight: '600',
});
const { nowDbInst } = toRefs(state);
onMounted(() => {
self.completionItemProvider?.dispose();
setHeight();
// 监听浏览器窗口大小变化,更新对应组件高度
window.onresize = () => setHeight();
});
onBeforeUnmount(() => {
dispposeCompletionItemProvider('sql');
});
/**
* 设置editor高度和数据表高度
*/
const setHeight = () => {
state.editorHeight = window.innerHeight - 518 + 'px';
state.dataTabsTableHeight = window.innerHeight - 256 + 'px';
state.tagTreeHeight = window.innerHeight - 165 + 'px';
state.editorHeight = window.innerHeight - 525 + 'px';
state.dataTabsTableHeight = window.innerHeight - 255;
state.tablesOpHeight = window.innerHeight - 220 + 'px';
};
/**
* instmap; tagPaht -> info[]
*/
const instMap: Map<string, any[]> = new Map();
const getInsts = async () => {
const res = await dbApi.dbs.request({ pageNum: 1, pageSize: 1000 });
if (!res.total) return;
for (const db of res.list) {
const tagPath = db.tagPath;
let dbInsts = instMap.get(tagPath) || [];
dbInsts.push(db);
instMap.set(tagPath, dbInsts?.sort());
}
};
/**
* 加载树节点
* @param {Object} node
* @param {Object} resolve
*/
const loadNode = async (node: any) => {
// 一级为tagPath
if (node.level === 0) {
await getInsts();
const tagPaths = instMap.keys();
const tagNodes = [];
for (let tagPath of tagPaths) {
tagNodes.push(new TagTreeNode(tagPath, tagPath));
}
return tagNodes;
}
const data = node.data;
const nodeType = data.type;
const params = data.params;
// 点击tagPath -> 加载数据库实例信息列表
if (nodeType === TagTreeNode.TagPath) {
const dbInfos = instMap.get(data.key);
return dbInfos?.map((x: any) => {
return new TagTreeNode(`${data.key}.${x.id}`, x.name, NodeType.DbInst).withParams(x);
});
}
// 点击数据库实例 -> 加载库列表
if (nodeType === NodeType.DbInst) {
const dbs = params.database.split(' ')?.sort();
console.log(dbs);
return dbs.map((x: any) => {
return new TagTreeNode(`${data.key}.${x}`, x, NodeType.Db).withParams({
tagPath: params.tagPath,
id: params.id,
name: params.name,
type: params.type,
dbs: dbs,
db: x,
});
});
}
// 点击数据库 -> 加载 表&Sql 菜单
if (nodeType === NodeType.Db) {
return [
new TagTreeNode(`${params.id}.${params.db}.table-menu`, '表', NodeType.TableMenu).withParams(params),
new TagTreeNode(getSqlMenuNodeKey(params.id, params.db), 'SQL', NodeType.SqlMenu).withParams(params),
];
}
// 点击表菜单 -> 加载表列表
if (nodeType === NodeType.TableMenu) {
return await getTables(params);
}
if (nodeType === NodeType.SqlMenu) {
return await loadSqls(params.id, params.db, params.dbs);
}
return [];
};
const nodeClick = async (data: any) => {
const params = data.params;
const nodeKey = data.key;
const dataType = data.type;
// 点击数据库,修改当前数据库信息
if (dataType === NodeType.Db || dataType === NodeType.SqlMenu || dataType === NodeType.TableMenu || dataType === NodeType.DbInst) {
changeSchema({ id: params.id, name: params.name, type: params.type, tagPath: params.tagPath, databases: params.database }, params.db);
return;
}
// 点击表加载表数据tab
if (dataType === NodeType.Table) {
await loadTableData({ id: params.id, nodeKey: nodeKey }, params.db, params.tableName);
return;
}
// 点击表加载表数据tab
if (dataType === NodeType.Sql) {
await addQueryTab({ id: params.id, nodeKey: nodeKey, dbs: params.dbs }, params.db, params.sqlName);
}
};
const getContextmenuItems = (data: any) => {
const dataType = data.type;
if (dataType === NodeType.TableMenu) {
return [{ contextMenuClickId: ContextmenuClickId.ReloadTable, txt: '刷新', icon: 'RefreshRight' }];
}
return [];
};
// 当前右击菜单点击事件
const onCurrentContextmenuClick = (clickData: any) => {
const clickId = clickData.id;
if (clickId == ContextmenuClickId.ReloadTable) {
reloadTables(clickData.item.key);
}
};
const getTables = async (params: any) => {
const { id, db } = params;
let tables = await DbInst.getInst(id).loadTables(db, state.reloadStatus);
state.reloadStatus = false;
return tables.map((x: any) => {
return new TagTreeNode(`${id}.${db}.${x.tableName}`, x.tableName, NodeType.Table).withIsLeaf(true).withParams({
id,
db,
tableName: x.tableName,
tableComment: x.tableComment,
});
});
};
/**
* 加载用户保存的sql脚本
*
* @param inst
* @param schema
*/
const loadSqls = async (id: any, db: string, dbs: any) => {
const sqls = await dbApi.getSqlNames.request({ id: id, db: db });
return sqls.map((x: any) => {
return new TagTreeNode(`${id}.${db}.${x.name}`, x.name, NodeType.Sql).withIsLeaf(true).withParams({
id,
db,
dbs,
sqlName: x.name,
});
});
};
// 选择数据库
const changeSchema = (inst: any, schema: string) => {
state.nowDbInst = DbInst.getOrNewInst(inst);
state.db = schema;
// 选择数据库,改变当前正在操作的数据库信息
const changeDb = (db: any, dbName: string) => {
state.nowDbInst = DbInst.getOrNewInst(db);
state.db = dbName;
};
// 加载选中的表数据即新增表数据操作tab
const loadTableData = async (inst: any, schema: string, tableName: string) => {
changeSchema(inst, schema);
const loadTableData = async (db: any, dbName: string, tableName: string) => {
if (tableName == '') {
return;
}
changeDb(db, dbName);
const label = `${inst.id}:\`${schema}\`.${tableName}`;
let tab = state.tabs.get(label);
state.activeName = label;
const key = `${db.id}:\`${dbName}\`.${tableName}`;
let tab = state.tabs.get(key);
state.activeName = key;
// 如果存在该表tab则直接返回
if (tab) {
return;
}
tab = new TabInfo();
tab.key = label;
tab.treeNodeKey = inst.nodeKey;
tab.dbId = inst.id;
tab.db = schema;
tab.label = tableName;
tab.key = key;
tab.treeNodeKey = db.nodeKey;
tab.dbId = db.id;
tab.db = dbName;
tab.type = TabType.TableData;
tab.params = {
...getNowDbInfo(),
table: tableName,
};
state.tabs.set(label, tab);
state.tabs.set(key, tab);
};
// 新建查询panel
const addQueryTab = async (inst: any, db: string, sqlName: string = '') => {
if (!db || !inst.id) {
// 新建查询tab
const addQueryTab = async (db: any, dbName: string, sqlName: string = '') => {
if (!dbName || !db.id) {
ElMessage.warning('请选择数据库实例及对应的schema');
return;
}
changeDb(db, dbName);
const dbId = inst.id;
const dbId = db.id;
let label;
let key;
// 存在sql模板名则该模板名只允许一个tab
if (sqlName) {
label = `查询:${dbId}:${db}.${sqlName}`;
label = `查询-${sqlName}`;
key = `查询:${dbId}:${dbName}.${sqlName}`;
} else {
let count = 1;
state.tabs.forEach((v) => {
@@ -361,24 +459,66 @@ const addQueryTab = async (inst: any, db: string, sqlName: string = '') => {
count++;
}
});
label = `新查询${count}:${dbId}:${db}`;
label = `新查询-${count}`;
key = `新查询${count}:${dbId}:${dbName}`;
}
state.activeName = label;
let tab = state.tabs.get(label);
state.activeName = key;
let tab = state.tabs.get(key);
if (tab) {
return;
}
tab = new TabInfo();
tab.key = label;
tab.treeNodeKey = inst.nodeKey;
tab.key = key;
tab.label = label;
tab.treeNodeKey = db.nodeKey;
tab.dbId = dbId;
tab.db = db;
tab.db = dbName;
tab.type = TabType.Query;
tab.params = {
...getNowDbInfo(),
sqlName: sqlName,
dbs: inst.dbs,
dbs: db.dbs,
};
state.tabs.set(label, tab);
state.tabs.set(key, tab);
// 注册当前sql编辑框提示词
registerDbCompletionItemProvider('sql', tab.dbId, tab.db, tab.params.dbs);
};
/**
* 添加数据操作tab
* @param inst
*/
const addTablesOpTab = async (db: any) => {
const dbName = db.db;
if (!db || !db.id) {
ElMessage.warning('请选择数据库实例及对应的schema');
return;
}
changeDb(db, dbName);
const dbId = db.id;
let key = `表操作:${dbId}:${dbName}.tablesOp`;
state.activeName = key;
let tab = state.tabs.get(key);
if (tab) {
return;
}
tab = new TabInfo();
tab.key = key;
tab.label = `表操作-${dbName}`;
tab.treeNodeKey = db.nodeKey;
tab.dbId = dbId;
tab.db = dbName;
tab.type = TabType.TablesOp;
tab.params = {
...getNowDbInfo(),
id: db.id,
db: dbName,
type: db.type,
};
state.tabs.set(key, tab);
};
const onRemoveTab = (targetName: string) => {
@@ -397,6 +537,7 @@ const onRemoveTab = (targetName: string) => {
}
state.tabs.delete(targetName);
state.activeName = activeName;
onTabChange();
}
};
@@ -406,23 +547,34 @@ const onTabChange = () => {
state.db = '';
return;
}
const nowTab = state.tabs.get(state.activeName);
state.nowDbInst = DbInst.getInst(nowTab?.dbId);
state.db = nowTab?.db as string;
};
const onGenerateInsertSql = async (sql: string) => {
state.genSqlDialog.sql = sql;
state.genSqlDialog.visible = true;
if (nowTab?.type == TabType.Query) {
// 注册sql提示
registerDbCompletionItemProvider('sql', nowTab.dbId, nowTab.db, nowTab.params.dbs);
}
};
const reloadSqls = (dbId: number, db: string) => {
tagTreeRef.value.reloadNode(getSqlMenuNodeKey(dbId, db));
};
const deleteSqlScript = (ti: TabInfo) => {
reloadSqls(ti.dbId, ti.db);
onRemoveTab(ti.key);
const deleteSql = async (dbId: any, db: string, sqlName: string) => {
try {
await ElMessageBox.confirm(`确定删除【${sqlName}】该SQL内容?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await dbApi.deleteDbSql.request({ id: dbId, db: db, name: sqlName });
ElMessage.success('删除成功');
reloadSqls(dbId, db);
} catch (err) {
//
}
};
const getSqlMenuNodeKey = (dbId: number, db: string) => {
@@ -433,50 +585,53 @@ const reloadTables = (nodeKey: string) => {
state.reloadStatus = true;
tagTreeRef.value.reloadNode(nodeKey);
};
/**
* 获取当前操作的数据库信息
*/
const getNowDbInfo = () => {
const di = state.nowDbInst;
return {
tagPath: di.tagPath,
id: di.id,
name: di.name,
type: di.type,
host: di.host,
dbName: state.db,
};
};
</script>
<style lang="scss">
.sql-file-exec {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
vertical-align: middle;
position: relative;
text-decoration: none;
}
.db-sql-exec {
.db-table-size {
color: #c4c9c4;
font-size: 9px;
}
.sqlEditor {
font-size: 8pt;
font-weight: 600;
border: 1px solid #ccc;
}
#data-exec {
min-height: calc(100vh - 155px);
.editor-move-resize {
cursor: n-resize;
height: 3px;
text-align: center;
}
.el-tabs {
--el-tabs-header-height: 30px;
}
#data-exec {
min-height: calc(100vh - 155px);
.el-tabs__header {
margin: 0 0 5px;
.el-tabs__header {
margin: 0 0 5px;
.el-tabs__item {
padding: 0 10px;
}
}
.el-tabs__item {
padding: 0 5px;
.el-tabs__nav-next,
.el-tabs__nav-prev {
line-height: 30px;
}
}
}
.update_field_active {
background-color: var(--el-color-success);
}
.instances-pop-form {
.el-form-item {
margin-bottom: unset;
.update_field_active {
background-color: var(--el-color-success);
}
}
</style>

View File

@@ -10,8 +10,8 @@ export const dbApi = {
tableInfos: Api.newGet('/dbs/{id}/t-infos'),
tableIndex: Api.newGet('/dbs/{id}/t-index'),
tableDdl: Api.newGet('/dbs/{id}/t-create-ddl'),
tableMetadata: Api.newGet('/dbs/{id}/t-metadata'),
columnMetadata: Api.newGet('/dbs/{id}/c-metadata'),
pgSchemas: Api.newGet('/dbs/{id}/pg/schemas'),
// 获取表即列提示
hintTables: Api.newGet('/dbs/{id}/hint-tables'),
sqlExec: Api.newPost('/dbs/{id}/exec-sql'),
@@ -27,8 +27,9 @@ export const dbApi = {
// 获取权限列表
instances: Api.newGet('/instances'),
getInstance: Api.newGet("/instances/{instanceId}"),
getInstance: Api.newGet('/instances/{instanceId}'),
getAllDatabase: Api.newGet('/instances/{instanceId}/databases'),
testConn: Api.newPost('/instances/test-conn'),
saveInstance: Api.newPost('/instances'),
getInstancePwd: Api.newGet('/instances/{id}/pwd'),
deleteInstance: Api.newDelete('/instances/{id}'),

View File

@@ -1,335 +0,0 @@
<template>
<div>
<el-table
@cell-dblclick="(row: any, column: any, cell: any, event: any) => cellClick(row, column, cell)"
@sort-change="(sort: any) => onTableSortChange(sort)"
@selection-change="onDataSelectionChange"
:data="datas"
size="small"
:max-height="tableHeight"
v-loading="loading"
element-loading-text="查询中..."
:empty-text="emptyText"
highlight-current-row
stripe
border
class="mt5"
>
<el-table-column v-if="datas.length > 0 && table" type="selection" width="35" />
<template v-for="(item, index) in columns">
<el-table-column
min-width="100"
:width="DbInst.flexColumnWidth(item.columnName, datas)"
align="center"
v-if="item.show"
:key="index"
:prop="item.columnName"
:label="item.columnName"
show-overflow-tooltip
:sortable="sortable"
>
<template #header v-if="showColumnTip">
<el-tooltip raw-content placement="top" effect="customized">
<template #content> {{ getColumnTip(item) }} </template>
{{ item.columnName }}
</el-tooltip>
</template>
</el-table-column>
</template>
</el-table>
</div>
</template>
<script lang="ts" setup>
import { onMounted, watch, reactive, toRefs } from 'vue';
import { DbInst, UpdateFieldsMeta, FieldsMeta } from '../db';
const emits = defineEmits(['sortChange', 'deleteData', 'selectionChange', 'changeUpdatedField'])
const props = defineProps({
dbId: {
type: Number,
required: true,
},
dbType: {
type: String,
default: ''
},
db: {
type: String,
required: true,
},
table: {
type: String,
default: '',
},
data: {
type: Array,
},
columns: {
type: Array<any>,
},
sortable: {
type: [String, Boolean],
default: false,
},
loading: {
type: Boolean,
default: false,
},
emptyText: {
type: String,
default: '暂无数据',
},
showColumnTip: {
type: Boolean,
default: false,
},
height: {
type: String,
default: '600'
}
})
const state = reactive({
dbId: 0, // 当前选中操作的数据库实例
dbType: '',
db: '', // 数据库名
table: '', // 当前的表名
datas: [],
columns: [],
sortable: false,
loading: false,
selectionDatas: [] as any,
showColumnTip: false,
tableHeight: '600',
emptyText: '',
updatedFields: [] as UpdateFieldsMeta[],// 各个tab表被修改的字段信息
});
const {
tableHeight,
datas,
sortable,
loading,
showColumnTip,
} = toRefs(state);
watch(props, (newValue: any) => {
setState(newValue);
});
onMounted(async () => {
console.log('in DbTable mounted');
setState(props);
})
const setState = (props: any) => {
state.dbId = props.dbId;
state.dbType = props.dbType;
state.db = props.db;
state.table = props.table;
state.datas = props.data;
state.tableHeight = props.height;
state.sortable = props.sortable;
state.loading = props.loading;
state.columns = props.columns;
state.showColumnTip = props.showColumnTip;
state.emptyText = props.emptyText;
}
const getColumnTip = (column: any) => {
const comment = column.columnComment;
return `${column.columnType} ${comment ? ' | ' + comment : ''}`;
};
/**
* 表排序字段变更
*/
const onTableSortChange = async (sort: any) => {
if (!sort.prop) {
return;
}
cancelUpdateFields();
emits('sortChange', sort);
};
const onDataSelectionChange = (datas: []) => {
state.selectionDatas = datas;
emits('selectionChange', datas);
};
// 监听单元格点击事件
const cellClick = (row: any, column: any, cell: any) => {
const property = column.property;
// 如果当前操作的表名不存在 或者 当前列的property不存在(如多选框),则不允许修改当前单元格内容
if (!state.table || !property) {
return;
}
let div: HTMLElement = cell.children[0];
if (div && div.tagName === 'DIV') {
// 转为字符串比较,可能存在数字等
let text = (row[property] || row[property] == 0 ? row[property] : '') + '';
let input = document.createElement('input');
input.setAttribute('value', text);
// 将表格width也赋值于输入框避免输入框长度超过表格长度
input.setAttribute('style', 'height:23px;text-align:center;border:none;' + div.getAttribute('style'));
cell.replaceChildren(input);
input.focus();
input.addEventListener('blur', async () => {
row[property] = input.value;
cell.replaceChildren(div);
if (input.value !== text) {
let currentUpdatedFields = state.updatedFields
const dbInst = getNowDbInst();
// 主键
const primaryKey = await dbInst.loadTableColumn(state.db, state.table);
const primaryKeyValue = row[primaryKey.columnName];
// 更新字段列信息
const updateColumn = await dbInst.loadTableColumn(state.db, state.table, property);
const newField = {
div, row,
fieldName: property,
fieldType: updateColumn.columnType,
oldValue: text,
newValue: input.value
} as FieldsMeta;
// 被修改的字段
const primaryKeyFields = currentUpdatedFields.filter((meta) => meta.primaryKey === primaryKeyValue)
let hasKey = false;
if (primaryKeyFields.length <= 0) {
primaryKeyFields[0] = {
primaryKey: primaryKeyValue,
primaryKeyName: primaryKey.columnName,
primaryKeyType: primaryKey.columnType,
fields: [newField]
}
} else {
hasKey = true
let hasField = primaryKeyFields[0].fields.some(a => {
if (a.fieldName === newField.fieldName) {
a.newValue = newField.newValue
}
return a.fieldName === newField.fieldName
})
if (!hasField) {
primaryKeyFields[0].fields.push(newField)
}
}
let fields = primaryKeyFields[0].fields
const fieldsParam = fields.filter((a) => {
if (a.fieldName === column.property) {
a.newValue = input.value
}
return a.fieldName === column.property
})
const field = fieldsParam.length > 0 && fieldsParam[0] || {} as FieldsMeta
if (field.oldValue === input.value) { // 新值=旧值
// 删除数据
div.classList.remove('update_field_active')
let delIndex: number[] = [];
currentUpdatedFields.forEach((a, i) => {
if (a.primaryKey === primaryKeyValue) {
a.fields = a.fields && a.fields.length > 0 ? a.fields.filter(f => f.fieldName !== column.property) : [];
a.fields.length <= 0 && delIndex.push(i)
}
});
delIndex.forEach(i => delete currentUpdatedFields[i])
currentUpdatedFields = currentUpdatedFields.filter(a => a)
} else {
// 新增数据
div.classList.add('update_field_active')
if (hasKey) {
currentUpdatedFields.forEach((value, index, array) => {
if (value.primaryKey === primaryKeyValue) {
array[index].fields = fields
}
})
} else {
currentUpdatedFields.push({
primaryKey: primaryKeyValue,
primaryKeyName: primaryKey.columnName,
primaryKeyType: primaryKey.columnType,
fields
})
}
}
state.updatedFields = currentUpdatedFields;
changeUpdatedField();
}
});
}
};
const submitUpdateFields = () => {
const dbInst = DbInst.getInst(state.dbId)
let currentUpdatedFields = state.updatedFields;
if (currentUpdatedFields.length <= 0) {
return;
}
const db = state.db;
let res = '';
let divs: HTMLElement[] = [];
currentUpdatedFields.forEach(a => {
let sql = `UPDATE ${dbInst.wrapName(state.table)} SET `;
let primaryKey = a.primaryKey;
let primaryKeyType = a.primaryKeyType;
let primaryKeyName = a.primaryKeyName;
a.fields.forEach(f => {
sql += ` ${dbInst.wrapName(f.fieldName)} = ${DbInst.wrapColumnValue(f.fieldType, f.newValue)},`
// 如果修改的字段是主键
if (f.fieldName === primaryKeyName) {
primaryKey = f.oldValue
}
divs.push(f.div)
})
sql = sql.substring(0, sql.length - 1)
sql += ` WHERE ${dbInst.wrapName(primaryKeyName)} = ${DbInst.wrapColumnValue(primaryKeyType, primaryKey)} ;`
res += sql;
})
dbInst.promptExeSql(db, res, () => { }, () => {
currentUpdatedFields = [];
divs.forEach(a => {
a.classList.remove('update_field_active');
})
state.updatedFields = [];
changeUpdatedField();
});
}
const cancelUpdateFields = () => {
state.updatedFields.forEach((a: any) => {
a.fields.forEach((b: any) => {
b.div.classList.remove('update_field_active')
b.row[b.fieldName] = b.oldValue
})
})
state.updatedFields = [];
changeUpdatedField();
}
const changeUpdatedField = () => {
emits('changeUpdatedField', state.updatedFields);
}
const getNowDbInst = () => {
return DbInst.getInst(state.dbId);
}
defineExpose({
submitUpdateFields,
cancelUpdateFields
})
</script>
<style lang="scss">
.update_field_active {
background-color: var(--el-color-success);
}
</style>

View File

@@ -0,0 +1,712 @@
<template>
<div>
<div>
<div class="toolbar">
<div class="fl">
<el-link @click="onRunSql()" :underline="false" class="ml15" icon="VideoPlay"> </el-link>
<el-divider direction="vertical" border-style="dashed" />
<el-tooltip :show-after="1000" class="box-item" effect="dark" content="format sql" placement="top">
<el-link @click="formatSql()" type="primary" :underline="false" icon="MagicStick"> </el-link>
</el-tooltip>
<el-divider direction="vertical" border-style="dashed" />
<el-tooltip :show-after="1000" class="box-item" effect="dark" content="commit" placement="top">
<el-link @click="onCommit()" type="success" :underline="false" icon="CircleCheck"> </el-link>
</el-tooltip>
<el-divider direction="vertical" border-style="dashed" />
<el-upload
class="sql-file-exec"
:before-upload="beforeUpload"
:on-success="execSqlFileSuccess"
:headers="{ Authorization: token }"
:action="getUploadSqlFileUrl()"
:show-file-list="false"
name="file"
multiple
:limit="100"
>
<el-tooltip :show-after="1000" class="box-item" effect="dark" content="SQL脚本执行" placement="top">
<el-link type="success" :underline="false" icon="Document"></el-link>
</el-tooltip>
</el-upload>
</div>
<div style="float: right" class="fl">
<el-button @click="saveSql()" type="primary" icon="document-add" plain size="small">保存SQL</el-button>
</div>
</div>
</div>
<MonacoEditor ref="monacoEditorRef" class="mt5" v-model="state.sql" language="sql" :height="state.editorHeight" :id="'MonacoTextarea-' + getKey()" />
<div class="editor-move-resize" @mousedown="onDragSetHeight">
<el-icon>
<Minus />
</el-icon>
</div>
<div class="mt5 sql-exec-res">
<el-tabs v-if="state.execResTabs.length > 0" @tab-remove="onRemoveTab" style="width: 100%" v-model="state.activeTab">
<el-tab-pane closable v-for="dt in state.execResTabs" :label="dt.id" :name="dt.id" :key="dt.id">
<template #label>
<el-popover :show-after="1000" placement="top-start" title="执行信息" trigger="hover" :width="300">
<template #reference>
<div>
<span>
<span v-if="dt.loading">
<SvgIcon class="mb2 is-loading" name="Loading" color="var(--el-color-primary)" />
</span>
<span v-else>
<SvgIcon class="mb2" v-if="!dt.errorMsg" name="CircleCheck" color="var(--el-color-success)" />
<SvgIcon class="mb2" v-if="dt.errorMsg" name="CircleClose" color="var(--el-color-error)" />
</span>
</span>
<span> 结果{{ dt.id }} </span>
</div>
</template>
<template #default>
<el-descriptions v-if="dt.sql" :column="1" size="small">
<el-descriptions-item>
<div style="width: 280px">
<el-text size="small" truncated :title="dt.sql"> {{ dt.sql }} </el-text>
</div>
</el-descriptions-item>
<el-descriptions-item label="耗时 :"> {{ dt.execTime }}ms </el-descriptions-item>
<el-descriptions-item label="结果集 :">
{{ dt.data?.length }}
</el-descriptions-item>
</el-descriptions>
</template>
</el-popover>
</template>
<el-row>
<span v-if="dt.hasUpdatedFileds" class="mt5">
<span>
<el-link type="success" :underline="false" @click="submitUpdateFields(dt)"><span style="font-size: 12px">提交</span></el-link>
</span>
<span>
<el-divider direction="vertical" border-style="dashed" />
<el-link type="warning" :underline="false" @click="cancelUpdateFields(dt)"><span style="font-size: 12px">取消</span></el-link>
</span>
</span>
</el-row>
<db-table-data
v-if="!dt.errorMsg"
:ref="(el) => (dt.dbTableRef = el)"
:db-id="dbId"
:db="dbName"
:data="dt.data"
:table="dt.table"
:columns="dt.tableColumn"
:loading="dt.loading"
:height="tableDataHeight"
empty-text="tips: select *开头的单表查询或点击表名默认查询的数据,可双击数据在线修改"
@change-updated-field="changeUpdatedField($event, dt)"
@data-delete="onDeleteData($event, dt)"
></db-table-data>
<el-result v-else icon="error" title="执行失败" :sub-title="dt.errorMsg"> </el-result>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<script lang="ts" setup>
import { h, nextTick, watch, onMounted, reactive, toRefs, ref } from 'vue';
import { getToken } from '@/common/utils/storage';
import { notBlank } from '@/common/assert';
import { format as sqlFormatter } from 'sql-formatter';
import config from '@/common/config';
import { ElMessage, ElMessageBox } from 'element-plus';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { editor } from 'monaco-editor';
import DbTableData from '@/views/ops/db/component/table/DbTableData.vue';
import { DbInst } from '../../db';
import { dbApi } from '../../api';
import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
import { joinClientParams } from '@/common/request';
import { buildProgressProps } from '@/components/progress-notify/progress-notify';
import ProgressNotify from '@/components/progress-notify/progress-notify.vue';
import { ElNotification } from 'element-plus';
import syssocket from '@/common/syssocket';
import SvgIcon from '@/components/svgIcon/index.vue';
import { getDbDialect } from '../../dialect';
const emits = defineEmits(['saveSqlSuccess']);
const props = defineProps({
dbId: {
type: Number,
required: true,
},
dbName: {
type: String,
required: true,
},
// sql脚本名若有则去加载该sql内容
sqlName: {
type: String,
},
editorHeight: {
type: String,
default: '600',
},
});
class ExecResTab {
id: number;
/**
* 当前结果集对应的sql
*/
sql: string;
loading: boolean;
dbTableRef: any;
tableColumn: any[] = [];
data: any[] = [];
execTime: number;
/**
* 当前单表操作sql关联的表信息
*/
table: string;
/**
* 是否有更新字段
*/
hasUpdatedFileds: boolean;
errorMsg: string;
constructor(id: number) {
this.id = id;
}
}
const token = getToken();
const monacoEditorRef: any = ref(null);
let monacoEditor: editor.IStandaloneCodeEditor;
const state = reactive({
token,
sql: '', // 当前编辑器的sql内容s
sqlName: '' as any, // sql模板名称
execResTabs: [] as ExecResTab[],
activeTab: 1,
editorHeight: '500',
tableDataHeight: 255 as any,
});
const { tableDataHeight } = toRefs(state);
watch(
() => props.editorHeight,
(newValue: any) => {
state.editorHeight = newValue;
}
);
const getNowDbInst = () => {
return DbInst.getInst(props.dbId);
};
onMounted(async () => {
console.log('in query mounted');
state.editorHeight = props.editorHeight;
// 默认新建一个结果集tab
state.execResTabs.push(new ExecResTab(1));
state.sqlName = props.sqlName;
if (props.sqlName) {
const res = await dbApi.getSql.request({ id: props.dbId, type: 1, db: props.dbName, name: props.sqlName });
state.sql = res.sql;
}
nextTick(() => {
setTimeout(() => initMonacoEditor(), 50);
});
await getNowDbInst().loadDbHints(props.dbName);
});
const onRemoveTab = (targetId: number) => {
let activeTab = state.activeTab;
const tabs = [...state.execResTabs];
for (let i = 0; i < tabs.length; i++) {
const tabId = tabs[i].id;
if (tabId !== targetId) {
continue;
}
const nextTab = tabs[i + 1] || tabs[i - 1];
if (nextTab) {
activeTab = nextTab.id;
} else {
activeTab = 0;
}
state.execResTabs.splice(i, 1);
state.activeTab = activeTab;
}
};
/**
* 拖拽改变sql编辑区和查询结果区高度
*/
const onDragSetHeight = () => {
document.onmousemove = (e) => {
e.preventDefault();
//得到鼠标拖动的宽高距离:取绝对值
state.editorHeight = `${document.getElementById('MonacoTextarea-' + getKey())!.clientHeight + e.movementY}px`;
state.tableDataHeight -= e.movementY;
};
document.onmouseup = () => {
document.onmousemove = null;
};
};
const getKey = () => {
if (props.sqlName) {
return `${props.dbId}:${props.dbName}.${props.sqlName}`;
}
return props.dbId + ':' + props.dbName;
};
/**
* 执行sql
*/
const onRunSql = async (newTab = false) => {
// 没有选中的文本,则为全部文本
let sql = getSql() as string;
notBlank(sql && sql.trim(), '请选中需要执行的sql');
// 去除字符串前的空格、换行等
sql = sql.replace(/(^\s*)/g, '');
let execRemark = '';
let canRun = true;
if (
sql.startsWith('update') ||
sql.startsWith('UPDATE') ||
sql.startsWith('INSERT') ||
sql.startsWith('insert') ||
sql.startsWith('DELETE') ||
sql.startsWith('delete')
) {
const res: any = await ElMessageBox.prompt('请输入备注', 'Tip', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /^[\s\S]*.*[^\s][\s\S]*$/,
inputErrorMessage: '请输入执行该sql的备注信息',
});
execRemark = res.value;
if (!execRemark) {
canRun = false;
}
}
if (!canRun) {
return;
}
let execRes: ExecResTab;
let i = 0;
let id;
// 新tab执行或者tabs为0则新建tab执行sql
if (newTab || state.execResTabs.length == 0) {
// 取最后一个tab的id + 1
id = state.execResTabs.length == 0 ? 1 : state.execResTabs[state.execResTabs.length - 1].id + 1;
execRes = new ExecResTab(id);
state.execResTabs.push(execRes);
i = state.execResTabs.length - 1;
} else {
// 不是新建tab执行则在当前激活的tab上执行sql
i = state.execResTabs.findIndex((x) => x.id == state.activeTab);
execRes = state.execResTabs[i];
id = execRes.id;
}
state.activeTab = id;
const startTime = new Date().getTime();
try {
execRes.loading = true;
execRes.errorMsg = '';
execRes.sql = '';
const colAndData: any = await getNowDbInst().runSql(props.dbName, sql, execRemark);
if (!colAndData.res || colAndData.res.length === 0) {
ElMessage.warning('未查询到结果集');
}
// 要实时响应,故需要用索引改变数据才生效
state.execResTabs[i].data = colAndData.res;
// 兼容表格字段配置
state.execResTabs[i].tableColumn = colAndData.colNames.map((x: any) => {
return {
columnName: x,
show: true,
};
});
cancelUpdateFields(execRes);
} catch (e: any) {
execRes.data = [];
execRes.tableColumn = [];
execRes.table = '';
execRes.errorMsg = e.msg;
return;
} finally {
state.execResTabs[i].loading = false;
execRes.sql = sql;
execRes.execTime = new Date().getTime() - startTime;
}
// 即只有以该字符串开头的sql才可修改表数据内容
if (sql.startsWith('SELECT *') || sql.startsWith('select *') || sql.startsWith('SELECT\n *')) {
const tableName = sql.split(/from/i)[1];
if (tableName) {
const tn = tableName.trim().split(' ')[0].split('\n')[0];
execRes.table = tn;
execRes.table = tn;
} else {
execRes.table = '';
}
} else {
execRes.table = '';
}
};
/**
* 获取sql如果有鼠标选中则返回选中内容否则返回输入框内所有内容
*/
const getSql = () => {
let res = '' as string | undefined;
// 编辑器还没初始化
if (!monacoEditor?.getModel) {
return res;
}
// 选择选中的sql
let selection = monacoEditor.getSelection();
if (selection) {
res = monacoEditor.getModel()?.getValueInRange(selection);
}
// 整个编辑器的sql
if (!res) {
return monacoEditor.getModel()?.getValue();
}
return res;
};
const saveSql = async () => {
const sql = monacoEditor.getModel()?.getValue();
notBlank(sql, 'sql内容不能为空');
let sqlName = state.sqlName;
if (!sqlName) {
try {
const input = await ElMessageBox.prompt('请输入SQL脚本名', 'SQL名', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /\w+/,
inputErrorMessage: '请输入SQL脚本名',
});
sqlName = input.value;
state.sqlName = sqlName;
} catch (e) {
return;
}
}
await dbApi.saveSql.request({ id: props.dbId, db: props.dbName, sql: sql, type: 1, name: sqlName });
ElMessage.success('保存成功');
// 保存sql脚本成功事件
emits('saveSqlSuccess', props.dbId, props.dbName);
};
/**
* 格式化sql
*/
const formatSql = () => {
let selection = monacoEditor.getSelection();
if (!selection) {
return;
}
const formatDialect = getDbDialect(getNowDbInst().type).getFormatDialect();
let sql = monacoEditor.getModel()?.getValueInRange(selection);
// 有选中sql则格式化并替换选中sql, 否则格式化编辑器所有内容
if (sql) {
replaceSelection(sqlFormatter(sql, { language: formatDialect }), selection);
return;
}
monacoEditor.getModel()?.setValue(sqlFormatter(monacoEditor.getValue(), { language: formatDialect }));
};
/**
* 提交事务,用于没有开启自动提交事务
*/
const onCommit = () => {
getNowDbInst().runSql(props.dbName, 'COMMIT;');
ElMessage.success('COMMIT success');
};
/**
* 替换选中的内容
*/
const replaceSelection = (str: string, selection: any) => {
const model = monacoEditor.getModel();
if (!model) {
return;
}
if (!selection) {
model.setValue(str);
return;
}
const { startLineNumber, endLineNumber, startColumn, endColumn } = selection;
const textBeforeSelection = model.getValueInRange({
startLineNumber: 1,
startColumn: 0,
endLineNumber: startLineNumber,
endColumn: startColumn,
});
const textAfterSelection = model.getValueInRange({
startLineNumber: endLineNumber,
startColumn: endColumn,
endLineNumber: model.getLineCount(),
endColumn: model.getLineMaxColumn(model.getLineCount()),
});
monacoEditor.setValue(textBeforeSelection + str + textAfterSelection);
monacoEditor.focus();
monacoEditor.setPosition({
lineNumber: startLineNumber,
column: 0,
});
};
/**
* sql文件执行进度通知缓存
*/
const sqlExecNotifyMap: Map<string, any> = new Map();
const beforeUpload = (file: File) => {
ElMessage.success(`'${file.name}' 正在上传执行, 请关注结果通知`);
syssocket.registerMsgHandler('execSqlFileProgress', function (message: any) {
const content = JSON.parse(message.msg);
const id = content.id;
let progress = sqlExecNotifyMap.get(id);
if (content.terminated) {
if (progress != undefined) {
progress.notification?.close();
sqlExecNotifyMap.delete(id);
progress = undefined;
}
return;
}
if (progress == undefined) {
progress = {
props: reactive(buildProgressProps()),
notification: undefined,
};
}
progress.props.progress.title = content.title;
progress.props.progress.executedStatements = content.executedStatements;
if (!sqlExecNotifyMap.has(id)) {
progress.notification = ElNotification({
duration: 0,
title: message.title,
message: h(ProgressNotify, progress.props),
type: syssocket.getMsgType(message.type),
showClose: false,
});
sqlExecNotifyMap.set(id, progress);
}
});
};
// 执行sql成功
const execSqlFileSuccess = (res: any) => {
if (res.code !== 200) {
ElMessage.error(res.msg);
}
};
// 获取sql文件上传执行url
const getUploadSqlFileUrl = () => {
return `${config.baseApiUrl}/dbs/${props.dbId}/exec-sql-file?db=${props.dbName}&${joinClientParams()}`;
};
const changeUpdatedField = (updatedFields: any, dt: ExecResTab) => {
// 如果存在要更新字段,则显示提交和取消按钮
dt.hasUpdatedFileds = updatedFields && updatedFields.size > 0;
};
/**
* 数据删除事件
*/
const onDeleteData = async (deleteDatas: any, dt: ExecResTab) => {
const db = props.dbName;
const dbInst = getNowDbInst();
const primaryKey = await dbInst.loadTableColumn(db, dt.table);
const primaryKeyColumnName = primaryKey.columnName;
dt.data = dt.data.filter((d: any) => !(deleteDatas.findIndex((x: any) => x[primaryKeyColumnName] == d[primaryKeyColumnName]) != -1));
};
const submitUpdateFields = (dt: ExecResTab) => {
dt?.dbTableRef?.submitUpdateFields();
};
const cancelUpdateFields = (dt: ExecResTab) => {
dt?.dbTableRef?.cancelUpdateFields();
};
const initMonacoEditor = () => {
monacoEditor = monacoEditorRef.value.getEditor();
// 注册快捷键ctrl + R 运行选中的sql
monacoEditor.addAction({
// An unique identifier of the contributed action.
// id: 'run-sql-action' + state.ti.key,
id: 'run-sql-action' + getKey(),
// A label of the action that will be presented to the user.
label: '执行SQL',
// A precondition for this action.
precondition: undefined,
// A rule to evaluate on top of the precondition in order to dispatch the keybindings.
keybindingContext: undefined,
keybindings: [
// chord
monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyR, 0),
],
contextMenuGroupId: 'navigation',
contextMenuOrder: 1.5,
// Method that will be executed when the action is triggered.
// @param editor The editor instance is passed in as a convenience
run: async function () {
try {
await onRunSql();
} catch (e: any) {
e.message && ElMessage.error(e.message);
}
},
});
// 注册快捷键ctrl + R 运行选中的sql
monacoEditor.addAction({
// An unique identifier of the contributed action.
// id: 'run-sql-action' + state.ti.key,
id: 'run-sql-action-on-newtab' + getKey(),
// A label of the action that will be presented to the user.
label: '新标签执行SQL',
// A precondition for this action.
precondition: undefined,
// A rule to evaluate on top of the precondition in order to dispatch the keybindings.
keybindingContext: undefined,
keybindings: [
// chord
monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyR, 0),
],
contextMenuGroupId: 'navigation',
contextMenuOrder: 1.6,
// Method that will be executed when the action is triggered.
// @param editor The editor instance is passed in as a convenience
run: async function () {
try {
await onRunSql(true);
} catch (e: any) {
e.message && ElMessage.error(e.message);
}
},
});
// 注册快捷键ctrl + shift + f 格式化sql
monacoEditor.addAction({
// An unique identifier of the contributed action.
id: 'format-sql-action' + getKey(),
// A label of the action that will be presented to the user.
label: '格式化SQL',
// A precondition for this action.
precondition: undefined,
// A rule to evaluate on top of the precondition in order to dispatch the keybindings.
keybindingContext: undefined,
keybindings: [
// chord
monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyF, 0),
],
contextMenuGroupId: 'navigation',
contextMenuOrder: 2,
// Method that will be executed when the action is triggered.
// @param editor The editor instance is passed in as a convenience
run: async function () {
try {
await formatSql();
} catch (e: any) {
e.message && ElMessage.error(e.message);
}
},
});
// 注册快捷键ctrl + shift + f 格式化sql
monacoEditor.addAction({
// An unique identifier of the contributed action.
id: 'save-sql-action' + getKey(),
// A label of the action that will be presented to the user.
label: '保存SQL',
// A precondition for this action.
precondition: undefined,
// A rule to evaluate on top of the precondition in order to dispatch the keybindings.
keybindingContext: undefined,
keybindings: [
// chord
monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, 0),
],
contextMenuGroupId: 'navigation',
contextMenuOrder: 3,
// Method that will be executed when the action is triggered.
// @param editor The editor instance is passed in as a convenience
run: async function () {
await saveSql();
},
});
};
</script>
<style lang="scss">
.sql-file-exec {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
vertical-align: middle;
position: relative;
text-decoration: none;
}
.editor-move-resize {
cursor: n-resize;
height: 3px;
text-align: center;
}
.sql-exec-res {
.el-tabs__header {
margin: 0 0 !important;
}
.el-tabs__item {
font-size: 12px;
height: 25px;
margin: 0px;
padding: 0 6px !important;
}
}
</style>

View File

@@ -1,10 +1,12 @@
import { h, render, VNode } from 'vue';
import SqlExecDialog from './SqlExecDialog.vue';
import {SqlLanguage} from 'sql-formatter/lib/src/sqlFormatter'
export type SqlExecProps = {
sql: string;
dbId: number;
db: string;
dbType?: SqlLanguage;
runSuccessCallback?: Function;
cancelCallback?: Function;
};

View File

@@ -2,7 +2,7 @@
<div>
<el-dialog :destroy-on-close="true" title="待执行SQL" v-model="dialogVisible" :show-close="false" width="600px" @close="cancel">
<monaco-editor height="300px" class="codesql" language="sql" v-model="sqlValue" />
<el-input ref="remarkInputRef" v-model="remark" placeholder="请输入执行备注" class="mt5" />
<el-input @keyup.enter="runSql" ref="remarkInputRef" v-model="remark" placeholder="请输入执行备注" class="mt5" />
<template #footer>
<span class="dialog-footer">
<el-button @click="cancel"> </el-button>
@@ -15,7 +15,7 @@
<script lang="ts" setup>
import { toRefs, ref, nextTick, reactive } from 'vue';
import { dbApi } from '../api';
import { dbApi } from '@/views/ops/db/api';
import { ElDialog, ElButton, ElInput, ElMessage, InputInstance } from 'element-plus';
// import base style
import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
@@ -113,7 +113,8 @@ const cancel = () => {
const open = (props: SqlExecProps) => {
runSuccessCallback = props.runSuccessCallback;
cancelCallback = props.cancelCallback;
state.sqlValue = sqlFormatter(props.sql);
props.dbType = props.dbType || 'mysql';
state.sqlValue = sqlFormatter(props.sql, { language: props.dbType });
state.dbId = props.dbId;
state.db = props.db;
state.dialogVisible = true;

View File

@@ -1,923 +0,0 @@
<template>
<div>
<div>
<div class="toolbar">
<div class="fl">
<el-link @click="onRunSql()" :underline="false" class="ml15" icon="VideoPlay"> </el-link>
<el-divider direction="vertical" border-style="dashed" />
<el-tooltip class="box-item" effect="dark" content="format sql" placement="top">
<el-link @click="formatSql()" type="primary" :underline="false" icon="MagicStick"> </el-link>
</el-tooltip>
<el-divider direction="vertical" border-style="dashed" />
<el-tooltip class="box-item" effect="dark" content="commit" placement="top">
<el-link @click="onCommit()" type="success" :underline="false" icon="CircleCheck"> </el-link>
</el-tooltip>
<el-divider direction="vertical" border-style="dashed" />
<el-upload
class="sql-file-exec"
:before-upload="beforeUpload"
:on-success="execSqlFileSuccess"
:headers="{ Authorization: token }"
:action="getUploadSqlFileUrl()"
:show-file-list="false"
name="file"
multiple
:limit="100"
>
<el-tooltip class="box-item" effect="dark" content="SQL脚本执行" placement="top">
<el-link type="success" :underline="false" icon="Document"></el-link>
</el-tooltip>
</el-upload>
<el-divider direction="vertical" border-style="dashed" />
<el-tooltip class="box-item" effect="dark" content="limit" placement="top">
<el-link @click="onLimit()" type="success" :underline="false" icon="Operation"> </el-link>
</el-tooltip>
</div>
<div style="float: right" class="fl">
<el-button @click="saveSql()" type="primary" icon="document-add" plain size="small">保存SQL </el-button>
<el-button v-if="sqlName" @click="deleteSql()" type="danger" icon="delete" plain size="small">删除SQL </el-button>
</div>
</div>
</div>
<div class="mt5 sqlEditor">
<div :id="'MonacoTextarea-' + ti.key" :style="{ height: editorHeight }"></div>
</div>
<div class="editor-move-resize" @mousedown="onDragSetHeight">
<el-icon>
<Minus />
</el-icon>
</div>
<div class="mt5">
<el-row>
<el-link v-if="table" @click="onDeleteData()" class="ml5" type="danger" icon="delete" :underline="false"></el-link>
<span v-if="execRes.data.length > 0">
<el-divider direction="vertical" border-style="dashed" />
<el-link type="success" :underline="false" @click="exportData"><span style="font-size: 12px">导出</span></el-link>
</span>
<span v-if="hasUpdatedFileds">
<el-divider direction="vertical" border-style="dashed" />
<el-link type="success" :underline="false" @click="submitUpdateFields()"><span style="font-size: 12px">提交</span></el-link>
</span>
<span v-if="hasUpdatedFileds">
<el-divider direction="vertical" border-style="dashed" />
<el-link type="warning" :underline="false" @click="cancelUpdateFields"><span style="font-size: 12px">取消</span></el-link>
</span>
</el-row>
<db-table
ref="dbTableRef"
:db-id="state.ti.dbId"
:db="state.ti.db"
:data="execRes.data"
:table="state.table"
:columns="execRes.tableColumn"
:loading="loading"
:height="tableDataHeight"
empty-text="tips: select *开头的单表查询或点击表名默认查询的数据,可双击数据在线修改"
@selection-change="onDataSelectionChange"
@change-updated-field="changeUpdatedField"
></db-table>
</div>
</div>
</template>
<script lang="ts" setup>
import { nextTick, watch, onMounted, reactive, toRefs, ref, Ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useThemeConfig } from '@/store/themeConfig';
import { getSession } from '@/common/utils/storage';
import { isTrue, notBlank } from '@/common/assert';
import { format as sqlFormatter } from 'sql-formatter';
import config from '@/common/config';
import { ElMessage, ElMessageBox } from 'element-plus';
import { language as sqlLanguage } from 'monaco-editor/esm/vs/basic-languages/mysql/mysql.js';
import { language as addSqlLanguage } from '../../lang/mysql.js';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker.js?worker';
// import * as monaco from 'monaco-editor';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { editor, languages, Position } from 'monaco-editor';
// 相关语言
import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution.js';
import 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestController.js';
import 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestInlineCompletions.js';
// 右键菜单
import 'monaco-editor/esm/vs/editor/contrib/contextmenu/browser/contextmenu.js';
import 'monaco-editor/esm/vs/editor/contrib/caretOperations/browser/caretOperations.js';
import 'monaco-editor/esm/vs/editor/contrib/clipboard//browser/clipboard.js';
import 'monaco-editor/esm/vs/editor/contrib/find/browser/findController.js';
import 'monaco-editor/esm/vs/editor/contrib/format//browser/formatActions.js';
// 主题仓库 https://github.com/brijeshb42/monaco-themes
// 主题例子 https://editor.bitwiser.in/
import SolarizedLight from 'monaco-themes/themes/Solarized-light.json';
import DbTable from '../DbTable.vue';
import { DbInst, TabInfo } from '../../db';
import { exportCsv } from '@/common/utils/export';
import { dateStrFormat } from '@/common/utils/date';
import { dbApi } from '../../api';
const sqlCompletionKeywords = [...sqlLanguage.keywords, ...addSqlLanguage.keywords];
const sqlCompletionOperators = [...sqlLanguage.operators, ...addSqlLanguage.operators];
const sqlCompletionBuiltinFunctions = [...sqlLanguage.builtinFunctions, ...addSqlLanguage.builtinFunctions];
const sqlCompletionBuiltinVariables = [...sqlLanguage.builtinVariables, ...addSqlLanguage.builtinVariables];
const emits = defineEmits(['saveSqlSuccess', 'deleteSqlSuccess']);
const props = defineProps({
data: {
type: TabInfo,
required: true,
},
// sql脚本名若有则去加载该sql内容
sqlName: {
type: String,
default: '',
},
editorHeight: {
type: String,
default: '600',
},
});
const { themeConfig } = storeToRefs(useThemeConfig());
const token = getSession('token');
let monacoEditor = {} as editor.IStandaloneCodeEditor;
const dbTableRef = ref(null) as Ref;
const state = reactive({
token,
ti: {} as TabInfo,
dbs: [],
dbId: null, // 当前选中操作的数据库实例
table: '', // 当前单表操作sql的表信息
sqlName: '',
sql: '', // 当前编辑器的sql内容
loading: false, // 是否在加载数据
execRes: {
data: [],
tableColumn: [],
},
selectionDatas: [] as any,
editorHeight: '500',
tableDataHeight: 250 as any,
hasUpdatedFileds: false,
});
const { tableDataHeight, editorHeight, ti, execRes, table, sqlName, loading, hasUpdatedFileds } = toRefs(state);
watch(
() => props.editorHeight,
(newValue: any) => {
state.editorHeight = newValue;
}
);
// 监听 themeConfig editorTheme配置文件的变化
watch(
() => themeConfig.value.editorTheme,
(val) => {
console.log('monaco editor theme change: ', val);
monaco?.editor?.setTheme(val);
}
);
onMounted(async () => {
console.log('in query mounted');
state.ti = props.data;
state.editorHeight = props.editorHeight;
const params = state.ti.params;
state.dbs = params && params.dbs;
if (params && params.sqlName) {
state.sqlName = params.sqlName;
const res = await dbApi.getSql.request({ id: state.ti.dbId, type: 1, name: state.sqlName, db: state.ti.db });
state.sql = res.sql;
}
nextTick(() => {
setTimeout(() => initMonacoEditor(), 50);
});
await state.ti.getNowDbInst().loadDbHints(state.ti.db);
});
self.MonacoEnvironment = {
getWorker() {
return new EditorWorker();
},
};
const initMonacoEditor = () => {
registerSqlCompletionItemProvider();
let monacoTextarea = document.getElementById('MonacoTextarea-' + state.ti.key) as HTMLElement;
// options参数参考 https://microsoft.github.io/monaco-editor/api/interfaces/monaco.editor.IStandaloneEditorConstructionOptions.html#language
// 初始化一些主题
monaco.editor.defineTheme('SolarizedLight', SolarizedLight);
monacoEditor = monaco.editor.create(monacoTextarea, {
language: 'sql',
theme: themeConfig.value.editorTheme,
automaticLayout: true, //自适应宽高布局
folding: false,
roundedSelection: false, // 禁用选择文本背景的圆角
matchBrackets: 'near',
linkedEditing: true,
cursorBlinking: 'smooth', // 光标闪烁样式
mouseWheelZoom: true, // 在按住Ctrl键的同时使用鼠标滚轮时在编辑器中缩放字体
overviewRulerBorder: false, // 不要滚动条的边框
tabSize: 2, // tab 缩进长度
// fontFamily: 'JetBrainsMono', // 字体 暂时不要设置,否则光标容易错位
fontWeight: 'bold',
// letterSpacing: 1, 字符间距
// quickSuggestions:false, // 禁用代码提示
minimap: {
enabled: false, // 不要小地图
},
});
// 注册快捷键ctrl + R 运行选中的sql
monacoEditor.addAction({
// An unique identifier of the contributed action.
id: 'run-sql-action' + state.ti.key,
// A label of the action that will be presented to the user.
label: '执行SQL',
// A precondition for this action.
precondition: undefined,
// A rule to evaluate on top of the precondition in order to dispatch the keybindings.
keybindingContext: undefined,
keybindings: [
// chord
monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyR, 0),
],
contextMenuGroupId: 'navigation',
contextMenuOrder: 1.5,
// Method that will be executed when the action is triggered.
// @param editor The editor instance is passed in as a convenience
run: async function () {
try {
await onRunSql();
} catch (e: any) {
e.message && ElMessage.error(e.message);
}
},
});
// 注册快捷键ctrl + shift + f 格式化sql
monacoEditor.addAction({
// An unique identifier of the contributed action.
id: 'format-sql-action' + state.ti.key,
// A label of the action that will be presented to the user.
label: '格式化SQL',
// A precondition for this action.
precondition: undefined,
// A rule to evaluate on top of the precondition in order to dispatch the keybindings.
keybindingContext: undefined,
keybindings: [
// chord
monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyF, 0),
],
contextMenuGroupId: 'navigation',
contextMenuOrder: 2,
// Method that will be executed when the action is triggered.
// @param editor The editor instance is passed in as a convenience
run: async function () {
try {
await formatSql();
} catch (e: any) {
e.message && ElMessage.error(e.message);
}
},
});
// 如果sql有值则默认赋值
if (state.sql) {
monacoEditor.getModel()?.setValue(state.sql);
}
};
/**
* 拖拽改变sql编辑区和查询结果区高度
*/
const onDragSetHeight = () => {
document.onmousemove = (e) => {
e.preventDefault();
//得到鼠标拖动的宽高距离:取绝对值
state.editorHeight = `${document.getElementById('MonacoTextarea-' + state.ti.key)!.offsetHeight + e.movementY}px`;
state.tableDataHeight -= e.movementY;
};
document.onmouseup = () => {
document.onmousemove = null;
};
};
/**
* 执行sql
*/
const onRunSql = async () => {
// 没有选中的文本,则为全部文本
let sql = getSql() as string;
notBlank(sql && sql.trim(), '请选中需要执行的sql');
// 去除字符串前的空格、换行等
sql = sql.replace(/(^\s*)/g, '');
let execRemark = '';
let canRun = true;
if (
sql.startsWith('update') ||
sql.startsWith('UPDATE') ||
sql.startsWith('INSERT') ||
sql.startsWith('insert') ||
sql.startsWith('DELETE') ||
sql.startsWith('delete')
) {
const res: any = await ElMessageBox.prompt('请输入备注', 'Tip', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /^[\s\S]*.*[^\s][\s\S]*$/,
inputErrorMessage: '请输入执行该sql的备注信息',
});
execRemark = res.value;
if (!execRemark) {
canRun = false;
}
}
if (!canRun) {
return;
}
try {
state.loading = true;
const colAndData: any = await state.ti.getNowDbInst().runSql(state.ti.db, sql, execRemark);
if (!colAndData.res || colAndData.res.length === 0) {
ElMessage.warning('未查询到结果集');
}
state.execRes.data = colAndData.res;
// 兼容表格字段配置
state.execRes.tableColumn = colAndData.colNames.map((x: any) => {
return {
columnName: x,
show: true,
};
});
cancelUpdateFields();
} catch (e: any) {
state.execRes.data = [];
state.execRes.tableColumn = [];
state.table = '';
return;
} finally {
state.loading = false;
}
// 即只有以该字符串开头的sql才可修改表数据内容
if (sql.startsWith('SELECT *') || sql.startsWith('select *') || sql.startsWith('SELECT\n *')) {
state.selectionDatas = [];
const tableName = sql.split(/from/i)[1];
if (tableName) {
const tn = tableName.trim().split(' ')[0].split('\n')[0];
state.table = tn;
state.table = tn;
} else {
state.table = '';
}
} else {
state.table = '';
}
};
/**
* 获取sql如果有鼠标选中则返回选中内容否则返回输入框内所有内容
*/
const getSql = () => {
let res = '' as string | undefined;
// 编辑器还没初始化
if (!monacoEditor?.getModel) {
return res;
}
// 选择选中的sql
let selection = monacoEditor.getSelection();
if (selection) {
res = monacoEditor.getModel()?.getValueInRange(selection);
}
// 整个编辑器的sql
if (!res) {
return monacoEditor.getModel()?.getValue();
}
return res;
};
const saveSql = async () => {
const sql = monacoEditor.getModel()?.getValue();
notBlank(sql, 'sql内容不能为空');
let sqlName = state.sqlName;
const newSql = !sqlName;
if (newSql) {
try {
const input = await ElMessageBox.prompt('请输入SQL脚本名', 'SQL名', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /\w+/,
inputErrorMessage: '请输入SQL脚本名',
});
sqlName = input.value;
state.sqlName = sqlName;
} catch (e) {
return;
}
}
await dbApi.saveSql.request({ id: state.ti.dbId, db: state.ti.db, sql: sql, type: 1, name: sqlName });
ElMessage.success('保存成功');
// 保存sql脚本成功事件
emits('saveSqlSuccess', state.ti.dbId, state.ti.db);
};
const deleteSql = async () => {
const sqlName = state.sqlName;
notBlank(sqlName, '该sql内容未保存');
const { dbId, db } = state.ti;
try {
await ElMessageBox.confirm(`确定删除【${sqlName}】该SQL内容?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await dbApi.deleteDbSql.request({ id: dbId, db: db, name: sqlName });
ElMessage.success('删除成功');
emits('deleteSqlSuccess', dbId, db);
} catch (err) {}
};
/**
* 格式化sql
*/
const formatSql = () => {
let selection = monacoEditor.getSelection();
if (!selection) {
return;
}
let sql = monacoEditor.getModel()?.getValueInRange(selection);
// 有选中sql则格式化并替换选中sql, 否则格式化编辑器所有内容
if (sql) {
replaceSelection(sqlFormatter(sql), selection);
return;
}
monacoEditor.getModel()?.setValue(sqlFormatter(monacoEditor.getValue()));
};
/**
* 提交事务,用于没有开启自动提交事务
*/
const onCommit = () => {
state.ti.getNowDbInst().runSql(state.ti.db, 'COMMIT;');
ElMessage.success('COMMIT success');
};
/**
* 替换选中的内容
*/
const replaceSelection = (str: string, selection: any) => {
const model = monacoEditor.getModel();
if (!model) {
return;
}
if (!selection) {
model.setValue(str);
return;
}
const { startLineNumber, endLineNumber, startColumn, endColumn } = selection;
const textBeforeSelection = model.getValueInRange({
startLineNumber: 1,
startColumn: 0,
endLineNumber: startLineNumber,
endColumn: startColumn,
});
const textAfterSelection = model.getValueInRange({
startLineNumber: endLineNumber,
startColumn: endColumn,
endLineNumber: model.getLineCount(),
endColumn: model.getLineMaxColumn(model.getLineCount()),
});
monacoEditor.setValue(textBeforeSelection + str + textAfterSelection);
monacoEditor.focus();
monacoEditor.setPosition({
lineNumber: startLineNumber,
column: 0,
});
};
const onLimit = () => {
let position = monacoEditor.getPosition() as monaco.Position;
let newText = ' limit 10';
monacoEditor?.getModel()?.applyEdits([
{
range: new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column),
text: newText,
},
]);
};
/**
* 导出当前页数据
*/
const exportData = () => {
const dataList = state.execRes.data as any;
isTrue(dataList.length > 0, '没有数据可导出');
exportCsv(
`数据查询导出-${dateStrFormat('yyyyMMddHHmm', new Date().toString())}`,
state.execRes.tableColumn.map((x: any) => x.columnName),
dataList
);
};
const beforeUpload = (file: File) => {
ElMessage.success(`'${file.name}' 正在上传执行, 请关注结果通知`);
};
// 执行sql成功
const execSqlFileSuccess = (res: any) => {
if (res.code !== 200) {
ElMessage.error(res.msg);
}
};
// 获取sql文件上传执行url
const getUploadSqlFileUrl = () => {
return `${config.baseApiUrl}/dbs/${state.ti.dbId}/exec-sql-file?db=${state.ti.db}`;
};
const onDataSelectionChange = (datas: []) => {
state.selectionDatas = datas;
};
const changeUpdatedField = (updatedFields: []) => {
// 如果存在要更新字段,则显示提交和取消按钮
state.hasUpdatedFileds = updatedFields && updatedFields.length > 0;
};
/**
* 执行删除数据事件
*/
const onDeleteData = async () => {
const deleteDatas = state.selectionDatas;
isTrue(deleteDatas && deleteDatas.length > 0, '请先选择要删除的数据');
const { db } = state.ti;
const dbInst = state.ti.getNowDbInst();
const primaryKey = await dbInst.loadTableColumn(db, state.table);
const primaryKeyColumnName = primaryKey.columnName;
dbInst.promptExeSql(db, dbInst.genDeleteByPrimaryKeysSql(db, state.table, deleteDatas), null, () => {
state.execRes.data = state.execRes.data.filter(
(d: any) => !(deleteDatas.findIndex((x: any) => x[primaryKeyColumnName] == d[primaryKeyColumnName]) != -1)
);
state.selectionDatas = [];
});
};
const submitUpdateFields = () => {
dbTableRef.value.submitUpdateFields();
};
const cancelUpdateFields = () => {
dbTableRef.value.cancelUpdateFields();
};
const registerSqlCompletionItemProvider = () => {
// 参考 https://microsoft.github.io/monaco-editor/playground.html#extending-language-services-completion-provider-example
self.completionItemProvider =
self.completionItemProvider ||
monaco.languages.registerCompletionItemProvider('sql', {
triggerCharacters: ['.', ' '],
provideCompletionItems: async (model: editor.ITextModel, position: Position): Promise<languages.CompletionList | null | undefined> => {
let word = model.getWordUntilPosition(position);
const nowTab = props.data;
if (!nowTab) {
return;
}
const { db, dbId } = nowTab;
const dbInst = DbInst.getInst(dbId);
const { lineNumber, column } = position;
const { startColumn, endColumn } = word;
// 当前行文本
let lineContent = model.getLineContent(lineNumber);
// 注释行不需要代码提示
if (lineContent.startsWith('--')) {
return { suggestions: [] };
}
let range = {
startLineNumber: lineNumber,
endLineNumber: lineNumber,
startColumn,
endColumn,
};
// 光标前文本
const textBeforePointer = model.getValueInRange({
startLineNumber: lineNumber,
startColumn: 0,
endLineNumber: lineNumber,
endColumn: column,
});
const textBeforePointerMulti = model.getValueInRange({
startLineNumber: 1,
startColumn: 0,
endLineNumber: lineNumber,
endColumn: column,
});
// 光标后文本
const textAfterPointerMulti = model.getValueInRange({
startLineNumber: lineNumber,
startColumn: column,
endLineNumber: model.getLineCount(),
endColumn: model.getLineMaxColumn(model.getLineCount()),
});
// // const nextTokens = textAfterPointer.trim().split(/\s+/)
// // const nextToken = nextTokens[0].toLowerCase()
const tokens = textBeforePointer.trim().split(/\s+/);
let lastToken = tokens[tokens.length - 1].toLowerCase();
const secondToken = (tokens.length > 2 && tokens[tokens.length - 2].toLowerCase()) || '';
const dbs = (nowTab.params && nowTab.params.dbs && nowTab.params.dbs) || [];
// console.log("光标前文本:=>" + textBeforePointerMulti)
// console.log("最后输入的:=>" + lastToken)
let suggestions: languages.CompletionItem[] = [];
const tables = await dbInst.loadTables(db);
async function hintTableColumns(tableName: any, db: any) {
let dbHits = await dbInst.loadDbHints(db);
let columns = dbHits[tableName];
let suggestions: languages.CompletionItem[] = [];
columns?.forEach((a: string, index: any) => {
// 字段数据格式 字段名 字段注释, 如: create_time [datetime][创建时间]
const nameAndComment = a.split(' ');
const fieldName = nameAndComment[0];
suggestions.push({
label: {
label: a,
description: 'column',
},
kind: monaco.languages.CompletionItemKind.Property,
detail: '', // 不显示detail, 否则选中时备注等会被遮挡
insertText: fieldName, // create_time
range,
sortText: 100 + index + '', // 使用表字段声明顺序排序,排序需为字符串类型
});
});
return suggestions;
}
if (lastToken.indexOf('.') > -1 || secondToken.indexOf('.') > -1) {
// 如果是.触发代码提示,则进行【 库.表名联想 】 或 【 表别名.表字段联想 】
let str = lastToken.substring(0, lastToken.lastIndexOf('.'));
if (lastToken.trim().startsWith('.')) {
str = secondToken;
}
// 如果字符串粘连起了如:'a.creator,a.',需要重新取出别名
let aliasArr = lastToken.split(',');
if (aliasArr.length > 1) {
lastToken = aliasArr[aliasArr.length - 1];
str = lastToken.substring(0, lastToken.lastIndexOf('.'));
if (lastToken.trim().startsWith('.')) {
str = secondToken;
}
}
// 库.表名联想
if (dbs && dbs.filter((a: any) => a === str)?.length > 0) {
let tables = await dbInst.loadTables(str);
let suggestions: languages.CompletionItem[] = [];
for (let item of tables) {
const { tableName, tableComment } = item;
suggestions.push({
label: {
label: tableName + (tableComment ? ' - ' + tableComment : ''),
description: 'table',
},
kind: monaco.languages.CompletionItemKind.File,
insertText: tableName,
range,
});
}
return { suggestions };
}
let sql = textBeforePointerMulti.split(';')[textBeforePointerMulti.split(';').length - 1] + textAfterPointerMulti.split(';')[0];
// 表别名.表字段联想
let tableInfo = getTableByAlias(sql, db, str);
if (tableInfo.tableName) {
let tableName = tableInfo.tableName;
let db = tableInfo.dbName;
// 取出表名并提示
let suggestions = await hintTableColumns(tableName, db);
if (suggestions.length > 0) {
return { suggestions };
}
}
return { suggestions: [] };
} else {
// 如果sql里含有表名则提示表字段
let mat = textBeforePointerMulti.match(/[from|update]\n*\s+\n*(\w+)\n*\s+\n*/i);
if (mat && mat.length > 1) {
let tableName = mat[1];
// 取出表名并提示
let addSuggestions = await hintTableColumns(tableName, db);
if (addSuggestions.length > 0) {
suggestions = suggestions.concat(addSuggestions);
}
}
}
// 表名联想
tables.forEach((tableMeta: any) => {
const { tableName, tableComment } = tableMeta;
suggestions.push({
label: {
label: tableName + ' - ' + tableComment,
description: 'table',
},
kind: monaco.languages.CompletionItemKind.File,
detail: tableComment,
insertText: tableName + ' ',
range,
});
});
// mysql关键字
sqlCompletionKeywords.forEach((item: any) => {
suggestions.push({
label: {
label: item,
description: 'keyword',
},
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: item,
range,
});
});
// 操作符
sqlCompletionOperators.forEach((item: any) => {
suggestions.push({
label: {
label: item,
description: 'opt',
},
kind: monaco.languages.CompletionItemKind.Operator,
insertText: item,
range,
});
});
let replacedFunctions = [] as string[];
// 添加的函数
addSqlLanguage.replaceFunctions.forEach((item: any) => {
replacedFunctions.push(item.label);
suggestions.push({
label: {
label: item.label,
description: item.description,
},
kind: monaco.languages.CompletionItemKind.Function,
insertText: item.insertText,
range,
});
});
// 内置函数
sqlCompletionBuiltinFunctions.forEach((item: any) => {
replacedFunctions.indexOf(item) < 0 &&
suggestions.push({
label: {
label: item,
description: 'func',
},
kind: monaco.languages.CompletionItemKind.Function,
insertText: item,
range,
});
});
// 内置变量
sqlCompletionBuiltinVariables.forEach((item: string) => {
suggestions.push({
label: {
label: item,
description: 'var',
},
kind: monaco.languages.CompletionItemKind.Variable,
insertText: item,
range,
});
});
// 库名提示
if (dbs && dbs.length > 0) {
dbs.forEach((a: any) => {
suggestions.push({
label: {
label: a,
description: 'schema',
},
kind: monaco.languages.CompletionItemKind.Folder,
insertText: a,
range,
});
});
}
// 默认提示
return {
suggestions: suggestions,
};
},
});
};
/**
* 根据别名获取sql里的表名
* @param sql sql
* @param db 默认数据库
* @param alias 别名
*/
const getTableByAlias = (sql: string, db: string, alias: string): { dbName: string; tableName: string } => {
// 表别名:表名
let result = {};
let defName = '';
let defResult = {};
// 正则匹配取出表名和表别名
// 测试sql
/*
`select * from database.Outvisit l
left join patient p on l.patid=p.patientid
join patstatic c on l.patid=c.patid inner join patphone ph on l.patid=ph.patid
where l.name='kevin' and exsits(select 1 from pharmacywestpas pw where p.outvisitid=l.outvisitid)
unit all
select * from invisit v where`.match(/(join|from)\s+(\w*-?\w*\.?\w+)\s*(as)?\s*(\w*)/gi)
*/
let match = sql.match(/(join|from)\n*\s+\n*(\w*-?\w*\.?\w+)\s*(as)?\s*(\w*)\n*/gi);
if (match && match.length > 0) {
match.forEach((a) => {
// 去掉前缀,取出
let t = a
.substring(5, a.length)
.replaceAll(/\s+/g, ' ')
.replaceAll(/\s+as\s+/gi, ' ')
.replaceAll(/\r\n/g, ' ')
.trim()
.split(/\s+/);
let withDb = t[0].split('.');
// 表名是 db名.表名
let tName = withDb.length > 1 ? withDb[1] : withDb[0];
let dbName = withDb.length > 1 ? withDb[0] : db || '';
if (t.length == 2) {
// 表别名:表名
result[t[1]] = { tableName: tName, dbName };
} else {
// 只有表名无别名 取第一个无别名的表为默认表
!defName && (defResult = { tableName: tName, dbName: db });
}
});
}
return result[alias] || defResult;
};
</script>
<style lang="scss">
.sql-file-exec {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
vertical-align: middle;
position: relative;
text-decoration: none;
}
.sqlEditor {
font-size: 8pt;
font-weight: 600;
border: 1px solid var(--el-border-color-light, #ebeef5);
}
.update_field_active {
background-color: var(--el-color-success);
}
.editor-move-resize {
cursor: n-resize;
height: 3px;
text-align: center;
}
</style>

View File

@@ -0,0 +1,750 @@
<template>
<div class="db-table-data mt5" :style="{ height: `${tableHeight}px` }">
<el-auto-resizer>
<template #default="{ height, width }">
<el-table-v2
ref="tableRef"
:header-height="30"
:row-height="30"
:row-class="rowClass"
:columns="state.columns"
:data="datas"
:width="width"
:height="height"
fixed
class="table"
:row-event-handlers="rowEventHandlers"
>
<template #header="{ columns }">
<div v-for="(column, i) in columns" :key="i">
<div
:style="{
width: `${column.width}px`,
height: '100%',
lineHeight: '30px',
textAlign: 'center',
borderRight: 'var(--el-table-border)',
}"
>
<!-- 行号列表头 -->
<div v-if="column.key == rowNoColumn.key || !showColumnTip">
<el-text tag="b"> {{ column.title }} </el-text>
</div>
<div v-else @contextmenu="headerContextmenuClick($event, column)">
<div v-if="showColumnTip" @mouseover="column.showSetting = true" @mouseleave="column.showSetting = false">
<el-tooltip :show-after="500" raw-content placement="top">
<template #content> {{ getColumnTip(column) }} </template>
<el-text tag="b" style="cursor: pointer"> {{ column.title }} </el-text>
</el-tooltip>
<span>
<SvgIcon
color="var(--el-color-primary)"
v-if="column.title == nowSortColumn?.columnName"
:name="nowSortColumn?.order == 'asc' ? 'top' : 'bottom'"
></SvgIcon>
</span>
</div>
<div v-else>
<el-text tag="b" style="cursor: pointer"> {{ column.title }} </el-text>
</div>
</div>
</div>
</div>
</template>
<template #cell="{ rowData, column, rowIndex, columnIndex }">
<div @contextmenu="dataContextmenuClick($event, rowIndex, column, rowData)" class="table-data-cell">
<!-- 行号列 -->
<div v-if="column.key == 'tableDataRowNo'">
<el-text tag="b" size="small">
{{ rowIndex + 1 }}
</el-text>
</div>
<!-- 数据列 -->
<div v-else @dblclick="onEnterEditMode(rowData, column, rowIndex, columnIndex)">
<div v-if="canEdit(rowIndex, columnIndex)">
<el-input
:ref="(el: any) => el?.focus()"
@blur="onExitEditMode(rowData, column, rowIndex)"
class="w100"
input-style="text-align: center; height: 26px;"
size="small"
v-model="rowData[column.dataKey!]"
></el-input>
</div>
<div v-else :class="isUpdated(rowIndex, column.dataKey) ? 'update_field_active' : ''">
<el-text style="color: var(--el-color-info-light-5)" v-if="rowData[column.dataKey!] === null" size="small" truncated>
NULL
</el-text>
<el-text v-else :title="rowData[column.dataKey!]" size="small" truncated>
{{ rowData[column.dataKey!] }}
</el-text>
</div>
</div>
</div>
</template>
<template v-if="loading" #overlay>
<div class="el-loading-mask" style="display: flex; align-items: center; justify-content: center">
<SvgIcon class="is-loading" name="loading" color="var(--el-color-primary)" :size="42" />
</div>
</template>
<template #empty>
<div style="text-align: center">
<el-empty :style="{ height: `${tableHeight}px` }" :description="state.emptyText" :image-size="100" />
</div>
</template>
</el-table-v2>
</template>
</el-auto-resizer>
<el-dialog @close="state.genTxtDialog.visible = false" v-model="state.genTxtDialog.visible" :title="state.genTxtDialog.title" width="1000px">
<template #header>
<div class="mr15" style="display: flex; justify-content: flex-end">
<el-button id="copyValue" @click="copyGenTxt(state.genTxtDialog.txt)" icon="CopyDocument" type="success" size="small">一键复制</el-button>
</div>
</template>
<el-input v-model="state.genTxtDialog.txt" type="textarea" rows="20" />
</el-dialog>
<contextmenu :dropdown="state.contextmenu.dropdown" :items="state.contextmenu.items" ref="contextmenuRef" />
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, watch, reactive, toRefs } from 'vue';
import { ElInput } from 'element-plus';
import { copyToClipboard } from '@/common/utils/string';
import { DbInst } from '@/views/ops/db/db';
import { ContextmenuItem, Contextmenu } from '@/components/contextmenu';
import SvgIcon from '@/components/svgIcon/index.vue';
import { exportCsv, exportFile } from '@/common/utils/export';
import { dateStrFormat } from '@/common/utils/date';
const emits = defineEmits(['dataDelete', 'sortChange', 'deleteData', 'selectionChange', 'changeUpdatedField']);
const props = defineProps({
dbId: {
type: Number,
required: true,
},
dbType: {
type: String,
default: '',
},
db: {
type: String,
required: true,
},
table: {
type: String,
default: '',
},
data: {
type: Array,
},
columns: {
type: Array<any>,
},
loading: {
type: Boolean,
default: false,
},
emptyText: {
type: String,
default: '暂无数据',
},
showColumnTip: {
type: Boolean,
default: false,
},
height: {
type: Number,
default: 600,
},
});
const contextmenuRef = ref();
const tableRef = ref();
/** 表头 contextmenu items **/
const cmHeaderAsc = new ContextmenuItem('asc', '升序').withIcon('top').withOnClick((data: any) => {
onTableSortChange({ columnName: data.dataKey, order: 'asc' });
});
const cmHeaderDesc = new ContextmenuItem('desc', '降序').withIcon('bottom').withOnClick((data: any) => {
onTableSortChange({ columnName: data.dataKey, order: 'desc' });
});
const cmHeaderFixed = new ContextmenuItem('fixed', '固定')
.withIcon('Paperclip')
.withOnClick((data: any) => {
data.fixed = true;
})
.withHideFunc((data: any) => data.fixed);
const cmHeaderCancenFixed = new ContextmenuItem('cancelFixed', '取消固定')
.withIcon('Minus')
.withOnClick((data: any) => (data.fixed = false))
.withHideFunc((data: any) => !data.fixed);
/** 表数据 contextmenu items **/
const cmDataCopyCell = new ContextmenuItem('copyValue', '复制')
.withIcon('CopyDocument')
.withOnClick(async (data: any) => {
await copyToClipboard(data.rowData[data.column.dataKey]);
})
.withHideFunc(() => {
// 选中多条则隐藏该复制按钮
return selectionRowsMap.size > 1;
});
const cmDataDel = new ContextmenuItem('deleteData', '删除')
.withIcon('delete')
.withOnClick(() => onDeleteData())
.withHideFunc(() => {
return state.table == '';
});
const cmDataGenInsertSql = new ContextmenuItem('genInsertSql', 'Insert SQL')
.withIcon('tickets')
.withOnClick(() => onGenerateInsertSql())
.withHideFunc(() => {
return state.table == '';
});
const cmDataGenJson = new ContextmenuItem('genJson', '生成JSON').withIcon('tickets').withOnClick(() => onGenerateJson());
const cmDataExportCsv = new ContextmenuItem('exportCsv', '导出CSV').withIcon('document').withOnClick(() => onExportCsv());
const cmDataExportSql = new ContextmenuItem('exportSql', '导出SQL')
.withIcon('document')
.withOnClick(() => onExportSql())
.withHideFunc(() => {
return state.table == '';
});
class NowUpdateCell {
rowIndex: number;
colIndex: number;
oldValue: any;
}
class UpdatedRow {
/**
* 主键值
*/
primaryValue: any;
/**
* 行数据
*/
rowData: any;
/**
* 修改到的列信息, columnName -> tablecelldata
*/
columnsMap: Map<string, TableCellData> = new Map();
}
class TableCellData {
/**
* 旧值
*/
oldValue: any;
}
let nowSortColumn = null as any;
// 当前正在更新的单元格
let nowUpdateCell: NowUpdateCell = null as any;
// 选中的数据, key->rowIndex value->primaryKeyValue
const selectionRowsMap: Map<number, any> = new Map();
// 更新单元格 key-> rowIndex value -> 更新行
const cellUpdateMap: Map<number, UpdatedRow> = new Map();
const state = reactive({
dbId: 0, // 当前选中操作的数据库实例
dbType: '',
db: '', // 数据库名
table: '', // 当前的表名
datas: [],
columns: [] as any,
loading: false,
tableHeight: 600,
emptyText: '',
contextmenu: {
dropdown: {
x: 0,
y: 0,
},
items: [] as ContextmenuItem[],
},
genTxtDialog: {
title: 'SQL',
visible: false,
txt: '',
},
});
const { tableHeight, datas } = toRefs(state);
/**
* 行号字段列
*/
const rowNoColumn = {
title: 'No.',
key: 'tableDataRowNo',
dataKey: 'tableDataRowNo',
width: 45,
fixed: true,
align: 'center',
headerClass: 'table-column',
class: 'table-column',
};
watch(
() => props.data,
(newValue: any) => {
setTableData(newValue);
}
);
watch(
() => props.columns,
(newValue: any) => {
// 赋值列字段值是否隐藏state.columns多了一列索引列
if (newValue.length + 1 == state.columns.length) {
for (let i = 0; i < newValue.length; i++) {
state.columns[i + 1].hidden = !newValue[i].show;
}
}
},
{
deep: true,
}
);
watch(
() => props.table,
(newValue: any) => {
state.table = newValue;
}
);
watch(
() => props.height,
(newValue: any) => {
state.tableHeight = newValue;
}
);
watch(
() => props.loading,
(newValue: any) => {
state.loading = newValue;
}
);
onMounted(async () => {
console.log('in DbTable mounted');
state.tableHeight = props.height;
state.loading = props.loading;
state.emptyText = props.emptyText;
state.dbId = props.dbId;
state.dbType = props.dbType;
state.db = props.db;
state.table = props.table;
setTableData(props.data);
});
const setTableData = (datas: any) => {
tableRef.value.scrollTo({ scrollLeft: 0, scrollTop: 0 });
selectionRowsMap.clear();
cellUpdateMap.clear();
state.datas = datas;
setTableColumns(props.columns);
};
const setTableColumns = (columns: any) => {
state.columns = columns.map((x: any) => {
const columnName = x.columnName;
return {
...x,
key: columnName,
dataKey: columnName,
width: DbInst.flexColumnWidth(columnName, state.datas),
title: columnName,
align: 'center',
headerClass: 'table-column',
class: 'table-column',
sortable: true,
hidden: !x.show,
};
});
if (state.columns.length > 0) {
state.columns.unshift(rowNoColumn);
}
};
/**
* 当前单元格是否允许编辑
* @param rowIndex ri
* @param colIndex ci
*/
const canEdit = (rowIndex: number, colIndex: number) => {
return state.table && nowUpdateCell && nowUpdateCell.rowIndex == rowIndex && nowUpdateCell.colIndex == colIndex;
};
/**
* 判断当前单元格是否被更新了
* @param rowIndex ri
* @param columnName cn
*/
const isUpdated = (rowIndex: number, columnName: string) => {
return cellUpdateMap.get(rowIndex)?.columnsMap.get(columnName);
};
/**
* 判断当前行是否被选中
* @param rowIndex
*/
const isSelection = (rowIndex: number): boolean => {
return selectionRowsMap.get(rowIndex);
};
/**
* 选中指定行
* @param rowIndex
* @param rowData
* @param isMultiple 是否允许多选
*/
const selectionRow = (rowIndex: number, rowData: any, isMultiple = false) => {
if (isMultiple) {
// 如果重复点击,则取消改选中数据
if (selectionRowsMap.get(rowIndex)) {
selectionRowsMap.delete(rowIndex);
triggerRefresh();
return;
}
} else {
selectionRowsMap.clear();
}
selectionRowsMap.set(rowIndex, rowData);
triggerRefresh();
};
/**
* 行事件处理
*/
const rowEventHandlers = {
onClick: (e: any) => {
const event = e.event;
const rowIndex = e.rowIndex;
const rowData = e.rowData;
// 按住ctrl点击则新建标签页打开, metaKey对应mac command键
if (event.ctrlKey || event.metaKey) {
selectionRow(rowIndex, rowData, true);
return;
}
selectionRow(rowIndex, rowData);
},
};
const headerContextmenuClick = (event: any, data: any) => {
event.preventDefault(); // 阻止默认的右击菜单行为
const { clientX, clientY } = event;
state.contextmenu.dropdown.x = clientX;
state.contextmenu.dropdown.y = clientY;
state.contextmenu.items = [cmHeaderAsc, cmHeaderDesc, cmHeaderFixed, cmHeaderCancenFixed];
contextmenuRef.value.openContextmenu(data);
};
const dataContextmenuClick = (event: any, rowIndex: number, column: any, data: any) => {
event.preventDefault(); // 阻止默认的右击菜单行为
// 当前行未选中,则单行选中该行
if (!isSelection(rowIndex)) {
selectionRow(rowIndex, data);
}
const { clientX, clientY } = event;
state.contextmenu.dropdown.x = clientX;
state.contextmenu.dropdown.y = clientY;
state.contextmenu.items = [cmDataCopyCell, cmDataDel, cmDataGenInsertSql, cmDataGenJson, cmDataExportCsv, cmDataExportSql];
contextmenuRef.value.openContextmenu({ column, rowData: data });
};
/**
* 表排序字段变更
*/
const onTableSortChange = async (sort: any) => {
nowSortColumn = sort;
cancelUpdateFields();
emits('sortChange', sort);
};
/**
* 执行删除数据事件
*/
const onDeleteData = async () => {
const deleteDatas = Array.from(selectionRowsMap.values());
const db = state.db;
const dbInst = getNowDbInst();
dbInst.promptExeSql(db, await dbInst.genDeleteByPrimaryKeysSql(db, state.table, deleteDatas as any), null, () => {
emits('dataDelete', deleteDatas);
});
};
const onGenerateInsertSql = async () => {
const selectionDatas = Array.from(selectionRowsMap.values());
state.genTxtDialog.txt = await getNowDbInst().genInsertSql(state.db, state.table, selectionDatas);
state.genTxtDialog.title = 'SQL';
state.genTxtDialog.visible = true;
};
const onGenerateJson = async () => {
const selectionDatas = Array.from(selectionRowsMap.values());
// 按列字段重新排序对象key
const jsonObj = [];
for (let selectionData of selectionDatas) {
let obj = {};
for (let column of state.columns) {
if (column.show) {
obj[column.title] = selectionData[column.dataKey];
}
}
jsonObj.push(obj);
}
state.genTxtDialog.txt = JSON.stringify(jsonObj, null, 4);
state.genTxtDialog.title = 'JSON';
state.genTxtDialog.visible = true;
};
const copyGenTxt = async (txt: string) => {
await copyToClipboard(txt);
state.genTxtDialog.visible = false;
};
/**
* 导出当前页数据
*/
const onExportCsv = () => {
const dataList = state.datas as any;
let columnNames = [];
for (let column of state.columns) {
if (column.show) {
columnNames.push(column.columnName);
}
}
exportCsv(`数据导出-${state.table}-${dateStrFormat('yyyyMMddHHmm', new Date().toString())}`, columnNames, dataList);
};
const onExportSql = async () => {
const selectionDatas = state.datas;
exportFile(
`数据导出-${state.table}-${dateStrFormat('yyyyMMddHHmm', new Date().toString())}.sql`,
await getNowDbInst().genInsertSql(state.db, state.table, selectionDatas)
);
};
const onEnterEditMode = (rowData: any, column: any, rowIndex = 0, columnIndex = 0) => {
if (!state.table) {
return;
}
triggerRefresh();
nowUpdateCell = {
rowIndex: rowIndex,
colIndex: columnIndex,
oldValue: rowData[column.dataKey],
};
};
const onExitEditMode = (rowData: any, column: any, rowIndex = 0) => {
const oldValue = nowUpdateCell.oldValue;
const newValue = rowData[column.dataKey];
// 未改变单元格值
if (oldValue == newValue) {
nowUpdateCell = null as any;
triggerRefresh();
return;
}
let updatedRow = cellUpdateMap.get(rowIndex);
if (!updatedRow) {
updatedRow = new UpdatedRow();
updatedRow.rowData = rowData;
cellUpdateMap.set(rowIndex, updatedRow);
}
const columnName = column.dataKey;
let cellData = updatedRow.columnsMap.get(columnName);
if (cellData) {
// 多次修改情况,可能又修改回原值,则移除该修改单元格
if (cellData.oldValue == newValue) {
cellUpdateMap.delete(rowIndex);
}
} else {
cellData = new TableCellData();
cellData.oldValue = oldValue;
updatedRow.columnsMap.set(columnName, cellData);
}
nowUpdateCell = null as any;
triggerRefresh();
changeUpdatedField();
};
const submitUpdateFields = async () => {
const dbInst = getNowDbInst();
if (cellUpdateMap.size == 0) {
return;
}
const db = state.db;
let res = '';
for (let updateRow of cellUpdateMap.values()) {
let sql = `UPDATE ${dbInst.wrapName(state.table)} SET `;
const rowData = updateRow.rowData;
// 主键列信息
const primaryKey = await dbInst.loadTableColumn(db, state.table);
let primaryKeyType = primaryKey.columnType;
let primaryKeyName = primaryKey.columnName;
let primaryKeyValue = rowData[primaryKeyName];
for (let k of updateRow.columnsMap.keys()) {
const v = updateRow.columnsMap.get(k);
if (!v) {
continue;
}
// 更新字段列信息
const updateColumn = await dbInst.loadTableColumn(db, state.table, k);
sql += ` ${dbInst.wrapName(k)} = ${DbInst.wrapColumnValue(updateColumn.columnType, rowData[k])},`;
// 如果修改的字段是主键
if (k === primaryKeyName) {
primaryKeyValue = v.oldValue;
}
}
sql = sql.substring(0, sql.length - 1);
sql += ` WHERE ${dbInst.wrapName(primaryKeyName)} = ${DbInst.wrapColumnValue(primaryKeyType, primaryKeyValue)} ;`;
res += sql;
}
dbInst.promptExeSql(
db,
res,
() => {},
() => {
triggerRefresh();
cellUpdateMap.clear();
changeUpdatedField();
}
);
};
const cancelUpdateFields = () => {
const updateRows = cellUpdateMap.values();
// 恢复原值
for (let updateRow of updateRows) {
const rowData = updateRow.rowData;
updateRow.columnsMap.forEach((v: TableCellData, k: string) => {
rowData[k] = v.oldValue;
});
}
cellUpdateMap.clear();
changeUpdatedField();
};
const changeUpdatedField = () => {
emits('changeUpdatedField', cellUpdateMap);
};
const rowClass = (row: any) => {
if (isSelection(row.rowIndex)) {
return 'data-selection';
}
if (row.rowIndex % 2 != 0) {
return 'data-spacing';
}
return '';
};
const getColumnTip = (column: any) => {
const comment = column.columnComment;
return `${column.columnType} ${comment ? ' | ' + comment : ''}`;
};
/**
* 触发响应式实时刷新,否则需要滑动或移动才能使样式实时生效
*/
const triggerRefresh = () => {
// 改变columns等属性值才能触发slot中的if条件等, 暂不知为啥
if (state.columns[0].opTimes) {
state.columns[0].opTimes = state.columns[0].opTimes + 1;
} else {
state.columns[0].opTimes = 1;
}
};
const getNowDbInst = () => {
return DbInst.getInst(state.dbId);
};
defineExpose({
submitUpdateFields,
cancelUpdateFields,
});
</script>
<style lang="scss">
.db-table-data {
.table {
border-left: var(--el-table-border);
border-top: var(--el-table-border);
}
.table-column {
padding: 0 2px;
font-size: 12px;
border-right: var(--el-table-border);
}
.table-data-cell {
width: 100%;
height: 100%;
line-height: 30px;
cursor: pointer;
}
.data-selection {
background-color: var(--el-table-current-row-bg-color);
}
.data-spacing {
background-color: var(--el-fill-color-lighter);
}
.update_field_active {
background-color: var(--el-color-success);
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More