前端 微信小程序点歌 设计思路

点歌小程序不是 CRUD:歌手端从曲库、歌词匹配到演出队列的状态设计

如果只看页面,点歌小程序很容易被误解成几个普通功能:

  • 我的歌曲
  • 添加歌曲
  • 编辑歌词
  • 上台演出
  • 查看点歌订单

但真正做下来会发现,它不是一个简单的列表 CRUD。

我之前做过一个歌手端点歌小程序,歌手需要先认证、绑定酒吧,然后维护自己的歌单和歌曲;用户点歌后,歌手在“演出模式”里处理待完成、演唱中、已结束的订单。中间还要支持云端搜索歌曲、匹配歌词、重新编辑歌词、启停歌曲、批量管理。

这类项目的难点不在某一个 API,而在整条链路的状态设计。

先说业务链路

歌手端小程序大概有三条主线。

第一条是上台链路:

登录 -> 实名认证 -> 绑定酒吧 -> 上台 -> 进入演出模式

第二条是曲库链路:

歌单列表 -> 展开歌单 -> 已启用歌曲 / 已停用歌曲 -> 添加或编辑歌曲 -> 启停或删除

第三条是歌词链路:

输入歌曲名 -> 云端搜索 -> 选择候选歌曲 -> 拉取歌词 -> 编辑歌词 -> 回填歌曲表单 -> 保存到曲库

还有一条演出链路:

待完成订单 -> 开始表演 -> 演唱中 -> 表演结束 -> 已结束

这些链路互相影响。比如歌手没有绑定酒吧,就不能上台;歌曲被停用,用户就不能点播;歌词没有匹配到,歌手要能手动编辑;已经有歌曲演唱中时,不能再开始另一首。

所以这篇文章想讲的是:点歌小程序怎么把这些状态写稳,而不是怎么写一个 uni.request

为什么不能按普通 CRUD 写

最开始很容易把“我的歌曲”写成普通管理页:

查歌单
查歌曲
新增
修改
删除

但项目里实际不是这样。

一个歌手的曲库至少有这些状态:

  • 当前选中的酒吧 organizationId
  • 当前展开的歌单。
  • 当前查看的是已启用歌曲,还是已停用歌曲。
  • 当前管理模式是歌单,还是歌曲。
  • 当前是否处于批量选择状态。
  • 当前弹窗是新增歌单、新增歌曲、确认停用、确认删除,还是编辑歌曲。
  • 当前歌词来自云端匹配,还是本地手动编辑。

这些状态叠在一起,如果每个按钮都直接改页面字段,很快会出现一种问题:操作成功后页面不知道该刷新哪里。

比如停用一首歌曲后,页面应该刷新当前歌单下的“已启用”列表;如果当前正在看“已停用”,则应该刷新“已停用”列表;如果当前歌单是折叠状态,则只刷新歌单数量就够了。

这就是项目里 globalUptata 这种方法存在的原因:它不是简单重新请求,而是要恢复用户当前上下文。

曲库页的核心状态

pageWdgq/wdgq/wdgq.vue 里,曲库页维护了很多状态。

我更愿意把它整理成四类:

state = {
  // 当前业务上下文
  organizationId,
  activeSongTypeIndex,
  activeSongUseTab,

  // 列表数据
  songTypes,
  songs,

  // 编辑态
  editorMode,       // songType or song
  selectedSongTypeIds,
  selectedSongIds,

  // 弹窗态
  modalType,
  editingSong,
  editingSongType
}

项目里有几个变量很能说明问题:

  • songMenuformDataIndex:当前展开的歌单。
  • GQUsejudge:当前看启用歌曲还是停用歌曲。
  • bottomEditorState:底部操作当前针对歌单还是歌曲。
  • selectGD / selectGQ:批量选择的歌单或歌曲。
  • modalForm.editGq:当前正在添加或编辑的歌曲。

这些变量不是 UI 细节,它们是曲库管理的“操作上下文”。

刷新不是重新加载,而是恢复上下文

很多管理页在操作成功后会直接重新拉一遍列表。

点歌曲库不能这么粗暴。

假设歌手正在做这件事:

展开“热门歌曲”歌单
切到“已停用”
勾选三首歌
点击启用

启用成功后,理想状态应该是:

  • 仍然停留在“热门歌曲”这个歌单。
  • 仍然能看到“已停用”列表被刷新。
  • 已选择项清空。
  • 已启用 / 已停用数量更新。

这就是“恢复上下文”的刷新。

