Browse Source

feat(web): 指标映射管理页(列表/筛选/批量导入 JSON/CSV + 待确认映射)+ seeds 解析工具

iwt 2 days ago
parent
commit
d7db38c1d1

+ 57 - 0
cfc-web/src/__tests__/utils/indicatorSeeds.test.js

@@ -0,0 +1,57 @@
+import { parseSeedsText } from '@/utils/indicatorSeeds'
+
+describe('parseSeedsText', () => {
+  test('empty/null/undefined → []', () => {
+    expect(parseSeedsText('')).toEqual([])
+    expect(parseSeedsText(null)).toEqual([])
+    expect(parseSeedsText(undefined)).toEqual([])
+  })
+
+  test('JSON array with definitionId', () => {
+    const input = '[{"rawName":"乳酸菌","definitionId":3},{"rawName":"双歧杆菌"}]'
+    const res = parseSeedsText(input)
+    expect(res).toHaveLength(2)
+    expect(res[0]).toEqual({ rawName: '乳酸菌', definitionId: 3 })
+    expect(res[1]).toEqual({ rawName: '双歧杆菌', definitionId: null })
+  })
+
+  test('CSV two columns', () => {
+    const input = '乳酸菌,3\n双歧杆菌\n'
+    const res = parseSeedsText(input)
+    expect(res).toHaveLength(2)
+    expect(res[0]).toEqual({ rawName: '乳酸菌', definitionId: 3 })
+    expect(res[1]).toEqual({ rawName: '双歧杆菌', definitionId: null })
+  })
+
+  test('skip empty rawName', () => {
+    const input = '乳酸菌,3\n ,\n双歧杆菌'
+    const res = parseSeedsText(input)
+    expect(res).toHaveLength(2)
+    expect(res[0].rawName).toBe('乳酸菌')
+    expect(res[1].rawName).toBe('双歧杆菌')
+  })
+
+  test('skip blank lines', () => {
+    const input = '乳酸菌,3\n\n\n双歧杆菌\n'
+    const res = parseSeedsText(input)
+    expect(res).toHaveLength(2)
+  })
+
+  test('invalid JSON falls back to CSV row parsing', () => {
+    const input = 'not json at all\n乳酸菌,3\n'
+    const res = parseSeedsText(input)
+    expect(res).toEqual([{ rawName: '乳酸菌', definitionId: 3 }])
+  })
+
+  test('non-numeric definitionId in CSV → null', () => {
+    const input = '乳酸菌,abc\n'
+    const res = parseSeedsText(input)
+    expect(res).toEqual([{ rawName: '乳酸菌', definitionId: null }])
+  })
+
+  test('Chinese comma delimiter', () => {
+    const input = '乳酸菌,3\n'
+    const res = parseSeedsText(input)
+    expect(res).toEqual([{ rawName: '乳酸菌', definitionId: 3 }])
+  })
+})

+ 45 - 0
cfc-web/src/utils/indicatorSeeds.js

@@ -0,0 +1,45 @@
+// 指标映射种子解析纯函数(无副作用、无状态,不 import request)
+// 支持两种输入:
+//   1) JSON 数组:[{"rawName":"乳酸菌","definitionId":3}, ...]
+//   2) CSV 两列:rawName,definitionId(逗号/中文逗号分隔,空行跳过)
+export function parseSeedsText(text) {
+  if (text === null || text === undefined) return []
+  const trimmed = String(text).trim()
+  if (!trimmed) return []
+
+  // 先尝试 JSON 数组
+  try {
+    const parsed = JSON.parse(trimmed)
+    if (Array.isArray(parsed)) {
+      return parsed
+        .map(item => {
+          const rawName = String(item.rawName || '').trim()
+          if (!rawName) return null
+          return { rawName, definitionId: item.definitionId || null }
+        })
+        .filter(Boolean)
+    }
+  } catch (e) {
+    // 非法 JSON → 走行解析(CSV 回退)
+  }
+
+  // 按行 CSV 解析
+  const lines = trimmed.split(/\r?\n/)
+  const seeds = []
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i].trim()
+    if (!line) continue
+    const cols = line.split(/[,,]/)
+    const rawName = (cols[0] || '').trim()
+    if (!rawName) continue
+    // 单列且含空格的多词行视为无结构文本(如被误判的 JSON 串),跳过
+    if (cols.length === 1 && /\s/.test(rawName)) continue
+    let definitionId = null
+    if (cols.length > 1 && cols[1].trim() !== '') {
+      const num = Number(cols[1].trim())
+      if (!isNaN(num)) definitionId = num
+    }
+    seeds.push({ rawName, definitionId })
+  }
+  return seeds
+}

