Explorar o código

Merge branch 'professional2' of http://git.mydig.net/Sagoo-Cloud/sagoo-admin-ui into professional2

vera_min hai 1 mes
pai
achega
2cbe6f0213

+ 113 - 95
src/api/datahub/index.ts

@@ -10,106 +10,124 @@ 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)
-   },
+  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),
+    getData: (params: object) => get('/indicator/getData', 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) => post('/indicator/publish', { code }),
+    unpublish: (code: string) => post('/indicator/unpublish', { code }),
+  }
 }

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

@@ -277,6 +277,8 @@ export default {
 			indexManagement: "Index Management",
 			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: "标签管理",
+			indicator: "指标管理",
 		},
 		developmentTools: {
 			title: "开发工具",

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

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

+ 1 - 1
src/theme/element.scss

@@ -215,7 +215,7 @@
 }
 .el-dialog__body {
 	max-height: calc(90vh - 111px) !important;
-	min-height: 50vh !important;
+	min-height: 200px !important;
 	overflow-y: auto;
 	overflow-x: hidden;
 }

+ 44 - 11
src/views/system/api/index.vue

@@ -142,17 +142,50 @@ const onDel = (row: ApiRow) => {
 };
 
 // const autoAddList = [
-// 	{
-// 		menuIds: [240],
-// 		name: "插件测试",
-// 		types: 2,
-// 		apiTypes: "IOT",
-// 		address: "/api/v1/plugin/test",
-// 		method: "POST",
-// 		remark: "",
-// 		status: 1,
-// 		parentId: 708,
-// 	},
+//   {
+//     menuIds: [241],
+//     name: "标签新增",
+//     types: 2,
+//     apiTypes: "IOT",
+//     address: "/api/v1/tag/add",
+//     method: "POST",
+//     remark: "",
+//     status: 1,
+//     parentId: 748,
+//   },
+//   {
+//     menuIds: [241],
+//     name: "标签删除",
+//     types: 2,
+//     apiTypes: "IOT",
+//     address: "/api/v1/tag/del",
+//     method: "DELETE",
+//     remark: "",
+//     status: 1,
+//     parentId: 748,
+//   },
+//   {
+//     menuIds: [241],
+//     name: "标签修改",
+//     types: 2,
+//     apiTypes: "IOT",
+//     address: "/api/v1/tag/edit",
+//     method: "PUT",
+//     remark: "",
+//     status: 1,
+//     parentId: 748,
+//   },
+//   {
+//     menuIds: [241],
+//     name: "标签详情",
+//     types: 2,
+//     apiTypes: "IOT",
+//     address: "/api/v1/tag/detail",
+//     method: "GET",
+//     remark: "",
+//     status: 1,
+//     parentId: 748,
+//   },
 // ];
 
 // function autoAddApi() {

+ 172 - 0
src/views/system/datahub/indicator/component/data.vue

