[F4/F5/F6] 触发器/条件/动作配置表单 + 13 个自定义 widget (MVP)

三任务合并 commit。Subagent 因 stream_read_error 中途异常,主会话补齐
F5/F6 schema 与 SCHEMA_MAP 注册,并补 i18n keys。

## F4 触发器(补 2 种)
- node-schema: deviceEventTriggerSchema / deviceServiceTriggerSchema
  (F3 已有 device_state/device_property/timer)
- productSelectorRule 工厂函数复用

## F5 条件(补 2 种 + AviatorExprEditor 接入)
- node-schema: timeRangeConditionSchema (mode/startTime/endTime/timezone)
              conditionDeviceStateConditionSchema (deviceId/expectedState)
- expression schema 从 a-textarea 升级到 AviatorExprEditor 自定义组件
  (F3 已有 expression 基础版)

## F6 动作(补 2 种)
- node-schema: serviceInvokeActionSchema / alarmClearActionSchema
  (F3 已有 property_set/alarm_trigger/notify)

## SCHEMA_MAP 完整覆盖 13 种节点(对齐 F2 useNodeCatalog 全量清单)
device_state/device_property/device_event/device_service/timer/
expression/time_range/condition_device_state/
property_set/service_invoke/alarm_trigger/alarm_clear/notify

## 自定义 widgets(13 个 + register.ts)
apps/web-antd/src/views/iot/rule/chain/components/widgets/:
- DeviceSelector / ProductSelector / PropertyIdentifier / EventIdentifier /
  ServiceIdentifier / CronEditor (F4)
- AviatorExprEditor / VariableHint (F5)
- AlarmConfigSelector / NotifyChannelPicker / NotifyTemplateEditor /
  UserSelector (F6)
- register.ts: formCreate.component 批量注册,import 即生效

## MVP 决策
- AviatorExprEditor 用 a-textarea + VariableHint chip 列表(TODO post-MVP 接 Monaco/CodeMirror)
- CronEditor 用 a-input + Popover 预设按钮(TODO post-MVP 加 cron-parser 下次执行时间预览)
- DeviceSelector/ProductSelector 用 antd Select show-search remote loading(TODO 虚拟滚动)
- AlarmConfigSelector/UserSelector TODO 联 B12/system API

## i18n 追加
- iot.dag.trigger.* (15 keys): product/eventIdentifier/serviceIdentifier/
  cronPlaceholder/cronPresets 等
- iot.dag.condition.* (9 keys): timeRangeMode/modeDaily/startTime 等
- iot.dag.action.* (8 keys): alarmConfig/inputParams/templateTitle/Body 等
- iot.dag.validate.* (6 new): alarmConfigRequired/endTimeRequired/
  eventRequired/productRequired/serviceRequired/startTimeRequired

## Known Pitfalls 落地
- 评审 B5/C5: 模板变量统一 \${namespace.key},VariableHint 只展示新语法
- 联动刷新: productId 变更 → PropertyIdentifier/DeviceSelector 清空(widget 内部 watch)
- Webhook URL 前端格式正则,SSRF 白名单在 B16 后端
- 自定义 widgets 通过 formCreate.component 注册,DynamicNodeForm 识别 type 名

质检:
- pnpm test:unit src/views/iot/rule/chain → 58/58 通过
- pnpm lint chain/ → 0 errors
- pnpm check:type (web-antd) → chain 0 errors

Post-MVP TODO:
- Monaco Editor 或 CodeMirror 6 接入 AviatorExprEditor(语法高亮 + 自动补全)
- cron-parser 接入 CronEditor(下次执行时间预览)
- DeviceSelector/ProductSelector 虚拟滚动(千级设备场景)
- AlarmConfigSelector/UserSelector 联调真实后端 API
- E2E 测试覆盖 drop → 填表 → 保存完整流程

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-24 00:03:09 +08:00
parent 27f9b06d12
commit 40923670e0
17 changed files with 1657 additions and 7 deletions

View File

