[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:
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 中的 type,buildMockMetadata 应过滤掉
|
||||
const metadata = buildMockMetadata(catalog);
|
||||
expect(metadata).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
*
|
||||
* 切换到后端真实 metadata:Pinia store loadMetadata() 完成后 getSchemaByType 优先读
|
||||
* 真实数据,本 mock 仅为后端未就绪时的兜底。
|
||||
*
|
||||
* F4/F5/F6 扩展:补全 6 种 schema(device_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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* AviatorExprEditor — Aviator 表达式编辑器(MVP 版)
|
||||
*
|
||||
* MVP 策略:
|
||||
* - 使用 antd Textarea(不引入 Monaco / CodeMirror 等重型依赖)
|
||||
* - 下方 VariableHint chip 支持点击插入变量
|
||||
* - TODO(post-MVP): 引入 Monaco Editor(vite 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 -->
|
||||
${{{ group.ns }}.{{ v.key }}}
|
||||
</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>
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user