|
|
@@ -0,0 +1,1146 @@
|
|
|
+<template>
|
|
|
+ <div class="strategy-editor">
|
|
|
+ <!-- 顶部工具栏 -->
|
|
|
+ <div class="editor-header">
|
|
|
+ <div class="header-left">
|
|
|
+ <el-button icon="el-icon-arrow-left" @click="goBack">返回</el-button>
|
|
|
+ <el-divider direction="vertical"></el-divider>
|
|
|
+ <div class="strategy-info">
|
|
|
+ <h2 class="strategy-name">
|
|
|
+ {{ strategyData.strategyName || '新策略' }}
|
|
|
+ <el-tag :type="getSceneTagType(strategyData.sceneType)" size="small">
|
|
|
+ {{ getSceneLabel(strategyData.sceneType) }}
|
|
|
+ </el-tag>
|
|
|
+ </h2>
|
|
|
+ <span class="strategy-code">{{ strategyData.strategyCode }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="header-center">
|
|
|
+ <el-radio-group v-model="editorMode" size="small">
|
|
|
+ <el-radio-button label="design">
|
|
|
+ <i class="el-icon-edit"></i> 设计
|
|
|
+ </el-radio-button>
|
|
|
+ <el-radio-button label="preview">
|
|
|
+ <i class="el-icon-view"></i> 预览
|
|
|
+ </el-radio-button>
|
|
|
+ <el-radio-button label="debug">
|
|
|
+ <i class="el-icon-magic-stick"></i> 调试
|
|
|
+ </el-radio-button>
|
|
|
+ </el-radio-group>
|
|
|
+ </div>
|
|
|
+ <div class="header-right">
|
|
|
+ <el-button size="small" @click="handleUndo" :disabled="!canUndo">
|
|
|
+ <i class="el-icon-refresh-left"></i>
|
|
|
+ </el-button>
|
|
|
+ <el-button size="small" @click="handleRedo" :disabled="!canRedo">
|
|
|
+ <i class="el-icon-refresh-right"></i>
|
|
|
+ </el-button>
|
|
|
+ <el-divider direction="vertical"></el-divider>
|
|
|
+ <el-button type="success" size="small" icon="el-icon-video-play" @click="handleTestRun">
|
|
|
+ 测试执行
|
|
|
+ </el-button>
|
|
|
+ <el-button type="primary" size="small" icon="el-icon-check" @click="handleSave">
|
|
|
+ 保存
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="editor-main">
|
|
|
+ <!-- 左侧组件面板 -->
|
|
|
+ <div class="component-panel">
|
|
|
+ <el-collapse v-model="activePanels">
|
|
|
+ <!-- 触发器组件 -->
|
|
|
+ <el-collapse-item title="触发器" name="triggers">
|
|
|
+ <div class="component-list">
|
|
|
+ <div
|
|
|
+ v-for="trigger in triggerComponents"
|
|
|
+ :key="trigger.type"
|
|
|
+ class="component-item"
|
|
|
+ draggable="true"
|
|
|
+ @dragstart="(e) => handleDragStart(e, 'trigger', trigger)"
|
|
|
+ >
|
|
|
+ <div class="component-icon" :style="{ background: trigger.color }">
|
|
|
+ <i :class="trigger.icon"></i>
|
|
|
+ </div>
|
|
|
+ <span class="component-name">{{ trigger.name }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-collapse-item>
|
|
|
+
|
|
|
+ <!-- 设备能力 -->
|
|
|
+ <el-collapse-item title="设备能力" name="devices">
|
|
|
+ <div class="device-search">
|
|
|
+ <el-input
|
|
|
+ v-model="deviceKeyword"
|
|
|
+ placeholder="搜索设备"
|
|
|
+ size="mini"
|
|
|
+ prefix-icon="el-icon-search"
|
|
|
+ clearable
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="device-tree">
|
|
|
+ <el-tree
|
|
|
+ :data="deviceTreeData"
|
|
|
+ :props="{ children: 'children', label: 'label' }"
|
|
|
+ node-key="id"
|
|
|
+ :filter-node-method="filterDeviceNode"
|
|
|
+ :expand-on-click-node="false"
|
|
|
+ @node-click="handleDeviceNodeClick"
|
|
|
+ ref="deviceTree"
|
|
|
+ >
|
|
|
+ <span class="device-tree-node" slot-scope="{ node, data }">
|
|
|
+ <i :class="getDeviceTreeIcon(data)"></i>
|
|
|
+ <span>{{ node.label }}</span>
|
|
|
+ <el-tag v-if="data.type === 'ability'" size="mini" type="success">能力</el-tag>
|
|
|
+ </span>
|
|
|
+ </el-tree>
|
|
|
+ </div>
|
|
|
+ </el-collapse-item>
|
|
|
+
|
|
|
+ <!-- 流程控制 -->
|
|
|
+ <el-collapse-item title="流程控制" name="flow">
|
|
|
+ <div class="component-list">
|
|
|
+ <div
|
|
|
+ v-for="flow in flowComponents"
|
|
|
+ :key="flow.type"
|
|
|
+ class="component-item"
|
|
|
+ draggable="true"
|
|
|
+ @dragstart="(e) => handleDragStart(e, 'flow', flow)"
|
|
|
+ >
|
|
|
+ <div class="component-icon" :style="{ background: flow.color }">
|
|
|
+ <i :class="flow.icon"></i>
|
|
|
+ </div>
|
|
|
+ <span class="component-name">{{ flow.name }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-collapse-item>
|
|
|
+
|
|
|
+ <!-- 上下文变量 -->
|
|
|
+ <el-collapse-item title="上下文变量" name="context">
|
|
|
+ <div class="context-vars">
|
|
|
+ <div
|
|
|
+ v-for="variable in contextVariables"
|
|
|
+ :key="variable.varKey"
|
|
|
+ class="context-var-item"
|
|
|
+ draggable="true"
|
|
|
+ @dragstart="(e) => handleDragStart(e, 'variable', variable)"
|
|
|
+ >
|
|
|
+ <span class="var-name">{{ variable.varName }}</span>
|
|
|
+ <span class="var-type">{{ variable.dataType }}</span>
|
|
|
+ </div>
|
|
|
+ <el-button type="text" icon="el-icon-plus" size="mini" @click="showAddVariable">
|
|
|
+ 添加变量
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </el-collapse-item>
|
|
|
+ </el-collapse>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 中间画布区域 -->
|
|
|
+ <div class="canvas-panel" @dragover.prevent @drop="handleCanvasDrop">
|
|
|
+ <div class="canvas-toolbar">
|
|
|
+ <el-button-group>
|
|
|
+ <el-button size="mini" icon="el-icon-zoom-in" @click="zoomIn"></el-button>
|
|
|
+ <el-button size="mini">{{ Math.round(zoom * 100) }}%</el-button>
|
|
|
+ <el-button size="mini" icon="el-icon-zoom-out" @click="zoomOut"></el-button>
|
|
|
+ </el-button-group>
|
|
|
+ <el-button size="mini" icon="el-icon-rank" @click="autoLayout">自动布局</el-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="canvas-container" ref="canvasContainer">
|
|
|
+ <div class="canvas-content" :style="{ transform: `scale(${zoom})` }" ref="canvas">
|
|
|
+ <!-- 开始节点 -->
|
|
|
+ <div class="flow-node start-node">
|
|
|
+ <div class="node-icon"><i class="el-icon-caret-right"></i></div>
|
|
|
+ <span>开始</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 触发器节点 -->
|
|
|
+ <div
|
|
|
+ v-for="(trigger, index) in triggers"
|
|
|
+ :key="'trigger-' + index"
|
|
|
+ :class="['flow-node', 'trigger-node', { selected: selectedNode === trigger }]"
|
|
|
+ @click="selectNode(trigger)"
|
|
|
+ >
|
|
|
+ <div class="node-header" :style="{ background: getTriggerColor(trigger.triggerType) }">
|
|
|
+ <i :class="getTriggerIcon(trigger.triggerType)"></i>
|
|
|
+ <span>{{ trigger.triggerName || '触发器' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="node-body">
|
|
|
+ <p>{{ getTriggerDesc(trigger) }}</p>
|
|
|
+ </div>
|
|
|
+ <div class="node-actions">
|
|
|
+ <i class="el-icon-edit" @click.stop="editNode(trigger, 'trigger')"></i>
|
|
|
+ <i class="el-icon-delete" @click.stop="deleteNode(trigger, 'trigger')"></i>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 连接线 -->
|
|
|
+ <div class="flow-connector" v-if="triggers.length > 0"></div>
|
|
|
+
|
|
|
+ <!-- 步骤节点列表 -->
|
|
|
+ <div class="steps-container">
|
|
|
+ <template v-for="(step, index) in steps">
|
|
|
+ <div
|
|
|
+ :key="'step-' + step.id"
|
|
|
+ :class="['flow-node', 'step-node', getStepNodeClass(step), { selected: selectedNode === step }]"
|
|
|
+ @click="selectNode(step)"
|
|
|
+ draggable="true"
|
|
|
+ @dragstart="(e) => handleStepDragStart(e, step, index)"
|
|
|
+ @dragover.prevent
|
|
|
+ @drop="(e) => handleStepDrop(e, index)"
|
|
|
+ >
|
|
|
+ <div class="node-header" :style="{ background: getStepColor(step.stepType) }">
|
|
|
+ <i :class="getStepIcon(step.stepType)"></i>
|
|
|
+ <span>{{ step.stepName || getStepTypeName(step.stepType) }}</span>
|
|
|
+ <el-tag v-if="!step.enable" size="mini" type="info">禁用</el-tag>
|
|
|
+ </div>
|
|
|
+ <div class="node-body">
|
|
|
+ <template v-if="step.stepType === 'ABILITY'">
|
|
|
+ <p class="node-target"><i class="el-icon-aim"></i>{{ step.targetObjCode || '未选择设备' }}</p>
|
|
|
+ <p class="node-ability"><i class="el-icon-s-operation"></i>{{ step.abilityKey || '未选择能力' }}</p>
|
|
|
+ </template>
|
|
|
+ <template v-else-if="step.stepType === 'DELAY'">
|
|
|
+ <p class="node-delay"><i class="el-icon-time"></i>延时 {{ step.delaySeconds || 0 }} 秒</p>
|
|
|
+ </template>
|
|
|
+ <template v-else-if="step.stepType === 'CONDITION'">
|
|
|
+ <p class="node-condition"><i class="el-icon-question"></i>{{ getConditionPreview(step.conditionExpr) }}</p>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ <div class="node-actions">
|
|
|
+ <i class="el-icon-edit" @click.stop="editNode(step, 'step')"></i>
|
|
|
+ <i class="el-icon-document-copy" @click.stop="copyStep(step)"></i>
|
|
|
+ <i class="el-icon-delete" @click.stop="deleteNode(step, 'step')"></i>
|
|
|
+ </div>
|
|
|
+ <div class="node-index">{{ index + 1 }}</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 连接线 -->
|
|
|
+ <div v-if="index < steps.length - 1" :key="'conn-' + index" class="flow-connector">
|
|
|
+ <div class="connector-line"></div>
|
|
|
+ <div class="connector-add" @click="addStepAfter(index)"><i class="el-icon-plus"></i></div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 添加步骤按钮 -->
|
|
|
+ <div class="add-step-btn" @click="addStepAtEnd">
|
|
|
+ <i class="el-icon-plus"></i>
|
|
|
+ <span>添加步骤</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 结束节点 -->
|
|
|
+ <div class="flow-node end-node">
|
|
|
+ <div class="node-icon"><i class="el-icon-circle-check"></i></div>
|
|
|
+ <span>结束</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 右侧属性面板 -->
|
|
|
+ <div class="property-panel" v-show="selectedNode || showStrategyConfig">
|
|
|
+ <div class="panel-header">
|
|
|
+ <span>{{ propertyPanelTitle }}</span>
|
|
|
+ <i class="el-icon-close" @click="closePropertyPanel"></i>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="panel-body">
|
|
|
+ <!-- 策略基础配置 -->
|
|
|
+ <template v-if="showStrategyConfig">
|
|
|
+ <el-form :model="strategyData" label-width="90px" size="small">
|
|
|
+ <el-form-item label="策略名称">
|
|
|
+ <el-input v-model="strategyData.strategyName" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="场景类型">
|
|
|
+ <el-select v-model="strategyData.sceneType" style="width: 100%">
|
|
|
+ <el-option v-for="scene in sceneTypes" :key="scene.value" :label="scene.label" :value="scene.value" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="优先级">
|
|
|
+ <el-slider v-model="strategyData.priority" :min="0" :max="100" show-input />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="超时时间">
|
|
|
+ <el-input-number v-model="strategyData.timeout" :min="0" style="width: 100%" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="失败重试">
|
|
|
+ <el-input-number v-model="strategyData.retryTimes" :min="0" :max="5" style="width: 100%" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="策略描述">
|
|
|
+ <el-input type="textarea" v-model="strategyData.strategyDesc" :rows="3" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <!-- 触发器配置 -->
|
|
|
+ <template v-else-if="selectedNodeType === 'trigger'">
|
|
|
+ <trigger-config :trigger="selectedNode" :device-list="deviceList" @change="handleTriggerChange" />
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <!-- 步骤配置 -->
|
|
|
+ <template v-else-if="selectedNodeType === 'step'">
|
|
|
+ <step-config :step="selectedNode" :device-list="deviceList" :context-variables="contextVariables" @change="handleStepChange" />
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 快速添加步骤弹窗 -->
|
|
|
+ <el-dialog title="添加步骤" :visible.sync="addStepDialogVisible" width="500px">
|
|
|
+ <div class="step-type-grid">
|
|
|
+ <div v-for="stepType in stepTypes" :key="stepType.type" class="step-type-card" @click="confirmAddStep(stepType.type)">
|
|
|
+ <div class="step-type-icon" :style="{ background: stepType.color }">
|
|
|
+ <i :class="stepType.icon"></i>
|
|
|
+ </div>
|
|
|
+ <div class="step-type-info">
|
|
|
+ <span class="step-type-name">{{ stepType.name }}</span>
|
|
|
+ <span class="step-type-desc">{{ stepType.desc }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- 添加变量弹窗 -->
|
|
|
+ <el-dialog title="添加上下文变量" :visible.sync="addVarDialogVisible" width="450px">
|
|
|
+ <el-form :model="newVariable" label-width="80px" size="small">
|
|
|
+ <el-form-item label="变量键">
|
|
|
+ <el-input v-model="newVariable.varKey" placeholder="如: pvPower" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="变量名称">
|
|
|
+ <el-input v-model="newVariable.varName" placeholder="如: 光伏功率" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="变量类型">
|
|
|
+ <el-select v-model="newVariable.varType" style="width: 100%">
|
|
|
+ <el-option label="输入变量" value="INPUT" />
|
|
|
+ <el-option label="输出变量" value="OUTPUT" />
|
|
|
+ <el-option label="临时变量" value="TEMP" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="数据类型">
|
|
|
+ <el-select v-model="newVariable.dataType" style="width: 100%">
|
|
|
+ <el-option label="字符串" value="STRING" />
|
|
|
+ <el-option label="数字" value="NUMBER" />
|
|
|
+ <el-option label="布尔值" value="BOOLEAN" />
|
|
|
+ <el-option label="对象" value="OBJECT" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="默认值">
|
|
|
+ <el-input v-model="newVariable.defaultValue" placeholder="可选" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="值来源">
|
|
|
+ <el-input v-model="newVariable.valueSource" placeholder="如: ESS001.soc" />
|
|
|
+ <div class="form-tip">格式: 设备代码.属性键</div>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <div slot="footer">
|
|
|
+ <el-button @click="addVarDialogVisible = false">取消</el-button>
|
|
|
+ <el-button type="primary" @click="confirmAddVariable">确定</el-button>
|
|
|
+ </div>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import {
|
|
|
+ getEnergyStrategy,
|
|
|
+ getEnergyStrategyByCode,
|
|
|
+ updateEnergyStrategy,
|
|
|
+ getStrategySteps,
|
|
|
+ addStrategyStep,
|
|
|
+ updateStrategyStep,
|
|
|
+ deleteStrategyStep,
|
|
|
+ saveStrategyStepBatch,
|
|
|
+ getTriggers,
|
|
|
+ saveTrigger,
|
|
|
+ deleteTrigger,
|
|
|
+ executeStrategy,
|
|
|
+ getStrategyContext,
|
|
|
+ saveStrategyContext
|
|
|
+} from '@/api/mgr/energyStrategy';
|
|
|
+import { listDevRecursionByArea } from '@/api/device/device';
|
|
|
+import { listAbility } from '@/api/basecfg/objAbility';
|
|
|
+import TriggerConfig from './components/TriggerConfig.vue';
|
|
|
+import StepConfig from './components/StepConfig.vue';
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: 'StrategyEditor',
|
|
|
+ components: { TriggerConfig, StepConfig },
|
|
|
+
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ strategyCode: '',
|
|
|
+ strategyData: {},
|
|
|
+ triggers: [],
|
|
|
+ steps: [],
|
|
|
+ contextVariables: [],
|
|
|
+
|
|
|
+ editorMode: 'design',
|
|
|
+ zoom: 1,
|
|
|
+ selectedNode: null,
|
|
|
+ selectedNodeType: null,
|
|
|
+ showStrategyConfig: false,
|
|
|
+
|
|
|
+ history: [],
|
|
|
+ historyIndex: -1,
|
|
|
+
|
|
|
+ activePanels: ['triggers', 'devices', 'flow'],
|
|
|
+ deviceKeyword: '',
|
|
|
+
|
|
|
+ deviceList: [],
|
|
|
+ deviceTreeData: [],
|
|
|
+
|
|
|
+ addStepDialogVisible: false,
|
|
|
+ addStepPosition: -1,
|
|
|
+ addVarDialogVisible: false,
|
|
|
+ newVariable: {},
|
|
|
+
|
|
|
+ triggerComponents: [
|
|
|
+ { type: 'EVENT', name: '事件触发', icon: 'el-icon-lightning', color: '#f56c6c' },
|
|
|
+ { type: 'ATTR', name: '属性变化', icon: 'el-icon-data-line', color: '#e6a23c' },
|
|
|
+ { type: 'TIME', name: '定时触发', icon: 'el-icon-time', color: '#409eff' },
|
|
|
+ { type: 'CONDITION', name: '条件触发', icon: 'el-icon-set-up', color: '#67c23a' }
|
|
|
+ ],
|
|
|
+
|
|
|
+ flowComponents: [
|
|
|
+ { type: 'CONDITION', name: '条件判断', icon: 'el-icon-question', color: '#e6a23c' },
|
|
|
+ { type: 'PARALLEL', name: '并行执行', icon: 'el-icon-sort', color: '#909399' },
|
|
|
+ { type: 'LOOP', name: '循环执行', icon: 'el-icon-refresh', color: '#67c23a' },
|
|
|
+ { type: 'DELAY', name: '延时等待', icon: 'el-icon-time', color: '#409eff' }
|
|
|
+ ],
|
|
|
+
|
|
|
+ stepTypes: [
|
|
|
+ { type: 'ABILITY', name: '能力调用', icon: 'el-icon-s-operation', color: '#409eff', desc: '调用设备能力执行操作' },
|
|
|
+ { type: 'DELAY', name: '延时等待', icon: 'el-icon-time', color: '#67c23a', desc: '等待指定时间后继续' },
|
|
|
+ { type: 'CONDITION', name: '条件判断', icon: 'el-icon-question', color: '#e6a23c', desc: '根据条件决定执行分支' },
|
|
|
+ { type: 'PARALLEL', name: '并行执行', icon: 'el-icon-sort', color: '#909399', desc: '同时执行多个步骤' },
|
|
|
+ { type: 'LOOP', name: '循环执行', icon: 'el-icon-refresh', color: '#f56c6c', desc: '重复执行指定步骤' }
|
|
|
+ ],
|
|
|
+
|
|
|
+ sceneTypes: [
|
|
|
+ { value: 'PV_ESS', label: '光储协同' },
|
|
|
+ { value: 'DEMAND_RESP', label: '需求响应' },
|
|
|
+ { value: 'PEAK_VALLEY', label: '削峰填谷' },
|
|
|
+ { value: 'EMERGENCY', label: '应急保供' },
|
|
|
+ { value: 'ENERGY_SAVE', label: '节能优化' }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+ },
|
|
|
+
|
|
|
+ computed: {
|
|
|
+ canUndo() { return this.historyIndex > 0; },
|
|
|
+ canRedo() { return this.historyIndex < this.history.length - 1; },
|
|
|
+ propertyPanelTitle() {
|
|
|
+ if (this.showStrategyConfig) return '策略配置';
|
|
|
+ if (this.selectedNodeType === 'trigger') return '触发器配置';
|
|
|
+ if (this.selectedNodeType === 'step') return '步骤配置';
|
|
|
+ return '属性';
|
|
|
+ },
|
|
|
+ // 获取返回的基础路径(去掉 /editor/:strategyCode 部分)
|
|
|
+ parentPath() {
|
|
|
+ const path = this.$route.path;
|
|
|
+ // 移除 /editor/xxx 部分
|
|
|
+ const idx = path.indexOf('/editor/');
|
|
|
+ if (idx > -1) {
|
|
|
+ return path.substring(0, idx);
|
|
|
+ }
|
|
|
+ // 兜底:使用 matched 路由
|
|
|
+ const matched = this.$route.matched;
|
|
|
+ if (matched.length >= 2) {
|
|
|
+ return matched[matched.length - 2].path;
|
|
|
+ }
|
|
|
+ return '/mgr/strategy';
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ watch: {
|
|
|
+ deviceKeyword(val) {
|
|
|
+ this.$refs.deviceTree && this.$refs.deviceTree.filter(val);
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ created() {
|
|
|
+ // 调试:打印路由信息
|
|
|
+ console.log('=== 编排页面路由调试 ===');
|
|
|
+ console.log('完整路径:', this.$route.path);
|
|
|
+ console.log('fullPath:', this.$route.fullPath);
|
|
|
+ console.log('params:', JSON.stringify(this.$route.params));
|
|
|
+ console.log('query:', JSON.stringify(this.$route.query));
|
|
|
+
|
|
|
+ // 同时支持 params 和 query
|
|
|
+ this.strategyCode = this.$route.params.strategyCode || this.$route.query.strategyCode;
|
|
|
+ console.log('解析到的 strategyCode:', this.strategyCode);
|
|
|
+
|
|
|
+ if (this.strategyCode) {
|
|
|
+ this.loadStrategy();
|
|
|
+ this.loadTriggers();
|
|
|
+ this.loadSteps();
|
|
|
+ } else {
|
|
|
+ this.$message.warning('缺少策略代码参数');
|
|
|
+ setTimeout(() => {
|
|
|
+ this.goBack();
|
|
|
+ }, 1500);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.loadDevices();
|
|
|
+ },
|
|
|
+
|
|
|
+ methods: {
|
|
|
+ // 加载策略数据
|
|
|
+ async loadStrategy() {
|
|
|
+ try {
|
|
|
+ // 优先通过code查询
|
|
|
+ let response;
|
|
|
+ try {
|
|
|
+ response = await getEnergyStrategyByCode(this.strategyCode);
|
|
|
+ } catch {
|
|
|
+ // 如果接口不存在,尝试用列表接口查询
|
|
|
+ const listRes = await listEnergyStrategy({ strategyCode: this.strategyCode, pageSize: 1 });
|
|
|
+ if (listRes.rows && listRes.rows.length > 0) {
|
|
|
+ response = { data: listRes.rows[0] };
|
|
|
+ }
|
|
|
+ }
|
|
|
+ this.strategyData = response?.data || {};
|
|
|
+ } catch (error) {
|
|
|
+ console.error('加载策略失败', error);
|
|
|
+ this.$message.error('加载策略失败');
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ async loadTriggers() {
|
|
|
+ try {
|
|
|
+ const response = await getTriggers(this.strategyCode);
|
|
|
+ this.triggers = response.data || response.rows || [];
|
|
|
+ } catch (error) {
|
|
|
+ console.error('加载触发器失败', error);
|
|
|
+ this.triggers = [];
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ async loadSteps() {
|
|
|
+ try {
|
|
|
+ const response = await getStrategySteps(this.strategyCode);
|
|
|
+ this.steps = (response.data || response.rows || []).sort((a, b) => a.stepIndex - b.stepIndex);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('加载步骤失败', error);
|
|
|
+ this.steps = [];
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ async loadContextVariables() {
|
|
|
+ try {
|
|
|
+ const response = await getStrategyContext(this.strategyCode);
|
|
|
+ this.contextVariables = response.data || response.rows || [];
|
|
|
+ } catch (error) {
|
|
|
+ console.error('加载上下文变量失败', error);
|
|
|
+ this.contextVariables = [];
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ async loadDevices() {
|
|
|
+ try {
|
|
|
+ const response = await listDevRecursionByArea({ pageNum: 1, pageSize: 1000 });
|
|
|
+ this.deviceList = response.rows || [];
|
|
|
+ this.buildDeviceTree();
|
|
|
+ } catch (error) {
|
|
|
+ console.error('加载设备失败', error);
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ buildDeviceTree() {
|
|
|
+ const categoryMap = {
|
|
|
+ E: { label: '能源生产系统', icon: 'el-icon-sunny', children: [] },
|
|
|
+ C: { label: '存储系统', icon: 'el-icon-coin', children: [] },
|
|
|
+ W: { label: '传输系统', icon: 'el-icon-connection', children: [] },
|
|
|
+ Z: { label: '用能系统', icon: 'el-icon-office-building', children: [] }
|
|
|
+ };
|
|
|
+
|
|
|
+ this.deviceList.forEach(device => {
|
|
|
+ const cat = device.deviceCategory || 'Z';
|
|
|
+ const node = {
|
|
|
+ id: device.deviceCode,
|
|
|
+ label: device.deviceName,
|
|
|
+ type: 'device',
|
|
|
+ data: device,
|
|
|
+ children: []
|
|
|
+ };
|
|
|
+ if (categoryMap[cat]) {
|
|
|
+ categoryMap[cat].children.push(node);
|
|
|
+ } else {
|
|
|
+ categoryMap['Z'].children.push(node);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ this.deviceTreeData = Object.entries(categoryMap)
|
|
|
+ .filter(([_, v]) => v.children.length > 0)
|
|
|
+ .map(([code, cat]) => ({
|
|
|
+ id: code,
|
|
|
+ label: cat.label,
|
|
|
+ icon: cat.icon,
|
|
|
+ type: 'category',
|
|
|
+ children: cat.children
|
|
|
+ }));
|
|
|
+ },
|
|
|
+
|
|
|
+ filterDeviceNode(value, data) {
|
|
|
+ if (!value) return true;
|
|
|
+ return data.label.toLowerCase().indexOf(value.toLowerCase()) !== -1;
|
|
|
+ },
|
|
|
+
|
|
|
+ async handleDeviceNodeClick(data, node) {
|
|
|
+ if (data.type === 'device' && data.children.length === 0) {
|
|
|
+ try {
|
|
|
+ const response = await listAbility({ modelCode: data.data.modelCode });
|
|
|
+ const abilities = response.data || response.rows || [];
|
|
|
+ data.children = abilities.map(ab => ({
|
|
|
+ id: data.id + '-' + ab.abilityKey,
|
|
|
+ label: ab.abilityName,
|
|
|
+ type: 'ability',
|
|
|
+ data: { ...ab, deviceCode: data.id, deviceName: data.label }
|
|
|
+ }));
|
|
|
+ this.$set(node, 'expanded', true);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('加载能力失败', error);
|
|
|
+ }
|
|
|
+ } else if (data.type === 'ability') {
|
|
|
+ this.addAbilityStep(data.data);
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ addAbilityStep(abilityData) {
|
|
|
+ const newStep = {
|
|
|
+ strategyCode: this.strategyCode,
|
|
|
+ stepCode: 'STEP_' + Date.now(),
|
|
|
+ stepName: abilityData.abilityName,
|
|
|
+ stepType: 'ABILITY',
|
|
|
+ stepIndex: this.steps.length + 1,
|
|
|
+ targetObjType: 2,
|
|
|
+ targetObjCode: abilityData.deviceCode,
|
|
|
+ targetModelCode: abilityData.modelCode,
|
|
|
+ abilityKey: abilityData.abilityKey,
|
|
|
+ paramSource: 'STATIC',
|
|
|
+ enable: 1
|
|
|
+ };
|
|
|
+ this.steps.push(newStep);
|
|
|
+ this.selectNode(newStep);
|
|
|
+ this.selectedNodeType = 'step';
|
|
|
+ this.saveHistory();
|
|
|
+ },
|
|
|
+
|
|
|
+ handleDragStart(e, category, component) {
|
|
|
+ e.dataTransfer.setData('category', category);
|
|
|
+ e.dataTransfer.setData('component', JSON.stringify(component));
|
|
|
+ },
|
|
|
+
|
|
|
+ handleCanvasDrop(e) {
|
|
|
+ const category = e.dataTransfer.getData('category');
|
|
|
+ const component = JSON.parse(e.dataTransfer.getData('component') || '{}');
|
|
|
+ if (category === 'trigger') {
|
|
|
+ this.addTrigger(component);
|
|
|
+ } else if (category === 'flow') {
|
|
|
+ this.addFlowStep(component);
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ handleStepDragStart(e, step, index) {
|
|
|
+ e.dataTransfer.setData('stepIndex', index.toString());
|
|
|
+ },
|
|
|
+
|
|
|
+ handleStepDrop(e, targetIndex) {
|
|
|
+ const sourceIndex = parseInt(e.dataTransfer.getData('stepIndex'));
|
|
|
+ if (sourceIndex === targetIndex) return;
|
|
|
+ const [movedStep] = this.steps.splice(sourceIndex, 1);
|
|
|
+ this.steps.splice(targetIndex, 0, movedStep);
|
|
|
+ this.steps.forEach((step, idx) => { step.stepIndex = idx + 1; });
|
|
|
+ this.saveHistory();
|
|
|
+ },
|
|
|
+
|
|
|
+ addTrigger(component) {
|
|
|
+ const newTrigger = {
|
|
|
+ strategyCode: this.strategyCode,
|
|
|
+ triggerName: component.name,
|
|
|
+ triggerType: component.type,
|
|
|
+ sourceObjType: 2,
|
|
|
+ priority: 50,
|
|
|
+ enable: 1
|
|
|
+ };
|
|
|
+ this.triggers.push(newTrigger);
|
|
|
+ this.selectNode(newTrigger);
|
|
|
+ this.selectedNodeType = 'trigger';
|
|
|
+ this.saveHistory();
|
|
|
+ },
|
|
|
+
|
|
|
+ addFlowStep(component) {
|
|
|
+ const newStep = {
|
|
|
+ strategyCode: this.strategyCode,
|
|
|
+ stepCode: 'STEP_' + Date.now(),
|
|
|
+ stepName: component.name,
|
|
|
+ stepType: component.type,
|
|
|
+ stepIndex: this.steps.length + 1,
|
|
|
+ enable: 1
|
|
|
+ };
|
|
|
+ if (component.type === 'DELAY') {
|
|
|
+ newStep.delaySeconds = 10;
|
|
|
+ }
|
|
|
+ this.steps.push(newStep);
|
|
|
+ this.selectNode(newStep);
|
|
|
+ this.selectedNodeType = 'step';
|
|
|
+ this.saveHistory();
|
|
|
+ },
|
|
|
+
|
|
|
+ addStepAfter(index) {
|
|
|
+ this.addStepPosition = index + 1;
|
|
|
+ this.addStepDialogVisible = true;
|
|
|
+ },
|
|
|
+
|
|
|
+ addStepAtEnd() {
|
|
|
+ this.addStepPosition = this.steps.length;
|
|
|
+ this.addStepDialogVisible = true;
|
|
|
+ },
|
|
|
+
|
|
|
+ confirmAddStep(stepType) {
|
|
|
+ const newStep = {
|
|
|
+ strategyCode: this.strategyCode,
|
|
|
+ stepCode: 'STEP_' + Date.now(),
|
|
|
+ stepName: this.getStepTypeName(stepType),
|
|
|
+ stepType: stepType,
|
|
|
+ stepIndex: this.addStepPosition + 1,
|
|
|
+ enable: 1
|
|
|
+ };
|
|
|
+ if (stepType === 'DELAY') {
|
|
|
+ newStep.delaySeconds = 10;
|
|
|
+ } else if (stepType === 'ABILITY') {
|
|
|
+ newStep.targetObjType = 2;
|
|
|
+ newStep.paramSource = 'STATIC';
|
|
|
+ }
|
|
|
+ this.steps.splice(this.addStepPosition, 0, newStep);
|
|
|
+ for (let i = this.addStepPosition + 1; i < this.steps.length; i++) {
|
|
|
+ this.steps[i].stepIndex = i + 1;
|
|
|
+ }
|
|
|
+ this.addStepDialogVisible = false;
|
|
|
+ this.selectNode(newStep);
|
|
|
+ this.selectedNodeType = 'step';
|
|
|
+ this.saveHistory();
|
|
|
+ },
|
|
|
+
|
|
|
+ copyStep(step) {
|
|
|
+ const newStep = {
|
|
|
+ ...step,
|
|
|
+ id: null,
|
|
|
+ stepCode: 'STEP_' + Date.now(),
|
|
|
+ stepName: step.stepName + '_副本',
|
|
|
+ stepIndex: step.stepIndex + 1
|
|
|
+ };
|
|
|
+ const index = this.steps.findIndex(s => s === step);
|
|
|
+ this.steps.splice(index + 1, 0, newStep);
|
|
|
+ for (let i = index + 2; i < this.steps.length; i++) {
|
|
|
+ this.steps[i].stepIndex = i + 1;
|
|
|
+ }
|
|
|
+ this.saveHistory();
|
|
|
+ },
|
|
|
+
|
|
|
+ selectNode(node) {
|
|
|
+ this.selectedNode = node;
|
|
|
+ this.showStrategyConfig = false;
|
|
|
+ },
|
|
|
+
|
|
|
+ editNode(node, type) {
|
|
|
+ this.selectedNode = node;
|
|
|
+ this.selectedNodeType = type;
|
|
|
+ this.showStrategyConfig = false;
|
|
|
+ },
|
|
|
+
|
|
|
+ deleteNode(node, type) {
|
|
|
+ this.$confirm('确认删除该节点吗?', '提示', { type: 'warning' }).then(() => {
|
|
|
+ if (type === 'trigger') {
|
|
|
+ const index = this.triggers.indexOf(node);
|
|
|
+ if (index > -1) {
|
|
|
+ if (node.id) deleteTrigger(node.id).catch(() => {});
|
|
|
+ this.triggers.splice(index, 1);
|
|
|
+ }
|
|
|
+ } else if (type === 'step') {
|
|
|
+ const index = this.steps.indexOf(node);
|
|
|
+ if (index > -1) {
|
|
|
+ if (node.id) deleteStrategyStep(node.id).catch(() => {});
|
|
|
+ this.steps.splice(index, 1);
|
|
|
+ for (let i = index; i < this.steps.length; i++) {
|
|
|
+ this.steps[i].stepIndex = i + 1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ this.selectedNode = null;
|
|
|
+ this.saveHistory();
|
|
|
+ }).catch(() => {});
|
|
|
+ },
|
|
|
+
|
|
|
+ closePropertyPanel() {
|
|
|
+ this.selectedNode = null;
|
|
|
+ this.showStrategyConfig = false;
|
|
|
+ },
|
|
|
+
|
|
|
+ handleTriggerChange(trigger) {
|
|
|
+ const index = this.triggers.findIndex(t => t === this.selectedNode);
|
|
|
+ if (index > -1) {
|
|
|
+ this.$set(this.triggers, index, trigger);
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ handleStepChange(step) {
|
|
|
+ const index = this.steps.findIndex(s => s === this.selectedNode);
|
|
|
+ if (index > -1) {
|
|
|
+ this.$set(this.steps, index, step);
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ showAddVariable() {
|
|
|
+ this.newVariable = {
|
|
|
+ strategyCode: this.strategyCode,
|
|
|
+ varKey: '',
|
|
|
+ varName: '',
|
|
|
+ varType: 'TEMP',
|
|
|
+ dataType: 'STRING',
|
|
|
+ defaultValue: '',
|
|
|
+ valueSource: ''
|
|
|
+ };
|
|
|
+ this.addVarDialogVisible = true;
|
|
|
+ },
|
|
|
+
|
|
|
+ confirmAddVariable() {
|
|
|
+ if (!this.newVariable.varKey || !this.newVariable.varName) {
|
|
|
+ this.$message.warning('请填写变量键和名称');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.contextVariables.push({ ...this.newVariable });
|
|
|
+ this.addVarDialogVisible = false;
|
|
|
+ },
|
|
|
+
|
|
|
+ zoomIn() { this.zoom = Math.min(2, this.zoom + 0.1); },
|
|
|
+ zoomOut() { this.zoom = Math.max(0.5, this.zoom - 0.1); },
|
|
|
+ autoLayout() { this.$message.info('自动布局功能开发中'); },
|
|
|
+
|
|
|
+ saveHistory() {
|
|
|
+ const snapshot = {
|
|
|
+ triggers: JSON.parse(JSON.stringify(this.triggers)),
|
|
|
+ steps: JSON.parse(JSON.stringify(this.steps))
|
|
|
+ };
|
|
|
+ this.history = this.history.slice(0, this.historyIndex + 1);
|
|
|
+ this.history.push(snapshot);
|
|
|
+ this.historyIndex = this.history.length - 1;
|
|
|
+ },
|
|
|
+
|
|
|
+ handleUndo() {
|
|
|
+ if (!this.canUndo) return;
|
|
|
+ this.historyIndex--;
|
|
|
+ this.restoreFromHistory();
|
|
|
+ },
|
|
|
+
|
|
|
+ handleRedo() {
|
|
|
+ if (!this.canRedo) return;
|
|
|
+ this.historyIndex++;
|
|
|
+ this.restoreFromHistory();
|
|
|
+ },
|
|
|
+
|
|
|
+ restoreFromHistory() {
|
|
|
+ const snapshot = this.history[this.historyIndex];
|
|
|
+ this.triggers = JSON.parse(JSON.stringify(snapshot.triggers));
|
|
|
+ this.steps = JSON.parse(JSON.stringify(snapshot.steps));
|
|
|
+ },
|
|
|
+
|
|
|
+ async handleSave() {
|
|
|
+ try {
|
|
|
+ // 保存策略基础信息
|
|
|
+ await updateEnergyStrategy(this.strategyData);
|
|
|
+ // 保存触发器
|
|
|
+ for (const trigger of this.triggers) {
|
|
|
+ await saveTrigger(trigger);
|
|
|
+ }
|
|
|
+ // 批量保存步骤
|
|
|
+ if (this.steps.length > 0) {
|
|
|
+ await saveStrategyStepBatch(this.steps);
|
|
|
+ }
|
|
|
+ this.$message.success('保存成功');
|
|
|
+ } catch (error) {
|
|
|
+ this.$message.error('保存失败: ' + (error.message || '未知错误'));
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ handleTestRun() {
|
|
|
+ this.$confirm('确认执行测试吗?', '提示').then(() => {
|
|
|
+ executeStrategy(this.strategyCode, { testMode: true }).then(response => {
|
|
|
+ const result = response.data || response;
|
|
|
+ this.$message.success('测试执行已启动,执行ID: ' + (result.execId || ''));
|
|
|
+ this.editorMode = 'debug';
|
|
|
+ }).catch(error => {
|
|
|
+ this.$message.error('执行失败: ' + (error.msg || error.message || ''));
|
|
|
+ });
|
|
|
+ }).catch(() => {});
|
|
|
+ },
|
|
|
+
|
|
|
+ // 返回上级页面
|
|
|
+ goBack() {
|
|
|
+ this.$router.push('/strategy-mgr/strategy-index');
|
|
|
+ },
|
|
|
+
|
|
|
+ // 辅助方法
|
|
|
+ getSceneTagType(scene) {
|
|
|
+ const map = { 'PV_ESS': 'warning', 'DEMAND_RESP': '', 'PEAK_VALLEY': 'success', 'EMERGENCY': 'danger', 'ENERGY_SAVE': 'info' };
|
|
|
+ return map[scene] || '';
|
|
|
+ },
|
|
|
+ getSceneLabel(scene) {
|
|
|
+ const found = this.sceneTypes.find(s => s.value === scene);
|
|
|
+ return found ? found.label : scene || '未分类';
|
|
|
+ },
|
|
|
+ getTriggerColor(type) {
|
|
|
+ const map = { 'EVENT': '#f56c6c', 'ATTR': '#e6a23c', 'TIME': '#409eff', 'CONDITION': '#67c23a' };
|
|
|
+ return map[type] || '#909399';
|
|
|
+ },
|
|
|
+ getTriggerIcon(type) {
|
|
|
+ const map = { 'EVENT': 'el-icon-lightning', 'ATTR': 'el-icon-data-line', 'TIME': 'el-icon-time', 'CONDITION': 'el-icon-set-up' };
|
|
|
+ return map[type] || 'el-icon-question';
|
|
|
+ },
|
|
|
+ getTriggerDesc(trigger) {
|
|
|
+ if (trigger.triggerType === 'EVENT') return `监听 ${trigger.sourceObjCode || '?'} 的 ${trigger.eventKey || '?'} 事件`;
|
|
|
+ if (trigger.triggerType === 'ATTR') return `监控 ${trigger.sourceObjCode || '?'} 的 ${trigger.attrKey || '?'} 属性变化`;
|
|
|
+ if (trigger.triggerType === 'TIME') return `定时执行`;
|
|
|
+ if (trigger.triggerType === 'CONDITION') return `条件满足时触发`;
|
|
|
+ return '-';
|
|
|
+ },
|
|
|
+ getStepColor(type) {
|
|
|
+ const map = { 'ABILITY': '#409eff', 'DELAY': '#67c23a', 'CONDITION': '#e6a23c', 'PARALLEL': '#909399', 'LOOP': '#f56c6c' };
|
|
|
+ return map[type] || '#909399';
|
|
|
+ },
|
|
|
+ getStepIcon(type) {
|
|
|
+ const map = { 'ABILITY': 'el-icon-s-operation', 'DELAY': 'el-icon-time', 'CONDITION': 'el-icon-question', 'PARALLEL': 'el-icon-sort', 'LOOP': 'el-icon-refresh' };
|
|
|
+ return map[type] || 'el-icon-tickets';
|
|
|
+ },
|
|
|
+ getStepTypeName(type) {
|
|
|
+ const map = { 'ABILITY': '能力调用', 'DELAY': '延时等待', 'CONDITION': '条件判断', 'PARALLEL': '并行执行', 'LOOP': '循环执行' };
|
|
|
+ return map[type] || type;
|
|
|
+ },
|
|
|
+ getStepNodeClass(step) { return 'step-' + (step.stepType || 'default').toLowerCase(); },
|
|
|
+ getConditionPreview(expr) {
|
|
|
+ if (!expr) return '未设置条件';
|
|
|
+ try {
|
|
|
+ const obj = JSON.parse(expr);
|
|
|
+ return `${obj.left} ${obj.op} ${obj.right}`;
|
|
|
+ } catch { return expr; }
|
|
|
+ },
|
|
|
+ getDeviceTreeIcon(data) {
|
|
|
+ if (data.type === 'category') return data.icon || 'el-icon-folder';
|
|
|
+ if (data.type === 'device') return 'el-icon-cpu';
|
|
|
+ if (data.type === 'ability') return 'el-icon-s-operation';
|
|
|
+ return 'el-icon-document';
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+/* 样式与原文件保持一致,此处省略大部分样式 */
|
|
|
+.strategy-editor {
|
|
|
+ height: 100vh;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ background: #f0f2f5;
|
|
|
+}
|
|
|
+
|
|
|
+.editor-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 12px 20px;
|
|
|
+ background: #fff;
|
|
|
+ border-bottom: 1px solid #e4e7ed;
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
|
|
+
|
|
|
+ .header-left {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ .strategy-info {
|
|
|
+ margin-left: 16px;
|
|
|
+ .strategy-name {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 600;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ }
|
|
|
+ .strategy-code { font-size: 12px; color: #909399; }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .header-right { display: flex; align-items: center; gap: 8px; }
|
|
|
+}
|
|
|
+
|
|
|
+.editor-main {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.component-panel {
|
|
|
+ width: 260px;
|
|
|
+ background: #fff;
|
|
|
+ border-right: 1px solid #e4e7ed;
|
|
|
+ overflow-y: auto;
|
|
|
+
|
|
|
+ .component-list {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(2, 1fr);
|
|
|
+ gap: 8px;
|
|
|
+ padding: 8px;
|
|
|
+
|
|
|
+ .component-item {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ padding: 12px 8px;
|
|
|
+ background: #f5f7fa;
|
|
|
+ border-radius: 8px;
|
|
|
+ cursor: grab;
|
|
|
+ transition: all 0.2s;
|
|
|
+ &:hover { background: #ecf5ff; transform: translateY(-2px); }
|
|
|
+ .component-icon {
|
|
|
+ width: 36px; height: 36px; border-radius: 8px;
|
|
|
+ display: flex; align-items: center; justify-content: center; margin-bottom: 6px;
|
|
|
+ i { color: #fff; font-size: 18px; }
|
|
|
+ }
|
|
|
+ .component-name { font-size: 12px; color: #606266; }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .device-search { padding: 8px; }
|
|
|
+ .device-tree {
|
|
|
+ max-height: 300px;
|
|
|
+ overflow-y: auto;
|
|
|
+ .device-tree-node { display: flex; align-items: center; gap: 6px; i { color: #909399; } }
|
|
|
+ }
|
|
|
+
|
|
|
+ .context-vars {
|
|
|
+ padding: 8px;
|
|
|
+ .context-var-item {
|
|
|
+ display: flex; justify-content: space-between;
|
|
|
+ padding: 8px 12px; background: #f5f7fa; border-radius: 6px; margin-bottom: 6px; cursor: grab;
|
|
|
+ .var-name { font-size: 13px; color: #303133; }
|
|
|
+ .var-type { font-size: 12px; color: #909399; }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.canvas-panel {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ background: #fafafa;
|
|
|
+ position: relative;
|
|
|
+
|
|
|
+ .canvas-toolbar {
|
|
|
+ position: absolute; top: 16px; left: 16px; z-index: 10;
|
|
|
+ display: flex; gap: 12px; padding: 8px;
|
|
|
+ background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
|
+ }
|
|
|
+
|
|
|
+ .canvas-container { flex: 1; overflow: auto; padding: 80px 40px 40px; }
|
|
|
+
|
|
|
+ .canvas-content {
|
|
|
+ display: flex; flex-direction: column; align-items: center; gap: 16px;
|
|
|
+ transform-origin: top center; min-width: 400px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.flow-node {
|
|
|
+ background: #fff; border-radius: 10px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
|
|
+ transition: all 0.2s; position: relative;
|
|
|
+ &:hover { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); }
|
|
|
+ &.selected { box-shadow: 0 0 0 2px #409eff, 0 4px 16px rgba(64, 158, 255, 0.3); }
|
|
|
+}
|
|
|
+
|
|
|
+.start-node, .end-node {
|
|
|
+ display: flex; align-items: center; gap: 8px; padding: 12px 24px;
|
|
|
+ .node-icon {
|
|
|
+ width: 32px; height: 32px; border-radius: 50%;
|
|
|
+ display: flex; align-items: center; justify-content: center;
|
|
|
+ }
|
|
|
+ span { font-size: 14px; font-weight: 500; color: #606266; }
|
|
|
+}
|
|
|
+.start-node .node-icon { background: #67c23a; i { color: #fff; } }
|
|
|
+.end-node .node-icon { background: #909399; i { color: #fff; } }
|
|
|
+
|
|
|
+.trigger-node, .step-node {
|
|
|
+ width: 280px;
|
|
|
+ .node-header {
|
|
|
+ padding: 10px 14px; border-radius: 10px 10px 0 0;
|
|
|
+ display: flex; align-items: center; gap: 8px;
|
|
|
+ i, span { color: #fff; } span { font-weight: 500; }
|
|
|
+ }
|
|
|
+ .node-body { padding: 12px 14px; p { margin: 0 0 6px; font-size: 13px; color: #606266; display: flex; align-items: center; gap: 6px; i { color: #909399; } &:last-child { margin-bottom: 0; } } }
|
|
|
+ .node-actions {
|
|
|
+ position: absolute; top: 8px; right: 8px; display: none; gap: 8px;
|
|
|
+ i { color: rgba(255, 255, 255, 0.8); cursor: pointer; &:hover { color: #fff; } }
|
|
|
+ }
|
|
|
+ &:hover .node-actions { display: flex; }
|
|
|
+}
|
|
|
+
|
|
|
+.step-node {
|
|
|
+ cursor: grab;
|
|
|
+ .node-index {
|
|
|
+ position: absolute; top: -8px; left: -8px;
|
|
|
+ width: 24px; height: 24px; background: #409eff; border-radius: 50%;
|
|
|
+ display: flex; align-items: center; justify-content: center;
|
|
|
+ color: #fff; font-size: 12px; font-weight: 600;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.flow-connector {
|
|
|
+ width: 2px; height: 24px; background: #dcdfe6; position: relative;
|
|
|
+ .connector-line { width: 100%; height: 100%; background: #dcdfe6; }
|
|
|
+ .connector-add {
|
|
|
+ position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
|
|
+ width: 20px; height: 20px; background: #fff; border: 2px solid #dcdfe6; border-radius: 50%;
|
|
|
+ display: flex; align-items: center; justify-content: center;
|
|
|
+ cursor: pointer; opacity: 0; transition: all 0.2s;
|
|
|
+ i { font-size: 12px; color: #909399; }
|
|
|
+ &:hover { border-color: #409eff; i { color: #409eff; } }
|
|
|
+ }
|
|
|
+ &:hover .connector-add { opacity: 1; }
|
|
|
+}
|
|
|
+
|
|
|
+.add-step-btn {
|
|
|
+ display: flex; align-items: center; gap: 8px;
|
|
|
+ padding: 12px 24px; background: #fff; border: 2px dashed #dcdfe6; border-radius: 10px;
|
|
|
+ cursor: pointer; color: #909399; transition: all 0.2s;
|
|
|
+ &:hover { border-color: #409eff; color: #409eff; }
|
|
|
+}
|
|
|
+
|
|
|
+.property-panel {
|
|
|
+ width: 320px; background: #fff; border-left: 1px solid #e4e7ed;
|
|
|
+ display: flex; flex-direction: column;
|
|
|
+ .panel-header {
|
|
|
+ display: flex; justify-content: space-between; align-items: center;
|
|
|
+ padding: 14px 16px; border-bottom: 1px solid #e4e7ed; font-weight: 600;
|
|
|
+ i { cursor: pointer; color: #909399; &:hover { color: #606266; } }
|
|
|
+ }
|
|
|
+ .panel-body { flex: 1; overflow-y: auto; padding: 16px; }
|
|
|
+}
|
|
|
+
|
|
|
+.step-type-grid {
|
|
|
+ display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px;
|
|
|
+ .step-type-card {
|
|
|
+ display: flex; align-items: center; gap: 12px;
|
|
|
+ padding: 16px; background: #f5f7fa; border-radius: 10px;
|
|
|
+ cursor: pointer; transition: all 0.2s;
|
|
|
+ &:hover { background: #ecf5ff; transform: translateY(-2px); }
|
|
|
+ .step-type-icon {
|
|
|
+ width: 44px; height: 44px; border-radius: 10px;
|
|
|
+ display: flex; align-items: center; justify-content: center;
|
|
|
+ i { color: #fff; font-size: 20px; }
|
|
|
+ }
|
|
|
+ .step-type-info {
|
|
|
+ flex: 1;
|
|
|
+ .step-type-name { display: block; font-weight: 600; color: #303133; margin-bottom: 4px; }
|
|
|
+ .step-type-desc { display: block; font-size: 12px; color: #909399; }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.form-tip { font-size: 12px; color: #909399; margin-top: 4px; }
|
|
|
+</style>
|