diff --git a/components/trend-chart/trend-chart.js b/components/trend-chart/trend-chart.js new file mode 100644 index 0000000..afb4556 --- /dev/null +++ b/components/trend-chart/trend-chart.js @@ -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 }) + } + } +}) diff --git a/components/trend-chart/trend-chart.json b/components/trend-chart/trend-chart.json new file mode 100644 index 0000000..a89ef4d --- /dev/null +++ b/components/trend-chart/trend-chart.json @@ -0,0 +1,4 @@ +{ + "component": true, + "usingComponents": {} +} diff --git a/components/trend-chart/trend-chart.wxml b/components/trend-chart/trend-chart.wxml new file mode 100644 index 0000000..c4ca74b --- /dev/null +++ b/components/trend-chart/trend-chart.wxml @@ -0,0 +1,12 @@ + + + + + {{item.valueText}} + + + + + {{item.label}} + + diff --git a/components/trend-chart/trend-chart.wxss b/components/trend-chart/trend-chart.wxss new file mode 100644 index 0000000..2ac4f91 --- /dev/null +++ b/components/trend-chart/trend-chart.wxss @@ -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; +} diff --git a/pages/records/records.js b/pages/records/records.js index 5c9691b..3b1b6aa 100644 --- a/pages/records/records.js +++ b/pages/records/records.js @@ -19,6 +19,8 @@ Page({ monthRecords: [], historyList: [], listMode: 'month', + trendMode: 'week', + trendData: [], showDayDetail: false, dayDetail: {}, icons: iconsMod.build() @@ -52,7 +54,7 @@ Page({ storage.validateStreak() const stats = storage.getTotalStats() 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, // walk months in reverse chrono order and concat — O(months*30) instead of O(n log n). @@ -61,7 +63,7 @@ Page({ const historyList = [] for (const mk of monthKeys) { 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 @@ -80,6 +82,7 @@ Page({ historyList, listMode: this.data.listMode || 'month', displayList: (this.data.listMode || 'month') === 'month' ? records : historyList, + trendData: this._computeTrend(this.data.trendMode || 'week'), loading: false }) }, @@ -115,6 +118,61 @@ Page({ 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) { const { year, month, day } = e.detail const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` diff --git a/pages/records/records.json b/pages/records/records.json index f938af9..9355eea 100644 --- a/pages/records/records.json +++ b/pages/records/records.json @@ -1,6 +1,7 @@ { "usingComponents": { "calendar-heatmap": "/components/calendar-heatmap/calendar-heatmap", + "trend-chart": "/components/trend-chart/trend-chart", "ui-card": "/components/ui-card/ui-card" }, "navigationBarTitleText": "训练记录" diff --git a/pages/records/records.wxml b/pages/records/records.wxml index 0d2e0e7..a3252fc 100644 --- a/pages/records/records.wxml +++ b/pages/records/records.wxml @@ -44,6 +44,23 @@ + + +