feat: 分类浏览体验优化
- 后端 CategoryItem 新增 sample 字段,每个器械/部位分类附首条动作代表图(720 JPG 海报),前端网格无需点进详情即可预览 - 首页『按器械浏览』『按部位浏览』网格 tile 改为图文卡片,展示分类代表图 - filter-bar 内联改为一行可横滑的分类 chips + 末尾『全部类型』入口;点开面板展开全部,选中项自动置顶
This commit is contained in:
@@ -80,6 +80,8 @@ export interface CategoryItem {
|
||||
/** 仅 target 分类会附带其所属部位,便于前端按部位分组 */
|
||||
bodyPart?: string;
|
||||
bodyPartLabel?: string;
|
||||
/** 该分类下首条动作的代表图(JPG 海报),供前端网格预览,无需点进详情即可辨认分类内容 */
|
||||
sample?: string | null;
|
||||
}
|
||||
|
||||
/** 推荐结果项(带相关性评分与推荐理由) */
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
108
miniprogram/components/filter-bar/index.js
Normal file
108
miniprogram/components/filter-bar/index.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
6
miniprogram/components/filter-bar/index.json
Normal file
6
miniprogram/components/filter-bar/index.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"component": true,
|
||||
"usingComponents": {
|
||||
"fc-icon": "/components/fc-icon/index"
|
||||
}
|
||||
}
|
||||
38
miniprogram/components/filter-bar/index.wxml
Normal file
38
miniprogram/components/filter-bar/index.wxml
Normal 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>
|
||||
107
miniprogram/components/filter-bar/index.wxss
Normal file
107
miniprogram/components/filter-bar/index.wxss
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user