Skip to content

实现 文本列处理与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-inputel-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 效果截图

文本列处理与VLOOKUP 效果截图

工具访问地址:https://www.i91tools.com/tools/text-columns