Sfoglia il codice sorgente

数据产品:数据预览可视化图谱

Xiao_123 15 ore fa
parent
commit
ed30204c10

+ 5 - 1
src/api/dataService.js

@@ -43,8 +43,12 @@ const dataService = {
   // 注册数据产品
   registerProduct (data) {
     return http.post('/dataservice/products', data)
+  },
+
+  // 加工数据可视化
+  getVisualizeData (productId, data) {
+    return http.post(`/dataservice/products/${productId}/lineage-visualization`, data)
   }
 }
 
 export const api = dataService
-export default dataService

+ 127 - 4
src/views/dataService/dataProduct/components/PreviewDialog.vue

@@ -1,5 +1,6 @@
 <template>
-  <m-dialog :visible="visible" title="数据预览" widthType="1" :footer="false" max-width="90%" @update:visible="handleVisibleChange">
+  <div>
+    <MDialog :visible="visible" title="数据预览" widthType="1" :footer="false" max-width="90%" @update:visible="handleVisibleChange">
     <div v-if="previewData.product" class="mb-4">
       <v-card outlined>
         <v-card-text>
@@ -53,6 +54,7 @@
                   <div>{{ col.name }}</div>
                 </div>
               </th>
+              <th class="text-center" width="100">操作</th>
             </tr>
           </thead>
           <tbody>
@@ -60,6 +62,9 @@
               <td v-for="col in displayColumns" :key="col.name">
                 {{ row[col.name] }}
               </td>
+              <td class="text-center">
+                <v-btn text color="primary" @click="handleVisualize(row)">可视化</v-btn>
+              </td>
             </tr>
           </tbody>
         </template>
@@ -87,16 +92,33 @@
         <v-btn text @click="handleClose">关闭</v-btn>
       </v-card-actions>
     </template>
-  </m-dialog>
+    </MDialog>
+    <!-- 可视化图谱弹窗 -->
+    <MDialog
+      :visible="graphDialogVisible"
+      title="数据加工可视化"
+      widthType="1"
+      :footer="false"
+      max-width="95%"
+      @update:visible="handleGraphDialogVisibleChange"
+    >
+      <div style="height: 80vh;">
+        <m-graph :graph-data="graphData"></m-graph>
+      </div>
+    </MDialog>
+  </div>
 </template>
 
 <script>
 import MDialog from '@/components/Dialog'
