feat: 小程序连接服务器IP + 同步未提交开发

- miniprogram/config.js: BASE_URL 指向 http://192.227.237.8:3001
- 动作标题中文化(nameZh),搜索匹配 name/nameZh/nameEn
- 新增计划/收藏/我的 等页面与组件
- 后端 Docker 化(Dockerfile/.dockerignore)与翻译脚本
- .gitignore 补充 .DS_Store
This commit is contained in:
2026-07-22 14:05:30 +08:00
parent b3b58e66fb
commit 70ff806042
79 changed files with 2397 additions and 146070 deletions

View File

@@ -24,24 +24,37 @@ module.exports = { BASE_URL: 'http://localhost:3000' };
## 页面
| 页面 | 文件 | 说明 |
|------|------|------|
| 首页 | `pages/index` | Hero + 快捷入口 + 精选轮播 + 训练组合 + 按部位/器械浏览 |
| 首页 | `pages/index` | Hero + 快捷入口 + 精选轮播 + 官方计划分级入口 + 按部位/器械浏览 |
| 分类浏览 | `pages/category` | 按 dimension(bodyPart/equipment/target/type/muscleGroup) 筛选列表 |
| 智能推荐 | `pages/recommend` | 选目标肌群(按部位分组)+ 可选器械 → 评分排序结果 |
| 动作详情 | `pages/detail` | GIF + 中文步骤 + 主要/协同肌群 + 相关推荐 + 分享 |
| 训练组合 | `pages/collection` | 组合列表(推/拉/腿/核心/上肢/全身/居家/有氧) |
| 组合详情 | `pages/collection-detail` | 某组合下的动作 |
| 搜索 | `pages/search` | 名称/部位/器械/目标模糊搜索 |
| 动作详情 | `pages/detail` | GIF + 中文步骤 + 主要/协同肌群 + 收藏按钮 + 加入计划 + 相关推荐 + 分享 |
| 官方计划 | `pages/collection` | 按水平分级(新手/进阶/高级/全阶段)的官方推荐计划列表 |
| 计划详情 | `pages/collection-detail` | 官方计划动作列表 + 收藏/一键导入我的计划 |
| 搜索 | `pages/search` | 模糊搜索`mode=select` 时用于向计划添加动作 |
| **我的** | `pages/profile` | 个人中心:登录态、资料编辑、收藏/计划统计、菜单入口 |
| **收藏动作** | `pages/favorites` | 我收藏的动作,可管理移除 |
| **我的计划** | `pages/my-plans` | 计划列表,新建/进入 |
| **编辑计划** | `pages/plan-edit` | 新建/编辑计划(名称/描述/图标/主题色) |
| **计划详情** | `pages/plan-detail` | 我的计划动作管理(增删/编辑/删除) |
| **收藏计划** | `pages/plan-favorites` | 我收藏的官方计划 |
## 用户体系
- **登录**`utils/auth.js` 封装 `wx.login → POST /api/auth/login`;后端未配 `WX_APPID` 时走开发降级(任意 code 建号)。登录态存于 `wx.storage``access_token`/`openid`/`user_profile`)。
- **自动重登**`utils/request.js` 在收到 401 时调用注册的 `unauthorizedHandler` 重新登录并重试一次原请求,页面无需感知。
- **个人中心** `pages/profile`:未登录显示登录卡片;已登录显示头像/昵称/统计/菜单,支持改昵称与 emoji 头像、退出登录。
- **收藏**:动作收藏(详情页心形)、官方计划收藏(计划详情页)。
- **计划**:可新建/编辑计划、向计划添加或移除动作(搜索页 `select` 模式)、从官方计划一键导入为个人副本。
## 组件
`exercise-card`(动作卡)、`section-header`(分区标题)、`chip`(筛选标签)、
`navbar`(自定义导航栏,处理状态栏高度)、`bottom-nav`(底部导航)。
`navbar`(自定义导航栏,处理状态栏高度)、`bottom-nav`(底部导航:首页/分类/我的)、
`level-tag`(难度等级标签)、`avatar`emoji 头像)、`plan-card`(计划卡)。
## 接口映射
`services/exercise.js` 直接映射后端 7 类接口(`listExercises` / `getExercise` /
`getCategories` / `recommend` / `listCollections` / `getCollectionExercises` /
`search` / `getStats`),字段与后端响应一一对应。
- `services/exercise.js`:列表/详情/分类/推荐/官方计划/搜索/统计。
- `services/user.js``services/favorite.js``services/plan.js`:用户资料、收藏、计划,字段与后端响应一一对应。
## 注意事项
- 小程序 `<image>` 支持 GIF动作演示直接使用 `gifUrl`
- 所有请求经 `utils/request.js` 单点封装,失败统一 toast。
- 所有请求经 `utils/request.js` 单点封装,自动带 `Authorization` 头、失败统一 toast。
- 包体保持精简:主包仅含页面与组件,无大体积本地资源。

View File

@@ -6,7 +6,13 @@
"pages/detail/detail",
"pages/collection/collection",
"pages/collection-detail/collection-detail",
"pages/search/search"
"pages/search/search",
"pages/profile/profile",
"pages/favorites/favorites",
"pages/my-plans/my-plans",
"pages/plan-edit/plan-edit",
"pages/plan-detail/plan-detail",
"pages/plan-favorites/plan-favorites"
],
"window": {
"navigationStyle": "custom",

View File

@@ -86,3 +86,18 @@ view, text, scroll-view, image, input {
color: var(--gold-2);
background: var(--gold-soft);
}
/* ===== 横向滚动提示:右侧渐隐遮罩,提示「还可向右滑动」 ===== */
.hscroll, .chip-scroll, .filterbar, .emoji-row, .prog-scroll {
position: relative;
}
.hscroll::after, .chip-scroll::after, .filterbar::after, .emoji-row::after, .prog-scroll::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 56rpx;
pointer-events: none;
background: linear-gradient(90deg, rgba(14,17,16,0) 0%, rgba(14,17,16,0.9) 100%);
}

View File

@@ -0,0 +1,17 @@
// 头像组件:纯 emoji 圆形头像(无二进制图片)。
// props: avatar(emoji 字符串), size(rpx, 默认 120), bg(背景色, 默认 gold-soft)
Component({
properties: {
avatar: { type: String, value: '💪' },
size: { type: Number, value: 120 },
bg: { type: String, value: 'rgba(217,179,108,0.12)' }
},
data: {
fontSize: 60
},
observers: {
size: function (size) {
this.setData({ fontSize: Math.round(size * 0.5) });
}
}
});

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,5 @@
<view
class="avatar"
style="width: {{size}}rpx; height: {{size}}rpx; background: {{bg}}; font-size: {{fontSize}}rpx;">
{{avatar || '💪'}}
</view>

View File

@@ -0,0 +1,9 @@
.avatar {
display: flex;
align-items: center;
justify-content: center;
border-radius: 999rpx;
border: 1rpx solid var(--gold-line);
line-height: 1;
overflow: hidden;
}

View File

