Appearance
实现 文本集合运算 工具
分类:developer | 标签:集合、交集、差集 两段文本求交集/差集/并集/对称差与计数
2.1 功能说明
两段文本求交集/差集/并集/对称差与计数
2.1.1 使用指南
功能说明
把两段文本当作两个集合来做比对运算,常用于核对名单、找出新增或缺失的数据、排查重复项。所有计算都在浏览器本地完成,不会上传任何内容。
六种运算
- 交集:A 与 B 中都存在的元素,用于找出两份名单的重合部分。
- 差集 (A - B):只在 A 中出现、B 中没有的元素,用于找出「缺失」或「新增」。
- 并集:A 与 B 合并后去重的全部元素。
- 对称差:只在其中一边出现的元素,即 (A - B) 加上 (B - A),用于快速定位两份数据的所有差异。
- 提取 A 中重复项:列出在 A 内部出现两次及以上的元素。
- 统计出现次数:输出制表符分隔的四列(元素、A 中次数、B 中次数、合计),按合计倒序排列,可直接粘贴进 Excel。
选项含义
- 分割方式:决定如何把文本拆成元素。按行最常用;按逗号会同时兼容中文逗号;按空白会用连续空格或换行拆分。
- 忽略大小写:勾选后
Apple与apple视为同一元素,输出时保留首次出现的原始写法。 - 忽略首尾空白:去掉每个元素两端的空格与制表符,避免因为多敲了空格而误判为不同元素。
- 忽略空项:丢弃拆分后得到的空字符串,避免空行参与运算。
使用提示
运算结果保留元素在原文中首次出现的书写形式与顺序(统计功能除外,它按出现总次数倒序)。若两份数据来自不同系统,建议同时勾选「忽略大小写」和「忽略首尾空白」,可以显著减少误差。
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> 中定义的主要函数/方法:
splitText()keyOf()uniqueMap()countMap()run()swapAB()loadDemo()clearAll()copyResult()downloadResult()
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 textA = ref('')
const textB = ref('')
const splitMode = ref('line')
const ignoreCase = ref(false)
const trimItem = ref(true)
const dropEmpty = ref(true)
const result = ref('')
const summary = ref('')
const currentOp = ref('')
const operations = [
{ key: 'intersect', label: '交集' },
{ key: 'diff', label: '差集 (A - B)' },
{ key: 'union', label: '并集' },
{ key: 'symmetric', label: '对称差' },
{ key: 'dupA', label: '提取 A 中重复项' },
{ key: 'count', label: '统计出现次数' }
]
function splitText(text) {
if (!text) return []
let parts
if (splitMode.value === 'line') parts = text.split(/\r?\n/)
else if (splitMode.value === 'comma') parts = text.split(/[,,]/)
else parts = text.split(/\s+/)
if (trimItem.value) parts = parts.map(s => s.trim())
if (dropEmpty.value) parts = parts.filter(s => s.length > 0)
return parts
}
const listA = computed(() => splitText(textA.value))
const listB = computed(() => splitText(textB.value))
function keyOf(s) {
return ignoreCase.value ? s.toLowerCase() : s
}
// 键 -> 首次出现的原始文本,保证输出保留原始书写形式
function uniqueMap(list) {
const map = new Map()
list.forEach(v => {
const k = keyOf(v)
if (!map.has(k)) map.set(k, v)
})
return map
}
function countMap(list) {
const map = new Map()
list.forEach(v => {
const k = keyOf(v)
map.set(k, (map.get(k) || 0) + 1)
})
return map
}
const uniqCountA = computed(() => uniqueMap(listA.value).size)
const uniqCountB = computed(() => uniqueMap(listB.value).size)
function run(op) {
currentOp.value = op
const a = listA.value
const b = listB.value
if (!a.length && !b.length) {
result.value = ''
summary.value = ''
ElMessage.warning('请先输入文本 A 或文本 B')
return
}
const uniqA = uniqueMap(a)
const uniqB = uniqueMap(b)
let lines = []
if (op === 'intersect') {
lines = [...uniqA.entries()].filter(([k]) => uniqB.has(k)).map(([, v]) => v)
summary.value = `交集:${lines.length} 项(A 去重 ${uniqA.size} 项,B 去重 ${uniqB.size} 项)`
} else if (op === 'diff') {
lines = [...uniqA.entries()].filter(([k]) => !uniqB.has(k)).map(([, v]) => v)
summary.value = `差集 A - B:${lines.length} 项(A 去重 ${uniqA.size} 项)`
} else if (op === 'union') {
const merged = new Map(uniqA)
uniqB.forEach((v, k) => {
if (!merged.has(k)) merged.set(k, v)
})
lines = [...merged.values()]
summary.value = `并集:${lines.length} 项`
} else if (op === 'symmetric') {
const onlyA = [...uniqA.entries()].filter(([k]) => !uniqB.has(k)).map(([, v]) => v)
const onlyB = [...uniqB.entries()].filter(([k]) => !uniqA.has(k)).map(([, v]) => v)
lines = [...onlyA, ...onlyB]
summary.value = `对称差:${lines.length} 项(仅 A 有 ${onlyA.length} 项,仅 B 有 ${onlyB.length} 项)`
} else if (op === 'dupA') {
const counts = countMap(a)
lines = [...uniqA.entries()].filter(([k]) => counts.get(k) > 1).map(([, v]) => v)
summary.value = `A 中重复元素:${lines.length} 项(A 共 ${a.length} 项,去重后 ${uniqA.size} 项)`
} else if (op === 'count') {
const ca = countMap(a)
const cb = countMap(b)
const all = new Map(uniqA)
uniqB.forEach((v, k) => {
if (!all.has(k)) all.set(k, v)
})
const rows = [...all.entries()].map(([k, v]) => {
const na = ca.get(k) || 0
const nb = cb.get(k) || 0
return { text: v, na, nb, total: na + nb }
})
rows.sort((x, y) => y.total - x.total)
lines = ['元素\tA中次数\tB中次数\t合计', ...rows.map(r => `${r.text}\t${r.na}\t${r.nb}\t${r.total}`)]
summary.value = `统计完成:共 ${rows.length} 个不同元素(制表符分隔,可直接粘贴到表格软件)`
}
result.value = lines.join('\n')
}
function swapAB() {
const tmp = textA.value
textA.value = textB.value
textB.value = tmp
if (currentOp.value) run(currentOp.value)
}
function loadDemo() {
textA.value = ['apple', 'banana', 'cherry', 'apple', 'durian', 'Elderberry'].join('\n')
textB.value = ['banana', 'cherry', 'fig', 'grape', 'elderberry'].join('\n')
run('symmetric')
}
function clearAll() {
textA.value = ''
textB.value = ''
result.value = ''
summary.value = ''
currentOp.value = ''
}
async function copyResult() {
if (!result.value) return
try {
await navigator.clipboard.writeText(result.value)
ElMessage.success('已复制到剪贴板')
} catch {
ElMessage.warning('复制失败,请手动选择文本复制')
}
}
function downloadResult() {
if (!result.value) return
const blob = new Blob([result.value], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `text-set-${currentOp.value || 'result'}.txt`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success('已开始下载')
}2.3 效果截图
