Commit ce3693a6 authored by Data Governance Dev's avatar Data Governance Dev

feat(web3): 通用结果表组件 ResultTable

**新增 web3/src/components/ResultTable.vue**
- props: columns / rows / pageSizes / defaultPageSize / rowKey
- 自动追加末尾「备注」列(展示该行所有 issues)
- 错误单元格 el-tooltip 出错误描述(按 col.cn 匹配 issues)
- 表头「⚠ N规则」+ el-tooltip(col.rules / col.ruleList 驱动)
- 内置分页 + 每页条数下拉
- rows 长度变化自动回第 1 页(避免停在空页)
- 空数据占位行
- 样式复用 global.css 已定义的 .dq-table / .cell-error / .err-flag / .col-reason / .issue-line

**重构 web3/src/views/DataQualityView.vue**
- 删:内嵌 <thead><tbody>、isErrorCell/errTip/cellClass/ruleHint 辅助、page/pageSize/pagedRows/rangeStart/rangeEnd 计算、分页按钮块
- 删:「操作」列(修改/删除按钮按 plan §3 决策拿掉)
- 换:<ResultTable :columns="fields" :rows="rows" ... />
- 留:查询区、任务信息条、卡片头部操作按钮(字段配置/导出/导入/批量删除)、FieldConfigDrawer

**API 契约(结果表通用)**
  row[col.key]              显示值
  row.errorCells?: [key]    哪些单元格标红+tooltip
  row.issues?: [{field,desc}] 备注列展开
  col.rules                 表头规则数
  col.ruleList?             表头 tooltip 明细