@@ -1,12 +1,13 @@
Component({
properties: {
active: { type: String, value: 'home' } // 'home' | 'category' | 'plan'
active: { type: String, value: 'home' } // 'home' | 'category' | 'mine'
},
data: {
items: [
{ key: 'home', label: '首页', emoji: '🏠', page: '/pages/index/index' },
{ key: 'category', label: '分类', emoji: '📚', page: '/pages/category/category' },
{ key: 'plan', label: '计划', emoji: '📋', page: '/pages/collection/collection' }
{ key: 'plans', label: '计划', emoji: '📋', page: '/pages/my-plans/my-plans' },
{ key: 'mine', label: '我的', emoji: '👤', page: '/pages/profile/profile' }
]
},
methods: {

View File

@@ -9,6 +9,7 @@
z-index: 60;
}
.bn__item {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
@@ -16,6 +17,15 @@
padding: 16rpx 0 14rpx;
color: var(--text-3);
}
.bn__item--active::before {
content: '';
position: absolute;
top: 0; left: 50%;
transform: translateX(-50%);
width: 48rpx; height: 4rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, var(--gold), var(--gold-2));
}
.bn__item--active { color: var(--gold); }
.bn__emoji { font-size: 40rpx; line-height: 1; }
.bn__label { font-size: 22rpx; margin-top: 6rpx; }

View File

@@ -5,7 +5,20 @@ Component({
reason: { type: String, value: '' },
compact: { type: Boolean, value: false }
},
data: {
imgSrc: '',
imgError: false
},
observers: {
// 卡片用轻量静态图(~6KB避免 91KB 的 GIF 拖慢列表
exercise(ex) {
this.setData({ imgSrc: (ex && ex.image) || '', imgError: false });
}
},
methods: {
onImgError() {
this.setData({ imgError: true });
},
onTap() {
const ex = this.data.exercise || {};
this.triggerEvent('tap', { id: ex.id, exercise: ex });

View File

@@ -1,17 +1,18 @@
<view class="ex-card {{compact ? 'ex-card--compact' : ''}}" bindtap="onTap">
<view class="ex-card__media">
<image
wx:if="{{exercise.gifUrl || exercise.image}}"
wx:if="{{imgSrc && !imgError}}"
class="ex-card__img"
src="{{exercise.gifUrl || exercise.image}}"
src="{{imgSrc}}"
mode="aspectFill"
lazy-load="true" />
lazy-load="true"
binderror="onImgError" />
<view wx:else class="ex-card__ph">🏋️</view>
<view wx:if="{{exercise.typeLabel}}" class="ex-card__type">{{exercise.typeLabel}}</view>
<view wx:if="{{score > 0}}" class="ex-card__score">匹配 {{score}}</view>
</view>
<view class="ex-card__body">
<view class="ex-card__name">{{exercise.name}}</view>
<view class="ex-card__name">{{exercise.displayName || exercise.name}}</view>
<view class="ex-card__chips">
<text wx:if="{{exercise.targetLabel}}" class="tag tag--gold">{{exercise.targetLabel}}</text>
<text wx:if="{{exercise.equipmentLabel}}" class="tag">{{exercise.equipmentLabel}}</text>

View File

@@ -0,0 +1,29 @@
// 难度等级标签beginner / intermediate / advanced / all
const MAP = {
beginner: { text: '新手入门', color: '#3FBF7F', bg: 'rgba(63,191,127,0.14)' },
intermediate: { text: '进阶提升', color: '#5B9DF9', bg: 'rgba(91,157,249,0.14)' },
advanced: { text: '高级挑战', color: '#F08A5D', bg: 'rgba(240,138,93,0.14)' },
all: { text: '全阶段', color: '#58C9B9', bg: 'rgba(88,201,185,0.14)' }
};
Component({
properties: {
level: { type: String, value: 'all' },
label: { type: String, value: '' }
},
data: {
text: '全阶段',
color: '#58C9B9',
bg: 'rgba(88,201,185,0.14)'
},
observers: {
'level, label': function (level, label) {
const m = MAP[level] || MAP.all;
this.setData({
text: label || m.text,
color: m.color,
bg: m.bg
});
}
}
});

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1 @@
<view class="lvl" style="color: {{color}}; background: {{bg}};">{{text}}</view>

View File

@@ -0,0 +1,9 @@
.lvl {
display: inline-flex;
align-items: center;
font-size: 20rpx;
font-weight: 600;
padding: 6rpx 16rpx;
border-radius: 999rpx;
line-height: 1;
}

View File

@@ -3,23 +3,40 @@ const app = getApp();
Component({
properties: {
title: { type: String, value: '' },
showBack: { type: Boolean, value: false }
showBack: { type: Boolean, value: false },
// 拦截返回:为 true 时点击返回仅触发 bind:back 事件,由页面自行处理(如未保存草稿确认)
interceptBack: { type: Boolean, value: false }
},
data: {
statusBarHeight: 20
statusBarHeight: 20,
// 运行期根据页面栈动态决定:只有确实存在上一页时才显示返回键,
// 避免在底部 tab 根页(页面栈深度 = 1误显示返回键
canBack: false
},
lifetimes: {
attached() {
const h = (app.globalData && app.globalData.statusBarHeight) || 20;
this.setData({ statusBarHeight: h });
const pages = getCurrentPages();
this.setData({
statusBarHeight: h,
canBack: pages.length > 1
});
}
},
methods: {
onBack() {
const pages = getCurrentPages();
// 页面要求自行处理返回逻辑(典型场景:编辑页有未保存内容需确认)
if (this.data.interceptBack) {
this.triggerEvent('back');
return;
}
if (pages.length > 1) {
wx.navigateBack();
wx.navigateBack({ delta: 1 });
} else {
// 兜底:确实没有上一页时回到首页(正常 tab 根页不会显示返回键,此为保险)
wx.reLaunch({ url: '/pages/index/index' });
}
}

View File

@@ -1,7 +1,14 @@
<view class="navbar">
<view class="navbar__status" style="height: {{statusBarHeight}}px;"></view>
<view class="navbar__bar">
<view wx:if="{{showBack}}" class="navbar__back" bindtap="onBack"></view>
<view
wx:if="{{showBack && canBack}}"
class="navbar__back"
hover-class="navbar__back--hover"
hover-stay-time="80"
bindtap="onBack">
<view class="navbar__arrow"></view>
</view>
<view wx:if="{{title}}" class="navbar__title">{{title}}</view>
</view>
</view>

View File

@@ -10,20 +10,51 @@
display: flex;
align-items: center;
position: relative;
padding: 0 28rpx;
padding: 0 24rpx;
}
/* 返回键:整块左侧区域可点,热区远大于图标本身 */
.navbar__back {
font-size: 56rpx;
color: var(--gold);
line-height: 1;
width: 60rpx;
position: relative;
width: 72rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: flex-start;
margin-left: -10rpx; /* 让箭头更贴近系统返回键位置 */
z-index: 2;
}
/* 扩展可点热区,提升命中率(左右留白也算可点) */
.navbar__back::after {
content: '';
position: absolute;
left: 0;
right: -24rpx;
top: 0;
bottom: 0;
}
.navbar__back--hover { opacity: 0.5; }
/* 用 CSS 绘制清晰、较粗的左箭头,避免依赖字体渲染 字符 */
.navbar__arrow {
width: 20rpx;
height: 20rpx;
border-left: 5rpx solid var(--gold);
border-bottom: 5rpx solid var(--gold);
transform: rotate(45deg);
margin-left: 14rpx;
box-sizing: border-box;
}
.navbar__title {
position: absolute;
left: 0; right: 0;
left: 0;
right: 0;
text-align: center;
font-size: 34rpx;
font-weight: 700;
letter-spacing: 2rpx;
color: var(--text);
z-index: 1;
pointer-events: none; /* 不拦截返回键的点击 */
}

View File

@@ -0,0 +1,33 @@
// 计划卡片:复用官方计划(CollectionDef)与我自己的计划(PlanSummary)。
// props:
// plan: { id|slug, emoji, color, name, description, count|exerciseCount, cover? }
// target: 点击跳转的完整 url
Component({
properties: {
plan: { type: Object, value: {} },
target: { type: String, value: '' }
},
data: {
count: 0,
cover: '',
hasCover: false,
color: '#D9B36C'
},
observers: {
plan: function (plan) {
plan = plan || {};
this.setData({
count: plan.count || plan.exerciseCount || 0,
cover: plan.cover || '',
hasCover: !!(plan.cover),
color: plan.color || '#D9B36C'
});
}
},
methods: {
onTap() {
const url = this.data.target;
if (url) wx.navigateTo({ url });
}
}
});

View File

@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,22 @@
<view class="pc">
<view
wx:if="{{hasCover}}"
class="pc__cover"
style="background: {{color}}22;">
<image class="pc__img" src="{{cover}}" mode="aspectFill" lazy-load="true" />
<view class="pc__badge" style="background: {{color}}22; color: {{color}};">{{plan.emoji}}</view>
</view>
<view wx:else class="pc__top" style="background: linear-gradient(135deg, {{color}}26, var(--surface));">
<view class="pc__emoji" style="background: {{color}}22; color: {{color}};">{{plan.emoji}}</view>
</view>
<view class="pc__body">
<view class="pc__name" style="color: {{color}};">{{plan.name}}</view>
<view wx:if="{{plan.description}}" class="pc__desc">{{plan.description}}</view>
</view>
<view class="pc__foot">
<text wx:if="{{count > 0}}" class="pc__count">{{count}} 个动作</text>
<text class="pc__arrow"></text>
</view>
</view>

View File

@@ -0,0 +1,37 @@
.pc {
background: var(--surface);
border-radius: var(--radius);
overflow: hidden;
border: 1rpx solid var(--line);
box-shadow: var(--shadow);
width: 100%;
}
.pc__cover { position: relative; width: 100%; height: 220rpx; background: var(--surface-2); }
.pc__img { width: 100%; height: 100%; display: block; }
.pc__badge {
position: absolute; left: 16rpx; top: 16rpx;
width: 64rpx; height: 64rpx; border-radius: 18rpx;
display: flex; align-items: center; justify-content: center;
font-size: 38rpx; line-height: 1;
}
.pc__top { padding: 28rpx; display: flex; }
.pc__emoji {
width: 96rpx; height: 96rpx; border-radius: 24rpx;
display: flex; align-items: center; justify-content: center;
font-size: 52rpx; line-height: 1;
}
.pc__body { padding: 18rpx 22rpx 8rpx; }
.pc__name { font-size: 30rpx; font-weight: 700; }
.pc__desc {
margin-top: 10rpx;
font-size: 24rpx; color: var(--text-2); line-height: 1.5;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
}
.pc__foot {
display: flex; align-items: center; justify-content: space-between;
padding: 16rpx 22rpx 22rpx;
border-top: 1rpx solid var(--line);
margin-top: 8rpx;
}
.pc__count { font-size: 24rpx; color: var(--text-2); }
.pc__arrow { font-size: 36rpx; color: var(--gold); line-height: 1; }

View File

@@ -1,7 +1,7 @@
// 后端基础地址配置。
// ⚠️ 生产环境必须替换为已在「微信公众平台 -> 开发 -> 开发设置 -> 服务器域名」
// 中配置的 HTTPS 合法域名request 合法域名),否则真机无法发起请求。
// localhost 仅用于开发者工具本地联调。
// 当前直连服务器 IP开发期在微信开发者工具中勾选「不校验合法域名」即可联调
module.exports = {
BASE_URL: 'http://localhost:3000'
BASE_URL: 'http://192.227.237.8:3001'
};

View File

@@ -1,5 +1,7 @@
const app = getApp();
const svc = require('../../services/exercise');
const planSvc = require('../../services/plan');
const favSvc = require('../../services/favorite');
const auth = require('../../utils/auth');
Page({
data: {
@@ -7,24 +9,25 @@ Page({
slug: '',
collection: null,
exercises: [],
total: 0,
loading: true,
page: 1,
pageSize: 30,
total: 0
favorited: false
},
onLoad(query) {
const slug = query.slug || '';
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20, slug });
this.load();
if (auth.isLogin()) {
favSvc.planStatus(slug).then((r) => {
this.setData({ favorited: !!(r && r.favorited) });
}).catch(() => {});
}
},
load() {
this.setData({ loading: true });
svc.getCollectionExercises(this.data.slug, {
page: this.data.page,
pageSize: this.data.pageSize
}).then((res) => {
planSvc.officialExercises(this.data.slug, 1, 60).then((res) => {
this.setData({
collection: res.collection,
exercises: (res && res.items) || [],
@@ -36,7 +39,39 @@ Page({
});
},
// 收藏 / 取消收藏官方计划
onToggleFav() {
auth.ensureLogin().then(() => {
const slug = this.data.slug;
const wasFav = this.data.favorited;
const op = wasFav ? favSvc.removePlan(slug) : favSvc.addPlan(slug);
op.then(() => {
this.setData({ favorited: !wasFav });
wx.showToast({ title: wasFav ? '已取消收藏' : '已收藏', icon: 'none' });
}).catch(() => {});
}).catch(() => {});
},
// 导入到我的计划
onImport() {
auth.ensureLogin().then(() => {
wx.showLoading({ title: '导入中…', mask: true });
planSvc.importOfficial(this.data.slug).then(() => {
wx.hideLoading();
wx.showToast({ title: '已导入到我的计划', icon: 'success' });
setTimeout(() => {
wx.navigateTo({ url: '/pages/my-plans/my-plans' });
}, 600);
}).catch(() => {
wx.hideLoading();
});
}).catch(() => {});
},
onExercise(e) {
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });
wx.navigateTo({
url: '/pages/detail/detail?from=collection&slug=' +
encodeURIComponent(this.data.slug) + '&id=' + encodeURIComponent(e.detail.id)
});
}
});

View File

@@ -2,6 +2,8 @@
"navigationStyle": "custom",
"usingComponents": {
"navbar": "/components/navbar/index",
"exercise-card": "/components/exercise-card/index"
"exercise-card": "/components/exercise-card/index",
"section-header": "/components/section-header/index",
"level-tag": "/components/level-tag/index"
}
}

View File

@@ -3,22 +3,42 @@
<view class="header" style="background: linear-gradient(135deg, {{collection.color}}26, var(--surface));">
<view class="header__emoji" style="background: {{collection.color}}22; color: {{collection.color}};">{{collection.emoji}}</view>
<view class="header__name">{{collection.name}}</view>
<view class="header__name" style="color: {{collection.color}};">{{collection.name}}</view>
<view wx:if="{{collection.nameEn}}" class="header__en">{{collection.nameEn}}</view>
<view class="header__desc">{{collection.description}}</view>
<view class="header__count" style="color: {{collection.color}};">共 {{total}} 个动作</view>
<view class="header__meta">
<level-tag level="{{collection.level}}" />
<text class="header__sep">·</text>
<text class="header__m" wx:if="{{collection.sessionsPerWeek}}">每周 {{collection.sessionsPerWeek}} 次</text>
<text class="header__sep" wx:if="{{collection.sessionsPerWeek && collection.durationWeeks}}">·</text>
<text class="header__m" wx:if="{{collection.durationWeeks}}">约 {{collection.durationWeeks}} 周</text>
<text class="header__sep" wx:if="{{collection.focus}}">·</text>
<text class="header__m" wx:if="{{collection.focus}}">{{collection.focus}}</text>
</view>
</view>
<view class="section" style="padding-top: 8rpx;">
<section-header eyebrow="EXERCISES" title="动作列表" subtitle="共 {{total}} 个动作" />
<view class="grid" wx:if="{{exercises.length}}">
<view class="grid__item" wx:for="{{exercises}}" wx:key="id">
<exercise-card exercise="{{item}}" bind:tap="onExercise" />
</view>
</view>
<view wx:elif="{{!loading}}" class="empty">暂无数据</view>
<view wx:elif="{{!loading}}" class="empty">该计划暂无动作</view>
<view wx:else class="empty">加载中…</view>
</view>
<view class="actionbar">
<view class="btn btn--ghost {{favorited ? 'btn--faved' : ''}}" bindtap="onToggleFav">
<text>{{favorited ? '♥ 已收藏' : '♡ 收藏'}}</text>
</view>
<view class="btn btn--gold" bindtap="onImport">
<text> 导入我的计划</text>
</view>
</view>
</view>
<view wx:else class="empty-page">
<view class="empty">{{ loading ? '加载中…' : '未找到该训练组合' }}</view>
<view class="empty">{{ loading ? '加载中…' : '未找到该训练计划' }}</view>
</view>

View File

@@ -1,14 +1,33 @@
.page { min-height: 100vh; background: var(--bg-grad); padding-bottom: 60rpx; }
.page { min-height: 100vh; background: var(--bg-grad); padding-bottom: 160rpx; }
.header { padding: 32rpx; border-bottom: 1rpx solid var(--line); }
.header__emoji {
width: 120rpx; height: 120rpx; border-radius: 28rpx;
display: flex; align-items: center; justify-content: center; font-size: 64rpx;
}
.header__name { font-size: 44rpx; font-weight: 700; margin-top: 20rpx; }
.header__en { font-size: 24rpx; color: var(--text-3); margin-top: 6rpx; letter-spacing: 1rpx; }
.header__desc { font-size: 26rpx; color: var(--text-2); margin-top: 12rpx; line-height: 1.5; }
.header__count { font-size: 24rpx; margin-top: 16rpx; }
.header__meta { display: flex; align-items: center; flex-wrap: wrap; gap: 12rpx; margin-top: 20rpx; }
.header__sep { color: var(--text-3); font-size: 22rpx; }
.header__m { font-size: 24rpx; color: var(--text-2); }
.grid { display: flex; flex-wrap: wrap; gap: 18rpx; }
.grid__item { width: calc((100% - 18rpx) / 2); }
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 120rpx 0; }
.empty-page { min-height: 100vh; background: var(--bg-grad); }
.actionbar {
position: fixed; left: 0; right: 0; bottom: 0;
display: flex; gap: 20rpx; padding: 20rpx 32rpx;
background: rgba(14,17,16,0.92);
backdrop-filter: blur(12px);
border-top: 1rpx solid var(--line);
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
}
.btn {
flex: 1; display: flex; align-items: center; justify-content: center;
border-radius: 999rpx; padding: 26rpx; font-size: 30rpx; font-weight: 700;
}
.btn--gold { background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%); color: #0E1110; }
.btn--ghost { background: var(--surface-2); color: var(--text); border: 1rpx solid var(--gold-line); }
.btn--faved { color: var(--gold-2); border-color: var(--gold); }

View File

@@ -1,11 +1,10 @@
const app = getApp();
const svc = require('../../services/exercise');
const planSvc = require('../../services/plan');
Page({
data: {
statusBarHeight: 20,
collections: [],
counts: {},
groups: [],
loading: true
},
@@ -14,27 +13,22 @@ Page({
this.load();
},
onShow() {
// 从详情返回(可能收藏/导入变化)时刷新
if (!this.data.loading) this.load();
},
load() {
this.setData({ loading: true });
svc.listCollections().then((list) => {
const collections = list || [];
this.setData({ collections, loading: false });
// 异步获取每个训练组合的动作数量
collections.forEach(c => {
svc.getCollectionExercises(c.slug, { pageSize: 1 }).then((res) => {
const key = 'counts.' + c.slug;
this.setData({ [key]: (res && res.total) || 0 });
}).catch(() => {});
});
planSvc.officialGroups().then((res) => {
this.setData({ groups: (res && res.groups) || [], loading: false });
}).catch(() => {
this.setData({ loading: false });
this.setData({ groups: [], loading: false });
});
},
onOpen(e) {
wx.navigateTo({
url: '/pages/collection-detail/collection-detail?slug=' +
encodeURIComponent(e.currentTarget.dataset.slug)
});
// 计算某计划的跳转地址(供 wxml 使用)
planTarget(slug) {
return '/pages/collection-detail/collection-detail?slug=' + encodeURIComponent(slug);
}
});

View File

@@ -2,6 +2,7 @@
"navigationStyle": "custom",
"usingComponents": {
"navbar": "/components/navbar/index",
"bottom-nav": "/components/bottom-nav/index"
"section-header": "/components/section-header/index",
"plan-card": "/components/plan-card/index"
}
}

View File

@@ -1,31 +1,29 @@
<view class="page">
<navbar title="训练计划" />
<navbar title="官方训练计划" show-back />
<view class="section" style="padding-top: 8rpx;">
<view class="intro">
<view class="intro__eyebrow">PROGRAMS</view>
<view class="intro__title">选你的训练主题</view>
<view class="intro__sub">从推、拉、腿到居家无器械,一键开启</view>
<view class="intro__eyebrow">OFFICIAL PROGRAMS</view>
<view class="intro__title">选你的训练阶段</view>
<view class="intro__sub">从新手入门到高级挑战,分级规划你的每一次进阶</view>
</view>
<view class="list" wx:if="{{collections.length}}">
<view
wx:for="{{collections}}"
wx:key="slug"
class="col-card"
style="background: linear-gradient(135deg, {{item.color}}26, var(--surface)); border-color: {{item.color}}55;"
data-slug="{{item.slug}}"
bindtap="onOpen">
<view class="col-card__emoji" style="background: {{item.color}}22;">{{item.emoji}}</view>
<view class="col-card__main">
<view class="col-card__name">{{item.name}}</view>
<view class="col-card__desc">{{item.description}}</view>
<block wx:if="{{groups.length}}">
<view class="group" wx:for="{{groups}}" wx:for-item="g" wx:key="level">
<section-header eyebrow="LEVEL" title="{{g.label}}" />
<view class="grid">
<view class="grid__item" wx:for="{{g.plans}}" wx:for-item="p" wx:key="slug">
<plan-card
plan="{{p}}"
target="/pages/collection-detail/collection-detail?slug={{p.slug}}" />
</view>
</view>
<view class="col-card__count" style="color: {{item.color}};">{{counts[item.slug] || ''}} 个动作 </view>
</view>
</view>
<view wx:elif="{{!loading}}" class="empty">暂无数据</view>
</block>
<view wx:elif="{{!loading}}" class="empty">暂无官方计划</view>
<view wx:else class="empty">加载中…</view>
</view>
<view style="height: 160rpx;"></view>
<bottom-nav active="plan" />
<view style="height: 60rpx;"></view>
</view>

View File

@@ -4,19 +4,8 @@
.intro__title { font-size: 44rpx; font-weight: 700; margin-top: 12rpx; }
.intro__sub { font-size: 26rpx; color: var(--text-2); margin-top: 12rpx; }
.list { display: flex; flex-direction: column; gap: 20rpx; }
.col-card {
display: flex; align-items: center; gap: 24rpx;
background: var(--surface); border: 1rpx solid var(--line);
border-radius: var(--radius); padding: 28rpx;
}
.col-card__emoji {
width: 96rpx; height: 96rpx; border-radius: 24rpx;
display: flex; align-items: center; justify-content: center;
font-size: 52rpx; flex: 0 0 auto;
}
.col-card__main { flex: 1; min-width: 0; }
.col-card__name { font-size: 34rpx; font-weight: 700; color: var(--text); }
.col-card__desc { font-size: 24rpx; color: var(--text-2); margin-top: 8rpx; line-height: 1.4; }
.col-card__count { font-size: 24rpx; flex: 0 0 auto; }
.group { margin-top: 12rpx; }
.group + .group { margin-top: 36rpx; }
.grid { display: flex; flex-wrap: wrap; gap: 18rpx; }
.grid__item { width: calc((100% - 18rpx) / 2); }
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 80rpx 0; }

View File

@@ -1,25 +1,40 @@
const app = getApp();
const svc = require('../../services/exercise');
const favSvc = require('../../services/favorite');
const planSvc = require('../../services/plan');
const auth = require('../../utils/auth');
Page({
data: {
statusBarHeight: 20,
id: '',
planId: '',
exercise: null,
heroSrc: '',
heroError: false,
related: [],
loading: true
loading: true,
favorited: false
},
onLoad(query) {
const id = query.id || '';
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20, id });
const planId = query.planId || '';
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20, id, planId });
this.load();
if (auth.isLogin()) {
favSvc.exerciseStatus(id).then((r) => {
this.setData({ favorited: !!(r && r.favorited) });
}).catch(() => {});
}
},
load() {
this.setData({ loading: true });
svc.getExercise(this.data.id).then((ex) => {
this.setData({ exercise: ex, loading: false });
// 详情页用 GIF 演示动作;记录当前图源以便失败时降级到 JPG / 占位
const heroSrc = ex && (ex.gifUrl || ex.image) ? (ex.gifUrl || ex.image) : '';
this.setData({ exercise: ex, heroSrc, heroError: false, loading: false });
if (ex && ex.target) {
svc.listExercises({ target: ex.target, pageSize: 6 }).then((res) => {
const related = ((res && res.items) || [])
@@ -33,8 +48,52 @@ Page({
});
},
// 收藏 / 取消收藏(心形按钮)
onToggleFav() {
auth.ensureLogin().then(() => {
const id = this.data.id;
const wasFav = this.data.favorited;
const op = wasFav ? favSvc.removeExercise(id) : favSvc.addExercise(id);
op.then(() => {
this.setData({ favorited: !wasFav });
wx.showToast({ title: wasFav ? '已取消收藏' : '已收藏', icon: 'none' });
}).catch(() => {});
}).catch(() => {});
},
// 底部主按钮:加入计划(真实选择器)
onAdd() {
wx.showToast({ title: '已加入今日训练', icon: 'success' });
if (this.data.planId) {
planSvc.addExercise(this.data.planId, this.data.id).then(() => {
wx.showToast({ title: '已加入计划', icon: 'success' });
setTimeout(() => wx.navigateBack(), 500);
}).catch(() => {});
return;
}
auth.ensureLogin().then(() => planSvc.listMine()).then((res) => {
const plans = (res && res.items) || res || [];
if (!plans.length) {
wx.showModal({
title: '还没有训练计划',
content: '先创建一个计划,再把动作加进去吧~',
confirmText: '去创建',
cancelText: '稍后',
success: (r) => { if (r.confirm) wx.navigateTo({ url: '/pages/plan-edit/plan-edit' }); }
});
return;
}
const names = plans.slice(0, 6).map((p) => p.name);
wx.showActionSheet({
itemList: names,
success: (sheet) => {
const plan = plans[sheet.tapIndex];
if (!plan) return;
planSvc.addExercise(plan.id, this.data.id).then(() => {
wx.showToast({ title: '已加入「' + plan.name + '」', icon: 'success' });
}).catch(() => {});
}
});
}).catch(() => {});
},
onBack() {
@@ -46,14 +105,26 @@ Page({
}
},
// 详情大图加载失败GIF→JPG→emoji 占位,三级降级保证不空白
onHeroError() {
const ex = this.data.exercise || {};
const showingGif = this.data.heroSrc && ex.gifUrl && this.data.heroSrc === ex.gifUrl;
if (showingGif && ex.image) {
this.setData({ heroSrc: ex.image });
} else {
this.setData({ heroError: true });
}
},
onExercise(e) {
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });
},
onShareAppMessage() {
const ex = this.data.exercise || {};
const name = ex.displayName || ex.name || '';
return {
title: ex.name ? ('FITCOACH · ' + ex.name) : 'FITCOACH 智能健身教练',
title: name ? ('FITCOACH · ' + name) : 'FITCOACH 智能健身教练',
path: '/pages/detail/detail?id=' + this.data.id
};
},

View File

@@ -1,17 +1,23 @@
<view class="page" wx:if="{{exercise}}">
<navbar title="{{exercise.name}}" show-back />
<navbar title="{{exercise.displayName || exercise.name}}" show-back />
<view class="favbtn" style="top: {{statusBarHeight + 12}}px;" bindtap="onToggleFav">
<text>{{favorited ? '♥' : '♡'}}</text>
</view>
<view class="hero">
<image
wx:if="{{exercise.gifUrl || exercise.image}}"
wx:if="{{heroSrc && !heroError}}"
class="hero__img"
src="{{exercise.gifUrl || exercise.image}}"
mode="aspectFill" />
src="{{heroSrc}}"
mode="aspectFill"
lazy-load="true"
binderror="onHeroError" />
<view wx:else class="hero__ph">🏋️</view>
</view>
<view class="body section">
<view class="name">{{exercise.name}}</view>
<view class="name">{{exercise.displayName || exercise.name}}</view>
<view class="metas">
<view class="meta"><view class="meta__k">部位</view><view class="meta__v">{{exercise.bodyPartLabel}}</view></view>
@@ -60,7 +66,7 @@
</view>
<view class="addbar">
<view class="cta" bindtap="onAdd">加入今日训练</view>
<view class="cta" bindtap="onAdd">{{ planId ? '加入该计划' : '加入计划' }}</view>
</view>
</view>

View File

@@ -55,3 +55,13 @@
.topbar { height: 88rpx; display: flex; align-items: center; padding: 0 24rpx; }
.topbar__back { font-size: 56rpx; color: var(--gold); width: 60rpx; line-height: 1; }
.empty { color: var(--text-3); font-size: 28rpx; text-align: center; padding: 120rpx 0; }
.favbtn {
position: fixed; right: 28rpx; z-index: 60;
width: 72rpx; height: 72rpx; border-radius: 999rpx;
background: rgba(14,17,16,0.55);
backdrop-filter: blur(8px);
border: 1rpx solid var(--gold-line);
display: flex; align-items: center; justify-content: center;
font-size: 44rpx; color: var(--gold-2); line-height: 1;
}

View File

@@ -0,0 +1,55 @@
const app = getApp();
const favSvc = require('../../services/favorite');
const auth = require('../../utils/auth');
Page({
data: {
statusBarHeight: 20,
list: [],
loading: true,
managing: false
},
onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20 });
},
onShow() {
if (auth.isLogin()) this.load();
else {
wx.showToast({ title: '请先登录', icon: 'none' });
setTimeout(() => wx.navigateBack(), 600);
}
},
load() {
this.setData({ loading: true });
favSvc.listExercises().then((list) => {
this.setData({ list: list || [], loading: false });
}).catch(() => {
this.setData({ list: [], loading: false });
});
},
onManage() {
this.setData({ managing: !this.data.managing });
},
onRemove(e) {
const id = e.currentTarget.dataset.id;
favSvc.removeExercise(id).then(() => {
const list = this.data.list.filter(i => i.id !== id);
this.setData({ list });
wx.showToast({ title: '已移除', icon: 'none' });
}).catch(() => {});
},
onExercise(e) {
if (this.data.managing) return;
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });
},
goRecommend() {
wx.navigateTo({ url: '/pages/recommend/recommend' });
}
});

View File

@@ -0,0 +1,7 @@
{
"navigationStyle": "custom",
"usingComponents": {
"navbar": "/components/navbar/index",
"exercise-card": "/components/exercise-card/index"
}
}

View File

@@ -0,0 +1,28 @@
<view class="page">
<navbar title="我的收藏" show-back />
<view class="bar">
<view class="bar__count">{{list.length}} 个收藏动作</view>
<view wx:if="{{list.length}}" class="bar__manage" bindtap="onManage">{{managing ? '完成' : '管理'}}</view>
</view>
<view class="section" style="padding-top: 8rpx;">
<view class="grid" wx:if="{{list.length}}">
<view class="grid__item" wx:for="{{list}}" wx:key="id">
<view class="item">
<exercise-card exercise="{{item}}" bind:tap="onExercise" />
<view wx:if="{{managing}}" class="remove" data-id="{{item.id}}" catchtap="onRemove">移除</view>
</view>
</view>
</view>
<view wx:elif="{{!loading}}" class="empty-box">
<view class="empty-box__emoji">🔍</view>
<view class="empty-box__t">还没有收藏,去发现喜欢的动作吧</view>
<view class="empty-box__btn" bindtap="goRecommend">去推荐</view>
</view>
<view wx:else class="empty">加载中…</view>
</view>
<view style="height: 80rpx;"></view>
</view>

View File

@@ -0,0 +1,33 @@
.page { min-height: 100vh; background: var(--bg-grad); }
.bar {
display: flex; align-items: center; justify-content: space-between;
padding: 16rpx 32rpx;
}
.bar__count { font-size: 28rpx; color: var(--text-2); }
.bar__manage {
font-size: 28rpx; color: var(--gold); font-weight: 600;
padding: 8rpx 28rpx; border: 1rpx solid var(--gold-line);
border-radius: 999rpx;
}
.grid { display: flex; flex-wrap: wrap; gap: 18rpx; }
.grid__item { width: calc((100% - 18rpx) / 2); }
.item { position: relative; }
.remove {
position: absolute; right: 12rpx; top: 12rpx; z-index: 5;
background: rgba(229,84,75,0.92); color: #fff;
font-size: 22rpx; padding: 8rpx 18rpx; border-radius: 999rpx;
}
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 120rpx 0; }
.empty-box { display: flex; flex-direction: column; align-items: center; padding: 120rpx 40rpx; }
.empty-box__emoji {
width: 140rpx; height: 140rpx; border-radius: 999rpx;
background: var(--surface-2); display: flex; align-items: center; justify-content: center;
font-size: 72rpx;
}
.empty-box__t { font-size: 28rpx; color: var(--text-2); margin-top: 32rpx; text-align: center; }
.empty-box__btn {
margin-top: 40rpx; padding: 22rpx 64rpx; border-radius: 999rpx;
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%);
color: #0E1110; font-weight: 700; font-size: 30rpx;
}

View File

@@ -14,9 +14,9 @@ Page({
{ key: 'recommend', label: '智能推荐', emoji: '✨', url: '/pages/recommend/recommend' },
{ key: 'bodyPart', label: '按部位', emoji: '💪', url: '/pages/category/category?dimension=bodyPart' },
{ key: 'equipment', label: '按器械', emoji: '🏋️', url: '/pages/category/category?dimension=equipment' },
{ key: 'plan', label: '训练计划', emoji: '📋', url: '/pages/collection/collection' },
{ key: 'home', label: '居家无器械', emoji: '🏠', url: '/pages/collection-detail/collection-detail?slug=home' },
{ key: 'search', label: '搜索', emoji: '🔍', url: '/pages/search/search' }
{ key: 'official', label: '官方计划', emoji: '📋', url: '/pages/collection/collection' },
{ key: 'myPlans', label: '我的计划', emoji: '🗂️', url: '/pages/my-plans/my-plans' },
{ key: 'mine', label: '我的', emoji: '👤', url: '/pages/profile/profile' }
]
},

View File

@@ -0,0 +1,44 @@
const app = getApp();
const planSvc = require('../../services/plan');
const auth = require('../../utils/auth');
Page({
data: {
statusBarHeight: 20,
plans: [],
loading: true
},
onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20 });
},
onShow() {
if (auth.isLogin()) this.load();
else {
wx.showToast({ title: '请先登录', icon: 'none' });
setTimeout(() => wx.navigateBack(), 600);
}
},
load() {
this.setData({ loading: true });
planSvc.listMine().then((plans) => {
this.setData({ plans: plans || [], loading: false });
}).catch(() => {
this.setData({ plans: [], loading: false });
});
},
onNew() {
wx.navigateTo({ url: '/pages/plan-edit/plan-edit?mode=new' });
},
onOpen(e) {
wx.navigateTo({ url: '/pages/plan-detail/plan-detail?id=' + encodeURIComponent(e.currentTarget.dataset.id) });
},
goOfficial() {
wx.navigateTo({ url: '/pages/collection/collection' });
}
});

View File

@@ -0,0 +1,8 @@
{
"navigationStyle": "custom",
"usingComponents": {
"navbar": "/components/navbar/index",
"plan-card": "/components/plan-card/index",
"bottom-nav": "/components/bottom-nav/index"
}
}

View File

@@ -0,0 +1,30 @@
<view class="page">
<navbar title="我的训练计划" show-back />
<view class="newbar">
<view class="newbar__btn" bindtap="onNew"> 新建计划</view>
</view>
<view class="section" style="padding-top: 8rpx;">
<block wx:if="{{plans.length}}">
<view class="grid">
<view class="grid__item" wx:for="{{plans}}" wx:key="id">
<plan-card
plan="{{item}}"
target="/pages/plan-detail/plan-detail?id={{item.id}}" />
</view>
</view>
</block>
<view wx:elif="{{!loading}}" class="empty-box">
<view class="empty-box__emoji">📋</view>
<view class="empty-box__t">还没有计划,创建一个开始训练吧</view>
<view class="empty-box__btn" bindtap="onNew"> 新建计划</view>
<view class="empty-box__link" bindtap="goOfficial">或浏览官方训练计划 </view>
</view>
<view wx:else class="empty">加载中…</view>
</view>
<view style="height: 160rpx;"></view>
<bottom-nav active="plans" />
</view>

View File

@@ -0,0 +1,25 @@
.page { min-height: 100vh; background: var(--bg-grad); }
.newbar { padding: 16rpx 32rpx; }
.newbar__btn {
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%);
color: #0E1110; font-weight: 700; font-size: 32rpx;
border-radius: 999rpx; padding: 26rpx;
}
.grid { display: flex; flex-wrap: wrap; gap: 18rpx; }
.grid__item { width: calc((100% - 18rpx) / 2); }
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 120rpx 0; }
.empty-box { display: flex; flex-direction: column; align-items: center; padding: 120rpx 40rpx; }
.empty-box__emoji {
width: 160rpx; height: 160rpx; border-radius: 36rpx;
background: var(--surface-2); display: flex; align-items: center; justify-content: center;
font-size: 88rpx;
}
.empty-box__t { font-size: 30rpx; color: var(--text-2); margin-top: 36rpx; text-align: center; }
.empty-box__btn {
margin-top: 44rpx; padding: 24rpx 88rpx; border-radius: 999rpx;
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%);
color: #0E1110; font-weight: 700; font-size: 30rpx;
}
.empty-box__link { margin-top: 28rpx; font-size: 26rpx; color: var(--gold); }

