69 Commits

Author SHA1 Message Date
meilin.huang
f4a64b96a9 feat: v1.7.1新增支持sqlite&oracle分页限制等问题修复 2024-01-19 21:33:37 +08:00
zongyangleo
9a59749763 !86 dbms支持sqlite和一些bug修复
* fix: 达梦数据库连接修复,以支持带特殊字符的密码和schema
* fix: oracle bug修复
* feat: dbms支持sqlite
* fix: dbms 修改字段名bug
2024-01-19 08:59:35 +00:00
kanzihuang
b017b902f8 !85 fix: 修复 BINLOG同步任务加载问题
* Merge branch 'dev' of gitee.com:dromara/mayfly-go into feat-db-bak
* fix: 修复 BINLOG 同步任务加载问题
2024-01-19 00:40:44 +00:00
meilin.huang
7c53353c60 fix: sqlite数据问题时间类型问题修复等 2024-01-18 17:18:17 +08:00
meilin.huang
63f0615445 feat: v1.7.0 2024-01-17 17:02:15 +08:00
kanzihuang
94da6df33e !84 fix: 修复数据库备份与恢复问题
* refactor dbScheduler
* fix: 按团队名称检索团队
* feat: 创建数据库资源时支持全选数据库
* refactor dbScheduler
* fix: 修复数据库备份与恢复问题
2024-01-17 08:37:22 +00:00
meilin.huang
cc3981d99c fix: 数据库编辑导致标签关联删除修复、cron组件调整 2024-01-17 12:13:18 +08:00
meilin.huang
8332d9b354 feat: 新增cron组件、cron支持6位秒级别 2024-01-16 20:04:04 +08:00
meilin.huang
493925064c feat: 机器定时删除终端操作记录 2024-01-15 20:51:41 +08:00
kanzihuang
c0232c4c75 fix: 修复数据库备份与恢复问题 (#85)
* fix: 修复数据库备份与恢复问题

* fix: 修复数据库备份与恢复问题
2024-01-15 20:11:28 +08:00
zongyangleo
b873855b44 !82 feat: dbms支持oracle数据库
* fix:oracle bug修复
* feat: dbms支持oracle数据库
2024-01-15 11:55:59 +00:00
meilin.huang
9c524292f0 refactor: 数组比较方法优化等 2024-01-13 13:38:53 +08:00
meilin.huang
bfd346e65a fix: 机器文件下载问题修复&dbm重构 2024-01-12 13:15:30 +08:00
meilin.huang
bc811cbd49 refactor: 数据同步编辑页优化等 2024-01-11 12:35:44 +08:00
kanzihuang
bbec3eca0d feat: 实现数据库备份和恢复并发调度 (#84) 2024-01-11 11:35:51 +08:00
meilin.huang
3857d674ba refactor: 数据同步编辑页调整、echarts组件重构 2024-01-10 23:41:55 +08:00
meilin.huang
25b0ae4d2f fix: model.FillBaseInfo遗漏调整完善等 2024-01-09 21:37:56 +08:00
meilin.huang
b7aa281611 refactor: code review 2024-01-09 17:31:21 +08:00
meilin.huang
3c89a285f5 feat: 数据库表格数据表头支持展示备注 2024-01-09 12:19:20 +08:00
Coder慌
d3d26c85c3 !81 fix: 数据同步相关bug修复
Merge pull request !81 from zongyangleo/dev_0109
2024-01-09 04:12:53 +00:00
刘宗洋
a764c4f974 fix: 数据同步相关bug修复 2024-01-09 10:35:13 +08:00
meilin.huang
af454f7d5d refactor: 数据同步优化, base.App、base.Repo新增Save方法 2024-01-07 21:46:25 +08:00
meilin.huang
eea759e10e refactor: code review 2024-01-06 22:36:50 +08:00
meilin.huang
e158422091 refactor: code review、数据库备份恢复支持ssh隧道操作 2024-01-05 22:16:38 +08:00
meilin.huang
5ada63d4a1 Merge branch 'dev' of https://gitee.com/objs/mayfly-go into dev 2024-01-05 20:51:50 +08:00
Coder慌
5ef35001cc !80 fix: 字段映射大小写等问题
Merge pull request !80 from zongyangleo/dev_0105_table_sync_fix
2024-01-05 12:51:32 +00:00
kanzihuang
0be50f0995 feat: 新增数据库类型 mariadb, 分别为 mysql 和 mariadb 设置不同的数据库备份与恢复程序路径 (#81) 2024-01-05 17:23:29 +08:00
刘宗洋
61e1b0ca70 fix: 字段映射大小写等问题 2024-01-05 14:43:51 +08:00
zongyangleo
85910bf440 !79 feat: 支持自定义数据定时同步
* fix: 达梦数据权限问题
* feat: 支持自定义数据定时同步
2024-01-05 05:31:32 +00:00
meilin.huang
7a7a7020b4 feat: 新增系统样式配置,支持改logo图标与标题 2024-01-05 12:09:12 +08:00
kanzihuang
ae3d2659aa 重构数据库备份与恢复模块 (#80)
* fix: 保存 LastResult 时截断字符串过长部分,以避免数据库报错

* refactor: 新增 entity.DbTaskBase 和 persistence.dbTaskBase, 用于实现数据库备份和恢复任务处理相关部分

* fix: aeskey变更后,解密密码出现数组越界访问错误

* fix: 时间属性为零值时,保存到 mysql 数据库报错

* refactor db.infrastructure.service.scheduler

* feat: 实现立即备份功能

* refactor db.infrastructure.service.db_instance

* refactor: 从数据库中获取数据库备份目录、mysql文件路径等配置信息

* fix: 数据库备份和恢复问题

* fix: 修改 .gitignore 文件,忽略数据库备份目录和数据库程序目录
2024-01-05 08:55:34 +08:00
meilin.huang
76fd6675b5 refactor: code review 2023-12-29 16:48:15 +08:00
may-fly
664118a709 Merge pull request #78 from kanzihuang/feat-db-bak
feat: 实现数据库备份与恢复
2023-12-29 08:57:57 +08:00
kanzihuang
e344722794 feat: 实现数据库备份与恢复 2023-12-29 08:30:10 +08:00
meilin.huang
1a7d425f60 refactor: 动态路由重构 2023-12-28 17:21:33 +08:00
meilin.huang
a0582192bf refactor: code review 2023-12-27 19:55:36 +08:00
meilin.huang
4ac9f02d6a refactor: code review 2023-12-26 22:31:51 +08:00
meilin.huang
7e0febef8f refactor: 页面小优化 2023-12-25 17:43:42 +08:00
meilin.huang
94ed4b77d6 refactor: 单元格编辑优化 2023-12-22 17:04:06 +08:00
meilin.huang
2b419bca11 refactor: 数据库表支持editor编辑调整 2023-12-22 12:29:46 +08:00
meilin.huang
54a0f0b3c7 refactor: 数据库虚拟table卡顿优化 2023-12-22 00:47:01 +08:00
Coder慌
86bccc3b3d !78 feat: 表格支持编辑json、xml数据
Merge pull request !78 from zongyangleo/dev_1221
2023-12-21 10:55:30 +00:00
刘宗洋
0e601b5033 feat: 表格支持编辑json、xml数据 2023-12-21 15:03:11 +08:00
meilin.huang
85d745fcee feat: 数据库表单元格编辑封装 2023-12-21 13:07:02 +08:00
meilin.huang
550631c03b refactor: 达梦ssh连接调整 2023-12-20 23:01:51 +08:00
Coder慌
f29a1560aa !77 fix: 达梦支持ssh
Merge pull request !77 from zongyangleo/dev_1220
2023-12-20 13:25:45 +00:00
刘宗洋
8c4c41cf0b fix: 达梦支持ssh 2023-12-20 20:37:29 +08:00
meilin.huang
f5c90277b1 feat: 新增数据库版本查看 2023-12-20 17:29:16 +08:00
meilin.huang
2ae0cd7ab4 refactor: 数据库表操作界面优化 2023-12-19 19:23:33 +08:00
meilin.huang
1f6c14ee2f refactor: 系统模块角色分配相关优化 2023-12-18 22:39:32 +08:00
meilin.huang
574d27f6da refactor: PageTable优化 2023-12-17 14:38:53 +08:00
meilin.huang
7d62841783 refactor: rsa存储方式调整等 2023-12-17 01:43:38 +08:00
Coder慌
970d74bd70 !75 refactor: 表格日期组件大小调整
Merge pull request !75 from zongyangleo/dev_1216
2023-12-16 16:46:00 +00:00
刘宗洋
0c797f8da9 refactor: 表格日期组件大小调整 2023-12-16 23:10:38 +08:00
meilin.huang
68f8603c75 refactor: PageTable优化 2023-12-16 17:41:15 +08:00
meilin.huang
06bce33c48 refactor: 样式调整 2023-12-15 17:33:22 +08:00
Coder慌
f8837f28c3 !74 fix: 数据库查询结果日期格式化
Merge pull request !74 from zongyangleo/dev_1215_fix
2023-12-15 07:27:47 +00:00
刘宗洋
61aed08dde fix: 数据库查询结果日期格式化 2023-12-15 15:21:54 +08:00
meilin.huang
a5a813f95f refactor: sql执行列返回字段类型 2023-12-14 21:27:11 +08:00
meilin.huang
18cf2e54c4 refactor: 样式调整 2023-12-14 13:05:21 +08:00
Coder慌
5c72b1de57 !73 fix: 达梦ddl相关
Merge pull request !73 from zongyangleo/dev_1214
2023-12-14 01:30:10 +00:00
刘宗洋
6e44e90d67 fix: 达梦数据库ddl 2023-12-14 08:24:14 +08:00
Coder慌
0e699ba20e !71 fix: 达梦数据库操作bug
Merge pull request !71 from zongyangleo/dev_1213_1
2023-12-13 11:39:42 +00:00
刘宗洋
cf24c2671f fix: 达梦数据库操作bug 2023-12-13 18:39:44 +08:00
meilin.huang
d3b99ec88d Merge branch 'dev' of https://gitee.com/objs/mayfly-go into dev 2023-12-13 17:36:50 +08:00
meilin.huang
0b5ab090a4 refactor: 报错代码调整 2023-12-13 17:32:17 +08:00
Coder慌
454698286c !70 fix: 达梦数据库大小写敏感问题
Merge pull request !70 from zongyangleo/dev_1213
2023-12-13 09:31:14 +00:00
刘宗洋
14e0aadbba fix: 达梦数据库操作bug 2023-12-13 17:26:53 +08:00
meilin.huang
73986a834c fix: 删除机器、数据库时关联的标签未删除 2023-12-13 14:01:13 +08:00
337 changed files with 20085 additions and 5824 deletions

View File

@@ -1,16 +1,16 @@
# 构建前端资源
FROM node:18-alpine3.16 as fe-builder
FROM node:18-bookworm-slim as fe-builder
WORKDIR /mayfly
COPY mayfly_go_web .
RUN yarn
RUN yarn build
RUN yarn config set registry 'https://registry.npm.taobao.org' && \
yarn install && \
yarn build
# 构建后端资源
FROM golang:1.21.0 as be-builder
FROM golang:1.21.5 as be-builder
ENV GOPROXY https://goproxy.cn
WORKDIR /mayfly
@@ -24,12 +24,14 @@ COPY --from=fe-builder /mayfly/dist /mayfly/static/static
# Build
RUN GO111MODULE=on CGO_ENABLED=0 GOOS=linux \
go build -a \
go build -a -ldflags=-w \
-o mayfly-go main.go
FROM alpine:3.16
FROM debian:bookworm-slim
RUN apk add --no-cache ca-certificates bash expat
RUN apt-get update && \
apt-get install -y ca-certificates expat libncurses5 && \
apt-get clean
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

View File

@@ -22,7 +22,7 @@
### 介绍
web 版 **linux(终端[终端回放] 文件 脚本 进程 计划任务)、数据库mysql postgres、redis(单机 哨兵 集群)、mongo 统一管理操作平台**
web 版 **linux(终端[终端回放] 文件 脚本 进程 计划任务)、数据库mysql postgres oracle 达梦 高斯 sqlite、redis(单机 哨兵 集群)、mongo 统一管理操作平台**
### 开发语言与主要框架

View File

@@ -56,7 +56,7 @@ function build() {
if [ "${os}" == "windows" ];then
execFileName="${execFileName}.exe"
fi
CGO_ENABLE=0 GOOS=${os} GOARCH=${arch} go build -o ${execFileName} main.go
CGO_ENABLE=0 GOOS=${os} GOARCH=${arch} go build -ldflags=-w -o ${execFileName} main.go
if [ -d ${toFolder} ] ; then
echo_green "目标文件夹已存在,清空文件夹"

View File

@@ -1,6 +1,8 @@
# 本地环境
ENV = 'development'
VITE_OPEN = true
# 本地环境接口地址
VITE_API_URL = '/api'

View File

@@ -1,7 +1,8 @@
.DS_Store
node_modules
/dist
*.lock
pnpm-lock.yaml
# local env files
.env.local

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2021 lyt-Top
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -9,15 +9,15 @@
"lint-fix": "eslint --fix --ext .js --ext .jsx --ext .vue src/"
},
"dependencies": {
"@element-plus/icons-vue": "^2.1.0",
"@vueuse/core": "^10.7.0",
"asciinema-player": "^3.6.2",
"@element-plus/icons-vue": "^2.3.1",
"@vueuse/core": "^10.7.2",
"asciinema-player": "^3.6.3",
"axios": "^1.6.2",
"clipboard": "^2.0.11",
"countup.js": "^2.7.0",
"cropperjs": "^1.5.11",
"echarts": "^5.4.3",
"element-plus": "^2.4.3",
"element-plus": "^2.5.2",
"js-base64": "^3.7.5",
"jsencrypt": "^3.3.2",
"lodash": "^4.17.21",
@@ -31,9 +31,9 @@
"screenfull": "^6.0.2",
"sortablejs": "^1.15.0",
"splitpanes": "^3.1.5",
"sql-formatter": "^14.0.0",
"sql-formatter": "^15.0.2",
"uuid": "^9.0.1",
"vue": "^3.3.11",
"vue": "^3.4.15",
"vue-router": "^4.2.5",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
@@ -42,21 +42,21 @@
},
"devDependencies": {
"@types/lodash": "^4.14.178",
"@types/node": "^15.6.0",
"@types/node": "^18.14.0",
"@types/nprogress": "^0.2.0",
"@types/sortablejs": "^1.15.3",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"@vitejs/plugin-vue": "^4.5.1",
"@vue/compiler-sfc": "^3.3.10",
"@vitejs/plugin-vue": "^5.0.3",
"@vue/compiler-sfc": "^3.4.14",
"dotenv": "^16.3.1",
"eslint": "^8.35.0",
"eslint-plugin-vue": "^9.19.2",
"prettier": "^3.1.0",
"sass": "^1.69.0",
"typescript": "^5.3.2",
"vite": "^5.0.7",
"vue-eslint-parser": "^9.3.2"
"vite": "^5.0.11",
"vue-eslint-parser": "^9.4.0"
},
"browserslist": [
"> 1%",

View File

@@ -22,12 +22,9 @@ import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue';
import { useRoute } from 'vue-router';
import { storeToRefs } from 'pinia';
import { useThemeConfig } from '@/store/themeConfig';
import { getLocal } from '@/common/utils/storage';
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';
import { useIntervalFn } from '@vueuse/core';
const setingsRef = ref();
@@ -49,17 +46,8 @@ onMounted(() => {
openSetingsDrawer();
});
// 获取缓存中的布局配置
const tc = getThemeConfig();
if (tc) {
themeConfigStores.setThemeConfig({ themeConfig: tc });
document.documentElement.style.cssText = getLocal('themeConfigStyle');
}
// 是否开启水印
useWatermark().then((res) => {
themeConfigStores.setWatermarkConfig(res);
});
// 初始化系统主题
themeConfigStores.initThemeConfig();
});
});

File diff suppressed because one or more lines are too long

View File

@@ -53,6 +53,27 @@
"font_class": "redis",
"unicode": "e619",
"unicode_decimal": 58905
},
{
"icon_id": "25271976",
"name": "oracle",
"font_class": "oracle",
"unicode": "e507",
"unicode_decimal": 58631
},
{
"icon_id": "8105644",
"name": "mariadb",
"font_class": "mariadb",
"unicode": "e513",
"unicode_decimal": 58643
},
{
"icon_id": "13601813",
"name": "sqlite",
"font_class": "sqlite",
"unicode": "e546",
"unicode_decimal": 58694
}
]
}

View File

@@ -1,5 +1,5 @@
import request from './request';
import { useApiFetch } from './useRequest';
import { useApiFetch } from '@/hooks/useRequest';
/**
* 可用于各模块定义各自api请求

View File

@@ -15,7 +15,7 @@ const config = {
baseWsUrl: `${(window as any).globalConfig.BaseWsUrl || `${location.protocol == 'https:' ? 'wss:' : 'ws:'}//${getBaseApiUrl()}`}/api`,
// 系统版本
version: 'v1.6.1',
version: 'v1.7.0',
};
export default config;

View File

@@ -1,176 +0,0 @@
{
"seriesCnt": "4",
"backgroundColor": "rgba(0,0,0,0)",
"titleColor": "#008acd",
"subtitleColor": "#aaaaaa",
"textColorShow": false,
"textColor": "#333",
"markTextColor": "#eeeeee",
"color": [
"#2ec7c9",
"#b6a2de",
"#5ab1ef",
"#ffb980",
"#d87a80",
"#8d98b3",
"#e5cf0d",
"#97b552",
"#95706d",
"#dc69aa",
"#07a2a4",
"#9a7fd1",
"#588dd5",
"#f5994e",
"#c05050",
"#59678c",
"#c9ab00",
"#7eb00a",
"#6f5553",
"#c14089"
],
"borderColor": "#ccc",
"borderWidth": 0,
"visualMapColor": [
"#5ab1ef",
"#e0ffff"
],
"legendTextColor": "#333333",
"kColor": "#d87a80",
"kColor0": "#2ec7c9",
"kBorderColor": "#d87a80",
"kBorderColor0": "#2ec7c9",
"kBorderWidth": 1,
"lineWidth": 2,
"symbolSize": 3,
"symbol": "emptyCircle",
"symbolBorderWidth": 1,
"lineSmooth": true,
"graphLineWidth": 1,
"graphLineColor": "#aaaaaa",
"mapLabelColor": "#d87a80",
"mapLabelColorE": "rgb(100,0,0)",
"mapBorderColor": "#eeeeee",
"mapBorderColorE": "#444",
"mapBorderWidth": 0.5,
"mapBorderWidthE": 1,
"mapAreaColor": "#dddddd",
"mapAreaColorE": "rgba(254,153,78,1)",
"axes": [
{
"type": "all",
"name": "通用坐标轴",
"axisLineShow": true,
"axisLineColor": "#eeeeee",
"axisTickShow": true,
"axisTickColor": "#eeeeee",
"axisLabelShow": true,
"axisLabelColor": "#eeeeee",
"splitLineShow": true,
"splitLineColor": [
"#aaaaaa"
],
"splitAreaShow": false,
"splitAreaColor": [
"#eeeeee"
]
},
{
"type": "category",
"name": "类目坐标轴",
"axisLineShow": true,
"axisLineColor": "#008acd",
"axisTickShow": true,
"axisTickColor": "#333",
"axisLabelShow": true,
"axisLabelColor": "#333",
"splitLineShow": false,
"splitLineColor": [
"#eee"
],
"splitAreaShow": false,
"splitAreaColor": [
"rgba(250,250,250,0.3)",
"rgba(200,200,200,0.3)"
]
},
{
"type": "value",
"name": "数值坐标轴",
"axisLineShow": true,
"axisLineColor": "#008acd",
"axisTickShow": true,
"axisTickColor": "#333",
"axisLabelShow": true,
"axisLabelColor": "#333",
"splitLineShow": true,
"splitLineColor": [
"#eee"
],
"splitAreaShow": true,
"splitAreaColor": [
"rgba(250,250,250,0.3)",
"rgba(200,200,200,0.3)"
]
},
{
"type": "log",
"name": "对数坐标轴",
"axisLineShow": true,
"axisLineColor": "#008acd",
"axisTickShow": true,
"axisTickColor": "#333",
"axisLabelShow": true,
"axisLabelColor": "#333",
"splitLineShow": true,
"splitLineColor": [
"#eee"
],
"splitAreaShow": true,
"splitAreaColor": [
"rgba(250,250,250,0.3)",
"rgba(200,200,200,0.3)"
]
},
{
"type": "time",
"name": "时间坐标轴",
"axisLineShow": true,
"axisLineColor": "#008acd",
"axisTickShow": true,
"axisTickColor": "#333",
"axisLabelShow": true,
"axisLabelColor": "#333",
"splitLineShow": true,
"splitLineColor": [
"#eee"
],
"splitAreaShow": false,
"splitAreaColor": [
"rgba(250,250,250,0.3)",
"rgba(200,200,200,0.3)"
]
}
],
"axisSeperateSetting": true,
"toolboxColor": "#2ec7c9",
"toolboxEmphasisColor": "#18a4a6",
"tooltipAxisColor": "#008acd",
"tooltipAxisWidth": "1",
"timelineLineColor": "#008acd",
"timelineLineWidth": 1,
"timelineItemColor": "#008acd",
"timelineItemColorE": "#a9334c",
"timelineCheckColor": "#2ec7c9",
"timelineCheckBorderColor": "#2ec7c9",
"timelineItemBorderWidth": 1,
"timelineControlColor": "#008acd",
"timelineControlBorderColor": "#008acd",
"timelineControlBorderWidth": 0.5,
"timelineLabelColor": "#008acd",
"datazoomBackgroundColor": "rgba(47,69,84,0)",
"datazoomDataColor": "#efefff",
"datazoomFillColor": "rgba(182,162,222,0.2)",
"datazoomHandleColor": "#008acd",
"datazoomHandleWidth": "100",
"datazoomLabelColor": "#333333"
}

View File

@@ -1,38 +0,0 @@
// import * as echarts from 'echarts'
// 引入 echarts 核心模块,核心模块提供了 echarts 使用必须要的接口。
import * as echarts from 'echarts/core';
/** 图表后缀都为 Chart */
import { PieChart } from 'echarts/charts';
// 引入提示框,标题,直角坐标系,数据集,内置数据转换器组件,组件后缀都为 Component
import { TitleComponent, TooltipComponent, GridComponent, DatasetComponent, TransformComponent, LegendComponent } from 'echarts/components';
// 标签自动布局,全局过渡动画等特性
import { LabelLayout, UniversalTransition } from 'echarts/features';
// 引入 Canvas 渲染器,注意引入 CanvasRenderer 或者 SVGRenderer 是必须的一步
import { CanvasRenderer } from 'echarts/renderers';
// 注册必须的组件
echarts.use([
TitleComponent,
TooltipComponent,
GridComponent,
DatasetComponent,
TransformComponent,
LegendComponent,
// BarChart,
LabelLayout,
UniversalTransition,
CanvasRenderer,
// LineChart,
PieChart,
]);
export default function (dom: any, theme: any = null, option: any) {
let chart = echarts.init(dom, theme);
chart.setOption(option);
return chart;
}

View File

@@ -4,7 +4,7 @@ import { getClientId, getToken } from './utils/storage';
import { templateResolve } from './utils/string';
import { ElMessage } from 'element-plus';
import axios from 'axios';
import { useApiFetch } from './useRequest';
import { useApiFetch } from '../hooks/useRequest';
import Api from './Api';
export default {

View File

@@ -1,9 +1,77 @@
import openApi from './openApi';
// 登录是否使用验证码配置key
const AccountLoginSecurity = 'AccountLoginSecurity';
const UseWatermarkConfigKey = 'UseWatermark';
const MachineConfig = 'MachineConfig';
const AccountLoginSecurityKey = 'AccountLoginSecurity';
const MachineConfigKey = 'MachineConfig';
const SysStyleConfigKey = 'SysStyleConfig';
/**
* 获取账号登录安全配置
*
* @returns
*/
export async function getAccountLoginSecurity(): Promise<any> {
const value = await getConfigValue(AccountLoginSecurityKey);
if (!value) {
return null;
}
const jsonValue = JSON.parse(value);
jsonValue.useCaptcha = convertBool(jsonValue.useCaptcha, true);
jsonValue.useOtp = convertBool(jsonValue.useOtp, true);
return jsonValue;
}
/**
* 获取全局系统样式配置logo、title等
*
* @returns
*/
export async function getSysStyleConfig(): Promise<any> {
const value = await getConfigValue(SysStyleConfigKey);
const defaultValue = {
useWatermark: true,
};
if (!value) {
return defaultValue;
}
const jsonValue = JSON.parse(value);
// 将字符串转为bool
jsonValue.useWatermark = convertBool(jsonValue.useWatermark, true);
return jsonValue;
}
/**
* 获取LDAP登录配置
*
* @returns
*/
export async function getLdapEnabled(): Promise<any> {
const value = await openApi.getLdapEnabled();
return convertBool(value, false);
}
/**
* 获取机器配置
*
* @returns
*/
export async function getMachineConfig(): Promise<any> {
const value = await getConfigValue(MachineConfigKey);
const defaultValue = {
// 默认1gb
uploadMaxFileSize: '1GB',
};
if (!value) {
return defaultValue;
}
try {
const jsonValue = JSON.parse(value);
return jsonValue;
} catch (e) {
return defaultValue;
}
}
/**
* 获取系统配置值
@@ -27,77 +95,6 @@ export async function getBoolConfigValue(key: string, defaultValue: boolean): Pr
return convertBool(value, defaultValue);
}
/**
* 获取账号登录安全配置
*
* @returns
*/
export async function getAccountLoginSecurity(): Promise<any> {
const value = await getConfigValue(AccountLoginSecurity);
if (!value) {
return null;
}
const jsonValue = JSON.parse(value);
jsonValue.useCaptcha = convertBool(jsonValue.useCaptcha, true);
jsonValue.useOtp = convertBool(jsonValue.useOtp, true);
return jsonValue;
}
/**
* 是否启用水印信息配置
*
* @returns
*/
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;
}
}
/**
* 获取LDAP登录配置
*
* @returns
*/
export async function getLdapEnabled(): Promise<any> {
const value = await openApi.getLdapEnabled();
return convertBool(value, false);
}
/**
* 是否启用水印信息配置
*
* @returns
*/
export async function getMachineConfig(): Promise<any> {
const value = await getConfigValue(MachineConfig);
const defaultValue = {
// 默认1gb
uploadMaxFileSize: '1GB',
};
if (!value) {
return defaultValue;
}
try {
const jsonValue = JSON.parse(value);
return jsonValue;
} catch (e) {
return defaultValue;
}
}
function convertBool(value: string, defaultValue: boolean) {
if (!value) {
return defaultValue;

View File

@@ -41,7 +41,14 @@ class SysSocket {
const sysMsgUrl = `${Config.baseWsUrl}/sysmsg?${joinClientParams()}`;
this.socket = SocketBuilder.builder(sysMsgUrl)
.message((event: { data: string }) => {
const message = JSON.parse(event.data);
let message;
try {
message = JSON.parse(event.data);
} catch (e) {
console.error('解析ws消息失败', e);
return;
}
// 存在消息类别对应的处理器,则进行处理,否则进行默认通知处理
const handler = this.categoryHandlers.get(message.category);
if (handler) {
@@ -66,9 +73,9 @@ class SysSocket {
}
destory() {
this.socket.close();
this.socket?.close();
this.socket = null;
this.categoryHandlers.clear();
this.categoryHandlers?.clear();
}
/**

View File

@@ -7,6 +7,7 @@ export function dateFormat2(fmt: string, date: Date) {
'H+': date.getHours().toString(), // 时
'm+': date.getMinutes().toString(), // 分
's+': date.getSeconds().toString(), // 秒
'S+': date.getMilliseconds() ? date.getMilliseconds().toString() : '', // 毫秒
// 有其他格式化字符需求可以继续添加,必须转化成字符串
};
for (const k in opt) {
@@ -23,5 +24,8 @@ export function dateStrFormat(fmt: string, dateStr: string) {
}
export function dateFormat(dateStr: string) {
if (!dateStr) {
return '';
}
return dateFormat2('yyyy-MM-dd HH:mm:ss', new Date(dateStr));
}

View File

@@ -33,7 +33,9 @@ export function getThemeConfig() {
return getLocal('themeConfig');
}
// 清除用户相关的用户信息
/**
* 清除当前登录用户相关信息
*/
export function clearUser() {
removeLocal(TokenKey);
removeLocal(UserKey);

View File

@@ -2,6 +2,7 @@
<component
:is="item?.render ?? `el-${item.type}`"
v-bind="{ ...handleSearchProps, ...placeholder, clearable: true }"
v-on="{ ...handleEvents }"
v-model.trim="itemValue"
:data="item.type === 'tree-select' ? item.options : []"
:options="['cascader', 'select-v2'].includes(item.type!) ? item.options : []"
@@ -27,17 +28,13 @@
<script setup lang="ts" name="SearchFormItem">
import { computed } from 'vue';
import { SearchItem } from '../index';
import { useVModel } from '@vueuse/core';
interface SearchFormItemProps {
modelValue: any;
item: SearchItem;
}
const props = defineProps<SearchFormItemProps>();
const emit = defineEmits(['update:modelValue']);
const itemValue = useVModel(props, 'modelValue', emit);
const itemValue = defineModel('modelValue');
// 判断 fieldNames 设置 label && value && children 的 key 值
const fieldNames = computed(() => {
@@ -48,19 +45,6 @@ const fieldNames = computed(() => {
};
});
// 接收 enumMap (el 为 select-v2 需单独处理 enumData)
// const enumMap = inject('enumMap', ref(new Map()));
// const columnEnum = computed(() => {
// let enumData = enumMap.value.get(props.item.prop);
// if (!enumData) return [];
// if (props.item?.type === 'select-v2' && props.item.fieldNames) {
// enumData = enumData.map((item: { [key: string]: any }) => {
// return { ...item, label: item[fieldNames.value.label], value: item[fieldNames.value.value] };
// });
// }
// return enumData;
// });
// 处理透传的 searchProps (type 为 tree-select、cascader 的时候需要给下默认 label && value && children)
const handleSearchProps = computed(() => {
const label = fieldNames.value.label;
@@ -77,6 +61,12 @@ const handleSearchProps = computed(() => {
return searchProps;
});
// 处理透传的 事件
const handleEvents = computed(() => {
let itemEvents = props.item?.events ?? {};
return itemEvents;
});
// 处理默认 placeholder
const placeholder = computed(() => {
const search = props.item;

View File

@@ -1,4 +1,5 @@
import { VNode } from 'vue';
import Api from '@/common/Api';
import { VNode, ref, toValue } from 'vue';
export type FieldNamesProps = {
label: string;
@@ -19,6 +20,94 @@ export type SearchItemType =
| 'switch'
| 'slider';
/**
* 表单组件可选项的api信息
*/
export class OptionsApi {
/**
* 请求获取options的api
*/
api: Api;
/**
* 请求参数
*/
params: any;
/**
* 是否立即执行否则在组件focus事件中获取
*/
immediate: boolean = false;
/**
* 是否只获取一次即若以获取则不继续调用该api
*/
once: boolean = true;
/**
* 转换函数主要用于将响应的api结果转换为满足组件options的结构
*/
convertFn: (apiResp: any) => any;
// remote: boolean = false;
/**
* 远程方法参数属性字段存在该值则说明使用remote-method进行远程搜索
*/
remoteMethodParamProp: string;
withConvertFn(fn: (apiResp: any) => any) {
this.convertFn = fn;
return this;
}
/**
* 立即获取该可选值
* @returns
*/
withImmediate() {
this.immediate = true;
return this;
}
/**
* 设为非一次性api即每次组件focus获取的时候都允许重新获取options
* @returns this
*/
withNoOnce() {
this.once = false;
return this;
}
/**
* 是否使用select的remote方式远程搜索调用
* @param remoteReqParamKey remote请求参数对应的prop需要将输入的value赋值给params[paramProp]进行远程搜索
*/
isRemote(paramProp: string) {
this.remoteMethodParamProp = paramProp;
return this;
}
/**
* 调用api获取组件可选项
* @returns 组件可选项信息
*/
async getOptions() {
let res = await this.api.request(toValue(this.params));
if (this.convertFn) {
res = this.convertFn(res);
}
return res;
}
static new(api: Api, params: any): OptionsApi {
const oa = new OptionsApi();
oa.api = api;
oa.params = params;
return oa;
}
}
/**
* 搜索项
*/
@@ -43,22 +132,50 @@ export class SearchItem {
*/
options: any;
/**
* 获取可选项的api信息
*/
optionsApi: OptionsApi;
/**
* 插槽名
*/
slot: string;
props?: any; // 搜索项参数,根据 element plus 官方文档来传递,该属性所有值会透传到组件
/**
* 搜索项参数,根据 element plus 官方文档来传递,该属性所有值会透传到组件
*/
props?: any;
tooltip?: string; // 搜索提示
/**
* 搜索项事件,根据 element plus 官方文档来传递,该属性所有值会透传到组件
*/
events?: any;
span?: number; // 搜索项所占用的列数,默认为 1 列
/**
* 搜索提示
*/
tooltip?: string;
offset?: number; // 搜索字段左侧偏移列数
/**
* 搜索项所占用的列数,默认为 1 列
*/
span?: number;
fieldNames: FieldNamesProps; // 指定 label && value && children 的 key 值用于select等类型组件
/**
* 搜索字段左侧偏移列数
*/
offset?: number;
render?: (scope: any) => VNode; // 自定义搜索内容渲染tsx语法
/**
* 指定 label && value && children 的 key 值用于select等类型组件
*/
fieldNames: FieldNamesProps;
/**
* 自定义搜索内容渲染tsx语法
*/
render?: (scope: any) => VNode;
constructor(prop: string, label: string) {
this.prop = prop;
@@ -69,7 +186,7 @@ export class SearchItem {
return new SearchItem(prop, label);
}
static text(prop: string, label: string): SearchItem {
static input(prop: string, label: string): SearchItem {
const tq = new SearchItem(prop, label);
tq.type = 'input';
return tq;
@@ -78,10 +195,11 @@ export class SearchItem {
static select(prop: string, label: string): SearchItem {
const tq = new SearchItem(prop, label);
tq.type = 'select';
tq.withOneProps('filterable', true);
return tq;
}
static date(prop: string, label: string): SearchItem {
static datePicker(prop: string, label: string): SearchItem {
const tq = new SearchItem(prop, label);
tq.type = 'date-picker';
return tq;
@@ -93,8 +211,40 @@ export class SearchItem {
return tq;
}
withSpan(span: number): SearchItem {
this.span = span;
/**
* 为组件设置一个props属性
* @param propsKey 属性key
* @param propsValue 属性value
* @returns
*/
withOneProps(propsKey: string, propsValue: any): SearchItem {
if (!this.props) {
this.props = {};
}
this.props[propsKey] = propsValue;
return this;
}
/**
* 为组件传递组件自身的props属性 (根据 element plus 官方文档来传递,该属性所有值会透传到组件)
* @returns this
*/
withProps(props: any = {}): SearchItem {
this.props = props;
return this;
}
/**
* 为组件传递组件自身事件函数
* @param event 事件名称
* @param fn 事件处理函数
* @returns
*/
bindEvent(event: string, eventFn: any): SearchItem {
if (!this.events) {
this.events = {};
}
this.events[event] = eventFn;
return this;
}
@@ -108,8 +258,61 @@ export class SearchItem {
return this;
}
setOptions(options: any): SearchItem {
/**
* 设置获取组件options可选项值的api配置
* @param optionsApi 可选项api配置
* @returns this
*/
withOptionsApi(optionsApi: OptionsApi): SearchItem {
this.optionsApi = optionsApi;
// 使用api获取组件可选项需要将options转为响应式否则组件无法响应式获取组件可选项
this.options = ref(null);
// 存在远程搜索请求参数prop则为使用远程搜索可选项
if (optionsApi.remoteMethodParamProp) {
return this.withOneProps('remote', true).withOneProps('remote-method', async (value: any) => {
if (!value) {
this.options.value = [];
return;
}
// 将输入的内容赋值为真实api请求参数中指定的属性字段
optionsApi.params[optionsApi.remoteMethodParamProp] = value;
this.options.value = await this.optionsApi.getOptions();
});
}
// 立即执行则直接调用api获取并赋值options
if (this.optionsApi.immediate) {
this.optionsApi.getOptions().then((res) => {
this.options.value = res;
});
} else {
// 注册focus事件在触发focus时赋值options
this.bindEvent('focus', async () => {
if (!toValue(this.options) || !optionsApi.once) {
this.options.value = await this.optionsApi.getOptions();
}
});
}
return this;
}
withSpan(span: number): SearchItem {
this.span = span;
return this;
}
withOptions(options: any): SearchItem {
this.options = options;
return this;
}
/**
* 赋值placeholder
* @param val placeholder
* @returns
*/
withPlaceholder(val: string): SearchItem {
return this.withOneProps('placeholder', val);
}
}

View File

@@ -14,7 +14,7 @@
<span>:</span>
</template>
<SearchFormItem v-if="!item.slot" :item="item" v-model="searchParam[item.prop]" />
<SearchFormItem @keyup.enter="handleItemKeyupEnter(item)" v-if="!item.slot" :item="item" v-model="searchParam[item.prop]" />
<slot v-else :name="item.slot"></slot>
</el-form-item>
@@ -44,11 +44,9 @@ import Grid from '@/components/Grid/index.vue';
import GridItem from '@/components/Grid/components/GridItem.vue';
import SvgIcon from '@/components/svgIcon/index.vue';
import { SearchItem } from './index';
import { useVModel } from '@vueuse/core';
interface ProTableProps {
items: SearchItem[]; // 搜索配置项
modelValue?: { [key: string]: any }; // 搜索参数
searchCol: number | Record<BreakPoint, number>;
search: (params: any) => void; // 搜索方法
reset: (params: any) => void; // 重置方法
@@ -60,9 +58,7 @@ const props = withDefaults(defineProps<ProTableProps>(), {
modelValue: () => ({}),
});
const emit = defineEmits(['update:modelValue']);
const searchParam = useVModel(props, 'modelValue', emit);
const searchParam: any = defineModel('modelValue');
// 获取响应式设置
const getResponsive = (item: SearchItem) => {
@@ -98,6 +94,12 @@ const showCollapse = computed(() => {
}, 0);
return show;
});
const handleItemKeyupEnter = (item: SearchItem) => {
if (item.type == 'input') {
props.search(searchParam);
}
};
</script>
<style lang="scss">
.search-form {

View File

@@ -184,6 +184,10 @@ defineExpose({
z-index: 2190;
position: fixed;
.el-dropdown-menu__item {
padding: 5px 12px;
}
.el-dropdown-menu__item {
font-size: 12px !important;
white-space: nowrap;

View File

@@ -0,0 +1,294 @@
<template>
<div class="crontab">
<el-tabs v-model="state.activeName" @tab-change="changeTab(state.activeName)" type="border-card">
<el-tab-pane label="秒" name="second" v-if="shouldHide('second')">
<CrontabSecond :cron="crontabValueObj" ref="secondRef" />
</el-tab-pane>
<el-tab-pane label="分钟" name="min" v-if="shouldHide('min')">
<CrontabMin :cron="crontabValueObj" ref="minRef" />
</el-tab-pane>
<el-tab-pane label="小时" name="hour" v-if="shouldHide('hour')">
<CrontabHour :cron="crontabValueObj" ref="hourRef" />
</el-tab-pane>
<el-tab-pane label="日" name="day" v-if="shouldHide('day')">
<CrontabDay :cron="crontabValueObj" ref="dayRef" />
</el-tab-pane>
<el-tab-pane label="月" name="mouth" v-if="shouldHide('mouth')">
<CrontabMouth :cron="crontabValueObj" ref="mouthRef" />
</el-tab-pane>
<el-tab-pane label="周" name="week" v-if="shouldHide('week')">
<CrontabWeek :cron="crontabValueObj" ref="weekRef" />
</el-tab-pane>
<el-tab-pane label="年" name="year" v-if="shouldHide('year')">
<CrontabYear :cron="crontabValueObj" ref="yearRef" />
</el-tab-pane>
</el-tabs>
<div class="popup-main">
<div class="popup-result">
<p class="title">时间表达式</p>
<table>
<thead>
<th v-for="item of tabTitles" width="40" :key="item">{{ item }}</th>
<th>crontab完整表达式</th>
</thead>
<tbody>
<td>
<span>{{ crontabValueObj.second }}</span>
</td>
<td>
<span>{{ crontabValueObj.min }}</span>
</td>
<td>
<span>{{ crontabValueObj.hour }}</span>
</td>
<td>
<span>{{ crontabValueObj.day }}</span>
</td>
<td>
<span>{{ crontabValueObj.mouth }}</span>
</td>
<td>
<span>{{ crontabValueObj.week }}</span>
</td>
<td>
<span>{{ crontabValueObj.year }}</span>
</td>
<td>
<span>{{ contabValueString }}</span>
</td>
</tbody>
</table>
</div>
<CrontabResult :ex="contabValueString"></CrontabResult>
<div class="pop_btn">
<el-button size="small" @click="hidePopup">取消</el-button>
<el-button size="small" type="warning" @click="clearCron">重置</el-button>
<el-button size="small" type="primary" @click="submitFill">确定</el-button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, toRefs, onMounted, reactive, ref, nextTick, watch } from 'vue';
import CrontabSecond from './CrontabSecond.vue';
import CrontabMin from './CrontabMin.vue';
import CrontabHour from './CrontabHour.vue';
import CrontabDay from './CrontabDay.vue';
import CrontabMouth from './CrontabMouth.vue';
import CrontabWeek from './CrontabWeek.vue';
import CrontabYear from './CrontabYear.vue';
import CrontabResult from './CrontabResult.vue';
const secondRef: any = ref(null);
const minRef: any = ref(null);
const hourRef: any = ref(null);
const dayRef: any = ref(null);
const mouthRef: any = ref(null);
const weekRef: any = ref(null);
const yearRef: any = ref(null);
const props = defineProps({
expression: {
type: String,
required: true,
},
hideComponent: {
type: Array,
},
});
//定义事件
const emit = defineEmits(['hide', 'fill']);
const state = reactive({
tabTitles: ['秒', '分钟', '小时', '日', '月', '周', '年'],
tabActive: 0,
activeName: 'second',
myindex: 0,
crontabValueObj: {
second: '*',
min: '*',
hour: '*',
day: '*',
mouth: '*',
week: '?',
year: '',
},
});
const { tabTitles, crontabValueObj } = toRefs(state);
onMounted(() => {
resolveExp();
});
watch(
() => props.expression,
() => {
resolveExp();
}
);
function shouldHide(key: string) {
if (props.hideComponent && props.hideComponent.includes(key)) return false;
return true;
}
function resolveExp() {
//反解析 表达式
if (props.expression) {
let arr = props.expression.split(' ');
if (arr.length >= 6) {
//6 位以上是合法表达式
let obj = {
second: arr[0],
min: arr[1],
hour: arr[2],
day: arr[3],
mouth: arr[4],
week: arr[5],
year: arr[6] ? arr[6] : '',
};
state.crontabValueObj = {
...obj,
};
}
changeTab(state.activeName);
} else {
//没有传入的表达式 则还原
clearCron();
}
}
// 改变tab
const changeTab = (name: string) => {
nextTick(() => {
getRefByName(name).value?.parse();
});
};
const getRefByName = (name: string) => {
switch (name) {
case 'second':
return secondRef;
case 'min':
return minRef;
case 'hour':
return hourRef;
case 'day':
return dayRef;
case 'mouth':
return mouthRef;
case 'week':
return weekRef;
case 'year':
return yearRef;
}
};
// 隐藏弹窗
function hidePopup() {
emit('hide');
}
// 填充表达式
const submitFill = () => {
emit('fill', contabValueString.value);
hidePopup();
};
const clearCron = () => {
// 还原选择项
state.crontabValueObj = {
second: '*',
min: '*',
hour: '*',
day: '*',
mouth: '*',
week: '?',
year: '',
};
changeTab(state.activeName);
};
const contabValueString = computed(() => {
let obj = state.crontabValueObj;
let str = obj.second + ' ' + obj.min + ' ' + obj.hour + ' ' + obj.day + ' ' + obj.mouth + ' ' + obj.week + (obj.year == '' ? '' : ' ' + obj.year);
return str;
});
</script>
<style scoped lang="scss">
.pop_btn {
text-align: right;
}
.popup-main {
position: relative;
margin: 10px auto;
background: var(--el-bg-color-overlay);
border-radius: 5px;
font-size: 12px;
overflow: hidden;
}
.popup-title {
overflow: hidden;
line-height: 34px;
padding-top: 6px;
background: #f2f2f2;
}
.popup-result {
box-sizing: border-box;
line-height: 24px;
margin: 15px auto;
padding: 15px 20px 10px;
border: 1px solid var(--el-border-color);
position: relative;
}
.popup-result .title {
position: absolute;
top: -18px;
left: 50%;
width: 140px;
font-size: 14px;
margin-left: -70px;
text-align: center;
line-height: 30px;
background: var(--el-bg-color-overlay);
}
.popup-result table {
text-align: center;
width: 100%;
margin: 0 auto;
}
.popup-result table span {
display: block;
width: 100%;
font-family: arial;
line-height: 30px;
height: 30px;
white-space: nowrap;
overflow: hidden;
border: 1px solid var(--el-border-color);
}
.popup-result-scroll {
font-size: 12px;
line-height: 24px;
height: 10em;
overflow-y: auto;
}
.crontab {
::v-deep(.el-form-item) {
margin-bottom: 10px !important;
}
}
</style>

View File

@@ -0,0 +1,216 @@
<template>
<el-form size="small">
<el-form-item>
<el-radio v-model="radioValue" :label="1"> 允许的通配符[, - * / L M] </el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :label="2"> 不指定 </el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :label="3">
周期从
<el-input-number v-model="cycle01" :min="0" :max="31" /> - <el-input-number v-model="cycle02" :min="0" :max="31" />
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :label="4">
<el-input-number v-model="average01" :min="0" :max="31" /> 号开始,每 <el-input-number v-model="average02" :min="0" :max="31" /> 日执行一次
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :label="5">
每月
<el-input-number v-model="workday" :min="0" :max="31" /> 号最近的那个工作日
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :label="6"> 本月最后一天 </el-radio>
</el-form-item>
<el-form-item>
<div class="flex-align-center w100">
<el-radio v-model="radioValue" :label="7" class="mr5"> 指定 </el-radio>
<el-select @click="radioValue = 7" class="w100" clearable v-model="checkboxList" placeholder="可多选" multiple>
<el-option v-for="item in 31" :key="item" :value="`${item}`">{{ item }}</el-option>
</el-select>
</div>
</el-form-item>
</el-form>
</template>
<script lang="ts" setup>
import { computed, toRefs, watch, reactive } from 'vue';
import { checkNumber, CrontabValueObj } from './index';
const cron = defineModel<CrontabValueObj>('cron', { required: true });
const state = reactive({
radioValue: 1,
workday: 1,
cycle01: 1,
cycle02: 2,
average01: 1,
average02: 1,
checkboxList: [] as any,
});
const { radioValue, workday, cycle01, cycle02, average01, average02, checkboxList } = toRefs(state);
// 单选按钮值变化时
function radioChange() {
if (state.radioValue === 1) {
cron.value.day = '*';
cron.value.week = '?';
cron.value.mouth = '*';
} else {
if (cron.value.hour === '*') {
cron.value.hour = '0';
}
if (cron.value.min === '*') {
cron.value.min = '0';
}
if (cron.value.second === '*') {
cron.value.second = '0';
}
}
switch (state.radioValue) {
case 2:
cron.value.day = '?';
break;
case 3:
cron.value.day = state.cycle01 + '-' + state.cycle02;
break;
case 4:
cron.value.day = state.average01 + '/' + state.average02;
break;
case 5:
cron.value.day = state.workday + 'W';
break;
case 6:
cron.value.day = 'L';
break;
case 7:
cron.value.day = checkboxString.value;
break;
}
}
// 周期两个值变化时
function cycleChange() {
if (state.radioValue == 3) {
cron.value.day = cycleTotal.value;
}
}
// 平均两个值变化时
function averageChange() {
state.average01 = checkNumber(state.average01, 1, 31);
state.average02 = checkNumber(state.average02, 1, 31);
if (state.radioValue == 4) {
cron.value.day = averageTotal.value;
}
}
// 最近工作日值变化时
function workdayChange() {
state.workday = checkNumber(state.workday, 1, 31);
if (state.radioValue == 5) {
cron.value.day = state.workday + 'W';
}
}
// checkbox值变化时
function checkboxChange() {
if (state.radioValue == 7) {
cron.value.day = checkboxString.value;
}
}
// 父组件传递的week发生变化触发
// function weekChange() {
// //判断week值与day不能同时为“?”
// if (cron.value.week == '?' && state.radioValue == 2) {
// state.radioValue = 1;
// } else if (cron.value.week !== '?' && state.radioValue != 2) {
// state.radioValue = 2;
// }
// }
// 计算两个周期值
const cycleTotal = computed(() => {
return state.cycle01 + '-' + state.cycle02;
});
// 计算平均用到的值
const averageTotal = computed(() => {
return state.average01 + '/' + state.average02;
});
// 计算工作日格式
const workdayCheck = computed(() => {
return state.workday;
});
// 计算勾选的checkbox值合集
const checkboxString = computed(() => {
let str = state.checkboxList.join();
return str == '' ? '*' : str;
});
watch(
() => state.radioValue,
() => {
radioChange();
}
);
watch(cycleTotal, () => {
cycleChange();
});
watch(averageTotal, () => {
averageChange();
});
watch(workdayCheck, () => {
workdayChange();
});
watch(checkboxString, () => {
checkboxChange();
});
const parse = () => {
//反解析
let value = cron.value.day;
if (value === '*') {
state.radioValue = 1;
} else if (value == '?') {
state.radioValue = 2;
} else if (value.indexOf('-') > -1) {
state.radioValue = 3;
let indexArr = value.split('-') as any;
isNaN(indexArr[0]) ? (state.cycle01 = 0) : (state.cycle01 = indexArr[0]);
state.cycle02 = indexArr[1];
} else if (value.indexOf('/') > -1) {
state.radioValue = 4;
let indexArr = value.split('/') as any;
isNaN(indexArr[0]) ? (state.average01 = 0) : (state.average01 = indexArr[0]);
state.average02 = indexArr[1];
} else if (value.indexOf('W') > -1) {
state.radioValue = 5;
let indexArr = value.split('W') as any;
isNaN(indexArr[0]) ? (state.workday = 0) : (state.workday = indexArr[0]);
} else if (value === 'L') {
state.radioValue = 6;
} else {
state.checkboxList = value.split(',');
state.radioValue = 7;
}
};
defineExpose({ parse });
</script>

View File

@@ -0,0 +1,156 @@
<template>
<el-form size="small">
<el-form-item>
<el-radio v-model="radioValue" :label="1"> 小时允许的通配符[, - * /] </el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :label="2">
周期从
<el-input-number v-model="cycle01" :min="0" :max="60" /> - <el-input-number v-model="cycle02" :min="0" :max="60" /> 小时
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :label="3">
<el-input-number v-model="average01" :min="0" :max="60" /> 小时开始,每 <el-input-number v-model="average02" :min="0" :max="60" /> 小时执行一次
</el-radio>
</el-form-item>
<el-form-item>
<div class="flex-align-center w100">
<el-radio v-model="radioValue" :label="4" class="mr5"> 指定 </el-radio>
<el-select @click="radioValue = 4" class="w100" clearable v-model="checkboxList" placeholder="可多选" multiple>
<el-option v-for="item in 60" :key="item" :value="`${item - 1}`">{{ item - 1 }}</el-option>
</el-select>
</div>
</el-form-item>
</el-form>
</template>
<script lang="ts" setup>
import { computed, toRefs, watch, reactive } from 'vue';
import { checkNumber, CrontabValueObj } from './index';
const cron = defineModel<CrontabValueObj>('cron', { required: true });
const state = reactive({
radioValue: 1,
cycle01: 0,
cycle02: 1,
average01: 0,
average02: 1,
checkboxList: [] as any,
});
const { radioValue, cycle01, cycle02, average01, average02, checkboxList } = toRefs(state);
// 单选按钮值变化时
function radioChange() {
if (state.radioValue === 1) {
cron.value.hour = '*';
cron.value.day = '*';
} else {
if (cron.value.min === '*') {
cron.value.min = '0';
}
if (cron.value.second === '*') {
cron.value.second = '0';
}
}
switch (state.radioValue) {
case 2:
cron.value.hour = state.cycle01 + '-' + state.cycle02;
break;
case 3:
cron.value.hour = state.average01 + '/' + state.average02;
break;
case 4:
cron.value.hour = checkboxString.value;
break;
}
}
// 周期两个值变化时
function cycleChange() {
state.cycle01 = checkNumber(state.cycle01, 0, 23);
state.cycle02 = checkNumber(state.cycle02, 0, 23);
if (state.radioValue == 2) {
cron.value.hour = cycleTotal.value;
}
}
// 平均两个值变化时
function averageChange() {
state.average01 = checkNumber(state.average01, 0, 23);
state.average02 = checkNumber(state.average02, 0, 23);
if (state.radioValue == 3) {
cron.value.hour = averageTotal.value;
}
}
// checkbox值变化时
function checkboxChange() {
if (state.radioValue == 4) {
cron.value.hour = checkboxString.value;
}
}
// 计算两个周期值
const cycleTotal = computed(() => {
return state.cycle01 + '-' + state.cycle02;
});
// 计算平均用到的值
const averageTotal = computed(() => {
return state.average01 + '/' + state.average02;
});
// 计算勾选的checkbox值合集
const checkboxString = computed(() => {
let str = state.checkboxList.join();
return str == '' ? '*' : str;
});
watch(
() => state.radioValue,
() => {
radioChange();
}
);
watch(cycleTotal, () => {
cycleChange();
});
watch(averageTotal, () => {
averageChange();
});
watch(checkboxString, () => {
checkboxChange();
});
const parse = () => {
//反解析
let ins = cron.value.hour;
if (ins === '*') {
state.radioValue = 1;
} else if (ins.indexOf('-') > -1) {
state.radioValue = 2;
let indexArr = ins.split('-') as any;
isNaN(indexArr[0]) ? (state.cycle01 = 0) : (state.cycle01 = indexArr[0]);
state.cycle02 = indexArr[1];
} else if (ins.indexOf('/') > -1) {
state.radioValue = 3;
let indexArr = ins.split('/') as any;
isNaN(indexArr[0]) ? (state.average01 = 0) : (state.average01 = indexArr[0]);
state.average02 = indexArr[1];
} else {
state.radioValue = 4;
state.checkboxList = ins.split(',');
}
};
defineExpose({ parse });
</script>

View File

@@ -0,0 +1,19 @@
<template>
<el-input v-model="cron" placeholder="可点击左边按钮进行可视化配置">
<template #prepend>
<el-button @click="showCron = true" icon="Pointer"></el-button>
</template>
</el-input>
<el-dialog title="生成 cron" v-model="showCron" width="600px">
<Crontab :expression="cron" @hide="showCron = false" @fill="(ex: any) => (cron = ex)" />
</el-dialog>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import Crontab from '@/components/crontab/Crontab.vue';
const cron = defineModel<string>('modelValue', { required: true });
const showCron = ref(false);
</script>

View File

@@ -0,0 +1,152 @@
<template>
<el-form size="small">
<el-form-item>
<el-radio v-model="radioValue" :label="1"> 分钟允许的通配符[, - * /] </el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :label="2">
周期从
<el-input-number v-model="cycle01" :min="0" :max="60" /> - <el-input-number v-model="cycle02" :min="0" :max="60" /> 分钟
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :label="3">
<el-input-number v-model="average01" :min="0" :max="60" /> 分钟开始,每 <el-input-number v-model="average02" :min="0" :max="60" /> 分钟执行一次
</el-radio>
</el-form-item>
<el-form-item>
<div class="flex-align-center w100">
<el-radio v-model="radioValue" :label="4" class="mr5"> 指定 </el-radio>
<el-select @click="radioValue = 4" class="w100" clearable v-model="checkboxList" placeholder="可多选" multiple>
<el-option v-for="item in 60" :key="item" :value="`${item - 1}`">{{ item - 1 }}</el-option>
</el-select>
</div>
</el-form-item>
</el-form>
</template>
<script lang="ts" setup>
import { computed, toRefs, watch, reactive } from 'vue';
import { checkNumber, CrontabValueObj } from './index';
const cron = defineModel<CrontabValueObj>('cron', { required: true });
const state = reactive({
radioValue: 1,
cycle01: 0,
cycle02: 1,
average01: 0,
average02: 1,
checkboxList: [] as any,
});
const { radioValue, cycle01, cycle02, average01, average02, checkboxList } = toRefs(state);
// 单选按钮值变化时
function radioChange() {
if (state.radioValue !== 1 && cron.value.second === '*') {
cron.value.second = '0';
}
switch (state.radioValue) {
case 1:
cron.value.min = '*';
cron.value.hour = '*';
break;
case 2:
cron.value.min = state.cycle01 + '-' + state.cycle02;
break;
case 3:
cron.value.min = state.average01 + '/' + state.average02;
break;
case 4:
cron.value.min = checkboxString.value;
break;
}
}
// 周期两个值变化时
function cycleChange() {
state.cycle01 = checkNumber(state.cycle01, 0, 59);
state.cycle02 = checkNumber(state.cycle02, 0, 59);
if (state.radioValue == 2) {
cron.value.min = cycleTotal.value;
}
}
// 平均两个值变化时
function averageChange() {
state.average01 = checkNumber(state.average01, 0, 59);
state.average02 = checkNumber(state.average02, 1, 59);
if (state.radioValue == 3) {
cron.value.min = averageTotal.value;
}
}
// checkbox值变化时
function checkboxChange() {
if (state.radioValue == 4) {
cron.value.min = checkboxString.value;
}
}
// 计算两个周期值
const cycleTotal = computed(() => {
return state.cycle01 + '-' + state.cycle02;
});
// 计算平均用到的值
const averageTotal = computed(() => {
return state.average01 + '/' + state.average02;
});
// 计算勾选的checkbox值合集
const checkboxString = computed(() => {
let str = state.checkboxList.join();
return str == '' ? '*' : str;
});
watch(
() => state.radioValue,
() => {
radioChange();
}
);
watch(cycleTotal, () => {
cycleChange();
});
watch(averageTotal, () => {
averageChange();
});
watch(checkboxString, () => {
checkboxChange();
});
const parse = () => {
//反解析
let ins = cron.value.min;
if (ins === '*') {
state.radioValue = 1;
} else if (ins.indexOf('-') > -1) {
state.radioValue = 2;
let indexArr = ins.split('-') as any;
isNaN(indexArr[0]) ? (state.cycle01 = 0) : (state.cycle01 = indexArr[0]);
state.cycle02 = indexArr[1];
} else if (ins.indexOf('/') > -1) {
state.radioValue = 3;
let indexArr = ins.split('/') as any;
isNaN(indexArr[0]) ? (state.average01 = 0) : (state.average01 = indexArr[0]);
state.average02 = indexArr[1];
} else {
state.radioValue = 4;
state.checkboxList = ins.split(',');
}
};
defineExpose({ parse });
</script>

View File

@@ -0,0 +1,163 @@
<template>
<el-form size="small">
<el-form-item>
<el-radio v-model="radioValue" :label="1"> 允许的通配符[, - * /] </el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :label="2">
周期从
<el-input-number v-model="cycle01" :min="1" :max="12" /> - <el-input-number v-model="cycle02" :min="1" :max="12" />
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :label="3">
<el-input-number v-model="average01" :min="1" :max="12" /> 月开始,每 <el-input-number v-model="average02" :min="1" :max="12" /> 月月执行一次
</el-radio>
</el-form-item>
<el-form-item>
<div class="flex-align-center w100">
<el-radio v-model="radioValue" :label="4" class="mr5"> 指定 </el-radio>
<el-select @click="radioValue = 4" class="w100" clearable v-model="checkboxList" placeholder="可多选" multiple>
<el-option v-for="item in 12" :key="item" :value="`${item}`">{{ item }}</el-option>
</el-select>
</div>
</el-form-item>
</el-form>
</template>
<script lang="ts" setup>
import { computed, toRefs, watch, reactive } from 'vue';
import { checkNumber, CrontabValueObj } from './index';
const cron = defineModel<CrontabValueObj>('cron', { required: true });
const state = reactive({
radioValue: 1,
cycle01: 1,
cycle02: 2,
average01: 1,
average02: 1,
checkboxList: [] as any,
});
const { radioValue, cycle01, cycle02, average01, average02, checkboxList } = toRefs(state);
// 单选按钮值变化时
function radioChange() {
if (state.radioValue === 1) {
cron.value.mouth = '*';
cron.value.year = '*';
} else {
if (cron.value.day === '*') {
cron.value.day = '0';
}
if (cron.value.hour === '*') {
cron.value.hour = '0';
}
if (cron.value.min === '*') {
cron.value.min = '0';
}
if (cron.value.second === '*') {
cron.value.second = '0';
}
}
switch (state.radioValue) {
case 2:
cron.value.mouth = state.cycle01 + '-' + state.cycle02;
break;
case 3:
cron.value.mouth = state.average01 + '/' + state.average02;
break;
case 4:
cron.value.mouth = checkboxString.value;
break;
}
}
// 周期两个值变化时
function cycleChange() {
state.cycle01 = checkNumber(state.cycle01, 1, 12);
state.cycle02 = checkNumber(state.cycle02, 1, 12);
if (state.radioValue == 2) {
cron.value.mouth = cycleTotal.value;
}
}
// 平均两个值变化时
function averageChange() {
state.average01 = checkNumber(state.average01, 1, 12);
state.average02 = checkNumber(state.average02, 1, 12);
if (state.radioValue == 3) {
cron.value.mouth = averageTotal.value;
}
}
// checkbox值变化时
function checkboxChange() {
if (state.radioValue == 4) {
cron.value.mouth = checkboxString.value;
}
}
// 计算两个周期值
const cycleTotal = computed(() => {
return state.cycle01 + '-' + state.cycle02;
});
// 计算平均用到的值
const averageTotal = computed(() => {
return state.average01 + '/' + state.average02;
});
// 计算勾选的checkbox值合集
const checkboxString = computed(() => {
let str = state.checkboxList.join();
return str == '' ? '*' : str;
});
watch(
() => state.radioValue,
() => {
radioChange();
}
);
watch(cycleTotal, () => {
cycleChange();
});
watch(averageTotal, () => {
averageChange();
});
watch(checkboxString, () => {
checkboxChange();
});
const parse = () => {
//反解析
let ins = cron.value.mouth;
if (ins === '*') {
state.radioValue = 1;
} else if (ins.indexOf('-') > -1) {
state.radioValue = 2;
let indexArr = ins.split('-') as any;
isNaN(indexArr[0]) ? (state.cycle01 = 0) : (state.cycle01 = indexArr[0]);
state.cycle02 = indexArr[1];
} else if (ins.indexOf('/') > -1) {
state.radioValue = 3;
let indexArr = ins.split('/') as any;
isNaN(indexArr[0]) ? (state.average01 = 0) : (state.average01 = indexArr[0]);
state.average02 = indexArr[1];
} else {
state.radioValue = 4;
state.checkboxList = ins.split(',');
}
};
defineExpose({ parse });
</script>

View File

@@ -0,0 +1,588 @@
<template>
<div class="popup-result">
<p class="title">最近5次运行时间</p>
<ul class="popup-result-scroll">
<template v-if="isShow">
<li v-for="item in resultList" :key="item">{{ item }}</li>
</template>
<li v-else>计算结果中...</li>
</ul>
</div>
</template>
<script lang="js" setup>
import { toRefs, watch, onMounted, reactive } from 'vue';
const props = defineProps({
ex: {
type: String,
required: true,
},
});
const state = reactive({
dayRule: '',
dayRuleSup: '',
dateArr: [],
resultList: [],
isShow: false,
});
const { resultList, isShow } = toRefs(state);
watch(
() => props.ex,
() => {
expressionChange();
}
);
onMounted(() => {
expressionChange();
});
// 表达式值变化时,开始去计算结果
function expressionChange() {
// 计算开始-隐藏结果
state.isShow = false;
// 获取规则数组[0秒、1分、2时、3日、4月、5星期、6年]
let ruleArr = props.ex.split(' ');
// 用于记录进入循环的次数
let nums = 0;
// 用于暂时存符号时间规则结果的数组
let resultArr = [];
// 获取当前时间精确至[年、月、日、时、分、秒]
let nTime = new Date();
let nYear = nTime.getFullYear();
let nMouth = nTime.getMonth() + 1;
let nDay = nTime.getDate();
let nHour = nTime.getHours();
let nMin = nTime.getMinutes();
let nSecond = nTime.getSeconds();
// 根据规则获取到近100年可能年数组、月数组等等
getSecondArr(ruleArr[0]);
getMinArr(ruleArr[1]);
getHourArr(ruleArr[2]);
getDayArr(ruleArr[3]);
getMouthArr(ruleArr[4]);
getWeekArr(ruleArr[5]);
getYearArr(ruleArr[6], nYear);
// 将获取到的数组赋值-方便使用
let sDate = state.dateArr[0];
let mDate = state.dateArr[1];
let hDate = state.dateArr[2];
let DDate = state.dateArr[3];
let MDate = state.dateArr[4];
let YDate = state.dateArr[5];
// 获取当前时间在数组中的索引
let sIdx = getIndex(sDate, nSecond);
let mIdx = getIndex(mDate, nMin);
let hIdx = getIndex(hDate, nHour);
let DIdx = getIndex(DDate, nDay);
let MIdx = getIndex(MDate, nMouth);
let YIdx = getIndex(YDate, nYear);
// 重置月日时分秒的函数(后面用的比较多)
const resetSecond = function () {
sIdx = 0;
nSecond = sDate[sIdx];
};
const resetMin = function () {
mIdx = 0;
nMin = mDate[mIdx];
resetSecond();
};
const resetHour = function () {
hIdx = 0;
nHour = hDate[hIdx];
resetMin();
};
const resetDay = function () {
DIdx = 0;
nDay = DDate[DIdx];
resetHour();
};
const resetMouth = function () {
MIdx = 0;
nMouth = MDate[MIdx];
resetDay();
};
// 如果当前年份不为数组中当前值
if (nYear !== YDate[YIdx]) {
resetMouth();
}
// 如果当前月份不为数组中当前值
if (nMouth !== MDate[MIdx]) {
resetDay();
}
// 如果当前“日”不为数组中当前值
if (nDay !== DDate[DIdx]) {
resetHour();
}
// 如果当前“时”不为数组中当前值
if (nHour !== hDate[hIdx]) {
resetMin();
}
// 如果当前“分”不为数组中当前值
if (nMin !== mDate[mIdx]) {
resetSecond();
}
// 循环年份数组
goYear: for (let Yi = YIdx; Yi < YDate.length; Yi++) {
let YY = YDate[Yi];
// 如果到达最大值时
if (nMouth > MDate[MDate.length - 1]) {
resetMouth();
continue;
}
// 循环月份数组
goMouth: for (let Mi = MIdx; Mi < MDate.length; Mi++) {
// 赋值、方便后面运算
let MM = MDate[Mi];
MM = MM < 10 ? '0' + MM : MM;
// 如果到达最大值时
if (nDay > DDate[DDate.length - 1]) {
resetDay();
if (Mi == MDate.length - 1) {
resetMouth();
continue goYear;
}
continue;
}
// 循环日期数组
goDay: for (let Di = DIdx; Di < DDate.length; Di++) {
// 赋值、方便后面运算
let DD = DDate[Di];
let thisDD = DD < 10 ? '0' + DD : DD;
// 如果到达最大值时
if (nHour > hDate[hDate.length - 1]) {
resetHour();
if (Di == DDate.length - 1) {
resetDay();
if (Mi == MDate.length - 1) {
resetMouth();
continue goYear;
}
continue goMouth;
}
continue;
}
// 判断日期的合法性,不合法的话也是跳出当前循环
if (
checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true &&
state.dayRule !== 'workDay' &&
state.dayRule !== 'lastWeek' &&
state.dayRule !== 'lastDay'
) {
resetDay();
continue goMouth;
}
// 如果日期规则中有值时
if (state.dayRule == 'lastDay') {
//如果不是合法日期则需要将前将日期调到合法日期即月末最后一天
if (checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
while (DD > 0 && checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
DD--;
thisDD = DD < 10 ? '0' + DD : DD;
}
}
} else if (state.dayRule == 'workDay') {
//校验并调整如果是2月30号这种日期传进来时需调整至正常月底
if (checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
while (DD > 0 && checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
DD--;
thisDD = DD < 10 ? '0' + DD : DD;
}
}
// 获取达到条件的日期是星期X
let thisWeek = formatDate(new Date(YY + '-' + MM + '-' + thisDD + ' 00:00:00'), 'week');
// 当星期日时
if (thisWeek == 0) {
//先找下一个日,并判断是否为月底
DD++;
thisDD = DD < 10 ? '0' + DD : DD;
//判断下一日已经不是合法日期
if (checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
DD -= 3;
}
} else if (thisWeek == 6) {
//当星期6时只需判断不是1号就可进行操作
if (state.dayRuleSup !== 1) {
DD--;
} else {
DD += 2;
}
}
} else if (state.dayRule == 'weekDay') {
//如果指定了是星期几
//获取当前日期是属于星期几
let thisWeek = formatDate(new Date(YY + '-' + MM + '-' + DD + ' 00:00:00'), 'week');
//校验当前星期是否在星期池state.dayRuleSup
if (Array.indexOf(state.dayRuleSup, thisWeek) < 0) {
// 如果到达最大值时
if (Di == DDate.length - 1) {
resetDay();
if (Mi == MDate.length - 1) {
resetMouth();
continue goYear;
}
continue goMouth;
}
continue;
}
} else if (state.dayRule == 'assWeek') {
//如果指定了是第几周的星期几
//获取每月1号是属于星期几
let thisWeek = formatDate(new Date(YY + '-' + MM + '-' + DD + ' 00:00:00'), 'week');
if (state.dayRuleSup[1] >= thisWeek) {
DD = (state.dayRuleSup[0] - 1) * 7 + state.dayRuleSup[1] - thisWeek + 1;
} else {
DD = state.dayRuleSup[0] * 7 + state.dayRuleSup[1] - thisWeek + 1;
}
} else if (state.dayRule == 'lastWeek') {
//如果指定了每月最后一个星期几
//校验并调整如果是2月30号这种日期传进来时需调整至正常月底
if (checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
while (DD > 0 && checkDate(YY + '-' + MM + '-' + thisDD + ' 00:00:00') !== true) {
DD--;
thisDD = DD < 10 ? '0' + DD : DD;
}
}
//获取月末最后一天是星期几
let thisWeek = formatDate(new Date(YY + '-' + MM + '-' + thisDD + ' 00:00:00'), 'week');
//找到要求中最近的那个星期几
if (state.dayRuleSup < thisWeek) {
DD -= thisWeek - state.dayRuleSup;
} else if (state.dayRuleSup > thisWeek) {
DD -= 7 - (state.dayRuleSup - thisWeek);
}
}
// 判断时间值是否小于10置换成“05”这种格式
DD = DD < 10 ? '0' + DD : DD;
// 循环“时”数组
goHour: for (let hi = hIdx; hi < hDate.length; hi++) {
let hh = hDate[hi] < 10 ? '0' + hDate[hi] : hDate[hi];
// 如果到达最大值时
if (nMin > mDate[mDate.length - 1]) {
resetMin();
if (hi == hDate.length - 1) {
resetHour();
if (Di == DDate.length - 1) {
resetDay();
if (Mi == MDate.length - 1) {
resetMouth();
continue goYear;
}
continue goMouth;
}
continue goDay;
}
continue;
}
// 循环"分"数组
goMin: for (let mi = mIdx; mi < mDate.length; mi++) {
let mm = mDate[mi] < 10 ? '0' + mDate[mi] : mDate[mi];
// 如果到达最大值时
if (nSecond > sDate[sDate.length - 1]) {
resetSecond();
if (mi == mDate.length - 1) {
resetMin();
if (hi == hDate.length - 1) {
resetHour();
if (Di == DDate.length - 1) {
resetDay();
if (Mi == MDate.length - 1) {
resetMouth();
continue goYear;
}
continue goMouth;
}
continue goDay;
}
continue goHour;
}
continue;
}
// 循环"秒"数组
// eslint-disable-next-line no-unused-labels
goSecond: for (let si = sIdx; si <= sDate.length - 1; si++) {
let ss = sDate[si] < 10 ? '0' + sDate[si] : sDate[si];
// 添加当前时间(时间合法性在日期循环时已经判断)
if (MM !== '00' && DD !== '00') {
resultArr.push(YY + '-' + MM + '-' + DD + ' ' + hh + ':' + mm + ':' + ss);
nums++;
}
//如果条数满了就退出循环
if (nums == 5) break goYear;
//如果到达最大值时
if (si == sDate.length - 1) {
resetSecond();
if (mi == mDate.length - 1) {
resetMin();
if (hi == hDate.length - 1) {
resetHour();
if (Di == DDate.length - 1) {
resetDay();
if (Mi == MDate.length - 1) {
resetMouth();
continue goYear;
}
continue goMouth;
}
continue goDay;
}
continue goHour;
}
continue goMin;
}
} //goSecond
} //goMin
} //goHour
} //goDay
} //goMouth
}
// 判断100年内的结果条数
if (resultArr.length == 0) {
state.resultList = ['没有达到条件的结果!'];
} else {
state.resultList = resultArr;
if (resultArr.length !== 5) {
state.resultList.push('最近100年内只有上面' + resultArr.length + '条结果!');
}
}
// 计算完成-显示结果
state.isShow = true;
}
//用于计算某位数字在数组中的索引
function getIndex(arr, value) {
if (value <= arr[0] || value > arr[arr.length - 1]) {
return 0;
} else {
for (let i = 0; i < arr.length - 1; i++) {
if (value > arr[i] && value <= arr[i + 1]) {
return i + 1;
}
}
}
}
// 获取"年"数组
function getYearArr(rule, year) {
state.dateArr[5] = getOrderArr(year, year + 100);
if (rule !== undefined) {
if (rule.indexOf('-') >= 0) {
state.dateArr[5] = getCycleArr(rule, year + 100, false);
} else if (rule.indexOf('/') >= 0) {
state.dateArr[5] = getAverageArr(rule, year + 100);
} else if (rule !== '*') {
state.dateArr[5] = getAssignArr(rule);
}
}
}
// 获取"月"数组
function getMouthArr(rule) {
state.dateArr[4] = getOrderArr(1, 12);
if (rule.indexOf('-') >= 0) {
state.dateArr[4] = getCycleArr(rule, 12, false);
} else if (rule.indexOf('/') >= 0) {
state.dateArr[4] = getAverageArr(rule, 12);
} else if (rule !== '*') {
state.dateArr[4] = getAssignArr(rule);
}
}
// 获取"日"数组-主要为日期规则
function getWeekArr(rule) {
//只有当日期规则的两个值均为“”时则表达日期是有选项的
if (state.dayRule == '' && state.dayRuleSup == '') {
if (rule.indexOf('-') >= 0) {
state.dayRule = 'weekDay';
state.dayRuleSup = getCycleArr(rule, 7, false);
} else if (rule.indexOf('#') >= 0) {
state.dayRule = 'assWeek';
let matchRule = rule.match(/[0-9]{1}/g);
state.dayRuleSup = [Number(matchRule[0]), Number(matchRule[1])];
state.dateArr[3] = [1];
if (state.dayRuleSup[1] == 7) {
state.dayRuleSup[1] = 0;
}
} else if (rule.indexOf('L') >= 0) {
state.dayRule = 'lastWeek';
state.dayRuleSup = Number(rule.match(/[0-9]{1,2}/g)[0]);
state.dateArr[3] = [31];
if (state.dayRuleSup == 7) {
state.dayRuleSup = 0;
}
} else if (rule !== '*' && rule !== '?') {
state.dayRule = 'weekDay';
state.dayRuleSup = getAssignArr(rule);
}
//如果weekDay时将7调整为0【week值0即是星期日】
if (state.dayRule == 'weekDay') {
for (let i = 0; i < state.dayRuleSup.length; i++) {
if (state.dayRuleSup[i] == 7) {
state.dayRuleSup[i] = 0;
}
}
}
}
}
// 获取"日"数组-少量为日期规则
function getDayArr(rule) {
state.dateArr[3] = getOrderArr(1, 31);
state.dayRule = '';
state.dayRuleSup = '';
if (rule.indexOf('-') >= 0) {
state.dateArr[3] = getCycleArr(rule, 31, false);
state.dayRuleSup = 'null';
} else if (rule.indexOf('/') >= 0) {
state.dateArr[3] = getAverageArr(rule, 31);
state.dayRuleSup = 'null';
} else if (rule.indexOf('W') >= 0) {
state.dayRule = 'workDay';
state.dayRuleSup = Number(rule.match(/[0-9]{1,2}/g)[0]);
state.dateArr[3] = [state.dayRuleSup];
} else if (rule.indexOf('L') >= 0) {
state.dayRule = 'lastDay';
state.dayRuleSup = 'null';
state.dateArr[3] = [31];
} else if (rule !== '*' && rule !== '?') {
state.dateArr[3] = getAssignArr(rule);
state.dayRuleSup = 'null';
} else if (rule == '*') {
state.dayRuleSup = 'null';
}
}
// 获取"时"数组
function getHourArr(rule) {
state.dateArr[2] = getOrderArr(0, 23);
if (rule.indexOf('-') >= 0) {
state.dateArr[2] = getCycleArr(rule, 24, true);
} else if (rule.indexOf('/') >= 0) {
state.dateArr[2] = getAverageArr(rule, 23);
} else if (rule !== '*') {
state.dateArr[2] = getAssignArr(rule);
}
}
// 获取"分"数组
function getMinArr(rule) {
state.dateArr[1] = getOrderArr(0, 59);
if (rule.indexOf('-') >= 0) {
state.dateArr[1] = getCycleArr(rule, 60, true);
} else if (rule.indexOf('/') >= 0) {
state.dateArr[1] = getAverageArr(rule, 59);
} else if (rule !== '*') {
state.dateArr[1] = getAssignArr(rule);
}
}
// 获取"秒"数组
function getSecondArr(rule) {
state.dateArr[0] = getOrderArr(0, 59);
if (rule.indexOf('-') >= 0) {
state.dateArr[0] = getCycleArr(rule, 60, true);
} else if (rule.indexOf('/') >= 0) {
state.dateArr[0] = getAverageArr(rule, 59);
} else if (rule !== '*') {
state.dateArr[0] = getAssignArr(rule);
}
}
// 根据传进来的min-max返回一个顺序的数组
function getOrderArr(min, max) {
let arr = [];
for (let i = min; i <= max; i++) {
arr.push(i);
}
return arr;
}
// 根据规则中指定的零散值返回一个数组
function getAssignArr(rule) {
let arr = [];
let assiginArr = rule.split(',');
for (let i = 0; i < assiginArr.length; i++) {
arr[i] = Number(assiginArr[i]);
}
arr.sort(compare);
return arr;
}
// 根据一定算术规则计算返回一个数组
function getAverageArr(rule, limit) {
let arr = [];
let agArr = rule.split('/');
let min = Number(agArr[0]);
let step = Number(agArr[1]);
while (min <= limit) {
arr.push(min);
min += step;
}
return arr;
}
// 根据规则返回一个具有周期性的数组
function getCycleArr(rule, limit, status) {
//status--表示是否从0开始则从1开始
let arr = [];
let cycleArr = rule.split('-');
let min = Number(cycleArr[0]);
let max = Number(cycleArr[1]);
if (min > max) {
max += limit;
}
for (let i = min; i <= max; i++) {
let add = 0;
if (status == false && i % limit == 0) {
add = limit;
}
arr.push(Math.round((i % limit) + add));
}
arr.sort(compare);
return arr;
}
//比较数字大小用于Array.sort
function compare(value1, value2) {
if (value2 - value1 > 0) {
return -1;
} else {
return 1;
}
}
// 格式化日期格式如2017-9-19 18:04:33
function formatDate(value, type) {
// 计算日期相关值
let time = typeof value == 'number' ? new Date(value) : value;
let Y = time.getFullYear();
let M = time.getMonth() + 1;
let D = time.getDate();
let h = time.getHours();
let m = time.getMinutes();
let s = time.getSeconds();
let week = time.getDay();
// 如果传递了type的话
if (type == undefined) {
return (
Y +
'-' +
(M < 10 ? '0' + M : M) +
'-' +
(D < 10 ? '0' + D : D) +
' ' +
(h < 10 ? '0' + h : h) +
':' +
(m < 10 ? '0' + m : m) +
':' +
(s < 10 ? '0' + s : s)
);
} else if (type == 'week') {
return week;
}
}
// 检查日期是否存在
function checkDate(value) {
let time = new Date(value);
let format = formatDate(time);
return value == format ? true : false;
}
</script>

View File

@@ -0,0 +1,149 @@
<template>
<el-form size="small">
<el-form-item>
<el-radio v-model="radioValue" :label="1"> 允许的通配符[, - * /] </el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :label="2">
周期从
<el-input-number v-model="cycle01" :min="0" :max="60" /> - <el-input-number v-model="cycle02" :min="0" :max="60" />
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :label="3">
<el-input-number v-model="average01" :min="0" :max="60" /> 秒开始,每 <el-input-number v-model="average02" :min="0" :max="60" /> 秒执行一次
</el-radio>
</el-form-item>
<el-form-item>
<div class="flex-align-center w100">
<el-radio v-model="radioValue" :label="4" class="mr5"> 指定 </el-radio>
<el-select @click="radioValue = 4" class="w100" clearable v-model="checkboxList" placeholder="可多选" multiple>
<el-option v-for="item in 60" :key="item" :value="`${item - 1}`">{{ item - 1 }}</el-option>
</el-select>
</div>
</el-form-item>
</el-form>
</template>
<script lang="ts" setup>
import { computed, toRefs, watch, reactive } from 'vue';
import { checkNumber, CrontabValueObj } from './index';
const cron = defineModel<CrontabValueObj>('cron', { required: true });
const state = reactive({
radioValue: 1,
cycle01: 1,
cycle02: 2,
average01: 0,
average02: 1,
checkboxList: [] as any,
});
const { radioValue, cycle01, cycle02, average01, average02, checkboxList } = toRefs(state);
// 单选按钮值变化时
function radioChange() {
switch (state.radioValue) {
case 1:
cron.value.second = '*';
cron.value.min = '*';
break;
case 2:
cron.value.second = state.cycle01 + '-' + state.cycle02;
break;
case 3:
cron.value.second = state.average01 + '/' + state.average02;
break;
case 4:
cron.value.second = checkboxString.value;
break;
}
}
// 周期两个值变化时
function cycleChange() {
state.cycle01 = checkNumber(state.cycle01, 0, 59);
state.cycle02 = checkNumber(state.cycle02, 0, 59);
if (state.radioValue == 2) {
cron.value.second = cycleTotal.value;
}
}
// 平均两个值变化时
function averageChange() {
state.average01 = checkNumber(state.average01, 0, 59);
state.average02 = checkNumber(state.average02, 1, 59);
if (state.radioValue == 3) {
cron.value.second = averageTotal.value;
}
}
// checkbox值变化时
function checkboxChange() {
if (state.radioValue == 4) {
cron.value.second = checkboxString.value;
}
}
// 计算两个周期值
const cycleTotal = computed(() => {
return state.cycle01 + '-' + state.cycle02;
});
// 计算平均用到的值
const averageTotal = computed(() => {
return state.average01 + '/' + state.average02;
});
// 计算勾选的checkbox值合集
const checkboxString = computed(() => {
let str = state.checkboxList.join();
return str == '' ? '*' : str;
});
watch(
() => state.radioValue,
() => {
radioChange();
}
);
watch(cycleTotal, () => {
cycleChange();
});
watch(averageTotal, () => {
averageChange();
});
watch(checkboxString, () => {
checkboxChange();
});
const parse = () => {
//反解析
let ins = cron.value.second;
if (ins === '*') {
state.radioValue = 1;
} else if (ins.indexOf('-') > -1) {
state.radioValue = 2;
let indexArr = ins.split('-') as any;
isNaN(indexArr[0]) ? (state.cycle01 = 0) : (state.cycle01 = indexArr[0]);
state.cycle02 = indexArr[1];
} else if (ins.indexOf('/') > -1) {
state.radioValue = 3;
let indexArr = ins.split('/') as any;
isNaN(indexArr[0]) ? (state.average01 = 0) : (state.average01 = indexArr[0]);
state.average02 = indexArr[1];
} else {
state.radioValue = 4;
state.checkboxList = ins.split(',');
}
};
defineExpose({ parse });
</script>

View File

@@ -0,0 +1,205 @@
<template>
<el-form size="small">
<el-form-item>
<el-radio v-model="radioValue" :label="1"> 允许的通配符[, - * / L #] </el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :label="2"> 不指定 </el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :label="3">
周期从星期
<el-input-number v-model="cycle01" :min="1" :max="7" /> -
<el-input-number v-model="cycle02" :min="1" :max="7" />
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :label="4">
<el-input-number v-model="average01" :min="1" :max="4" /> 周的星期
<el-input-number v-model="average02" :min="1" :max="7" />
</el-radio>
</el-form-item>
<el-form-item>
<el-radio v-model="radioValue" :label="5">
本月最后一个星期
<el-input-number v-model="weekday" :min="1" :max="7" />
</el-radio>
</el-form-item>
<el-form-item>
<div class="flex-align-center w100">
<el-radio v-model="radioValue" :label="6" class="mr5"> 指定 </el-radio>
<el-select @click="radioValue = 6" class="w100" clearable v-model="checkboxList" placeholder="可多选" multiple>
<el-option v-for="(item, index) of weekList" :label="item" :key="index" :value="`${index + 1}`">{{ item }}</el-option>
</el-select>
</div>
</el-form-item>
</el-form>
</template>
<script lang="ts" setup>
import { computed, toRefs, watch, reactive } from 'vue';
import { checkNumber, CrontabValueObj } from './index';
const cron = defineModel<CrontabValueObj>('cron', { required: true });
const state = reactive({
radioValue: 2,
weekday: 1,
cycle01: 1,
cycle02: 2,
average01: 1,
average02: 1,
checkboxList: [] as any,
weekList: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
});
const { radioValue, cycle01, cycle02, average01, average02, checkboxList, weekday, weekList } = toRefs(state);
// 单选按钮值变化时
function radioChange() {
if (state.radioValue === 1) {
cron.value.week = '*';
cron.value.year = '*';
} else {
if (cron.value.mouth === '*') {
cron.value.mouth = '0';
}
if (cron.value.day === '*') {
cron.value.day = '0';
}
if (cron.value.hour === '*') {
cron.value.hour = '0';
}
if (cron.value.min === '*') {
cron.value.min = '0';
}
if (cron.value.second === '*') {
cron.value.second = '0';
}
}
switch (state.radioValue) {
case 2:
cron.value.week = '?';
break;
case 3:
cron.value.week = state.cycle01 + '-' + state.cycle02;
break;
case 4:
cron.value.week = state.average01 + '#' + state.average02;
break;
case 5:
cron.value.week = state.weekday + 'L';
break;
case 6:
cron.value.week = checkboxString.value;
break;
}
}
// 周期两个值变化时
function cycleChange() {
state.cycle01 = checkNumber(state.cycle01, 1, 7);
state.cycle02 = checkNumber(state.cycle02, 1, 7);
if (state.radioValue == 3) {
cron.value.week = cycleTotal.value;
}
}
// 平均两个值变化时
function averageChange() {
state.average01 = checkNumber(state.average01, 1, 4);
state.average02 = checkNumber(state.average02, 1, 7);
if (state.radioValue == 4) {
cron.value.week = averageTotal.value;
}
}
// checkbox值变化时
function checkboxChange() {
if (state.radioValue == 6) {
cron.value.week = checkboxString.value;
}
}
// 计算两个周期值
const cycleTotal = computed(() => {
return state.cycle01 + '-' + state.cycle02;
});
// 计算平均用到的值
const averageTotal = computed(() => {
return state.average01 + '#' + state.average02;
});
// 计算勾选的checkbox值合集
const checkboxString = computed(() => {
let str = state.checkboxList.join();
return str == '' ? '*' : str;
});
watch(
() => state.radioValue,
() => {
radioChange();
}
);
watch(cycleTotal, () => {
cycleChange();
});
watch(averageTotal, () => {
averageChange();
});
watch(checkboxString, () => {
checkboxChange();
});
watch(
() => state.weekday,
() => {
state.weekday = checkNumber(state.weekday, 1, 7);
if (state.radioValue == 5) {
cron.value.week = state.weekday + 'L';
}
}
);
const parse = () => {
//反解析
let value = cron.value.week;
if (!value) {
return;
}
if (value === '*') {
state.radioValue = 1;
} else if (value == '?') {
state.radioValue = 2;
} else if (value.indexOf('-') > -1) {
let indexArr = value.split('-') as any;
isNaN(indexArr[0]) ? (state.cycle01 = 0) : (state.cycle01 = indexArr[0]);
state.cycle02 = indexArr[1];
state.radioValue = 3;
} else if (value.indexOf('#') > -1) {
let indexArr = value.split('#') as any;
isNaN(indexArr[0]) ? (state.average01 = 1) : (state.average01 = indexArr[0]);
state.average02 = indexArr[1];
state.radioValue = 4;
} else if (value.indexOf('L') > -1) {
let indexArr = value.split('L') as any;
isNaN(indexArr[0]) ? (state.weekday = 1) : (state.weekday = indexArr[0]);
state.radioValue = 5;
} else {
state.checkboxList = value.split(',');
state.radioValue = 6;
}
};
defineExpose({ parse });
</script>

View File

@@ -0,0 +1,159 @@
<template>
<el-form size="small">
<el-form-item>
<el-radio :label="1" v-model="radioValue"> 不填允许的通配符[, - * /] </el-radio>
</el-form-item>
<el-form-item>
<el-radio :label="2" v-model="radioValue"> 每年 </el-radio>
</el-form-item>
<el-form-item>
<el-radio :label="3" v-model="radioValue">
周期从
<el-input-number v-model="cycle01" :min="fullYear" /> -
<el-input-number v-model="cycle02" :min="fullYear" />
</el-radio>
</el-form-item>
<el-form-item>
<el-radio :label="4" v-model="radioValue">
<el-input-number v-model="average01" :min="fullYear" /> 年开始,每 <el-input-number v-model="average02" :min="fullYear" /> 年执行一次
</el-radio>
</el-form-item>
<el-form-item>
<div class="flex-align-center w100">
<el-radio v-model="radioValue" :label="5" class="mr5"> 指定 </el-radio>
<el-select @click="radioValue = 5" class="w100" clearable v-model="checkboxList" placeholder="可多选" multiple>
<el-option v-for="item in 9" :key="item" :value="`${item - 1 + fullYear}`" :label="item - 1 + fullYear" />
</el-select>
</div>
</el-form-item>
</el-form>
</template>
<script lang="ts" setup>
import { computed, toRefs, watch, onMounted, reactive } from 'vue';
import { checkNumber, CrontabValueObj } from './index';
const cron = defineModel<CrontabValueObj>('cron', { required: true });
const state = reactive({
fullYear: 0,
radioValue: 1,
cycle01: 0,
cycle02: 0,
average01: 0,
average02: 1,
checkboxList: [] as any,
});
const { radioValue, cycle01, cycle02, average01, average02, checkboxList, fullYear } = toRefs(state);
onMounted(() => {
// 仅获取当前年份
state.fullYear = Number(new Date().getFullYear());
});
// 单选按钮值变化时
function radioChange() {
switch (state.radioValue) {
case 1:
cron.value.year = '';
break;
case 2:
cron.value.year = '*';
break;
case 3:
cron.value.year = state.cycle01 + '-' + state.cycle02;
break;
case 4:
cron.value.year = state.average01 + '/' + state.average02;
break;
case 5:
cron.value.year = checkboxString.value;
break;
}
}
// 周期两个值变化时
function cycleChange() {
state.cycle01 = checkNumber(state.cycle01, state.fullYear, state.fullYear + 100);
state.cycle02 = checkNumber(state.cycle02, state.fullYear + 1, state.fullYear + 101);
if (state.radioValue == 3) {
cron.value.year = cycleTotal.value;
}
}
// 平均两个值变化时
function averageChange() {
state.average01 = checkNumber(state.average01, state.fullYear, state.fullYear + 100);
state.average02 = checkNumber(state.average02, 1, 10);
if (state.radioValue == 4) {
cron.value.year = averageTotal.value;
}
}
// checkbox值变化时
function checkboxChange() {
if (state.radioValue == 5) {
cron.value.year = checkboxString.value;
}
}
// 计算两个周期值
const cycleTotal = computed(() => {
return state.cycle01 + '-' + state.cycle02;
});
// 计算平均用到的值
const averageTotal = computed(() => {
return state.average01 + '/' + state.average02;
});
// 计算勾选的checkbox值合集
const checkboxString = computed(() => {
let str = state.checkboxList.join();
return str == '' ? '*' : str;
});
watch(
() => state.radioValue,
() => {
radioChange();
}
);
watch(cycleTotal, () => {
cycleChange();
});
watch(averageTotal, () => {
averageChange();
});
watch(checkboxString, () => {
checkboxChange();
});
const parse = () => {
//反解析
let value = cron.value.year;
if (value == '') {
state.radioValue = 1;
} else if (value == '*') {
state.radioValue = 2;
} else if (value.indexOf('-') > -1) {
state.radioValue = 3;
} else if (value.indexOf('/') > -1) {
state.radioValue = 4;
} else {
state.checkboxList = value.split(',');
state.radioValue = 5;
}
};
defineExpose({ parse });
</script>

View File

@@ -0,0 +1,21 @@
// 表单选项的子组件校验数字格式
export function checkNumber(value: any, minLimit: number, maxLimit: number) {
//检查必须为整数
value = Math.floor(value);
if (value < minLimit) {
value = minLimit;
} else if (value > maxLimit) {
value = maxLimit;
}
return value;
}
export interface CrontabValueObj {
second: string;
min: string;
hour: string;
day: string;
mouth: string;
week: string;
year: string;
}

View File

@@ -1,10 +1,10 @@
<template>
<div class="dynamic-form">
<el-form v-bind="$attrs" ref="formRef" :model="formData" label-width="auto">
<el-form-item v-for="item in formItems as any" :key="item.name" :prop="item.model" :label="item.name" required>
<el-input v-if="!item.options" v-model="formData[item.model]" :placeholder="item.placeholder" autocomplete="off" clearable></el-input>
<el-form v-bind="$attrs" ref="formRef" :model="modelValue" label-width="auto">
<el-form-item v-for="item in props.formItems as any" :key="item.name" :prop="item.model" :label="item.name" :required="item.required ?? true">
<el-input v-if="!item.options" v-model="modelValue[item.model]" :placeholder="item.placeholder" autocomplete="off" clearable></el-input>
<el-select v-else v-model="formData[item.model]" :placeholder="item.placeholder" filterable autocomplete="off" clearable style="width: 100%">
<el-select v-else v-model="modelValue[item.model]" :placeholder="item.placeholder" filterable autocomplete="off" clearable style="width: 100%">
<el-option v-for="option in item.options.split(',')" :key="option" :label="option" :value="option" />
</el-select>
</el-form-item>
@@ -13,19 +13,15 @@
</template>
<script lang="ts" setup>
import { useVModel } from '@vueuse/core';
import { ref } from 'vue';
const props = defineProps({
formItems: { type: Array },
modelValue: { type: Object },
});
const emit = defineEmits(['update:modelValue']);
const formRef: any = ref();
const formData: any = useVModel(props, 'modelValue', emit);
const modelValue: any = defineModel();
const validate = async (func: any) => {
await formRef.value.validate(func);

View File

@@ -1,7 +1,7 @@
<template>
<div class="form-dialog">
<el-dialog @close="close" v-bind="$attrs" :title="title" v-model="dialogVisible" :width="width">
<dynamic-form ref="df" :form-items="formItems" v-model="formData" />
<dynamic-form ref="df" :form-items="props.formItems" v-model="formData" />
<template #footer>
<span>
@@ -18,22 +18,19 @@
<script lang="ts" setup>
import { ref } from 'vue';
import DynamicForm from './DynamicForm.vue';
import { useVModel } from '@vueuse/core';
const emit = defineEmits(['update:visible', 'update:modelValue', 'close', 'confirm']);
const emit = defineEmits(['close', 'confirm']);
const props = defineProps({
title: { type: String },
visible: { type: Boolean },
width: { type: [String, Number], default: '500px' },
formItems: { type: Array },
modelValue: { type: Object },
});
const df: any = ref();
const formData: any = useVModel(props, 'modelValue', emit);
const dialogVisible: any = useVModel(props, 'visible', emit);
const formData: any = defineModel('modelValue');
const dialogVisible = defineModel<boolean>('visible', { default: false });
const close = () => {
emit('close');

View File

@@ -29,6 +29,12 @@
</template>
</el-table-column>
<el-table-column prop="required" label="必填" min-width="40px">
<template #default="scope">
<el-checkbox v-model="scope.row['required']" />
</template>
</el-table-column>
<el-table-column label="操作" wdith="20px">
<template #default="scope">
<el-button type="danger" @click="deleteItem(scope.$index)" icon="delete" plain></el-button>
@@ -39,15 +45,7 @@
</template>
<script lang="ts" setup>
import { useVModel } from '@vueuse/core';
const props = defineProps({
modelValue: { type: Array },
});
const emit = defineEmits(['update:modelValue']);
const formItems: any = useVModel(props, 'modelValue', emit);
const formItems: any = defineModel('modelValue');
const addItem = () => {
formItems.value.push({});

View File

@@ -0,0 +1,86 @@
<template>
<div id="echarts" ref="chartRef" :style="echartsStyle" />
</template>
<script setup lang="ts" name="ECharts">
import { ref, onMounted, onBeforeUnmount, watch, computed, markRaw, nextTick } from 'vue';
import { EChartsType, ECElementEvent } from 'echarts/core';
import echarts, { ECOption } from './config';
import { useDebounceFn, useEventListener } from '@vueuse/core';
import { light } from './config/theme';
// import { useThemeConfig } from '@/store/themeConfig';
// import { storeToRefs } from 'pinia';
interface Props {
option: ECOption;
renderer?: 'canvas' | 'svg';
resize?: boolean;
theme?: Object | string;
width?: number | string;
height?: number | string;
onClick?: (event: ECElementEvent) => any;
}
const props = withDefaults(defineProps<Props>(), {
renderer: 'canvas',
theme: light as any,
resize: true,
});
const echartsStyle = computed(() => {
return props.width || props.height ? { height: props.height + 'px', width: props.width + 'px' } : { height: '100%', width: '100%' };
});
const chartRef = ref<HTMLDivElement | HTMLCanvasElement>();
const chartInstance = ref<EChartsType>();
const draw = () => {
if (chartInstance.value) {
chartInstance.value.setOption(props.option, { notMerge: true });
}
};
watch(props, () => {
draw();
});
const handleClick = (event: ECElementEvent) => props.onClick && props.onClick(event);
const init = () => {
if (!chartRef.value) return;
chartInstance.value = echarts.getInstanceByDom(chartRef.value);
if (!chartInstance.value) {
chartInstance.value = markRaw(
echarts.init(chartRef.value, props.theme, {
renderer: props.renderer,
})
);
chartInstance.value.on('click', handleClick);
draw();
}
};
const resize = () => {
if (chartInstance.value && props.resize) {
chartInstance.value.resize({ animation: { duration: 300 } });
}
};
const debouncedResize = useDebounceFn(resize, 300, { maxWait: 800 });
onMounted(() => {
nextTick(() => init());
useEventListener('resize', debouncedResize);
});
onBeforeUnmount(() => {
chartInstance.value?.dispose();
});
defineExpose({
getInstance: () => chartInstance.value,
resize,
draw,
});
</script>

View File

@@ -0,0 +1,67 @@
import * as echarts from 'echarts/core';
import { BarChart, LineChart, LinesChart, PieChart, ScatterChart, RadarChart, GaugeChart } from 'echarts/charts';
import {
TitleComponent,
TooltipComponent,
GridComponent,
DatasetComponent,
TransformComponent,
LegendComponent,
PolarComponent,
GeoComponent,
ToolboxComponent,
DataZoomComponent,
} from 'echarts/components';
import { LabelLayout, UniversalTransition } from 'echarts/features';
import { CanvasRenderer } from 'echarts/renderers';
import type {
BarSeriesOption,
LineSeriesOption,
LinesSeriesOption,
PieSeriesOption,
ScatterSeriesOption,
RadarSeriesOption,
GaugeSeriesOption,
} from 'echarts/charts';
import type { TitleComponentOption, TooltipComponentOption, GridComponentOption, DatasetComponentOption } from 'echarts/components';
import type { ComposeOption } from 'echarts/core';
// import 'echarts-liquidfill';
export type ECOption = ComposeOption<
| BarSeriesOption
| LineSeriesOption
| LinesSeriesOption
| PieSeriesOption
| RadarSeriesOption
| GaugeSeriesOption
| TitleComponentOption
| TooltipComponentOption
| GridComponentOption
| DatasetComponentOption
| ScatterSeriesOption
>;
echarts.use([
TitleComponent,
TooltipComponent,
GridComponent,
DatasetComponent,
TransformComponent,
LegendComponent,
PolarComponent,
GeoComponent,
ToolboxComponent,
DataZoomComponent,
BarChart,
LineChart,
LinesChart,
PieChart,
ScatterChart,
RadarChart,
GaugeChart,
LabelLayout,
UniversalTransition,
CanvasRenderer,
]);
export default echarts;

View File

@@ -0,0 +1,151 @@
const light = {
seriesCnt: '4',
backgroundColor: 'rgba(0,0,0,0)',
titleColor: '#008acd',
subtitleColor: '#aaaaaa',
textColorShow: false,
textColor: '#333',
markTextColor: '#eeeeee',
color: [
'#2ec7c9',
'#b6a2de',
'#5ab1ef',
'#ffb980',
'#d87a80',
'#8d98b3',
'#e5cf0d',
'#97b552',
'#95706d',
'#dc69aa',
'#07a2a4',
'#9a7fd1',
'#588dd5',
'#f5994e',
'#c05050',
'#59678c',
'#c9ab00',
'#7eb00a',
'#6f5553',
'#c14089',
],
borderColor: '#ccc',
borderWidth: 0,
visualMapColor: ['#5ab1ef', '#e0ffff'],
legendTextColor: '#333333',
kColor: '#d87a80',
kColor0: '#2ec7c9',
kBorderColor: '#d87a80',
kBorderColor0: '#2ec7c9',
kBorderWidth: 1,
lineWidth: 2,
symbolSize: 3,
symbol: 'emptyCircle',
symbolBorderWidth: 1,
lineSmooth: true,
graphLineWidth: 1,
graphLineColor: '#aaaaaa',
mapLabelColor: '#d87a80',
mapLabelColorE: 'rgb(100,0,0)',
mapBorderColor: '#eeeeee',
mapBorderColorE: '#444',
mapBorderWidth: 0.5,
mapBorderWidthE: 1,
mapAreaColor: '#dddddd',
mapAreaColorE: 'rgba(254,153,78,1)',
axes: [
{
type: 'all',
name: '通用坐标轴',
axisLineShow: true,
axisLineColor: '#eeeeee',
axisTickShow: true,
axisTickColor: '#eeeeee',
axisLabelShow: true,
axisLabelColor: '#eeeeee',
splitLineShow: true,
splitLineColor: ['#aaaaaa'],
splitAreaShow: false,
splitAreaColor: ['#eeeeee'],
},
{
type: 'category',
name: '类目坐标轴',
axisLineShow: true,
axisLineColor: '#008acd',
axisTickShow: true,
axisTickColor: '#333',
axisLabelShow: true,
axisLabelColor: '#333',
splitLineShow: false,
splitLineColor: ['#eee'],
splitAreaShow: false,
splitAreaColor: ['rgba(250,250,250,0.3)', 'rgba(200,200,200,0.3)'],
},
{
type: 'value',
name: '数值坐标轴',
axisLineShow: true,
axisLineColor: '#008acd',
axisTickShow: true,
axisTickColor: '#333',
axisLabelShow: true,
axisLabelColor: '#333',
splitLineShow: true,
splitLineColor: ['#eee'],
splitAreaShow: true,
splitAreaColor: ['rgba(250,250,250,0.3)', 'rgba(200,200,200,0.3)'],
},
{
type: 'log',
name: '对数坐标轴',
axisLineShow: true,
axisLineColor: '#008acd',
axisTickShow: true,
axisTickColor: '#333',
axisLabelShow: true,
axisLabelColor: '#333',
splitLineShow: true,
splitLineColor: ['#eee'],
splitAreaShow: true,
splitAreaColor: ['rgba(250,250,250,0.3)', 'rgba(200,200,200,0.3)'],
},
{
type: 'time',
name: '时间坐标轴',
axisLineShow: true,
axisLineColor: '#008acd',
axisTickShow: true,
axisTickColor: '#333',
axisLabelShow: true,
axisLabelColor: '#333',
splitLineShow: true,
splitLineColor: ['#eee'],
splitAreaShow: false,
splitAreaColor: ['rgba(250,250,250,0.3)', 'rgba(200,200,200,0.3)'],
},
],
axisSeperateSetting: true,
toolboxColor: '#2ec7c9',
toolboxEmphasisColor: '#18a4a6',
tooltipAxisColor: '#008acd',
tooltipAxisWidth: '1',
timelineLineColor: '#008acd',
timelineLineWidth: 1,
timelineItemColor: '#008acd',
timelineItemColorE: '#a9334c',
timelineCheckColor: '#2ec7c9',
timelineCheckBorderColor: '#2ec7c9',
timelineItemBorderWidth: 1,
timelineControlColor: '#008acd',
timelineControlBorderColor: '#008acd',
timelineControlBorderWidth: 0.5,
timelineLabelColor: '#008acd',
datazoomBackgroundColor: 'rgba(47,69,84,0)',
datazoomDataColor: '#efefff',
datazoomFillColor: 'rgba(182,162,222,0.2)',
datazoomHandleColor: '#008acd',
datazoomHandleWidth: '100',
datazoomLabelColor: '#333333',
};
export { light };

View File

@@ -1,7 +1,7 @@
<template>
<div class="monaco-editor" style="border: 1px solid var(--el-border-color-light, #ebeef5); height: 100%">
<div class="monaco-editor-content" ref="monacoTextarea" :style="{ height: height }"></div>
<el-select v-if="canChangeMode" class="code-mode-select" v-model="languageMode" @change="changeLanguage">
<el-select v-if="canChangeMode" class="code-mode-select" v-model="languageMode" @change="changeLanguage" filterable>
<el-option v-for="mode in languageArr" :key="mode.value" :label="mode.label" :value="mode.value"> </el-option>
</el-select>
</div>
@@ -294,17 +294,19 @@ const registeShell = () => {
};
const format = () => {
/*
触发自动格式化;
*/
// 触发自动格式化;
monacoEditorIns.trigger('', 'editor.action.formatDocument', '');
};
const focus = () => {
monacoEditorIns.focus();
};
const getEditor = () => {
return monacoEditorIns;
};
defineExpose({ getEditor, format });
defineExpose({ getEditor, format, focus });
</script>
<style lang="scss">

View File

@@ -0,0 +1,37 @@
import { VNode, h, render } from 'vue';
import MonacoEditorDialogComp from './MonacoEditorDialogComp.vue';
export type MonacoEditorDialogProps = {
content: string;
title: string;
language: string;
height?: string;
width?: string;
confirmFn?: Function; // 点击确认的回调函数入参editor value
cancelFn?: Function; // 点击取消 或 关闭弹窗的回调函数
};
const boxId = 'monaco-editor-dialog-id';
let boxInstance: VNode;
const MonacoEditorDialog = (props: MonacoEditorDialogProps): void => {
if (!boxInstance) {
const container = document.createElement('div');
container.id = boxId;
// 创建 虚拟dom
boxInstance = h(MonacoEditorDialogComp);
// 将虚拟dom渲染到 container dom 上
render(boxInstance, container);
// 最后将 container 追加到 body 上
document.body.appendChild(container);
}
const boxVue = boxInstance.component;
if (boxVue) {
// 调用open方法显示弹框注意不能使用boxVue.ctx来调用组件函数build打包后ctx会获取不到
boxVue.exposed?.open(props);
}
};
export default MonacoEditorDialog;

View File

@@ -0,0 +1,136 @@
<template>
<div>
<el-dialog :title="state.title" v-model="state.dialogVisible" :width="state.width" @close="cancel">
<monaco-editor ref="editorRef" :height="state.height" class="editor" :language="state.language" v-model="contentValue" can-change-mode />
<template #footer>
<span class="dialog-footer">
<el-button @click="cancel">取消</el-button>
<el-button @click="confirm" type="primary">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { toRefs, ref, reactive } from 'vue';
import { ElDialog, ElButton, ElMessage } from 'element-plus';
// import base style
import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
import { MonacoEditorDialogProps } from './MonacoEditorDialog';
const editorRef: any = ref(null);
const state = reactive({
dialogVisible: false,
height: '450px',
width: '800px',
contentValue: '',
title: '',
language: '',
});
let confirmFn: any;
let cancelFn: any;
const { contentValue } = toRefs(state);
function compressHTML(html: string) {
return (
html
.replace(/[\r\n\t]+/g, ' ') // 移除换行符和制表符
// .replace(/<!--[\s\S]*?-->/g, '') // 移除注释
.replace(/\s{2,}/g, ' ') // 合并多个空格为一个空格
.replace(/>\s+</g, '><')
); // 移除标签之间的空格
}
/**
* 确认按钮
*/
const confirm = async () => {
if (confirmFn) {
if (state.language === 'json') {
let val;
try {
val = JSON.parse(contentValue.value);
if (typeof val !== 'object') {
ElMessage.error('请输入正确的json');
return;
}
} catch (e) {
ElMessage.error('请输入正确的json');
return;
}
// 压缩json字符串
confirmFn(JSON.stringify(val));
} else if (state.language === 'html') {
// 压缩html字符串
confirmFn(compressHTML(contentValue.value));
} else {
confirmFn(contentValue.value);
}
}
state.dialogVisible = false;
setTimeout(() => {
state.contentValue = '';
state.title = '';
}, 200);
};
const cancel = () => {
state.dialogVisible = false;
// 没有执行成功,并且取消回调函数存在,则执行
cancelFn && cancelFn();
setTimeout(() => {
state.contentValue = '';
state.title = '';
}, 200);
};
const formatXML = function (xml: string, tab?: string) {
let formatted = '',
indent = '';
tab = tab || ' ';
xml.split(/>\s*</).forEach(function (node) {
if (node.match(/^\/\w/)) indent = indent.substring(tab!.length);
formatted += indent + '<' + node + '>\r\n';
if (node.match(/^<?\w[^>]*[^\/]$/)) indent += tab;
});
return formatted.substring(1, formatted.length - 3);
};
const open = (optionProps: MonacoEditorDialogProps) => {
confirmFn = optionProps.confirmFn;
cancelFn = optionProps.cancelFn;
const language = optionProps.language;
state.language = language;
state.title = optionProps.title;
if (optionProps.height) {
state.height = optionProps.height;
}
state.contentValue = optionProps.content;
// 格式化输出html;
if (language === 'html' || language == 'xml') {
state.contentValue = formatXML(optionProps.content);
}
setTimeout(() => {
editorRef.value?.format();
editorRef.value?.focus();
}, 300);
state.dialogVisible = true;
};
defineExpose({ open });
</script>
<style lang="scss" scoped>
.editor {
font-size: 9pt;
font-weight: 600;
}
</style>

View File

@@ -1,14 +1,16 @@
<template>
<div>
<!-- 查询表单 -->
<SearchForm v-show="isShowSearch" :items="searchItems" v-model="queryForm_" :search="queryData" :reset="reset" :search-col="searchCol">
<!-- 遍历父组件传入的 solts 透传给子组件 -->
<template v-for="(_, key) in useSlots()" v-slot:[key]>
<slot :name="key"></slot>
</template>
</SearchForm>
<transition name="el-zoom-in-top">
<!-- 查询表单 -->
<SearchForm v-if="isShowSearch" :items="tableSearchItems" v-model="queryForm" :search="search" :reset="reset" :search-col="searchCol">
<!-- 遍历父组件传入的 solts 透传给子组件 -->
<template v-for="(_, key) in useSlots()" v-slot:[key]>
<slot :name="key"></slot>
</template>
</SearchForm>
</transition>
<el-card>
<div class="card">
<div class="table-main">
<!-- 表格头部 操作按钮 -->
<div class="table-header">
@@ -18,39 +20,84 @@
<div v-if="toolButton" class="header-button-ri">
<slot name="toolButton">
<el-button v-if="showToolButton('refresh')" icon="Refresh" circle @click="execQuery()" />
<div class="tool-button">
<!-- 简易单个搜索项 -->
<div v-if="nowSearchItem" class="simple-search-form">
<el-dropdown v-if="searchItems?.length > 1">
<SvgIcon :size="16" name="CaretBottom" class="mr4 mt6 simple-search-form-btn" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="searchItem in searchItems"
:key="searchItem.prop"
@click="changeSimpleFormItem(searchItem)"
>
{{ searchItem.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button v-if="showToolButton('search') && searchItems?.length" icon="Search" circle @click="isShowSearch = !isShowSearch" />
<div class="simple-search-form-label mt5">
<el-text truncated tag="b">{{ `${nowSearchItem?.label} : ` }}</el-text>
</div>
<el-popover
placement="bottom"
title="表格配置"
popper-style="max-height: 550px; overflow: auto; max-width: 450px"
width="auto"
trigger="click"
>
<div v-for="(item, index) in tableColumns" :key="index">
<el-checkbox v-model="item.show" :label="item.label" :true-label="true" :false-label="false" />
<el-form-item style="width: 200px" :key="nowSearchItem.prop">
<SearchFormItem
@keyup.enter.native="searchFormItemKeyUpEnter"
v-if="!nowSearchItem.slot"
:item="nowSearchItem"
v-model="queryForm[nowSearchItem.prop]"
/>
<slot @keyup.enter.native="searchFormItemKeyUpEnter" v-else :name="nowSearchItem.slot"></slot>
</el-form-item>
</div>
<template #reference>
<el-button icon="Operation" circle :size="props.size"></el-button>
</template>
</el-popover>
<div>
<el-button v-if="showToolButton('search') && searchItems?.length" icon="Search" circle @click="search" />
<!-- <el-button v-if="showToolButton('refresh')" icon="Refresh" circle @click="execQuery()" /> -->
<el-button
v-if="showToolButton('search') && searchItems?.length > 1"
:icon="isShowSearch ? 'ArrowDown' : 'ArrowUp'"
circle
@click="isShowSearch = !isShowSearch"
/>
<el-popover
placement="bottom"
title="表格配置"
popper-style="max-height: 550px; overflow: auto; max-width: 450px"
width="auto"
trigger="click"
>
<div v-for="(item, index) in tableColumns" :key="index">
<el-checkbox v-model="item.show" :label="item.label" :true-label="true" :false-label="false" />
</div>
<template #reference>
<el-button icon="Operation" circle :size="props.size"></el-button>
</template>
</el-popover>
</div>
</div>
</slot>
</div>
</div>
<el-table
ref="tableRef"
v-bind="$attrs"
:max-height="tableMaxHeight"
@selection-change="handleSelectionChange"
:data="state.data"
:data="tableData"
highlight-current-row
v-loading="state.loading"
:size="props.size"
v-loading="loading"
:size="props.size as any"
:border="border"
>
<el-table-column v-if="props.showSelection" type="selection" width="40" />
<el-table-column v-if="props.showSelection" :selectable="selectable" type="selection" width="40" />
<template v-for="(item, index) in tableColumns">
<el-table-column
@@ -106,126 +153,150 @@
</el-table>
</div>
<el-row class="mt20" type="flex" justify="end">
<el-row v-if="props.pageable" class="mt20" type="flex" justify="end">
<el-pagination
:small="props.size == 'small'"
@current-change="handlePageChange"
@size-change="handleSizeChange"
@current-change="handlePageNumChange"
@size-change="handlePageSizeChange"
style="text-align: right"
layout="prev, pager, next, total, sizes, jumper"
:total="state.total"
v-model:current-page="queryForm_.pageNum"
v-model:page-size="queryForm_.pageSize"
:total="total"
v-model:current-page="queryForm.pageNum"
v-model:page-size="queryForm.pageSize"
:page-sizes="pageSizes"
/>
</el-row>
</el-card>
</div>
</div>
</template>
<script lang="ts" setup>
import { toRefs, watch, reactive, onMounted, Ref, ref, useSlots } from 'vue';
import { toRefs, watch, reactive, onMounted, Ref, ref, useSlots, toValue } from 'vue';
import { TableColumn } from './index';
import EnumTag from '@/components/enumtag/EnumTag.vue';
import { useThemeConfig } from '@/store/themeConfig';
import { storeToRefs } from 'pinia';
import { useVModel, useEventListener } from '@vueuse/core';
import { useEventListener } from '@vueuse/core';
import Api from '@/common/Api';
import SearchForm from '@/components/SearchForm/index.vue';
import { SearchItem } from '../SearchForm/index';
import SearchFormItem from '../SearchForm/components/SearchFormItem.vue';
import SvgIcon from '@/components/svgIcon/index.vue';
import { usePageTable } from '@/hooks/usePageTable';
import { ElTable } from 'element-plus';
const emit = defineEmits(['update:queryForm', 'update:selectionData', 'pageChange']);
export interface PageTableProps {
size?: string;
showSelection?: boolean;
showSearch?: boolean; // 是否显示搜索表单
columns: TableColumn[]; // 列配置项 ==> 必传
data?: any[]; // 静态 table data 数据,若存在则不会使用 requestApi 返回的 data ==> 非必传
pageApi: Api; // 请求表格数据的 api
columns: TableColumn[]; // 列配置项 ==> 必传
showSelection?: boolean;
selectable?: (row: any) => boolean; // 是否可选
pageable?: boolean;
showSearch?: boolean; // 是否显示搜索表单
data?: any[]; // 静态 table data 数据,若存在则不会使用 requestApi 返回的 data ==> 非必传
lazy?: boolean; // 是否自动执行请求 api ==> 非必传默认为false
beforeQueryFn?: (params: any) => any; // 执行查询时对查询参数进行处理,调整等
dataHandlerFn?: (data: any) => any; // 数据处理回调函数,用于将请求回来的数据二次加工处理等
searchItems?: SearchItem[];
queryForm?: any; // 查询表单参数 ==> 非必传(默认为{pageNum:1, pageSize: 10}
border?: boolean; // 是否带有纵向边框 ==> 非必传默认为false
toolButton?: ('refresh' | 'setting' | 'search')[] | boolean; // 是否显示表格功能按钮 ==> 非必传默认为true
searchCol?: any; // 表格搜索项 每列占比配置 ==> 非必传 { xs: 1, sm: 2, md: 2, lg: 3, xl: 4 }
toolButton?: ('setting' | 'search')[] | boolean; // 是否显示表格功能按钮 ==> 非必传默认为true
searchCol?: any; // 表格搜索项 每列占比配置 ==> 非必传 { xs: 1, sm: 2, md: 2, lg: 3, xl: 4 } | number 如 3
}
// 接受父组件参数,配置默认值
const props = withDefaults(defineProps<PageTableProps>(), {
columns: () => [],
pageable: true,
showSelection: false,
lazy: false,
initParam: {},
queryForm: {
pageNum: 1,
pageSize: 0,
},
border: false,
toolButton: true,
showSearch: false,
searchItems: () => [],
searchCol: () => ({ xs: 1, sm: 3, md: 3, lg: 4, xl: 4 }),
searchCol: () => ({ xs: 1, sm: 3, md: 3, lg: 4, xl: 5 }),
});
// 查询表单参数 ==> 非必传(默认为{pageNum:1, pageSize: 10}
const queryForm: Ref<any> = defineModel('queryForm', {
default: {
pageNum: 1,
pageSize: 0,
},
});
// table 实例
const tableRef = ref<InstanceType<typeof ElTable>>();
// 接收 columns 并设置为响应式
const tableColumns = reactive<TableColumn[]>(props.columns);
const { themeConfig } = storeToRefs(useThemeConfig());
// 接收 searchItems 并设置为响应式
const tableSearchItems = reactive<SearchItem[]>(props.searchItems);
const state = reactive({
pageSizes: [] as any, // 可选每页显示的数据量
isOpenMoreQuery: false,
defaultQueryCount: 2, // 默认显示的查询参数个数展开后每行显示查询条件个数为该值加1。第一行用最后一列来占用按钮
loading: false,
data: [],
total: 0,
// 输入框宽度
inputWidth_: '200px' as any,
formatVal: '', // 格式化后的值
tableMaxHeight: '500px',
});
const { themeConfig } = storeToRefs(useThemeConfig());
// 是否显示搜索模块
const isShowSearch = ref(props.showSearch);
// 控制 ToolButton 显示
const showToolButton = (key: 'refresh' | 'setting' | 'search') => {
const showToolButton = (key: 'setting' | 'search') => {
return Array.isArray(props.toolButton) ? props.toolButton.includes(key) : props.toolButton;
};
const nowSearchItem: Ref<SearchItem> = ref(null) as any;
/**
* 改变当前的搜索项
* @param searchItem 当前点击的搜索项
*/
const changeSimpleFormItem = (searchItem: SearchItem) => {
// 将之前的值置为空,避免因为只显示一个搜索项却搜索多个条件
queryForm.value[nowSearchItem.value.prop] = null;
nowSearchItem.value = searchItem;
};
const { tableData, total, loading, search, reset, getTableData, handlePageNumChange, handlePageSizeChange } = usePageTable(
props.pageable,
props.pageApi,
queryForm,
props.beforeQueryFn,
props.dataHandlerFn
);
const state = reactive({
pageSizes: [] as any, // 可选每页显示的数据量
// 输入框宽度
formatVal: '', // 格式化后的值
tableMaxHeight: '500px',
});
const { pageSizes, formatVal, tableMaxHeight } = toRefs(state);
const queryForm_: Ref<any> = useVModel(props, 'queryForm', emit);
watch(
() => state.data,
(newValue: any) => {
if (newValue && newValue.length > 0) {
props.columns.forEach((item) => {
if (item.autoWidth && item.show) {
item.autoCalculateMinWidth(state.data);
}
});
}
watch(tableData, (newValue: any) => {
if (newValue && newValue.length > 0) {
props.columns.forEach((item) => {
if (item.autoWidth && item.show) {
item.autoCalculateMinWidth(tableData.value);
}
});
}
);
});
watch(
() => isShowSearch.value,
() => {
console.log('watch show sa');
calcuTableHeight();
}
);
watch(isShowSearch, () => {
calcuTableHeight();
});
onMounted(async () => {
calcuTableHeight();
useEventListener(window, 'resize', calcuTableHeight);
let pageSize = queryForm_.value.pageSize;
if (props.searchItems.length > 0) {
nowSearchItem.value = props.searchItems[0];
}
let pageSize = queryForm.value.pageSize;
// 如果pageSize设为0则使用系统全局配置的pageSize
if (!pageSize) {
pageSize = themeConfig.value.defaultListPageSize;
@@ -235,20 +306,25 @@ onMounted(async () => {
}
}
queryForm_.value.pageNum = 1;
queryForm_.value.pageSize = pageSize;
queryForm.value.pageNum = 1;
queryForm.value.pageSize = pageSize;
state.pageSizes = [pageSize, pageSize * 2, pageSize * 3, pageSize * 4, pageSize * 5];
if (!props.lazy) {
await reqPageApi();
await getTableData();
}
});
const calcuTableHeight = () => {
const headerHeight = isShowSearch.value ? 320 : 240;
const headerHeight = isShowSearch.value ? 330 : 250;
state.tableMaxHeight = window.innerHeight - headerHeight + 'px';
};
const searchFormItemKeyUpEnter = (event: any) => {
event.preventDefault();
search();
};
const formatText = (data: any) => {
state.formatVal = '';
try {
@@ -262,66 +338,14 @@ const handleSelectionChange = (val: any) => {
emit('update:selectionData', val);
};
const reqPageApi = async () => {
try {
state.loading = true;
let qf = queryForm_.value;
if (props.beforeQueryFn) {
qf = await props.beforeQueryFn(qf);
}
const res = await props.pageApi?.request(qf);
if (!res) {
return;
}
state.total = res.total;
if (props.dataHandlerFn) {
state.data = await props.dataHandlerFn(res.list);
} else {
state.data = res.list;
}
} finally {
state.loading = false;
}
};
const handlePageChange = (val: number) => {
queryForm_.value.pageNum = val;
execQuery();
};
const handleSizeChange = () => {
changePageNum(1);
execQuery();
};
const queryData = () => {
changePageNum(1);
execQuery();
};
const reset = () => {
// 将查询参数绑定的值置空,并重新粗发查询接口
for (let qi of props.searchItems) {
queryForm_.value[qi.prop] = null;
}
changePageNum(1);
execQuery();
};
const changePageNum = (pageNum: number) => {
queryForm_.value.pageNum = pageNum;
};
const execQuery = async () => {
await reqPageApi();
const getData = () => {
return toValue(tableData);
};
defineExpose({
search: execQuery,
tableRef: tableRef,
search: getTableData,
getData,
});
</script>
<style scoped lang="scss">
@@ -342,6 +366,30 @@ defineExpose({
.header-button-ri {
float: right;
.tool-button {
display: flex;
justify-content: space-between;
}
.simple-search-form {
margin-right: 10px;
display: flex;
justify-content: space-between;
::v-deep(.el-form-item__content > *) {
width: 100% !important;
}
.simple-search-form-label {
text-align: right;
margin-right: 5px;
}
.simple-search-form-btn:hover {
color: var(--el-color-primary);
}
}
}
.el-button {
@@ -404,9 +452,9 @@ defineExpose({
border-radius: 50%;
}
}
}
::v-deep(.el-form-item__label) {
font-weight: bold;
::v-deep(.el-form-item__label) {
font-weight: bold;
}
}
</style>

View File

@@ -121,7 +121,7 @@ export class TableColumn {
/**
* 使用标签类型展示该列(用于枚举值友好展示)
* @param param 枚举对象
* @param param 枚举对象, 如AccountStatusEnum
* @returns this
*/
typeTag(param: any): TableColumn {

View File

@@ -19,6 +19,7 @@ import { ref, nextTick, reactive, onMounted, onBeforeUnmount, watch } from 'vue'
import TerminalSearch from './TerminalSearch.vue';
import { debounce } from 'lodash';
import { TerminalStatus } from './common';
import { useEventListener } from '@vueuse/core';
const props = defineProps({
/**
@@ -145,7 +146,7 @@ const onConnected = () => {
state.status = TerminalStatus.Connected;
// 注册窗口大小监听器
window.addEventListener('resize', debounce(fitTerminal, 400));
useEventListener('resize', debounce(fitTerminal, 400));
focus();
@@ -178,7 +179,7 @@ const clear = () => {
function initSocket() {
if (props.socketUrl) {
let socketUrl = `${props.socketUrl}&rows=${term.rows}&cols=${term.cols}`;
let socketUrl = `${props.socketUrl}&rows=${term?.rows}&cols=${term?.cols}`;
socket = new WebSocket(socketUrl);
}
@@ -196,8 +197,6 @@ function initSocket() {
socket.onclose = (e: CloseEvent) => {
console.log('terminal socket close...', e.reason);
// 关闭窗口大小监听器
window.removeEventListener('resize', debounce(fitTerminal, 100));
// 清除 ping
pingInterval && clearInterval(pingInterval);
state.status = TerminalStatus.Disconnected;

View File

@@ -58,7 +58,7 @@
</div>
</div>
</template>
<div class="terminal-wrapper" :style="{ height: `calc(100vh - ${openTerminal.fullscreen ? '47px' : '200px'})` }">
<div class="terminal-wrapper" :style="{ height: `calc(100vh - ${openTerminal.fullscreen ? '49px' : '200px'})` }">
<TerminalBody
@status-change="terminalStatusChange(openTerminal.terminalId, $event)"
:ref="(el) => setTerminalRef(el, openTerminal.terminalId)"

View File

@@ -0,0 +1,126 @@
import Api from '@/common/Api';
import { isReactive, reactive, toRefs, toValue } from 'vue';
/**
* @description table 页面操作方法封装
* @param pageable 是否为分页获取
* @param {Api} api 获取表格数据 api (必传)
* @param {Object} param 获取数据请求参数 (非必传,默认为{pageNum: 1, pageSize: 10})
* @param {Function} dataCallBack 对api请求返回的数据进行处理的回调方法 (非必传)
* */
export const usePageTable = (
pageable: boolean = true,
api?: Api,
params: any = {
// 当前页数
pageNum: 1,
// 每页显示条数
pageSize: 10,
},
beforeQueryFn?: (params: any) => any,
dataCallBack?: (data: any) => any
) => {
const state = reactive({
// 表格数据
tableData: [],
// 总数量
total: 0,
// 查询参数,包含分页参数
searchParams: params,
loading: false,
});
/**
* @description 获取表格数据
* @return void
* */
const getTableData = async () => {
if (!api) return;
try {
state.loading = true;
let sp = toValue(state.searchParams);
if (beforeQueryFn) {
sp = beforeQueryFn(sp);
if (isReactive(state.searchParams)) {
state.searchParams.value = sp;
} else {
state.searchParams = sp;
}
}
let res = await api.request(sp);
dataCallBack && (res = await dataCallBack(res));
if (pageable) {
state.tableData = res.list;
state.total = res.total;
} else {
state.tableData = res;
}
} finally {
state.loading = false;
}
};
const setPageNum = (pageNum: number) => {
if (!pageable) {
return;
}
state.searchParams.pageNum = pageNum;
};
/**
* @description 表格数据查询pageNum = 1
* @return void
* */
const search = () => {
setPageNum(1);
getTableData();
};
/**
* @description 表格数据重置pageNum = 1,除分页相关参数外其他查询参数置为空
* @return void
* */
const reset = () => {
setPageNum(1);
for (let prop of Object.keys(state.searchParams)) {
if (prop == 'pageNum' || prop == 'pageSize') {
continue;
}
state.searchParams[prop] = null;
}
getTableData();
};
/**
* @description 每页条数改变
* @param {Number} val 当前条数
* @return void
* */
const handlePageSizeChange = (val: number) => {
setPageNum(1);
state.searchParams.pageSize = val;
getTableData();
};
/**
* @description 当前页改变
* @param {Number} val 当前页
* @return void
* */
const handlePageNumChange = (val: number) => {
state.searchParams.pageNum = val;
getTableData();
};
return {
...toRefs(state),
getTableData,
search,
reset,
handlePageSizeChange,
handlePageNumChange,
};
};

View File

@@ -1,12 +1,13 @@
import router from '../router';
import { getClientId, getToken } from './utils/storage';
import { templateResolve } from './utils/string';
import router from '@/router';
import { getClientId, getToken } from '@/common/utils/storage';
import { templateResolve } from '@/common/utils/string';
import { ElMessage } from 'element-plus';
import { createFetch } from '@vueuse/core';
import Api from './Api';
import { Result, ResultEnum } from './request';
import config from './config';
import Api from '@/common/Api';
import { Result, ResultEnum } from '@/common/request';
import config from '@/common/config';
import { unref } from 'vue';
import { URL_401 } from '@/router/staticRouter';
const baseUrl: string = config.baseApiUrl;
@@ -25,6 +26,7 @@ const useCustomFetch = createFetch({
headers.set('Authorization', token);
headers.set('ClientId', getClientId());
}
headers.set('Content-Type', 'application/json');
options.headers = headers;
return { options };
@@ -95,6 +97,16 @@ export function useApiFetch<T>(api: Api, params: any = null, reqOptions: Request
return rejectPromise;
}
const respStatus = uaf.response.value?.status;
if (respStatus == 404) {
ElMessage.error('请求接口不存在');
return rejectPromise;
}
if (respStatus == 500) {
ElMessage.error('服务器响应异常');
return rejectPromise;
}
console.error(e);
ElMessage.error('网络请求错误');
return rejectPromise;
@@ -112,11 +124,12 @@ export function useApiFetch<T>(api: Api, params: any = null, reqOptions: Request
return;
}
// 如果提示没有权限,则移除token使其重新登录
// 如果提示没有权限,则跳转至无权限页面
if (result.code === ResultEnum.NO_PERMISSION) {
router.push({
path: '/401',
path: URL_401,
});
return Promise.reject(result);
}
// 如果返回的code不为成功则会返回对应的错误msg则直接统一通知即可。忽略登录超时或没有权限的提示直接跳转至401页面

View File

@@ -1,26 +1,22 @@
<template>
<el-main class="layout-main">
<el-scrollbar
class="layout-scrollbar"
ref="layoutScrollbarRef"
v-show="!state.currentRouteMeta.link && state.currentRouteMeta.linkType != 1"
:style="{ minHeight: `calc(100vh - ${state.headerHeight}` }"
>
<el-scrollbar ref="layoutScrollbarRef" v-show="!state.currentRouteMeta.link && state.currentRouteMeta.linkType != 1">
<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"
/>
<Link class="h100" :meta="state.currentRouteMeta" v-if="state.currentRouteMeta.link && state.currentRouteMeta.linkType == 2" />
<Iframes
:style="{ height: `calc(100vh - ${state.headerHeight}` }"
class="h100"
:meta="state.currentRouteMeta"
v-if="state.currentRouteMeta.link && state.currentRouteMeta.linkType == 1 && state.isShowLink"
@getCurrentRouteMeta="onGetCurrentRouteMeta"
/>
</el-main>
<el-footer v-if="themeConfig.isFooter">
<Footer />
</el-footer>
</template>
<script setup lang="ts" name="layoutMain">

View File

@@ -1,8 +1,7 @@
<template>
<div class="layout-footer mt15" v-show="isDelayFooter">
<div class="layout-footer flex-all-center" v-show="isDelayFooter">
<div class="layout-footer-warp">
<div>Made by mayfly with </div>
<div class="mt5">mayfly-go</div>
<div>Made by mayfly-go with </div>
</div>
</div>
</template>
@@ -32,13 +31,14 @@ export default {
<style scoped lang="scss">
.layout-footer {
width: 100%;
display: flex;
&-warp {
margin: auto;
color: #9e9e9e;
text-align: center;
animation: logoAnimation 0.3s ease-in-out;
height: 30px;
background-color: var(--el-bg-color);
border-top: 1px solid var(--el-border-color-light);
.layout-footer-warp {
font-size: 14px;
color: var(--el-text-color-secondary);
text-decoration: none;
letter-spacing: 0.5px;
}
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div class="layout-logo" v-if="setShowLogo" @click="onThemeConfigChange">
<img src="@/assets/image/logo.svg" class="layout-logo-medium-img" />
<img :src="themeConfig.logoIcon" class="layout-logo-medium-img" />
<span>
{{ `${themeConfig.globalTitle}` }}
<sub
@@ -9,7 +9,7 @@
</span>
</div>
<div class="layout-logo-size" v-else @click="onThemeConfigChange">
<img src="@/assets/image/logo.svg" class="layout-logo-size-img" />
<img :src="themeConfig.logoIcon" class="layout-logo-size-img" />
</div>
</template>

View File

@@ -15,23 +15,15 @@
</el-container>
</template>
<script lang="ts">
<script lang="ts" setup name="layoutColumns">
import { computed } from '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',
components: { Aside, Header, Main, ColumnsAside },
setup() {
const isFixedHeader = computed(() => {
return useThemeConfig().themeConfig.isFixedHeader;
});
return {
isFixedHeader,
};
},
};
const isFixedHeader = computed(() => {
return useThemeConfig().themeConfig.isFixedHeader;
});
</script>

View File

@@ -12,32 +12,26 @@
</el-container>
</template>
<script lang="ts">
<script lang="ts" setup name="layoutDefaults">
import { computed, getCurrentInstance, watch } from 'vue';
import { useRoute } from 'vue-router';
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',
components: { Aside, Header, Main },
setup() {
const { proxy } = getCurrentInstance() as any;
const route = useRoute();
const isFixedHeader = computed(() => {
return useThemeConfig().themeConfig.isFixedHeader;
});
// 监听路由的变化
watch(
() => route.path,
() => {
proxy.$refs.layoutScrollbarRef.wrapRef.scrollTop = 0;
}
);
return {
isFixedHeader,
};
},
};
const { proxy } = getCurrentInstance() as any;
const route = useRoute();
const isFixedHeader = computed(() => {
return useThemeConfig().themeConfig.isFixedHeader;
});
// 监听路由的变化
watch(
() => route.path,
() => {
try {
proxy.$refs.layoutScrollbarRef.wrapRef.scrollTop = 0;
} catch (e) {}
}
);
</script>

View File

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

View File

@@ -596,7 +596,7 @@ const openDrawer = () => {
themeConfig.value.isDrawer = true;
nextTick(() => {
// 初始化复制功能,防止点击两次才可以复制
onCopyConfigClick(copyConfigBtnRef.value.$el);
onCopyConfigClick(copyConfigBtnRef.value?.$el);
});
};
// 触发 store 布局配置更新

View File

@@ -282,7 +282,9 @@ const onContextmenu = (v: any, e: any) => {
const onTagsClick = (v: any, k: number) => {
state.routePath = decodeURI(v.path);
state.tagsRefsIndex = k;
router.push(v);
try {
router.push(v);
} catch (e) {}
};
// 更新滚动条显示
const updateScrollbar = () => {

View File

@@ -2,19 +2,19 @@
<template v-for="val in chils">
<el-sub-menu :index="val.path" :key="val.path" v-if="val.children && val.children.length > 0">
<template #title>
<SvgIcon :name="val.meta.icon"/>
<SvgIcon :name="val.meta.icon" />
<span>{{ val.meta.title }}</span>
</template>
<sub-item :chil="val.children" />
</el-sub-menu>
<el-menu-item :index="val.path" :key="val?.path" v-else>
<template v-if="!val.meta.link || (val.meta.link && val.meta.linkType == 1)">
<SvgIcon :name="val.meta.icon"/>
<SvgIcon :name="val.meta.icon" />
<span>{{ val.meta.title }}</span>
</template>
<template v-else>
<a :href="val.meta.link" target="_blank">
<SvgIcon :name="val.meta.icon"/>
<SvgIcon :name="val.meta.icon" />
{{ val.meta.title }}
</a>
</template>
@@ -24,7 +24,6 @@
<script lang="ts">
import { computed, defineComponent } from 'vue';
import SvgIcon from '@/components/svgIcon/index.vue';
export default defineComponent({
name: 'navMenuSubItem',
props: {

View File

@@ -1,5 +1,5 @@
<template>
<div class="layout-scrollbar">
<div>
<div class="layout-view-bg-white flex h100" v-loading="iframeLoading">
<iframe :src="iframeUrl" frameborder="0" height="100%" width="100%" id="iframe" v-show="!iframeLoading"></iframe>
</div>
@@ -7,7 +7,7 @@
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, onMounted, onBeforeMount, onUnmounted, nextTick, getCurrentInstance } from 'vue';
import { defineComponent, reactive, toRefs, onMounted, onBeforeMount, onUnmounted, nextTick } from 'vue';
import { useRoute } from 'vue-router';
import mittBus from '@/common/utils/mitt';
export default defineComponent({

View File

@@ -1,5 +1,5 @@
<template>
<div class="layout-scrollbar">
<div>
<div class="layout-view-bg-white flex layout-view-link">
<a :href="currentRouteMeta.link" target="_blank" class="flex-margin">{{ currentRouteMeta.title }}{{ currentRouteMeta.link }}</a>
</div>

View File

@@ -1,13 +1,11 @@
<template>
<div class="h100">
<router-view v-slot="{ Component }">
<transition :name="setTransitionName" mode="out-in">
<keep-alive :include="getKeepAliveNames">
<component :is="Component" :key="state.refreshRouterViewKey" class="w100" />
</keep-alive>
</transition>
</router-view>
</div>
<router-view v-slot="{ Component }">
<transition appear :name="setTransitionName" mode="out-in">
<keep-alive :include="getKeepAliveNames">
<component :is="Component" :key="state.refreshRouterViewKey" />
</keep-alive>
</transition>
</router-view>
</template>
<script lang="ts" setup name="layoutParentView">

View File

@@ -0,0 +1,157 @@
import 'nprogress/nprogress.css';
import { clearSession, getToken } from '@/common/utils/storage';
import openApi from '@/common/openApi';
import { useUserInfo } from '@/store/userInfo';
import { useRoutesList } from '@/store/routesList';
import { useKeepALiveNames } from '@/store/keepAliveNames';
import router from '.';
import { RouteRecordRaw } from 'vue-router';
import { LAYOUT_ROUTE_NAME } from './staticRouter';
/**
* 获取目录下的 .vue、.tsx 全部文件
* @method import.meta.glob
* @link 参考https://cn.vitejs.dev/guide/features.html#json
*/
const viewsModules: Record<string, Function> = import.meta.glob(['../views/**/*.{vue,tsx}']);
const dynamicViewsModules: Record<string, Function> = Object.assign({}, { ...viewsModules });
// 后端控制路由:执行路由数据初始化
export async function initBackendRoutes() {
const token = getToken(); // 获取浏览器缓存 token 值
if (!token) {
// 无 token 停止执行下一步
return false;
}
useUserInfo().setUserInfo({});
// 获取路由
let menuRoute = await getBackEndControlRoutes();
const cacheList: Array<string> = [];
// 处理路由component
const routes = backEndRouterConverter(menuRoute, (router: any) => {
// 可能为false时不存在isKeepAlive属性
if (!router.meta.isKeepAlive) {
router.meta.isKeepAlive = false;
}
if (router.meta.isKeepAlive) {
cacheList.push(router.name);
}
});
routes.forEach((item: any) => {
if (item.meta.isFull) {
// 菜单为全屏展示 (示例:数据大屏页面等)
router.addRoute(item as RouteRecordRaw);
} else {
// 要将嵌套路由添加到现有的路由中,可以将路由的 name 作为第一个参数传递给 router.addRoute(),这将有效地添加路由,就像通过 children 添加的一样
router.addRoute(LAYOUT_ROUTE_NAME, item as RouteRecordRaw);
}
});
useKeepALiveNames().setCacheKeepAlive(cacheList);
useRoutesList().setRoutesList(routes);
}
// 后端控制路由isRequestRoutes 为 true则开启后端控制路由
export async function getBackEndControlRoutes() {
try {
const menuAndPermission = await openApi.getPermissions();
// 赋值权限码,用于控制按钮等
useUserInfo().userInfo.permissions = menuAndPermission.permissions;
return menuAndPermission.menus;
} catch (e: any) {
console.error('获取菜单权限信息失败', e);
clearSession();
throw e;
}
}
type RouterConvCallbackFunc = (router: any) => void;
/**
* 后端控制路由,后端返回路由 转换为vue route
*
* @description routes参数配置简介
* @param code(path) ==> route.path -> 路由菜单访问路径
* @param name ==> title路由标题 相当于route.meta.title
*
* @param meta ==> 路由菜单元信息
* @param meta.routeName ==> route.name -> 路由 name (对应页面组件 name, 可用作 KeepAlive 缓存标识 && 按钮权限筛选)
* @param meta.redirect ==> route.redirect -> 路由重定向地址
* @param meta.component ==> 文件路径
* @param meta.icon ==> 菜单和面包屑对应的图标
* @param meta.isHide ==> 是否在菜单中隐藏 (通常列表详情页需要隐藏)
* @param meta.isFull ==> 菜单是否全屏 (示例:数据大屏页面)
* @param meta.isAffix ==> 菜单是否固定在标签页中 (首页通常是固定项)
* @param meta.isKeepAlive ==> 当前路由是否缓存
* @param meta.linkType ==> 外链类型, 内嵌: 以iframe展示、外链: 新标签打开
* @param meta.link ==> 外链地址
* */
export function backEndRouterConverter(routes: any, callbackFunc: RouterConvCallbackFunc = null as any, parentPath: string = '/') {
if (!routes) return [];
return routes.map((item: any) => {
if (!item.meta) {
return item;
}
// 将json字符串的meta转为对象
item.meta = JSON.parse(item.meta);
// 将meta.comoponet 解析为route.component
if (item.meta.component) {
item.component = dynamicImport(dynamicViewsModules, item.meta.component);
delete item.meta['component'];
}
let path = item.code;
// 如果不是以 / 开头,则路径需要拼接父路径
if (!path.startsWith('/')) {
path = parentPath + '/' + path;
}
item.path = path;
delete item['code'];
// route.meta.title == resource.name
item.meta.title = item.name;
delete item['name'];
// route.name == resource.meta.routeName
item.name = item.meta.routeName;
delete item.meta['routeName'];
// route.redirect == resource.meta.redirect
if (item.meta.redirect) {
item.redirect = item.meta.redirect;
delete item.meta['redirect'];
}
// 存在回调,则执行回调
callbackFunc && callbackFunc(item);
item.children && backEndRouterConverter(item.children, callbackFunc, item.path);
return item;
});
}
/**
* 后端路由 component 转换函数
* @param dynamicViewsModules 获取目录下的 .vue、.tsx 全部文件
* @param component 当前要处理项 component
* @returns 返回处理成函数后的 component
*/
export function dynamicImport(dynamicViewsModules: Record<string, Function>, component: string) {
const keys = Object.keys(dynamicViewsModules);
const matchKeys = keys.filter((key) => {
const k = key.replace(/..\/views|../, '');
return k.startsWith(`${component}`) || k.startsWith(`/${component}`);
});
if (matchKeys?.length === 1) {
return dynamicViewsModules[matchKeys[0]];
}
if (matchKeys?.length > 1) {
console.error('匹配到多个相似组件路径, 可添加后缀.vue或.tsx进行区分或者重命名组件名, 请调整...', matchKeys);
return null;
}
console.error(`未匹配到[${component}]组件名对应的组件文件`);
return null;
}

View File

@@ -1,30 +1,21 @@
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router';
import { createRouter, createWebHashHistory } from 'vue-router';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import { clearSession, getToken } from '@/common/utils/storage';
import { 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 { staticRoutes, URL_LOGIN, URL_401, ROUTER_WHITE_LIST, errorRoutes } from './staticRouter';
import syssocket from '@/common/syssocket';
import pinia from '@/store/index';
import { useThemeConfig } from '@/store/themeConfig';
import { useUserInfo } from '@/store/userInfo';
import { useRoutesList } from '@/store/routesList';
import { useKeepALiveNames } from '@/store/keepAliveNames';
/**
* 获取目录下的 .vue、.tsx 全部文件
* @method import.meta.glob
* @link 参考https://cn.vitejs.dev/guide/features.html#json
*/
const viewsModules: any = import.meta.glob(['../views/**/*.{vue,tsx}']);
const dynamicViewsModules: Record<string, Function> = Object.assign({}, { ...viewsModules });
import { initBackendRoutes } from './dynamicRouter';
// 添加静态路由
const router = createRouter({
history: createWebHashHistory(),
routes: staticRoutes,
routes: [...staticRoutes, ...errorRoutes],
});
// 前端控制路由:初始化方法,防止刷新时丢失
@@ -35,128 +26,15 @@ export function initAllFun() {
return false;
}
useUserInfo().setUserInfo({});
router.addRoute(pathMatch); // 添加404界面
resetRoute(); // 删除/重置路由
router.addRoute(dynamicRoutes[0]);
// 过滤权限菜单
useRoutesList().setRoutesList(dynamicRoutes[0].children);
}
// 后端控制路由:执行路由数据初始化
export async function initBackEndControlRoutesFun() {
const token = getToken(); // 获取浏览器缓存 token 值
if (!token) {
// 无 token 停止执行下一步
return false;
}
useUserInfo().setUserInfo({});
// 获取路由
let menuRoute = await getBackEndControlRoutes();
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(); // 删除/重置路由
router.addRoute(dynamicRoutes[0] as unknown as RouteRecordRaw);
useRoutesList().setRoutesList(dynamicRoutes[0].children);
}
// 后端控制路由isRequestRoutes 为 true则开启后端控制路由
export async function getBackEndControlRoutes() {
try {
const menuAndPermission = await openApi.getPermissions();
// 赋值权限码,用于控制按钮等
useUserInfo().userInfo.permissions = menuAndPermission.permissions;
return menuAndPermission.menus;
} catch (e: any) {
console.error(e);
return [];
}
}
type RouterConvCallbackFunc = (router: any) => void;
// 后端控制路由,后端返回路由 转换为vue route
export function backEndRouterConverter(routes: any, callbackFunc: RouterConvCallbackFunc = null as any, parentPath: string = '/') {
if (!routes) return;
return routes.map((item: any) => {
if (!item.meta) {
return item;
}
// 将json字符串的meta转为对象
item.meta = JSON.parse(item.meta);
// 将meta.comoponet 解析为route.component
if (item.meta.component) {
item.component = dynamicImport(dynamicViewsModules, item.meta.component);
delete item.meta['component'];
}
// route.path == resource.code
let path = item.code;
// 如果不是以 / 开头,则路径需要拼接父路径
if (!path.startsWith('/')) {
path = parentPath + '/' + path;
}
item.path = path;
delete item['code'];
// route.meta.title == resource.name
item.meta.title = item.name;
delete item['name'];
// route.name == resource.meta.routeName
item.name = item.meta.routeName;
delete item.meta['routeName'];
// route.redirect == resource.meta.redirect
if (item.meta.redirect) {
item.redirect = item.meta.redirect;
delete item.meta['redirect'];
}
// 存在回调,则执行回调
callbackFunc && callbackFunc(item);
item.children && backEndRouterConverter(item.children, callbackFunc, item.path);
return item;
});
}
/**
* 后端路由 component 转换函数
* @param dynamicViewsModules 获取目录下的 .vue、.tsx 全部文件
* @param component 当前要处理项 component
* @returns 返回处理成函数后的 component
*/
export function dynamicImport(dynamicViewsModules: Record<string, Function>, component: string) {
const keys = Object.keys(dynamicViewsModules);
const matchKeys = keys.filter((key) => {
const k = key.replace(/..\/views|../, '');
return k.startsWith(`${component}`) || k.startsWith(`/${component}`);
});
if (matchKeys?.length === 1) {
const matchKey = matchKeys[0];
return dynamicViewsModules[matchKey];
}
if (matchKeys?.length > 1) {
return false;
}
// router.addRoute(dynamicRoutes[0]);
// // 过滤权限菜单
// useRoutesList().setRoutesList(dynamicRoutes[0].children);
}
// 删除/重置路由
export function resetRoute() {
useRoutesList().routesList.forEach((route: any) => {
useRoutesList().routesList?.forEach((route: any) => {
const { name } = route;
router.hasRoute(name) && router.removeRoute(name);
});
@@ -172,19 +50,17 @@ export async function initRouter() {
initAllFun();
} else if (isRequestRoutes) {
// 后端控制路由isRequestRoutes 为 true则开启后端控制路由
await initBackEndControlRoutesFun();
await initBackendRoutes();
}
} finally {
NextLoading.done();
}
}
let loadRouter = false;
// 路由加载前
router.beforeEach(async (to, from, next) => {
NProgress.configure({ showSpinner: false });
if (to.meta.title) NProgress.start();
NProgress.start();
// 如果有标题参数,则再原标题后加上参数来区别
if (to.meta.titleRename && to.meta.title) {
@@ -192,24 +68,27 @@ router.beforeEach(async (to, from, next) => {
}
const token = getToken();
if ((to.path === '/login' || to.path == '/oauth2/callback') && !token) {
next();
NProgress.done();
return;
}
if (!token) {
next(`/login?redirect=${to.path}`);
clearSession();
resetRoute();
NProgress.done();
const toPath = to.path;
// 判断是访问登陆页有token就在当前页面没有token重置路由与用户信息到登陆页
if (toPath === URL_LOGIN) {
if (token) {
return next(from.fullPath);
}
resetRoute();
syssocket.destory();
return;
return next();
}
if (token && to.path === '/login') {
next('/');
NProgress.done();
return;
// 判断访问页面是否在路由白名单地址(静态路由)中,如果存在直接放行
if (ROUTER_WHITE_LIST.includes(toPath)) {
return next();
}
// 判断是否有token没有重定向到 login 页面
if (!token) {
return next(`${URL_LOGIN}?redirect=${toPath}`);
}
// 终端不需要连接系统websocket消息
@@ -217,14 +96,18 @@ router.beforeEach(async (to, from, next) => {
syssocket.init();
}
// 不存在路由(避免刷新页面找不到路由)并且未加载过避免token过期导致获取权限接口报权限不足无限获取,则重新初始化路由
if (useRoutesList().routesList.length == 0 && !loadRouter) {
await initRouter();
loadRouter = true;
next({ path: to.path, query: to.query });
} else {
next();
// 不存在路由(避免刷新页面找不到路由),则重新初始化路由
if (useRoutesList().routesList?.length == 0) {
try {
// 可能token过期无法获取菜单权限信息等
await initRouter();
} catch (e) {
return next(`${URL_401}?redirect=${toPath}`);
}
return next({ path: toPath, query: to.query });
}
next();
});
// 路由加载后
@@ -232,5 +115,13 @@ router.afterEach(() => {
NProgress.done();
});
/**
* @description 路由跳转错误
* */
router.onError((error) => {
NProgress.done();
console.warn('路由错误', error.message);
});
// 导出路由
export default router;

View File

@@ -1,171 +0,0 @@
import { RouteRecordRaw } from 'vue-router';
import Layout from '@/layout/index.vue';
// 定义动态路由
export const dynamicRoutes = [
{
path: '/',
name: '/',
component: Layout,
redirect: '/home',
meta: {
isKeepAlive: true,
},
children: [],
// children: [
// {
// path: '/home',
// name: 'home',
// component: () => import('@/views/home/index.vue'),
// meta: {
// title: '首页',
// // iframe链接
// link: '',
// // 是否在菜单栏显示,默认显示
// isHide: false,
// isKeepAlive: true,
// // tag标签是否不可删除
// isAffix: true,
// // 是否为iframe
// isIframe: false,
// icon: 'el-icon-s-home',
// },
// },
// {
// path: '/sys',
// name: 'Resource',
// redirect: '/sys/resources',
// meta: {
// title: '系统管理',
// // 资源code用于校验用户是否拥有该资源权限
// code: 'sys',
// // isKeepAlive: true,
// icon: 'el-icon-monitor',
// },
// children: [
// {
// path: 'sys/resources',
// name: 'ResourceList',
// component: () => import('@/views/system/resource'),
// meta: {
// title: '资源管理',
// code: 'resource:list',
// isKeepAlive: true,
// icon: 'el-icon-menu',
// },
// },
// {
// path: 'sys/roles',
// name: 'RoleList',
// component: () => import('@/views/system/role'),
// meta: {
// title: '角色管理',
// code: 'role:list',
// isKeepAlive: true,
// icon: 'el-icon-menu',
// },
// },
// {
// path: 'sys/accounts',
// name: 'ResourceList',
// component: () => import('@/views/system/account'),
// meta: {
// title: '账号管理',
// code: 'account:list',
// isKeepAlive: true,
// icon: 'el-icon-menu',
// },
// },
// ],
// },
// {
// path: '/machine',
// name: 'Machine',
// redirect: '/machine/list',
// meta: {
// title: '机器管理',
// // 资源code用于校验用户是否拥有该资源权限
// code: 'machine',
// // isKeepAlive: true,
// icon: 'el-icon-monitor',
// },
// children: [
// {
// path: '/list',
// name: 'MachineList',
// component: () => import('@/views/ops/machine'),
// meta: {
// title: '机器列表',
// code: 'machine:list',
// isKeepAlive: true,
// icon: 'el-icon-menu',
// },
// },
// ],
// },
// {
// path: '/personal',
// name: 'personal',
// component: () => import('@/views/personal/index.vue'),
// meta: {
// title: '个人中心',
// isKeepAlive: true,
// icon: 'el-icon-user',
// },
// },
// ],
},
];
// 定义静态路由
export const staticRoutes: Array<RouteRecordRaw> = [
{
path: '/login',
name: 'login',
component: () => import('@/views/login/index.vue'),
meta: {
title: '登录',
},
},
{
path: '/404',
name: 'notFound',
component: () => import('@/views/error/404.vue'),
meta: {
title: '找不到此页面',
},
},
{
path: '/401',
name: 'noPower',
component: () => import('@/views/error/401.vue'),
meta: {
title: '没有权限',
},
},
{
path: '/oauth2/callback',
name: 'oauth2Callback',
component: () => import('@/views/oauth/Oauth2Callback.vue'),
meta: {
title: 'oauth2回调',
},
},
{
path: '/machine/terminal',
name: 'machineTerminal',
component: () => import('@/views/ops/machine/SshTerminalPage.vue'),
meta: {
// 将路径 'xxx?name=名字' 里的name字段值替换到title里
title: '终端 | {name}',
// 是否根据query对标题名进行参数替换即最终显示为终端_机器名
titleRename: true,
},
},
];
// 定义404界面
export const pathMatch = {
path: '/:path(.*)*',
redirect: '/404',
};

View File

@@ -0,0 +1,82 @@
import { RouteRecordRaw } from 'vue-router';
export const URL_HOME: string = '/home';
// 登录页地址(默认)
export const URL_LOGIN: string = '/login';
export const URL_401: string = '/401';
export const URL_404: string = '/404';
export const LAYOUT_ROUTE_NAME: string = 'layout';
// 路由白名单地址(本地存在的路由 staticRouter.ts 中)
export const ROUTER_WHITE_LIST: string[] = [URL_404, URL_401, '/oauth2/callback'];
// 静态路由
export const staticRoutes: Array<RouteRecordRaw> = [
{
path: '/',
redirect: URL_HOME,
},
{
path: URL_LOGIN,
name: 'login',
component: () => import('@/views/login/index.vue'),
meta: {
title: '登录',
},
},
{
path: '/layout',
name: LAYOUT_ROUTE_NAME,
component: () => import('@/layout/index.vue'),
redirect: URL_HOME,
children: [],
},
{
path: '/oauth2/callback',
name: 'oauth2Callback',
component: () => import('@/views/oauth/Oauth2Callback.vue'),
meta: {
title: 'oauth2回调',
},
},
{
path: '/machine/terminal',
name: 'machineTerminal',
component: () => import('@/views/ops/machine/SshTerminalPage.vue'),
meta: {
// 将路径 'xxx?name=名字' 里的name字段值替换到title里
title: '终端 | {name}',
// 是否根据query对标题名进行参数替换即最终显示为终端_机器名
titleRename: true,
},
},
];
// 错误页面路由
export const errorRoutes: Array<RouteRecordRaw> = [
{
path: URL_404,
name: 'notFound',
component: () => import('@/views/error/404.vue'),
meta: {
title: '找不到此页面',
},
},
{
path: URL_401,
name: 'noPower',
component: () => import('@/views/error/401.vue'),
meta: {
title: '没有权限',
},
},
// Resolve refresh page, route warnings
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/error/404.vue'),
},
];

View File

@@ -11,7 +11,7 @@ export const useRoutesList = defineStore('routesList', {
routesList: [],
}),
actions: {
async setRoutesList(data: Array<string>) {
async setRoutesList(data: Array<any>) {
this.routesList = data;
},
},

View File

@@ -1,6 +1,12 @@
import { defineStore } from 'pinia';
import { dateFormat2 } from '@/common/utils/date';
import { useUserInfo } from '@/store/userInfo';
import { getSysStyleConfig } from '@/common/sysconfig';
import { getLocal, getThemeConfig } from '@/common/utils/storage';
// 系统默认logo图标对应于@/assets/image/logo.svg
const logoIcon =
'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+PHN2ZyB0PSIxNjIxODU5MDA5NjA1IiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9Ijk3MDkiIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCI+PGRlZnM+PHN0eWxlIHR5cGU9InRleHQvY3NzIj48L3N0eWxlPjwvZGVmcz48cGF0aCBkPSJNODIwLjIwMzkyMiA4MTIuMTcyNTQ5SDY4NC42NzQ1MXYtNDUuMTc2NDcxaDExMi40MzkyMTVWMjc5LjA5MDE5Nkg2MzMuNDc0NTFsLTg1LjMzMzMzNCAyNzcuMDgyMzUzYy0zLjAxMTc2NSAxMC4wMzkyMTYtMTIuMDQ3MDU5IDE2LjA2Mjc0NS0yMi4wODYyNzQgMTYuMDYyNzQ1LTEwLjAzOTIxNiAwLTE5LjA3NDUxLTcuMDI3NDUxLTIxLjA4MjM1My0xNy4wNjY2NjdsLTcxLjI3ODQzMS0yODAuMDk0MTE3aC0xODAuNzA1ODgzVjc2Mi45ODAzOTJoMTIwLjQ3MDU4OXY0NS4xNzY0NzFIMjI5Ljg5ODAzOWMtMTIuMDQ3MDU5IDAtMjIuMDg2Mjc1LTEwLjAzOTIxNi0yMi4wODYyNzQtMjIuMDg2Mjc1VjI1Mi45ODgyMzVjMC0xMi4wNDcwNTkgMTAuMDM5MjE2LTIyLjA4NjI3NSAyMi4wODYyNzQtMjIuMDg2Mjc0SDQ1MS43NjQ3MDZjMTAuMDM5MjE2IDAgMTkuMDc0NTEgNy4wMjc0NTEgMjIuMDg2Mjc0IDE3LjA2NjY2Nmw1NS4yMTU2ODcgMjE4Ljg1NDkwMkw1OTUuMzI1NDkgMjUwLjk4MDM5MmMzLjAxMTc2NS05LjAzNTI5NCAxMi4wNDcwNTktMTYuMDYyNzQ1IDIxLjA4MjM1My0xNi4wNjI3NDVoMjAyLjc5MjE1N2MxMi4wNDcwNTkgMCAyMi4wODYyNzUgMTAuMDM5MjE2IDIyLjA4NjI3NSAyMi4wODYyNzV2NTMzLjA4MjM1M2MxLjAwMzkyMiAxMi4wNDcwNTktOS4wMzUyOTQgMjIuMDg2Mjc1LTIxLjA4MjM1MyAyMi4wODYyNzR6IG0wIDAiIGZpbGw9IiNlMjU4MTMiIHAtaWQ9Ijk3MTAiPjwvcGF0aD48cGF0aCBkPSJNNzMxLjg1ODgyNCA0MjUuNjYyNzQ1YzQuMDE1Njg2LTEyLjA0NzA1OS0yLjAwNzg0My0yNS4wOTgwMzktMTQuMDU0OTAyLTI5LjExMzcyNS0xMi4wNDcwNTktNC4wMTU2ODYtMjUuMDk4MDM5IDIuMDA3ODQzLTI5LjExMzcyNiAxNC4wNTQ5MDJMNTYzLjIgNzY2Ljk5NjA3OGgtNzMuMjg2Mjc1TDM3MS40NTA5OCA0MTAuNjAzOTIyYy00LjAxNTY4Ni0xMi4wNDcwNTktMTcuMDY2NjY3LTE4LjA3MDU4OC0yOC4xMDk4MDQtMTQuMDU0OTAyLTEyLjA0NzA1OSA0LjAxNTY4Ni0xOC4wNzA1ODggMTcuMDY2NjY3LTE0LjA1NDkwMSAyOC4xMDk4MDRsMTIzLjQ4MjM1MiAzNzEuNDUwOThjMy4wMTE3NjUgOS4wMzUyOTQgMTIuMDQ3MDU5IDE1LjA1ODgyNCAyMS4wODIzNTMgMTUuMDU4ODIzaDcyLjI4MjM1M2wtNTMuMjA3ODQzIDE2MC42Mjc0NTEgNDYuMTgwMzkyIDIuMDA3ODQ0IDE5Mi43NTI5NDItNTQ4LjE0MTE3N3oiIGZpbGw9IiMyYzJjMmMiIHAtaWQ9Ijk3MTEiPjwvcGF0aD48L3N2Zz4=';
export const useThemeConfig = defineStore('themeConfig', {
state: (): ThemeConfigState => ({
@@ -130,7 +136,9 @@ export const useThemeConfig = defineStore('themeConfig', {
// 网站主标题(菜单导航、浏览器当前网页标题)
globalTitle: 'mayfly',
// 网站副标题(登录页顶部文字)
globalViceTitle: 'mayfly',
globalViceTitle: 'mayfly-go',
// 网站logo icon, base64编码内容
logoIcon: logoIcon,
// 默认初始语言,可选值"<zh-cn|en|zh-tw>",默认 zh-cn
globalI18n: 'zh-cn',
// 默认全局组件大小,可选值"<|large|default|small>",默认 ''
@@ -142,19 +150,34 @@ export const useThemeConfig = defineStore('themeConfig', {
},
}),
actions: {
// 设置布局配置
setThemeConfig(data: ThemeConfigState) {
this.themeConfig = data.themeConfig;
},
// 设置水印配置信息
setWatermarkConfig(useWatermarkConfig: any) {
this.themeConfig.watermarkText = [];
this.themeConfig.isWatermark = useWatermarkConfig.isUse;
if (!useWatermarkConfig.isUse) {
return;
initThemeConfig() {
// 获取缓存中的布局配置
const tc = getThemeConfig();
if (tc) {
this.themeConfig = tc;
document.documentElement.style.cssText = getLocal('themeConfigStyle');
}
// 索引2为用户自定义水印信息
this.themeConfig.watermarkText[2] = useWatermarkConfig.content;
// 根据后台系统配置初始化
getSysStyleConfig().then((res) => {
if (res?.title) {
this.themeConfig.globalTitle = res.title;
}
if (res?.viceTitle) {
this.themeConfig.globalViceTitle = res.viceTitle;
}
if (res?.logoIcon) {
this.themeConfig.logoIcon = res.logoIcon;
}
this.themeConfig.watermarkText = [];
this.themeConfig.isWatermark = res?.useWatermark;
if (!res?.useWatermark) {
return;
}
// 索引2为用户自定义水印信息
this.themeConfig.watermarkText[2] = res.watermarkContent;
});
},
// 设置水印用户信息
setWatermarkUser(del: boolean = false) {

View File

@@ -70,8 +70,9 @@ body,
}
.layout-main {
padding: 0 !important;
overflow: hidden;
box-sizing: border-box;
padding: 10px 10px;
overflow-x: hidden;
width: 100%;
background-color: var(--bg-main-color);
}
@@ -81,7 +82,7 @@ body,
}
.layout-view-bg-white {
background: white;
background: var(--bg-main-color);
width: 100%;
height: 100%;
border-radius: 4px;
@@ -107,11 +108,6 @@ body,
transition: width 0.3s ease;
}
.layout-scrollbar {
@extend .el-scrollbar;
padding: 10px;
}
.layout-mian-height-50 {
height: calc(100vh - 50px);
}
@@ -125,6 +121,11 @@ body,
.layout-hide {
display: none;
}
.el-footer {
height: auto;
padding: 0;
}
}
/* element plus 全局样式
@@ -190,6 +191,23 @@ body,
}
}
.flex-justify-between {
display: flex;
align-items: center; // 垂直方向水平居中
justify-content: space-between; // 使第一个子元素靠近父容器的起始位置,最后一个子元素靠近终止位置,而其他子元素均匀分布在它们之间
}
.flex-all-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-align-center {
display: flex;
align-items: center; // 垂直方向水平居中
}
/* 宽高 100%
------------------------------- */
.w100 {
@@ -236,7 +254,7 @@ body,
/* 字体大小全局样式
------------------------------- */
@for $i from 10 through 32 {
@for $i from 8 through 32 {
.font#{$i} {
font-size: #{$i}px !important;
}
@@ -276,6 +294,10 @@ body,
.pl#{$i} {
padding-left: #{$i}px !important;
}
.pd#{$i} {
padding: #{$i}px !important;
}
}
@@ -335,12 +357,23 @@ body,
user-select: none;
}
.toolbar {
width: 100%;
padding: 4px;
overflow: hidden;
line-height: 24px;
border: 1px solid var(--el-border-color-light, #ebeef5);
/* custom card */
.card {
box-sizing: border-box;
padding: 20px;
overflow-x: hidden;
background-color: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
box-shadow: 0 0 12px rgb(0 0 0 / 5%);
}
/* content-box (常用内容盒子) */
.content-box {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
}
.fl {
@@ -361,10 +394,6 @@ body,
z-index: inherit !important;
}
.f12 {
font-size: 12px
}
.pointer {
cursor: pointer;
}
@@ -396,6 +425,7 @@ body,
.default-theme.splitpanes--horizontal>.splitpanes__splitter {
border-top: 1px solid var(--bg-main-color);
}
// 竖线样式
.splitpanes.default-theme .splitpanes__splitter::before,
.splitpanes.default-theme .splitpanes__splitter::after {

View File

@@ -49,6 +49,7 @@ declare interface ThemeConfigState {
isRequestRoutes: boolean;
globalTitle: string;
globalViceTitle: string;
logoIcon: string;
globalI18n: string;
globalComponentSize: string;
terminalForeground: string;

View File

@@ -19,16 +19,21 @@
</template>
<script lang="ts">
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { clearSession } from '@/common/utils/storage';
import { URL_LOGIN } from '@/router/staticRouter';
export default {
name: '401',
setup() {
const router = useRouter();
const route = useRoute();
const onSetAuth = () => {
clearSession();
router.push('/login');
router.push({ path: URL_LOGIN, query: route.query });
};
return {
onSetAuth,
};
@@ -93,3 +98,4 @@ export default {
}
}
</style>
@/router/staticRouter

View File

@@ -2,9 +2,9 @@
<div class="login-container flex">
<div class="login-left">
<div class="login-left-logo">
<img :src="logoMini" />
<img :src="themeConfig.logoIcon" />
<div class="login-left-logo-text">
<span>mayfly-go</span>
<span>{{ themeConfig.globalViceTitle }}</span>
<!-- <span class="login-left-logo-text-msg">mayfly-go</span> -->
</div>
</div>
@@ -18,7 +18,7 @@
<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-title">{{ themeConfig.globalViceTitle }}</div>
<div class="login-right-warp-main-form">
<div v-if="!state.isScan">
<el-tabs v-model="state.tabsActiveName">
@@ -47,11 +47,11 @@
<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';
import { storeToRefs } from 'pinia';
// 引入组件
const Account = defineAsyncComponent(() => import('./component/AccountLogin.vue'));
@@ -60,6 +60,7 @@ const loginForm = ref<{ loginResDeal: (data: any) => void } | null>(null);
// 定义变量内容
const storesThemeConfig = useThemeConfig();
const { themeConfig } = storeToRefs(storesThemeConfig);
const state = reactive({
tabsActiveName: 'account',

View File

@@ -1,7 +1,7 @@
<template>
<div class="tag-tree">
<el-input v-model="filterText" placeholder="输入关键字->搜索已展开节点信息" clearable size="small" class="mb5" />
<el-scrollbar :style="{ height: state.height, maxHeight: state.height, backgroundColor: 'var(--el-fill-color-blank)' }">
<div class="tag-tree card pd5">
<el-scrollbar>
<el-input v-model="filterText" placeholder="输入关键字->搜索已展开节点信息" clearable size="small" class="mb5 w100" />
<el-tree
ref="treeRef"
:highlight-current="true"
@@ -24,7 +24,7 @@
<slot v-else :node="node" :data="data" name="prefix"></slot>
<span class="ml3">
<span class="ml3" :title="data.labelRemark">
<slot name="label" :data="data"> {{ data.label }}</slot>
</span>
@@ -32,8 +32,9 @@
</span>
</template>
</el-tree>
<contextmenu :dropdown="state.dropdown" :items="state.contextmenuItems" ref="contextmenuRef" @currentContextmenuClick="onCurrentContextmenuClick" />
</el-scrollbar>
<contextmenu :dropdown="state.dropdown" :items="state.contextmenuItems" ref="contextmenuRef" @currentContextmenuClick="onCurrentContextmenuClick" />
</div>
</template>
@@ -43,7 +44,6 @@ import { NodeType, TagTreeNode } from './tag';
import TagInfo from './TagInfo.vue';
import { Contextmenu } from '@/components/contextmenu';
import { tagApi } from '../tag/api';
import { useEventListener, useWindowSize } from '@vueuse/core';
const props = defineProps({
resourceType: {
@@ -74,8 +74,6 @@ const emit = defineEmits(['nodeClick', 'currentContextmenuClick']);
const treeRef: any = ref(null);
const contextmenuRef = ref();
const { height: vh } = useWindowSize();
const state = reactive({
height: 600 as any,
filterText: '',
@@ -88,14 +86,7 @@ const state = reactive({
});
const { filterText } = toRefs(state);
onMounted(async () => {
setHeight();
useEventListener(window, 'resize', setHeight);
});
const setHeight = () => {
state.height = vh.value - 138 + 'px';
};
onMounted(async () => {});
watch(filterText, (val) => {
treeRef.value?.filter(val);
@@ -195,8 +186,7 @@ defineExpose({
<style lang="scss" scoped>
.tag-tree {
border-radius: var(--el-input-border-radius, var(--el-border-radius-base));
border: 1px solid var(--el-border-color-light, #ebeef5);
height: calc(100vh - 108px);
.el-tree {
display: inline-block;

View File

@@ -0,0 +1,135 @@
<template>
<el-tree-select
v-bind="$attrs"
ref="treeRef"
:highlight-current="true"
:indent="10"
:load="loadNode"
:props="treeProps"
lazy
node-key="key"
:expand-on-click-node="true"
filterable
:filter-node-method="filterNode"
v-model="modelValue"
@change="changeNode"
>
<template #default="{ node, data }">
<span>
<span v-if="data.type.value == TagTreeNode.TagPath">
<tag-info :tag-path="data.label" />
</span>
<slot v-else :node="node" :data="data" name="prefix"></slot>
<span class="ml3" :title="data.labelRemark">
<slot name="label" :data="data"> {{ data.label }}</slot>
</span>
<slot :node="node" :data="data" name="suffix"></slot>
</span>
</template>
</el-tree-select>
</template>
<script lang="ts" setup>
import { onMounted, reactive, ref, watch, toRefs } from 'vue';
import { NodeType, TagTreeNode } from './tag';
import TagInfo from './TagInfo.vue';
import { tagApi } from '../tag/api';
const props = defineProps({
resourceType: {
type: [Number],
required: true,
},
tagPathNodeType: {
type: [NodeType],
required: true,
},
load: {
type: Function,
required: false,
},
});
const treeProps = {
label: 'name',
children: 'zones',
isLeaf: 'isLeaf',
};
const emit = defineEmits(['change']);
const treeRef: any = ref(null);
const modelValue = defineModel('modelValue');
const state = reactive({
height: 600 as any,
filterText: '',
opend: {},
});
const { filterText } = toRefs(state);
onMounted(async () => {});
watch(filterText, (val) => {
treeRef.value?.filter(val);
});
const filterNode = (value: string, data: any) => {
if (!value) return true;
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
* @param { Object } resolve
*/
const loadNode = async (node: any, resolve: any) => {
if (typeof resolve !== 'function') {
return;
}
let nodes = [];
try {
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);
}
return resolve(nodes);
};
const getNode = (nodeKey: any) => {
let node = treeRef.value.getNode(nodeKey);
if (!node) {
throw new Error('未找到节点: ' + nodeKey);
}
return node;
};
const changeNode = (val: any) => {
// 触发改变时间,并传递节点数据
emit('change', getNode(val)?.data);
};
</script>
<style lang="scss" scoped></style>

View File

@@ -1,4 +1,6 @@
import { OptionsApi, SearchItem } from '@/components/SearchForm';
import { ContextmenuItem } from '@/components/contextmenu';
import { tagApi } from '../tag/api';
export class TagTreeNode {
/**
@@ -11,6 +13,11 @@ export class TagTreeNode {
*/
label: string;
/**
* 节点名称备注用于元素title属性
*/
labelRemark: string;
/**
* 树节点类型
*/
@@ -36,6 +43,11 @@ export class TagTreeNode {
this.type = type || new NodeType(TagTreeNode.TagPath);
}
withLabelRemark(labelRemark: any) {
this.labelRemark = labelRemark;
return this;
}
withIsLeaf(isLeaf: boolean) {
this.isLeaf = isLeaf;
return this;
@@ -115,3 +127,21 @@ export class NodeType {
return this;
}
}
/**
* 获取标签搜索项配置
* @param resourceType 资源类型
* @returns
*/
export function getTagPathSearchItem(resourceType: number) {
return SearchItem.select('tagPath', '标签').withOptionsApi(
OptionsApi.new(tagApi.getResourceTagPaths, { resourceType }).withConvertFn((res: any) => {
return res.map((x: any) => {
return {
label: x,
value: x,
};
});
})
);
}

View File

@@ -0,0 +1,232 @@
<template>
<div>
<el-dialog :title="title" :model-value="visible" :before-close="cancel" :close-on-click-modal="false" :destroy-on-close="true" width="38%">
<el-form :model="state.form" ref="backupForm" label-width="auto" :rules="rules">
<el-form-item prop="dbNames" label="数据库名称">
<el-select
v-model="state.dbNamesSelected"
multiple
clearable
collapse-tags
collapse-tags-tooltip
filterable
:disabled="state.editOrCreate"
:filter-method="filterDbNames"
placeholder="数据库名称"
style="width: 100%"
>
<template #header>
<el-checkbox v-model="checkAllDbNames" :indeterminate="indeterminateDbNames" @change="handleCheckAll"> 全选 </el-checkbox>
</template>
<el-option v-for="db in state.dbNamesFiltered" :key="db" :label="db" :value="db" />
</el-select>
</el-form-item>
<el-form-item prop="name" label="任务名称">
<el-input v-model.number="state.form.name" type="text" placeholder="任务名称"></el-input>
</el-form-item>
<el-form-item prop="startTime" label="开始时间">
<el-date-picker v-model="state.form.startTime" type="datetime" placeholder="开始时间" />
</el-form-item>
<el-form-item prop="intervalDay" label="备份周期">
<el-input v-model.number="state.form.intervalDay" type="number" placeholder="备份周期(单位:天)"></el-input>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancel()"> </el-button>
<el-button type="primary" :loading="state.btnLoading" @click="btnOk"> </el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref, toRefs, watch } from 'vue';
import { dbApi } from './api';
import { ElMessage } from 'element-plus';
import type { CheckboxValueType } from 'element-plus';
const props = defineProps({
data: {
type: [Boolean, Object],
},
title: {
type: String,
},
dbId: {
type: [Number],
required: true,
},
});
const visible = defineModel<boolean>('visible', {
default: false,
});
//定义事件
const emit = defineEmits(['cancel', 'val-change']);
const rules = {
dbNames: [
{
required: true,
message: '请选择需要备份的数据库',
trigger: ['change', 'blur'],
},
],
intervalDay: [
{
required: true,
pattern: /^[1-9]\d*$/,
message: '请输入正整数',
trigger: ['change', 'blur'],
},
],
startTime: [
{
required: true,
message: '请选择开始时间',
trigger: ['change', 'blur'],
},
],
};
const backupForm: any = ref(null);
const state = reactive({
form: {
id: 0,
dbId: 0,
dbNames: '',
name: null as any,
intervalDay: null,
startTime: null as any,
repeated: null as any,
},
btnLoading: false,
dbNamesSelected: [] as any,
dbNamesWithoutBackup: [] as any,
dbNamesFiltered: [] as any,
filterString: '',
editOrCreate: false,
});
const { dbNamesSelected, dbNamesWithoutBackup } = toRefs(state);
const checkAllDbNames = ref(false);
const indeterminateDbNames = ref(false);
watch(visible, (newValue: any) => {
if (newValue) {
init(props.data);
}
});
const init = (data: any) => {
state.dbNamesSelected = [];
state.form.dbId = props.dbId;
if (data) {
state.editOrCreate = true;
state.dbNamesWithoutBackup = [data.dbName];
state.dbNamesSelected = [data.dbName];
state.form.id = data.id;
state.form.dbNames = data.dbName;
state.form.name = data.name;
state.form.intervalDay = data.intervalDay;
state.form.startTime = data.startTime;
} else {
state.editOrCreate = false;
state.form.name = '';
state.form.intervalDay = null;
const now = new Date();
state.form.startTime = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
getDbNamesWithoutBackup();
}
};
const getDbNamesWithoutBackup = async () => {
if (props.dbId > 0) {
state.dbNamesWithoutBackup = await dbApi.getDbNamesWithoutBackup.request({ dbId: props.dbId });
}
};
const btnOk = async () => {
backupForm.value.validate(async (valid: boolean) => {
if (!valid) {
ElMessage.error('请正确填写信息');
return false;
}
state.form.repeated = true;
const reqForm = { ...state.form };
let api = dbApi.createDbBackup;
if (props.data) {
api = dbApi.saveDbBackup;
}
try {
state.btnLoading = true;
await api.request(reqForm);
ElMessage.success('保存成功');
emit('val-change', state.form);
cancel();
} finally {
state.btnLoading = false;
}
});
};
const cancel = () => {
visible.value = false;
emit('cancel');
};
const checkDbSelect = (val: string[]) => {
const selected = val.filter((dbName: string) => {
return dbName.includes(state.filterString);
});
if (selected.length === 0) {
checkAllDbNames.value = false;
indeterminateDbNames.value = false;
return;
}
if (selected.length === state.dbNamesFiltered.length) {
checkAllDbNames.value = true;
indeterminateDbNames.value = false;
return;
}
indeterminateDbNames.value = true;
};
watch(dbNamesSelected, (val: string[]) => {
checkDbSelect(val);
state.form.dbNames = val.join(' ');
});
watch(dbNamesWithoutBackup, (val: string[]) => {
state.dbNamesFiltered = val.map((dbName: string) => dbName);
});
const handleCheckAll = (val: CheckboxValueType) => {
const selected = state.dbNamesSelected.filter((dbName: string) => {
return !state.dbNamesFiltered.includes(dbName);
});
if (val) {
state.dbNamesSelected = selected.concat(state.dbNamesFiltered);
} else {
state.dbNamesSelected = selected;
}
};
const filterDbNames = (filterString: string) => {
state.dbNamesFiltered = state.dbNamesWithoutBackup.filter((dbName: string) => {
return dbName.includes(filterString);
});
state.filterString = filterString;
checkDbSelect(state.dbNamesSelected);
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,172 @@
<template>
<div class="db-backup">
<page-table
height="100%"
ref="pageTableRef"
:page-api="dbApi.getDbBackups"
:show-selection="true"
v-model:selection-data="state.selectedData"
:searchItems="searchItems"
:before-query-fn="beforeQueryFn"
v-model:query-form="query"
:columns="columns"
>
<template #dbSelect>
<el-select v-model="query.dbName" placeholder="请选择数据库" style="width: 200px" filterable clearable>
<el-option v-for="item in props.dbNames" :key="item" :label="`${item}`" :value="item"> </el-option>
</el-select>
</template>
<template #tableHeader>
<el-button type="primary" icon="plus" @click="createDbBackup()">添加</el-button>
<el-button type="primary" icon="video-play" @click="enableDbBackup(null)">启用</el-button>
<el-button type="primary" icon="video-pause" @click="disableDbBackup(null)">禁用</el-button>
</template>
<template #action="{ data }">
<div>
<el-button @click="editDbBackup(data)" type="primary" link>编辑</el-button>
<el-button v-if="!data.enabled" @click="enableDbBackup(data)" type="primary" link>启用</el-button>
<el-button v-if="data.enabled" @click="disableDbBackup(data)" type="primary" link>禁用</el-button>
<el-button v-if="data.enabled" @click="startDbBackup(data)" type="primary" link>立即备份</el-button>
</div>
</template>
</page-table>
<db-backup-edit
@val-change="search"
:title="dbBackupEditDialog.title"
:dbId="dbId"
:data="dbBackupEditDialog.data"
v-model:visible="dbBackupEditDialog.visible"
></db-backup-edit>
</div>
</template>
<script lang="ts" setup>
import { toRefs, reactive, defineAsyncComponent, Ref, ref } from 'vue';
import { dbApi } from './api';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { SearchItem } from '@/components/SearchForm';
import { ElMessage } from 'element-plus';
const DbBackupEdit = defineAsyncComponent(() => import('./DbBackupEdit.vue'));
const pageTableRef: Ref<any> = ref(null);
const props = defineProps({
dbId: {
type: [Number],
required: true,
},
dbNames: {
type: [Array<String>],
required: true,
},
});
const searchItems = [SearchItem.slot('dbName', '数据库名称', 'dbSelect')];
const columns = [
TableColumn.new('dbName', '数据库名称'),
TableColumn.new('name', '任务名称'),
TableColumn.new('startTime', '启动时间').isTime(),
TableColumn.new('intervalDay', '备份周期'),
TableColumn.new('enabled', '是否启用'),
TableColumn.new('lastResult', '执行结果'),
TableColumn.new('lastTime', '执行时间').isTime(),
TableColumn.new('action', '操作').isSlot().setMinWidth(180).fixedRight(),
];
const emptyQuery = {
dbId: 0,
dbName: '',
pageNum: 1,
pageSize: 10,
repeated: true,
};
const state = reactive({
data: [],
total: 0,
query: emptyQuery,
dbBackupEditDialog: {
visible: false,
data: null as any,
title: '创建数据库备份任务',
},
/**
* 选中的数据
*/
selectedData: [],
});
const { query, dbBackupEditDialog } = toRefs(state);
const beforeQueryFn = (query: any) => {
query.dbId = props.dbId;
return query;
};
const search = async () => {
await pageTableRef.value.search();
};
const createDbBackup = async () => {
state.dbBackupEditDialog.data = null;
state.dbBackupEditDialog.title = '创建数据库备份任务';
state.dbBackupEditDialog.visible = true;
};
const editDbBackup = async (data: any) => {
state.dbBackupEditDialog.data = data;
state.dbBackupEditDialog.title = '修改数据库备份任务';
state.dbBackupEditDialog.visible = true;
};
const enableDbBackup = async (data: any) => {
let backupId: String;
if (data) {
backupId = data.id;
} else if (state.selectedData.length > 0) {
backupId = state.selectedData.map((x: any) => x.id).join(' ');
} else {
ElMessage.error('请选择需要启用的备份任务');
return;
}
await dbApi.enableDbBackup.request({ dbId: props.dbId, backupId: backupId });
await search();
ElMessage.success('启用成功');
};
const disableDbBackup = async (data: any) => {
let backupId: String;
if (data) {
backupId = data.id;
} else if (state.selectedData.length > 0) {
backupId = state.selectedData.map((x: any) => x.id).join(' ');
} else {
ElMessage.error('请选择需要禁用的备份任务');
return;
}
await dbApi.disableDbBackup.request({ dbId: props.dbId, backupId: backupId });
await search();
ElMessage.success('禁用成功');
};
const startDbBackup = async (data: any) => {
let backupId: String;
if (data) {
backupId = data.id;
} else if (state.selectedData.length > 0) {
backupId = state.selectedData.map((x: any) => x.id).join(' ');
} else {
ElMessage.error('请选择需要启用的备份任务');
return;
}
await dbApi.startDbBackup.request({ dbId: props.dbId, backupId: backupId });
await search();
ElMessage.success('备份任务启动成功');
};
</script>
<style lang="scss"></style>

View File

@@ -52,20 +52,23 @@
<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="数据库名">
<el-select
@change="changeDatabase"
v-model="databaseList"
v-model="dbNamesSelected"
multiple
clearable
collapse-tags
collapse-tags-tooltip
filterable
:filter-method="filterDbNames"
allow-create
placeholder="请确保数据库实例信息填写完整后获取库名"
style="width: 100%"
>
<el-option v-for="db in allDatabases" :key="db" :label="db" :value="db" />
<template #header>
<el-checkbox v-model="checkAllDbNames" :indeterminate="indeterminateDbNames" @change="handleCheckAll"> 全选 </el-checkbox>
</template>
<el-option v-for="db in state.dbNamesFiltered" :key="db" :label="db" :value="db" />
</el-select>
</el-form-item>
@@ -90,6 +93,7 @@ import { dbApi } from './api';
import { ElMessage } from 'element-plus';
import TagTreeSelect from '../component/TagTreeSelect.vue';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import type { CheckboxValueType } from 'element-plus';
const props = defineProps({
visible: {
@@ -139,13 +143,18 @@ const rules = {
],
};
const checkAllDbNames = ref(false);
const indeterminateDbNames = ref(false);
const dbForm: any = ref(null);
const tagSelectRef: any = ref(null);
const state = reactive({
dialogVisible: false,
allDatabases: [] as any,
databaseList: [] as any,
dbNamesSelected: [] as any,
dbNamesFiltered: [] as any,
filterString: '',
form: {
id: null,
tagId: [],
@@ -158,7 +167,7 @@ const state = reactive({
instances: [] as any,
});
const { dialogVisible, allDatabases, form, databaseList } = toRefs(state);
const { dialogVisible, allDatabases, form, dbNamesSelected } = toRefs(state);
const { isFetching: saveBtnLoading, execute: saveDbExec } = dbApi.saveDb.useApi(form);
@@ -171,25 +180,18 @@ watch(props, async (newValue: any) => {
state.form = { ...newValue.db };
// 将数据库名使用空格切割,获取所有数据库列表
state.databaseList = newValue.db.database.split(' ');
state.dbNamesSelected = newValue.db.database.split(' ');
} else {
state.form = {} as any;
state.databaseList = [];
state.dbNamesSelected = [];
}
});
const changeInstance = () => {
state.databaseList = [];
state.dbNamesSelected = [];
getAllDatabase();
};
/**
* 改变表单中的数据库字段,方便表单错误提示。如全部删光,可提示请添加数据库
*/
const changeDatabase = () => {
state.form.database = state.databaseList.length == 0 ? '' : state.databaseList.join(' ');
};
const getAllDatabase = async () => {
if (state.form.instanceId > 0) {
state.allDatabases = await dbApi.getAllDatabase.request({ instanceId: state.form.instanceId });
@@ -210,7 +212,7 @@ const getInstances = async (instanceName: string = '', id = 0) => {
const open = async () => {
if (state.form.instanceId) {
// 根据id获取因为需要回显实例名称
getInstances('', state.form.instanceId);
await getInstances('', state.form.instanceId);
}
await getAllDatabase();
};
@@ -230,7 +232,7 @@ const btnOk = async () => {
};
const resetInputDb = () => {
state.databaseList = [];
state.dbNamesSelected = [];
state.allDatabases = [];
state.instances = [];
};
@@ -242,5 +244,62 @@ const cancel = () => {
resetInputDb();
}, 500);
};
const checkDbSelect = (val: string[]) => {
const selected = val.filter((dbName: string) => {
return dbName.includes(state.filterString);
});
if (selected.length === 0) {
checkAllDbNames.value = false;
indeterminateDbNames.value = false;
return;
}
if (selected.length === state.dbNamesFiltered.length) {
checkAllDbNames.value = true;
indeterminateDbNames.value = false;
return;
}
indeterminateDbNames.value = true;
};
watch(dbNamesSelected, (val: string[]) => {
checkDbSelect(val);
state.form.database = val.join(' ');
});
watch(allDatabases, (val: string[]) => {
state.dbNamesFiltered = val.map((dbName: string) => dbName);
});
const handleCheckAll = (val: CheckboxValueType) => {
const otherSelected = state.dbNamesSelected.filter((dbName: string) => {
return !state.dbNamesFiltered.includes(dbName);
});
if (val) {
state.dbNamesSelected = otherSelected.concat(state.dbNamesFiltered);
} else {
state.dbNamesSelected = otherSelected;
}
};
const filterDbNames = (filterString: string) => {
const dbNamesCreated = state.dbNamesSelected.filter((dbName: string) => {
return !state.allDatabases.includes(dbName);
});
if (filterString.length === 0) {
state.dbNamesFiltered = dbNamesCreated.concat(state.allDatabases);
checkDbSelect(state.dbNamesSelected);
return;
}
state.dbNamesFiltered = dbNamesCreated.concat(state.allDatabases).filter((dbName: string) => {
if (dbName == filterString) {
return false;
}
return dbName.includes(filterString);
});
state.dbNamesFiltered.unshift(filterString);
state.filterString = filterString;
checkDbSelect(state.dbNamesSelected);
};
</script>
<style lang="scss"></style>

View File

@@ -10,12 +10,6 @@
v-model:selection-data="state.selectionData"
:columns="columns"
>
<template #tagPathSelect>
<el-select @focus="getTags" v-model="query.tagPath" placeholder="请选择标签" filterable clearable>
<el-option v-for="item in tags" :key="item" :label="item" :value="item"> </el-option>
</el-select>
</template>
<template #instanceSelect>
<el-select remote :remote-method="getInstances" v-model="query.instanceId" placeholder="输入并选择实例" filterable clearable>
<el-option v-for="item in state.instances" :key="item.id" :label="`${item.name}`" :value="item.id">
@@ -67,10 +61,9 @@
<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-item :command="{ type: 'dumpDb', data }" v-if="supportAction('dumpDb', data.type)"> 导出 </el-dropdown-item>
<el-dropdown-item :command="{ type: 'dbBackup', data }" v-if="supportAction('dbBackup', data.type)"> 备份 </el-dropdown-item>
<el-dropdown-item :command="{ type: 'dbRestore', data }" v-if="supportAction('dbRestore', data.type)"> 恢复 </el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -128,6 +121,26 @@
<db-sql-exec-log :db-id="sqlExecLogDialog.dbId" :dbs="sqlExecLogDialog.dbs" />
</el-dialog>
<el-dialog
width="80%"
:title="`${dbBackupDialog.title} - 数据库备份`"
:close-on-click-modal="false"
:destroy-on-close="true"
v-model="dbBackupDialog.visible"
>
<db-backup-list :dbId="dbBackupDialog.dbId" :dbNames="dbBackupDialog.dbs" />
</el-dialog>
<el-dialog
width="80%"
:title="`${dbRestoreDialog.title} - 数据库恢复`"
:close-on-click-modal="false"
:destroy-on-close="true"
v-model="dbRestoreDialog.visible"
>
<db-restore-list :dbId="dbRestoreDialog.dbId" :dbNames="dbRestoreDialog.dbs" />
</el-dialog>
<el-dialog v-model="infoDialog.visible" :before-close="onBeforeCloseInfoDialog" :close-on-click-modal="false">
<el-descriptions title="详情" :column="3" border>
<!-- <el-descriptions-item :span="3" label="标签路径">{{ infoDialog.data?.tagPath }}</el-descriptions-item> -->
@@ -166,11 +179,13 @@ import { TableColumn } 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';
import { getDbDialect } from './dialect/index';
import { getTagPathSearchItem } from '../component/tag';
import { SearchItem } from '@/components/SearchForm';
import DbBackupList from './DbBackupList.vue';
import DbRestoreList from './DbRestoreList.vue';
const DbEdit = defineAsyncComponent(() => import('./DbEdit.vue'));
@@ -180,14 +195,14 @@ const perms = {
delDb: 'db:del',
};
const searchItems = [SearchItem.slot('tagPath', '标签', 'tagPathSelect'), SearchItem.slot('instanceId', '实例', 'instanceSelect')];
const searchItems = [getTagPathSearchItem(TagResourceTypeEnum.Db.value), SearchItem.slot('instanceId', '实例', 'instanceSelect')];
const columns = ref([
TableColumn.new('instanceName', '实例名'),
TableColumn.new('name', '名'),
TableColumn.new('type', '类型').isSlot().setAddWidth(-15).alignCenter(),
TableColumn.new('instanceName', '实例名'),
TableColumn.new('host', 'ip:port').isSlot().setAddWidth(40),
TableColumn.new('username', 'username'),
TableColumn.new('name', '名称'),
TableColumn.new('tagPath', '关联标签').isSlot().setAddWidth(10).alignCenter(),
TableColumn.new('remark', '备注'),
]);
@@ -202,7 +217,6 @@ const state = reactive({
row: {} as any,
dbId: 0,
db: '',
tags: [],
instances: [] as any,
/**
* 选中的数据
@@ -232,6 +246,24 @@ const state = reactive({
dbs: [],
dbId: 0,
},
// 数据库备份弹框
dbBackupDialog: {
title: '',
visible: false,
dbs: [],
dbId: 0,
},
// 数据库恢复弹框
dbRestoreDialog: {
title: '',
visible: false,
dbs: [],
dbId: 0,
},
chooseTableName: '',
tableInfoDialog: {
visible: false,
},
exportDialog: {
visible: false,
dbId: 0,
@@ -253,7 +285,7 @@ const state = reactive({
},
});
const { db, tags, selectionData, query, infoDialog, sqlExecLogDialog, exportDialog, dbEditDialog } = toRefs(state);
const { db, selectionData, query, infoDialog, sqlExecLogDialog, exportDialog, dbEditDialog, dbBackupDialog, dbRestoreDialog } = toRefs(state);
onMounted(async () => {
if (Object.keys(actionBtns).length > 0) {
@@ -286,10 +318,6 @@ const onBeforeCloseInfoDialog = () => {
state.infoDialog.instance = null;
};
const getTags = async () => {
state.tags = await tagApi.getResourceTagPaths.request({ resourceType: TagResourceTypeEnum.Db.value });
};
const getInstances = async (instanceName = '') => {
if (!instanceName) {
state.instances = [];
@@ -315,6 +343,15 @@ const handleMoreActionCommand = (commond: any) => {
}
case 'dumpDb': {
onDumpDbs(data);
return;
}
case 'dbBackup': {
onShowDbBackupDialog(data);
return;
}
case 'dbRestore': {
onShowDbRestoreDialog(data);
return;
}
}
};
@@ -358,6 +395,20 @@ const onBeforeCloseSqlExecDialog = () => {
state.sqlExecLogDialog.dbId = 0;
};
const onShowDbBackupDialog = async (row: any) => {
state.dbBackupDialog.title = `${row.name}`;
state.dbBackupDialog.dbId = row.id;
state.dbBackupDialog.dbs = row.database.split(' ');
state.dbBackupDialog.visible = true;
};
const onShowDbRestoreDialog = async (row: any) => {
state.dbRestoreDialog.title = `${row.name}`;
state.dbRestoreDialog.dbId = row.id;
state.dbRestoreDialog.dbs = row.database.split(' ');
state.dbRestoreDialog.visible = true;
};
const onDumpDbs = async (row: any) => {
const dbs = row.database.split(' ');
const data = [];
@@ -398,6 +449,16 @@ const dumpDbs = () => {
a.click();
state.exportDialog.visible = false;
};
const supportAction = (action: string, dbType: string): boolean => {
let actions: string[] = [];
switch (dbType) {
case DbType.mysql:
case DbType.mariadb:
actions = ['dumpDb', 'dbBackup', 'dbRestore'];
}
return actions.includes(action);
};
</script>
<style lang="scss">
.db-list {

View File

@@ -0,0 +1,288 @@
<template>
<div>
<el-dialog :title="title" :model-value="visible" :before-close="cancel" :close-on-click-modal="false" width="38%">
<el-form :model="state.form" ref="restoreForm" label-width="auto" :rules="rules">
<el-form-item label="恢复方式">
<el-radio-group :disabled="state.editOrCreate" v-model="state.restoreMode">
<el-radio label="point-in-time">指定时间点</el-radio>
<el-radio label="backup-history">指定备份</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item prop="dbName" label="数据库名称">
<el-select
:disabled="state.editOrCreate"
@change="changeDatabase"
v-model="state.form.dbName"
placeholder="数据库名称"
filterable
clearable
class="w100"
>
<el-option v-for="item in props.dbNames" :key="item" :label="`${item}`" :value="item"> </el-option>
</el-select>
</el-form-item>
<el-form-item v-if="state.restoreMode == 'point-in-time'" prop="pointInTime" label="恢复时间点">
<el-date-picker :disabled="state.editOrCreate" v-model="state.form.pointInTime" type="datetime" placeholder="恢复时间点" />
</el-form-item>
<el-form-item v-if="state.restoreMode == 'backup-history'" prop="dbBackupHistoryId" label="数据库备份">
<el-select
:disabled="state.editOrCreate"
@change="changeHistory"
v-model="state.history"
value-key="id"
placeholder="数据库备份"
filterable
clearable
class="w100"
>
<el-option v-for="item in state.histories" :key="item.id" :label="item.name" :value="item"> </el-option>
</el-select>
</el-form-item>
<el-form-item prop="startTime" label="开始时间">
<el-date-picker :disabled="state.editOrCreate" v-model="state.form.startTime" type="datetime" placeholder="开始时间" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancel()"> </el-button>
<el-button type="primary" :loading="state.btnLoading" @click="btnOk"> </el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { onMounted, reactive, ref, watch } from 'vue';
import { dbApi } from './api';
import { ElMessage } from 'element-plus';
const props = defineProps({
data: {
type: [Boolean, Object],
},
title: {
type: String,
},
dbId: {
type: [Number],
required: true,
},
dbNames: {
type: Array,
required: true,
},
});
//定义事件
const emit = defineEmits(['cancel', 'val-change']);
const visible = defineModel<boolean>('visible', {
default: false,
});
const validatePointInTime = (rule: any, value: any, callback: any) => {
if (!state.histories || state.histories.length == 0) {
callback(new Error('数据库没有备份记录'));
return;
}
const history = state.histories[state.histories.length - 1];
if (value < new Date(history.createTime)) {
callback(new Error('在此之前数据库没有备份记录'));
return;
}
if (value > new Date()) {
callback(new Error('恢复时间点晚于当前时间'));
return;
}
callback();
};
const rules = {
dbName: [
{
required: true,
message: '请选择需要恢复的数据库',
trigger: ['change', 'blur'],
},
],
pointInTime: [
{
required: true,
// message: '请选择恢复时间点',
validator: validatePointInTime,
trigger: ['change', 'blur'],
},
],
dbBackupHistoryId: [
{
required: true,
message: '请选择数据库备份',
trigger: ['change', 'blur'],
},
],
intervalDay: [
{
required: true,
pattern: /^[1-9]\d*$/,
message: '请输入正整数',
trigger: ['change', 'blur'],
},
],
startTime: [
{
required: true,
message: '请选择开始时间',
trigger: ['change', 'blur'],
},
],
};
const restoreForm: any = ref(null);
const state = reactive({
form: {
id: 0,
dbId: 0,
dbName: null as any,
intervalDay: 1,
startTime: null as any,
repeated: null as any,
dbBackupId: null as any,
dbBackupHistoryId: null as any,
dbBackupHistoryName: null as any,
pointInTime: null as any,
},
btnLoading: false,
dbNamesSelected: [] as any,
dbNamesWithoutRestore: [] as any,
editOrCreate: false,
histories: [] as any,
history: null as any,
restoreMode: null as any,
});
onMounted(async () => {
await init(props.data);
});
watch(visible, (newValue: any) => {
if (newValue) {
init(props.data);
}
});
/**
* 改变表单中的数据库字段,方便表单错误提示。如全部删光,可提示请添加数据库
*/
const changeDatabase = async () => {
await getBackupHistories(props.dbId, state.form.dbName);
};
const changeHistory = async () => {
if (state.history) {
state.form.dbBackupId = state.history.dbBackupId;
state.form.dbBackupHistoryId = state.history.id;
state.form.dbBackupHistoryName = state.history.name;
}
};
const init = async (data: any) => {
state.dbNamesSelected = [];
state.form.dbId = props.dbId;
if (data) {
state.editOrCreate = true;
state.dbNamesWithoutRestore = [data.dbName];
state.dbNamesSelected = [data.dbName];
state.form.id = data.id;
state.form.dbName = data.dbName;
state.form.intervalDay = data.intervalDay;
state.form.pointInTime = data.pointInTime;
state.form.startTime = data.startTime;
state.form.dbBackupId = data.dbBackupId;
state.form.dbBackupHistoryId = data.dbBackupHistoryId;
state.form.dbBackupHistoryName = data.dbBackupHistoryName;
if (data.pointInTime) {
state.restoreMode = 'point-in-time';
} else {
state.restoreMode = 'backup-history';
}
state.history = {
dbBackupId: data.dbBackupId,
id: data.dbBackupHistoryId,
name: data.dbBackupHistoryName,
createTime: data.createTime,
};
await getBackupHistories(props.dbId, data.dbName);
} else {
state.form.dbName = '';
state.editOrCreate = false;
state.form.intervalDay = 1;
state.form.pointInTime = new Date();
state.form.startTime = new Date();
state.histories = [];
state.history = null;
state.restoreMode = 'point-in-time';
await getDbNamesWithoutRestore();
}
};
const getDbNamesWithoutRestore = async () => {
if (props.dbId > 0) {
state.dbNamesWithoutRestore = await dbApi.getDbNamesWithoutRestore.request({ dbId: props.dbId });
}
};
const btnOk = async () => {
restoreForm.value.validate(async (valid: any) => {
if (valid) {
if (state.restoreMode == 'point-in-time') {
state.form.dbBackupId = 0;
state.form.dbBackupHistoryId = 0;
state.form.dbBackupHistoryName = '';
} else {
state.form.pointInTime = null;
}
state.form.repeated = false;
const reqForm = { ...state.form };
let api = dbApi.createDbRestore;
if (props.data) {
api = dbApi.saveDbRestore;
}
api.request(reqForm).then(() => {
ElMessage.success('保存成功');
emit('val-change', state.form);
state.btnLoading = true;
setTimeout(() => {
state.btnLoading = false;
}, 1000);
cancel();
});
} else {
ElMessage.error('请正确填写信息');
return false;
}
});
};
const cancel = () => {
visible.value = false;
emit('cancel');
};
const getBackupHistories = async (dbId: Number, dbName: String) => {
if (!dbId || !dbName) {
state.histories = [];
return;
}
const data = await dbApi.getDbBackupHistories.request({ dbId, dbName });
if (!data || !data.list) {
ElMessage.error('该数据库没有备份记录,无法创建数据库恢复任务');
state.histories = [];
return;
}
state.histories = data.list;
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,173 @@
<template>
<div class="db-restore">
<page-table
height="100%"
ref="pageTableRef"
:page-api="dbApi.getDbRestores"
:show-selection="true"
v-model:selection-data="state.selectedData"
:searchItems="searchItems"
:before-query-fn="beforeQueryFn"
v-model:query-form="query"
:columns="columns"
>
<template #dbSelect>
<el-select v-model="query.dbName" placeholder="请选择数据库" style="width: 200px" filterable clearable>
<el-option v-for="item in dbNames" :key="item" :label="`${item}`" :value="item"> </el-option>
</el-select>
</template>
<template #tableHeader>
<el-button type="primary" icon="plus" @click="createDbRestore()">添加</el-button>
<el-button type="primary" icon="video-play" @click="enableDbRestore(null)">启用</el-button>
<el-button type="primary" icon="video-pause" @click="disableDbRestore(null)">禁用</el-button>
</template>
<template #action="{ data }">
<el-button @click="showDbRestore(data)" type="primary" link>详情</el-button>
<el-button @click="enableDbRestore(data)" v-if="!data.enabled" type="primary" link>启用</el-button>
<el-button @click="disableDbRestore(data)" v-if="data.enabled" type="primary" link>禁用</el-button>
</template>
</page-table>
<db-restore-edit
@val-change="search"
:title="dbRestoreEditDialog.title"
:dbId="dbId"
:dbNames="dbNames"
:data="dbRestoreEditDialog.data"
v-model:visible="dbRestoreEditDialog.visible"
></db-restore-edit>
<el-dialog v-model="infoDialog.visible" title="数据库恢复">
<el-descriptions :column="1" border>
<el-descriptions-item :span="1" label="数据库名称">{{ infoDialog.data.dbName }}</el-descriptions-item>
<el-descriptions-item v-if="infoDialog.data.pointInTime" :span="1" label="恢复时间点">{{
dateFormat(infoDialog.data.pointInTime)
}}</el-descriptions-item>
<el-descriptions-item v-if="!infoDialog.data.pointInTime" :span="1" label="数据库备份">{{
infoDialog.data.dbBackupHistoryName
}}</el-descriptions-item>
<el-descriptions-item :span="1" label="开始时间">{{ dateFormat(infoDialog.data.startTime) }}</el-descriptions-item>
<el-descriptions-item :span="1" label="是否启用">{{ infoDialog.data.enabled }}</el-descriptions-item>
<el-descriptions-item :span="1" label="执行时间">{{ dateFormat(infoDialog.data.lastTime) }}</el-descriptions-item>
<el-descriptions-item :span="1" label="执行结果">{{ infoDialog.data.lastResult }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { toRefs, reactive, defineAsyncComponent, Ref, ref } from 'vue';
import { dbApi } from './api';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { SearchItem } from '@/components/SearchForm';
import { ElMessage } from 'element-plus';
import { dateFormat } from '@/common/utils/date';
const DbRestoreEdit = defineAsyncComponent(() => import('./DbRestoreEdit.vue'));
const pageTableRef: Ref<any> = ref(null);
const props = defineProps({
dbId: {
type: [Number],
required: true,
},
dbNames: {
type: [Array<String>],
required: true,
},
});
// const queryConfig = [TableQuery.slot('dbName', '数据库名称', 'dbSelect')];
const searchItems = [SearchItem.slot('dbName', '数据库名称', 'dbSelect')];
const columns = [
TableColumn.new('dbName', '数据库名称'),
TableColumn.new('startTime', '启动时间').isTime(),
TableColumn.new('enabled', '是否启用'),
TableColumn.new('lastTime', '执行时间').isTime(),
TableColumn.new('lastResult', '执行结果'),
TableColumn.new('action', '操作').isSlot().setMinWidth(220).fixedRight().alignCenter(),
];
const emptyQuery = {
dbId: props.dbId,
dbName: '',
pageNum: 1,
pageSize: 10,
repeated: false,
};
const state = reactive({
data: [],
total: 0,
query: emptyQuery,
dbRestoreEditDialog: {
visible: false,
data: null as any,
title: '创建数据库恢复任务',
},
infoDialog: {
visible: false,
data: null as any,
},
/**
* 选中的数据
*/
selectedData: [],
});
const { query, dbRestoreEditDialog, infoDialog } = toRefs(state);
const beforeQueryFn = (query: any) => {
query.dbId = props.dbId;
return query;
};
const search = async () => {
await pageTableRef.value.search();
};
const createDbRestore = async () => {
state.dbRestoreEditDialog.data = null;
state.dbRestoreEditDialog.title = '数据库恢复';
state.dbRestoreEditDialog.visible = true;
};
const showDbRestore = async (data: any) => {
state.infoDialog.data = data;
state.infoDialog.visible = true;
};
const enableDbRestore = async (data: any) => {
let restoreId: String;
if (data) {
restoreId = data.id;
} else if (state.selectedData.length > 0) {
restoreId = state.selectedData.map((x: any) => x.id).join(' ');
} else {
ElMessage.error('请选择需要启用的恢复任务');
return;
}
await dbApi.enableDbRestore.request({ dbId: props.dbId, restoreId: restoreId });
await search();
ElMessage.success('启用成功');
};
const disableDbRestore = async (data: any) => {
let restoreId: String;
if (data) {
restoreId = data.id;
} else if (state.selectedData.length > 0) {
restoreId = state.selectedData.map((x: any) => x.id).join(' ');
} else {
ElMessage.error('请选择需要禁用的恢复任务');
return;
}
await dbApi.disableDbRestore.request({ dbId: props.dbId, restoreId: restoreId });
await search();
ElMessage.success('禁用成功');
};
</script>
<style lang="scss"></style>

View File

@@ -56,7 +56,7 @@ const props = defineProps({
const searchItems = [
SearchItem.slot('db', '数据库', 'dbSelect'),
SearchItem.text('table', '表名'),
SearchItem.input('table', '表名'),
SearchItem.select('type', '操作类型').withEnum(DbSqlExecTypeEnum),
];

View File

@@ -9,25 +9,33 @@
</el-form-item>
<el-form-item prop="type" label="类型" required>
<el-select @change="changeDbType" style="width: 100%" v-model="form.type" placeholder="请选择数据库类型">
<el-option v-for="dt in dbTypes" :key="dt.type" :value="dt.type">
<el-option v-for="dt in dbTypes" :key="dt.type" :value="dt.type" :label="dt.label">
<SvgIcon :name="getDbDialect(dt.type).getInfo().icon" :size="18" />
{{ dt.label }}
</el-option>
</el-select>
</el-form-item>
<el-form-item prop="host" label="host" required>
<el-form-item v-if="form.type !== DbType.sqlite" 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>
<el-col style="text-align: center" :span="1">:</el-col>
<el-col :span="5">
<el-input type="number" v-model.number="form.port" placeholder="请输入端口"></el-input>
<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 v-if="form.type === DbType.sqlite" prop="host" label="sqlite地址">
<el-input v-model.trim="form.host" placeholder="请输入sqlite文件在服务器的绝对地址"></el-input>
</el-form-item>
<el-form-item v-if="form.type === DbType.oracle" prop="sid" label="SID">
<el-input v-model.trim="form.sid" placeholder="请输入服务id"></el-input>
</el-form-item>
<el-form-item v-if="form.type !== DbType.sqlite" 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 v-if="form.type !== DbType.sqlite" 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">
@@ -49,7 +57,7 @@
<el-tab-pane label="其他配置" name="other">
<el-form-item prop="params" label="连接参数">
<el-input v-model.trim="form.params" placeholder="其他连接参数,形如: key1=value1&key2=value2">
<template #suffix>
<!-- <template #suffix>
<el-link
target="_blank"
href="https://github.com/go-sql-driver/mysql#parameters"
@@ -58,7 +66,7 @@
class="mr5"
>参数参考</el-link
>
</template>
</template> -->
</el-input>
</el-form-item>
@@ -87,7 +95,7 @@ import { ElMessage } from 'element-plus';
import { notBlank } from '@/common/assert';
import { RsaEncrypt } from '@/common/rsa';
import SshTunnelSelect from '../component/SshTunnelSelect.vue';
import { getDbDialect } from './dialect';
import { DbType, getDbDialect } from './dialect';
import SvgIcon from '@/components/svgIcon/index.vue';
const props = defineProps({
@@ -134,6 +142,13 @@ const rules = {
trigger: ['change', 'blur'],
},
],
sid: [
{
required: true,
message: '请输入SID',
trigger: ['change', 'blur'],
},
],
};
const dbForm: any = ref(null);
@@ -143,13 +158,25 @@ const dbTypes = [
type: 'mysql',
label: 'mysql',
},
{
type: 'mariadb',
label: 'mariadb',
},
{
type: 'postgres',
label: 'postgres',
},
{
type: 'dm',
label: '达梦(暂不支持ssh)',
label: '达梦',
},
{
type: 'oracle',
label: 'oracle',
},
{
type: 'sqlite',
label: 'sqlite',
},
];
@@ -158,11 +185,12 @@ const state = reactive({
tabActiveName: 'basic',
form: {
id: null,
type: null,
type: '',
name: null,
host: '',
port: null,
username: null,
sid: null, // oracle类项目需要服务id
password: null,
params: null,
remark: '',
@@ -228,10 +256,12 @@ const testConn = async () => {
};
const btnOk = async () => {
if (!state.form.id) {
notBlank(state.form.password, '新增操作,密码不可为空');
} else if (state.form.username != state.oldUserName) {
notBlank(state.form.password, '已修改用户名,请输入密码');
if (state.form.type !== DbType.sqlite) {
if (!state.form.id) {
notBlank(state.form.password, '新增操作,密码不可为空');
} else if (state.form.username != state.oldUserName) {
notBlank(state.form.password, '已修改用户名,请输入密码');
}
}
dbForm.value.validate(async (valid: boolean) => {

View File

@@ -28,8 +28,8 @@
</template>
</page-table>
<el-dialog v-model="infoDialog.visible">
<el-descriptions title="详情" :column="3" border>
<el-dialog v-model="infoDialog.visible" title="详情">
<el-descriptions :column="3" border>
<el-descriptions-item :span="2" label="名称">{{ infoDialog.data.name }}</el-descriptions-item>
<el-descriptions-item :span="1" label="id">{{ infoDialog.data.id }}</el-descriptions-item>
<el-descriptions-item :span="2" label="主机">{{ infoDialog.data.host }}</el-descriptions-item>
@@ -79,7 +79,7 @@ const perms = {
delInstance: 'db:instance:del',
};
const searchItems = [SearchItem.text('name', '名称')];
const searchItems = [SearchItem.input('name', '名称')];
const columns = ref([
TableColumn.new('name', '名称'),

View File

@@ -5,7 +5,14 @@
<tag-tree :resource-type="TagResourceTypeEnum.Db.value" :tag-path-node-type="NodeTypeTagPath" ref="tagTreeRef">
<template #prefix="{ data }">
<span v-if="data.type.value == SqlExecNodeType.DbInst">
<el-popover :show-after="500" placement="right-start" title="数据库实例信息" trigger="hover" :width="250">
<el-popover
@show="showDbInfo(data.params)"
:show-after="500"
placement="right-start"
title="数据库实例信息"
trigger="hover"
:width="250"
>
<template #reference>
<SvgIcon :name="getDbDialect(data.params.type).getInfo().icon" :size="18" />
</template>
@@ -17,6 +24,9 @@
<el-descriptions-item label="host">
{{ `${data.params.host}:${data.params.port}` }}
</el-descriptions-item>
<el-descriptions-item label="数据库版本">
<span v-loading="loadingServerInfo"> {{ `${dbServerInfo?.version}` }}</span>
</el-descriptions-item>
<el-descriptions-item label="user">
{{ data.params.username }}
</el-descriptions-item>
@@ -31,12 +41,6 @@
<SvgIcon v-if="data.icon" :name="data.icon.name" :color="data.icon.color" />
</template>
<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>
<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">{{
@@ -47,101 +51,103 @@
</Pane>
<Pane>
<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 }, state.db)"
size="small"
>新建查询</el-button
></el-descriptions-item
>
<div class="card db-op pd5">
<el-row>
<el-col :span="24" v-if="state.db">
<el-descriptions :column="4" size="small" border>
<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 }, 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" label="tag">{{ nowDbInst.tagPath }}</el-descriptions-item>
<el-descriptions-item label-align="right">
<template #label>
<div>
<SvgIcon :name="getDbDialect(nowDbInst.type).getInfo().icon" :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"
class="h100"
>
<el-tab-pane class="h100" 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).getInfo().icon" :size="18" />
{{ dt.params.host }}
</el-descriptions-item>
<el-descriptions-item label="库名">
{{ dt.params.dbName }}
</el-descriptions-item>
</el-descriptions>
<el-descriptions-item label-align="right">
<template #label>
<div>
<SvgIcon :name="getDbDialect(nowDbInst.type).getInfo().icon" :size="18" />
实例
</div>
</template>
</el-popover>
</template>
{{ nowDbInst.id }}
<el-divider direction="vertical" border-style="dashed" />
{{ nowDbInst.name }}
<el-divider direction="vertical" border-style="dashed" />
{{ nowDbInst.host }}
</el-descriptions-item>
<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>
<el-descriptions-item label="库名" label-align="right">{{ state.db }}</el-descriptions-item>
</el-descriptions>
</el-col>
</el-row>
<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"
>
</db-sql-editor>
<div id="data-exec" class="mt5">
<el-tabs
v-if="state.tabs.size > 0"
type="card"
@tab-remove="onRemoveTab"
@tab-change="onTabChange"
style="width: 100%"
v-model="state.activeName"
class="h100"
>
<el-tab-pane class="h100" 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).getInfo().icon" :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-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>
<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"
>
</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>
</div>
</div>
</Pane>
</Splitpanes>
@@ -149,20 +155,20 @@
</template>
<script lang="ts" setup>
import { defineAsyncComponent, onMounted, reactive, ref, toRefs, onBeforeUnmount } from 'vue';
import { defineAsyncComponent, onBeforeUnmount, onMounted, reactive, ref, toRefs } 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 { DbInst, registerDbCompletionItemProvider, TabInfo, TabType } from './db';
import { NodeType, TagTreeNode } 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 { DbType, getDbDialect } from './dialect/index';
import { sleep } from '@/common/utils/loading';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { Splitpanes, Pane } from 'splitpanes';
import { Pane, Splitpanes } from 'splitpanes';
import { useEventListener } from '@vueuse/core';
const DbSqlEditor = defineAsyncComponent(() => import('./component/sqleditor/DbSqlEditor.vue'));
@@ -249,51 +255,44 @@ const NodeTypeDbInst = new NodeType(SqlExecNodeType.DbInst).withLoadNodesFunc((p
// 数据库节点
const NodeTypeDb = new NodeType(SqlExecNodeType.Db)
.withContextMenuItems([new ContextmenuItem('reloadTables', '刷新').withIcon('RefreshRight').withOnClick((data: any) => reloadNode(data.key))])
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
if (params.type == 'postgres' || params.type === 'dm') {
return [new TagTreeNode(`${params.id}.${params.db}.schema-menu`, 'schema', NodeTypePostgresScheamMenu).withParams(params).withIcon(SchemaIcon)];
// pg类数据库会多一层schema
if (params.type == DbType.postgresql || params.type === DbType.dm || params.type === DbType.oracle) {
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;
nParams.dbs = schemaNames;
return new TagTreeNode(`${params.id}.${params.db}.schema.${sn}`, sn, NodeTypePostgresSchema).withParams(nParams).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),
];
return NodeTypeTables(params);
})
.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;
nParams.dbs = schemaNames;
return new TagTreeNode(`${params.id}.${params.db}.schema.${sn}`, sn, NodeTypePostgresScheam).withParams(nParams).withIcon(SchemaIcon);
});
})
.withNodeClickFunc(nodeClickChangeDb);
const NodeTypeTables = (params: any) => {
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),
];
};
// 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),
];
})
const NodeTypePostgresSchema = new NodeType(SqlExecNodeType.PgSchema)
.withContextMenuItems([new ContextmenuItem('reloadTables', '刷新').withIcon('RefreshRight').withOnClick((data: any) => reloadNode(data.key))])
.withLoadNodesFunc(async (parentNode: TagTreeNode) => NodeTypeTables(parentNode.params))
.withNodeClickFunc(nodeClickChangeDb);
// 数据库表菜单节点
const NodeTypeTableMenu = new NodeType(SqlExecNodeType.TableMenu)
.withContextMenuItems([
new ContextmenuItem('reloadTables', '刷新').withIcon('RefreshRight').withOnClick((data: any) => reloadTables(data.key)),
new ContextmenuItem('reloadTables', '刷新').withIcon('RefreshRight').withOnClick((data: any) => reloadNode(data.key)),
new ContextmenuItem('tablesOp', '表操作').withIcon('Setting').withOnClick((data: any) => {
const params = data.params;
@@ -308,7 +307,8 @@ const NodeTypeTableMenu = new NodeType(SqlExecNodeType.TableMenu)
state.reloadStatus = false;
let dbTableSize = 0;
const tablesNode = tables.map((x: any) => {
dbTableSize += x.dataLength + x.indexLength;
const tableSize = x.dataLength + x.indexLength;
dbTableSize += tableSize;
return new TagTreeNode(`${id}.${db}.${x.tableName}`, x.tableName, NodeTypeTable)
.withIsLeaf(true)
.withParams({
@@ -316,12 +316,13 @@ const NodeTypeTableMenu = new NodeType(SqlExecNodeType.TableMenu)
db,
tableName: x.tableName,
tableComment: x.tableComment,
size: formatByteSize(x.dataLength + x.indexLength, 1),
size: tableSize == 0 ? '' : formatByteSize(tableSize, 1),
})
.withIcon(TableIcon);
.withIcon(TableIcon)
.withLabelRemark(`${x.tableName} ${x.tableComment ? '| ' + x.tableComment : ''}`);
});
// 设置父节点参数的表大小
parentNode.params.dbTableSize = formatByteSize(dbTableSize);
parentNode.params.dbTableSize = dbTableSize == 0 ? '' : formatByteSize(dbTableSize);
return tablesNode;
})
.withNodeClickFunc(nodeClickChangeDb);
@@ -379,10 +380,19 @@ const state = reactive({
tabs,
dataTabsTableHeight: '600px',
tablesOpHeight: '600',
dbServerInfo: {
loading: true,
version: '',
},
});
const { nowDbInst } = toRefs(state);
const serverInfoReqParam = ref({
instanceId: 0,
});
const { execute: getDbServerInfo, isFetching: loadingServerInfo, data: dbServerInfo } = dbApi.getInstanceServerInfo.useApi<any>(serverInfoReqParam);
onMounted(() => {
setHeight();
// 监听浏览器窗口大小变化,更新对应组件高度
@@ -397,8 +407,16 @@ onBeforeUnmount(() => {
* 设置editor高度和数据表高度
*/
const setHeight = () => {
state.dataTabsTableHeight = window.innerHeight - 255 + 'px';
state.tablesOpHeight = window.innerHeight - 212 + 'px';
state.dataTabsTableHeight = window.innerHeight - 270 + 'px';
state.tablesOpHeight = window.innerHeight - 225 + 'px';
};
const showDbInfo = async (db: any) => {
if (dbServerInfo.value) {
dbServerInfo.value.version = '';
}
serverInfoReqParam.value.instanceId = db.instanceId;
await getDbServerInfo();
};
// 选择数据库,改变当前正在操作的数据库信息
@@ -579,7 +597,7 @@ const getSqlMenuNodeKey = (dbId: number, db: string) => {
return `${dbId}.${db}.sql-menu`;
};
const reloadTables = (nodeKey: string) => {
const reloadNode = (nodeKey: string) => {
state.reloadStatus = true;
tagTreeRef.value.reloadNode(nodeKey);
};
@@ -607,6 +625,10 @@ const getNowDbInfo = () => {
font-size: 9px;
}
.db-op {
height: calc(100vh - 108px);
}
#data-exec {
.el-tabs {
--el-tabs-header-height: 30px;

View File

@@ -0,0 +1,501 @@
<template>
<div class="sync-task-edit">
<el-dialog
:title="title"
v-model="dialogVisible"
:before-close="cancel"
:close-on-click-modal="false"
:close-on-press-escape="false"
:destroy-on-close="true"
width="850px"
>
<el-form :model="form" ref="dbForm" :rules="rules" label-width="auto">
<el-tabs v-model="tabActiveName" style="height: 450px">
<el-tab-pane label="基本信息" :name="basicTab">
<el-form-item>
<el-row>
<el-col :span="11">
<el-form-item prop="taskName" label="任务名" required>
<el-input v-model.trim="form.taskName" placeholder="请输入数据库别名" auto-complete="off" />
</el-form-item>
</el-col>
<el-col :span="11">
<el-form-item prop="taskCron" label="cron" required>
<CrontabInput v-model="form.taskCron" />
</el-form-item>
</el-col>
<el-col :span="2">
<el-form-item prop="status" label="状态" label-width="60" required>
<el-switch
v-model="form.status"
inline-prompt
active-text="启用"
inactive-text="禁用"
:active-value="1"
:inactive-value="-1"
/>
</el-form-item>
</el-col>
</el-row>
</el-form-item>
<el-form-item prop="srcDbId" label="源数据库" required>
<db-select-tree
placeholder="请选择源数据库"
v-model:db-id="form.srcDbId"
v-model:db-name="form.srcDbName"
v-model:tag-path="form.srcTagPath"
@select-db="onSelectSrcDb"
/>
</el-form-item>
<el-form-item prop="targetDbId" label="目标数据库" required>
<db-select-tree
placeholder="请选择目标数据库"
v-model:db-id="form.targetDbId"
v-model:db-name="form.targetDbName"
v-model:tag-path="form.targetTagPath"
@select-db="onSelectTargetDb"
/>
</el-form-item>
<el-form-item prop="dataSql" label="源数据sql" required>
<monaco-editor height="150px" class="task-sql" language="sql" v-model="form.dataSql" />
</el-form-item>
<el-form-item prop="targetTableName" label="目标库表" required>
<el-select v-model="form.targetTableName" filterable placeholder="请选择目标数据库表">
<el-option
v-for="item in state.targetTableList"
:key="item.tableName"
:label="item.tableName + (item.tableComment && '-' + item.tableComment)"
:value="item.tableName"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-row>
<el-col :span="8">
<el-form-item prop="pageSize" label="分页大小" required>
<el-input type="number" v-model.number="form.pageSize" placeholder="同步数据时查询的每页数据大小" auto-complete="off" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item prop="updField" label="更新字段" required>
<el-input v-model.trim="form.updField" placeholder="查询数据源的时候会带上这个字段当前最大值" auto-complete="off" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item prop="updFieldVal" label="更新值">
<el-input v-model.trim="form.updFieldVal" placeholder="更新字段当前最大值" auto-complete="off" />
</el-form-item>
</el-col>
</el-row>
</el-form-item>
</el-tab-pane>
<el-tab-pane label="字段映射" :name="fieldTab" :disabled="!baseFieldCompleted">
<el-form-item prop="fieldMap" label="字段映射" required>
<el-table :data="form.fieldMap" :max-height="400" size="small">
<el-table-column prop="src" label="源字段" :width="200" />
<el-table-column prop="target" label="目标字段">
<template #default="scope">
<el-select v-model="scope.row.target">
<el-option
v-for="item in state.targetColumnList"
:key="item.columnName"
:label="item.columnName + ` ${item.columnType}` + (item.columnComment && ' - ' + item.columnComment)"
:value="item.columnName"
/>
</el-select>
</template>
</el-table-column>
</el-table>
</el-form-item>
</el-tab-pane>
<el-tab-pane label="sql预览" :name="sqlPreviewTab" :disabled="!baseFieldCompleted">
<el-form-item prop="fieldMap" label="查询sql">
<el-input type="textarea" v-model="state.previewDataSql" readonly :input-style="{ height: '190px' }" />
</el-form-item>
<el-form-item prop="fieldMap" label="插入sql">
<el-input type="textarea" v-model="state.previewInsertSql" readonly :input-style="{ height: '190px' }" />
</el-form-item>
</el-tab-pane>
</el-tabs>
</el-form>
<template #footer>
<div>
<el-button
v-if="tabActiveName != basicTab"
@click="
() => {
switch (tabActiveName) {
case fieldTab:
tabActiveName = basicTab;
break;
case sqlPreviewTab:
tabActiveName = fieldTab;
break;
}
}
"
>上一步</el-button
>
<el-button
v-if="tabActiveName != sqlPreviewTab"
:disabled="!baseFieldCompleted"
@click="
() => {
switch (tabActiveName) {
case basicTab:
tabActiveName = fieldTab;
break;
case fieldTab:
tabActiveName = sqlPreviewTab;
break;
}
}
"
>下一步</el-button
>
<el-button @click="cancel()"> </el-button>
<el-button type="primary" :loading="saveBtnLoading" @click="btnOk"> </el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, toRefs, watch } from 'vue';
import { dbApi } from './api';
import { ElMessage } from 'element-plus';
import DbSelectTree from '@/views/ops/db/component/DbSelectTree.vue';
import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
import { DbInst, registerDbCompletionItemProvider } from '@/views/ops/db/db';
import { getDbDialect } from '@/views/ops/db/dialect';
import CrontabInput from '@/components/crontab/CrontabInput.vue';
const props = defineProps({
data: {
type: [Boolean, Object],
},
title: {
type: String,
},
});
//定义事件
const emit = defineEmits(['update:visible', 'cancel', 'val-change']);
const dialogVisible = defineModel<boolean>('visible', { default: false });
const rules = {
taskName: [
{
required: true,
message: '请输入任务名',
trigger: ['change', 'blur'],
},
],
taskCron: [
{
required: true,
message: '请输入任务cron表达式',
trigger: ['change', 'blur'],
},
],
};
const dbForm: any = ref(null);
const basicTab = 'basic';
const fieldTab = 'field';
const sqlPreviewTab = 'sqlPreview';
type FormData = {
id?: number;
taskName?: string;
taskCron: string;
srcDbId?: number;
srcDbName?: string;
srcTagPath?: string;
targetDbId?: number;
targetDbName?: string;
targetTagPath?: string;
targetTableName?: string;
dataSql?: string;
pageSize?: number;
updField?: string;
updFieldVal?: string;
fieldMap?: { src: string; target: string }[];
status?: 1 | 2;
};
const basicFormData = {
srcDbId: -1,
targetDbId: -1,
dataSql: 'select * from',
pageSize: 1000,
updField: 'id',
updFieldVal: '0',
fieldMap: [{ src: 'a', target: 'b' }],
status: 1,
} as FormData;
const state = reactive({
tabActiveName: 'basic',
form: basicFormData,
submitForm: {} as any,
srcTableFields: [] as string[],
targetTableList: [] as { tableName: string; tableComment: string }[],
targetColumnList: [] as any[],
srcDbInst: {} as DbInst,
targetDbInst: {} as DbInst,
previewRes: {} as any,
previewDataSql: '',
previewInsertSql: '',
});
const { tabActiveName, form, submitForm } = toRefs(state);
const { isFetching: saveBtnLoading, execute: saveExec } = dbApi.saveDatasyncTask.useApi(submitForm);
// 基础字段信息是否填写完整
const baseFieldCompleted = computed(() => {
return state.form.srcDbId && state.form.srcDbName && state.form.targetDbId && state.form.targetDbName && state.form.targetTableName;
});
watch(dialogVisible, async (newValue: boolean) => {
if (!newValue) {
return;
}
state.tabActiveName = 'basic';
const propsData = props.data as any;
if (!propsData?.id) {
state.form = basicFormData;
return;
}
let data = await dbApi.getDatasyncTask.request({ taskId: propsData?.id });
state.form = data;
try {
state.form.fieldMap = JSON.parse(data.fieldMap);
} catch (e) {
state.form.fieldMap = [];
}
let { srcDbId, srcDbName, targetDbId } = state.form;
// 初始化src数据源
if (srcDbId) {
// 通过tagPath查询实例列表
const dbInfoRes = await dbApi.dbs.request({ id: srcDbId });
const db = dbInfoRes.list[0];
// 初始化实例
db.databases = db.database?.split(' ').sort() || [];
state.srcDbInst = DbInst.getOrNewInst(db);
}
// 初始化target数据源
if (targetDbId) {
// 通过tagPath查询实例列表
const dbInfoRes = await dbApi.dbs.request({ id: targetDbId });
const db = dbInfoRes.list[0];
// 初始化实例
db.databases = db.database?.split(' ').sort() || [];
state.targetDbInst = DbInst.getOrNewInst(db);
}
if (targetDbId && state.form.targetDbName) {
await loadDbTables(targetDbId, state.form.targetDbName);
}
// 注册sql代码提示
if (srcDbId && srcDbName) {
registerDbCompletionItemProvider(srcDbId, srcDbName, state.srcDbInst.databases, state.srcDbInst.type);
}
});
watch(tabActiveName, async (newValue: string) => {
switch (newValue) {
case fieldTab:
await handleGetSrcFields();
await handleGetTargetFields();
break;
case sqlPreviewTab:
let srcDbDialect = getDbDialect(state.srcDbInst.type);
let targetDbDialect = getDbDialect(state.targetDbInst.type);
let updField = srcDbDialect.quoteIdentifier(state.form.updField!);
state.previewDataSql = `SELECT * FROM (\n ${state.form.dataSql?.trim() || '请输入数据sql'} \n ) t \n where ${updField} > '${
state.form.updFieldVal || ''
}'`;
// 检查字段映射中是否存在重复的目标字段
let fields = new Set();
state.form.fieldMap?.map((a) => {
if (a.target) {
fields.add(a.target);
}
});
if (fields.size < (state.form.fieldMap?.length || 0)) {
ElMessage.warning('字段映射中存在重复的目标字段,请检查');
state.previewInsertSql = '';
return;
}
let fieldArr = state.form.fieldMap?.map((a: any) => targetDbDialect.quoteIdentifier(a.target)) || [];
let placeholder = '?'.repeat(fieldArr.length).split('').join(',');
state.previewInsertSql = ` insert into ${targetDbDialect.quoteIdentifier(state.form.targetTableName!)}(${fieldArr.join(
','
)}) values (${placeholder});`;
break;
default:
break;
}
});
const onSelectSrcDb = async (params: any) => {
// 初始化数据源
params.databases = params.dbs; // 数据源里需要这个值
state.srcDbInst = DbInst.getOrNewInst(params);
registerDbCompletionItemProvider(params.id, params.db, params.dbs, params.type);
};
const onSelectTargetDb = async (params: any) => {
state.targetDbInst = DbInst.getOrNewInst(params);
await loadDbTables(params.id, params.db);
};
const loadDbTables = async (dbId: number, db: string) => {
// 加载db下的表
let data = await dbApi.tableInfos.request({ id: dbId, db });
state.targetTableList = data;
if (data && data.length > 0) {
let names = data.map((a: any) => a.tableName);
if (!names.includes(state.form.targetTableName)) {
state.form.targetTableName = data[0].tableName;
}
}
};
const handleGetSrcFields = async () => {
// 执行sql获取字段信息
if (!state.form.dataSql || !state.form.dataSql.trim()) {
ElMessage.warning('请输入数据源sql');
return;
}
// 判断sql是否是查询语句
if (!/^select/i.test(state.form.dataSql!)) {
let msg = 'sql语句错误请输入查询语句';
ElMessage.warning(msg);
return;
}
// 判断是否有多条sql
if (/;/i.test(state.form.dataSql!)) {
let msg = 'sql语句错误请输入单条查询语句';
ElMessage.warning(msg);
return;
}
// 执行sql
const res = await dbApi.sqlExec.request({
id: state.form.srcDbId,
db: state.form.srcDbName,
sql: state.form.dataSql.trim() + ' limit 1',
});
if (!res.columns) {
ElMessage.warning('没有查询到字段请检查sql');
return;
}
let filedMap = {};
if (state.form.fieldMap && state.form.fieldMap.length > 0) {
state.form.fieldMap.forEach((a: any) => {
filedMap[a.src] = a.target;
});
}
state.srcTableFields = res.columns.map((a: any) => a.name);
state.form.fieldMap = res.columns.map((a: any) => ({ src: a.name, target: filedMap[a.name] || '' }));
state.previewRes = res;
};
const handleGetTargetFields = async () => {
// 查询目标表下的字段信息
if (state.form.targetDbName && state.form.targetTableName) {
let columns = await state.targetDbInst.loadColumns(state.form.targetDbName, state.form.targetTableName);
if (columns && Array.isArray(columns)) {
state.targetColumnList = columns;
// 过滤目标字段,不存在的字段值设置为空
let names = columns.map((a) => a.columnName?.toLowerCase());
state.form.fieldMap?.forEach((a) => {
if (a.target && !names.includes(a.target)) {
a.target = '';
}
// 优先设置字段名和src一样的值
if (names.includes(a.src?.toLowerCase())) {
// 从columns中取出
let res = columns.find((col: any) => col.columnName?.toLowerCase() === a.src?.toLowerCase());
if (res) {
a.target = res.columnName;
}
}
});
}
}
};
const getReqForm = async () => {
return { ...state.form };
};
const btnOk = async () => {
dbForm.value.validate(async (valid: boolean) => {
if (!valid) {
ElMessage.error('请正确填写信息');
return false;
}
// 正则表达式检测corn表达式正确性
// 处理一些数字类型
state.submitForm = await getReqForm();
state.submitForm.fieldMap = JSON.stringify(state.form.fieldMap);
await saveExec();
ElMessage.success('保存成功');
emit('val-change', state.form);
cancel();
});
};
const cancel = () => {
dialogVisible.value = false;
emit('cancel');
};
</script>
<style lang="scss">
.sync-task-edit {
.el-select {
width: 100%;
}
.task-sql {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,192 @@
<template>
<div class="db-list">
<page-table
ref="pageTableRef"
:page-api="dbApi.datasyncTasks"
:searchItems="searchItems"
v-model:query-form="query"
:show-selection="true"
v-model:selection-data="state.selectionData"
:columns="columns"
>
<template #tableHeader>
<el-button v-auth="perms.save" type="primary" icon="plus" @click="edit(false)">添加</el-button>
<el-button v-auth="perms.del" :disabled="selectionData.length < 1" @click="del()" type="danger" icon="delete">删除</el-button>
</template>
<template #status="{ data }">
<span v-if="actionBtns[perms.status]">
<el-switch
v-model="data.status"
@click="updStatus(data.id, data.status)"
inline-prompt
active-text="启用"
inactive-text="禁用"
:active-value="1"
:inactive-value="-1"
/>
</span>
<span v-else>
<el-tag v-if="data.status == 1" class="ml-2" type="success">启用</el-tag>
<el-tag v-else class="ml-2" type="danger">禁用</el-tag>
</span>
</template>
<template #action="{ data }">
<!-- 删除启停用编辑 -->
<el-button v-if="actionBtns[perms.save]" @click="edit(data)" type="primary" link>编辑</el-button>
<el-button v-if="data.status === 1 && data.runningState !== 1" @click="run(data.id)" type="success" link>执行</el-button>
<el-button v-if="data.runningState === 1" @click="stop(data.id)" type="danger" link>停止</el-button>
<el-button v-if="actionBtns[perms.log]" type="primary" link @click="log(data)">日志</el-button>
</template>
</page-table>
<data-sync-task-edit @val-change="search" :title="editDialog.title" v-model:visible="editDialog.visible" v-model:data="editDialog.data" />
<data-sync-task-log v-model:visible="logsDialog.visible" v-model:taskId="logsDialog.taskId" :running="state.logsDialog.running" />
</div>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { dbApi } from './api';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { hasPerms } from '@/components/auth/auth';
import { SearchItem } from '@/components/SearchForm';
import { DbDataSyncRecentStateEnum, DbDataSyncRunningStateEnum } from './enums';
const DataSyncTaskEdit = defineAsyncComponent(() => import('./SyncTaskEdit.vue'));
const DataSyncTaskLog = defineAsyncComponent(() => import('./SyncTaskLog.vue'));
const perms = {
save: 'db:sync:save',
del: 'db:sync:del',
status: 'db:sync:status',
log: 'db:sync:log',
};
const searchItems = [SearchItem.input('name', '名称')];
// 任务名、修改人、修改时间、最近一次任务执行状态、状态(停用启用)、操作
const columns = ref([
TableColumn.new('taskName', '任务名'),
TableColumn.new('runningState', '运行状态').alignCenter().typeTag(DbDataSyncRunningStateEnum),
TableColumn.new('recentState', '最近任务状态').alignCenter().typeTag(DbDataSyncRecentStateEnum),
TableColumn.new('status', '状态').alignCenter().isSlot(),
TableColumn.new('modifier', '修改人').alignCenter(),
TableColumn.new('updateTime', '修改时间').alignCenter().isTime(),
]);
// 该用户拥有的的操作列按钮权限
const actionBtns = hasPerms([perms.save, perms.del, perms.status, perms.log]);
const actionWidth = ((actionBtns[perms.save] ? 1 : 0) + (actionBtns[perms.log] ? 1 : 0)) * 55 + 55;
const actionColumn = TableColumn.new('action', '操作').isSlot().setMinWidth(actionWidth).fixedRight().alignCenter();
const pageTableRef: Ref<any> = ref(null);
const state = reactive({
row: {},
dbId: 0,
db: '',
/**
* 选中的数据
*/
selectionData: [],
/**
* 查询条件
*/
query: {
name: null,
pageNum: 1,
pageSize: 0,
},
editDialog: {
visible: false,
data: null as any,
title: '新增数据同步任务',
},
logsDialog: {
taskId: 0,
visible: false,
data: null as any,
running: false,
},
});
const { selectionData, query, editDialog, logsDialog } = toRefs(state);
onMounted(async () => {
if (Object.keys(actionBtns).length > 0) {
columns.value.push(actionColumn);
}
});
const search = () => {
pageTableRef.value.search();
};
const edit = async (data: any) => {
if (!data) {
state.editDialog.data = null;
state.editDialog.title = '新增数据同步任务';
} else {
state.editDialog.data = data;
state.editDialog.title = '修改数据同步任务';
}
state.editDialog.visible = true;
};
const run = async (id: any) => {
await ElMessageBox.confirm(`确定执行?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await dbApi.runDatasyncTask.request({ taskId: id });
ElMessage.success(`执行成功`);
setTimeout(search, 1000);
};
const stop = async (id: any) => {
await ElMessageBox.confirm(`确定停止?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await dbApi.stopDatasyncTask.request({ taskId: id });
ElMessage.success(`停止成功`);
search();
};
const log = async (data: any) => {
state.logsDialog.taskId = data.id;
state.logsDialog.visible = true;
state.logsDialog.running = data.state === 1;
};
const updStatus = async (id: any, status: 1 | -1) => {
try {
await dbApi.updateDatasyncTaskStatus.request({ taskId: id, status });
ElMessage.success(`${status === 1 ? '启用' : '禁用'}成功`);
search();
} catch (err) {
//
}
};
const del = async () => {
try {
await ElMessageBox.confirm(`确定删除数据同步任务【${state.selectionData.map((x: any) => x.taskName).join(', ')}】?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await dbApi.deleteDatasyncTask.request({ taskId: state.selectionData.map((x: any) => x.id).join(',') });
ElMessage.success('删除成功');
search();
} catch (err) {
//
}
};
</script>
<style lang="scss"></style>

View File

@@ -0,0 +1,111 @@
<template>
<div class="sync-task-logs">
<el-dialog v-model="dialogVisible" :before-close="cancel" :destroy-on-close="false" width="1120px">
<template #header>
<span class="mr10">任务执行日志</span>
<el-switch v-model="realTime" @change="watchPolling" inline-prompt active-text="实时" inactive-text="非实时" />
<el-button @click="search" icon="Refresh" circle size="small" :loading="realTime" class="ml10"></el-button>
</template>
<page-table ref="logTableRef" :page-api="dbApi.datasyncLogs" v-model:query-form="query" :tool-button="false" :columns="columns" size="small">
</page-table>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { reactive, Ref, ref, toRefs, watch } from 'vue';
import { dbApi } from '@/views/ops/db/api';
import PageTable from '@/components/pagetable/PageTable.vue';
import { TableColumn } from '@/components/pagetable';
import { DbDataSyncLogStatusEnum } from './enums';
const props = defineProps({
taskId: {
type: Number,
},
running: {
type: Boolean,
default: false,
},
});
const dialogVisible = defineModel<boolean>('visible', { default: false });
const columns = ref([
// 状态:1.成功 -1.失败
TableColumn.new('status', '状态').alignCenter().typeTag(DbDataSyncLogStatusEnum),
TableColumn.new('createTime', '时间').alignCenter().isTime(),
TableColumn.new('errText', '日志'),
TableColumn.new('dataSqlFull', 'SQL').alignCenter(),
TableColumn.new('resNum', '数据条数'),
]);
watch(dialogVisible, (newValue: any) => {
if (!newValue) {
state.polling = false;
watchPolling(false);
return;
}
state.query.taskId = props.taskId!;
search();
state.realTime = props.running;
watchPolling(props.running);
});
const startPolling = () => {
if (!state.polling) {
state.polling = true;
state.pollingIndex = setInterval(search, 1000);
}
};
const stopPolling = () => {
if (state.polling) {
state.polling = false;
clearInterval(state.pollingIndex);
}
};
const watchPolling = (polling: boolean) => {
if (polling) {
startPolling();
} else {
stopPolling();
}
};
const logTableRef: Ref<any> = ref(null);
const search = () => {
try {
logTableRef.value.search();
} catch (e) {
/* empty */
}
};
const emit = defineEmits(['update:visible', 'cancel', 'val-change']);
//定义事件
const cancel = () => {
dialogVisible.value = false;
emit('cancel');
watchPolling(false);
};
const state = reactive({
polling: false,
pollingIndex: 0 as any,
realTime: props.running,
/**
* 查询条件
*/
query: {
taskId: 0,
name: null,
pageNum: 1,
pageSize: 0,
},
});
const { query, realTime } = toRefs(state);
</script>

View File

@@ -18,6 +18,10 @@ export const dbApi = {
sqlExec: Api.newPost('/dbs/{id}/exec-sql').withBeforeHandler((param: any) => {
// sql编码处理
if (param.sql) {
// 判断是开发环境就打印sql
if (process.env.NODE_ENV === 'development') {
console.log(param.sql);
}
param.sql = Base64.encode(param.sql);
}
return param;
@@ -32,12 +36,46 @@ export const dbApi = {
// 获取数据库sql执行记录
getSqlExecs: Api.newGet('/dbs/{dbId}/sql-execs'),
// 获取权限列表
instances: Api.newGet('/instances'),
getInstance: Api.newGet('/instances/{instanceId}'),
getAllDatabase: Api.newGet('/instances/{instanceId}/databases'),
getInstanceServerInfo: Api.newGet('/instances/{instanceId}/server-info'),
testConn: Api.newPost('/instances/test-conn'),
saveInstance: Api.newPost('/instances'),
getInstancePwd: Api.newGet('/instances/{id}/pwd'),
deleteInstance: Api.newDelete('/instances/{id}'),
// 获取数据库备份列表
getDbBackups: Api.newGet('/dbs/{dbId}/backups'),
createDbBackup: Api.newPost('/dbs/{dbId}/backups'),
getDbNamesWithoutBackup: Api.newGet('/dbs/{dbId}/db-names-without-backup'),
enableDbBackup: Api.newPut('/dbs/{dbId}/backups/{backupId}/enable'),
disableDbBackup: Api.newPut('/dbs/{dbId}/backups/{backupId}/disable'),
startDbBackup: Api.newPut('/dbs/{dbId}/backups/{backupId}/start'),
saveDbBackup: Api.newPut('/dbs/{dbId}/backups/{id}'),
getDbBackupHistories: Api.newGet('/dbs/{dbId}/backup-histories'),
// 获取数据库备份列表
getDbRestores: Api.newGet('/dbs/{dbId}/restores'),
createDbRestore: Api.newPost('/dbs/{dbId}/restores'),
getDbNamesWithoutRestore: Api.newGet('/dbs/{dbId}/db-names-without-restore'),
enableDbRestore: Api.newPut('/dbs/{dbId}/restores/{restoreId}/enable'),
disableDbRestore: Api.newPut('/dbs/{dbId}/restores/{restoreId}/disable'),
saveDbRestore: Api.newPut('/dbs/{dbId}/restores/{id}'),
// 数据同步相关
datasyncTasks: Api.newGet('/datasync/tasks'),
saveDatasyncTask: Api.newPost('/datasync/tasks/save').withBeforeHandler((param: any) => {
// sql编码处理
if (param.dataSql) {
param.dataSql = Base64.encode(param.dataSql);
}
return param;
}),
getDatasyncTask: Api.newGet('/datasync/tasks/{taskId}'),
deleteDatasyncTask: Api.newDelete('/datasync/tasks/{taskId}/del'),
updateDatasyncTaskStatus: Api.newPost('/datasync/tasks/{taskId}/status'),
runDatasyncTask: Api.newPost('/datasync/tasks/{taskId}/run'),
stopDatasyncTask: Api.newPost('/datasync/tasks/{taskId}/stop'),
datasyncLogs: Api.newGet('/datasync/tasks/{taskId}/logs'),
};

View File

@@ -0,0 +1,157 @@
<template>
<TagTreeResourceSelect
v-bind="$attrs"
v-model="selectNode"
@change="changeNode"
:resource-type="TagResourceTypeEnum.Db.value"
:tag-path-node-type="NodeTypeTagPath"
>
<template #prefix="{ data }">
<SvgIcon v-if="data.type.value == SqlExecNodeType.DbInst" :name="getDbDialect(data.params.type).getInfo().icon" :size="18" />
<SvgIcon v-if="data.icon" :name="data.icon.name" :color="data.icon.color" />
</template>
</TagTreeResourceSelect>
</template>
<script setup lang="ts">
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { NodeType, TagTreeNode } from '@/views/ops/component/tag';
import { dbApi } from '@/views/ops/db/api';
import { sleep } from '@/common/utils/loading';
import SvgIcon from '@/components/svgIcon/index.vue';
import { DbType, getDbDialect } from '@/views/ops/db/dialect';
import TagTreeResourceSelect from '../../component/TagTreeResourceSelect.vue';
import { computed } from 'vue';
const props = defineProps({
dbId: {
type: Number,
},
dbName: {
type: String,
},
tagPath: {
type: String,
},
});
const emits = defineEmits(['update:dbName', 'update:tagPath', 'update:dbId', 'selectDb']);
/**
* 树节点类型
*/
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;
}
const selectNode = computed({
get: () => {
return props.dbName ? `${props.tagPath} - ${props.dbId} - ${props.dbName}` : '';
},
set: () => {
//
},
});
const DbIcon = {
name: 'Coin',
color: '#67c23a',
};
// pgsql schema icon
const SchemaIcon = {
name: 'List',
color: '#67c23a',
};
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);
});
});
/** mysql类型的数据库没有schema层 */
const mysqlType = (type: string) => {
return type === DbType.mysql;
};
// 数据库实例节点类型
const NodeTypeDbInst = new NodeType(SqlExecNodeType.DbInst).withLoadNodesFunc((parentNode: TagTreeNode) => {
const params = parentNode.params;
const dbs = params.database.split(' ')?.sort();
let fn: NodeType;
if (mysqlType(params.type)) {
fn = MysqlNodeTypes;
} else {
fn = PgNodeTypes;
}
return dbs.map((x: any) => {
let tagTreeNode = new TagTreeNode(`${parentNode.key}.${x}`, `${x}`, fn)
.withParams({
tagPath: params.tagPath,
id: params.id,
instanceId: params.instanceId,
name: params.name,
type: params.type,
host: `${params.host}:${params.port}`,
dbs: dbs,
db: x,
})
.withIcon(DbIcon);
if (mysqlType(params.type)) {
tagTreeNode.isLeaf = true;
}
return tagTreeNode;
});
});
// 数据库节点
const PgNodeTypes = new NodeType(SqlExecNodeType.Db).withLoadNodesFunc(async (parentNode: TagTreeNode) => {
// pg类数据库会多一层schema
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;
nParams.dbs = schemaNames;
let tagTreeNode = new TagTreeNode(`${params.id}.${params.db}.schema.${sn}`, sn, NodeTypePostgresSchema).withParams(nParams).withIcon(SchemaIcon);
tagTreeNode.isLeaf = true;
return tagTreeNode;
});
});
const MysqlNodeTypes = new NodeType(SqlExecNodeType.Db);
// postgres schema模式
const NodeTypePostgresSchema = new NodeType(SqlExecNodeType.PgSchema);
const changeNode = (nodeData: TagTreeNode) => {
const params = nodeData.params;
// postgres
emits('update:dbName', params.db);
emits('update:dbId', params.id);
emits('update:tagPath', params.tagPath);
emits('selectDb', params);
};
</script>
<style lang="scss"></style>

View File

@@ -1,8 +1,8 @@
<template>
<div>
<div>
<div class="toolbar">
<div class="fl">
<div class="card pd5 flex-justify-between">
<div>
<el-link @click="onRunSql()" :underline="false" class="ml15" icon="VideoPlay"> </el-link>
<el-divider direction="vertical" border-style="dashed" />
@@ -33,7 +33,7 @@
</el-upload>
</div>
<div class="fr">
<div>
<el-button @click="saveSql()" type="primary" icon="document-add" plain size="small">保存SQL</el-button>
</div>
</div>
@@ -44,7 +44,7 @@
@resize="resizeTableHeight"
horizontal
class="default-theme"
style="height: calc(100vh - 220px)"
style="height: calc(100vh - 233px)"
>
<Pane :size="state.editorSize" max-size="80">
<MonacoEditor ref="monacoEditorRef" class="mt5" v-model="state.sql" language="sql" height="100%" :id="'MonacoTextarea-' + getKey()" />
@@ -128,7 +128,7 @@
</template>
<script lang="ts" setup>
import { h, nextTick, onMounted, reactive, toRefs, ref } from 'vue';
import { h, nextTick, onMounted, reactive, toRefs, ref, unref } from 'vue';
import { getToken } from '@/common/utils/storage';
import { notBlank } from '@/common/assert';
import { format as sqlFormatter } from 'sql-formatter';
@@ -276,7 +276,7 @@ const onRemoveTab = (targetId: number) => {
const resizeTableHeight = (e: any) => {
const vh = window.innerHeight;
state.editorSize = e[0].size;
const plitpaneHeight = vh - 210;
const plitpaneHeight = vh - 233;
const editorHeight = plitpaneHeight * (state.editorSize / 100);
state.tableDataHeight = plitpaneHeight - editorHeight - 40 + 'px';
};
@@ -336,7 +336,7 @@ const onRunSql = async (newTab = false) => {
// 不是新建tab执行则在当前激活的tab上执行sql
i = state.execResTabs.findIndex((x) => x.id == state.activeTab);
execRes = state.execResTabs[i];
if (execRes.loading?.value) {
if (unref(execRes.loading)) {
ElMessage.error('当前结果集tab正在执行, 请使用新标签执行');
return;
}
@@ -362,9 +362,10 @@ const onRunSql = async (newTab = false) => {
// 要实时响应,故需要用索引改变数据才生效
state.execResTabs[i].data = colAndData.res;
// 兼容表格字段配置
state.execResTabs[i].tableColumn = colAndData.colNames.map((x: any) => {
state.execResTabs[i].tableColumn = colAndData.columns.map((x: any) => {
return {
columnName: x,
columnName: x.name,
columnType: x.type,
show: true,
};
});
@@ -385,8 +386,8 @@ const onRunSql = async (newTab = false) => {
const tableName = sql.split(/from/i)[1];
if (tableName) {
const tn = tableName.trim().split(' ')[0].split('\n')[0];
execRes.table = tn;
execRes.table = tn;
// 去除表名前后的字符`或者"
execRes.table = tn.replace(/`/g, '').replace(/"/g, '');
} else {
execRes.table = '';
}
@@ -427,7 +428,7 @@ const saveSql = async () => {
const input = await ElMessageBox.prompt('请输入SQL脚本名', 'SQL名', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /\w+/,
inputPattern: /.+/,
inputErrorMessage: '请输入SQL脚本名',
});
sqlName = input.value;

View File

@@ -1,45 +1,35 @@
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;
dbType?: string;
runSuccessCallback?: Function;
cancelCallback?: Function;
};
const boxId = 'sql-exec-id';
const boxId = 'sql-exec-dialog-id';
const renderBox = (): VNode => {
const props: SqlExecProps = {
sql: '',
dbId: 0,
} as any;
const container = document.createElement('div');
container.id = boxId;
// 创建 虚拟dom
const boxVNode = h(SqlExecDialog, props);
// 将虚拟dom渲染到 container dom 上
render(boxVNode, container);
// 最后将 container 追加到 body 上
document.body.appendChild(container);
return boxVNode;
};
let boxInstance: any;
let boxInstance: VNode;
const SqlExecBox = (props: SqlExecProps): void => {
if (boxInstance) {
const boxVue = boxInstance.component;
if (!boxInstance) {
const container = document.createElement('div');
container.id = boxId;
// 创建 虚拟dom
boxInstance = h(SqlExecDialog);
// 将虚拟dom渲染到 container dom 上
render(boxInstance, container);
// 最后将 container 追加到 body 上
document.body.appendChild(container);
}
const boxVue = boxInstance.component;
if (boxVue) {
// 调用open方法显示弹框注意不能使用boxVue.ctx来调用组件函数build打包后ctx会获取不到
boxVue.exposed.open(props);
} else {
boxInstance = renderBox();
SqlExecBox(props);
boxVue.exposed?.open(props);
}
};

View File

@@ -1,6 +1,6 @@
<template>
<div>
<el-dialog :destroy-on-close="true" title="待执行SQL" v-model="dialogVisible" :show-close="false" width="600px" @close="cancel">
<el-dialog 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 @keyup.enter="runSql" ref="remarkInputRef" v-model="remark" placeholder="请输入执行备注" class="mt5" />
<template #footer>

View File

@@ -0,0 +1,233 @@
<template>
<div class="string-input-container w100" v-if="dataType == DataType.String">
<el-input
v-if="dataType == DataType.String"
:ref="(el: any) => focus && el?.focus()"
@blur="handleBlur"
:class="`w100 mb4 ${showEditorIcon ? 'string-input-container-show-icon' : ''}`"
input-style="text-align: center; height: 26px;"
size="small"
v-model="itemValue"
:placeholder="placeholder"
/>
<SvgIcon v-if="showEditorIcon" @mousedown="openEditor" class="string-input-container-icon" name="FullScreen" :size="10" />
</div>
<el-input
v-else-if="dataType == DataType.Number"
:ref="(el: any) => focus && el?.focus()"
@blur="handleBlur"
class="w100 mb4"
input-style="text-align: center; height: 26px;"
size="small"
v-model.number="itemValue"
:placeholder="placeholder"
type="number"
/>
<el-date-picker
v-else-if="dataType == DataType.Date"
:ref="(el: any) => focus && el?.focus()"
@change="emit('blur')"
@blur="handleBlur"
class="edit-time-picker mb4"
popper-class="edit-time-picker-popper"
size="small"
v-model="itemValue"
:clearable="false"
type="Date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
/>
<el-date-picker
v-else-if="dataType == DataType.DateTime"
:ref="(el: any) => focus && el?.focus()"
@change="handleBlur"
@blur="handleBlur"
class="edit-time-picker mb4"
popper-class="edit-time-picker-popper"
size="small"
v-model="itemValue"
:clearable="false"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择日期时间"
/>
<el-time-picker
v-else-if="dataType == DataType.Time"
:ref="(el: any) => focus && el?.focus()"
@change="handleBlur"
@blur="handleBlur"
class="edit-time-picker mb4"
popper-class="edit-time-picker-popper"
size="small"
v-model="itemValue"
:clearable="false"
value-format="HH:mm:ss"
placeholder="选择时间"
/>
</template>
<script lang="ts" setup>
import { Ref, ref, computed } from 'vue';
import { ElInput } from 'element-plus';
import { DataType } from '../../dialect/index';
import SvgIcon from '@/components/svgIcon/index.vue';
import MonacoEditorDialog from '@/components/monaco/MonacoEditorDialog';
export interface ColumnFormItemProps {
modelValue: string | number; // 绑定的值
dataType: DataType; // 数据类型
focus?: boolean; // 是否获取焦点
placeholder?: string;
columnName?: string;
}
const props = withDefaults(defineProps<ColumnFormItemProps>(), {
focus: false,
dataType: DataType.String,
});
const emit = defineEmits(['update:modelValue', 'blur']);
const itemValue: Ref<any> = ref(props.modelValue);
const showEditorIcon = computed(() => {
return typeof itemValue.value === 'string' && itemValue.value.length > 50;
});
const editorOpening = ref(false);
const openEditor = () => {
editorOpening.value = true;
// 编辑器语言json、html、text
let editorLang = getEditorLangByValue(itemValue.value);
MonacoEditorDialog({
content: itemValue.value,
title: `编辑字段 [${props.columnName}]`,
language: editorLang,
confirmFn: (newVal: any) => {
itemValue.value = newVal;
closeEditorDialog();
},
cancelFn: closeEditorDialog,
});
};
const closeEditorDialog = () => {
editorOpening.value = false;
handleBlur();
};
const handleBlur = () => {
if (editorOpening.value) {
return;
}
emit('update:modelValue', itemValue.value);
emit('blur');
};
const getEditorLangByValue = (value: any) => {
// 判断是否是json
try {
if (typeof JSON.parse(value) === 'object') {
return 'json';
}
} catch (e) {
/* empty */
}
// 判断是否是html
try {
const doc = new DOMParser().parseFromString(value, 'text/html');
if (Array.from(doc.body.childNodes).some((node) => node.nodeType === 1)) {
return 'html';
}
} catch (e) {
/* empty */
}
return 'text';
};
</script>
<style lang="scss">
.string-input-container {
position: relative;
}
.string-input-container-show-icon {
.el-input__inner {
padding-right: 10px;
}
}
.string-input-container-icon {
position: absolute;
top: 5px; /* 调整图标的垂直位置 */
right: 3px; /* 调整图标的水平位置 */
color: var(--el-color-primary);
}
.string-input-container-icon:hover {
color: var(--el-color-success);
}
.edit-time-picker {
height: 26px;
width: 100% !important;
.el-input__prefix {
display: none;
}
.el-input__inner {
text-align: center;
}
}
.edit-time-picker-popper {
.el-date-picker {
width: 250px !important;
.el-date-picker__header {
margin: 0 5px;
}
.el-picker-panel__content {
width: unset;
margin: 0 5px;
}
.el-date-picker__header-label {
font-size: 13px;
}
.el-picker-panel__footer {
padding: 0 5px;
button {
font-size: 11px;
padding: 5px 6px;
height: 20px;
}
}
}
.el-date-table {
th {
font-size: 10px;
font-weight: 600;
padding: 0;
}
td {
padding: 0;
}
}
.el-time-panel {
width: 100px;
.el-time-spinner__list {
&::after,
&::before {
height: 10px;
}
.el-time-spinner__item {
height: 20px;
line-height: 20px;
}
}
}
}
</style>

View File

@@ -4,9 +4,10 @@
<template #default="{ height, width }">
<el-table-v2
ref="tableRef"
:header-height="30"
:header-height="showColumnTip && dbConfig.showColumnComment ? 45 : 30"
:row-height="30"
:row-class="rowClass"
:row-key="null"
:columns="state.columns"
:data="datas"
:width="width"
@@ -21,34 +22,54 @@
: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 v-if="column.key == rowNoColumn.key" class="header-column-title">
<b class="el-text" tag="b"> {{ column.title }} </b>
</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>
<!-- 字段名列 -->
<div v-else @contextmenu="headerContextmenuClick($event, column)" style="position: relative">
<!-- 字段列的数据类型 -->
<div class="column-type">
<span v-if="column.dataTypeSubscript === 'icon-clock'">
<SvgIcon :size="10" name="Clock" style="cursor: unset" />
</span>
<span class="font8" v-else>{{ column.dataTypeSubscript }}</span>
</div>
<span>
<SvgIcon
color="var(--el-color-primary)"
v-if="column.title == nowSortColumn?.columnName"
:name="nowSortColumn?.order == 'asc' ? 'top' : 'bottom'"
></SvgIcon>
<div v-if="showColumnTip">
<div class="header-column-title">
<b :title="column.remark" class="el-text" style="cursor: pointer">
{{ column.title }}
</b>
<span>
<SvgIcon
color="var(--el-color-primary)"
v-if="column.title == nowSortColumn?.columnName"
:name="nowSortColumn?.order == 'asc' ? 'top' : 'bottom'"
></SvgIcon>
</span>
</div>
<!-- 字段备注信息 -->
<span
v-if="dbConfig.showColumnComment"
style="color: var(--el-color-info-light-3)"
class="font10 el-text el-text--small is-truncated"
>
{{ column.columnComment }}
</span>
</div>
<div v-else>
<el-text tag="b" style="cursor: pointer"> {{ column.title }} </el-text>
<div v-else class="header-column-title">
<b class="el-text">
{{ column.title }}
</b>
</div>
</div>
</div>
@@ -58,33 +79,30 @@
<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">
<div v-if="column.key == rowNoColumn.key">
<b class="el-text el-text--small">
{{ rowIndex + 1 }}
</el-text>
</b>
</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"
<ColumnFormItem
v-model="rowData[column.dataKey!]"
></el-input>
:data-type="column.dataType"
@blur="onExitEditMode(rowData, column, rowIndex)"
:column-name="column.columnName"
focus
/>
</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>
<span v-if="rowData[column.dataKey!] === null" style="color: var(--el-color-info-light-5)"> NULL </span>
<el-text v-else :title="rowData[column.dataKey!]" size="small" truncated>
<span v-else :title="rowData[column.dataKey!]" class="el-text el-text--small is-truncated">
{{ rowData[column.dataKey!] }}
</el-text>
</span>
</div>
</div>
</div>
@@ -125,15 +143,17 @@
</template>
<script lang="ts" setup>
import { ref, onMounted, watch, reactive, toRefs, onBeforeUnmount } from 'vue';
import { onBeforeUnmount, onMounted, reactive, ref, toRefs, watch } 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 { Contextmenu, ContextmenuItem } from '@/components/contextmenu';
import SvgIcon from '@/components/svgIcon/index.vue';
import { exportCsv, exportFile } from '@/common/utils/export';
import { dateStrFormat } from '@/common/utils/date';
import { useIntervalFn } from '@vueuse/core';
import { useIntervalFn, useStorage } from '@vueuse/core';
import { ColumnTypeSubscript, compatibleMysql, DataType, DbDialect, getDbDialect } from '../../dialect/index';
import ColumnFormItem from './ColumnFormItem.vue';
const emits = defineEmits(['dataDelete', 'sortChange', 'deleteData', 'selectionChange', 'changeUpdatedField']);
@@ -142,10 +162,6 @@ const props = defineProps({
type: Number,
required: true,
},
dbType: {
type: String,
default: '',
},
db: {
type: String,
required: true,
@@ -184,15 +200,21 @@ const props = defineProps({
const contextmenuRef = ref();
const tableRef = ref();
/** 表头 contextmenu items **/
/** 表头 menu items **/
const cmHeaderAsc = new ContextmenuItem('asc', '升序').withIcon('top').withOnClick((data: any) => {
onTableSortChange({ columnName: data.dataKey, order: 'asc' });
});
const cmHeaderAsc = new ContextmenuItem('asc', '升序')
.withIcon('top')
.withOnClick((data: any) => {
onTableSortChange({ columnName: data.dataKey, order: 'asc' });
})
.withHideFunc(() => !props.showColumnTip);
const cmHeaderDesc = new ContextmenuItem('desc', '降序').withIcon('bottom').withOnClick((data: any) => {
onTableSortChange({ columnName: data.dataKey, order: 'desc' });
});
const cmHeaderDesc = new ContextmenuItem('desc', '降序')
.withIcon('bottom')
.withOnClick((data: any) => {
onTableSortChange({ columnName: data.dataKey, order: 'desc' });
})
.withHideFunc(() => !props.showColumnTip);
const cmHeaderFixed = new ContextmenuItem('fixed', '固定')
.withIcon('Paperclip')
@@ -246,6 +268,7 @@ const cmDataExportSql = new ContextmenuItem('exportSql', '导出SQL')
class NowUpdateCell {
rowIndex: number;
colIndex: number;
dataType: DataType;
oldValue: any;
}
@@ -273,6 +296,8 @@ class TableCellData {
oldValue: any;
}
let dbDialect: DbDialect = null as any;
let nowSortColumn = null as any;
// 当前正在更新的单元格
@@ -318,6 +343,8 @@ const state = reactive({
const { tableHeight, datas } = toRefs(state);
const dbConfig = useStorage('dbConfig', { showColumnComment: false });
/**
* 行号字段列
*/
@@ -387,7 +414,9 @@ onMounted(async () => {
state.emptyText = props.emptyText;
state.dbId = props.dbId;
state.dbType = props.dbType;
state.dbType = getNowDbInst().type;
dbDialect = getDbDialect(state.dbType);
state.db = props.db;
state.table = props.table;
setTableData(props.data);
@@ -401,10 +430,24 @@ onBeforeUnmount(() => {
endLoading();
});
const formatDataValues = (datas: any) => {
// mysql数据暂不做处理
if (compatibleMysql(getNowDbInst().type)) {
return;
}
for (let data of datas) {
for (let column of props.columns!) {
data[column.columnName] = getFormatTimeValue(dbDialect.getDataType(column.columnType), data[column.columnName]);
}
}
};
const setTableData = (datas: any) => {
tableRef.value.scrollTo({ scrollLeft: 0, scrollTop: 0 });
selectionRowsMap.clear();
cellUpdateMap.clear();
formatDataValues(datas);
state.datas = datas;
setTableColumns(props.columns);
};
@@ -412,6 +455,11 @@ const setTableData = (datas: any) => {
const setTableColumns = (columns: any) => {
state.columns = columns.map((x: any) => {
const columnName = x.columnName;
// 数据类型
x.dataType = dbDialect.getDataType(x.columnType);
x.dataTypeSubscript = ColumnTypeSubscript[x.dataType];
x.remark = `${x.columnType} ${x.columnComment ? ' | ' + x.columnComment : ''}`;
return {
...x,
key: columnName,
@@ -450,7 +498,7 @@ const cancelLoading = async () => {
* @param colIndex ci
*/
const canEdit = (rowIndex: number, colIndex: number) => {
return state.table && nowUpdateCell && nowUpdateCell.rowIndex == rowIndex && nowUpdateCell.colIndex == colIndex;
return state.table && nowUpdateCell?.rowIndex == rowIndex && nowUpdateCell?.colIndex == colIndex;
};
/**
@@ -615,10 +663,14 @@ const onEnterEditMode = (rowData: any, column: any, rowIndex = 0, columnIndex =
rowIndex: rowIndex,
colIndex: columnIndex,
oldValue: rowData[column.dataKey],
dataType: column.dataType,
};
};
const onExitEditMode = (rowData: any, column: any, rowIndex = 0) => {
if (!nowUpdateCell) {
return;
}
const oldValue = nowUpdateCell.oldValue;
const newValue = rowData[column.dataKey];
@@ -662,6 +714,7 @@ const submitUpdateFields = async () => {
const db = state.db;
let res = '';
const dbDialect = getDbDialect(dbInst.type);
for (let updateRow of cellUpdateMap.values()) {
let sql = `UPDATE ${dbInst.wrapName(state.table)} SET `;
@@ -679,7 +732,8 @@ const submitUpdateFields = async () => {
}
// 更新字段列信息
const updateColumn = await dbInst.loadTableColumn(db, state.table, k);
sql += ` ${dbInst.wrapName(k)} = ${DbInst.wrapColumnValue(updateColumn.columnType, rowData[k])},`;
sql += ` ${dbInst.wrapName(k)} = ${DbInst.wrapColumnValue(updateColumn.columnType, rowData[k], dbDialect)},`;
// 如果修改的字段是主键
if (k === primaryKeyName) {
@@ -725,15 +779,33 @@ 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 : ''}`;
/**
* 根据数据库返回的时间字段类型,获取格式化后的时间值
* @param dataType getDataType返回的数据类型
* @param originValue 原始值
* @return 格式化后的值
*/
const getFormatTimeValue = (dataType: DataType, originValue: string): string => {
if (!originValue || dataType === DataType.Number || dataType === DataType.String) {
return originValue;
}
// 把Z去掉
originValue = originValue.replace('Z', '');
switch (dataType) {
case DataType.Time:
return dateStrFormat('HH:mm:ss', originValue);
case DataType.Date:
return dateStrFormat('yyyy-MM-dd', originValue);
case DataType.DateTime:
return dateStrFormat('yyyy-MM-dd HH:mm:ss', originValue);
default:
return originValue;
}
};
/**
@@ -771,6 +843,12 @@ defineExpose({
border-right: var(--el-table-border);
}
.header-column-title {
height: 30px;
display: flex;
justify-content: center;
}
.table-data-cell {
width: 100%;
height: 100%;
@@ -782,12 +860,17 @@ defineExpose({
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-light-3);
}
.update_field_active {
background-color: var(--el-color-success);
.column-type {
color: var(--el-color-info-light-3);
font-weight: bold;
position: absolute;
top: -5px;
padding: 2px;
height: 12px;
}
}
</style>

View File

@@ -40,36 +40,77 @@
<template #content>
1. 右击数据/表头可显示操作菜单 <br />
2. 按住Ctrl点击数据则为多选 <br />
3. 双击单元格可编辑数据
3. 双击单元格可编辑数据 <br />
4. 鼠标悬停字段名或标签树的表名可提示相关备注
</template>
<el-link icon="QuestionFilled" :underline="false"> </el-link>
</el-tooltip>
<el-divider direction="vertical" border-style="dashed" />
<!-- 表数据展示配置 -->
<el-popover
popper-style="max-height: 550px; overflow: auto; max-width: 450px"
placement="bottom"
width="auto"
title="展示配置"
trigger="click"
>
<el-checkbox v-model="dbConfig.showColumnComment" label="显示字段备注" :true-label="true" :false-label="false" size="small" />
<template #reference>
<el-link type="primary" icon="setting" :underline="false"></el-link>
</template>
</el-popover>
<el-divider direction="vertical" border-style="dashed" />
<el-tooltip :show-after="500" v-if="hasUpdatedFileds" class="box-item" effect="dark" content="提交修改" placement="top">
<el-link @click="submitUpdateFields()" type="success" :underline="false" class="f12">提交</el-link>
<el-link @click="submitUpdateFields()" type="success" :underline="false" class="font12">提交</el-link>
</el-tooltip>
<el-divider v-if="hasUpdatedFileds" direction="vertical" border-style="dashed" />
<el-tooltip :show-after="500" v-if="hasUpdatedFileds" class="box-item" effect="dark" content="取消修改" placement="top">
<el-link @click="cancelUpdateFields" type="warning" :underline="false" class="f12">取消</el-link>
<el-link @click="cancelUpdateFields" type="warning" :underline="false" class="font12">取消</el-link>
</el-tooltip>
</div>
</el-col>
<el-col :span="16">
<el-input
ref="condInputRef"
@keyup.enter.native="onSelectByCondition()"
<el-autocomplete
v-model="condition"
placeholder="若需条件过滤,可选择列并点击对应的字段并输入需要过滤的内容后回车或点击查询按钮即可"
clearable
:fetch-suggestions="getColumnTips"
@keyup.enter.native="onSelectByCondition"
@select="handlerColumnSelect"
popper-class="my-autocomplete"
placeholder="选择列 或 输入SQL条件表达式后回车或点击查询图标过滤结果, 输入时可根据字段名提示"
@clear="selectData"
size="small"
style="width: 100%"
clearable
class="w100"
highlight-first-item
value-key="columnName"
ref="condInputRef"
>
<template #suffix>
<SvgIcon @click="onSelectByCondition" name="search" />
</template>
<template #default="{ item }">
<el-text tag="b"> {{ item.columnName }}</el-text>
<el-divider direction="vertical" />
<span style="color: var(--el-color-info-light-3)">
{{ item.columnType }}
<template v-if="item.columnComment">
<el-divider direction="vertical" />
{{ item.columnComment }}
</template>
</span>
</template>
<template #prepend>
<el-popover :visible="state.condPopVisible" trigger="click" :width="320" placement="right">
<template #reference>
<el-link @click.stop="chooseCondColumnName" type="success" :underline="false">选择列</el-link>
<el-button @click.stop="chooseCondColumnName" style="color: var(--el-color-success)" text size="small">选择列</el-button>
</template>
<el-table
:data="filterCondColumns"
@@ -88,8 +129,8 @@
ref="columnNameSearchInputRef"
v-model="state.columnNameSearch"
size="small"
placeholder="列名: 输入可过滤"
clearable
placeholder="输入列名或备注过滤"
@click.stop="(e: any) => e.preventDefault()"
/>
</template>
</el-table-column>
@@ -97,11 +138,7 @@
</el-table>
</el-popover>
</template>
<template #append>
<el-button @click="onSelectByCondition()" icon="search" size="small"></el-button>
</template>
</el-input>
</el-autocomplete>
</el-col>
</el-row>
@@ -152,7 +189,7 @@
<el-col :span="19">
<el-input
@keyup.enter.native="onConfirmCondition"
ref="oneCondInputRef"
ref="condDialogInputRef"
v-model="conditionDialog.value"
:placeholder="conditionDialog.placeholder"
/>
@@ -167,23 +204,21 @@
</el-dialog>
<el-dialog v-model="addDataDialog.visible" :title="addDataDialog.title" :destroy-on-close="true" width="600px">
<el-form ref="dataForm" :model="addDataDialog.data" label-width="auto" size="small">
<el-form ref="dataForm" :model="addDataDialog.data" :show-message="false" label-width="auto" size="small">
<el-form-item
v-for="column in columns"
:key="column.columnName"
class="w100"
class="w100 mb5"
:prop="column.columnName"
:label="column.columnName"
:required="column.nullable != 'YES' && column.columnKey != 'PRI'"
>
<el-input-number
v-if="DbInst.isNumber(column.columnType)"
<ColumnFormItem
v-model="addDataDialog.data[`${column.columnName}`]"
:data-type="dbDialect.getDataType(column.columnType)"
:placeholder="`${column.columnType} ${column.columnComment}`"
class="w100"
:column-name="column.columnName"
/>
<el-input v-else v-model="addDataDialog.data[`${column.columnName}`]" :placeholder="`${column.columnType} ${column.columnComment}`" />
</el-form-item>
</el-form>
<template #footer>
@@ -197,12 +232,15 @@
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, reactive, Ref, ref, toRefs, watch } from 'vue';
import { notEmpty } from '@/common/assert';
import { computed, onMounted, reactive, Ref, ref, toRefs, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { DbInst } from '@/views/ops/db/db';
import DbTableData from './DbTableData.vue';
import { DbDialect, getDbDialect } from '@/views/ops/db/dialect';
import SvgIcon from '@/components/svgIcon/index.vue';
import ColumnFormItem from './ColumnFormItem.vue';
import { useEventListener, useStorage } from '@vueuse/core';
const props = defineProps({
dbId: {
@@ -224,13 +262,15 @@ const props = defineProps({
});
const dataForm: any = ref(null);
const dbTableRef = ref(null) as Ref;
const columnNameSearchInputRef = ref(null) as Ref;
const oneCondInputRef: any = ref();
const condInputRef = ref(null) as Ref;
const dbTableRef: Ref = ref(null);
const condInputRef: Ref = ref(null);
const columnNameSearchInputRef: Ref = ref(null);
const condDialogInputRef: Ref = ref(null);
const defaultPageSize = DbInst.DefaultLimit;
const dbConfig = useStorage('dbConfig', { showColumnComment: false });
const state = reactive({
datas: [],
sql: '', // 当前数据tab执行的sql
@@ -270,9 +310,10 @@ const state = reactive({
},
tableHeight: '600px',
hasUpdatedFileds: false,
dbDialect: {} as DbDialect,
});
const { datas, condition, loading, columns, pageNum, pageSize, pageSizes, count, hasUpdatedFileds, conditionDialog, addDataDialog } = toRefs(state);
const { datas, condition, loading, columns, pageNum, pageSize, pageSizes, count, hasUpdatedFileds, conditionDialog, addDataDialog, dbDialect } = toRefs(state);
watch(
() => props.tableHeight,
@@ -288,20 +329,10 @@ const getNowDbInst = () => {
onMounted(async () => {
console.log('in table data mounted');
state.tableHeight = props.tableHeight;
const columns = await getNowDbInst().loadColumns(props.dbName, props.tableName);
columns.forEach((x: any) => {
x.show = true;
});
state.columns = columns;
await onRefresh();
// 点击除选择列按钮外,若存在条件弹窗,则关闭该弹窗
window.addEventListener('click', handlerWindowClick);
});
onUnmounted(() => {
window.removeEventListener('click', handlerWindowClick);
state.dbDialect = getDbDialect(getNowDbInst().type);
useEventListener('click', handlerWindowClick);
});
const handlerWindowClick = () => {
@@ -331,8 +362,16 @@ const selectData = async () => {
const db = props.dbName;
const table = props.tableName;
try {
if (state.columns.length == 0) {
const columns = await getNowDbInst().loadColumns(props.dbName, props.tableName);
columns.forEach((x: any) => {
x.show = true;
});
state.columns = columns;
}
const countRes = await dbInst.runSql(db, dbInst.getDefaultCountSql(table, state.condition));
state.count = countRes.res[0].count || countRes.res[0].COUNT || 0;
state.count = parseInt(countRes.res[0].count || countRes.res[0].COUNT || 0);
let sql = dbInst.getDefaultSelectSql(table, state.condition, state.orderBy, state.pageNum, state.pageSize);
state.sql = sql;
if (state.count > 0) {
@@ -352,6 +391,50 @@ const handleSizeChange = async (size: any) => {
await selectData();
};
// 完整的条件,每次选中后会重置条件框内容,故需要这个变量在获取建议时将文本框内容保存
let completeCond = '';
// 是否存在列建议
let existSuggestion = false;
const getColumnTips = (queryString: string, callback: any) => {
const columns = state.columns;
var words = queryString.split(' '); // 使用空格分割字符串为数组
let columnNameSearch = words[words.length - 1]; // 获取最后一个元素
let res = [];
if (columnNameSearch) {
columnNameSearch = columnNameSearch.toLowerCase();
res = columns.filter((data: any) => {
return data.columnName.toLowerCase().includes(columnNameSearch);
});
}
completeCond = condition.value;
callback(res);
existSuggestion = res.length > 0;
};
const handlerColumnSelect = (column: any) => {
// 获取最后一个空格的索引
var lastSpaceIndex = completeCond.lastIndexOf(' ');
// 默认拼接上 columnName =
let value = column.columnName + ' = ';
// 不是数字类型默认拼接上''
if (!DbInst.isNumber(column.columnType)) {
value = `${value} ''`;
}
if (lastSpaceIndex != -1) {
// 获取最后一个空格之前的文本,拼上当前选中的建议列
condition.value = `${completeCond.slice(0, lastSpaceIndex)} ${value}`;
} else {
condition.value = value;
}
};
/**
* 选择条件列
*/
@@ -368,16 +451,13 @@ const chooseCondColumnName = () => {
*/
const filterCondColumns = computed(() => {
const columns = state.columns;
const columnNameSearch = state.columnNameSearch;
let columnNameSearch = state.columnNameSearch;
if (!columnNameSearch) {
return columns;
}
columnNameSearch = columnNameSearch.toLowerCase();
return columns.filter((data: any) => {
let tnMatch = true;
if (columnNameSearch) {
tnMatch = data.columnName.toLowerCase().includes(columnNameSearch.toLowerCase());
}
return tnMatch;
return data.columnName.toLowerCase().includes(columnNameSearch) || data.columnComment.toLowerCase().includes(columnNameSearch);
});
});
@@ -391,7 +471,7 @@ const onConditionRowClick = (event: any) => {
state.conditionDialog.columnRow = row;
state.conditionDialog.visible = true;
setTimeout(() => {
oneCondInputRef.value.focus();
condDialogInputRef.value.focus();
}, 100);
};
@@ -427,10 +507,10 @@ const onCommit = () => {
};
const onSelectByCondition = async () => {
notEmpty(state.condition, '条件不能为空');
state.pageNum = 1;
await selectData();
condInputRef.value.blur();
if (!existSuggestion) {
state.pageNum = 1;
await selectData();
}
};
/**

View File

@@ -1,6 +1,6 @@
<template>
<div>
<el-dialog :title="title" v-model="dialogVisible" :before-close="cancel" width="90%">
<el-dialog :title="title" v-model="dialogVisible" :before-close="cancel" width="70%" :close-on-press-escape="false" :close-on-click-modal="false">
<el-form label-position="left" ref="formRef" :model="tableData" label-width="80px">
<el-row>
<el-col :span="12">
@@ -18,9 +18,15 @@
<el-tabs v-model="activeName">
<el-tab-pane label="字段" name="1">
<el-table :data="tableData.fields.res" :max-height="tableData.height">
<el-table-column :prop="item.prop" :label="item.label" v-for="item in tableData.fields.colNames" :key="item.prop">
<el-table-column
:prop="item.prop"
:label="item.label"
v-for="item in tableData.fields.colNames"
:key="item.prop"
:width="item.width"
>
<template #default="scope">
<el-input v-if="item.prop === 'name'" size="small" v-model="scope.row.name"> </el-input>
<el-input v-if="item.prop === 'name'" size="small" v-model="scope.row.name" />
<el-select v-else-if="item.prop === 'type'" filterable size="small" v-model="scope.row.type">
<el-option
@@ -36,35 +42,30 @@
</el-option>
</el-select>
<el-input v-else-if="item.prop === 'value'" size="small" v-model="scope.row.value"> </el-input>
<el-input v-else-if="item.prop === 'value'" size="small" v-model="scope.row.value" />
<el-input v-else-if="item.prop === 'length'" size="small" v-model="scope.row.length"> </el-input>
<el-input v-else-if="item.prop === 'length'" type="number" size="small" v-model.number="scope.row.length" />
<el-input v-else-if="item.prop === 'numScale'" size="small" v-model="scope.row.numScale"> </el-input>
<el-input v-else-if="item.prop === 'numScale'" type="number" size="small" v-model.number="scope.row.numScale" />
<el-checkbox v-else-if="item.prop === 'notNull'" size="small" v-model="scope.row.notNull"> </el-checkbox>
<el-checkbox v-else-if="item.prop === 'notNull'" size="small" v-model="scope.row.notNull" />
<el-checkbox v-else-if="item.prop === 'pri'" size="small" v-model="scope.row.pri"> </el-checkbox>
<el-checkbox v-else-if="item.prop === 'pri'" size="small" v-model="scope.row.pri" />
<el-checkbox
v-else-if="item.prop === 'auto_increment'"
size="small"
v-model="scope.row.auto_increment"
:disabled="dbType === DbType.postgresql"
>
</el-checkbox>
/>
<el-input v-else-if="item.prop === 'remark'" size="small" v-model="scope.row.remark"> </el-input>
<el-input v-else-if="item.prop === 'remark'" size="small" v-model="scope.row.remark" />
<el-link
v-else-if="item.prop === 'action'"
type="danger"
plain
size="small"
:underline="false"
@click.prevent="deleteRow(scope.$index)"
>删除</el-link
>
<el-popconfirm v-else-if="item.prop === 'action'" title="确定删除?" @confirm="deleteRow(scope.$index)">
<template #reference>
<el-link type="danger" plain size="small" :underline="false">删除</el-link>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
@@ -104,15 +105,11 @@
<el-input v-if="item.prop === 'indexComment'" size="small" v-model="scope.row.indexComment"> </el-input>
<el-link
v-if="item.prop === 'action'"
type="danger"
plain
size="small"
:underline="false"
@click.prevent="deleteIndex(scope.$index)"
>删除</el-link
>
<el-popconfirm v-else-if="item.prop === 'action'" title="确定删除?" @confirm="deleteIndex(scope.$index)">
<template #reference>
<el-link type="danger" plain size="small" :underline="false">删除</el-link>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
@@ -124,6 +121,7 @@
</el-tabs>
</el-form>
<template #footer>
<el-button @click="cancel()">取消</el-button>
<el-button :loading="btnloading" @click="submit()" type="primary">保存</el-button>
</template>
</el-dialog>
@@ -134,7 +132,7 @@
import { reactive, ref, toRefs, watch } from 'vue';
import { ElMessage } from 'element-plus';
import SqlExecBox from '../sqleditor/SqlExecBox';
import { getDbDialect, DbType, RowDefinition, IndexDefinition } from '../../dialect/index';
import { DbType, getDbDialect, IndexDefinition, RowDefinition } from '../../dialect/index';
const props = defineProps({
visible: {
@@ -162,6 +160,12 @@ const emit = defineEmits(['update:visible', 'cancel', 'val-change', 'submit-sql'
const dbDialect = getDbDialect(props.dbType);
type ColName = {
prop: string;
label: string;
width?: number;
};
const formRef: any = ref();
const state = reactive({
dialogVisible: false,
@@ -175,35 +179,43 @@ const state = reactive({
{
prop: 'name',
label: '字段名称',
width: 200,
},
{
prop: 'type',
label: '字段类型',
width: 120,
},
{
prop: 'length',
label: '长度',
width: 120,
},
{
prop: 'numScale',
label: '小数点',
width: 120,
},
{
prop: 'value',
label: '默认值',
width: 120,
},
{
prop: 'notNull',
label: '非空',
width: 60,
},
{
prop: 'pri',
label: '主键',
width: 60,
},
{
prop: 'auto_increment',
label: '自增',
width: 60,
},
{
prop: 'remark',
@@ -212,9 +224,11 @@ const state = reactive({
{
prop: 'action',
label: '操作',
width: 70,
},
],
] as ColName[],
res: [] as RowDefinition[],
oldFields: [] as RowDefinition[],
},
indexs: {
colNames: [
@@ -245,10 +259,12 @@ const state = reactive({
],
columns: [{ name: '', remark: '' }],
res: [] as IndexDefinition[],
oldIndexs: [] as IndexDefinition[],
},
tableName: '',
tableComment: '',
height: 450,
db: '',
},
});
@@ -343,7 +359,10 @@ const filterChangedData = (oldArr: object[], nowArr: object[], key: string): { d
nowArr.forEach((a) => {
let k = a[key];
newMap[k] = a;
if (!oldMap.hasOwnProperty(k)) {
// 取oldName因为修改了name但是oldName不会变
let oldName = a['oldName'];
oldName && (newMap[oldName] = a);
if (!oldMap.hasOwnProperty(k) && (!oldName || (oldName && !oldMap.hasOwnProperty(oldName)))) {
// 新增
data.add.push(a);
}
@@ -360,7 +379,7 @@ const filterChangedData = (oldArr: object[], nowArr: object[], key: string): { d
for (let f in a) {
let oldV = a[f];
let newV = newData[f];
if (oldV.toString() !== newV.toString()) {
if (oldV?.toString() !== newV?.toString()) {
data.upd.push(newData);
break;
}
@@ -383,11 +402,11 @@ const genSql = () => {
// 修改
if (state.activeName === '1') {
// 修改列
let changeData = filterChangedData(oldData.fields, state.tableData.fields.res, 'name');
return dbDialect.getModifyColumnSql(data.tableName, changeData);
let changeData = filterChangedData(state.tableData.fields.oldFields, state.tableData.fields.res, 'name');
return dbDialect.getModifyColumnSql(data, data.tableName, changeData);
} else if (state.activeName === '2') {
// 修改索引
let changeData = filterChangedData(oldData.indexs, state.tableData.indexs.res, 'indexName');
let changeData = filterChangedData(state.tableData.indexs.oldIndexs, state.tableData.indexs.res, 'indexName');
return dbDialect.getModifyIndexSql(data.tableName, changeData);
}
}
@@ -440,7 +459,6 @@ const indexChanges = (row: any) => {
row.indexComment = `${tableData.value.tableName}表(${name.replaceAll('_', ',')})${commentSuffix}`;
};
const oldData = { indexs: [] as any[], fields: [] as RowDefinition[] };
watch(
() => props.data,
(newValue: any) => {
@@ -448,9 +466,10 @@ watch(
// 回显表名表注释
state.tableData.tableName = row.tableName;
state.tableData.tableComment = row.tableComment;
state.tableData.db = props.db!;
// 回显列
if (columns && Array.isArray(columns) && columns.length > 0) {
oldData.fields = [];
state.tableData.fields.oldFields = [];
state.tableData.fields.res = [];
// 索引列下拉选
state.tableData.indexs.columns = [];
@@ -458,10 +477,17 @@ watch(
let typeObj = a.columnType.replace(')', '').split('(');
let type = typeObj[0];
let length = (typeObj.length > 1 && typeObj[1]) || '';
let defaultValue = '';
if (a.columnDefault) {
defaultValue = a.columnDefault.trim().replace(/^'|'$/g, '');
// 解决高斯的默认值问题
defaultValue = defaultValue.replace("'::character varying", '');
}
let data = {
name: a.columnName,
oldName: a.columnName,
type,
value: a.columnDefault || '',
value: defaultValue,
length,
numScale: a.numScale,
notNull: a.nullable !== 'YES',
@@ -470,14 +496,14 @@ watch(
remark: a.columnComment,
};
state.tableData.fields.res.push(data);
oldData.fields.push(JSON.parse(JSON.stringify(data)));
state.tableData.fields.oldFields.push(JSON.parse(JSON.stringify(data)));
// 索引字段下拉选项
state.tableData.indexs.columns.push({ name: a.columnName, remark: a.columnComment });
});
}
// 回显索引
if (indexs && Array.isArray(indexs) && indexs.length > 0) {
oldData.indexs = [];
state.tableData.indexs.oldIndexs = [];
state.tableData.indexs.res = [];
// 索引过滤掉主键
indexs
@@ -491,7 +517,7 @@ watch(
indexComment: a.indexComment,
};
state.tableData.indexs.res.push(data);
oldData.indexs.push(JSON.parse(JSON.stringify(data)));
state.tableData.indexs.oldIndexs.push(JSON.parse(JSON.stringify(data)));
});
}
}

View File

@@ -63,7 +63,7 @@
{{ formatByteSize(scope.row.indexLength) }}
</template>
</el-table-column>
<el-table-column v-if="dbType === DbType.mysql" property="createTime" label="创建时间" min-width="150"> </el-table-column>
<el-table-column v-if="compatibleMysql(dbType)" property="createTime" label="创建时间" min-width="150"> </el-table-column>
<el-table-column label="更多信息" min-width="160">
<template #default="scope">
<el-link @click.prevent="showColumns(scope.row)" type="primary">字段</el-link>
@@ -127,7 +127,7 @@ import SqlExecBox from '../sqleditor/SqlExecBox';
import config from '@/common/config';
import { joinClientParams } from '@/common/request';
import { isTrue } from '@/common/assert';
import { DbType } from '../../dialect/index';
import { compatibleMysql, DbType } from '../../dialect/index';
const DbTableOp = defineAsyncComponent(() => import('./DbTableOp.vue'));
@@ -181,7 +181,7 @@ const state = reactive({
visible: false,
activeName: '1',
type: '',
enableEditTypes: [DbType.mysql, DbType.postgresql, DbType.dm], // 支持"编辑表"的数据库类型
enableEditTypes: [DbType.mysql, DbType.mariadb, DbType.postgresql, DbType.dm, DbType.oracle, DbType.sqlite], // 支持"编辑表"的数据库类型
data: {
// 修改表时,传递修改数据
edit: false,
@@ -321,7 +321,7 @@ const dropTable = async (row: any) => {
dbId: props.dbId as any,
db: props.db as any,
runSuccessCallback: async () => {
state.tables = await dbApi.tableInfos.request({ id: props.dbId, db: props.db });
await getTables();
},
});
} catch (err) {
@@ -357,8 +357,7 @@ const openEditTable = async (row: any) => {
const onSubmitSql = async (row: { tableName: string }) => {
await openEditTable(row);
state.tableCreateDialog.visible = false;
state.tables = await dbApi.tableInfos.request({ id: props.dbId, db: props.db });
await getTables();
};
</script>
<style lang="scss"></style>

View File

@@ -6,7 +6,7 @@ import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { editor, languages, Position } from 'monaco-editor';
import { registerCompletionItemProvider } from '@/components/monaco/completionItemProvider';
import { EditorCompletionItem, getDbDialect } from './dialect';
import { DbDialect, EditorCompletionItem, getDbDialect } from './dialect';
const dbInstCache: Map<number, DbInst> = new Map();
@@ -91,7 +91,7 @@ export class DbInst {
return tables;
}
async loadTableSuggestions(dbName: string, range: any, reload?: boolean) {
async loadTableSuggestions(dbDialect: DbDialect, dbName: string, range: any, reload?: boolean) {
const tables = await this.loadTables(dbName, reload);
// 表名联想
let suggestions: languages.CompletionItem[] = [];
@@ -104,7 +104,7 @@ export class DbInst {
},
kind: monaco.languages.CompletionItemKind.File,
detail: tableComment,
insertText: tableName + ' ',
insertText: dbDialect.quoteIdentifier(tableName) + ' ',
range,
sortText: 300 + index + '',
});
@@ -113,7 +113,7 @@ export class DbInst {
}
/** 加载列信息提示 */
async loadTableColumnSuggestions(db: string, tableName: string, range: any) {
async loadTableColumnSuggestions(dbDialect: DbDialect, db: string, tableName: string, range: any) {
let dbHits = await this.loadDbHints(db);
let columns = dbHits[tableName];
let suggestions: languages.CompletionItem[] = [];
@@ -128,7 +128,7 @@ export class DbInst {
},
kind: monaco.languages.CompletionItemKind.Property,
detail: '', // 不显示detail, 否则选中时备注等会被遮挡
insertText: fieldName, // create_time
insertText: dbDialect.quoteIdentifier(fieldName) + ' ', // create_time
range,
sortText: 100 + index + '', // 使用表字段声明顺序排序,排序需为字符串类型
});
@@ -139,6 +139,7 @@ export class DbInst {
/**
* 获取表的所有列信息
* @param dbName 数据库名
* @param table 表名
*/
async loadColumns(dbName: string, table: string) {
@@ -220,7 +221,7 @@ export class DbInst {
* @returns count sql
*/
getDefaultCountSql = (table: string, condition?: string) => {
return `SELECT COUNT(*) count FROM ${this.wrapName(table)} ${condition ? 'WHERE ' + condition : ''} limit 1`;
return `SELECT COUNT(*) count FROM ${this.wrapName(table)} ${condition ? 'WHERE ' + condition : ''}`;
};
// 获取指定表的默认查询sql
@@ -286,7 +287,7 @@ export class DbInst {
* @returns
*/
wrapName = (name: string) => {
return getDbDialect(this.type).wrapName(name);
return getDbDialect(this.type).quoteIdentifier(name);
};
/**
@@ -357,11 +358,14 @@ export class DbInst {
/**
* 根据字段类型包装字段值,如为字符串等则添加‘’,数字类型则直接返回即可
*/
static wrapColumnValue(columnType: string, value: any) {
static wrapColumnValue(columnType: string, value: any, dbDialect?: DbDialect) {
if (this.isNumber(columnType)) {
return value;
}
return `'${value}'`;
if (!dbDialect) {
return `${value}`;
}
return dbDialect.wrapStrValue(columnType, value);
}
/**
@@ -370,7 +374,7 @@ export class DbInst {
* @returns
*/
static isNumber(columnType: string) {
return columnType.match(/int|double|float|nubmer|decimal|byte|bit/gi);
return columnType.match(/int|double|float|number|decimal|byte|bit/gi);
}
/**
@@ -385,8 +389,8 @@ export class DbInst {
return;
}
// 获取列名称的长度 加上排序图标长度
const columnWidth: number = getTextWidth(prop) + 23;
// 获取列名称的长度 加上排序图标长度、abc为字段类型简称占位符
const columnWidth: number = getTextWidth(prop + 'abc') + 23;
// prop为该列的字段名(传字符串);tableData为该表格的数据源(传变量);
if (!tableData || !tableData.length || tableData.length === 0 || tableData === undefined) {
return columnWidth;
@@ -617,7 +621,7 @@ export function registerDbCompletionItemProvider(dbId: number, db: string, dbs:
description: 'schema',
},
kind: monaco.languages.CompletionItemKind.Folder,
insertText: a,
insertText: dbDialect.quoteIdentifier(a),
range,
});
});
@@ -650,20 +654,20 @@ export function registerDbCompletionItemProvider(dbId: number, db: string, dbs:
if (db.indexOf('/') > 0) {
dbName = db.substring(0, db.indexOf('/') + 1) + alias;
}
return await dbInst.loadTableSuggestions(dbName, range);
return await dbInst.loadTableSuggestions(dbDialect, dbName, range);
}
// 表下列名联想 .前的字符串是表名或表别名
const sqlInfo = getTableName4SqlCtx(sqlStatement, alias, db);
// 提出到表名,则将表对应的字段也添加进提示建议
if (sqlInfo) {
return await dbInst.loadTableColumnSuggestions(sqlInfo.db, sqlInfo.tableName, range);
return await dbInst.loadTableColumnSuggestions(dbDialect, sqlInfo.db, sqlInfo.tableName, range);
}
}
// 空格触发也会提示字段信息
const sqlInfo = getTableName4SqlCtx(sqlStatement, alias, db);
if (sqlInfo) {
const columnSuggestions = await dbInst.loadTableColumnSuggestions(sqlInfo.db, sqlInfo.tableName, range);
const columnSuggestions = await dbInst.loadTableColumnSuggestions(dbDialect, sqlInfo.db, sqlInfo.tableName, range);
suggestions.push(...columnSuggestions.suggestions);
}
@@ -678,7 +682,7 @@ export function registerDbCompletionItemProvider(dbId: number, db: string, dbs:
},
kind: monaco.languages.CompletionItemKind.File,
detail: tableComment,
insertText: tableName + ' ',
insertText: dbDialect.quoteIdentifier(tableName) + ' ',
range,
sortText: 300 + index + '',
});

View File

@@ -1,5 +1,17 @@
import { DbDialect, sqlColumnType, DialectInfo, RowDefinition, IndexDefinition, EditorCompletionItem, commonCustomKeywords, EditorCompletion } from './index';
import { DbInst } from '../db';
import {
commonCustomKeywords,
DataType,
DbDialect,
DialectInfo,
EditorCompletion,
EditorCompletionItem,
IndexDefinition,
RowDefinition,
sqlColumnType,
} from './index';
import { language as sqlLanguage } from 'monaco-editor/esm/vs/basic-languages/sql/sql.js';
export { DMDialect, DM_TYPE_LIST };
// 参考文档:https://eco.dameng.com/document/dm/zh-cn/sql-dev/dmpl-sql-datatype.html#%E5%AD%97%E7%AC%A6%E6%95%B0%E6%8D%AE%E7%B1%BB%E5%9E%8B
@@ -42,6 +54,7 @@ const DM_TYPE_LIST: sqlColumnType[] = [
{ udtName: 'BFILE', dataType: 'BFILE', desc: '二进制文件', space: '', range: '100G-1' },
];
// 参考官方文档https://eco.dameng.com/document/dm/zh-cn/pm/function.html
const replaceFunctions: EditorCompletionItem[] = [
// 数值函数
{ label: 'ABS', insertText: 'ABS(n)', description: '求数值 n 的绝对值' },
@@ -355,7 +368,7 @@ class DMDialect implements DbDialect {
dmDialectInfo = {
icon: 'iconfont icon-db-dm',
defaultPort: 5236,
formatSqlDialect: 'postgresql',
formatSqlDialect: 'plsql',
columnTypes: DM_TYPE_LIST.sort((a, b) => a.udtName.localeCompare(b.udtName)),
editorCompletions,
};
@@ -363,9 +376,11 @@ class DMDialect implements DbDialect {
}
getDefaultSelectSql(table: string, condition: string, orderBy: string, pageNum: number, limit: number) {
return `SELECT * FROM ${this.wrapName(table)} ${condition ? 'WHERE ' + condition : ''} ${orderBy ? orderBy : ''} OFFSET ${
(pageNum - 1) * limit
} LIMIT ${limit};`;
return `SELECT * FROM "${table}" ${condition ? 'WHERE ' + condition : ''} ${orderBy ? orderBy : ''} ${this.getPageSql(pageNum, limit)};`;
}
getPageSql(pageNum: number, limit: number) {
return ` OFFSET ${(pageNum - 1) * limit} LIMIT ${limit};`;
}
getDefaultRows(): RowDefinition[] {
@@ -429,8 +444,9 @@ class DMDialect implements DbDialect {
indexComment: '',
};
}
wrapName = (name: string) => {
return name;
quoteIdentifier = (name: string) => {
return `"${name}"`;
};
matchType(text: string, arr: string[]): boolean {
@@ -485,7 +501,9 @@ class DMDialect implements DbDialect {
// 默认值
let defVal = this.getDefaultValueSql(cl);
let incr = cl.auto_increment ? 'IDENTITY' : '';
return ` ${cl.name} ${cl.type}${length} ${incr} ${cl.notNull ? 'NOT NULL' : ''} ${defVal} `;
// 如果有原名以原名为准
let name = cl.oldName && cl.name !== cl.oldName ? cl.oldName : cl.name;
return ` ${this.quoteIdentifier(name)} ${cl.type}${length} ${incr} ${cl.notNull ? 'NOT NULL' : ''} ${defVal} `;
}
getCreateTableSql(data: any): string {
@@ -503,18 +521,18 @@ class DMDialect implements DbDialect {
}
// 列注释
if (item.remark) {
columCommentSql += ` comment on column ${data.tableName}.${item.name} is '${item.remark}'; `;
columCommentSql += ` comment on column "${data.tableName}"."${item.name}" is '${item.remark}'; `;
}
});
// 建表
createSql = `CREATE TABLE ${data.tableName}
createSql = `CREATE TABLE "${data.tableName}"
(
${fields.join(',')}
${pks ? `, PRIMARY KEY (${pks.join(',')})` : ''}
);`;
// 表注释
if (data.tableComment) {
tableCommentSql = ` comment on table ${data.tableName} is '${data.tableComment}'; `;
tableCommentSql = ` comment on table "${data.tableName}" is '${data.tableComment}'; `;
}
return createSql + tableCommentSql + columCommentSql;
@@ -526,37 +544,80 @@ class DMDialect implements DbDialect {
// 创建索引
let sql: string[] = [];
tableData.indexs.res.forEach((a: any) => {
sql.push(` CREATE ${a.unique ? 'UNIQUE' : ''} INDEX ${a.indexName} USING btree ("${a.columnNames.join('","')})"`);
sql.push(` CREATE ${a.unique ? 'UNIQUE' : ''} INDEX ${a.indexName} ON "${tableData.tableName}" ("${a.columnNames.join('","')})"`);
});
return sql.join(';');
}
getModifyColumnSql(tableName: string, changeData: { del: RowDefinition[]; add: RowDefinition[]; upd: RowDefinition[] }): string {
let sql: string[] = [];
getModifyColumnSql(tableData: any, tableName: string, changeData: { del: RowDefinition[]; add: RowDefinition[]; upd: RowDefinition[] }): string {
let schemaArr = tableData.db.split('/');
let schema = schemaArr.length > 1 ? schemaArr[schemaArr.length - 1] : schemaArr[0];
let dbTable = `${this.quoteIdentifier(schema)}.${this.quoteIdentifier(tableName)}`;
let modifySql = '';
let dropSql = '';
let renameSql = '';
let commentSql = '';
// 主键字段
let priArr = new Set();
if (changeData.add.length > 0) {
changeData.add.forEach((a) => {
sql.push(`ALTER TABLE ${tableName} add COLUMN ${this.genColumnBasicSql(a)}`);
modifySql += `ALTER TABLE ${dbTable} add COLUMN ${this.genColumnBasicSql(a)};`;
if (a.remark) {
sql.push(`comment on COLUMN "${tableName}"."${a.name}" is '${a.remark}'`);
commentSql += `COMMENT ON COLUMN ${dbTable}.${this.quoteIdentifier(a.name)} IS '${a.remark}';`;
}
if (a.pri) {
priArr.add(`"${a.name}"`);
}
});
}
if (changeData.upd.length > 0) {
changeData.upd.forEach((a) => {
sql.push(`ALTER TABLE ${tableName} MODIFY ${this.genColumnBasicSql(a)}`);
if (a.remark) {
sql.push(`comment on COLUMN "${tableName}"."${a.name}" is '${a.remark}'`);
let cmtSql = `COMMENT ON COLUMN ${dbTable}.${this.quoteIdentifier(a.name)} IS '${a.remark}';`;
if (a.remark && a.oldName === a.name) {
commentSql += cmtSql;
}
// 修改了字段名
if (a.oldName !== a.name) {
renameSql += `ALTER TABLE ${dbTable} RENAME COLUMN ${this.quoteIdentifier(a.oldName!)} TO ${this.quoteIdentifier(a.name)};`;
if (a.remark) {
commentSql += cmtSql;
}
}
modifySql += `ALTER TABLE ${dbTable} MODIFY ${this.genColumnBasicSql(a)};`;
if (a.pri) {
priArr.add(`${this.quoteIdentifier(a.name)}`);
}
});
}
if (changeData.del.length > 0) {
changeData.del.forEach((a) => {
sql.push(`ALTER TABLE ${tableName} DROP COLUMN ${a.name}`);
dropSql += `ALTER TABLE ${dbTable} DROP COLUMN ${a.name};`;
});
}
return sql.join(';');
// 编辑主键
let dropPkSql = '';
if (priArr.size > 0) {
let resPri = tableData.fields.res.filter((a: RowDefinition) => a.pri);
if (resPri) {
priArr.add(`${this.quoteIdentifier(resPri.name)}`);
}
// 如果有编辑主键字段,则删除主键,再添加主键
// 解析表字段中是否含有主键,有的话就删除主键
if (tableData.fields.oldFields.find((a: RowDefinition) => a.pri)) {
dropPkSql = `ALTER TABLE ${dbTable} DROP PRIMARY KEY;`;
}
}
let addPkSql = priArr.size > 0 ? `ALTER TABLE ${dbTable} ADD PRIMARY KEY (${Array.from(priArr).join(',')});` : '';
return dropPkSql + modifySql + dropSql + renameSql + addPkSql + commentSql;
}
getModifyIndexSql(tableName: string, changeData: { del: any[]; add: any[]; upd: any[] }): string {
@@ -593,14 +654,35 @@ class DMDialect implements DbDialect {
if (addIndexs.length > 0) {
addIndexs.forEach((a) => {
sql.push(`CREATE ${a.unique ? 'UNIQUE' : ''} INDEX ${a.indexName}(${a.columnNames.join(',')})`);
if (a.indexComment) {
sql.push(`COMMENT ON INDEX ${a.indexName} IS '${a.indexComment}'`);
}
sql.push(`CREATE ${a.unique ? 'UNIQUE' : ''} INDEX ${a.indexName} ON "${tableName}" (${a.columnNames.join(',')})`);
});
}
return sql.join(';');
}
return '';
}
getDataType(columnType: string): DataType {
if (DbInst.isNumber(columnType)) {
return DataType.Number;
}
// 日期时间类型
if (/datetime|timestamp/gi.test(columnType)) {
return DataType.DateTime;
}
// 日期类型
if (/date/gi.test(columnType)) {
return DataType.Date;
}
// 时间类型
if (/time/gi.test(columnType)) {
return DataType.Time;
}
return DataType.String;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars,no-unused-vars
wrapStrValue(columnType: string, value: string): string {
return `'${value}'`;
}
}

View File

@@ -1,7 +1,9 @@
import { MysqlDialect } from './mysql_dialect';
import { PostgresqlDialect } from './postgres_dialect';
import { DMDialect } from '@/views/ops/db/dialect/dm_dialect';
import { SqlLanguage } from 'sql-formatter/lib/src/sqlFormatter';
import { OracleDialect } from '@/views/ops/db/dialect/oracle_dialect';
import { MariadbDialect } from '@/views/ops/db/dialect/mariadb_dialect';
import { SqliteDialect } from '@/views/ops/db/dialect/sqlite_dialect';
export interface sqlColumnType {
udtName: string;
@@ -13,6 +15,7 @@ export interface sqlColumnType {
export interface RowDefinition {
name: string;
oldName?: string;
type: string;
value: string;
length: string;
@@ -52,6 +55,29 @@ export interface EditorCompletion {
variables: EditorCompletionItem[];
}
// 定义一个数据类型的枚举,包含字符串、数字、日期、时间、日期时间
export enum DataType {
String = 'string',
Number = 'number',
Date = 'date',
Time = 'time',
DateTime = 'datetime',
}
/** 列数据类型角标 */
export const ColumnTypeSubscript = {
/** 字符串 */
string: 'abc',
/** 数字 */
number: '123',
/** 日期 */
date: 'icon-clock',
/** 时间 */
time: 'icon-clock',
/** 日期时间 */
datetime: 'icon-clock',
};
// 数据库基础信息
export interface DialectInfo {
/**
@@ -67,7 +93,7 @@ export interface DialectInfo {
/**
* 格式化sql的方言
*/
formatSqlDialect: SqlLanguage;
formatSqlDialect: string;
/**
* 列字段类型
@@ -82,8 +108,21 @@ export interface DialectInfo {
export const DbType = {
mysql: 'mysql',
mariadb: 'mariadb',
postgresql: 'postgres',
dm: 'dm', // 达梦
oracle: 'oracle',
sqlite: 'sqlite',
};
export const compatibleMysql = (dbType: string): boolean => {
switch (dbType) {
case DbType.mysql:
case DbType.mariadb:
return true;
default:
return false;
}
};
export interface DbDialect {
@@ -102,15 +141,17 @@ export interface DbDialect {
*/
getDefaultSelectSql(table: string, condition: string, orderBy: string, pageNum: number, limit: number): string;
getPageSql(pageNum: number, limit: number): string;
getDefaultRows(): RowDefinition[];
getDefaultIndex(): IndexDefinition;
/**
* 包裹数据库表名、字段名等,避免使用关键字为字段名或表名时报错
* 引用标识符,包裹数据库表名、字段名等,避免使用关键字为字段名或表名时报错
* @param name 名称
*/
wrapName(name: string): string;
quoteIdentifier(name: string): string;
/**
* 生成创建表sql
@@ -126,10 +167,11 @@ export interface DbDialect {
/**
* 生成编辑列sql
* @param tableData 表数据,包含表名、列数据、索引数据
* @param tableName 表名
* @param changeData 改变信息
*/
getModifyColumnSql(tableName: string, changeData: { del: RowDefinition[]; add: RowDefinition[]; upd: RowDefinition[] }): string;
getModifyColumnSql(tableData: any, tableName: string, changeData: { del: RowDefinition[]; add: RowDefinition[]; upd: RowDefinition[] }): string;
/**
* 生成编辑索引sql
@@ -137,24 +179,39 @@ export interface DbDialect {
* @param changeData 改变数据
*/
getModifyIndexSql(tableName: string, changeData: { del: any[]; add: any[]; upd: any[] }): string;
/** 通过数据库字段类型,返回基本数据类型 */
getDataType(columnType: string): DataType;
/** 包装字符串数据, 如oracle需要把date类型改为 to_date(str, 'yyyy-mm-dd hh24:mi:ss') */
wrapStrValue(columnType: string, value: string): string;
}
let mysqlDialect = new MysqlDialect();
let mariadbDialect = new MariadbDialect();
let postgresDialect = new PostgresqlDialect();
let dmDialect = new DMDialect();
let oracleDialect = new OracleDialect();
let sqliteDialect = new SqliteDialect();
export const getDbDialect = (dbType: string | undefined): DbDialect => {
if (!dbType) {
return mysqlDialect;
}
if (dbType === DbType.mysql) {
return mysqlDialect;
switch (dbType) {
case DbType.mysql:
return mysqlDialect;
case DbType.mariadb:
return mariadbDialect;
case DbType.postgresql:
return postgresDialect;
case DbType.dm:
return dmDialect;
case DbType.oracle:
return oracleDialect;
case DbType.sqlite:
return sqliteDialect;
default:
throw new Error('不支持的数据库');
}
if (dbType === DbType.postgresql) {
return postgresDialect;
}
if (dbType === DbType.dm) {
return dmDialect;
}
throw new Error('不支持的数据库');
};

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