mirror of
https://github.com/yudaocode/yudao-ui-admin-vue3.git
synced 2026-05-06 07:49:33 +00:00
perf:【IoT 物联网】场景联动目录结构优化
This commit is contained in:
242
src/views/iot/rule/scene/form/inputs/CronBuilder.vue
Normal file
242
src/views/iot/rule/scene/form/inputs/CronBuilder.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<!-- CRON 可视化构建器组件 -->
|
||||
<!-- TODO @puhui999:看看能不能复用全局的 cron 组件 -->
|
||||
<template>
|
||||
<div class="cron-builder">
|
||||
<div class="builder-header">
|
||||
<span class="header-title">可视化 CRON 编辑器</span>
|
||||
</div>
|
||||
|
||||
<div class="builder-content">
|
||||
<!-- 快捷选项 -->
|
||||
<div class="quick-options">
|
||||
<span class="options-label">常用配置:</span>
|
||||
<el-button
|
||||
v-for="option in quickOptions"
|
||||
:key="option.label"
|
||||
size="small"
|
||||
@click="applyQuickOption(option)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 详细配置 -->
|
||||
<div class="detailed-config">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="4">
|
||||
<el-form-item label="秒">
|
||||
<el-select v-model="cronParts.second" @change="updateCronExpression">
|
||||
<el-option label="每秒" value="*" />
|
||||
<el-option label="0秒" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-form-item label="分钟">
|
||||
<el-select v-model="cronParts.minute" @change="updateCronExpression">
|
||||
<el-option label="每分钟" value="*" />
|
||||
<el-option
|
||||
v-for="i in 60"
|
||||
:key="i - 1"
|
||||
:label="`${i - 1}分`"
|
||||
:value="String(i - 1)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-form-item label="小时">
|
||||
<el-select v-model="cronParts.hour" @change="updateCronExpression">
|
||||
<el-option label="每小时" value="*" />
|
||||
<el-option
|
||||
v-for="i in 24"
|
||||
:key="i - 1"
|
||||
:label="`${i - 1}时`"
|
||||
:value="String(i - 1)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-form-item label="日">
|
||||
<el-select v-model="cronParts.day" @change="updateCronExpression">
|
||||
<el-option label="每日" value="*" />
|
||||
<el-option v-for="i in 31" :key="i" :label="`${i}日`" :value="String(i)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-form-item label="月">
|
||||
<el-select v-model="cronParts.month" @change="updateCronExpression">
|
||||
<el-option label="每月" value="*" />
|
||||
<el-option
|
||||
v-for="(month, index) in months"
|
||||
:key="index"
|
||||
:label="month"
|
||||
:value="String(index + 1)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-form-item label="周">
|
||||
<el-select v-model="cronParts.week" @change="updateCronExpression">
|
||||
<el-option label="每周" value="*" />
|
||||
<el-option
|
||||
v-for="(week, index) in weeks"
|
||||
:key="index"
|
||||
:label="week"
|
||||
:value="String(index)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
|
||||
/** CRON 可视化构建器组件 */
|
||||
defineOptions({ name: 'CronBuilder' })
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'validate', result: { valid: boolean; message: string }): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const localValue = useVModel(props, 'modelValue', emit)
|
||||
|
||||
// CRON 各部分
|
||||
const cronParts = reactive({
|
||||
second: '0',
|
||||
minute: '0',
|
||||
hour: '12',
|
||||
day: '*',
|
||||
month: '*',
|
||||
week: '?'
|
||||
})
|
||||
|
||||
// 常量数据
|
||||
const months = [
|
||||
'1月',
|
||||
'2月',
|
||||
'3月',
|
||||
'4月',
|
||||
'5月',
|
||||
'6月',
|
||||
'7月',
|
||||
'8月',
|
||||
'9月',
|
||||
'10月',
|
||||
'11月',
|
||||
'12月'
|
||||
]
|
||||
const weeks = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
|
||||
// 快捷选项
|
||||
const quickOptions = [
|
||||
{ label: '每分钟', cron: '0 * * * * ?' },
|
||||
{ label: '每小时', cron: '0 0 * * * ?' },
|
||||
{ label: '每天中午', cron: '0 0 12 * * ?' },
|
||||
{ label: '每天凌晨', cron: '0 0 0 * * ?' },
|
||||
{ label: '工作日9点', cron: '0 0 9 * * MON-FRI' },
|
||||
{ label: '每周一', cron: '0 0 9 * * MON' }
|
||||
]
|
||||
|
||||
// 方法
|
||||
const updateCronExpression = () => {
|
||||
localValue.value = `${cronParts.second} ${cronParts.minute} ${cronParts.hour} ${cronParts.day} ${cronParts.month} ${cronParts.week}`
|
||||
emit('validate', { valid: true, message: 'CRON表达式验证通过' })
|
||||
}
|
||||
|
||||
const applyQuickOption = (option: any) => {
|
||||
localValue.value = option.cron
|
||||
parseCronExpression()
|
||||
emit('validate', { valid: true, message: 'CRON表达式验证通过' })
|
||||
}
|
||||
|
||||
const parseCronExpression = () => {
|
||||
if (!localValue.value) return
|
||||
|
||||
const parts = localValue.value.split(' ')
|
||||
if (parts.length >= 6) {
|
||||
cronParts.second = parts[0] || '0'
|
||||
cronParts.minute = parts[1] || '0'
|
||||
cronParts.hour = parts[2] || '12'
|
||||
cronParts.day = parts[3] || '*'
|
||||
cronParts.month = parts[4] || '*'
|
||||
cronParts.week = parts[5] || '?'
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
if (localValue.value) {
|
||||
parseCronExpression()
|
||||
} else {
|
||||
updateCronExpression()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cron-builder {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.builder-header {
|
||||
padding: 12px 16px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.builder-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.quick-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.options-label {
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detailed-config {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
141
src/views/iot/rule/scene/form/inputs/CronInput.vue
Normal file
141
src/views/iot/rule/scene/form/inputs/CronInput.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<!-- CRON 表达式输入组件 -->
|
||||
<!-- TODO @puhui999:看看能不能复用全局的 cron 组件 -->
|
||||
<template>
|
||||
<div class="cron-input">
|
||||
<el-input
|
||||
v-model="localValue"
|
||||
placeholder="请输入 CRON 表达式,如:0 0 12 * * ?"
|
||||
@blur="handleBlur"
|
||||
@input="handleInput"
|
||||
>
|
||||
<template #suffix>
|
||||
<el-tooltip content="CRON 表达式帮助" placement="top">
|
||||
<Icon icon="ep:question-filled" class="input-help" @click="showHelp = !showHelp" />
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<!-- 帮助信息 -->
|
||||
<div v-if="showHelp" class="cron-help">
|
||||
<el-alert title="CRON 表达式格式:秒 分 时 日 月 周" type="info" :closable="false" show-icon>
|
||||
<template #default>
|
||||
<div class="help-content">
|
||||
<p><strong>示例:</strong></p>
|
||||
<ul>
|
||||
<li><code>0 0 12 * * ?</code> - 每天中午12点执行</li>
|
||||
<li><code>0 */5 * * * ?</code> - 每5分钟执行一次</li>
|
||||
<li><code>0 0 9-17 * * MON-FRI</code> - 工作日9-17点每小时执行</li>
|
||||
</ul>
|
||||
<p><strong>特殊字符:</strong></p>
|
||||
<ul>
|
||||
<li><code>*</code> - 匹配任意值</li>
|
||||
<li><code>?</code> - 不指定值(用于日和周)</li>
|
||||
<li><code>/</code> - 间隔触发,如 */5 表示每5个单位</li>
|
||||
<li><code>-</code> - 范围,如 9-17 表示9到17</li>
|
||||
<li><code>,</code> - 列举,如 MON,WED,FRI</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { validateCronExpression } from '../../utils/validation'
|
||||
|
||||
/** CRON 表达式输入组件 */
|
||||
defineOptions({ name: 'CronInput' })
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'validate', result: { valid: boolean; message: string }): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const localValue = useVModel(props, 'modelValue', emit)
|
||||
|
||||
// 状态
|
||||
const showHelp = ref(false)
|
||||
|
||||
// 事件处理
|
||||
const handleInput = () => {
|
||||
validateExpression()
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
validateExpression()
|
||||
}
|
||||
|
||||
const validateExpression = () => {
|
||||
if (!localValue.value) {
|
||||
emit('validate', { valid: false, message: '请输入CRON表达式' })
|
||||
return
|
||||
}
|
||||
|
||||
const isValid = validateCronExpression(localValue.value)
|
||||
if (isValid) {
|
||||
emit('validate', { valid: true, message: 'CRON表达式验证通过' })
|
||||
} else {
|
||||
emit('validate', { valid: false, message: 'CRON表达式格式不正确' })
|
||||
}
|
||||
}
|
||||
|
||||
// 监听值变化
|
||||
watch(
|
||||
() => localValue.value,
|
||||
() => {
|
||||
validateExpression()
|
||||
}
|
||||
)
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
if (localValue.value) {
|
||||
validateExpression()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cron-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-help {
|
||||
color: var(--el-text-color-placeholder);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.input-help:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.cron-help {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.help-content ul {
|
||||
margin: 8px 0 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.help-content li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.help-content code {
|
||||
background: var(--el-fill-color-light);
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
</style>
|
||||
178
src/views/iot/rule/scene/form/inputs/DescriptionInput.vue
Normal file
178
src/views/iot/rule/scene/form/inputs/DescriptionInput.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<!-- 场景描述输入组件 -->
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<el-input
|
||||
ref="inputRef"
|
||||
v-model="localValue"
|
||||
type="textarea"
|
||||
placeholder="请输入场景描述(可选)"
|
||||
:rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
resize="none"
|
||||
@input="handleInput"
|
||||
/>
|
||||
|
||||
<!-- 描述模板 -->
|
||||
<teleport to="body">
|
||||
<div v-if="showTemplates" ref="templateDropdownRef" class="fixed z-1000 bg-white border border-[var(--el-border-color-light)] rounded-6px shadow-[var(--el-box-shadow)] min-w-300px max-w-400px" :style="dropdownStyle">
|
||||
<div class="flex items-center justify-between p-12px border-b border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-light)]">
|
||||
<span class="text-14px font-500 text-[var(--el-text-color-primary)]">描述模板</span>
|
||||
<el-button type="text" size="small" @click="showTemplates = false">
|
||||
<Icon icon="ep:close" />
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="max-h-300px overflow-y-auto">
|
||||
<div
|
||||
v-for="template in descriptionTemplates"
|
||||
:key="template.title"
|
||||
class="p-12px border-b border-[var(--el-border-color-lighter)] cursor-pointer transition-colors duration-200 hover:bg-[var(--el-fill-color-light)] last:border-b-0"
|
||||
@click="applyTemplate(template)"
|
||||
>
|
||||
<div class="text-14px font-500 text-[var(--el-text-color-primary)] mb-4px">{{ template.title }}</div>
|
||||
<div class="text-12px text-[var(--el-text-color-secondary)] leading-relaxed">{{ template.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
|
||||
<!-- TODO @puhui999:不用模版哈,简单点。。。 -->
|
||||
<!-- 模板按钮 -->
|
||||
<div v-if="!localValue && !showTemplates" class="absolute top-2px right-2px">
|
||||
<el-button type="text" size="small" @click="toggleTemplates">
|
||||
<Icon icon="ep:document" class="mr-1" />
|
||||
使用模板
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
|
||||
/** 场景描述输入组件 */
|
||||
defineOptions({ name: 'DescriptionInput' })
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const localValue = useVModel(props, 'modelValue', emit, {
|
||||
defaultValue: ''
|
||||
})
|
||||
|
||||
const showTemplates = ref(false)
|
||||
const templateDropdownRef = ref()
|
||||
const inputRef = ref()
|
||||
const dropdownStyle = ref({})
|
||||
|
||||
// 描述模板
|
||||
const descriptionTemplates = [
|
||||
{
|
||||
title: '温度控制场景',
|
||||
content: '当环境温度超过设定阈值时,自动启动空调降温设备,确保环境温度保持在舒适范围内。'
|
||||
},
|
||||
{
|
||||
title: '设备监控场景',
|
||||
content: '实时监控关键设备的运行状态,当设备出现异常或离线时,立即发送告警通知相关人员。'
|
||||
},
|
||||
{
|
||||
title: '节能控制场景',
|
||||
content: '根据时间段和环境条件,自动调节设备功率或关闭非必要设备,实现智能节能管理。'
|
||||
},
|
||||
{
|
||||
title: '安防联动场景',
|
||||
content: '当检测到异常情况时,自动触发安防设备联动,包括报警器、摄像头录制等安全措施。'
|
||||
},
|
||||
{
|
||||
title: '定时任务场景',
|
||||
content: '按照预设的时间计划,定期执行设备检查、数据备份或系统维护等自动化任务。'
|
||||
}
|
||||
]
|
||||
|
||||
// 计算下拉框位置
|
||||
const calculateDropdownPosition = () => {
|
||||
if (!inputRef.value) return
|
||||
|
||||
const inputElement = inputRef.value.$el || inputRef.value
|
||||
const rect = inputElement.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight
|
||||
const dropdownHeight = 300 // 预估下拉框高度
|
||||
|
||||
let top = rect.bottom + 4
|
||||
let left = rect.left
|
||||
|
||||
// 如果下方空间不够,显示在上方
|
||||
if (top + dropdownHeight > viewportHeight) {
|
||||
top = rect.top - dropdownHeight - 4
|
||||
}
|
||||
|
||||
// 确保不超出左右边界
|
||||
const maxLeft = window.innerWidth - 400 // 下拉框最大宽度
|
||||
if (left > maxLeft) {
|
||||
left = maxLeft
|
||||
}
|
||||
if (left < 10) {
|
||||
left = 10
|
||||
}
|
||||
|
||||
dropdownStyle.value = {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`
|
||||
}
|
||||
}
|
||||
|
||||
const handleInput = (value: string) => {
|
||||
if (value.length > 0) {
|
||||
showTemplates.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const applyTemplate = (template: any) => {
|
||||
localValue.value = template.content
|
||||
showTemplates.value = false
|
||||
}
|
||||
|
||||
const toggleTemplates = () => {
|
||||
showTemplates.value = !showTemplates.value
|
||||
if (showTemplates.value) {
|
||||
nextTick(() => {
|
||||
calculateDropdownPosition()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 点击外部关闭下拉框
|
||||
const handleClickOutside = (event: Event) => {
|
||||
if (
|
||||
templateDropdownRef.value &&
|
||||
!templateDropdownRef.value.contains(event.target as Node) &&
|
||||
inputRef.value &&
|
||||
!inputRef.value.$el.contains(event.target as Node)
|
||||
) {
|
||||
showTemplates.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听窗口大小变化和点击事件
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', calculateDropdownPosition)
|
||||
window.addEventListener('scroll', calculateDropdownPosition)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', calculateDropdownPosition)
|
||||
window.removeEventListener('scroll', calculateDropdownPosition)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
111
src/views/iot/rule/scene/form/inputs/NameInput.vue
Normal file
111
src/views/iot/rule/scene/form/inputs/NameInput.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<!-- 场景名称输入组件 -->
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<el-input
|
||||
v-model="localValue"
|
||||
placeholder="请输入场景名称"
|
||||
maxlength="50"
|
||||
show-word-limit
|
||||
clearable
|
||||
@blur="handleBlur"
|
||||
@input="handleInput"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon icon="ep:edit" class="text-[var(--el-text-color-placeholder)]" />
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<!-- 智能提示 -->
|
||||
<!-- TODO @puhui999:暂时不用考虑智能推荐哈。用途不大 -->
|
||||
<div v-if="showSuggestions && suggestions.length > 0" class="absolute top-full left-0 right-0 z-1000 bg-white border border-[var(--el-border-color-light)] rounded-4px shadow-[var(--el-box-shadow-light)] mt-4px">
|
||||
<div class="p-8px px-12px border-b border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-light)]">
|
||||
<span class="text-12px text-[var(--el-text-color-secondary)] font-500">推荐名称</span>
|
||||
</div>
|
||||
<div class="max-h-200px overflow-y-auto">
|
||||
<div
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion"
|
||||
class="p-8px px-12px cursor-pointer transition-colors duration-200 text-14px text-[var(--el-text-color-primary)] hover:bg-[var(--el-fill-color-light)] last:border-b-0"
|
||||
@click="applySuggestion(suggestion)"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
|
||||
/** 场景名称输入组件 */
|
||||
defineOptions({ name: 'NameInput' })
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const localValue = useVModel(props, 'modelValue', emit)
|
||||
|
||||
// 智能提示相关
|
||||
const showSuggestions = ref(false)
|
||||
const suggestions = ref<string[]>([])
|
||||
|
||||
// 常用场景名称模板
|
||||
const nameTemplates = [
|
||||
'温度过高自动降温',
|
||||
'设备离线告警通知',
|
||||
'湿度异常自动调节',
|
||||
'夜间安防模式启动',
|
||||
'能耗超标自动关闭',
|
||||
'故障设备自动重启',
|
||||
'定时设备状态检查',
|
||||
'环境数据异常告警'
|
||||
]
|
||||
|
||||
const handleInput = (value: string) => {
|
||||
if (value.length > 0 && value.length < 10) {
|
||||
// 根据输入内容过滤建议
|
||||
suggestions.value = nameTemplates
|
||||
.filter(
|
||||
(template) =>
|
||||
template.includes(value) || (value.includes('温度') && template.includes('温度'))
|
||||
)
|
||||
.slice(0, 5)
|
||||
showSuggestions.value = suggestions.value.length > 0
|
||||
} else {
|
||||
showSuggestions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
// 延迟隐藏建议,允许点击建议项
|
||||
setTimeout(() => {
|
||||
showSuggestions.value = false
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const applySuggestion = (suggestion: string) => {
|
||||
localValue.value = suggestion
|
||||
showSuggestions.value = false
|
||||
}
|
||||
|
||||
// 监听外部点击隐藏建议
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.name-input')) {
|
||||
showSuggestions.value = false
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
158
src/views/iot/rule/scene/form/inputs/StatusRadio.vue
Normal file
158
src/views/iot/rule/scene/form/inputs/StatusRadio.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<!-- 场景状态选择组件 -->
|
||||
<template>
|
||||
<div class="status-radio">
|
||||
<el-radio-group
|
||||
v-model="localValue"
|
||||
@change="handleChange"
|
||||
>
|
||||
<el-radio :value="0" class="status-option">
|
||||
<div class="status-content">
|
||||
<div class="status-indicator enabled"></div>
|
||||
<div class="status-info">
|
||||
<div class="status-label">启用</div>
|
||||
<div class="status-desc">场景规则生效,满足条件时自动执行</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-radio>
|
||||
|
||||
<el-radio :value="1" class="status-option">
|
||||
<div class="status-content">
|
||||
<div class="status-indicator disabled"></div>
|
||||
<div class="status-info">
|
||||
<div class="status-label">禁用</div>
|
||||
<div class="status-desc">场景规则暂停,不会触发任何执行动作</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
|
||||
/** 场景状态选择组件 */
|
||||
defineOptions({ name: 'StatusRadio' })
|
||||
|
||||
interface Props {
|
||||
modelValue: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: number): void
|
||||
(e: 'change', value: number): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const localValue = useVModel(props, 'modelValue', emit)
|
||||
|
||||
const handleChange = (value: number) => {
|
||||
emit('change', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-radio {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.status-radio :deep(.el-radio) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-radio :deep(.el-radio:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.el-radio-group) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
:deep(.el-radio) {
|
||||
margin-right: 0;
|
||||
width: auto;
|
||||
flex: 1;
|
||||
height: auto;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.status-option {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.el-radio__input) {
|
||||
margin-top: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.el-radio__label) {
|
||||
width: 100%;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.status-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
:deep(.el-radio.is-checked) .status-content {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.status-content:hover {
|
||||
border-color: var(--el-color-primary-light-3);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
margin-top: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-indicator.enabled {
|
||||
background: var(--el-color-success);
|
||||
box-shadow: 0 0 0 2px var(--el-color-success-light-8);
|
||||
}
|
||||
|
||||
.status-indicator.disabled {
|
||||
background: var(--el-color-danger);
|
||||
box-shadow: 0 0 0 2px var(--el-color-danger-light-8);
|
||||
}
|
||||
|
||||
.status-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.status-desc {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
415
src/views/iot/rule/scene/form/inputs/ValueInput.vue
Normal file
415
src/views/iot/rule/scene/form/inputs/ValueInput.vue
Normal file
@@ -0,0 +1,415 @@
|
||||
<!-- 值输入组件 -->
|
||||
<!-- TODO @yunai:这个需要在看看。。。 -->
|
||||
<template>
|
||||
<div class="value-input">
|
||||
<!-- 布尔值选择 -->
|
||||
<el-select
|
||||
v-if="propertyType === 'bool'"
|
||||
v-model="localValue"
|
||||
placeholder="请选择布尔值"
|
||||
@change="handleChange"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option label="真 (true)" value="true" />
|
||||
<el-option label="假 (false)" value="false" />
|
||||
</el-select>
|
||||
|
||||
<!-- 枚举值选择 -->
|
||||
<el-select
|
||||
v-else-if="propertyType === 'enum' && enumOptions.length > 0"
|
||||
v-model="localValue"
|
||||
placeholder="请选择枚举值"
|
||||
@change="handleChange"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in enumOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<!-- 范围输入 (between 操作符) -->
|
||||
<div v-else-if="operator === 'between'" class="range-input">
|
||||
<el-input
|
||||
v-model="rangeStart"
|
||||
:type="getInputType()"
|
||||
placeholder="最小值"
|
||||
@input="handleRangeChange"
|
||||
class="range-start"
|
||||
/>
|
||||
<span class="range-separator">至</span>
|
||||
<el-input
|
||||
v-model="rangeEnd"
|
||||
:type="getInputType()"
|
||||
placeholder="最大值"
|
||||
@input="handleRangeChange"
|
||||
class="range-end"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 列表输入 (in 操作符) -->
|
||||
<div v-else-if="operator === 'in'" class="list-input">
|
||||
<el-input
|
||||
v-model="localValue"
|
||||
placeholder="请输入值列表,用逗号分隔"
|
||||
@input="handleChange"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>
|
||||
<el-tooltip content="多个值用逗号分隔,如:1,2,3" placement="top">
|
||||
<Icon icon="ep:question-filled" class="input-tip" />
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-input>
|
||||
<div v-if="listPreview.length > 0" class="list-preview">
|
||||
<span class="preview-label">解析结果:</span>
|
||||
<el-tag v-for="(item, index) in listPreview" :key="index" size="small" class="preview-tag">
|
||||
{{ item }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日期时间输入 -->
|
||||
<el-date-picker
|
||||
v-else-if="propertyType === 'date'"
|
||||
v-model="dateValue"
|
||||
type="datetime"
|
||||
placeholder="请选择日期时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
@change="handleDateChange"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<!-- 数字输入 -->
|
||||
<el-input-number
|
||||
v-else-if="isNumericType()"
|
||||
v-model="numberValue"
|
||||
:precision="getPrecision()"
|
||||
:step="getStep()"
|
||||
:min="getMin()"
|
||||
:max="getMax()"
|
||||
placeholder="请输入数值"
|
||||
@change="handleNumberChange"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<!-- 文本输入 -->
|
||||
<el-input
|
||||
v-else
|
||||
v-model="localValue"
|
||||
:type="getInputType()"
|
||||
:placeholder="getPlaceholder()"
|
||||
@input="handleChange"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>
|
||||
<el-tooltip
|
||||
v-if="propertyConfig?.unit"
|
||||
:content="`单位:${propertyConfig.unit}`"
|
||||
placement="top"
|
||||
>
|
||||
<span class="input-unit">{{ propertyConfig.unit }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<!-- 验证提示 -->
|
||||
<div v-if="validationMessage" class="validation-message">
|
||||
<el-text :type="isValid ? 'success' : 'danger'" size="small">
|
||||
<Icon :icon="isValid ? 'ep:check' : 'ep:warning-filled'" />
|
||||
{{ validationMessage }}
|
||||
</el-text>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVModel } from '@vueuse/core'
|
||||
|
||||
/** 值输入组件 */
|
||||
defineOptions({ name: 'ValueInput' })
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
propertyType?: string
|
||||
operator?: string
|
||||
propertyConfig?: any
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'validate', result: { valid: boolean; message: string }): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const localValue = useVModel(props, 'modelValue', emit, {
|
||||
defaultValue: ''
|
||||
})
|
||||
|
||||
// 状态
|
||||
const rangeStart = ref('')
|
||||
const rangeEnd = ref('')
|
||||
const dateValue = ref('')
|
||||
const numberValue = ref<number>()
|
||||
const validationMessage = ref('')
|
||||
const isValid = ref(true)
|
||||
|
||||
// 计算属性
|
||||
const enumOptions = computed(() => {
|
||||
if (props.propertyConfig?.enum) {
|
||||
return props.propertyConfig.enum.map((item: any) => ({
|
||||
label: item.name || item.label || item.value,
|
||||
value: item.value
|
||||
}))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const listPreview = computed(() => {
|
||||
if (props.operator === 'in' && localValue.value) {
|
||||
return localValue.value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item)
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// 工具函数
|
||||
const isNumericType = () => {
|
||||
return ['int', 'float', 'double'].includes(props.propertyType || '')
|
||||
}
|
||||
|
||||
const getInputType = () => {
|
||||
switch (props.propertyType) {
|
||||
case 'int':
|
||||
case 'float':
|
||||
case 'double':
|
||||
return 'number'
|
||||
default:
|
||||
return 'text'
|
||||
}
|
||||
}
|
||||
|
||||
const getPlaceholder = () => {
|
||||
const typeMap = {
|
||||
string: '请输入字符串',
|
||||
int: '请输入整数',
|
||||
float: '请输入浮点数',
|
||||
double: '请输入双精度数',
|
||||
struct: '请输入JSON格式数据',
|
||||
array: '请输入数组格式数据'
|
||||
}
|
||||
return typeMap[props.propertyType || ''] || '请输入值'
|
||||
}
|
||||
|
||||
const getPrecision = () => {
|
||||
return props.propertyType === 'int' ? 0 : 2
|
||||
}
|
||||
|
||||
const getStep = () => {
|
||||
return props.propertyType === 'int' ? 1 : 0.1
|
||||
}
|
||||
|
||||
const getMin = () => {
|
||||
return props.propertyConfig?.min || undefined
|
||||
}
|
||||
|
||||
const getMax = () => {
|
||||
return props.propertyConfig?.max || undefined
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
const handleChange = () => {
|
||||
validateValue()
|
||||
}
|
||||
|
||||
const handleRangeChange = () => {
|
||||
if (rangeStart.value && rangeEnd.value) {
|
||||
localValue.value = `${rangeStart.value},${rangeEnd.value}`
|
||||
} else {
|
||||
localValue.value = ''
|
||||
}
|
||||
validateValue()
|
||||
}
|
||||
|
||||
const handleDateChange = (value: string) => {
|
||||
localValue.value = value || ''
|
||||
validateValue()
|
||||
}
|
||||
|
||||
const handleNumberChange = (value: number | undefined) => {
|
||||
localValue.value = value?.toString() || ''
|
||||
validateValue()
|
||||
}
|
||||
|
||||
// 验证函数
|
||||
const validateValue = () => {
|
||||
if (!localValue.value) {
|
||||
isValid.value = false
|
||||
validationMessage.value = '请输入值'
|
||||
emit('validate', { valid: false, message: validationMessage.value })
|
||||
return
|
||||
}
|
||||
|
||||
// 数字类型验证
|
||||
if (isNumericType()) {
|
||||
const num = parseFloat(localValue.value)
|
||||
if (isNaN(num)) {
|
||||
isValid.value = false
|
||||
validationMessage.value = '请输入有效的数字'
|
||||
emit('validate', { valid: false, message: validationMessage.value })
|
||||
return
|
||||
}
|
||||
|
||||
// 范围验证
|
||||
const min = getMin()
|
||||
const max = getMax()
|
||||
if (min !== undefined && num < min) {
|
||||
isValid.value = false
|
||||
validationMessage.value = `值不能小于 ${min}`
|
||||
emit('validate', { valid: false, message: validationMessage.value })
|
||||
return
|
||||
}
|
||||
if (max !== undefined && num > max) {
|
||||
isValid.value = false
|
||||
validationMessage.value = `值不能大于 ${max}`
|
||||
emit('validate', { valid: false, message: validationMessage.value })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 范围输入验证
|
||||
if (props.operator === 'between') {
|
||||
const parts = localValue.value.split(',')
|
||||
if (parts.length !== 2) {
|
||||
isValid.value = false
|
||||
validationMessage.value = '范围格式错误'
|
||||
emit('validate', { valid: false, message: validationMessage.value })
|
||||
return
|
||||
}
|
||||
|
||||
const start = parseFloat(parts[0])
|
||||
const end = parseFloat(parts[1])
|
||||
if (isNaN(start) || isNaN(end)) {
|
||||
isValid.value = false
|
||||
validationMessage.value = '范围值必须是数字'
|
||||
emit('validate', { valid: false, message: validationMessage.value })
|
||||
return
|
||||
}
|
||||
|
||||
if (start >= end) {
|
||||
isValid.value = false
|
||||
validationMessage.value = '起始值必须小于结束值'
|
||||
emit('validate', { valid: false, message: validationMessage.value })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 列表输入验证
|
||||
if (props.operator === 'in') {
|
||||
if (listPreview.value.length === 0) {
|
||||
isValid.value = false
|
||||
validationMessage.value = '请输入至少一个值'
|
||||
emit('validate', { valid: false, message: validationMessage.value })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 验证通过
|
||||
isValid.value = true
|
||||
validationMessage.value = '输入值验证通过'
|
||||
emit('validate', { valid: true, message: validationMessage.value })
|
||||
}
|
||||
|
||||
// 监听值变化
|
||||
watch(
|
||||
() => localValue.value,
|
||||
() => {
|
||||
validateValue()
|
||||
}
|
||||
)
|
||||
|
||||
// 监听操作符变化
|
||||
watch(
|
||||
() => props.operator,
|
||||
() => {
|
||||
localValue.value = ''
|
||||
rangeStart.value = ''
|
||||
rangeEnd.value = ''
|
||||
dateValue.value = ''
|
||||
numberValue.value = undefined
|
||||
}
|
||||
)
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
if (localValue.value) {
|
||||
validateValue()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.value-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.range-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.range-start,
|
||||
.range-end {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.range-separator {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-tip {
|
||||
color: var(--el-text-color-placeholder);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.input-unit {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.list-preview {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.preview-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user