diff --git a/apps/web-antd/src/components/iot-dag/DagNodePanel.vue b/apps/web-antd/src/components/iot-dag/DagNodePanel.vue new file mode 100644 index 000000000..604d4cdce --- /dev/null +++ b/apps/web-antd/src/components/iot-dag/DagNodePanel.vue @@ -0,0 +1,298 @@ + + + + + diff --git a/apps/web-antd/src/components/iot-dag/NodeTypeCard.vue b/apps/web-antd/src/components/iot-dag/NodeTypeCard.vue new file mode 100644 index 000000000..84e63fff5 --- /dev/null +++ b/apps/web-antd/src/components/iot-dag/NodeTypeCard.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/apps/web-antd/src/components/iot-dag/__tests__/useNodeCatalog.spec.ts b/apps/web-antd/src/components/iot-dag/__tests__/useNodeCatalog.spec.ts new file mode 100644 index 000000000..b9b20a727 --- /dev/null +++ b/apps/web-antd/src/components/iot-dag/__tests__/useNodeCatalog.spec.ts @@ -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>() + .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); + }); +}); diff --git a/apps/web-antd/src/components/iot-dag/hooks/useNodeCatalog.ts b/apps/web-antd/src/components/iot-dag/hooks/useNodeCatalog.ts new file mode 100644 index 000000000..41ee8c4f6 --- /dev/null +++ b/apps/web-antd/src/components/iot-dag/hooks/useNodeCatalog.ts @@ -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('/iot/rule/provider/metadata'); + * ``` + */ +export async function fetchNodeCatalog(): Promise { + // 模拟网络延迟 + await new Promise((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; + permissionFilter?: (meta: NodeTypeMeta) => boolean; +}) { + const allTypes = ref([]); + const loading = ref(false); + const error = ref(null); + + const _fetcher = options?.fetcher ?? fetchNodeCatalog; + + async function load(): Promise { + 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, + }; +} diff --git a/apps/web-antd/src/components/iot-dag/index.ts b/apps/web-antd/src/components/iot-dag/index.ts index b90269611..09ed4920a 100644 --- a/apps/web-antd/src/components/iot-dag/index.ts +++ b/apps/web-antd/src/components/iot-dag/index.ts @@ -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, diff --git a/apps/web-antd/src/locales/langs/en-US/page.json b/apps/web-antd/src/locales/langs/en-US/page.json index 974deda11..6edb73d69 100644 --- a/apps/web-antd/src/locales/langs/en-US/page.json +++ b/apps/web-antd/src/locales/langs/en-US/page.json @@ -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)" + } } } } diff --git a/apps/web-antd/src/locales/langs/zh-CN/page.json b/apps/web-antd/src/locales/langs/zh-CN/page.json index 7f746945c..a14307127 100644 --- a/apps/web-antd/src/locales/langs/zh-CN/page.json +++ b/apps/web-antd/src/locales/langs/zh-CN/page.json @@ -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)" + } } } }