feat: 器械分类网格化 + 分类页标题防乱码 + 按器械/部位多维度筛选

- 首页『热门器械』由横滑 chips 改为与『按部位浏览』一致的网格
- 分类页 navbar 标题改由本地分类枚举回写,避免外部传入标题乱码
- 分类页新增二级维度多选筛选:器械维度交叉按部位,其余维度交叉按器械
- 后端 exercises.service 支持 equipment/bodyPart 逗号分隔多值 OR 过滤(向后兼容)
This commit is contained in:
2026-07-24 10:28:40 +08:00
parent ec0eb98411
commit 393157e56b
6 changed files with 122 additions and 27 deletions

View File

@@ -46,8 +46,15 @@ export class ExercisesService implements OnModuleInit {
findAll(query: QueryExercisesDto) {
let list = this.exercises;
if (query.bodyPart) list = list.filter((e) => e.bodyPart === query.bodyPart);
if (query.equipment) list = list.filter((e) => e.equipment === query.equipment);
// 支持逗号分隔多值OR 关系),向后兼容单值
if (query.bodyPart) {
const arr = String(query.bodyPart).split(',').map((s) => s.trim()).filter(Boolean);
if (arr.length) list = list.filter((e) => arr.includes(e.bodyPart));
}
if (query.equipment) {
const arr = String(query.equipment).split(',').map((s) => s.trim()).filter(Boolean);
if (arr.length) list = list.filter((e) => arr.includes(e.equipment));
}
if (query.type) list = list.filter((e) => e.type === query.type);
if (query.target) list = list.filter((e) => e.target === query.target);
if (query.muscleGroup)

View File

@@ -9,6 +9,11 @@ const DIM_MAP = {
type: 'types'
};
const SECONDARY_LABEL = {
bodyPart: '部位',
equipment: '器械'
};
function labelForDim(d) {
return ({
bodyPart: '按部位',
@@ -27,6 +32,12 @@ Page({
value: '',
values: [],
activeValue: '',
// 二级维度(多维度筛选):进入器械维度时交叉按部位,其余维度交叉按器械
secondaryDim: 'equipment',
secondaryKey: 'equipment',
secondaryLabel: '器械',
secondaryList: [],
secondarySelected: [],
exercises: [],
loading: true,
loadingMore: false,
@@ -39,11 +50,15 @@ Page({
onLoad(query) {
const dimension = query.dimension || 'bodyPart';
const value = query.value || '';
const secondaryDim = dimension === 'equipment' ? 'bodyPart' : 'equipment';
const title = query.title || labelForDim(dimension);
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 20,
dimension,
value,
secondaryDim,
secondaryKey: DIM_MAP[secondaryDim] || 'equipment',
secondaryLabel: SECONDARY_LABEL[secondaryDim] || '器械',
title
});
this.loadValues();
@@ -52,14 +67,30 @@ Page({
loadValues() {
this.setData({ loading: true });
const key = DIM_MAP[this.data.dimension] || 'body-parts';
svc.getCategories(key).then((items) => {
const secKey = this.data.secondaryKey;
Promise.all([
svc.getCategories(key),
svc.getCategories(secKey).catch(() => [])
]).then(([items, secItems]) => {
const list = (items || []).slice();
// 头部插入「全部」项:无 value 入参时默认选中,明确当前为「全部」
// 头部插入「全部」项:明确当前为「全部」
list.unshift({ value: '', label: '全部' });
const activeValue = this.data.value || '';
// 用分类枚举里的干净中文标签回写标题,避免外部传入的标题出现乱码
let title = this.data.title;
if (activeValue) {
const hit = list.find((it) => it.value === activeValue);
if (hit && hit.label) title = hit.label;
}
const secList = (secItems || []).slice().map((it) => ({
...it,
active: this.data.secondarySelected.includes(it.value)
}));
this.setData({
values: list,
activeValue: activeValue,
secondaryList: secList,
title,
loading: false
});
this.loadExercises();
@@ -68,10 +99,19 @@ Page({
});
},
syncSecondaryActive() {
const sel = this.data.secondarySelected;
const list = this.data.secondaryList.map((it) => ({ ...it, active: sel.includes(it.value) }));
this.setData({ secondaryList: list });
},
loadExercises() {
this.setData({ loading: true, page: 1 });
const params = { page: 1, pageSize: this.data.pageSize };
params[this.data.dimension] = this.data.activeValue;
if (this.data.secondarySelected.length) {
params[this.data.secondaryDim] = this.data.secondarySelected.join(',');
}
svc.listExercises(params).then((res) => {
const items = (res && res.items) || [];
const total = (res && res.total) || 0;
@@ -96,6 +136,9 @@ Page({
this.setData({ loadingMore: true });
const params = { page: next, pageSize: this.data.pageSize };
params[this.data.dimension] = this.data.activeValue;
if (this.data.secondarySelected.length) {
params[this.data.secondaryDim] = this.data.secondarySelected.join(',');
}
svc.listExercises(params).then((res) => {
const items = (res && res.items) || [];
const total = (res && res.total) || 0;
@@ -118,6 +161,24 @@ Page({
this.setData({ activeValue: value, page: 1 }, () => this.loadExercises());
},
onSecondary(e) {
const value = e.detail.value;
if (!value) return;
const cur = this.data.secondarySelected;
const next = cur.includes(value) ? cur.filter((v) => v !== value) : cur.concat(value);
this.setData({ secondarySelected: next, page: 1 }, () => {
this.syncSecondaryActive();
this.loadExercises();
});
},
onSecondaryClear() {
this.setData({ secondarySelected: [], page: 1 }, () => {
this.syncSecondaryActive();
this.loadExercises();
});
},
onExercise(e) {
if (!e || !e.detail || !e.detail.id) return;
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });

View File

@@ -2,16 +2,35 @@
<navbar title="{{title}}" show-back />
<view class="section" style="padding-top: 8rpx;">
<scroll-view wx:if="{{values.length}}" scroll-x class="filterbar" style="top: calc({{statusBarHeight}}px + 88rpx);">
<chip
wx:for="{{values}}"
wx:key="value"
label="{{item.label}}"
value="{{item.value}}"
active="{{activeValue === item.value}}"
bind:select="onValue" />
<view class="filterbar__spacer"></view>
</scroll-view>
<view wx:if="{{values.length}}" class="filterbar" style="top: calc({{statusBarHeight}}px + 88rpx);">
<scroll-view scroll-x class="filterbar__row">
<chip
wx:for="{{values}}"
wx:key="value"
label="{{item.label}}"
value="{{item.value}}"
active="{{activeValue === item.value}}"
bind:select="onValue" />
<view class="filterbar__spacer"></view>
</scroll-view>
<view wx:if="{{secondaryList.length}}" class="filterbar__sub">
<view class="filterbar__subhead">
<text class="filterbar__sublabel">{{secondaryLabel}}</text>
<text wx:if="{{secondarySelected.length}}" class="filterbar__clear" bindtap="onSecondaryClear">清除 ({{secondarySelected.length}})</text>
</view>
<scroll-view scroll-x class="filterbar__row">
<chip
wx:for="{{secondaryList}}"
wx:key="value"
label="{{item.label}}"
value="{{item.value}}"
active="{{item.active}}"
bind:select="onSecondary" />
<view class="filterbar__spacer"></view>
</scroll-view>
</view>
</view>
<view wx:else class="filterbar__loading muted">{{ loading ? '加载分类中…' : '暂无分类' }}</view>
<view class="count muted" wx:if="{{!loading}}">共 {{total}} 个动作</view>

View File

@@ -3,12 +3,17 @@
.filterbar {
position: sticky;
z-index: 40;
white-space: nowrap;
padding: 8rpx 0 4rpx;
padding: 8rpx 0 12rpx;
background: var(--bg-grad);
}
.filterbar__row { white-space: nowrap; }
.filterbar chip { display: inline-block; margin-right: 16rpx; }
.filterbar__spacer { display: inline-block; width: 4rpx; }
/* 二级维度(多维度筛选):主维度横列下方再叠一条横列,带标签与清除 */
.filterbar__sub { margin-top: 12rpx; padding-top: 14rpx; border-top: 1rpx solid var(--line); }
.filterbar__subhead { display: flex; align-items: center; justify-content: space-between; padding: 0 4rpx 10rpx; }
.filterbar__sublabel { font-size: 24rpx; color: var(--text-2); font-weight: 600; }
.filterbar__clear { font-size: 22rpx; color: var(--accent); }
/* 加载 / 空态占位:横列区域常驻,避免加载期间整块消失 */
.filterbar__loading {
font-size: 24rpx;

View File

@@ -124,10 +124,10 @@
</view>
</view>
<!-- 热门器械 -->
<!-- 热门器械(网格,与按部位浏览一致) -->
<view class="section" wx:if="{{equipment.length}}">
<section-header eyebrow="EQUIPMENT" title="热门器械" subtitle="按器械筛选动作" />
<scroll-view scroll-x class="chip-scroll">
<section-header eyebrow="EQUIPMENT" title="按器械浏览" subtitle="选择你的训练器械" />
<view class="eq-grid">
<view
wx:for="{{equipment}}"
wx:key="value"
@@ -135,10 +135,10 @@
data-value="{{item.value}}"
data-label="{{item.label}}"
bindtap="onScanEquipment">
<text class="eq__name">{{item.label}}</text>
<text class="eq__count">{{item.count}}</text>
<text class="eq__label">{{item.label}}</text>
<text class="eq__count">{{item.count}} 个动作</text>
</view>
</scroll-view>
</view>
</view>
<view style="height: 160rpx;"></view>

View File

@@ -100,12 +100,15 @@
.bp__label { font-size: 26rpx; color: var(--text); font-weight: 600; }
.bp__count { font-size: 20rpx; color: var(--text-3); margin-top: 6rpx; }
.eq-grid { display: flex; flex-wrap: wrap; gap: 16rpx; }
.eq {
display: inline-flex; align-items: center; gap: 12rpx;
background: var(--surface-2); border: 1rpx solid var(--line);
border-radius: var(--radius-pill); padding: 16rpx 28rpx; margin-right: 16rpx;
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;
}
.eq__name { font-size: 26rpx; color: var(--text); }
.eq__count { font-size: 22rpx; color: var(--accent); }
.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; }
.empty { color: var(--text-3); font-size: var(--fs-sm); text-align: center; padding: 40rpx 0; }