[F2] DAG 节点面板(左侧拖拽区)

- apps/web-antd/src/components/iot-dag/DagNodePanel.vue
  - 3 类分组 collapse + 搜索过滤 + 加载/错误/空态
- apps/web-antd/src/components/iot-dag/NodeTypeCard.vue
  - HTML5 dragstart 双 MIME:application/x-iot-dag-node + text/plain
  - payload: { type: providerType, category: dagNodeType }
- apps/web-antd/src/components/iot-dag/hooks/useNodeCatalog.ts
  - Mock 13 节点类型(5 trigger / 3 condition / 5 action)
  - fetcher 可注入、permissionFilter 预留
  - TODO(F3/F7): 替换为 @vben/request /iot/rule/provider/metadata
- apps/web-antd/src/components/iot-dag/__tests__/useNodeCatalog.spec.ts 17 用例
- apps/web-antd/src/components/iot-dag/index.ts barrel 增加 DagNodePanel/NodeTypeCard
- i18n: iot.dag.panel.* + iot.dag.node.<type>.{name,desc} zh-CN/en-US 同步
- Known Pitfalls 落地: ⚠️ Firefox dataTransfer 双 MIME / 权限过滤预留 / icon Lucide 名

note: Acceptance #3 drop 由 F3/F7 DagCanvasContainer 实现,F2 只负责发送方。
      图标通过 data-icon attribute 传递,F3/F7 集成时接入 lucide-vue-next 渲染。

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-23 22:07:37 +08:00
parent 5253a7a818
commit 8613641d1d
7 changed files with 985 additions and 0 deletions

View File

