Files
wx_pbzc/pages/index/index.js
T
lc 6176f2c341 feat(ui): 记录页月份联动与多项 UI 美化/修复
- records: 底部列表与月份选择器联动,新增 [本月|最近] 分段控件;标题/空态随模式切换
- leaderboard: 领奖台布局(冠军居中+皇冠)、次数标注于底座且三档水平对齐;头像 cloud:// 批量预解析提速(云函数需重新部署)
- index/timer: 主色底图标改白色变体修复隐形;目标数字按微信字体缩放补偿真机遮挡
- settings: 配色方案横滑渐变色卡;头像上传前压缩至 200px
- theme: 新增 teal 主题;暗黑背景统一对齐
- 圆形头像双保险(image 自身 border-radius)修复月/年榜方图

注: cloudfunctions/leaderboard 改动须在开发者工具重新部署才生效。
2026-07-22 09:36:22 +08:00

195 lines
6.6 KiB
JavaScript

const storage = require('../../utils/storage')
const planMod = require('../../utils/plan')
const util = require('../../utils/util')
const themeMod = require('../../utils/theme')
const iconsMod = require('../../utils/icons')
const MAX_CUSTOM_DURATION = 3600 // 1 hour max
Page({
data: {
theme: { primary: '#FF6B35', primaryLight: '#FF8C5A', primaryBg: '#FFF3ED', primaryRgb: '255,107,53' },
themeStyle: themeMod.BASE_VARS,
todayTarget: 0,
todayDone: false,
todayDuration: 0,
todayTargetText: '',
// Ring number font size in rpx. WeChat auto-scales text by the user's
// font-size setting (fontSizeScaleFactor) but does NOT scale rpx box
// dimensions — so on a real device with enlarged system font the 84rpx
// number grows past the fixed-size circular ring and gets clipped. We
// cancel that scale here so the number renders at a constant visual size
// (matching the devtools/simulator) on every device. See _readFontScale().
targetTimeFontSize: 84,
streakCount: 0,
planName: '',
planDay: 1,
planTotal: 7,
planProgress: 0,
showPicker: false,
presets: [30, 60, 90, 120, 180, 300],
customDuration: 60,
customInput: '',
icons: iconsMod.build()
},
onLoad() {
themeMod.applyThemeToPage(this)
this._readFontScale()
this._firstShow = true
},
/**
* Read the device's WeChat font-size scale and compensate the ring number
* so it never overflows the fixed circular clip on real devices.
*
* WeChat auto-applies `fontSizeScaleFactor` to ALL text (including rpx font
* sizes) but leaves rpx box dimensions untouched. A user who bumps up the
* system font therefore sees enlarged text inside a non-enlarged ring.
*
* CRITICAL asymmetry: the DevTools simulator reports the SAME
* `fontSizeScaleFactor` as a real device (the user's actual WeChat setting)
* but does NOT actually scale rendered text. So if we compensate there, the
* number ends up too small in the simulator. We therefore SKIP the
* compensation under DevTools (host.env === 'devtools') and keep the design
* size — matching what the simulator renders literally.
*
* On real devices, dividing our design size by the factor cancels the
* auto-scale, keeping the number a constant 84rpx-equivalent everywhere.
* Bounded so a bad reading can only ever make the number smaller (safe),
* never larger (overflow).
*/
_readFontScale() {
let factor = 1
try {
const info = wx.getAppBaseInfo ? wx.getAppBaseInfo() : wx.getSystemInfoSync()
const isDevtools = info.host && info.host.env === 'devtools'
if (!isDevtools) {
if (typeof info.fontSizeScaleFactor === 'number' && info.fontSizeScaleFactor > 0) {
factor = info.fontSizeScaleFactor
} else if (typeof info.fontSizeSetting === 'number' && info.fontSizeSetting > 0) {
// fontSizeScaleFactor == currentFontSize / standardFontSize(17px)
factor = info.fontSizeSetting / 17
}
}
} catch (e) {}
const DESIGN = 84
let size = Math.round(DESIGN / factor)
size = Math.max(48, Math.min(96, size))
this.setData({ targetTimeFontSize: size })
},
onShow() {
themeMod.applyThemeToPage(this)
try {
const tb = this.getTabBar()
if (tb) tb.setData({ selected: 0 })
} catch (e) {}
// Skip the first onShow to avoid double-refresh; subsequent tab switches still refresh.
if (this._firstShow) {
this._firstShow = false
this.refresh()
this._checkFreshTrain()
return
}
this.refresh()
this._checkFreshTrain()
},
/**
* If user just returned from completing a training, briefly highlight
* the "今日已完成" badge so the success feels tangible. The flag is
* timestamped; older than 10s is treated as stale (e.g. user reopened
* the app later).
*/
_checkFreshTrain() {
try {
const at = wx.getStorageSync('_last_train_at')
if (!at) return
const age = Date.now() - at
if (age < 0 || age > 10 * 1000) {
wx.removeStorageSync('_last_train_at')
return
}
wx.removeStorageSync('_last_train_at')
this.setData({ justCompleted: true })
if (this._justCompletedTimer) clearTimeout(this._justCompletedTimer)
this._justCompletedTimer = setTimeout(() => {
this.setData({ justCompleted: false })
}, 3000)
} catch (e) {}
},
onPullDownRefresh() {
this.refresh()
wx.stopPullDownRefresh()
},
refresh() {
const settings = storage.getSettings()
const streak = storage.validateStreak()
const todayRecord = storage.getTodayRecord()
const allRecords = Object.values(storage.getRecords()).flat()
const customPlans = storage.getCustomPlans()
const plan = planMod.getPlan(settings.planId, customPlans)
const planDay = planMod.getPlanDay(settings.planId, allRecords, settings.planStartDate, customPlans)
const clampedDay = Math.min(planDay, plan.totalDays)
const target = planMod.getTodayTarget(settings.planId, clampedDay)
const theme = themeMod.getCurrentTheme()
// 防御:totalDays 为 0(极端数据损坏)时避免除以 0 得到 NaN
const planProgress = plan.totalDays > 0 ? Math.round((clampedDay / plan.totalDays) * 100) : 0
this.setData({
theme,
icons: iconsMod.build(theme),
todayTarget: target,
todayDone: todayRecord && todayRecord.duration >= target,
todayDuration: todayRecord ? todayRecord.duration : 0,
todayTargetText: util.formatTime(target),
streakCount: streak.count,
planName: plan.name,
planDay: clampedDay,
planTotal: plan.totalDays,
planProgress
})
},
onStartTrain() {
wx.navigateTo({ url: '/pages/timer/timer' })
},
onFreeTrain() {
this.setData({ showPicker: true, customInput: '', customDuration: 60 })
},
onClosePicker() {
this.setData({ showPicker: false })
},
onSelectPreset(e) {
const val = e.currentTarget.dataset.value
this.setData({ customDuration: val, customInput: '' })
},
onCustomInput(e) {
let val = parseInt(e.detail.value) || 0
if (val > MAX_CUSTOM_DURATION) val = MAX_CUSTOM_DURATION
this.setData({ customInput: e.detail.value, customDuration: val })
},
onConfirmFree() {
const dur = this.data.customDuration
if (!dur || dur <= 0) {
wx.showToast({ title: '请输入有效时长', icon: 'none' })
return
}
if (dur > MAX_CUSTOM_DURATION) {
wx.showToast({ title: '最大时长为1小时', icon: 'none' })
return
}
this.setData({ showPicker: false })
wx.navigateTo({ url: `/pages/timer/timer?free=${dur}` })
}
})