View File

@@ -0,0 +1,74 @@
const app = getApp();
const planSvc = require('../../services/plan');
Page({
data: {
statusBarHeight: 20,
id: '',
plan: null,
exercises: [],
loading: true
},
onLoad(query) {
const id = query.id || '';
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20, id });
this.load();
},
load() {
this.setData({ loading: true });
planSvc.get(this.data.id).then((plan) => {
const raw = plan && plan.updatedAt;
const updatedAtText = typeof raw === 'string'
? raw.slice(0, 10)
: (raw ? String(raw).slice(0, 10) : '');
this.setData({
plan: { ...plan, updatedAtText },
exercises: (plan && plan.exercises) || [],
loading: false
});
}).catch(() => {
this.setData({ loading: false });
});
},
onRemoveExercise(e) {
const exerciseId = e.currentTarget.dataset.id;
planSvc.removeExercise(this.data.id, exerciseId).then(() => {
this.setData({ exercises: this.data.exercises.filter(i => i.id !== exerciseId) });
wx.showToast({ title: '已移除', icon: 'none' });
}).catch(() => {});
},
onExercise(e) {
wx.navigateTo({
url: '/pages/detail/detail?planId=' + encodeURIComponent(this.data.id) +
'&id=' + encodeURIComponent(e.detail.id)
});
},
onAdd() {
wx.navigateTo({ url: '/pages/search/search?mode=select&planId=' + encodeURIComponent(this.data.id) });
},
onEdit() {
wx.navigateTo({ url: '/pages/plan-edit/plan-edit?mode=edit&id=' + encodeURIComponent(this.data.id) });
},
onDelete() {
wx.showModal({
title: '删除计划',
content: '确定要删除该训练计划吗?此操作不可恢复。',
confirmColor: '#E5544B',
success: (res) => {
if (res.confirm) {
planSvc.remove(this.data.id).then(() => {
wx.showToast({ title: '已删除', icon: 'success' });
setTimeout(() => wx.navigateBack(), 500);
}).catch(() => {});
}
}
});
}
});

