QQ 群号一多,复制粘贴这事儿就成了体力活。

有同行跟我抱怨过,拿着 Excel 一列一列贴,贴到第 200 个的时候眼睛就开始花了。漏了重复了全凭运气,心态直接炸。用 Vue 3 搭这个采集面板,第一个坑就是导入——不能指望用户还在那一个一个敲,得让他们“扔个文件走人”。

前端做不了太复杂的校验,但也不能全信用户给的数据。CSV 和 TXT 我都支持,每行一个群号,空格和空行自动忽略。正则直接筛掉非数字字符,核心逻辑写在 uploadFile 函数里,用 FileReader 逐行读,结果扔进 rawList 那个 ref 里。watch 监听 rawList 的变化实时算进度——解析了多少行、多少条格式不对、重复的有几条,都列清楚。进度条绑在 el-progress 上,用户看着至少不慌。

手动输入的入口我也留着,一个 el-inputtype="textarea",临时补几个号挺方便。两边数据合并时直接用 Set 去重:[...new Set(rawList.value)],Vue 3 响应式配合这写法一行搞定前端重复过滤。数据量大时可别用 filter 去重,性能差一截,Set 在万级数据下几乎是瞬时完成的。

踩过最深的一个坑是文件第一行的 BOM 头。Windows 记事本导出的 UTF-8 CSV 特别容易带,必须用 String.startsWith('\uFEFF') 判一下,去掉再解析。不然第一条群号死活对不上,查一下午都查不出来。

文件丢进来其实只算开了个头。真正让人头疼的是那些空格、空行、乱码,还有翻来覆去的重复号——前端要是不做清洗,后面导出来的数据绝对是一锅粥。

脏数据比你想象的更脏,清洗函数改了三次

拿到 rawList 后先过一遍规整函数:tidyData 负责 trim 每行,干掉 BOM 头,筛掉纯空行。QQ 号只认数字,非数字直接丢弃,同时在 el-progress 的 format 里记一笔“格式错误”。遇到同名但不同格式的也要统一,比如全角数字转半角,不然同一个号可能被当成两个。

const tidyData = (list) => list
  .map(s => s.trim())
  .filter(s => !['', '\n', '\r'].includes(s))
  .map(s => {
    if (s.startsWith('\uFEFF')) s = s.slice(1);
    return /^\d+$/.test(s) ? s : null;
  })
  .filter(Boolean);

去重用 Set 确实最快,但别一股脑儿扔进去就完事。要先按“群号+QQ号”做复合判重,否则同一个人在不同群里会被当成重复。响应式这边用 computed 包一层,返回 Array.from(new Set(compositeKeys)) 再映射回原对象。单一维度去重和复合维度去重差别挺大,前者只是 [...new Set(list.map(r => r.qq))],后者必须拼接 ${r.group}${r.qq}

Element Plus 的 el-table 贴上去之后,清洗动作就能可视化了。列里加个“状态”tag,标出“疑似重复”和“空字段”。右侧塞两个 el-button,一个单删当前行,一个批量清空选中。deleteRow 走 splice,batchDelete 走 filter,配合 弹个确认框,手滑也不至于误杀。最后把 cleanedList 推到 export 队列,留给后面处理。

Data cleaning and deduplication table with Element Plus

面板刚跑起来就被同事问候了

数据洗干净后,下一步就是让人看得见摸得着。总不能每次都靠 检查结果。第一版我其实只用了一个 el-table,把 cleanedList 往里一绑就交差了。结果数据一多——两千多条成员,页面直接卡成幻灯片。搜索框敲个字母,等两三秒才出结果。同事路过说了句“这玩意儿是跑在树莓派上吗”,我嘴上没理他,心里默默打开了 Element Plus 文档。

分页是第一步。 接上当前页和总条数,绑定一个 @current-change 事件,每次翻页重新 slice 列表就行。性能问题立刻缓解,但搜索这块还得单独处理。我的做法是弄一个 computed 叫 ,里面先跑搜索关键词:

const keyword = ref('')
const filteredList = computed(() => {
  if (!keyword.value.trim()) return cleanedList.value
  const kw = keyword.value.toLowerCase()
  return cleanedList.value.filter(item => {
    return item.qq.includes(kw) || item.nickname?.toLowerCase().includes(kw)
  })
})

注意 nickname 字段可能是 undefined,得用可选链。不然搜索时直接爆 TypeError,烦人。分页数据就基于 filteredList 再切:

const pagedList = computed(() => {
  const start = (page.value - 1) * pageSize.value
  return filteredList.value.slice(start, start + pageSize.value)
})

el-table 的 :data 绑 pagedList。搜索和分页是联动的——每次 keyword 变化,computed 重新跑,分页自动重置到第一页。逻辑上很清爽。但有个现实问题:如果群成员列表里带了群备注,或者 QQ 号本身就是纯数字,用户有时候会输入“查找123”,这时候只搜 qq 和 nickname 不够。我加了个可选的搜索维度选择器,比如“按QQ号”“按昵称”“全部”,用 radioGroup 控制,filter 函数里再加一层判断。

筛选这块我做了两个下拉:一个按群号过滤(用户可能同时导入了多个群),一个按状态过滤(比如只看“已清洗”或“疑似重复”)。状态标签在前面清洗那章已经写好了,这里只需要 map 一下。分页组件我习惯把 pageSize 也做成可调的,默认 20,用户能改成 50 或 100。:page-sizes="[10, 20, 50, 100]" 就行。数据量确实大的场景——比如五千条以上——可以考虑上虚拟滚动。Element Plus 2.9 之后 el-table-v2 支持了,但配置起来稍微多几行:要传 columns 数组和 data 数组,不能用默认的列插槽。我试过一次性能确实好,但项目里如果已经用了一堆自定义模板,迁移成本不低。几千条的话分页就够了,别过度设计。

UI 细节上,搜索框别放页面上方就跑,要加防抖。我用的 lodash-es 里的 debounce,300ms 延迟。不然每次敲键都触发 computed 重算,虽然响应式快,但 filter 里如果有复杂逻辑——比如中文拼音匹配——还是会卡。

import { debounce } from 'lodash-es'
const onSearch = debounce((val) => {
  keyword.value = val
}, 300)

其实面板做到这一步已经能应付大多数场景了。真正让我意外的不是性能,而是用户在实际操作中会把搜索框当记事本用——有人直接往里面粘贴一整段聊天记录,然后问我为什么搜不出来。这种事文档写再多也没用,只能加个输入长度限制,超过 50 字符就弹提示。前端工程师嘛,就是在帮用户擦屁股的路上越走越远。

清洗、过滤、分页都做完了,用户盯着表格看了半天,最后还是会问:“能不能直接给我一份干净的 Excel?”这问题其实挺合理——他们要的不是界面,而是可以带走的文件。于是我在面板右侧塞了一个导出按钮,把整套流程的最后一环补上。

Member management panel with search filter and pagination

导出按钮按下去那一下,才觉得没白忙

我选的是 xlsx 0.18.x,因为它能在浏览器里直接把二维数组变成 .xlsx,不需要后端。装完只要一行 import * as XLSX from 'xlsx' 就能拿到整个 API。这类库最怕的就是版本升级把函数名改了,所以我会在 里把版本号钉死,免得哪天凌晨被报警叫醒。

真正麻烦的从来不是“导出”,而是“按什么格式导出”。QQ 昵称里常有 emoji 和繁体字,Excel 默认单元格格式有时会截断。有人把群名片写成一整段备注,换行符混在里面,打开表时会自动折行,看起来像乱码。后来我干脆在生成工作表前先把数据做一轮二次清洗:trim 掉多余空白字符,特殊符号转成 Excel 能识别的 Unicode 替代字符,至少不会出现整列都是 #####。