伪代码可以写成:

async function refreshAfterMutation(message) {
  const openedIndex = state.activeSongTypeIndex
  const openedTab = state.activeSongUseTab

  await fetchSongTypes()

  if (openedIndex >= 0) {
    state.activeSongTypeIndex = openedIndex

    if (openedTab === 'enabled') {
      await fetchEnabledSongs()
    } else {
      await fetchDisabledSongs()
    }
  }

  clearSelection()
  toast(message)
}

这个逻辑看起来比 fetchList() 多很多,但真实项目里很有必要。否则用户每操作一次,就被打回默认状态,体验会很差。

歌词匹配链路的难点

添加歌曲时,歌手一般先输入歌曲名,然后系统去云端搜索候选歌曲。选择候选歌曲后,再去拉歌词。

这个链路里有几个细节:

  1. 云端搜索可能返回多个同名歌曲。
  2. 同一首歌可能因为歌手名、版本不同重复出现。
  3. 歌词有时直接返回文本,有时返回歌词地址,需要二次请求。
  4. 歌词可能不准,歌手需要手动编辑。
  5. 编辑歌词是在另一个页面完成的,保存后要回填到当前歌曲表单。

所以它不是简单的:

搜索 -> 选择 -> 保存

而是:

搜索歌曲 -> 去重候选项 -> 选择歌曲 -> 拉取歌词 -> 暂存到全局 -> 进入歌词编辑页 -> 保存后回填表单

项目里用了 Vuex 的 song 做跨页面暂存:

state: {
  song: {}
}

mutations: {
  setsong(state, val) {
    state.song = val
  }
}

这不是复杂架构,但很实用。

因为小程序页面跳转时,如果把整段歌词塞到 query 参数里,会有长度、编码和可读性问题。歌词这种多行文本,更适合用全局临时状态或本地缓存承接。

候选歌曲要做去重

云端搜索歌曲时,返回结果可能有重复。

项目里用歌曲名和歌手名做了一层去重:

if (songName === item.songName && singerName === item.songerName) {
  duplicated = true
}

这个判断不复杂,但它解决的是产品体验问题。

歌手输入一首歌,如果候选列表里出现一堆重复项,他很难判断该点哪一个。前端先做一层轻量去重,可以明显减少误选。

如果现在再做一版,我会把候选项设计成这样的结构:

{
  cloudSongId,
  songName,
  singerName,
  albumName,
  source,
  hasLyric
}

展示时优先显示歌曲名和歌手名,必要时再显示专辑或版本。这样既不会丢掉版本差异,也能避免纯重复。

歌词编辑页其实是一个小型解析器

歌词不是普通文本,它有时间轴。

常见 LRC 格式长这样:

[00:12.340]第一句歌词
[00:18.120]第二句歌词

编辑页要做两件事:

  1. 把 LRC 字符串解析成可编辑数组。
  2. 保存时再拼回 LRC 字符串。

项目里的编辑页会把每行拆成:

{
  s: '00',
  f: '12',
  m: '340',
  content: '第一句歌词'
}

这样用户可以单独改分钟、秒、毫秒和歌词内容。

保存时再拼回:

value += `[${s}:${f}.${m}]${content}\n`

这个设计的关键点是:编辑态和存储态不要混在一起。

存储态是 LRC 字符串,适合提交给服务端;编辑态是数组,适合页面渲染和用户修改。

修改时间后要重新排序

歌词编辑还有一个很隐蔽的问题:用户改了某一句歌词的时间后,顺序可能变了。

比如原来是:

00:20 第一行
00:25 第二行

用户把第二行改成 00:18,那它应该排到第一行前面。

项目里在输入框失焦时做了排序处理,按秒、毫秒分段比较。这个点很值得写,因为它不是页面样式问题,而是数据一致性问题。

我更建议把排序抽成独立方法:

function lyricTimeToNumber(row) {
  return Number(row.s) * 60 * 1000 +
    Number(row.f) * 1000 +
    Number(row.m)
}

function sortLyrics(rows) {
  return rows.slice().sort((a, b) => lyricTimeToNumber(a) - lyricTimeToNumber(b))
}

这样比在输入框事件里分别按 sfm 排更容易验证。

演出模式是订单状态机

歌手上台以后,会进入“演出模式”。

这个页面里有三个 tab:

  • 待完成
  • 演唱中
  • 已结束

这不是简单筛选,它对应订单状态流转:

待完成 -> 演唱中 -> 已结束