@@ -0,0 +1,172 @@
+<template>
+  <el-dialog v-model="visible" :title="`指标数据 - ${title}`" width="1100px" :close-on-click-modal="false" destroy-on-close>
+    <div v-if="visible">
+      <el-form :inline="true" class="toolbar">
+        <el-form-item>
+          <el-input v-model="query.searchValue" placeholder="输入指标值或原始值" clearable style="width: 160px" />
+        </el-form-item>
+        <el-form-item label="时间范围">
+          <el-date-picker v-model="query.dateRange" type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width: 380px" />
+        </el-form-item>
+        <el-form-item label="维度筛选">
+          <el-select v-model="dimensionSelectedText" style="width: 120px" placeholder="全部维度" />
+        </el-form-item>
+        <el-form-item label="">
+          <el-button type="primary" :icon="Filter" @click="fetchList(1)">筛选</el-button>
+          <!-- <el-button @click="exportData">导出</el-button> -->
+        </el-form-item>
+      </el-form>
+
+      <el-table :data="list" style="width: 100%" v-loading="loading" max-height="50vh">
+        <el-table-column label="时间" align="left" min-width="160">
+          <template #default="scope">{{ scope.row.time || scope.row.createdAt || scope.row.createTime || "-" }}</template>
+        </el-table-column>
+        <el-table-column :label="`指标值${detail.unit ? ' (' + detail.unit + ')' : ''}`" min-width="140" align="left">
+          <template #default="scope">
+            <el-link type="primary" :underline="false">{{ scope.row.value ?? scope.row.indicatorValue ?? "-" }}</el-link>
+          </template>
+        </el-table-column>
+        <el-table-column label="原始值" width="120" align="left">
+          <template #default="scope">{{ scope.row.rawValue ?? scope.row.originValue ?? "-" }}</template>
+        </el-table-column>
+        <el-table-column label="监测点" prop="monitorPoint" width="120" align="left"> </el-table-column>
+        <el-table-column label="深度" prop="depth" width="120" align="left"> </el-table-column>
+        <el-table-column label="设备" prop="device" width="120" align="left"> </el-table-column>
+        <el-table-column v-for="k in dimKeys" :key="k" :label="dimNameMap[k] || k" min-width="120" show-overflow-tooltip>
+          <template #default="scope">{{ (scope.row.dimensions && scope.row.dimensions[k]) ?? scope.row[k] ?? "-" }}</template>
+        </el-table-column>
+      </el-table>
+
+      <div class="pager flex-end">
+        <el-pagination background layout="prev, pager, next, ->, total, sizes" :page-sizes="[20, 50, 100, 200]" :total="total" :page-size="query.pageSize" :current-page="query.pageNum" @current-change="(p:number)=>fetchList(p)" @size-change="(s:number)=>{query.pageSize=s;fetchList(1)}" />
+      </div>
+    </div>
+  </el-dialog>
+</template>
+
+<script lang="ts" setup>
+import { reactive, ref, computed } from "vue";
+import { ElMessage } from "element-plus";
+import { Filter } from "@element-plus/icons-vue";
+import api from "/@/api/datahub";
+
+const visible = ref(false);
+const loading = ref(false);
+const code = ref("");
+const title = ref("");
+const detail = reactive<any>({}); // 详情含 unit/维度等
+const list = ref<any[]>([]);
+const total = ref(0);
+
+// 查询参数(对应后端文档)
+const query = reactive({
+  searchValue: "",
+  keyWord: "",
+  year: "",
+  accurate: "h",
+  dateRange: [],
+  pageNum: 1,
+  pageSize: 20,
+});
+
+const dimensionFilters = reactive<Record<string, any>>({});
+
+const dimKeys = computed(() => {
+  const set = new Set<string>();
+  list.value.forEach((row: any) => {
+    if (row?.dimensions && typeof row.dimensions === "object") {
+      Object.keys(row.dimensions).forEach((k) => set.add(k));
+    }
+  });
+  return Array.from(set);
+});
+const dimNameMap = computed<Record<string, string>>(() => {
+  const map: Record<string, string> = {};
+  (detail.dimensions || []).forEach((d: any) => {
+    map[d.key || d.name] = d.name || d.key;
+  });
+  return map;
+});
+const dimensionSelectedText = computed(() => {
+  const entries = Object.entries(dimensionFilters).filter(([, v]) => v !== "" && v !== undefined && v !== null);
+  if (!entries.length) return "全部维度";
+  return entries.map(([k, v]) => `${dimNameMap.value[k] || k}:${v}`).join(";");
+});
+
+function buildParams() {
+  const params: any = {
+    ...query,
+    code: code.value,
+  };
+  // 维度 JSON 字符串
+  const dims: Record<string, any> = {};
+  Object.entries(dimensionFilters).forEach(([k, v]) => {
+    if (v !== "" && v !== undefined && v !== null) dims[k] = v;
+  });
+  if (Object.keys(dims).length) params.dimensions = JSON.stringify(dims);
+  return params;
+}
+
+function fetchDetail() {
+  const getDetail = (api.indicator as any).detail || (api.indicator as any).data;
+  return getDetail(code.value).then((res: any) => {
+    const data = res?.data || res?.Info || res || {};
+    Object.assign(detail, data);
+  });
+}
+
+function fetchList(p?: number) {
+  if (typeof p === "number") query.pageNum = p;
+  loading.value = true;
+  const params = buildParams();
+  api.indicator
+    .getData(params)
+    .then((res: any) => {
+      // 兼容不同返回格式
+      const data = res?.data ?? res?.Info ?? res ?? {};
+      const rows = data?.list ?? data?.rows ?? data?.records ?? data ?? [];
+      list.value = Array.isArray(rows) ? rows : [];
+      total.value = res?.total ?? data?.total ?? data?.count ?? list.value.length ?? 0;
+    })
+    .finally(() => (loading.value = false));
+}
+
+function open(row: any) {
+  code.value = row?.code || "";
+  title.value = `${row?.name || "-"} (${code.value})`;
+  visible.value = true;
+  // 清空筛选
+  Object.assign(query, { searchValue: "", keyWord: "", year: "", startTime: "", endTime: "", accurate: "", accurateRanges: "", orderBy: "", pageNum: 1, pageSize: 20 });
+  Object.keys(dimensionFilters).forEach((k) => delete (dimensionFilters as any)[k]);
+  fetchDetail().then(() => fetchList(1));
+}
+
+function exportData() {
+  ElMessage.info("导出功能请在接口确定后接入");
+}
+
+defineExpose({ open });
+</script>
+
+<style lang="scss" scoped>
+.sub-title {
+  color: var(--el-text-color-secondary);
+}
+.toolbar {
+  margin-top: 10px;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  align-items: center;
+}
+.pager {
+  margin-top: 12px;
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+}
+.empty-tip {
+  color: var(--el-text-color-secondary);
+  text-align: center;
+}
+</style>