View File

@@ -0,0 +1,8 @@
{
"navigationStyle": "custom",
"usingComponents": {
"navbar": "/components/navbar/index",
"exercise-card": "/components/exercise-card/index",
"section-header": "/components/section-header/index"
}
}

View File

@@ -0,0 +1,39 @@
<view class="page" wx:if="{{plan}}">
<navbar title="{{plan.name}}" show-back />
<view class="header" style="background: linear-gradient(135deg, {{plan.color}}26, var(--surface));">
<view class="header__emoji" style="background: {{plan.color}}22; color: {{plan.color}};">{{plan.emoji}}</view>
<view class="header__name" style="color: {{plan.color}};">{{plan.name}}</view>
<view wx:if="{{plan.description}}" class="header__desc">{{plan.description}}</view>
<view class="header__meta">共 {{exercises.length}} 个动作<text wx:if="{{plan.updatedAtText}}"> · 更新于 {{plan.updatedAtText}}</text></view>
</view>
<view class="section" style="padding-top: 8rpx;">
<section-header eyebrow="EXERCISES" title="动作列表" subtitle="点击查看详情 · 可在动作页加入本计划" />
<view class="list" wx:if="{{exercises.length}}">
<view class="list__item" wx:for="{{exercises}}" wx:key="id">
<view class="list__card">
<exercise-card exercise="{{item}}" bind:tap="onExercise" />
</view>
<view class="list__remove" data-id="{{item.id}}" catchtap="onRemoveExercise">移除</view>
</view>
</view>
<view wx:elif="{{!loading}}" class="empty-box">
<view class="empty-box__emoji">🏋️</view>
<view class="empty-box__t">这个计划还没有动作,去添加吧</view>
<view class="empty-box__btn" bindtap="onAdd"> 添加动作</view>
</view>
<view wx:else class="empty">加载中…</view>
</view>
<view class="actionbar">
<view class="btn btn--gold" bindtap="onAdd"> 添加动作</view>
<view class="btn btn--ghost" bindtap="onEdit">编辑</view>
<view class="btn btn--del" bindtap="onDelete">删除计划</view>
</view>
</view>
<view wx:else class="empty-page">
<view class="empty">{{ loading ? '加载中…' : '未找到该计划' }}</view>
</view>

