瀏覽代碼

数据审核

Xiao_123 10 小時之前
父節點
當前提交
b2bebc1eff

+ 16 - 0
src/api/dataDeduplication.js

@@ -0,0 +1,16 @@
+import http from '@/utils/request'
+
+// 审核记录列表
+export function dataReviewList (params) {
+  return http.post('/meta/review/list', params)
+}
+
+// 审核记录详情
+export function dataReviewDetail (id) {
+  return http.get(`/meta/review/detail?id=${id}`)
+}
+
+// 处理审核记录
+export function dataReviewResolve (params) {
+  return http.post('/meta/review/resolve', params)
+}

+ 4 - 4
src/router/routes.js

@@ -2007,20 +2007,20 @@ export default {
           hidden: 1,
           icon: '',
           type: 1,
-          title: '数据去重',
+          title: '数据审核',
           path: '/dataDeduplication',
           children: [],
           enName: 'data Deduplication',
           redirect: '',
           active: '',
-          label: '数据去重',
+          label: '数据审核',
           sort: 0,
           component: 'dataDeduplication',
           meta: {
             roles: [],
             enName: 'data Deduplication',
             icon: '',
-            title: '数据去重',
+            title: '数据审核',
             fullScreen: false
           },
           name: 'index',
@@ -2037,7 +2037,7 @@ export default {
         roles: [],
         enName: 'data Deduplication',
         icon: 'mdi-widgets-outline',
-        title: '数据去重',
+        title: '数据审核',
         target: false,
         effectiveStatus: true
       },

+ 160 - 0
src/views/dataDeduplication/components/change.vue

@@ -0,0 +1,160 @@
+<template>
+  <div>
+    <m-card title="元数据新旧差异" class="pb-3">
+      <m-table
+        class="candidate-table"
+        :headers="headers"
+        :items="items"
+        :loading="false"
+        :is-tools="false"
+        :disable-sort="true"
+        :show-select="false"
+        :can-delete="false"
+        :show-page="false"
+        :items-per-page="-1"
+        :no-radius="true"
+      >
+      </m-table>
+    </m-card>
+
+    <m-card title="影响关系图谱" bodyStyle="flex: 1" style="height: 800px;" class="d-flex flex-column mt-3">
+      <Graph :graphData="info.impact_graph"></Graph>
+    </m-card>
+  </div>
+</template>
+
+<script>
+import MCard from '@/components/MCard'
+import MTable from '@/components/List/table.vue'
+import Graph from './graph.vue'
+
+export default {
+  name: 'changeComponent',
+  components: {
+    MCard,
+    MTable,
+    Graph
+  },
+  props: {
+    info: {
+      type: Object,
+      default: () => ({})
+    }
+  },
+  data () {
+    return {
+      selectedCandidateMetaId: null
+    }
+  },
+  computed: {
+    newMetaSnapshot () {
+      if (!this.info || !this.info.new_meta) {
+        return {}
+      }
+      return this.info.new_meta
+    },
+    headers () {
+      return [
+        { text: '', align: 'start', value: 'title' },
+        { text: '元数据(新)', align: 'start', value: 'new' },
+        { text: '元数据(旧)', align: 'start', value: 'old' }
+      ]
+    },
+    items () {
+      if (!this.info || !this.info.new_meta || !this.info.old_meta) {
+        return []
+      }
+      return [
+        {
+          title: '中文名',
+          new: this.info.new_meta.name_zh || '-',
+          old: this.info.old_meta.snapshot.name_zh || '-'
+        },
+        {
+          title: '英文名',
+          new: this.info.new_meta.name_en || '-',
+          old: this.info.old_meta.snapshot.name_en || '-'
+        },
+        {
+          title: '数据类型',
+          new: this.info.new_meta.data_type || '-',
+          old: this.info.old_meta.snapshot.data_type || '-'
+        },
+        {
+          title: '数据标签',
+          new: this.info.new_meta.tag_ids?.join(', ') || '-',
+          old: this.info.old_meta.snapshot.tag_ids?.join(', ') || '-'
+        }
+      ]
+    }
+  },
+  methods: {
+    handleSelect (candidateMetaId) {
+      this.selectedCandidateMetaId = candidateMetaId
+      this.$emit('selectCandidateMeta', candidateMetaId)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.snapshot-card {
+  background: #f5f3f3;
+  border-radius: 6px;
+  padding: 16px 20px;
+  box-shadow: 0 0 0 1px #eee;
+}
+
+.snapshot-title {
+  font-weight: 700;
+  margin-bottom: 16px;
+  // font-size: 14px;
+  color: #333;
+}
+
+.snapshot-wrapper {
+  display: flex;
+  align-items: flex-start;
+}
+
+.snapshot-labels,
+.snapshot-values {
+  list-style: none;
+  padding: 0;
+  margin: 0;
+  font-size: 15px;
+  line-height: 1.6;
+}
+
+.snapshot-labels {
+  // flex: 0 0 90px;
+  text-align: right;
+  margin-right: 12px;
+  color: #403b3b;
+  // font-weight: 700;
+}
+
+.snapshot-values {
+  flex: 1;
+  word-break: break-all;
+}
+
+.snapshot-labels li,
+.snapshot-values li {
+  margin: 6px 0;
+}
+
+.candidate-table thead th {
+  background-color: #f5f5f5;
+  font-weight: 600;
+  font-size: 13px;
+}
+
+.candidate-table tbody tr:hover {
+  background-color: #f9f9f9;
+}
+
+.candidate-table td {
+  font-size: 13px;
+}
+</style>

+ 0 - 202
src/views/dataDeduplication/components/edit.vue

@@ -1,202 +0,0 @@
-<template>
-  <m-form ref="form" class="mt-5" :items="formItems" v-model="formValues">
-    <template #name_en>
-      <v-btn color="primary" class="ml-3" @click="getTranslate" :loading="translateLoading">翻译</v-btn>
-    </template>
-    <template #tag>
-      <div class="mb-6" style="border: 1px dashed #666; padding: 10px; border-radius: 5px; width: 100%;">
-        <v-chip-group
-          v-if="tagItems.length"
-          v-model="formValues.tag"
-          multiple
-          column
-          active-class="primary--text"
-        >
-          <v-chip v-for="tag in tagItems" :key="tag.id" filter :value="tag">
-            {{ tag.name_zh }}
-          </v-chip>
-        </v-chip-group>
-        <div v-else class="text-center" style="color: #999; padding: 10px;">暂无可选标签</div>
-      </div>
-    </template>
-  </m-form>
-</template>
-
-<script>
-import MForm from '@/components/MForm'
-import {
-  getTranslate
-} from '@/api'
-
-import { metadata } from '@/utils/dataGovernance'
-import { api } from '@/api/dataGovernance'
-
-export default {
-  name: 'metadata-edit',
-  components: { MForm },
-  props: {
-    itemData: {
-      type: Object,
-      default: () => ({})
-    }
-  },
-  data () {
-    return {
-      translateLoading: false,
-      formValues: {
-        name_zh: null,
-        name_en: null,
-        category: null,
-        alias: null,
-        affiliation: null,
-        data_type: null,
-        tag: [],
-        describe: null,
-        status: 1
-      },
-      pageInfo: {
-        size: 50,
-        current: 1
-      },
-      total: 0,
-      loading: false,
-      tagItems: []
-    }
-  },
-  computed: {
-    formItems () {
-      return [
-        {
-          type: 'text',
-          key: 'name_zh',
-          label: '请输入中文名称 *',
-          rules: [v => !!v || '请输入中文名称']
-        },
-        {
-          type: 'text',
-          key: 'name_en',
-          label: '请输入英文名称 *',
-          rules: [v => !!v || '请输入英文名称'],
-          slotName: 'name_en'
-        },
-        {
-          type: 'autocomplete',
-          key: 'category',
-          label: '请选择分类 *',
-          col: 6,
-          noAttach: true,
-          rules: [v => !!v || '请选择分类'],
-          items: [...metadata]
-        },
-        {
-          type: 'text',
-          key: 'data_type',
-          label: '请输入数据类型 *',
-          col: 6,
-          rules: [v => !!v || '请输入数据类型']
-        },
-        {
-          type: 'text',
-          key: 'alias',
-          label: '请输入别名'
-        },
-        {
-          type: 'text',
-          key: 'affiliation',
-          label: '请输入制作单位'
-        },
-        {
-          // type: 'autocomplete',
-          slotName: 'tag',
-          key: 'tag',
-          // value: [],
-          slotTitle: '请选择标签',
-          slotTitleStyle: 'color: rgba(0, 0, 0, 0.6); padding: 5px'
-          // label: '请选择标签',
-          // returnObject: true,
-          // multiple: true,
-          // noAttach: true,
-          // itemText: 'name_zh',
-          // itemValue: 'id',
-          // items: this.tagItems
-        },
-        {
-          type: 'text',
-          key: 'describe',
-          label: '请输入描述'
-        },
-        {
-          type: 'ifRadio',
-          key: 'status',
-          label: '启用',
-          width: 120,
-          items: [{ label: '是', value: true }, { label: '否', value: false }]
-        }
-      ]
-    }
-  },
-  created () {
-    this.init()
-    if (!Object.keys(this.itemData).length) {
-      return
-    }
-    this.formValues = this.formItems.reduce((acc, cur) => {
-      acc[cur.key] = this.itemData[cur.key] ?? this.formValues[cur.key]
-      return acc
-    }, {})
-  },
-  methods: {
-    async init () {
-      try {
-        this.loading = true
-        try {
-          const { data } = await api.getLabelList({
-            ...this.pageInfo,
-            category_filter: 'DataOps'
-          })
-          this.tagItems = data.records
-          this.total = data.total
-        } catch (error) {
-          this.$snackbar.error(error)
-        } finally {
-          this.loading = false
-        }
-      } catch (error) {
-        this.$snackbar.error(error)
-      }
-    },
-    async getTranslate () {
-      if (!this.formValues.name_zh) {
-        this.$snackbar.error('请输入名称')
-        return
-      }
-      this.translateLoading = true
-      try {
-        const { data } = await getTranslate({
-          node_name: this.formValues.name_zh
-        })
-        this.formValues.name_en = data.translated
-      } catch (error) {
-        this.$snackbar.error(error)
-      } finally {
-        this.translateLoading = false
-      }
-    },
-    getValue () {
-      if (!this.$refs.form.validate()) {
-        return
-      }
-      const id = this.itemData.id
-      return {
-        ...this.formValues,
-        id
-      }
-    }
-  }
-
-}
-</script>
-
-<style lang="scss" scoped>
-
-</style>

+ 276 - 0
src/views/dataDeduplication/components/graph.vue

@@ -0,0 +1,276 @@
+<template>
+  <div
+    style="height:100%; position: relative; font-size: 16px;"
+    v-loading="loading"
+    class="d-flex align-center justify-center"
+  >
+    <MEmpty v-if="empty"></MEmpty>
+    <relation-graph
+      v-show="!empty"
+      ref="graphRef"
+      :options="graphOptions"
+      :on-node-click="onNodeClick"
+      :on-line-click="onLineClick"
+    >
+    </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
+}
+// }
+export default {
+  name: 'details-graph',
+  components: { RelationGraph, MEmpty },
+  props: {
+    graphData: {
+      type: Object,
+      default: () => ({})
+    }
+  },
+  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
+        },
+        DataSource: {
+          color: '#4CAF50',
+          title: '数据源',
+          className: 'modelNode',
+          ...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
+        }
+      }
+    }
+  },
+  computed: {
+    legend () {
+      return Object.values(this.config)
+    }
+  },
+  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.init(() => {
+        this.menu.activeId = +this.menu.item.id
+      })
+    },
+    async init (successCallback = () => {}) {
+      // 清空再渲染
+      this.loading = true
+      this.empty = false
+      try {
+        this.graphOptions.downloadImageFileName = this.graphData.rootId ?? ''
+
+        this.graphData.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])
+        })
+
+        this.$nextTick(() => {
+          this.$refs.graphRef.setOptions(this.graphOptions, async (graphInstance) => {
+            if (!this.$refs.graphRef || !this.$refs.graphRef.setJsonData) {
+              return
+            }
+            this.$refs.graphRef.setJsonData(this.graphData, async (_graphInstance) => {
+              await _graphInstance.setZoom(75)
+              successCallback()
+              this.loading = false
+            })
+          })
+        })
+      } catch (error) {
+        this.empty = true
+        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;
+  // background: #000 !important;
+  // border: 2px solid #000;
+}
+.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>

