ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

健身重量进度计算器开发实战:1RM估算与渐进超负荷可视化

健身重量进度计算器开发实战:1RM估算与渐进超负荷可视化 在实际的训练场景里很多人记录了每次卧推、深蹲用了多少重量、做了多少次却很难说清自己的训练到底有没有进步。只看杠铃片重量并不等于训练强度因为 60kg 做 5 次和 40kg 做 15 次对力量的评估完全不同。gym weight progress calculator这个项目要解决的就是这类问题它根据输入的动作、重量、次数和组数自动计算杠铃配重组合估算出对应的 1RM并且把历史记录用折线图展示出来让渐进超负荷的变化趋势变得可见。这篇文章会带你从零实现一个可运行的健身重量进度计算器。最终可以得到一个浏览器中直接使用的单页工具能完成三类关键任务一是把目标总重量拆成杠铃两侧的配重片组合二是用经验公式估算 1RM三是把每次训练记录持久化到本地并按日期绘制趋势图。适合正在学习前端工程化、想做一个小而完整的实战项目或者希望给自己训练加一个数据工具的开发者阅读。1. 先理解一个健身计算器真正要算的是什么不少初学开发者拿到这个题目第一反应是“做一个加减乘除表单”。但实际动手后会发现真正的难点不在界面而在三个需要想清楚的业务规则杠铃配重如何拆片、1RM 如何估算、历史记录如何组织。这三个规则对应着工具的核心价值也是本文实现的主线。1.1 核心问题一怎么把目标重量拆成杠铃片组合真实健身房里杠铃片通常有 1.25kg、2.5kg、5kg、10kg、15kg、20kg、25kg 等规格。训练者说“今天深蹲 60kg 做 5 次”但走到杠铃前他需要知道左右两边各放多少片。计算逻辑并不复杂总重量 空杆重量 2 × 单边配重总重量例如标准空杆为 20kg目标 60kg 时单边需要承担 20kg。如果健身房有 20kg 片单边放一片即可如果只有 15kg 和 5kg则单边是 15 5如果只有 10kg那就单边两片 10kg。实际项目中还会遇到“目标重量无法被现有片规格整除”的情况比如想用 62kg但最小片是 2.5kg单边 21kg 无法凑齐。这时工具需要明确提示用户调整目标重量。这本质上是一个“用有限面值组合出目标数值”的问题。最直接的做法是贪心算法从最大规格的片开始能放多少放多少剩余重量继续用次大规格片匹配。贪心在这里不一定总得到最少数量的解但足够贴近真实场景因为杠铃片规格本身具备倍数关系实际项目可以直接使用并保持可读性。1.2 核心问题二1RM 是什么以及为什么要估算1RMOne Repetition Maximum指一个动作能完成一次的最大重量。但普通人不会在日常训练中反复做单次极限测试因为风险高、对神经系统消耗大。所以健身领域普遍使用“次最大重量推测最大重量”的经验公式用“某个重量能完成多少次”来反推 1RM。常见公式有两个Brzycki 公式1RM 重量 × 36 ÷ (37 - 次数)Epley 公式1RM 重量 × (1 次数 ÷ 30)Brzycki 在 1 到 10 次的区间内比较常用对高次数训练的误差会增大。Epley 对偏高次数的适应性和稳定性要好一些但它在 1 次时等于输入重量2 次时是 1.0667 倍增长速度比 Brzycki 更温和。实际项目不应只保留一个公式而应在界面上允许用户切换并且在结果旁边标注使用的是哪个公式。这样用户查看自己历史记录时不会被“为什么今天和上周的 1RM 估算方法不同”迷惑。1.3 核心问题三记录不只是一行数据要能形成趋势只记录“2025-01-15 深蹲 60kg 5 次”没有太大意义因为单次数据无法回答“我是否在变强”。工具需要把多次记录串成一条曲线让用户看到重量和估算 1RM 的走向。这也是为什么数据结构里必须有日期、动作名称、重量、次数、组数和派生出来的 1RM 字段。渐进超负荷的核心是“在相同动作上让训练强度或容量随时间逐步提升”。所以可视化时至少应该按动作分别展示两条趋势原始重量的变化以及估算 1RM 的变化。后者比前者更能排除“这次做了 3 次上次做了 5 次”这种次数变化带来的干扰。2. 技术选型与项目结构要先想清楚否则越写越乱这个项目涉及计算、持久化、图表绘制和页面交互。选择技术栈时不一定要引入重型框架关键是让每个职责边界清晰。下面的方案使用原生 JavaScript ES Module、localStorage 和 Canvas 绘图不依赖构建工具也能在本地静态服务器上直接运行。2.1 为什么用原生 JavaScript 而不是 React 或 Vue对于这种规模的工具原生 JavaScript 完全足够。核心计算是纯函数不依赖 DOM页面只有表单、配重结果区、记录列表和折线图四个区域状态量不多。引入框架反而会增加依赖安装和构建成本。这里有一个更重要的工程依据把计算逻辑写成纯函数后无论将来迁移到 Vue、React 还是小程序都可以复用。实际开发中最怕的不是功能复杂而是业务逻辑和页面渲染耦合在一起。用原生 JavaScript 先把计算模块、存储模块、图表模块分离出来本身就是一个很好的架构训练。2.2 目录结构与文件职责推荐按下面这种方式组织文件gym-progress-calculator/ ├── index.html ├── styles.css ├── src/ │ ├── main.js │ ├── calculations.js │ ├── storage.js │ └── chart.js └── test/ └── calculations.test.mjs每个文件只做一件事文件职责index.html页面结构、表单、展示区域styles.css布局和基础样式src/calculations.js配重计算、1RM 估算等纯函数src/storage.jslocalStorage 读写、数据迁移兜底src/chart.jsCanvas 折线图绘制src/main.js事件绑定、组装模块、渲染逻辑test/calculations.test.mjs计算函数的 Node 断言测试2.3 数据结构与 localStorage 存储设计localStorage 只能保存字符串所以需要把记录数组序列化成 JSON。记录对象的结构设计如下{ version: 1, records: [ { id: 20250115-153000-abc, date: 2025-01-15, movement: Squat, weight: 60, reps: 5, sets: 3, oneRepMax: 67.5, note: 今天状态正常 } ] }存储 key 建议固定为gym-progress-records-v1。带上版本号的原因很简单将来如果字段结构变化比如增加 RPE 或单位字段旧数据仍然可以识别并做迁移而不是直接解析失败。日期字段统一使用YYYY-MM-DD格式理由有两个。第一它是 ISO 8601 的子集字符串排序结果等价于日期排序第二在图表绘制时可以直接按字符串排序不需要额外解析时间戳。需要注意表单输入框建议直接使用input typedate避免用户在不同浏览器里输入不同格式。3. 实现核心计算模块先让数字正确计算模块是整个工具的地基。配重组合算错后面的 1RM 和图表都没有意义。所以这一章先实现两个核心纯函数并配套测试。3.1 杠铃配重计算贪心算法实现先定义默认配置// src/calculations.js export const BAR_WEIGHT 20; export const PLATE_OPTIONS [ { weight: 25, count: 4 }, { weight: 20, count: 4 }, { weight: 15, count: 4 }, { weight: 10, count: 4 }, { weight: 5, count: 4 }, { weight: 2.5, count: 4 }, { weight: 1.25, count: 4 } ]; function roundTo2(value) { return Math.round((value Number.EPSILON) * 100) / 100; } export function calcPlates(targetWeight, barWeight BAR_WEIGHT, plateOptions PLATE_OPTIONS) { if (!Number.isFinite(targetWeight) || targetWeight 0) { return { ok: false, reason: 目标重量必须是大于 0 的数字 }; } const perSide (targetWeight - barWeight) / 2; if (perSide 0) { return { ok: false, reason: 目标重量不能小于空杆重量 ${barWeight}kg }; } let remaining perSide; const selected []; for (const option of plateOptions) { if (option.count 0) continue; const maxUse Math.min(Math.floor(remaining / option.weight), option.count); for (let i 0; i maxUse; i) { selected.push(option.weight); } remaining roundTo2(remaining - maxUse * option.weight); if (remaining 0.001) break; } if (remaining 0.001) { return { ok: false, reason: 无法用现有配重片精确组合剩余 ${remaining}kg请调整目标重量到最小片步进 }; } const perSideTotal selected.reduce((sum, w) sum w, 0); return { ok: true, perSide: selected, perSideTotal: roundTo2(perSideTotal), total: roundTo2(barWeight 2 * perSideTotal) }; }这段代码有几个关键细节第一先计算每边需要多少重量。(targetWeight - barWeight) / 2这一步容易漏掉除以 2结果会导致配对片数量翻倍。第二从大到小遍历配重片。Math.floor(remaining / option.weight)计算整片数量Math.min限制不能超过配置中可用的片数。默认每种片有 4 片足够覆盖常见重量。第三为什么要roundTo2。JavaScript 的浮点数在计算 2.5 的倍数时会因为二进制表示产生类似0.004999999的误差。不收敛的话最后判断remaining 0.001会误判为无法组合。这里的 0.001 是容忍阈值因为所有重量精度最多到 0.01kg。第四返回结构同时包含perSide和total。前者用于界面展示“左侧20 2.5右侧20 2.5”后者用于让用户确认输入的目标重量确实被组装出来了。3.2 1RM 估算公式选择与边界处理同样放在 calculations.js 中export function estimateOneRepMax(weight, reps, formula brzycki) { if (!Number.isFinite(weight) || weight 0) { return null; } if (!Number.isInteger(reps) || reps 1 || reps 35) { return null; } if (formula epley) { return roundTo2(weight * (1 reps / 30)); } if (reps 37) { return null; } return roundTo2((weight * 36) / (37 - reps)); }这里的边界处理很重要。Brzycki 公式的分母是37 - reps当 reps 等于 37 时分母为 0超过 37 时会变成负数计算出的 1RM 数值没有意义。所以在函数入口直接限制 reps 最大为 35可以避免界面上出现 Infinity 或负值。Epley 公式没有分母风险但超过 30 次以后它对 1RM 的推演已经高度失真所以统一限制在 1 到 35 次区间。实际肌肉耐力训练很少需要使用超过 30 次的组次。如果用户确实需要记录高次数耐力训练也应该在 UI 上提示公式误差范围而不是默默输出一个看似精确的数。3.3 编写 Node 自测脚本验证计算正确性在 test 目录下创建一个不依赖测试框架的脚本// test/calculations.test.mjs import { calcPlates, estimateOneRepMax, BAR_WEIGHT } from ../src/calculations.js; let passed 0; let failed 0; function assertEqual(actual, expected, message) { if (actual expected) { passed; } else { failed; console.error(FAIL: ${message}); console.error( expected: ${expected}); console.error( actual: ${actual}); } } function assertApprox(actual, expected, gap, message) { if (Math.abs(actual - expected) gap) { passed; } else { failed; console.error(FAIL: ${message}); console.error( expected ~${expected}, actual ${actual}); } } // 配重测试 const r1 calcPlates(60); assertEqual(r1.ok, true, 60kg 应该可以配重); assertEqual(r1.total, 60, 60kg 配重总重量应为 60kg); assertEqual(r1.perSide.includes(20), true, 单边应包含 20kg 片); const r2 calcPlates(62.5); assertEqual(r2.ok, true, 62.5kg 应该可以配重); assertEqual(r2.total, 62.5, 62.5kg 配重总重量应为 62.5kg); const r3 calcPlates(19); assertEqual(r3.ok, false, 小于空杆重量时应返回失败); const r4 calcPlates(73.3); assertEqual(r4.ok, false, 73.3kg 无法用 1.25 步进精确组合); // 1RM 测试 assertApprox(estimateOneRepMax(60, 5), 67.5, 0.01, Brzycki: 60kg 5次约 67.5kg); assertApprox(estimateOneRepMax(60, 5, epley), 70, 0.01, Epley: 60kg 5次约 70kg); assertEqual(estimateOneRepMax(60, 0), null, 0 次应返回 null); assertEqual(estimateOneRepMax(60, 40), null, 40 次应返回 null); console.log(通过 ${passed} 项失败 ${failed} 项); if (failed 0) process.exit(1);运行命令node test/calculations.test.mjs预期输出通过 11 项失败 0 项这里要注意estimateOneRepMax(60, 5)用 Brzycki 计算是60 * 36 / 32 67.5用 Epley 计算是60 * 1.1667 70。两者差异不小恰好说明为什么界面必须标注公式来源。测试脚本的价值在于以后如果有人修改公式或配重逻辑可以立刻发现数值回归。4. 完成页面交互与记录持久化计算模块就绪后接下来做 HTML 表单、存储模块和主逻辑组装。这一章会给出一个可运行的完整页面骨架。4.1 表单设计与数据校验!-- index.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title健身重量进度计算器/title link relstylesheet hrefstyles.css / /head body main classcontainer h1健身重量进度计算器/h1 section classrecord-form h2添加训练记录/h2 form idtraining-form div classform-row label formovement动作名称/label input typetext idmovement namemovement required placeholder例如 Squat / /div div classform-row label fordate日期/label input typedate iddate namedate required / /div div classform-row label forweight总重量kg/label input typenumber idweight nameweight required min1 step0.5 placeholder60 / /div div classform-row label forreps次数/label input typenumber idreps namereps required min1 max35 step1 placeholder5 / /div div classform-row label forsets组数/label input typenumber idsets namesets required min1 max20 step1 value3 / /div div classform-row label forformula1RM 估算公式/label select idformula nameformula option valuebrzyckiBrzycki/option option valueepleyEpley/option /select /div div classform-row label fornote备注可选/label input typetext idnote namenote placeholder状态、辅助重量等 / /div button typesubmit计算并保存/button /form /section section classresult-area h2当前结果/h2 div idplate-result/div div idone-rm-result/div /section section classchart-area h21RM 趋势/h2 canvas idprogress-chart width800 height300/canvas /section section classrecord-list h2历史记录/h2 button idexport-btn typebutton导出 JSON/button ul idrecord-list/ul /section /main script typemodule srcsrc/main.js/script /body /html表单里有两个容易被忽视的设计。第一重量输入框step0.5允许半公斤输入但实际配重最小步进可能受杠铃片限制。表单层放宽输入范围计算层负责校验这种“入口宽松、核心严格”的思路适合工具类项目。如果用户在输入框里限制得太死比如必须输入 2.5 的倍数会阻止用户先输入一个计划重量再查看误差提示体验反而差。第二公式选择使用下拉框而不是同时显示两个数值。这样设计是为了让界面保持简洁。如果用户想看两种公式的对比可以在当前结果区之外再加一个展开面板。真实训练记录里一旦用户选好公式后续应该保持一致否则 1RM 趋势会因公式切换产生不必要的波动。4.2 存储模块带版本保护和异常兜底// src/storage.js const STORAGE_KEY gym-progress-records-v1; export function loadRecords() { try { const raw localStorage.getItem(STORAGE_KEY); if (!raw) return []; const parsed JSON.parse(raw); if (!parsed || !Array.isArray(parsed.records)) { return []; } return parsed.records.filter(validateRecord); } catch (error) { console.error(加载本地记录失败:, error); return []; } } export function saveRecords(records) { try { const payload JSON.stringify({ version: 1, records }); localStorage.setItem(STORAGE_KEY, payload); return true; } catch (error) { console.error(保存本地记录失败:, error); return false; } } export function buildRecord({ date, movement, weight, reps, sets, oneRepMax, note }) { return { id: ${date}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}, date, movement: movement.trim(), weight: Number(weight), reps: Number(reps), sets: Number(sets), oneRepMax: Number(oneRepMax), note: note ? note.trim() : }; } function validateRecord(record) { return ( record typeof record.date string typeof record.movement string Number.isFinite(record.weight) Number.isFinite(record.reps) Number.isFinite(record.oneRepMax) ); }这段代码把持久化细节封装在 storage.js 中页面主逻辑不需要关心 key 名称和 JSON 结构。try/catch是必须的因为 localStorage 在不同场景下可能抛异常例如 Safari 隐私模式或存储空间已满。加载失败时返回空数组而不是让整个页面崩溃。id 的生成方式虽然简单但在单机场景足够唯一。如果后续要同步到后端需要替换成服务端生成的 UUID。4.3 主逻辑提交表单、计算、保存、渲染// src/main.js import { calcPlates, estimateOneRepMax } from ./calculations.js; import { loadRecords, saveRecords, buildRecord } from ./storage.js; import { drawProgressChart } from ./chart.js; const form document.getElementById(training-form); const plateResult document.getElementById(plate-result); const oneRmResult document.getElementById(one-rm-result); const recordList document.getElementById(record-list); const exportBtn document.getElementById(export-btn); const chartCanvas document.getElementById(progress-chart); let records loadRecords(); function formatDate(dateStr) { return dateStr; } function renderRecords() { if (records.length 0) { recordList.innerHTML li暂无记录/li; return; } recordList.innerHTML ; const sorted [...records].sort((a, b) b.date.localeCompare(a.date)); for (const record of sorted) { const li document.createElement(li); li.innerHTML [ strong${formatDate(record.date)}/strong, ${record.movement}, ${record.weight}kg × ${record.reps} 次 × ${record.sets} 组, 1RM≈${record.oneRepMax}kg ].join( · ); recordList.appendChild(li); } } function renderCurrentResult(formData) { const { movement, weight, reps } formData; const plate calcPlates(weight); if (plate.ok) { plateResult.textContent 配重方案单边 ${plate.perSide.join( )}总重量 ${plate.total}kg; } else { plateResult.textContent 配重提示${plate.reason}; } const oneRepMax estimateOneRepMax(weight, reps, formData.formula); if (oneRepMax) { oneRmResult.textContent ${movement} 的估算 1RM 为 ${oneRepMax}kg${formData.formula} 公式; } else { oneRmResult.textContent 1RM 估算失败请检查重量和次数范围; } } form.addEventListener(submit, (event) { event.preventDefault(); const formData new FormData(form); const movement formData.get(movement).trim(); const date formData.get(date); const weight Number(formData.get(weight)); const reps Number(formData.get(reps)); const sets Number(formData.get(sets)); const formula formData.get(formula); const note formData.get(note); if (!movement || !date || !Number.isFinite(weight) || !Number.isFinite(reps)) { alert(请填写动作、日期、重量和次数); return; } const oneRepMax estimateOneRepMax(weight, reps, formula); renderCurrentResult({ movement, weight, reps, formula }); if (!oneRepMax) { return; } const record buildRecord({ date, movement, weight, reps, sets, oneRepMax, note }); records.push(record); saveRecords(records); renderRecords(); drawProgressChart(chartCanvas, records); form.reset(); }); exportBtn.addEventListener(click, () { const blob new Blob([JSON.stringify({ version: 1, records }, null, 2)], { type: application/json }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download gym-progress-backup.json; a.click(); URL.revokeObjectURL(url); }); records.sort((a, b) a.date.localeCompare(b.date)); renderRecords(); drawProgressChart(chartCanvas, records);注意这里的排序方向差异。历史列表希望最新记录在最上面所以使用b.date.localeCompare(a.date)。绘制图表时希望时间从左到右递增所以主流程里先按日期升序排序再把排序后的 records 传给图表模块。列表和图表使用不同的排序方向是合理的但代码里要写清楚避免复制粘贴时弄反。4.4 表单重置和日期默认值form.reset()会把表单恢复成 HTML 中的默认值。这一步结束后日期输入框会清空对用户不够友好。建议在页面加载时把日期设置为今天document.getElementById(date).value new Date().toISOString().slice(0, 10);需要注意时区问题。toISOString()返回的是 UTC 时间在 UTC8 时区下如果本地时间是凌晨日期可能会比本地日期早一天。对工具类项目可以接受但如果要严格按本地日期可以手动拼接function getTodayString() { const now new Date(); const offset now.getTimezoneOffset() * 60000; return new Date(now.getTime() - offset).toISOString().slice(0, 10); }5. 用 Canvas 绘制 1RM 趋势图图表是这个工具最有说服力的部分。它能直接回答“我的训练重量是否在上升”这个问题。这里选择 Canvas 而不是 SVG 或第三方图表库是为了减少依赖同时也可以展示原生绘图的基本思路。5.1 图表绘制的基本思路折线图的本质是“数据点坐标映射”。真实数据有日期和 1RM 两个维度画布上每个点需要换算成 x 和 y// src/chart.js export function drawProgressChart(canvas, records) { const ctx canvas.getContext(2d); const width canvas.width; const height canvas.height; const padding { top: 20, right: 20, bottom: 40, left: 50 }; ctx.clearRect(0, 0, width, height); const sorted [...records].sort((a, b) a.date.localeCompare(b.date)); if (sorted.length 0) { ctx.fillStyle #666; ctx.fillText(暂无数据, width / 2 - 20, height / 2); return; } if (sorted.length 1) { ctx.fillStyle #666; ctx.fillText(至少需要两条记录才能绘制趋势图, width / 2 - 100, height / 2); return; } const values sorted.map((r) r.oneRepMax); const minValue Math.floor(Math.min(...values) * 0.9); const maxValue Math.ceil(Math.max(...values) * 1.1); const plotWidth width - padding.left - padding.right; const plotHeight height - padding.top - padding.bottom; const xStep plotWidth / (sorted.length - 1); const getX (index) padding.left index * xStep; const getY (value) padding.top ((maxValue - value) / (maxValue - minValue)) * plotHeight; // 绘制坐标轴 ctx.strokeStyle #ccc; ctx.lineWidth 1; ctx.beginPath(); ctx.moveTo(padding.left, padding.top); ctx.lineTo(padding.left, height - padding.bottom); ctx.lineTo(width - padding.right, height - padding.bottom); ctx.stroke(); // 绘制网格线和 Y 轴刻度 ctx.fillStyle #666; ctx.font 12px sans-serif; const yTicks 4; for (let i 0; i yTicks; i) { const ratio i / yTicks; const tickValue maxValue - ratio * (maxValue - minValue); const y padding.top ratio * plotHeight; ctx.beginPath(); ctx.moveTo(padding.left - 4, y); ctx.lineTo(padding.left, y); ctx.stroke(); ctx.fillText(Math.round(tickValue), 10, y 4); } // 绘制折线 ctx.strokeStyle #2d7dd2; ctx.lineWidth 2; ctx.beginPath(); sorted.forEach((record, index) { const x getX(index); const y getY(record.oneRepMax); if (index 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } }); ctx.stroke(); // 绘制数据点 ctx.fillStyle #2d7dd2; sorted.forEach((record, index) { const x getX(index); const y getY(record.oneRepMax); ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fill(); }); }图表中的 min 和 max 不是直接用原始最大值和最小值而是各扩展 10%。这样折线不会贴到画布边缘视觉上更舒服。如果所有记录的 1RM 都是 60kgminValue 会是 54maxValue 是 66图表仍然可以展示一个水平的线。5.2 区分动作名称过滤上面代码把所有记录画在一个图里。如果用户同时记录深蹲、卧推、硬拉会导致三条不同量级的数据混在一起趋势图失去意义。一个简单的改进是在 main.js 中获取当前选中的动作名称按名称过滤图表数据。// 可以结合一个动作筛选下拉框 const selectedMovement document.getElementById(movement-filter).value; const filtered selectedMovement ? records.filter((r) r.movement selectedMovement) : records; drawProgressChart(chartCanvas, filtered);实际产品里动作筛选通常做成下拉框选项来自历史记录中出现的动作名称。这样用户每切换一个动作看到的都是独立的重量变化曲线。5.3 Canvas 在高 DPI 屏幕下的清晰度问题默认 Canvas 在高分屏上会显得模糊因为 Canvas 的 CSS 尺寸和实际像素尺寸不一致。简单处理方式是读取设备像素比然后缩放画布function setupCanvas(canvas) { const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); const width rect.width; const height rect.height; canvas.width width * dpr; canvas.height height * dpr; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); return { width, height }; }注意这个函数会修改 canvas 的真实尺寸绘制逻辑里应该使用返回的 CSS 逻辑尺寸而不是canvas.width。这一个细节在移动端很常见不做处理时图表边缘会明显发虚。6. 运行验证与常见问题排查代码写完后需要验证的不只是“页面能不能打开”还要验证计算是否正确、数据是否持久化、图表是否按预期渲染。这一章给出具体的验证步骤和常见问题排查路径。6.1 本地运行方式因为使用了 ES Module不能直接双击 index.html 打开必须通过 HTTP 服务访问。推荐使用 Node 自带的静态服务或 VS Code 的 Live Server。最简单的做法npx serve .或者用 Pythonpython3 -m http.server 8000浏览器访问http://localhost:8000打开控制台确认没有报错然后输入一次 60kg、5 次、Brzycki 公式预期看到配重方案单边 201RM 约 67.5kg。6.2 手工验证用例推荐用下面几个用例核对计算逻辑目标重量预期单边配重说明20kg空杆不配重目标等于空杆重量40kg10kg 一片单边 10kg60kg20kg 一片单边 20kg62.5kg20kg 1.25kg单边 21.25kg65kg20kg 2.5kg单边 22.5kg100kg25kg 15kg优先大规格单边 40kg验证时注意贪心算法会选择“尽量用大规格片”的组合。因此 100kg 会输出25 15而不是20 10 10。从减少装卸片次数的角度看这是更贴近实际的选择。6.3 检查 localStorage 中的数据打开浏览器 DevTools进入 Application 面板在 Local Storage 下找到当前域名查看gym-progress-records-v1的值。预期是包含 records 数组的 JSON 字符串。如果看不到数据说明 saveRecords 执行失败或者页面运行在隐私模式下。6.4 针对常见现象按这条链路排查现象可能原因检查方式处理建议1RM 显示 NaN次数超出公式范围或输入非法在 DevTools 中检查 weight、reps 是否为有效数字表单已限制 1 到 35代码里再加一次兜底校验配重提示“无法组合”目标重量不是最小片步进的倍数打印 remaining 值检查 1.25kg 片是否被包含在配置中提示用户调整到 2.5 的倍数刷新后记录消失localStorage 未写成功或读取失败Application 面板查看 key 是否存在检查 saveRecords 的 try/catch排除隐私模式折线图连线乱数据没有按日期排序在 main.js 中打印 sorted 数组确保绘制前先按 date.localeCompare 升序排序高 DPI 下图表模糊未处理 devicePixelRatio用 setupCanvas 缩放画布使用逻辑尺寸绘制调用 ctx.scale(dpr, dpr)同一动作被画成多序列没有按动作过滤检查图表数据源是否过滤了 movement增加动作筛选下拉框6.5 数据导入导出的价值localStorage 本质上是浏览器域下的本地存储用户清除浏览器数据、换电脑、换浏览器都会丢失记录。这也解释了为什么工具需要提供 JSON 导出按钮。更进一步可以做一个导入按钮让用户把导出的 JSON 文件再次读回来。注意导入时需要校验 JSON 结构而不是直接records parsed.records。7. 生产环境扩展方向与工程实践建议这个工具如果只在本地跑localStorage 已经够用。但如果想做成一个长期使用的训练数据工具或者发布给其他健身者使用还需要在工程上补齐几个关键能力。能力本地工具状态生产环境建议数据存储localStorage后端数据库 账号体系或至少接入 IndexedDB 支持更大数据量数据同步无多设备同步、冲突处理策略单位制式只有 kg增加 kg / lb 切换存储时统一为 kg 或保留单位字段多用户无认证、权限、个人数据隔离错误监控console日志上报、异常监控、版本灰度数据迁移固定 key 带版本设计 migration 函数按 version 逐级升级离线支持依赖静态服务器增加 Service Worker做成 PWA7.1 从 localStorage 迁移到后端时的数据模型如果要把记录同步到服务器数据结构不宜直接沿用当前版本。建议服务端按用户维度拆分{ userId: user-uuid, movement: Squat, records: [ { date: 2025-01-15, weightKg: 60, reps: 5, sets: 3, oneRmKg: 67.5, rpe: 8, note: } ] }数据库表设计时最好把动作、记录分成两张表。动作表保存动作名称和用户 ID记录表保存日期、重量、次数、组数和关联动作 ID。一个动作对应多条训练记录这是典型的一对多关系。查询某个动作的趋势时使用WHERE movement_id ? ORDER BY date ASC。7.2 加入 RPE 反馈1RM 公式只依赖重量和次数但训练者当天状态差异很大。比如同样 60kg 做 5 次状态好时感觉留有余力状态差时接近力竭两者的真实 1RM 可能不同。更精细的做法是让用户填写 RPE自觉疲劳程度通常 1 到 10 或 6 到 10 范围内。有了 RPE可以用 e1RMestimated One Rep Max公式计算e1RM 重量 × (1 (0.0333 × 实际重复次数)) × (10 - RPE 对应的次数换算)实际生产中RPE 的引入会让数据模型更复杂但对长期跟踪力量变化有显著帮助。推荐作为第二期功能。7.3 给初学者最好的三个练习建议第一把计算模块用其他语言重写。比如用 Python 实现同样的calcPlates和estimateOneRepMax体会浮点精度在不同语言中的差异。第二给图表模块增加“动作筛选下拉框”并测试三个不同动作的曲线是否正确分离。第三自己实现一个轻量 JSON 导入功能格式不限重点是理解“解析外部数据时必须做结构校验”这个原则。如果只记住一个工程判断就是计算逻辑和页面渲染分离。纯函数放在独立模块里用命令能测试用浏览器能复用将来无论 UI 怎么换核心逻辑都能稳如磐石。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表