reminder.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. <template>
  2. <CustomNav title="健康提醒" leftType="back" />
  3. <view class="content">
  4. <view class="notification-toggle">
  5. <text class="notification-label">消息通知</text>
  6. <switch :checked="notificationsEnabled" @change="onNotificationChange" />
  7. </view>
  8. <view class="template-info">
  9. <text class="tmpl-title">每日健康上报提醒</text>
  10. <text class="tmpl-meta">模板编号:7536 · 模板ID:ACS7cwcbx0F0Y_YaB4GZr7rWP7BO2-7wQOtYsnUjmFI</text>
  11. </view>
  12. <view class="reminder-list">
  13. <view class="reminder-item" v-for="(reminder, index) in reminders" :key="index">
  14. <view class="reminder-info">
  15. <text class="reminder-title">{{ reminder.title }}</text>
  16. <text class="reminder-time">{{ reminder.time }}</text>
  17. </view>
  18. <switch :checked="reminder.enabled" @change="toggleReminder(index)" />
  19. </view>
  20. </view>
  21. </view>
  22. <!-- 权限引导弹窗 -->
  23. <view class="permission-modal" v-if="showPermissionGuide">
  24. <view class="modal-mask" @click="closePermissionGuide"></view>
  25. <view class="modal-content">
  26. <view class="modal-header">
  27. <text class="modal-title">系统提示</text>
  28. </view>
  29. <view class="modal-body">
  30. <view class="modal-text">检测到您可能误关闭了消息通知权限,或之前曾拒绝过授权。{{ '\n' }}消息通知权限能帮助您及时接收血压、血糖等重要健康提醒!{{ '\n' }}
  31. 若您希望开启通知,请按以下步骤操作{{ '\n' }}(若您无需通知,可点击下方取消按钮):{{ '\n' }}{{ '\n' }}1. 点击下方【去开启】按钮{{ '\n' }}2. 进入【通知管理】选项{{ '\n' }}3. 开启【接收通知】开关{{ '\n' }}4. 确保各项健康提醒均为【接收】状态</view>
  32. </view>
  33. <view class="modal-footer">
  34. <button class="modal-button cancel" @click="closePermissionGuide">取消</button>
  35. <button class="modal-button confirm" @click="openSettings">去开启</button>
  36. </view>
  37. </view>
  38. </view>
  39. <TabBar />
  40. </template>
  41. <script setup lang="ts">
  42. import { ref, onMounted, onUnmounted, computed } from 'vue'
  43. import { onShow, onHide } from '@dcloudio/uni-app'
  44. import CustomNav from '@/components/custom-nav.vue'
  45. import TabBar from '@/components/tab-bar.vue'
  46. interface Reminder {
  47. title: string
  48. time: string
  49. enabled: boolean
  50. }
  51. const reminders = ref<Reminder[]>([
  52. { title: '喝水提醒', time: '08:00', enabled: true },
  53. { title: '运动提醒', time: '18:00', enabled: false },
  54. { title: '测量血压', time: '07:00', enabled: true },
  55. { title: '测量血糖', time: '12:00', enabled: false },
  56. { title: '服药提醒', time: '09:00', enabled: true }
  57. ])
  58. // 模板 ID & 其他展示信息
  59. const TEMPLATE_ID = 'ACS7cwcbx0F0Y_YaB4GZr7rWP7BO2-7wQOtYsnUjmFI'
  60. const TEMPLATE_NO = '7536'
  61. // 全局消息开关
  62. const notificationsEnabled = ref<boolean>(false)
  63. // 是否显示权限引导
  64. const showPermissionGuide = ref<boolean>(false)
  65. // 记录页面进入来源:'healthIndex' | 'subscribe' | 'unknown'
  66. const entrySource = ref<'healthIndex' | 'subscribe' | 'unknown'>('unknown')
  67. const entrySourceText = computed(() => {
  68. switch (entrySource.value) {
  69. case 'healthIndex':
  70. return '来自 健康首页'
  71. case 'subscribe':
  72. return '来自 订阅消息'
  73. default:
  74. return '来源 未知'
  75. }
  76. })
  77. onMounted(() => {
  78. try {
  79. const val = (uni as any).getStorageSync('notificationsEnabled')
  80. console.log('已获取全局消息开关状态:', val)
  81. if (typeof val === 'boolean') notificationsEnabled.value = val
  82. } catch (e) {
  83. // 忽略错误
  84. }
  85. // 检查用户订阅状态
  86. // checkSubscriptionStatus()
  87. // 尝试判断来源:优先检查页面栈的上一个页面;若无(直接打开),则检查 launch options 的 query
  88. try {
  89. const pages = (getCurrentPages as any)()
  90. console.log('当前页面栈:', pages)
  91. if (pages && pages.length >= 1) {
  92. const currentPage = pages[pages.length - 1]
  93. const currentRoute = currentPage?.route || currentPage?.__route || currentPage?.$page?.route
  94. const currentOptions = currentPage?.options || {}
  95. console.log('当前页面路由:', currentRoute)
  96. console.log('当前页面参数:', currentOptions)
  97. // 检查当前页面是否包含 from=subscribe 参数
  98. if (currentOptions.from === 'subscribe') {
  99. entrySource.value = 'subscribe'
  100. console.log('通过当前页面参数识别为订阅消息来源')
  101. }
  102. // 检查当前页面是否包含 templateId 相关参数
  103. if (currentOptions.templateId === TEMPLATE_ID || currentOptions.template_id === TEMPLATE_ID) {
  104. entrySource.value = 'subscribe'
  105. console.log('通过当前页面的模板ID参数识别为订阅消息来源')
  106. }
  107. }
  108. if (pages && pages.length >= 2) {
  109. const prev = pages[pages.length - 2]
  110. const prevRoute = prev?.route || prev?.__route || prev?.$page?.route
  111. console.log('上一页路由:', prevRoute)
  112. if (prevRoute && String(prevRoute).includes('pages/public/health/index')) {
  113. entrySource.value = 'healthIndex'
  114. console.log('识别来源为健康首页')
  115. }
  116. } else {
  117. // 如果没有上一页信息,检查小程序启动参数(例如用户通过订阅消息打开)
  118. if ((uni as any).getLaunchOptionsSync) {
  119. const launch = (uni as any).getLaunchOptionsSync()
  120. console.log('启动参数:', launch)
  121. if (launch && launch.query) {
  122. console.log('查询参数:', launch.query)
  123. // 如果你在推送消息里把 from=subscribe 作为参数传入,这里会命中
  124. if (launch.query.from === 'subscribe') {
  125. entrySource.value = 'subscribe'
  126. console.log('通过 from 参数识别为订阅消息来源')
  127. }
  128. // 有些平台会把 templateId 或其它字段放在 query 中,可据此判断
  129. if (launch.query.templateId === TEMPLATE_ID || launch.query.template_id === TEMPLATE_ID) {
  130. entrySource.value = 'subscribe'
  131. console.log('通过模板ID参数识别为订阅消息来源')
  132. }
  133. }
  134. }
  135. }
  136. } catch (e) {
  137. console.error('识别入口来源时出错:', e)
  138. }
  139. // 显示来源提示(用于测试),延迟一点以保证 UI 已就绪
  140. try {
  141. setTimeout(() => {
  142. console.log('入口来源:', entrySource.value, entrySourceText.value)
  143. }, 250)
  144. } catch (e) {
  145. // 忽略错误
  146. }
  147. if (entrySource.value === 'unknown') {
  148. console.log('入口来源默认为未知')
  149. }
  150. })
  151. // 监听页面显示/隐藏(用于检测用户将小程序/APP切到后台或再次回到前台)
  152. onShow(() => {
  153. console.log('[reminder] 页面/应用返回前台(onShow)', { entrySource: entrySource.value })
  154. // 在页面返回前台时,检查订阅设置;如果用户关闭了通知订阅主开关或对本模板拒绝,则主动关闭本地消息开关
  155. try {
  156. if (typeof (uni as any).getSetting === 'function') {
  157. ;(uni as any).getSetting({
  158. withSubscriptions: true,
  159. success(res: any) {
  160. try {
  161. const subs = res.subscriptionsSetting || {}
  162. const mainSwitch = subs.mainSwitch
  163. const itemSettings = subs.itemSettings || {}
  164. const templateStatus = itemSettings[TEMPLATE_ID]
  165. console.log('[reminder][onShow] 订阅设置检查结果:', { mainSwitch, templateStatus })
  166. // 若主开关关闭或模板被拒绝/屏蔽,则主动关闭本地通知开关
  167. if (mainSwitch === false || templateStatus === 'reject' || templateStatus === 'ban') {
  168. console.log('[reminder][onShow] 发现订阅被关闭或本模板被拒绝,主动关闭通知开关')
  169. notificationsEnabled.value = false
  170. //showPermissionGuide.value = (mainSwitch === false)
  171. try {
  172. ;(uni as any).setStorageSync('notificationsEnabled', false)
  173. } catch (e) {}
  174. } else {
  175. // 订阅为开启或未明确拒绝,不修改本地开关(按要求)
  176. console.log('[reminder][onShow] 订阅设置未发现关闭,不更改本地开关状态')
  177. }
  178. } catch (e) {
  179. console.error('[reminder][onShow] 处理 getSetting 返回值时出错:', e)
  180. }
  181. },
  182. fail(err: any) {
  183. console.error('[reminder][onShow] 获取订阅设置失败:', err)
  184. }
  185. })
  186. } else {
  187. console.log('[reminder][onShow] 当前环境不支持 getSetting,跳过订阅检查')
  188. }
  189. } catch (e) {
  190. console.error('[reminder][onShow] 检查订阅设置时异常:', e)
  191. }
  192. })
  193. onHide(() => {
  194. console.log('[reminder] 页面/应用进入后台(onHide)', { entrySource: entrySource.value })
  195. })
  196. // H5 平台兼容:document.visibilityState
  197. const handleVisibilityChange = () => {
  198. try {
  199. const state = (document as any).visibilityState
  200. if (state === 'hidden') {
  201. console.log('[reminder][visibilitychange] H5 页面变为 hidden(退到后台/切换窗口)')
  202. } else if (state === 'visible') {
  203. console.log('[reminder][visibilitychange] H5 页面变为 visible(回到前台)')
  204. }
  205. } catch (e) {
  206. // 忽略在非 H5 环境下的错误
  207. }
  208. }
  209. onMounted(() => {
  210. // 注册 H5 可见性变更监听
  211. try {
  212. if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
  213. document.addEventListener('visibilitychange', handleVisibilityChange)
  214. }
  215. } catch (e) {
  216. // 忽略
  217. }
  218. // 额外注册 uni 全局的 App 前后台事件(部分平台需要)
  219. try {
  220. if ((uni as any) && typeof (uni as any).onAppShow === 'function') {
  221. ;(uni as any).onAppShow((res: any) => {
  222. console.log('[reminder][uni.onAppShow] App 回到前台', res)
  223. })
  224. }
  225. if ((uni as any) && typeof (uni as any).onAppHide === 'function') {
  226. ;(uni as any).onAppHide(() => {
  227. console.log('[reminder][uni.onAppHide] App 进入后台')
  228. })
  229. }
  230. } catch (e) {
  231. // 忽略注册错误
  232. }
  233. })
  234. onUnmounted(() => {
  235. try {
  236. if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
  237. document.removeEventListener('visibilitychange', handleVisibilityChange)
  238. }
  239. } catch (e) {
  240. // 忽略
  241. }
  242. })
  243. /**
  244. * 检查用户订阅状态
  245. */
  246. const checkSubscriptionStatus = () => {
  247. // 使用 uni.getSetting 检查用户授权状态
  248. if (typeof (uni as any).getSetting === 'function') {
  249. (uni as any).getSetting({
  250. withSubscriptions: true, // 启用订阅设置查询
  251. success: (res: any) => {
  252. console.log('[checkSubscriptionStatus] 用户设置:', res)
  253. // 检查订阅消息设置
  254. if (res.subscriptionsSetting) {
  255. console.log('[checkSubscriptionStatus] 订阅设置详情:', JSON.stringify(res.subscriptionsSetting, null, 2))
  256. // 检查主开关
  257. const mainSwitch = res.subscriptionsSetting.mainSwitch
  258. console.log(`[checkSubscriptionStatus] 订阅主开关状态: ${mainSwitch ? '已开启(用户允许接收通知)' : '已关闭(用户禁止接收所有通知)'}`)
  259. // 检查针对特定模板的设置
  260. const itemSettings = res.subscriptionsSetting.itemSettings || {}
  261. const templateStatus = itemSettings[TEMPLATE_ID]
  262. console.log(`[checkSubscriptionStatus] 模板 ${TEMPLATE_ID} 状态:`, templateStatus)
  263. // 如果主开关关闭或特定模板被拒绝,则更新本地状态
  264. if (mainSwitch === false || templateStatus === 'reject' || templateStatus === 'ban') {
  265. console.log('[checkSubscriptionStatus] 用户关闭了订阅设置,正在更新本地状态')
  266. notificationsEnabled.value = false
  267. // 显示权限引导
  268. showPermissionGuide.value = (mainSwitch === false)
  269. try {
  270. (uni as any).setStorageSync('notificationsEnabled', false)
  271. } catch (e) {
  272. console.error('[checkSubscriptionStatus] 更新存储失败:', e)
  273. }
  274. }
  275. // 如果模板被接受且主开关开启,更新本地状态为true
  276. else if (mainSwitch === true && templateStatus === 'accept') {
  277. console.log('[checkSubscriptionStatus] 用户开启了订阅设置,正在更新本地状态')
  278. notificationsEnabled.value = true
  279. showPermissionGuide.value = false
  280. try {
  281. (uni as any).setStorageSync('notificationsEnabled', true)
  282. } catch (e) {
  283. console.error('[checkSubscriptionStatus] 更新存储失败:', e)
  284. }
  285. }
  286. } else {
  287. console.log('[checkSubscriptionStatus] 响应中未找到订阅设置')
  288. }
  289. },
  290. fail: (err: any) => {
  291. console.error('[checkSubscriptionStatus] 获取用户设置失败:', err)
  292. }
  293. })
  294. } else {
  295. console.log('当前环境不支持 uni.getSetting')
  296. }
  297. }
  298. /**
  299. * 打开微信设置页面
  300. */
  301. const openSettings = () => {
  302. console.log('用户点击前往设置')
  303. showPermissionGuide.value = false
  304. if (typeof (uni as any).openSetting === 'function') {
  305. (uni as any).openSetting({
  306. success: (res: any) => {
  307. console.log('打开设置成功:', res)
  308. // 重新检查订阅状态
  309. checkSubscriptionStatus()
  310. },
  311. fail: (err: any) => {
  312. console.error('打开设置失败:', err)
  313. uni.showToast({ title: '打开设置失败', icon: 'none' })
  314. }
  315. })
  316. } else {
  317. uni.showToast({ title: '当前环境不支持打开设置', icon: 'none' })
  318. }
  319. }
  320. /**
  321. * 关闭权限引导弹窗
  322. */
  323. const closePermissionGuide = () => {
  324. console.log('用户关闭权限引导')
  325. showPermissionGuide.value = false
  326. }
  327. /**
  328. * 顶部总开关变更
  329. * - 打开时:调用 uni.requestSubscribeMessage 请求用户同意订阅 TEMPLATE_ID
  330. * - 如果用户同意(返回 accept),将状态置为 true 并持久化
  331. * - 否则回滚并提示
  332. */
  333. const onNotificationChange = (e: any) => {
  334. const newVal = e?.detail?.value
  335. console.log('通知开关更改为:', newVal)
  336. if (newVal) {
  337. // 先将开关设置为开启状态
  338. notificationsEnabled.value = true
  339. // 请求订阅(仅在微信/小程序有效)
  340. ;(uni as any).requestSubscribeMessage({
  341. tmplIds: [TEMPLATE_ID],
  342. success(res: any) {
  343. console.log('订阅消息请求成功结果:', res)
  344. // res 可能形如 { "ACS7...": 'accept' }
  345. const result = res && res[TEMPLATE_ID]
  346. if (result === 'accept') {
  347. console.log('用户接受了订阅')
  348. try {
  349. ;(uni as any).setStorageSync('notificationsEnabled', true)
  350. } catch (err) {
  351. // 忽略存储错误
  352. }
  353. uni.showToast({ title: '订阅成功', icon: 'success' })
  354. // 隐藏权限引导
  355. showPermissionGuide.value = false
  356. } else {
  357. console.log('用户未接受订阅,结果:', result)
  358. // 用户拒绝或关闭了弹窗
  359. notificationsEnabled.value = false
  360. try {
  361. ;(uni as any).setStorageSync('notificationsEnabled', false)
  362. } catch (err) {}
  363. uni.showToast({ title: '订阅被拒绝', icon: 'none' })
  364. // 显示权限引导
  365. showPermissionGuide.value = true
  366. }
  367. },
  368. fail(err: any) {
  369. console.log('订阅消息请求失败:', err)
  370. notificationsEnabled.value = false
  371. try {
  372. ;(uni as any).setStorageSync('notificationsEnabled', false)
  373. } catch (e) {}
  374. // 根据错误类型显示不同的提示信息
  375. if (err.errCode === 20004) {
  376. uni.showToast({ title: '推送权限已关闭', icon: 'none' })
  377. // 显示权限引导
  378. showPermissionGuide.value = true
  379. } else {
  380. uni.showToast({ title: '订阅请求失败', icon: 'none' })
  381. }
  382. }
  383. })
  384. } else {
  385. // 关闭订阅:不需要额外调用接口,只改变本地记录
  386. console.log('通知开关已关闭')
  387. notificationsEnabled.value = false
  388. try {
  389. ;(uni as any).setStorageSync('notificationsEnabled', false)
  390. } catch (err) {}
  391. uni.showToast({ title: '已关闭通知', icon: 'none' })
  392. // 隐藏权限引导
  393. showPermissionGuide.value = false
  394. }
  395. }
  396. const toggleReminder = (index: number) => {
  397. reminders.value[index].enabled = !reminders.value[index].enabled
  398. }
  399. </script>
  400. <style scoped>
  401. .content {
  402. padding: calc(var(--status-bar-height) + 44px) 50rpx 50rpx 50rpx;
  403. min-height: 100vh;
  404. background-color: #f5f5f5;
  405. box-sizing: border-box;
  406. }
  407. .reminder-list {
  408. background-color: #fff;
  409. border-radius: 12rpx;
  410. overflow: hidden;
  411. margin-top: 50rpx;
  412. }
  413. .reminder-item {
  414. display: flex;
  415. justify-content: space-between;
  416. align-items: center;
  417. padding: 30rpx 40rpx;
  418. border-bottom: 1rpx solid #eee;
  419. }
  420. .reminder-item:last-child {
  421. border-bottom: none;
  422. }
  423. .reminder-info {
  424. flex: 1;
  425. }
  426. .reminder-title {
  427. font-size: 32rpx;
  428. color: #000000;
  429. display: block;
  430. margin-bottom: 10rpx;
  431. }
  432. .reminder-time {
  433. font-size: 28rpx;
  434. color: #5a5a5a;
  435. }
  436. .notification-toggle {
  437. display: flex;
  438. justify-content: space-between;
  439. align-items: center;
  440. padding: 30rpx 40rpx;
  441. background-color: #fff;
  442. border-radius: 12rpx;
  443. margin-top: 20rpx;
  444. }
  445. .notification-label {
  446. font-size: 32rpx;
  447. color: #000;
  448. }
  449. .template-info {
  450. margin-top: 12rpx;
  451. padding: 0 10rpx;
  452. color: #666;
  453. font-size: 24rpx;
  454. }
  455. .tmpl-title {
  456. display: block;
  457. font-size: 26rpx;
  458. color: #333;
  459. }
  460. .tmpl-meta {
  461. display: block;
  462. font-size: 22rpx;
  463. color: #8a8a8a;
  464. margin-top: 6rpx;
  465. }
  466. /* 权限引导弹窗样式 */
  467. .permission-modal {
  468. position: fixed;
  469. top: 0;
  470. left: 0;
  471. right: 0;
  472. bottom: 0;
  473. z-index: 9999;
  474. }
  475. .modal-mask {
  476. position: absolute;
  477. top: 0;
  478. left: 0;
  479. right: 0;
  480. bottom: 0;
  481. background-color: rgba(0, 0, 0, 0.6);
  482. }
  483. .modal-content {
  484. position: absolute;
  485. top: 50%;
  486. left: 50%;
  487. transform: translate(-50%, -50%);
  488. width: 80%;
  489. background-color: #fff;
  490. border-radius: 12rpx;
  491. overflow: hidden;
  492. }
  493. .modal-header {
  494. padding: 30rpx;
  495. text-align: center;
  496. border-bottom: 1rpx solid #eee;
  497. }
  498. .modal-title {
  499. font-size: 32rpx;
  500. color: #333;
  501. font-weight: bold;
  502. }
  503. .modal-body {
  504. padding: 30rpx;
  505. text-align: center;
  506. }
  507. .modal-text {
  508. font-size: 28rpx;
  509. color: #666;
  510. line-height: 1.5;
  511. white-space: pre-line; /* 添加这行来支持换行 */
  512. }
  513. .modal-footer {
  514. display: flex;
  515. border-top: 1rpx solid #eee;
  516. }
  517. .modal-button {
  518. flex: 1;
  519. border: none;
  520. padding: 20rpx 0;
  521. font-size: 28rpx;
  522. }
  523. .modal-button.cancel {
  524. background-color: #f5f5f5;
  525. color: #666;
  526. }
  527. .modal-button.confirm {
  528. background-color: #07c160;
  529. color: #fff;
  530. }
  531. .modal-button:after {
  532. border: none;
  533. }
  534. </style>