7bf84bff61
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>
429 lines
13 KiB
JavaScript
429 lines
13 KiB
JavaScript
// 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 } })
|
||
}
|
||
})
|
||
|