feat(records): 训练勋章 UI 改造

- 勋章图标从 emoji 改为矢量 SVG 线性图标(utils/icons.js 新增 bolt/moon/diamond 路径及 white 变体)
- 命名改为累计语义(一周累计/半月累计/月度累计/双月累计/百日累计),并标注"非连续打卡"
- 进度条改回等距节点,解锁时填充正好抵达节点、满级满格
- 新增单枚解锁庆祝:持久化已见解锁集合,进入页面弹 toast + 节点 pop 动画
- 已解锁节点常驻显示勋章名 + 右上角 ✓ 角标,直观体现"已拥有"
- utils/storage.js 新增 totalDays(累计去重训练日),为勋章统计提供数据源
This commit is contained in:
2026-07-28 16:25:55 +08:00
parent 75bad477b1
commit c5ae332cbe
6 changed files with 316 additions and 5 deletions
+20 -4
View File
@@ -4,13 +4,13 @@
*/
module.exports = {
/** 应用版本号,外显在设置页「关于」 */
version: 'v3.01',
version: 'v3.02',
/** 最后更新日期,外显在设置页「关于」 */
updatedAt: '2026-07-28',
/** 开发者名称 / 微信号,设置页点击可复制 */
developer: '刘承',
developer: '三口一瓶',
/**
* 公众号名称。设置页「关于 → 公众号」直接展示,用户可在微信搜索关注,
@@ -30,8 +30,8 @@ module.exports = {
* speed: 语速 -2~2(正数加快,0 为默认)。
*/
tts: {
female: { voiceType: 501004, volume: 9, speed: 1 },
male: { voiceType: 502005, volume: 9, speed: 1 }
female: { voiceType: 501004, volume: 9, speed: 0 },
male: { voiceType: 502005, volume: 9, speed: 0 }
},
/**
@@ -54,6 +54,22 @@ module.exports = {
/** 姿势要领每条显示的秒数 */
tipInterval: 10,
/**
* 累计训练天数里程碑勋章,按累计去重训练日(可中断,非连续打卡)解锁。
* 记录页用里程碑进度条展示,节点按 sqrt 感知映射分布(早期里程碑更显眼),
* 填充与节点同映射,解锁时填充正好抵达该节点、满级时满格。增删改这里即可,
* 但 name 一律用"累计"措辞,避免暗示连续打卡误导用户。
*/
trainingDayBadges: [
{ days: 3, name: '抖登', icon: 'hot', iconWhite: 'hotWhite' },
{ days: 7, name: '中登', icon: 'bolt', iconWhite: 'boltWhite' },
{ days: 14, name: '老登', icon: 'moon', iconWhite: 'moonWhite' },
{ days: 30, name: '神登', icon: 'trophy', iconWhite: 'trophyWhite' },
{ days: 60, name: '上古神登', icon: 'diamond', iconWhite: 'diamondWhite' },
{ days: 80, name: '洪荒神登', icon: 'diamond', iconWhite: 'diamondWhite' },
{ days: 100, name: '究极登祖', icon: 'crown', iconWhite: 'crownWhite' }
],
/**
* 小程序分享配置。微信分享能力依赖每个 Page 实现 onShareAppMessage /
* onShareTimeline,这里集中维护所有场景的分享文案,改这里即可。
+84
View File
@@ -14,6 +14,10 @@ Page({
totalSessions: 0,
maxDuration: 0,
maxDurationText: '',
totalDays: 0,
trainingBadges: [],
badgeProgress: 0,
nextBadgeHint: '',
currentYear: new Date().getFullYear(),
currentMonth: new Date().getMonth() + 1,
monthRecords: [],
@@ -69,6 +73,43 @@ Page({
if (historyList.length >= 30) break
}
// 累计训练天数勋章(里程碑进度条):统计口径=累计去重训练日(可中断)。
// 节点等距排列(间距一致、视觉整齐),填充在区间内线性插值,
// 解锁时填充正好抵达该节点,满级时填充满格。
const totalDays = stats.totalDays
const badgeDefs = config.trainingDayBadges
const n = badgeDefs.length
const maxDays = badgeDefs[n - 1].days
const TRACK_PAD = 4 // 首尾留白,保证首尾节点/标签不溢出
const segWidth = n > 1 ? (100 - TRACK_PAD * 2) / (n - 1) : 0
const posOf = (i) => TRACK_PAD + i * segWidth
const trainingBadges = badgeDefs.map((b, i) => ({
...b,
unlocked: totalDays >= b.days,
pos: Math.round(posOf(i) * 10) / 10
}))
let badgeProgress
if (totalDays <= 0) {
badgeProgress = 0
} else if (totalDays >= maxDays) {
badgeProgress = 100
} else {
let i = 0
while (i < n - 1 && totalDays >= badgeDefs[i + 1].days) i++
const ratio = (totalDays - badgeDefs[i].days) / (badgeDefs[i + 1].days - badgeDefs[i].days)
badgeProgress = Math.round((posOf(i) + ratio * segWidth) * 10) / 10
}
// 单枚解锁庆祝反馈:对比持久化"已见解锁集合",只庆祝新增勋章。
// 首次运行(无存储)仅初始化集合,避免老用户一打开就被历史勋章刷屏。
const unlockedDays = trainingBadges.filter(b => b.unlocked).map(b => b.days)
const newlySet = this._checkBadgeCelebration(unlockedDays)
if (newlySet) trainingBadges.forEach(b => { b.justUnlocked = newlySet.has(b.days) })
const nextIdx = trainingBadges.findIndex(b => !b.unlocked)
const nextBadgeHint = nextIdx === -1
? '全部勋章已解锁!'
: `还差 ${badgeDefs[nextIdx].days - totalDays} 天解锁「${badgeDefs[nextIdx].name}`
const theme = themeMod.getCurrentTheme()
this.setData({
theme,
@@ -78,6 +119,10 @@ Page({
totalSessions: stats.totalSessions,
maxDuration: stats.maxDuration,
maxDurationText: util.formatDuration(stats.maxDuration),
totalDays,
trainingBadges,
badgeProgress,
nextBadgeHint,
monthRecords: records,
historyList,
listMode: this.data.listMode || 'month',
@@ -245,5 +290,44 @@ Page({
return {
title: config.share.timelineTitle
}
},
/**
* 点击勋章节点:显示勋章名称与解锁状态。
*/
onBadgeTap(e) {
const days = Number(e.currentTarget.dataset.days)
const b = config.trainingDayBadges.find(x => x.days === days)
if (!b) return
const unlocked = this.data.totalDays >= days
wx.showToast({
title: unlocked ? `已解锁:${b.name}` : `再练 ${days - this.data.totalDays} 天解锁「${b.name}`,
icon: 'none',
duration: 1800
})
},
/**
* 单枚勋章解锁庆祝:对比持久化的"已见解锁集合",只对新增的解锁弹 toast
* 并标记 justUnlocked(节点高亮动画)。首次运行(无存储)仅初始化集合、不庆祝,
* 避免老用户一打开就刷一堆历史勋章。返回新增天数集合(无新增返回 null)。
*/
_checkBadgeCelebration(unlockedDays) {
const KEY = 'badge_celebrated'
const prev = wx.getStorageSync(KEY)
if (!Array.isArray(prev)) {
wx.setStorageSync(KEY, unlockedDays)
return null
}
const prevSet = new Set(prev)
const newly = unlockedDays.filter(d => !prevSet.has(d))
wx.setStorageSync(KEY, unlockedDays)
if (newly.length === 0) return null
const names = newly.map(d => {
const b = config.trainingDayBadges.find(x => x.days === d)
return b ? b.name : (d + '天')
})
wx.showToast({ title: '解锁勋章:' + names.join('、'), icon: 'none', duration: 2000 })
return new Set(newly)
}
})
+33
View File
@@ -44,6 +44,39 @@
</view>
</ui-card>
<!-- 训练勋章 (里程碑进度条) -->
<ui-card variant="in" hidden="{{totalSessions === 0}}">
<view class="badge-section">
<view class="badge-header">
<view class="badge-title-wrap">
<image class="badge-title-icon" src="{{icons.crownFill}}" mode="aspectFit"></image>
<text class="section-title">训练勋章</text>
</view>
</view>
<view class="badge-status">
<text class="badge-status-days">累计训练 <text class="badge-days-num">{{totalDays}}</text> 天</text>
<text class="badge-status-dot" wx:if="{{nextBadgeHint}}">·</text>
<text class="badge-status-next" wx:if="{{nextBadgeHint}}">{{nextBadgeHint}}</text>
</view>
<view class="badge-track">
<view class="badge-fill" style="width: {{badgeProgress}}%;"></view>
<view class="badge-node {{item.unlocked ? 'unlocked' : ''}} {{item.justUnlocked ? 'celebrate' : ''}}"
wx:for="{{trainingBadges}}" wx:key="days"
style="left: {{item.pos}}%;"
data-days="{{item.days}}"
bindtap="onBadgeTap">
<image class="badge-node-icon" src="{{item.unlocked ? icons[item.iconWhite] : icons[item.icon]}}" mode="aspectFit"></image>
<view class="badge-check" wx:if="{{item.unlocked}}"></view>
</view>
</view>
<view class="badge-labels">
<text class="badge-label {{item.unlocked ? 'unlocked' : ''}}"
wx:for="{{trainingBadges}}" wx:key="days"
style="left: {{item.pos}}%;">{{item.unlocked ? item.name : item.days}}</text>
</view>
</view>
</ui-card>
<!-- 训练趋势 -->
<ui-card variant="in" hidden="{{totalSessions === 0}}">
<view class="trend-section">
+163
View File
@@ -33,6 +33,169 @@
text-align: center;
}
/* ---- badges ---- */
.badge-section {
padding: 0;
}
.badge-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.badge-header .section-title {
margin-bottom: 0;
}
.badge-title-wrap {
display: flex;
align-items: center;
gap: 8rpx;
}
.badge-title-icon {
width: 36rpx;
height: 36rpx;
align-self: center;
margin-top: -2rpx;
}
.badge-status {
display: flex;
align-items: baseline;
justify-content: center;
flex-wrap: wrap;
gap: 10rpx;
margin: 12rpx 0 44rpx;
text-align: center;
}
.badge-status-days {
font-size: 24rpx;
color: var(--text-secondary);
}
.badge-status-dot {
font-size: 24rpx;
color: var(--text-secondary);
opacity: 0.5;
}
.badge-status-next {
font-size: 24rpx;
color: var(--text-secondary);
}
.badge-days-num {
font-size: 44rpx;
font-weight: 800;
color: var(--primary);
}
.badge-track {
position: relative;
height: 4rpx;
margin: 48rpx 0 20rpx;
background: rgba(var(--primary-rgb), 0.12);
border-radius: 2rpx;
}
.badge-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: var(--primary);
border-radius: 2rpx;
transition: width 0.6s ease;
}
.badge-node {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: var(--card-bg, #fff);
border: 2rpx solid rgba(var(--primary-rgb), 0.2);
display: flex;
align-items: center;
justify-content: center;
transition: background 0.4s ease, border-color 0.4s ease, transform 0.4s ease;
}
.badge-node.unlocked {
background: var(--primary);
border-color: var(--primary);
}
.badge-node.celebrate {
animation: badgePop 0.6s ease;
box-shadow: 0 0 0 8rpx rgba(var(--primary-rgb), 0.22);
}
@keyframes badgePop {
0% { transform: translate(-50%, -50%) scale(1); }
35% { transform: translate(-50%, -50%) scale(1.35); }
65% { transform: translate(-50%, -50%) scale(0.9); }
100% { transform: translate(-50%, -50%) scale(1); }
}
.badge-node-icon {
width: 32rpx;
height: 32rpx;
}
/* 已解锁节点的 ✓ 角标:纯 CSS 画勾(图标 SVG 颜色烤死,无法用变量改色) */
.badge-check {
position: absolute;
right: -3rpx;
bottom: -3rpx;
width: 24rpx;
height: 24rpx;
border-radius: 50%;
background: var(--primary);
border: 2rpx solid var(--card-bg, #fff);
box-sizing: border-box;
}
.badge-check::after {
content: '';
position: absolute;
left: 7rpx;
top: 3rpx;
width: 6rpx;
height: 11rpx;
border: solid var(--card-bg, #fff);
border-width: 0 3rpx 3rpx 0;
transform: rotate(45deg);
}
.badge-labels {
position: relative;
height: 30rpx;
margin-top: 32rpx;
}
.badge-label {
position: absolute;
top: 0;
transform: translateX(-50%);
font-size: 20rpx;
color: var(--text-secondary);
white-space: nowrap;
opacity: 0.8;
}
.badge-label.unlocked {
color: var(--primary);
font-size: 22rpx;
font-weight: 700;
opacity: 1;
}
/* ---- calendar ---- */
.calendar-section {
padding: 0;
+11
View File
@@ -56,6 +56,12 @@ const PATHS = {
// 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"/>',
// Badge icons (training milestone medals) — replace the old emoji set.
// Single-color fills; a gray <name> (locked) and white <name>White
// (unlocked, sits on the theme-color disc) variant are both built.
bolt: '<path d="M13 2L4 14h6l-1 8 9-12h-6l1-8z"/>',
moon: '<path d="M12 21a9 9 0 1 1 9-9 7 7 0 0 0-9 9z"/>',
diamond: '<path d="M7 2h10l4 7-11 13L3 9l4-7z"/>',
// 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"/>',
@@ -154,6 +160,11 @@ const build = (theme) => {
out.playWhite = _svg(PATHS.playFill, '#FFFFFF')
out.sparkleWhite = _svg(PATHS.sparkleFill, '#FFFFFF')
out.checkWhite = _svg(PATHS.check, '#FFFFFF')
// Badge white variants — sit on the theme-color (filled) node disc.
out.boltWhite = _svg(PATHS.bolt, '#FFFFFF')
out.moonWhite = _svg(PATHS.moon, '#FFFFFF')
out.diamondWhite = _svg(PATHS.diamond, '#FFFFFF')
out.crownWhite = _svg(PATHS.crown, '#FFFFFF')
return out
}
+5 -1
View File
@@ -154,15 +154,19 @@ const getTotalStats = () => {
let totalDuration = 0
let totalSessions = 0
let maxDuration = 0
// 累计训练天数:有训练记录的不同日期数(一天多次只算 1 天)。
// 用于记录页里程碑勋章,只增不减(删记录才会减)。
const days = new Set()
Object.keys(records).forEach((key) => {
records[key].forEach((r) => {
const d = Number(r && r.duration) || 0
totalDuration += d
totalSessions++
if (d > maxDuration) maxDuration = d
if (r && r.date) days.add(dateOnly(r.date))
})
})
return { totalDuration, totalSessions, maxDuration }
return { totalDuration, totalSessions, maxDuration, totalDays: days.size }
}
const getSettings = () => {