|
@@ -0,0 +1,1388 @@
|
|
|
+<template>
|
|
|
+ <div class="property-manager-container">
|
|
|
+ <!-- 页面标题与导航 -->
|
|
|
+ <el-page-header
|
|
|
+ @back="handleBack"
|
|
|
+ content="设备属性管理"
|
|
|
+ class="page-header"
|
|
|
+ >
|
|
|
+ <template #extra>
|
|
|
+ <el-breadcrumb separator="/" class="breadcrumb">
|
|
|
+ <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
|
|
|
+ <el-breadcrumb-item :to="{ path: '/devices' }">设备管理</el-breadcrumb-item>
|
|
|
+ <el-breadcrumb-item>属性配置</el-breadcrumb-item>
|
|
|
+ </el-breadcrumb>
|
|
|
+ </template>
|
|
|
+ </el-page-header>
|
|
|
+
|
|
|
+ <div class="main-content">
|
|
|
+ <!-- 左侧设备/分类选择面板 -->
|
|
|
+ <div class="sidebar">
|
|
|
+ <el-card class="sidebar-card">
|
|
|
+ <template #header>
|
|
|
+ <div class="sidebar-header">
|
|
|
+ <h3 class="sidebar-title">设备与分类</h3>
|
|
|
+ <el-input
|
|
|
+ v-model="searchQuery"
|
|
|
+ placeholder="搜索设备或分类..."
|
|
|
+ size="small"
|
|
|
+ class="search-input"
|
|
|
+ >
|
|
|
+ <template #prefix>
|
|
|
+ <el-icon class="search-icon"><Search /></el-icon>
|
|
|
+ </template>
|
|
|
+ </el-input>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <el-tree
|
|
|
+ ref="deviceTree"
|
|
|
+ :data="deviceTreeData"
|
|
|
+ :props="treeProps"
|
|
|
+ :filter-node-method="filterNode"
|
|
|
+ node-key="id"
|
|
|
+ default-expand-all
|
|
|
+ @node-click="handleNodeSelect"
|
|
|
+ class="device-tree"
|
|
|
+ :highlight-current="true"
|
|
|
+ >
|
|
|
+ <!-- 树形节点自定义插槽 -->
|
|
|
+ <template #default="{ node, data }">
|
|
|
+ <div class="tree-node">
|
|
|
+ <el-icon :class="data.type === 'category' ? 'category-icon' : 'device-icon'">
|
|
|
+ <template v-if="data.type === 'category'">
|
|
|
+ <FolderOpened />
|
|
|
+ </template>
|
|
|
+ <template v-else>
|
|
|
+ <Monitor />
|
|
|
+ </template>
|
|
|
+ </el-icon>
|
|
|
+ <span class="node-label">{{ node.label }}</span>
|
|
|
+ <el-badge
|
|
|
+ v-if="data.propertyCount"
|
|
|
+ :value="data.propertyCount"
|
|
|
+ class="property-count-badge"
|
|
|
+ size="small"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-tree>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 右侧属性配置区域 -->
|
|
|
+ <div class="property-config-area">
|
|
|
+ <!-- 选中项信息与操作 -->
|
|
|
+ <div v-if="selectedNode" class="selected-info-bar">
|
|
|
+ <div class="selected-info">
|
|
|
+ <el-icon :class="selectedNode.type === 'category' ? 'category-icon' : 'device-icon'">
|
|
|
+ <template v-if="selectedNode.type === 'category'">
|
|
|
+ <FolderOpened />
|
|
|
+ </template>
|
|
|
+ <template v-else>
|
|
|
+ <Monitor />
|
|
|
+ </template>
|
|
|
+ </el-icon>
|
|
|
+ <h2 class="selected-name">{{ selectedNode.label }}</h2>
|
|
|
+ <el-tag :type="selectedNode.type === 'category' ? 'info' : 'primary'" class="node-type-tag">
|
|
|
+ {{ selectedNode.type === 'category' ? '分类' : '设备' }}
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="action-buttons">
|
|
|
+ <el-button
|
|
|
+ type="primary"
|
|
|
+ @click="showAddPropertyDialog"
|
|
|
+ class="add-property-btn"
|
|
|
+ size="default"
|
|
|
+ >
|
|
|
+ <template #icon>
|
|
|
+ <Plus />
|
|
|
+ </template>
|
|
|
+ 添加属性
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ type="success"
|
|
|
+ @click="saveAllProperties"
|
|
|
+ :loading="saveLoading"
|
|
|
+ size="default"
|
|
|
+ >
|
|
|
+ <template #icon>
|
|
|
+ <Check />
|
|
|
+ </template>
|
|
|
+ 保存配置
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 未选择任何项时的空状态 -->
|
|
|
+ <div v-else class="empty-state">
|
|
|
+ <el-empty
|
|
|
+ description="请从左侧选择一个设备或分类"
|
|
|
+ class="main-empty-state"
|
|
|
+ >
|
|
|
+ <template #image>
|
|
|
+ <div class="empty-image-container">
|
|
|
+ <el-icon class="empty-icon"><Icon icon="ep:add" /></el-icon>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-empty>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 属性卡片列表 -->
|
|
|
+ <div v-if="selectedNode && properties.length > 0" class="properties-grid">
|
|
|
+ <!-- 属性卡片组件,使用v-for渲染 -->
|
|
|
+ <property-card
|
|
|
+ v-for="(property, index) in properties"
|
|
|
+ :key="property.id"
|
|
|
+ :property="property"
|
|
|
+ @update-property="handlePropertyUpdate(index, $event)"
|
|
|
+ @delete-property="handlePropertyDelete(index)"
|
|
|
+ class="property-card-item"
|
|
|
+ >
|
|
|
+ <!-- 卡片底部插槽示例 - 可以根据需要自定义内容 -->
|
|
|
+ <template #footer-actions>
|
|
|
+ <el-tooltip content="查看历史记录">
|
|
|
+ <el-button icon="Clock" size="mini" class="history-btn" />
|
|
|
+ </el-tooltip>
|
|
|
+ </template>
|
|
|
+ </property-card>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 有选中项但无属性时的空状态 -->
|
|
|
+ <div v-if="selectedNode && properties.length === 0" class="no-properties-state">
|
|
|
+ <el-empty
|
|
|
+ description="暂无属性,请点击添加属性按钮创建"
|
|
|
+ >
|
|
|
+ <template #image>
|
|
|
+ <div class="no-properties-image">
|
|
|
+ <el-icon class="no-properties-icon"><Icon icon="ep:add" /></el-icon>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <template #bottom>
|
|
|
+ <el-button
|
|
|
+ type="primary"
|
|
|
+ @click="showAddPropertyDialog"
|
|
|
+ size="default"
|
|
|
+ >
|
|
|
+ <template #icon>
|
|
|
+ <Plus />
|
|
|
+ </template>
|
|
|
+ 添加第一个属性
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ </el-empty>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 添加/编辑属性对话框 -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="propertyDialogVisible"
|
|
|
+ :title="isEditing ? '编辑属性' : '添加新属性'"
|
|
|
+ :width="500"
|
|
|
+ @close="resetPropertyForm"
|
|
|
+ class="property-dialog"
|
|
|
+ >
|
|
|
+ <el-form
|
|
|
+ ref="propertyForm"
|
|
|
+ :model="currentProperty"
|
|
|
+ :rules="propertyRules"
|
|
|
+ label-width="120px"
|
|
|
+ class="property-form"
|
|
|
+ >
|
|
|
+ <el-form-item label="属性名称" prop="name">
|
|
|
+ <el-input
|
|
|
+ v-model="currentProperty.name"
|
|
|
+ placeholder="请输入属性名称"
|
|
|
+ maxlength="50"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item label="属性标识" prop="code">
|
|
|
+ <el-input
|
|
|
+ v-model="currentProperty.code"
|
|
|
+ placeholder="请输入属性唯一标识"
|
|
|
+ maxlength="30"
|
|
|
+ :disabled="isEditing"
|
|
|
+ />
|
|
|
+ <div class="form-hint">标识用于系统内部识别,添加后不可修改</div>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item label="数据类型" prop="dataType">
|
|
|
+ <el-select
|
|
|
+ v-model="currentProperty.dataType"
|
|
|
+ placeholder="请选择数据类型"
|
|
|
+ @change="handleDataTypeChange"
|
|
|
+ >
|
|
|
+ <el-option label="整数" value="integer" />
|
|
|
+ <el-option label="浮点数" value="float" />
|
|
|
+ <el-option label="布尔值" value="boolean" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item label="单位" prop="unit">
|
|
|
+ <el-input
|
|
|
+ v-model="currentProperty.unit"
|
|
|
+ placeholder="例如:℃、%、m"
|
|
|
+ maxlength="10"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-row :gutter="16" class="limit-inputs-row">
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-form-item label="最小值" prop="minValue">
|
|
|
+ <el-input-number
|
|
|
+ v-model.number="currentProperty.minValue"
|
|
|
+ :precision="currentProperty.dataType === 'float' ? 2 : 0"
|
|
|
+ :step="currentProperty.dataType === 'float' ? 0.1 : 1"
|
|
|
+ placeholder="请输入最小值"
|
|
|
+ :disabled="currentProperty.dataType === 'boolean'"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-form-item label="最大值" prop="maxValue">
|
|
|
+ <el-input-number
|
|
|
+ v-model.number="currentProperty.maxValue"
|
|
|
+ :precision="currentProperty.dataType === 'float' ? 2 : 0"
|
|
|
+ :step="currentProperty.dataType === 'float' ? 0.1 : 1"
|
|
|
+ placeholder="请输入最大值"
|
|
|
+ :disabled="currentProperty.dataType === 'boolean'"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+
|
|
|
+ <el-form-item label="描述信息" prop="description">
|
|
|
+ <el-input
|
|
|
+ v-model="currentProperty.description"
|
|
|
+ placeholder="请输入属性描述(可选)"
|
|
|
+ type="textarea"
|
|
|
+ :rows="3"
|
|
|
+ maxlength="200"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item>
|
|
|
+ <el-checkbox v-model="currentProperty.required">设为必填属性</el-checkbox>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+
|
|
|
+ <template #footer>
|
|
|
+ <el-button @click="propertyDialogVisible = false">取消</el-button>
|
|
|
+ <el-button
|
|
|
+ type="primary"
|
|
|
+ @click="confirmPropertyAction"
|
|
|
+ :loading="dialogLoading"
|
|
|
+ >
|
|
|
+ {{ isEditing ? '更新属性' : '创建属性' }}
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, reactive, watch, nextTick, computed } from 'vue';
|
|
|
+import {
|
|
|
+ Search, FolderOpened, Monitor, Plus, Check,
|
|
|
+ Edit, Delete, InfoFilled, Clock
|
|
|
+} from '@element-plus/icons-vue';
|
|
|
+import { ElMessage, ElMessageBox, ElNotification } from 'element-plus';
|
|
|
+
|
|
|
+// 属性卡片组件(使用slot)
|
|
|
+const PropertyCard = {
|
|
|
+ props: {
|
|
|
+ property: {
|
|
|
+ type: Object,
|
|
|
+ required: true,
|
|
|
+ default: () => ({})
|
|
|
+ }
|
|
|
+ },
|
|
|
+ emits: ['update-property', 'delete-property'],
|
|
|
+ template: `
|
|
|
+ <el-card class="property-card">
|
|
|
+ <!-- 卡片头部 -->
|
|
|
+ <template #header>
|
|
|
+ <div class="property-card-header">
|
|
|
+ <div class="property-name">
|
|
|
+ <span>{{ property.name }}</span>
|
|
|
+ <el-tag
|
|
|
+ v-if="property.required"
|
|
|
+ size="mini"
|
|
|
+ type="danger"
|
|
|
+ class="required-tag"
|
|
|
+ >
|
|
|
+ 必填
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+ <div class="property-actions">
|
|
|
+ <el-tooltip content="编辑属性" placement="top">
|
|
|
+ <el-button
|
|
|
+ icon="Edit"
|
|
|
+ size="mini"
|
|
|
+ @click="$emit('update-property', { ...property })"
|
|
|
+ class="action-btn edit-btn"
|
|
|
+ />
|
|
|
+ </el-tooltip>
|
|
|
+ <el-tooltip content="删除属性" placement="top">
|
|
|
+ <el-button
|
|
|
+ icon="Delete"
|
|
|
+ size="mini"
|
|
|
+ @click="$emit('delete-property')"
|
|
|
+ class="action-btn delete-btn"
|
|
|
+ />
|
|
|
+ </el-tooltip>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <!-- 卡片内容区 -->
|
|
|
+ <div class="property-content">
|
|
|
+ <div class="property-info-item">
|
|
|
+ <span class="info-label">标识:</span>
|
|
|
+ <span class="info-value">{{ property.code }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="property-info-item">
|
|
|
+ <span class="info-label">类型:</span>
|
|
|
+ <span class="info-value">
|
|
|
+ <el-tag size="small" :type="getTypeTagType(property.dataType)">
|
|
|
+ {{ getTypeName(property.dataType) }}
|
|
|
+ </el-tag>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 上下限配置区域 -->
|
|
|
+ <div v-if="property.dataType !== 'boolean'" class="property-limits">
|
|
|
+ <div class="limits-label">数值范围:</div>
|
|
|
+ <div class="limits-inputs">
|
|
|
+ <el-input-number
|
|
|
+ v-model.number="property.minValue"
|
|
|
+ :precision="property.dataType === 'float' ? 2 : 0"
|
|
|
+ :step="property.dataType === 'float' ? 0.1 : 1"
|
|
|
+ :placeholder="'最小'"
|
|
|
+ size="small"
|
|
|
+ class="limit-input min-input"
|
|
|
+ @change="handleLimitChange"
|
|
|
+ />
|
|
|
+ <span class="limit-separator">-</span>
|
|
|
+ <el-input-number
|
|
|
+ v-model.number="property.maxValue"
|
|
|
+ :precision="property.dataType === 'float' ? 2 : 0"
|
|
|
+ :step="property.dataType === 'float' ? 0.1 : 1"
|
|
|
+ :placeholder="'最大'"
|
|
|
+ size="small"
|
|
|
+ class="limit-input max-input"
|
|
|
+ :min="property.minValue"
|
|
|
+ @change="handleLimitChange"
|
|
|
+ />
|
|
|
+ <span v-if="property.unit" class="property-unit">{{ property.unit }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 布尔值特殊处理 -->
|
|
|
+ <div v-else class="boolean-value">
|
|
|
+ <span class="info-label">值:</span>
|
|
|
+ <el-switch
|
|
|
+ v-model="property.booleanValue"
|
|
|
+ active-text="真"
|
|
|
+ inactive-text="假"
|
|
|
+ @change="handleBooleanChange"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 描述信息 -->
|
|
|
+ <div v-if="property.description" class="property-description">
|
|
|
+ <el-icon class="description-icon"><InfoFilled /></el-icon>
|
|
|
+ <span>{{ property.description }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 卡片底部,包含默认内容和插槽 -->
|
|
|
+ <template #footer>
|
|
|
+ <div class="property-card-footer">
|
|
|
+ <span class="last-updated">
|
|
|
+ 最后更新:{{ formatDate(property.updatedAt) }}
|
|
|
+ </span>
|
|
|
+ <div class="footer-actions">
|
|
|
+ <!-- 插槽:允许父组件添加额外的操作按钮 -->
|
|
|
+ <slot name="footer-actions"></slot>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-card>
|
|
|
+ `,
|
|
|
+ methods: {
|
|
|
+ // 获取数据类型显示名称
|
|
|
+ getTypeName(type) {
|
|
|
+ const types = {
|
|
|
+ 'integer': '整数',
|
|
|
+ 'float': '浮点数',
|
|
|
+ 'boolean': '布尔值'
|
|
|
+ };
|
|
|
+ return types[type] || type;
|
|
|
+ },
|
|
|
+ // 获取数据类型标签样式
|
|
|
+ getTypeTagType(type) {
|
|
|
+ const types = {
|
|
|
+ 'integer': 'primary',
|
|
|
+ 'float': 'success',
|
|
|
+ 'boolean': 'warning'
|
|
|
+ };
|
|
|
+ return types[type] || 'info';
|
|
|
+ },
|
|
|
+ // 处理上下限变化
|
|
|
+ handleLimitChange() {
|
|
|
+ this.$emit('update-property', { ...this.property });
|
|
|
+ },
|
|
|
+ // 处理布尔值变化
|
|
|
+ handleBooleanChange() {
|
|
|
+ this.$emit('update-property', { ...this.property });
|
|
|
+ },
|
|
|
+ // 格式化日期
|
|
|
+ formatDate(timestamp) {
|
|
|
+ if (!timestamp) return '未更新';
|
|
|
+ const date = new Date(timestamp);
|
|
|
+ return date.toLocaleString();
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 生成唯一ID
|
|
|
+const generateId = () => {
|
|
|
+ return Date.now().toString(36) + Math.random().toString(36).substr(2, 8);
|
|
|
+};
|
|
|
+
|
|
|
+// 设备树形结构数据
|
|
|
+const deviceTreeData = ref([
|
|
|
+ {
|
|
|
+ id: 'cat1',
|
|
|
+ label: '温度设备',
|
|
|
+ type: 'category',
|
|
|
+ propertyCount: 3,
|
|
|
+ children: [
|
|
|
+ {
|
|
|
+ id: 'dev11',
|
|
|
+ label: '室内温度计',
|
|
|
+ type: 'device',
|
|
|
+ propertyCount: 5
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 'dev12',
|
|
|
+ label: '室外温度计',
|
|
|
+ type: 'device',
|
|
|
+ propertyCount: 4
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 'cat2',
|
|
|
+ label: '湿度设备',
|
|
|
+ type: 'category',
|
|
|
+ propertyCount: 2,
|
|
|
+ children: [
|
|
|
+ {
|
|
|
+ id: 'dev21',
|
|
|
+ label: '车间湿度计',
|
|
|
+ type: 'device',
|
|
|
+ propertyCount: 3
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 'dev22',
|
|
|
+ label: '仓库湿度计',
|
|
|
+ type: 'device',
|
|
|
+ propertyCount: 3
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+]);
|
|
|
+
|
|
|
+// 树形结构配置
|
|
|
+const treeProps = {
|
|
|
+ children: 'children',
|
|
|
+ label: 'label'
|
|
|
+};
|
|
|
+
|
|
|
+// 状态管理
|
|
|
+const searchQuery = ref('');
|
|
|
+const deviceTree = ref(null);
|
|
|
+const selectedNode = ref(null);
|
|
|
+const properties = ref([]);
|
|
|
+const saveLoading = ref(false);
|
|
|
+const dialogLoading = ref(false);
|
|
|
+const propertyDialogVisible = ref(false);
|
|
|
+const isEditing = ref(false);
|
|
|
+const currentEditIndex = ref(-1);
|
|
|
+
|
|
|
+// 属性表单数据
|
|
|
+const currentProperty = reactive({
|
|
|
+ id: '',
|
|
|
+ name: '',
|
|
|
+ code: '',
|
|
|
+ dataType: 'float',
|
|
|
+ unit: '',
|
|
|
+ minValue: null,
|
|
|
+ maxValue: null,
|
|
|
+ booleanValue: false,
|
|
|
+ description: '',
|
|
|
+ required: false,
|
|
|
+ updatedAt: null
|
|
|
+});
|
|
|
+
|
|
|
+// 属性表单验证规则
|
|
|
+const propertyRules = {
|
|
|
+ name: [
|
|
|
+ { required: true, message: '请输入属性名称', trigger: 'blur' },
|
|
|
+ { max: 50, message: '属性名称不能超过50个字符', trigger: 'blur' }
|
|
|
+ ],
|
|
|
+ code: [
|
|
|
+ { required: true, message: '请输入属性标识', trigger: 'blur' },
|
|
|
+ { pattern: /^[a-zA-Z0-9_]+$/, message: '标识只能包含字母、数字和下划线', trigger: 'blur' },
|
|
|
+ { max: 30, message: '属性标识不能超过30个字符', trigger: 'blur' }
|
|
|
+ ],
|
|
|
+ dataType: [
|
|
|
+ { required: true, message: '请选择数据类型', trigger: 'change' }
|
|
|
+ ],
|
|
|
+ minValue: [
|
|
|
+ {
|
|
|
+ required: () => currentProperty.dataType !== 'boolean',
|
|
|
+ message: '请输入最小值',
|
|
|
+ trigger: 'blur'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'number',
|
|
|
+ message: '请输入有效的数字',
|
|
|
+ trigger: 'blur',
|
|
|
+ validator: (rule, value, callback) => {
|
|
|
+ if (currentProperty.dataType === 'boolean') {
|
|
|
+ return callback();
|
|
|
+ }
|
|
|
+ if (value === null || value === undefined) {
|
|
|
+ return callback(new Error('请输入最小值'));
|
|
|
+ }
|
|
|
+ if (currentProperty.maxValue !== null && value > currentProperty.maxValue) {
|
|
|
+ return callback(new Error('最小值不能大于最大值'));
|
|
|
+ }
|
|
|
+ callback();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ maxValue: [
|
|
|
+ {
|
|
|
+ required: () => currentProperty.dataType !== 'boolean',
|
|
|
+ message: '请输入最大值',
|
|
|
+ trigger: 'blur'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'number',
|
|
|
+ message: '请输入有效的数字',
|
|
|
+ trigger: 'blur',
|
|
|
+ validator: (rule, value, callback) => {
|
|
|
+ if (currentProperty.dataType === 'boolean') {
|
|
|
+ return callback();
|
|
|
+ }
|
|
|
+ if (value === null || value === undefined) {
|
|
|
+ return callback(new Error('请输入最大值'));
|
|
|
+ }
|
|
|
+ if (currentProperty.minValue !== null && value < currentProperty.minValue) {
|
|
|
+ return callback(new Error('最大值不能小于最小值'));
|
|
|
+ }
|
|
|
+ callback();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+};
|
|
|
+
|
|
|
+// 过滤节点方法
|
|
|
+const filterNode = (value, data) => {
|
|
|
+ if (!value) return true;
|
|
|
+ return data.label.toLowerCase().includes(value.toLowerCase());
|
|
|
+};
|
|
|
+
|
|
|
+// 监听搜索关键词变化
|
|
|
+watch(searchQuery, (value) => {
|
|
|
+ deviceTree.value?.filter(value);
|
|
|
+});
|
|
|
+
|
|
|
+// 处理节点选择
|
|
|
+const handleNodeSelect = (data) => {
|
|
|
+ // 检查是否有未保存的修改
|
|
|
+ if (hasUnsavedChanges.value) {
|
|
|
+ ElMessageBox.confirm(
|
|
|
+ '当前有未保存的修改,切换设备/分类将丢失这些更改,是否继续?',
|
|
|
+ '确认切换',
|
|
|
+ {
|
|
|
+ confirmButtonText: '继续',
|
|
|
+ cancelButtonText: '取消',
|
|
|
+ type: 'warning'
|
|
|
+ }
|
|
|
+ ).then(() => {
|
|
|
+ loadNodeProperties(data);
|
|
|
+ }).catch(() => {
|
|
|
+ // 取消切换,恢复之前的选择
|
|
|
+ nextTick(() => {
|
|
|
+ deviceTree.value.setCurrentKey(selectedNode.value?.id);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ loadNodeProperties(data);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 加载节点属性
|
|
|
+const loadNodeProperties = (data) => {
|
|
|
+ selectedNode.value = data;
|
|
|
+ // 模拟API加载属性数据
|
|
|
+ saveLoading.value = true;
|
|
|
+
|
|
|
+ setTimeout(() => {
|
|
|
+ // 根据不同节点加载不同的属性示例数据
|
|
|
+ if (data.id === 'dev11') { // 室内温度计
|
|
|
+ properties.value = [
|
|
|
+ {
|
|
|
+ id: generateId(),
|
|
|
+ name: '测量温度',
|
|
|
+ code: 'measure_temp',
|
|
|
+ dataType: 'float',
|
|
|
+ unit: '℃',
|
|
|
+ minValue: -10,
|
|
|
+ maxValue: 50,
|
|
|
+ required: true,
|
|
|
+ description: '设备测量的环境温度',
|
|
|
+ updatedAt: Date.now() - 86400000
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: generateId(),
|
|
|
+ name: '测量精度',
|
|
|
+ code: 'measure_precision',
|
|
|
+ dataType: 'float',
|
|
|
+ unit: '℃',
|
|
|
+ minValue: 0,
|
|
|
+ maxValue: 0.5,
|
|
|
+ required: false,
|
|
|
+ description: '温度测量的允许误差范围',
|
|
|
+ updatedAt: Date.now() - 3600000
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: generateId(),
|
|
|
+ name: '采样频率',
|
|
|
+ code: 'sample_rate',
|
|
|
+ dataType: 'integer',
|
|
|
+ unit: '次/分钟',
|
|
|
+ minValue: 1,
|
|
|
+ maxValue: 60,
|
|
|
+ required: true,
|
|
|
+ updatedAt: Date.now()
|
|
|
+ }
|
|
|
+ ];
|
|
|
+ } else if (data.id === 'cat1') { // 温度设备分类
|
|
|
+ properties.value = [
|
|
|
+ {
|
|
|
+ id: generateId(),
|
|
|
+ name: '工作温度',
|
|
|
+ code: 'working_temp',
|
|
|
+ dataType: 'float',
|
|
|
+ unit: '℃',
|
|
|
+ minValue: -20,
|
|
|
+ maxValue: 70,
|
|
|
+ required: true,
|
|
|
+ description: '设备正常工作的环境温度范围',
|
|
|
+ updatedAt: Date.now() - 86400000 * 2
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: generateId(),
|
|
|
+ name: '存储温度',
|
|
|
+ code: 'storage_temp',
|
|
|
+ dataType: 'float',
|
|
|
+ unit: '℃',
|
|
|
+ minValue: -40,
|
|
|
+ maxValue: 85,
|
|
|
+ required: true,
|
|
|
+ description: '设备存储的环境温度范围',
|
|
|
+ updatedAt: Date.now() - 86400000
|
|
|
+ }
|
|
|
+ ];
|
|
|
+ } else {
|
|
|
+ // 其他节点的默认属性
|
|
|
+ properties.value = [];
|
|
|
+ }
|
|
|
+
|
|
|
+ saveLoading.value = false;
|
|
|
+ ElNotification({
|
|
|
+ title: '已加载',
|
|
|
+ message: `已加载 ${data.label} 的 ${properties.value.length} 个属性`,
|
|
|
+ duration: 1500,
|
|
|
+ position: 'bottom-right'
|
|
|
+ });
|
|
|
+ }, 600);
|
|
|
+};
|
|
|
+
|
|
|
+// 检查是否有未保存的更改
|
|
|
+const hasUnsavedChanges = ref(false);
|
|
|
+
|
|
|
+// 监听属性变化
|
|
|
+watch(properties, (newVal) => {
|
|
|
+ hasUnsavedChanges.value = true;
|
|
|
+}, { deep: true });
|
|
|
+
|
|
|
+// 显示添加属性对话框
|
|
|
+const showAddPropertyDialog = () => {
|
|
|
+ resetPropertyForm();
|
|
|
+ isEditing.value = false;
|
|
|
+ currentProperty.id = generateId();
|
|
|
+ propertyDialogVisible.value = true;
|
|
|
+
|
|
|
+ // 自动聚焦到第一个输入框
|
|
|
+ nextTick(() => {
|
|
|
+ const firstInput = document.querySelector('.property-dialog .el-input__inner');
|
|
|
+ if (firstInput) firstInput.focus();
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 显示编辑属性对话框
|
|
|
+const showEditPropertyDialog = (index, property) => {
|
|
|
+ resetPropertyForm();
|
|
|
+ isEditing.value = true;
|
|
|
+ currentEditIndex.value = index;
|
|
|
+
|
|
|
+ // 复制属性数据到表单
|
|
|
+ Object.assign(currentProperty, { ...property });
|
|
|
+
|
|
|
+ propertyDialogVisible.value = true;
|
|
|
+ nextTick(() => {
|
|
|
+ const firstInput = document.querySelector('.property-dialog .el-input__inner');
|
|
|
+ if (firstInput) firstInput.focus();
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 重置属性表单
|
|
|
+const resetPropertyForm = () => {
|
|
|
+ currentProperty.name = '';
|
|
|
+ currentProperty.code = '';
|
|
|
+ currentProperty.dataType = 'float';
|
|
|
+ currentProperty.unit = '';
|
|
|
+ currentProperty.minValue = null;
|
|
|
+ currentProperty.maxValue = null;
|
|
|
+ currentProperty.booleanValue = false;
|
|
|
+ currentProperty.description = '';
|
|
|
+ currentProperty.required = false;
|
|
|
+
|
|
|
+ // 重置表单验证
|
|
|
+ nextTick(() => {
|
|
|
+ propertyForm.value?.clearValidate();
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 处理数据类型变化
|
|
|
+const handleDataTypeChange = (type) => {
|
|
|
+ // 根据数据类型重置相关值
|
|
|
+ if (type === 'boolean') {
|
|
|
+ currentProperty.minValue = null;
|
|
|
+ currentProperty.maxValue = null;
|
|
|
+ } else if (type === 'integer') {
|
|
|
+ currentProperty.minValue = currentProperty.minValue !== null ? Math.round(currentProperty.minValue) : 0;
|
|
|
+ currentProperty.maxValue = currentProperty.maxValue !== null ? Math.round(currentProperty.maxValue) : 100;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 确认属性操作(添加或编辑)
|
|
|
+const propertyForm = ref(null);
|
|
|
+const confirmPropertyAction = () => {
|
|
|
+ propertyForm.value.validate((valid) => {
|
|
|
+ if (valid) {
|
|
|
+ dialogLoading.value = true;
|
|
|
+
|
|
|
+ // 模拟API请求延迟
|
|
|
+ setTimeout(() => {
|
|
|
+ const newProperty = { ...currentProperty };
|
|
|
+ newProperty.updatedAt = Date.now();
|
|
|
+
|
|
|
+ if (isEditing.value) {
|
|
|
+ // 编辑现有属性
|
|
|
+ properties.value.splice(currentEditIndex.value, 1, newProperty);
|
|
|
+ ElMessage.success('属性已更新');
|
|
|
+ } else {
|
|
|
+ // 添加新属性
|
|
|
+ properties.value.push(newProperty);
|
|
|
+ // 更新节点的属性计数
|
|
|
+ if (selectedNode.value) {
|
|
|
+ selectedNode.value.propertyCount = (selectedNode.value.propertyCount || 0) + 1;
|
|
|
+ }
|
|
|
+ ElMessage.success('新属性已添加');
|
|
|
+ }
|
|
|
+
|
|
|
+ dialogLoading.value = false;
|
|
|
+ propertyDialogVisible.value = false;
|
|
|
+ }, 500);
|
|
|
+ }
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 处理属性更新
|
|
|
+const handlePropertyUpdate = (index, updatedProperty) => {
|
|
|
+ updatedProperty.updatedAt = Date.now();
|
|
|
+ properties.value.splice(index, 1, updatedProperty);
|
|
|
+};
|
|
|
+
|
|
|
+// 处理属性删除
|
|
|
+const handlePropertyDelete = (index) => {
|
|
|
+ const property = properties.value[index];
|
|
|
+ ElMessageBox.confirm(
|
|
|
+ `确定要删除属性"${property.name}"吗?此操作不可撤销。`,
|
|
|
+ '确认删除',
|
|
|
+ {
|
|
|
+ confirmButtonText: '确定',
|
|
|
+ cancelButtonText: '取消',
|
|
|
+ type: 'danger'
|
|
|
+ }
|
|
|
+ ).then(() => {
|
|
|
+ properties.value.splice(index, 1);
|
|
|
+ // 更新节点的属性计数
|
|
|
+ if (selectedNode.value) {
|
|
|
+ selectedNode.value.propertyCount = Math.max(0, (selectedNode.value.propertyCount || 0) - 1);
|
|
|
+ }
|
|
|
+ ElMessage.success('属性已删除');
|
|
|
+ }).catch(() => {
|
|
|
+ // 取消删除
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 保存所有属性配置
|
|
|
+const saveAllProperties = () => {
|
|
|
+ if (properties.value.length === 0) {
|
|
|
+ ElMessage.warning('没有可保存的属性');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ saveLoading.value = true;
|
|
|
+
|
|
|
+ // 模拟API保存
|
|
|
+ setTimeout(() => {
|
|
|
+ saveLoading.value = false;
|
|
|
+ hasUnsavedChanges.value = false;
|
|
|
+
|
|
|
+ ElNotification({
|
|
|
+ title: '保存成功',
|
|
|
+ message: `已成功保存 ${properties.value.length} 个属性配置`,
|
|
|
+ type: 'success',
|
|
|
+ duration: 2000,
|
|
|
+ position: 'bottom-right'
|
|
|
+ });
|
|
|
+ }, 800);
|
|
|
+};
|
|
|
+
|
|
|
+// 返回操作
|
|
|
+const handleBack = () => {
|
|
|
+ if (hasUnsavedChanges.value) {
|
|
|
+ ElMessageBox.confirm(
|
|
|
+ '当前有未保存的修改,离开页面将丢失这些更改,是否继续?',
|
|
|
+ '确认离开',
|
|
|
+ {
|
|
|
+ confirmButtonText: '离开',
|
|
|
+ cancelButtonText: '取消',
|
|
|
+ type: 'warning'
|
|
|
+ }
|
|
|
+ ).then(() => {
|
|
|
+ // 实际应用中这里会执行路由跳转
|
|
|
+ ElMessage.info('已返回上一页');
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ // 实际应用中这里会执行路由跳转
|
|
|
+ ElMessage.info('已返回上一页');
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 页面加载时默认选中第一个设备
|
|
|
+watch(() => deviceTreeData.value.length, (length) => {
|
|
|
+ if (length > 0 && !selectedNode.value) {
|
|
|
+ // 尝试选择第一个设备节点
|
|
|
+ const firstDevice = deviceTreeData.value[0]?.children?.[0];
|
|
|
+ if (firstDevice) {
|
|
|
+ nextTick(() => {
|
|
|
+ handleNodeSelect(firstDevice);
|
|
|
+ deviceTree.value.setCurrentKey(firstDevice.id);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+}, { immediate: true });
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+/* 全局样式 */
|
|
|
+.property-manager-container {
|
|
|
+ padding: 20px;
|
|
|
+ background-color: #f5f7fa;
|
|
|
+ min-height: 100vh;
|
|
|
+}
|
|
|
+
|
|
|
+/* 页面头部 */
|
|
|
+.page-header {
|
|
|
+ margin-bottom: 24px;
|
|
|
+ --el-page-header-text-color: #1d2129;
|
|
|
+ --el-page-header-font-size: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.breadcrumb {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #86909c;
|
|
|
+}
|
|
|
+
|
|
|
+/* 主内容区 */
|
|
|
+.main-content {
|
|
|
+ display: flex;
|
|
|
+ gap: 24px;
|
|
|
+ height: calc(100vh - 120px);
|
|
|
+}
|
|
|
+
|
|
|
+/* 左侧边栏 */
|
|
|
+.sidebar {
|
|
|
+ width: 320px;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.sidebar-card {
|
|
|
+ height: 100%;
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
|
+ border-radius: 8px;
|
|
|
+ overflow: hidden;
|
|
|
+ border: none;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+.sidebar-header {
|
|
|
+ padding: 16px;
|
|
|
+ background-color: #f7f8fa;
|
|
|
+ border-bottom: 1px solid #f0f0f0;
|
|
|
+}
|
|
|
+
|
|
|
+.sidebar-title {
|
|
|
+ margin: 0 0 12px 0;
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #1d2129;
|
|
|
+}
|
|
|
+
|
|
|
+.search-input {
|
|
|
+ width: 100%;
|
|
|
+ --el-input-bg-color: #fff;
|
|
|
+}
|
|
|
+
|
|
|
+.search-icon {
|
|
|
+ color: #86909c;
|
|
|
+}
|
|
|
+
|
|
|
+.device-tree {
|
|
|
+ flex: 1;
|
|
|
+ overflow-y: auto;
|
|
|
+ padding: 8px 0;
|
|
|
+ --el-tree-node-content-hover-bg-color: #f5f7fa;
|
|
|
+}
|
|
|
+
|
|
|
+.tree-node {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ padding: 4px 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.category-icon {
|
|
|
+ color: #409eff;
|
|
|
+ margin-right: 8px;
|
|
|
+ font-size: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.device-icon {
|
|
|
+ color: #00b42a;
|
|
|
+ margin-right: 8px;
|
|
|
+ font-size: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.node-label {
|
|
|
+ font-size: 14px;
|
|
|
+ flex: 1;
|
|
|
+ transition: color 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content .node-label {
|
|
|
+ color: #409eff;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+
|
|
|
+.property-count-badge {
|
|
|
+ background-color: #f2f3f5;
|
|
|
+ color: #86909c;
|
|
|
+ --el-badge-font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 右侧属性配置区 */
|
|
|
+.property-config-area {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+/* 选中项信息栏 */
|
|
|
+.selected-info-bar {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 16px 20px;
|
|
|
+ background-color: #fff;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.selected-info {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.selected-name {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #1d2129;
|
|
|
+}
|
|
|
+
|
|
|
+.node-type-tag {
|
|
|
+ margin-left: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.action-buttons {
|
|
|
+ display: flex;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.add-property-btn {
|
|
|
+ transition: all 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.add-property-btn:hover {
|
|
|
+ transform: translateY(-2px);
|
|
|
+ box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
|
|
|
+}
|
|
|
+
|
|
|
+/* 属性卡片网格 */
|
|
|
+.properties-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
|
|
+ gap: 20px;
|
|
|
+ padding: 4px;
|
|
|
+ overflow-y: auto;
|
|
|
+ flex: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.property-card-item {
|
|
|
+ transition: all 0.3s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.property-card-item:hover {
|
|
|
+ transform: translateY(-4px);
|
|
|
+}
|
|
|
+
|
|
|
+/* 属性卡片样式 */
|
|
|
+.property-card {
|
|
|
+ height: 100%;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
|
|
+ transition: all 0.2s;
|
|
|
+ border: none;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.property-card:hover {
|
|
|
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
|
|
+}
|
|
|
+
|
|
|
+.property-card-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 12px 16px;
|
|
|
+ background-color: #f7f8fa;
|
|
|
+ border-bottom: 1px solid #f0f0f0;
|
|
|
+}
|
|
|
+
|
|
|
+.property-name {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #1d2129;
|
|
|
+}
|
|
|
+
|
|
|
+.required-tag {
|
|
|
+ font-size: 12px;
|
|
|
+ padding: 0 4px;
|
|
|
+ height: 18px;
|
|
|
+ line-height: 18px;
|
|
|
+}
|
|
|
+
|
|
|
+.property-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.action-btn {
|
|
|
+ padding: 0 4px;
|
|
|
+ height: 24px;
|
|
|
+ border-radius: 4px;
|
|
|
+ transition: all 0.2s;
|
|
|
+}
|
|
|
+
|
|
|
+.edit-btn {
|
|
|
+ color: #409eff;
|
|
|
+ background-color: #ecf5ff;
|
|
|
+}
|
|
|
+
|
|
|
+.edit-btn:hover {
|
|
|
+ background-color: #d9ecff;
|
|
|
+}
|
|
|
+
|
|
|
+.delete-btn {
|
|
|
+ color: #f56c6c;
|
|
|
+ background-color: #fef0f0;
|
|
|
+}
|
|
|
+
|
|
|
+.delete-btn:hover {
|
|
|
+ background-color: #fee4e4;
|
|
|
+}
|
|
|
+
|
|
|
+.property-content {
|
|
|
+ padding: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.property-info-item {
|
|
|
+ margin-bottom: 12px;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.info-label {
|
|
|
+ color: #86909c;
|
|
|
+ display: inline-block;
|
|
|
+ width: 60px;
|
|
|
+}
|
|
|
+
|
|
|
+.info-value {
|
|
|
+ color: #1d2129;
|
|
|
+}
|
|
|
+
|
|
|
+.property-limits {
|
|
|
+ margin: 16px 0;
|
|
|
+ padding: 12px;
|
|
|
+ background-color: #f7f8fa;
|
|
|
+ border-radius: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.limits-label {
|
|
|
+ color: #86909c;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.limits-inputs {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.limit-input {
|
|
|
+ flex: 1;
|
|
|
+ --el-input-number-input-height: 32px;
|
|
|
+}
|
|
|
+
|
|
|
+.min-input {
|
|
|
+ --el-input-number-bg-color: #f0f7ff;
|
|
|
+}
|
|
|
+
|
|
|
+.max-input {
|
|
|
+ --el-input-number-bg-color: #f0fff4;
|
|
|
+}
|
|
|
+
|
|
|
+.limit-separator {
|
|
|
+ color: #86909c;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+
|
|
|
+.property-unit {
|
|
|
+ color: #86909c;
|
|
|
+ white-space: nowrap;
|
|
|
+ padding-left: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.boolean-value {
|
|
|
+ margin: 16px 0;
|
|
|
+ padding: 12px;
|
|
|
+ background-color: #f7f8fa;
|
|
|
+ border-radius: 6px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.property-description {
|
|
|
+ margin-top: 12px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ background-color: #f0f7ff;
|
|
|
+ border-radius: 4px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #4e5969;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.description-icon {
|
|
|
+ color: #409eff;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.property-card-footer {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 8px 16px;
|
|
|
+ background-color: #f7f8fa;
|
|
|
+ border-top: 1px solid #f0f0f0;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #86909c;
|
|
|
+}
|
|
|
+
|
|
|
+.last-updated {
|
|
|
+ flex: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.footer-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.history-btn {
|
|
|
+ color: #86909c;
|
|
|
+ background-color: #f2f3f5;
|
|
|
+ padding: 0 4px;
|
|
|
+ height: 24px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 空状态样式 */
|
|
|
+.empty-state {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background-color: #fff;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
|
+}
|
|
|
+
|
|
|
+.empty-image-container {
|
|
|
+ width: 120px;
|
|
|
+ height: 120px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background-color: #f0f7ff;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ margin-bottom: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.empty-icon {
|
|
|
+ font-size: 60px;
|
|
|
+ color: #409eff;
|
|
|
+}
|
|
|
+
|
|
|
+.no-properties-state {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background-color: #fff;
|
|
|
+ border-radius: 8px;
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
|
+ padding: 40px;
|
|
|
+}
|
|
|
+
|
|
|
+.no-properties-image {
|
|
|
+ width: 100px;
|
|
|
+ height: 100px;
|
|
|
+ margin-bottom: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.no-properties-icon {
|
|
|
+ font-size: 100px;
|
|
|
+ color: #c9cdD4;
|
|
|
+}
|
|
|
+
|
|
|
+/* 对话框样式 */
|
|
|
+.property-dialog {
|
|
|
+ --el-dialog-border-radius: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.property-form {
|
|
|
+ margin-top: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.form-hint {
|
|
|
+ margin-top: 4px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #86909c;
|
|
|
+ line-height: 1.4;
|
|
|
+}
|
|
|
+
|
|
|
+.limit-inputs-row {
|
|
|
+ margin-bottom: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 滚动条美化 */
|
|
|
+::-webkit-scrollbar {
|
|
|
+ width: 6px;
|
|
|
+ height: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+::-webkit-scrollbar-track {
|
|
|
+ background: #f1f1f1;
|
|
|
+ border-radius: 3px;
|
|
|
+}
|
|
|
+
|
|
|
+::-webkit-scrollbar-thumb {
|
|
|
+ background: #c1c1c1;
|
|
|
+ border-radius: 3px;
|
|
|
+}
|
|
|
+
|
|
|
+::-webkit-scrollbar-thumb:hover {
|
|
|
+ background: #a8a8a8;
|
|
|
+}
|
|
|
+
|
|
|
+/* 动画效果 */
|
|
|
+.el-collapse-transition {
|
|
|
+ transition: height 0.3s ease, opacity 0.3s ease;
|
|
|
+}
|
|
|
+
|
|
|
+/* 响应式调整 */
|
|
|
+@media (max-width: 1200px) {
|
|
|
+ .properties-grid {
|
|
|
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@media (max-width: 992px) {
|
|
|
+ .main-content {
|
|
|
+ flex-direction: column;
|
|
|
+ height: auto;
|
|
|
+ }
|
|
|
+
|
|
|
+ .sidebar {
|
|
|
+ width: 100%;
|
|
|
+ height: 300px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .properties-grid {
|
|
|
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|