feat(ui): 记录页月份联动与多项 UI 美化/修复

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

注: cloudfunctions/leaderboard 改动须在开发者工具重新部署才生效。
This commit is contained in:
2026-07-22 09:36:22 +08:00
parent 70b87d424d
commit 6176f2c341
32 changed files with 924 additions and 197 deletions
+2 -1
View File
@@ -7,7 +7,8 @@ const STORAGE_KEY = 'dark_mode_pref'
function _readSystemTheme() {
try {
const info = wx.getSystemInfoSync()
// wx.getSystemInfoSync 已废弃,改用 wx.getWindowInfo(同样返回 theme 字段)
const info = wx.getWindowInfo()
return info.theme === 'dark'
} catch (e) {
return false
+22
View File
@@ -53,6 +53,15 @@ const PATHS = {
rankFill: '<path d="M3.5 18.49l6-6.01 4 4L22 6.92l-1.41-1.41-7.09 7.97-4-4L2 16.99z"/>',
crown: '<path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5zm0 2h14v2H5z" fill-rule="evenodd"/>',
crownFill: '<path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5zm0 2h14v2H5z"/>',
// Trophy (record celebrations) — Material emoji-events
trophy: '<path d="M19 5h-2V3H7v2H5c-1.1 0-2 .9-2 2v1c0 2.55 1.92 4.63 4.39 4.94.63 1.5 1.98 2.63 3.61 2.96V19H7v2h10v-2h-4v-3.1c1.63-.33 2.98-1.46 3.61-2.96C19.08 12.63 21 10.55 21 8V7c0-1.1-.9-2-2-2zM5 8V7h2v3.82C5.84 10.4 5 9.3 5 8zm14 0c0 1.3-.84 2.4-2 2.82V7h2v1z" fill-rule="evenodd"/>',
trophyFill: '<path d="M19 5h-2V3H7v2H5c-1.1 0-2 .9-2 2v1c0 2.55 1.92 4.63 4.39 4.94.63 1.5 1.98 2.63 3.61 2.96V19H7v2h10v-2h-4v-3.1c1.63-.33 2.98-1.46 3.61-2.96C19.08 12.63 21 10.55 21 8V7c0-1.1-.9-2-2-2zM5 8V7h2v3.82C5.84 10.4 5 9.3 5 8zm14 0c0 1.3-.84 2.4-2 2.82V7h2v1z"/>',
// Party popper (first-time celebrations) — Material celebration
party: '<path d="M2 22l14-5-9-9-5 14zm12.53-9.47l1.41-1.41 2.83 2.83-1.41 1.41-2.83-2.83zm.53-6.53L16.47 4.59l1.41-1.41 1.41 1.41-1.41 1.42zM21 11h-3v-2h3v2zm-5.65-4.35L14.24 5.54 15.65 4.1l1.11 1.11-1.41 1.44zM13 3V0h2v3h-2zM8.35 5.65l-1.41-1.44L8.06 3.1l1.41 1.41 1.12 1.14z" fill-rule="evenodd"/>',
partyFill: '<path d="M2 22l14-5-9-9-5 14zm12.53-9.47l1.41-1.41 2.83 2.83-1.41 1.41-2.83-2.83zm.53-6.53L16.47 4.59l1.41-1.41 1.41 1.41-1.41 1.42zM21 11h-3v-2h3v2zm-5.65-4.35L14.24 5.54 15.65 4.1l1.11 1.11-1.41 1.44zM13 3V0h2v3h-2zM8.35 5.65l-1.41-1.44L8.06 3.1l1.41 1.41 1.12 1.14z"/>',
// Sparkle (generic celebrations + confetti particles)
sparkle: '<path d="M12 2l1.8 5.7L19.5 9l-5.7 1.3L12 16l-1.8-5.7L4.5 9l5.7-1.3L12 2zm7 11l.9 2.8 2.8.9-2.8.9-.9 2.8-.9-2.8-2.8-.9 2.8-.9.9-2.8zM5 14l.7 2.1 2.1.7-2.1.7L5 19.5l-.7-2.1-2.1-.7 2.1-.7L5 14z" fill-rule="evenodd"/>',
sparkleFill: '<path d="M12 2l1.8 5.7L19.5 9l-5.7 1.3L12 16l-1.8-5.7L4.5 9l5.7-1.3L12 2zm7 11l.9 2.8 2.8.9-2.8.9-.9 2.8-.9-2.8-2.8-.9 2.8-.9.9-2.8zM5 14l.7 2.1 2.1.7-2.1.7L5 19.5l-.7-2.1-2.1-.7 2.1-.7L5 14z"/>',
form: '<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" fill-rule="evenodd"/>',
formFill: '<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/>',
// Notification
@@ -132,6 +141,19 @@ const build = (theme) => {
out.deleteInactive = _svg(PATHS.delete, INACTIVE)
out.successFill = _svg(PATHS.roundCheckFill, s)
out.successLine = _svg(PATHS.roundCheck, INACTIVE)
// White variants for icons sitting on the theme-gradient card / disc
// (completion modal, podium accents, the index hero card, etc.) AND for
// icons rendered on the primary (filled) buttons, whose background is the
// same theme color as a *Fill variant — orange-on-orange would vanish.
// Baked-in white is REQUIRED: these are data-URI SVG <image> tags, so CSS
// color / currentColor cannot recolor them; only a white SVG works.
out.trophyWhite = _svg(PATHS.trophyFill, '#FFFFFF')
out.partyWhite = _svg(PATHS.partyFill, '#FFFFFF')
out.hotWhite = _svg(PATHS.hotFill, '#FFFFFF')
out.formWhite = _svg(PATHS.formFill, '#FFFFFF')
out.playWhite = _svg(PATHS.playFill, '#FFFFFF')
out.sparkleWhite = _svg(PATHS.sparkleFill, '#FFFFFF')
out.checkWhite = _svg(PATHS.check, '#FFFFFF')
return out
}
+6 -1
View File
@@ -75,6 +75,9 @@ const getPlan = (planId, customPlans) => {
const getTodayTarget = (planId, currentDay) => {
const plan = getPlan(planId)
// 防御:计划数据损坏(planId 无效 / days 为空)时直接返回 0,
// 避免 plan.days[0] 为 undefined 后访问 .target 抛出崩溃。
if (!plan.days || plan.days.length === 0) return 0
const day = plan.days.find(d => d.day === currentDay)
return day ? day.target : plan.days[0].target
}
@@ -95,7 +98,9 @@ const getPlanDay = (planId, records, planStartDate, customPlans) => {
return true
})
const trainedDays = new Set(eligibleRecords.map(r => r.date)).size
// 用 dateOnly 去重:r.date 带时分秒,同一天多次训练必须算作"1 天",
// 否则会被当成分属两天而错误跳过计划进度。
const trainedDays = new Set(eligibleRecords.map(r => dateOnly(r.date))).size
return Math.min(trainedDays + 1, plan.totalDays)
}
+15 -4
View File
@@ -38,6 +38,14 @@ const THEMES = [
primaryLight: '#F06A9A',
primaryBg: '#FDEEF3',
primaryRgb: '236,64,122'
},
{
id: 'teal',
name: '湖青',
primary: '#00BCD4',
primaryLight: '#4DD0E1',
primaryBg: '#E0F7FA',
primaryRgb: '0,188,212'
}
]
@@ -95,9 +103,12 @@ const setTheme = (id) => {
const getThemeStyle = (theme, isDark = false) => {
const t = theme || getCurrentTheme()
// Dark palette is unified with theme.json (#131316 family) so the chrome
// background and the page background stop disagreeing. Card is brightened
// to keep elevation readable on the darker backdrop.
const baseVars = isDark
? '--text:#E5E5E7;--text-secondary:#98989F;--bg:#2C2C2E;--bg-gradient-start:#2C2C2E;--bg-gradient-end:#38383A;--card-bg:#3A3A3C;--bg-soft:#48484A;--border:#48484A;--success:#30D158;--success-rgb:48,209,88;--danger:#FF6B6B;--danger-rgb:255,107,107;'
: BASE_VARS
? '--text:#E5E5E7;--text-secondary:#98989F;--bg:#131316;--bg-gradient-start:#131316;--bg-gradient-end:#1C1C20;--card-bg:#26262B;--bg-soft:#343439;--border:#343439;--success:#30D158;--success-rgb:48,209,88;--danger:#FF6B6B;--danger-rgb:255,107,107;'
: `--text:#333333;--text-secondary:#999999;--bg:${t.primaryBg};--bg-gradient-start:${t.primaryBg};--bg-gradient-end:#FAFAFA;--card-bg:#FFFFFF;--bg-soft:#F0F0F0;--border:#EEEEEE;--success:#4CAF50;--success-rgb:76,175,80;--danger:#E53935;--danger-rgb:229,57,53;`
return `${baseVars}--primary:${t.primary};--primary-light:${t.primaryLight};--primary-bg:${t.primaryBg};--primary-rgb:${t.primaryRgb};`
}
@@ -116,7 +127,7 @@ const applyThemeToPage = (self) => {
// theme.json 的 dark section 只响应系统暗黑模式。用户手动选择暗黑
// 但系统是明亮时,窗口背景仍为浅色,切 tab 会闪白。这里用 API 强制设置
// 窗口背景,让 tab 切换时的瞬间底色与当前模式一致。
const bg = isDark ? '#0F0F12' : '#F5F5F5'
const bg = isDark ? '#131316' : theme.primaryBg
try { wx.setBackgroundColor({ backgroundColor: bg, backgroundColorTop: bg, backgroundColorBottom: bg }) } catch (e) {}
}
@@ -125,7 +136,7 @@ const applyWindowBg = () => {
try {
const darkMod = require('./darkMode')
const isDark = darkMod.getInitialDarkMode()
const bg = isDark ? '#0F0F12' : '#F5F5F5'
const bg = isDark ? '#131316' : getCurrentTheme().primaryBg
wx.setBackgroundColor({ backgroundColor: bg, backgroundColorTop: bg, backgroundColorBottom: bg })
} catch (e) {}
}
+19 -7
View File
@@ -36,14 +36,25 @@ class Timer {
this._tick()
this._intervalId = setInterval(() => { this._tick() }, 1000)
// Register foreground listener (idempotent — we keep a flag so we don't
// Register foreground listener (idempotent — keep a flag so we don't
// double-register if start() is called multiple times in one session)
if (!this._appShowBound && typeof wx.onAppShow === 'function') {
try {
wx.onAppShow(this._onAppShow)
this._appShowBound = true
} catch (e) {}
}
this._bindAppShow()
}
_bindAppShow() {
if (this._appShowBound || typeof wx.onAppShow !== 'function') return
try {
wx.onAppShow(this._onAppShow)
this._appShowBound = true
} catch (e) {}
}
_unbindAppShow() {
if (!this._appShowBound || typeof wx.offAppShow !== 'function') return
try {
wx.offAppShow(this._onAppShow)
} catch (e) {}
this._appShowBound = false
}
pause() {
@@ -67,6 +78,7 @@ class Timer {
this._paused = false
clearInterval(this._intervalId)
this._intervalId = null
this._unbindAppShow()
return this._elapsed
}