@@ -0,0 +1,298 @@
<script lang="ts" setup>
import type { NodeCategory } from './hooks/useNodeCatalog';
import { computed, onMounted, ref } from 'vue';
import { $t } from '#/locales';
import { useNodeCatalog } from './hooks/useNodeCatalog';
import NodeTypeCard from './NodeTypeCard.vue';
// ── Props ─────────────────────────────────────────────────────────────────────
interface Props {
/** 是否隐藏面板(降级:用属性面板手动创建节点) */
hidePanel?: boolean;
/** 面板宽度,默认 220px */
width?: number;
}
const props = withDefaults(defineProps<Props>(), {
hidePanel: false,
width: 220,
});
// ── State ─────────────────────────────────────────────────────────────────────
const searchQuery = ref('');
/** 各分类的折叠展开状态(默认全部展开) */
const collapsedKeys = ref<Set<string>>(new Set());
const { error, getCategorized, load, loading } = useNodeCatalog();
onMounted(() => {
load();
});
// ── 计算属性 ──────────────────────────────────────────────────────────────────
const categorized = computed<NodeCategory[]>(() =>
getCategorized(searchQuery.value),
);
// ── 事件处理 ──────────────────────────────────────────────────────────────────
function toggleCollapse(key: string): void {
if (collapsedKeys.value.has(key)) {
collapsedKeys.value.delete(key);
} else {
collapsedKeys.value.add(key);
}
// 触发 Set 的响应式更新
collapsedKeys.value = new Set(collapsedKeys.value);
}
function isCollapsed(key: string): boolean {
return collapsedKeys.value.has(key);
}
const panelStyle = computed(() => ({
width: `${props.width}px`,
}));
</script>
<template>
<aside
v-if="!hidePanel"
class="dag-node-panel"
:aria-label="$t('iot.dag.panel.title')"
:style="panelStyle"
>
<!-- 面板标题 -->
<div class="dag-node-panel__header">
<span class="dag-node-panel__title">{{ $t('iot.dag.panel.title') }}</span>
</div>
<!-- 搜索框 -->
<div class="dag-node-panel__search">
<input
v-model="searchQuery"
autocomplete="off"
class="dag-node-panel__search-input"
type="text"
:placeholder="$t('iot.dag.panel.searchPlaceholder')"
/>
</div>
<!-- 加载中 -->
<div v-if="loading" class="dag-node-panel__status">
{{ $t('iot.dag.panel.loading') }}
</div>
<!-- 加载出错 -->
<div
v-else-if="error"
class="dag-node-panel__status dag-node-panel__status--error"
>
{{ $t('iot.dag.panel.loadError') }}
</div>
<!-- 无匹配 -->
<div v-else-if="categorized.length === 0" class="dag-node-panel__status">
{{ $t('iot.dag.panel.noMatch') }}
</div>
<!-- 节点分类列表 -->
<div v-else class="dag-node-panel__body">
<section
v-for="cat in categorized"
:key="cat.key"
class="dag-node-panel__group"
>
<!-- 分类标题可折叠 -->
<button
class="dag-node-panel__group-header"
type="button"
:aria-expanded="!isCollapsed(cat.key)"
@click="toggleCollapse(cat.key)"
>
<span
class="dag-node-panel__group-icon"
:class="`dag-node-panel__group-icon--${cat.key}`"
>
</span>
<span class="dag-node-panel__group-label">
{{ $t(cat.label) }}
</span>
<span class="dag-node-panel__group-count">
({{ cat.types.length }})
</span>
<span
class="dag-node-panel__group-chevron"
:class="{
'dag-node-panel__group-chevron--collapsed': isCollapsed(cat.key),
}"
>
</span>
</button>
<!-- 节点卡片列表 -->
<div v-if="!isCollapsed(cat.key)" class="dag-node-panel__group-body">
<NodeTypeCard
v-for="meta in cat.types"
:key="meta.type"
:meta="meta"
/>
</div>
</section>
</div>
</aside>
</template>
<style scoped>
/* ── 面板容器 ── */
.dag-node-panel {
display: flex;
flex-direction: column;
flex-shrink: 0;
height: 100%;
overflow: hidden;
background: #fff;
border-right: 1px solid #e8e8e8;
border-radius: 6px 0 0 6px;
}
/* ── 头部 ── */
.dag-node-panel__header {
display: flex;
align-items: center;
padding: 12px 14px 8px;
border-bottom: 1px solid #f0f0f0;
}
.dag-node-panel__title {
font-size: 13px;
font-weight: 600;
color: #262626;
}
/* ── 搜索框 ── */
.dag-node-panel__search {
padding: 8px 10px;
}
.dag-node-panel__search-input {
box-sizing: border-box;
width: 100%;
height: 32px;
padding: 0 10px;
font-size: 13px;
color: #262626;
outline: none;
background: #fafafa;
border: 1px solid #d9d9d9;
border-radius: 6px;
transition: border-color 0.2s;
}
.dag-node-panel__search-input::placeholder {
color: #bfbfbf;
}
.dag-node-panel__search-input:focus {
background: #fff;
border-color: #1677ff;
}
/* ── 状态(加载 / 错误 / 无匹配) ── */
.dag-node-panel__status {
padding: 20px 14px;
font-size: 13px;
color: #8c8c8c;
text-align: center;
}
.dag-node-panel__status--error {
color: #ff4d4f;
}
/* ── 内容区域(可滚动) ── */
.dag-node-panel__body {
flex: 1;
padding: 4px 0 8px;
overflow-y: auto;
}
/* ── 分类组 ── */
.dag-node-panel__group {
margin-bottom: 2px;
}
.dag-node-panel__group-header {
display: flex;
gap: 6px;
align-items: center;
width: 100%;
padding: 6px 10px;
font-size: 12px;
color: #595959;
text-align: left;
cursor: pointer;
background: transparent;
border: none;
border-radius: 0;
transition: background 0.15s;
}
.dag-node-panel__group-header:hover {
background: #f5f5f5;
}
.dag-node-panel__group-icon {
flex-shrink: 0;
font-size: 8px;
}
.dag-node-panel__group-icon--trigger {
color: #1677ff;
}
.dag-node-panel__group-icon--condition {
color: #faad14;
}
.dag-node-panel__group-icon--action {
color: #52c41a;
}
.dag-node-panel__group-label {
flex: 1;
font-weight: 600;
}
.dag-node-panel__group-count {
font-size: 11px;
color: #8c8c8c;
}
.dag-node-panel__group-chevron {
font-size: 12px;
color: #8c8c8c;
transition: transform 0.2s;
}
.dag-node-panel__group-chevron--collapsed {
transform: rotate(-90deg);
}
/* ── 卡片列表 ── */
.dag-node-panel__group-body {
display: flex;
flex-direction: column;
gap: 4px;
padding: 2px 10px 6px;
}
</style>

