Compare commits

..

8 Commits
v1.11.3 ... dev

Author SHA1 Message Date
meilin.huang
db8f30f27f fix: sql脚本点击操作tab展示问题 2026-06-03 18:28:36 +08:00
meilin.huang
434c1fdfb3 refactor: 资源操作tab优化 2026-06-02 19:00:32 +08:00
zongyangleo
96ef4d2d6f !157 refactor: kafka操作优化
* feat: es新增导出功能
* refactor: kafka操作优化
2026-06-02 10:30:29 +00:00
meilin.huang
fab45f0823 refactor: 资源操作tab优化&修复tab点击定位树节点 2026-06-01 19:23:49 +08:00
meilin.huang
44b5f6ebfd refactor: 资源操作tab优化 2026-06-01 13:17:12 +08:00
zongyangleo
f234aff250 !156 各资源打开标签页重构
* refactor: 各资源打开标签页重构
2026-05-31 05:41:42 +00:00
saa99999
a17fa5a103 Fix CWE-347: JWT algorithm confusion + CWE-798: hardcoded credentials in example config (#131)
- Add HMAC algorithm verification in ParseToken to prevent JWT algorithm
  confusion attacks (CWE-347). Reject tokens with non-HMAC signing methods.
- Replace hardcoded secrets in config.yml.example with empty values
  (JWT key, DB password, AES key) to prevent users from deploying with
  weak/known credentials (CWE-798).
2026-05-27 19:10:12 +08:00
meilin.huang
519089d8d0 feat: sql脚本执行支持zip,统一读取body流,去除资源tagpath条件搜索 2026-05-26 19:31:05 +08:00
136 changed files with 5192 additions and 3169 deletions

View File

@@ -19,17 +19,16 @@
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"asciinema-player": "^3.15.1",
"axios": "^1.16.0",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.20",
"echarts": "^6.0.0",
"element-plus": "^2.14.0",
"dayjs": "^1.11.21",
"echarts": "^6.1.0",
"element-plus": "^2.14.1",
"js-base64": "^3.7.8",
"jsencrypt": "^3.5.4",
"json-bigint": "^1.0.0",
"mermaid": "^11.15.0",
"monaco-editor": "^0.55.1",
"monaco-sql-languages": "^1.0.0",
"monaco-sql-languages": "^1.1.0",
"nprogress": "^0.2.0",
"pinia": "^3.0.4",
"qrcode.vue": "^3.9.1",
@@ -42,7 +41,7 @@
"vue": "3.6.0-beta.11",
"vue-element-plus-x": "^2.0.3",
"vue-i18n": "^11.4.4",
"vue-router": "^5.0.7",
"vue-router": "^5.1.0",
"vuedraggable": "^4.1.0",
"x-markdown-vue": "0.0.200",
"xlsx": "^0.18.5"
@@ -68,7 +67,7 @@
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.14",
"vite": "^8.0.16",
"vite-plugin-progress": "0.0.7",
"vue-eslint-parser": "^10.4.0"
},

View File

