Xiao_123 il y a 18 heures
Parent
commit
13068282ba

+ 50 - 0
src/api/dataService.js

@@ -0,0 +1,50 @@
+import http from '@/utils/request'
+
+const dataService = {
+  // 获取数据产品列表
+  getProducts (params) {
+    return http.get('/dataservice/products', params)
+  },
+
+  // 获取数据产品详情
+  getProductDetail (productId) {
+    return http.get(`/dataservice/products/${productId}`)
+  },
+
+  // 获取数据预览
+  getProductPreview (productId, limit = 200) {
+    return http.get(`/dataservice/products/${productId}/preview`, {
+      limit
+    })
+  },
+
+  // 下载 Excel (返回 Blob)
+  downloadExcel (productId, limit = 200) {
+    return http.getDownload(`/dataservice/products/${productId}/download`, {
+      limit
+    })
+  },
+
+  // 标记为已查看
+  markAsViewed (productId) {
+    return http.post(`/dataservice/products/${productId}/viewed`)
+  },
+
+  // 刷新统计信息
+  refreshStats (productId) {
+    return http.post(`/dataservice/products/${productId}/refresh`)
+  },
+
+  // 删除数据产品
+  deleteProduct (productId) {
+    return http.del(`/dataservice/products/${productId}`)
+  },
+
+  // 注册数据产品
+  registerProduct (data) {
+    return http.post('/dataservice/products', data)
+  }
+}
+
+export const api = dataService
+export default dataService

+ 131 - 0
src/views/dataService/components/RegisterForm.vue

@@ -0,0 +1,131 @@
+<template>
+  <m-form ref="form" :items="formItems" v-model="formValues">
+  </m-form>
+</template>
+
+<script>
+import MForm from '@/components/MForm'
+
+export default {
+  name: 'RegisterForm',
+  components: { MForm },
+  props: {
+    itemData: {
+      type: Object,
+      default: () => ({})
+    }
+  },
+  data () {
+    return {
+      formValues: {
+        product_name: '',
+        product_name_en: '',
+        target_table: '',
+        target_schema: 'public',
+        description: '',
+        source_dataflow_id: null,
+        source_dataflow_name: ''
+      }
+    }
+  },
+  computed: {
+    formItems () {
+      return [
+        {
+          type: 'text',
+          key: 'product_name',
+          label: '产品名称 *',
+          placeholder: '请输入中文名称',
+          rules: [v => !!v || '请输入产品名称']
+        },
+        {
+          type: 'text',
+          key: 'product_name_en',
+          label: '英文名称 *',
+          placeholder: '请输入英文名称',
+          rules: [v => !!v || '请输入英文名称']
+        },
+        {
+          type: 'text',
+          key: 'target_table',
+          label: '目标表名 *',
+          placeholder: '例如: dwd_talent_info',
+          rules: [v => !!v || '请输入目标表名']
+        },
+        {
+          type: 'text',
+          key: 'target_schema',
+          label: 'Schema',
+          placeholder: '默认 public'
+        },
+        {
+          type: 'textarea',
+          key: 'description',
+          label: '描述',
+          placeholder: '请输入产品描述',
+          rows: 3
+        },
+        {
+          type: 'number',
+          key: 'source_dataflow_id',
+          label: '关联数据流ID'
+        },
+        {
+          type: 'text',
+          key: 'source_dataflow_name',
+          label: '关联数据流名称'
+        }
+      ]
+    }
+  },
+  watch: {
+    itemData: {
+      handler (newVal) {
+        if (newVal && Object.keys(newVal).length) {
+          this.formValues = {
+            product_name: newVal.product_name || '',
+            product_name_en: newVal.product_name_en || '',
+            target_table: newVal.target_table || '',
+            target_schema: newVal.target_schema || 'public',
+            description: newVal.description || '',
+            source_dataflow_id: newVal.source_dataflow_id || null,
+            source_dataflow_name: newVal.source_dataflow_name || ''
+          }
+        } else {
+          this.formValues = {
+            product_name: '',
+            product_name_en: '',
+            target_table: '',
+            target_schema: 'public',
+            description: '',
+            source_dataflow_id: null,
+            source_dataflow_name: ''
+          }
+        }
+      },
+      immediate: true,
+      deep: true
+    }
+  },
+  methods: {
+    validate () {
+      return this.$refs.form.validate()
+    },
+    resetValidation () {
+      if (this.$refs.form) {
+        this.$refs.form.resetValidation()
+      }
+    },
+    getValue () {
+      if (!this.$refs.form.validate()) {
+        return
+      }
+      const id = this.itemData.id
+      return {
+        ...this.formValues,
+        id
+      }
+    }
+  }
+}
+</script>

+ 324 - 40
src/views/dataService/index.vue

@@ -1,6 +1,6 @@
 <template>
-  <!-- 数据目录 -->
-  <div class="pa-3" style="background-color: #FFF;">
+  <!-- 数据服务 -->
+  <div class="pa-3 white">
     <m-filter :option="filter" @search="handleSearch" />
     <table-list
       class="mt-3"
@@ -9,38 +9,117 @@
       :items="items"
       :total="total"
       :page-info="pageInfo"
-      :is-tools="false"
+      :is-tools="true"
+      :disable-sort="true"
+      :can-delete="false"
       :show-select="false"
+      @add="handleAdd"
       @pageHandleChange="pageHandleChange"
-      @sort="handleSort"
     >
+      <template #product_name="{ item }">
+        <span @click="handlePreview(item)">{{ item.product_name }}</span>
+        <v-chip v-if="item.has_new_data" color="error" small class="ml-2">新</v-chip>
+      </template>
       <template #status="{ item }">
-        <v-chip
-          :color="item.status ? 'success' : 'error'"
-          small
-        >
-          {{ item.status ? '已启用' : '已禁用'}}
-        </v-chip>
+        <v-chip :color="getStatusColor(item.status)" small>{{ getStatusText(item.status) }}</v-chip>
       </template>
-      <template #tag="{ item }">
-        {{ item.tag.map(e => e.name_zh).join(',') }}
+      <template #last_updated_at="{ item }">
+        <span>{{ formatDateTime(item.last_updated_at) }}</span>
       </template>
-      <template v-slot:name_zh = "{item}">
-        <span class="defaultLink" @click="toDetail(item)">{{ item.name_zh }}</span>
+      <template #actions="{ item }">
+        <v-btn text color="success" @click="handleAdd(item)">编辑</v-btn>
+        <v-btn text color="primary" @click="handlePreview(item)">预览</v-btn>
+        <v-btn text color="info" @click="handleRefresh(item)">刷新</v-btn>
+        <v-btn text color="error" @click="handleDelete(item)">删除</v-btn>
       </template>
     </table-list>
+
+    <!-- 预览对话框 -->
+    <m-dialog :visible.sync="previewDialog.show" title="数据预览" :footer="false" max-width="90%">
+      <div v-if="previewDialog.data.product" class="mb-4">
+        <v-card outlined>
+          <v-card-text>
+            <div class="d-flex align-center mb-2">
+              <span class="text-h6 mr-3">{{ previewDialog.data.product.product_name }}</span>
+              <v-chip small>{{ previewDialog.data.total_count }} 条记录</v-chip>
+            </div>
+            <div class="text-body-2">
+              <span class="mr-4">目标表: {{ previewDialog.data.product.target_schema }}.{{ previewDialog.data.product.target_table }}</span>
+              <span>列数: {{ previewDialog.data.product.column_count }}</span>
+            </div>
+          </v-card-text>
+        </v-card>
+        <v-alert v-if="previewDialog.data.error" type="warning" dense class="ma-3">{{ previewDialog.data.error }}</v-alert>
+      </div>
+      <div v-loading="previewDialog.loading" style="max-height: 600px; overflow-y: auto;">
+        <v-simple-table v-if="previewDialog.data.columns && previewDialog.data.columns.length" fixed-header dense>
+          <template v-slot:default>
+            <thead>
+              <tr>
+                <th v-for="col in previewDialog.data.columns" :key="col.name" class="text-left">
+                  <div>
+                    <div>{{ col.name }}</div>
+                    <small style="color: #909399; font-weight: normal;">{{ col.type }}</small>
+                  </div>
+                </th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr v-for="(row, index) in previewDialog.data.data" :key="index">
+                <td v-for="col in previewDialog.data.columns" :key="col.name">
+                  {{ row[col.name] }}
+                </td>
+              </tr>
+            </tbody>
+          </template>
+        </v-simple-table>
+        <div v-else class="text-center pa-10 text--secondary">
+          暂无数据
+        </div>
+        <div class="text-end text--secondary">
+          <span>已加载 {{ previewDialog.data.preview_count || 0 }} / {{ previewDialog.data.total_count || 0 }} 条</span>
+        </div>
+      </div>
+      <template #footer>
+        <v-divider></v-divider>
+        <v-card-actions>
+          <v-spacer></v-spacer>
+          <v-btn
+            v-if="previewDialog.data.preview_count < previewDialog.data.total_count && previewDialog.limit < 1000"
+            text
+            color="primary"
+            @click="handleLoadMore"
+          >
+            加载更多
+          </v-btn>
+          <v-btn v-if="previewDialog.data.product" text color="success" @click="handleDownloadFromPreview">下载Excel</v-btn>
+          <v-btn text @click="previewDialog.show = false">关闭</v-btn>
+        </v-card-actions>
+      </template>
+    </m-dialog>
+
+    <!-- 注册对话框 -->
+    <m-dialog :visible.sync="registerDialog.show" :title="registerDialog.isEdit ? '编辑数据产品' : '新增数据产品'" @submit="handleRegisterSubmit">
+      <register-form v-if="registerDialog.show" ref="registerForm" :item-data="registerDialog.form"></register-form>
+    </m-dialog>
   </div>
 </template>
 
 <script>
 import MFilter from '@/components/Filter'
 import TableList from '@/components/List/table'
