Files
wxserver/pages/add-anniversary/add-anniversary.js
T
yuming 7bf84bff61 保存纪念日去掉 await 上报并加防重入,避免弱网下存出重复数据
onSubmit 原本 await requestSubscribe(),而它内部的 grant 上报走 api.request、
超时 10 秒。对已勾「总是保持以上选择」的用户连订阅弹窗都不会出现,点「保存」后
界面最长 10 秒毫无反馈;期间又没有任何提交中标志,重复点击会走两遍
storage.addAnniversary(),generateId() 每次生成新 id,直接存出两条重复纪念日。

改为:订阅授权 fire-and-forget(上报失败本就有 pending_sync_queue 兜底),
onSubmit 去掉 async、加实例级 submitting 标志防重入,本地写失败时放开标志并提示。

手势铁律未破:onSubmit 由 bindtap 直接绑定,方法体去掉了 async,从入口到
requestSubscribe 之间只有 if 判断、解构和三个同步校验,无任何 await 或 then。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:25:05 +08:00

429 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// add-anniversary.js
const storage = require('../../utils/storage')
const dateUtils = require('../../utils/date')
const sync = require('../../utils/sync')
const lunar = require('../../utils/lunar')
const subscribe = require('../../utils/subscribe')
Page({
// 保存中标志。放在实例上而不是 data 里:它只用于防重入,不参与渲染,
// 走 setData 反而会多一次没必要的视图层通信。
submitting: false,
data: {
anniversaryId: null,
personId: null,
personList: [],
inputName: '',
typeList: ['生日', '结婚纪念日', '订婚纪念日', '其他纪念日'],
typeIndex: 0,
showCustomType: false,
dateValue: '',
remindDaysList: ['提前3天', '提前7天', '提前14天', '提前30天', '自定义'],
remindDaysIndex: 0,
// UI 反馈:选了农历日期时展示对应农历文本 + 闰月警示
lunarText: '',
isLeapMonth: false,
formData: {
isLunar: false,
type: 'birthday',
customTypeName: '',
solarYear: '',
solarMonth: '',
solarDay: '',
lunarYear: '',
lunarMonth: '',
lunarDay: '',
isLeapMonth: false,
importance: 'low',
remindEnabled: true,
remindDays: 7,
remark: ''
}
},
onLoad(options) {
// 获取人员列表用于快捷选择
const persons = storage.getPersons()
this.setData({ personList: persons })
if (options.personId) {
// 从人员详情页进入,预选关联人员
const person = persons.find(p => p.id === options.personId)
if (person) {
this.setData({
personId: person.id,
inputName: person.name
})
}
}
if (options.id) {
// 编辑模式
this.setData({ anniversaryId: options.id })
this.loadAnniversary(options.id)
} else {
// 设置默认日期为今天
const today = new Date()
this.setData({
dateValue: dateUtils.formatDate(today, 'YYYY-MM-DD')
})
}
},
/**
* 加载纪念日信息(编辑模式)
*/
loadAnniversary(id) {
const anniversaries = storage.getAnniversaries()
const anniversary = anniversaries.find(a => a.id === id)
if (anniversary) {
const date = dateUtils.formatDate(
new Date(anniversary.solarYear, anniversary.solarMonth - 1, anniversary.solarDay),
'YYYY-MM-DD'
)
const typeIndex = this.getTypeIndex(anniversary.type)
// 编辑时不允许改关联人员,姓名展示用 personName 或从 personList 查
const person = this.data.personList.find(p => p.id === anniversary.personId)
this.setData({
formData: anniversary,
dateValue: date,
typeIndex,
showCustomType: anniversary.type === 'other',
personId: anniversary.personId,
inputName: person ? person.name : (anniversary.personName || '')
})
// 农历日期:编辑时也要回显
if (anniversary.isLunar) this._refreshLunar()
wx.setNavigationBarTitle({ title: '编辑纪念日' })
}
},
/**
* 获取类型索引
* 注:lunar_birthday 是老数据兼容,新 UI 没有这一项,统一映射到「生日」(0)
*/
getTypeIndex(type) {
const indexMap = {
birthday: 0,
lunar_birthday: 0,
wedding: 1,
engagement: 2,
other: 3
}
return indexMap[type] || 0
},
/**
* 姓名输入:用户打字时实时更新;点 chip 时也会触发
* 输入框值变了就清掉已绑定的 personId,提交时再按姓名查找/创建
*/
onNameInput(e) {
const name = e.detail.value
const matched = this.data.personList.find(p => p.name === name.trim())
this.setData({
inputName: name,
personId: matched ? matched.id : null
})
},
/**
* 点已有人员快捷 chip
*/
onPickPerson(e) {
const { id, name } = e.currentTarget.dataset
this.setData({ personId: id, inputName: name })
},
/**
* 选择类型
*/
onTypeChange(e) {
const index = parseInt(e.detail.value)
const types = ['birthday', 'wedding', 'engagement', 'other']
const isOther = index === 3
this.setData({
typeIndex: index,
showCustomType: isOther,
'formData.type': types[index]
})
},
/**
* 自定义类型输入
*/
onCustomTypeInput(e) {
this.setData({
'formData.customTypeName': e.detail.value
})
},
/**
* 日期类型改变
*/
onDateTypeChange(e) {
const isLunar = e.detail.value === 'lunar'
this.setData({ 'formData.isLunar': isLunar })
this._refreshLunar()
},
/**
* 日期改变
*/
onDateChange(e) {
const dateStr = e.detail.value
const parts = dateStr.split('-')
this.setData({
dateValue: dateStr,
'formData.solarYear': parseInt(parts[0]),
'formData.solarMonth': parseInt(parts[1]),
'formData.solarDay': parseInt(parts[2])
})
this._refreshLunar()
},
/**
* 选了"农历"时,把当前公历日期反算成农历,写入 formData + 提示文案
* Why:闰月生日必须在 UI 上让用户确认这是不是他想要的农历日期
*/
_refreshLunar() {
const { formData } = this.data
if (!formData.isLunar || !formData.solarYear || !formData.solarMonth || !formData.solarDay) {
this.setData({ lunarText: '', isLeapMonth: false, 'formData.isLeapMonth': false })
return
}
const solarDate = new Date(formData.solarYear, formData.solarMonth - 1, formData.solarDay)
const ld = lunar.solarToLunar(solarDate)
this.setData({
lunarText: ld.lunarText,
isLeapMonth: ld.isLeap,
'formData.lunarYear': ld.year,
'formData.lunarMonth': ld.month,
'formData.lunarDay': ld.day,
'formData.isLeapMonth': ld.isLeap
})
},
/**
* 重要程度改变
*/
onImportanceChange(e) {
this.setData({
'formData.importance': e.detail.value
})
},
/**
* 提醒开关改变
*/
onRemindEnabledChange(e) {
this.setData({
'formData.remindEnabled': e.detail.value
})
},
/**
* 提醒天数改变
*/
onRemindDaysChange(e) {
const index = parseInt(e.detail.value)
this.setData({ remindDaysIndex: index })
if (index === 4) {
// 自定义天数
wx.showModal({
title: '自定义提前天数',
editable: true,
placeholderText: '请输入天数(1-365',
success: (res) => {
if (res.confirm) {
const days = parseInt(res.content)
if (!days || days < 1 || days > 365) {
wx.showToast({ title: '请输入1-365之间的天数', icon: 'none' })
this.setData({ remindDaysIndex: 1, 'formData.remindDays': 7 })
return
}
this.setData({ 'formData.remindDays': days })
} else {
// 取消则回到默认7天
this.setData({ remindDaysIndex: 1, 'formData.remindDays': 7 })
}
}
})
} else {
const days = [3, 7, 14, 30][index]
this.setData({ 'formData.remindDays': days })
}
},
/**
* 备注改变
*/
onRemarkChange(e) {
this.setData({
'formData.remark': e.detail.value
})
},
/**
* 取消
*/
onCancel() {
wx.navigateBack()
},
/**
* 提交
*
* ⚠️ 本方法绝对不能加 async,方法体内在调用 requestSubscribe 之前也不能出现任何 await
* 或其他异步跳跃(Promise.then、wx 异步 API 的回调里再调等):
* wx.requestSubscribeMessage 必须由用户的真实点击手势同步触发,一旦中间断开,
* 手势上下文丢失,真机上必然报 "fail can only be invoked by user TAP gesture"。
* 从 bindtap="onSubmit" 进来到 requestSubscribe 之间,只有 setData 读取和几个同步校验。
*/
onSubmit() {
// 防重入。
// Why:保存链路本身是同步的,但保存成功后要等 1.5 秒的 toast 才 navigateBack
// 这段时间用户完全可能再点一次「保存」;而 storage.addAnniversary 每次都会
// generateId() 生成一个新 id,重复点击的结果就是存出两条一模一样的纪念日。
// 注:这一句是同步的 if 判断,不影响下面订阅调用的手势上下文。
if (this.submitting) return
const { formData, anniversaryId, inputName } = this.data
let { personId } = this.data
// 验证姓名
const name = (inputName || '').trim()
if (!name) {
wx.showToast({ title: '请输入姓名', icon: 'none' })
return
}
// 验证日期
if (!formData.solarYear || !formData.solarMonth || !formData.solarDay) {
wx.showToast({ title: '请选择日期', icon: 'none' })
return
}
// 验证自定义类型
if (formData.type === 'other' && !formData.customTypeName) {
wx.showToast({ title: '请输入自定义类型', icon: 'none' })
return
}
// 如果开启了提醒,请求订阅消息授权。
// 这里是 fire-and-forget,故意不 await
// 1) await 会切断手势上下文,订阅调用必然失败(见方法头注释);
// 2) requestAndReport 内部的 grant 上报走 api.request,超时长达 10 秒。对已勾选
// 「总是保持以上选择」的用户连订阅弹窗都不会出现,await 会让用户点完「保存」后
// 最长干等 10 秒、界面毫无反馈;
// 3) 上报失败本来就有 pending_sync_queue 兜底,下次启动会自动 flush,等它没有意义。
// requestAndReport 只 resolve、不 reject,所以这里不挂 catch 也不会产生未处理拒绝。
if (formData.remindEnabled) {
this.requestSubscribe()
}
// 订阅调用已经发出,后面就没有手势上下文的顾虑了,可以安全地置位防重入标志
this.submitting = true
// 新增模式且没绑定 personId → 按姓名查找或自动创建
if (!anniversaryId && !personId) {
const person = storage.ensurePerson(name)
if (!person) {
this.submitting = false
wx.showToast({ title: '保存失败', icon: 'none' })
return
}
personId = person.id
}
// 农历生日:兜底反算(onDateChange/_refreshLunar 通常已填好,这里防边界)
if (formData.isLunar) {
const solarDate = new Date(formData.solarYear, formData.solarMonth - 1, formData.solarDay)
const lunarDate = lunar.solarToLunar(solarDate)
formData.lunarYear = lunarDate.year
formData.lunarMonth = lunarDate.month
formData.lunarDay = lunarDate.day
formData.isLeapMonth = lunarDate.isLeap
}
const personName = name
if (anniversaryId) {
// 编辑模式
const success = storage.updateAnniversary(anniversaryId, {
personId,
personName,
...formData
})
if (success) {
// 同步到云端
this.syncToCloud(anniversaryId, {
personId,
personName,
...formData
}, 'update')
wx.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => wx.navigateBack(), 1500)
} else {
// 写本地失败:必须放开防重入标志,否则用户连重试的机会都没有
this.submitting = false
wx.showToast({ title: '保存失败', icon: 'none' })
}
} else {
// 新增模式
const newAnniversary = storage.addAnniversary({
personId,
personName,
...formData
})
if (newAnniversary) {
// 同步到云端
this.syncToCloud(newAnniversary.id, newAnniversary, 'add')
wx.showToast({ title: '添加成功', icon: 'success' })
setTimeout(() => wx.navigateBack(), 1500)
} else {
// 同上:写本地失败要放开标志,让用户能重试
this.submitting = false
wx.showToast({ title: '保存失败', icon: 'none' })
}
}
},
/**
* 请求订阅消息授权
* 委托给 utils/subscribe.js 的 requestAndReport:同意后会自动把新增额度
* 上报给后端(走同步队列,失败自动入队)。
* 注意:本方法不能加 async,且内部不能在调用 requestAndReport 之前插入
* 任何 await —— wx.requestSubscribeMessage 必须由用户点击手势同步触发,
* 一旦中间出现异步跳跃,手势上下文丢失会导致调用必然失败。
* @returns {Promise<boolean>} 用户是否同意
*/
requestSubscribe() {
return subscribe.requestAndReport()
},
/**
* 同步纪念日到后端(失败自动入队,启动时 flush)
*/
syncToCloud(id, data, action) {
const openid = wx.getStorageSync('openid')
if (!openid) {
console.log('未获取到openid,跳过云端同步')
return
}
sync.syncOrEnqueue({ kind: 'anniversary', action, data: { id, ...data } })
}
})