feat: 分类浏览体验优化

- 后端 CategoryItem 新增 sample 字段,每个器械/部位分类附首条动作代表图(720 JPG 海报),前端网格无需点进详情即可预览
- 首页『按器械浏览』『按部位浏览』网格 tile 改为图文卡片,展示分类代表图
- filter-bar 内联改为一行可横滑的分类 chips + 末尾『全部类型』入口;点开面板展开全部,选中项自动置顶
This commit is contained in:
2026-07-26 12:45:28 +08:00
parent 8d584129ec
commit b6e2093c82
8 changed files with 294 additions and 10 deletions

View File

@@ -80,6 +80,8 @@ export interface CategoryItem {
/** 仅 target 分类会附带其所属部位,便于前端按部位分组 */
bodyPart?: string;
bodyPartLabel?: string;
/** 该分类下首条动作的代表图JPG 海报),供前端网格预览,无需点进详情即可辨认分类内容 */
sample?: string | null;
}
/** 推荐结果项(带相关性评分与推荐理由) */

View File

@@ -116,12 +116,31 @@ export class ExercisesService implements OnModuleInit {
.sort((a, b) => b.count - a.count);
}
/** 每个分类取首条动作的代表图JPG 海报),供前端网格预览 */
private sampleBy(getKey: (e: Exercise) => string): Map<string, string> {
const map = new Map<string, string>();
for (const e of this.exercises) {
const k = getKey(e);
if (k && !map.has(k) && e.image) map.set(k, e.image);
}
return map;
}
private withSample(
items: CategoryItem[],
samples: Map<string, string>,
): CategoryItem[] {
return items.map((it) => ({ ...it, sample: samples.get(it.value) || null }));
}
getBodyPartCategories(): CategoryItem[] {
return this.toCategory(this.countBy((e) => e.bodyPart), require('./data/labels').BODY_PART_LABELS);
const items = this.toCategory(this.countBy((e) => e.bodyPart), require('./data/labels').BODY_PART_LABELS);
return this.withSample(items, this.sampleBy((e) => e.bodyPart));
}
getEquipmentCategories(): CategoryItem[] {
return this.toCategory(this.countBy((e) => e.equipment), require('./data/labels').EQUIPMENT_LABELS);
const items = this.toCategory(this.countBy((e) => e.equipment), require('./data/labels').EQUIPMENT_LABELS);
return this.withSample(items, this.sampleBy((e) => e.equipment));
}
getTargetCategories(): CategoryItem[] {