From e2becc24aaaeb107177f83e830af16de3d41d0e7 Mon Sep 17 00:00:00 2001 From: cnliucheng Date: Mon, 27 Jul 2026 17:28:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E6=8E=92=E8=A1=8C=E6=A6=9C?= =?UTF-8?q?=E4=B8=8E=E9=A6=96=E9=A1=B5=E6=89=93=E5=8D=A1=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=A4=9A=E9=A1=B9=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排行榜性能: - 客户端乐观缓存:进页面/切周期秒显旧数据,后台静默刷新,消除 2-3s 空白 - 云函数实例缓存 TTL 60s -> 5min,降低全表扫描频率 排行榜 UI: - 空状态重设计:奖杯光环插画(径向光晕 + 闪光漂浮) - 领奖台 slot 撑满对齐列表、金银铜底座配色、角标与昵称间距修复、底线对齐 首页打卡卡片: - 火焰光晕脉动 + 72rpx 大数字 + 分段进度格子 - 计划 >10 天时进度退化为里程碑连续条 Co-Authored-By: Claude --- cloudfunctions/leaderboard/index.js | 8 +- config.js | 2 +- pages/index/index.js | 9 +- pages/index/index.wxml | 48 ++++++--- pages/index/index.wxss | 157 ++++++++++++++++++++-------- pages/leaderboard/leaderboard.js | 37 ++++++- pages/leaderboard/leaderboard.wxml | 13 +-- pages/leaderboard/leaderboard.wxss | 135 +++++++++++++++--------- utils/storage.js | 41 +++++++- 9 files changed, 329 insertions(+), 121 deletions(-) diff --git a/cloudfunctions/leaderboard/index.js b/cloudfunctions/leaderboard/index.js index 310bde8..0ecacbc 100644 --- a/cloudfunctions/leaderboard/index.js +++ b/cloudfunctions/leaderboard/index.js @@ -20,7 +20,13 @@ const TZ_OFFSET_MS = 8 * 60 * 60 * 1000 // a short TTL so repeated board opens (and day/month/year switches within // a session) reuse one scan. Cold starts just re-scan. Not persistent // across instances, which is fine - the board needn't be real-time. -const CACHE_TTL = 60 * 1000 +// 5min: the board is non-real-time, and the expensive part is the serial +// full-collection scan (100/page). A longer TTL means far fewer re-scans +// while the instance stays warm; the trade-off is a freshly-trained user +// may not see themselves for up to 5min, acceptable for a motivational +// board. The client also keeps its own display cache, so re-entry paints +// instantly regardless of this TTL. +const CACHE_TTL = 5 * 60 * 1000 let _docsCache = null // { latestByOpenid: Map, ts } let _docsCachePromise = null // in-flight 扫描 promise,防缓存击穿 diff --git a/config.js b/config.js index 9e4d214..ff9be66 100644 --- a/config.js +++ b/config.js @@ -7,7 +7,7 @@ module.exports = { version: 'v3.01', /** 最后更新日期,外显在设置页「关于」 */ - updatedAt: '2026-07-25', + updatedAt: '2026-07-27', /** 开发者名称 / 微信号,设置页点击可复制 */ developer: '刘承', diff --git a/pages/index/index.js b/pages/index/index.js index 2f6443c..88f3418 100644 --- a/pages/index/index.js +++ b/pages/index/index.js @@ -27,6 +27,8 @@ Page({ planDay: 1, planTotal: 7, planProgress: 0, + useSegments: false, + planDays: [], showPicker: false, pickerInputFocus: false, presets: [30, 60, 90, 120, 180, 300], @@ -142,6 +144,9 @@ Page({ const theme = themeMod.getCurrentTheme() // 防御:totalDays 为 0(极端数据损坏)时避免除以 0 得到 NaN const planProgress = plan.totalDays > 0 ? Math.round((clampedDay / plan.totalDays) * 100) : 0 + // 分段格子(每天一格)只在计划天数 <= 10 时用;更长则退化为里程碑连续条,避免格子过密 + const useSegments = plan.totalDays > 0 && plan.totalDays <= 10 + const planDays = useSegments ? Array.from({ length: plan.totalDays }, (_, i) => i) : [] this.setData({ theme, icons: iconsMod.build(theme), @@ -153,7 +158,9 @@ Page({ planName: plan.name, planDay: clampedDay, planTotal: plan.totalDays, - planProgress + planProgress, + useSegments, + planDays }) }, diff --git a/pages/index/index.wxml b/pages/index/index.wxml index 5ea2220..9b419b8 100644 --- a/pages/index/index.wxml +++ b/pages/index/index.wxml @@ -9,28 +9,46 @@ {{todayDone ? '继续保持这个势头' : '今天的平板支撑等着你'}} - + - - - {{streakCount}} - 连续打卡 - - - + + + + + + + + + {{streakCount}} + 连续打卡 + + {{planName}} - - - - - - - 第 {{planDay}} / {{planTotal}} 天 + + + + + + + + + + + + + + + + 第 {{planDay}} / {{planTotal}} 天 diff --git a/pages/index/index.wxss b/pages/index/index.wxss index 6cc7d0d..c3eaf78 100644 --- a/pages/index/index.wxss +++ b/pages/index/index.wxss @@ -33,32 +33,72 @@ /* ---- header card ---- */ .header-card { display: flex; - align-items: center; + flex-direction: column; + gap: 28rpx; padding: 4rpx 0; } +/* 上区:左连胜视觉重心,右计划徽章 */ +.streak-row { + display: flex; + align-items: center; + justify-content: space-between; +} + .streak-section { display: flex; - flex-direction: column; align-items: center; - min-width: 140rpx; +} + +/* 火焰图标 + 光晕:光晕脉动让火焰"活"起来 */ +.streak-icon-wrap { + position: relative; + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + margin-right: 16rpx; + flex-shrink: 0; +} + +.streak-halo { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 50%; + background: radial-gradient(circle at center, + rgba(var(--primary-rgb), 0.45) 0%, + rgba(var(--primary-rgb), 0.18) 50%, + rgba(var(--primary-rgb), 0) 75%); + animation: streakHalo 2s ease-in-out infinite; } .streak-icon { - width: 44rpx; - height: 44rpx; - margin-bottom: 4rpx; + position: relative; + width: 64rpx; + height: 64rpx; + z-index: 1; animation: float 2.5s ease-in-out infinite; } +.streak-text { + display: flex; + flex-direction: column; +} + .streak-num { - font-size: 56rpx; - font-weight: 700; + font-size: 72rpx; + font-weight: 800; + line-height: 1; /* On the gradient hero card --text cascades to white; on any plain card it stays dark, so var(--text) reads correctly in both contexts. We must NOT use var(--primary) here — that would be orange-on-orange and invisible. */ color: var(--text); - text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.12); + text-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.15); + font-variant-numeric: tabular-nums; } .streak-label { @@ -68,29 +108,24 @@ line-height: 1; } -.divider { - width: 2rpx; - height: 80rpx; - background: var(--border); - margin: 0 28rpx; -} - -.plan-info { - flex: 1; - display: flex; - flex-direction: column; +@keyframes streakHalo { + 0%, 100% { transform: scale(0.85); opacity: 0.7; } + 50% { transform: scale(1.1); opacity: 1; } } +/* 计划徽章:移到上区右侧,半透明白底药丸,与火焰渐变卡片区分 */ .plan-badge { display: flex; align-items: center; - margin-bottom: 16rpx; + background: rgba(255, 255, 255, 0.18); + padding: 8rpx 18rpx; + border-radius: 999rpx; + flex-shrink: 0; } .plan-badge-icon { width: 28rpx; height: 28rpx; - color: var(--primary); margin-right: 8rpx; } @@ -101,39 +136,70 @@ line-height: 1; } -.progress-bar-wrap { +/* 下区:分段进度 */ +.segment-progress { display: flex; flex-direction: column; + gap: 10rpx; } -.progress-bar { +/* planTotal <= 10:按天分段格子 */ +.segment-track { + display: flex; + gap: 8rpx; +} + +.segment-cell { + flex: 1; + height: 12rpx; + border-radius: 6rpx; + background: transparent; + border: 2rpx solid rgba(255, 255, 255, 0.30); + box-sizing: border-box; + transition: background 0.3s ease, border-color 0.3s ease; +} + +.segment-cell.done { + background: var(--on-primary, #FFFFFF); + border-color: var(--on-primary, #FFFFFF); +} + +.segment-cell.current { + background: var(--on-primary, #FFFFFF); + border-color: var(--on-primary, #FFFFFF); + animation: segmentPulse 1.2s ease-in-out infinite; +} + +/* planTotal > 10:连续条 + 里程碑节点,避免格子过密 */ +.milestone-track { width: 100%; - height: 8rpx; - background: var(--border); - border-radius: 4rpx; - overflow: visible; - margin-bottom: 8rpx; } -.progress-fill { - height: 100%; - /* On the gradient hero card --on-primary (white) overrides the fallback, - so the fill stays visible instead of blending into the orange card. */ - background: var(--on-primary, linear-gradient(90deg, var(--primary), var(--primary-light))); - border-radius: 4rpx; - transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); +.milestone-bar { position: relative; + width: 100%; + height: 12rpx; + background: rgba(0, 0, 0, 0.12); + border-radius: 6rpx; + overflow: visible; } -.progress-glow { +.milestone-fill { + height: 100%; + background: var(--on-primary, linear-gradient(90deg, var(--primary), var(--primary-light))); + border-radius: 6rpx; + transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); +} + +.milestone-node { position: absolute; - right: -4rpx; - top: -4rpx; - width: 16rpx; - height: 16rpx; - background: var(--on-primary, var(--primary)); + top: 50%; + width: 10rpx; + height: 10rpx; border-radius: 50%; - box-shadow: 0 0 10rpx var(--on-primary, var(--primary)); + background: var(--card-bg); + transform: translate(-50%, -50%); + border: 2rpx solid var(--border); } .progress-label { @@ -142,6 +208,11 @@ line-height: 1; } +@keyframes segmentPulse { + 0%, 100% { box-shadow: 0 0 4rpx rgba(var(--primary-rgb), 0.4); } + 50% { box-shadow: 0 0 14rpx rgba(var(--primary-rgb), 0.9); } +} + /* ---- target card ---- */ .target-section { text-align: center; diff --git a/pages/leaderboard/leaderboard.js b/pages/leaderboard/leaderboard.js index 91e2b0a..8a8b377 100644 --- a/pages/leaderboard/leaderboard.js +++ b/pages/leaderboard/leaderboard.js @@ -17,6 +17,7 @@ Page({ rankedList: [], myEntry: null, loading: false, + refreshing: false, empty: false, icons: iconsMod.build() }, @@ -58,7 +59,24 @@ Page({ this._fetchSeq = (this._fetchSeq || 0) + 1 const seq = this._fetchSeq const period = this.data.activePeriod - this.setData({ loading: true, empty: false }) + + // 乐观缓存:命中该 period 的近期缓存就先秒显旧数据,后台再静默拉新。 + // 命中时不进 loading 骨架屏(避免旧数据被空白覆盖),用 refreshing 标记 + // 后台刷新状态;未命中才显示骨架屏。云函数全表扫描冷启动可达 2-3s, + // 这层客户端缓存把"重复进入 / 切 period"的等待几乎降到无感。 + const cached = storage.getLeaderboardCache(period) + if (cached && cached.rankedList) { + this.setData({ + rankedList: cached.rankedList, + myEntry: cached.myEntry, + loading: false, + empty: cached.empty, + refreshing: true + }) + } else { + this.setData({ loading: true, empty: false, refreshing: false }) + } + wx.cloud.callFunction({ name: 'leaderboard', data: { @@ -76,7 +94,12 @@ Page({ // them into thinking the cloud is broken. console.warn('[leaderboard] cloud function failed, using local fallback:', err) if (seq !== this._fetchSeq) { if (cb) cb(); return } - this._applyLocal() + // 已有缓存秒显时后台刷新失败:保持旧数据,不打断用户;仅无缓存才走本地兜底。 + if (cached && cached.rankedList) { + this.setData({ refreshing: false }) + } else { + this._applyLocal() + } if (cb) cb() }) }, @@ -91,12 +114,16 @@ Page({ durationText: util.formatDuration(result.myEntry.duration), isMe: true } : null + const empty = list.length === 0 && !myEntry this.setData({ rankedList: list, myEntry, loading: false, - empty: list.length === 0 && !myEntry + refreshing: false, + empty }) + // 写入乐观缓存:下次进页面 / 切 period 秒显 + storage.setLeaderboardCache(this.data.activePeriod, { rankedList: list, myEntry, empty }) }, /** Local fallback: use local storage when cloud function isn't deployed */ @@ -122,9 +149,9 @@ Page({ rank: 1, openid: 'local', name: localName, nickname: profile.nickname || '', duration, durationText: util.formatDuration(duration), sessions } - this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, empty: false }) + this.setData({ rankedList: [entry], myEntry: { ...entry, isMe: true }, loading: false, refreshing: false, empty: false }) } else { - this.setData({ loading: false, empty: true }) + this.setData({ loading: false, refreshing: false, empty: true }) } }, diff --git a/pages/leaderboard/leaderboard.wxml b/pages/leaderboard/leaderboard.wxml index 697e61b..10c4a28 100644 --- a/pages/leaderboard/leaderboard.wxml +++ b/pages/leaderboard/leaderboard.wxml @@ -21,16 +21,17 @@ - + - - - - 加油 + + + + + 暂无排行数据 - 快去训练吧,争当第一名! + 快去训练,争当第一名! diff --git a/pages/leaderboard/leaderboard.wxss b/pages/leaderboard/leaderboard.wxss index 56b683a..49f08b7 100644 --- a/pages/leaderboard/leaderboard.wxss +++ b/pages/leaderboard/leaderboard.wxss @@ -82,7 +82,7 @@ display: flex; flex-direction: column; align-items: center; - width: 200rpx; + flex: 1; } .podium-crown { @@ -128,7 +128,7 @@ opacity: 0.7; border: 4rpx solid var(--card-bg); box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08); - margin-bottom: 12rpx; + margin-bottom: 0; } .avatar--lg { @@ -138,7 +138,7 @@ border: 4rpx solid rgba(var(--primary-rgb), 0.4); box-shadow: 0 6rpx 20rpx rgba(var(--primary-rgb), 0.25); opacity: 0.85; - margin-bottom: 12rpx; + margin-bottom: 0; } /* A real photo fills the whole circle (no inner ring), mirroring the @@ -178,6 +178,10 @@ text-overflow: ellipsis; white-space: nowrap; line-height: 1.3; + /* 用 name 的 margin-top 直接控制与上方头像/角标的间距:它是 flex 子元素, + margin 必定生效不折叠,不依赖 avatar margin-bottom 的 collapse 行为 + (WebView/Skyline 下不一致,曾导致角标压住昵称)。 */ + margin-top: 30rpx; } .podium-name--first { @@ -196,7 +200,7 @@ text-align: center; font-size: 22rpx; font-weight: 700; - color: var(--primary); + color: var(--text); line-height: 1; } @@ -222,9 +226,21 @@ position: relative; } -.podium-base--first { height: 96rpx; background: linear-gradient(180deg, rgba(var(--primary-rgb), 0.18), rgba(var(--primary-rgb), 0.04)); } -.podium-base--second { height: 64rpx; } -.podium-base--third { height: 48rpx; } +.podium-base--first { + height: 96rpx; + background: linear-gradient(180deg, rgba(255, 197, 61, 0.55), rgba(250, 140, 22, 0.20)); + box-shadow: 0 6rpx 18rpx rgba(250, 140, 22, 0.30), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45); +} +.podium-base--second { + height: 64rpx; + background: linear-gradient(180deg, rgba(201, 205, 212, 0.50), rgba(140, 140, 140, 0.16)); + box-shadow: 0 6rpx 18rpx rgba(140, 140, 140, 0.22), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45); +} +.podium-base--third { + height: 48rpx; + background: linear-gradient(180deg, rgba(232, 160, 106, 0.50), rgba(212, 107, 8, 0.16)); + box-shadow: 0 6rpx 18rpx rgba(212, 107, 8, 0.22), inset 0 2rpx 4rpx rgba(255, 255, 255, 0.45); +} /* Rank list */ .rank-list { @@ -369,56 +385,79 @@ .empty-illus { position: relative; - width: 200rpx; - height: 200rpx; - margin-bottom: 40rpx; + width: 280rpx; + height: 280rpx; + margin-bottom: 48rpx; + display: flex; + align-items: center; + justify-content: center; } -.empty-illus-body { - position: absolute; - bottom: 30rpx; - left: 50%; - transform: translateX(-50%); - width: 160rpx; - height: 30rpx; - background: linear-gradient(90deg, var(--primary), var(--primary-light)); - border-radius: 16rpx; -} - -.empty-illus-arm { - position: absolute; - bottom: 36rpx; - width: 20rpx; - height: 60rpx; - background: var(--primary); - border-radius: 10rpx; -} - -.empty-illus-arm--l { - left: 30rpx; - transform: rotate(30deg); -} - -.empty-illus-arm--r { - right: 30rpx; - transform: rotate(-30deg); -} - -.empty-illus-bubble { +/* 光晕:主题色径向渐变圆,托住奖杯 */ +.empty-halo { position: absolute; top: 0; + left: 0; right: 0; - background: var(--primary-bg); - color: var(--primary); - font-size: 22rpx; - font-weight: 600; - padding: 8rpx 16rpx; - border-radius: 16rpx; - line-height: 1; + bottom: 0; + border-radius: 50%; + background: radial-gradient(circle at center, + rgba(var(--primary-rgb), 0.20) 0%, + rgba(var(--primary-rgb), 0.10) 45%, + rgba(var(--primary-rgb), 0) 72%); +} + +/* 奖杯:居中,轻微浮动 + 投影 */ +.empty-trophy { + position: relative; + width: 144rpx; + height: 144rpx; + z-index: 2; + animation: emptyFloat 3s ease-in-out infinite; + filter: drop-shadow(0 6rpx 16rpx rgba(var(--primary-rgb), 0.25)); +} + +/* 闪光点:散布光晕周围,错峰闪烁 + 缩放 */ +.empty-spark { + position: absolute; + z-index: 3; + opacity: 0.85; +} +.empty-spark--1 { + top: 18rpx; + left: 40rpx; + width: 32rpx; + height: 32rpx; + animation: emptySparkle 2.4s ease-in-out infinite; +} +.empty-spark--2 { + top: 48rpx; + right: 32rpx; + width: 26rpx; + height: 26rpx; + animation: emptySparkle 2.4s ease-in-out 0.6s infinite; +} +.empty-spark--3 { + bottom: 40rpx; + right: 60rpx; + width: 22rpx; + height: 22rpx; + animation: emptySparkle 2.4s ease-in-out 1.2s infinite; +} + +@keyframes emptyFloat { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-10rpx); } +} + +@keyframes emptySparkle { + 0%, 100% { opacity: 0.2; transform: scale(0.8); } + 50% { opacity: 0.95; transform: scale(1.15); } } .empty-text { font-size: 30rpx; + font-weight: 600; color: var(--text); margin-bottom: 12rpx; } diff --git a/utils/storage.js b/utils/storage.js index de0ee96..0149170 100644 --- a/utils/storage.js +++ b/utils/storage.js @@ -418,6 +418,43 @@ const getTodayRecord = () => { return hasRecord ? { date: today, duration: totalDuration } : null } +/** + * Leaderboard display cache, keyed by period ('day' | 'month' | 'year'). + * + * Why: building the board requires a full-collection scan in the cloud + * function (cursor-paginated 100/page, serial), which takes 2-3s when + * the function's in-instance cache is cold. This client cache lets the + * page paint the last-rendered result instantly on re-entry / period + * switch while the cloud refreshes in the background. + * + * Freshness: entries older than LB_CACHE_MAX_AGE are treated as misses - + * the cached avatarUrl is a cloud:// temp URL (~2h TTL) and stale links + * render as broken images. 30min stays safely inside that window while + * still covering the common "switch tab / switch period" re-entry case. + */ +const LEADERBOARD_CACHE_KEY = 'leaderboard_cache' +const LB_CACHE_MAX_AGE = 30 * 60 * 1000 + +const getLeaderboardCache = (period) => { + if (!period) return null + try { + const all = wx.getStorageSync(LEADERBOARD_CACHE_KEY) || {} + const entry = all[period] + if (!entry) return null + if (Date.now() - (entry.cachedAt || 0) > LB_CACHE_MAX_AGE) return null + return entry + } catch (e) { return null } +} + +const setLeaderboardCache = (period, payload) => { + if (!period || !payload) return + try { + const all = wx.getStorageSync(LEADERBOARD_CACHE_KEY) || {} + all[period] = { ...payload, cachedAt: Date.now() } + wx.setStorageSync(LEADERBOARD_CACHE_KEY, all) + } catch (e) {} +} + module.exports = { getRecords, saveRecord, @@ -439,5 +476,7 @@ module.exports = { _markLocalChanged, getToday, getDateOffset, - getTodayRecord + getTodayRecord, + getLeaderboardCache, + setLeaderboardCache }