diff --git a/config.js b/config.js
index 94b681f..f70fb33 100644
--- a/config.js
+++ b/config.js
@@ -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,这里集中维护所有场景的分享文案,改这里即可。
diff --git a/pages/records/records.js b/pages/records/records.js
index 3b1b6aa..6ba2ee2 100644
--- a/pages/records/records.js
+++ b/pages/records/records.js
@@ -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)
}
})
diff --git a/pages/records/records.wxml b/pages/records/records.wxml
index a3252fc..ea87771 100644
--- a/pages/records/records.wxml
+++ b/pages/records/records.wxml
@@ -44,6 +44,39 @@
+
+
+
+
+
+ 累计训练 {{totalDays}} 天
+ ·
+ {{nextBadgeHint}}
+
+
+
+
+
+
+
+
+
+ {{item.unlocked ? item.name : item.days}}
+
+
+
+
diff --git a/pages/records/records.wxss b/pages/records/records.wxss
index 2e56f81..3f32457 100644
--- a/pages/records/records.wxss
+++ b/pages/records/records.wxss
@@ -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;
diff --git a/utils/icons.js b/utils/icons.js
index aa3f8a8..aead57b 100644
--- a/utils/icons.js
+++ b/utils/icons.js
@@ -56,6 +56,12 @@ const PATHS = {
// Trophy (record celebrations) — Material emoji-events
trophy: '',
trophyFill: '',
+ // Badge icons (training milestone medals) — replace the old emoji set.
+ // Single-color fills; a gray (locked) and white White
+ // (unlocked, sits on the theme-color disc) variant are both built.
+ bolt: '',
+ moon: '',
+ diamond: '',
// Party popper (first-time celebrations) — Material celebration
party: '',
partyFill: '',
@@ -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
}
diff --git a/utils/storage.js b/utils/storage.js
index 0149170..adf282c 100644
--- a/utils/storage.js
+++ b/utils/storage.js
@@ -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 = () => {