View File

@@ -0,0 +1,140 @@
<script lang="ts" setup>
import type { NodeTypeMeta } from './hooks/useNodeCatalog';
import { $t } from '#/locales';
// ── Props ─────────────────────────────────────────────────────────────────────
interface Props {
meta: NodeTypeMeta;
}
const props = defineProps<Props>();
// ── Drag handlers ─────────────────────────────────────────────────────────────
/**
* HTML5 drag-n-drop 协议
*
* dataTransfer 写入两个 MIME 类型:
* - 'application/x-iot-dag-node' → Chrome / Edge首选
* - 'text/plain' → Firefox 兼容(⚠️ Known Pitfall
*
* 载荷结构:{ type: providerType, category: dagNodeType }
* 画布侧F3/F7 DagCanvasContainer读取 'application/x-iot-dag-node'
* 降级读取 'text/plain'。
*/
function handleDragStart(event: DragEvent): void {
if (!event.dataTransfer) return;
const payload = JSON.stringify({
category: props.meta.dagNodeType,
type: props.meta.type,
});
// 主 MIME 类型
event.dataTransfer.setData('application/x-iot-dag-node', payload);
// Firefox 兼容
event.dataTransfer.setData('text/plain', props.meta.type);
event.dataTransfer.effectAllowed = 'copy';
}
</script>
<template>
<div
class="node-type-card"
draggable="true"
:title="$t(`iot.dag.node.${meta.type}.name`)"
@dragstart="handleDragStart"
>
<!-- 图标区 -->
<span
class="node-type-card__icon"
:class="`node-type-card__icon--${meta.category}`"
>
<!-- Lucide 图标名通过 data-icon 传递父组件或 CSS content 处理渲染 -->
<span
aria-hidden="true"
class="lucide-icon"
:data-icon="meta.icon"
></span>
</span>
<!-- 名称 -->
<span class="node-type-card__name">
{{ $t(`iot.dag.node.${meta.type}.name`) }}
</span>
</div>
</template>
<style scoped>
.node-type-card {
display: flex;
gap: 8px;
align-items: center;
padding: 7px 10px;
cursor: grab;
user-select: none;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
transition:
background 0.15s,
border-color 0.15s,
box-shadow 0.15s;
}
.node-type-card:hover {
background: #e6f4ff;
border-color: #91caff;
box-shadow: 0 1px 4px rgb(22 119 255 / 12%);
}
.node-type-card:active {
cursor: grabbing;
}
/* 拖拽时半透明 */
.node-type-card[draggable='true']:active {
opacity: 0.6;
}
/* ── 图标 ── */
.node-type-card__icon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
font-size: 14px;
border-radius: 6px;
}
.node-type-card__icon--trigger {
color: #1677ff;
background: #e6f4ff;
}
.node-type-card__icon--condition {
color: #faad14;
background: #fffbe6;
}
.node-type-card__icon--action {
color: #52c41a;
background: #f6ffed;
}
/* ── 名称 ── */
.node-type-card__name {
flex: 1;
min-width: 0;
overflow: hidden;
font-size: 13px;
color: #262626;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>

View File

@@ -0,0 +1,160 @@
import { describe, expect, it, vi } from 'vitest';
import { fetchNodeCatalog, useNodeCatalog } from '../hooks/useNodeCatalog';
// ── fetchNodeCatalog ──────────────────────────────────────────────────────────
describe('fetchNodeCatalog', () => {
it('returns 13 node types in total', async () => {
const types = await fetchNodeCatalog();
expect(types).toHaveLength(13);
});
it('returns 5 trigger types', async () => {
const types = await fetchNodeCatalog();
const triggers = types.filter((t) => t.category === 'trigger');
expect(triggers).toHaveLength(5);
});
it('returns 3 condition types', async () => {
const types = await fetchNodeCatalog();
const conditions = types.filter((t) => t.category === 'condition');
expect(conditions).toHaveLength(3);
});
it('returns 5 action types', async () => {
const types = await fetchNodeCatalog();
const actions = types.filter((t) => t.category === 'action');
expect(actions).toHaveLength(5);
});
it('each type has required fields', async () => {
const types = await fetchNodeCatalog();
for (const t of types) {
expect(t.type).toBeTruthy();
expect(t.icon).toBeTruthy();
expect(['trigger', 'condition', 'action']).toContain(t.category);
expect(['trigger', 'condition', 'action']).toContain(t.dagNodeType);
}
});
it('returns a deep clone (mutations do not affect subsequent calls)', async () => {
const types1 = await fetchNodeCatalog();
const first = types1[0];
if (first) {
first.type = 'mutated';
}
const types2 = await fetchNodeCatalog();
expect(types2[0]?.type).not.toBe('mutated');
});
});
// ── useNodeCatalog ────────────────────────────────────────────────────────────
describe('useNodeCatalog', () => {
it('starts with empty allTypes', () => {
const { allTypes, loading, error } = useNodeCatalog();
expect(allTypes.value).toHaveLength(0);
expect(loading.value).toBe(false);
expect(error.value).toBeNull();
});
it('load() populates allTypes', async () => {
const { allTypes, load } = useNodeCatalog();
await load();
expect(allTypes.value).toHaveLength(13);
});
it('load() sets loading to false after completion', async () => {
const { loading, load } = useNodeCatalog();
await load();
expect(loading.value).toBe(false);
});
it('getCategorized returns 3 groups without search', async () => {
const { getCategorized, load } = useNodeCatalog();
await load();
const groups = getCategorized('');
expect(groups).toHaveLength(3);
const keys = groups.map((g) => g.key);
expect(keys).toContain('trigger');
expect(keys).toContain('condition');
expect(keys).toContain('action');
});
it('getCategorized groups are ordered: trigger → condition → action', async () => {
const { getCategorized, load } = useNodeCatalog();
await load();
const groups = getCategorized('');
expect(groups[0]?.key).toBe('trigger');
expect(groups[1]?.key).toBe('condition');
expect(groups[2]?.key).toBe('action');
});
it('getCategorized filters by type substring (case-insensitive)', async () => {
const { getCategorized, load } = useNodeCatalog();
await load();
// 'timer' should only match the timer trigger type
const groups = getCategorized('timer');
expect(groups).toHaveLength(1);
expect(groups[0]?.key).toBe('trigger');
expect(groups[0]?.types).toHaveLength(1);
expect(groups[0]?.types[0]?.type).toBe('timer');
});
it('getCategorized returns empty array when no match', async () => {
const { getCategorized, load } = useNodeCatalog();
await load();
const groups = getCategorized('xyznonexistent');
expect(groups).toHaveLength(0);
});
it('getCategorized filters device_ prefix matching multiple types', async () => {
const { getCategorized, load } = useNodeCatalog();
await load();
const groups = getCategorized('device_');
// device_state, device_property, device_event, device_service are triggers
// condition_device_state is condition
const triggerGroup = groups.find((g) => g.key === 'trigger');
expect(triggerGroup?.types.length).toBeGreaterThanOrEqual(4);
});
it('permissionFilter option excludes filtered types', async () => {
const { allTypes, load } = useNodeCatalog({
permissionFilter: (meta) => meta.type !== 'timer',
});
await load();
expect(allTypes.value.find((t) => t.type === 'timer')).toBeUndefined();
expect(allTypes.value).toHaveLength(12);
});
it('load() captures errors and sets error ref', async () => {
// 注入抛错的 fetcher测试错误捕获路径
const failingFetcher = vi
.fn<() => Promise<never>>()
.mockRejectedValue(new Error('Network error'));
const { error, load } = useNodeCatalog({ fetcher: failingFetcher });
await load();
expect(error.value).toBeInstanceOf(Error);
expect(error.value?.message).toBe('Network error');
});
});
// ── Drag-n-drop payload structure ─────────────────────────────────────────────
describe('drag payload structure', () => {
it('payload JSON has type and category fields', async () => {
const types = await fetchNodeCatalog();
const meta = types[0];
expect(meta).toBeDefined();
if (!meta) return;
const payload = JSON.stringify({
category: meta.dagNodeType,
type: meta.type,
});
const parsed = JSON.parse(payload) as { category: string; type: string };
expect(parsed.type).toBe(meta.type);
expect(parsed.category).toBe(meta.dagNodeType);
});
});

View File

@@ -0,0 +1,251 @@
/**
* useNodeCatalog
*
* 从后端 /iot/rule/provider/metadata 拉取节点类型列表。
* 当前后端 B4-B6 未就绪,返回 mock 数据;
* 后续 F3/F7 联调时将 `fetchNodeCatalog` 内部替换为 @vben/request 调用。
*/
import type { DagNodeType } from '../types';
import { ref } from 'vue';
// ── 类型定义 ──────────────────────────────────────────────────────────────────
/** 单个节点类型元数据 */
export interface NodeTypeMeta {
/** 所属大类 */
category: 'action' | 'condition' | 'trigger';
/** 节点描述 */
description?: string;
/** Lucide 图标名lucide-vue-next */
icon: string;
/** 所需权限(预留,权限过滤用) */
requiredPermission?: string;
/** Provider 类型标识(如 'device_property' */
type: string;
/** 对应 DagNodeType */
dagNodeType: DagNodeType;
}
/** 节点大类分组 */
export interface NodeCategory {
/** 图标 */
icon: string;
/** i18n key */
label: string;
/** 分类 key */
key: 'action' | 'condition' | 'trigger';
/** 该分类下的节点类型 */
types: NodeTypeMeta[];
}
// ── Mock 数据 ─────────────────────────────────────────────────────────────────
/**
* Mock 节点类型列表
* 后端 B4-B6 就绪后fetchNodeCatalog 内部替换为真实 API
* 结构需与此保持一致。
*/
const MOCK_NODE_TYPES: NodeTypeMeta[] = [
// ── Trigger触发器5 种 ──────────────────────────────────────────────────
{
category: 'trigger',
dagNodeType: 'trigger',
description: 'iot.dag.node.device_state.desc',
icon: 'cpu',
type: 'device_state',
},
{
category: 'trigger',
dagNodeType: 'trigger',
description: 'iot.dag.node.device_property.desc',
icon: 'activity',
type: 'device_property',
},
{
category: 'trigger',
dagNodeType: 'trigger',
description: 'iot.dag.node.device_event.desc',
icon: 'bell',
type: 'device_event',
},
{
category: 'trigger',
dagNodeType: 'trigger',
description: 'iot.dag.node.device_service.desc',
icon: 'wrench',
type: 'device_service',
},
{
category: 'trigger',
dagNodeType: 'trigger',
description: 'iot.dag.node.timer.desc',
icon: 'clock',
type: 'timer',
},
// ── Condition条件3 种 ──────────────────────────────────────────────────
{
category: 'condition',
dagNodeType: 'condition',
description: 'iot.dag.node.expression.desc',
icon: 'code-2',
type: 'expression',
},
{
category: 'condition',
dagNodeType: 'condition',
description: 'iot.dag.node.time_range.desc',
icon: 'calendar-range',
type: 'time_range',
},
{
category: 'condition',
dagNodeType: 'condition',
description: 'iot.dag.node.condition_device_state.desc',
icon: 'monitor-check',
type: 'condition_device_state',
},
// ── Action动作5 种 ────────────────────────────────────────────────────
{
category: 'action',
dagNodeType: 'action',
description: 'iot.dag.node.property_set.desc',
icon: 'sliders-horizontal',
type: 'property_set',
},
{
category: 'action',
dagNodeType: 'action',
description: 'iot.dag.node.service_invoke.desc',
icon: 'play-circle',
type: 'service_invoke',
},
{
category: 'action',
dagNodeType: 'action',
description: 'iot.dag.node.alarm_trigger.desc',
icon: 'alert-triangle',
type: 'alarm_trigger',
},
{
category: 'action',
dagNodeType: 'action',
description: 'iot.dag.node.alarm_clear.desc',
icon: 'shield-check',
type: 'alarm_clear',
},
{
category: 'action',
dagNodeType: 'action',
description: 'iot.dag.node.notify.desc',
icon: 'send',
type: 'notify',
},
];
/** 分类元数据 */
const CATEGORY_META: Record<
'action' | 'condition' | 'trigger',
{ icon: string; label: string }
> = {
action: { icon: 'zap', label: 'iot.dag.panel.category.action' },
condition: { icon: 'filter', label: 'iot.dag.panel.category.condition' },
trigger: { icon: 'radio', label: 'iot.dag.panel.category.trigger' },
};
// ── API 层(当前 mock后续替换──────────────────────────────────────────────
/**
* 拉取节点类型列表
*
* TODO(F3/F7): 替换为
* ```ts
* import { requestClient } from '@vben/request';
* return requestClient.get<NodeTypeMeta[]>('/iot/rule/provider/metadata');
* ```
*/
export async function fetchNodeCatalog(): Promise<NodeTypeMeta[]> {
// 模拟网络延迟
await new Promise<void>((resolve) => setTimeout(resolve, 0));
return structuredClone(MOCK_NODE_TYPES);
}
// ── Composable ─────────────────────────────────────────────────────────────────
/**
* useNodeCatalog
*
* 加载并整理节点类型列表,提供分组视图和搜索过滤。
*
* @param options.permissionFilter 可选,接收 NodeTypeMeta 返回 boolean
* 过滤无权限的节点(预留,权限场景)
* @param options.fetcher 可选,自定义数据获取函数(测试注入 / 替换 API
*/
export function useNodeCatalog(options?: {
fetcher?: () => Promise<NodeTypeMeta[]>;
permissionFilter?: (meta: NodeTypeMeta) => boolean;
}) {
const allTypes = ref<NodeTypeMeta[]>([]);
const loading = ref(false);
const error = ref<Error | null>(null);
const _fetcher = options?.fetcher ?? fetchNodeCatalog;
async function load(): Promise<void> {
loading.value = true;
error.value = null;
try {
let data = await _fetcher();
// 权限过滤(预留接口)
if (options?.permissionFilter) {
data = data.filter(options.permissionFilter);
}
allTypes.value = data;
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e));
} finally {
loading.value = false;
}
}
/**
* 按搜索词过滤后,返回分组视图
* 搜索词匹配 NodeTypeMeta.type含 i18n name key 对应的搜索文案在外部处理)
*/
function getCategorized(searchQuery: string): NodeCategory[] {
const q = searchQuery.trim().toLowerCase();
const filtered = q
? allTypes.value.filter((t) => t.type.toLowerCase().includes(q))
: allTypes.value;
const order: Array<'action' | 'condition' | 'trigger'> = [
'trigger',
'condition',
'action',
];
return order
.map((key) => {
const types = filtered.filter((t) => t.category === key);
const meta = CATEGORY_META[key];
return {
icon: meta.icon,
key,
label: meta.label,
types,
} satisfies NodeCategory;
})
.filter((cat) => cat.types.length > 0);
}
return {
allTypes,
error,
getCategorized,
load,
loading,
};
}