+ 61 - 0
src/views/system/datahub/indicator/component/detail.vue

@@ -0,0 +1,61 @@
+<template>
+  <el-dialog v-model="visible" :title="`指标详情 - ${detail.name || '-'} (${detail.code || code})`" width="720px" :close-on-click-modal="false">
+    <div v-if="visible">
+      <el-descriptions :column="1" border class="mt16">
+        <el-descriptions-item label="指标名称">{{ detail.name || "-" }}</el-descriptions-item>
+        <el-descriptions-item label="指标描述">{{ detail.description || "-" }}</el-descriptions-item>
+        <el-descriptions-item label="指标类型">
+          <el-tag size="small">{{ detail.type || "-" }}</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="单位">{{ detail.unit || "-" }}</el-descriptions-item>
+        <el-descriptions-item label="计算公式">
+          <span class="mono">{{ detail.formula || "-" }}</span>
+        </el-descriptions-item>
+        <el-descriptions-item label="维度数">{{ (detail.dimensions && detail.dimensions.length) || detail.dimensionCount || 0 }} 个</el-descriptions-item>
+        <el-descriptions-item label="状态">
+          <el-tag :type="detail.status == '1' ? 'success' : 'info'">{{ detail.status == "1" ? "已发布" : "未发布" }}</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="指标描述">{{ detail.description || "-" }}</el-descriptions-item>
+        <el-descriptions-item label="创建时间">{{ detail.createdAt || detail.createTime || "-" }}</el-descriptions-item>
+        <el-descriptions-item label="创建人">{{ detail.createdBy || "-" }}</el-descriptions-item>
+      </el-descriptions>
+    </div>
+    <template #footer>
+      <el-button @click="visible = false">关闭</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script lang="ts" setup>
+import { reactive, ref } from "vue";
+import api from "/@/api/datahub";
+
+const visible = ref(false);
+const code = ref("");
+const detail = reactive<any>({});
+
+function open(row: any) {
+  code.value = row?.code || "";
+  Object.keys(detail).forEach((k) => delete (detail as any)[k]);
+  visible.value = true;
+  if (!code.value) return;
+  api.indicator.detail(code.value).then((res: any) => {
+    const data = res?.data || res?.Info || res || {};
+    Object.assign(detail, data);
+  });
+}
+
+defineExpose({ open });
+</script>
+
+<style lang="scss" scoped>
+.sub-title {
+  color: var(--el-text-color-secondary);
+}
+.mt16 {
+  margin-top: 16px;
+}
+.mono {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+}
+</style>

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

