// 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} 用户是否同意 */ 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 } }) } })