-import { api } from '@/api/dataGovernance'
+import MDialog from '@/components/Dialog'
+import RegisterForm from './components/RegisterForm'
+import { api } from '@/api/dataService'
+import { formatDate } from '@/utils/date'
+
 export default {
-  name: 'dataCatalog',
+  name: 'dataService',
   components: {
     MFilter,
-    TableList
+    TableList,
+    MDialog,
+    RegisterForm
   },
   data () {
     return {
@@ -48,49 +127,91 @@ export default {
       loading: false,
       filter: {
         list: [
-          { type: 'textField', value: '', label: '关键词', key: 'title' }
+          { type: 'textField', value: '', label: '关键词', key: 'search', placeholder: '搜索产品名称、英文名、描述、表名' },
+          {
+            type: 'select',
+            value: null,
+            label: '状态',
+            key: 'status',
+            items: [
+              { label: '全部', value: null },
+              { label: '活跃', value: 'active' },
+              { label: '非活跃', value: 'inactive' },
+              { label: '错误', value: 'error' }
+            ],
+            itemText: 'label',
+            itemValue: 'value'
+          }
         ]
       },
       headers: [
-        { text: '中文名', value: 'name_zh' },
-        { text: '英文名', value: 'name_en' },
-        { text: '分类', value: 'category', sortable: false },
-        { text: '描述', value: 'describe', sortable: false },
-        { text: '标签', value: 'tag', sortable: false },
-        { text: '状态', value: 'status', sortable: false },
-        { text: '血缘关系数量', value: 'blood_count', align: 'center' },
-        { text: '创建时间', value: 'create_time' }
+        { text: '产品名称', value: 'product_name' },
+        { text: '英文名称', value: 'product_name_en' },
+        { text: '目标表', value: 'target_table' },
+        { text: 'Schema', value: 'target_schema' },
+        { text: '记录数', value: 'record_count', align: 'center' },
+        { text: '列数', value: 'column_count', align: 'center' },
+        { text: '数据流', value: 'source_dataflow_name' },
+        { text: '状态', value: 'status', align: 'center' },
+        { text: '最后更新时间', value: 'last_updated_at' },
+        { text: '操作', value: 'actions', align: 'center' }
       ],
       items: [],
       total: 0,
       pageInfo: {
-        size: 10,
+        size: 20,
         current: 1
       },
       query: {},
-      orders: []
+      previewDialog: {
+        show: false,
+        loading: false,
+        limit: 200,
+        currentItem: null,
+        data: {
+          product: null,
+          columns: [],
+          data: [],
+          total_count: 0,
+          preview_count: 0,
+          error: null
+        }
+      },
+      registerDialog: {
+        show: false,
+        isEdit: false,
+        currentId: null,
+        form: {}
+      }
     }
   },
   created () {
-    // this.init()
+    this.init()
   },
   methods: {
     async init () {
       this.loading = true
       try {
-        const { data } = await api.getMetaDataList({ ...this.pageInfo, ...this.query })
-        this.total = data.total
-        this.items = data.records
+        const params = {
+          page: this.pageInfo.current,
+          page_size: this.pageInfo.size,
+          ...this.query
+        }
+        // 移除空值
+        Object.keys(params).forEach(key => {
+          if (params[key] === '' || params[key] === null || params[key] === undefined) {
+            delete params[key]
+          }
+        })
+        const { data } = await api.getProducts(params)
+        this.total = data.pagination.total
+        this.items = data.list
       } catch (error) {
         this.$snackbar.error(error)
       } finally {
         this.loading = false
       }
     },
-    handleSort (val) {
-      this.orders = val
-      this.init()
-    },
     handleSearch (obj) {
       Object.assign(this.query, obj)
       this.pageInfo.current = 1
@@ -99,11 +220,174 @@ export default {
     pageHandleChange (index) {
       this.pageInfo.current = index
       this.init()
+    },
+    // 预览数据
+    async handlePreview (item) {
+      this.previewDialog.show = true
+      this.previewDialog.loading = true
+      this.previewDialog.limit = 200
+      this.previewDialog.currentItem = item
+      this.previewDialog.data = {
+        product: null,
+        columns: [],
+        data: [],
+        total_count: 0,
+        preview_count: 0,
+        error: null
+      }
+      try {
+        const { data } = await api.getProductPreview(item.id, this.previewDialog.limit)
+        this.previewDialog.data = data
+        // 标记为已查看
+        if (item.has_new_data) {
+          item.has_new_data = false
+        }
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.previewDialog.loading = false
+      }
+    },
+    // 加载更多数据
+    async handleLoadMore () {
+      this.previewDialog.limit = Math.min(this.previewDialog.limit + 200, 1000)
+      this.previewDialog.loading = true
+      try {
+        const { data } = await api.getProductPreview(this.previewDialog.data.product.id, this.previewDialog.limit)
+        this.previewDialog.data = data
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.previewDialog.loading = false
+      }
+    },
+    // 从预览弹窗下载
+    async handleDownloadFromPreview () {
+      if (!this.previewDialog.currentItem) {
+        return
+      }
+      await this.handleDownload(this.previewDialog.currentItem)
+    },
+    // 下载数据
+    async handleDownload (item) {
+      try {
+        const response = await api.downloadExcel(item.id, 200)
+
+        // 响应拦截器已处理错误情况,这里response格式为 { data: blob, name: filename }
+        const filename = response.name || `${item.product_name_en}_${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.xlsx`
+
+        // 创建下载链接
+        const blob = new Blob([response.data], {
+          type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+        })
+        const url = window.URL.createObjectURL(blob)
+        const link = document.createElement('a')
+        link.href = url
+        link.download = filename
+        link.click()
+        window.URL.revokeObjectURL(url)
+
+        // 标记为已查看
+        if (item.has_new_data) {
+          item.has_new_data = false
+        }
+        this.$snackbar.success('下载成功')
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    // 刷新数据产品
+    async handleRefresh (item) {
+      try {
+        const { data } = await api.refreshStats(item.id)
+        // 更新本地数据
+        const index = this.items.findIndex(p => p.id === item.id)
+        if (index !== -1) {
+          Object.assign(this.items[index], data)
+        }
+        this.$snackbar.success(`刷新成功,共 ${data.record_count} 条记录`)
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    // 删除数据产品
+    async handleDelete (item) {
+      try {
+        await this.$confirm('删除确认', '确定要删除该数据产品吗?此操作不可恢复。')
+        await api.deleteProduct(item.id)
+        this.$snackbar.success('删除成功')
+        // 从本地列表移除
+        this.items = this.items.filter(p => p.id !== item.id)
+        this.total--
+      } catch (error) {
+        if (error !== 'cancel') {
+          this.$snackbar.error(error)
+        }
+      }
+    },
+    // 注册数据产品
+    handleAdd (item = null) {
+      this.registerDialog.show = true
+      if (item) {
+        // 回显数据
+        this.registerDialog.isEdit = true
+        this.registerDialog.currentId = item.id
+        this.registerDialog.form = {
+          id: item.id,
+          product_name: item.product_name || '',
+          product_name_en: item.product_name_en || '',
+          target_table: item.target_table || '',
+          target_schema: item.target_schema || 'public',
+          description: item.description || '',
+          source_dataflow_id: item.source_dataflow_id || null,
+          source_dataflow_name: item.source_dataflow_name || ''
+        }
+      } else {
+        // 新增模式
+        this.registerDialog.isEdit = false
+        this.registerDialog.currentId = null
+        this.registerDialog.form = {}
+      }
+      this.$nextTick(() => {
+        if (this.$refs.registerForm) {
+          this.$refs.registerForm.resetValidation()
+        }
+      })
+    },
+    async handleRegisterSubmit () {
+      const formData = this.$refs.registerForm?.getValue()
+      if (!formData) {
+        return
+      }
+      try {
+        await api.registerProduct(formData)
+        this.$snackbar.success(this.registerDialog.isEdit ? '更新成功' : '注册成功')
+        this.registerDialog.show = false
+        // 刷新列表
+        this.init()
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    getStatusColor (status) {
+      const colors = {
+        active: 'success',
+        inactive: 'info',
+        error: 'error'
+      }
+      return colors[status] || 'info'
+    },
+    getStatusText (status) {
+      const texts = {
+        active: '活跃',
+        inactive: '非活跃',
+        error: '错误'
+      }
+      return texts[status] || status
+    },
+    formatDateTime (dateTime) {
+      return formatDate(dateTime)
     }
   }
 }
 </script>
-
-<style lang="scss" scoped>
-
-</style>