VarSelect.vue 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. <template>
  2. <div class="var-select">
  3. <el-popover
  4. v-model:visible="visible"
  5. trigger="click"
  6. width="320"
  7. popper-class="var-select__popover"
  8. placement="bottom-start"
  9. >
  10. <template #reference>
  11. <div class="var-select__trigger">
  12. <el-input-tag
  13. :model-value="innerValue ? [innerValue] : undefined"
  14. placeholder="选择变量"
  15. aria-label="选择变量"
  16. clearable
  17. @clear="handleClear"
  18. @remove-tag="handleClear"
  19. autocomplete
  20. v-bind="$attrs"
  21. >
  22. <template #tag>
  23. <div v-if="valueInfo.type === 'env'" class="flex gap-1 items-center truncate">
  24. <span class="var-select__item-prefix env text-6px">
  25. <span>ENV</span>
  26. </span>
  27. <span class="text-gray-600">{{ valueInfo.value }}</span>
  28. </div>
  29. <div v-else-if="valueInfo.type === 'sys'" class="flex gap-1 items-center truncate">
  30. <span class="var-select__item-prefix sys text-6px">
  31. <span>SYS</span>
  32. </span>
  33. <span class="text-gray-600">{{ valueInfo.value }}</span>
  34. </div>
  35. <div v-else class="truncate" :title="valueInfo.nodeName + ' / ' + valueInfo.value">
  36. <span
  37. class="var-select__item-prefix env text-10px"
  38. :style="{ background: nodeMap[valueInfo?.nodeType ?? '']?.iconColor ?? '' }"
  39. >
  40. <Icon :icon="nodeMap[valueInfo?.nodeType!]?.icon!" :size="18" />
  41. </span>
  42. <span>{{ valueInfo.nodeName }}</span>
  43. <span class="mx-2px text-gray-400">/</span>
  44. <span class="text-gray-600">{{ valueInfo.value }}</span>
  45. </div>
  46. </template>
  47. </el-input-tag>
  48. </div>
  49. </template>
  50. <div class="var-select__panel">
  51. <el-input
  52. v-model="keyword"
  53. clearable
  54. size="small"
  55. placeholder="搜索变量"
  56. class="var-select__search"
  57. />
  58. <div class="var-select__list">
  59. <!-- 变量列表 -->
  60. <div v-for="group in filteredVars" class="var-select__group">
  61. <div class="var-select__group-title">{{ group.name }}</div>
  62. <div
  63. v-for="item in group.variableList"
  64. :key="item.expression"
  65. class="var-select__item"
  66. @click="
  67. handleSelect({
  68. value: item.expression,
  69. type: item.type
  70. })
  71. "
  72. >
  73. <span
  74. class="var-select__item-prefix env text-10px"
  75. :style="{ background: nodeMap[group?.type ?? '']?.iconColor ?? '' }"
  76. >
  77. <span v-if="group.id === 'env'" class="text-6px">ENV</span>
  78. <span v-if="group.id === 'sys'" class="text-6px">SYS</span>
  79. <Icon v-if="group?.type" :icon="nodeMap[group?.type]?.icon!" :size="18" />
  80. </span>
  81. <span class="var-select__item-name" :title="item.name">{{ item.name }}</span>
  82. <span class="var-select__item-type">{{ normalizeTypeLabel(item.type) }}</span>
  83. </div>
  84. </div>
  85. <div v-if="!filteredVars.length" class="var-select__empty">暂无匹配的变量</div>
  86. </div>
  87. </div>
  88. </el-popover>
  89. </div>
  90. </template>
  91. <script setup lang="ts">
  92. import { computed, ref, watch, inject, type Ref } from 'vue'
  93. import { Icon } from '@repo/ui'
  94. import { nodeMap } from '@/nodes'
  95. import { VARIABLE_TYPE_OPTIONS } from '@/constant'
  96. import type { NodeVar, VarType } from '@/types/var'
  97. interface Props {
  98. /**
  99. * 选中的变量,格式 #{xxx.var}
  100. */
  101. modelValue: string
  102. /**
  103. * 过滤方法
  104. */
  105. filterFn?: (list: NodeVar[]) => NodeVar[]
  106. }
  107. const props = withDefaults(defineProps<Props>(), {
  108. modelValue: ''
  109. })
  110. const emit = defineEmits<{
  111. (e: 'update:modelValue', value: string): void
  112. (e: 'change', value: { value: string; type: string }): void
  113. }>()
  114. const nodeVars = inject<Ref<NodeVar[]>>('nodeVars')
  115. const visible = ref(false)
  116. const keyword = ref('')
  117. const innerValue = ref<string>(props.modelValue)
  118. watch(
  119. () => props.modelValue,
  120. (val) => {
  121. if (val !== innerValue.value) {
  122. innerValue.value = val
  123. }
  124. },
  125. { deep: true }
  126. )
  127. const handleClear = () => {
  128. innerValue.value = ''
  129. emit('update:modelValue', '')
  130. }
  131. watch(
  132. () => innerValue.value,
  133. (val) => {
  134. emit('update:modelValue', val)
  135. },
  136. { deep: true }
  137. )
  138. const valueInfo = computed(() => {
  139. // 根据#{xxx.var}解析变量
  140. // 解析格式 #{env.xxx} 或 #{nodeId.xxx}
  141. if (!innerValue.value?.startsWith('#{') || !innerValue.value?.endsWith('}')) {
  142. return {
  143. type: 'env',
  144. name: innerValue.value || '',
  145. value: innerValue.value || ''
  146. }
  147. }
  148. const expr = innerValue.value.slice(2, -1) // 去掉 #{ 和 }
  149. const [prefix, ...rest] = expr.split('.')
  150. const varName = rest.join('.')
  151. if (!prefix || !varName) {
  152. return {
  153. type: 'env',
  154. name: innerValue.value || '',
  155. value: innerValue.value || ''
  156. }
  157. }
  158. if (prefix === 'env') {
  159. // 环境变量
  160. return {
  161. type: 'env',
  162. name: 'env',
  163. value: varName
  164. }
  165. } else if (prefix.startsWith('sys')) {
  166. // 系统变量
  167. return {
  168. type: 'sys',
  169. name: 'sys',
  170. value: varName
  171. }
  172. } else {
  173. // 节点变量,需要解析节点类型名称
  174. if (nodeVars && Array.isArray(nodeVars.value)) {
  175. const node = nodeVars.value.find((item) => item.id === prefix)
  176. return {
  177. type: 'node',
  178. nodeType: node?.type || '',
  179. nodeName: node?.name || prefix,
  180. name: varName,
  181. value: varName
  182. }
  183. } else {
  184. return {
  185. type: 'node',
  186. nodeType: '',
  187. nodeName: prefix,
  188. name: varName,
  189. value: varName
  190. }
  191. }
  192. }
  193. })
  194. const normalizeTypeLabel = (type?: VarType) => {
  195. if (!type) return ''
  196. return VARIABLE_TYPE_OPTIONS.find((item) => item.value === type)?.label || ''
  197. }
  198. /**
  199. * 过滤变量列表
  200. */
  201. const filteredVars = computed(() => {
  202. const kw = keyword.value.trim().toLowerCase()
  203. let list = nodeVars?.value || []
  204. // 先根据 filterFn 过滤一遍
  205. if (props.filterFn) {
  206. list = props.filterFn(list)
  207. }
  208. const options = list?.filter((item) => item.variableList.find((i) => i.name.includes(kw)))
  209. return options || []
  210. })
  211. const handleSelect = (variable: { value: string; type: string }) => {
  212. innerValue.value = variable.value
  213. visible.value = false
  214. keyword.value = ''
  215. emit('change', variable)
  216. }
  217. </script>
  218. <style scoped lang="less">
  219. .var-select {
  220. width: 100%;
  221. &__trigger {
  222. width: 100%;
  223. cursor: pointer;
  224. }
  225. }
  226. .var-select__popover {
  227. padding: 8px !important;
  228. }
  229. .var-select__panel {
  230. display: flex;
  231. flex-direction: column;
  232. gap: 8px;
  233. }
  234. .var-select__search {
  235. :deep(.el-input__wrapper) {
  236. box-shadow: none;
  237. border-radius: 8px;
  238. background-color: #f5f5f5;
  239. }
  240. }
  241. .var-select__list {
  242. max-height: 260px;
  243. overflow-y: auto;
  244. }
  245. .var-select__group + .var-select__group {
  246. margin-top: 8px;
  247. border-top: 1px solid #f2f2f2;
  248. padding-top: 8px;
  249. }
  250. .var-select__group-title {
  251. font-size: 12px;
  252. color: #999;
  253. margin-bottom: 4px;
  254. }
  255. .var-select__item {
  256. display: flex;
  257. align-items: center;
  258. gap: 6px;
  259. padding: 4px 6px;
  260. border-radius: 6px;
  261. cursor: pointer;
  262. font-size: 12px;
  263. color: #333;
  264. &:hover {
  265. background-color: #f5f7ff;
  266. }
  267. }
  268. .var-select__item-prefix {
  269. display: inline-flex;
  270. align-items: center;
  271. justify-content: center;
  272. width: 20px;
  273. height: 20px;
  274. border-radius: 6px;
  275. color: #fff;
  276. flex-shrink: 0;
  277. &.env {
  278. background: #6366f1;
  279. }
  280. &.sys {
  281. background: #f97316;
  282. }
  283. }
  284. .var-select__item-name {
  285. flex: 1;
  286. overflow: hidden;
  287. text-overflow: ellipsis;
  288. white-space: nowrap;
  289. }
  290. .var-select__item-type {
  291. color: #999;
  292. flex-shrink: 0;
  293. }
  294. .var-select__empty {
  295. padding: 12px 4px;
  296. font-size: 12px;
  297. color: #999;
  298. text-align: center;
  299. }
  300. :deep(.el-input-tag__inner) {
  301. flex-wrap: nowrap;
  302. }
  303. </style>