+ 191 - 0
src/views/dataDeduplication/components/redundancy.vue

@@ -0,0 +1,191 @@
+<template>
+  <!-- <m-card class="mb-3 pa-3" no-title> -->
+    <v-row>
+      <!-- 左侧:新解析元数据快照 new_meta -->
+      <v-col cols="5">
+        <m-card title="新解析元数据信息">
+          <div class="snapshot-wrapper mt-3">
+            <ul class="snapshot-labels">
+              <li>中文名称:</li>
+              <li>英文名称:</li>
+              <li>数据类型:</li>
+              <li>数据标签:</li>
+            </ul>
+            <ul class="snapshot-values">
+              <li>{{ newMetaSnapshot.name_zh || '-' }}</li>
+              <li>{{ newMetaSnapshot.name_en || '-' }}</li>
+              <li>{{ newMetaSnapshot.data_type || '-' }}</li>
+              <li>
+                <span v-if="Array.isArray(newMetaSnapshot.tag_ids) && newMetaSnapshot.tag_ids.length">
+                  {{ newMetaSnapshot.tag_ids.join(', ') }}
+                </span>
+                <span v-else>-</span>
+              </li>
+            </ul>
+          </div>
+        </m-card>
+      </v-col>
+
+      <!-- 右侧:候选列表 candidates -->
+      <v-col cols="7">
+        <m-card title="候选元数据列表">
+          <m-table
+            class="candidate-table"
+            :headers="candidateHeaders"
+            :items="candidateList"
+            :loading="false"
+            :is-tools="false"
+            :disable-sort="true"
+            :show-select="false"
+            :can-delete="false"
+            :show-page="false"
+            :items-per-page="-1"
+            :no-radius="true"
+          >
+            <template #name_zh="{ item }">
+              {{ item.snapshot?.name_zh || '-' }}
+            </template>
+            <template #name_en="{ item }">
+              {{ item.snapshot?.name_en || '-' }}
+            </template>
+            <template #data_type="{ item }">
+              {{ item.snapshot?.data_type || '-' }}
+            </template>
+            <template #diff_fields="{ item }">
+              <span v-if="Array.isArray(item.diff_fields) && item.diff_fields.length">
+                {{ item.diff_fields.join(', ') }}
+              </span>
+              <span v-else>-</span>
+            </template>
+            <template #actions="{ item }">
+              <v-btn
+                class="mr-2"
+                :color="selectedCandidateMetaId === item.candidate_meta_id ? '' : 'success'"
+                text
+                :disabled="selectedCandidateMetaId === item.candidate_meta_id"
+                @click="handleSelect(item.candidate_meta_id)"
+                >
+                {{ selectedCandidateMetaId === item.candidate_meta_id ? '已选择' : '选择' }}
+              </v-btn>
+            </template>
+          </m-table>
+        </m-card>
+      </v-col>
+    </v-row>
+  <!-- </m-card> -->
+</template>
+
+<script>
+import MCard from '@/components/MCard'
+import MTable from '@/components/List/table.vue'
+
+export default {
+  name: 'redundancyComponent',
+  components: {
+    MCard,
+    MTable
+  },
+  props: {
+    info: {
+      type: Object,
+      default: () => ({})
+    }
+  },
+  data () {
+    return {
+      selectedCandidateMetaId: null
+    }
+  },
+  computed: {
+    newMetaSnapshot () {
+      if (!this.info || !this.info.new_meta) {
+        return {}
+      }
+      return this.info.new_meta
+    },
+    candidateList () {
+      if (!this.info || !Array.isArray(this.info.candidates)) {
+        return []
+      }
+      return this.info.candidates
+    },
+    candidateHeaders () {
+      return [
+        { text: '中文名称', align: 'start', value: 'name_zh' },
+        { text: '英文名称', align: 'start', value: 'name_en' },
+        { text: '数据类型', align: 'start', value: 'data_type' },
+        { text: '差异字段', align: 'start', value: 'diff_fields' },
+        { text: '操作', align: 'center', value: 'actions' }
+      ]
+    }
+  },
+  methods: {
+    handleSelect (candidateMetaId) {
+      this.selectedCandidateMetaId = candidateMetaId
+      this.$emit('selectCandidateMeta', candidateMetaId)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.snapshot-card {
+  background: #f5f3f3;
+  border-radius: 6px;
+  padding: 16px 20px;
+  box-shadow: 0 0 0 1px #eee;
+}
+
+.snapshot-title {
+  font-weight: 700;
+  margin-bottom: 16px;
+  // font-size: 14px;
+  color: #333;
+}
+
+.snapshot-wrapper {
+  display: flex;
+  align-items: flex-start;
+}
+
+.snapshot-labels,
+.snapshot-values {
+  list-style: none;
+  padding: 0;
+  margin: 0;
+  font-size: 15px;
+  line-height: 1.6;
+}
+
+.snapshot-labels {
+  // flex: 0 0 90px;
+  text-align: right;
+  margin-right: 12px;
+  color: #403b3b;
+  // font-weight: 700;
+}
+
+.snapshot-values {
+  flex: 1;
+  word-break: break-all;
+}
+
+.snapshot-labels li,
+.snapshot-values li {
+  margin: 6px 0;
+}
+
+.candidate-table thead th {
+  background-color: #f5f5f5;
+  font-weight: 600;
+  font-size: 13px;
+}
+
+.candidate-table tbody tr:hover {
+  background-color: #f9f9f9;
+}
+
+.candidate-table td {
+  font-size: 13px;
+}
+</style>

+ 208 - 50
src/views/dataDeduplication/index.vue

@@ -14,83 +14,174 @@
       :can-delete="false"
       @pageHandleChange="pageHandleChange"
     >
+      <template #record_type="{ item }">
+        {{ item.record_type === 'redundancy' ? '疑似冗余' : '疑似变动' }}
+      </template>
       <template #status="{ item }">
-        <v-chip small :color="item.status === 'active' ? 'success' : 'error'">
-          {{ item.status === 'active' ? '启用' : '禁用' }}
+        <v-chip small :color="item.status === 'pending' ? 'warning' : item.status === 'resolved' ? 'success' : ''">
+          {{ item.status === 'pending' ? '待处理' : item.status === 'resolved' ? '已处理' : '已忽略' }}
         </v-chip>
       </template>
+      <template #created_at="{ item }">
+        {{ formatDate(item.created_at) }}
+      </template>
       <template #actions="{ item }">
-        <v-btn color="primary" text class="mr-2" @click="onEdit(item)">编辑</v-btn>
-        <v-btn color="error" text  @click="onDelete(item)">删除</v-btn>
+        <v-btn color="success" text class="mr-2" @click="handleAction(item)">详情</v-btn>
+        <v-btn v-if="item.status === 'pending'" color="primary" text @click="handleAction(item)">处理</v-btn>
       </template>
     </m-table>
-    <m-dialog title="数据去重" :visible.sync="show" fullscreen :footer="false">
-      <edit-page v-if="show" ref="edit" :item-data="itemData" @success="show = false; init()"></edit-page>
-    </m-dialog>
+
+    <edit-dialog :visible.sync="show" title="数据审核详情" fullscreen :footer="false">
+      <!-- 疑似冗余 -->
+      <redundancy-component
+        v-if="info?.record_type === 'redundancy'"
+        :info="info"
+        @selectCandidateMeta="val => candidateMetaId = val"
+      />
+      <!-- 疑似变动 -->
+      <change-component
+        v-if="info?.record_type === 'change'"
+        :info="info"
+      />
+      <template v-if="info?.status === 'pending'" #footer>
+        <v-divider></v-divider>
+          <v-card-actions>
+            <v-spacer></v-spacer>
+            <v-btn v-if="info?.record_type === 'redundancy'" text color="pink" class="mr-2" @click="handleSubmit(info, 'alias')">设为别名</v-btn>
+            <v-btn v-if="info?.record_type === 'redundancy'" text color="primary" class="mr-2" @click="handleSubmit(info, 'create_new')">创建新元数据</v-btn>
+            <v-btn v-if="info?.record_type === 'change'" text color="success" class="mr-2" @click="handleSubmit(info, 'accept_change')">接受变动</v-btn>
+            <v-btn v-if="info?.record_type === 'change'" text color="error" class="mr-2" @click="handleSubmit(info, 'reject_change')">拒绝变动</v-btn>
+            <v-btn text color="warning" @click="handleSubmit(info, 'ignore')">忽略</v-btn>
+          </v-card-actions>
+      </template>
+    </edit-dialog>
+
+    <!-- 创建新元数据 -->
+    <edit-dialog :visible.sync="createNew.show" title="创建新元数据" @submit="handleCreateNew">
+      <v-text-field
+        v-model="createNew.name_zh"
+        label="中文名称"
+        outlined
+        clearable
+        dense
+        hide-details
+        placeholder="请输入元数据中文名称"
+      />
+    </edit-dialog>
   </div>
 </template>
 
 <script>
 import MFilter from '@/components/Filter'
 import MTable from '@/components/List/table.vue'
-import MDialog from '@/components/Dialog'
-import EditPage from './components/edit'
+import EditDialog from '@/components/Dialog'
+import RedundancyComponent from './components/redundancy.vue'
+import ChangeComponent from './components/change.vue'
+import { dataReviewList, dataReviewDetail, dataReviewResolve } from '@/api/dataDeduplication'
 import { api } from '@/api/dataGovernance'
 export default {
   name: 'dataProcess',
   components: {
     MFilter,
     MTable,
-    MDialog,
-    EditPage
+    EditDialog,
+    RedundancyComponent,
+    ChangeComponent
   },
   data () {
     return {
-      loading: false,
       show: false,
+      loading: false,
       filter: {
         list: [
-          { type: 'textField', value: '', label: '关键词', key: 'title' }
+          {
+            type: 'textField',
+            value: '',
+            label: '关键词',
+            key: 'keyword'
+          },
+          {
+            type: 'autocomplete',
+            value: null,
+            label: '业务域',
+            itemValue: 'id',
+            itemText: 'name_zh',
+            key: 'business_domain_id',
+            items: []
+          },
+          {
+            type: 'autocomplete',
+            value: null,
+            label: '记录类型',
+            key: 'record_type',
+            items: [{ label: '疑似冗余', value: 'redundancy' }, { label: '疑似变动', value: 'change' }]
+          },
+          {
+            type: 'autocomplete',
+            value: 'pending',
+            label: '状态',
+            key: 'status',
+            items: [{ label: '待处理', value: 'pending' }, { label: '已处理', value: 'resolved' }, { label: '已忽略', value: 'ignored' }]
+          }
         ]
       },
       queryData: {
-        title: null
+        keyword: null,
+        status: 'pending',
+        business_domain_id: null,
+        record_type: null
       },
       headers: [
-        { text: '中文名称', align: 'start', value: 'name_zh' },
-        { text: '英文名称', align: 'start', value: 'name_en' },
+        { text: '中文名称', align: 'start', value: 'new_meta.name_zh' },
+        { text: '英文名称', align: 'start', value: 'new_meta.name_en' },
+        { text: '记录类型', align: 'center', value: 'record_type' },
         { text: '状态', align: 'center', value: 'status' },
-        { text: '分类', align: 'center', value: 'category' },
-        { text: '作者', align: 'center', value: 'leader' },
-        { text: '机构', align: 'center', value: 'organization' },
-        { text: '描述', align: 'start', value: 'describe' },
         { text: '创建时间', align: 'start', value: 'created_at' },
-        { text: '更新时间', align: 'start', value: 'updated_at' },
         { text: '操作', align: 'start', value: 'actions' }
       ],
-      itemData: {},
       items: [],
       pageInfo: {
         size: 10,
         current: 1
       },
-      total: 0
+      candidateMetaId: null,
+      total: 0,
+      info: {},
+      createNew: {
+        show: false,
+        name_zh: null,
+        params: {}
+      }
     }
   },
   created () {
-    // this.init()
+    this.getBusinessDomainList()
+    this.init()
   },
   methods: {
+    // 获取业务域列表
+    async getBusinessDomainList () {
+      try {
+        const { data } = await api.getBusinessDomainList2()
+        const businessDomain = this.filter.list.find(item => item.key === 'business_domain_id')
+        if (!data || !data?.length) {
+          businessDomain.items = []
+          return
+        }
+        businessDomain.items = data
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
     async init () {
       this.loading = true
       try {
-        const { data } = await api.getDataFlowList({
-          page: this.pageInfo.current,
-          page_size: this.pageInfo.size,
-          search: this.queryData.title || undefined
+        const { data } = await dataReviewList({
+          ...this.pageInfo,
+          ...this.queryData
         })
-        this.items = data.list
-        this.total = data.pagination.total
+        this.items = data.records
+        this.total = data.total
       } catch (error) {
         this.$snackbar.error(error)
       } finally {
@@ -102,29 +193,96 @@ export default {
       this.pageInfo.current = 1
       this.init()
     },
-    handleAdd () {
-      this.itemData = {}
-      this.show = true
-    },
-    async onEdit (item) {
-      this.itemData = item
-      this.show = true
-    },
-    onDelete ({ id }) {
-      this.$confirm('提示', '是否删除该选项')
-        .then(async () => {
-          try {
-            await api.deleteDataFlow(id)
-            this.$snackbar.success('删除成功')
-            this.init()
-          } catch (error) {
-            this.$snackbar.error('删除失败')
-          }
-        })
-    },
     pageHandleChange (page) {
       this.pageInfo.current = page
       this.init()
+    },
+    // 详情、处理
+    async handleAction (item) {
+      try {
+        const { data } = await dataReviewDetail(item.id)
+        this.info = data || {}
+        this.show = true
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    // 作为新元数据创建
+    async handleCreateNew () {
+      if (!this.createNew.name_zh) {
+        this.$snackbar.warning('请输入元数据中文名称')
+        return
+      }
+      this.createNew.params.payload = {
+        new_name_zh: this.createNew.name_zh
+      }
+      try {
+        await dataReviewResolve(this.createNew.params)
+        this.$snackbar.success('操作成功')
+        this.createNew.show = false
+        this.show = false
+        this.init()
+      } catch (error) {
+        this.$snackbar.error(error.message)
+      } finally {
+        this.createNew = {
+          name_zh: null,
+          params: {},
+          show: false
+        }
+      }
+    },
+    // 设为别名、接受变动、拒绝变动、忽略
+    async handleSubmit (item, action) {
+      const params = {
+        id: item.id,
+        action: action,
+        resolved_by: this.$store.getters.userInfo.username,
+        notes: ''
+      }
+      // 作为新元数据创建
+      if (action === 'create_new') {
+        this.createNew = {
+          show: true,
+          name_zh: item.new_meta.name_zh,
+          params
+        }
+        return
+      }
+      // 设为某候选元数据别名
+      if (action === 'alias') {
+        if (!this.candidateMetaId) {
+          this.$snackbar.warning('请选择候选元数据')
+          return
+        }
+        params.payload = {
+          candidate_meta_id: this.candidateMetaId
+        }
+      } else if (action === 'accept_change') { // 接受变动
+        params.payload = {
+          meta_id: item?.old_meta?.meta_id
+        }
+      }
+      try {
+        await dataReviewResolve(params)
+        this.$snackbar.success('操作成功')
+        this.show = false
+        this.init()
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    formatDate (date) {
+      if (!date) return '-'
+      const d = new Date(date)
+      if (isNaN(d.getTime())) return date
+      const year = d.getFullYear()
+      const month = String(d.getMonth() + 1).padStart(2, '0')
+      const day = String(d.getDate()).padStart(2, '0')
+      const hours = String(d.getHours()).padStart(2, '0')
+      const minutes = String(d.getMinutes()).padStart(2, '0')
+      const seconds = String(d.getSeconds()).padStart(2, '0')
+      return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
     }
   }
 }