View File

@@ -1,7 +1,11 @@
export { default as DagCanvas } from './DagCanvas.vue';
export { default as DagCanvasToolbar } from './DagCanvasToolbar.vue';
export { default as DagNodePanel } from './DagNodePanel.vue';
export { useDagShortcuts } from './hooks/useDagShortcuts';
export { useDagState } from './hooks/useDagState';
export { fetchNodeCatalog, useNodeCatalog } from './hooks/useNodeCatalog';
export { default as NodeTypeCard } from './NodeTypeCard.vue';
export type { NodeCategory, NodeTypeMeta } from './hooks/useNodeCatalog';
export type {
DagCanvasEmits,
DagCanvasProps,

View File

@@ -53,6 +53,72 @@
"canvas": {
"readonly": "Read-only mode",
"empty": "Drag nodes to canvas to start"
},
"panel": {
"title": "Node Types",
"searchPlaceholder": "Search nodes...",
"loading": "Loading...",
"loadError": "Load failed, please refresh",
"noMatch": "No matching nodes found",
"category": {
"trigger": "Trigger",
"condition": "Condition",
"action": "Action"
}
},
"node": {
"device_state": {
"name": "Device State",
"desc": "Triggered when device goes online/offline"
},
"device_property": {
"name": "Device Property Report",
"desc": "Triggered when device reports property data"
},
"device_event": {
"name": "Device Event",
"desc": "Triggered when device reports an event"
},
"device_service": {
"name": "Device Service Call",
"desc": "Triggered when device service is invoked"
},
"timer": {
"name": "Timer",
"desc": "Triggered by Cron expression on schedule"
},
"expression": {
"name": "Expression",
"desc": "Aviator expression evaluation condition"
},
"time_range": {
"name": "Time Range",
"desc": "Check if current time is within a specified range"
},
"condition_device_state": {
"name": "Device State Check",
"desc": "Check current device online status"
},
"property_set": {
"name": "Set Property",
"desc": "Send property set command to device"
},
"service_invoke": {
"name": "Invoke Service",
"desc": "Invoke device service"
},
"alarm_trigger": {
"name": "Trigger Alarm",
"desc": "Trigger a device alarm"
},
"alarm_clear": {
"name": "Clear Alarm",
"desc": "Clear a device alarm"
},
"notify": {
"name": "Send Notification",
"desc": "Send notification (SMS/Email/Webhook)"
}
}
}
}

