Forráskód Böngészése

feat: 增加指标管理页面,实现指标管理的增删改查

yanglzh 1 hónapja
szülő
commit
a1962c3d83

+ 112 - 102
src/api/datahub/index.ts

@@ -10,113 +10,123 @@ import { get, post, del, put, file } from '/@/utils/request';
 
 export default {
 
-   common: {
-      getList: (params: object) => get('/source/search', params),
-      add: (data: object) => post('/source/api/add', data),
-      delete: (ids: number) => del('/source/del', { ids }),
-      edit: (data: object) => put('/source/api/edit', data),
-      detail: (sourceId: number) => get('/source/detail', { sourceId }),
-      deploy: (data: object) => post('/source/deploy', data),
-      undeploy: (data: object) => post('/source/undeploy', data),
-      api: (sourceId: number) => get('/source/api/get', { sourceId }),
-      devadd: (data: object) => post('/source/device/add', data),
-      devedit: (data: object) => put('/source/device/edit', data),
-      devapi: (sourceId: number) => get('/source/device/get', { sourceId }),
-      getdevList: (params: object) => get('/product/device/list', params),
-      getdata: (params: object) => get('/source/getdata', params),
-      getLists: (params: object) => get('/source/list', params),
-      copy: (params: object) => post('/source/copy', params),
+  common: {
+    getList: (params: object) => get('/source/search', params),
+    add: (data: object) => post('/source/api/add', data),
+    delete: (ids: number) => del('/source/del', { ids }),
+    edit: (data: object) => put('/source/api/edit', data),
+    detail: (sourceId: number) => get('/source/detail', { sourceId }),
+    deploy: (data: object) => post('/source/deploy', data),
+    undeploy: (data: object) => post('/source/undeploy', data),
+    api: (sourceId: number) => get('/source/api/get', { sourceId }),
+    devadd: (data: object) => post('/source/device/add', data),
+    devedit: (data: object) => put('/source/device/edit', data),
+    devapi: (sourceId: number) => get('/source/device/get', { sourceId }),
+    getdevList: (params: object) => get('/product/device/list', params),
+    getdata: (params: object) => get('/source/getdata', params),
+    getLists: (params: object) => get('/source/list', params),
+    copy: (params: object) => post('/source/copy', params),
 
-      dbadd: (data: object) => post('/source/db/add', data),
-      dbedit: (data: object) => put('/source/db/edit', data),
-      getfields: (sourceId: number) => get('/source/db/fields', { sourceId }),
+    dbadd: (data: object) => post('/source/db/add', data),
+    dbedit: (data: object) => put('/source/db/edit', data),
+    getfields: (sourceId: number) => get('/source/db/fields', { sourceId }),
 
-      devdb: (sourceId: number) => get('/source/db/get', { sourceId }),
+    devdb: (sourceId: number) => get('/source/db/get', { sourceId }),
 
-   },
+  },
 
-   node: {
-      getList: (params: object) => get('/source/node/list', params),
-      add: (data: object) => post('/source/node/add', data),
-      delete: (nodeId: number) => del('/source/node/del', { nodeId }),
-      edit: (data: object) => put('/source/node/edit', data),
-      getpropertyList: (params: object) => get('/product/tsl/property/all', params),
-   },
+  node: {
+    getList: (params: object) => get('/source/node/list', params),
+    add: (data: object) => post('/source/node/add', data),
+    delete: (nodeId: number) => del('/source/node/del', { nodeId }),
+    edit: (data: object) => put('/source/node/edit', data),
+    getpropertyList: (params: object) => get('/product/tsl/property/all', params),
+  },
 
-   template: {
-      getList: (params: object) => get('/source/template/search', params),
-      add: (data: object) => post('/source/template/add', data),
-      delete: (ids: number) => del('/source/template/del', { ids }),
-      edit: (data: object) => put('/source/template/edit', data),
-      detail: (id: string) => get('/source/template/detail', { id }),
-      allList: (params: object) => get('/source/template/list', params), // 获取所有已发布列表
-      getdata: (params: object) => get('/source/template/getdata', params),
-      getDictData: (params: object) => get('/common/dict/data/getDictData', params),
-      cityTree: (params: object) => get('/common/city/tree', params),
-      copy: (params: object) => post('/source/template/copy', params),
-      relation_check: (id: number) => get('/source/template/relation_check', { id }),
-      source_list: (id: number) => get('/source/template/source_list', { id }),
-      aggregate_from: (id: number) => get('/source/template/aggregate_from', { id }),
-      relation: (data: object) => post('/source/template/relation', data),
-      aggregate: (data: object) => post('/source/template/aggregate', data),
-   },
+  template: {
+    getList: (params: object) => get('/source/template/search', params),
+    add: (data: object) => post('/source/template/add', data),
+    delete: (ids: number) => del('/source/template/del', { ids }),
+    edit: (data: object) => put('/source/template/edit', data),
+    detail: (id: string) => get('/source/template/detail', { id }),
+    allList: (params: object) => get('/source/template/list', params), // 获取所有已发布列表
+    getdata: (params: object) => get('/source/template/getdata', params),
+    getDictData: (params: object) => get('/common/dict/data/getDictData', params),
+    cityTree: (params: object) => get('/common/city/tree', params),
+    copy: (params: object) => post('/source/template/copy', params),
+    relation_check: (id: number) => get('/source/template/relation_check', { id }),
+    source_list: (id: number) => get('/source/template/source_list', { id }),
+    aggregate_from: (id: number) => get('/source/template/aggregate_from', { id }),
+    relation: (data: object) => post('/source/template/relation', data),
+    aggregate: (data: object) => post('/source/template/aggregate', data),
+  },
 
-   tnode: {
-      getList: (params: object) => get('/source/template/node/list', params),
-      add: (data: object) => post('/source/template/node/add', data),
-      delete: (id: number) => del('/source/template/node/del', { id }),
-      edit: (data: object) => put('/source/template/node/edit', data),
-      deploy: (data: object) => post('/source/template/deploy', data),
-      undeploy: (data: object) => post('/source/template/undeploy', data),
-   },
+  tnode: {
+    getList: (params: object) => get('/source/template/node/list', params),
+    add: (data: object) => post('/source/template/node/add', data),
+    delete: (id: number) => del('/source/template/node/del', { id }),
+    edit: (data: object) => put('/source/template/node/edit', data),
+    deploy: (data: object) => post('/source/template/deploy', data),
+    undeploy: (data: object) => post('/source/template/undeploy', data),
+  },
 
-   weather: {
-      getCityWeatherList: () => get('/envirotronics/weather/cityWeatherList'),
-      getWhichCityWeather: (params: object) => get('/envirotronics/weather/getInfoById', params),
-      getTemperatureEchartById: (params: object) => get('/envirotronics/weather/getTemperatureEchartById', params),
-      getWindpowerEchartById: (params: object) => get('/envirotronics/weather/getWindpowerEchartById', params),
-      getCityWeatherHistory: (params: object) => get('/envirotronics/weather/GetCityWeatherHistory', params),
-      getCityWeatherHistoryExport: (params: object) => file('/envirotronics/weather/GetCityWeatherHistoryExport', params),
-   },
-   statistics: {
-      getStatisticsChartData: (params: object) => get('/statistics/bar/chart/data', params),
-      getStatisticsLineChartData: (params: object) => get('/statistics/broken/line/data', params),
-      getStatisticsTotalData: (params: object) => get('/statistics/city/data', params),
-      getStatisticsPieData: (params: object) => get('/statistics/tempering/ratio/data', params),
-      getStatisticsOverview: (params: object) => get('/statistics/overview', params),
-   },
-   iotManage: {
-      getOverviewData: () => get('/thing/overview'),
-      getAlarmList: (params: object) => get('/alarm/log/list', params),
-      getAlarmDetail: (id: number) => get('/alarm/log/detail', { id }),
-      getAlarmHandle: (data: object) => post('/alarm/log/handle', data),
-      // 设备消息总量本年统计
-      deviceDataTotalCount: (dateType: 'year' | 'month' | 'day') => get('/analysis/deviceDataTotalCount', { dateType }),
-      // 设备在线离线及总数统计
-      deviceOnlineOfflineCount: () => get('/analysis/deviceOnlineOfflineCount'),
-      // 本年度每月设备消息量统计 
-      deviceDataCount: (dateType: 'year' | 'month') => get('/analysis/deviceDataCount', { dateType }),
-      // 按年度每月设备告警数统计
-      deviceAlertCountByYearMonth: (year = '2023') => get('/analysis/deviceAlertCountByYearMonth', { year }),
-      // 按告警级别统计
-      deviceAlarmLevelCount: (dateType: 'year' | 'month' | 'day', date: string) => get('/analysis/deviceAlarmLevelCount', { dateType, date }),
-      // 产品数量统计
-      productCount: () => get('/analysis/productCount'),
-   },
-   // 计算指标管理
-   calculationIndicator: {
-      getList: (params: object) => get('/compute/list', params),
-      add: (data: object) => post('/compute/add', data),
-      delete: (id: number) => del('/compute/del', { id }),
-      edit: (data: object) => put('/compute/edit', data),
-      deploy: (data: object) => put('/compute/publish', data),
-      checkDeploy: (params: object) => get('/compute/checkComputeIndexDeploy', params)
-   },
-   tags: {
-      getTree: (data: object) => get('/tag/tree', data),
-      add: (data: object) => post('/tag/add', data),
-      edit: (data: object) => put('/tag/edit', data),
-      detail: (id: number) => get('/tag/detail', { id }),
-      del: (id: number) => del('/tag/del', { id }),
-   }
+  weather: {
+    getCityWeatherList: () => get('/envirotronics/weather/cityWeatherList'),
+    getWhichCityWeather: (params: object) => get('/envirotronics/weather/getInfoById', params),
+    getTemperatureEchartById: (params: object) => get('/envirotronics/weather/getTemperatureEchartById', params),
+    getWindpowerEchartById: (params: object) => get('/envirotronics/weather/getWindpowerEchartById', params),
+    getCityWeatherHistory: (params: object) => get('/envirotronics/weather/GetCityWeatherHistory', params),
+    getCityWeatherHistoryExport: (params: object) => file('/envirotronics/weather/GetCityWeatherHistoryExport', params),
+  },
+  statistics: {
+    getStatisticsChartData: (params: object) => get('/statistics/bar/chart/data', params),
+    getStatisticsLineChartData: (params: object) => get('/statistics/broken/line/data', params),
+    getStatisticsTotalData: (params: object) => get('/statistics/city/data', params),
+    getStatisticsPieData: (params: object) => get('/statistics/tempering/ratio/data', params),
+    getStatisticsOverview: (params: object) => get('/statistics/overview', params),
+  },
+  iotManage: {
+    getOverviewData: () => get('/thing/overview'),
+    getAlarmList: (params: object) => get('/alarm/log/list', params),
+    getAlarmDetail: (id: number) => get('/alarm/log/detail', { id }),
+    getAlarmHandle: (data: object) => post('/alarm/log/handle', data),
+    // 设备消息总量本年统计
+    deviceDataTotalCount: (dateType: 'year' | 'month' | 'day') => get('/analysis/deviceDataTotalCount', { dateType }),
+    // 设备在线离线及总数统计
+    deviceOnlineOfflineCount: () => get('/analysis/deviceOnlineOfflineCount'),
+    // 本年度每月设备消息量统计 
+    deviceDataCount: (dateType: 'year' | 'month') => get('/analysis/deviceDataCount', { dateType }),
+    // 按年度每月设备告警数统计
+    deviceAlertCountByYearMonth: (year = '2023') => get('/analysis/deviceAlertCountByYearMonth', { year }),
+    // 按告警级别统计
+    deviceAlarmLevelCount: (dateType: 'year' | 'month' | 'day', date: string) => get('/analysis/deviceAlarmLevelCount', { dateType, date }),
+    // 产品数量统计
+    productCount: () => get('/analysis/productCount'),
+  },
+  // 计算指标管理
+  calculationIndicator: {
+    getList: (params: object) => get('/compute/list', params),
+    add: (data: object) => post('/compute/add', data),
+    delete: (id: number) => del('/compute/del', { id }),
+    edit: (data: object) => put('/compute/edit', data),
+    deploy: (data: object) => put('/compute/publish', data),
+    checkDeploy: (params: object) => get('/compute/checkComputeIndexDeploy', params)
+  },
+  tags: {
+    getTree: (data: object) => get('/tag/tree', data),
+    add: (data: object) => post('/tag/add', data),
+    edit: (data: object) => put('/tag/edit', data),
+    detail: (id: number) => get('/tag/detail', { id }),
+    del: (id: number) => del('/tag/del', { id }),
+  },
+  indicator: {
+    getList: (params: object) => get('/indicator/list', params),
+    data: (params: object) => get('/indicator/data', params),
+    detail: (code: string) => get('/indicator/detail', { code }),
+    add: (data: object) => post('/indicator/add', data),
+    del: (code: string) => del('/indicator/del', { code }),
+    edit: (data: object) => put('/indicator/edit', data),
+    publish: (code: string) => put('/indicator/publish', { code }),
+    unpublish: (code: string) => put('/indicator/unpublish', { code }),
+  }
 }

