微信小程序批量录入功能的实现:从文本到结构化数据
前言
最近在开发一款礼账小程序时,遇到一个典型的前端需求:用户把"姓名 金额"格式的文本粘贴到textarea里,前端要实时解析成结构化数据,预览确认后再批量提交。
这个需求看似简单,但有几个坑:正则匹配的边界情况、大数据量渲染性能、预览交互设计。本文记录实现过程,适合做小程序前端的开发者参考。
需求分析
用户输入的文本格式:
yaml
张叔叔 800
李阿姨 500
表哥 1000
王老师 600元
二姑 800(提前给)
需要解析成:
javascript
[
{ name: '张叔叔', amount: 800, remark: '' },
{ name: '李阿姨', amount: 500, remark: '' },
{ name: '表哥', amount: 1000, remark: '' },
{ name: '王老师', amount: 600, remark: '' },
{ name: '二姑', amount: 800, remark: '提前给' }
]
正则解析
核心是一条正则,覆盖"姓名 + 空格 + 金额 + 可选单位 + 可选备注":
javascript
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
xml
<view class="container">
<view class="tip">把名单粘贴到下方,格式:姓名 金额(每行一条)</view>
<textarea
class="input-area"
placeholder="张叔叔 800 李阿姨 500 表哥 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
javascript
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
css
.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+条记录时,直接渲染会卡顿。用虚拟列表或分页渲染:
javascript
// 简单方案:只渲染前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
})
}
})
防抖处理
输入时实时解析会卡,加防抖:
javascript
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要换行,需要用 而不是\n:
xml
<!-- ❌ 错误 -->
<textarea placeholder="张叔叔 800\n李阿姨 500" />
<!-- ✅ 正确 -->
<textarea placeholder="张叔叔 800 李阿姨 500" />
坑2:金额精度
JavaScript的浮点数精度问题:
javascript
// ❌ 错误:0.1 + 0.2 = 0.30000000000000004
const amount = 0.1 + 0.2
// ✅ 正确:金额用分(整数)存储
const amountInCents = 10 + 20 // 30分 = 0.3元
坑3:正则的贪婪匹配
javascript
// ❌ 错误:贪婪匹配,"张三 800 李四 600"会匹配整行
const pattern = /^(.+)\s+(\d+)$/
// ✅ 正确:非贪婪匹配
const pattern = /^(.+?)\s+(\d{1,7})$/
总结
批量录入功能的核心是"文本解析+预览确认+批量提交"三步。技术要点:
- 正则解析:一条正则覆盖多种格式变体
- 前端预览:解析在前端完成,用户确认后再提交
- 容错处理:解析失败的行单独提示,不影响整体
- 性能优化:大数据量用分页渲染+防抖
- 交互设计:可删除单条、显示合计金额
这个方案已在线上稳定运行,100条记录解析+预览约200ms,批量提交约28秒。适合任何需要"文本→结构化数据"的小程序场景。
作者简介: 本文由1024House技术团队撰写。简记往来是一款专注于人情礼金管理的小程序,支持批量录入、双向差额统计、多人协作和云端备份。搜索"简记往来"即可体验。