feat(records): 训练勋章 UI 改造
- 勋章图标从 emoji 改为矢量 SVG 线性图标(utils/icons.js 新增 bolt/moon/diamond 路径及 white 变体) - 命名改为累计语义(一周累计/半月累计/月度累计/双月累计/百日累计),并标注"非连续打卡" - 进度条改回等距节点,解锁时填充正好抵达节点、满级满格 - 新增单枚解锁庆祝:持久化已见解锁集合,进入页面弹 toast + 节点 pop 动画 - 已解锁节点常驻显示勋章名 + 右上角 ✓ 角标,直观体现"已拥有" - utils/storage.js 新增 totalDays(累计去重训练日),为勋章统计提供数据源
This commit is contained in:
@@ -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,这里集中维护所有场景的分享文案,改这里即可。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user