简记往来批量录入功能的实现:从文本到结构化数据

11 阅读2分钟

微信小程序批量录入功能的实现:从文本到结构化数据

前言

最近在开发一款礼账小程序时,遇到一个典型的前端需求:用户把"姓名 金额"格式的文本粘贴到textarea里,前端要实时解析成结构化数据,预览确认后再批量提交。

这个需求看似简单,但有几个坑:正则匹配的边界情况、大数据量渲染性能、预览交互设计。本文记录实现过程,适合做小程序前端的开发者参考。

需求分析

用户输入的文本格式:

张叔叔 800
李阿姨 500
表哥 1000
王老师 600
二姑 800(提前给)

需要解析成:

[
  { name: '张叔叔', amount: 800, remark: '' },
  { name: '李阿姨', amount: 500, remark: '' },
  { name: '表哥', amount: 1000, remark: '' },
  { name: '王老师', amount: 600, remark: '' },
  { name: '二姑', amount: 800, remark: '提前给' }
]

正则解析

核心是一条正则,覆盖"姓名 + 空格 + 金额 + 可选单位 + 可选备注":

function parseLine(line) {
  const pattern = /^(.+?)\s+(\d{1,7})(?:\s*(?:元|块|人民币))?(?:\s*[((]([^))]+)[))])?$/
  const match = line.trim().match(pattern)
  
  if (!match) return null
  
  const name = match[1].trim()
  const amount = parseInt(match[2])
  const remark = match[3] ? match[3].trim() : ''
  
  // 校验
  if (name.length < 1 || name.length > 20) return null
  if (amount < 1 || amount > 9999999) return null
  
  return { name, amount, remark }
}

function parseText(text) {
  const lines = text.split('\n').filter(l => l.trim())
  const records = []
  const failed = []
  
  lines.forEach((line, idx) => {
    const result = parseLine(line)
    if (result) {
      records.push(result)
    } else {
      failed.push({ lineNum: idx + 1, text: line })
    }
  })
  
  return { records, failed }
}

正则解释:

  • (.+?) 非贪婪匹配姓名
  • \s+ 一个或多个空白符(空格/制表符)
  • (\d{1,7}) 1-7位数字金额
  • (?:\s*(?:元|块|人民币))? 可选的单位
  • (?:\s*[((]([^))]+)[))])? 可选的括号备注

前端实现

WXML

<view class="container">
  <view class="tip">把名单粘贴到下方,格式:姓名 金额(每行一条)</view>
  
  <textarea
    class="input-area"
    placeholder="张叔叔 800&#10;李阿姨 500&#10;表哥 1000"
    value="{{rawText}}"
    bindinput="onInput"
    auto-height
    maxlength="-1"
  />
  
  <view class="actions">
    <button type="primary" bindtap="onParse" loading="{{parsing}}">
      解析预览
    </button>
    <button bindtap="onClear">清空</button>
  </view>
  
  <!-- 解析失败提示 -->
  <view wx:if="{{failedLines.length > 0}}" class="failed-tip">
    <text>⚠️ {{failedLines.length}} 行解析失败:</text>
    <view wx:for="{{failedLines}}" wx:key="lineNum" class="failed-line">
      第{{item.lineNum}}行:{{item.text}}
    </view>
  </view>
  
  <!-- 预览列表 -->
  <view wx:if="{{parsedRecords.length > 0}}" class="preview">
    <view class="preview-header">
      <text>解析到 {{parsedRecords.length}} 条记录</text>
      <text class="total">合计:{{totalAmount}}元</text>
    </view>
    
    <scroll-view scroll-y class="record-list">
      <view wx:for="{{parsedRecords}}" wx:key="index" class="record-item">
        <view class="record-info">
          <text class="name">{{item.name}}</text>
          <text class="amount">{{item.amount}}元</text>
          <text wx:if="{{item.remark}}" class="remark">({{item.remark}})</text>
        </view>
        <icon type="clear" size="16" catchtap="onRemoveItem" data-index="{{index}}"/>
      </view>
    </scroll-view>
    
    <button type="primary" bindtap="onConfirm" loading="{{submitting}}">
      确认录入({{parsedRecords.length}}条)
    </button>
  </view>