@@ -0,0 +1,409 @@
+<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" align="center" />
+              <el-table-column label="参数编码" prop="code" align="center"/>
+              <el-table-column label="参数值" prop="values" align="center"/>
+              <el-table-column label="参数描述" prop="description" align="center"/>
+              <el-table-column label="操作" width="120" 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" align="center"/>
+              <el-table-column label="维度标识" prop="code" align="center"/>
+              <el-table-column label="维度值类型" prop="valueType" align="center">
+                <template #default="scope">{{ formatValueType(scope.row.valueType) }}</template>
+              </el-table-column>
+              <el-table-column label="维度值" prop="values" align="center"/>
+              <el-table-column label="维度描述" prop="description" align="center"/>
+              <el-table-column label="操作" width="120" 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="100px">
+        <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="values">
+          <el-input v-if="dimDialog.form.valueType === 'string'" v-model.trim="dimDialog.form.values" placeholder="请输入维度值" />
+          <el-input v-else-if="dimDialog.form.valueType === 'number'" type="number" v-model.trim="dimDialog.form.values" placeholder="请输入维度值" />
+          <el-radio-group v-else-if="dimDialog.form.valueType === 'boolean'" v-model="dimDialog.form.values">
+            <el-radio :label="true">是</el-radio>
+            <el-radio :label="false">否</el-radio>
+          </el-radio-group>
+          <el-date-picker v-else-if="dimDialog.form.valueType === 'datetime'" type="datetime" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" v-model="dimDialog.form.values" placeholder="请选择维度值" />
+        </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"; values: string; 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", values: "", 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",
+    values: row?.values || "",
+    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>

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

