Appearance
实现 文本列处理与VLOOKUP 工具
分类:developer | 标签:分列、VLOOKUP、统计 分列、求和、分组统计与两份数据 VLOOKUP 匹配
2.1 功能说明
分列、求和、分组统计与两份数据 VLOOKUP 匹配
核心能力
- s.label
- 分列预览
- 数值列求和
- 按列分组计数
- 分组值
- 计数
- 占比
- VLOOKUP 匹配
2.1.1 使用指南
功能说明
把带分隔符的纯文本表格(从 Excel、数据库客户端、日志中复制出来的内容)在浏览器里直接做分列、汇总和跨表匹配,不需要打开 Excel,也不会上传数据。
四个功能
- 分列预览:按选定分隔符把文本切成表格,确认列数和对齐是否正确,可导出为 CSV。
- 数值列求和:对指定列求合计,同时给出有效数值行数、被忽略的非数值行数、平均值、最大值与最小值。数值会自动去掉千分位逗号、货币符号、百分号和空格后再解析。
- 按列分组计数:统计某一列各个取值出现的次数并计算占比,按次数倒序排列,相当于 Excel 的数据透视计数。
- VLOOKUP 匹配:以 A 表为主表做左连接。选择 A 与 B 的键列,勾选需要从 B 带回的列,A 的每一行都会保留,匹配不到时用「未匹配填充」的内容占位。
分隔符与标题行
从 Excel 复制的内容默认是 Tab 分隔;CSV 文件是逗号分隔;也可以选择分号、竖线或自定义任意字符串。勾选「首行是标题行」后会用首行文字作为列名,否则列名自动生成为「列1、列2……」。A 表和 B 表可以使用各自不同的分隔符和标题行设置。
注意事项
- 解析采用简单切分,不支持 CSV 中「用引号包裹且字段内含分隔符」的复杂情况;如遇到这类数据,建议先换成 Tab 分隔再粘贴。
- 每个单元格会自动去掉首尾空白。行数不齐时会自动补足空单元格,保证列对齐。
- B 表中若存在重复的键,VLOOKUP 只取第一条匹配结果,这与 Excel 的行为一致,界面上会提示重复键的数量。
- 导出的 CSV 带 UTF-8 BOM,用 Excel 打开不会出现中文乱码。
2.2 代码实现
2.2.1 组件结构
本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-input、el-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。
2.2.2 核心逻辑概览
<script setup> 中定义的主要函数/方法:
resolveSep()buildTable()toObjects()parseNumber()formatNum()toCsv()downloadCsv()exportPreview()exportGroup()exportVlookup()loadDemo()clearAll()
2.2.3 关键实现代码
以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。
vue
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import ToolPageShell from '@/components/ToolPageShell.vue'
const sepOptions = [
{ label: 'Tab 制表符', value: 'tab' },
{ label: '逗号 ,', value: 'comma' },
{ label: '分号 ;', value: 'semicolon' },
{ label: '竖线 |', value: 'pipe' },
{ label: '自定义', value: 'custom' }
]
const rawA = ref('')
const rawB = ref('')
const sepA = ref('tab')
const sepB = ref('tab')
const customSepA = ref('')
const customSepB = ref('')
const headerA = ref(true)
const headerB = ref(true)
const activeTab = ref('preview')
const previewLimit = ref(20)
const sumCol = ref(null)
const groupCol = ref(null)
const groupIgnoreCase = ref(false)
const keyColA = ref(null)
const keyColB = ref(null)
const returnCols = ref([])
const lookupIgnoreCase = ref(false)
const notFoundText = ref('')
const onlyUnmatched = ref(false)
function resolveSep(mode, custom) {
if (mode === 'tab') return '\t'
if (mode === 'comma') return ','
if (mode === 'semicolon') return ';'
if (mode === 'pipe') return '|'
return custom || ','
}
function buildTable(text, sep, hasHeader) {
const lines = (text || '').split(/\r?\n/).filter(l => l.trim() !== '')
if (!lines.length) return { headers: [], rows: [] }
const cells = lines.map(l => l.split(sep).map(c => c.trim()))
const colCount = Math.max(...cells.map(r => r.length))
const normalized = cells.map(r => {
const copy = r.slice()
while (copy.length < colCount) copy.push('')
return copy
})
let headers
let rows
if (hasHeader) {
headers = normalized[0].map((h, i) => h || `列${i + 1}`)
rows = normalized.slice(1)
} else {
headers = Array.from({ length: colCount }, (_, i) => `列${i + 1}`)
rows = normalized
}
return { headers, rows }
}
const tableA = computed(() => buildTable(rawA.value, resolveSep(sepA.value, customSepA.value), headerA.value))
const tableB = computed(() => buildTable(rawB.value, resolveSep(sepB.value, customSepB.value), headerB.value))
// el-table 需要对象数组,这里把二维数组转换为 { c0, c1, ... } 结构
function toObjects(rows) {
return rows.map(r => {
const obj = {}
r.forEach((v, i) => {
obj['c' + i] = v
})
return obj
})
}
const previewData = computed(() => toObjects(tableA.value.rows.slice(0, previewLimit.value)))
function parseNumber(raw) {
if (raw === null || raw === undefined) return null
const cleaned = String(raw).replace(/[,\s¥$€£%]/g, '')
if (cleaned === '' || cleaned === '-') return null
const n = Number(cleaned)
return Number.isFinite(n) ? n : null
}
function formatNum(n) {
if (n === null || n === undefined || !Number.isFinite(n)) return '-'
const rounded = Math.round(n * 1e6) / 1e6
return String(rounded)
}
const sumResult = computed(() => {
const idx = sumCol.value
if (idx === null || idx === undefined) return null
const rows = tableA.value.rows
if (!rows.length) return null
let total = 0
let valid = 0
let invalid = 0
let max = null
let min = null
rows.forEach(r => {
const n = parseNumber(r[idx])
if (n === null) {
invalid++
return
}
valid++
total += n
if (max === null || n > max) max = n
if (min === null || n < min) min = n
})
return { total, valid, invalid, max, min, avg: valid ? total / valid : null }
})
const groupResult = computed(() => {
const idx = groupCol.value
if (idx === null || idx === undefined) return { headers: [], rows: [], data: [] }
const rows = tableA.value.rows
if (!rows.length) return { headers: [], rows: [], data: [] }
const counter = new Map()
const display = new Map()
rows.forEach(r => {
const original = r[idx] === '' ? '(空值)' : r[idx]
const key = groupIgnoreCase.value ? original.toLowerCase() : original
counter.set(key, (counter.get(key) || 0) + 1)
if (!display.has(key)) display.set(key, original)
})
const total = rows.length
const list = [...counter.entries()].sort((a, b) => b[1] - a[1])
const outRows = list.map(([k, c]) => [display.get(k), String(c), ((c / total) * 100).toFixed(2) + '%'])
return { headers: ['分组值', '计数', '占比'], rows: outRows, data: toObjects(outRows) }
})
const vlookupResult = computed(() => {
const empty = { headers: [], rows: [], data: [], matched: 0, unmatched: 0, bKeys: 0, dupKeys: 0 }
const ia = keyColA.value
const ib = keyColB.value
if (ia === null || ia === undefined || ib === null || ib === undefined) return empty
const a = tableA.value
const b = tableB.value
if (!a.rows.length || !b.rows.length) return empty
const norm = v => (lookupIgnoreCase.value ? String(v).toLowerCase() : String(v))
const lookup = new Map()
let dupKeys = 0
b.rows.forEach(r => {
const k = norm(r[ib])
if (lookup.has(k)) dupKeys++
else lookup.set(k, r)
})
const picked = returnCols.value.length ? [...returnCols.value].sort((x, y) => x - y) : b.headers.map((_, i) => i)
const headers = [...a.headers, ...picked.map(i => `B.${b.headers[i]}`)]
let matched = 0
let unmatched = 0
const rows = []
a.rows.forEach(r => {
const hit = lookup.get(norm(r[ia]))
if (hit) matched++
else unmatched++
if (onlyUnmatched.value && hit) return
const extra = picked.map(i => (hit ? hit[i] ?? '' : notFoundText.value))
rows.push([...r, ...extra])
})
return { headers, rows, data: toObjects(rows), matched, unmatched, bKeys: lookup.size, dupKeys }
})
function toCsv(headers, rows) {
const escape = v => {
const s = String(v === null || v === undefined ? '' : v)
return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s
}
const lines = [headers.map(escape).join(',')]
rows.forEach(r => lines.push(r.map(escape).join(',')))
return lines.join('\r\n')
}
function downloadCsv(headers, rows, filename) {
if (!rows.length) {
ElMessage.warning('没有可导出的数据')
return
}
// 加 BOM 避免 Excel 打开中文乱码
const blob = new Blob(['\ufeff' + toCsv(headers, rows)], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success('已开始下载')
}
function exportPreview() {
downloadCsv(tableA.value.headers, tableA.value.rows, 'columns-preview.csv')
}
function exportGroup() {
downloadCsv(groupResult.value.headers, groupResult.value.rows, 'columns-group-count.csv')
}
function exportVlookup() {
downloadCsv(vlookupResult.value.headers, vlookupResult.value.rows, 'columns-vlookup.csv')
}
function loadDemo() {
rawA.value = [
'订单号\t客户ID\t金额',
'A1001\tC01\t1,200.50',
'A1002\tC02\t860',
'A1003\tC01\t340.25',
'A1004\tC03\t2000',
'A1005\tC09\t150'
].join('\n')
rawB.value = ['客户ID\t客户名称\t城市', 'C01\t张三商贸\t上海', 'C02\t李四科技\t北京', 'C03\t王五物流\t广州'].join('\n')
sepA.value = 'tab'
sepB.value = 'tab'
headerA.value = true
headerB.value = true
sumCol.value = 2
groupCol.value = 1
keyColA.value = 1
keyColB.value = 0
returnCols.value = [1, 2]
ElMessage.success('示例数据已载入,可切换各标签页查看效果')
}
function clearAll() {
rawA.value = ''
rawB.value = ''
sumCol.value = null
groupCol.value = null
keyColA.value = null
keyColB.value = null
returnCols.value = []
}2.3 效果截图