View File

@@ -0,0 +1,50 @@
.page { min-height: 100vh; background: var(--bg-grad); padding-bottom: 160rpx; }
.header { padding: 32rpx; border-bottom: 1rpx solid var(--line); }
.header__emoji {
width: 120rpx; height: 120rpx; border-radius: 28rpx;
display: flex; align-items: center; justify-content: center; font-size: 64rpx;
}
.header__name { font-size: 44rpx; font-weight: 700; margin-top: 20rpx; }
.header__desc { font-size: 26rpx; color: var(--text-2); margin-top: 12rpx; line-height: 1.5; }
.header__meta { font-size: 24rpx; color: var(--text-2); margin-top: 16rpx; }
.list { display: flex; flex-direction: column; gap: 16rpx; }
.list__item { position: relative; }
.list__card { width: 100%; }
.list__remove {
position: absolute; right: 12rpx; top: 12rpx; z-index: 5;
background: rgba(229,84,75,0.92); color: #fff;
font-size: 22rpx; padding: 8rpx 18rpx; border-radius: 999rpx;
}
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 120rpx 0; }
.empty-page { min-height: 100vh; background: var(--bg-grad); }
.empty-box { display: flex; flex-direction: column; align-items: center; padding: 100rpx 40rpx; }
.empty-box__emoji {
width: 150rpx; height: 150rpx; border-radius: 36rpx;
background: var(--surface-2); display: flex; align-items: center; justify-content: center;
font-size: 80rpx;
}
.empty-box__t { font-size: 28rpx; color: var(--text-2); margin-top: 32rpx; text-align: center; }
.empty-box__btn {
margin-top: 40rpx; padding: 22rpx 64rpx; border-radius: 999rpx;
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%);
color: #0E1110; font-weight: 700; font-size: 30rpx;
}
.actionbar {
position: fixed; left: 0; right: 0; bottom: 0;
display: flex; gap: 16rpx; padding: 20rpx 24rpx;
background: rgba(14,17,16,0.92);
backdrop-filter: blur(12px);
border-top: 1rpx solid var(--line);
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
}
.btn {
flex: 1; display: flex; align-items: center; justify-content: center;
border-radius: 999rpx; padding: 24rpx 10rpx; font-size: 28rpx; font-weight: 700;
}
.btn--gold { background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%); color: #0E1110; }
.btn--ghost { background: var(--surface-2); color: var(--text); border: 1rpx solid var(--line); flex: 0 0 auto; width: 180rpx; }
.btn--del { background: rgba(229,84,75,0.16); color: #E5544B; border: 1rpx solid rgba(229,84,75,0.4); flex: 0 0 auto; width: 180rpx; }

View File

@@ -0,0 +1,88 @@
const app = getApp();
const planSvc = require('../../services/plan');
const EMOJIS = ['📋', '💪', '🔥', '🏋️', '🏃', '🧘', '🤸', '🥊', '🚴', '⚡', '🌟', '🎯'];
const COLORS = ['#D9B36C', '#58C9B9', '#9B8CFF', '#F08A5D', '#3FBF7F', '#5B9DF9'];
Page({
data: {
statusBarHeight: 20,
mode: 'new',
id: '',
name: '',
description: '',
emoji: '📋',
color: '#D9B36C',
emojis: EMOJIS,
colors: COLORS,
loading: false,
dirty: false // 是否有未保存的修改
},
onLoad(query) {
const mode = query.mode === 'edit' ? 'edit' : 'new';
const id = query.id || '';
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20, mode, id });
if (mode === 'edit' && id) {
this.setData({ loading: true });
planSvc.get(id).then((p) => {
this.setData({
name: p.name || '',
description: p.description || '',
emoji: p.emoji || '📋',
color: p.color || '#D9B36C',
loading: false
});
}).catch(() => {
this.setData({ loading: false });
});
}
},
onName(e) { this.setData({ name: e.detail.value, dirty: true }); },
onDesc(e) { this.setData({ description: e.detail.value, dirty: true }); },
onPickEmoji(e) { this.setData({ emoji: e.currentTarget.dataset.emoji, dirty: true }); },
onPickColor(e) { this.setData({ color: e.currentTarget.dataset.color, dirty: true }); },
// 点返回键navbar 拦截模式)触发:有未保存改动时先确认
onBackRequest() {
if (!this.data.dirty) {
wx.navigateBack();
return;
}
wx.showModal({
title: '放弃编辑?',
content: '当前修改尚未保存,确定要退出吗?',
confirmText: '退出',
cancelText: '继续编辑',
success: (res) => {
if (res.confirm) wx.navigateBack();
}
});
},
onSave() {
const name = (this.data.name || '').trim();
if (!name) {
wx.showToast({ title: '请填写计划名称', icon: 'none' });
return;
}
const dto = {
name,
description: (this.data.description || '').trim(),
emoji: this.data.emoji,
color: this.data.color
};
wx.showLoading({ title: '保存中…', mask: true });
const op = this.data.mode === 'edit'
? planSvc.update(this.data.id, dto)
: planSvc.create(dto);
op.then(() => {
wx.hideLoading();
wx.showToast({ title: '已保存', icon: 'success' });
setTimeout(() => wx.navigateBack(), 500);
}).catch(() => {
wx.hideLoading();
});
}
});