@@ -1,7 +1,7 @@
import { templateResolve } from '@/common/utils/string';
import { RequestOptions, useApiFetch } from '@/hooks/useRequest';
import config from './config';
import request, { joinClientParams } from './request';
import { getToken } from './utils/storage';
/**
* 文件上传选项
@@ -78,17 +78,6 @@ class Api<T = any, P = any> {
return (data.value as T) || (res as T);
}
/**
* xhr 请求对应的该api
* @param {Object} param 请求该api的参数
*/
async xhrReq(param: any = null, options: any = {}): Promise<T> {
if (this.beforeHandler) {
await this.beforeHandler(param);
}
return request.xhrReq(this.method, this.url, param, options);
}
/**
* 文件上传请求
* @param formData FormData 对象(调用方自行构建,包含文件和其他参数)
@@ -137,14 +126,44 @@ class Api<T = any, P = any> {
/**
* 原始文件流上传请求(直接使用文件流作为 body参数通过 URL query 传递)
* @param file 文件对象
* @param queryParams URL 查询参数字符串
* @param queryParams URL 查询参数对象(可选)
* @param options 上传选项(可包含自定义 headers
* @returns { abort: () => void } 返回中止方法
*/
uploadRaw(file: File, queryParams: string, options: UploadOptions & { headers?: Record<string, string> } = {}): { abort: () => void } {
uploadRaw(file: File, queryParams?: Record<string, string>, options: UploadOptions & { headers?: Record<string, string> } = {}): { abort: () => void } {
const { onSuccess, onError, headers = {} } = options;
const url = `${config.baseApiUrl}${this.url}?${queryParams}&${joinClientParams()}`;
// 构建 URL兼容没有 queryParams 的情况
let url = `${config.baseApiUrl}${this.url}`;
// 简单判断该url是否是restful风格
if (url.indexOf('{') != -1 && queryParams) {
url = templateResolve(url, queryParams);
}
const searchParams = new URLSearchParams();
// 添加业务参数
if (queryParams) {
Object.entries(queryParams).forEach(([key, value]) => {
searchParams.append(key, value);
});
}
// 添加客户端参数
const clientParams = joinClientParams();
if (clientParams) {
// 将 joinClientParams 返回的字符串追加到 searchParams
const clientParamsObj = new URLSearchParams(clientParams);
clientParamsObj.forEach((value, key) => {
searchParams.append(key, value);
});
}
// 拼接完整的 query string
const queryString = searchParams.toString();
if (queryString) {
url += `?${queryString}`;
}
// 创建 AbortController 用于取消请求
const abortController = new AbortController();

View File

@@ -1,20 +1,10 @@
import router from '../router';
import config from './config';
import { getClientId, getToken } from './utils/storage';
import { templateResolve } from './utils/string';
import { Msg } from '@/hooks/useI18n';
import axios from 'axios';
import JSONBig from 'json-bigint';
import { useApiFetch } from '../hooks/useRequest';
import Api from './Api';
// 配置 JSONBig将大数int64/uint64转为字符串避免精度丢失
// storeAsString: 将大数存储为字符串,而不是 BigNumber 对象
const JSONBigString = JSONBig({ storeAsString: true });
import config from './config';
import { getClientId, getToken } from './utils/storage';
export default {
request,
xhrReq,
get,
post,
put,
@@ -50,138 +40,6 @@ export const baseUrl: string = config.baseApiUrl;
// const baseUrl: string = 'http://localhost:18888/api';
// const baseWsUrl: string = config.baseWsUrl;
/**
* 通知错误消息
* @param msg 错误消息
*/
function notifyErrorMsg(msg: string) {
// 危险通知
Msg.error(msg);
}
// create an axios instance
const axiosInst = axios.create({
baseURL: baseUrl, // url = base url + request url
timeout: 60000, // request timeout
// 使用 json-bigint 处理响应数据,解决 int64/uint64 精度丢失问题
transformResponse: [
function (data) {
// 对响应数据进行转换
if (typeof data === 'string') {
try {
// 使用 JSONBigString 解析,大数会被转为字符串
return JSONBigString.parse(data);
} catch (err) {
// 如果解析失败,返回原始数据
return data;
}
}
return data;
},
],
});
// request interceptor
axiosInst.interceptors.request.use(
(config: any) => {
// do something before request is sent
const token = getToken();
if (token) {
// 设置token
config.headers['Authorization'] = token;
config.headers['ClientId'] = getClientId();
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// response interceptor
axiosInst.interceptors.response.use(
(response) => response,
(e: any) => {
const rejectPromise = Promise.reject(e);
if (axios.isCancel(e)) {
console.log('请求已取消');
return rejectPromise;
}
const statusCode = e.response?.status;
if (statusCode == 500) {
notifyErrorMsg('服务器未知异常');
return rejectPromise;
}
if (statusCode == 404) {
notifyErrorMsg('请求接口未找到');
return rejectPromise;
}
if (e.message) {
// 对响应错误做点什么
if (e.message.indexOf('timeout') != -1) {
notifyErrorMsg('网络请求超时');
return rejectPromise;
}
if (e.message == 'Network Error') {
notifyErrorMsg('网络连接错误');
return rejectPromise;
}
}
notifyErrorMsg('网络请求错误');
return rejectPromise;
}
);
/**
* xhr请求url
*
* @param method 请求方法
* @param url url
* @param params 参数
* @param options 可选
* @returns
*/
export function xhrReq(method: string, url: string, params: any = null, options: any = {}) {
if (!url) {
throw new Error('请求url不能为空');
}
// 简单判断该url是否是restful风格
if (url.indexOf('{') != -1) {
url = templateResolve(url, params);
}
const req: any = {
method,
url,
...options,
};
// post和put使用json格式传参
if (method === 'post' || method === 'put') {
req.data = params;
} else {
req.params = params;
}
return axiosInst
.request(req)
.then((response) => {
// 获取请求返回结果
const result: Result = response.data;
return parseResult(result);
})
.catch((e) => {
return Promise.reject(e);
});
}
/**
* fetch请求url
*
@@ -277,23 +135,3 @@ export function downloadFile(key: string) {
a.click();
a.remove();
}
function parseResult(result: Result) {
if (result.code === ResultEnum.SUCCESS) {
return result.data;
}
// 如果提示没有权限则移除token使其重新登录
if (result.code === ResultEnum.NO_PERMISSION) {
router.push({
path: '/401',
});
}
// 如果返回的code不为成功则会返回对应的错误msg则直接统一通知即可。忽略登录超时或没有权限的提示直接跳转至401页面
if (result.msg && result?.code != ResultEnum.NO_PERMISSION) {
notifyErrorMsg(result.msg);
}
return Promise.reject(result);
}

View File

@@ -21,6 +21,7 @@
<!-- Drawer 模式 -->
<el-drawer
:append-to-body="false"
:title="props.title"
v-model="dialogVisible"
:size="props.drawerSize || '50%'"

View File

@@ -418,19 +418,22 @@ const showContextMenu = (event: MouseEvent, selectedText: string) => {
.withHideFunc(() => !selectedText)
.withOnClick(() => {
downloadSelectedFile(state.contextmenu.selectedItem);
}),
})
.withPermission('machine:file:upload'),
new ContextmenuItem('uploadFile', 'components.terminal.uploadFileToCurrentDir')
.withIcon('Upload')
.withHideFunc(() => !props.machineId || !props.authCertName)
.withOnClick(() => {
triggerFilesUpload();
}),
})
.withPermission('machine:file:upload'),
new ContextmenuItem('uploadFolder', 'components.terminal.uploadFolderToCurrentDir')
.withIcon('Upload')
.withHideFunc(() => !props.machineId || !props.authCertName)
.withOnClick(() => {
triggerFolderUpload();
}),
})
.withPermission('machine:file:upload'),
];
// 打开右键菜单

View File

@@ -1,6 +1,6 @@
<template>
<div>
<el-drawer v-model="visible" :before-close="cancel" size="50%" body-class="flex flex-col">
<el-drawer :append-to-body="false" v-model="visible" :before-close="cancel" size="50%" body-class="flex flex-col">
<template #header>
<DrawerHeader :header="props.title" :back="cancel">
<template #extra>

View File

@@ -119,6 +119,8 @@ export default {
close: 'Close',
closeOther: 'Close Other',
closeAll: 'Close All',
closeLeft: 'Close Left',
closeRight: 'Close Right',
fullscreen: 'Fullscreen',
closeFullscreen: 'Close Fullscreen',
},

View File

@@ -38,6 +38,7 @@ export default {
stopImageConfirm: 'Are you sure to stop image [{name}] ?',
export: 'Export',
imageUploading: 'Image uploading, please wait...',
uploadSuccess: 'Image uploaded successfully',
imageTips: 'Support manual input and select',
forcePull: 'Force Pull Image',
hostPortPlaceholder: '80',

View File

@@ -29,6 +29,8 @@ export default {
indexStats: 'Stats',
opViewColumns: 'Option View Columns',
opIndex: 'Index Management',
opDataManage: 'Data Management',
selectIndexFirst: 'Please select an index first',
opSearch: 'Search',
searchParamsPreview: 'Search Params Preview',
opBasicSearch: 'Basic Search',
@@ -98,6 +100,31 @@ export default {
text: 'Text',
startAnalyze: 'Start Analyze',
},
export: {
title: 'Export Data',
selectedCount: '{count} rows selected',
exportAll: 'Export All Data',
exportSelected: 'Export Selected Data',
exportQuery: 'Export Query Results',
exportType: 'Export Type',
csv: 'CSV File',
excel: 'Excel File',
json: 'JSON File',
confirm: 'Confirm Export',
exporting: 'Exporting...',
exportAllConfirm: 'Export all data from index [{name}] ({total} docs total). Continue?',
largeExportTip: 'Large dataset ({total} docs), will be exported via backend batch processing and compressed download',
selectAllFields: 'Select All Fields',
exportFields: 'Export Fields',
noData: 'No data to export',
phase: {
querying: 'Querying...',
exporting: 'Exporting...',
compressing: 'Compressing...',
completed: 'Completed',
unknown: 'Unknown',
},
},
contextmenu: {
index: {
addIndex: 'Add Index',

View File

@@ -83,6 +83,12 @@ export default {
searchGroup: 'Enter group name',
selectGroupPlaceholder: 'select group',
Members: 'Members',
groupMembers: 'Consumer Group Members',
clientHost: 'Client Host',
clientID: 'Client ID',
instanceID: 'Instance ID',
memberID: 'Member ID',
assignedTopics: 'Assigned Topic Partitions',
partitionsFeatureComingSoon: 'Partitions feature coming soon',
},
},

View File

@@ -16,9 +16,12 @@ export default {
rootTag: 'Root Tag',
selectTagPlaceholder: 'Select the associated tag',
machineOp: 'Machine Operation',
machineTerminal: 'Machine Terminal',
machineFile: 'Machine File',
dbDataOp: 'Db Operation',
redisDataOp: 'Redis Operation',
esDataOp: 'Es Operation',
esIndexData: 'ES Index Data',
mongoDataOp: 'Mongo Operation',
allResource: 'All Resource',
mq: {

View File

@@ -117,6 +117,8 @@ export default {
close: '关闭',
closeOther: '关闭其它',
closeAll: '全部关闭',
closeLeft: '关闭左侧',
closeRight: '关闭右侧',
fullscreen: '当前页全屏',
closeFullscreen: '关闭全屏',
},

View File

@@ -38,6 +38,7 @@ export default {
stopImageConfirm: '确定删除该镜像?',
export: '导出',
imageUploading: '镜像导入中,请稍后...',
uploadSuccess: '镜像导入成功',
imageTips: '支持手动输入并选择',
forcePull: '强制拉取镜像',
hostPortPlaceholder: '80',

View File

@@ -29,6 +29,8 @@ export default {
indexStats: '统计信息',
opViewColumns: '设置显示字段',
opIndex: '索引管理',
opDataManage: '数据管理',
selectIndexFirst: '请先选择索引',
opSearch: '搜索',
searchParamsPreview: '搜索条件预览',
opBasicSearch: '基础搜索',
@@ -97,6 +99,31 @@ export default {
text: '文本',
startAnalyze: '开始分析',
},
export: {
title: '导出数据',
selectedCount: '已选择 {count} 条数据',
exportAll: '导出所有数据',
exportSelected: '导出已选数据',
exportQuery: '导出查询结果',
exportType: '导出类型',
csv: 'CSV 文件',
excel: 'Excel 文件',
json: 'JSON 文件',
confirm: '确认导出',
exporting: '正在导出...',
exportAllConfirm: '将导出索引 [{name}] 的所有数据(共 {total} 条),确认继续吗?',
largeExportTip: '数据量较大(共 {total} 条),将通过后台分批查询并压缩后下载',
selectAllFields: '全选字段',
exportFields: '导出字段',
noData: '没有可导出的数据',
phase: {
querying: '正在查询...',
exporting: '正在导出数据...',
compressing: '正在压缩...',
completed: '导出完成',
unknown: '未知状态',
},
},
contextmenu: {
index: {
addIndex: '添加索引',

View File

@@ -83,6 +83,12 @@ export default {
searchGroup: '输入组名称',
selectGroupPlaceholder: '选择分组',
Members: '成员',
groupMembers: '消费者组成员',
clientHost: '客户端地址',
clientID: '客户端 ID',
instanceID: '实例 ID',
memberID: '成员 ID',
assignedTopics: '分配的 Topic 分区',
partitionsFeatureComingSoon: '分区详情功能即将上线',
},
},

View File

@@ -18,9 +18,12 @@ export default {
rootTag: '根标签',
selectTagPlaceholder: '请选择关联标签',
machineOp: '机器操作',
machineTerminal: '机器终端',
machineFile: '机器文件',
dbDataOp: '数据库操作',
redisDataOp: 'Redis操作',
esDataOp: 'ES操作',
esIndexData: 'ES索引数据',
mongoDataOp: 'Mongo操作',
containerOp: '容器操作',
allResource: '所有资源',

View File

@@ -580,19 +580,16 @@ const initSetLayoutChange = () => {
themeConfig.value.isShowLogo = true;
themeConfig.value.isBreadcrumb = false;
themeConfig.value.isCollapse = false;
themeConfig.value.isTagsview = true;
themeConfig.value.isClassicSplitMenu = false;
} else if (themeConfig.value.layout === 'columns') {
themeConfig.value.isShowLogo = true;
themeConfig.value.isBreadcrumb = true;
themeConfig.value.isCollapse = false;
themeConfig.value.isTagsview = true;
themeConfig.value.isClassicSplitMenu = false;
} else {
themeConfig.value.isShowLogo = false;
themeConfig.value.isBreadcrumb = true;
themeConfig.value.isCollapse = false;
themeConfig.value.isTagsview = true;
themeConfig.value.isClassicSplitMenu = false;
}

View File

@@ -1,8 +1,8 @@
import { defineStore } from 'pinia';
import { formatDate } from '@/common/utils/format';
import { useUserInfo } from '@/store/userInfo';
import { getServerConf, getSysStyleConfig } from '@/common/sysconfig';
import { formatDate } from '@/common/utils/format';
import { getLocal, getThemeConfig } from '@/common/utils/storage';
import { useUserInfo } from '@/store/userInfo';
import { defineStore } from 'pinia';
// 系统默认logo图标对应于@/assets/image/logo.svg
const logoIcon =

View File

@@ -1,5 +1,6 @@
<template>
<el-drawer
:append-to-body="false"
:title="title"
v-model="dialogVisible"
z-index="2000"

View File

@@ -1,6 +1,6 @@
<template>
<div>
<el-drawer :title="props.title" v-model="visible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="50%">
<el-drawer :append-to-body="false" :title="props.title" v-model="visible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="50%">
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>

View File

@@ -1,6 +1,6 @@
<template>
<div>
<el-drawer :title="title" v-model="visible" :before-close="onCancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<el-drawer :append-to-body="false" :title="title" v-model="visible" :before-close="onCancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<template #header>
<DrawerHeader :header="title" :back="onCancel" />
</template>

View File

@@ -1,5 +1,6 @@
<template>
<el-drawer
:append-to-body="false"
:title="props.title"
v-model="visible"
:before-close="cancel"

View File

@@ -1,5 +1,6 @@
<template>
<el-drawer
:append-to-body="false"
:title="title"
v-model="visible"
:before-close="cancel"

View File

@@ -1,5 +1,6 @@
<template>
<el-drawer
:append-to-body="false"
body-class="!pt-2"
header-class="!mb-2"
:title="title"

View File

@@ -17,6 +17,7 @@
<img :src="userInfo.photo" class="w-full h-full rounded transition-transform duration-300 hover:scale-110" />
</el-upload>
</div>
<div class="flex-1 px-3.75">
<div class="mb-4 text-lg truncate">{{ $t('home.welcomeMsg', { name: userInfo.name }) }}</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1.5 text-[13px]">

View File

@@ -1,6 +1,6 @@
<template>
<div>
<el-drawer :title="title" v-model="visible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<el-drawer :append-to-body="false" :title="title" v-model="visible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>

View File

@@ -1,6 +1,6 @@
<template>
<div>
<el-drawer :title="title" v-model="visible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<el-drawer :append-to-body="false" :title="title" v-model="visible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>

View File

@@ -1,51 +1,6 @@
import { OptionsApi, SearchItem } from '@/components/pagetable/SearchForm';
import { ContextmenuItem } from '@/components/contextmenu';
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { tagApi } from '../tag/api';
import { markRaw } from 'vue';
// 资源配置
export interface ResourceConfig {
order?: number;
resourceType: number; // 资源类型
rootNodeType: NodeType; // 资源根节点类型
// 资源管理组件配置
manager?: {
componentConf: ResourceComponentConfig; // 组件
countKey?: string; // 统计数keytab展示的数字对象key
permCode?: string; // 权限码
};
}
export interface ResourceComponentConfig {
name: string; // 名称
component?: any; // 组件
icon?: {
name: string;
color?: string;
};
}
export interface ResourceOpCtx {
/**
* 添加资源相关组件
* @param component 资源相关组件配置
* @returns 组件引用
*/
addResourceComponent(component: ResourceComponentConfig): Promise<any>;
/**
* 获取树节点
* @param nodeKey 节点key
*/
getTreeNode(nodeKey: string): any;
setCurrentTreeKey(nodeKey: string): void;
reloadTreeNode(nodeKey: string): void;
}
export class TagTreeNode {
/**
* 节点id
@@ -87,11 +42,6 @@ export class TagTreeNode {
// 节点组件
nodeComponent?: any;
/**
* 节点上下文
*/
ctx?: ResourceOpCtx;
static TagPath = -1;
constructor(key: any, label: string, type?: NodeType) {
@@ -101,7 +51,7 @@ export class TagTreeNode {
}
static new(parent: TagTreeNode, key: any, label: string, type?: NodeType) {
return new TagTreeNode(key, label, type).withContext(parent.ctx);
return new TagTreeNode(key, label, type);
}
withLabelRemark(labelRemark: any) {
@@ -134,14 +84,6 @@ export class TagTreeNode {
return this;
}
withContext(ctx: ResourceOpCtx | undefined | null) {
if (!ctx) {
return this;
}
this.ctx = ctx;
return this;
}
/**
* 加载子节点使用节点类型的loadNodesFunc去加载子节点
* @returns 子节点信息
@@ -178,6 +120,11 @@ export class NodeType {
// 节点双击事件
nodeDblclickFunc?: (node: TagTreeNode) => void;
/**
* 折叠时是否删除子节点以释放内存(展开时重新懒加载)
*/
collapseRemoveChildren: boolean = false;
constructor(value: number) {
this.value = value;
}
@@ -221,24 +168,14 @@ export class NodeType {
this.contextMenuItems = contextMenuItems;
return this;
}
}
/**
* 获取标签搜索项配置
* @param resourceType 资源类型
* @returns
*/
export function getTagPathSearchItem(resourceType: any) {
return SearchItem.select('tagPath', 'common.tag').withOptionsApi(
OptionsApi.new(tagApi.getResourceTagPaths, { resourceType }).withConvertFn((res: any) => {
return res.map((x: any) => {
return {
label: x,
value: x,
};
});
})
);
/**
* 设置折叠时是否删除子节点
*/
withCollapseRemoveChildren(collapseRemoveChildren: boolean = true) {
this.collapseRemoveChildren = collapseRemoveChildren;
return this;
}
}
export function expandCodePath(codePath: string) {

View File

@@ -1,6 +1,7 @@
<template>
<div class="db-list">
<el-drawer
:append-to-body="false"
:title="title"
v-model="dialogVisible"
@open="search"

View File

@@ -1,6 +1,6 @@
<template>
<div>
<el-drawer :title="title" v-model="dialogVisible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<el-drawer :append-to-body="false" :title="title" v-model="dialogVisible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>

View File

@@ -76,7 +76,6 @@
</template>
<script lang="ts" setup>
import { TagResourceTypePath } from '@/common/commonEnum';
import { formatDate } from '@/common/utils/format';
import { hasPerms } from '@/components/auth/auth';
import { TableColumn } from '@/components/pagetable';
@@ -87,7 +86,6 @@ import { Msg, useI18nCreateTitle, useI18nDeleteConfirm, useI18nEditTitle } from
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import { useI18n } from 'vue-i18n';
import ResourceAuthCert from '../component/ResourceAuthCert.vue';
import { getTagPathSearchItem } from '../component/tag';
import TagCodePath from '../component/TagCodePath.vue';
import { dbApi } from './api';
import { getDbDialect } from './dialect';
@@ -110,7 +108,7 @@ const perms = {
saveDb: 'db:save',
};
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('db.keywordPlaceholder'), getTagPathSearchItem(TagResourceTypePath.Db)];
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('db.keywordPlaceholder')];
const columns = ref([
TableColumn.new('name', 'common.name').isSlot('name').setAddWidth(15),

View File

@@ -1,7 +1,6 @@
import Api from '@/common/Api';
import { registerSqlExecAborter, createSqlExecNotification } from '@/components/sysmsg/db/db-sql-exec-progress';
import { AesEncrypt } from '@/common/crypto';
import { joinClientParams } from '@/common/request';
import { createSqlExecNotification, registerSqlExecAborter } from '@/components/sysmsg/db/db-sql-exec-progress';
export const dbApi = {
// 获取权限列表
@@ -103,12 +102,12 @@ export function uploadSqlFile(
// 生成 uploadId
const uploadId = `sql_exec_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
// 使用 URLSearchParams 构建查询参数
const queryParams = new URLSearchParams({
// 构建查询参数对象
const queryParams: Record<string, string> = {
db: params.dbName,
uploadId: uploadId,
filename: file.name,
}).toString();
};
// 创建 Api 实例
const api = Api.newPost(`/dbs/${params.dbId}/exec-sql-file`);

View File

@@ -1,6 +1,6 @@
<template>
<div>
<div>
<div class="h-full flex flex-col">
<div class="flex-shrink-0">
<div class="card p-1! flex items-center justify-between">
<div>
<el-link @click="onRunSql()" underline="never" class="ml-3.5" icon="VideoPlay"> </el-link>
@@ -40,7 +40,7 @@
</div>
</div>
<el-splitter style="height: calc(100vh - 220px)" layout="vertical" @resize-end="onResizeTableHeight">
<el-splitter ref="splitterRef" class="flex-1 min-h-0" layout="vertical" @resize-end="onResizeTableHeight">
<el-splitter-panel :size="state.editorSize" max="80%">
<MonacoEditor ref="monacoEditorRef" class="mt-1" v-model="state.sql" language="sql" height="100%" :id="'MonacoTextarea-' + getKey()" />
</el-splitter-panel>
@@ -56,7 +56,14 @@
>
<el-tab-pane class="h-full!" closable v-for="dt in state.execResTabs" :label="dt.id" :name="dt.id" :key="dt.id">
<template #label>
<el-popover :show-after="1000" placement="top-start" :title="$t('db.execInfo')" trigger="hover" :width="300">
<el-popover
:show-after="1000"
placement="top-start"
:title="$t('db.execInfo')"
trigger="hover"
:width="300"
:teleported="false"
>
<template #reference>
<div>
<span>
@@ -135,7 +142,7 @@ import config from '@/common/config';
import { getToken } from '@/common/utils/storage';
import { ElMessageBox } from 'element-plus';
import { format as sqlFormatter } from 'sql-formatter';
import { nextTick, onMounted, reactive, ref, toRefs, unref } from 'vue';
import { nextTick, onMounted, reactive, ref, toRefs, unref, useTemplateRef } from 'vue';
import { editor } from 'monaco-editor';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
@@ -276,13 +283,17 @@ const onRemoveTab = (targetId: number) => {
}
};
const splitterRef = useTemplateRef<HTMLElement>('splitterRef');
const onResizeTableHeight = (index: number, sizes: number[]) => {
if (!sizes || sizes.length === 0) {
return;
}
const vh = window.innerHeight;
const plitpaneHeight = vh - 200;
// 基于splitter容器实际高度计算兼容全屏模式
const splitterEl = splitterRef.value?.$el || splitterRef.value;
const containerHeight = splitterEl ? (splitterEl as HTMLElement).getBoundingClientRect().height : window.innerHeight - 220;
const plitpaneHeight = containerHeight - 10;
let editorHeight = sizes[0];
if (editorHeight < 0 || editorHeight > plitpaneHeight - 43) {
@@ -290,7 +301,7 @@ const onResizeTableHeight = (index: number, sizes: number[]) => {
editorHeight = plitpaneHeight / 2;
}
let tableDataHeight = plitpaneHeight - editorHeight - 47;
let tableDataHeight = plitpaneHeight - editorHeight - 15;
state.editorSize = editorHeight;
state.tableDataHeight = tableDataHeight + 'px';

View File

@@ -1,176 +1,172 @@
<template>
<div class="db-table-data mt-1" :style="{ height: tableHeight }">
<el-auto-resizer>
<template #default="{ height, width }">
<el-table-v2
ref="tableRef"
:header-height="showColumnTip && dbConfig.showColumnComment ? 48 : 30"
:row-height="30"
:row-class="rowClass"
:row-key="null"
:columns="state.columns"
:data="datas"
:width="width"
:height="height"
fixed
class="table"
:row-event-handlers="rowEventHandlers"
@scroll="onTableScroll"
>
<template #header="{ columns }">
<div v-for="(column, i) in columns" :key="i">
<div
:style="{
width: `${column.width}px`,
height: '100%',
textAlign: 'center',
borderRight: 'var(--el-table-border)',
borderTop: 'var(--el-table-border)',
}"
>
<!-- 行号列 -->
<div v-if="column.key == rowNoColumn.key" class="header-column-title">
<b class="el-text" tag="b"> {{ column.title }} </b>
</div>
<!-- 字段名列 -->
<div v-else style="position: relative" @mouseenter="showColumnAction(column)" @mouseleave="hideColumnAction">
<!-- 字段列的数据类型 -->
<div class="column-type">
<span v-if="column.dataTypeSubscript === 'icon-clock'">
<SvgIcon :size="9" name="Clock" style="cursor: unset" />
</span>
<span class="text-[8px]!" v-else>{{ column.dataTypeSubscript }}</span>
</div>
<div v-if="showColumnTip">
<div class="header-column-title">
<b :title="column.remark" class="el-text cursor-pointer">
{{ column.title }}
</b>
</div>
<!-- 字段备注信息 -->
<div
v-if="dbConfig.showColumnComment"
style="color: var(--el-color-info-light-3)"
class="text-[10px]! el-text el-text--small is-truncated"
>
{{ column.columnComment }}
</div>
</div>
<div v-else class="header-column-title">
<b class="el-text"> {{ column.title }} </b>
</div>
<!-- 字段列右部分内容 -->
<div class="column-right">
<el-dropdown
@command="handleColumnCommand(column, $event)"
@visibleChange="onColumnActionVisibleChange(column, $event)"
trigger="click"
v-if="column.key !== rowNoColumn.key"
size="small"
placement="bottom-start"
>
<span class="column-actions-trigger">
<!-- 排序箭头图标 -->
<SvgIcon
v-if="
column.key == nowSortColumn?.key && !showColumnActions[column.key] && !columnActionVisible[column.key]
"
:color="'var(--el-color-primary)'"
:name="nowSortColumn?.order == 'asc' ? 'top' : 'bottom'"
:size="14"
/>
<!-- 更多操作图标 -->
<SvgIcon
v-if="columnActionVisible[column.key] || showColumnActions[column.key]"
name="MoreFilled"
:size="14"
:color="'var(--el-color-primary)'"
class="column-more-icon"
:class="{ 'column-more-icon-visible': columnActionVisible[column.key] || showColumnActions[column.key] }"
/>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="showColumnActionSort" command="sort-asc">
<SvgIcon name="top" class="mr-1" />
{{ $t('db.asc') }}
</el-dropdown-item>
<el-dropdown-item v-if="showColumnActionSort" command="sort-desc">
<SvgIcon name="bottom" class="mr-1" />
{{ $t('db.desc') }}
</el-dropdown-item>
<el-dropdown-item v-if="showColumnActionFixed && !column.fixed" command="fix">
<SvgIcon name="Paperclip" class="mr-1" />
{{ $t('db.fixed') }}
</el-dropdown-item>
<el-dropdown-item v-if="showColumnActionFixed && column.fixed" command="unfix">
<SvgIcon name="Minus" class="mr-1" />
{{ $t('db.cancelFiexd') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
<div ref="containerRef" class="db-table-data" :style="height ? { height } : {}">
<el-table-v2
ref="tableRef"
:header-height="showColumnTip && dbConfig.showColumnComment ? 48 : 30"
:row-height="30"
:row-class="rowClass"
:row-key="null"
:columns="state.columns"
:data="datas"
:width="state.containerWidth"
:height="state.containerHeight"
fixed
class="table"
:row-event-handlers="rowEventHandlers"
@scroll="onTableScroll"
>
<template #header="{ columns }">
<div v-for="(column, i) in columns" :key="i">
<div
:style="{
width: `${column.width}px`,
height: '100%',
textAlign: 'center',
borderRight: 'var(--el-table-border)',
borderTop: 'var(--el-table-border)',
}"
>
<!-- 行号列 -->
<div v-if="column.key == rowNoColumn.key" class="header-column-title">
<b class="el-text" tag="b"> {{ column.title }} </b>
</div>
</template>
<template #cell="{ rowData, column, rowIndex, columnIndex }">
<div @contextmenu="dataContextmenuClick($event, rowIndex, column, rowData)" class="table-data-cell">
<!-- 行号列 -->
<div v-if="column.key == rowNoColumn.key">
<b class="el-text el-text--small">
{{ (pageNum - 1) * pageSize + rowIndex + 1 }}
</b>
<!-- 字段名列 -->
<div v-else style="position: relative" @mouseenter="showColumnAction(column)" @mouseleave="hideColumnAction">
<!-- 字段列的数据类型 -->
<div class="column-type">
<span v-if="column.dataTypeSubscript === 'icon-clock'">
<SvgIcon :size="9" name="Clock" style="cursor: unset" />
</span>
<span class="text-[8px]!" v-else>{{ column.dataTypeSubscript }}</span>
</div>
<!-- 数据列 -->
<div v-else @dblclick="onEnterEditMode(rowData, column, rowIndex, columnIndex)">
<div v-if="canEdit(rowIndex, columnIndex)">
<ColumnFormItem
v-model="rowData[column.key!]"
:data-type="column.dataType"
@blur="onExitEditMode(rowData, column, rowIndex)"
:column-name="column.columnName"
focus
/>
<div v-if="showColumnTip">
<div class="header-column-title">
<b :title="column.remark" class="el-text cursor-pointer">
{{ column.title }}
</b>
</div>
<div v-else :class="isUpdated(rowIndex, column.key) ? 'update_field_active ml-0.5 mr-0.5' : 'ml-0.5 mr-0.5'">
<span v-if="rowData[column.key!] === null" style="color: var(--el-color-info-light-5)"> NULL </span>
<!-- 字段备注信息 -->
<div
v-if="dbConfig.showColumnComment"
style="color: var(--el-color-info-light-3)"
class="text-[10px]! el-text el-text--small is-truncated"
>
{{ column.columnComment }}
</div>
</div>
<span v-else :title="rowData[column.key!]" class="el-text el-text--small is-truncated">
{{ rowData[column.key!] }}
<div v-else class="header-column-title">
<b class="el-text"> {{ column.title }} </b>
</div>
<!-- 字段列右部分内容 -->
<div class="column-right">
<el-dropdown
@command="handleColumnCommand(column, $event)"
@visibleChange="onColumnActionVisibleChange(column, $event)"
trigger="click"
v-if="column.key !== rowNoColumn.key"
size="small"
placement="bottom-start"
>
<span class="column-actions-trigger">
<!-- 排序箭头图标 -->
<SvgIcon
v-if="
column.key == nowSortColumn?.key && !showColumnActions[column.key] && !columnActionVisible[column.key]
"
:color="'var(--el-color-primary)'"
:name="nowSortColumn?.order == 'asc' ? 'top' : 'bottom'"
:size="14"
/>
<!-- 更多操作图标 -->
<SvgIcon
v-if="columnActionVisible[column.key] || showColumnActions[column.key]"
name="MoreFilled"
:size="14"
:color="'var(--el-color-primary)'"
class="column-more-icon"
:class="{ 'column-more-icon-visible': columnActionVisible[column.key] || showColumnActions[column.key] }"
/>
</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="showColumnActionSort" command="sort-asc">
<SvgIcon name="top" class="mr-1" />
{{ $t('db.asc') }}
</el-dropdown-item>
<el-dropdown-item v-if="showColumnActionSort" command="sort-desc">
<SvgIcon name="bottom" class="mr-1" />
{{ $t('db.desc') }}
</el-dropdown-item>
<el-dropdown-item v-if="showColumnActionFixed && !column.fixed" command="fix">
<SvgIcon name="Paperclip" class="mr-1" />
{{ $t('db.fixed') }}
</el-dropdown-item>
<el-dropdown-item v-if="showColumnActionFixed && column.fixed" command="unfix">
<SvgIcon name="Minus" class="mr-1" />
{{ $t('db.cancelFiexd') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<template v-if="state.loading" #overlay>
<div class="el-loading-mask flex flex-col items-center justify-center">
<div>
<SvgIcon class="is-loading" name="loading" color="var(--el-color-primary)" :size="28" />
<el-text class="ml-1" tag="b">{{ $t('db.execTime') }} - {{ state.execTime.toFixed(1) }}s</el-text>
</div>
<div v-if="loading && abortFn" class="mt-2!">
<el-button @click="cancelLoading" type="info" size="small" plain>{{ $t('common.cancel') }}</el-button>
</div>
</div>
</template>
<template #empty>
<el-empty class="text-center" :description="props.emptyText" :image-size="60" />
</template>
</el-table-v2>
</div>
</div>
</template>
</el-auto-resizer>
<template #cell="{ rowData, column, rowIndex, columnIndex }">
<div @contextmenu="dataContextmenuClick($event, rowIndex, column, rowData)" class="table-data-cell">
<!-- 行号列 -->
<div v-if="column.key == rowNoColumn.key">
<b class="el-text el-text--small">
{{ (pageNum - 1) * pageSize + rowIndex + 1 }}
</b>
</div>
<!-- 数据列 -->
<div v-else @dblclick="onEnterEditMode(rowData, column, rowIndex, columnIndex)">
<div v-if="canEdit(rowIndex, columnIndex)">
<ColumnFormItem
v-model="rowData[column.key!]"
:data-type="column.dataType"
@blur="onExitEditMode(rowData, column, rowIndex)"
:column-name="column.columnName"
focus
/>
</div>
<div v-else :class="isUpdated(rowIndex, column.key) ? 'update_field_active ml-0.5 mr-0.5' : 'ml-0.5 mr-0.5'">
<span v-if="rowData[column.key!] === null" style="color: var(--el-color-info-light-5)"> NULL </span>
<span v-else :title="rowData[column.key!]" class="el-text el-text--small is-truncated">
{{ rowData[column.key!] }}
</span>
</div>
</div>
</div>
</template>
<template v-if="state.loading" #overlay>
<div class="el-loading-mask flex flex-col items-center justify-center">
<div>
<SvgIcon class="is-loading" name="loading" color="var(--el-color-primary)" :size="28" />
<el-text class="ml-1" tag="b">{{ $t('db.execTime') }} - {{ state.execTime.toFixed(1) }}s</el-text>
</div>
<div v-if="loading && abortFn" class="mt-2!">
<el-button @click="cancelLoading" type="info" size="small" plain>{{ $t('common.cancel') }}</el-button>
</div>
</div>
</template>
<template #empty>
<el-empty class="text-center" :description="props.emptyText" :image-size="60" />
</template>
</el-table-v2>
<el-dialog @close="state.genTxtDialog.visible = false" v-model="state.genTxtDialog.visible" :title="state.genTxtDialog.title" width="1000px">
<template #header>
@@ -208,7 +204,7 @@ import SvgIcon from '@/components/svgIcon/index.vue';
import { DbInst, DbThemeConfig } from '@/views/ops/db/db';
import { useIntervalFn, useStorage } from '@vueuse/core';
import { ElInput } from 'element-plus';
import { Ref, computed, onBeforeUnmount, onMounted, reactive, ref, toRefs, watch } from 'vue';
import { Ref, computed, onBeforeUnmount, onMounted, reactive, ref, toRefs, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Msg } from '../../../../../hooks/useI18n';
import { ColumnTypeSubscript, DataType, DbDialect, getDbDialect } from '../../dialect/index';
@@ -259,7 +255,7 @@ const props = defineProps({
},
height: {
type: String,
default: '600px',
default: '',
},
pageSize: {
type: Number,
@@ -273,6 +269,8 @@ const props = defineProps({
const contextmenuRef = ref();
const tableRef = ref();
const containerRef = useTemplateRef<HTMLElement>('containerRef');
let resizeObserver: ResizeObserver | null = null;
// 用于控制列操作按钮的显示
const showColumnActions = ref({} as any);
@@ -370,6 +368,9 @@ let nowUpdateCell: Ref<NowUpdateCell> = ref(null) as any;
// 选中的数据, key->rowIndex value->primaryKeyValue
const selectionRowsMap = ref(new Map<number, any>());
// 最后一次点击的行索引,用于 shift 批量选择
let lastSelectedRowIndex: number | null = null;
// 更新单元格 key-> rowIndex value -> 更新行
const cellUpdateMap = ref(new Map<number, UpdatedRow>());
@@ -387,6 +388,8 @@ const state = reactive({
columns: [] as any,
loading: false,
tableHeight: '600px',
containerHeight: 600,
containerWidth: 800,
execTime: 0,
contextmenu: {
dropdown: {
@@ -407,7 +410,7 @@ const state = reactive({
},
});
const { tableHeight, datas } = toRefs(state);
const { containerHeight, containerWidth, datas } = toRefs(state);
const dbConfig = useStorage('dbConfig', DbThemeConfig);
@@ -487,6 +490,22 @@ onMounted(async () => {
state.tableHeight = props.height;
state.loading = props.loading;
// 使用 ResizeObserver 自动测量容器尺寸,确保 el-table-v2 固定表头 + body滚动
if (containerRef.value) {
const rect = containerRef.value.getBoundingClientRect();
state.containerHeight = rect.height || 600;
state.containerWidth = rect.width || 800;
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { height, width } = entry.contentRect;
if (height > 0) state.containerHeight = height;
if (width > 0) state.containerWidth = width;
}
});
resizeObserver.observe(containerRef.value);
}
state.dbId = props.dbId;
state.dbType = getNowDbInst().type;
dbDialect = getDbDialect(state.dbType);
@@ -502,12 +521,14 @@ onMounted(async () => {
onBeforeUnmount(() => {
endLoading();
resizeObserver?.disconnect();
});
const setTableData = (datas: any) => {
tableRef.value?.scrollTo({ scrollLeft: 0, scrollTop: 0 });
selectionRowsMap.value.clear();
cellUpdateMap.value.clear();
lastSelectedRowIndex = null;
// formatDataValues(datas);
state.datas = datas;
setTableColumns(props.columns);
@@ -633,7 +654,7 @@ const isSelection = (rowIndex: number): boolean => {
*/
const selectionRow = (rowIndex: number, rowData: any, isMultiple = false) => {
if (isMultiple) {
// 如果重复点击,则取消选中数据
// 如果重复点击,则取消选中数据
if (selectionRowsMap.value.get(rowIndex)) {
selectionRowsMap.value.delete(rowIndex);
return;
@@ -642,6 +663,21 @@ const selectionRow = (rowIndex: number, rowData: any, isMultiple = false) => {
selectionRowsMap.value.clear();
}
selectionRowsMap.value.set(rowIndex, rowData);
lastSelectedRowIndex = rowIndex;
};
/**
* Shift 批量选择:选中起始行到当前行之间的所有行
*/
const selectionRowRange = (startIndex: number, endIndex: number) => {
const from = Math.min(startIndex, endIndex);
const to = Math.max(startIndex, endIndex);
for (let i = from; i <= to; i++) {
const rowData = state.datas[i];
if (rowData) {
selectionRowsMap.value.set(i, rowData);
}
}
};
/**
@@ -652,11 +688,17 @@ const rowEventHandlers = {
const event = e.event;
const rowIndex = e.rowIndex;
const rowData = e.rowData;
// 按住ctrl点击,则新建标签页打开, metaKey对应mac command键
// 按住ctrl/meta点击则多选切换
if (event.ctrlKey || event.metaKey) {
selectionRow(rowIndex, rowData, true);
return;
}
// 按住shift点击则批量选择起始行到当前行
if (event.shiftKey && lastSelectedRowIndex !== null) {
selectionRowsMap.value.clear();
selectionRowRange(lastSelectedRowIndex, rowIndex);
return;
}
selectionRow(rowIndex, rowData);
},
};

View File

@@ -1,6 +1,6 @@
<template>
<div>
<el-row>
<div class="h-full flex flex-col gap-1">
<el-row class="flex-shrink-0">
<el-col :span="8">
<div class="mt-1">
<el-link :disabled="state.loading" @click="onRefresh()" icon="refresh" underline="never" class="ml-1"> </el-link>
@@ -12,6 +12,7 @@
width="auto"
:title="$t('db.tableFieldConf')"
trigger="click"
:teleported="false"
@hide="triggerCheckedColumns"
>
<div><el-input v-model="checkedShowColumns.searchKey" size="small" :placeholder="$t('db.columnFilterPlaceholder')" /></div>
@@ -45,16 +46,16 @@
<el-link @click="onShowAddDataDialog()" type="primary" icon="plus" underline="never"></el-link>
<el-divider direction="vertical" border-style="dashed" />
<el-tooltip :show-after="500" effect="dark" content="commit" placement="top">
<el-tooltip :show-after="500" effect="dark" content="commit" placement="top" :teleported="false">
<el-link @click="onCommit()" type="success" icon="CircleCheck" underline="never"> </el-link>
</el-tooltip>
<el-divider direction="vertical" border-style="dashed" />
<el-tooltip :show-after="500" v-if="hasUpdatedFileds" :content="$t('db.submitUpdate')" placement="top">
<el-tooltip :show-after="500" v-if="hasUpdatedFileds" :content="$t('db.submitUpdate')" placement="top" :teleported="false">
<el-link @click="submitUpdateFields()" type="success" underline="never" class="!text-[12px]">{{ $t('common.submit') }}</el-link>
</el-tooltip>
<el-divider v-if="hasUpdatedFileds" direction="vertical" border-style="dashed" />
<el-tooltip :show-after="500" v-if="hasUpdatedFileds" :content="$t('db.cancelUpdate')" placement="top">
<el-tooltip :show-after="500" v-if="hasUpdatedFileds" :content="$t('db.cancelUpdate')" placement="top" :teleported="false">
<el-link @click="cancelUpdateFields" type="warning" underline="never" class="!text-[12px]">{{ $t('common.cancel') }}</el-link>
</el-tooltip>
</div>
@@ -74,6 +75,7 @@
highlight-first-item
value-key="columnName"
ref="condInputRef"
:teleported="false"
>
<template #suffix>
<SvgIcon @click="onSelectByCondition" name="search" />
@@ -95,7 +97,7 @@
</template>
<template #prepend>
<el-popover :visible="state.condPopVisible" trigger="click" :width="320" placement="right">
<el-popover :visible="state.condPopVisible" trigger="click" :width="320" placement="right" :teleported="false">
<template #reference>
<el-button @click.stop="chooseCondColumnName" style="color: var(--el-color-success)" text size="small">
{{ $t('db.selectColumn') }}
@@ -133,13 +135,13 @@
<db-table-data
ref="dbTableRef"
class="flex-1 min-h-0 overflow-hidden"
:db-id="dbId"
:db="dbName"
:data="datas"
:table="tableName"
:columns="columns"
:loading="loading"
:height="tableHeight"
:page-size="pageSize"
:page-num="pageNum"
:show-column-tip="true"
@@ -149,7 +151,7 @@
@data-delete="onRefresh"
></db-table-data>
<el-row type="flex" class="mt-2" :gutter="10" justify="space-between" style="user-select: none">
<el-row type="flex" class="flex-shrink-0" :gutter="10" justify="space-between" style="user-select: none">
<el-col :span="12">
<el-text
id="copyValue"
@@ -185,7 +187,7 @@
<el-link class="op-page" underline="never" @click="++pageNum" :disabled="datas.length < pageSize" icon="Right" />
<el-link class="op-page" underline="never" @click="handleEndPage" :disabled="datas.length < pageSize" icon="DArrowRight" />
<div style="width: 90px" class="op-page ml-2">
<el-select size="small" :default-first-option="true" v-model="pageSize" @change="handleSizeChange">
<el-select size="small" :default-first-option="true" v-model="pageSize" @change="handleSizeChange" :teleported="false">
<el-option
style="font-size: 12px; height: 24px; line-height: 24px"
v-for="(op, i) in pageSizes"
@@ -206,7 +208,7 @@
<el-dialog v-model="conditionDialog.visible" :title="conditionDialog.title" width="500px">
<el-row gutter="5">
<el-col :span="5">
<el-select v-model="conditionDialog.condition">
<el-select v-model="conditionDialog.condition" :teleported="false">
<el-option label="=" value="="> </el-option>
<el-option label="LIKE" value="LIKE"> </el-option>
<el-option label=">" value=">"> </el-option>
@@ -273,10 +275,6 @@ const props = defineProps({
type: String,
required: true,
},
tableHeight: {
type: [String],
default: '600px',
},
});
const dbTableRef: Ref = ref(null);
@@ -325,7 +323,6 @@ const state = reactive({
title: '',
visible: false,
},
tableHeight: '600px',
hasUpdatedFileds: false,
dbDialect: {} as DbDialect,
@@ -340,20 +337,12 @@ const state = reactive({
const { datas, condition, loading, columns, checkedShowColumns, pageNum, pageSize, pageSizes, sql, hasUpdatedFileds, conditionDialog, addDataDialog } =
toRefs(state);
watch(
() => props.tableHeight,
(newValue: any) => {
state.tableHeight = newValue;
}
);
const getNowDbInst = () => {
return DbInst.getInst(props.dbId);
};
onMounted(async () => {
console.log('in table data mounted');
state.tableHeight = props.tableHeight;
await onRefresh();
state.dbDialect = getNowDbInst().getDialect();

View File

@@ -1,5 +1,5 @@
<template>
<el-drawer :title="title" v-model="dialogVisible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="75%">
<el-drawer :append-to-body="false" :title="title" v-model="dialogVisible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="75%">
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>

View File

@@ -1,7 +1,7 @@
<template>
<div class="db-table">
<div class="db-table flex flex-col gap-1">
<el-row class="mb-1">
<el-popover v-model:visible="state.dumpInfo.visible" trigger="click" :width="470" placement="right">
<el-popover v-model:visible="state.dumpInfo.visible" trigger="click" :width="470" placement="right" :teleported="false">
<template #reference>
<el-button v-auth="'db:data:export'" :disabled="state.dumpInfo.tables?.length == 0" class="ml-1" type="success" size="small">
{{ $t('db.dump') }}
@@ -353,4 +353,11 @@ const onSubmitSql = async (row: { tableName: string }) => {
await getTables();
};
</script>
<style lang="scss"></style>
<style lang="scss">
.db-table {
> .el-table {
flex: 1;
min-height: 0;
}
}
</style>

View File

@@ -404,7 +404,7 @@ export class DbInst {
* @param inst 数据库实例,后端返回的列表接口中的信息
* @returns DbInst
*/
static async getOrNewInst(inst: any) {
static getOrNewInst(inst: any) {
if (!inst) {
throw new Error('inst不能为空');
}
@@ -427,7 +427,9 @@ export class DbInst {
dbInst.databases = inst.databases;
if (dbInst.databases?.[0]) {
dbInst.version = await dbApi.getCompatibleDbVersion.request({ id: inst.id, db: dbInst.databases?.[0] });
dbApi.getCompatibleDbVersion.request({ id: inst.id, db: dbInst.databases?.[0] }).then((version) => {
dbInst.version = version;
});
}
dbInstCache.set(dbInst.id, dbInst);

View File

@@ -1,5 +1,5 @@
<template>
<div class="db-sql-exec h-full">
<div class="db-sql-exec h-full flex flex-col">
<el-row>
<el-col :span="24" v-if="state.db">
<el-descriptions :column="4" size="small" border>
@@ -27,6 +27,7 @@
width="auto"
:title="$t('db.dbShowSetting')"
trigger="click"
:teleported="false"
>
<el-row>
<el-checkbox
@@ -79,18 +80,18 @@
</el-col>
</el-row>
<div id="data-exec" class="mt-1">
<div id="data-exec" ref="dataExecRef" class="mt-1 flex-1 min-h-0 overflow-visible">
<el-tabs
v-if="state.tabs.size > 0"
type="card"
@tab-remove="onRemoveTab"
@tab-change="onTabChange"
v-model="state.activeName"
class="h-full! w-full"
class="db-data-tabs w-full"
>
<el-tab-pane class="h-full!" 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">
<el-popover :show-after="1000" placement="bottom-start" trigger="hover" :width="250" :teleported="false">
<template #reference>
<span @contextmenu.prevent="onTabContextmenu(dt, $event)" class="text-[12px]!">{{ dt.label }}</span>
</template>
@@ -119,7 +120,6 @@
:db-id="dt.dbId"
:db-name="dt.db"
:table-name="dt.params.table"
:table-height="state.dataTabsTableHeight"
:ref="(el: any) => (dt.componentRef = el)"
></db-table-data-op>
@@ -170,14 +170,12 @@ import { dispposeCompletionItemProvider } from '@/components/monaco/completionIt
import MonacoEditor from '@/components/monaco/MonacoEditor.vue';
import SvgIcon from '@/components/svgIcon/index.vue';
import { Msg, useI18nCreateTitle, useI18nDeleteConfirm, useI18nEditTitle } from '@/hooks/useI18n';
import { ResourceOpCtx } from '@/views/ops/component/tag';
import SqlExecBox from '@/views/ops/db/component/sqleditor/SqlExecBox';
import { DbDataOpComp } from '@/views/ops/db/resource';
import { ResourceOpCtxKey } from '@/views/ops/resource/resource';
import { ResourceOpCtx, ResourceOpCtxKey } from '@/views/ops/resource/resourceOp';
import { useEventListener, useStorage } from '@vueuse/core';
import { ElCheckbox, ElMessageBox } from 'element-plus';
import { format as sqlFormatter } from 'sql-formatter';
import { defineAsyncComponent, getCurrentInstance, h, inject, onBeforeUnmount, onMounted, reactive, ref, toRefs, useTemplateRef } from 'vue';
import { defineAsyncComponent, h, inject, onBeforeUnmount, onMounted, reactive, ref, toRefs, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { dbApi } from '../api';
import { DbInst, DbThemeConfig, registerDbCompletionItemProvider, TabInfo, TabType } from '../db';
@@ -192,6 +190,11 @@ const { t } = useI18n();
const resourceOpCtx: ResourceOpCtx | undefined = inject(ResourceOpCtxKey);
const props = defineProps<{
dbInfo: any;
db: string;
}>();
const emits = defineEmits(['init']);
const tabContextmenuRef: any = useTemplateRef('tabContextmenuRef');
@@ -227,7 +230,6 @@ const state = reactive({
dropdown: { x: 0, y: 0 },
items: tabContextmenuItems,
},
dataTabsTableHeight: '600px',
tablesOpHeight: '600',
dbServerInfo: {
loading: true,
@@ -256,28 +258,35 @@ const { nowDbInst, tableCreateDialog } = toRefs(state);
const dbConfig = useStorage('dbConfig', DbThemeConfig);
onMounted(() => {
changeDb(props.dbInfo, props.db);
state.reloadStatus = !dbConfig.value.cacheTable;
emits('init', { name: DbDataOpComp.name, ref: getCurrentInstance()?.exposed });
setHeight();
// 监听浏览器窗口大小变化,更新对应组件高度
useEventListener(window, 'resize', setHeight);
});
const dataExecRef = useTemplateRef<HTMLElement>('dataExecRef');
onBeforeUnmount(() => {
dispposeCompletionItemProvider('sql');
});
/**
* 设置editor高度和数据表高度
* 设置editor高度和数据表高度(基于容器位置计算,兼容全屏模式)
*/
const setHeight = () => {
state.dataTabsTableHeight = window.innerHeight - 253 + 'px';
state.tablesOpHeight = window.innerHeight - 225 + 'px';
const el = document.getElementById('data-exec');
if (el) {
const rect = el.getBoundingClientRect();
state.tablesOpHeight = window.innerHeight - rect.top - 60 + 'px';
} else {
state.tablesOpHeight = window.innerHeight - 225 + 'px';
}
};
// 选择数据库,改变当前正在操作的数据库信息
const changeDb = async (db: any, dbName: string) => {
state.nowDbInst = await DbInst.getOrNewInst(db);
const changeDb = (db: any, dbName: string) => {
state.nowDbInst = DbInst.getOrNewInst(db);
state.nowDbInst.databases = db.databases;
state.db = dbName;
};
@@ -287,7 +296,7 @@ const loadTableData = async (db: any, dbName: string, tableName: string) => {
if (tableName == '') {
return;
}
await changeDb(db, dbName);
changeDb(db, dbName);
const key = `tableData:${db.id}.${dbName}.${tableName}`;
let tab = state.tabs.get(key);
@@ -316,7 +325,7 @@ const addQueryTab = async (db: any, dbName: string, sqlName: string = '') => {
Msg.warning('db.noDbInstMsg');
return;
}
await changeDb(db, dbName);
changeDb(db, dbName);
const dbId = db.id;
let label;
@@ -367,7 +376,7 @@ const addTablesOpTab = async (db: any) => {
Msg.warning('db.noDbInstMsg');
return;
}
await changeDb(db, dbName);
changeDb(db, dbName);
const dbId = db.id;
let key = `tablesOp:${dbId}.${dbName}`;
@@ -642,7 +651,12 @@ const loadTables = async (dbInfo: any) => {
return tables;
};
const onRefresh = () => {
state.tabs.clear();
};
defineExpose({
onRefresh,
onChangeDb: changeDb,
loadTables,
loadTableData,
@@ -663,15 +677,29 @@ defineExpose({
<style lang="scss" scoped>
.db-sql-exec {
#data-exec {
::v-deep(.el-tabs) {
::v-deep(.db-data-tabs) {
height: 100%;
display: flex;
flex-direction: column;
--el-tabs-header-height: 30px;
}
::v-deep(.el-tabs__header) {
margin: 0 0 5px;
> .el-tabs__header {
margin: 0 0 5px;
flex-shrink: 0;
.el-tabs__item {
padding: 0 5px;
.el-tabs__item {
padding: 0 5px;
}
}
> .el-tabs__content {
flex: 1;
min-height: 0;
overflow: visible;
}
.el-tab-pane {
height: 100%;
}
}

View File

@@ -1,14 +1,16 @@
import { ContextmenuItem } from '@/components/contextmenu';
import { NodeType, TagTreeNode, ResourceConfig } from '../../component/tag';
import { ResourceTypeEnum, TagResourceTypeEnum } from '@/common/commonEnum';
import { defineAsyncComponent } from 'vue';
import { dbApi } from '../api';
import { sleep } from '@/common/utils/loading';
import { DbInst } from '../db';
import { schemaDbTypes } from '../dialect/index';
import { i18n } from '@/i18n';
import { formatByteSize } from '@/common/utils/format';
import { sleep } from '@/common/utils/loading';
import { i18n } from '@/i18n';
import type { ResourceConfig } from '@/views/ops/resource/resource';
import { defineAsyncComponent } from 'vue';
import { NodeType, TagTreeNode } from '../../component/tag';
import { createResourceOpTab } from '../../resource/resourceOp';
import { dbApi } from '../api';
import { DbInst } from '../db';
import { getDbDialect, schemaDbTypes } from '../dialect/index';
const DbInstList = defineAsyncComponent(() => import('../InstanceList.vue'));
const DbDataOp = defineAsyncComponent(() => import('./DbDataOp.vue'));
@@ -37,19 +39,14 @@ const SqlIcon = {
color: '#f56c6c',
};
export const DbDataOpComp = {
name: 'tag.dbDataOp',
component: DbDataOp,
icon: DbIcon,
};
// node节点点击时触发改变db事件
const nodeClickChangeDb = async (nodeData: TagTreeNode) => {
const params = nodeData.params;
if (params.db) {
const compRef = await nodeData.ctx?.addResourceComponent(DbDataOpComp);
compRef.onChangeDb(
{
const getDbOpTab = async (params: any) => {
const tabKey = `${params.instCode}.${params.dbCode}.${params.db}`;
return await createResourceOpTab({
key: tabKey,
name: `${params.name}/${params.db}`,
component: DbDataOp,
componentProps: {
dbInfo: {
id: params.id,
host: `${params.host}`,
name: params.name,
@@ -57,18 +54,24 @@ const nodeClickChangeDb = async (nodeData: TagTreeNode) => {
tagPath: params.tagPath,
databases: params.dbs,
},
params.db
);
}
db: params.db,
},
tabComponentProps: {
icon: { name: getDbDialect(params.type)?.getInfo().icon },
},
});
};
const getDbOpTabCompInst = async (params: any) => {
return (await getDbOpTab(params)).componentInstance;
};
const ContextmenuItemRefresh = new ContextmenuItem('refresh', 'common.refresh')
.withIcon('RefreshRight')
.withOnClick(async (node: TagTreeNode) => (await node.ctx?.addResourceComponent(DbDataOpComp)).reloadNode(node.key));
.withOnClick(async (node: TagTreeNode) => (await getDbOpTabCompInst(node.params))?.reloadNode(node.key));
// 数据库实例节点类型
export const NodeTypeDbInst = new NodeType(TagResourceTypeEnum.DbInstance.value).withLoadNodesFunc(async (parentNode: TagTreeNode) => {
parentNode.ctx?.addResourceComponent(DbDataOpComp);
const tagPath = parentNode.params.tagPath;
const dbInstancesRes = await dbApi.instances.request({ tagPath, pageSize: 100 });
@@ -110,7 +113,7 @@ export const NodeTypeDbConf = new NodeType(TagResourceTypeEnum.Db.value)
x.username = authCerts[x.authCertName]?.username;
x.instCode = params.instCode;
x.dbCode = x.code;
return TagTreeNode.new(parentNode, `${x.code}`, x.name, NodeTypeDbs).withParams(x).withIcon(DbIcon).withNodeComponent(NodeDb);
return TagTreeNode.new(parentNode, `${parentNode.key}.${x.code}`, x.name, NodeTypeDbs).withParams(x).withIcon(DbIcon).withNodeComponent(NodeDb);
});
})
.withContextMenuItems([ContextmenuItemRefresh]);
@@ -141,37 +144,32 @@ export const NodeTypeDbs = new NodeType(222).withLoadNodesFunc(async (parentNode
});
// 数据库节点
export const NodeTypeDb = new NodeType(223)
.withContextMenuItems([ContextmenuItemRefresh])
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
params.parentKey = parentNode.key;
// pg类数据库会多一层schema
if (schemaDbTypes.includes(params.type)) {
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 TagTreeNode.new(parentNode, `${params.id}.${params.db}.schema.${sn}`, sn, NodeTypePostgresSchema)
.withParams(nParams)
.withIcon(SchemaIcon);
});
}
export const NodeTypeDb = new NodeType(223).withContextMenuItems([ContextmenuItemRefresh]).withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
params.parentKey = parentNode.key;
// pg类数据库会多一层schema
if (schemaDbTypes.includes(params.type)) {
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 TagTreeNode.new(parentNode, `${parentNode.key}/${sn}`, sn, NodeTypePostgresSchema).withParams(nParams).withIcon(SchemaIcon);
});
}
return getNodeTypeTables(parentNode);
})
.withNodeClickFunc(nodeClickChangeDb);
return getNodeTypeTables(parentNode);
});
export const getNodeTypeTables = (parentNode: TagTreeNode) => {
const params = parentNode.params;
let tableKey = `${params.id}.${params.db}.table-menu`;
let sqlKey = getSqlMenuNodeKey(params.id, params.db);
let tableKey = `${parentNode.key}.table-menu`;
let sqlKey = `${parentNode.key}.sql-menu`;
return [
TagTreeNode.new(parentNode, `${params.id}.${params.db}.table-menu`, i18n.global.t('db.table'), NodeTypeTableMenu)
TagTreeNode.new(parentNode, tableKey, i18n.global.t('db.table'), NodeTypeTableMenu)
.withParams({
...params,
key: tableKey,
@@ -185,25 +183,23 @@ export const getNodeTypeTables = (parentNode: TagTreeNode) => {
};
// postgres schema模式
export const NodeTypePostgresSchema = new NodeType(224)
.withContextMenuItems([ContextmenuItemRefresh])
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
params.parentKey = parentNode.key;
return getNodeTypeTables(parentNode);
})
.withNodeClickFunc(nodeClickChangeDb);
export const NodeTypePostgresSchema = new NodeType(224).withContextMenuItems([ContextmenuItemRefresh]).withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
params.parentKey = parentNode.key;
return getNodeTypeTables(parentNode);
});
// 数据库表菜单节点
const NodeTypeTableMenu = new NodeType(4)
.withCollapseRemoveChildren()
.withContextMenuItems([
ContextmenuItemRefresh,
new ContextmenuItem('createTable', 'db.createTable').withIcon('Plus').withOnClick(async (parentNode: TagTreeNode) => {
(await parentNode.ctx?.addResourceComponent(DbDataOpComp))?.onEditTable(parentNode);
(await getDbOpTabCompInst(parentNode.params))?.onEditTable(parentNode);
}),
new ContextmenuItem('tablesOp', 'db.tableOp').withIcon('Setting').withOnClick(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
(await parentNode.ctx?.addResourceComponent(DbDataOpComp)).addTablesOpTab({
(await getDbOpTabCompInst(params))?.addTablesOpTab({
id: params.id,
db: params.db,
type: params.type,
@@ -212,24 +208,19 @@ const NodeTypeTableMenu = new NodeType(4)
}),
])
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const compRef = await parentNode.ctx?.addResourceComponent(DbDataOpComp);
const params = parentNode.params;
const compRef = await getDbOpTabCompInst(params);
// // 获取当前库的所有表信息
const tables = await compRef.loadTables(params);
let { id, db, type, schema, version } = params;
let dbTableSize = 0;
const tablesNode = tables.map((x: any) => {
const tableSize = x.dataLength + x.indexLength;
dbTableSize += tableSize;
const key = `${id}.${db}.${x.tableName}`;
const key = `${parentNode.key}.${x.tableName}`;
return TagTreeNode.new(parentNode, key, x.tableName, NodeTypeTable)
.withIsLeaf(true)
.withParams({
id,
db,
type,
schema,
version,
...params,
key: key,
parentKey: parentNode.key,
tableName: x.tableName,
@@ -244,72 +235,56 @@ const NodeTypeTableMenu = new NodeType(4)
parentNode.params.dbTableSize = dbTableSize == 0 ? '' : formatByteSize(dbTableSize);
return tablesNode;
});
// .withNodeDblclickFunc((node: TagTreeNode) => {
// const params = node.params;
// addTablesOpTab({ id: params.id, db: params.db, type: params.type, version: params.version, nodeKey: node.key });
// });
// 数据库sql模板菜单节点
const NodeTypeSqlMenu = new NodeType(225)
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
const id = params.id;
const db = params.db;
const dbs = params.dbs;
// 加载用户保存的sql脚本
const sqls = await dbApi.getSqlNames.request({ id: id, db: db });
return sqls.map((x: any) => {
return TagTreeNode.new(parentNode, `${id}.${db}.${x.name}`, x.name, NodeTypeSql)
.withIsLeaf(true)
.withParams({ id, db, dbs, sqlName: x.name })
.withIcon(SqlIcon);
});
})
.withNodeClickFunc(nodeClickChangeDb);
const NodeTypeSqlMenu = new NodeType(225).withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
// 加载用户保存的sql脚本
const sqls = await dbApi.getSqlNames.request({ id: params.id, db: params.db });
return sqls.map((x: any) => {
return TagTreeNode.new(parentNode, `${parentNode.key}.${x.name}`, x.name, NodeTypeSql)
.withIsLeaf(true)
.withParams({ ...params, sqlName: x.name })
.withIcon(SqlIcon);
});
});
// 表节点类型
const NodeTypeTable = new NodeType(226)
.withContextMenuItems([
new ContextmenuItem('copyTable', 'db.copyTable')
.withIcon('copyDocument')
.withOnClick(async (node: TagTreeNode) => (await node.ctx?.addResourceComponent(DbDataOpComp)).onCopyTable(node)),
.withOnClick(async (node: TagTreeNode) => (await getDbOpTabCompInst(node.params))?.onCopyTable(node)),
new ContextmenuItem('renameTable', 'db.renameTable')
.withIcon('edit')
.withOnClick(async (node: TagTreeNode) => (await node.ctx?.addResourceComponent(DbDataOpComp)).onRenameTable(node)),
.withOnClick(async (node: TagTreeNode) => (await getDbOpTabCompInst(node.params))?.onRenameTable(node)),
new ContextmenuItem('editTable', 'db.editTable')
.withIcon('edit')
.withOnClick(async (node: TagTreeNode) => (await node.ctx?.addResourceComponent(DbDataOpComp)).onEditTable(node)),
.withOnClick(async (node: TagTreeNode) => (await getDbOpTabCompInst(node.params))?.onEditTable(node)),
new ContextmenuItem('delTable', 'db.delTable')
.withIcon('Delete')
.withOnClick(async (node: TagTreeNode) => (await node.ctx?.addResourceComponent(DbDataOpComp)).onDeleteTable(node)),
.withOnClick(async (node: TagTreeNode) => (await getDbOpTabCompInst(node.params))?.onDeleteTable(node)),
new ContextmenuItem('ddl', 'DDL')
.withIcon('Document')
.withOnClick(async (node: TagTreeNode) => (await node.ctx?.addResourceComponent(DbDataOpComp)).onGenDdl(node)),
.withOnClick(async (node: TagTreeNode) => (await getDbOpTabCompInst(node.params))?.onGenDdl(node)),
])
.withNodeClickFunc(async (node: TagTreeNode) => {
const params = node.params;
(await node.ctx?.addResourceComponent(DbDataOpComp)).loadTableData({ id: params.id, nodeKey: node.key }, params.db, params.tableName);
(await getDbOpTabCompInst(node.params))?.loadTableData({ id: params.id, nodeKey: node.key }, params.db, params.tableName);
});
// sql模板节点类型
const NodeTypeSql = new NodeType(227)
.withNodeClickFunc(async (parentNode: TagTreeNode) => {
const compRef = await parentNode.ctx?.addResourceComponent(DbDataOpComp);
const params = parentNode.params;
compRef.addQueryTab({ id: params.id, nodeKey: parentNode.key, dbs: params.dbs }, params.db, params.sqlName);
(await getDbOpTabCompInst(params))?.addQueryTab({ id: params.id, nodeKey: parentNode.key, dbs: params.dbs }, params.db, params.sqlName);
})
.withContextMenuItems([
new ContextmenuItem('delSql', 'common.delete')
.withIcon('delete')
.withOnClick(async (node: TagTreeNode) =>
(await node.ctx?.addResourceComponent(DbDataOpComp)).deleteSql(node.params.id, node.params.db, node.params.sqlName)
),
.withOnClick(async (node: TagTreeNode) => (await getDbOpTabCompInst(node.params))?.deleteSql(node.params.id, node.params.db, node.params.sqlName)),
]);
const getSqlMenuNodeKey = (dbId: number, db: string) => {
return `${dbId}.${db}.sql-menu`;
};
export default {
order: 2,
resourceType: ResourceTypeEnum.Db.value,

View File

@@ -1,6 +1,6 @@
<template>
<div class="sync-task-edit">
<el-drawer :title="title" v-model="dialogVisible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="45%">
<el-drawer :append-to-body="false" :title="title" v-model="dialogVisible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="45%">
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>

View File

@@ -1,6 +1,6 @@
<template>
<div class="db-transfer-edit">
<el-drawer :title="title" v-model="dialogVisible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="45%">
<el-drawer :append-to-body="false" :title="title" v-model="dialogVisible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="false" size="45%">
<template #header>
<DrawerHeader :header="title" :back="cancel" />
</template>

View File

@@ -58,7 +58,6 @@
</template>
<script lang="ts" setup>
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { formatDate } from '@/common/utils/format';
import { TableColumn } from '@/components/pagetable';
import PageTable from '@/components/pagetable/PageTable.vue';
@@ -67,7 +66,6 @@ import { Msg, useI18nCreateTitle, useI18nDeleteConfirm, useI18nEditTitle } from
import TagCodePath from '@/views/ops/component/TagCodePath.vue';
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import { useRoute } from 'vue-router';
import { getTagPathSearchItem } from '../component/tag';
import { dockerApi } from './api';
const ContainerConfEdit = defineAsyncComponent(() => import('./CotainerConfEdit.vue'));
@@ -82,10 +80,7 @@ const props = defineProps({
const route = useRoute();
const pageTableRef: Ref<any> = ref(null);
const searchItems = [
SearchItem.input('keyword', 'common.keyword').withPlaceholder('redis.keywordPlaceholder'),
getTagPathSearchItem(TagResourceTypeEnum.Container.value),
];
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('redis.keywordPlaceholder')];
const columns = ref([
TableColumn.new('name', 'common.name').isSlot('name').setAddWidth(15),

View File

@@ -1,6 +1,6 @@
<template>
<div>
<el-drawer :title="title" v-model="dialogVisible" :before-close="onCancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<el-drawer :append-to-body="false" :title="title" v-model="dialogVisible" :before-close="onCancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<template #header>
<DrawerHeader :header="title" :back="onCancel" />
</template>

View File

@@ -1,6 +1,6 @@
<template>
<div>
<el-drawer title="Docker" v-model="dialogVisible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="true" size="80%">
<el-drawer :append-to-body="false" title="Docker" v-model="dialogVisible" :before-close="cancel" :destroy-on-close="true" :close-on-click-modal="true" size="80%">
<template #header>
<DrawerHeader :header="props.host" :back="cancel">
<template #extra>

View File

@@ -1,5 +1,5 @@
<template>
<el-drawer v-model="dialogVisible" :append-to-body="true" :destroy-on-close="true" :close-on-click-modal="false" :before-close="cancel" size="40%">
<el-drawer v-model="dialogVisible" :append-to-body="false" :destroy-on-close="true" :close-on-click-modal="false" :before-close="cancel" size="40%">
<template #header>
<DrawerHeader :header="$t('docker.createContainer')" :back="cancel">
<template #extra>

View File

@@ -1,6 +1,7 @@
<template>
<div class="card !p-2">
<el-row justify="space-between">
<div class="component-container">
<div class="card !p-2">
<el-row justify="space-between">
<el-col :span="16">
<el-row :gutter="5">
<el-col :span="6">
@@ -21,15 +22,15 @@
</el-row>
</el-col>
</el-row>
</div>
</div>
<el-table :data="filterTableDatas" v-loading="state.loadingContainers">
<el-table :data="filterTableDatas" v-loading="state.loadingContainers">
<el-table-column prop="name" :label="$t('docker.name')" :min-width="120" show-overflow-tooltip> </el-table-column>
<el-table-column prop="imageName" :label="$t('docker.image')" :min-width="150" show-overflow-tooltip> </el-table-column>
<el-table-column prop="state" :label="$t('common.status')" :min-width="110">
<template #default="{ row }">
<el-dropdown @command="handleCommand">
<el-dropdown @command="handleCommand" :teleported="false">
<el-button :type="EnumValue.getEnumByValue(ContainerStateEnum, row.state)?.tag.type" round plain size="small">
{{ $t(EnumValue.getLabelByValue(ContainerStateEnum, row.state)) || '-' }}
<SvgIcon class="ml-1" :name="EnumValue.getEnumByValue(ContainerStateEnum, row.state)?.extra.icon" />
@@ -68,7 +69,7 @@
<span>{{ Number(row.stats.memoryPercent).toFixed(2) }}%</span>
</el-text>
<el-popover placement="right" :width="300" trigger="hover">
<el-popover placement="right" :width="300" trigger="hover" :teleported="false">
<template #reference>
<SvgIcon class="mt5 ml5" color="var(--el-color-primary)" name="MoreFilled" />
</template>
@@ -129,7 +130,7 @@
<el-button @click="openLog(row)" type="success" link plain>{{ $t('docker.log') }}</el-button>
<el-dropdown @command="handleCommand">
<el-dropdown @command="handleCommand" :teleported="false">
<el-button type="primary" link plain class="ml-3"> {{ $t('common.more') }} <SvgIcon name="arrow-down" :size="12" /> </el-button>
<template #dropdown>
@@ -144,6 +145,7 @@
</template>
</el-table-column>
</el-table>
</div>
<el-dialog
v-if="terminalDialog.visible"
@@ -358,3 +360,17 @@ function formatCpuValue(t: number) {
return Number((t / Math.pow(num, 3)).toFixed(2)) + ' s';
}
</script>
<style scoped>
.component-container {
height: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.component-container :deep(.el-table) {
flex: 1;
min-height: 0;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div>
<el-drawer title="logs" v-model="visible" @close="close" :destroy-on-close="true" :close-on-click-modal="true" size="60%">
<el-drawer :append-to-body="false" title="logs" v-model="visible" @close="close" :destroy-on-close="true" :close-on-click-modal="true" size="60%">
<template #header>
<DrawerHeader :header="`${props.title}`" :back="() => (visible = false)">
<template #extra>
@@ -12,7 +12,7 @@
<div class="flex flex-col flex-1">
<el-row :gutter="10" class="mb-2">
<el-col :span="6">
<el-select @change="searchLog" v-model.number="state.tail">
<el-select @change="searchLog" v-model.number="state.tail" :teleported="false">
<template #prefix>{{ $t('docker.lines') }}</template>
<el-option :value="100" :label="100" />
<el-option :value="200" :label="200" />

View File

@@ -1,6 +1,7 @@
<template>
<div class="card !p-2">
<el-row :gutter="5">
<div class="component-container">
<div class="card !p-2">
<el-row :gutter="5">
<el-col :span="4">
<el-input :placeholder="$t('docker.imageName')" v-model="params.name" plain clearable></el-input>
</el-col>
@@ -18,12 +19,12 @@
</div>
</el-col>
</el-row>
</div>
</div>
<el-table :data="filterTableDatas" v-loading="state.loadingImages">
<el-table :data="filterTableDatas" v-loading="state.loadingImages">
<el-table-column prop="id" label="ID" :min-width="100" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" :underline="false">
<el-link type="primary" underline="never">
{{ row.id.split(':')[1].substring(0, 12) }}
</el-link>
</template>
@@ -67,6 +68,7 @@
</template>
</el-table-column>
</el-table>
</div>
<el-dialog
v-if="terminalDialog.visible"
@@ -86,6 +88,7 @@
<script lang="ts" setup>
import config from '@/common/config';
import { joinClientParams } from '@/common/request';
import { downloadFile } from '@/common/utils/file';
import { formatByteSize, formatDate } from '@/common/utils/format';
import { getToken } from '@/common/utils/storage';
import { fuzzyMatchField } from '@/common/utils/string';
@@ -159,33 +162,28 @@ const getImages = async () => {
};
const exportImage = async (row: any) => {
const a = document.createElement('a');
a.setAttribute('href', `${config.baseApiUrl}/docker/${props.id}/images/save?id=${props.id}&tag=${row.tags[0]}&${joinClientParams()}`);
a.setAttribute('target', '_blank');
a.click();
downloadFile(`${config.baseApiUrl}/docker/${props.id}/images/save?id=${props.id}&tag=${row.tags[0]}&${joinClientParams()}`);
};
const uploadImage = (content: any) => {
const params = new FormData();
// const path = state.nowPath;
params.append('file', content.file);
params.append('id', props.id + '');
params.append('token', token);
dockerApi.imageUpload
.xhrReq(params, {
headers: { 'Content-Type': 'multipart/form-data; boundary=----WebKitFormBoundaryF1uyUD0tWdqmJqpl' },
// onUploadProgress: onUploadProgress,
timeout: 3 * 60 * 60 * 1000,
})
.then(() => {
Msg.success('machine.uploadSuccess');
setTimeout(() => {
getImages();
}, 3000);
})
.catch(() => {
// state.uploadProgressShow = false;
});
const file = content.file;
// 直接使用文件流作为 body不包装为 FormData
dockerApi.imageUpload.uploadRaw(
file,
{ id: String(props.id) },
{
onSuccess: () => {
Msg.success('docker.uploadSuccess');
setTimeout(() => {
getImages();
}, 1000);
},
onError: (error) => {
Msg.error(error.message);
},
}
);
Msg.info('docker.imageUploading');
};
@@ -210,3 +208,17 @@ const closeTerminal = () => {
state.terminalDialog.visible = false;
};
</script>
<style scoped>
.component-container {
height: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.component-container :deep(.el-table) {
flex: 1;
min-height: 0;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div class="card h-full">
<el-tabs v-model="activeName" @tab-change="handleTabChange">
<div class="card h-full flex flex-col">
<el-tabs v-model="activeName" @tab-change="handleTabChange" class="container-op-tabs">
<el-tab-pane :label="$t('docker.container')" :name="containerTab">
<ContainerList :id="containerConfId" />
</el-tab-pane>
@@ -12,18 +12,22 @@
</div>
</template>
<script lang="ts" setup>
import { ContainerOpComp } from '@/views/ops/docker/resource';
import { toRefs, reactive, onMounted, defineAsyncComponent, ref, getCurrentInstance } from 'vue';
import { defineAsyncComponent, onMounted, reactive, ref, toRefs } from 'vue';
const ContainerList = defineAsyncComponent(() => import('../container/ContainerList.vue'));
const ImageList = defineAsyncComponent(() => import('../image/ImageList.vue'));
const props = defineProps<{
containerId?: number;
tabKey?: string;
}>();
const emits = defineEmits(['init']);
const containerTab = 'containerTab';
const imageTab = 'imageTab';
const containerConfId = ref<number>(0);
const containerConfId = ref<number>(props.containerId || 0);
const state = reactive({
activeName: containerTab,
@@ -33,7 +37,6 @@ const state = reactive({
const { activeName } = toRefs(state);
onMounted(async () => {
emits('init', { name: ContainerOpComp.name, ref: getCurrentInstance()?.exposed });
state.activeName = containerTab;
});
@@ -45,3 +48,22 @@ defineExpose({
},
});
</script>
<style lang="scss" scoped>
.container-op-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
:deep(.el-tabs__content) {
flex: 1;
min-height: 0;
overflow: visible;
}
:deep(.el-tab-pane) {
height: 100%;
}
}
</style>

View File

@@ -1,7 +1,9 @@
import { ResourceTypeEnum } from '@/common/commonEnum';
import { defineAsyncComponent } from 'vue';
import { NodeType, TagTreeNode, ResourceComponentConfig, ResourceConfig } from '@/views/ops/component/tag';
import { NodeType, TagTreeNode } from '@/views/ops/component/tag';
import { dockerApi } from '@/views/ops/docker/api';
import type { ResourceConfig } from '@/views/ops/resource/resource';
import { createResourceOpTab } from '@/views/ops/resource/resourceOp';
import { defineAsyncComponent } from 'vue';
const ContainerConfList = defineAsyncComponent(() => import('../ContainerConfList.vue'));
const ContainerOp = defineAsyncComponent(() => import('./ContainerOp.vue'));
@@ -11,10 +13,18 @@ const Icon = {
color: ResourceTypeEnum.Container.extra.iconColor,
};
export const ContainerOpComp: ResourceComponentConfig = {
name: 'tag.containerOp',
component: ContainerOp,
icon: Icon,
const getContainerOpTab = async (container: any) => {
const tabKey = `${container.code}`;
return await createResourceOpTab({
key: tabKey,
name: container.name,
component: ContainerOp,
tabComponentProps: { icon: Icon },
});
};
const getContainerOpTabCompInst = async (container: any) => {
return (await getContainerOpTab(container)).componentInstance;
};
export const NodeTypeContainerTag = new NodeType(TagTreeNode.TagPath).withLoadNodesFunc(async (node: TagTreeNode) => {
@@ -23,11 +33,13 @@ export const NodeTypeContainerTag = new NodeType(TagTreeNode.TagPath).withLoadNo
// 把list 根据name字段排序
return res?.list
.sort((a: any, b: any) => a.name.localeCompare(b.name))
.map((x: any) => TagTreeNode.new(node, x.code, x.name, NodeTypeContainer).withIsLeaf(true).withParams(x).withIcon(Icon));
.map((x: any) => TagTreeNode.new(node, `${x.code}`, x.name, NodeTypeContainer).withIsLeaf(true).withParams(x).withIcon(Icon));
});
const NodeTypeContainer = new NodeType(11).withNodeClickFunc(async (node: TagTreeNode) => {
(await node.ctx?.addResourceComponent(ContainerOpComp)).init(node.params.id);
const container = node.params;
const compRef = await getContainerOpTabCompInst(container);
compRef?.init?.(container.id);
});
export default {

View File

@@ -1,6 +1,14 @@
<template>
<div>
<el-drawer :title="title" v-model="dialogVisible" :before-close="onCancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<el-drawer
:append-to-body="false"
:title="title"
v-model="dialogVisible"
:before-close="onCancel"
:destroy-on-close="true"
:close-on-click-modal="false"
size="40%"
>
<template #header>
<DrawerHeader :header="title" :back="onCancel" />
</template>
@@ -160,7 +168,7 @@ const onTestConn = async (authCert: any) => {
submitForm.authCerts = [authCert];
}
await testConnExec(submitForm);
state.form.version = testConnRes.value.version.number;
state.form.version = testConnRes.value.version?.number;
Msg.success('es.connSuccess');
};

View File

@@ -64,7 +64,6 @@
</template>
<script lang="ts" setup>
import { TagResourceTypePath } from '@/common/commonEnum';
import { formatDate } from '@/common/utils/format';
import { hasPerms } from '@/components/auth/auth';
import { TableColumn } from '@/components/pagetable';
@@ -73,7 +72,6 @@ import { SearchItem } from '@/components/pagetable/SearchForm';
import { Msg, useI18nCreateTitle, useI18nDeleteConfirm, useI18nEditTitle } from '@/hooks/useI18n';
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import ResourceAuthCert from '../component/ResourceAuthCert.vue';
import { getTagPathSearchItem } from '../component/tag';
import TagCodePath from '../component/TagCodePath.vue';
import { esApi } from './api';
@@ -91,7 +89,7 @@ const perms = {
delInstance: 'es:instance:del',
};
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('es.keywordPlaceholder'), getTagPathSearchItem(TagResourceTypePath.Es)];
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('es.keywordPlaceholder')];
const columns = ref([
TableColumn.new('name', 'common.name').isSlot('name').setAddWidth(15),

View File

@@ -7,6 +7,8 @@ export const esApi = {
deleteInstance: Api.newDelete('/es/instance/{id}'),
saveInstance: Api.newPost('/es/instance'),
testConn: Api.newPost('/es/instance/test-conn'),
exportData: Api.newPost('/es/instance/export/{instanceId}'),
exportProgress: Api.newGet('/es/instance/export/progress/{exportId}'),
// proxyGet: Api.newGet('/es/instance/proxy/{id}/{path}'),
// proxyPost: Api.newPost('/es/instance/proxy/{id}/{path}'),

View File

@@ -2,6 +2,7 @@
<template>
<el-drawer
:append-to-body="false"
:title="t('es.addIndex')"
v-model="visible"
size="50%"
@@ -18,7 +19,7 @@
</el-form-item>
<el-space>
<el-form-item>
<el-select v-model="formData.copyIdxName">
<el-select v-model="formData.copyIdxName" style="width: 200px;" filterable>
<el-option v-for="idx in idxNames" :key="idx" :value="idx" :label="idx" />
</el-select>
</el-form-item>

View File

@@ -1,5 +1,17 @@
<template>
<el-tabs v-model="state.tabName" type="card">
<el-tabs v-model="state.tabName" type="card" class="es-dashboard-tabs">
<el-tab-pane name="idxManage" :label="t('es.opIndex')">
<div class="idx-manage-content">
<EsIndexManage :instId="props.instId" @viewData="onViewIndexData" />
</div>
</el-tab-pane>
<el-tab-pane name="dataManage" :label="t('es.opDataManage')">
<div class="idx-manage-content">
<EsIndexData ref="esIndexDataRef" :instId="props.instId" />
</div>
</el-tab-pane>
<el-tab-pane name="nodesStats" v-loading="state.nodesStatsLoading" style="height: calc(100vh - 200px); overflow-y: auto">
<template #label>
{{ t('es.dashboard.nodes') }}
@@ -142,12 +154,12 @@
<el-card class="h-full">
<el-form :model="state.analyze" ref="analyzeFormRef" label-position="right" label-width="100">
<el-form-item :label="t('es.dashboard.idxName')" required prop="idxName">
<el-select v-model="state.analyze.idxName" filterable clearable @change="onSelectIdxField">
<el-select v-model="state.analyze.idxName" filterable clearable :teleported="false" @change="onSelectIdxField">
<el-option v-for="idx in state.idxFields" :key="idx.name" :value="idx.name" :label="idx.name" />
</el-select>
</el-form-item>
<el-form-item :label="t('es.dashboard.field')" required prop="field">
<el-select v-model="state.analyze.field" filterable clearable>
<el-select v-model="state.analyze.field" filterable clearable :teleported="false">
<el-option v-for="field in state.analyze.fields" :key="field" :value="field" :label="field" />
</el-select>
</el-form-item>
@@ -169,11 +181,14 @@
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { nextTick, onMounted, reactive, ref, watch } from 'vue';
import { esApi } from '@/views/ops/es/api';
import { formatByteSize } from '@/common/utils/format';
import { esApi } from '@/views/ops/es/api';
import dayjs from 'dayjs';
import { defineAsyncComponent, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const EsIndexData = defineAsyncComponent(() => import('./EsIndexData.vue'));
const EsIndexManage = defineAsyncComponent(() => import('./EsIndexManage.vue'));
const { t } = useI18n();
@@ -183,9 +198,16 @@ interface Props {
const props = defineProps<Props>();
const analyzeFormRef = ref();
const esIndexDataRef = ref();
const onViewIndexData = async (idxName: string) => {
state.tabName = 'dataManage';
await nextTick();
esIndexDataRef.value?.selectIndex(idxName);
};
const state = reactive({
tabName: 'nodesStats',
tabName: 'idxManage',
instInfo: [] as any[],
clusterHealth: [] as any[],
nodesStats: { _nodes: {} as any, nodes: [] as any[] } as any,
@@ -246,7 +268,7 @@ const fetchInstInfo = async () => {
function flattenObject(obj: Record<string, any>, parentKey = '', result: Record<string, any> = {}): Record<string, any> {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const newKey = parentKey ? `${parentKey}.${key}` : key;
if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
flattenObject(obj[key], newKey, result);
@@ -379,6 +401,29 @@ const onAnalyze = async () => {
</script>
<style scoped lang="scss">
.es-dashboard-tabs {
height: 100%;
display: flex;
flex-direction: column;
:deep(.el-tabs__content) {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
:deep(.el-tab-pane) {
flex: 1;
min-height: 0;
}
}
.idx-manage-content {
height: 100%;
}
.nodes-num {
font-size: 20px;
}

View File

@@ -1,5 +1,6 @@
<template>
<el-drawer
:append-to-body="false"
:title="`${model.isAdd ? t('common.add') : t('common.edit')} ${model.idxName}`"
v-model="visible"
:destroy-on-close="false"

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
<template>
<el-drawer
:append-to-body="false"
:title="t('es.indexDetail') + ' - ' + state.idxName"
v-model="visible"
size="50%"

View File

@@ -0,0 +1,472 @@
<template>
<div class="es-index-manage-box">
<div class="es-idx-toolbar flex-shrink-0">
<el-space>
<el-button type="primary" icon="Plus" size="small" @click="onAddIndex">{{ t('es.addIndex') }}</el-button>
<el-button icon="Refresh" size="small" @click="fetchIndices">{{ t('common.refresh') }}</el-button>
<el-button type="primary" size="small" @click="templateVisible = true">{{ t('es.templates') }}</el-button>
<el-checkbox v-model="showSysIndex" size="small" @change="fetchIndices">{{ t('es.contextmenu.index.showSys') }}</el-checkbox>
</el-space>
</div>
<div class="es-idx-table">
<el-auto-resizer>
<template #default="{ height, width }">
<el-table-v2
:columns="tableColumns"
:data="filteredIndices"
:width="width"
:height="height"
:row-height="40"
v-loading="loading"
:sort-state="sortState"
@column-sort="onColumnSort"
fixed
/>
</template>
</el-auto-resizer>
</div>
<!-- 查看/编辑 Mapping 对话框 -->
<el-drawer
v-model="mappingDrawer.visible"
:title="`${t('es.indexMapping')} - ${mappingDrawer.idxName}`"
size="55%"
:append-to-body="false"
:destroy-on-close="false"
>
<el-auto-resizer>
<template #default="{ height, width }">
<monaco-editor
v-model="mappingDrawer.content"
language="json"
:height="height - 60 + 'px'"
:width="width + 'px'"
:options="{ tabSize: 2, readOnly: !mappingDrawer.editable }"
/>
</template>
</el-auto-resizer>
<template #footer>
<el-space>
<el-button @click="mappingDrawer.editable = !mappingDrawer.editable">
{{ mappingDrawer.editable ? t('common.cancel') : t('common.edit') }}
</el-button>
<el-button v-if="mappingDrawer.editable" type="primary" @click="onSaveMapping" :loading="mappingDrawer.saving">{{
t('common.save')
}}</el-button>
</el-space>
</template>
</el-drawer>
<!-- 添加索引对话框 -->
<EsAddIndex :instId="props.instId" :idxNames="idxNames" v-model:visible="addIndexVisible" @success="fetchIndices" />
<!-- 索引迁移对话框 -->
<EsReindex
:instId="reindexState.instId"
:idxName="reindexState.idxName"
:idxNames="reindexState.idxNames"
v-model:visible="reindexState.visible"
@success="fetchIndices"
/>
<!-- 索引详情 -->
<EsIndexDetail ref="esIndexDetailRef" />
<!-- 索引模板管理 -->
<EsIndexTemplate :instId="props.instId" :version="esVersion" v-model="templateVisible" />
<!-- 添加别名对话框 -->
<el-dialog v-model="aliasDialog.visible" :title="t('es.addAlias')" width="400" :append-to-body="false">
<el-form @submit.prevent="onSubmitAddAlias">
<el-form-item :label="t('es.aliases')">
<el-input v-model="aliasDialog.name" autocomplete="off" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="aliasDialog.visible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="onSubmitAddAlias" :loading="aliasDialog.loading">{{ t('common.confirm') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, h, onMounted, reactive, ref } from 'vue';
import { ElButton, ElDropdown, ElDropdownItem, ElDropdownMenu, ElIcon, ElTag } from 'element-plus';
import { useI18n } from 'vue-i18n';
import { ArrowDown, Close, CopyDocument, Delete, Plus, Refresh, Select, Switch } from '@element-plus/icons-vue';
import { esApi } from '@/views/ops/es/api';
import { copyToClipboard } from '@/common/utils/string';
import { Msg, useI18nConfirm, useI18nDeleteConfirm } from '@/hooks/useI18n';
const MonacoEditor = defineAsyncComponent(() => import('@/components/monaco/MonacoEditor.vue'));
const EsAddIndex = defineAsyncComponent(() => import('./EsAddIndex.vue'));
const EsReindex = defineAsyncComponent(() => import('./EsReindex.vue'));
const EsIndexDetail = defineAsyncComponent(() => import('./EsIndexDetail.vue'));
const EsIndexTemplate = defineAsyncComponent(() => import('./EsIndexTemplate.vue'));
const { t } = useI18n();
const props = defineProps<{
instId: any;
}>();
const loading = ref(false);
const showSysIndex = ref(false);
const indices = ref<any[]>([]);
const aliasesMap = reactive<Record<string, string[]>>({});
const sortState = ref({ index: 'ascending' });
// tableColumns for el-table-v2
const tableColumns = computed(() => [
{
dataKey: 'index',
key: 'index',
title: t('es.indexName'),
width: 220,
sortable: true,
cellRenderer: ({ rowData }: any) => h('a', {
href: 'javascript:void(0)',
style: { color: 'var(--el-color-primary)', textDecoration: 'none' },
onClick: () => emit('viewData', rowData.index)
}, rowData.index)
},
{
dataKey: 'aliases',
key: 'aliases',
title: t('es.aliases'),
width: 200,
cellRenderer: ({ rowData }: any) => {
const aliases = aliasesMap[rowData.index] || [];
return h('div', { class: 'flex items-center gap-1 flex-wrap' },
[...aliases.map((alias: string) => h(ElTag, {
closable: true,
size: 'small',
type: 'info',
onClose: () => onRemoveAlias(rowData.index, alias)
}, () => alias)),
h(ElButton, {
link: true,
type: 'primary',
size: 'small',
onClick: () => onAddAlias(rowData)
}, () => h(ElIcon, () => h(Plus)))]
);
}
},
{
dataKey: 'health',
key: 'health',
title: t('es.health'),
width: 100,
sortable: true,
align: 'center',
cellRenderer: ({ rowData }: any) => h(ElTag, { size: 'small', type: getHealthTagType(rowData.health) }, () => rowData.health)
},
{
dataKey: 'status',
key: 'status',
title: t('es.status'),
width: 100,
sortable: true,
align: 'center',
cellRenderer: ({ rowData }: any) => h(ElTag, { size: 'small', type: rowData.status === 'open' ? 'success' : 'danger' }, () => rowData.status)
},
{ dataKey: 'pri', key: 'pri', title: 'pri', width: 70, align: 'center' },
{ dataKey: 'rep', key: 'rep', title: 'rep', width: 70, align: 'center' },
{
dataKey: 'docs.count',
key: 'docs.count',
title: t('es.docs'),
width: 120,
sortable: true,
align: 'right',
cellRenderer: ({ rowData }: any) => rowData['docs.count'] ?? '-'
},
{ dataKey: 'store.size', key: 'store.size', title: t('es.size'), width: 120, sortable: true, align: 'right' },
{
dataKey: 'operation',
key: 'operation',
title: t('common.operation'),
width: 200,
fixed: 'right',
align: 'center',
cellRenderer: ({ rowData }: any) => {
const dropdownTrigger = h(ElButton, { link: true, type: 'primary', size: 'small' }, () => [t('common.more'), h(ElIcon, () => h(ArrowDown))]);
const dropdownMenu = [
h(ElDropdownItem, { key: 'copyName', command: 'copyName' }, () => t('es.contextmenu.index.copyName')),
h(ElDropdownItem, { key: 'refresh', command: 'refresh' }, () => t('es.contextmenu.index.refresh')),
h(ElDropdownItem, { key: 'flush', command: 'flush' }, () => t('es.contextmenu.index.flush')),
h(ElDropdownItem, { key: 'clearCache', command: 'clearCache' }, () => t('es.contextmenu.index.clearCache')),
h(ElDropdownItem, { key: 'reindex', command: 'reindex' }, () => t('es.Reindex')),
rowData.status === 'open'
? h(ElDropdownItem, { key: 'close', command: 'close' }, () => t('es.contextmenu.index.Close'))
: h(ElDropdownItem, { key: 'open', command: 'open' }, () => t('es.contextmenu.index.Open')),
h(ElDropdownItem, { key: 'delete', command: 'delete', divided: true }, () => t('common.delete'))
];
return h('div', { class: 'flex items-center justify-center gap-1' }, [
h(ElButton, { link: true, type: 'primary', size: 'small', onClick: () => onViewDetail(rowData) }, () => t('es.indexDetail')),
h(ElDropdown, {
trigger: 'click',
onCommand: (cmd: string) => onRowCommand(cmd, rowData)
}, { default: () => dropdownTrigger, dropdown: () => h(ElDropdownMenu, {}, () => dropdownMenu) })
]);
}
}
]);
const addIndexVisible = ref(false);
const templateVisible = ref(false);
const esVersion = ref('');
const emit = defineEmits(['viewData']);
const esIndexDetailRef = ref();
const aliasDialog = reactive({
visible: false,
idxName: '',
name: '',
loading: false,
});
const reindexState = reactive({
instId: '' as any,
idxName: '',
visible: false,
idxNames: [] as string[],
});
const mappingDrawer = reactive({
visible: false,
idxName: '',
content: '',
editable: false,
saving: false,
});
const idxNames = computed(() => indices.value.map((idx: any) => idx.index).filter((n: string) => !n.startsWith('.')));
const filteredIndices = computed(() => {
const data = [...indices.value];
const entries = Object.entries(sortState.value);
if (entries.length === 0) return data;
const [key, order] = entries[0];
const dir = order === 'ascending' ? 1 : -1;
return data.sort((a, b) => {
const va = a[key] ?? '';
const vb = b[key] ?? '';
if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir;
return String(va).localeCompare(String(vb)) * dir;
});
});
onMounted(() => {
fetchIndices();
fetchVersion();
});
const fetchVersion = async () => {
try {
const res = await esApi.proxyReq('get', props.instId, '/');
esVersion.value = res?.version?.number || '';
} catch {
// non-critical
}
};
const fetchIndices = async () => {
loading.value = true;
try {
const res = await esApi.proxyReq('get', props.instId, `/_cat/indices/?h=index,health,status,uuid,pri,rep,docs.count,docs.deleted,store.size,sc,cd`);
const list = res || [];
indices.value = showSysIndex.value ? list : list.filter((idx: any) => !idx.index.startsWith('.'));
// Fetch aliases for all indices
await fetchAliases();
} finally {
loading.value = false;
}
};
const fetchAliases = async () => {
try {
const res = await esApi.proxyReq('get', props.instId, '/_alias');
// Clear and rebuild
for (const key of Object.keys(aliasesMap)) {
delete aliasesMap[key];
}
for (const idxName of Object.keys(res || {})) {
const aliases = Object.keys(res[idxName]?.aliases || {});
if (aliases.length > 0) {
aliasesMap[idxName] = aliases;
}
}
} catch {
// Alias fetch failure is non-critical
}
};
const onColumnSort = ({ key, order }: any) => {
sortState.value = { [key]: order } as any;
};
const onAddIndex = () => {
addIndexVisible.value = true;
};
const getHealthTagType = (health: string) => {
return health == 'green' ? 'success' : health == 'yellow' ? 'warning' : 'danger';
};
// ---- Index operations ----
const onRowCommand = async (cmd: string, row: any) => {
switch (cmd) {
case 'copyName':
await copyToClipboard(row.index);
break;
case 'refresh':
await esApi.proxyReq('post', props.instId, `/${row.index}/_refresh`);
Msg.operateSuccess();
break;
case 'mapping':
await onViewMapping(row);
break;
case 'flush':
await onFlushIndex(row);
break;
case 'clearCache':
await onClearCache(row);
break;
case 'reindex':
onReindex(row);
break;
case 'close':
await onCloseIndex(row);
break;
case 'open':
await onOpenIndex(row);
break;
case 'delete':
await onDeleteIndex(row);
break;
}
};
const onViewDetail = (row: any) => {
esIndexDetailRef.value?.open({ idxName: row.index, instId: props.instId });
};
const onReindex = async (row: any) => {
reindexState.instId = props.instId;
reindexState.idxName = row.index;
reindexState.idxNames = idxNames.value.filter((n: string) => n !== row.index);
reindexState.visible = true;
};
const onViewMapping = async (row: any) => {
const res = await esApi.proxyReq('get', props.instId, `/${row.index}/_mappings`);
mappingDrawer.idxName = row.index;
mappingDrawer.content = JSON.stringify(res[row.index]?.mappings || {}, null, 2);
mappingDrawer.editable = false;
mappingDrawer.saving = false;
mappingDrawer.visible = true;
};
const onSaveMapping = async () => {
mappingDrawer.saving = true;
try {
await esApi.proxyReq('put', props.instId, `/${mappingDrawer.idxName}/_mappings`, JSON.parse(mappingDrawer.content));
Msg.saveSuccess();
mappingDrawer.editable = false;
} finally {
mappingDrawer.saving = false;
}
};
const onCloseIndex = async (row: any) => {
await useI18nConfirm('es.closeIndexConfirm', { name: row.index });
await esApi.proxyReq('post', props.instId, `/${row.index}/_close`);
row.status = 'close';
Msg.operateSuccess();
};
const onOpenIndex = async (row: any) => {
await useI18nConfirm('es.openIndexConfirm', { name: row.index });
await esApi.proxyReq('post', props.instId, `/${row.index}/_open`);
row.status = 'open';
Msg.operateSuccess();
};
const onFlushIndex = async (row: any) => {
await esApi.proxyReq('post', props.instId, `/${row.index}/_flush`);
Msg.operateSuccess();
};
const onClearCache = async (row: any) => {
await useI18nConfirm('es.clearCacheConfirm', { name: row.index });
await esApi.proxyReq('post', props.instId, `/${row.index}/_cache/clear`);
Msg.operateSuccess();
};
const onDeleteIndex = async (row: any) => {
await useI18nDeleteConfirm(row.index);
await esApi.proxyReq('delete', props.instId, row.index);
Msg.deleteSuccess();
await fetchIndices();
};
// ---- Alias operations ----
const onAddAlias = (row: any) => {
aliasDialog.idxName = row.index;
aliasDialog.name = '';
aliasDialog.loading = false;
aliasDialog.visible = true;
};
const onSubmitAddAlias = async () => {
if (!aliasDialog.name) return;
aliasDialog.loading = true;
try {
await esApi.proxyReq('put', props.instId, `/${aliasDialog.idxName}/_alias/${aliasDialog.name}`);
Msg.saveSuccess();
// Update local aliases
if (!aliasesMap[aliasDialog.idxName]) {
aliasesMap[aliasDialog.idxName] = [];
}
aliasesMap[aliasDialog.idxName].push(aliasDialog.name);
aliasDialog.visible = false;
} finally {
aliasDialog.loading = false;
}
};
const onRemoveAlias = async (idxName: string, alias: string) => {
await useI18nDeleteConfirm(`${t('es.aliases')}: ${alias}`);
await esApi.proxyReq('delete', props.instId, `/${idxName}/_alias/${alias}`);
Msg.deleteSuccess();
// Update local aliases
if (aliasesMap[idxName]) {
aliasesMap[idxName] = aliasesMap[idxName].filter((a: string) => a !== alias);
}
};
</script>
<style scoped lang="scss">
.es-index-manage-box {
height: 100%;
display: flex;
flex-direction: column;
}
.es-idx-toolbar {
padding: 6px 8px;
border-bottom: 1px solid var(--el-border-color-light);
}
.es-idx-table {
flex: 1;
min-height: 0;
}
</style>

View File

@@ -1,5 +1,6 @@
<template>
<el-drawer
:append-to-body="false"
size="50%"
:destroy-on-close="false"
:close-on-click-modal="false"
@@ -53,6 +54,7 @@
</el-drawer>
<el-drawer
:append-to-body="false"
size="50%"
:destroy-on-close="true"
:close-on-click-modal="false"

View File

@@ -1,5 +1,6 @@
<template>
<el-drawer
:append-to-body="false"
:title="t('es.Reindex')"
v-model="visible"
size="40%"

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,10 @@
import { defineAsyncComponent } from 'vue';
import { ResourceTypeEnum, TagResourceTypeEnum } from '@/common/commonEnum';
import { sleep } from '@/common/utils/loading';
import { ContextmenuItem } from '@/components/contextmenu';
import { NodeType, TagTreeNode } from '@/views/ops/component/tag';
import { esApi } from '@/views/ops/es/api';
import { i18n } from '@/i18n';
import { NodeType, TagTreeNode, ResourceComponentConfig } from '@/views/ops/component/tag';
import { ResourceConfig } from '../../component/tag';
import type { ResourceConfig } from '@/views/ops/resource/resource';
import { createResourceOpTab } from '@/views/ops/resource/resourceOp';
import { defineAsyncComponent } from 'vue';
const Icon = {
name: ResourceTypeEnum.Es.extra.icon,
@@ -13,151 +12,39 @@ const Icon = {
};
const EsInstanceList = defineAsyncComponent(() => import('../EsInstanceList.vue'));
const EsDataOp = defineAsyncComponent(() => import('./EsDataOp.vue'));
const EsDashboard = defineAsyncComponent(() => import('../component/EsDashboard.vue'));
const NodeEs = defineAsyncComponent(() => import('./NodeEs.vue'));
const NodeEsIndex = defineAsyncComponent(() => import('./NodeEsIndex.vue'));
export const EsOpComp: ResourceComponentConfig = {
name: 'tag.esDataOp',
component: EsDataOp,
icon: Icon,
};
// tagpath 节点类型
const NodeTypeEsTag = new NodeType(TagTreeNode.TagPath)
.withContextMenuItems([
new ContextmenuItem('refresh', 'common.refresh')
.withIcon('refresh')
.withOnClick(async (nodeData: TagTreeNode) => (await nodeData.ctx?.addResourceComponent(EsOpComp)).reloadNode(nodeData.key)),
])
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
parentNode.ctx?.addResourceComponent(EsOpComp);
// 加载es实例列表
const res = await esApi.instances.request({ tagPath: parentNode.params.tagPath });
if (!res.total) {
return [];
}
const insts = res.list;
await sleep(100);
return insts?.map((x: any) => {
x.tagPath = parentNode.key;
return TagTreeNode.new(parentNode, `es.inst.${x.code}`, x.name, NodeTypeInst).withNodeComponent(NodeEs).withParams(x);
});
const NodeTypeEsTag = new NodeType(TagTreeNode.TagPath).withLoadNodesFunc(async (parentNode: TagTreeNode) => {
// 加载es实例列表
const res = await esApi.instances.request({ tagPath: parentNode.params.tagPath });
if (!res.total) {
return [];
}
const insts = res.list;
await sleep(100);
return insts?.map((x: any) => {
x.tagPath = parentNode.key;
return TagTreeNode.new(parentNode, `${x.code}`, x.name, NodeTypeInst).withNodeComponent(NodeEs).withIsLeaf(true).withParams(x);
});
});
// 加载实例列表
const NodeTypeInst = new NodeType(1)
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
let oiKey = `es.${params.id}.opIndex`;
let bsKey = `es.${params.id}.opBasicSearch`;
let ssKey = `es.${params.id}.opSeniorSearch`;
let dbKey = `es.${params.id}.opDashboard`;
let stKey = `es.${params.id}.opSettings`;
let tpKey = `es.${params.id}.optemplates`;
let nodeParams = { inst: params, instId: params.id };
return [
TagTreeNode.new(parentNode, oiKey, i18n.global.t('es.opIndex'), NodeTypeIndexs).withParams(nodeParams).withIcon({ name: 'Document' }),
// new TagTreeNode(ssKey, t('es.opSeniorSearch'), NodeTypeSeniorSearch).withParams(nodeParams).withIsLeaf(true),
// new TagTreeNode(dbKey, t('es.opDashboard'), NodeTypeDashboard).withParams(nodeParams).withIsLeaf(true),
// new TagTreeNode(stKey, t('es.opSettings'), NodeTypeSettings).withParams(nodeParams),
];
})
.withNodeClickFunc(async (nodeData: TagTreeNode) => {
// 添加一个dashboard tab
(await nodeData.ctx?.addResourceComponent(EsOpComp)).onInstClick(nodeData);
});
const NodeTypeIndexs = new NodeType(2)
.withContextMenuItems([
new ContextmenuItem('refresh', 'common.refresh')
.withIcon('refresh')
.withOnClick(async (nodeData: TagTreeNode) =>
(await nodeData.ctx?.addResourceComponent(EsOpComp)).onRefreshIndices(nodeData.params.instId, nodeData.key)
),
new ContextmenuItem('addIndex', 'es.contextmenu.index.addIndex')
.withIcon('plus')
.withOnClick(async (nodeData: TagTreeNode) => (await nodeData.ctx?.addResourceComponent(EsOpComp)).onAddIndex(nodeData)),
new ContextmenuItem('showSys', 'es.contextmenu.index.showSys')
.withIcon('View')
.withOnClick(async (nodeData: TagTreeNode) => (await nodeData.ctx?.addResourceComponent(EsOpComp)).onShowSysIndex(nodeData)),
new ContextmenuItem('idxTemplate', 'es.templates')
.withIcon('DocumentCopy')
.withOnClick(async (nodeData: TagTreeNode) => (await nodeData.ctx?.addResourceComponent(EsOpComp)).onShowTemplate(nodeData)),
])
.withLoadNodesFunc(async (parentNode: TagTreeNode) => {
const params = parentNode.params;
console.log(params);
// 展示索引列表,显示索引名,文档总数
// 加载索引列表
let indicesRes = await (await parentNode.ctx?.addResourceComponent(EsOpComp)).loadIdxs(params);
let idxNodes = [];
for (let idx of indicesRes) {
idxNodes.push(
TagTreeNode.new(parentNode, idx.key, idx.idxName, NodeTypeIndex)
.withIsLeaf(true)
.withParams({
parentKey: parentNode.key,
...idx,
})
.withNodeComponent(NodeEsIndex)
);
}
return idxNodes;
});
// 索引操作
const NodeTypeIndex = new NodeType(3)
.withContextMenuItems([
// 右键菜单支持:复制名字、新增别名、迁移索引、关闭、启用、删除、数据浏览、跳转基础查询、跳转高级查询
new ContextmenuItem('copyName', 'es.contextmenu.index.copyName')
.withIcon('copyDocument')
.withOnClick(async (nodeData: TagTreeNode) => (await nodeData.ctx?.addResourceComponent(EsOpComp)).onIdxCopyName(nodeData)),
new ContextmenuItem('refresh', 'es.contextmenu.index.refresh')
.withIcon('refresh')
.withOnClick(async (nodeData: TagTreeNode) => (await nodeData.ctx?.addResourceComponent(EsOpComp)).onRefreshIdx(nodeData)),
new ContextmenuItem('clearCache', 'es.contextmenu.index.clearCache')
.withIcon('refresh')
.withOnClick(async (nodeData: TagTreeNode) => (await nodeData.ctx?.addResourceComponent(EsOpComp)).onClearIdxCache(nodeData)),
new ContextmenuItem('flush', 'es.contextmenu.index.flush')
.withIcon('refresh')
.withOnClick(async (nodeData: TagTreeNode) => (await nodeData.ctx?.addResourceComponent(EsOpComp)).onFlushIdx(nodeData)),
new ContextmenuItem('Reindex', 'es.Reindex')
.withIcon('Switch')
.withOnClick(async (nodeData: TagTreeNode) => (await nodeData.ctx?.addResourceComponent(EsOpComp)).onIdxReindex(nodeData)),
new ContextmenuItem('Close', 'es.contextmenu.index.Close')
.withIcon('Close')
.withOnClick(async (nodeData: TagTreeNode) => (await nodeData.ctx?.addResourceComponent(EsOpComp)).onIdxClose(nodeData))
.withHideFunc((data: any) => {
return data.params.idx.status !== 'open';
}),
new ContextmenuItem('Open', 'es.contextmenu.index.Open')
.withIcon('Select')
.withOnClick(async (nodeData: TagTreeNode) => (await nodeData.ctx?.addResourceComponent(EsOpComp)).onIdxOpen(nodeData))
.withHideFunc((data: any) => {
return data.params.idx.status === 'open';
}),
new ContextmenuItem('Delete', 'es.contextmenu.index.Delete')
.withIcon('Delete')
.withOnClick(async (nodeData: TagTreeNode) => (await nodeData.ctx?.addResourceComponent(EsOpComp)).onIdxDelete(nodeData)),
new ContextmenuItem('BaseSearch', 'es.contextmenu.index.BaseSearch')
.withIcon('Search')
.withOnClick(async (nodeData: TagTreeNode) => (await nodeData.ctx?.addResourceComponent(EsOpComp)).onIdxBaseSearch(nodeData)),
// new ContextmenuItem('SeniorSearch', 'es.contextmenu.index.SeniorSearch').withIcon('Search').withOnClick((data: any) => onIdxSeniorSearch(data)),
new ContextmenuItem('IndexDetail', 'es.indexDetail')
.withIcon('InfoFilled')
.withOnClick(async (nodeData: TagTreeNode) => (await nodeData.ctx?.addResourceComponent(EsOpComp)).onIndexDetail(nodeData)),
])
.withNodeClickFunc(async (nodeData: TagTreeNode) => {
const params = nodeData.params;
(await nodeData.ctx?.addResourceComponent(EsOpComp)).loadIndexData(params.params.inst.id, params);
const NodeTypeInst = new NodeType(1).withNodeClickFunc(async (nodeData: TagTreeNode) => {
const inst = nodeData.params;
const tabKey = `${inst.code}`;
createResourceOpTab({
key: tabKey,
name: inst.name,
component: EsDashboard,
componentProps: {
instId: inst.id,
},
tabComponentProps: { icon: Icon },
});
});
export default {
order: 5,

View File

@@ -1,6 +1,6 @@
<template>
<div>
<el-drawer :title="title" v-model="dialogVisible" :before-close="onCancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<el-drawer :append-to-body="false" :title="title" v-model="dialogVisible" :before-close="onCancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<template #header>
<DrawerHeader :header="title" :back="onCancel" />
</template>

View File

@@ -262,7 +262,6 @@
</template>
<script lang="ts" setup>
import { TagResourceTypePath } from '@/common/commonEnum';
import { formatByteSize, formatDate } from '@/common/utils/format';
import { hasPerms } from '@/components/auth/auth';
import { TableColumn } from '@/components/pagetable';
@@ -272,7 +271,6 @@ import { Msg, useI18nDeleteConfirm } from '@/hooks/useI18n';
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { getTagPathSearchItem } from '../component/tag';
import TagCodePath from '../component/TagCodePath.vue';
import { getMachineTerminalSocketUrl, machineApi } from './api';
import { MachineProtocolEnum } from './enums';
@@ -310,10 +308,7 @@ const perms = {
terminal: 'machine:terminal',
};
const searchItems = [
SearchItem.input('keyword', 'common.keyword').withPlaceholder('machine.keywordPlaceholder'),
getTagPathSearchItem(TagResourceTypePath.MachineAuthCert),
];
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('machine.keywordPlaceholder')];
const columns = [
TableColumn.new('name', 'common.name').isSlot('name').setAddWidth(15),

View File

@@ -97,14 +97,18 @@ const state = reactive({
const { dialogVisible, query, playerDialogVisible, execCmdsDialogVisible } = toRefs(state);
watch(props, async (newValue: any) => {
const visible = newValue.visible;
state.dialogVisible = visible;
if (visible) {
state.query.machineId = newValue.machineId;
state.title = newValue.title;
}
});
watch(
props,
async (newValue: any) => {
const visible = newValue.visible;
state.dialogVisible = visible;
if (visible) {
state.query.machineId = newValue.machineId;
state.title = newValue.title;
}
},
{ immediate: true }
);
const getTermOps = async () => {
pageTableRef.value.search();

View File

@@ -105,21 +105,25 @@ const state = reactive({
const { dialogVisible, stats, netInter } = toRefs(state);
watch(props, async (newValue: any) => {
const visible = newValue.visible;
if (visible) {
await setStats();
}
state.dialogVisible = visible;
if (visible) {
initCharts();
}
});
const setStats = async () => {
state.stats = await machineApi.stats.request({ id: props.machineId });
};
watch(
props,
async (newValue: any) => {
const visible = newValue.visible;
if (visible) {
await setStats();
}
state.dialogVisible = visible;
if (visible) {
initCharts();
}
},
{ immediate: true }
);
const onRefresh = async () => {
await setStats();
initCharts();

View File

@@ -1,6 +1,13 @@
<template>
<div class="file-manage">
<el-dialog :title="$t('machine.process')" v-model="dialogVisible" :destroy-on-close="true" :show-close="true" :before-close="handleClose" width="65%">
<el-dialog
:title="$t('machine.process') + `: ${title}`"
v-model="dialogVisible"
:destroy-on-close="true"
:show-close="true"
:before-close="handleClose"
width="65%"
>
<div class="card p-1!">
<el-row>
<el-col :span="4">
@@ -123,14 +130,6 @@ const state = reactive({
const { dialogVisible, params, processList } = toRefs(state);
watch(props, (newValue) => {
if (props.machineId) {
state.params.id = props.machineId;
getProcess();
}
state.dialogVisible = newValue.visible;
});
const getProcess = async () => {
const res = await machineApi.process.request(state.params);
// 解析字符串
@@ -173,6 +172,18 @@ const getProcess = async () => {
state.processList = ps as any;
};
watch(
props,
(newValue) => {
if (props.machineId) {
state.params.id = props.machineId;
getProcess();
}
state.dialogVisible = newValue.visible;
},
{ immediate: true }
);
const confirmKillProcess = async (pid: any) => {
await machineApi.killProcess.request({
pid,

View File

@@ -1,6 +1,7 @@
<template>
<div>
<el-drawer
:append-to-body="false"
:title="title"
v-model="dialogVisible"
:close-on-click-modal="false"

View File

@@ -67,7 +67,7 @@
width="80%"
:close-on-click-modal="false"
:modal="false"
@close="closeTermnial"
@close="closeTerminal"
body-class="h-[65vh]"
draggable
append-to-body
@@ -98,10 +98,9 @@
import { DynamicFormDialog } from '@/components/dynamic-form';
import { TableColumn } from '@/components/pagetable';
import PageTable from '@/components/pagetable/PageTable.vue';
import { SearchItem } from '@/components/pagetable/SearchForm';
import { OptionsApi } from '@/components/pagetable/SearchForm/index';
import { SearchItem, OptionsApi } from '@/components/pagetable/SearchForm';
import { Msg, useI18nCreateTitle, useI18nDeleteConfirm, useI18nEditTitle } from '@/hooks/useI18n';
import { defineAsyncComponent, reactive, ref, Ref, toRefs } from 'vue';
import { defineAsyncComponent, nextTick, onMounted, reactive, ref, Ref, toRefs, watch } from 'vue';
import { getMachineTerminalSocketUrl, machineApi } from './api';
import { ScriptResultEnum, ScriptTypeEnum } from './enums';
@@ -174,7 +173,7 @@ const state = reactive({
const { columns, selectionData, query, editDialog, scriptParamsDialog, resultDialog, terminalDialog } = toRefs(state);
const getScripts = async () => {
pageTableRef.value.search();
pageTableRef.value?.search();
};
const checkScriptType = (query: any) => {
@@ -215,7 +214,7 @@ const run = async (script: any) => {
if (script.type == ScriptResultEnum.Result.value || noResult) {
const res = await machineApi.runScript.request({
machineId: props.machineId,
ac: props.authCertName,
acName: props.authCertName,
scriptId: script.id,
params: JSON.stringify(state.scriptParamsDialog.params),
});
@@ -254,7 +253,7 @@ function templateResolve(template: string, param: any) {
});
}
const closeTermnial = () => {
const closeTerminal = () => {
state.terminalDialog.visible = false;
};
@@ -293,5 +292,9 @@ const handleClose = () => {
state.query.type = ScriptTypeEnum.Private.value;
state.scriptParamsDialog.paramsFormItem = [];
};
onMounted(() => {
nextTick(getScripts);
});
</script>
<style lang="scss"></style>

View File

@@ -24,7 +24,7 @@ export const machineApi = {
del: Api.newDelete('/machines/{id}'),
scripts: Api.newGet('/machines/{machineId}/scripts'),
scriptCategorys: Api.newGet('/machines/scripts/categorys'),
runScript: Api.newGet('/machines/scripts/{scriptId}/{ac}/run'),
runScript: Api.newGet('/machines/scripts/{scriptId}/{acName}/run'),
saveScript: Api.newPost('/machines/{machineId}/scripts'),
deleteScript: Api.newDelete('/machines/{machineId}/scripts/{scriptId}'),
// 获取配置文件列表
@@ -117,8 +117,8 @@ export function uploadFile(file: File, params: UploadParams, options: UploadOpti
// 业务层生成 uploadId
const uploadId = params.uploadId || `upload_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
// 构建查询参数
const queryParams = new URLSearchParams({
// 构建查询参数对象
const queryParams: Record<string, string> = {
machineId: String(params.machineId),
authCertName: params.authCertName,
protocol: String(params.protocol),
@@ -126,15 +126,15 @@ export function uploadFile(file: File, params: UploadParams, options: UploadOpti
path: params.path,
uploadId: uploadId,
filename: file.name,
});
};
// 如果是文件夹上传,添加标识参数
if (params.isFolderUpload) {
queryParams.set('isFolderUpload', 'true');
queryParams['isFolderUpload'] = 'true';
}
// 直接使用文件流作为 body不包装为 FormData
const { abort } = machineApi.uploadFile.uploadRaw(file, queryParams.toString(), {
const { abort } = machineApi.uploadFile.uploadRaw(file, queryParams, {
...options,
});

View File

@@ -1,6 +1,7 @@
<template>
<div class="mock-data-dialog">
<el-drawer
:append-to-body="false"
:title="title"
v-model="dialogVisible"
:close-on-click-modal="false"

View File

@@ -42,6 +42,7 @@
</el-dialog>
<el-drawer
:append-to-body="false"
resizable
destroy-on-close
:title="fileDialog.title"
@@ -74,7 +75,7 @@
<script lang="ts" setup>
import EnumSelect from '@/components/enumselect/EnumSelect.vue';
import { Msg, useI18nDeleteConfirm } from '@/hooks/useI18n';
import { defineAsyncComponent, reactive, toRefs, watch } from 'vue';
import {defineAsyncComponent, onMounted, reactive, toRefs, watch} from 'vue';
import { machineApi } from '../api';
import { FileTypeEnum } from '../enums';
@@ -137,8 +138,11 @@ watch(props, async (newValue) => {
const getFiles = async () => {
try {
state.loading = true;
state.query.id = props.machineId as any;
if (!state.query.id){
return
}
state.loading = true;
const res = await files.request(state.query);
state.fileTable = res.list || [];
state.total = res.total;
@@ -147,6 +151,8 @@ const getFiles = async () => {
}
};
onMounted(getFiles)
const handlePageChange = (curPage: number) => {
state.query.pageNum = curPage;
getFiles();

View File

@@ -32,7 +32,7 @@
<el-button class="!ml-1" type="primary" circle size="small" icon="Refresh" @click="refresh()"> </el-button>
<!-- 文件&文件夹上传 -->
<el-dropdown class="machine-file-upload-exec" trigger="click" size="small">
<el-dropdown class="machine-file-upload-exec" trigger="click" size="small" :teleported="false">
<span>
<el-button
v-auth="'machine:file:upload'"
@@ -131,7 +131,7 @@
</el-button>
<el-button-group v-if="state.copyOrMvFile.paths.length > 0" size="small" class="!ml-1">
<el-tooltip effect="customized" raw-content placement="top">
<el-tooltip effect="customized" raw-content placement="top" :teleported="false">
<template #content>
<div v-for="path in state.copyOrMvFile.paths" v-bind:key="path">{{ path }}</div>
</template>
@@ -153,24 +153,26 @@
</template>
<template #default="scope">
<span v-if="scope.row.isFolder">
<SvgIcon :size="15" name="folder" color="#007AFF" />
</span>
<span v-else>
<SvgIcon :size="15" :name="scope.row.icon" />
</span>
<div class="w-full cursor-pointer" @click="getFile(scope.row)">
<span v-if="scope.row.isFolder">
<SvgIcon :size="15" name="folder" color="#007AFF" />
</span>
<span v-else>
<SvgIcon :size="15" :name="scope.row.icon" />
</span>
<span class="!ml-1 inline-block w-[90%]">
<div v-if="scope.row.nameEdit">
<el-input
@keyup.enter="fileRename(scope.row)"
:ref="(el: any) => el?.focus()"
@blur="filenameBlur(scope.row)"
v-model="scope.row.name"
/>
</div>
<el-link v-else @click="getFile(scope.row)" style="font-weight: bold" underline="never">{{ scope.row.name }}</el-link>
</span>
<span class="!ml-1 inline-block w-[90%]">
<div v-if="scope.row.nameEdit">
<el-input
@keyup.enter="fileRename(scope.row)"
:ref="(el: any) => el?.focus()"
@blur="filenameBlur(scope.row)"
v-model="scope.row.name"
/>
</div>
<el-link v-else style="font-weight: bold" underline="never">{{ scope.row.name }}</el-link>
</span>
</div>
</template>
</el-table-column>
@@ -204,7 +206,7 @@
<el-table-column :width="100">
<template #header>
<el-popover placement="top" :width="270" trigger="hover">
<el-popover placement="top" :width="270" trigger="hover" :teleported="false">
<template #reference>
<SvgIcon name="QuestionFilled" :size="18" class="pointer-icon mr-2" />
</template>
@@ -308,7 +310,7 @@
<script lang="ts" setup>
import { Msg } from '@/hooks/useI18n';
import { ElInput } from 'element-plus';
import { computed, defineAsyncComponent, onMounted, reactive, ref, toRefs } from 'vue';
import { computed, defineAsyncComponent, getCurrentInstance, onMounted, reactive, ref, toRefs } from 'vue';
import { machineApi, uploadFile, uploadFolder } from '../api';
import { isTrue, notBlank } from '@/common/assert';
@@ -333,6 +335,7 @@ const props = defineProps({
fileId: { type: Number, default: 0 },
path: { type: String, default: '' },
isFolder: { type: Boolean, default: true },
tabKey: { type: String, default: '' },
});
const token = getToken();
@@ -379,7 +382,12 @@ const state = reactive({
const { basePath, nowPath, loading, fileNameFilter, fileContent, createFileDialog } = toRefs(state);
const emits = defineEmits(['init']);
// Init as MachineOp component
onMounted(async () => {
emits('init', { name: 'tag.machineOp', tabKey: props.tabKey, ref: getCurrentInstance()?.exposed });
state.basePath = props.path;
const machineId = props.machineId;
@@ -877,7 +885,7 @@ const dontOperate = (data: any) => {
return ls.indexOf(path) != -1;
};
defineExpose({ showFileContent });
defineExpose({ showFileContent, onRefresh: refresh });
</script>
<style lang="scss">
.machine-file-upload-exec {

View File

@@ -1,487 +0,0 @@
<template>
<div class="h-full machine-terminal-tabs">
<el-tabs v-if="state.tabs.size > 0" type="card" @tab-remove="onRemoveTab" v-model="state.activeTermName" class="!h-full w-full">
<el-tab-pane class="h-full! flex flex-col" closable v-for="dt in state.tabs.values()" :label="dt.label" :name="dt.key" :key="dt.key">
<template #label>
<el-popconfirm @confirm="handleReconnect(dt, true)" :title="$t('machine.reConnTips')" v-if="dt.type === 'terminal'">
<template #reference>
<el-icon
class="mr-1"
:color="EnumValue.getEnumByValue(TerminalStatusEnum, dt.status)?.extra?.iconColor"
:title="dt.status == TerminalStatusEnum.Connected.value ? '' : $t('machine.clickReConn')"
>
<Connection />
</el-icon>
</template>
</el-popconfirm>
<el-popover :show-after="1000" placement="bottom-start" trigger="hover" :width="250">
<template #reference>
<div>
<span class="machine-terminal-tab-label">{{ dt.label }}</span>
</div>
</template>
<template #default>
<el-descriptions :column="1" size="small">
<el-descriptions-item :label="$t('common.name')"> {{ dt.params?.name }} </el-descriptions-item>
<el-descriptions-item label="host"> {{ dt.params?.ip }} : {{ dt.params?.port }} </el-descriptions-item>
<el-descriptions-item label="username"> {{ dt.params?.selectAuthCert.username }} </el-descriptions-item>
<el-descriptions-item label="remark"> {{ dt.params?.remark }} </el-descriptions-item>
</el-descriptions>
</template>
</el-popover>
</template>
<!-- 终端类型 tab -->
<div v-if="dt.type === 'terminal'" class="terminal-wrapper flex-1 min-h-0">
<TerminalBody
v-if="dt.params.protocol == MachineProtocolEnum.Ssh.value"
:mount-init="false"
@status-change="terminalStatusChange(dt.key, $event)"
:ref="(el: any) => setTerminalRef(el, dt.key)"
:socket-url="dt.socketUrl"
:machine-id="dt.params.id"
:auth-cert-name="dt.authCert"
:file-id="0"
:protocol="dt.params.protocol"
/>
<machine-rdp
v-if="dt.params.protocol != MachineProtocolEnum.Ssh.value"
:machine-id="dt.params.id"
:auth-cert="dt.authCert"
:protocol="dt.params.protocol"
:ref="(el: any) => setTerminalRef(el, dt.key)"
@status-change="terminalStatusChange(dt.key, $event)"
/>
</div>
<!-- 文件操作类型 tab -->
<div v-if="dt.type === 'file'" class="file-wrapper flex-1 min-h-0">
<machine-file :machine-id="dt.machineId" :auth-cert-name="dt.authCertName" :protocol="dt.protocol" :file-id="dt.fileId" :path="dt.path" />
</div>
</el-tab-pane>
</el-tabs>
<el-dialog v-if="infoDialog.visible" v-model="infoDialog.visible">
<el-descriptions :title="$t('common.detail')" :column="3" border>
<el-descriptions-item :span="1.5" label="ID">{{ infoDialog.data.id }}</el-descriptions-item>
<el-descriptions-item :span="1.5" :label="$t('common.name')">{{ infoDialog.data.name }}</el-descriptions-item>
<el-descriptions-item :span="3" :label="$t('tag.relateTag')">
<TagCodePath :path="infoDialog.data.tags" />
</el-descriptions-item>
<el-descriptions-item :span="2" label="IP">{{ infoDialog.data.ip }}</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('machine.port')">{{ infoDialog.data.port }}</el-descriptions-item>
<el-descriptions-item :span="3" :label="$t('common.remark')">{{ infoDialog.data.remark }}</el-descriptions-item>
<el-descriptions-item :span="1.5" :label="$t('machine.sshTunnel')"
>{{ infoDialog.data.sshTunnelMachineId > 0 ? $t('common.yes') : $t('common.no') }}
</el-descriptions-item>
<el-descriptions-item :span="1.5" :label="$t('machine.terminalPlayback')"
>{{ infoDialog.data.enableRecorder == 1 ? $t('common.yes') : $t('common.no') }}
</el-descriptions-item>
<el-descriptions-item :span="2" :label="$t('common.createTime')">
{{ formatDate(infoDialog.data.createTime) }}
</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('common.creator')">
{{ infoDialog.data.creator }}
</el-descriptions-item>
<el-descriptions-item :span="2" :label="$t('common.updateTime')">
{{ formatDate(infoDialog.data.updateTime) }}
</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('common.modifier')">
{{ infoDialog.data.modifier }}
</el-descriptions-item>
</el-descriptions>
</el-dialog>
<process-list v-model:visible="processDialog.visible" v-model:machineId="processDialog.machineId" />
<script-manage
:title="serviceDialog.title"
v-model:visible="serviceDialog.visible"
v-model:machineId="serviceDialog.machineId"
:auth-cert-name="serviceDialog.authCertName"
/>
<file-conf-list
v-model:visible="fileDialog.visible"
:machine-id="fileDialog.machine?.id"
:auth-cert-name="fileDialog.machine?.selectAuthCert?.name"
:protocol="fileDialog.machine?.protocol"
:open-file-manager="false"
@select="onFileConfigSelect"
/>
<machine-stats v-model:visible="machineStatsDialog.visible" :machineId="machineStatsDialog.machineId" :title="machineStatsDialog.title" />
<machine-rec v-model:visible="machineRecDialog.visible" :machineId="machineRecDialog.machineId" :title="machineRecDialog.title" />
</div>
</template>
<script lang="ts" setup>
import EnumValue from '@/common/Enum';
import { formatDate } from '@/common/utils/format';
import { hasPerms } from '@/components/auth/auth';
import MachineRdp from '@/components/terminal-rdp/MachineRdp.vue';
import TerminalBody from '@/components/terminal/TerminalBody.vue';
import { TerminalStatus, TerminalStatusEnum } from '@/components/terminal/common';
import { ResourceOpCtx } from '@/views/ops/component/tag';
import MachineFile from '@/views/ops/machine/file/MachineFile.vue';
import { MachineOpComp } from '@/views/ops/machine/resource';
import { ResourceOpCtxKey } from '@/views/ops/resource/resource';
import { defineAsyncComponent, getCurrentInstance, inject, nextTick, onMounted, reactive, toRefs, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import TagCodePath from '../../component/TagCodePath.vue';
import { getMachineTerminalSocketUrl } from '../api';
import { MachineProtocolEnum } from '../enums';
// 组件
const ScriptManage = defineAsyncComponent(() => import('../ScriptManage.vue'));
const FileConfList = defineAsyncComponent(() => import('../file/FileConfList.vue'));
const MachineStats = defineAsyncComponent(() => import('../MachineStats.vue'));
const MachineRec = defineAsyncComponent(() => import('../MachineRec.vue'));
const ProcessList = defineAsyncComponent(() => import('../ProcessList.vue'));
const { t } = useI18n();
const router = useRouter();
// 机器信息类型定义
interface MachineInfo {
id: number;
name: string;
ip: string;
port: number;
protocol: number;
remark?: string;
selectAuthCert: {
name: string;
username: string;
};
}
const perms = {
addMachine: 'machine:add',
updateMachine: 'machine:update',
delMachine: 'machine:del',
terminal: 'machine:terminal',
closeCli: 'machine:close-cli',
};
// 该用户拥有的的操作列按钮权限使用v-if进行判断v-auth对el-dropdown-item无效
const actionBtns = hasPerms([perms.updateMachine, perms.closeCli]);
const emits = defineEmits(['init']);
const resourceOpCtx: ResourceOpCtx | undefined = inject(ResourceOpCtxKey);
const state = reactive({
defaultExpendKey: [] as any,
params: {
pageNum: 1,
pageSize: 0,
ip: null,
name: null,
tagPath: '',
},
infoDialog: {
visible: false,
data: null as any,
},
serviceDialog: {
visible: false,
machineId: 0,
authCertName: '',
title: '',
},
processDialog: {
visible: false,
machineId: 0,
},
fileDialog: {
visible: false,
machine: null as MachineInfo | null,
},
machineStatsDialog: {
visible: false,
stats: null,
title: '',
machineId: 0,
},
machineRecDialog: {
visible: false,
machineId: 0,
title: '',
},
activeTermName: '',
tabs: new Map<string, any>(),
});
const { infoDialog, serviceDialog, processDialog, fileDialog, machineStatsDialog, machineRecDialog } = toRefs(state);
let openIds: any = {};
watch(
() => state.activeTermName,
(newValue, oldValue) => {
fitTerminal();
// 只有终端类型才需要 blur/focus
const oldTab = state.tabs.get(oldValue);
const newTab = state.tabs.get(newValue);
if (oldTab?.type === 'terminal') {
terminalRefs[oldValue]?.blur && terminalRefs[oldValue]?.blur();
}
if (newTab?.type === 'terminal') {
terminalRefs[newValue]?.focus && terminalRefs[newValue]?.focus();
}
resourceOpCtx?.setCurrentTreeKey(newTab?.authCert || newTab?.authCertName);
}
);
onMounted(() => {
emits('init', { name: MachineOpComp.name, ref: getCurrentInstance()?.exposed });
});
const openTerminal = (machine: any, ex?: boolean) => {
// 授权凭证名
const ac = machine.selectAuthCert.name;
// 新窗口打开
if (ex) {
if (machine.protocol == MachineProtocolEnum.Ssh.value) {
const { href } = router.resolve({
path: `/machine/terminal`,
query: {
ac,
name: machine.name,
},
});
window.open(href, '_blank');
return;
}
if (machine.protocol == MachineProtocolEnum.Rdp.value) {
const { href } = router.resolve({
path: `/machine/terminal-rdp`,
query: {
machineId: machine.id,
ac: ac,
name: machine.name,
},
});
window.open(href, '_blank');
return;
}
}
let { name } = machine;
const labelName = `${machine.selectAuthCert.username}@${name}`;
// 同一个机器的终端打开多次key后添加下划线和数字区分
openIds[ac] = openIds[ac] ? ++openIds[ac] : 1;
let sameIndex = openIds[ac];
let key = `${ac}_${sameIndex}`;
// 只保留name的15个字超出部分只保留前后10个字符中间用省略号代替
const label = labelName.length > 15 ? labelName.slice(0, 10) + '...' + labelName.slice(-10) : labelName;
let tab = {
key,
label: `${label}${sameIndex === 1 ? '' : ':' + sameIndex}`, // label组成为:总打开term次数+name+同一个机器打开的次数
type: 'terminal',
params: machine,
authCert: ac,
socketUrl: getMachineTerminalSocketUrl(ac),
status: TerminalStatusEnum.Disconnected.value,
};
state.tabs.set(key, tab);
nextTick(() => {
handleReconnect(tab);
state.activeTermName = key;
setTimeout(() => fitTerminal(), 300);
});
};
const serviceManager = (row: any) => {
const authCert = row.selectAuthCert;
state.serviceDialog.machineId = row.id;
state.serviceDialog.visible = true;
state.serviceDialog.authCertName = authCert.name;
state.serviceDialog.title = `${row.name} => ${authCert.username}@${row.ip}`;
};
/**
* 显示机器状态统计信息
*/
const showMachineStats = (machine: any) => {
state.machineStatsDialog.machineId = machine.id;
state.machineStatsDialog.title = `${t('machine.machineState')}: ${machine.name} => ${machine.ip}`;
state.machineStatsDialog.visible = true;
};
const showFileManage = (selectionData: any) => {
state.fileDialog.machine = selectionData;
state.fileDialog.visible = true;
};
/**
* 处理文件配置选择事件
*/
const onFileConfigSelect = (fileConfig: { fileId: number; path: string; name: string; type: number }) => {
const machine = state.fileDialog.machine;
if (!machine) return;
// 获取当前机器信息
const machineId = machine.id;
const authCertName = machine.selectAuthCert.name;
// 生成文件操作 tab 的 key
const fileTabKey = `file_${machineId}_${authCertName}_${fileConfig.fileId}`;
// 检查是否已经存在该文件操作 tab
if (state.tabs.has(fileTabKey)) {
// 如果已存在,直接切换到该 tab
state.activeTermName = fileTabKey;
return;
}
// 使用国际化前缀拼接 tab 标签
const labelName = `${t('machine.fileTabPrefix')}${machine.selectAuthCert.username}@${machine.name}/${fileConfig.name}`;
let tab = {
key: fileTabKey,
label: labelName.length > 25 ? labelName.slice(0, 18) + '...' + labelName.slice(-7) : labelName,
type: 'file',
machineId: machineId,
authCertName: authCertName,
protocol: machine.protocol,
fileId: fileConfig.fileId,
path: fileConfig.path,
params: machine,
};
state.tabs.set(fileTabKey, tab);
state.activeTermName = fileTabKey;
};
const showInfo = (info: any) => {
state.infoDialog.data = info;
state.infoDialog.visible = true;
};
const showProcess = (row: any) => {
state.processDialog.machineId = row.id;
state.processDialog.visible = true;
};
const showRec = (row: any) => {
state.machineRecDialog.title = `${row.name}[${row.ip}]-${t('machine.terminalPlayback')}`;
state.machineRecDialog.machineId = row.id;
state.machineRecDialog.visible = true;
};
const onRemoveTab = (targetName: string) => {
let activeTermName = state.activeTermName;
const tabNames = [...state.tabs.keys()];
for (let i = 0; i < tabNames.length; i++) {
const tabName = tabNames[i];
if (tabName !== targetName) {
continue;
}
const tab = state.tabs.get(targetName);
// 只有终端类型才需要关闭连接
if (tab?.type === 'terminal') {
terminalRefs[targetName]?.close();
}
state.tabs.delete(targetName);
if (activeTermName != targetName) {
break;
}
// 如果删除的 tab 是当前激活的 tab则切换到前一个或后一个 tab
const nextTab = tabNames[i + 1] || tabNames[i - 1];
if (nextTab) {
activeTermName = nextTab;
} else {
activeTermName = '';
}
state.activeTermName = activeTermName;
break;
}
};
const terminalStatusChange = (key: string, status: TerminalStatus) => {
state.tabs.get(key).status = status;
};
const terminalRefs: any = {};
const setTerminalRef = (el: any, key: any) => {
if (key) {
terminalRefs[key] = el;
}
};
const fitTerminal = () => {
setTimeout(() => {
let info = state.tabs.get(state.activeTermName);
// 只有终端类型才需要调整大小
if (info && info.type === 'terminal') {
terminalRefs[info.key]?.fitTerminal && terminalRefs[info.key]?.fitTerminal();
}
});
};
const handleReconnect = (tab: any, force = false) => {
// 只有终端类型才需要重连
if (tab?.type === 'terminal') {
terminalRefs[tab.key]?.init();
}
};
defineExpose({
openTerminal,
onResize: fitTerminal,
showInfo,
showProcess,
showRec,
showMachineStats,
showFileManage,
serviceManager,
});
</script>
<style lang="scss">
.machine-terminal-tabs {
--el-tabs-header-height: 30px;
.el-tabs {
--el-tabs-header-height: 30px;
}
.machine-terminal-tab-label {
font-size: 12px;
}
.el-tabs__header {
margin-bottom: 5px;
}
.el-tabs__item {
padding: 0 8px !important;
}
}
</style>

View File

@@ -1,13 +1,22 @@
import { ContextmenuItem } from '@/components/contextmenu';
import { ResourceTypeEnum } from '@/common/commonEnum';
import { defineAsyncComponent } from 'vue';
import { NodeType, TagTreeNode, ResourceComponentConfig, ResourceConfig } from '@/views/ops/component/tag';
import { ContextmenuItem } from '@/components/contextmenu';
import router from '@/router';
import { NodeType, TagTreeNode } from '@/views/ops/component/tag';
import { machineApi } from '@/views/ops/machine/api';
import { MachineProtocolEnum } from '@/views/ops/machine/enums';
import type { ResourceConfig } from '@/views/ops/resource/resource';
import { defineAsyncComponent } from 'vue';
import { createResourceOpTab, showResourceOpOverlay } from '../../resource/resourceOp';
const MachineList = defineAsyncComponent(() => import('../MachineList.vue'));
const MachineOp = defineAsyncComponent(() => import('./MachineOp.vue'));
const TerminalTab = defineAsyncComponent(() => import('./tabs/TerminalTab.vue'));
const TerminalTabLabel = defineAsyncComponent(() => import('./tabs/TerminalTabLabel.vue'));
const FileTab = defineAsyncComponent(() => import('./tabs/FileTab.vue'));
const ScriptManage = defineAsyncComponent(() => import('../ScriptManage.vue'));
const MachineDetailDialog = defineAsyncComponent(() => import('./tabs/MachineDetailDialog.vue'));
const MachineStats = defineAsyncComponent(() => import('../MachineStats.vue'));
const ProcessList = defineAsyncComponent(() => import('../ProcessList.vue'));
const MachineRec = defineAsyncComponent(() => import('../MachineRec.vue'));
const NodeMachineAc = defineAsyncComponent(() => import('./NodeMachineAc.vue'));
const MachineIcon = {
@@ -15,21 +24,14 @@ const MachineIcon = {
color: ResourceTypeEnum.Machine.extra.iconColor,
};
export const MachineOpComp: ResourceComponentConfig = {
name: 'tag.machineOp',
component: MachineOp,
icon: MachineIcon,
};
const FileIcon = { name: 'FolderOpened', color: '#E6A23C' };
export const NodeTypeMachineTag = new NodeType(TagTreeNode.TagPath).withLoadNodesFunc(async (node: TagTreeNode) => {
node.ctx?.addResourceComponent(MachineOpComp);
// 加载标签树下的机器列表
const res = await machineApi.list.request({ tagPath: node.params.tagPath });
// 把list 根据name字段排序
return res?.list
.sort((a: any, b: any) => a.name.localeCompare(b.name))
.map((x: any) =>
TagTreeNode.new(node, x.code, x.name, NodeTypeMachine)
TagTreeNode.new(node, `${x.code}`, x.name, NodeTypeMachine)
.withParams(x)
.withDisabled(x.status == -1 && x.protocol == MachineProtocolEnum.Ssh.value)
.withIcon(MachineIcon)
@@ -39,10 +41,9 @@ export const NodeTypeMachineTag = new NodeType(TagTreeNode.TagPath).withLoadNode
export const NodeTypeMachine = new NodeType(11)
.withLoadNodesFunc((node: TagTreeNode) => {
const machine = node.params;
// 获取授权凭证列表
const authCerts = machine.authCerts;
return authCerts.map((x: any) =>
TagTreeNode.new(node, x.name, x.username, NodeTypeAuthCert)
TagTreeNode.new(node, `${node.key}.${x.name}`, x.username, NodeTypeAuthCert)
.withNodeComponent(NodeMachineAc)
.withParams({ ...machine, selectAuthCert: x })
.withDisabled(machine.status == -1 && machine.protocol == MachineProtocolEnum.Ssh.value)
@@ -55,57 +56,167 @@ export const NodeTypeMachine = new NodeType(11)
})
.withContextMenuItems([
new ContextmenuItem('detail', 'common.detail').withIcon('More').withOnClick(async (node: TagTreeNode) => {
(await node.ctx?.addResourceComponent(MachineOpComp))?.showInfo(node.params);
const m = node.params;
// 显示机器详情弹窗(已存在则更新 props否则注册
showResourceOpOverlay('machine_detail', MachineDetailDialog, {
code: m.code,
});
}),
new ContextmenuItem('status', 'common.status')
.withIcon('Compass')
.withHideFunc((node: any) => node.params.protocol != MachineProtocolEnum.Ssh.value)
.withOnClick(async (node: TagTreeNode) => {
(await node.ctx?.addResourceComponent(MachineOpComp))?.showMachineStats(node.params);
const m = node.params;
// 显示机器状态弹窗
showResourceOpOverlay('machine_stats', MachineStats, {
machineId: m.id,
title: `${m.name} => ${m.ip}`,
});
}),
new ContextmenuItem('process', 'machine.process')
.withIcon('DataLine')
.withHideFunc((node: any) => node.params.protocol != MachineProtocolEnum.Ssh.value)
.withOnClick(async (node: TagTreeNode) => {
(await node.ctx?.addResourceComponent(MachineOpComp))?.showProcess(node.params);
const m = node.params;
// 显示进程列表弹窗
showResourceOpOverlay('machine_process', ProcessList, {
machineId: m.id,
title: `${m.name} => ${m.ip}`,
});
}),
new ContextmenuItem('edit', 'machine.terminalPlayback')
.withIcon('Compass')
.withOnClick(async (node: TagTreeNode) => {
(await node.ctx?.addResourceComponent(MachineOpComp))?.showRec(node.params);
const m = node.params;
showResourceOpOverlay('machine_rec', MachineRec, {
machineId: m.id,
title: `${m.name} => ${m.ip}`,
});
})
.withHideFunc((node: any) => node.params.enableRecorder == 1),
]);
export const NodeTypeAuthCert = new NodeType(12)
.withNodeDblclickFunc(async (node: TagTreeNode) => {
(await node.ctx?.addResourceComponent(MachineOpComp))?.openTerminal(node.params);
const m = node.params;
const key = `${m.code}.${m.selectAuthCert.name}.${new Date().getTime()}`;
createResourceOpTab({
key,
name: `${m.selectAuthCert.username}@${m.name}`,
component: TerminalTab,
componentProps: {
tabKey: key,
machineId: m.id,
authCertName: m.selectAuthCert.name,
protocol: m.protocol,
},
tabComponent: TerminalTabLabel,
tabComponentProps: {
icon: MachineIcon,
status: 'disconnected',
},
});
})
.withContextMenuItems([
new ContextmenuItem('term', 'machine.openTerminal')
.withIcon('Monitor')
.withPermission('machine:terminal')
.withOnClick(async (node: TagTreeNode) => {
(await node.ctx?.addResourceComponent(MachineOpComp))?.openTerminal(node.params);
const m = node.params;
const key = `${m.code}.${m.selectAuthCert.name}.${new Date().getTime()}`;
createResourceOpTab({
key,
name: `${m.selectAuthCert.username}@${m.name}`,
component: TerminalTab,
tabComponent: TerminalTabLabel,
tabComponentProps: {
icon: MachineIcon,
status: 'disconnected',
},
componentProps: {
tabKey: key,
machineId: m.id,
authCertName: m.selectAuthCert.name,
protocol: m.protocol,
},
});
}),
new ContextmenuItem('term-ex', 'machine.newTabOpenTerminal')
.withIcon('Monitor')
.withPermission('machine:terminal')
.withOnClick(async (node: TagTreeNode) => {
(await node.ctx?.addResourceComponent(MachineOpComp))?.openTerminal(node.params, true);
const machine = node.params;
const ac = machine.selectAuthCert.name;
if (machine.protocol == MachineProtocolEnum.Ssh.value) {
const { href } = router.resolve({
path: `/machine/terminal`,
query: {
ac,
name: machine.name,
},
});
window.open(href, '_blank');
return;
}
if (machine.protocol == MachineProtocolEnum.Rdp.value) {
const { href } = router.resolve({
path: `/machine/terminal-rdp`,
query: {
machineId: machine.id,
ac: ac,
name: machine.name,
},
});
window.open(href, '_blank');
return;
}
}),
new ContextmenuItem('files', 'machine.fileManage').withIcon('FolderOpened').withOnClick(async (node: any) => {
(await node.ctx?.addResourceComponent(MachineOpComp))?.showFileManage(node.params);
const m = node.params;
const acName = m.selectAuthCert.name;
// 直接打开文件管理 tabFileTab 内部会处理配置选择
const tabKey = `${m.code}.${acName}`;
createResourceOpTab({
key: tabKey,
name: `${m.selectAuthCert.username}@${m.name}`,
component: FileTab,
tabComponentProps: {
icon: FileIcon,
},
componentProps: {
tabKey: tabKey,
machineId: m.id,
authCertName: acName,
protocol: m.protocol,
},
});
}),
new ContextmenuItem('scripts', 'machine.scriptManage')
.withIcon('Files')
.withHideFunc((node: any) => node.params.protocol != MachineProtocolEnum.Ssh.value)
.withOnClick(async (node: any) => {
(await node.ctx?.addResourceComponent(MachineOpComp))?.serviceManager(node.params);
const m = node.params;
// 显示脚本管理弹窗(已存在则更新 props否则注册
showResourceOpOverlay('script_manage', ScriptManage, {
machineId: m.id,
authCertName: m.selectAuthCert.name,
title: `${m.name} => ${m.selectAuthCert.username}@${m.ip}`,
});
}),
]);

View File

@@ -0,0 +1,62 @@
<template>
<div class="machine-file-tab h-full">
<!-- 如果已选择文件配置显示文件管理器 -->
<MachineFile
v-if="selectedFileConf"
:machine-id="machineId"
:auth-cert-name="authCertName"
:protocol="protocol"
:file-id="selectedFileConf.fileId"
:path="selectedFileConf.path"
/>
<!-- 否则显示文件配置选择列表 -->
<FileConfList
v-else
:machine-id="machineId"
:auth-cert-name="authCertName"
:protocol="protocol"
:visible="true"
:open-file-manager="false"
@select="handleSelect"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import MachineFile from '@/views/ops/machine/file/MachineFile.vue';
import FileConfList from '@/views/ops/machine/file/FileConfList.vue';
const props = defineProps<{
tabKey?: string;
machineId: number;
authCertName: string;
protocol: number;
fileId?: number; // 可选:直接指定文件配置 ID
initialPath?: string; // 可选:初始路径
}>();
// 已选择的文件配置
const selectedFileConf = ref<{ fileId: number; path: string; name: string } | null>(
// 如果传入了 fileId 和 initialPath直接初始化
props.fileId && props.initialPath
? { fileId: props.fileId, path: props.initialPath, name: '' }
: null
);
// 处理文件配置选择
const handleSelect = (fileConf: any) => {
selectedFileConf.value = {
fileId: fileConf.fileId,
path: fileConf.path,
name: fileConf.name,
};
};
</script>
<style lang="scss">
.machine-file-tab {
height: 100%;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<el-dialog v-model="dialogVisible" :title="$t('common.detail')" width="600px" :destroy-on-close="true">
<el-descriptions v-loading="loading" :column="3" border>
<el-descriptions-item :span="1" label="ID">{{ machineDetail.id }}</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('common.code')">{{ machineDetail.code }}</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('common.name')">{{ machineDetail.name }}</el-descriptions-item>
<el-descriptions-item :span="3" :label="$t('tag.relateTag')"><TagCodePath :code="machineDetail.code" /></el-descriptions-item>
<el-descriptions-item :span="2" label="IP">{{ machineDetail.ip }}</el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('machine.port')">{{ machineDetail.port }}</el-descriptions-item>
<el-descriptions-item :span="3" :label="$t('common.remark')">{{ machineDetail.remark }}</el-descriptions-item>
<el-descriptions-item :span="1.5" :label="$t('machine.sshTunnel')">
{{ machineDetail.sshTunnelMachineId > 0 ? $t('common.yes') : $t('common.no') }}
</el-descriptions-item>
<el-descriptions-item :span="1.5" :label="$t('machine.terminalPlayback')">
{{ machineDetail.enableRecorder == 1 ? $t('common.yes') : $t('common.no') }}
</el-descriptions-item>
<el-descriptions-item :span="2" :label="$t('common.createTime')">{{ formatDate(machineDetail.createTime) }} </el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('common.creator')">{{ machineDetail.creator }}</el-descriptions-item>
<el-descriptions-item :span="2" :label="$t('common.updateTime')">{{ formatDate(machineDetail.updateTime) }} </el-descriptions-item>
<el-descriptions-item :span="1" :label="$t('common.modifier')">{{ machineDetail.modifier }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
</template>
<script lang="ts" setup>
import { formatDate } from '@/common/utils/format';
import TagCodePath from '@/views/ops/component/TagCodePath.vue';
import { ref, watch } from 'vue';
import { machineApi } from '../../api';
const props = defineProps({
code: {
type: String,
required: true,
},
});
const dialogVisible = defineModel<boolean>('visible', { default: false });
const loading = ref(false);
const machineDetail = ref<any>({});
const getMachineDetail = async () => {
try {
machineDetail.value = {};
loading.value = true;
const res = await machineApi.list.request({
code: props.code,
});
if (res.total == 0) {
return;
}
machineDetail.value = res.list?.[0];
} finally {
loading.value = false;
}
};
// 监听 visible 变化,打开时加载数据
watch(
dialogVisible,
(val: boolean) => {
if (val) {
getMachineDetail();
}
},
{ immediate: true }
);
</script>

View File

@@ -0,0 +1,42 @@
<template>
<div class="machine-script-tab h-full">
<ScriptManage
:machine-id="machineId"
:auth-cert-name="authCertName"
:title="title"
v-model:visible="dialogVisible"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onActivated, watch } from 'vue';
import ScriptManage from '@/views/ops/machine/ScriptManage.vue';
const props = defineProps<{
tabKey?: string;
machineId: number;
authCertName: string;
title?: string;
}>();
// 控制 dialog 显示
const dialogVisible = ref(false);
// 组件挂载时打开 dialog
onMounted(() => {
dialogVisible.value = true;
});
// keep-alive 激活时也打开 dialog
onActivated(() => {
dialogVisible.value = true;
});
</script>
<style lang="scss">
.machine-script-tab {
height: 100%;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<div class="machine-terminal-tab h-full flex flex-col">
<!-- Terminal body -->
<div class="terminal-body flex-1 min-h-0">
<TerminalBody
v-if="protocol == MachineProtocolEnum.Ssh.value"
:mount-init="false"
@status-change="onStatusChange"
ref="terminalRef"
:socket-url="socketUrl"
:machine-id="machineId"
:auth-cert-name="authCertName"
:file-id="0"
:protocol="protocol"
/>
<MachineRdp
v-if="protocol != MachineProtocolEnum.Ssh.value"
:machine-id="machineId"
:auth-cert="authCertName"
:protocol="protocol"
ref="terminalRef"
@status-change="onStatusChange"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import MachineRdp from '@/components/terminal-rdp/MachineRdp.vue';
import TerminalBody from '@/components/terminal/TerminalBody.vue';
import { TerminalStatus, TerminalStatusEnum } from '@/components/terminal/common';
import { getMachineTerminalSocketUrl } from '@/views/ops/machine/api';
import { MachineProtocolEnum } from '@/views/ops/machine/enums';
import { updateTabComponentProps } from '@/views/ops/resource/resourceOp';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
const props = defineProps<{
tabKey?: string;
machineId: number;
authCertName: string;
protocol: number; // Machine protocol
}>();
const terminalRef = ref();
const status = ref(TerminalStatusEnum.Disconnected.value);
// 映射终端状态到 tab 标签状态
const getTabStatus = (terminalStatus: number): string => {
switch (terminalStatus) {
case TerminalStatusEnum.Connected.value:
return 'connected';
case TerminalStatusEnum.NoConnected.value:
return 'disconnected';
case TerminalStatusEnum.Error.value:
return 'error';
case TerminalStatusEnum.Disconnected.value:
default:
return 'disconnected';
}
};
// Watch status changes and update tab component props
watch(status, (newStatus: number) => {
if (props.tabKey) {
updateTabComponentProps(props.tabKey, {
status: getTabStatus(newStatus),
});
}
});
// Compute socket URL
const socketUrl = computed(() => {
return getMachineTerminalSocketUrl(props.authCertName);
});
onMounted(() => {
// Auto-connect terminal on mount
nextTick(() => {
handleReconnect();
setTimeout(() => fitTerminal(), 300);
});
});
const onStatusChange = (statusValue: TerminalStatus) => {
status.value = statusValue;
};
const handleReconnect = () => {
terminalRef.value?.init?.();
};
const fitTerminal = () => {
terminalRef.value?.fitTerminal?.();
};
const close = () => {
terminalRef.value?.close?.();
};
const focus = () => {
terminalRef.value?.focus?.();
};
const blur = () => {
terminalRef.value?.blur?.();
};
defineExpose({
onRefresh: handleReconnect,
onActivate: focus,
onResize: fitTerminal,
close,
fitTerminal,
focus,
blur,
});
</script>
<style lang="scss">
.machine-terminal-tab {
height: 100%;
display: flex;
flex-direction: column;
}
.terminal-body {
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<div class="terminal-tab-label flex items-center gap-1.5">
<!-- 连接状态指示器 -->
<span
class="w-2 h-2 rounded-full shrink-0 transition-all duration-300"
:class="statusClasses"
></span>
<!-- 终端图标 -->
<SvgIcon v-if="icon" :name="icon.name" :color="icon.color" class="text-xs shrink-0" />
<!-- 终端名称从外层 tab.name 获取 -->
<span class="max-w-[120px] overflow-hidden text-ellipsis" :title="tabName">{{ tabName }}</span>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import SvgIcon from '@/components/svgIcon/index.vue';
const props = withDefaults(
defineProps<{
tabName?: string; // 从外层 tab.name 传入
icon?: { name: string; color?: string };
status?: 'connected' | 'disconnected' | 'connecting' | 'error' | string;
}>(),
{
tabName: '终端',
status: 'disconnected',
}
);
// 计算状态样式
const statusClasses = computed(() => {
switch (props.status) {
case 'connected':
return 'bg-green-500';
case 'connecting':
return 'bg-yellow-500 animate-pulse';
case 'error':
return 'bg-red-500 animate-pulse';
case 'disconnected':
default:
return 'bg-red-500 animate-pulse';
}
});
</script>

View File

@@ -31,6 +31,7 @@
</el-table>
<el-drawer
:append-to-body="false"
:title="$t('machine.cmdConfig')"
v-model="dialogVisible"
:show-close="false"

View File

@@ -1,6 +1,6 @@
<template>
<div>
<el-drawer :title="title" v-model="dialogVisible" :before-close="onCancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<el-drawer :append-to-body="false" :title="title" v-model="dialogVisible" :before-close="onCancel" :destroy-on-close="true" :close-on-click-modal="false" size="40%">
<template #header>
<DrawerHeader :header="title" :back="onCancel" />
</template>

View File

@@ -37,17 +37,15 @@
</template>
<script setup lang="ts">
import { TagResourceTypePath } from '@/common/commonEnum';
import { TableColumn } from '@/components/pagetable';
import PageTable from '@/components/pagetable/PageTable.vue';
import { SearchItem } from '@/components/pagetable/SearchForm';
import { Msg, useI18nCreateTitle, useI18nDeleteConfirm, useI18nEditTitle } from '@/hooks/useI18n';
import { getTagPathSearchItem } from '@/views/ops/component/tag';
import { defineAsyncComponent, ref, Ref } from 'vue';
import ResourceAuthCert from '../component/ResourceAuthCert.vue';
import TagCodePath from '../component/TagCodePath.vue';
import { milvusApi, perms } from './api';
import type { IMilvus } from './types';
import TagCodePath from '../component/TagCodePath.vue';
import ResourceAuthCert from '../component/ResourceAuthCert.vue';
const MilvusEdit = defineAsyncComponent(() => import('./MilvusEdit.vue'));
@@ -60,7 +58,7 @@ const query = ref({
const selectionData = ref([]);
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('db.keywordPlaceholder'), getTagPathSearchItem(TagResourceTypePath.Db)];
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('db.keywordPlaceholder')];
const columns = ref([
TableColumn.new('name', 'common.name').isSlot('name').setAddWidth(15),

View File

@@ -88,7 +88,7 @@ export const milvusApi = {
insertSampleData: (milvusId: number, collection: string, data: any) =>
Api.newPost(`/milvus/${milvusId}/collections/${collection}/insert-sample-data?db=${db}`).request(withAc(data)),
importFile: (milvusId: number, collection: string, formData: FormData) =>
Api.newPost(`/milvus/${milvusId}/collections/${collection}/import-file?db=${db}`).request(formData),
Api.newPost(`/milvus/${milvusId}/collections/${collection}/import-file?db=${db}&ac=${currentAcName}`).request(formData),
// 用户权限
listUsers: (milvusId: number) => Api.newGet(`/milvus/${milvusId}/users`).request(withAc()),

View File

@@ -1,7 +1,8 @@
<template>
<el-space>
<el-tooltip :content="t('db.selectDbPlaceholder')" placement="top">
<el-select size="small" v-model="selectedDb" style="width: 150px" @change="onChangeDb">
<div class="component-container">
<el-space>
<el-tooltip :content="t('db.selectDbPlaceholder')" placement="top" :teleported="false">
<el-select size="small" v-model="selectedDb" style="width: 150px" @change="onChangeDb" :teleported="false">
<el-option v-for="item in dbs" :key="item.name" :value="item.name">{{ item.name }}</el-option>
</el-select>
</el-tooltip>
@@ -53,6 +54,8 @@
</el-table-column>
</el-table>
</div>
<CollectionsCreate v-model:visible="createDrawerVisible" :milvus-id="milvusId" :mode="drawerMode" :edit-data="editData" @success="loadList" />
<el-dialog v-model="aliasDialogVisible" :title="$t('milvus.addAlias')" width="400px">
@@ -79,13 +82,14 @@ import { useI18n } from 'vue-i18n';
import { milvusApi } from '../api';
import CollectionsCreate from './CollectionsCreate.vue';
const milvusStore = useMilvusStore();
const { dbs, selectedDb } = storeToRefs(milvusStore);
const { t } = useI18n();
const props = defineProps<{
milvusId: number;
tabKey?: string;
}>();
const milvusStore = useMilvusStore(props.tabKey || 'milvusStore');
const { dbs, selectedDb } = storeToRefs(milvusStore);
const emit = defineEmits(['changeTab']);
const list = ref<any[]>([]);
@@ -313,6 +317,18 @@ watch(
</script>
<style scoped>
.component-container {
height: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.component-container :deep(.el-table) {
flex: 1;
min-height: 0;
}
pre {
background: #f5f7fa;
padding: 15px;

View File

@@ -1,187 +1,189 @@
<template>
<!-- 顶部查询工具栏 -->
<el-space>
<el-button size="small" type="primary" @click="() => handleQuery()" :loading="queryLoading" icon="search">
{{ $t('milvus.query') }}
</el-button>
<div ref="tableContainerRef" style="height: 100%">
<!-- 顶部查询工具栏 -->
<el-space>
<el-button size="small" type="primary" @click="() => handleQuery()" :loading="queryLoading" icon="search">
{{ $t('milvus.query') }}
</el-button>
<el-button size="small" text @click="handleReset" icon="refresh" :disabled="queryLoading">
{{ $t('milvus.reset') }}
</el-button>
<el-button size="small" text @click="handleReset" icon="refresh" :disabled="queryLoading">
{{ $t('milvus.reset') }}
</el-button>
<el-tooltip content="collection" placement="top">
<el-select size="small" v-model="selectedCollection" style="min-width: 200px" filterable clearable>
<el-option v-for="item in collections" :key="item" :label="item" :value="item" />
<el-tooltip content="collection" placement="top" :teleported="false">
<el-select size="small" v-model="selectedCollection" style="min-width: 200px" filterable clearable :teleported="false">
<el-option v-for="item in collections" :key="item" :label="item" :value="item" />
</el-select>
</el-tooltip>
<el-input
size="small"
style="min-width: 180px"
v-model="queryExpr"
:placeholder="$t('milvus.queryExpr') + $t('milvus.queryExprPlaceholder')"
clearable
@keyup.enter="() => handleQuery()"
>
</el-input>
<el-tooltip :content="$t('milvus.consistencyLevel')" placement="top" :teleported="false">
<el-select size="small" v-model="consistencyLevel" style="width: 130px" :teleported="false">
<el-option label="Strong" :value="0" />
<el-option label="Session" :value="1" />
<el-option label="Bounded" :value="2" />
<el-option label="Eventually" :value="3" />
<el-option label="Customized" :value="4" />
</el-select>
</el-tooltip>
<el-select size="small" v-model="selectedPartition" :placeholder="$t('milvus.partitionManagement')" style="width: 120px" clearable :teleported="false">
<el-option size="small" :label="$t('milvus.allPartitions')" value="" />
<el-option size="small" v-for="partition in partitions" :key="partition" :label="partition" :value="partition" />
</el-select>
</el-tooltip>
<el-input
size="small"
style="min-width: 180px"
v-model="queryExpr"
:placeholder="$t('milvus.queryExpr') + $t('milvus.queryExprPlaceholder')"
clearable
@keyup.enter="() => handleQuery()"
>
</el-input>
<el-tooltip :content="$t('milvus.consistencyLevel')" placement="top">
<el-select size="small" v-model="consistencyLevel" style="width: 130px">
<el-option label="Strong" :value="0" />
<el-option label="Session" :value="1" />
<el-option label="Bounded" :value="2" />
<el-option label="Eventually" :value="3" />
<el-option label="Customized" :value="4" />
</el-select>
</el-tooltip>
<el-select size="small" v-model="selectedPartition" :placeholder="$t('milvus.partitionManagement')" style="width: 120px" clearable>
<el-option size="small" :label="$t('milvus.allPartitions')" value="" />
<el-option size="small" v-for="partition in partitions" :key="partition" :label="partition" :value="partition" />
</el-select>
<el-dropdown size="small" trigger="click">
<el-button size="small" text icon="grid"> {{ $t('milvus.outputFields') }} ({{ selectedFields.length }}/{{ collectionFields.length }}) </el-button>
<template #dropdown>
<el-dropdown-menu class="fields-dropdown-menu">
<div class="fields-dropdown-header">
<el-checkbox
:model-value="selectedFields.length === collectionFields.length && collectionFields.length > 0"
:indeterminate="selectedFields.length > 0 && selectedFields.length < collectionFields.length"
@change="handleSelectAll"
>
{{ $t('milvus.outputFields') }}
</el-checkbox>
</div>
<div class="fields-dropdown-list">
<div v-for="field in collectionFields" :key="field" class="field-item" @click="toggleField(field)">
<el-checkbox :model-value="selectedFields.includes(field)">
<span class="field-label">
<el-icon v-if="isPrimaryKey(field)" class="field-icon primary">
<Key />
</el-icon>
<el-icon v-else-if="isVectorField(field)" class="field-icon vector">
<DataAnalysis />
</el-icon>
<el-icon v-else-if="isDynamicField(field)" class="field-icon dynamic">
<InfoFilled />
</el-icon>
<el-icon v-else class="field-icon normal">
<Grid />
</el-icon>
{{ field }}
</span>
<el-dropdown size="small" trigger="click" :teleported="false">
<el-button size="small" text icon="grid"> {{ $t('milvus.outputFields') }} ({{ selectedFields.length }}/{{ collectionFields.length }}) </el-button>
<template #dropdown>
<el-dropdown-menu class="fields-dropdown-menu">
<div class="fields-dropdown-header">
<el-checkbox
:model-value="selectedFields.length === collectionFields.length && collectionFields.length > 0"
:indeterminate="selectedFields.length > 0 && selectedFields.length < collectionFields.length"
@change="handleSelectAll"
>
{{ $t('milvus.outputFields') }}
</el-checkbox>
</div>
</div>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-space>
<div class="fields-dropdown-list">
<div v-for="field in collectionFields" :key="field" class="field-item" @click="toggleField(field)">
<el-checkbox :model-value="selectedFields.includes(field)">
<span class="field-label">
<el-icon v-if="isPrimaryKey(field)" class="field-icon primary">
<Key />
</el-icon>
<el-icon v-else-if="isVectorField(field)" class="field-icon vector">
<DataAnalysis />
</el-icon>
<el-icon v-else-if="isDynamicField(field)" class="field-icon dynamic">
<InfoFilled />
</el-icon>
<el-icon v-else class="field-icon normal">
<Grid />
</el-icon>
{{ field }}
</span>
</el-checkbox>
</div>
</div>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-space>
<!-- 操作工具栏 -->
<el-space style="padding: 5px 0">
<el-button text size="small" icon="upload" @click="handleImportFile">
{{ $t('milvus.importFile') }}
</el-button>
<el-button text size="small" icon="plus" @click="handleInsertSample">
{{ $t('milvus.insertSampleData') }}
</el-button>
<el-button text size="small" icon="delete" @click="handleClearData" :disabled="queryResults.length === 0">
{{ $t('milvus.clearData') }}
</el-button>
<el-button text size="small" icon="edit" @click="handleEditData" :disabled="selectedRows.length === 0">
{{ $t('common.edit') }}
</el-button>
<el-button text size="small" icon="download" :disabled="queryResults.length === 0"> {{ $t('milvus.export') }} ({{ selectedRows.length }}) </el-button>
<el-button text size="small" icon="document-copy" :disabled="selectedRows.length === 0" @click="handleCopySelected">
{{ $t('common.copy') }} JSON
</el-button>
<el-button text size="small" icon="delete" :disabled="selectedRows.length === 0" @click="handleDeleteSelected">
{{ $t('common.delete') }}
</el-button>
</el-space>
<!-- 操作工具栏 -->
<el-space style="padding: 5px 0">
<el-button text size="small" icon="upload" @click="handleImportFile">
{{ $t('milvus.importFile') }}
</el-button>
<el-button text size="small" icon="plus" @click="handleInsertSample">
{{ $t('milvus.insertSampleData') }}
</el-button>
<el-button text size="small" icon="delete" @click="handleClearData" :disabled="queryResults.length === 0">
{{ $t('milvus.clearData') }}
</el-button>
<el-button text size="small" icon="edit" @click="handleEditData" :disabled="selectedRows.length === 0">
{{ $t('common.edit') }}
</el-button>
<el-button text size="small" icon="download" :disabled="queryResults.length === 0"> {{ $t('milvus.export') }} ({{ selectedRows.length }}) </el-button>
<el-button text size="small" icon="document-copy" :disabled="selectedRows.length === 0" @click="handleCopySelected">
{{ $t('common.copy') }} JSON
</el-button>
<el-button text size="small" icon="delete" :disabled="selectedRows.length === 0" @click="handleDeleteSelected">
{{ $t('common.delete') }}
</el-button>
</el-space>
<el-table
v-loading="queryLoading"
:data="queryResults"
style="width: 100%"
@selection-change="handleSelectionChange"
border
stripe
height="calc(100vh - 288px)"
:loading="queryLoading"
>
<el-table-column type="selection" width="55" />
<el-table
v-loading="queryLoading"
:data="queryResults"
style="width: 100%"
@selection-change="handleSelectionChange"
border
stripe
:loading="queryLoading"
:height="tableHeight"
>
<el-table-column type="selection" width="55" />
<el-table-column v-for="field in displayFields" :key="field" :label="field" :min-width="getMinWidth(field)">
<template #header>
<span class="field-label">
<el-icon v-if="isPrimaryKey(field)" title="Primary Key">
<Key />
</el-icon>
<el-icon v-else-if="isVectorField(field)" title="Vector Field">
<DataAnalysis />
</el-icon>
<el-icon v-else-if="isDynamicField(field)" title="Dynamic Fields">
<InfoFilled />
</el-icon>
<el-icon v-else>
<Grid />
</el-icon>
{{ getDisplayLabel(field) }}
</span>
</template>
<template #default="{ row }">
<div class="cell-content">
<div
class="cell-value"
:class="{ 'url-link': isUrl(row[field]), 'vector-cell': isVectorField(field) }"
:title="formatCellValue(row[field], field)"
@click="handleCellClick(row[field], field)"
>
{{ formatCellValue(row[field], field) }}
</div>
<div class="cell-actions">
<el-icon class="copy-icon" @click.stop="copyToClipboard(row[field], field)">
<DocumentCopy />
<el-table-column v-for="field in displayFields" :key="field" :label="field" :min-width="getMinWidth(field)">
<template #header>
<span class="field-label">
<el-icon v-if="isPrimaryKey(field)" title="Primary Key">
<Key />
</el-icon>
<el-icon v-else-if="isVectorField(field)" title="Vector Field">
<DataAnalysis />
</el-icon>
<el-icon v-else-if="isDynamicField(field)" title="Dynamic Fields">
<InfoFilled />
</el-icon>
<el-icon v-else>
<Grid />
</el-icon>
{{ getDisplayLabel(field) }}
</span>
</template>
<template #default="{ row }">
<div class="cell-content">
<div
class="cell-value"
:class="{ 'url-link': isUrl(row[field]), 'vector-cell': isVectorField(field) }"
:title="formatCellValue(row[field], field)"
@click="handleCellClick(row[field], field)"
>
{{ formatCellValue(row[field], field) }}
</div>
<div class="cell-actions">
<el-icon class="copy-icon" @click.stop="copyToClipboard(row[field], field)">
<DocumentCopy />
</el-icon>
</div>
</div>
</div>
</template>
</el-table-column>
</el-table>
</template>
</el-table-column>
</el-table>
<!-- 分页控件 -->
<div class="pagination-container">
<div class="pagination-info">
<span>{{ $t('milvus.paginationInfo', { total: totalRecords, current: currentPage, pages: totalPages }) }}</span>
<el-select size="small" v-model="pageSize" @change="handlePageSizeChange" style="width: 110px; margin-left: 12px">
<el-option size="small" :label="$t('milvus.pageSize10')" :value="10" />
<el-option size="small" :label="$t('milvus.pageSize20')" :value="20" />
<el-option size="small" :label="$t('milvus.pageSize50')" :value="50" />
<el-option size="small" :label="$t('milvus.pageSize100')" :value="100" />
</el-select>
</div>
<div class="pagination-controls">
<el-button text :disabled="currentPage <= 1" @click="handlePageChange(currentPage - 1)" icon="ArrowLeft" size="small">
{{ $t('milvus.prevPage') }}
</el-button>
<div class="pagination-jump">
<span>{{ $t('milvus.jumpTo') }}</span>
<el-input-number
v-model="jumpPage"
:min="1"
:max="totalPages || 999999"
size="small"
style="width: 80px"
@change="handleJumpPage"
@keyup.enter="handleJumpPage"
/>
<!-- 分页控件 -->
<div class="pagination-container">
<div class="pagination-info">
<span>{{ $t('milvus.paginationInfo', { total: totalRecords, current: currentPage, pages: totalPages }) }}</span>
<el-select size="small" v-model="pageSize" @change="handlePageSizeChange" style="width: 110px; margin-left: 12px" :teleported="false">
<el-option size="small" :label="$t('milvus.pageSize10')" :value="10" />
<el-option size="small" :label="$t('milvus.pageSize20')" :value="20" />
<el-option size="small" :label="$t('milvus.pageSize50')" :value="50" />
<el-option size="small" :label="$t('milvus.pageSize100')" :value="100" />
</el-select>
</div>
<div class="pagination-controls">
<el-button text :disabled="currentPage <= 1" @click="handlePageChange(currentPage - 1)" icon="ArrowLeft" size="small">
{{ $t('milvus.prevPage') }}
</el-button>
<div class="pagination-jump">
<span>{{ $t('milvus.jumpTo') }}</span>
<el-input-number
v-model="jumpPage"
:min="1"
:max="totalPages || 999999"
size="small"
style="width: 80px"
@change="handleJumpPage"
@keyup.enter="handleJumpPage"
/>
</div>
<el-button text :disabled="currentPage >= totalPages" @click="handlePageChange(currentPage + 1)" icon="ArrowRight" size="small">
{{ $t('milvus.nextPage') }}
</el-button>
</div>
<el-button text :disabled="currentPage >= totalPages" @click="handlePageChange(currentPage + 1)" icon="ArrowRight" size="small">
{{ $t('milvus.nextPage') }}
</el-button>
</div>
</div>
@@ -201,7 +203,7 @@
<!-- 分区选择 -->
<div class="import-field">
<label class="field-label">{{ $t('milvus.partition') }}</label>
<el-select v-model="selectedPartitionForImport" style="width: 100%">
<el-select v-model="selectedPartitionForImport" style="width: 100%" :teleported="false">
<el-option v-for="partition in partitions" :key="partition" :label="partition" :value="partition" />
</el-select>
</div>
@@ -256,22 +258,52 @@ import MonacoEditorBox from '@/components/monaco/MonacoEditorBox';
import { Msg } from '@/hooks/useI18n';
import { useMilvusStore } from '@/views/ops/milvus/resource/store';
import { DataAnalysis, DocumentCopy, Grid, InfoFilled, Key } from '@element-plus/icons-vue';
import { useClipboard } from '@vueuse/core';
import { useClipboard, useResizeObserver } from '@vueuse/core';
import { ElMessageBox } from 'element-plus';
import { storeToRefs } from 'pinia';
import { computed, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { milvusApi } from '../api';
// 表格动态高度(仅 body 滚动,表头固定)
const tableContainerRef = ref<HTMLElement>();
const tableHeight = ref(300);
let resizeObserver: ReturnType<typeof useResizeObserver> | undefined;
const calcTableHeight = () => {
if (!tableContainerRef.value) return;
const table = tableContainerRef.value.querySelector('.el-table');
if (!table) return;
const containerRect = tableContainerRef.value.getBoundingClientRect();
const tableRect = table.getBoundingClientRect();
const offset = tableRect.top - containerRect.top;
const paginationEl = tableContainerRef.value.querySelector('.pagination-container') as HTMLElement;
const paginationH = paginationEl ? paginationEl.offsetHeight + 15 : 0;
const newHeight = containerRect.height - offset - paginationH;
if (newHeight > 100) tableHeight.value = newHeight;
};
onMounted(() => {
resizeObserver = useResizeObserver(tableContainerRef, () => {
nextTick(calcTableHeight);
});
});
onBeforeUnmount(() => {
resizeObserver?.stop();
});
const { t } = useI18n();
const { copy } = useClipboard();
const milvusStore = useMilvusStore();
const { collections, selectedCollection } = storeToRefs(milvusStore);
const props = defineProps<{
milvusId: number;
tabKey?: string;
}>();
const milvusStore = useMilvusStore(props.tabKey || 'milvusStore');
const { collections, selectedCollection } = storeToRefs(milvusStore);
// 查询状态
const queryLoading = ref(false);
const loading = ref(false);

View File

@@ -1,8 +1,11 @@
<template>
<el-button size="small" type="primary" @click="handleCreate" icon="plus">{{ $t('milvus.createDatabase') }}</el-button>
<el-button size="small" text icon="refresh" @click="loadList" :loading="loading" />
<div class="component-container">
<div>
<el-button size="small" type="primary" @click="handleCreate" icon="plus">{{ $t('milvus.createDatabase') }}</el-button>
<el-button size="small" text icon="refresh" @click="loadList" :loading="loading" />
</div>
<el-table :data="dbs" style="width: 100%">
<el-table :data="dbs" style="width: 100%">
<el-table-column prop="name" :label="$t('milvus.dbName')" sortable>
<template #default="{ row }">
<el-link type="primary" underline="never" @click="handleUse(row)">{{ row.name }}</el-link>
@@ -15,7 +18,8 @@
<el-button type="danger" size="small" @click="handleDrop(row)">{{ $t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</el-table>
</div>
<el-dialog v-model="createDialog.visible" :title="$t('milvus.createDatabase')" width="500px">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="auto">
@@ -61,13 +65,14 @@ import { storeToRefs } from 'pinia';
import { onMounted, ref, watch } from 'vue';
import { milvusApi, timezones } from '../api';
const milvusStore = useMilvusStore();
const { dbs } = storeToRefs(milvusStore);
const props = defineProps<{
milvusId: number;
tabKey?: string;
}>();
const milvusStore = useMilvusStore(props.tabKey || 'milvusStore');
const { dbs } = storeToRefs(milvusStore);
const emits = defineEmits(['use']);
const createDialog = ref({
@@ -194,4 +199,16 @@ watch(
onMounted(loadList);
</script>
<style scoped></style>
<style scoped>
.component-container {
height: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.component-container :deep(.el-table) {
flex: 1;
min-height: 0;
}
</style>

View File

@@ -1,6 +1,7 @@
<template>
<el-space>
<el-select size="small" v-model="selectedCollection" style="min-width: 200px" @change="loadList" filterable clearable>
<div class="component-container">
<el-space>
<el-select size="small" v-model="selectedCollection" style="min-width: 200px" @change="loadList" filterable clearable :teleported="false">
<el-option v-for="item in collections" :key="item" :label="item" :value="item" />
</el-select>
@@ -20,7 +21,8 @@
<el-button size="small" type="danger" @click="handleDrop(row)">{{ $t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</el-table>
</div>
<el-dialog v-model="createDialog.visible" :title="$t('milvus.createPartition')" width="500px">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="auto">
@@ -44,13 +46,14 @@ import { storeToRefs } from 'pinia';
import { onMounted, ref, watch } from 'vue';
import { milvusApi } from '../api';
const milvusStore = useMilvusStore();
const { collections, selectedCollection } = storeToRefs(milvusStore);
const props = defineProps<{
milvusId: number;
tabKey?: string;
}>();
const milvusStore = useMilvusStore(props.tabKey || 'milvusStore');
const { collections, selectedCollection } = storeToRefs(milvusStore);
const list = ref<any[]>([]);
const createDialog = ref({
visible: false,
@@ -126,4 +129,16 @@ watch(
);
</script>
<style scoped></style>
<style scoped>
.component-container {
height: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.component-container :deep(.el-table) {
flex: 1;
min-height: 0;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div>
<div class="component-container">
<!-- 工具栏 -->
<div class="mb5" style="display: flex; align-items: center">
<el-button size="small" type="primary" icon="plus" @click="handleCreate">{{ $t('milvus.addPrivilegeGroup') }}</el-button>
@@ -116,3 +116,17 @@ watch(
}
);
</script>
<style scoped>
.component-container {
height: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.component-container :deep(.el-table) {
flex: 1;
min-height: 0;
}
</style>

View File

@@ -1,8 +1,11 @@
<template>
<el-button size="small" icon="plus" type="primary" @click="handleCreate">{{ $t('milvus.createResourceGroup') }}</el-button>
<el-button size="small" text icon="refresh" @click="loadList" :loading="loading" />
<div class="component-container">
<div>
<el-button size="small" icon="plus" type="primary" @click="handleCreate">{{ $t('milvus.createResourceGroup') }}</el-button>
<el-button size="small" text icon="refresh" @click="loadList" :loading="loading" />
</div>
<el-table :data="list">
<el-table :data="list">
<el-table-column prop="name" :label="$t('milvus.resourceGroupName')" />
<el-table-column :label="$t('common.operation')" width="250">
<template #default="{ row }">
@@ -10,7 +13,8 @@
<el-button size="small" type="danger" @click="handleDrop(row)">{{ $t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</el-table>
</div>
<el-dialog v-model="createDialog.visible" :title="$t('milvus.createResourceGroup')" width="500px">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="auto">
@@ -35,12 +39,14 @@ import { onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { milvusApi } from '../api';
const milvusStore = useMilvusStore();
const { t } = useI18n();
const props = defineProps<{
milvusId: number;
tabKey?: string;
}>();
const milvusStore = useMilvusStore(props.tabKey || 'milvusStore');
const list = ref<any[]>([]);
const createDialog = ref({
visible: false,
@@ -127,4 +133,16 @@ watch(
);
</script>
<style scoped></style>
<style scoped>
.component-container {
height: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.component-container :deep(.el-table) {
flex: 1;
min-height: 0;
}
</style>

View File

@@ -1,8 +1,11 @@
<template>
<el-button size="small" icon="plus" type="primary" @click="handleCreate">{{ $t('milvus.createRole') }}</el-button>
<el-button size="small" text icon="refresh" @click="loadList" :loading="loading" />
<div class="component-container">
<div>
<el-button size="small" icon="plus" type="primary" @click="handleCreate">{{ $t('milvus.createRole') }}</el-button>
<el-button size="small" text icon="refresh" @click="loadList" :loading="loading" />
</div>
<el-table :data="list">
<el-table :data="list">
<el-table-column prop="roleName" :label="$t('milvus.roleName')" />
<el-table-column :label="$t('common.operation')" width="250">
<template #default="{ row }">
@@ -12,7 +15,8 @@
<el-button size="small" type="danger" @click="handleDrop(row)">{{ $t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</el-table>
</div>
<!-- 创建角色弹窗 -->
<el-dialog v-model="createDialog.visible" :title="$t('milvus.createRole')" width="500px">
@@ -40,12 +44,13 @@ import { onMounted, ref, watch } from 'vue';
import { milvusApi } from '../api';
import RolesGrantPrivilege from './RolesGrantPrivilege.vue';
const milvusStore = useMilvusStore();
const props = defineProps<{
milvusId: number;
tabKey?: string;
}>();
const milvusStore = useMilvusStore(props.tabKey || 'milvusStore');
const list = ref<any[]>([]);
const createDialog = ref({
visible: false,
@@ -120,3 +125,17 @@ watch(
}
);
</script>
<style scoped>
.component-container {
height: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.component-container :deep(.el-table) {
flex: 1;
min-height: 0;
}
</style>

View File

@@ -209,14 +209,15 @@ import { useI18n } from 'vue-i18n';
import { milvusApi } from '../api';
import { useMilvusStore } from '@/views/ops/milvus/resource/store';
const milvusStore = useMilvusStore();
const { t } = useI18n();
const props = defineProps<{
milvusId: number;
tabKey?: string;
}>();
const milvusStore = useMilvusStore(props.tabKey || 'milvusStore');
const loading = ref(false);
const versionLoading = ref(false);
const healthLoading = ref(false);
@@ -414,6 +415,8 @@ watch([() => props.milvusId, () => milvusStore.authCertName], () => {
<style scoped>
.system-info-container {
height: 100%;
overflow: auto;
padding: 16px;
}

View File

@@ -1,8 +1,11 @@
<template>
<el-button size="small" icon="plus" type="primary" @click="handleCreate">{{ $t('milvus.createUser') }}</el-button>
<el-button text icon="refresh" @click="loadList" :loading="loading" />
<div class="component-container">
<div>
<el-button size="small" icon="plus" type="primary" @click="handleCreate">{{ $t('milvus.createUser') }}</el-button>
<el-button size="small" text icon="refresh" @click="loadList" :loading="loading" />
</div>
<el-table :data="list">
<el-table :data="list">
<el-table-column prop="name" :label="$t('common.username')" />
<el-table-column :label="$t('common.operation')" width="350">
<template #default="{ row }">
@@ -11,7 +14,8 @@
<el-button size="small" type="danger" @click="handleDelete(row)">{{ $t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</el-table>
</div>
<el-dialog v-model="createDialog.visible" :title="$t('milvus.createUser')" width="500px">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="auto">
@@ -72,12 +76,13 @@ import { FormInstance } from 'element-plus';
import { onMounted, ref, watch } from 'vue';
import { milvusApi } from '../api';
const milvusStore = useMilvusStore();
const props = defineProps<{
milvusId: number;
tabKey?: string;
}>();
const milvusStore = useMilvusStore(props.tabKey || 'milvusStore');
const list = ref<any[]>([]);
const createDialog = ref({
visible: false,
@@ -239,4 +244,16 @@ watch(
);
</script>
<style scoped></style>
<style scoped>
.component-container {
height: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.component-container :deep(.el-table) {
flex: 1;
min-height: 0;
}
</style>

View File

@@ -1,84 +1,126 @@
<template>
<el-tabs v-model="activeTab" type="border-card">
<el-tabs v-model="activeTab" type="border-card" class="milvus-tabs">
<!-- 数据库管理 -->
<el-tab-pane :label="$t('milvus.databaseManagement')" name="databases">
<Databases :milvus-id="milvusId" v-if="activeTab === 'databases'" @use="onUseDb" />
<Databases :milvus-id="milvusId" :tab-key="tabKey" v-if="activeTab === 'databases'" @use="onUseDb" />
</el-tab-pane>
<!-- Collection 管理 -->
<el-tab-pane :label="$t('milvus.collectionManagement')" name="collections">
<Collections :milvus-id="milvusId" v-if="activeTab === 'collections'" @change-tab="(name) => (activeTab = name)" />
<Collections :milvus-id="milvusId" :tab-key="tabKey" v-if="activeTab === 'collections'" @change-tab="(name) => (activeTab = name)" />
</el-tab-pane>
<!-- 数据操作 -->
<el-tab-pane :label="$t('milvus.dataOperation')" name="data">
<DataOperation :milvus-id="milvusId" v-if="activeTab === 'data'" />
<DataOperation :milvus-id="milvusId" :tab-key="tabKey" v-if="activeTab === 'data'" />
</el-tab-pane>
<!-- 分区管理 -->
<el-tab-pane :label="$t('milvus.partitionManagement')" name="partitions">
<Partitions :milvus-id="milvusId" v-if="activeTab === 'partitions'" />
<Partitions :milvus-id="milvusId" :tab-key="tabKey" v-if="activeTab === 'partitions'" />
</el-tab-pane>
<!-- 用户权限 -->
<el-tab-pane :label="$t('milvus.userPermission')" name="users">
<Users :milvus-id="milvusId" v-if="activeTab === 'users'" />
<Users :milvus-id="milvusId" :tab-key="tabKey" v-if="activeTab === 'users'" />
</el-tab-pane>
<!-- 角色管理 -->
<el-tab-pane :label="$t('milvus.roleManagement')" name="roles">
<Roles :milvus-id="milvusId" v-if="activeTab === 'roles'" />
<Roles :milvus-id="milvusId" :tab-key="tabKey" v-if="activeTab === 'roles'" />
</el-tab-pane>
<!-- 资源组 -->
<el-tab-pane :label="$t('milvus.resourceGroup')" name="resourceGroups">
<ResourceGroups :milvus-id="milvusId" v-if="activeTab === 'resourceGroups'" />
<ResourceGroups :milvus-id="milvusId" :tab-key="tabKey" v-if="activeTab === 'resourceGroups'" />
</el-tab-pane>
<!-- 系统信息 -->
<el-tab-pane :label="$t('milvus.systemInfo')" name="system">
<SystemInfo :milvus-id="milvusId" v-if="activeTab === 'system'" />
<SystemInfo :milvus-id="milvusId" :tab-key="tabKey" v-if="activeTab === 'system'" />
</el-tab-pane>
</el-tabs>
</template>
<script setup lang="ts">
import { onMounted, ref, getCurrentInstance } from 'vue';
import Databases from '../components/Databases.vue';
import { setCurrentAcName } from '@/views/ops/milvus/resource/authCert';
import { useMilvusStore } from '@/views/ops/milvus/resource/store';
import { onActivated, onBeforeMount, onMounted, ref } from 'vue';
import Collections from '../components/Collections.vue';
import Databases from '../components/Databases.vue';
import DataOperation from '../components/DataOperation.vue';
import Partitions from '../components/Partitions.vue';
import Users from '../components/Users.vue';
import Roles from '../components/Roles.vue';
import PrivilegeGroups from '../components/PrivilegeGroups.vue';
import ResourceGroups from '../components/ResourceGroups.vue';
import Roles from '../components/Roles.vue';
import SystemInfo from '../components/SystemInfo.vue';
import { MilvusOpComp } from '@/views/ops/milvus/resource/index';
import { useMilvusStore } from '@/views/ops/milvus/resource/store';
import Users from '../components/Users.vue';
const milvusStore = useMilvusStore();
const milvusId = ref<number>(0);
const props = defineProps<{
milvusId: number;
acName: string;
tabKey: string;
}>();
// 使用 per-tab 独立 store实现多标签页状态隔离
const milvusStore = useMilvusStore(props.tabKey || 'milvusStore');
const emits = defineEmits(['init']);
const initMilvus = (params: any) => {
activeTab.value = 'databases';
milvusId.value = params.id;
// 设置当前选中的授权凭证名(无论是否选择凭证,都重置,确保切换实例后不会残留旧凭证)
milvusStore.setAuthCertName(params.selectAuthCert?.name || '');
};
const activeTab = ref('databases');
const onUseDb = (db: string) => {
// 在子组件挂载前同步全局 ac确保子组件 watcher / onMounted 发出的 API 请求使用正确的凭证
onBeforeMount(() => {
if (props.acName) {
setCurrentAcName(props.acName);
}
});
const initMilvus = (params: any) => {
// 设置当前选中的授权凭证名(同步全局 ac确保 API 调用使用正确的凭证)
const newAcName = params.selectAuthCert?.name || '';
milvusStore.setAuthCertName(newAcName);
};
// 标签页激活时同步全局 ac确保 API 调用使用正确的凭证)
const onActivate = () => {
if (milvusStore.authCertName) {
milvusStore.setAuthCertName(milvusStore.authCertName);
}
};
const onUseDb = (_db: string) => {
activeTab.value = 'collections';
};
onMounted(() => {
emits('init', { name: MilvusOpComp.name, ref: getCurrentInstance()?.exposed });
onMounted(() => {});
// keep-alive 激活时重新同步全局 ac确保切换标签后 API 调用正确)
onActivated(() => {
if (milvusStore.authCertName) {
milvusStore.setAuthCertName(milvusStore.authCertName);
}
});
defineExpose({
initMilvus,
onActivate,
});
</script>
<style scoped></style>
<style scoped lang="scss">
.milvus-tabs {
height: 100%;
display: flex;
flex-direction: column;
:deep(.el-tabs__content) {
flex: 1;
min-height: 0;
overflow: visible;
padding: 8px;
}
:deep(.el-tab-pane) {
height: 100%;
}
}
</style>

View File

@@ -1,8 +1,10 @@
import { defineAsyncComponent } from 'vue';
import { NodeType, TagTreeNode, ResourceComponentConfig, ResourceConfig } from '../../component/tag';
import { ResourceTypeEnum, TagResourceTypeEnum } from '@/common/commonEnum';
import { sleep } from '@/common/utils/loading';
import { milvusApi, perms } from '@/views/ops/milvus/api';
import type { ResourceConfig } from '@/views/ops/resource/resource';
import { createResourceOpTab } from '@/views/ops/resource/resourceOp';
import { defineAsyncComponent } from 'vue';
import { NodeType, TagTreeNode } from '../../component/tag';
export const MilvusIcon = {
name: ResourceTypeEnum.Milvus.extra.icon,
@@ -15,25 +17,39 @@ const MilvusOp = defineAsyncComponent(() => import('./MilvusOp.vue'));
const NodeMilvus = defineAsyncComponent(() => import('./NodeMilvus.vue'));
const NodeMilvusAc = defineAsyncComponent(() => import('./NodeMilvusAc.vue'));
export const MilvusOpComp: ResourceComponentConfig = {
name: 'tag.milvusOp',
component: MilvusOp,
icon: MilvusIcon,
const getMilvusOpTab = async (milvus: any, acName: string) => {
const tabKey = `milvus.${milvus.id}.${acName}`;
return await createResourceOpTab({
key: tabKey,
name: milvus.acUsername ? `${milvus.name} (${milvus.acUsername})` : milvus.name,
component: MilvusOp,
componentProps: {
milvusId: milvus.id,
acName,
tabKey,
},
tabComponentProps: { icon: MilvusIcon },
});
};
// milvus 授权凭证节点类型
const getMilvusOpTabCompInst = async (milvus: any, acName: string) => {
return (await getMilvusOpTab(milvus, acName)).componentInstance;
};
// milvus 授权凭证节点类型:点击后打开独立标签页
const NodeTypeMilvusAc = new NodeType(TagResourceTypeEnum.Milvus.value * 10 + 1).withNodeClickFunc(async (node: TagTreeNode) => {
(await node.ctx?.addResourceComponent(MilvusOpComp))?.initMilvus(node.params);
console.log(node.params);
const milvus = node.params;
const acName = milvus.selectAuthCert?.name || '';
// 仅在首次创建时初始化(已存在的标签页只是激活,不重置状态)
const compRef = await getMilvusOpTabCompInst(milvus, acName);
compRef?.initMilvus?.(milvus);
});
const NodeTypeMilvus = new NodeType(TagResourceTypeEnum.Milvus.value).withLoadNodesFunc((node: TagTreeNode) => {
const milvus = node.params;
const authCerts = milvus.authCerts || [];
return authCerts.map((x: any) =>
TagTreeNode.new(node, x.name, x.username, NodeTypeMilvusAc)
TagTreeNode.new(node, `milvus.${milvus.id}.${x.name}`, x.username, NodeTypeMilvusAc)
.withNodeComponent(NodeMilvusAc)
.withParams({ ...milvus, selectAuthCert: x })
.withIsLeaf(true)
@@ -51,7 +67,7 @@ const NodeTypeMilvusTag = new NodeType(TagTreeNode.TagPath).withLoadNodesFunc(as
const milvusInfos = res.list;
await sleep(100);
return milvusInfos.map((x: any) => {
return TagTreeNode.new(parentNode, `${x.code}`, x.name, NodeTypeMilvus).withParams(x).withNodeComponent(NodeMilvus);
return TagTreeNode.new(parentNode, `milvus.${x.id}`, x.name, NodeTypeMilvus).withParams(x).withNodeComponent(NodeMilvus);
});
});

View File

@@ -3,57 +3,58 @@ import { milvusApi } from '@/views/ops/milvus/api';
import { setCurrentAcName } from './authCert';
/**
* 缓存milvus一些参数
* Milvus 动态 store 工厂函数,支持按 tabKey 隔离状态。
* - 不传 id 或传 'milvusStore' 时返回全局默认 store向后兼容
* - 传自定义 id如 tabKey时返回独立的 per-tab store
*/
export const useMilvusStore = defineStore('milvusStore', {
state: (): MilvusState => ({
dbs: [],
selectedDb: 'default',
collections: [],
selectedCollection: '',
authCertName: '',
}),
actions: {
setDbs(dbs: any[]) {
this.dbs = dbs;
export const useMilvusStore = (id: string = 'milvusStore') =>
defineStore(id, {
state: (): MilvusState => ({
dbs: [],
selectedDb: 'default',
collections: [],
selectedCollection: '',
authCertName: '',
}),
actions: {
setDbs(dbs: any[]) {
this.dbs = dbs;
},
async refreshDbs(milvusId: number) {
const res = await milvusApi.listDatabases(milvusId);
// res 通过dbid排序
res.sort((a: any, b: any) => {
return a.create_time.localeCompare(b.create_time);
});
this.dbs = res;
if (res.length > 0) {
this.selectedDb = res[0].name;
milvusApi.useDatabase(res[0].id, res[0].name);
}
},
setSelectedDb(db: string) {
this.selectedDb = db;
},
setSelectedCollection(coll: string) {
this.selectedCollection = coll;
},
setCollections(collections: string[]) {
collections.sort();
this.collections = collections;
// 默认选中第一个 collection
if (!this.selectedCollection && this.collections.length > 0) {
this.setSelectedCollection(this.collections[0]);
}
},
clear() {
this.collections = [];
this.selectedCollection = '';
this.selectedDb = 'default';
this.dbs = [];
},
setAuthCertName(name: string) {
this.authCertName = name;
setCurrentAcName(name);
},
},
async refreshDbs(milvusId: number) {
const res = await milvusApi.listDatabases(milvusId);
// res 通过dbid排序
res.sort((a: any, b: any) => {
return a.create_time.localeCompare(b.create_time);
});
this.dbs = res;
if (res.length > 0) {
this.selectedDb = res[0].name;
milvusApi.useDatabase(res[0].id, res[0].name);
}
},
setSelectedDb(db: string) {
this.selectedDb = db;
},
setSelectedCollection(coll: string) {
console.log('[MilvusStore] 切换 collection:', coll);
this.selectedCollection = coll;
},
setCollections(collections: string[]) {
collections.sort();
this.collections = collections;
// 默认选中第一个 collection
if (!this.selectedCollection && this.collections.length > 0) {
this.setSelectedCollection(this.collections[0]);
}
},
clear() {
console.log('[MilvusStore] 清空状态');
this.collections = [];
this.selectedCollection = '';
this.selectedDb = 'default';
this.dbs = [];
},
setAuthCertName(name: string) {
this.authCertName = name;
setCurrentAcName(name);
},
},
});
})();

View File

@@ -46,14 +46,12 @@
</template>
<script lang="ts" setup>
import { TagResourceTypeEnum } from '@/common/commonEnum';
import { TableColumn } from '@/components/pagetable';
import PageTable from '@/components/pagetable/PageTable.vue';
import { SearchItem } from '@/components/pagetable/SearchForm';
import { Msg, useI18nCreateTitle, useI18nDeleteConfirm, useI18nEditTitle } from '@/hooks/useI18n';
import { defineAsyncComponent, onMounted, reactive, ref, Ref, toRefs } from 'vue';
import { useRoute } from 'vue-router';
import { getTagPathSearchItem } from '../component/tag';
import TagCodePath from '../component/TagCodePath.vue';
import { mongoApi } from './api';
@@ -71,10 +69,7 @@ const props = defineProps({
const route = useRoute();
const pageTableRef: Ref<any> = ref(null);
const searchItems = [
SearchItem.input('keyword', 'common.keyword').withPlaceholder('mongo.keywordPlaceholder'),
getTagPathSearchItem(TagResourceTypeEnum.Mongo.value),
];
const searchItems = [SearchItem.input('keyword', 'common.keyword').withPlaceholder('mongo.keywordPlaceholder')];
const columns = [
TableColumn.new('name', 'common.name').isSlot('name').setAddWidth(25),

View File

@@ -1,5 +1,5 @@
<template>
<div class="mongo-data-tab card h-full !p-1 w-full">
<div class="mongo-data-tab card h-full !p-1 w-full flex flex-col">
<el-row v-if="nowColl">
<el-descriptions class="!w-full" :column="10" size="small" border>
<!-- <el-descriptions-item label-align="right" label="tag">xxx</el-descriptions-item> -->
@@ -28,8 +28,8 @@
</el-descriptions>
</el-row>
<el-row type="flex">
<el-tabs @tab-remove="removeDataTab" class="!w-full ml-1" v-model="state.activeName">
<el-row type="flex" class="flex-1 min-h-0">
<el-tabs @tab-remove="removeDataTab" class="!w-full ml-1 h-full flex flex-col" v-model="state.activeName">
<el-tab-pane closable v-for="dt in state.dataTabs" :key="dt.key" :label="dt.label" :name="dt.key">
<el-row>
<el-col :span="2">
@@ -50,7 +50,7 @@
</el-input>
</el-col>
</el-row>
<el-scrollbar class="mongo-data-tab-data">
<el-scrollbar class="mongo-data-tab-data flex-1 min-h-0" v-loading="findLoading">
<el-row>
<el-col :span="6" v-for="item in dt.datas" :key="item">
<el-card :body-style="{ padding: '0px', position: 'relative' }">
@@ -61,7 +61,12 @@
<el-divider direction="vertical" border-style="dashed" />
<el-popconfirm @confirm="onDeleteDoc(item.value)" :title="$t('mongo.deleteDocConfirm')" width="160">
<el-popconfirm
@confirm="onDeleteDoc(item.value)"
:title="$t('mongo.deleteDocConfirm')"
width="160"
:teleported="false"
>
<template #reference>
<el-link v-auth="perms.delData" underline="never" type="danger" icon="DocumentDelete"> </el-link>
</template>
@@ -121,9 +126,8 @@ import { isTrue, notBlank } from '@/common/assert';
import { formatByteSize } from '@/common/utils/format';
import { Msg } from '@/hooks/useI18n';
import { mongoApi } from '@/views/ops/mongo/api';
import { MongoOpComp } from '@/views/ops/mongo/resource';
import { ResourceOpCtxKey } from '@/views/ops/resource/resource';
import { computed, defineAsyncComponent, getCurrentInstance, inject, onMounted, reactive, ref, toRefs } from 'vue';
import { ResourceOpCtxKey } from '@/views/ops/resource/resourceOp';
import { computed, defineAsyncComponent, inject, onMounted, reactive, ref, toRefs } from 'vue';
import { useI18n } from 'vue-i18n';
import { ResourceOpCtx } from '../../component/tag';
@@ -138,6 +142,10 @@ const perms = {
const resourceOpCtx: ResourceOpCtx | undefined = inject(ResourceOpCtxKey);
const props = defineProps<{
tabKey?: string;
}>();
const emits = defineEmits(['init']);
const findParamInputRef: any = ref(null);
@@ -179,12 +187,10 @@ const nowColl = computed(() => {
return getNowDataTab();
});
onMounted(() => {
emits('init', { name: MongoOpComp.name, ref: getCurrentInstance()?.exposed });
});
onMounted(() => {});
const changeCollection = async (id: any, schema: string, collection: string) => {
const label = `${id}:\`${schema}\`.${collection}`;
const label = `${schema}.${collection}`;
let dataTab = state.dataTabs[label];
if (!dataTab) {
// 默认查询参数
@@ -230,6 +236,8 @@ const confirmFindDialog = () => {
findCommand(state.activeName);
};
const findLoading = ref(false);
const findCommand = async (key: string) => {
const dataTab = getNowDataTab();
const findParma = dataTab.findParam;
@@ -242,27 +250,32 @@ const findCommand = async (key: string) => {
return;
}
const datas = await mongoApi.findCommand.request({
id: dataTab.mongoId,
database: dataTab.database,
collection: dataTab.collection,
filter,
sort,
limit: findParma.limit || 12,
skip: findParma.skip || 0,
});
state.dataTabs[key].datas = wrapDatas(datas);
try {
findLoading.value = true;
const datas = await mongoApi.findCommand.request({
id: dataTab.mongoId,
database: dataTab.database,
collection: dataTab.collection,
filter,
sort,
limit: findParma.limit || 12,
skip: findParma.skip || 0,
});
state.dataTabs[key].datas = wrapDatas(datas);
// 获取coll stats
state.dataTabs[key].stats = await mongoApi.runCommand.request({
id: dataTab.mongoId,
database: dataTab.database,
command: [
{
collStats: dataTab.collection,
},
],
});
// 获取coll stats
state.dataTabs[key].stats = await mongoApi.runCommand.request({
id: dataTab.mongoId,
database: dataTab.database,
command: [
{
collStats: dataTab.collection,
},
],
});
} finally {
findLoading.value = false;
}
};
/**
@@ -393,6 +406,9 @@ const getNowDataTab = () => {
defineExpose({
changeCollection,
onRefresh: () => {
findCommand(state.activeName);
},
});
</script>
@@ -406,8 +422,16 @@ defineExpose({
}
.mongo-data-tab {
.mongo-data-tab-data {
height: calc(100vh - 230px);
.el-tabs__content {
flex: 1;
min-height: 0;
overflow: hidden;
.el-tab-pane {
height: 100%;
display: flex;
flex-direction: column;
}
}
.el-tabs__header {

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