feat(records): 训练趋势图表 + 列表时长改 MM:SS
- 新增 trend-chart 柱状图组件(CSS,近7天/近6月切换) - records 页加训练趋势卡片(统计卡片后) - 训练记录列表时长从秒数改为 MM:SS 显示 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Trend bar chart - pure CSS (no canvas), matches the calendar-heatmap's
|
||||||
|
* rendering approach for consistency.
|
||||||
|
*
|
||||||
|
* chartData: [{ label, value, valueText, highlight }]
|
||||||
|
* - label: bottom caption (e.g. "一" / "7月")
|
||||||
|
* - value: numeric magnitude (seconds); drives bar height
|
||||||
|
* - valueText: formatted string shown above the bar (e.g. "1分30秒")
|
||||||
|
* - highlight: truthy -> theme glow + bold label (today / this month)
|
||||||
|
*
|
||||||
|
* Bar height is value/max*100%, with an 8% floor for any non-zero value so
|
||||||
|
* short sessions stay visible. Zero values render a faint stub.
|
||||||
|
*/
|
||||||
|
Component({
|
||||||
|
properties: {
|
||||||
|
chartData: { type: Array, value: [] }
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
bars: []
|
||||||
|
},
|
||||||
|
observers: {
|
||||||
|
'chartData'(chartData) {
|
||||||
|
this._compute(chartData)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
lifetimes: {
|
||||||
|
ready() {
|
||||||
|
this._compute(this.properties.chartData)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
_compute(chartData) {
|
||||||
|
const arr = Array.isArray(chartData) ? chartData : []
|
||||||
|
const max = arr.reduce((m, d) => Math.max(m, Number(d && d.value) || 0), 0)
|
||||||
|
const bars = arr.map((d) => {
|
||||||
|
const v = Number(d && d.value) || 0
|
||||||
|
return {
|
||||||
|
label: (d && d.label) || '',
|
||||||
|
value: v,
|
||||||
|
valueText: (d && d.valueText) || '',
|
||||||
|
highlight: !!(d && d.highlight),
|
||||||
|
percent: max > 0 ? Math.max((v / max) * 100, v > 0 ? 8 : 0) : 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.setData({ bars })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"component": true,
|
||||||
|
"usingComponents": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<view class="trend-chart">
|
||||||
|
<view class="trend-bars">
|
||||||
|
<view class="trend-bar-col" wx:for="{{bars}}" wx:key="label">
|
||||||
|
<view class="trend-bar {{item.value === 0 ? 'zero' : ''}} {{item.highlight ? 'highlight' : ''}}" style="height: {{item.percent}}%;">
|
||||||
|
<text class="trend-bar-value" wx:if="{{item.value > 0}}">{{item.valueText}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="trend-labels">
|
||||||
|
<text class="trend-bar-label {{item.highlight ? 'highlight' : ''}}" wx:for="{{bars}}" wx:key="label">{{item.label}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
.trend-chart {
|
||||||
|
/* top room so the value labels above tall bars aren't clipped */
|
||||||
|
padding-top: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-bars {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
height: 220rpx;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-bar-col {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-bar {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8rpx 8rpx 0 0;
|
||||||
|
background: linear-gradient(180deg, var(--primary), var(--primary-light));
|
||||||
|
min-height: 6rpx;
|
||||||
|
transition: height 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-bar.zero {
|
||||||
|
background: var(--border);
|
||||||
|
min-height: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-bar.highlight {
|
||||||
|
box-shadow: 0 0 14rpx rgba(var(--primary-rgb), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* value caption floats above the bar so it never eats into bar height */
|
||||||
|
.trend-bar-value {
|
||||||
|
position: absolute;
|
||||||
|
top: -28rpx;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-labels {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-bar-label {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-bar-label.highlight {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ Page({
|
|||||||
monthRecords: [],
|
monthRecords: [],
|
||||||
historyList: [],
|
historyList: [],
|
||||||
listMode: 'month',
|
listMode: 'month',
|
||||||
|
trendMode: 'week',
|
||||||
|
trendData: [],
|
||||||
showDayDetail: false,
|
showDayDetail: false,
|
||||||
dayDetail: {},
|
dayDetail: {},
|
||||||
icons: iconsMod.build()
|
icons: iconsMod.build()
|
||||||
@@ -52,7 +54,7 @@ Page({
|
|||||||
storage.validateStreak()
|
storage.validateStreak()
|
||||||
const stats = storage.getTotalStats()
|
const stats = storage.getTotalStats()
|
||||||
const monthKey = `${this.data.currentYear}-${String(this.data.currentMonth).padStart(2, '0')}`
|
const monthKey = `${this.data.currentYear}-${String(this.data.currentMonth).padStart(2, '0')}`
|
||||||
const records = storage.getRecordsByMonth(monthKey)
|
const records = (storage.getRecordsByMonth(monthKey) || []).map(r => ({ ...r, durationText: util.formatTime(r.duration) }))
|
||||||
|
|
||||||
// Each month is already sorted newest-first in storage. To get top 30 most recent,
|
// Each month is already sorted newest-first in storage. To get top 30 most recent,
|
||||||
// walk months in reverse chrono order and concat — O(months*30) instead of O(n log n).
|
// walk months in reverse chrono order and concat — O(months*30) instead of O(n log n).
|
||||||
@@ -61,7 +63,7 @@ Page({
|
|||||||
const historyList = []
|
const historyList = []
|
||||||
for (const mk of monthKeys) {
|
for (const mk of monthKeys) {
|
||||||
for (const r of byMonth[mk]) {
|
for (const r of byMonth[mk]) {
|
||||||
historyList.push(r)
|
historyList.push({ ...r, durationText: util.formatTime(r.duration) })
|
||||||
if (historyList.length >= 30) break
|
if (historyList.length >= 30) break
|
||||||
}
|
}
|
||||||
if (historyList.length >= 30) break
|
if (historyList.length >= 30) break
|
||||||
@@ -80,6 +82,7 @@ Page({
|
|||||||
historyList,
|
historyList,
|
||||||
listMode: this.data.listMode || 'month',
|
listMode: this.data.listMode || 'month',
|
||||||
displayList: (this.data.listMode || 'month') === 'month' ? records : historyList,
|
displayList: (this.data.listMode || 'month') === 'month' ? records : historyList,
|
||||||
|
trendData: this._computeTrend(this.data.trendMode || 'week'),
|
||||||
loading: false
|
loading: false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -115,6 +118,61 @@ Page({
|
|||||||
this.refresh()
|
this.refresh()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onToggleTrendMode(e) {
|
||||||
|
const mode = e.currentTarget.dataset.mode
|
||||||
|
if (!mode || mode === this.data.trendMode) return
|
||||||
|
this.setData({ trendMode: mode, trendData: this._computeTrend(mode) })
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 训练趋势数据:
|
||||||
|
* week - 近 7 天每天的训练时长(秒)
|
||||||
|
* month - 近 6 个月每月的训练时长(秒)
|
||||||
|
* 返回 [{ label, value, valueText, highlight }] 供 trend-chart 渲染。
|
||||||
|
*/
|
||||||
|
_computeTrend(mode) {
|
||||||
|
const allRecords = Object.values(storage.getRecords()).flat()
|
||||||
|
const today = new Date()
|
||||||
|
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
|
||||||
|
if (mode === 'month') {
|
||||||
|
const data = []
|
||||||
|
for (let i = 5; i >= 0; i--) {
|
||||||
|
const d = new Date(today.getFullYear(), today.getMonth() - i, 1)
|
||||||
|
const monthKey = `${d.getFullYear()}-${pad(d.getMonth() + 1)}`
|
||||||
|
const monthRecords = storage.getRecordsByMonth(monthKey) || []
|
||||||
|
const total = monthRecords.reduce((s, r) => s + (Number(r.duration) || 0), 0)
|
||||||
|
data.push({
|
||||||
|
label: `${d.getMonth() + 1}月`,
|
||||||
|
value: total,
|
||||||
|
valueText: total > 0 ? util.formatDuration(total) : '',
|
||||||
|
highlight: i === 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// default: week (近 7 天)
|
||||||
|
const data = []
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const d = new Date(today)
|
||||||
|
d.setDate(d.getDate() - i)
|
||||||
|
const dateStr = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
|
const total = allRecords
|
||||||
|
.filter((r) => util.dateOnly(r.date) === dateStr)
|
||||||
|
.reduce((s, r) => s + (Number(r.duration) || 0), 0)
|
||||||
|
const isToday = i === 0
|
||||||
|
data.push({
|
||||||
|
label: isToday ? '今天' : weekdays[d.getDay()],
|
||||||
|
value: total,
|
||||||
|
valueText: total > 0 ? util.formatDuration(total) : '',
|
||||||
|
highlight: isToday
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
onDayTap(e) {
|
onDayTap(e) {
|
||||||
const { year, month, day } = e.detail
|
const { year, month, day } = e.detail
|
||||||
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"usingComponents": {
|
"usingComponents": {
|
||||||
"calendar-heatmap": "/components/calendar-heatmap/calendar-heatmap",
|
"calendar-heatmap": "/components/calendar-heatmap/calendar-heatmap",
|
||||||
|
"trend-chart": "/components/trend-chart/trend-chart",
|
||||||
"ui-card": "/components/ui-card/ui-card"
|
"ui-card": "/components/ui-card/ui-card"
|
||||||
},
|
},
|
||||||
"navigationBarTitleText": "训练记录"
|
"navigationBarTitleText": "训练记录"
|
||||||
|
|||||||
@@ -44,6 +44,23 @@
|
|||||||
</view>
|
</view>
|
||||||
</ui-card>
|
</ui-card>
|
||||||
|
|
||||||
|
<!-- 训练趋势 -->
|
||||||
|
<ui-card variant="in" hidden="{{totalSessions === 0}}">
|
||||||
|
<view class="trend-section">
|
||||||
|
<view class="trend-header">
|
||||||
|
<view class="trend-title-wrap">
|
||||||
|
<image class="history-title-icon" src="{{icons.targetFill}}" mode="aspectFit"></image>
|
||||||
|
<text class="section-title">训练趋势</text>
|
||||||
|
</view>
|
||||||
|
<view class="rec-seg">
|
||||||
|
<view class="rec-seg-item {{trendMode === 'week' ? 'active' : ''}}" data-mode="week" bindtap="onToggleTrendMode">近7天</view>
|
||||||
|
<view class="rec-seg-item {{trendMode === 'month' ? 'active' : ''}}" data-mode="month" bindtap="onToggleTrendMode">近6月</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<trend-chart chart-data="{{trendData}}"></trend-chart>
|
||||||
|
</view>
|
||||||
|
</ui-card>
|
||||||
|
|
||||||
<!-- 日历热力图 -->
|
<!-- 日历热力图 -->
|
||||||
<ui-card variant="in" hidden="{{totalSessions === 0}}">
|
<ui-card variant="in" hidden="{{totalSessions === 0}}">
|
||||||
<view class="calendar-section">
|
<view class="calendar-section">
|
||||||
@@ -104,7 +121,7 @@
|
|||||||
<text class="history-plan">{{item.day === 0 ? '自由训练' : '第 ' + item.day + ' 天'}}</text>
|
<text class="history-plan">{{item.day === 0 ? '自由训练' : '第 ' + item.day + ' 天'}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="history-right">
|
<view class="history-right">
|
||||||
<text class="history-duration">{{item.duration}}s</text>
|
<text class="history-duration">{{item.durationText}}</text>
|
||||||
<view class="history-delete-btn" catch:tap="onDeleteRecord" data-id="{{item.id}}">
|
<view class="history-delete-btn" catch:tap="onDeleteRecord" data-id="{{item.id}}">
|
||||||
<image class="history-delete-icon" src="{{icons.deleteActive}}" mode="aspectFit"></image>
|
<image class="history-delete-icon" src="{{icons.deleteActive}}" mode="aspectFit"></image>
|
||||||
<text class="history-delete-text">删除</text>
|
<text class="history-delete-text">删除</text>
|
||||||
|
|||||||
@@ -79,6 +79,28 @@
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- trend ---- */
|
||||||
|
.trend-section {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-title-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-header .section-title {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- history ---- */
|
/* ---- history ---- */
|
||||||
.history-section {
|
.history-section {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user