View File

@@ -53,6 +53,72 @@
"canvas": {
"readonly": "只读模式",
"empty": "拖拽节点到画布开始编排"
},
"panel": {
"title": "节点类型",
"searchPlaceholder": "搜索节点...",
"loading": "加载中...",
"loadError": "加载失败,请刷新重试",
"noMatch": "未找到匹配节点",
"category": {
"trigger": "触发器",
"condition": "条件",
"action": "动作"
}
},
"node": {
"device_state": {
"name": "设备状态",
"desc": "设备上线/下线时触发"
},
"device_property": {
"name": "设备属性上报",
"desc": "设备属性数据上报时触发"
},
"device_event": {
"name": "设备事件",
"desc": "设备上报事件时触发"
},
"device_service": {
"name": "设备服务调用",
"desc": "设备服务被调用时触发"
},
"timer": {
"name": "定时触发",
"desc": "按 Cron 表达式定时触发"
},
"expression": {
"name": "表达式判断",
"desc": "Aviator 表达式计算条件"
},
"time_range": {
"name": "时间段判断",
"desc": "判断当前时间是否在指定范围"
},
"condition_device_state": {
"name": "设备状态判断",
"desc": "判断设备当前在线状态"
},
"property_set": {
"name": "下发属性",
"desc": "向设备下发属性设置指令"
},
"service_invoke": {
"name": "调用服务",
"desc": "调用设备服务"
},
"alarm_trigger": {
"name": "触发告警",
"desc": "触发设备告警"
},
"alarm_clear": {
"name": "清除告警",
"desc": "清除设备告警"
},
"notify": {
"name": "发送通知",
"desc": "发送消息通知(短信/邮件/webhook"
}
}
}
}