perf:【IoT 物联网】场景联动触发器优化

This commit is contained in:
puhui999
2025-07-28 16:45:43 +08:00
parent d3d6f8f8ab
commit 274ecb5dca
14 changed files with 96 additions and 2740 deletions

View File

@@ -1,242 +0,0 @@
<!-- 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>

View File

@@ -1,141 +0,0 @@
<!-- 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>

View File

@@ -1,178 +0,0 @@
<!-- 场景描述输入组件 -->
<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>

View File

@@ -1,111 +0,0 @@
<!-- 场景名称输入组件 -->
<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>

View File

@@ -1,158 +0,0 @@
<!-- 场景状态选择组件 -->
<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>