+ 1 - 0
src/i18n/lang/en.ts

@@ -278,6 +278,7 @@ export default {
 			dataModeling: "Data Modeling",
 			dataSourceManagement: "Data Source Management",
 			tags: "Tags",
+			indicator: "Indicator",
 		},
 		developmentTools: {
 			title: "Development Tools",

+ 2 - 1
src/i18n/lang/zh-cn.ts

@@ -279,7 +279,8 @@ export default {
 			indexManagement: "指数管理",
 			dataModeling: "数据建模",
 			dataSourceManagement: "数据源管理",
-			tags: "标签管理"
+			tags: "标签管理",
+			indicator: "指标管理",
 		},
 		developmentTools: {
 			title: "开发工具",

+ 2 - 1
src/i18n/lang/zh-tw.ts

@@ -286,7 +286,8 @@ export default {
 			indexManagement: "指數管理",
 			dataModeling: "數據建模",
 			dataSourceManagement: "數據源管理",
-			tags: "標籤管理"
+			tags: "標籤管理",
+			indicator: "指標管理",
 		},
 		developmentTools: {
 			title: "開發工具",

+ 398 - 0
src/views/system/datahub/indicator/component/edit.vue

@@ -0,0 +1,398 @@
+<template>
+  <div class="indicator-edit">
+    <el-dialog v-model="visible" :title="isEdit ? '编辑指标' : '新增指标'" width="800px" :close-on-click-modal="false" destroy-on-close>
+      <div v-if="visible">
+        <el-tabs v-model="activeTab">
+          <el-tab-pane label="基本信息" name="base">
+            <el-form ref="formRef" :model="form" :rules="rules" label-width="110px" class="pt10">
+              <div class="row">
+                <el-form-item label="指标编码" prop="code" class="flex1">
+                  <el-input v-model.trim="form.code" :disabled="isEdit" placeholder="请输入唯一编码,如 IND001" clearable />
+                </el-form-item>
+                <el-form-item label="指标名称" prop="name" class="flex1">
+                  <el-input v-model.trim="form.name" placeholder="请输入指标名称" clearable />
+                </el-form-item>
+              </div>
+              <div class="row">
+                <el-form-item label="指标类型" prop="type" class="flex1">
+                  <el-cascader :options="typeOptions" :props="{ checkStrictly: true, emitPath: false, value: 'code', label: 'name' }" placeholder="请选择指标类型" clearable class="w100" v-model="form.type">
+                    <template #default="{ node, data }">
+                      <span>{{ data.name }}</span>
+                      <span v-if="!node.isLeaf"> ({{ data.children.length }}) </span>
+                    </template>
+                  </el-cascader>
+                </el-form-item>
+                <el-form-item label="单位" prop="unit" class="flex1">
+                  <el-input v-model.trim="form.unit" placeholder="请输入计量单位(如 mg/L、%)" clearable />
+                </el-form-item>
+              </div>
+              <div class="row">
+                <el-form-item label="数据建模KEY" prop="dataTemplateKey" class="flex1">
+                  <el-input v-model.trim="form.dataTemplateKey" placeholder="请输入数据建模KEY" clearable />
+                </el-form-item>
+                <el-form-item label="计算策略" prop="computeStrategy" class="flex1">
+                  <el-select v-model="form.computeStrategy" placeholder="请选择计算策略" clearable style="width: 100%">
+                    <el-option label="聚合计算" value="aggregation" />
+                    <el-option label="累计计算" value="accumulation" />
+                    <el-option label="数学计算" value="mathematical" />
+                  </el-select>
+                </el-form-item>
+              </div>
+              <div class="row">
+                <el-form-item label="计算模式" prop="calculationMode" class="flex1">
+                  <el-radio-group v-model="form.calculationMode">
+                    <el-radio label="demand">按需计算</el-radio>
+                    <el-radio label="schedule">定时计算</el-radio>
+                  </el-radio-group>
+                </el-form-item>
+                <el-form-item v-if="form.calculationMode === 'schedule'" label="计算周期" prop="calculationSchedule" class="flex1">
+                  <el-input v-model.trim="form.calculationSchedule" placeholder="请输入cron或表达式" clearable />
+                </el-form-item>
+              </div>
+              <el-form-item label="指标描述" prop="description">
+                <el-input v-model.trim="form.description" type="textarea" :rows="3" placeholder="请输入指标描述" />
+              </el-form-item>
+            </el-form>
+          </el-tab-pane>
+
+          <el-tab-pane label="计算公式" name="formula">
+            <div class="section-title">计算公式</div>
+            <el-input v-model="form.formula" type="textarea" :rows="4" placeholder="请输入计算公式,例如:COD = (V1 - V2) × C × 8 × 1000 / V" />
+            <div class="mt10 text-gray">支持 + - × ÷ () 函数 等;参数请在下方维护。</div>
+
+            <div class="section-title mt20 flex-between">
+              <span>公式参数</span>
+              <el-button type="success" @click="openParamDialog()">+ 添加参数</el-button>
+            </div>
+
+            <el-table :data="form.formulaParams" border style="width: 100%">
+              <el-table-column type="index" label="序号" width="60" align="center" />
+              <el-table-column label="参数名称" prop="name" />
+              <el-table-column label="参数编码" prop="code" />
+              <el-table-column label="参数值" prop="values" width="200" />
+              <el-table-column label="参数描述" prop="description" />
+              <el-table-column label="操作" width="160" align="center">
+                <template #default="scope">
+                  <el-button size="small" text type="primary" @click="openParamDialog(scope.row, scope.$index)">编辑</el-button>
+                  <el-button size="small" text type="danger" @click="removeParam(scope.$index)">删除</el-button>
+                </template>
+              </el-table-column>
+            </el-table>
+            <div v-if="!form.formulaParams.length" class="empty-tip">暂无参数,请添加公式参数</div>
+          </el-tab-pane>
+
+          <el-tab-pane label="维度设置" name="dimension">
+            <div class="section-title flex-between">
+              <span>维度列表</span>
+              <el-button type="success" @click="openDimDialog()">+ 添加维度</el-button>
+            </div>
+
+            <el-table :data="form.dimensions" border style="width: 100%">
+              <el-table-column type="index" label="序号" width="60" align="center" />
+              <el-table-column label="维度名称" prop="name" />
+              <el-table-column label="维度标识" prop="code" width="180" />
+              <el-table-column label="值类型" prop="valueType" width="140">
+                <template #default="scope">{{ formatValueType(scope.row.valueType) }}</template>
+              </el-table-column>
+              <el-table-column label="维度描述" prop="description" />
+              <el-table-column label="操作" width="160" align="center">
+                <template #default="scope">
+                  <el-button size="small" text type="primary" @click="openDimDialog(scope.row, scope.$index)">编辑</el-button>
+                  <el-button size="small" text type="danger" @click="removeDim(scope.$index)">删除</el-button>
+                </template>
+              </el-table-column>
+            </el-table>
+            <div v-if="!form.dimensions.length" class="empty-tip">暂无维度,请添加分析维度</div>
+          </el-tab-pane>
+        </el-tabs>
+      </div>
+
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="onCancel">取消</el-button>
+          <el-button type="primary" @click="onSave">保存</el-button>
+        </span>
+      </template>
+    </el-dialog>
+
+    <!-- 添加/编辑 参数 -->
+    <el-dialog v-model="paramDialog.visible" title="添加参数" width="520px" :close-on-click-modal="false">
+      <el-form :model="paramDialog.form" ref="paramFormRef" :rules="paramRules" label-width="90px">
+        <el-form-item label="参数名称" prop="name">
+          <el-input v-model.trim="paramDialog.form.name" placeholder="请输入参数名称" />
+        </el-form-item>
+        <el-form-item label="参数编码" prop="code">
+          <el-input v-model.trim="paramDialog.form.code" placeholder="请输入参数编码" />
+        </el-form-item>
+        <el-form-item label="参数值" prop="values">
+          <el-input v-model.trim="paramDialog.form.values" placeholder="请输入参数值" />
+        </el-form-item>
+        <el-form-item label="参数描述" prop="description">
+          <el-input v-model.trim="paramDialog.form.description" type="textarea" placeholder="请输入参数描述" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="paramDialog.visible = false">取消</el-button>
+        <el-button type="primary" @click="confirmParam">添加</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 添加/编辑 维度 -->
+    <el-dialog v-model="dimDialog.visible" title="添加维度" width="520px" :close-on-click-modal="false">
+      <el-form :model="dimDialog.form" ref="dimFormRef" :rules="dimRules" label-width="90px">
+        <el-form-item label="维度名称" prop="name">
+          <el-input v-model.trim="dimDialog.form.name" placeholder="请输入维度名称" />
+        </el-form-item>
+        <el-form-item label="维度标识" prop="code">
+          <el-input v-model.trim="dimDialog.form.code" placeholder="请输入维度标识,如 time, location" />
+        </el-form-item>
+        <el-form-item label="值类型" prop="valueType">
+          <el-select v-model="dimDialog.form.valueType" placeholder="请选择值类型" style="width: 100%">
+            <el-option label="字符串" value="string" />
+            <el-option label="数值" value="number" />
+            <el-option label="布尔" value="boolean" />
+            <el-option label="时间" value="datetime" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="维度描述" prop="description">
+          <el-input v-model.trim="dimDialog.form.description" type="textarea" placeholder="请输入维度描述" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dimDialog.visible = false">取消</el-button>
+        <el-button type="primary" @click="confirmDim">添加</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { reactive, ref } from "vue";
+import { ElMessage } from "element-plus";
+import api from "/@/api/datahub";
+import apiSystem from "/@/api/system";
+
+const emit = defineEmits(["update"]);
+
+const visible = ref(false);
+const isEdit = ref(false);
+const activeTab = ref<"base" | "formula" | "dimension">("base");
+
+type FormulaParam = { name: string; code: string; values: string; description?: string };
+type Dimension = { name: string; code: string; valueType: "string" | "number" | "boolean" | "datetime"; description?: string };
+
+const formRef = ref();
+const form = reactive({
+  code: "",
+  name: "",
+  description: "",
+  type: "",
+  unit: "",
+  dataTemplateKey: "",
+  formula: "",
+  formulaParams: [] as FormulaParam[],
+  computeStrategy: "",
+  calculationMode: "demand",
+  calculationSchedule: "",
+  dimensions: [] as Dimension[],
+});
+
+const rules = {
+  code: [{ required: true, message: "请输入指标编码", trigger: "blur" }],
+  name: [{ required: true, message: "请输入指标名称", trigger: "blur" }],
+  type: [{ required: true, message: "请选择指标类型", trigger: "change" }],
+  unit: [{ required: true, message: "请输入单位", trigger: "blur" }],
+  calculationMode: [{ required: true, message: "请选择计算模式", trigger: "change" }],
+  calculationSchedule: [{ validator: (_: any, v: string, cb: any) => (form.calculationMode === "schedule" && !v ? cb(new Error("请输入计算周期")) : cb()), trigger: "blur" }],
+};
+
+const typeOptions = ref([]);
+
+apiSystem.getInfoByKey("sys.indicator.type.tagCode").then((res: any) => {
+  const tagCode = res?.data?.configValue || "HARDWARE";
+  api.tags.getTree({}).then((res: any) => {
+    typeOptions.value = (res.data || []).find((item: any) => item.code === tagCode)?.children || [];
+  });
+});
+
+const paramDialog = reactive({
+  visible: false,
+  index: -1,
+  form: { name: "", code: "", values: "", description: "" } as FormulaParam,
+});
+const paramFormRef = ref();
+const paramRules = {
+  name: [{ required: true, message: "请输入参数名称", trigger: "blur" }],
+  code: [{ required: true, message: "请输入参数编码", trigger: "blur" }],
+  values: [{ required: true, message: "请输入参数值", trigger: "blur" }],
+};
+
+const dimDialog = reactive({
+  visible: false,
+  index: -1,
+  form: { name: "", code: "", valueType: "string", description: "" } as Dimension,
+});
+const dimFormRef = ref();
+const dimRules = {
+  name: [{ required: true, message: "请输入维度名称", trigger: "blur" }],
+  code: [{ required: true, message: "请输入维度标识", trigger: "blur" }],
+  valueType: [{ required: true, message: "请选择值类型", trigger: "change" }],
+};
+
+function resetForm() {
+  Object.assign(form, {
+    code: "",
+    name: "",
+    description: "",
+    type: "",
+    unit: "",
+    dataTemplateKey: "",
+    formula: "",
+    formulaParams: [],
+    computeStrategy: "",
+    calculationMode: "demand",
+    calculationSchedule: "",
+    dimensions: [],
+  });
+  activeTab.value = "base";
+}
+
+function openDialog(row?: any) {
+  resetForm();
+  isEdit.value = !!row;
+  if (row?.code) {
+    // 获取详情
+    api.indicator.detail(row.code).then((res: any) => {
+      const data = res?.data || res || row;
+      Object.assign(form, {
+        id: data.id || row.id,
+        code: data.code || row.code,
+        name: data.name,
+        description: data.description,
+        type: data.type,
+        unit: data.unit,
+        dataTemplateKey: data.dataTemplateKey,
+        formula: data.formula,
+        formulaParams: data.formulaParams || [],
+        computeStrategy: data.computeStrategy,
+        calculationMode: data.calculationMode || "demand",
+        calculationSchedule: data.calculationSchedule || "",
+        dimensions: data.dimensions || [],
+      });
+    });
+  }
+  visible.value = true;
+}
+
+function onCancel() {
+  visible.value = false;
+}
+
+function onSave() {
+  formRef.value.validate((valid: boolean) => {
+    if (!valid) {
+      activeTab.value = "base";
+      return;
+    }
+    const payload = JSON.parse(JSON.stringify(form));
+    const apiFn = isEdit.value ? api.indicator.edit : api.indicator.add;
+    apiFn(payload).then(() => {
+      ElMessage.success("保存成功");
+      visible.value = false;
+      emit("update");
+    });
+  });
+}
+
+function openParamDialog(row?: FormulaParam, index?: number) {
+  paramDialog.index = typeof index === "number" ? index : -1;
+  paramDialog.form = { name: row?.name || "", code: row?.code || "", values: row?.values || "", description: row?.description || "" };
+  paramDialog.visible = true;
+}
+
+function confirmParam() {
+  paramFormRef.value.validate((valid: boolean) => {
+    if (!valid) return;
+    if (paramDialog.index > -1) {
+      form.formulaParams.splice(paramDialog.index, 1, { ...paramDialog.form });
+    } else {
+      form.formulaParams.push({ ...paramDialog.form });
+    }
+    paramDialog.visible = false;
+  });
+}
+
+function removeParam(i: number) {
+  form.formulaParams.splice(i, 1);
+}
+
+function openDimDialog(row?: Dimension, index?: number) {
+  dimDialog.index = typeof index === "number" ? index : -1;
+  dimDialog.form = {
+    name: row?.name || "",
+    code: row?.code || "",
+    valueType: (row?.valueType as any) || "string",
+    description: row?.description || "",
+  };
+  dimDialog.visible = true;
+}
+
+function confirmDim() {
+  dimFormRef.value.validate((valid: boolean) => {
+    if (!valid) return;
+    if (dimDialog.index > -1) {
+      form.dimensions.splice(dimDialog.index, 1, { ...dimDialog.form });
+    } else {
+      form.dimensions.push({ ...dimDialog.form });
+    }
+    dimDialog.visible = false;
+  });
+}
+
+function removeDim(i: number) {
+  form.dimensions.splice(i, 1);
+}
+
+function formatValueType(v: string) {
+  const map: any = { string: "字符串", number: "数值", boolean: "布尔", datetime: "时间" };
+  return map[v] || v || "-";
+}
+
+defineExpose({ openDialog });
+</script>
+
+<style lang="scss" scoped>
+.row {
+  display: flex;
+  gap: 16px;
+  .flex1 {
+    flex: 1;
+  }
+}
+.section-title {
+  margin: 10px 0;
+  font-weight: 600;
+}
+.flex-between {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.mt10 {
+  margin-top: 10px;
+}
+.mt20 {
+  margin-top: 20px;
+}
+.pt10 {
+  padding-top: 10px;
+}
+.text-gray {
+  color: var(--el-text-color-secondary);
+}
+.empty-tip {
+  text-align: center;
+  color: var(--el-text-color-secondary);
+  padding: 16px 0;
+}
+</style>

+ 134 - 0
src/views/system/datahub/indicator/index.vue

@@ -0,0 +1,134 @@
+<template>
+  <div class="page">
+    <el-card shadow="never">
+      <el-form :model="params" inline ref="queryRef" @keyup.enter="getList">
+        <el-form-item>
+          <el-input v-model="params.keyword" placeholder="输入指标名称、编码或描述" style="width: 300px" clearable />
+        </el-form-item>
+        <el-form-item>
+          <el-cascader :options="typeOptions" :props="{ checkStrictly: true, emitPath: false, value: 'code', label: 'name' }" placeholder="请选择指标类型" clearable class="w100" v-model="params.type">
+            <template #default="{ node, data }">
+              <span>{{ data.name }}</span>
+              <span v-if="!node.isLeaf"> ({{ data.children.length }}) </span>
+            </template>
+          </el-cascader>
+        </el-form-item>
+        <el-form-item>
+          <el-select v-model="params.status" placeholder="全部状态" clearable style="width: 160px">
+            <el-option :value="''" label="全部状态" />
+            <el-option value="enabled" label="启用" />
+            <el-option value="draft" label="草稿" />
+            <el-option value="disabled" label="停用" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" class="ml10" @click="getList">
+            <el-icon><ele-Search /></el-icon>
+            查询
+          </el-button>
+          <el-button type="primary" class="ml10" @click="addOrEdit()">
+            <el-icon><ele-FolderAdd /></el-icon>
+            新增指标
+          </el-button>
+        </el-form-item>
+      </el-form>
+
+      <el-table :data="tableData" style="width: 100%" row-key="code" v-loading="loading">
+        <el-table-column label="指标编号" prop="code" align="left" width="120">
+          <template #default="scope">
+            <el-link type="primary" :underline="false">{{ scope.row.code }}</el-link>
+          </template>
+        </el-table-column>
+        <el-table-column label="指标名称" prop="name" min-width="180" show-overflow-tooltip />
+        <el-table-column label="指标类型" prop="type" width="120" align="center">
+          <template #default="scope">
+            <el-tag size="small">{{ scope.row.type || "-" }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="单位" prop="unit" width="100" align="center" />
+        <el-table-column label="维度数" align="center" width="100">
+          <template #default="scope">
+            <el-tag size="small" type="info">{{ (scope.row.dimensions && scope.row.dimensions.length) || scope.row.dimensionCount || 0 }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="状态" prop="status" width="100" align="center">
+          <template #default="scope">
+            <el-tag size="small" :type="statusTagType(scope.row.status)">{{ formatStatus(scope.row.status) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="createdAt" label="创建时间" align="center" width="180" />
+        <el-table-column label="操作" align="center" width="180" fixed="right">
+          <template #default="scope">
+            <el-button size="small" text type="primary" @click="addOrEdit(scope.row)">编辑</el-button>
+            <el-button size="small" text type="danger" @click="onRowDel(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <pagination v-if="params.total" :total="params.total" v-model:page="params.pageNum" v-model:limit="params.pageSize" @pagination="getList()" />
+    </el-card>
+
+    <EditIndicator ref="editFormRef" :typeOptions="typeOptions" @update="getList" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from "vue";
+import { ElMessageBox, ElMessage } from "element-plus";
+import api from "/@/api/datahub";
+import { useSearch } from "/@/hooks/useCommon";
+import EditIndicator from "./component/edit.vue";
+import apiSystem from "/@/api/system";
+
+const editFormRef = ref();
+const queryRef = ref();
+
+const typeOptions = ref([]);
+
+apiSystem.getInfoByKey("sys.indicator.type.tagCode").then((res: any) => {
+  const tagCode = res?.data?.configValue || "HARDWARE";
+  api.tags.getTree({}).then((res: any) => {
+    typeOptions.value = (res.data || []).find((item: any) => item.code === tagCode)?.children || [];
+  });
+});
+
+const { params, tableData, getList, loading } = useSearch(api.indicator.getList, "data", {
+  keyword: "",
+  type: "",
+  status: "",
+});
+
+getList();
+
+const addOrEdit = (row?: any) => {
+  editFormRef.value.openDialog(row);
+};
+
+const onRowDel = (row: any) => {
+  ElMessageBox.confirm(`此操作将永久删除指标:${row.name}(${row.code}),是否继续?`, "提示", {
+    confirmButtonText: "删除",
+    cancelButtonText: "取消",
+    type: "warning",
+  }).then(() => {
+    api.indicator.del(row.code).then(() => {
+      ElMessage.success("删除成功");
+      getList();
+    });
+  });
+};
+
+const formatStatus = (s: string) => {
+  if (s === "enabled") return "启用";
+  if (s === "draft") return "草稿";
+  if (s === "disabled") return "停用";
+  return s || "-";
+};
+const statusTagType = (s: string) => {
+  if (s === "enabled") return "success";
+  if (s === "draft") return "info";
+  if (s === "disabled") return "warning";
+  return "info";
+};
+</script>
+
+<style lang="scss" scoped></style>

+ 1 - 1
src/views/system/datahub/tags/component/edit.vue

@@ -12,7 +12,7 @@
         </div>
         <div class="flex-row">
           <el-form-item label="父级标签" prop="parentId" class="flex1">
-            <el-cascader :options="treeData" :props="{ checkStrictly: true, emitPath: false, value: 'id', label: 'name' }" placeholder="请选择组织" clearable class="w100" v-model="formData.parentId">
+            <el-cascader :options="treeData" :props="{ checkStrictly: true, emitPath: false, value: 'id', label: 'name' }" placeholder="请选择父级标签" clearable class="w100" v-model="formData.parentId">
               <template #default="{ node, data }">
                 <span>{{ data.name }}</span>
                 <span v-if="!node.isLeaf"> ({{ data.children.length }}) </span>

+ 4 - 9
src/views/system/datahub/tags/index.vue

@@ -10,7 +10,7 @@
             </el-icon>
             查询
           </el-button>
-          <el-button type="primary" class="ml10" @click="onOpenAddDept" v-auth="'add'">
+          <el-button type="primary" class="ml10" @click="addOrEdit()" v-auth="'add'">
             <el-icon>
               <ele-FolderAdd />
             </el-icon>
@@ -38,8 +38,7 @@
         <el-table-column le-column label="操作" align="center" width="140" v-col="'handle'">
           <template #default="scope">
             <template v-if="scope.row.name !== '硬件设施'">
-              <el-button size="small" text type="primary" @click="onOpenAddDept(scope.row)" v-auth="'add'">新增</el-button>
-              <el-button size="small" text type="warning" @click="onOpenEditDept(scope.row)" v-auth="'edit'">修改</el-button>
+              <el-button size="small" text type="warning" @click="addOrEdit(scope.row)" v-auth="'edit'">修改</el-button>
               <el-button size="small" text type="info" @click="onTabelRowDel(scope.row)" v-auth="'del'">删除</el-button>
             </template>
           </template>
@@ -92,16 +91,12 @@ const getData = () => {
 };
 
 // 打开新增菜单弹窗
-const onOpenAddDept = (row?: any) => {
-  editDeptRef.value.openDialog(row?.deptId);
-};
-// 打开编辑菜单弹窗
-const onOpenEditDept = (row: any) => {
+const addOrEdit = (row?: any) => {
   editDeptRef.value.openDialog(row);
 };
 // 删除当前行
 const onTabelRowDel = (row: any) => {
-  ElMessageBox.confirm(`此操作将永久删除组织:${row.deptName}, 是否继续?`, "提示", {
+  ElMessageBox.confirm(`此操作将永久删除标签:${row.name}, 是否继续?`, "提示", {
     confirmButtonText: "删除",
     cancelButtonText: "取消",
     type: "warning",