View File

@@ -0,0 +1,6 @@
{
"navigationStyle": "custom",
"usingComponents": {
"navbar": "/components/navbar/index"
}
}

View File

@@ -0,0 +1,64 @@
<view class="page">
<navbar title="{{mode === 'edit' ? '编辑计划' : '新建计划'}}" show-back intercept-back bind:back="onBackRequest" />
<view class="section">
<view class="preview" style="background: linear-gradient(135deg, {{color}}26, var(--surface));">
<view class="preview__emoji" style="background: {{color}}22; color: {{color}};">{{emoji}}</view>
<view class="preview__name" style="color: {{color}};">{{name || '计划名称'}}</view>
<view class="preview__desc">{{description || '计划描述(选填)'}}</view>
</view>
<view class="field">
<view class="field__label">计划名称</view>
<input
class="field__input"
value="{{name}}"
placeholder="例如:居家增肌 12 周"
placeholder-class="ph"
maxlength="20"
bindinput="onName" />
</view>
<view class="field">
<view class="field__label">计划描述</view>
<textarea
class="field__area"
value="{{description}}"
placeholder="简单描述你的训练目标(选填)"
placeholder-class="ph"
maxlength="120"
auto-height
bindinput="onDesc" />
</view>
<view class="field">
<view class="field__label">图标</view>
<scroll-view scroll-x class="emoji-row">
<view
wx:for="{{emojis}}"
wx:key="*this"
class="emoji {{emoji === item ? 'emoji--active' : ''}}"
style="{{emoji === item ? 'border-color:' + color + '; background:' + color + '22;' : ''}}"
data-emoji="{{item}}"
bindtap="onPickEmoji">{{item}}</view>
</scroll-view>
</view>
<view class="field">
<view class="field__label">主题色</view>
<view class="colors">
<view
wx:for="{{colors}}"
wx:key="*this"
class="swatch {{color === item ? 'swatch--active' : ''}}"
style="background: {{item}};"
data-color="{{item}}"
bindtap="onPickColor"></view>
</view>
</view>
</view>
<view class="savebar">
<view class="savebar__btn" bindtap="onSave">保存计划</view>
</view>
</view>

View File

@@ -0,0 +1,54 @@
.page { min-height: 100vh; background: var(--bg-grad); padding-bottom: 160rpx; }
.preview {
border-radius: var(--radius); padding: 36rpx;
border: 1rpx solid var(--line); text-align: center;
}
.preview__emoji {
width: 120rpx; height: 120rpx; border-radius: 28rpx;
display: inline-flex; align-items: center; justify-content: center;
font-size: 64rpx;
}
.preview__name { font-size: 38rpx; font-weight: 700; margin-top: 20rpx; }
.preview__desc { font-size: 24rpx; color: var(--text-2); margin-top: 10rpx; }
.field { margin-top: 40rpx; }
.field__label { font-size: 26rpx; color: var(--text-2); margin-bottom: 16rpx; }
.field__input, .field__area {
background: var(--surface); border: 1rpx solid var(--line);
border-radius: var(--radius-sm); padding: 24rpx; color: var(--text);
font-size: 30rpx; width: 100%;
}
.field__area { min-height: 140rpx; }
.ph { color: var(--text-3); }
.emoji-row { white-space: nowrap; }
.emoji {
display: inline-flex; align-items: center; justify-content: center;
width: 84rpx; height: 84rpx; margin-right: 14rpx;
border-radius: 20rpx; background: var(--surface-2);
border: 1rpx solid var(--line); font-size: 44rpx;
}
.emoji--active { border-width: 2rpx; }
.colors { display: flex; flex-wrap: wrap; gap: 24rpx; }
.swatch {
width: 72rpx; height: 72rpx; border-radius: 999rpx;
border: 4rpx solid transparent; box-sizing: border-box;
}
.swatch--active { border-color: var(--text); box-shadow: 0 0 0 2rpx var(--bg); }
.savebar {
position: fixed; left: 0; right: 0; bottom: 0;
padding: 20rpx 32rpx;
background: rgba(14,17,16,0.92);
backdrop-filter: blur(12px);
border-top: 1rpx solid var(--line);
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
}
.savebar__btn {
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%);
color: #0E1110; font-weight: 700; font-size: 32rpx;
border-radius: 999rpx; padding: 26rpx;
}

View File

@@ -0,0 +1,36 @@
const app = getApp();
const favSvc = require('../../services/favorite');
const auth = require('../../utils/auth');
Page({
data: {
statusBarHeight: 20,
plans: [],
loading: true
},
onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20 });
},
onShow() {
if (auth.isLogin()) this.load();
else {
wx.showToast({ title: '请先登录', icon: 'none' });
setTimeout(() => wx.navigateBack(), 600);
}
},
load() {
this.setData({ loading: true });
favSvc.listPlans().then((plans) => {
this.setData({ plans: plans || [], loading: false });
}).catch(() => {
this.setData({ plans: [], loading: false });
});
},
goOfficial() {
wx.navigateTo({ url: '/pages/collection/collection' });
}
});

View File

@@ -0,0 +1,7 @@
{
"navigationStyle": "custom",
"usingComponents": {
"navbar": "/components/navbar/index",
"plan-card": "/components/plan-card/index"
}
}

View File

@@ -0,0 +1,24 @@
<view class="page">
<navbar title="收藏的官方计划" show-back />
<view class="section" style="padding-top: 8rpx;">
<block wx:if="{{plans.length}}">
<view class="grid">
<view class="grid__item" wx:for="{{plans}}" wx:key="slug">
<plan-card
plan="{{item}}"
target="/pages/collection-detail/collection-detail?slug={{item.slug}}" />
</view>
</view>
</block>
<view wx:elif="{{!loading}}" class="empty-box">
<view class="empty-box__emoji">⭐</view>
<view class="empty-box__t">还没有收藏官方计划</view>
<view class="empty-box__btn" bindtap="goOfficial">浏览官方计划</view>
</view>
<view wx:else class="empty">加载中…</view>
</view>
<view style="height: 80rpx;"></view>
</view>

View File

@@ -0,0 +1,17 @@
.page { min-height: 100vh; background: var(--bg-grad); }
.grid { display: flex; flex-wrap: wrap; gap: 18rpx; }
.grid__item { width: calc((100% - 18rpx) / 2); }
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 120rpx 0; }
.empty-box { display: flex; flex-direction: column; align-items: center; padding: 120rpx 40rpx; }
.empty-box__emoji {
width: 150rpx; height: 150rpx; border-radius: 36rpx;
background: var(--surface-2); display: flex; align-items: center; justify-content: center;
font-size: 80rpx;
}
.empty-box__t { font-size: 28rpx; color: var(--text-2); margin-top: 32rpx; text-align: center; }
.empty-box__btn {
margin-top: 40rpx; padding: 22rpx 64rpx; border-radius: 999rpx;
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%);
color: #0E1110; font-weight: 700; font-size: 30rpx;
}

View File

@@ -0,0 +1,129 @@
const app = getApp();
const auth = require('../../utils/auth');
const favSvc = require('../../services/favorite');
const planSvc = require('../../services/plan');
const EMOJIS = ['💪', '🏋️', '🔥', '🏃', '🧘', '🤸', '🥊', '🚴', '⚡', '🌟', '🐯', '🦁'];
Page({
data: {
statusBarHeight: 20,
loggedIn: false,
profile: { nickname: '', avatar: '💪' },
counts: { fav: 0, plans: 0, favPlans: 0 },
loading: false,
// 编辑态
showEmojiPicker: false,
emojis: EMOJIS,
draftNickname: '',
draftAvatar: '💪'
},
onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20 });
},
onShow() {
this.loadData();
},
loadData() {
if (!auth.isLogin()) {
this.setData({ loggedIn: false });
return;
}
const profile = auth.getProfile();
this.setData({ loggedIn: true, profile, loading: true });
Promise.all([
favSvc.listExercises().catch(() => []),
planSvc.listMine().catch(() => []),
favSvc.listPlans().catch(() => [])
]).then(([fav, plans, favPlans]) => {
this.setData({
counts: {
fav: (fav || []).length,
plans: (plans || []).length,
favPlans: (favPlans || []).length
},
loading: false
});
}).catch(() => {
this.setData({ loading: false });
});
},
onLogin() {
auth.login().then(() => {
this.loadData();
wx.showToast({ title: '登录成功', icon: 'success' });
}).catch(() => {});
},
onEdit() {
const cur = this.data.profile;
wx.showModal({
title: '修改昵称',
editable: true,
placeholderText: '输入昵称',
content: cur.nickname || '',
success: (res) => {
if (res.confirm) {
this.setData({
draftNickname: (res.content || '').trim() || cur.nickname,
draftAvatar: cur.avatar || '💪',
showEmojiPicker: true
});
}
}
});
},
onPickEmoji(e) {
this.setData({ draftAvatar: e.currentTarget.dataset.emoji });
},
onCancelEdit() {
this.setData({ showEmojiPicker: false });
},
onConfirmEdit() {
const nickname = (this.data.draftNickname || '').trim();
const avatar = this.data.draftAvatar;
if (!nickname) {
wx.showToast({ title: '昵称不能为空', icon: 'none' });
return;
}
wx.showLoading({ title: '保存中…', mask: true });
auth.updateProfile({ nickname, avatar }).then((p) => {
wx.hideLoading();
this.setData({ profile: p, showEmojiPicker: false });
wx.showToast({ title: '已保存', icon: 'success' });
}).catch(() => {
wx.hideLoading();
});
},
onLogout() {
wx.showModal({
title: '退出登录',
content: '确定要退出当前账号吗?',
success: (res) => {
if (res.confirm) {
auth.logout();
this.setData({
loggedIn: false,
profile: { nickname: '', avatar: '💪' },
counts: { fav: 0, plans: 0, favPlans: 0 }
});
}
}
});
},
goFav() { wx.navigateTo({ url: '/pages/favorites/favorites' }); },
goPlans() { wx.navigateTo({ url: '/pages/my-plans/my-plans' }); },
goFavPlans() { wx.navigateTo({ url: '/pages/plan-favorites/plan-favorites' }); },
goOfficial() { wx.navigateTo({ url: '/pages/collection/collection' }); },
noop() {}
});

View File

@@ -0,0 +1,8 @@
{
"navigationStyle": "custom",
"usingComponents": {
"navbar": "/components/navbar/index",
"avatar": "/components/avatar/index",
"bottom-nav": "/components/bottom-nav/index"
}
}

View File