歌手点击“开始表演”时,前端还要判断当前是否已经有歌曲演唱中。

项目里会先请求演唱中列表,如果已经有进行中的歌曲,就提示“当前正在有歌曲进行演奏”,避免同时开始多首歌。

这个约束很重要。因为点歌场景里,同一时间只能有一首歌正在被歌手演唱。前端必须做一层防误触,服务端也应该做最终校验。

伪代码可以这样表达:

async function startSing(orderId) {
  const singingOrders = await fetchOrders({ state: 'singing' })

  if (singingOrders.length > 0) {
    toast('当前已有歌曲演唱中')
    return
  }

  await singStart(orderId)
  await refreshOrders('waiting')
  await refreshSingingSnapshot()
}

这就是典型的多状态协作:按钮点击、订单列表、当前演唱中快照、服务端状态必须一致。

上台前置条件不要散在各页面

歌手点击“上台”前,需要满足两个条件:

  • 已完成歌手认证。
  • 已绑定酒吧。

项目里在上台首页做了判断:未认证弹窗提示,未绑定酒吧则跳转绑定页面。

这类前置条件很容易散在不同页面,比如“我的歌曲”判断一次,“演出模式”判断一次,“上台”又判断一次。散开以后,后续改规则会很麻烦。

更稳的做法是抽一个业务守卫:

function assertSingerCanPerform(actorInfo) {
  if (!actorInfo.authenticated) {
    return { ok: false, reason: 'need_auth' }
  }

  if (!actorInfo.organizationId) {
    return { ok: false, reason: 'need_bind_bar' }
  }

  return { ok: true }
}

页面根据 reason 做弹窗或跳转。

这样认证、绑定、上台的规则就不会到处复制。

我会怎么重构这套代码

如果现在重新整理,我会先拆出三个业务模块,而不是先拆组件。

第一块:曲库模块。

songLibraryService = {
  fetchSongTypes,
  fetchSongsByType,
  saveSongType,
  saveSong,
  enableSongs,
  disableSongs,
  deleteSongs
}

第二块:歌词模块。

lyricService = {
  searchCloudSongs,
  fetchCloudLyric,
  fetchLyricByUrl,
  parseLrc,
  stringifyLrc,
  sortLyricRows
}

第三块:演出模块。

performanceService = {
  fetchOrders,
  startSing,
  endSing,
  goOnStage,
  stepDown
}

页面只保留状态和交互,不直接塞满接口细节。

这样后面要改歌词匹配规则、换接口、加批量复制歌曲、加演出倒计时,都不会牵动整个页面。

这篇技术点的验证方式

写完这类功能,我会重点验证这些场景:

  1. 未认证歌手点击上台,只弹认证提示,不进入演出模式。
  2. 已认证但未绑定酒吧,点击上台跳转绑定酒吧。
  3. 展开某个歌单后切换已启用 / 已停用,数量和列表正确。
  4. 停用歌曲后,当前 tab 和展开歌单不丢失。
  5. 批量选择歌曲后,启用、停用、删除都会清空选择态。
  6. 搜索云端歌曲时,重复候选项不会反复出现。
  7. 选择候选歌曲后,歌词能回填到编辑表单。
  8. 重新匹配歌词后,编辑页能拿到新的歌词。
  9. 修改歌词时间后,保存前顺序正确。
  10. 已有歌曲演唱中时,不能开始另一首。
  11. 表演结束后,待完成、演唱中、已结束三个列表刷新正确。

这些验证点覆盖了异步时序、多状态协作、跨页面数据、异常恢复和业务规则。

最后总结

点歌小程序表面上是“歌手维护歌曲,用户点歌”,但前端真正要解决的是状态流转。

我的经验是:这类项目不要从页面开始理解,要从链路开始理解。

认证绑定链路
曲库管理链路
歌词匹配链路
演出订单链路

链路清楚以后,再写页面就不会乱。

曲库页的难点是恢复操作上下文;歌词页的难点是解析和回填;演出页的难点是订单状态机和并发约束。把这几个点想明白,点歌小程序就不再是“几个列表页面”,而是一个完整的歌手工作台。

这也是我觉得这个项目值得拿出来分享的地方:它不是炫技项目,但里面的状态设计非常真实。很多前端项目写到后期难维护,往往不是因为组件不会封装,而是业务状态一开始没有被当成系统来设计。

posted @ 2026-06-22 15:49  万物皆object  阅读(7)  评论(0)    收藏  举报