从零搭建一个微信小程序活动管理平台(二):活动报名 — 自定义表单、白名单与签到

140 阅读10分钟

系列导航

  • 从零搭建一个微信小程序活动管理平台(一):架构设计与技术选型
  • 从零搭建一个微信小程序活动管理平台(二):活动报名 — 自定义表单、白名单与签到(本篇)
  • 从零搭建一个微信小程序活动管理平台(三):服务预约 — 分时段预约名额限制与时间解析(敬请期待)
  • 从零搭建一个微信小程序活动管理平台(四):活动接龙:支持+1人和自定义字段的接龙系统(敬请期待)
  • 从零搭建一个微信小程序活动管理平台(五):出入登记与排号叫号:两种实时场景的技术实现(敬请期待)
  • 从零搭建一个微信小程序活动管理平台(六):通用能力:动态表单、二维码生成、分享机制与状态管理(敬请期待)
  • 从零搭建一个微信小程序活动管理平台(七):管理后台:审核系统与数据管理实践(敬请期待)

附上小程序成品,可以扫码看看。

口袋活动报名小程序二维码.jpg

活动报名是整个平台最核心、也是最复杂的模块。它需要解决三个关键问题:创建者如何灵活定义表单、参与者如何安全地验证身份、组织者如何高效地完成现场签到。

这篇文章将从源码出发,拆解动态表单引擎的设计思路,分享开发过程中踩过的几个微信特有坑,以及白名单验证和签到功能的完整实现。

动态表单引擎:让创建者自己决定收集什么

字段类型体系

项目的表单引擎支持 10 种字段类型,覆盖了信息收集的绝大多数场景:

// pages/registration/create/create.js
const FIELD_TYPES = [
  { type: 'text', label: '单行文本' }, { type: 'textarea', label: '多行文本' },
  { type: 'number', label: '数字' },   { type: 'phone', label: '手机号' },
  { type: 'date', label: '日期' },
  { type: 'select', label: '下拉选择' }, { type: 'radio', label: '单选' },
  { type: 'checkbox', label: '多选' },   { type: 'image', label: '图片' },
  { type: 'region', label: '省市区' }
]

每种字段类型在 UI 上的交互方式不同:文本类用 input 或 textarea,日期用原生 picker,选项类用 radio-group 或 checkbox-group,图片用 chooseImage 上传。这些差异全部封装在一个公共模板里,后面会详细展开。

创建流程:四步走

创建报名采用分步表单(Step Form),四个步骤依次是:

  1. 基本信息 — 标题、描述、地点、时间、人数上限
  2. 自定义字段 — 添加和管理表单字段
  3. 人员限制 — 公开报名 / 白名单 / 密码三种模式
  4. 预览发布 — 确认信息后保存草稿或提交审核

步骤切换通过 step 变量控制,每一步都有校验逻辑:

nextStep() {
  if (!this.validateStep()) return
  if (this.data.step < 3) {
    const fieldTitles = this.data.fields.filter(f => f.title.trim())
                                .map(f => f.title).join('、')
    this.setData({ step: this.data.step + 1, fieldTitles })
  } else {
    this.handleSubmit()
  }
}

字段的增删改排序

在第二步「自定义字段」中,创建者可以点击按钮添加字段,每个字段卡片支持编辑标题、设置提示文字、配置选项、标记必填,还可以上移下移调整顺序:

addField(e) {
  const type = e.currentTarget.dataset.type
  const newField = {
    id: `field_${Date.now()}`,
    type,
    title: '',
    placeholder: '',
    required: false,
    options: ['select', 'radio', 'checkbox'].includes(type) ? '' : undefined,
    sortOrder: this.data.fields.length + 1
  }
  this.setData({ fields: [...this.data.fields, newField] })
}

注意 options 的初始值:对于选项类字段(select / radio / checkbox),初始化为空字符串 '' 而不是空数组 []。这不是偷懒,而是有意为之 — 后面会解释为什么。

字段排序用的是经典的数组交换:

moveField(e) {
  const idx = e.currentTarget.dataset.idx
  const direction = e.currentTarget.dataset.direction
  const fields = [...this.data.fields]
  if (direction === 'up' && idx > 0) {
    [fields[idx - 1], fields[idx]] = [fields[idx], fields[idx - 1]]
  } else if (direction === 'down' && idx < fields.length - 1) {
    [fields[idx], fields[idx + 1]] = [fields[idx + 1], fields[idx]]
  }
  this.setData({ fields })
}

公共渲染模板 field-render.wxml

