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

feat(web2): IdCardTable 列选择(表头 checkbox)+ 导出 Excel

列选择(为「导出选列」预埋):
- 每列表头前加 el-checkbox,可单独控制该列是否参与导出
- 第 0 列固定全选 checkbox(支持半选 indeterminate)
- 「违规」列也加 checkbox,内部用 __violation__ 作 key
- 默认全部勾选;新出现的列自动勾上
- 未勾选的列**不隐藏**(仍显示),仅加 idcard-table__col-unselected class 灰显
- 暴露 selectedColumns / selectedDataColumns / selectAll / deselectAll 给父组件
- emit columns-change 事件

导出 Excel:
- 表格上方加工具栏:[📥 导出当前表 (N 行)] 已勾选 X/Y 列
- 文件格式:
  - 第 1 行:字段注释(column_comment)
  - 第 2 行:字段名(column_name,加粗 + 浅灰背景 + 居中)
  - 第 3 行起:数据
  - 含「违规」列时,第1行首列 = 「违规原因」,第2行首列 = _id_card_violation
- 黑色细边框(4 边 thin + #000000)
- 冻结前 2 行(Excel 滚动时表头固定)
- 超过 5000 行弹确认框
- sheet 名 sanitize(去掉 :\/?*[],最长 31 字符)
- 文件名 sanitize(去掉 <>:"/\|?*,最长 100 字符)

IdCardTool.vue:给 <IdCardTable> 加 :table-name="t.table_name"
parent c9e539bb
......@@ -2,7 +2,9 @@
共通表格组件 —— 展示「问题表」的字段元数据 + 违规行
设计:
- 第一列:违规摘要(小标签)+ tooltip 详细原因
- 第 0 列:列选择器(全选 checkbox,2026-08-17 加;为导出选列功能预埋)
- 第 1 列:违规摘要(小标签)+ tooltip 详细原因;表头也带 checkbox
- 数据列:表头每列名前带 checkbox,可单独切换该列是否参与导出
- 身份证列:放第一位(columnOrder[0])+ User 图标 + 蓝字 + 加宽到 220px
- 列可拖拽:el-table-column 默认 resizable=true,配合 border 即可拖拽列宽
- 表头:hover tooltip 显示字段名 + 字段注释 + 数据类型
......@@ -15,8 +17,37 @@
rows [{ col_name: value, ..., _id_card_violation: str}] 表格行数据(_id_card_violation 是后端注入)
idCardColumn string 身份证字段名(用于单元格高亮)
truncateAt int 多少行后显示截断提示(默认 5000)
tableName string 表名(用于导出文件名 / sheet 名)
Events:
columns-change (selectedCols: string[]) 当前勾选的列名数组(每次变化都触发;初始为全部列)
Expose(通过 ref 调用):
selectedColumns computed,当前勾选的列名数组
allColumns computed,所有可用的列名(含违规列 key)
selectAll() 勾选所有列
deselectAll() 取消勾选所有列
-->
<template>
<!-- 工具栏:导出按钮 + 勾选列数提示 -->
<div class="idcard-table__toolbar">
<el-button
size="small"
type="primary"
:icon="Download"
:loading="exporting"
:disabled="!rows.length"
@click="exportExcel"
>
导出当前表
<span class="idcard-table__toolbar-rows">({{ rows.length }} 行)</span>
</el-button>
<span class="idcard-table__toolbar-hint">
已勾选 <strong>{{ selectedDataColumns.length }}</strong> /
{{ dataColumns.length + 1 }} 列(含「违规」)
</span>
</div>
<el-table
:data="displayedRows"
size="small"
......@@ -24,7 +55,23 @@
stripe
class="idcard-table"
>
<!-- 第 1 列:违规摘要(每行都显示) -->
<!-- 第 0 列:列选择器(全选 checkbox,固定在左) -->
<el-table-column
width="46"
fixed="left"
align="center"
header-align="center"
>
<template #header>
<el-checkbox
:model-value="allSelected"
:indeterminate="someSelected"
@change="toggleAll"
/>
</template>
</el-table-column>
<!-- 第 1 列:违规摘要(每行都显示;表头可勾选,勾选状态仅影响导出) -->
<el-table-column
label="违规"
width="200"
......@@ -33,6 +80,15 @@
align="left"
header-align="center"
>
<template #header>
<div class="idcard-table__head-cell">
<el-checkbox
:model-value="isColSelected(KEY_VIOLATION)"
@change="(v) => toggleCol(KEY_VIOLATION, v)"
/>
<span class="idcard-table__head-label">违规</span>
</div>
</template>
<template #default="{ row }">
<el-tooltip
v-if="row._id_card_violation"
......@@ -56,7 +112,7 @@
</template>
</el-table-column>
<!-- 数据列:身份证列强制加宽 -->
<!-- 数据列:身份证列强制加宽;表头带 checkbox 标记该列是否参与导出(取消勾选不影响显示) -->
<el-table-column
v-for="colName in dataColumns"
:key="colName"
......@@ -65,31 +121,39 @@
:width="isIdCard(colName) ? 220 : undefined"
resizable
show-overflow-tooltip
:class-name="isColSelected(colName) ? '' : 'idcard-table__col-unselected'"
:header-class-name="isColSelected(colName) ? '' : 'idcard-table__col-unselected'"
>
<template #header>
<el-tooltip
placement="top"
effect="light"
:show-after="100"
>
<template #content>
<div class="idcard-table__tip">
<div class="idcard-table__tip-name">{{ colName }}</div>
<div v-if="commentOf(colName)" class="idcard-table__tip-comment">
{{ commentOf(colName) }}
</div>
<div v-if="typeOf(colName)" class="idcard-table__tip-type">
{{ typeOf(colName) }}
<div class="idcard-table__head-cell">
<el-checkbox
:model-value="isColSelected(colName)"
@change="(v) => toggleCol(colName, v)"
/>
<el-tooltip
placement="top"
effect="light"
:show-after="100"
>
<template #content>
<div class="idcard-table__tip">
<div class="idcard-table__tip-name">{{ colName }}</div>
<div v-if="commentOf(colName)" class="idcard-table__tip-comment">
{{ commentOf(colName) }}
</div>
<div v-if="typeOf(colName)" class="idcard-table__tip-type">
{{ typeOf(colName) }}
</div>
</div>
</div>
</template>
<span :class="{ 'idcard-table__head-id': isIdCard(colName) }">
{{ colName }}
<el-icon v-if="isIdCard(colName)" class="idcard-table__head-icon">
<User />
</el-icon>
</span>
</el-tooltip>
</template>
<span :class="{ 'idcard-table__head-id': isIdCard(colName) }">
{{ colName }}
<el-icon v-if="isIdCard(colName)" class="idcard-table__head-icon">
<User />
</el-icon>
</span>
</el-tooltip>
</div>
</template>
<template #default="{ row }">
......@@ -110,8 +174,10 @@
</template>
<script setup>
import { computed } from 'vue'
import { User, WarningFilled } from '@element-plus/icons-vue'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Download, User, WarningFilled } from '@element-plus/icons-vue'
import * as XLSX from 'xlsx-js-style'
const props = defineProps({
columns: {
......@@ -134,6 +200,10 @@ const props = defineProps({
type: Number,
default: 5000,
},
tableName: {
type: String,
default: '',
},
})
/* 字段名 → 元数据查找表(O(1)) */
......@@ -204,6 +274,257 @@ function truncate(s, n) {
if (!s) return ''
return s.length > n ? s.slice(0, n) + '…' : s
}
/* ── 列选择(表头每列加 checkbox,为「导出选列」功能预埋,2026-08-17 起) ── */
/** 「违规」列在 columnSelection map 里的 key(不是真实字段名,仅作内部标识) */
const KEY_VIOLATION = '__violation__'
/**
* 列勾选状态:{ colName: boolean }
* - 默认全部勾选(true)
* - 取消勾选 → el-table-column 直接不渲染(v-if 控制)
* - 新出现的列(columns 变化) → 自动勾选(避免新列被默认隐藏)
*/
const columnSelection = reactive({})
/** 把所有当前可见的列(含违规列)初始化为勾选;新出现的列也会被自动勾上 */
watch(
() => [KEY_VIOLATION, ...dataColumns.value],
(cols) => {
for (const c of cols) {
if (!(c in columnSelection)) {
columnSelection[c] = true
}
}
},
{ immediate: true, deep: true },
)
/** 已勾选的数据列 —— 只用于导出,UI 上所有列依然显示 */
const exportedDataColumns = computed(() =>
dataColumns.value.filter((c) => columnSelection[c] !== false),
)
/** 所有可勾选的列 key(含违规列) */
const allColumns = computed(() => [KEY_VIOLATION, ...dataColumns.value])
/** 当前勾选的列 key 数组(响应式)—— 含违规列 + 数据列 */
const selectedColumns = computed(() =>
allColumns.value.filter((c) => columnSelection[c] !== false),
)
/** 当前勾选的数据列(不含违规列特殊 key,导出时用) */
const selectedDataColumns = computed(() => exportedDataColumns.value)
/** 表头全选 checkbox 的 model-value(true / false) */
const allSelected = computed(() => {
const cols = allColumns.value
return cols.length > 0 && cols.every((c) => columnSelection[c] !== false)
})
/** 半选状态:有勾有未勾 */
const someSelected = computed(() => {
const cols = allColumns.value
if (!cols.length) return false
const selCount = cols.filter((c) => columnSelection[c] !== false).length
return selCount > 0 && selCount < cols.length
})
/** 单列是否勾选 */
function isColSelected(colKey) {
return columnSelection[colKey] !== false
}
/** 切换单列勾选 */
function toggleCol(colKey, val) {
columnSelection[colKey] = !!val
}
/** 全选 / 全不选(表头左上角 checkbox 回调) */
function toggleAll(val) {
for (const c of allColumns.value) {
columnSelection[c] = !!val
}
}
const emit = defineEmits(['columns-change'])
/** 列勾选变化时通知父组件(通过 watch 监听 selectedColumns 的变化) */
watch(
selectedColumns,
(cols) => emit('columns-change', cols),
{ immediate: true },
)
/* 通过 expose 暴露给父组件:当前勾选的列 + 全选/全不选 API */
defineExpose({
selectedColumns,
selectedDataColumns, // 导出时筛选用
allColumns,
selectAll: () => toggleAll(true),
deselectAll: () => toggleAll(false),
})
/* ── 导出 Excel ── */
/** 导出进行中状态(按钮 loading 用) */
const exporting = ref(false)
/**
* 把当前 rows 按勾选的列导出成 Excel:
* 第 1 行:字段注释(column_comment,空则留空)
* 第 2 行:字段名(column_name)
* 第 3 行起:数据
* 含「违规」列时,第一行首列填「违规原因」,第二行首列填 _id_card_violation
*/
async function exportExcel() {
if (!props.rows.length) {
ElMessage.warning('当前表无违规行,无需导出')
return
}
const cols = selectedDataColumns.value
const includeViolation = columnSelection[KEY_VIOLATION] !== false
if (!cols.length && !includeViolation) {
ElMessage.warning('未勾选任何列,无法导出')
return
}
// 大量行确认弹窗(>5000 行问一次,避免误点)
if (props.rows.length > 5000) {
try {
await ElMessageBox.confirm(
`当前表有 ${props.rows.length.toLocaleString()} 行,导出可能耗时数秒。是否继续?`,
'导出确认',
{ confirmButtonText: '导出', cancelButtonText: '取消', type: 'warning' },
)
} catch {
return // 用户取消
}
}
exporting.value = true
try {
// 第 1 行:注释
const commentRow = []
// 第 2 行:字段名
const nameRow = []
if (includeViolation) {
commentRow.push('违规原因')
nameRow.push('_id_card_violation')
}
for (const c of cols) {
commentRow.push(commentOf(c) || '')
nameRow.push(c)
}
// 数据行:按 nameRow 顺序抽取
const dataRows = props.rows.map((row) => {
const out = []
if (includeViolation) out.push(row._id_card_violation || '')
for (const c of cols) {
const v = row[c]
out.push(v === null || v === undefined ? '' : v)
}
return out
})
const aoa = [commentRow, nameRow, ...dataRows]
const ws = XLSX.utils.aoa_to_sheet(aoa)
// 给所有有数据的 cell 加黑色细边框(xlsx-js-style 才支持 s.border)
applyAllBorders(ws)
// 表头前两行加粗 + 浅灰背景(让用户在 Excel 一眼能区分表头和数据)
applyHeaderStyle(ws, nameRow.length)
// 冻结前 2 行(滚动时表头固定)
ws['!freeze'] = { xSplit: 0, ySplit: 2 }
const wb = XLSX.utils.book_new()
// sheet 名最长 31 字符;去掉非法字符
const sheetName = sanitizeSheetName(props.tableName || 'export')
XLSX.utils.book_append_sheet(wb, ws, sheetName)
const filename = sanitizeFilename(props.tableName || 'export') + '.xlsx'
XLSX.writeFile(wb, filename)
ElMessage.success(
`已导出 ${props.rows.length.toLocaleString()} 行 × ${nameRow.length} 列 → ${filename}`,
)
} catch (e) {
ElMessage.error(`导出失败: ${e.message || e}`)
} finally {
exporting.value = false
}
}
/**
* Sheet 名规则(Excel 限制):
* - 最长 31 字符
* - 不能含 : \ / ? * [ ]
*/
function sanitizeSheetName(s) {
return String(s || 'export')
.replace(/[:\\/?*\[\]]/g, '_')
.slice(0, 31) || 'export'
}
/**
* 文件名规则(Windows 限制):
* - 不能含 < > : " / \ | ? *
*/
function sanitizeFilename(s) {
return String(s || 'export').replace(/[<>:"/\\|?*]/g, '_').slice(0, 100) || 'export'
}
/* ── Excel 样式(xlsx-js-style 支持 cell-level s 属性) ── */
/** 黑色细边框(4 边) */
const BORDER_THIN = { style: 'thin', color: { rgb: 'FF000000' } }
const BORDER_ALL = {
top: BORDER_THIN,
bottom: BORDER_THIN,
left: BORDER_THIN,
right: BORDER_THIN,
}
/**
* 给 sheet 内所有有数据的 cell 加黑色细边框。
* 遍历 ws['!ref'] 表示的范围,给每个非空 cell 设 s.border。
*/
function applyAllBorders(ws) {
if (!ws['!ref']) return
const range = XLSX.utils.decode_range(ws['!ref'])
for (let R = range.s.r; R <= range.e.r; R++) {
for (let C = range.s.c; C <= range.e.c; C++) {
const addr = XLSX.utils.encode_cell({ r: R, c: C })
const cell = ws[addr]
if (!cell) continue
cell.s = { ...(cell.s || {}), border: BORDER_ALL }
}
}
}
/**
* 给前两行(注释行 + 字段名行)加粗 + 浅灰背景
* @param ws sheet 对象
* @param cols 列数(含违规列)
*/
function applyHeaderStyle(ws, cols) {
for (let R = 0; R < 2; R++) {
for (let C = 0; C < cols; C++) {
const addr = XLSX.utils.encode_cell({ r: R, c: C })
const cell = ws[addr]
if (!cell) continue
cell.s = {
...(cell.s || {}),
font: { bold: true, color: { rgb: 'FF303133' } },
fill: { patternType: 'solid', fgColor: { rgb: 'FFF2F2F2' } },
alignment: { horizontal: 'center', vertical: 'center', wrapText: true },
}
}
}
}
</script>
<style scoped>
......@@ -211,6 +532,58 @@ function truncate(s, n) {
margin-top: 4px;
}
/* 工具栏:导出按钮 + 勾选提示 */
.idcard-table__toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 0;
margin-bottom: 4px;
}
.idcard-table__toolbar-rows {
margin-left: 4px;
opacity: 0.85;
font-weight: normal;
}
.idcard-table__toolbar-hint {
font-size: 12px;
color: #909399;
}
.idcard-table__toolbar-hint strong {
color: #409eff;
font-weight: 600;
margin: 0 2px;
}
/* 未勾选的列:表头灰显 + 数据列轻微变灰,让用户一眼看出这列不参与导出 */
.idcard-table :deep(.idcard-table__col-unselected) {
background-color: #fafafa !important;
color: #c0c4cc !important;
}
.idcard-table :deep(.idcard-table__col-unselected .cell) {
color: #c0c4cc;
}
/* 表头单元格:checkbox + 字段名(横向排列) */
.idcard-table__head-cell {
display: inline-flex;
align-items: center;
gap: 6px;
line-height: 1.2;
}
.idcard-table__head-cell .el-checkbox {
margin-right: 0;
}
/* 让 tooltip 也能 hover 触发(包在 .idcard-table__head-cell 里) */
.idcard-table__head-cell .el-tooltip__trigger {
display: inline-flex;
align-items: center;
gap: 4px;
}
.idcard-table__head-label {
font-weight: 500;
}
/* 违规标签列 */
.idcard-table__reason-tag {
cursor: help;
......
......@@ -205,6 +205,7 @@
:rows="t.bad_rows"
:id-card-column="idColumnsOf(t)[0] || ''"
:truncate-at="5000"
:table-name="t.table_name"
/>
</el-collapse-item>
</el-collapse>
......
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