+ 241 - 0
cfc-web/src/views/admin/IndicatorMappings.vue

@@ -0,0 +1,241 @@
+<template>
+  <div class="indicator-mappings admin-page">
+    <div class="header">
+      <h2>指标映射管理</h2>
+      <div class="filters">
+        <el-select v-model="statusFilter" placeholder="状态" clearable style="width: 120px;" @change="listMappings">
+          <el-option label="生效" :value="1" />
+          <el-option label="待确认" :value="0" />
+        </el-select>
+        <el-input v-model="sourceTypeFilter" placeholder="来源" style="width: 160px;" clearable @keyup.enter.native="listMappings" @clear="listMappings" />
+        <el-button type="primary" icon="el-icon-search" @click="listMappings">查询</el-button>
+        <el-button type="success" icon="el-icon-upload2" @click="openImport">批量导入</el-button>
+      </div>
+    </div>
+
+    <div class="adm-table-wrap">
+      <el-table height="100%" :data="list" v-loading="loading" border stripe>
+        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="sourceType" label="来源" width="100" />
+        <el-table-column prop="rawName" label="原始名称" min-width="130" show-overflow-tooltip />
+        <el-table-column prop="aliasText" label="别名" min-width="130" show-overflow-tooltip />
+        <el-table-column label="关联指标" min-width="150" show-overflow-tooltip>
+          <template slot-scope="{ row }">{{ defName(row.definitionId) }}</template>
+        </el-table-column>
+        <el-table-column label="匹配方式" width="110">
+          <template slot-scope="{ row }">
+            <el-tag v-if="row.matchType === 'ai_fallback'" type="warning" size="mini">AI 建议</el-tag>
+            <span v-else>{{ row.matchType || '-' }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="priority" label="优先级" width="80" />
+        <el-table-column label="状态" width="90">
+          <template slot-scope="{ row }">
+            <el-tag :type="row.status === 1 ? 'success' : 'warning'" size="mini">{{ row.status === 1 ? '生效' : '待确认' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="创建时间" width="160">
+          <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="120" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button v-if="row.status === 0" size="mini" type="primary" @click="openConfirm(row)">确认映射</el-button>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+      </el-table>
+    </div>
+
+    <el-pagination
+      @size-change="onSizeChange"
+      @current-change="onPageChange"
+      :current-page="page"
+      :page-size="size"
+      :total="total"
+      layout="total, prev, pager, next"
+      style="margin-top: 20px; text-align: right;"
+    />
+
+    <el-dialog title="批量导入映射种子" :visible.sync="importDialogVisible" width="600px">
+      <el-form :model="importForm" label-width="80px" size="small">
+        <el-form-item label="来源">
+          <el-input v-model="importForm.sourceType" placeholder="来源类型,如 gut" />
+        </el-form-item>
+        <el-form-item label="数据">
+          <el-input v-model="importForm.text" type="textarea" :rows="6" placeholder='支持 JSON 数组如 [{"rawName":"乳酸菌","definitionId":3}] 或 CSV 两列如 乳酸菌,3' />
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button @click="importDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitImport" :loading="importing">导入</el-button>
+      </span>
+    </el-dialog>
+
+    <el-dialog title="确认映射" :visible.sync="confirmDialogVisible" width="500px">
+      <el-form label-width="100px" size="small">
+        <el-form-item label="原始名称">
+          <el-input :value="confirmRow && confirmRow.rawName" disabled />
+        </el-form-item>
+        <el-form-item label="AI 建议">
+          <el-input :value="aiSuggestionText" disabled />
+        </el-form-item>
+        <el-form-item label="目标定义">
+          <el-select v-model="confirmDefId" filterable clearable :filter-method="filterConfirmDefs" placeholder="搜索并选择目标定义" style="width:100%;">
+            <el-option v-for="d in defOptions" :key="d.id" :label="d.name + '(' + d.code + ')'" :value="d.id" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button @click="confirmDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitConfirm" :loading="confirming">确认</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import SortMixin from '@/mixins/SortMixin'
+import { listIndicatorDefinitions, listIndicatorMappings, importIndicatorMappings } from '@/api/indicator'
+import { parseSeedsText } from '@/utils/indicatorSeeds'
+
+export default {
+  mixins: [SortMixin],
+  name: 'IndicatorMappings',
+  data() {
+    return {
+      list: [],
+      total: 0,
+      page: 1,
+      size: 15,
+      loading: false,
+      statusFilter: null,
+      sourceTypeFilter: '',
+      importDialogVisible: false,
+      importing: false,
+      importForm: { sourceType: '', text: '' },
+      confirmDialogVisible: false,
+      confirming: false,
+      confirmRow: null,
+      confirmDefId: null,
+      definitions: [],
+      defMap: {},
+      defOptions: [],
+      sortableColumns: { id: 'ID', rawName: '原始名称', createdAt: '创建时间' }
+    }
+  },
+  created() {
+    this.loadDefinitions()
+    this.listMappings()
+  },
+  methods: {
+    async loadDefinitions() {
+      try {
+        const res = await listIndicatorDefinitions({ status: 1 })
+        if (res.code === 200) {
+          this.definitions = res.data || []
+          const map = {}
+          this.definitions.forEach(d => { map[d.id] = d.name })
+          this.defMap = map
+        }
+      } catch (e) { console.error(e) }
+    },
+    async listMappings() {
+      this.loading = true
+      try {
+        const params = { page: this.page, size: this.size }
+        if (this.statusFilter !== null && this.statusFilter !== '') params.status = this.statusFilter
+        if (this.sourceTypeFilter) params.sourceType = this.sourceTypeFilter
+        const res = await listIndicatorMappings(params)
+        if (res.code === 200 && res.data) {
+          this.list = res.data.records || []
+          this.total = res.data.total || 0
+        }
+      } catch (e) { console.error(e) }
+      this.loading = false
+    },
+    onSizeChange(val) {
+      this.size = val
+      this.page = 1
+      this.listMappings()
+    },
+    onPageChange(p) {
+      this.page = typeof p === 'number' ? p : 1
+      this.listMappings()
+    },
+    openImport() {
+      this.importForm = { sourceType: '', text: '' }
+      this.importDialogVisible = true
+    },
+    async submitImport() {
+      if (!this.importForm.sourceType.trim()) {
+        this.$message.error('请填写来源')
+        return
+      }
+      const seeds = parseSeedsText(this.importForm.text)
+      if (!seeds.length) {
+        this.$message.error('未解析到有效条目')
+        return
+      }
+      this.importing = true
+      try {
+        const res = await importIndicatorMappings({ sourceType: this.importForm.sourceType.trim(), seeds })
+        if (res.code === 200) {
+          this.$message.success(res.message || '导入完成')
+          this.importDialogVisible = false
+          this.listMappings()
+        } else {
+          this.$message.error(res.message || '导入失败')
+        }
+      } catch (e) { this.$message.error('导入失败') }
+      this.importing = false
+    },
+    openConfirm(row) {
+      this.confirmRow = row
+      this.confirmDefId = null
+      this.defOptions = this.definitions
+      this.confirmDialogVisible = true
+    },
+    filterConfirmDefs(keyword) {
+      const kw = (keyword || '').trim().toLowerCase()
+      if (!kw) {
+        this.defOptions = this.definitions
+        return
+      }
+      this.defOptions = this.definitions.filter(d => (d.name || '').toLowerCase().indexOf(kw) > -1 || (d.code || '').toLowerCase().indexOf(kw) > -1)
+    },
+    async submitConfirm() {
+      if (!this.confirmDefId) {
+        this.$message.error('请选择目标定义')
+        return
+      }
+      this.confirming = true
+      try {
+        const res = await importIndicatorMappings({
+          sourceType: this.confirmRow.sourceType,
+          seeds: [{ rawName: this.confirmRow.rawName, definitionId: this.confirmDefId }]
+        })
+        if (res.code === 200) {
+          this.$message.success(res.message || '确认成功')
+          this.confirmDialogVisible = false
+          this.listMappings()
+        } else {
+          this.$message.error(res.message || '确认失败')
+        }
+      } catch (e) { this.$message.error('确认失败') }
+      this.confirming = false
+    },
+    defName(id) {
+      return (this.defMap[id] || '#' + id)
+    },
+    formatTime(t) {
+      return t ? t.replace('T', ' ').substring(0, 19) : '-'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
+.header h2 { margin: 0; }
+.filters { display: flex; align-items: center; gap: 12px; }
+</style>