feat: init fitness-coach (backend + miniprogram)

- backend: NestJS service with exercise/plan data
- miniprogram: WeChat mini program client
- exclude node_modules, dist, runtime data-store, local env
This commit is contained in:
2026-07-22 00:35:40 +08:00
commit b3b58e66fb
103 changed files with 154759 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
/**
* 数据分析脚本:扫描 exercises.json输出各维度的去重取值与数量
* 并报告哪些取值尚未被中文标签映射覆盖(便于补全 labels.ts
*
* 用法: node scripts/analyze.mjs [path-to-exercises.json]
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA = process.argv[2] || path.join(__dirname, '..', 'src', 'exercises', 'data', 'exercises.json');
// 复用 TS 标签映射(用 require 不行,这里用一份 JS 镜像做缺失检测)
const BODY_PART_LABELS = {
back: '背部', cardio: '有氧心肺', chest: '胸部', 'lower arms': '前臂',
'lower legs': '小腿', neck: '颈部', shoulders: '肩部', 'upper arms': '上臂',
'upper legs': '大腿', waist: '腰腹',
};
const EQUIPMENT_LABELS = {
'body weight': '自重', dumbbell: '哑铃', barbell: '杠铃', kettlebell: '壶铃', cable: '钢索',
machine: '固定器械', band: '弹力带', 'exercise ball': '健身球', 'medicine ball': '药球',
'ez bar': 'EZ曲杆', 'foam roll': '泡沫轴', treadmill: '跑步机', 'stationary bike': '固定单车',
rope: '战绳', tire: '轮胎', skier: '滑雪训练器', 'sled machine': '雪橇机', roller: '滚轮',
'elliptical machine': '椭圆机', 'olympic barbell': '奥杆', 'decline bench': '下斜凳',
'flat bench': '平板凳', 'pull-up bar': '单杠', 'stability ball': '瑞士球', trx: 'TRX悬挂带',
'assistance machine': '辅助器械', 'bosu ball': 'BOSU半球', 'wheel roller': '健腹轮',
none: '无器械', other: '其他',
};
const MUSCLE_LABELS = {
biceps: '肱二头肌', triceps: '肱三头肌', forearms: '前臂', abs: '腹肌', abdominals: '腹肌',
obliques: '腹斜肌', pectorals: '胸大肌', chest: '胸部', quadriceps: '股四头肌',
hamstrings: '腘绳肌', glutes: '臀大肌', calves: '小腿', traps: '斜方肌', 'upper traps': '上斜方肌',
lats: '背阔肌', 'lower back': '下背', delts: '三角肌', shoulders: '肩部', hips: '髋部',
adductors: '内收肌', neck: '颈部', 'serratus anterior': '前锯肌', 'spinal erectors': '竖脊肌',
'hip flexors': '髋屈肌', 'middle back': '中背',
};
function tally(arr, keyFn) {
const m = new Map();
for (const x of arr) {
const k = keyFn(x);
if (!k) continue;
if (Array.isArray(k)) k.forEach((v) => m.set(v, (m.get(v) || 0) + 1));
else m.set(k, (m.get(k) || 0) + 1);
}
return m;
}
function report(name, map, labels) {
const missing = [];
const rows = Array.from(map.entries()).sort((a, b) => b[1] - a[1]);
console.log(`\n=== ${name} (${rows.length} 种) ===`);
for (const [v, c] of rows) {
const has = labels[v] ? '' : ' ⚠ 缺标签';
if (!labels[v]) missing.push(v);
console.log(` ${v.padEnd(22)} ${String(c).padStart(5)} ${labels[v] || ''}${has}`);
}
return missing;
}
const raw = JSON.parse(fs.readFileSync(DATA, 'utf-8'));
console.log(`总动作数: ${raw.length}`);
const eqMiss = report('EQUIPMENT', tally(raw, (e) => e.equipment), EQUIPMENT_LABELS);
const tMiss = report('TARGET', tally(raw, (e) => e.target), MUSCLE_LABELS);
const mgMiss = report('MUSCLE_GROUP', tally(raw, (e) => e.muscle_group), MUSCLE_LABELS);
const smMiss = report('SECONDARY_MUSCLES', tally(raw, (e) => e.secondary_muscles), MUSCLE_LABELS);
console.log('\n=== 缺失中文标签汇总(需补全 labels.ts===');
console.log('equipment :', eqMiss.join(', ') || '(无)');
console.log('target :', tMiss.join(', ') || '(无)');
console.log('muscle_group:', mgMiss.join(', ') || '(无)');
console.log('secondary :', smMiss.join(', ') || '(无)');

View File

@@ -0,0 +1,20 @@
// 将原始数据集 exercises.json 从 src 复制到编译产物 dist
// 因为 tsc 不会拷贝非 .ts 资源文件。
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const src = path.join(root, 'src', 'exercises', 'data', 'exercises.json');
const destDir = path.join(root, 'dist', 'exercises', 'data');
const dest = path.join(destDir, 'exercises.json');
if (!fs.existsSync(src)) {
console.warn('[copy-data] 源数据集不存在,跳过:', src);
process.exit(0);
}
fs.mkdirSync(destDir, { recursive: true });
fs.copyFileSync(src, dest);
console.log('[copy-data] 已复制数据集 ->', dest);

View File

@@ -0,0 +1,33 @@
/**
* prebuild 脚本:确保后端数据集存在。
* 若 backend/src/exercises/data/exercises.json 不存在,则从 GitHub 原始数据集下载。
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_FILE = path.join(__dirname, '..', 'src', 'exercises', 'data', 'exercises.json');
const SOURCE =
'https://raw.githubusercontent.com/hasaneyldrm/exercises-dataset/main/data/exercises.json';
async function main() {
if (fs.existsSync(DATA_FILE)) {
console.log('[ensure-data] 数据集已存在,跳过下载。');
return;
}
console.log('[ensure-data] 未找到数据集,开始从 GitHub 下载 ...');
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
const res = await fetch(SOURCE);
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(DATA_FILE, buf);
console.log(`[ensure-data] 已保存数据集 (${(buf.length / 1024 / 1024).toFixed(1)} MB)`);
}
main().catch((e) => {
console.error('[ensure-data] 错误:', e.message);
console.error('请手动将 exercises-dataset 的 data/exercises.json 放入 backend/src/exercises/data/');
process.exit(1);
});

View File

@@ -0,0 +1,74 @@
/**
* 媒体同步脚本:将 exercises-dataset 的 images/ 与 videos/ 下载到 backend/media/
* 使后端 /media 静态服务可直接提供缩略图与动画 GIF。
*
* 用法: node scripts/sync-media.mjs
* 可选环境变量:
* MEDIA_CONCURRENCY=8 并发下载数
* MEDIA_ONLY=gif|image 仅下载某类gif / image
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_FILE = path.join(__dirname, '..', 'src', 'exercises', 'data', 'exercises.json');
const MEDIA_DIR = path.join(__dirname, '..', 'media');
const RAW_BASE = 'https://raw.githubusercontent.com/hasaneyldrm/exercises-dataset/main/';
const CONCURRENCY = parseInt(process.env.MEDIA_CONCURRENCY || '8', 10);
const ONLY = process.env.MEDIA_ONLY || 'all';
async function main() {
if (!fs.existsSync(DATA_FILE)) {
console.error('[sync-media] 未找到数据集,请先运行 npm run build 或手动放置 exercises.json');
process.exit(1);
}
const raw = JSON.parse(fs.readFileSync(DATA_FILE, 'utf-8'));
const paths = new Set();
for (const e of raw) {
if (e.image && (ONLY === 'all' || ONLY === 'image')) paths.add(e.image);
if (e.gif_url && (ONLY === 'all' || ONLY === 'gif')) paths.add(e.gif_url);
}
fs.mkdirSync(MEDIA_DIR, { recursive: true });
const list = Array.from(paths);
console.log(`[sync-media] 待下载媒体 ${list.length} 个,并发 ${CONCURRENCY}`);
let done = 0;
let skipped = 0;
let failed = 0;
const queue = [...list];
async function worker() {
while (queue.length) {
const p = queue.shift();
const dest = path.join(MEDIA_DIR, p);
if (fs.existsSync(dest) && fs.statSync(dest).size > 0) {
skipped++;
continue;
}
try {
const res = await fetch(RAW_BASE + p);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, buf);
done++;
} catch (err) {
failed++;
console.error(` 失败 ${p}: ${err.message}`);
}
if ((done + skipped + failed) % 50 === 0) {
console.log(` 进度 ${done + skipped + failed}/${list.length}`);
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
console.log(`[sync-media] 完成:新增 ${done},已存在 ${skipped},失败 ${failed}`);
}
main().catch((e) => {
console.error('[sync-media] 错误:', e.message);
process.exit(1);
});