@@ -0,0 +1,87 @@
<view class="page">
<navbar title="我的" />
<!-- 未登录 -->
<view wx:if="{{!loggedIn}}" class="login">
<view class="login__logo">💪</view>
<view class="login__title">FITCOACH 智能健身教练</view>
<view class="login__sub">登录后同步你的收藏与训练计划</view>
<button class="login__btn" bindtap="onLogin">微信登录</button>
</view>
<!-- 已登录 -->
<view wx:else class="center">
<view class="profile">
<view class="profile__head">
<avatar avatar="{{profile.avatar}}" size="{{120}}" />
<view class="profile__info">
<view class="profile__name">{{profile.nickname || '健身达人'}}</view>
<view class="profile__edit" bindtap="onEdit">编辑 </view>
</view>
</view>
<view class="stats">
<view class="stat" bindtap="goFav">
<view class="stat__n">{{counts.fav}}</view>
<view class="stat__l">收藏动作</view>
</view>
<view class="stat" bindtap="goPlans">
<view class="stat__n">{{counts.plans}}</view>
<view class="stat__l">我的计划</view>
</view>
<view class="stat" bindtap="goFavPlans">
<view class="stat__n">{{counts.favPlans}}</view>
<view class="stat__l">收藏计划</view>
</view>
</view>
</view>
<view class="menu card">
<view class="cell" bindtap="goFav">
<text class="cell__icon">❤️</text>
<text class="cell__label">我的收藏动作</text>
<text class="cell__arrow"></text>
</view>
<view class="cell" bindtap="goPlans">
<text class="cell__icon">🗂️</text>
<text class="cell__label">我的训练计划</text>
<text class="cell__arrow"></text>
</view>
<view class="cell" bindtap="goFavPlans">
<text class="cell__icon">⭐</text>
<text class="cell__label">收藏的官方计划</text>
<text class="cell__arrow"></text>
</view>
<view class="cell" bindtap="goOfficial">
<text class="cell__icon">📋</text>
<text class="cell__label">官方训练计划</text>
<text class="cell__arrow"></text>
</view>
</view>
<view class="logout" bindtap="onLogout">退出登录</view>
</view>
<!-- 编辑头像弹层 -->
<view wx:if="{{showEmojiPicker}}" class="sheet" bindtap="onCancelEdit">
<view class="sheet__panel" catchtap="noop">
<view class="sheet__title">选择头像</view>
<scroll-view scroll-x class="emoji-row">
<view
wx:for="{{emojis}}"
wx:key="*this"
class="emoji {{draftAvatar === item ? 'emoji--active' : ''}}"
data-emoji="{{item}}"
bindtap="onPickEmoji">{{item}}</view>
</scroll-view>
<view class="sheet__nick">{{draftNickname || '未命名'}}</view>
<view class="sheet__actions">
<view class="sheet__btn sheet__btn--ghost" bindtap="onCancelEdit">取消</view>
<view class="sheet__btn sheet__btn--gold" bindtap="onConfirmEdit">保存</view>
</view>
</view>
</view>
<view style="height: 160rpx;"></view>
<bottom-nav active="mine" />
</view>

View File

