537 lines
15 KiB
Vue
537 lines
15 KiB
Vue
<script setup lang="ts">
|
||
import type { Component, PropType } from 'vue';
|
||
|
||
import { ref } from 'vue';
|
||
|
||
import { $t } from '@easyflow/locales';
|
||
|
||
import {
|
||
ElAvatar,
|
||
ElButton,
|
||
ElCheckbox,
|
||
ElCollapse,
|
||
ElCollapseItem,
|
||
ElDialog,
|
||
ElMessage,
|
||
ElText,
|
||
} from 'element-plus';
|
||
|
||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||
import PageData from '#/components/page/PageData.vue';
|
||
|
||
export interface ButtonConfig {
|
||
key?: number | string;
|
||
text: string;
|
||
type?: 'danger' | 'default' | 'info' | 'primary' | 'success' | 'warning';
|
||
icon?: Component | string; // 支持字符串图标或组件图标
|
||
disabled?: boolean;
|
||
permission?: string; // 权限编码
|
||
[key: string]: any; // 允许传递自定义属性
|
||
}
|
||
|
||
interface SelectedMcpTool {
|
||
name: string;
|
||
description: string;
|
||
}
|
||
|
||
const props = defineProps({
|
||
title: { type: String, default: '' },
|
||
width: { type: String, default: '80%' },
|
||
extraQueryParams: { type: Object, default: () => ({}) },
|
||
searchParams: {
|
||
type: Array as PropType<string[]>,
|
||
default: () => [],
|
||
},
|
||
titleKey: { type: String, default: 'name' },
|
||
pageUrl: { type: String, default: '' },
|
||
hasParent: { type: Boolean, default: false },
|
||
isSelectMcp: { type: Boolean, default: false },
|
||
singleSelect: { type: Boolean, default: false },
|
||
footerButtons: {
|
||
type: Array as PropType<ButtonConfig[]>,
|
||
default: () => [],
|
||
},
|
||
});
|
||
|
||
const emit = defineEmits(['getData', 'buttonClick']);
|
||
const dialogVisible = ref(false);
|
||
const pageDataRef = ref();
|
||
const loading = ref(false);
|
||
const selectedIds = ref<(number | string)[]>([]);
|
||
// 存储上一级id与选中tool.name的关联关系
|
||
const selectedToolMap = ref<Record<number | string, SelectedMcpTool[]>>({});
|
||
defineExpose({
|
||
openDialog(defaultSelectedIds: (number | string)[]) {
|
||
selectedIds.value = defaultSelectedIds ? [...defaultSelectedIds] : [];
|
||
dialogVisible.value = true;
|
||
},
|
||
/**
|
||
* MCP专属弹窗打开方法(适配MCP回显,传递格式化后的MCP数据)
|
||
* @param selectMcpMap - MCP已选数据映射(键:MCP父级ID,值:工具名称+描述数组)
|
||
*/
|
||
openMcpDialog(selectMcpMap: Record<number | string, SelectedMcpTool[]>) {
|
||
selectedIds.value = [];
|
||
selectedToolMap.value = structuredClone(selectMcpMap);
|
||
dialogVisible.value = true;
|
||
},
|
||
});
|
||
const isSelected = (id: number | string) => {
|
||
return selectedIds.value.includes(id);
|
||
};
|
||
|
||
const isSelectedMcp = (parentId: number | string, toolName: string) => {
|
||
// 查找当前parentId下是否存在该tool.name的工具
|
||
return !!selectedToolMap.value[parentId]?.some(
|
||
(tool) => tool.name === toolName,
|
||
);
|
||
};
|
||
|
||
const toggleSelectionMcp = (
|
||
mcpId: number | string,
|
||
toolName: string,
|
||
toolDescription: string,
|
||
checked: any,
|
||
) => {
|
||
if (checked) {
|
||
if (!selectedToolMap.value[mcpId]) {
|
||
selectedToolMap.value[mcpId] = []; // 初始化空数组
|
||
}
|
||
const isExisted = selectedToolMap.value[mcpId]?.some(
|
||
(tool) => tool.name === toolName,
|
||
);
|
||
if (!isExisted) {
|
||
selectedToolMap.value[mcpId]?.push({
|
||
name: toolName,
|
||
description: toolDescription,
|
||
});
|
||
}
|
||
} else {
|
||
if (selectedToolMap.value[mcpId]) {
|
||
selectedToolMap.value[mcpId] = selectedToolMap.value[mcpId].filter(
|
||
(tool) => tool.name !== toolName,
|
||
);
|
||
if (selectedToolMap.value[mcpId].length === 0) {
|
||
delete selectedToolMap.value[mcpId];
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const toggleSelection = (id: number | string, checked: any) => {
|
||
if (checked) {
|
||
// 单选模式:先清空已选,再添加当前项
|
||
if (props.singleSelect) {
|
||
selectedIds.value = [id];
|
||
} else {
|
||
// 多选模式:追加当前项(避免重复)
|
||
if (!selectedIds.value.includes(id)) {
|
||
selectedIds.value.push(id);
|
||
}
|
||
}
|
||
} else {
|
||
// 取消选中:仅多选模式生效,单选模式不允许取消(可选)
|
||
if (!props.singleSelect) {
|
||
selectedIds.value = selectedIds.value.filter((i) => i !== id);
|
||
}
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 封装:获取MCP选中的结构化信息(包含name和description)
|
||
* @returns {Record<number | string, string[][]>[]} 符合要求的数据:[{ 上一级id: [[name1, description1], [name2, description2]] }]
|
||
*/
|
||
const getMcpSelectedInfo = (): Record<number | string, string[][]>[] => {
|
||
const mcpSelectedResult: Record<number | string, string[][]>[] = [];
|
||
|
||
Object.entries(selectedToolMap.value).forEach(([parentId, selectedTools]) => {
|
||
// 转换每个工具为 [name, description] 一维数组
|
||
const formattedToolList: string[][] = selectedTools.map((tool) => [
|
||
tool.name,
|
||
tool.description,
|
||
]);
|
||
|
||
mcpSelectedResult.push({
|
||
[parentId]: formattedToolList,
|
||
});
|
||
});
|
||
|
||
return mcpSelectedResult;
|
||
};
|
||
|
||
const handleSubmitRun = () => {
|
||
const hasSelected = props.isSelectMcp
|
||
? Object.keys(selectedToolMap.value).some(
|
||
(parentId) => (selectedToolMap.value[parentId] ?? []).length > 0,
|
||
)
|
||
: selectedIds.value.length > 0;
|
||
|
||
// 未选中内容时提示并返回
|
||
if (!hasSelected) {
|
||
ElMessage.error($t('message.selectTip'));
|
||
return;
|
||
}
|
||
|
||
const submitData = props.isSelectMcp
|
||
? getMcpSelectedInfo()
|
||
: selectedIds.value;
|
||
emit('getData', submitData);
|
||
|
||
// 关闭弹窗并返回数据
|
||
dialogVisible.value = false;
|
||
return submitData;
|
||
};
|
||
|
||
const handleSearch = (query: string) => {
|
||
const tempParams = {} as Record<string, string>;
|
||
props.searchParams.forEach((paramName) => {
|
||
tempParams[paramName] = query;
|
||
});
|
||
|
||
pageDataRef.value?.setQuery({
|
||
isQueryOr: true,
|
||
...tempParams,
|
||
});
|
||
};
|
||
</script>
|
||
|
||
<template>
|
||
<ElDialog
|
||
v-model="dialogVisible"
|
||
draggable
|
||
:close-on-click-modal="false"
|
||
:width="props.width"
|
||
align-center
|
||
>
|
||
<template #header>
|
||
<div>
|
||
<p class="el-dialog__title mb-4">{{ props.title }}</p>
|
||
<HeaderSearch @search="handleSearch" />
|
||
</div>
|
||
</template>
|
||
<div class="select-modal-container p-5">
|
||
<PageData
|
||
ref="pageDataRef"
|
||
:page-url="pageUrl"
|
||
:page-size="10"
|
||
:extra-query-params="extraQueryParams"
|
||
>
|
||
<template #default="{ pageList }">
|
||
<template v-if="hasParent">
|
||
<div class="container-second">
|
||
<ElCollapse
|
||
accordion
|
||
v-for="(item, index) in pageList"
|
||
:key="index"
|
||
>
|
||
<ElCollapseItem>
|
||
<template #title="{ isActive }">
|
||
<div
|
||
class="title-wrapper"
|
||
:class="[{ 'is-active': isActive }]"
|
||
>
|
||
<div>
|
||
<ElAvatar :src="item.icon" v-if="item.icon" />
|
||
<ElAvatar v-else src="/favicon.svg" shape="circle" />
|
||
</div>
|
||
<div class="title-right-container">
|
||
<ElText truncated class="title">
|
||
{{ item[titleKey] }}
|
||
</ElText>
|
||
<div class="desc">{{ item.description }}</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<!--选择插件-->
|
||
<div v-if="!isSelectMcp">
|
||
<div v-for="tool in item.tools" :key="tool.id">
|
||
<div
|
||
class="content-title-wrapper"
|
||
@click="toggleSelection(tool.id, !isSelected(tool.id))"
|
||
:class="{ 'item-selected': isSelected(tool.id) }"
|
||
>
|
||
<div class="content-left-container">
|
||
<div class="title-right-container">
|
||
<ElText truncated class="title">
|
||
{{ tool.name }}
|
||
</ElText>
|
||
<div class="desc">{{ tool.description }}</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<ElCheckbox
|
||
:model-value="isSelected(tool.id)"
|
||
@change="(val) => toggleSelection(tool.id, val)"
|
||
@click.stop
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!--选择MCP-->
|
||
<div v-if="isSelectMcp">
|
||
<div v-for="tool in item.tools" :key="tool.name">
|
||
<!-- 2. MCP专属:绑定点击事件,取反MCP选中状态 -->
|
||
<div
|
||
class="content-title-wrapper"
|
||
@click="
|
||
toggleSelectionMcp(
|
||
item.id,
|
||
tool.name,
|
||
tool.description,
|
||
!isSelectedMcp(item.id, tool.name),
|
||
)
|
||
"
|
||
:class="{
|
||
'item-selected': isSelectedMcp(item.id, tool.name),
|
||
}"
|
||
>
|
||
<div class="content-left-container">
|
||
<div class="title-right-container">
|
||
<ElText truncated class="title">
|
||
{{ tool.name }}
|
||
</ElText>
|
||
<div class="desc">{{ tool.description }}</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<ElCheckbox
|
||
:model-value="isSelectedMcp(item.id, tool.name)"
|
||
@change="
|
||
(val) =>
|
||
toggleSelectionMcp(
|
||
item.id,
|
||
tool.name,
|
||
tool.description,
|
||
val,
|
||
)
|
||
"
|
||
@click.stop
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</ElCollapseItem>
|
||
</ElCollapse>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="container-second">
|
||
<div v-for="(item, index) in pageList" :key="index">
|
||
<div
|
||
class="content-title-wrapper"
|
||
@click="toggleSelection(item.id, !isSelected(item.id))"
|
||
:class="{ 'item-selected': isSelected(item.id) }"
|
||
>
|
||
<div class="content-sec-left-container">
|
||
<div>
|
||
<ElAvatar :src="item.icon" v-if="item.icon" />
|
||
<ElAvatar v-else src="/favicon.svg" shape="circle" />
|
||
</div>
|
||
<div class="title-sec-right-container">
|
||
<ElText truncated class="title">
|
||
{{ item.title }}
|
||
</ElText>
|
||
<div class="desc">{{ item.description }}</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<ElCheckbox
|
||
:model-value="isSelected(item.id)"
|
||
@change="(val) => toggleSelection(item.id, val)"
|
||
@click.stop
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
</PageData>
|
||
</div>
|
||
<template #footer>
|
||
<ElButton @click="dialogVisible = false">
|
||
{{ $t('button.cancel') }}
|
||
</ElButton>
|
||
<ElButton type="primary" @click="handleSubmitRun" :loading="loading">
|
||
{{ $t('button.confirm') }}
|
||
</ElButton>
|
||
</template>
|
||
</ElDialog>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.select-modal-container {
|
||
/* height: 100%;
|
||
overflow: auto; */
|
||
background-color: var(--bot-collapse-itme-back);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.title-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.content-title-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
height: 113px;
|
||
padding: 20px 50px 20px 20px;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
background-color: var(--el-bg-color);
|
||
border: 1px solid hsl(var(--border));
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.title {
|
||
font-family: PingFangSC, 'PingFang SC';
|
||
font-size: 16px;
|
||
font-style: normal;
|
||
font-weight: 500;
|
||
line-height: 24px;
|
||
text-align: left;
|
||
text-transform: none;
|
||
}
|
||
|
||
.content-left-container {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.content-sec-left-container {
|
||
display: flex;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.desc {
|
||
display: -webkit-box;
|
||
width: 100%;
|
||
|
||
/* height: 42px;
|
||
min-height: 42px; */
|
||
margin-top: 12px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
-webkit-line-clamp: 2;
|
||
font-family: PingFangSC, 'PingFang SC';
|
||
font-size: 14px;
|
||
font-style: normal;
|
||
font-weight: 400;
|
||
line-height: 22px;
|
||
text-align: left;
|
||
text-transform: none;
|
||
opacity: 0.65;
|
||
-webkit-box-orient: vertical;
|
||
}
|
||
|
||
.title-right-container {
|
||
/* display: flex;
|
||
flex-direction: column;
|
||
align-items: first baseline;
|
||
justify-content: center; */
|
||
padding-right: 10px;
|
||
margin-left: 10px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.title-sec-right-container {
|
||
/* display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start; */
|
||
padding-right: 10px;
|
||
margin-left: 10px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.container-second {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
|
||
/* padding: 20px 20px; */
|
||
}
|
||
|
||
.select-modal-container
|
||
:deep(.el-collapse-item__header .el-collapse-item__arrow) {
|
||
color: #666;
|
||
}
|
||
|
||
.select-modal-container
|
||
:deep(.el-collapse-item.is-active .el-collapse-item__arrow) {
|
||
color: #1976d2;
|
||
}
|
||
|
||
.select-modal-container
|
||
:deep(.el-collapse-item__content)
|
||
.content-title-wrapper:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.select-modal-container :deep(.el-collapse-item__header) {
|
||
height: auto;
|
||
padding: 12px;
|
||
line-height: normal;
|
||
color: #333;
|
||
background-color: hsl(var(--background));
|
||
}
|
||
|
||
.select-modal-container :deep(.el-collapse-item__header:hover) {
|
||
background-color: hsl(var(--background));
|
||
border-color: hsl(var(--border));
|
||
}
|
||
|
||
.select-modal-container
|
||
:deep(.el-collapse-item.is-active .el-collapse-item__header) {
|
||
color: #1976d2;
|
||
background-color: hsl(var(--background));
|
||
border: none;
|
||
border-bottom-color: transparent;
|
||
}
|
||
|
||
.select-modal-container :deep(.el-collapse-item__content) {
|
||
padding: 12px;
|
||
background-color: hsl(var(--background));
|
||
border: none;
|
||
}
|
||
|
||
.select-modal-container :deep(.el-collapse-item__wrap) {
|
||
background-color: hsl(var(--background));
|
||
border: none;
|
||
}
|
||
|
||
.select-modal-container :deep(.el-collapse-item) {
|
||
margin-bottom: 8px;
|
||
background-color: hsl(var(--background));
|
||
}
|
||
|
||
.select-modal-container
|
||
:deep(.el-collapse-item__content)
|
||
.content-title-wrapper {
|
||
margin-top: 12px;
|
||
margin-bottom: 8px;
|
||
background-color: var(--bot-collapse-itme-back);
|
||
border: 1px solid hsl(var(--border));
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.select-modal-container
|
||
:deep(.el-collapse-item__content)
|
||
.content-title-wrapper:hover {
|
||
background-color: var(--bot-collapse-itme-back);
|
||
}
|
||
|
||
.select-modal-container :deep(.el-collapse) {
|
||
overflow: hidden;
|
||
background-color: hsl(var(--background));
|
||
border: 1px solid hsl(var(--border));
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.select-modal-container :deep(.el-checkbox__inner) {
|
||
--el-checkbox-input-border: 1px solid hsl(var(--border));
|
||
}
|
||
</style>
|