</view>

JS

Page({
  data: {
    rawText: '',
    parsedRecords: [],
    failedLines: [],
    totalAmount: 0,
    parsing: false,
    submitting: false
  },
  
  onInput(e) {
    this.setData({ rawText: e.detail.value })
  },
  
  onParse() {
    const text = this.data.rawText
    if (!text.trim()) {
      wx.showToast({ title: '请输入内容', icon: 'none' })
      return
    }
    
    this.setData({ parsing: true })
    
    // 用setTimeout避免阻塞UI
    setTimeout(() => {
      const { records, failed } = this.parseText(text)
      const totalAmount = records.reduce((sum, r) => sum + r.amount, 0)
      
      this.setData({
        parsedRecords: records,
        failedLines: failed,
        totalAmount,
        parsing: false
      })
      
      if (records.length === 0) {
        wx.showToast({ title: '未解析到有效记录', icon: 'none' })
      }
    }, 100)
  },
  
  parseText(text) {
    const lines = text.split('\n').filter(l => l.trim())
    const records = []
    const failed = []
    
    lines.forEach((line, idx) => {
      const result = this.parseLine(line)
      if (result) {
        records.push(result)
      } else {
        failed.push({ lineNum: idx + 1, text: line })
      }
    })
    
    return { records, failed }
  },
  
  parseLine(line) {
    const pattern = /^(.+?)\s+(\d{1,7})(?:\s*(?:元|块|人民币))?(?:\s*[((]([^))]+)[))])?$/
    const match = line.trim().match(pattern)
    
    if (!match) return null
    
    const name = match[1].trim()
    const amount = parseInt(match[2])
    const remark = match[3] ? match[3].trim() : ''
    
    if (name.length < 1 || name.length > 20) return null
    if (amount < 1 || amount > 9999999) return null
    
    return { name, amount, remark }
  },
  
  onRemoveItem(e) {
    const index = e.currentTarget.dataset.index
    const records = this.data.parsedRecords
    records.splice(index, 1)
    
    this.setData({
      parsedRecords: records,
      totalAmount: records.reduce((sum, r) => sum + r.amount, 0)
    })
  },
  
  async onConfirm() {
    if (this.data.parsedRecords.length === 0) return
    
    this.setData({ submitting: true })
    
    try {
      const res = await wx.cloud.callFunction({
        name: 'batchInsert',
        data: {
          bookId: this.data.bookId,
          records: this.data.parsedRecords
        }
      })
      
      const { success, failed } = res.result
      
      if (failed.length > 0) {
        wx.showModal({
          title: '部分录入失败',
          content: `成功${success.length}条,失败${failed.length}条`,
          showCancel: false
        })
      } else {
        wx.showToast({ title: `录入${success.length}条成功` })
        setTimeout(() => wx.navigateBack(), 1500)
      }
    } catch (err) {
      wx.showToast({ title: '录入失败', icon: 'error' })
    } finally {
      this.setData({ submitting: false })
    }
  },
  
  onClear() {
    this.setData({
      rawText: '',
      parsedRecords: [],
      failedLines: [],
      totalAmount: 0
    })
  }
})

WXSS

.container {
  padding: 30rpx;
}

.tip {
  font-size: 26rpx;
  color: #888;
  margin-bottom: 20rpx;
}

.input-area {
  width: 100%;
  min-height: 300rpx;
  padding: 20rpx;
  border: 1rpx solid #ddd;
  border-radius: 12rpx;
  font-size: 28rpx;
  box-sizing: border-box;
}

.actions {
  display: flex;
  gap: 20rpx;
  margin: 30rpx 0;
}