表单字段的渲染被抽取到了一个公共模板 field-render.wxml 中,报名页(register)和详情页(detail)通过 <import> 引入,避免了大量重复代码:

<!-- pages/templates/field-render.wxml -->
<template name="fieldRender">
  <block wx:for="{{fields}}" wx:key="id">
    <view class="form-item">
      <text class="field-label">
        {{item.title}}
        <block wx:if="{{item.required}}"><text class="required">*</text></block>
      </text>
​
      <!-- text / number / phone -->
      <block wx:if="{{item.type === 'text' || item.type === 'number' || item.type === 'phone'}}">
        <input class="input" value="{{fieldValues[item.id]}}"
               bindinput="onFieldInput" data-fieldid="{{item.id}}"
               type="{{item.type === 'number' ? 'digit' : (item.type === 'phone' ? 'number' : 'text')}}" />
      </block>
​
      <!-- date -->
      <block wx:elif="{{item.type === 'date'}}">
        <picker mode="date" value="{{fieldValues[item.id]}}"
                bindchange="onFieldPicker" data-fieldid="{{item.id}}">
          <view class="input picker-display">
            {{fieldValues[item.id] || '请选择日期'}}
          </view>
        </picker>
      </block>
​
      <!-- radio -->
      <block wx:elif="{{item.type === 'radio'}}">
        <radio-group bindchange="onFieldInput" data-fieldid="{{item.id}}">
          <block wx:for="{{item.options}}" wx:for-item="opt" wx:key="*this">
            <label class="radio-label">
              <radio value="{{opt}}" checked="{{fieldValues[item.id] === opt}}" color="#4F6EF7" />
              <text>{{opt}}</text>
            </label>
          </block>
        </radio-group>
      </block>
​
      <!-- checkbox -->
      <block wx:elif="{{item.type === 'checkbox'}}">
        <checkbox-group bindchange="onFieldCheckbox" data-fieldid="{{item.id}}">
          <block wx:for="{{item.options}}" wx:for-item="opt" wx:key="*this">
            <label class="checkbox-label">
              <checkbox value="{{opt}}"
                        checked="{{fieldChecked[item.id + '_' + opt]}}" color="#4F6EF7" />
              <text>{{opt}}</text>
            </label>
          </block>
        </checkbox-group>
      </block>
      <!-- ... 其余类型省略 ... -->
    </view>
  </block>
</template>

使用方式非常简单,在需要的页面引入模板并传入数据即可:

<import src="/pages/templates/field-render.wxml" />
<template is="fieldRender" data="{{fields: activity.fields, fieldValues, fieldIndexes}}" />

模板约定了使用页面需要实现 onFieldInput、onFieldPicker、onFieldSelect、onFieldCheckbox 等事件处理函数。这种「模板 + 约定接口」的模式,在小程序原生开发中是复用 UI 的实用方案 — 比组件更轻量,不需要额外的 Component 定义和 properties 声明。

选项输入的踩坑经历:为什么 options 用字符串存?

这是开发过程中遇到的第一个坑。

问题现象

最初的设计是:选项类字段的 options 存为数组 ['选项A', '选项B', '选项C'],在创建页面用多个 input 分别编辑。但实际体验极差 — 每输入一个字符,输入框就失焦,用户根本没法连续打字。

根因分析

微信小程序的 setData 是异步的,每次调用都会触发视图层的 diff 和重新渲染。当 options 是数组时,修改其中一个元素需要:

// 修改 options[2] 的值
const fields = this.data.fields.map(f =>
  f.id === id ? { ...f, options: f.options.map((o, i) => i === 2 ? newValue : o) } : f
)
this.setData({ fields })

这会导致整个 fields 数组的引用变化,微信框架会重新渲染所有字段卡片。而 input 组件在重新渲染时会丢失焦点 — 这就是「打一个字就跳走」的根本原因。

解决方案:分号分隔的字符串

把 options 改为一个用分号分隔的字符串,用户在一个 input 里编辑全部选项:

<input class="input" value="{{item.options}}"
       bindinput="updateField" data-id="{{item.id}}" data-field="options"
       placeholder="选项(分号分隔)" />
<text class="options-hint">多个选项请用分号 ; 分隔,如:选项1;选项2;选项3</text>

updateField 是一个通用的字段属性更新函数,只修改目标字段的单个属性,不会触发其他字段的重渲染:

updateField(e) {
  const id = e.currentTarget.dataset.id
  const field = e.currentTarget.dataset.field
  const value = e.detail.value
  const fields = this.data.fields.map(f =>
    f.id === id ? { ...f, [field]: value } : f
  )
  this.setData({ fields })
}