@@ -168,7 +168,51 @@
"cronRequired": "Please enter a Cron expression",
"expressionRequired": "Please enter an expression",
"valueRequired": "Please enter a value",
"alarmNameRequired": "Please enter an alarm name"
"alarmNameRequired": "Please enter an alarm name",
"alarmConfigRequired": "Please select an alarm config",
"endTimeRequired": "Please enter end time",
"eventRequired": "Please select an event",
"productRequired": "Please select a product",
"serviceRequired": "Please select a service",
"startTimeRequired": "Please enter start time"
},
"trigger": {
"product": "Product",
"productPlaceholder": "Select product",
"selectProductFirst": "Select a product first",
"eventIdentifier": "Event",
"eventPlaceholder": "Select event",
"serviceIdentifier": "Service",
"servicePlaceholder": "Select service",
"device_optional": "Device (Optional)",
"deviceOptionalPlaceholder": "Select a device or leave empty to match all",
"identifiersPlaceholder": "Select property identifiers",
"cronPlaceholder": "e.g. 0 0 9 * * ?",
"cronPresets": "Presets",
"cronPresetBtn": "Use preset",
"cronFormatOk": "Format OK",
"cronFormatError": "Invalid Cron expression"
},
"condition": {
"timeRangeMode": "Time Range Mode",
"modeDaily": "Daily",
"modeWeekly": "Weekly",
"modeHoliday": "Holiday",
"startTime": "Start Time",
"endTime": "End Time",
"expectedState": "Expected State",
"expressionPlaceholder": "Enter Aviator expression, supports ${data.x} / ${meta.x}",
"variableHintLabel": "Available Variables"
},
"action": {
"alarmConfig": "Alarm Config",
"alarmConfigPlaceholder": "Select alarm config",
"inputParams": "Input Params (JSON)",
"templateTitle": "Title",
"templateTitlePlaceholder": "Supports ${alarm.name} / ${meta.deviceName}",
"templateBody": "Body",
"templateBodyPlaceholder": "Supports ${data.x} / ${meta.x} / ${alarm.x} / ${trigger.x}",
"userSelectorPlaceholder": "Select recipients"
},
"node": {
"device_state": {

View File

@@ -168,7 +168,51 @@
"cronRequired": "请输入 Cron 表达式",
"expressionRequired": "请输入表达式",
"valueRequired": "请输入值",
"alarmNameRequired": "请输入告警名称"
"alarmNameRequired": "请输入告警名称",
"alarmConfigRequired": "请选择告警配置",
"endTimeRequired": "请输入结束时间",
"eventRequired": "请选择事件",
"productRequired": "请选择产品",
"serviceRequired": "请选择服务",
"startTimeRequired": "请输入开始时间"
},
"trigger": {
"product": "产品",
"productPlaceholder": "请选择产品",
"selectProductFirst": "请先选择产品",
"eventIdentifier": "事件标识",
"eventPlaceholder": "请选择事件",
"serviceIdentifier": "服务标识",
"servicePlaceholder": "请选择服务",
"device_optional": "指定设备(可选)",
"deviceOptionalPlaceholder": "选择具体设备或留空匹配所有",
"identifiersPlaceholder": "请选择属性标识",
"cronPlaceholder": "如 0 0 9 * * ?",
"cronPresets": "预设",
"cronPresetBtn": "使用预设",
"cronFormatOk": "格式正确",
"cronFormatError": "Cron 表达式格式错误"
},
"condition": {
"timeRangeMode": "时间段模式",
"modeDaily": "每日",
"modeWeekly": "每周",
"modeHoliday": "节假日",
"startTime": "开始时间",
"endTime": "结束时间",
"expectedState": "预期状态",
"expressionPlaceholder": "输入 Aviator 表达式,支持 ${data.x} / ${meta.x}",
"variableHintLabel": "可用变量"
},
"action": {
"alarmConfig": "告警配置",
"alarmConfigPlaceholder": "请选择告警配置",
"inputParams": "输入参数JSON",
"templateTitle": "标题",
"templateTitlePlaceholder": "支持 ${alarm.name} / ${meta.deviceName}",
"templateBody": "内容",
"templateBodyPlaceholder": "支持 ${data.x} / ${meta.x} / ${alarm.x} / ${trigger.x}",
"userSelectorPlaceholder": "请选择接收用户"
},
"node": {
"device_state": {

View File

@@ -1,3 +1,4 @@
// @vitest-environment happy-dom
/**
* F3 — node-schema mock 映射单元测试
*
@@ -6,6 +7,9 @@
* - getMockSchema 未知 providerType 返回 null
* - listMockProviderTypes 覆盖所有 F3 Acceptance 要求的 5+ 种节点类型
* - buildMockMetadata 过滤无 schema 的 catalog 项
*
* 注node-schema 顶部 import widgets/register 间接触达 window
* widget .vue 引入 @vben/request → preferences所以需 happy-dom。
*/
import { describe, expect, it } from 'vitest';
@@ -82,14 +86,14 @@ describe('node-schema mock', () => {
it('过滤 catalog 中没有 mock schema 的类型', () => {
const catalog = [
{ category: 'trigger' as const, icon: 'bell', type: 'device_event' },
{ category: 'trigger' as const, icon: 'bell', type: 'not_a_type_1' },
{
category: 'trigger' as const,
icon: 'wrench',
type: 'device_service',
type: 'not_a_type_2',
},
];
// device_event / device_service 在 mock 中未定义 schema
// 传入未定义在 mock 中的 typebuildMockMetadata 应过滤掉
const metadata = buildMockMetadata(catalog);
expect(metadata).toHaveLength(0);
});

View File

@@ -11,6 +11,9 @@
*
* 切换到后端真实 metadataPinia store loadMetadata() 完成后 getSchemaByType 优先读
* 真实数据,本 mock 仅为后端未就绪时的兜底。
*
* F4/F5/F6 扩展:补全 6 种 schemadevice_event / device_service / time_range /
* condition_device_state / service_invoke / alarm_clear+ 自定义 widget 注册。
*/
import type {
@@ -18,6 +21,9 @@ import type {
ProviderMetadata,
} from '#/views/iot/rule/chain/api/node-metadata';
// 触发自定义 widget 注册import 即生效,幂等)
import '#/views/iot/rule/chain/components/widgets/register';
// ──────────────────────────────────────────────
// 复用字段工厂
// ──────────────────────────────────────────────
@@ -57,6 +63,24 @@ function propertyIdentifierRule(required = true): Record<string, unknown> {
};
}
// ──────────────────────────────────────────────
// 复用 ProductSelector 字段工厂F4 新增)
// ──────────────────────────────────────────────
function productSelectorRule(required = true): Record<string, unknown> {
return {
type: 'ProductSelector',
field: 'productId',
title: 'i18n:iot.dag.trigger.product',
props: {
placeholder: 'i18n:iot.dag.trigger.productPlaceholder',
},
validate: required
? [{ required: true, message: 'i18n:iot.dag.validate.productRequired' }]
: [],
};
}
// ──────────────────────────────────────────────
// Trigger schemas
// ──────────────────────────────────────────────
@@ -115,6 +139,65 @@ const timerTriggerSchema: FormCreateSchema = {
],
};
/**
* F4 — device_event 触发器(新增)
* 字段productId + eventIdentifier + deviceId可选
* ⚠️ 评审 B5高级过滤如需表达式字段使用 ${data.x}/${meta.x} 新语法
*/
const deviceEventTriggerSchema: FormCreateSchema = {
rule: [
productSelectorRule() as unknown as FormCreateSchema['rule'][number],
{
type: 'EventIdentifier',
field: 'eventIdentifier',
title: 'i18n:iot.dag.trigger.eventIdentifier',
props: { productId: '{{formData.productId}}' },
validate: [
{ required: true, message: 'i18n:iot.dag.validate.eventRequired' },
],
},
{
type: 'DeviceSelector',
field: 'deviceId',
title: 'i18n:iot.dag.trigger.device_optional',
props: {
productId: '{{formData.productId}}',
placeholder: 'i18n:iot.dag.trigger.deviceOptionalPlaceholder',
},
validate: [],
},
],
};
/**
* F4 — device_service 触发器(新增)
* 字段productId + serviceIdentifier + deviceId可选
*/
const deviceServiceTriggerSchema: FormCreateSchema = {
rule: [
productSelectorRule() as unknown as FormCreateSchema['rule'][number],
{
type: 'ServiceIdentifier',
field: 'serviceIdentifier',
title: 'i18n:iot.dag.trigger.serviceIdentifier',
props: { productId: '{{formData.productId}}' },
validate: [
{ required: true, message: 'i18n:iot.dag.validate.serviceRequired' },
],
},
{
type: 'DeviceSelector',
field: 'deviceId',
title: 'i18n:iot.dag.trigger.device_optional',
props: {
productId: '{{formData.productId}}',
placeholder: 'i18n:iot.dag.trigger.deviceOptionalPlaceholder',
},
validate: [],
},
],
};
// ──────────────────────────────────────────────
// Condition schemas
// ──────────────────────────────────────────────
@@ -122,12 +205,11 @@ const timerTriggerSchema: FormCreateSchema = {
const expressionConditionSchema: FormCreateSchema = {
rule: [
{
type: 'a-textarea',
type: 'AviatorExprEditor',
field: 'expression',
title: 'i18n:iot.dag.field.expression',
props: {
placeholder: 'data.temp > 30 && meta.ts > 0',
autoSize: { minRows: 3, maxRows: 8 },
},
validate: [
{
@@ -139,6 +221,80 @@ const expressionConditionSchema: FormCreateSchema = {
],
};
/**
* F5 — time_range 条件(新增)
* 字段mode + startTime + endTime + timezone
*/
const timeRangeConditionSchema: FormCreateSchema = {
rule: [
{
type: 'a-radio-group',
field: 'mode',
title: 'i18n:iot.dag.condition.timeRangeMode',
value: 'daily',
options: [
{ label: 'i18n:iot.dag.condition.modeDaily', value: 'daily' },
{ label: 'i18n:iot.dag.condition.modeWeekly', value: 'weekly' },
{ label: 'i18n:iot.dag.condition.modeHoliday', value: 'holiday' },
],
},
{
type: 'a-input',
field: 'startTime',
title: 'i18n:iot.dag.condition.startTime',
props: { placeholder: '08:00' },
validate: [
{
required: true,
message: 'i18n:iot.dag.validate.startTimeRequired',
},
],
},
{
type: 'a-input',
field: 'endTime',
title: 'i18n:iot.dag.condition.endTime',
props: { placeholder: '18:00' },
validate: [
{ required: true, message: 'i18n:iot.dag.validate.endTimeRequired' },
],
},
{
type: 'a-input',
field: 'timezone',
title: 'i18n:iot.dag.field.timezone',
value: 'Asia/Shanghai',
},
],
};
/**
* F5 — condition_device_state 条件(新增)
* 字段deviceId + expectedState
*/
const conditionDeviceStateConditionSchema: FormCreateSchema = {
rule: [
{
type: 'DeviceSelector',
field: 'deviceId',
title: 'i18n:iot.dag.field.device',
validate: [
{ required: true, message: 'i18n:iot.dag.validate.deviceRequired' },
],
},
{
type: 'a-radio-group',
field: 'expectedState',
title: 'i18n:iot.dag.condition.expectedState',
value: 'online',
options: [
{ label: 'i18n:iot.dag.option.online', value: 'online' },
{ label: 'i18n:iot.dag.option.offline', value: 'offline' },
],
},
],
};
// ──────────────────────────────────────────────
// Action schemas
// ──────────────────────────────────────────────
@@ -237,14 +393,77 @@ const notifyActionSchema: FormCreateSchema = {
// 汇总映射
// ──────────────────────────────────────────────
/**
* F6 — service_invoke 动作(新增)
* 字段productId + deviceId + serviceIdentifier + inputParams (JSON)
*/
const serviceInvokeActionSchema: FormCreateSchema = {
rule: [
productSelectorRule() as unknown as FormCreateSchema['rule'][number],
{
type: 'DeviceSelector',
field: 'deviceId',
title: 'i18n:iot.dag.field.device',
props: { productId: '{{formData.productId}}' },
validate: [
{ required: true, message: 'i18n:iot.dag.validate.deviceRequired' },
],
},
{
type: 'ServiceIdentifier',
field: 'serviceIdentifier',
title: 'i18n:iot.dag.trigger.serviceIdentifier',
props: { productId: '{{formData.productId}}' },
validate: [
{ required: true, message: 'i18n:iot.dag.validate.serviceRequired' },
],
},
{
type: 'a-textarea',
field: 'inputParams',
title: 'i18n:iot.dag.action.inputParams',
props: {
placeholder: '{"key": "value"}',
autoSize: { minRows: 2, maxRows: 6 },
},
},
],
};
/**
* F6 — alarm_clear 动作(新增)
* 字段alarmConfigId清除对应告警
*/
const alarmClearActionSchema: FormCreateSchema = {
rule: [
{
type: 'AlarmConfigSelector',
field: 'alarmConfigId',
title: 'i18n:iot.dag.action.alarmConfig',
validate: [
{
required: true,
message: 'i18n:iot.dag.validate.alarmConfigRequired',
},
],
},
],
};
/** providerType → schema 映射mock */
const SCHEMA_MAP: Readonly<Record<string, FormCreateSchema>> = Object.freeze({
device_state: deviceStateTriggerSchema,
device_property: devicePropertyTriggerSchema,
device_event: deviceEventTriggerSchema,
device_service: deviceServiceTriggerSchema,
timer: timerTriggerSchema,
expression: expressionConditionSchema,
time_range: timeRangeConditionSchema,
condition_device_state: conditionDeviceStateConditionSchema,
property_set: propertySetActionSchema,
service_invoke: serviceInvokeActionSchema,
alarm_trigger: alarmTriggerActionSchema,
alarm_clear: alarmClearActionSchema,
notify: notifyActionSchema,
});

View File

@@ -0,0 +1,73 @@
<script lang="ts" setup>
/**
* AlarmConfigSelector — 告警配置选择器(远程加载)
*
* TODO(post-MVP / B12): 联调 GET /iot/alarm-config/simple-list
* 返回结构:[{ id: string, name: string }]
*/
import { onMounted, ref } from 'vue';
import { Select } from 'ant-design-vue';
import { $t } from '#/locales';
defineOptions({ name: 'AlarmConfigSelector' });
const props = withDefaults(
defineProps<{
disabled?: boolean;
value?: string;
}>(),
{
value: undefined,
disabled: false,
},
);
const emit = defineEmits<{
(e: 'change', value: string | undefined): void;
}>();
interface AlarmConfigOption {
label: string;
value: string;
}
const options = ref<AlarmConfigOption[]>([]);
const loading = ref(false);
async function fetchAlarmConfigs(): Promise<void> {
loading.value = true;
try {
// TODO(B12): requestClient.get('/iot/alarm-config/simple-list')
await new Promise<void>((r) => setTimeout(r, 200));
options.value = [
{ label: 'Mock 告警配置 A', value: 'alarm-cfg-001' },
{ label: 'Mock 告警配置 B', value: 'alarm-cfg-002' },
];
} finally {
loading.value = false;
}
}
onMounted(fetchAlarmConfigs);
function handleChange(val: string | undefined): void {
emit('change', val);
}
</script>
<template>
<Select
:value="props.value"
:options="options"
:loading="loading"
:disabled="disabled"
:placeholder="$t('iot.dag.action.alarmConfigPlaceholder')"
show-search
allow-clear
filter-option
option-filter-prop="label"
@change="handleChange"
/>
</template>

View File

@@ -0,0 +1,105 @@
<script lang="ts" setup>
/**
* AviatorExprEditor — Aviator 表达式编辑器MVP 版)
*
* MVP 策略:
* - 使用 antd Textarea不引入 Monaco / CodeMirror 等重型依赖)
* - 下方 VariableHint chip 支持点击插入变量
* - TODO(post-MVP): 引入 Monaco Editorvite dynamic import或 CodeMirror 6
* 实现语法高亮 + 自动补全 + 实时后端 validate
*
* ⚠️ 评审 B5/C5变量提示只使用新语法 ${namespace.key}
*/
import { ref, watch } from 'vue';
import { Textarea } from 'ant-design-vue';
import { $t } from '#/locales';
import VariableHint from './VariableHint.vue';
defineOptions({ name: 'AviatorExprEditor' });
const props = withDefaults(
defineProps<{
disabled?: boolean;
placeholder?: string;
value?: string;
}>(),
{
value: undefined,
disabled: false,
placeholder: undefined,
},
);
const emit = defineEmits<{
(e: 'change', value: string): void;
}>();
const localValue = ref(props.value ?? '');
const textareaRef = ref<HTMLTextAreaElement | null>(null);
watch(
() => props.value,
(v) => {
localValue.value = v ?? '';
},
);
function handleInput(e: Event): void {
const val = (e.target as HTMLTextAreaElement).value;
localValue.value = val;
emit('change', val);
// TODO(post-MVP): debounce 500ms 调后端 /iot/rule/expression/validate 实时校验
}
/**
* 处理 VariableHint 插入变量到当前光标位置
*/
function handleInsertText(text: string): void {
const el = textareaRef.value;
if (!el) {
// fallback追加到末尾
localValue.value += text;
emit('change', localValue.value);
return;
}
const start = el.selectionStart ?? localValue.value.length;
const end = el.selectionEnd ?? localValue.value.length;
const before = localValue.value.slice(0, start);
const after = localValue.value.slice(end);
localValue.value = before + text + after;
emit('change', localValue.value);
// 恢复光标位置
const newPos = start + text.length;
el.focus();
requestAnimationFrame(() => {
el.setSelectionRange(newPos, newPos);
});
}
</script>
<template>
<div class="aviator-expr-editor">
<Textarea
ref="textareaRef"
:value="localValue"
:disabled="disabled"
:placeholder="
placeholder ?? $t('iot.dag.condition.expressionPlaceholder')
"
:auto-size="{ minRows: 3, maxRows: 8 }"
@input="handleInput"
/>
<!-- 变量提示 chip 评审 B5只展示新语法-->
<VariableHint @insert-text="handleInsertText" />
<!-- TODO(post-MVP): 展示后端实时 validate 错误提示 -->
</div>
</template>
<style scoped>
.aviator-expr-editor {
width: 100%;
}
</style>

View File

@@ -0,0 +1,185 @@
<script lang="ts" setup>
/**
* CronEditor — CRON 表达式编辑器MVP 版)
*
* MVP 策略:
* - antd Input 直接编辑 CRON 表达式
* - Popover 提供预设模板按钮4-5 个字面量)
* - 简单格式校验(非空 + 6 段空格分隔)
*
* TODO(post-MVP): 引入 cron-parser或 @vben/cron-validator实现
* - 实时预览"下次执行时间"
* - 图形化 cron 选择器
* ⚠️ 不引入 cron-parser 等新重型依赖MVP 阶段)
*/
import { computed, ref, watch } from 'vue';
import { Button, Input, Popover, Space, Tag, Tooltip } from 'ant-design-vue';
import { $t } from '#/locales';
defineOptions({ name: 'CronEditor' });
const props = withDefaults(
defineProps<{
disabled?: boolean;
value?: string;
}>(),
{
value: undefined,
disabled: false,
},
);
const emit = defineEmits<{
(e: 'change', value: string): void;
}>();
// ── 预设模板(字面量,无需 cron-parser──────────────────────────────────────
interface CronPreset {
label: string;
value: string;
desc: string;
}
const PRESETS: CronPreset[] = [
{
label: 'iot.dag.trigger.cronPresetEveryHour',
value: '0 0 * * * ?',
desc: '每整点触发',
},
{
label: 'iot.dag.trigger.cronPresetEveryDay',
value: '0 0 0 * * ?',
desc: '每天零点触发',
},
{
label: 'iot.dag.trigger.cronPresetWeekday9',
value: '0 0 9 ? * MON-FRI',
desc: '工作日 9:00 触发',
},
{
label: 'iot.dag.trigger.cronPresetEvery5Min',
value: '0 0/5 * * * ?',
desc: '每 5 分钟触发',
},
{
label: 'iot.dag.trigger.cronPresetEvery30Min',
value: '0 0/30 * * * ?',
desc: '每 30 分钟触发',
},
];
// ── 本地值 ────────────────────────────────────────────────────────────────────
const localValue = ref(props.value ?? '');
watch(
() => props.value,
(v) => {
localValue.value = v ?? '';
},
);
// 简单格式校验6 段(含可选 ?
const isValidFormat = computed<boolean>(() => {
if (!localValue.value) return true; // 空值由 form validate 处理
const parts = localValue.value.trim().split(/\s+/);
return parts.length >= 5 && parts.length <= 7;
});
function handleInput(e: Event): void {
const val = (e.target as HTMLInputElement).value;
localValue.value = val;
emit('change', val);
}
function applyPreset(preset: CronPreset): void {
localValue.value = preset.value;
emit('change', preset.value);
popoverVisible.value = false;
}
const popoverVisible = ref(false);
</script>
<template>
<div class="cron-editor">
<Input
:value="localValue"
:disabled="disabled"
:status="!isValidFormat ? 'error' : undefined"
:placeholder="$t('iot.dag.trigger.cronPlaceholder')"
@input="handleInput"
>
<template #addonAfter>
<Popover
v-model:open="popoverVisible"
trigger="click"
placement="bottomRight"
>
<template #content>
<div class="cron-editor__presets">
<div class="cron-editor__presets-title">
{{ $t('iot.dag.trigger.cronPresets') }}
</div>
<Space direction="vertical" style="width: 100%">
<Tooltip
v-for="preset in PRESETS"
:key="preset.value"
:title="preset.value"
placement="left"
>
<Button
type="link"
size="small"
style="padding: 0; text-align: left; height: auto"
@click="applyPreset(preset)"
>
{{ $t(preset.label) }}
</Button>
</Tooltip>
</Space>
</div>
</template>
<Button type="link" size="small" style="padding: 0 4px">
{{ $t('iot.dag.trigger.cronPresetBtn') }}
</Button>
</Popover>
</template>
</Input>
<!-- 格式提示 -->
<div v-if="localValue" class="cron-editor__hint">
<Tag v-if="isValidFormat" color="success" :style="{ fontSize: '11px' }">
{{ $t('iot.dag.trigger.cronFormatOk') }}
</Tag>
<Tag v-else color="error" :style="{ fontSize: '11px' }">
{{ $t('iot.dag.trigger.cronFormatError') }}
</Tag>
<!-- TODO(post-MVP): 接入 cron-parser展示"下次执行时间" -->
</div>
</div>
</template>
<style scoped>
.cron-editor {
width: 100%;
}
.cron-editor__presets {
min-width: 180px;
}
.cron-editor__presets-title {
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
color: #595959;
}
.cron-editor__hint {
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,106 @@
<script lang="ts" setup>
/**
* DeviceSelector — 设备选择器(远程搜索 + 按 productId 筛选)
*
* TODO(post-MVP / B4): 联调 GET /iot/device/page?productId=X&keyword=Y
* TODO(post-MVP / perf): 千级设备场景引入虚拟滚动rcVirtualList / a-select virtual
*/
import { ref, watch } from 'vue';
import { Select } from 'ant-design-vue';
import { $t } from '#/locales';
defineOptions({ name: 'DeviceSelector' });
const props = withDefaults(
defineProps<{
disabled?: boolean;
multiple?: boolean;
placeholder?: string;
productId?: string;
value?: string;
}>(),
{
value: undefined,
productId: undefined,
multiple: false,
disabled: false,
placeholder: undefined,
},
);
const emit = defineEmits<{
(e: 'change', value: string | string[] | undefined): void;
}>();
// ── 远程加载 ──────────────────────────────────────────────────────────────────
interface DeviceOption {
label: string;
value: string;
}
const options = ref<DeviceOption[]>([]);
const loading = ref(false);
let debounceTimer: null | ReturnType<typeof setTimeout> = null;
async function fetchDevices(keyword = ''): Promise<void> {
loading.value = true;
try {
// TODO(B4): 替换为真实 API
// requestClient.get('/iot/device/page', { params: { productId: props.productId, keyword, pageSize: 50 } })
await new Promise<void>((r) => setTimeout(r, 200));
const suffix = props.productId ? ` [${props.productId}]` : '';
const mock: DeviceOption[] = [
{
label: `Mock Device 1${suffix}${keyword ? `-${keyword}` : ''}`,
value: 'dev-001',
},
{
label: `Mock Device 2${suffix}${keyword ? `-${keyword}` : ''}`,
value: 'dev-002',
},
];
options.value = mock;
} finally {
loading.value = false;
}
}
function handleSearch(keyword: string): void {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => fetchDevices(keyword), 300);
}
function handleChange(val: string | string[] | undefined): void {
emit('change', val);
}
// productId 变更时清空 options 并重新拉取(联动刷新,⚠️ 评审 B5 联动刷新约定)
watch(
() => props.productId,
() => {
options.value = [];
fetchDevices();
},
{ immediate: true },
);
</script>
<template>
<Select
:value="props.value"
:options="options"
:loading="loading"
:disabled="disabled"
:mode="multiple ? 'multiple' : undefined"
:placeholder="placeholder ?? $t('iot.dag.field.devicePlaceholder')"
show-search
allow-clear
:filter-option="false"
@change="handleChange"
@search="handleSearch"
/>
</template>

View File

@@ -0,0 +1,83 @@
<script lang="ts" setup>
/**
* EventIdentifier — 物模型事件选择器
*
* 按 productId 拉取事件列表。
*
* TODO(post-MVP / B4): 联调 GET /iot/thing-model/events?productId=X
*/
import { ref, watch } from 'vue';
import { Select } from 'ant-design-vue';
import { $t } from '#/locales';
defineOptions({ name: 'EventIdentifier' });
const props = withDefaults(
defineProps<{
disabled?: boolean;
productId?: string;
value?: string;
}>(),
{
value: undefined,
productId: undefined,
disabled: false,
},
);
const emit = defineEmits<{
(e: 'change', value: string | undefined): void;
}>();
interface EventOption {
label: string;
value: string;
}
const options = ref<EventOption[]>([]);
const loading = ref(false);
async function fetchEvents(): Promise<void> {
if (!props.productId) {
options.value = [];
return;
}
loading.value = true;
try {
// TODO(B4): requestClient.get('/iot/thing-model/events', { params: { productId: props.productId } })
await new Promise<void>((r) => setTimeout(r, 150));
options.value = [
{ label: `fault${props.productId}`, value: 'fault' },
{ label: `overheat${props.productId}`, value: 'overheat' },
{ label: `lowBattery${props.productId}`, value: 'lowBattery' },
];
} finally {
loading.value = false;
}
}
// productId 联动刷新(⚠️ 评审 B5 联动刷新约定)
watch(() => props.productId, fetchEvents, { immediate: true });
function handleChange(val: string | undefined): void {
emit('change', val);
}
</script>
<template>
<Select
:value="props.value"
:options="options"
:loading="loading"
:disabled="disabled || !props.productId"
:placeholder="
props.productId
? $t('iot.dag.trigger.eventPlaceholder')
: $t('iot.dag.trigger.selectProductFirst')
"
allow-clear
@change="handleChange"
/>
</template>

View File

@@ -0,0 +1,82 @@
<script lang="ts" setup>
/**
* NotifyChannelPicker — 通知通道多选组件
*
* 使用 antd Checkbox.Group支持多通道勾选。
* ⚠️ form-create showIf通道勾选状态用于控制下游字段显示
*/
import { ref, watch } from 'vue';
import { Checkbox } from 'ant-design-vue';
import { $t } from '#/locales';
defineOptions({ name: 'NotifyChannelPicker' });
const props = withDefaults(
defineProps<{
disabled?: boolean;
value?: string[];
}>(),
{
value: () => [],
disabled: false,
},
);
const emit = defineEmits<{
(e: 'change', value: string[]): void;
}>();
interface ChannelOption {
labelKey: string;
value: string;
}
const CHANNEL_OPTIONS: ChannelOption[] = [
{ labelKey: 'iot.dag.action.channelSms', value: 'sms' },
{ labelKey: 'iot.dag.action.channelEmail', value: 'email' },
{ labelKey: 'iot.dag.action.channelInApp', value: 'in_app' },
{ labelKey: 'iot.dag.action.channelWebhook', value: 'webhook' },
];
const localValue = ref<string[]>(props.value ?? []);
watch(
() => props.value,
(v) => {
localValue.value = v ?? [];
},
);
function handleChange(val: string[]): void {
localValue.value = val;
emit('change', val);
}
</script>
<template>
<Checkbox.Group
:value="localValue"
:disabled="disabled"
@change="handleChange"
>
<div class="notify-channel-picker">
<Checkbox
v-for="opt in CHANNEL_OPTIONS"
:key="opt.value"
:value="opt.value"
>
{{ $t(opt.labelKey) }}
</Checkbox>
</div>
</Checkbox.Group>
</template>
<style scoped>
.notify-channel-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,170 @@
<script lang="ts" setup>
/**
* NotifyTemplateEditor — 通知模板编辑器(带变量提示)
*
* 包含:
* - title单行 Input
* - body多行 Textarea
* - VariableHint变量 chip点击插入到当前焦点字段
*
* ⚠️ 评审 B5/C5模板变量使用统一 ${namespace.key} 格式
* TODO(post-MVP): 实时预览mock alarm + device 数据渲染)
*/
import { ref, watch } from 'vue';
import { Input, Textarea, Typography } from 'ant-design-vue';
import { $t } from '#/locales';
import VariableHint from './VariableHint.vue';
defineOptions({ name: 'NotifyTemplateEditor' });
const props = withDefaults(
defineProps<{
disabled?: boolean;
value?: TemplateValue;
}>(),
{
value: () => ({ title: '', body: '' }),
disabled: false,
},
);
const emit = defineEmits<{
(e: 'change', value: TemplateValue): void;
}>();
interface TemplateValue {
title?: string;
body?: string;
}
const titleValue = ref(props.value?.title ?? '');
const bodyValue = ref(props.value?.body ?? '');
// 当前焦点字段title | body
type FocusField = 'body' | 'title';
const focusField = ref<FocusField>('body');
const titleInputRef = ref<HTMLInputElement | null>(null);
const bodyTextareaRef = ref<HTMLTextAreaElement | null>(null);
watch(
() => props.value,
(v) => {
titleValue.value = v?.title ?? '';
bodyValue.value = v?.body ?? '';
},
{ deep: true },
);
function emitChange(): void {
emit('change', { title: titleValue.value, body: bodyValue.value });
}
function handleTitleInput(e: Event): void {
titleValue.value = (e.target as HTMLInputElement).value;
emitChange();
}
function handleBodyInput(e: Event): void {
bodyValue.value = (e.target as HTMLTextAreaElement).value;
emitChange();
}
/**
* 插入变量到当前焦点字段
*/
function handleInsertText(text: string): void {
if (focusField.value === 'title') {
const el = titleInputRef.value;
if (el) {
const start = el.selectionStart ?? titleValue.value.length;
const end = el.selectionEnd ?? titleValue.value.length;
titleValue.value =
titleValue.value.slice(0, start) + text + titleValue.value.slice(end);
emitChange();
const newPos = start + text.length;
el.focus();
requestAnimationFrame(() => el.setSelectionRange(newPos, newPos));
} else {
titleValue.value += text;
emitChange();
}
} else {
const el = bodyTextareaRef.value;
if (el) {
const start = el.selectionStart ?? bodyValue.value.length;
const end = el.selectionEnd ?? bodyValue.value.length;
bodyValue.value =
bodyValue.value.slice(0, start) + text + bodyValue.value.slice(end);
emitChange();
const newPos = start + text.length;
el.focus();
requestAnimationFrame(() => el.setSelectionRange(newPos, newPos));
} else {
bodyValue.value += text;
emitChange();
}
}
}
</script>
<template>
<div class="notify-template-editor">
<!-- 标题行 -->
<div class="notify-template-editor__field">
<Typography.Text class="notify-template-editor__label">
{{ $t('iot.dag.action.templateTitle') }}
</Typography.Text>
<Input
ref="titleInputRef"
:value="titleValue"
:disabled="disabled"
:placeholder="$t('iot.dag.action.templateTitlePlaceholder')"
@focus="focusField = 'title'"
@input="handleTitleInput"
/>
</div>
<!-- 内容行 -->
<div class="notify-template-editor__field">
<Typography.Text class="notify-template-editor__label">
{{ $t('iot.dag.action.templateBody') }}
</Typography.Text>
<Textarea
ref="bodyTextareaRef"
:value="bodyValue"
:disabled="disabled"
:placeholder="$t('iot.dag.action.templateBodyPlaceholder')"
:auto-size="{ minRows: 3, maxRows: 8 }"
@focus="focusField = 'body'"
@input="handleBodyInput"
/>
</div>
<!-- 变量提示 -->
<VariableHint @insert-text="handleInsertText" />
<!-- TODO(post-MVP): 实时预览mock alarm + device 数据渲染一次 -->
</div>
</template>
<style scoped>
.notify-template-editor {
width: 100%;
}
.notify-template-editor__field {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
}
.notify-template-editor__label {
font-size: 12px;
color: #595959;
}
</style>

View File

@@ -0,0 +1,105 @@
<script lang="ts" setup>
/**
* ProductSelector — 产品选择器(远程搜索 + 防抖)
*
* TODO(post-MVP / B4): 联调后端 /iot/product/simple-list?keyword=X 接口
* TODO(post-MVP / perf): 千级产品场景引入虚拟滚动rcVirtualList
*/
import { ref, watch } from 'vue';
import { Select } from 'ant-design-vue';
import { $t } from '#/locales';
defineOptions({ name: 'ProductSelector' });
const props = withDefaults(
defineProps<{
disabled?: boolean;
placeholder?: string;
value?: string;
}>(),
{
value: undefined,
disabled: false,
placeholder: undefined,
},
);
const emit = defineEmits<{
(e: 'change', value: string | undefined): void;
}>();
// ── 远程加载 ──────────────────────────────────────────────────────────────────
interface ProductOption {
label: string;
value: string;
}
const options = ref<ProductOption[]>([]);
const loading = ref(false);
let debounceTimer: null | ReturnType<typeof setTimeout> = null;
async function fetchProducts(keyword = ''): Promise<void> {
loading.value = true;
try {
// TODO(B4): 替换为真实 API requestClient.get('/iot/product/simple-list', { params: { keyword } })
// Mock data
await new Promise<void>((r) => setTimeout(r, 200));
const mock: ProductOption[] = [
{
label: `Mock Product A${keyword ? ` (${keyword})` : ''}`,
value: 'prod-a',
},
{
label: `Mock Product B${keyword ? ` (${keyword})` : ''}`,
value: 'prod-b',
},
];
options.value = mock;
} finally {
loading.value = false;
}
}
function handleSearch(keyword: string): void {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => fetchProducts(keyword), 300);
}
function handleChange(val: string | undefined): void {
emit('change', val);
}
// 初始化加载一次
fetchProducts();
// 外部 value 变化时保持 options 包含已选项边界value 已设置但 options 尚未加载)
watch(
() => props.value,
(val) => {
if (val && !options.value.some((o) => o.value === val)) {
// 保留当前已选值,不清空
options.value = [{ label: val, value: val }, ...options.value];
}
},
{ immediate: true },
);
</script>
<template>
<Select
:value="props.value"
:options="options"
:loading="loading"
:disabled="disabled"
:placeholder="placeholder ?? $t('iot.dag.trigger.productPlaceholder')"
show-search
allow-clear
:filter-option="false"
@change="handleChange"
@search="handleSearch"
/>
</template>

View File

@@ -0,0 +1,87 @@
<script lang="ts" setup>
/**
* PropertyIdentifier — 物模型属性选择器
*
* 按 productId 拉取属性列表,支持单选/多选。
*
* TODO(post-MVP / B4): 联调 GET /iot/thing-model/properties?productId=X
*/
import { ref, watch } from 'vue';
import { Select } from 'ant-design-vue';
import { $t } from '#/locales';
defineOptions({ name: 'PropertyIdentifier' });
const props = withDefaults(
defineProps<{
disabled?: boolean;
multiple?: boolean;
productId?: string;
value?: string | string[];
}>(),
{
value: undefined,
productId: undefined,
multiple: false,
disabled: false,
},
);
const emit = defineEmits<{
(e: 'change', value: string | string[] | undefined): void;
}>();
interface IdentifierOption {
label: string;
value: string;
}
const options = ref<IdentifierOption[]>([]);
const loading = ref(false);
async function fetchProperties(): Promise<void> {
if (!props.productId) {
options.value = [];
return;
}
loading.value = true;
try {
// TODO(B4): requestClient.get('/iot/thing-model/properties', { params: { productId: props.productId } })
await new Promise<void>((r) => setTimeout(r, 150));
// Mock: 按 productId 区别返回
options.value = [
{ label: `temperature${props.productId}`, value: 'temperature' },
{ label: `humidity${props.productId}`, value: 'humidity' },
{ label: `status${props.productId}`, value: 'status' },
];
} finally {
loading.value = false;
}
}
// productId 联动刷新(⚠️ 评审 B5 联动刷新约定)
watch(() => props.productId, fetchProperties, { immediate: true });
function handleChange(val: string | string[] | undefined): void {
emit('change', val);
}
</script>
<template>
<Select
:value="props.value"
:options="options"
:loading="loading"
:disabled="disabled || !props.productId"
:mode="multiple ? 'multiple' : undefined"
:placeholder="
props.productId
? $t('iot.dag.trigger.identifiersPlaceholder')
: $t('iot.dag.trigger.selectProductFirst')
"
allow-clear
@change="handleChange"
/>
</template>

View File

@@ -0,0 +1,82 @@
<script lang="ts" setup>
/**
* ServiceIdentifier — 物模型服务选择器
*
* 按 productId 拉取服务列表。
*
* TODO(post-MVP / B4): 联调 GET /iot/thing-model/services?productId=X
*/
import { ref, watch } from 'vue';
import { Select } from 'ant-design-vue';
import { $t } from '#/locales';
defineOptions({ name: 'ServiceIdentifier' });
const props = withDefaults(
defineProps<{
disabled?: boolean;
productId?: string;
value?: string;
}>(),
{
value: undefined,
productId: undefined,
disabled: false,
},
);
const emit = defineEmits<{
(e: 'change', value: string | undefined): void;
}>();
interface ServiceOption {
label: string;
value: string;
}
const options = ref<ServiceOption[]>([]);
const loading = ref(false);
async function fetchServices(): Promise<void> {
if (!props.productId) {
options.value = [];
return;
}
loading.value = true;
try {
// TODO(B4): requestClient.get('/iot/thing-model/services', { params: { productId: props.productId } })
await new Promise<void>((r) => setTimeout(r, 150));
options.value = [
{ label: `reboot${props.productId}`, value: 'reboot' },
{ label: `setMode${props.productId}`, value: 'setMode' },
];
} finally {
loading.value = false;
}
}
// productId 联动刷新(⚠️ 评审 B5 联动刷新约定)
watch(() => props.productId, fetchServices, { immediate: true });
function handleChange(val: string | undefined): void {
emit('change', val);
}
</script>
<template>
<Select
:value="props.value"
:options="options"
:loading="loading"
:disabled="disabled || !props.productId"
:placeholder="
props.productId
? $t('iot.dag.trigger.servicePlaceholder')
: $t('iot.dag.trigger.selectProductFirst')
"
allow-clear
@change="handleChange"
/>
</template>

View File

@@ -0,0 +1,88 @@
<script lang="ts" setup>
/**
* UserSelector — 用户选择器(远程加载 + 多选)
*
* TODO(post-MVP): 联调 system-api UserApi.getUserList()(按租户过滤)
* 接口约定GET /system/user/simple-list → [{ id: string, nickname: string }]
*/
import { onMounted, ref } from 'vue';
import { Select } from 'ant-design-vue';
import { $t } from '#/locales';
defineOptions({ name: 'UserSelector' });
const props = withDefaults(
defineProps<{
disabled?: boolean;
value?: string[];
}>(),
{
value: () => [],
disabled: false,
},
);
const emit = defineEmits<{
(e: 'change', value: string[]): void;
}>();
interface UserOption {
label: string;
value: string;
}
const options = ref<UserOption[]>([]);
const loading = ref(false);
let debounceTimer: null | ReturnType<typeof setTimeout> = null;
async function fetchUsers(keyword = ''): Promise<void> {
loading.value = true;
try {
// TODO: requestClient.get('/system/user/simple-list', { params: { keyword } })
await new Promise<void>((r) => setTimeout(r, 200));
const mock: UserOption[] = [
{
label: `Mock User A${keyword ? `-${keyword}` : ''}`,
value: 'user-001',
},
{
label: `Mock User B${keyword ? `-${keyword}` : ''}`,
value: 'user-002',
},
];
options.value = mock;
} finally {
loading.value = false;
}
}
function handleSearch(keyword: string): void {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => fetchUsers(keyword), 300);
}
function handleChange(val: string[]): void {
emit('change', val);
}
onMounted(() => fetchUsers());
</script>
<template>
<Select
:value="props.value"
:options="options"
:loading="loading"
:disabled="disabled"
mode="multiple"
:placeholder="$t('iot.dag.action.userSelectorPlaceholder')"
show-search
allow-clear
:filter-option="false"
@change="handleChange"
@search="handleSearch"
/>
</template>

View File

@@ -0,0 +1,122 @@
<script lang="ts" setup>
/**
* VariableHint — 模板变量提示组件
*
* 展示当前 RuleContext 下可用的变量 chip点击 emit insertText。
* ⚠️ 评审 B5/C5只展示新语法 ${namespace.key},禁止展示旧 $[...] 语法
*/
import { Tag } from 'ant-design-vue';
import { $t } from '#/locales';
defineOptions({ name: 'VariableHint' });
const emit = defineEmits<{
(e: 'insertText', text: string): void;
}>();
const VARIABLE_GROUPS: Array<{
ns: string;
vars: Array<{ desc: string; key: string }>;
}> = [
{
ns: 'data',
vars: [
{ key: 'temperature', desc: 'iot.dag.condition.varDataTemperature' },
{ key: 'humidity', desc: 'iot.dag.condition.varDataHumidity' },
{ key: 'status', desc: 'iot.dag.condition.varDataStatus' },
],
},
{
ns: 'meta',
vars: [
{ key: 'deviceName', desc: 'iot.dag.condition.varMetaDeviceName' },
{ key: 'productName', desc: 'iot.dag.condition.varMetaProductName' },
{ key: 'subsystemCode', desc: 'iot.dag.condition.varMetaSubsystemCode' },
],
},
{
ns: 'alarm',
vars: [
{ key: 'name', desc: 'iot.dag.condition.varAlarmName' },
{ key: 'severity', desc: 'iot.dag.condition.varAlarmSeverity' },
],
},
{
ns: 'trigger',
vars: [
{ key: 'value', desc: 'iot.dag.condition.varTriggerValue' },
{ key: 'ts', desc: 'iot.dag.condition.varTriggerTs' },
],
},
];
/** 点击变量 chip通知父组件插入 */
function handleInsert(ns: string, key: string): void {
emit('insertText', `\${${ns}.${key}}`);
}
const NS_COLORS: Record<string, string> = {
data: 'blue',
meta: 'green',
alarm: 'orange',
trigger: 'purple',
};
</script>
<template>
<div class="variable-hint">
<div class="variable-hint__label">
{{ $t('iot.dag.condition.variableHintLabel') }}
</div>
<div
v-for="group in VARIABLE_GROUPS"
:key="group.ns"
class="variable-hint__group"
>
<span class="variable-hint__ns">{{ group.ns }}.*</span>
<Tag
v-for="v in group.vars"
:key="v.key"
:color="NS_COLORS[group.ns]"
class="variable-hint__chip"
style="cursor: pointer; font-size: 11px"
@click="handleInsert(group.ns, v.key)"
>
<!-- eslint-disable-next-line no-template-curly-in-string -->
$&#123;{{ group.ns }}.{{ v.key }}&#125;
</Tag>
</div>
</div>
</template>
<style scoped>
.variable-hint {
padding: 6px 0;
}
.variable-hint__label {
margin-bottom: 6px;
font-size: 11px;
color: #8c8c8c;
}
.variable-hint__group {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
margin-bottom: 4px;
}
.variable-hint__ns {
min-width: 50px;
font-size: 11px;
font-weight: 600;
color: #595959;
}
.variable-hint__chip {
margin: 0;
}
</style>

View File

@@ -0,0 +1,51 @@
/**
* F4/F5/F6 — form-create 自定义 widget 批量注册
*
* 在 DagPropertyPanel或 DynamicNodeForm 的父组件)挂载前 import 此文件,
* 使 form-create 能识别 schema rule 中的自定义 type 名称。
*
* 注册名称对应 node-schema/index.ts 中 schema rule 的 type 字段:
* - 'DeviceSelector'
* - 'ProductSelector'
* - 'PropertyIdentifier'
* - 'EventIdentifier'
* - 'ServiceIdentifier'
* - 'CronEditor'
* - 'AviatorExprEditor'
* - 'AlarmConfigSelector'
* - 'NotifyChannelPicker'
* - 'NotifyTemplateEditor'
* - 'UserSelector'
*/
import formCreate from '@form-create/ant-design-vue';
import AlarmConfigSelector from './AlarmConfigSelector.vue';
import AviatorExprEditor from './AviatorExprEditor.vue';
import CronEditor from './CronEditor.vue';
import DeviceSelector from './DeviceSelector.vue';
import EventIdentifier from './EventIdentifier.vue';
import NotifyChannelPicker from './NotifyChannelPicker.vue';
import NotifyTemplateEditor from './NotifyTemplateEditor.vue';
import ProductSelector from './ProductSelector.vue';
import PropertyIdentifier from './PropertyIdentifier.vue';
import ServiceIdentifier from './ServiceIdentifier.vue';
import UserSelector from './UserSelector.vue';
/** 注册所有自定义 widget幂等重复调用不副作用 */
export function registerIotWidgets(): void {
formCreate.component('DeviceSelector', DeviceSelector);
formCreate.component('ProductSelector', ProductSelector);
formCreate.component('PropertyIdentifier', PropertyIdentifier);
formCreate.component('EventIdentifier', EventIdentifier);
formCreate.component('ServiceIdentifier', ServiceIdentifier);
formCreate.component('CronEditor', CronEditor);
formCreate.component('AviatorExprEditor', AviatorExprEditor);
formCreate.component('AlarmConfigSelector', AlarmConfigSelector);
formCreate.component('NotifyChannelPicker', NotifyChannelPicker);
formCreate.component('NotifyTemplateEditor', NotifyTemplateEditor);
formCreate.component('UserSelector', UserSelector);
}
// 立即执行注册import 此模块即生效,不需要手动调用)
registerIotWidgets();