.actions button {
  flex: 1;
}

.failed-tip {
  padding: 20rpx;
  background: #fff7e6;
  border-radius: 8rpx;
  margin-bottom: 20rpx;
  font-size: 24rpx;
  color: #fa8c16;
}

.failed-line {
  margin-top: 10rpx;
  color: #666;
}

.preview {
  margin-top: 30rpx;
}

.preview-header {
  display: flex;
  justify-content: space-between;
  padding: 20rpx 0;
  font-size: 28rpx;
  font-weight: bold;
}

.total {
  color: #07c160;
}

.record-list {
  max-height: 600rpx;
  border: 1rpx solid #eee;
  border-radius: 8rpx;
  margin-bottom: 20rpx;
}

.record-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20rpx;
  border-bottom: 1rpx solid #f5f5f5;
}

.record-info {
  display: flex;
  align-items: center;
  gap: 15rpx;
}

.name {
  font-size: 28rpx;
  min-width: 150rpx;
}

.amount {
  color: #07c160;
  font-weight: bold;
}

.remark {
  color: #999;
  font-size: 24rpx;
}

性能优化

大数据量渲染

当解析出200+条记录时,直接渲染会卡顿。用虚拟列表或分页渲染:

// 简单方案:只渲染前50条,其余滚动加载
Page({
  data: {
    parsedRecords: [],
    displayRecords: [],  // 实际渲染的
    pageSize: 50,
    currentPage: 1
  },
  
  onParse() {
    // ...解析逻辑
    this.setData({
      parsedRecords: records,
      displayRecords: records.slice(0, this.data.pageSize)
    })
  },
  
  onLoadMore() {
    const nextPage = this.data.currentPage + 1
    const end = nextPage * this.data.pageSize
    this.setData({
      displayRecords: this.data.parsedRecords.slice(0, end),
      currentPage: nextPage
    })
  }
})

防抖处理

输入时实时解析会卡,加防抖:

onInput(e) {
  this.setData({ rawText: e.detail.value })
  
  // 防抖:停止输入500ms后自动解析
  clearTimeout(this.timer)
  this.timer = setTimeout(() => {
    if (e.detail.value.trim().length > 50) {
      this.onParse()
    }
  }, 500)
}

踩坑记录

坑1:textarea的placeholder换行

textarea的placeholder要换行,需要用&#10;而不是\n

<!-- ❌ 错误 -->
<textarea placeholder="张叔叔 800\n李阿姨 500" />

<!-- ✅ 正确 -->
<textarea placeholder="张叔叔 800&#10;李阿姨 500" />

坑2:金额精度

JavaScript的浮点数精度问题:

// ❌ 错误:0.1 + 0.2 = 0.30000000000000004
const amount = 0.1 + 0.2

// ✅ 正确:金额用分(整数)存储
const amountInCents = 10 + 20  // 30分 = 0.3元

坑3:正则的贪婪匹配

// ❌ 错误:贪婪匹配,"张三 800 李四 600"会匹配整行
const pattern = /^(.+)\s+(\d+)$/

// ✅ 正确:非贪婪匹配
const pattern = /^(.+?)\s+(\d{1,7})$/

总结

批量录入功能的核心是"文本解析+预览确认+批量提交"三步。技术要点:

  1. 正则解析:一条正则覆盖多种格式变体
  2. 前端预览:解析在前端完成,用户确认后再提交
  3. 容错处理:解析失败的行单独提示,不影响整体
  4. 性能优化:大数据量用分页渲染+防抖
  5. 交互设计:可删除单条、显示合计金额

这个方案已在线上稳定运行,100条记录解析+预览约200ms,批量提交约28秒。适合任何需要"文本→结构化数据"的小程序场景。


作者简介: 本文由1024House技术团队撰写。简记往来是一款专注于人情礼金管理的小程序,支持批量录入、双向差额统计、多人协作和云端备份。搜索"简记往来"即可体验。