@@ -0,0 +1,84 @@
.page { min-height: 100vh; background: var(--bg-grad); }
/* 未登录 */
.login {
min-height: 70vh; display: flex; flex-direction: column;
align-items: center; justify-content: center; padding: 60rpx;
}
.login__logo {
width: 160rpx; height: 160rpx; border-radius: 999rpx;
background: var(--gold-soft); border: 1rpx solid var(--gold-line);
display: flex; align-items: center; justify-content: center;
font-size: 88rpx;
}
.login__title { font-size: 38rpx; font-weight: 700; margin-top: 32rpx; color: var(--text); }
.login__sub { font-size: 26rpx; color: var(--text-2); margin-top: 14rpx; }
.login__btn {
margin-top: 56rpx; width: 420rpx;
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%);
color: #0E1110; font-weight: 700; font-size: 32rpx;
border-radius: 999rpx; padding: 24rpx;
}
.login__btn::after { border: none; }
/* 个人中心 */
.center { padding: 32rpx; }
.profile {
background: linear-gradient(135deg, rgba(217,179,108,0.14), var(--surface));
border: 1rpx solid var(--gold-line);
border-radius: var(--radius); padding: 36rpx;
}
.profile__head { display: flex; align-items: center; gap: 28rpx; }
.profile__info { flex: 1; min-width: 0; }
.profile__name { font-size: 40rpx; font-weight: 700; color: var(--gold); }
.profile__edit { font-size: 26rpx; color: var(--text-2); margin-top: 10rpx; }
.stats { display: flex; margin-top: 36rpx; }
.stat { flex: 1; display: flex; flex-direction: column; align-items: center; }
.stat + .stat { border-left: 1rpx solid var(--line); }
.stat__n { font-size: 44rpx; font-weight: 700; color: var(--text); }
.stat__l { font-size: 24rpx; color: var(--text-2); margin-top: 8rpx; }
.menu { margin-top: 28rpx; padding: 8rpx 28rpx; }
.cell {
display: flex; align-items: center; gap: 24rpx;
padding: 30rpx 0; border-bottom: 1rpx solid var(--line);
}
.cell:last-child { border-bottom: none; }
.cell__icon { font-size: 38rpx; width: 48rpx; text-align: center; }
.cell__label { flex: 1; font-size: 30rpx; color: var(--text); }
.cell__arrow { font-size: 40rpx; color: var(--gold); line-height: 1; }
.logout {
margin-top: 40rpx; text-align: center;
font-size: 28rpx; color: var(--text-3);
}
/* 头像选择弹层 */
.sheet {
position: fixed; inset: 0; z-index: 80;
background: rgba(0,0,0,0.5);
display: flex; align-items: flex-end;
}
.sheet__panel {
width: 100%; background: var(--surface);
border-top-left-radius: 32rpx; border-top-right-radius: 32rpx;
padding: 36rpx 32rpx calc(36rpx + env(safe-area-inset-bottom));
}
.sheet__title { font-size: 30rpx; font-weight: 700; color: var(--text); }
.emoji-row { white-space: nowrap; margin-top: 24rpx; }
.emoji {
display: inline-flex; align-items: center; justify-content: center;
width: 88rpx; height: 88rpx; margin-right: 16rpx;
border-radius: 20rpx; background: var(--surface-2);
border: 1rpx solid var(--line); font-size: 48rpx;
}
.emoji--active { border-color: var(--gold); background: var(--gold-soft); }
.sheet__nick { margin-top: 24rpx; font-size: 28rpx; color: var(--text-2); text-align: center; }
.sheet__actions { display: flex; gap: 20rpx; margin-top: 28rpx; }
.sheet__btn {
flex: 1; text-align: center; padding: 24rpx; border-radius: 999rpx;
font-size: 30rpx; font-weight: 700;
}
.sheet__btn--ghost { background: var(--surface-2); color: var(--text); border: 1rpx solid var(--line); }
.sheet__btn--gold { background: linear-gradient(135deg, var(--gold) 0%, var(--gold-2) 100%); color: #0E1110; }

View File

@@ -1,5 +1,6 @@
const app = getApp();
const svc = require('../../services/exercise');
const planSvc = require('../../services/plan');
Page({
data: {
@@ -7,11 +8,24 @@ Page({
q: '',
results: [],
loading: false,
searched: false
searched: false,
selectMode: false,
planId: '',
navTitle: '搜索'
},
onLoad() {
onLoad(query) {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 20 });
const selectMode = query.mode === 'select';
const planId = query.planId || '';
this.setData({
selectMode,
planId,
navTitle: selectMode ? '选择动作' : '搜索'
});
if (selectMode) {
wx.setNavigationBarTitle({ title: '选择动作' });
}
},
onInput(e) {
@@ -43,6 +57,14 @@ Page({
},
onExercise(e) {
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(e.detail.id) });
const id = e.detail.id;
if (this.data.selectMode && this.data.planId) {
planSvc.addExercise(this.data.planId, id).then(() => {
wx.showToast({ title: '已添加', icon: 'success' });
setTimeout(() => wx.navigateBack(), 500);
}).catch(() => {});
return;
}
wx.navigateTo({ url: '/pages/detail/detail?id=' + encodeURIComponent(id) });
}
});

View File

@@ -1,17 +1,18 @@
<view class="page">
<navbar title="搜索" />
<navbar title="{{navTitle}}" show-back="{{selectMode}}" />
<view class="searchbar">
<view class="searchbar__box">
<text class="searchbar__icon">🔍</text>
<input
class="searchbar__input"
placeholder="搜索动作 / 器械 / 部位"
placeholder="{{selectMode ? '选择动作加入计划' : '搜索动作 / 器械 / 部位'}}"
placeholder-class="ph"
value="{{q}}"
confirm-type="search"
bindinput="onInput" />
</view>
</view>
<view wx:if="{{selectMode}}" class="hint">从下方结果中选择动作,加入当前计划</view>
<view class="section" style="padding-top: 8rpx;">
<view class="grid" wx:if="{{results.length}}">

View File

@@ -12,3 +12,4 @@
.grid { display: flex; flex-wrap: wrap; gap: 18rpx; }
.grid__item { width: calc((100% - 18rpx) / 2); }
.empty { color: var(--text-3); font-size: 26rpx; text-align: center; padding: 120rpx 0; }
.hint { padding: 16rpx 36rpx 0; font-size: 24rpx; color: var(--gold); }

View File

@@ -1,6 +1,19 @@
// 业务服务层:直接映射后端 REST 接口字段,不做多余转换。
const { request } = require('../utils/request');
// 确保动作名称以中文展示(防御层:即使后端意外返回英文也能兜底)
function injectDisplayName(item) {
if (!item) return item;
// 优先级nameZh > name后端 normalize 已把 name 设为中文)> nameEn 不展示
item.displayName = item.nameZh || item.name || '';
return item;
}
function injectDisplayNames(list) {
if (!list || !Array.isArray(list)) return list || [];
return list.map(injectDisplayName);
}
// 将参数对象序列化为 query string跳过空值
function buildQuery(params) {
const qs = Object.keys(params || {})
@@ -12,12 +25,15 @@ function buildQuery(params) {
// 1) 动作列表(支持多维筛选与排序)
function listExercises(params = {}) {
return request({ url: '/api/exercises' + buildQuery(params) });
return request({ url: '/api/exercises' + buildQuery(params) }).then((res) => {
if (res && res.items) res.items = injectDisplayNames(res.items);
return res;
});
}
// 2) 动作详情
function getExercise(id) {
return request({ url: '/api/exercises/' + encodeURIComponent(id) });
return request({ url: '/api/exercises/' + encodeURIComponent(id) }).then(injectDisplayName);
}
// 3) 分类维度枚举
@@ -27,7 +43,10 @@ function getCategories(type) {
// 4) 智能推荐
function recommend(params = {}) {
return request({ url: '/api/recommend' + buildQuery(params) });
return request({ url: '/api/recommend' + buildQuery(params) }).then((res) => {
if (res && res.items) res.items = injectDisplayNames(res.items);
return res;
});
}
// 5) 训练组合列表
@@ -37,12 +56,18 @@ function listCollections() {
// 5) 训练组合下的动作
function getCollectionExercises(slug, params = {}) {
return request({ url: '/api/collections/' + encodeURIComponent(slug) + '/exercises' + buildQuery(params) });
return request({ url: '/api/collections/' + encodeURIComponent(slug) + '/exercises' + buildQuery(params) }).then((res) => {
if (res && res.items) res.items = injectDisplayNames(res.items);
return res;
});
}
// 6) 搜索
function search(q) {
return request({ url: '/api/search?q=' + encodeURIComponent(q) });
return request({ url: '/api/search?q=' + encodeURIComponent(q) }).then((res) => {
if (res && res.items) res.items = injectDisplayNames(res.items);
return res;
});
}
// 7) 统计概览

View File

@@ -0,0 +1,73 @@
// 收藏服务:动作收藏 + 官方计划收藏(均受保护)。
const { request } = require('../utils/request');
// 复用 displayName 注入(避免循环依赖,内联轻量版)
function injectDisplayName(item) {
if (!item) return item;
item.displayName = item.nameZh || item.name || '';
return item;
}
function injectDisplayNames(list) {
if (!list || !Array.isArray(list)) return list || [];
return list.map(injectDisplayName);
}
// 收藏的动作列表
function listExercises() {
return request({ url: '/api/favorites/exercises', method: 'GET' }).then((res) => {
const items = (res && Array.isArray(res)) ? res : (res && res.items);
if (items) {
const injected = injectDisplayNames(items);
if (Array.isArray(res)) return injected;
res.items = injected;
}
return res;
});
}
// 收藏某个动作
function addExercise(id) {
return request({ url: '/api/favorites/exercises', method: 'POST', data: { target: id } });
}
// 取消收藏某个动作
function removeExercise(id) {
return request({ url: '/api/favorites/exercises/' + encodeURIComponent(id), method: 'DELETE' });
}
// 查询某动作收藏状态 { favorited }
function exerciseStatus(id) {
return request({ url: '/api/favorites/exercises/' + encodeURIComponent(id) + '/status', method: 'GET' });
}
// 收藏的官方计划列表CollectionDef[]
function listPlans() {
return request({ url: '/api/favorites/plans', method: 'GET' });
}
// 收藏某个官方计划
function addPlan(slug) {
return request({ url: '/api/favorites/plans', method: 'POST', data: { target: slug } });
}
// 取消收藏某个官方计划
function removePlan(slug) {
return request({ url: '/api/favorites/plans/' + encodeURIComponent(slug), method: 'DELETE' });
}
// 查询某官方计划收藏状态 { favorited }
function planStatus(slug) {
return request({ url: '/api/favorites/plans/' + encodeURIComponent(slug) + '/status', method: 'GET' });
}
module.exports = {
listExercises,
addExercise,
removeExercise,
exerciseStatus,
listPlans,
addPlan,
removePlan,
planStatus
};

View File

@@ -0,0 +1,92 @@
// 训练计划服务:我自己的计划 CRUD + 官方计划分级与导入。
const { request } = require('../utils/request');
function injectDisplayName(item) {
if (!item) return item;
item.displayName = item.nameZh || item.name || '';
return item;
}
function injectDisplayNames(list) {
if (!list || !Array.isArray(list)) return list || [];
return list.map(injectDisplayName);
}
function buildQuery(params) {
const qs = Object.keys(params || {})
.filter(k => params[k] !== '' && params[k] !== undefined && params[k] !== null)
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
.join('&');
return qs ? '?' + qs : '';
}
// 我自己的计划列表
function listMine() {
return request({ url: '/api/plans', method: 'GET' });
}
// 新建计划
function create(dto) {
return request({ url: '/api/plans', method: 'POST', data: dto });
}
// 计划详情
function get(id) {
return request({ url: '/api/plans/' + encodeURIComponent(id), method: 'GET' }).then((plan) => {
if (plan && plan.exercises) plan.exercises = injectDisplayNames(plan.exercises);
return plan;
});
}
// 更新计划元信息
function update(id, dto) {
return request({ url: '/api/plans/' + encodeURIComponent(id), method: 'PATCH', data: dto });
}
// 删除计划
function remove(id) {
return request({ url: '/api/plans/' + encodeURIComponent(id), method: 'DELETE' });
}
// 向计划添加动作(自动去重)
function addExercise(id, exerciseId) {
return request({ url: '/api/plans/' + encodeURIComponent(id) + '/exercises', method: 'POST', data: { exerciseId } });
}
// 从计划移除动作
function removeExercise(id, exerciseId) {
return request({ url: '/api/plans/' + encodeURIComponent(id) + '/exercises/' + encodeURIComponent(exerciseId), method: 'DELETE' });
}
// 导入官方计划为我的副本
function importOfficial(slug, name) {
return request({ url: '/api/plans/import', method: 'POST', data: { slug, name } });
}
// 官方计划分级分组
function officialGroups() {
return request({ url: '/api/plans/official', method: 'GET' });
}
// 官方计划下的动作(分页)
function officialExercises(slug, page = 1, pageSize = 30) {
return request({
url: '/api/plans/official/' + encodeURIComponent(slug) + '/exercises' + buildQuery({ page, pageSize })
}).then((res) => {
if (res && res.items) res.items = injectDisplayNames(res.items);
return res;
});
}
module.exports = {
listMine,
create,
get,
update,
remove,
addExercise,
removeExercise,
importOfficial,
officialGroups,
officialExercises
};

View File

@@ -0,0 +1,13 @@
// 用户服务:映射 /api/auth/me 与 /api/users/me。
// request 自动携带 token页面无需手动处理鉴权头。
const { request } = require('../utils/request');
function getMe() {
return request({ url: '/api/auth/me', method: 'GET' });
}
function updateProfile(dto) {
return request({ url: '/api/users/me', method: 'PATCH', data: dto });
}
module.exports = { getMe, updateProfile };

View File

@@ -1,20 +1,115 @@
// 极简登录占位:仅演示 wx.login 拿到 code不请求真实后端服务端
// 生产环境中,应将 code 发送到后端换取 openid / session。
function getOpenid() {
return new Promise((resolve) => {
// 登录与用户态管理:封装 wx.login + 后端 /api/auth/login
// 后端在首次登录时自动创建用户;开发模式(未配置 WX_APPID接受任意 code
// 并返回稳定的虚拟 openid。
const { request, setUnauthorizedHandler } = require('./request');
const TOKEN_KEY = 'access_token';
const OPENID_KEY = 'openid';
const PROFILE_KEY = 'user_profile';
// 模块加载时注册 401 自动重新登录:成功后返回新的 token失败返回 null。
setUnauthorizedHandler(async () => {
try {
await login();
return wx.getStorageSync(TOKEN_KEY);
} catch (e) {
return null;
}
});
function login() {
return new Promise((resolve, reject) => {
wx.login({
success(res) {
if (res.code) {
// 真实实现wx.request({ url: BASE_URL + '/api/auth/login', data: { code } })
console.log('[auth] wx.login code =', res.code);
}
resolve(res.code || '');
const code = res.code || 'tourist';
request({ url: '/api/auth/login', method: 'POST', data: { code } })
.then((r) => {
const token = r.token;
const user = r.user || {};
wx.setStorageSync(TOKEN_KEY, token);
if (user.openid) wx.setStorageSync(OPENID_KEY, user.openid);
const profile = { nickname: user.nickname || '', avatar: user.avatar || '' };
wx.setStorageSync(PROFILE_KEY, profile);
resolve(profile);
})
.catch((err) => {
wx.showToast({ title: '登录失败,请重试', icon: 'none' });
reject(err);
});
},
fail() {
resolve('');
// 拿不到 code 时降级为游客 code
request({ url: '/api/auth/login', method: 'POST', data: { code: 'tourist' } })
.then((r) => {
const token = r.token;
const user = r.user || {};
wx.setStorageSync(TOKEN_KEY, token);
if (user.openid) wx.setStorageSync(OPENID_KEY, user.openid);
const profile = { nickname: user.nickname || '', avatar: user.avatar || '' };
wx.setStorageSync(PROFILE_KEY, profile);
resolve(profile);
})
.catch((err) => {
wx.showToast({ title: '登录失败,请重试', icon: 'none' });
reject(err);
});
}
});
});
}
module.exports = { getOpenid };
// 确保已登录:有 token 直接返回,否则执行登录。
async function ensureLogin() {
const token = getToken();
if (token) return token;
await login();
return getToken();
}
function getToken() {
return wx.getStorageSync(TOKEN_KEY) || '';
}
function getProfile() {
return wx.getStorageSync(PROFILE_KEY) || { nickname: '', avatar: '' };
}
function isLogin() {
return !!getToken();
}
function logout() {
wx.removeStorageSync(TOKEN_KEY);
wx.removeStorageSync(OPENID_KEY);
wx.removeStorageSync(PROFILE_KEY);
}
function setProfile(p) {
const profile = Object.assign({}, getProfile(), p || {});
wx.setStorageSync(PROFILE_KEY, profile);
return profile;
}
// 更新昵称 / 头像,写回后端并刷新本地状态。
function updateProfile(dto) {
return request({ url: '/api/users/me', method: 'PATCH', data: dto })
.then((user) => {
const profile = {
nickname: (user && user.nickname) || getProfile().nickname,
avatar: (user && user.avatar) || getProfile().avatar
};
wx.setStorageSync(PROFILE_KEY, profile);
return profile;
});
}
module.exports = {
login,
ensureLogin,
getToken,
getProfile,
isLogin,
logout,
setProfile,
updateProfile
};

View File

@@ -1,22 +1,37 @@
// 统一的网络请求封装:所有请求都经过此包装器。
// 后端为公开 API无需鉴权头失败时统一 toast 提示。
// 受保护接口自动携带 Authorization: Bearer <token>;遇到 401 时由注册的
// 未授权处理器(通常是自动重新登录)处理一次,然后重试原请求。
const { BASE_URL } = require('../config');
function request({ url, method = 'GET', data = {} }) {
let unauthorizedHandler = null;
// 注册「未授权」处理回调(由 auth 模块注入,用于自动重新登录)
function setUnauthorizedHandler(fn) {
unauthorizedHandler = fn;
}
function request({ url, method = 'GET', data = {}, _retry = false }) {
return new Promise((resolve, reject) => {
const token = wx.getStorageSync('access_token');
wx.request({
url: BASE_URL + url,
method,
data,
header: { 'content-type': 'application/json' },
header: {
'content-type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {})
},
success(res) {
// 401尝试一次自动重新登录后重试
if (res.statusCode === 401 && !_retry && unauthorizedHandler) {
return unauthorizedHandler()
.then((t) => (t ? resolve(request({ url, method, data, _retry: true })) : reject(res)))
.catch(() => reject(res));
}
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data);
} else {
wx.showToast({
title: '请求失败 (' + res.statusCode + ')',
icon: 'none'
});
wx.showToast({ title: '请求失败 (' + res.statusCode + ')', icon: 'none' });
reject(res);
}
},
@@ -28,4 +43,4 @@ function request({ url, method = 'GET', data = {} }) {
});
}
module.exports = { request };
module.exports = { request, setUnauthorizedHandler };