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[] {

View File

@@ -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 });
}
}
});

View File

@@ -0,0 +1,6 @@
{
"component": true,
"usingComponents": {
"fc-icon": "/components/fc-icon/index"
}
}

View File

@@ -0,0 +1,38 @@
<view class="fb">
<!-- 内联触发条:默认展示一行可横滑的分类 chips末尾带「全部类型」入口 -->
<scroll-view scroll-x class="fb-row" wx:if="{{items.length}}">
<view
wx:for="{{items}}"
wx:key="value"
class="fb-chip {{item.active ? 'fb-chip--active' : ''}}"
data-value="{{item.value}}"
catchtap="onChipTap">{{item.label}}</view>
<view class="fb-chip fb-chip--all" catchtap="togglePanel">
<text>全部类型</text>
<fc-icon name="chevron-down" size="20" color="var(--text-2)" />
</view>
</scroll-view>
<!-- 下拉面板:底部弹出,全部分类以网格呈现,选中项自动置顶 -->
<view wx:if="{{panelOpen}}" class="fb-mask" bindtap="closePanel">
<view class="fb-sheet" catchtap="noop">
<view class="fb-sheet__head">
<text class="fb-sheet__title">{{title}}</text>
<text class="fb-sheet__close" bindtap="closePanel">✕</text>
</view>
<scroll-view scroll-y class="fb-sheet__body">
<view class="fb-grid">
<view
wx:for="{{panelItems}}"
wx:key="value"
class="fb-opt {{item.active ? 'fb-opt--active' : ''}}"
data-value="{{item.value}}"
bindtap="onPanelTap">
{{item.label}}
</view>
</view>
</scroll-view>
<view wx:if="{{multiple}}" class="fb-sheet__done" bindtap="onDone">完成</view>
</view>
</view>
</view>

View File

@@ -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);
}

View File

@@ -118,6 +118,7 @@
data-value="{{item.value}}"
data-label="{{item.label}}"
bindtap="onScanBodyPart">
<image class="bp__img" src="{{item.sample}}" mode="aspectFill" lazy-load />
<text class="bp__label">{{item.label}}</text>
<text class="bp__count">{{item.count}} 个动作</text>
</view>
@@ -135,6 +136,7 @@
data-value="{{item.value}}"
data-label="{{item.label}}"
bindtap="onScanEquipment">
<image class="eq__img" src="{{item.sample}}" mode="aspectFill" lazy-load />
<text class="eq__label">{{item.label}}</text>
<text class="eq__count">{{item.count}} 个动作</text>
</view>

View File

@@ -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; }