teen-mode-guard.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. import { STORAGE } from '@/core/constants/storage-keys'
  2. import { isTeenModeEnabled, verifyTeenPin } from '@/features/account/teen-mode-settings'
  3. export const SESSION_LIMIT_MS = 40 * 60 * 1000
  4. export const DAILY_LIMIT_MS = 40 * 60 * 1000
  5. export const CURFEW_START_HOUR = 22
  6. export const CURFEW_END_HOUR = 6
  7. export const TICK_MS = 15000
  8. const LOCK_PAGE = 'pages/user/settings/teen-mode-lock'
  9. let i18nRef = null
  10. let started = false
  11. let foreground = false
  12. let tickTimer = null
  13. let navigatingLock = false
  14. const lockState = {
  15. locked: false,
  16. reason: ''
  17. }
  18. const listeners = new Set()
  19. function t(key, fallback) {
  20. if (!i18nRef) return fallback
  21. try {
  22. const v = i18nRef.t(key)
  23. return v && v !== key ? v : fallback
  24. } catch (_) {
  25. return fallback
  26. }
  27. }
  28. function pad2(n) {
  29. return n < 10 ? `0${n}` : String(n)
  30. }
  31. function todayKey(date = new Date()) {
  32. return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
  33. }
  34. function defaultUsage(now = Date.now()) {
  35. return {
  36. date: todayKey(new Date(now)),
  37. dailyMs: 0,
  38. sessionMs: 0,
  39. lastTickAt: now
  40. }
  41. }
  42. function normalizeUsage(raw) {
  43. if (!raw || typeof raw !== 'object') return defaultUsage()
  44. const now = Date.now()
  45. const usage = {
  46. date: String(raw.date || todayKey(new Date(now))),
  47. dailyMs: Math.max(0, Number(raw.dailyMs) || 0),
  48. sessionMs: Math.max(0, Number(raw.sessionMs) || 0),
  49. lastTickAt: Number(raw.lastTickAt) || now
  50. }
  51. if (usage.date !== todayKey()) {
  52. usage.date = todayKey()
  53. usage.dailyMs = 0
  54. usage.sessionMs = 0
  55. usage.lastTickAt = now
  56. }
  57. return usage
  58. }
  59. function readUsage() {
  60. try {
  61. return normalizeUsage(uni.getStorageSync(STORAGE.teenModeUsage))
  62. } catch (_) {
  63. return defaultUsage()
  64. }
  65. }
  66. function writeUsage(usage) {
  67. uni.setStorageSync(STORAGE.teenModeUsage, normalizeUsage(usage))
  68. }
  69. export function isCurfewHour(date = new Date()) {
  70. const hour = date.getHours()
  71. return hour >= CURFEW_START_HOUR || hour < CURFEW_END_HOUR
  72. }
  73. function notifyLock() {
  74. listeners.forEach((fn) => {
  75. try {
  76. fn({ ...lockState })
  77. } catch (_) {}
  78. })
  79. }
  80. function setLock(reason) {
  81. const nextReason = reason || ''
  82. if (lockState.locked === !!nextReason && lockState.reason === nextReason) return
  83. lockState.locked = !!nextReason
  84. lockState.reason = nextReason
  85. notifyLock()
  86. syncNativeMask()
  87. if (lockState.locked) {
  88. openLockPage(nextReason)
  89. }
  90. }
  91. function clearLock() {
  92. setLock('')
  93. closeLockPageIfNeeded()
  94. }
  95. export function refreshTeenModeGuard() {
  96. return evaluateLock()
  97. }
  98. export function getTeenLockState() {
  99. return { ...lockState }
  100. }
  101. export function subscribeTeenLock(listener) {
  102. if (typeof listener !== 'function') return () => {}
  103. listeners.add(listener)
  104. listener({ ...lockState })
  105. return () => listeners.delete(listener)
  106. }
  107. function flushUsage(now = Date.now()) {
  108. if (!foreground || isCurfewHour(new Date(now))) return readUsage()
  109. const usage = readUsage()
  110. const delta = Math.max(0, now - (usage.lastTickAt || now))
  111. if (delta <= 0) return usage
  112. usage.dailyMs += delta
  113. usage.sessionMs += delta
  114. usage.lastTickAt = now
  115. writeUsage(usage)
  116. return usage
  117. }
  118. function evaluateLock(now = Date.now()) {
  119. if (!isTeenModeEnabled()) {
  120. clearLock()
  121. return null
  122. }
  123. const date = new Date(now)
  124. if (isCurfewHour(date)) {
  125. setLock('curfew')
  126. return 'curfew'
  127. }
  128. if (lockState.locked && lockState.reason === 'curfew') {
  129. // 宵禁刚结束,继续检查时长
  130. } else if (lockState.locked && (lockState.reason === 'session' || lockState.reason === 'daily')) {
  131. return lockState.reason
  132. }
  133. const usage = flushUsage(now)
  134. if (usage.sessionMs >= SESSION_LIMIT_MS) {
  135. setLock('session')
  136. return 'session'
  137. }
  138. if (usage.dailyMs >= DAILY_LIMIT_MS) {
  139. setLock('daily')
  140. return 'daily'
  141. }
  142. clearLock()
  143. return null
  144. }
  145. function isOnLockPage() {
  146. const pages = getCurrentPages()
  147. const top = pages[pages.length - 1]
  148. const route = top && (top.route || (top.$page && top.$page.fullPath))
  149. return String(route || '').indexOf(LOCK_PAGE) !== -1
  150. }
  151. function openLockPage(reason) {
  152. if (navigatingLock || isOnLockPage()) return
  153. navigatingLock = true
  154. const url = `/pages/user/settings/teen-mode-lock?reason=${encodeURIComponent(reason || 'session')}`
  155. uni.navigateTo({
  156. url,
  157. fail: () => {
  158. uni.reLaunch({ url })
  159. },
  160. complete: () => {
  161. setTimeout(() => {
  162. navigatingLock = false
  163. syncNativeMask()
  164. }, 400)
  165. }
  166. })
  167. }
  168. function closeLockPageIfNeeded() {
  169. if (!isOnLockPage()) return
  170. uni.navigateBack({
  171. delta: 1,
  172. fail: () => {}
  173. })
  174. }
  175. export function tryUnlockTeenLock(pin) {
  176. if (!lockState.locked) return true
  177. if (lockState.reason === 'curfew') return false
  178. if (!verifyTeenPin(pin)) return false
  179. const usage = readUsage()
  180. const now = Date.now()
  181. if (lockState.reason === 'session') {
  182. usage.sessionMs = 0
  183. } else if (lockState.reason === 'daily') {
  184. usage.dailyMs = 0
  185. usage.sessionMs = 0
  186. }
  187. usage.lastTickAt = now
  188. writeUsage(usage)
  189. clearLock()
  190. return true
  191. }
  192. export function getTeenLockCopy(reason) {
  193. if (reason === 'curfew') {
  194. return {
  195. title: t('user.teenModeLockCurfewTitle', '宵禁时段'),
  196. desc: t(
  197. 'user.teenModeLockCurfewDesc',
  198. '每日 22:00 至次日 6:00 为宵禁时段,期间暂停使用。请明日 6:00 后再试。'
  199. ),
  200. showPin: false
  201. }
  202. }
  203. if (reason === 'daily') {
  204. return {
  205. title: t('user.teenModeLockDailyTitle', '今日使用时长已达上限'),
  206. desc: t(
  207. 'user.teenModeLockDailyDesc',
  208. '今日累计使用已超过 40 分钟,请输入监护密码后继续。'
  209. ),
  210. showPin: true
  211. }
  212. }
  213. return {
  214. title: t('user.teenModeLockSessionTitle', '该次使用时长已达上限'),
  215. desc: t(
  216. 'user.teenModeLockSessionDesc',
  217. '本次连续使用已超过 40 分钟,请输入监护密码后继续。'
  218. ),
  219. showPin: true
  220. }
  221. }
  222. function startTick() {
  223. stopTick()
  224. if (!foreground || !isTeenModeEnabled()) return
  225. tickTimer = setInterval(() => {
  226. evaluateLock()
  227. }, TICK_MS)
  228. }
  229. function stopTick() {
  230. if (tickTimer) {
  231. clearInterval(tickTimer)
  232. tickTimer = null
  233. }
  234. }
  235. export function notifyTeenModeChanged() {
  236. if (!isTeenModeEnabled()) {
  237. stopTick()
  238. clearLock()
  239. return
  240. }
  241. const usage = defaultUsage()
  242. writeUsage(usage)
  243. if (foreground) {
  244. evaluateLock()
  245. startTick()
  246. }
  247. }
  248. export function onTeenModeForeground() {
  249. foreground = true
  250. if (!isTeenModeEnabled()) {
  251. stopTick()
  252. return
  253. }
  254. const usage = readUsage()
  255. const now = Date.now()
  256. usage.lastTickAt = now
  257. writeUsage(usage)
  258. evaluateLock(now)
  259. startTick()
  260. }
  261. export function onTeenModeBackground() {
  262. if (!isTeenModeEnabled()) {
  263. foreground = false
  264. stopTick()
  265. return
  266. }
  267. flushUsage()
  268. foreground = false
  269. stopTick()
  270. }
  271. export function startTeenModeGuard(i18n) {
  272. if (started) return
  273. started = true
  274. i18nRef = i18n
  275. subscribeTeenLock(syncNativeMask)
  276. }
  277. // #ifdef APP-PLUS
  278. let nativeMask = null
  279. function ensureNativeMask() {
  280. if (nativeMask) return nativeMask
  281. nativeMask = new plus.nativeObj.View(
  282. 'hl88-teen-mode-blocker',
  283. {
  284. top: '0px',
  285. left: '0px',
  286. width: '100%',
  287. height: '100%',
  288. backgroundColor: 'rgba(0,0,0,0.01)'
  289. },
  290. []
  291. )
  292. nativeMask.interceptTouchEvent(true)
  293. nativeMask.addEventListener('click', () => {
  294. if (lockState.locked) {
  295. openLockPage(lockState.reason)
  296. }
  297. })
  298. return nativeMask
  299. }
  300. function syncNativeMask() {
  301. try {
  302. if (!lockState.locked || isOnLockPage()) {
  303. if (nativeMask) nativeMask.hide()
  304. return
  305. }
  306. ensureNativeMask().show()
  307. } catch (e) {
  308. console.warn('[teen-mode-guard] native mask', e)
  309. }
  310. }
  311. export function syncTeenLockOverlay() {
  312. syncNativeMask()
  313. }
  314. // #endif
  315. // #ifndef APP-PLUS
  316. function syncNativeMask() {}
  317. export function syncTeenLockOverlay() {}
  318. // #endif