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:
74
backend/scripts/analyze.mjs
Normal file
74
backend/scripts/analyze.mjs
Normal 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(', ') || '(无)');
|
||||
20
backend/scripts/copy-data.mjs
Normal file
20
backend/scripts/copy-data.mjs
Normal 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);
|
||||
33
backend/scripts/ensure-data.mjs
Normal file
33
backend/scripts/ensure-data.mjs
Normal 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);
|
||||
});
|
||||
74
backend/scripts/sync-media.mjs
Normal file
74
backend/scripts/sync-media.mjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user