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
+54 -3
View File
@@ -14,6 +14,13 @@ Page({
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,
@@ -28,9 +35,50 @@ Page({
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 {
@@ -86,9 +134,12 @@ Page({
const customPlans = storage.getCustomPlans()
const plan = planMod.getPlan(settings.planId, customPlans)
const planDay = planMod.getPlanDay(settings.planId, allRecords, settings.planStartDate, customPlans)
const target = planMod.getTodayTarget(settings.planId, Math.min(planDay, plan.totalDays))
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),
@@ -98,9 +149,9 @@ Page({
todayTargetText: util.formatTime(target),
streakCount: streak.count,
planName: plan.name,
planDay: Math.min(planDay, plan.totalDays),
planDay: clampedDay,
planTotal: plan.totalDays,
planProgress: Math.round((Math.min(planDay, plan.totalDays) / plan.totalDays) * 100)
planProgress
})
},
+5 -5
View File
@@ -10,17 +10,17 @@
</view>
<!-- 连续打卡卡片 -->
<ui-card variant="in" class="{{justCompleted ? 'just-completed' : ''}}">
<ui-card variant="gradient in" class="{{justCompleted ? 'just-completed' : ''}}">
<view class="header-card">
<view class="streak-section">
<image class="streak-icon" src="{{icons.hotFill}}" mode="aspectFit"></image>
<image class="streak-icon" src="{{icons.hotWhite}}" mode="aspectFit"></image>
<text class="streak-num">{{streakCount}}</text>
<text class="streak-label">连续打卡</text>
</view>
<view class="divider"></view>
<view class="plan-info">
<view class="plan-badge">
<image class="plan-badge-icon" src="{{icons.formFill}}" mode="aspectFit"></image>
<image class="plan-badge-icon" src="{{icons.formWhite}}" mode="aspectFit"></image>
<text class="plan-badge-text">{{planName}}</text>
</view>
<view class="progress-bar-wrap">
@@ -44,7 +44,7 @@
</view>
<view class="target-display">
<view class="target-ring">
<text class="target-time">{{todayTargetText}}</text>
<text class="target-time" style="font-size: {{targetTimeFontSize}}rpx;">{{todayTargetText}}</text>
</view>
<text class="target-unit">平板支撑</text>
</view>
@@ -70,7 +70,7 @@
size="xl"
block
text="{{todayDone ? '再次训练' : '开始训练'}}"
icon-src="{{icons.playFill}}"
icon-src="{{icons.playWhite}}"
bindtap="onStartTrain"
></ui-btn>
+24 -12
View File
@@ -54,8 +54,11 @@
.streak-num {
font-size: 56rpx;
font-weight: 700;
color: var(--primary);
line-height: 1.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);
}
.streak-label {
@@ -114,7 +117,9 @@
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), var(--primary-light));
/* 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);
position: relative;
@@ -126,9 +131,9 @@
top: -4rpx;
width: 16rpx;
height: 16rpx;
background: var(--primary);
background: var(--on-primary, var(--primary));
border-radius: 50%;
box-shadow: 0 0 10rpx var(--primary);
box-shadow: 0 0 10rpx var(--on-primary, var(--primary));
}
.progress-label {
@@ -167,20 +172,27 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 200rpx;
height: 200rpx;
width: 280rpx;
height: 280rpx;
border-radius: 50%;
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.08), rgba(var(--primary-rgb), 0.02));
border: 4rpx solid rgba(var(--primary-rgb), 0.15);
margin-bottom: 16rpx;
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.10), rgba(var(--primary-rgb), 0.03));
border: 6rpx solid rgba(var(--primary-rgb), 0.18);
box-shadow: 0 0 0 14rpx rgba(var(--primary-rgb), 0.05);
margin-bottom: 20rpx;
overflow: hidden;
}
.target-time {
font-size: 64rpx;
font-weight: 700;
font-size: 84rpx;
font-weight: 800;
color: var(--primary);
font-variant-numeric: tabular-nums;
letter-spacing: -1rpx;
line-height: 1;
max-width: 240rpx;
text-align: center;
white-space: nowrap;
overflow: hidden;
}
.target-unit {
+67 -30
View File
@@ -1,13 +1,15 @@
<view class="container page-enter" style="{{themeStyle}}">
<!-- Period tabs -->
<view class="period-tabs">
<view
wx:for="{{periods}}"
wx:key="key"
class="period-tab {{activePeriod === item.key ? 'active' : ''}}"
data-period="{{item.key}}"
bindtap="switchPeriod"
>{{item.label}}</view>
<!-- Period tabs: pill-style segmented control, consistent with settings -->
<view class="period-seg-wrap">
<view class="period-seg">
<view
wx:for="{{periods}}"
wx:key="key"
class="period-seg-item {{activePeriod === item.key ? 'active' : ''}}"
data-period="{{item.key}}"
bindtap="switchPeriod"
>{{item.label}}</view>
</view>
</view>
<!-- Loading skeleton -->
@@ -27,33 +29,68 @@
<text class="empty-sub">快去训练吧,争当第一名!</text>
</view>
<!-- Rank list -->
<view class="rank-list" wx:else>
<view
wx:for="{{rankedList}}"
wx:key="openid"
class="rank-item {{item.openid === myEntry.openid ? 'is-me' : ''}} rank-item--enter"
style="animation-delay: {{index < 10 ? index * 60 : 0}}ms;"
>
<view class="rank-num {{item.rank === 1 ? 'gold' : item.rank === 2 ? 'silver' : item.rank === 3 ? 'bronze' : ''}}">
<text wx:if="{{item.rank === 1}}">🥇</text>
<text wx:elif="{{item.rank === 2}}">🥈</text>
<text wx:elif="{{item.rank === 3}}">🥉</text>
<text wx:else>{{item.rank}}</text>
<block wx:else>
<!-- Podium: top 3 (only when there are at least 3 entries) -->
<view class="podium" wx:if="{{rankedList.length >= 3}}">
<!-- 2nd -->
<view class="podium-slot podium-slot--second rank-item--enter" style="animation-delay: 60ms;">
<view class="podium-avatar-wrap">
<view class="avatar avatar--md {{rankedList[1].avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{rankedList[1].avatarUrl || icons.peopleFill}}" mode="{{rankedList[1].avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="podium-medal podium-medal--silver">2</view>
</view>
<text class="podium-name">{{rankedList[1].name}}</text>
<text class="podium-dur">{{rankedList[1].durationText}}</text>
<view class="podium-base podium-base--second"><text class="podium-base__label">{{rankedList[1].sessions}}次</text></view>
</view>
<image class="rank-avatar" src="{{icons.peopleFill}}" mode="aspectFit"></image>
<view class="rank-info">
<text class="rank-name">{{item.name}}</text>
<text class="rank-sessions">{{item.sessions}}次</text>
<!-- 1st -->
<view class="podium-slot podium-slot--first rank-item--enter" style="animation-delay: 0ms;">
<image class="podium-crown" src="{{icons.crownFill}}" mode="aspectFit"></image>
<view class="podium-avatar-wrap">
<view class="avatar avatar--md avatar--lg {{rankedList[0].avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{rankedList[0].avatarUrl || icons.peopleFill}}" mode="{{rankedList[0].avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="podium-medal podium-medal--gold">1</view>
</view>
<text class="podium-name podium-name--first">{{rankedList[0].name}}</text>
<text class="podium-dur podium-dur--first">{{rankedList[0].durationText}}</text>
<view class="podium-base podium-base--first"><text class="podium-base__label">{{rankedList[0].sessions}}次</text></view>
</view>
<!-- 3rd -->
<view class="podium-slot podium-slot--third rank-item--enter" style="animation-delay: 120ms;">
<view class="podium-avatar-wrap">
<view class="avatar avatar--md {{rankedList[2].avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{rankedList[2].avatarUrl || icons.peopleFill}}" mode="{{rankedList[2].avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="podium-medal podium-medal--bronze">3</view>
</view>
<text class="podium-name">{{rankedList[2].name}}</text>
<text class="podium-dur">{{rankedList[2].durationText}}</text>
<view class="podium-base podium-base--third"><text class="podium-base__label">{{rankedList[2].sessions}}次</text></view>
</view>
<text class="rank-dur">{{item.durationText}}</text>
</view>
</view>
<!-- Rank list: top-3 shown on the podium are skipped when podium exists -->
<view class="rank-list">
<view
wx:for="{{rankedList}}"
wx:key="openid"
wx:if="{{rankedList.length < 3 || index >= 3}}"
class="rank-item {{myEntry && item.openid === myEntry.openid ? 'is-me' : ''}} rank-item--enter"
style="animation-delay: {{index < 10 ? index * 60 : 0}}ms;"
>
<view class="rank-num {{item.rank === 1 ? 'gold' : item.rank === 2 ? 'silver' : item.rank === 3 ? 'bronze' : ''}}">
<text>{{item.rank}}</text>
</view>
<view class="avatar avatar--sm {{item.avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{item.avatarUrl || icons.peopleFill}}" mode="{{item.avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="rank-info">
<text class="rank-name">{{item.name}}</text>
<text class="rank-sessions">{{item.sessions}}次</text>
</view>
<text class="rank-dur">{{item.durationText}}</text>
</view>
</view>
</block>
<!-- My entry pinned at bottom (if not already in list) -->
<view class="my-bar" wx:if="{{myEntry && myEntry.rank > rankedList.length}}">
<view class="rank-num">{{myEntry.rank}}</view>
<image class="rank-avatar" src="{{icons.peopleFill}}" mode="aspectFit"></image>
<view class="rank-num my-bar__num">{{myEntry.rank}}</view>
<view class="avatar avatar--sm my-bar__avatar {{myEntry.avatarUrl ? 'has-avatar' : ''}}"><image class="avatar__img" src="{{myEntry.avatarUrl || icons.peopleFill}}" mode="{{myEntry.avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image></view>
<view class="rank-info">
<text class="rank-name">我 ({{myEntry.name}})</text>
<text class="rank-sessions">{{myEntry.sessions}}次</text>
+211 -38
View File
@@ -5,42 +5,36 @@
box-sizing: border-box;
}
/* Period tabs */
.period-tabs {
display: flex;
background: var(--card-bg);
padding: 0 32rpx;
border-bottom: 1rpx solid var(--border);
/* Period tabs: pill segmented control, same language as settings .segmented */
.period-seg-wrap {
padding: 20rpx 24rpx 8rpx;
position: sticky;
top: 0;
z-index: 10;
}
.period-tab {
.period-seg {
display: flex;
background: var(--bg-soft);
border-radius: 999rpx;
padding: 6rpx;
}
.period-seg-item {
flex: 1;
text-align: center;
padding: 24rpx 0 20rpx;
font-size: 30rpx;
padding: 16rpx 0;
font-size: 28rpx;
color: var(--text-secondary);
position: relative;
transition: color 0.2s, background-color 0.3s ease;
border-radius: 999rpx;
transition: background 0.25s ease, color 0.25s ease, box-shadow 0.25s ease;
}
.period-tab.active {
.period-seg-item.active {
background: var(--card-bg);
color: var(--primary);
font-weight: 600;
}
.period-tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 6rpx;
border-radius: 3rpx;
background: var(--primary);
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.10);
}
/* Status */
@@ -75,6 +69,163 @@
opacity: 0.6;
}
/* ---- podium (top 3) ---- */
.podium {
display: flex;
align-items: flex-end;
justify-content: center;
padding: 32rpx 24rpx 0;
gap: 20rpx;
}
.podium-slot {
display: flex;
flex-direction: column;
align-items: center;
width: 200rpx;
}
.podium-crown {
width: 56rpx;
height: 56rpx;
margin-bottom: -8rpx;
z-index: 1;
animation: float 2.5s ease-in-out infinite;
}
.podium-avatar-wrap {
position: relative;
}
/* Circular avatar: a rounded <view> clips the <image> inside it.
border-radius on the <image> itself is NOT reliably clipped by some
WeChat base libraries, so we wrap it and clip on the parent instead. */
.avatar {
position: relative;
border-radius: 50%;
overflow: hidden;
background: var(--bg-soft);
flex-shrink: 0;
box-sizing: border-box;
}
.avatar__img {
width: 100%;
height: 100%;
display: block;
/* Some WeChat base libraries don't clip a remote <image>'s native layer
with the parent view's overflow:hidden (they DO clip the placeholder
SVG, which is why daily looked fine but monthly/yearly real photos
showed square). Rounding the <image> itself is the cross-version-safe
fix and also covers the wrapper. */
border-radius: 50%;
}
.avatar--md {
width: 96rpx;
height: 96rpx;
padding: 20rpx;
opacity: 0.7;
border: 4rpx solid var(--card-bg);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
margin-bottom: 12rpx;
}
.avatar--lg {
width: 128rpx;
height: 128rpx;
padding: 26rpx;
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;
}
/* A real photo fills the whole circle (no inner ring), mirroring the
settings-page avatar. The placeholder keeps the padding ring above. */
.avatar.has-avatar {
padding: 0;
opacity: 1;
}
.podium-medal {
position: absolute;
bottom: -8rpx;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 40rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
font-weight: 700;
color: #FFFFFF;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.15);
}
.podium-medal--gold { background: linear-gradient(135deg, #FFC53D, #FA8C16); }
.podium-medal--silver { background: linear-gradient(135deg, #C9CDD4, #8C8C8C); }
.podium-medal--bronze { background: linear-gradient(135deg, #E8A06A, #D46B08); }
.podium-name {
font-size: 26rpx;
font-weight: 500;
color: var(--text);
max-width: 180rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.3;
}
.podium-name--first {
font-size: 28rpx;
font-weight: 600;
}
/* session count printed on the podium base. Pinned a fixed offset above the
base's bottom edge so all three sit on the SAME horizontal line despite the
stepped base heights (the podium itself is bottom-aligned via flex-end). */
.podium-base__label {
position: absolute;
left: 0;
right: 0;
bottom: 10rpx;
text-align: center;
font-size: 22rpx;
font-weight: 700;
color: var(--primary);
line-height: 1;
}
.podium-dur {
font-size: 24rpx;
font-weight: 600;
color: var(--text-secondary);
margin-top: 2rpx;
font-variant-numeric: tabular-nums;
}
.podium-dur--first {
font-size: 28rpx;
color: var(--primary);
}
/* podium bases: stepped blocks under each slot */
.podium-base {
width: 100%;
margin-top: 16rpx;
border-radius: 16rpx 16rpx 0 0;
background: linear-gradient(180deg, rgba(var(--primary-rgb), 0.10), rgba(var(--primary-rgb), 0.02));
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; }
/* Rank list */
.rank-list {
padding: 16rpx 24rpx;
@@ -116,14 +267,11 @@
.rank-num.silver { background: var(--bg-soft); color: #8C8C8C; font-size: 36rpx; }
.rank-num.bronze { background: rgba(212, 107, 8, 0.15); color: #D46B08; font-size: 36rpx; }
.rank-avatar {
.avatar--sm {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: var(--bg-soft);
flex-shrink: 0;
margin-right: 16rpx;
opacity: 0.6;
margin-right: 16rpx;
}
.rank-info {
@@ -155,27 +303,46 @@
margin-left: 16rpx;
}
/* My rank bar (pinned) */
/* My rank bar (pinned): solid gradient card so it reads as "my highlight"
floating above the list, not a translucent outline */
.my-bar {
position: fixed;
bottom: calc(110rpx + env(safe-area-inset-bottom));
bottom: calc(120rpx + env(safe-area-inset-bottom));
left: 24rpx;
right: 24rpx;
display: flex;
align-items: center;
background: rgba(var(--primary-rgb), 0.12);
border: 2rpx solid var(--primary);
border-radius: 16rpx;
padding: 16rpx 24rpx;
background: linear-gradient(135deg, var(--primary), var(--primary-light));
border-radius: 20rpx;
padding: 20rpx 24rpx;
z-index: 10;
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
box-shadow: 0 8rpx 24rpx rgba(var(--primary-rgb), 0.35);
}
.my-bar .rank-num.my-bar__num {
background: rgba(255, 255, 255, 0.22);
color: #FFFFFF;
}
.my-bar__avatar {
opacity: 1;
background: rgba(255, 255, 255, 0.85);
border: 2rpx solid rgba(255, 255, 255, 0.6);
}
.my-bar .rank-name {
color: var(--primary);
color: #FFFFFF;
font-weight: 600;
}
.my-bar .rank-sessions {
color: rgba(255, 255, 255, 0.8);
}
.my-bar .rank-dur {
color: #FFFFFF;
}
/* ---- rank item entrance animation ---- */
.rank-item--enter {
animation: rankIn 0.4s cubic-bezier(0.2, 0, 0.2, 1) both;
@@ -191,7 +358,13 @@
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 32rpx;
justify-content: center;
/* 垂直居中在"顶部 segmented 控件以下、底部 tabBar 以上"的可用区域。
~120rpx ≈ seg-wrap 高度;134rpx + safe-area 与 .container 的
padding-bottom 一致,预留自定义 tabBar。 */
min-height: calc(100vh - 120rpx - 134rpx - env(safe-area-inset-bottom));
padding: 32rpx;
box-sizing: border-box;
}
.empty-illus {
+10
View File
@@ -17,6 +17,7 @@ Page({
currentMonth: new Date().getMonth() + 1,
monthRecords: [],
historyList: [],
listMode: 'month',
showDayDetail: false,
dayDetail: {},
icons: iconsMod.build()
@@ -76,6 +77,8 @@ Page({
maxDurationText: util.formatDuration(stats.maxDuration),
monthRecords: records,
historyList,
listMode: this.data.listMode || 'month',
displayList: (this.data.listMode || 'month') === 'month' ? records : historyList,
loading: false
})
},
@@ -104,6 +107,13 @@ Page({
this.refresh()
},
onToggleListMode(e) {
const mode = e.currentTarget.dataset.mode
if (!mode || mode === this.data.listMode) return
this.setData({ listMode: mode })
this.refresh()
},
onDayTap(e) {
const { year, month, day } = e.detail
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
+10 -6
View File
@@ -75,22 +75,26 @@
<view class="history-header">
<view class="history-title-wrap">
<image class="history-title-icon" src="{{icons.formFill}}" mode="aspectFit"></image>
<text class="section-title">最近记录</text>
<text class="section-title">{{listMode === 'month' ? currentYear + '年' + currentMonth + '月记录' : '最近记录'}}</text>
</view>
<view class="rec-seg">
<view class="rec-seg-item {{listMode === 'month' ? 'active' : ''}}" data-mode="month" bindtap="onToggleListMode">本月</view>
<view class="rec-seg-item {{listMode === 'recent' ? 'active' : ''}}" data-mode="recent" bindtap="onToggleListMode">最近</view>
</view>
<text class="history-hint" wx:if="{{historyList.length > 0}}">长按可删除</text>
</view>
<view wx:if="{{historyList.length === 0}}" class="empty-state">
<text class="history-hint" wx:if="{{displayList.length > 0}}">长按可删除</text>
<view wx:if="{{displayList.length === 0}}" class="empty-state">
<view class="empty-illus">
<view class="empty-illus-body"></view>
<view class="empty-illus-arm empty-illus-arm--l"></view>
<view class="empty-illus-arm empty-illus-arm--r"></view>
<view class="empty-illus-bubble">开始</view>
</view>
<text class="empty-text">还没有训练记录</text>
<text class="empty-sub">完成第一次训练,开启你的坚持!</text>
<text class="empty-text">{{listMode === 'month' ? '本月还没有训练记录' : '还没有训练记录'}}</text>
<text class="empty-sub">{{listMode === 'month' ? '换个有训练的月份看看吧' : '完成第一次训练,开启你的坚持!'}}</text>
</view>
<view class="history-list">
<view class="history-item" wx:for="{{historyList}}" wx:key="id" data-id="{{item.id}}" bindlongpress="onLongPressDelete">
<view class="history-item" wx:for="{{displayList}}" wx:key="id" data-id="{{item.id}}" bindlongpress="onLongPressDelete">
<image class="history-item-icon"
src="{{item.duration >= 120 ? icons.hotFill : item.duration >= 60 ? icons.likeFill : icons.roundCheckFill}}"
mode="aspectFit"
+27 -1
View File
@@ -109,6 +109,32 @@
.history-hint {
font-size: 22rpx;
color: var(--text-secondary);
margin-bottom: 8rpx;
}
/* segmented control: [本月 | 最近] in the records history header.
Mirrors the .period-seg language used on the leaderboard/settings pages. */
.rec-seg {
display: flex;
background: var(--bg-soft);
border-radius: 999rpx;
padding: 4rpx;
flex-shrink: 0;
}
.rec-seg-item {
padding: 8rpx 24rpx;
font-size: 24rpx;
color: var(--text-secondary);
border-radius: 999rpx;
transition: background 0.25s ease, color 0.25s ease, box-shadow 0.25s ease;
}
.rec-seg-item.active {
background: var(--card-bg);
color: var(--primary);
font-weight: 600;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.10);
}
.empty-state {
@@ -256,7 +282,7 @@
@media (prefers-color-scheme: dark) {
.day-detail-body {
/* 暗黑下 body(#1C1C1E)与页面(#0F0F12)都偏深,加深阴影 + 微亮描边拉开层次 */
/* 暗黑下 body(#26262B)与页面(#131316)都偏深,加深阴影 + 微亮描边拉开层次 */
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.6), 0 0 0 1rpx rgba(255, 255, 255, 0.06);
}
}
+26 -1
View File
@@ -194,12 +194,37 @@ Page({
*/
async _uploadAvatar(filePath) {
if (!cloud.enabled) throw new Error('cloud not enabled')
// Shrink the chosen avatar (chooseAvatar returns a full-res PNG) before
// upload — a 200px-wide file is tens of KB vs the original's MB, which
// is what lets remote avatars load in well under a second on the
// leaderboard. Falls back to the original file if compression is
// unavailable (e.g. old base lib / devtools) so upload never breaks.
const compressed = await this._compressAvatar(filePath)
const cloudPath = `avatar/${Date.now()}-${Math.floor(Math.random() * 1e6)}.png`
const res = await wx.cloud.uploadFile({ cloudPath, filePath })
const res = await wx.cloud.uploadFile({ cloudPath, filePath: compressed })
if (!res || !res.fileID) throw new Error('uploadFile returned no fileID')
return res.fileID
},
/**
* Compress an avatar temp file to ~200px wide before upload. Uses
* wx.compressImage (base lib 2.21.0+ honours `compressedWidth`; older
* libs ignore it and just apply `quality`, still a win). Resolves to the
* original path on any failure so the caller degrades gracefully.
*/
_compressAvatar(filePath) {
return new Promise((resolve) => {
if (!wx.compressImage || !filePath) { resolve(filePath); return }
wx.compressImage({
src: filePath,
quality: 80,
compressedWidth: 200,
success: (res) => resolve((res && res.tempFilePath) || filePath),
fail: () => resolve(filePath)
})
})
},
onNicknameBlur(e) {
const value = (e.detail.value || '').trim()
if (!value) return
+7 -6
View File
@@ -7,7 +7,7 @@
</view>
<view class="profile-row">
<button class="avatar-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<image class="avatar-img" src="{{avatarUrl || icons.peopleFill}}" mode="aspectFit"></image>
<image class="avatar-img {{avatarUrl ? 'has-avatar' : ''}}" src="{{avatarUrl || icons.peopleFill}}" mode="{{avatarUrl ? 'aspectFill' : 'aspectFit'}}"></image>
<view class="avatar-overlay">
<text>点击设置</text>
</view>
@@ -39,17 +39,18 @@
<image class="section-icon" src="{{icons.skinFill}}" mode="aspectFit"></image>
<text class="section-title">配色方案</text>
</view>
<view class="theme-list">
<view class="theme-cards">
<view
class="theme-item {{currentThemeId === item.id ? 'active' : ''}}"
class="theme-card {{currentThemeId === item.id ? 'active' : ''}}"
wx:for="{{themes}}"
wx:key="id"
data-id="{{item.id}}"
bindtap="onSelectTheme"
>
<view class="theme-color-dot" style="background: {{item.primary}};"></view>
<text class="theme-name">{{item.name}}</text>
<image class="theme-check-img" wx:if="{{currentThemeId === item.id}}" src="{{icons.check}}" mode="aspectFit"></image>
<view class="theme-card-swatch" style="background: linear-gradient(135deg, {{item.primary}}, {{item.primaryLight}});">
<image class="theme-card-check" wx:if="{{currentThemeId === item.id}}" src="{{icons.checkWhite}}" mode="aspectFit"></image>
</view>
<text class="theme-card-name {{currentThemeId === item.id ? 'active' : ''}}">{{item.name}}</text>
</view>
</view>
</ui-card>
+80 -15
View File
@@ -14,7 +14,64 @@
margin-bottom: 0;
}
/* ---- theme picker ---- */
/* ---- theme picker: horizontal gradient swatch cards ---- */
.theme-cards {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
padding-top: 8rpx;
}
.theme-card {
display: flex;
flex-direction: column;
align-items: center;
width: calc((100% - 40rpx) / 3);
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.theme-card:active {
transform: scale(0.94);
}
.theme-card-swatch {
width: 100%;
height: 96rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.10);
border: 4rpx solid transparent;
box-sizing: border-box;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.theme-card.active .theme-card-swatch {
border-color: var(--card-bg);
box-shadow: 0 0 0 4rpx var(--primary), 0 6rpx 16rpx rgba(var(--primary-rgb), 0.35);
transform: scale(1.04);
}
.theme-card-check {
width: 40rpx;
height: 40rpx;
filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.25));
}
.theme-card-name {
font-size: 24rpx;
color: var(--text-secondary);
margin-top: 12rpx;
line-height: 1;
}
.theme-card-name.active {
color: var(--primary);
font-weight: 600;
}
/* ---- dark-mode & plan list (row-style pickers, e.g. 深色模式) ---- */
.theme-list { margin-top: 0; }
.theme-item {
@@ -40,20 +97,6 @@
border-top: 1rpx solid transparent;
}
.theme-color-dot {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
margin-right: 20rpx;
flex-shrink: 0;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.15);
transition: transform 0.2s ease;
}
.theme-item:active .theme-color-dot {
transform: scale(1.15);
}
.theme-name {
flex: 1;
font-size: 28rpx;
@@ -78,6 +121,10 @@
.plan-item:last-child { border-bottom: none; }
.plan-item {
position: relative;
}
.plan-item.active {
background: rgba(var(--primary-rgb), 0.12);
margin: 0 -32rpx;
@@ -87,6 +134,19 @@
border-bottom: none;
}
/* left accent bar on the active plan, making the selection scan-able */
.plan-item.active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6rpx;
height: 48rpx;
border-radius: 3rpx;
background: linear-gradient(180deg, var(--primary), var(--primary-light));
}
.plan-radio {
width: 40rpx;
height: 40rpx;
@@ -283,6 +343,11 @@
box-sizing: border-box;
}
/* 真实头像(正方形)用 aspectFill 填满圆形容器,去掉占位图的内缩 padding */
.avatar-img.has-avatar {
padding: 0;
}
.avatar-overlay {
position: absolute;
bottom: 0;
+5 -6
View File
@@ -243,13 +243,13 @@ Page({
const nextStreak = (streak.lastDate && streak.count >= 1) ? streak.count + 1 : 1
if (stats.totalSessions === 0) {
return { level: 'first', message: '🎉 第一次训练!坚持就是胜利' }
return { level: 'first', message: '第一次训练!坚持就是胜利' }
}
if ([7, 14, 30, 50, 100, 365].includes(nextStreak)) {
return { level: 'milestone', message: `🔥 连续打卡 ${nextStreak} 天!` }
return { level: 'milestone', message: `连续打卡 ${nextStreak} 天!` }
}
if (elapsed > stats.maxDuration) {
return { level: 'record', message: `🏆 新纪录!${elapsed}` }
return { level: 'record', message: `新纪录!${elapsed}` }
}
return { level: 'normal', message: '完成!' }
},
@@ -454,6 +454,7 @@ Page({
this._lastCountdown = 0
this._lastTipIndex = -1
this._clearVibrateTimers()
this._countUpTask = null
this.setData({
showCompletion: false,
isCompleted: false,
@@ -462,9 +463,7 @@ Page({
isPaused: false,
remaining: this.data.duration,
overtime: 0,
goalReached: false,
// Re-init countUp so the big number rolls again on next start
_countUpTask: null
goalReached: false
})
},
+12 -12
View File
@@ -29,21 +29,21 @@
duration="{{duration}}"
remaining="{{isCompleted ? 0 : remaining}}"
size="{{360}}"
ringWidth="{{16}}"
ringWidth="{{22}}"
status="{{status}}"
primaryColor="{{theme.primary}}"
primaryLightColor="{{theme.primaryLight}}"
trackColor="{{isDark ? '#2C2C2E' : '#EEEEEE'}}"
trackColor="{{isDark ? '#3A3A40' : '#EEEEEE'}}"
></progress-ring>
<!-- 完成庆祝粒子 -->
<view class="celebrate-burst" wx:if="{{isCompleted}}">
<view class="spark s1">✨</view>
<view class="spark s2">⭐</view>
<view class="spark s3">💫</view>
<view class="spark s4">🌟</view>
<view class="spark s5">✨</view>
<view class="spark s6">💪</view>
<image class="spark s1" src="{{icons.sparkleFill}}" mode="aspectFit"></image>
<image class="spark s2" src="{{icons.sparkleFill}}" mode="aspectFit"></image>
<image class="spark s3" src="{{icons.crownFill}}" mode="aspectFit"></image>
<image class="spark s4" src="{{icons.sparkleFill}}" mode="aspectFit"></image>
<image class="spark s5" src="{{icons.sparkleFill}}" mode="aspectFit"></image>
<image class="spark s6" src="{{icons.likeFill}}" mode="aspectFit"></image>
</view>
<view class="overtime-badge" wx:if="{{overtime > 0}}">
@@ -87,7 +87,7 @@
size="xl"
block
text="开始"
icon-src="{{icons.playFill}}"
icon-src="{{icons.playWhite}}"
bindtap="onStart"
></ui-btn>
@@ -106,7 +106,7 @@
variant="primary"
size="xl"
text="继续"
icon-src="{{icons.playFill}}"
icon-src="{{icons.playWhite}}"
custom-style="margin-right: 28rpx;"
bindtap="onStart"
></ui-btn>
@@ -137,7 +137,7 @@
<view class="completion-mask" wx:if="{{showCompletion}}" catchtouchmove="onNoop">
<view class="completion-card">
<view class="completion-emoji-wrap">
<text class="completion-emoji">{{completionLevel === 'first' ? '🎉' : completionLevel === 'record' ? '🏆' : completionLevel === 'milestone' ? '🔥' : '✨'}}</text>
<image class="completion-icon" src="{{completionLevel === 'first' ? icons.partyWhite : completionLevel === 'record' ? icons.trophyWhite : completionLevel === 'milestone' ? icons.hotWhite : icons.sparkleWhite}}" mode="aspectFit"></image>
</view>
<text class="completion-title">{{completionMessage}}</text>
@@ -153,7 +153,7 @@
</view>
<view class="completion-streak" wx:if="{{completionStreak > 0}}">
<text class="streak-flame">🔥</text>
<image class="streak-flame" src="{{icons.hotFill}}" mode="aspectFit"></image>
<text class="streak-text">连续打卡 {{completionStreak}} 天</text>
</view>
+17 -14
View File
@@ -17,24 +17,25 @@
justify-content: center;
}
/* breathing guide rings — three concentric layers */
/* breathing guide rings — soft radial glows instead of hard outline circles,
so they read as ambient halo rather than a bullseye */
.breathe-ring {
position: absolute;
border-radius: 50%;
border: 3rpx solid rgba(var(--primary-rgb), 0.15);
background: radial-gradient(circle, rgba(var(--primary-rgb), 0.10) 0%, rgba(var(--primary-rgb), 0) 65%);
animation: breathePulse 3.5s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
.breathe-ring-1 { width: 480rpx; height: 480rpx; animation-delay: 0s; }
.breathe-ring-2 { width: 400rpx; height: 400rpx; animation-delay: 0.5s; border-color: rgba(var(--primary-rgb), 0.25); border-width: 4rpx; }
.breathe-ring-3 { width: 320rpx; height: 320rpx; animation-delay: 1s; border-color: rgba(var(--primary-rgb), 0.35); border-width: 5rpx; }
.breathe-ring-1 { width: 520rpx; height: 520rpx; animation-delay: 0s; }
.breathe-ring-2 { width: 440rpx; height: 440rpx; animation-delay: 0.5s; background: radial-gradient(circle, rgba(var(--primary-rgb), 0.14) 0%, rgba(var(--primary-rgb), 0) 65%); }
.breathe-ring-3 { width: 380rpx; height: 380rpx; animation-delay: 1s; background: radial-gradient(circle, rgba(var(--primary-rgb), 0.18) 0%, rgba(var(--primary-rgb), 0) 65%); }
.breathe-ring.fast { animation: breathePulseFast 1.4s ease-in-out infinite; }
.breathe-ring.fast.breathe-ring-1 { animation-delay: 0s; border-color: rgba(var(--primary-rgb), 0.3); }
.breathe-ring.fast.breathe-ring-2 { animation-delay: 0.25s; border-color: rgba(var(--primary-rgb), 0.45); }
.breathe-ring.fast.breathe-ring-3 { animation-delay: 0.5s; border-color: rgba(var(--primary-rgb), 0.6); }
.breathe-ring.fast.breathe-ring-1 { animation-delay: 0s; }
.breathe-ring.fast.breathe-ring-2 { animation-delay: 0.25s; }
.breathe-ring.fast.breathe-ring-3 { animation-delay: 0.5s; }
/* celebration burst */
.celebrate-burst {
@@ -47,11 +48,12 @@
.spark {
position: absolute;
font-size: 36rpx;
width: 44rpx;
height: 44rpx;
animation: sparkBurst 1s ease-out forwards;
}
.s1 { top: -10rpx; left: 50%; margin-left: -18rpx; animation-delay: 0s; }
.s1 { top: -10rpx; left: 50%; margin-left: -22rpx; animation-delay: 0s; }
.s2 { top: 30rpx; right: -10rpx; animation-delay: 0.1s; }
.s3 { bottom: -10rpx; right: 50rpx; animation-delay: 0.2s; }
.s4 { bottom: 20rpx; left: 30rpx; animation-delay: 0.15s; }
@@ -264,9 +266,9 @@
50% { transform: scale(1.08); }
}
.completion-emoji {
font-size: 80rpx;
line-height: 1;
.completion-icon {
width: 80rpx;
height: 80rpx;
}
.completion-title {
@@ -346,7 +348,8 @@
}
.streak-flame {
font-size: 32rpx;
width: 32rpx;
height: 32rpx;
}
.streak-text {