在最终提交时,再把字符串拆成数组:

buildActivityData() {
  const validFields = this.data.fields.filter(f => f.title.trim()).map(f => ({
    ...f,
    options: typeof f.options === 'string'
      ? f.options.split(/[;;]/).map(s => s.trim()).filter(Boolean)
      : f.options
  }))
  // ...
}

注意这里同时兼容了中文分号 ; 和英文分号 ;,避免用户输入中文标点导致解析失败。

在编辑模式加载活动时,反向操作把数组拼回字符串:

fields: (activity.fields || []).map(f => ({
  ...f,
  options: Array.isArray(f.options) ? f.options.join(';') : (f.options || undefined)
}))

这个方案的本质思路是:让输入框的 value 绑定一个字符串原始值,避免在用户输入过程中频繁修改数组结构。 类似的思路在 Vue 开发中也常见 — 对于列表项的内联编辑,先用字符串暂存、失焦后再同步到数组,是通用的优化手段。

微信 checkbox 不响应问题:fieldChecked 映射表

问题现象

微信的 <checkbox> 组件有一个让很多开发者头疼的行为:checked 属性不是完全受控的。当通过 setData 改变 checked 的绑定值时,部分情况下 checkbox 的勾选状态不会更新,尤其是在 wx:for 循环中渲染多个 checkbox 时更为明显。

这不是 bug,而是微信小程序框架的设计选择 — checkbox 的 checked 只在组件初始化时生效,后续的 setData 更新不一定能同步到原生组件层。

解决方案

维护一个扁平化的 fieldChecked 映射表,key 是 {fieldId}_{optionValue} 的组合键:

// register.js — checkbox 变更处理
onFieldCheckbox(e) {
  const fieldId = e.currentTarget.dataset.fieldid
  const values = e.detail.value  // checkbox-group 返回的选中值数组
  const updates = { [`fieldValues.${fieldId}`]: values }

  // 先清除该字段下所有旧的 checked 状态
  const oldChecked = this.data.fieldChecked || {}
  for (const key of Object.keys(oldChecked)) {
    if (key.startsWith(fieldId + '_')) {
      updates[`fieldChecked.${key}`] = false
    }
  }
  // 再根据当前选中值设置新的 checked 状态
  for (const v of values) {
    updates[`fieldChecked.${fieldId}_${v}`] = true
  }
  this.setData(updates)
}

模板中通过组合键读取状态:

<checkbox value="{{opt}}"
          checked="{{fieldChecked[item.id + '_' + opt]}}"
          color="#4F6EF7" />

这个方案的关键在于:

  1. 每个 checkbox 有唯一的 key,避免了数组索引变化导致的渲染混乱
  2. 使用 setData 的路径更新语法(fieldChecked.${key}),只更新变化的键值对,减少 diff 开销
  3. 先清后设,确保取消勾选的选项能正确还原为 false

对比 radio 的处理就简单很多 — 直接用 fieldValues[item.id] === opt 做表达式判断即可,因为 radio-group 是单选,每次只有一个值变化。

白名单手机号验证:三种准入模式

活动支持三种准入模式,在创建流程的第三步配置:

  • 公开报名(public)— 任何人扫码即可报名
  • 白名单报名(whitelist)— 仅导入的人员可报名,需要手机号验证
  • 密码报名(password)— 输入正确密码才可报名

白名单的创建

创建者在配置白名单时,通过文本域批量导入,每行一人,格式为「姓名 手机号」:

张三 13800138001
李四 13800138002
王五 13800138003

提交时解析为结构化数据:

const whitelist = this.data.accessMode === 'whitelist'
  ? this.data.whitelistText.split('\n').filter(l => l.trim()).map(l => {
      const parts = l.split(/[\t,,\s]+/)
      return {
        id: `wl_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
        name: parts[0] || '',
        phone: parts[1] || '',
        registered: false
      }
    })
  : undefined

支持 Tab、逗号、空格等多种分隔符,兼容从 Excel 粘贴过来的数据格式。

前端的验证流程

参与者进入报名页后,如果活动是白名单模式,会先展示手机号验证区域:

<block wx:if="{{activity.accessMode === 'whitelist' && !verified}}">
  <view class="card">
    <text class="label">白名单验证</text>
    <text class="hint">请输入您的手机号验证报名资格</text>
    <view class="verify-row">
      <input class="input flex-1" value="{{verifyPhone}}"
             bindinput="onInput" data-field="verifyPhone"
             placeholder="请输入手机号" type="number" maxlength="11" />
      <van-button type="primary" size="small" bindtap="handleVerify">验证</van-button>
    </view>
  </view>
</block>

验证通过后 verified 置为 true,报名表单才会显示。前端先做手机号格式校验,再调用后端接口:

handleVerify() {
  const { activityId, verifyPhone } = this.data
  const phoneErr = Validators.phone(verifyPhone)
  if (phoneErr) {
    wx.showToast({ title: phoneErr, icon: 'none' })
    return
  }
  api.verifyWhitelist(activityId, verifyPhone).then(result => {
    if (result.verified) {
      this.setData({ verified: true, phone: verifyPhone })
    } else {
      wx.showToast({ title: result.message || '验证失败', icon: 'none' })
    }
  })
}

后端的双重校验

后端的白名单验证分两层。第一层是专门的验证接口,给前端「验证按钮」用,根据活动 ID 和手机号在白名单中查找匹配项,返回验证结果。

第二层在报名提交时再次校验——后端从当前登录用户的资料中取出注册手机号,与白名单做比对,而不是使用前端传过来的验证手机号。这确保了身份的真实性:即使前端伪造了验证通过的手机号,后端也会用用户资料中的真实手机号做二次比对。

签到功能:基于手机号的现场核验

活动当天的签到是组织者的核心需求。签到方案选用了最朴素的手机号签到 — 组织者输入参与者的手机号,系统匹配报名记录并完成签到。

前端签到入口

签到功能集成在详情页的创建者视角中,也支持跳转到独立的签到页面。快速签到是一个简单的输入框 + 按钮:

handleCheckin() {
  const { activityId, checkinPhone, registrations } = this.data
  if (!checkinPhone.trim()) {
    wx.showToast({ title: '请输入手机号', icon: 'none' })
    return
  }
  // 前端预判:已签到的直接提示,避免无效请求
  const reg = registrations.find(r => r.phone == checkinPhone)
  if (reg && reg.status === 'checked_in') {
    wx.showToast({ title: '您已完成签到,无需重复操作', icon: 'none', duration: 2000 })
    this.setData({ checkinPhone: '' })
    return
  }
  api.checkin(activityId, checkinPhone).then(result => {
    this.setData({ checkinPhone: '' })
    if (result.success) {
      wx.showToast({ title: '签到成功', icon: 'success' })
      this.loadData()  // 刷新列表,更新签到状态
    } else {
      wx.showToast({ title: result.message || '签到失败', icon: 'none', duration: 2000 })
    }
  })
}

前端做了一次预判优化:如果本地列表中该手机号已经是「已签到」状态,直接拦截,不发请求。这减少了网络往返,也让体验更流畅。

后端签到逻辑

后端签到接口的处理流程:

  1. 校验活动存在性:确认活动 ID 对应的活动记录存在
  2. 权限校验:比对活动创建者和当前操作者的 ID,仅创建者可执行签到
  3. 匹配报名记录:根据手机号在报名记录中查找对应的参与者
  4. 更新签到状态:将匹配的记录状态更新为已签到,并记录签到时间
  5. 推送通知:向活动创建者发送签到通知

签到的权限控制很严格:只有活动创建者能操作签到。签到成功后还会推送一条通知,让创建者在消息中心也能看到签到动态。

报名列表的可展开卡片:创建者的全景视图

创建者在详情页可以看到所有报名记录的完整列表。为了在有限屏幕空间内展示大量信息,列表采用了可展开卡片的交互模式。

交互设计

每张卡片的收起状态只显示关键信息(昵称、手机号、报名时间、状态标签),点击后展开显示该参与者填写的所有自定义字段内容。

<view class="card record-card {{expandedMap[item.id] ? 'expanded' : ''}}"
      bindtap="toggleRecord" data-id="{{item.id}}">
  <!-- 摘要行:始终显示 -->
  <view class="reg-row">
    <view class="reg-summary">
      <view class="reg-name-row">
        <text class="reg-name">{{item.nickname}}</text>
        <text class="reg-name">{{item.phone}}</text>
      </view>
      <text class="reg-meta">报名时间:{{item.createTime}}</text>
    </view>
    <view class="reg-right">
      <van-tag type="{{item.status === 'registered' ? 'success' : 'success'}}"
               plain="{{item.status === 'cancelled'}}">
        {{item.status === 'registered' ? '已报名' : '已签到'}}
      </van-tag>
      <van-icon name="{{expandedMap[item.id] ? 'arrow-up' : 'arrow-down'}}"
                size="14" color="#9ca3af" />
    </view>
  </view>

  <!-- 展开区域:点击后显示 -->
  <view class="record-detail" wx:if="{{expandedMap[item.id]}}">
    <view class="record-detail-row">
      <text class="record-detail-label">报名人</text>
      <text class="record-detail-value">{{item.nickname || '-'}}</text>
    </view>
    <view class="record-detail-row">
      <text class="record-detail-label">手机号</text>
      <text class="record-detail-value">{{item.phone || '-'}}</text>
    </view>
    <!-- 动态渲染自定义字段的值 -->
    <block wx:for="{{activity.fields}}" wx:for-item="field" wx:key="id">
      <view class="record-detail-row" wx:if="{{item.fieldValues[field.id]}}">
        <text class="record-detail-label">{{field.title}}</text>
        <text class="record-detail-value">{{item.fieldValues[field.id]}}</text>
      </view>
    </block>
  </view>
</view>

展开/收起的状态管理

用一个 expandedMap 对象管理所有卡片的展开状态,key 是报名记录的 id:

toggleRecord(e) {
  const id = e.currentTarget.dataset.id
  const map = Object.assign({}, this.data.expandedMap)
  if (map[id]) {
    delete map[id]
  } else {
    map[id] = true
  }
  this.setData({ expandedMap: map })
}

用对象而非数组来存储展开状态,是因为对象的查找和删除都是 O(1),不会因为列表变长而变慢。模板中通过 expandedMap[item.id] 做条件渲染,wx:if 为 false 时不渲染展开区域的 DOM,保证了列表的渲染性能。

fieldValues 的字段映射

加载报名列表时有一个容易忽略的细节:后端返回的 fieldValues 的 key 可能和前端定义的 field.id 存在大小写差异(camelCase vs snake_case),所以做了一次归一化映射:

registrations.forEach(reg => {
  if (reg.fieldValues && typeof reg.fieldValues === 'object') {
    const normalized = {}
    const fieldIds = (activity.fields || []).map(f => f.id)
    Object.keys(reg.fieldValues).forEach(key => {
      let mapped = key
      for (const fid of fieldIds) {
        if (fid === key || fid.replace(/_([a-z])/g, (_, c) => c.toUpperCase()) === key) {
          mapped = fid
          break
        }
      }
      normalized[mapped] = reg.fieldValues[key]
    })
    reg.fieldValues = normalized
  }
})

这段代码处理了 field_abc 和 fieldAbc 之间的映射关系,确保展开卡片中能正确匹配到自定义字段的值。

后端:列表数据的权限隔离

报名列表接口做了严格的权限隔离 — 后端在处理列表请求时,会先校验当前请求者是否为活动创建者,非创建者直接返回空数组,不暴露其他参与者的信息。

非创建者的「我的报名」信息,是通过活动详情接口单独返回的,只包含当前用户自己的记录,避免隐私泄露。

报名提交的完整链路

从用户点击「提交报名」到数据落库,整个链路经过五层校验:

层级校验内容位置
1活动状态是否为 active前端 + 后端
2报名是否已开始/截止后端
3名额是否已满后端
4个人是否已报名后端
5白名单/密码验证前端 + 后端

后端的提交流程还做了一个贴心设计:如果用户之前取消过报名,再次报名时会复用已取消的记录(更新为新报名信息),而不是创建新记录。这避免了同一个用户对同一个活动产生多条记录,保持了数据的干净。

小结

这篇拆解了活动报名模块的几个核心技术点:

  • 动态表单引擎通过字段类型配置 + 公共模板的方式,实现了创建者自由定义表单、参与者自动渲染表单的能力
  • 选项字段用字符串暂存解决了微信小程序中数组绑定导致输入框失焦的问题
  • fieldChecked 映射表绕过了微信 checkbox 的 checked 属性不响应更新的限制
  • 白名单验证通过前后端双重校验保证了安全性
  • 手机号签到实现了简洁高效的现场核验
  • 可展开卡片用 expandedMap 对象管理状态,兼顾了交互体验和渲染性能

这些方案都是在一个实际项目中经过反复调试总结出来的,希望能帮到正在做类似项目的同学。


下一篇预告:(三)服务预约 — 分时段预约与名额控制

服务预约模块将解决一个更复杂的调度问题:如何按日期和时间段管理预约名额,如何处理同一时段多人预约的冲突,以及如何让管理者灵活调整时段配置。我们将深入后端的时段模型设计和前端的日历选择器实现。