+import mGraph from './mGraph.vue'
+import { api } from '@/api/dataService'
 
 export default {
   name: 'PreviewDialog',
   components: {
-    MDialog
+    MDialog,
+    mGraph
   },
   props: {
     visible: {
@@ -125,7 +147,9 @@ export default {
   },
   data () {
     return {
-      selectedColumns: []
+      selectedColumns: [],
+      graphDialogVisible: false,
+      graphData: null
     }
   },
   computed: {
@@ -174,6 +198,105 @@ export default {
     },
     handleClose () {
       this.$emit('update:visible', false)
+    },
+    // 数据可视化
+    async handleVisualize (row) {
+      try {
+        const { data } = await api.getVisualizeData(this.previewData.product.id, { sample_data: row })
+        this.graphData = data
+        this.graphDialogVisible = true
+      } catch (error) {
+        this.$snackbar.error(error.message)
+      }
+
+      // const data = {
+      //   nodes: [
+      //     {
+      //       id: 212,
+      //       name_zh: '用户标签库',
+      //       name_en: 'user_tag_library',
+      //       node_type: 'BusinessDomain',
+      //       labels: ['BusinessDomain'],
+      //       is_target: true,
+      //       is_source: false,
+      //       matched_fields: [
+      //         {
+      //           field_name: '用户ID',
+      //           field_name_en: 'user_id',
+      //           data_type: 'integer',
+      //           value: 12345,
+      //           meta_id: 234
+      //         },
+      //         {
+      //           field_name: '姓名',
+      //           field_name_en: 'name',
+      //           data_type: 'string',
+      //           value: '张三',
+      //           meta_id: 235
+      //         }
+      //       ]
+      //     },
+      //     {
+      //       id: 183,
+      //       name_zh: '用户标签生成',
+      //       name_en: 'user_tag_generate',
+      //       node_type: 'DataFlow',
+      //       labels: ['DataFlow'],
+      //       is_target: false,
+      //       is_source: false
+      //     },
+      //     {
+      //       id: 159,
+      //       name_zh: '用户画像',
+      //       name_en: 'user_profile',
+      //       node_type: 'BusinessDomain',
+      //       labels: ['BusinessDomain'],
+      //       is_target: false,
+      //       is_source: false,
+      //       matched_fields: [
+      //         {
+      //           field_name: '用户ID',
+      //           field_name_en: 'user_id',
+      //           data_type: 'integer',
+      //           value: 12345,
+      //           meta_id: 234
+      //         }
+      //       ]
+      //     },
+      //     {
+      //       id: 154,
+      //       name_zh: '用户基础数据',
+      //       name_en: 'user_base_info',
+      //       node_type: 'DataResource',
+      //       labels: ['DataResource', 'BusinessDomain'],
+      //       is_target: false,
+      //       is_source: true,
+      //       matched_fields: [
+      //         {
+      //           field_name: '用户ID',
+      //           field_name_en: 'user_id',
+      //           data_type: 'integer',
+      //           value: 12345,
+      //           meta_id: 234
+      //         }
+      //       ]
+      //     }
+      //   ],
+      //   lines: [
+      //     { from: '183', to: '212', text: 'OUTPUT' },
+      //     { from: '159', to: '183', text: 'INPUT' },
+      //     { from: '156', to: '159', text: 'OUTPUT' },
+      //     { from: '154', to: '156', text: 'INPUT' }
+      //   ],
+      //   lineage_depth: 2
+      // }
+    },
+    handleGraphDialogVisibleChange (val) {
+      this.graphDialogVisible = val
+      if (!val) {
+        // 关闭时清空数据
+        this.graphData = null
+      }
     }
   }
 }

+ 392 - 0
src/views/dataService/dataProduct/components/mGraph.vue

@@ -0,0 +1,392 @@
+<template>
+  <div
+    style="height:100%; position: relative; font-size: 16px;"
+    v-loading="loading"
+    class="d-flex align-center justify-center"
+  >
+    <div class="legend d-flex pa-3 justify-space-between">
+      <!-- 图例 -->
+      <div class="d-flex align-center">
+        <div
+          v-for="item in legend"
+          :key="item.title"
+          class="d-flex mr-5"
+        >
+          <div class="pa-3 mr-3 rounded-circle" :style="`background-color: ${item.color};`"></div>
+          {{ item.title }}
+        </div>
+      </div>
+    </div>
+    <MEmpty v-if="empty"></MEmpty>
+    <relation-graph
+      v-show="!empty"
+      ref="graphRef"
+      :options="graphOptions"
+      :on-node-click="onNodeClick"
+      :on-line-click="onLineClick"
+    >
+      <template #node="{node}">
+        <v-tooltip left :color="node.color || 'primary'">
+          <template v-slot:activator="{ on, attrs }">
+            <div @contextmenu.prevent="handleContextmenu($event, node)" v-bind="attrs" v-on="on">
+              <div
+                :style="{ width: node.width + 'px', height: node.height + 'px' }"
+                :class="{ 'node-active':  menu.activeId === +node.id }"
+                class="rounded-circle"
+              >
+              </div>
+              <div
+                :style="{ 'background-color': node.color + '44', width: 20 * ((node.text || '').length) + 'px' }"
+                class="node-text"
+                :class="{ 'node-active':  menu.activeId === +node.id }"
+              >
+                {{ node.text || '' }}
+              </div>
+            </div>
+          </template>
+          <!-- 浮窗展示节点详情 -->
+          <div class="d-flex">
+            <ul style="color: #fff;text-align:right;">
+              <template v-for="key in Object.keys(detailKeys)">
+                <li v-if="node.data[key]" :key="key">{{ detailKeys[key] }} :</li>
+              </template>
+            </ul>
+            <ul>
+              <template v-for="key in Object.keys(detailKeys)">
+                <li v-if="node.data[key]" :key="key">
+                  <span v-if="key === 'labels'">{{ node.data[key].join(',') }}</span>
+                  <span v-else-if="key === 'matched_fields'">{{ node.data[key].map(item => item.field_name).join(',') }}</span>
+                  <span v-else>{{ node.data[key] }}</span>
+                </li>
+              </template>
+            </ul>
+          </div>
+        </v-tooltip>
+      </template>
+      <template #graph-plug>
+        <v-menu v-model="menu.show" attach :position-x="menu.x" :position-y="menu.y" absolute offset-y min-width="200">
+          <v-list dense>
+            <v-list-item v-for="(k, i) in menu.items" :key="i" @click="k.handle">
+              <v-list-item-title>{{ k.title }}</v-list-item-title>
+            </v-list-item>
+          </v-list>
+        </v-menu>
+      </template>
+    </relation-graph>
+
+  </div>
+</template>
+
+<script>
+import RelationGraph from 'relation-graph'
+import MEmpty from '@/components/Common/empty'
+const defaultNodeColor = 'rgba(238, 178, 94, 1)'
+const NODES_SIZE = {
+  width: 30,
+  height: 30
+}
+// const LINE_COLOR_MAP = {
+//   包含: '#F44336', // red
+//   影响: '#E91E63', // pink
+//   依赖: '#3F51B5', // indigo
+//   来源: '#4CAF50', // green
+//   引用: '#9C27B0', // purple
+//   继承: '#673AB7', // deep-purple
+//   标记: '#2196F3', // blue
+//   使用: '#03A9F4', // light-blue
+//   关联: '#00BCD4', // cyan
+//   拥有: '#009688', // teal
+//   下级: '#8BC34A', // light-green
+//   上级: '#CDDC39', // lime
+//   联动: '#FF9800' // orange
+// }
+export default {
+  name: 'details-graph',
+  components: { RelationGraph, MEmpty },
+  props: {
+    graphData: {
+      type: Object,
+      default: null
+    }
+  },
+  data () {
+    return {
+      menu: {
+        x: 0,
+        y: 0,
+        show: false,
+        items: [
+          { title: '查看', handle: this.handleView }
+        ],
+        activeId: +this.$route.params.id,
+        item: {}
+      },
+      empty: true,
+      loading: false,
+      graphOptions: {
+        // defaultJunctionPoint: 'lr',
+        // 这里可以参考"Graph 图谱"中的参数进行设置 https://www.relation-graph.com/#/docs/graph
+        debug: false, // 是否开始调试模式,调试模式下会在控制台打印额外的日志信息
+        showDebugPanel: false, // 是否显示调试按钮,通过此按钮可以打印配置、数据等
+        backgroundImage: '', // 图谱水印url,如:https://ssl.relation-graph.com/images/relatioon-graph-canvas-bg.png
+        downloadImageFileName: '', // 下载图片时,图片的名称
+        disableZoom: false, // 是否禁用图谱的缩放功能
+        disableDragNode: false, // 是否禁用图谱中节点的拖动
+        moveToCenterWhenRefresh: true, // 当图谱刷新后(调用setJsonData或refresh方法都会触发),让图谱根据节点居中(图片会默认将根节点作为中心展示,此选项会根据节点分布寻找中心)
+        zoomToFitWhenRefresh: true, // 当图谱刷新后(调用setJsonData或refresh方法都会触发),是否让图谱缩放到适合可见区域大小,此选项不适用于fixed和force布局
+        useAnimationWhenRefresh: true, // 当图谱刷新后(调用setJsonData或refresh方法都会触发),使用动画让图居中、缩放
+        useAnimationWhenExpanded: true,
+        defaultFocusRootNode: true, // 默认为根节点添加一个被选中的样式
+        disableNodeClickEffect: false, // 是否禁用节点默认的点击效果(选中、闪烁)
+        disableLineClickEffect: false, // 是否禁用线条默认的点击效果(选中、闪烁)
+        allowShowZoomMenu: true, // 是否在右侧菜单栏显示放大缩小的按钮,此设置和disableZoom不冲突
+        allowAutoLayoutIfSupport: true, // 是否在工具栏中显示【自动布局】按钮(只有在布局支持且此选项为true时才会显示的按钮)
+        allowShowRefreshButton: true, // 是否在工具栏中显示【刷新】按钮
+        allowShowDownloadButton: true, // 是否在工具栏中显示【下载图片】按钮
+        backgroundImageNoRepeat: false, // 只在右下角显示水印,不重复显示水印
+        allowSwitchLineShape: true, // 是否在工具栏中显示切换线条形状的按钮
+        allowSwitchJunctionPoint: true, // 是否在工具栏中显示切换连接点位置的按钮
+        isMoveByParentNode: false, // 是否在拖动节点后让子节点跟随
+        defaultExpandHolderPosition: 'hide', // 默认的节点展开/关闭按钮位置(left/top/right/bottom/hide)
+        defaultNodeColor, // 默认的节点背景颜色
+        checkedLineColor: '#FD8B37', // 当线条被选中时的颜色
+        defaultNodeFontColor: '#ffffff', // 默认的节点文字颜色
+        defaultNodeBorderColor: '#90EE90', // 默认的节点边框颜色
+        defaultNodeBorderWidth: 0, // 默认的节点边框粗细(像素)
+        defaultLineColor: '#cccccc', // 默认的线条颜色
+        defaultLineWidth: 2, // 默认的线条粗细(像素)
+        defaultLineShape: 2, // 默认的线条样式(1:直线/2:样式2/3:样式3/4:折线/5:样式5/6:样式6)使用示例
+        defaultNodeShape: 0, // 默认的节点形状,0:圆形;1:矩形
+        defaultShowLineLabel: true, // 默认是否显示连线文字,v2版本此选项已无效,主要是这个选项没什么用
+        hideNodeContentByZoom: true, // 是否根据缩放比例隐藏节点内容
+        // disableDragCanvas: false,
+        // lineUseTextPath: false,
+        defaultLineMarker: { // 默认的线条箭头样式,示例参考:配置工具中的选项:连线箭头样式
+          markerWidth: 24,
+          markerHeight: 24,
+          refX: 6,
+          refY: 6,
+          data: 'M2,2 L10,6 L2,10 L6,6 L2,2'
+        },
+        layouts: [
+          {
+            label: '自动布局',
+            layoutName: 'center', // 布局方式(tree树状布局/center中心布局/force自动布局)
+            from: 'left',
+            maxLayoutTimes: 20,
+            layoutClassName: 'seeks-layout-force',
+            useLayoutStyleOptions: false,
+            defaultNodeColor: '#FFC5A6',
+            defaultNodeFontColor: '#000000',
+            defaultNodeBorderColor: '#efefef',
+            defaultNodeBorderWidth: 1,
+            defaultLineColor: '#FD8B37',
+            defaultLineWidth: 1,
+            defaultShowLineLabel: true,
+            defaultLineMarker: {
+              markerWidth: 12,
+              markerHeight: 12,
+              refX: 6,
+              refY: 6,
+              data: 'M2,2 L10,6 L2,10 L6,6 L2,2'
+            }
+          }
+        ]
+      },
+      config: {
+        BusinessDomain: {
+          color: '#9FA8DA',
+          title: '业务域',
+          className: 'sourceNode',
+          ...NODES_SIZE
+        },
+        DataResource: {
+          color: '#4CAF50',
+          title: '数据资源',
+          className: 'sourceNode',
+          ...NODES_SIZE
+        },
+        DataFlow: {
+          color: '#FF9800',
+          title: '数据流程',
+          className: 'dataFlowNode',
+          ...NODES_SIZE
+        },
+        data_standard: {
+          color: '#009688',
+          title: '数据标准',
+          className: 'standardNode',
+          ...NODES_SIZE
+        },
+        DataLabel: {
+          color: '#9C27B0',
+          title: '数据标签',
+          className: 'labelNode',
+          ...NODES_SIZE
+        },
+        DataMeta: {
+          color: defaultNodeColor,
+          title: '元数据',
+          className: '',
+          ...NODES_SIZE
+        }
+      },
+      // 节点详情
+      detailKeys: {
+        id: '节点ID',
+        name_zh: '中文名称',
+        name_en: '英文名称',
+        node_type: '节点类型',
+        labels: '节点标签',
+        is_target: '是否目标节点',
+        is_source: '是否源节点',
+        matched_fields: '匹配字段'
+      }
+    }
+  },
+  computed: {
+    legend () {
+      return Object.values(this.config)
+    }
+  },
+  watch: {
+    graphData: {
+      handler (newVal) {
+        if (newVal && newVal.nodes && newVal.nodes.length) {
+          this.drawWithData(newVal)
+        } else if (newVal === null) {
+          // 当 graphData 被清空时,重置状态
+          this.empty = true
+          this.loading = false
+        }
+      },
+      immediate: false,
+      deep: true
+    }
+  },
+  mounted () {
+    this.init()
+  },
+  methods: {
+    handleContextmenu (v, node) {
+      const { left, top } = this.$refs.graphRef.$el.getBoundingClientRect()
+      this.menu.x = v.clientX - left
+      this.menu.y = v.clientY - top
+      this.menu.item = node
+      this.menu.show = true
+    },
+    handleView () {
+      this.draw({ id: +this.menu.item.id }, () => {
+        this.menu.activeId = +this.menu.item.id
+      })
+    },
+    changeType () {
+      this.init()
+    },
+    handleClick (node) {
+      console.log(node)
+    },
+    async init () {
+      this.drawWithData(this.graphData)
+    },
+    drawWithData (data, successCallback = () => {}) {
+      // 清空再渲染
+      this.loading = true
+      this.empty = false
+      try {
+        if (!data.nodes || !data.nodes.length) {
+          this.empty = true
+          this.loading = false
+          return
+        }
+        this.graphOptions.downloadImageFileName = data.rootId ?? ''
+
+        data.nodes.forEach(ele => {
+          if (!this.config[ele.node_type]) {
+            return
+          }
+          ele.text = ele.name_zh || ele.name_en
+          ele.data = { ...ele }
+          Object.assign(ele, this.config[ele.node_type])
+        })
+
+        // data.lines.forEach(ele => {
+        //   ele.color = LINE_COLOR_MAP[ele.text]
+        // })
+        this.$nextTick(() => {
+          this.$refs.graphRef.setOptions(this.graphOptions, async (graphInstance) => {
+            if (!this.$refs.graphRef || !this.$refs.graphRef.setJsonData) {
+              return
+            }
+            this.$refs.graphRef.setJsonData(data, async (_graphInstance) => {
+              await _graphInstance.setZoom(75)
+              successCallback()
+              this.loading = false
+            })
+          })
+        })
+      } catch (error) {
+        this.empty = true
+        this.loading = false
+        this.$snackbar.error(error)
+      }
+    },
+    onNodeClick (nodeObject, $event) {
+      console.log('onNodeClick:', nodeObject)
+    },
+    onLineClick (lineObject, $event) {
+      console.log('onLineClick:', lineObject)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+ul{
+  list-style: none;
+}
+.legend {
+  pointer-events: none;
+  width: 100%;
+  user-select: none;
+  -moz-user-select: none;
+  -webkit-user-select: none;
+  -ms-user-select: none;
+  position: absolute;
+  top: 0;
+  z-index: 10;
+  color: #666;
+  .rounded-circle {
+    width: 24px;
+    height: 24px;
+  }
+}
+.pointerEvents {
+  pointer-events: auto;
+}
+::v-deep .rel-node-checked {
+  box-shadow: unset !important;
+}
+.node-active {
+  box-shadow: 0 0 0px 6px #f3f900 !important;
+}
+.node-text {
+  color: #000;
+  font-size: 16px;
+  position: absolute;
+  height:25px;
+  transform: translate(-50%, 0);
+  line-height: 25px;
+  left: 50%;
+  margin-top:5px;
+  text-align: center;
+}
+
+::v-deep .rel-toolbar {
+  background-color: #f39930;
+  color: #ffffff;
+  .c-current-zoom {
+    color: #ffffff;
+  }
+}
+</style>