导出字段的映射我列过一张表:nickname 保持原样但限制宽度 50 字符,qq 强制数值格式避免科学计数法吞掉短号,join_time 转成本地时区的日期字符串,status 用中文映射比如 active/silenced/banned。代码不长,却能把手工整理最头疼的环节省掉。写完这段之后第一次按下导出键,看着浏览器自动弹出下载框,忽然意识到:前端工程师的价值有时候不在于让系统多复杂,而在于让用户少开十个表格窗口。

import * as XLSX from 'xlsx'
function exportToExcel(data) {
  const ws = XLSX.utils.aoa_to_sheet([
    ['昵称', 'QQ号', '入群时间', '状态'],
    ...data.map(row => [
      row.nickname?.slice(0, 50) || '',
      String(row.qq),
      new Date(row.join_time).toLocaleDateString('zh-CN'),
      { active: '正常', silenced: '禁言', banned: '封禁' }[row.status] || '未知'
    ])
  ])
  const wb = XLSX.utils.book_new()
  XLSX.utils.book_append_worksheet(wb, ws, '成员列表')
  const buf = XLSX.write(wb, { type: 'array' })
  const url = URL.createObjectURL(new Blob([buf], { type: 'application/octet-stream' }))
  const a = document.createElement('a')
  a.href = url
  a.download = `members_${Date.now()}.xlsx`
  a.click()
  URL.revokeObjectURL(url)
}

按钮一按就响,剩下的事交给用户自己去处理。

功能跑通后反而睡不着了

导出按钮做完那刻确实爽。但第二天我就被自己的好奇心害了:连续点了十几次采集,同一个群号反复拉取,结果 QQ 直接弹了验证码。再试一次,账号被封了 24 小时。

这才意识到把功能做出来只是第一步。如果不想让这套面板变成“账号收割机”,有些东西必须提前想清楚。

QQ 那边的底线在哪

群里成员信息拉取是在模拟客户端请求。腾讯风控对“短时间内大量获取相同数据”特别敏感。我踩过的两个坑:频率限制不是线性的——同一群号请求间隔低于 5 秒,连续 3 次就会触发滑块验证;换 IP 也没用,因为风控会关联设备指纹。另一个是账号权重决定上限——新号或长期不发言的号,采集 200 条左右就可能被临时限制;老号日常活跃又加了多个群,能跑到 800 条左右才触发阈值。

解决办法其实不复杂:在采集逻辑里加一个队列,用 setTimeout 控制每次请求间隔至少 8 秒,每次请求完随机暂停 2~5 秒。代码量不到 20 行,但能让账号活得更久。如果用 axios,可以结合 做超时兜底——万一请求卡死,别让队列一直等着。

前端不能碰的数据雷区

拿到成员数据后,很多人习惯直接把 QQ 号和手机号存入 IndexedDB。但浏览器存储是明文的——任何第三方脚本、浏览器扩展甚至开发者工具里翻一下都能看到。

我见过有人把采集到的 7000 多条数据直接塞进 pinia 的 state 里,页面刷新后数据还在 localStorage 里躺着。那个瞬间我后背一凉:如果用户把面板部署到公共电脑上,或者浏览器被植入恶意扩展,这些数据就等于裸奔。至少做到两点:第一,敏感字段(手机号、真实姓名)在显示时用 v-text 配合 filter 做脱敏;第二,存储时用 crypto-jsjsencrypt 做一次简单加密,密钥不放在前端代码里,从后端接口动态获取。别嫌麻烦,数据泄露的后果比封号严重得多。

唯一的建议:让后端扛下所有

整个采集流程全放前端跑?那等于把炸药包搁客厅里。生产环境里,采集接口必须抽到后端,Node.js 或 Python 代理请求都行,前端只负责展示和触发。后端能控制更精准的限流、记 IP 黑名单,甚至上 Puppeteer 模拟真人操作。前端面板只干一件事:告诉用户“开始采集了”和“结果出来了”。中间的脏活,留给服务器。

自动化流水线写起来倒没那么难,真正磨人的,是搞清楚哪些坑你压根就不该踩进去。