踩坑(详见 work-logs/2026-08-20.md 主题 29)
- el-tooltip 默认包 <div> 会破坏 td inline 布局 → 触发元素改 inline-flex span
- global.css 旧 .cell-error:hover .err-tip 还在,hover 可能出鬼影 tooltip,留待清理
- 组件内管分页必须 watch rows 长度变 → 回第 1 页,否则批量删除后停在空页
parent 62f0ce1a
<!--
ResultTable · 通用结果表组件
设计要点(plan §3 结果表统一):
- props.columns: [{ key, cn, en, rules, ruleList? }] 业务字段;组件自动追加最后一列「备注」
- props.rows: 表格数据;每行约定:
* row[col.key] 单元格的显示值
* row.errorCells?: [key1, key2] 哪些单元格高亮+tooltip
* row.issues?: [{field, desc}] 备注列要展示的所有错误(每行末尾「备注」列展开)
- props.pageSizes / defaultPageSize 分页每页条数(默认 [5, 10, 20, 50],默认 5)
- 无「操作」列(plan §3 决策;后续如要加,应在父组件再套一层,不要塞回通用组件)
单元格错误展示:
- 错误单元格红色背景 + 末尾红圈「!」标记
- 鼠标 hover 出 el-tooltip 显示具体错误描述(plan §3 「每格可 tooltip」)
- 表头有 rules 的列也加「⚠ N规则」标记 + el-tooltip,hover 显示规则列表
样式复用 global.css 里 .dq-table / .cell-error / .err-flag / .col-reason 等已定义类
-->
<template>
<div class="result-table">
<div class="table-scroll">
<table class="dq-table">
<thead>
<tr>
<th
v-for="col in allColumns"
:key="col.key"
:class="{ 'col-reason': col.key === '__reason' }"
>
<template v-if="col.key === '__reason'">
<span class="flag">!</span><span class="cn">备注</span>
</template>
<template v-else>
<span class="cn">{{ col.cn }}</span>
<span class="en">{{ col.en }}</span>
<el-tooltip
v-if="col.rules > 0"
:content="ruleHint(col)"
placement="top"
effect="dark"
>
<span class="rule-mark">⚠ {{ col.rules }}规则</span>
</el-tooltip>
</template>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in pagedRows" :key="rowKey(row, i)">
<td
v-for="col in allColumns"
:key="col.key"
:class="cellClass(row, col.key)"
>
<!-- 备注列:所有 issues 列表 -->
<template v-if="col.key === '__reason'">
<template v-if="Array.isArray(row.issues) && row.issues.length > 0">
<span
v-for="(it, idx) in row.issues"
:key="idx"
class="issue-line"
>• {{ it.field }}:{{ it.desc }}</span>
</template>
<span v-else class="null-val">—</span>
</template>
<!-- 错误单元格:红底 + tooltip + ! 标记 -->
<template v-else-if="isErrorCell(row, col.key)">
<el-tooltip
:content="errTip(row, col.key)"
placement="top"
effect="dark"
>
<span class="cell-error-content">
<span :class="{ 'null-val': isNullLike(row[col.key]) }">
{{ formatCell(row[col.key]) }}
</span>
<span class="err-flag">!</span>
</span>
</el-tooltip>
</template>
<!-- 普通单元格 -->
<template v-else>
<span :class="{ 'null-val': isNullLike(row[col.key]) }">
{{ formatCell(row[col.key]) }}
</span>
</template>
</td>
</tr>
<!-- 空数据占位 -->
<tr v-if="pagedRows.length === 0">
<td :colspan="allColumns.length" class="empty-row">暂无数据</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页:含每页条数选择 -->
<div class="pagination">
<div class="pagination-info">
显示第 {{ rangeStart }}-{{ rangeEnd }} 条 / 共 <span class="highlight">{{ rows.length }}</span> 条
</div>
<div class="pagination-controls">
<select
class="select-input page-size-select"
v-model.number="pageSize"
@change="onPageSizeChange"
>
<option v-for="s in pageSizes" :key="s" :value="s">{{ s }} 条/页</option>
</select>
<button class="page-btn" :disabled="currentPage === 1" @click="currentPage--">‹</button>
<button
v-for="p in totalPages"
:key="p"
class="page-btn"
:class="{ active: currentPage === p }"
@click="currentPage = p"
>{{ p }}</button>
<button class="page-btn" :disabled="currentPage === totalPages" @click="currentPage++">›</button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
columns: { type: Array, required: true }, // [{ key, cn, en, rules, ruleList? }]
rows: { type: Array, required: true }, // 每行:{ errorCells?, issues?, ...业务字段 }
pageSizes: { type: Array, default: () => [5, 10, 20, 50] },
defaultPageSize: { type: Number, default: 5 },
// 行 key:默认用 index;父组件可传 (row, i) => string 走更稳定的 key(如 row.id)
rowKey: { type: Function, default: (_row, i) => i },
})
const emit = defineEmits(['page-change'])
// 业务列 + 自动追加的「备注」列
const allColumns = computed(() => [
...props.columns,
{ key: '__reason', cn: '备注' },
])
// ── 分页状态(组件内自治) ──
const currentPage = ref(1)
const pageSize = ref(props.defaultPageSize)
const totalPages = computed(() => Math.max(1, Math.ceil(props.rows.length / pageSize.value)))
const pagedRows = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return props.rows.slice(start, start + pageSize.value)
})
const rangeStart = computed(() => props.rows.length === 0 ? 0 : (currentPage.value - 1) * pageSize.value + 1)
const rangeEnd = computed(() => Math.min(currentPage.value * pageSize.value, props.rows.length))
// rows 长度变化 → 回到第 1 页(避免翻页中途被切走,停在空页)
watch(() => props.rows.length, () => { currentPage.value = 1 })
function onPageSizeChange() {
currentPage.value = 1
emit('page-change', { page: currentPage.value, pageSize: pageSize.value })
}
// ── 单元格渲染辅助 ──
function isNullLike(v) {
return v === null || v === undefined || v === ''
}
function formatCell(v) {
return isNullLike(v) ? '(NULL)' : v
}
function isErrorCell(row, key) {
return Array.isArray(row.errorCells) && row.errorCells.includes(key)
}
// 错误描述:优先按 col.cn 在 row.issues 里找匹配,找不到就给通用文案
function errTip(row, key) {
if (!isErrorCell(row, key)) return ''
const col = allColumns.value.find((x) => x.key === key)
const fieldLabel = col ? col.cn : key
const issue = (row.issues || []).find((it) => it.field === fieldLabel)
return issue ? issue.desc : '该字段不合法'
}
function cellClass(row, key) {
return {
'cell-error': isErrorCell(row, key),
'cell-reason': key === '__reason',
}
}
// 表头规则 tooltip:优先用 ruleList([{desc}]),否则给通用文案
function ruleHint(col) {
if (Array.isArray(col.ruleList) && col.ruleList.length > 0) {
return col.ruleList.map((r) => r.desc).join(';')
}
return `该字段配置了 ${col.rules || 0} 条校验规则`
}
</script>
<style scoped>
.result-table {
display: flex;
flex-direction: column;
}
.empty-row {
text-align: center;
color: #909399;
padding: 24px 0 !important;
background: #fafafa;
}
.cell-error-content {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: help;
}
.page-size-select {
margin-right: 12px;
width: auto;
padding: 4px 8px;
font-size: 13px;
}
</style>
\ No newline at end of file
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
<span class="meta">最近检测:2026-08-19 09:00 · 共扫描 10,234 条 · 命中不合规 <b style="color:#f56c6c;">12</b> 条</span> <span class="meta">最近检测:2026-08-19 09:00 · 共扫描 10,234 条 · 命中不合规 <b style="color:#f56c6c;">12</b> 条</span>
</div> </div>
<!-- 结果区 --> <!-- 结果区:表头操作 + 通用结果表 -->
<div class="q-card"> <div class="q-card">
<div class="q-card__header"> <div class="q-card__header">
<div class="q-card__title">不合规数据明细<span class="count">共 12 条</span></div> <div class="q-card__title">不合规数据明细<span class="count">共 12 条</span></div>
...@@ -30,71 +30,13 @@ ...@@ -30,71 +30,13 @@
</div> </div>
</div> </div>
<div class="table-scroll"> <ResultTable
<table class="dq-table"> :columns="fields"
<thead> :rows="rows"
<tr> :page-sizes="[5, 10, 20, 50]"
<th style="width: 40px;"> :default-page-size="5"
<input type="checkbox" class="checkbox" :checked="allChecked" @change="toggleAll" /> @page-change="onPageChange"
</th> />
<th v-for="f in fields" :key="f.key" :class="{ 'col-reason': f.key === '__reason' }">
<template v-if="f.key === '__reason'">
<span class="flag">!</span><span class="cn">备注</span>
</template>
<template v-else>
<span class="cn">{{ f.cn }}</span>
<span class="en">{{ f.en }}</span>
<span v-if="f.rules > 0" class="rule-mark" :title="`校验规则:${ruleHint(f)}`">⚠ {{ f.rules }}规则</span>
</template>
</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in pagedRows" :key="i">
<td style="text-align: center;">
<input type="checkbox" class="checkbox" v-model="row.checked" />
</td>
<td v-for="f in fields" :key="f.key" :class="cellClass(row, f.key)">
<template v-if="f.key === '__reason'">
<span v-for="(it, idx) in row.issues" :key="idx" class="issue-line">• {{ it.field }}:{{ it.desc }}</span>
</template>
<template v-else-if="isErrorCell(row, f.key)">
<span :class="{ 'null-val': row[f.key] === null || row[f.key] === undefined }">
{{ row[f.key] === null || row[f.key] === undefined ? '(NULL)' : row[f.key] }}
</span>
<span class="err-flag">!</span>
<span class="err-tip">{{ errTip(row, f.key) }}</span>
</template>
<template v-else>
<span :class="{ 'null-val': row[f.key] === null || row[f.key] === undefined }">
{{ row[f.key] === null || row[f.key] === undefined ? '(NULL)' : row[f.key] }}
</span>
</template>
</td>
<td>
<button class="btn-text" @click="editRow(row)">修改</button>
<button class="btn-text danger" @click="deleteRow(row)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<div class="pagination-info">显示第 {{ rangeStart }}-{{ rangeEnd }} 条 / 共 <span class="highlight">12</span> 条不合规数据</div>
<div class="pagination-controls">
<button class="page-btn" :disabled="page === 1" @click="page--">‹</button>
<button
v-for="p in totalPages"
:key="p"
class="page-btn"
:class="{ active: page === p }"
@click="page = p"
>{{ p }}</button>
<button class="page-btn" :disabled="page === totalPages" @click="page++">›</button>
</div>
</div>
</div> </div>
<!-- 字段配置抽屉 --> <!-- 字段配置抽屉 -->
...@@ -112,68 +54,35 @@ import { useRoute } from 'vue-router' ...@@ -112,68 +54,35 @@ import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { fieldsByType, taskOptionsByType, violationRows, fieldRuleSamples } from '@/data/mockData' import { fieldsByType, taskOptionsByType, violationRows, fieldRuleSamples } from '@/data/mockData'
import FieldConfigDrawer from '@/components/FieldConfigDrawer.vue' import FieldConfigDrawer from '@/components/FieldConfigDrawer.vue'
import ResultTable from '@/components/ResultTable.vue'
const route = useRoute() const route = useRoute()
// 当前数据类型(来自路由 param) // 当前数据类型(来自路由 param)
const type = computed(() => route.params.type || 'id-card') const type = computed(() => route.params.type || 'id-card')
// 表格字段:业务字段 + 末尾的「备注」列 // 表格字段:业务字段(组件会自动追加末尾的「备注」列)
const fields = computed(() => [...(fieldsByType[type.value] || []), { key: '__reason', cn: '备注' }]) const fields = computed(() => fieldsByType[type.value] || [])
const taskOptions = computed(() => taskOptionsByType[type.value] || []) const taskOptions = computed(() => taskOptionsByType[type.value] || [])
const selectedTask = ref(taskOptions.value[0]) const selectedTask = ref(taskOptions.value[0])
watch(taskOptions, (opts) => { selectedTask.value = opts[0] }) watch(taskOptions, (opts) => { selectedTask.value = opts[0] })
// 字段展示开关(默认全开) // 字段展示开关(默认全开)—— 暂时未应用在通用表里,预留给后续做字段隐藏
const visibleFields = ref(fields.value.filter((f) => f.key !== '__reason').map((f) => f.key)) const visibleFields = ref(fields.value.map((f) => f.key))
watch(fields, (fs) => { visibleFields.value = fs.filter((f) => f.key !== '__reason').map((f) => f.key) }) watch(fields, (fs) => { visibleFields.value = fs.map((f) => f.key) })
// 表格数据:把 mock 的 violationRows 复制并加 checked 字段
const rows = ref(violationRows.map((r) => ({ ...r, checked: false }))) const rows = ref(violationRows.map((r) => ({ ...r, checked: false })))
// 分页
const page = ref(1)
const pageSize = 5
const totalPages = computed(() => Math.max(1, Math.ceil(rows.value.length / pageSize)))
const pagedRows = computed(() => {
const start = (page.value - 1) * pageSize
return rows.value.slice(start, start + pageSize)
})
const rangeStart = computed(() => rows.value.length === 0 ? 0 : (page.value - 1) * pageSize + 1)
const rangeEnd = computed(() => Math.min(page.value * pageSize, rows.value.length))
// 全选
const allChecked = computed(() => pagedRows.value.length > 0 && pagedRows.value.every((r) => r.checked))
function toggleAll(e) {
const v = e.target.checked
pagedRows.value.forEach((r) => { r.checked = v })
}
// 错误单元格判定
function isErrorCell(row, key) {
return Array.isArray(row.errorCells) && row.errorCells.includes(key)
}
function errTip(row, key) {
if (!isErrorCell(row, key)) return ''
const f = fields.value.find((x) => x.key === key)
const fieldLabel = f ? f.cn : key
const issue = (row.issues || []).find((it) => it.field === fieldLabel)
return issue ? issue.desc : '该字段不合法'
}
function cellClass(row, key) {
return {
'cell-error': isErrorCell(row, key),
'cell-reason': key === '__reason',
}
}
function ruleHint(f) {
return fieldRuleSamples[f.key]?.map((r) => r.desc).join(';') || ''
}
// 操作 // 操作
function onQuery() { function onQuery() {
ElMessage.success(`已查询:${selectedTask.value}`) ElMessage.success(`已查询:${selectedTask.value}`)
} }
function onPageChange({ page, pageSize }) {
// 通用表已自己管分页;这里仅打点,后续要做「记忆翻页位置」之类可以挂这
console.debug('[DataQualityView] page change', { page, pageSize })
}
function editRow(row) { function editRow(row) {
ElMessageBox.alert(`打开「${row.user_id}」该条数据的编辑表单`, '修改') ElMessageBox.alert(`打开「${row.user_id}」该条数据的编辑表单`, '修改')
} }
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment