diff --git a/backend/src/exercises/exercises.interface.ts b/backend/src/exercises/exercises.interface.ts index 6fadf56..685ba20 100644 --- a/backend/src/exercises/exercises.interface.ts +++ b/backend/src/exercises/exercises.interface.ts @@ -80,6 +80,8 @@ export interface CategoryItem { /** 仅 target 分类会附带其所属部位,便于前端按部位分组 */ bodyPart?: string; bodyPartLabel?: string; + /** 该分类下首条动作的代表图(JPG 海报),供前端网格预览,无需点进详情即可辨认分类内容 */ + sample?: string | null; } /** 推荐结果项(带相关性评分与推荐理由) */ diff --git a/backend/src/exercises/exercises.service.ts b/backend/src/exercises/exercises.service.ts index 08b716c..5c8d53e 100644 --- a/backend/src/exercises/exercises.service.ts +++ b/backend/src/exercises/exercises.service.ts @@ -116,12 +116,31 @@ export class ExercisesService implements OnModuleInit { .sort((a, b) => b.count - a.count); } + /** 每个分类取首条动作的代表图(JPG 海报),供前端网格预览 */ + private sampleBy(getKey: (e: Exercise) => string): Map { + const map = new Map(); + 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, + ): 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[] { diff --git a/miniprogram/components/filter-bar/index.js b/miniprogram/components/filter-bar/index.js new file mode 100644 index 0000000..1136897 --- /dev/null +++ b/miniprogram/components/filter-bar/index.js @@ -0,0 +1,108 @@ +// filter-bar:分类筛选器 +// 设计目标:① 选中的项永远排在最前;② 内联条不横向滚动,超出部分收进「▾」下拉面板; +// ③ 单选(主维度)/ 多选(二级维度)两种模式。 +// 父级通过 items([{value,label,active}])传入全部分类;change 事件回传选中的 value / values。 +Component({ + properties: { + items: { type: Array, value: [] }, + multiple: { type: Boolean, value: false }, + title: { type: String, value: '分类' }, + placeholder: { type: String, value: '' } + }, + + data: { + panelOpen: false, + panelItems: [], // 选中置顶后的全量列表(供面板渲染) + visibleTags: [], // 多选模式下内联展示的已选标签(上限若干) + moreCount: 0, // 多选模式下内联未展示完的已选数量 + summary: '' // 单模式下内联展示的当前选中文案 + }, + + observers: { + 'items': function (items) { + this.recompute(items || []); + } + }, + + methods: { + recompute(items) { + // 选中置顶:active 的排前面,其余保持原序 + const list = items.slice().sort((a, b) => (b.active ? 1 : 0) - (a.active ? 1 : 0)); + if (this.data.multiple) { + const sel = list.filter((i) => i.active); + const MAX = 4; + this.setData({ + panelItems: list, + visibleTags: sel.slice(0, MAX), + moreCount: Math.max(0, sel.length - MAX), + summary: '' + }); + } else { + const active = list.find((i) => i.active); + this.setData({ + panelItems: list, + summary: active ? active.label : (this.data.placeholder || '全部'), + visibleTags: [] + }); + } + }, + + togglePanel() { + this.setData({ panelOpen: !this.data.panelOpen }); + }, + + // 内联一行 chips:单选直接选中并回传;多选切换 active 并回传全量 values + onChipTap(e) { + const value = e.currentTarget.dataset.value; + if (this.data.multiple) { + const next = this.data.items.map((i) => + i.value === value ? { ...i, active: !i.active } : i + ); + this.recompute(next); + const vals = next.filter((i) => i.active).map((i) => i.value); + this.triggerEvent('change', { values: vals }); + } else { + const cur = this.data.items.find((i) => i.value === value); + if (cur && cur.active) return; // 已是当前选中,避免重复触发 + this.recompute(this.data.items.map((i) => ({ ...i, active: i.value === value }))); + this.triggerEvent('change', { value }); + } + }, + closePanel() { + this.setData({ panelOpen: false }); + }, + noop() {}, + + onPanelTap(e) { + const value = e.currentTarget.dataset.value; + if (this.data.multiple) { + // 多选:本地切换 active,回传全量已选 values,面板保持打开 + const next = this.data.items.map((i) => + i.value === value ? { ...i, active: !i.active } : i + ); + this.recompute(next); + const vals = next.filter((i) => i.active).map((i) => i.value); + this.triggerEvent('change', { values: vals }); + } else { + // 单选:直接选中并关闭面板 + this.recompute(this.data.items.map((i) => ({ ...i, active: i.value === value }))); + this.setData({ panelOpen: false }); + this.triggerEvent('change', { value }); + } + }, + + onTagRemove(e) { + const value = e.currentTarget.dataset.value; + const next = this.data.items.map((i) => + i.value === value ? { ...i, active: false } : i + ); + this.recompute(next); + const vals = next.filter((i) => i.active).map((i) => i.value); + this.triggerEvent('change', { values: vals }); + }, + + onDone() { + this.setData({ panelOpen: false }); + } + } +}); diff --git a/miniprogram/components/filter-bar/index.json b/miniprogram/components/filter-bar/index.json new file mode 100644 index 0000000..a18bb03 --- /dev/null +++ b/miniprogram/components/filter-bar/index.json @@ -0,0 +1,6 @@ +{ + "component": true, + "usingComponents": { + "fc-icon": "/components/fc-icon/index" + } +} diff --git a/miniprogram/components/filter-bar/index.wxml b/miniprogram/components/filter-bar/index.wxml new file mode 100644 index 0000000..d46e2bb --- /dev/null +++ b/miniprogram/components/filter-bar/index.wxml @@ -0,0 +1,38 @@ + + + + {{item.label}} + + 全部类型 + + + + + + + + + {{title}} + + + + + + {{item.label}} + + + + 完成 + + + diff --git a/miniprogram/components/filter-bar/index.wxss b/miniprogram/components/filter-bar/index.wxss new file mode 100644 index 0000000..bd33548 --- /dev/null +++ b/miniprogram/components/filter-bar/index.wxss @@ -0,0 +1,107 @@ +.fb { width: 100%; background: var(--surface); } + +/* —— 内联一行可横滑的分类 chips —— */ +.fb-row { + white-space: nowrap; + padding: 4rpx 4rpx 8rpx; + background: var(--surface); +} +.fb-chip { + display: inline-flex; + align-items: center; + gap: 6rpx; + padding: 14rpx 26rpx; + margin-right: 14rpx; + border-radius: var(--radius-pill); + background: var(--surface-2); + color: var(--text); + font-size: var(--fs-sm); + border: 1rpx solid var(--line); + transition: all var(--motion-fast) var(--ease); +} +.fb-chip:active { background: var(--surface-3); } +.fb-chip--active { + background: var(--accent-soft); + color: var(--accent); + border-color: var(--accent-ring); + font-weight: var(--fw-semibold); +} +.fb-chip--all { + color: var(--text-2); + border-style: dashed; +} +.fb-chip--all:active { background: var(--surface-3); } + +/* —— 下拉面板 —— */ +.fb-mask { + position: fixed; + inset: 0; + z-index: 100; + background: var(--scrim); + display: flex; + align-items: flex-end; +} +.fb-sheet { + width: 100%; + max-height: 72vh; + background: var(--surface); + border-top-left-radius: var(--radius-lg); + border-top-right-radius: var(--radius-lg); + border-top: 1rpx solid var(--border-strong); + display: flex; + flex-direction: column; + animation: fb-up var(--motion-base) var(--ease); +} +@keyframes fb-up { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} +.fb-sheet__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 28rpx 32rpx 16rpx; +} +.fb-sheet__title { + font-size: var(--fs-lg); + font-weight: var(--fw-semibold); + color: var(--text); +} +.fb-sheet__close { + font-size: 32rpx; + color: var(--text-2); + padding: 0 8rpx; +} +.fb-sheet__body { + height: 50vh; + padding: 8rpx 24rpx 24rpx; +} +.fb-grid { + display: flex; + flex-wrap: wrap; + gap: 16rpx; +} +.fb-opt { + padding: 16rpx 28rpx; + border-radius: var(--radius-pill); + background: var(--surface-2); + color: var(--text-2); + font-size: var(--fs-sm); + border: 1rpx solid var(--line); +} +.fb-opt--active { + background: var(--accent-soft); + color: var(--accent); + border-color: var(--accent-ring); + font-weight: var(--fw-semibold); +} +.fb-sheet__done { + margin: 12rpx 24rpx 28rpx; + padding: 22rpx; + text-align: center; + border-radius: var(--radius); + background: var(--accent); + color: var(--accent-on); + font-size: var(--fs-md); + font-weight: var(--fw-semibold); +} diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml index 7b68e5a..14548e4 100644 --- a/miniprogram/pages/index/index.wxml +++ b/miniprogram/pages/index/index.wxml @@ -118,6 +118,7 @@ data-value="{{item.value}}" data-label="{{item.label}}" bindtap="onScanBodyPart"> + {{item.label}} {{item.count}} 个动作 @@ -135,6 +136,7 @@ data-value="{{item.value}}" data-label="{{item.label}}" bindtap="onScanEquipment"> + {{item.label}} {{item.count}} 个动作 diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss index b3d8e94..ef0c2d2 100644 --- a/miniprogram/pages/index/index.wxss +++ b/miniprogram/pages/index/index.wxss @@ -94,21 +94,23 @@ .bp { width: calc((100% - 48rpx) / 4); background: var(--surface); border: 1rpx solid var(--line); - border-radius: var(--radius-sm); padding: 22rpx 12rpx; - display: flex; flex-direction: column; align-items: center; + border-radius: var(--radius-sm); overflow: hidden; + display: flex; flex-direction: column; align-items: stretch; } -.bp__label { font-size: 26rpx; color: var(--text); font-weight: 600; } -.bp__count { font-size: 20rpx; color: var(--text-3); margin-top: 6rpx; } +.bp__img { width: 100%; height: 132rpx; background: var(--surface-2); display: block; } +.bp__label { font-size: 24rpx; color: var(--text); font-weight: 600; text-align: center; padding: 10rpx 6rpx 2rpx; } +.bp__count { font-size: 20rpx; color: var(--text-3); text-align: center; padding: 0 6rpx 12rpx; } .eq-grid { display: flex; flex-wrap: wrap; gap: 16rpx; } .eq { width: calc((100% - 48rpx) / 4); background: var(--surface); border: 1rpx solid var(--line); - border-radius: var(--radius-sm); padding: 22rpx 12rpx; - display: flex; flex-direction: column; align-items: center; + border-radius: var(--radius-sm); overflow: hidden; + display: flex; flex-direction: column; align-items: stretch; } +.eq__img { width: 100%; height: 132rpx; background: var(--surface-2); display: block; } .eq:active { background: var(--surface-2); } -.eq__label { font-size: 26rpx; color: var(--text); font-weight: 600; } -.eq__count { font-size: 20rpx; color: var(--text-3); margin-top: 6rpx; } +.eq__label { font-size: 24rpx; color: var(--text); font-weight: 600; text-align: center; padding: 10rpx 6rpx 2rpx; } +.eq__count { font-size: 20rpx; color: var(--text-3); text-align: center; padding: 0 6rpx 12rpx; } .empty { color: var(--text-3); font-size: var(--fs-sm); text-align: center; padding: 40rpx 0; }