[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:
298
apps/web-antd/src/components/iot-dag/DagNodePanel.vue
Normal file
298
apps/web-antd/src/components/iot-dag/DagNodePanel.vue
Normal 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>
|
||||
140
apps/web-antd/src/components/iot-dag/NodeTypeCard.vue
Normal file
140
apps/web-antd/src/components/iot-dag/NodeTypeCard.vue
Normal 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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
251
apps/web-antd/src/components/iot-dag/hooks/useNodeCatalog.ts
Normal file
251
apps/web-antd/src/components/iot-dag/hooks/useNodeCatalog.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user