@@ -0,0 +1,163 @@
+<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="-1" label="全部状态" />
+            <el-option value="0" label="未发布" />
+            <el-option value="1" 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="scope.row.status == '1' ? 'success' : 'danger'">{{ scope.row.status == "1" ? "已发布" : "未发布" }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="createdAt" label="创建时间" align="center" width="180" />
+        <el-table-column label="操作" align="center" width="220" fixed="right">
+          <template #default="scope">
+            <el-button size="small" text type="primary" v-if="scope.row.status == '0'" @click="publish(scope.row)">发布</el-button>
+            <el-button size="small" text type="warning" v-else @click="unpublish(scope.row)">取消发布</el-button>
+            <el-button size="small" text type="primary" @click="openDetail(scope.row)">详情</el-button>
+            <el-button size="small" text type="success" @click="openData(scope.row)">数据</el-button>
+            <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" />
+    <DetailDialog ref="detailRef" />
+    <DataDialog ref="dataRef" />
+  </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 DetailDialog from "./component/detail.vue";
+import DataDialog from "./component/data.vue";
+import apiSystem from "/@/api/system";
+
+const editFormRef = ref();
+const detailRef = ref();
+const dataRef = 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: "-1",
+});
+
+getList();
+
+const publish = (row?: any) => {
+  api.indicator.publish(row.code).then(() => {
+    ElMessage.success("发布成功");
+    getList();
+  });
+};
+
+const unpublish = (row?: any) => {
+  api.indicator.unpublish(row.code).then(() => {
+    ElMessage.success("取消发布成功");
+    getList();
+  });
+};
+
+const addOrEdit = (row?: any) => {
+  editFormRef.value.openDialog(row);
+};
+const openDetail = (row: any) => {
+  detailRef.value.open(row);
+};
+const openData = (row: any) => {
+  dataRef.value.open(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>

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

@@ -0,0 +1,134 @@
+<template>
+  <div class="system-edit-dept-container">
+    <el-dialog :title="(formData.id ? '修改' : '添加') + '标签'" v-model="isShowDialog" width="600px">
+      <el-form ref="formRef" :model="formData" :rules="rules" v-if="isShowDialog" label-width="90px">
+        <div class="flex-row">
+          <el-form-item label="标签名称" prop="name" class="flex1">
+            <el-input v-model.trim="formData.name" show-word-limit placeholder="请输入标签名称" clearable></el-input>
+          </el-form-item>
+          <el-form-item label="英文标识" prop="code" class="flex1">
+            <el-input v-model.trim="formData.code" show-word-limit placeholder="请输入英文标识" clearable></el-input>
+          </el-form-item>
+        </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">
+              <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="color" class="flex1">
+            <el-select v-model.trim="formData.color" placeholder="请选择标签颜色" clearable style="width: 100%">
+              <el-option v-for="item in colorList" :label="item.label" :key="item.value" :value="item.value">
+                <template #default>
+                  <div class="flex items-center">
+                    <div class="color" :style="{ backgroundColor: item.color }"></div>
+                    {{ item.label }}
+                  </div>
+                </template>
+              </el-option>
+            </el-select>
+          </el-form-item>
+        </div>
+        <el-form-item label="描述" prop="description">
+          <el-input v-model="formData.description" type="textarea" show-word-limit placeholder="请输入描述" clearable></el-input>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <span class="dialog-footer">
+          <el-button @click="closeDialog">取 消</el-button>
+          <el-button type="primary" @click="onSubmit">{{ formData.id ? "修 改" : "添 加" }}</el-button>
+        </span>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { reactive, ref } from "vue";
+import api from "/@/api/datahub";
+import { ElMessage } from "element-plus";
+
+const emit = defineEmits(["update"]);
+
+defineProps({
+  colorList: {
+    type: Array as () => { value: string; label: string; color: string }[],
+    default: () => [],
+  },
+});
+
+const baseForm = {
+  id: null,
+  parentId: -1, // 父级标签
+  name: "", // 标签名称
+  code: "", // 标签英文标识
+  color: "", // 标签颜色
+  description: "", // 标签描述
+};
+
+const isShowDialog = ref(false);
+const treeData = ref([]);
+const formRef = ref();
+const formData = reactive({ ...baseForm });
+const rules = {
+  name: [{ required: true, message: "标签名称不能为空", trigger: "blur" }],
+  code: [{ required: true, message: "标签英文标识不能为空", trigger: "blur" }],
+  color: [{ required: true, message: "标签颜色不能为空", trigger: "blur" }],
+  description: [{ required: true, message: "标签描述不能为空", trigger: "blur" }],
+};
+
+// 打开弹窗
+const openDialog = (row: any) => {
+  resetForm();
+
+  api.tags.getTree({}).then((res: any) => {
+    treeData.value = res?.data || [];
+  });
+
+  if (row) {
+    Object.assign(formData, { ...row });
+  } else {
+    Object.assign(formData, baseForm);
+  }
+  isShowDialog.value = true;
+};
+// 关闭弹窗
+const closeDialog = () => {
+  isShowDialog.value = false;
+};
+// 新增
+const onSubmit = () => {
+  formRef.value.validate((valid: boolean) => {
+    if (valid) {
+      const submitData = JSON.parse(JSON.stringify(formData));
+      if (!submitData.parentId) {
+        submitData.parentId = -1;
+      }
+
+      const theApi = formData.id ? api.tags.edit : api.tags.add;
+
+      theApi(submitData).then(() => {
+        ElMessage.success("添加成功");
+        closeDialog(); // 关闭弹窗
+        emit("update");
+      });
+    }
+  });
+};
+const resetForm = () => {
+  Object.assign(formData, baseForm);
+};
+
+defineExpose({ openDialog });
+</script>
+<style lang="scss" scoped>
+.color {
+  width: 12px;
+  height: 12px;
+  border-radius: 50%;
+  margin-right: 5px;
+}
+</style>

+ 122 - 0
src/views/system/datahub/tags/index.vue

@@ -0,0 +1,122 @@
+<template>
+  <div class="page">
+    <el-card shadow="never">
+      <el-form :model="param" inline ref="queryRef" @keyup.enter="getData">
+        <el-form-item label="" prop="name">
+          <el-input v-model="param.name" placeholder="搜索标签名称或英文标识" style="width: 250px" clearable />
+          <el-button type="primary" class="ml10" @click="getData">
+            <el-icon>
+              <ele-Search />
+            </el-icon>
+            查询
+          </el-button>
+          <el-button type="primary" class="ml10" @click="addOrEdit()" v-auth="'add'">
+            <el-icon>
+              <ele-FolderAdd />
+            </el-icon>
+            新增标签
+          </el-button>
+        </el-form-item>
+      </el-form>
+      <el-table :data="tableData" style="width: 100%" row-key="id" default-expand-all :tree-props="{ children: 'children', hasChildren: 'hasChildren' }" v-loading="loading">
+        <el-table-column prop="name" v-col="'name'" label="标签名称" min-width="180" show-overflow-tooltip> </el-table-column>
+        <el-table-column prop="code" v-col="'code'" label="英文标识" align="center" width="120">
+          <template #default="scope">
+            <el-tag type="primary" size="small">{{ scope.row.code }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="color" v-col="'color'" label="颜色" align="center" width="120">
+          <template #default="scope">
+            <div class="flex" style="justify-content: center">
+              <div class="color" :style="{ backgroundColor: colorMap.get(scope.row.color)?.color }"></div>
+              {{ colorMap.get(scope.row.color)?.label }}
+            </div>
+          </template>
+        </el-table-column>
+        <el-table-column prop="description" v-col="'description'" label="描述" align="center" show-overflow-tooltip min-width="180"></el-table-column>
+        <el-table-column prop="createdAt" v-col="'createdAt'" label="创建时间" align="center" width="180"></el-table-column>
+        <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="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>
+        </el-table-column>
+      </el-table>
+    </el-card>
+    <EditDept ref="editDeptRef" :colorList="colorList" @update="getData" />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, reactive, onMounted } from "vue";
+import { ElMessageBox, ElMessage } from "element-plus";
+import EditDept from "./component/edit.vue";
+import api from "/@/api/datahub";
+
+const colorList = ref([
+  { value: "red", label: "红色", color: "#FF0000" },
+  { value: "green", label: "绿色", color: "#00FF00" },
+  { value: "blue", label: "蓝色", color: "#0000FF" },
+  { value: "yellow", label: "黄色", color: "#E8A92A" },
+  { value: "purple", label: "紫色", color: "#9A4FF0" },
+  { value: "pink", label: "粉色", color: "#E7408C" },
+  { value: "indigo", label: "靛蓝", color: "#525EE9" },
+  { value: "gray", label: "灰色", color: "#606774" },
+  { value: "orange", label: "橙色", color: "#F86724" },
+  { value: "cyan", label: "青色", color: "#00C853" },
+]);
+
+const colorMap = new Map();
+colorList.value.forEach((item) => {
+  colorMap.set(item.value, item);
+});
+
+const editDeptRef = ref();
+const tableData = ref([]);
+const loading = ref(false);
+const param = reactive({
+  name: "",
+});
+
+const getData = () => {
+  loading.value = true;
+  api.tags
+    .getTree(param)
+    .then((res: any) => {
+      tableData.value = res?.data || [];
+    })
+    .finally(() => (loading.value = false));
+};
+
+// 打开新增菜单弹窗
+const addOrEdit = (row?: any) => {
+  editDeptRef.value.openDialog(row);
+};
+// 删除当前行
+const onTabelRowDel = (row: any) => {
+  ElMessageBox.confirm(`此操作将永久删除标签:${row.name}, 是否继续?`, "提示", {
+    confirmButtonText: "删除",
+    cancelButtonText: "取消",
+    type: "warning",
+  }).then(() => {
+    api.tags.del(row.id).then(() => {
+      ElMessage.success("删除成功");
+      getData();
+    });
+  });
+};
+// 页面加载时
+onMounted(() => {
+  getData();
+});
+</script>
+<style lang="scss" scoped>
+.color {
+  width: 12px;
+  height: 12px;
+  border-radius: 50%;
+  margin-right: 5px;
+}
+</style>