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 @@
+
+
+
+
+
+
+
+
@@ -104,7 +121,7 @@
{{item.day === 0 ? '自由训练' : '第 ' + item.day + ' 天'}}
- {{item.duration}}s
+ {{item.durationText}}
删除
diff --git a/pages/records/records.wxss b/pages/records/records.wxss
index 6c21d09..2e56f81 100644
--- a/pages/records/records.wxss
+++ b/pages/records/records.wxss
@@ -79,6 +79,28 @@
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-section {
padding: 0;