feat: 支持教学图鉴
This commit is contained in:
2
packages/app/src/config/education.ts
Normal file
2
packages/app/src/config/education.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Same HTTPS host as the existing API; served outside /api by Nest's static middleware. */
|
||||
export const REFORMER_LESSON_URL = 'https://focus.richarjiang.com/learn/reformer/'
|
||||
22
packages/app/src/content/explore.ts
Normal file
22
packages/app/src/content/explore.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** Add new published topics here; the Explore page renders the catalogue in order. */
|
||||
export interface ExploreEntry {
|
||||
id: string
|
||||
title: string
|
||||
summary: string
|
||||
category: string
|
||||
format: string
|
||||
cover: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export const exploreEntries: readonly ExploreEntry[] = [
|
||||
{
|
||||
id: 'reformer',
|
||||
title: '认识核心床',
|
||||
summary: '在一推一回之间,读懂阻力。',
|
||||
category: '器械图鉴',
|
||||
format: '交互探索',
|
||||
cover: '/static/learn/reformer.png',
|
||||
path: '/pages/learn/reformer',
|
||||
},
|
||||
]
|
||||
34
packages/app/src/content/reformer.ts
Normal file
34
packages/app/src/content/reformer.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Reference configuration, not a prescription for the studio's own equipment.
|
||||
export const reformerLesson = {
|
||||
title: '认识核心床',
|
||||
subtitle: '在一推一回之间,读懂阻力。',
|
||||
reference: 'Balanced Body Studio Reformer · 五弹簧参考配置',
|
||||
// Internal provenance only; editorial metadata is never displayed as a product badge.
|
||||
review: { status: 'pending' as 'pending' | 'approved', coach: '', reviewedAt: '', revision: '2026-10-18.2' },
|
||||
springs: [
|
||||
{ id: 'r1', label: '红 1', color: '#b56b61', level: '中阻力' },
|
||||
{ id: 'r2', label: '红 2', color: '#b56b61', level: '中阻力' },
|
||||
{ id: 'r3', label: '红 3', color: '#b56b61', level: '中阻力' },
|
||||
{ id: 'b1', label: '蓝', color: '#668c9d', level: '轻阻力' },
|
||||
{ id: 'g1', label: '绿', color: '#7b8e6e', level: '重阻力' },
|
||||
],
|
||||
sources: [
|
||||
{ title: 'Balanced Body · Studio Reformer 产品资料', url: 'https://www.pilates.com/products/pilates-studio-reformer' },
|
||||
{ title: 'Balanced Body · Reformer 结构与原理', url: 'https://www.pilates.com/ultimate-guide-to-the-pilates-reformer/' },
|
||||
{ title: 'Studio Reformer / Tower 厂家装配手册', url: 'https://balancedbody.eu/wp-content/uploads/2022/01/Studio-Reformer-with-tower-Unpacking-and-Assembly.pdf' },
|
||||
],
|
||||
} as const
|
||||
|
||||
export const reformerParts = [
|
||||
{ id: 'carriage', name: '滑动床板', en: 'Carriage', number: '01', description: '软垫床板通过底部滚轮沿滑轨移动。肩靠与头枕随床板一起运动,弹簧连接在床板下方。', observation: '拖动下方滑块,看床板与弹簧如何一起变化。' },
|
||||
{ id: 'springs', name: '弹簧组', en: 'Springs', number: '02', description: '弹簧连接床板与脚踏端的弹簧杆。床板远离脚踏端时,已连接的弹簧被拉长;回程时,弹簧产生拉回趋势。', observation: '切到分解视图,更容易看见床板下方的弹簧。' },
|
||||
{ id: 'hooks', name: '连接挂钩', en: 'Spring connections', number: '03', description: '连接件把弹簧一端固定在床板下方,另一端挂接脚踏端的弹簧杆。是否挂接,决定这根弹簧是否参与受力。', observation: '未挂接的弹簧随床板收纳,不会继续连接固定端。' },
|
||||
{ id: 'frame', name: '主框架', en: 'Frame', number: '04', description: '木质或金属框架承托器械,并为滑轨、脚踏杆和滑轮立柱提供固定位置。框架在床板移动时保持不动。', observation: '这幅图参考木质框架机型,不代表所有品牌的结构。' },
|
||||
{ id: 'rails', name: '滑轨与滚轮', en: 'Rails & wheels', number: '05', description: '两侧滑轨引导床板的运动方向。床板下方的承重轮与侧向导轮共同支撑并引导床板平稳移动。', observation: '滑轨负责导向,弹簧负责提供弹性拉力。' },
|
||||
{ id: 'footbar', name: '脚踏杆', en: 'Footbar', number: '06', description: '为手或脚提供支撑点,使用者可借此推动床板。实际高度、角度和锁定方式随器械型号不同。', observation: '此处固定角度展示结构,实际设置由教练确认。' },
|
||||
{ id: 'ropes', name: '绳索与拉环', en: 'Ropes & loops', number: '07', description: '绳索一端固定或调节于床板,经头端滑轮转向后连接拉环,将手脚的拉动传递给床板。', observation: '本图拉环处于收纳状态,随床板移动而自然松弛。' },
|
||||
{ id: 'pulleys', name: '滑轮', en: 'Pulleys', number: '08', description: '位于头端立柱上的滑轮改变绳索方向,让拉环可从头端方向向使用者传力。它本身不是阻力来源。', observation: '图中绳路为结构示意,不模拟具体动作的传动比。' },
|
||||
{ id: 'headrest', name: '头枕与肩靠', en: 'Headrest & shoulder rests', number: '09', description: '头枕提供头部支撑,肩靠帮助定位。两者安装在滑动床板上。头枕位置需按动作与个人情况由教练调整。', observation: '此处仅展示平放头枕,不演示人体姿势。' },
|
||||
] as const
|
||||
|
||||
export type ReformerPartId = typeof reformerParts[number]['id']
|
||||
@@ -33,6 +33,18 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/learn/index",
|
||||
"style": { "navigationStyle": "custom" }
|
||||
},
|
||||
{
|
||||
"path": "pages/learn/reformer",
|
||||
"style": { "navigationBarTitleText": "认识核心床", "navigationBarBackgroundColor": "#f8f7f2" }
|
||||
},
|
||||
{
|
||||
"path": "pages/learn/reformer-static",
|
||||
"style": { "navigationStyle": "custom" }
|
||||
},
|
||||
{
|
||||
"path": "pages/card/detail",
|
||||
"style": {
|
||||
@@ -346,6 +358,12 @@
|
||||
"iconPath": "static/tab/booking.png",
|
||||
"selectedIconPath": "static/tab/booking-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/learn/index",
|
||||
"text": "探索",
|
||||
"iconPath": "static/tab/learn.png",
|
||||
"selectedIconPath": "static/tab/learn-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/profile/index",
|
||||
"text": "个人中心",
|
||||
|
||||
86
packages/app/src/pages/learn/index.vue
Normal file
86
packages/app/src/pages/learn/index.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<view class="learn-page" :style="{ paddingTop: navBarHeight + 'px' }">
|
||||
<CustomNavBar title="探索" />
|
||||
<view class="content">
|
||||
<view class="intro">
|
||||
<text class="eyebrow">FOCUS CORE · JOURNAL</text>
|
||||
<text class="title">读懂每一次练习。</text>
|
||||
<text class="subtitle">关于器械、动作与身体的小小发现。</text>
|
||||
</view>
|
||||
<view class="library">
|
||||
<button v-for="entry in exploreEntries" :key="entry.id" class="lesson-card"
|
||||
hover-class="lesson-card--pressed" :aria-label="entry.title + ',' + entry.format" @tap="openEntry(entry)">
|
||||
<view class="cover"><image class="cover-image" :src="entry.cover" mode="aspectFit" /></view>
|
||||
<view class="card-body">
|
||||
<view class="card-meta"><text>{{ entry.category }}</text><text class="meta-dot">·</text><text>{{ entry.format }}</text></view>
|
||||
<view class="card-heading"><text class="card-title">{{ entry.title }}</text><text class="card-arrow" aria-hidden="true">↗</text></view>
|
||||
<text class="card-summary">{{ entry.summary }}</text>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onShareAppMessage } from '@dcloudio/uni-app'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { exploreEntries, type ExploreEntry } from '../../content/explore'
|
||||
|
||||
const { navBarHeight } = getSystemLayout()
|
||||
function openEntry(entry: ExploreEntry) { uni.navigateTo({ url: entry.path }) }
|
||||
onShareAppMessage(() => ({ title: '探索 | 读懂每一次练习 · Focus Core', path: '/pages/learn/index' }))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.learn-page {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
background: #f8f7f2;
|
||||
color: #374438;
|
||||
}
|
||||
.content { padding: 44rpx 40rpx 64rpx; }
|
||||
.intro { padding: 18rpx 0 48rpx; }
|
||||
.eyebrow {
|
||||
display: block;
|
||||
font: 19rpx Baskerville, 'Times New Roman', serif;
|
||||
letter-spacing: 3rpx;
|
||||
color: #98a18e;
|
||||
}
|
||||
.title {
|
||||
display: block;
|
||||
margin-top: 23rpx;
|
||||
font-family: 'Songti SC', STSong, serif;
|
||||
font-size: 45rpx;
|
||||
line-height: 1.55;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
.subtitle { display: block; margin-top: 16rpx; font-size: 23rpx; line-height: 1.8; color: #929a87; }
|
||||
.library { display: flex; flex-direction: column; gap: 32rpx; }
|
||||
.lesson-card {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid #e1e6d8;
|
||||
border-radius: 25rpx;
|
||||
background: #fffefa;
|
||||
text-align: left;
|
||||
line-height: normal;
|
||||
transition: opacity .16s;
|
||||
&::after { border: 0; }
|
||||
&--pressed { opacity: .75; }
|
||||
}
|
||||
.cover { height: 270rpx; background: #eef1e6; }
|
||||
.cover-image { display: block; width: 100%; height: 100%; }
|
||||
.card-body { padding: 28rpx 30rpx 32rpx; }
|
||||
.card-meta { display: flex; align-items: center; gap: 12rpx; color: #909b80; font-size: 18rpx; letter-spacing: 1rpx; }
|
||||
.meta-dot { color: #b8c2a9; }
|
||||
.card-heading { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; margin-top: 17rpx; }
|
||||
.card-title { font: 33rpx/1.5 'Songti SC', STSong, serif; letter-spacing: 2rpx; }
|
||||
.card-arrow { flex-shrink: 0; font-size: 30rpx; color: #7f9170; line-height: 1; }
|
||||
.card-summary { display: block; margin-top: 11rpx; font-size: 23rpx; line-height: 1.8; color: #929b88; }
|
||||
@media (prefers-reduced-motion: reduce) { .lesson-card { transition: none; } }
|
||||
</style>
|
||||
22
packages/app/src/pages/learn/reformer-static.vue
Normal file
22
packages/app/src/pages/learn/reformer-static.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<view class="page" :style="{ paddingTop: navBarHeight + 'px' }">
|
||||
<CustomNavBar title="核心床图鉴" show-back />
|
||||
<view class="content"><text class="eyebrow">FIG. 01 / THE REFORMER</text><text class="title">认识核心床</text><text class="subtitle">{{ reformerLesson.subtitle }}</text><image class="illustration" src="/static/learn/reformer.png" mode="aspectFit" /><text class="diagram-note">器械结构与行程为示意,实际设置请遵循实机说明与教练指导。</text>
|
||||
<view v-for="part in reformerParts" :key="part.id" class="part"><view class="heading"><text class="number">{{ part.number }}</text><view><text class="part-title">{{ part.name }}</text><text class="english">{{ part.en }}</text></view></view><text class="description">{{ part.description }}</text><text class="observation">↳ {{ part.observation }}</text></view>
|
||||
<view class="note"><text class="note-title">弹簧既提供阻力,也可能提供辅助。</text><text class="description">组合的选择需要考虑动作与身体位置,由教练确认。本期未接入真实弹簧参数,不计算阻力数值。</text></view>
|
||||
<view class="sources"><text class="source-title">资料来源</text><text class="description">{{ reformerLesson.reference }}。本图不代表所有品牌的结构。</text><view v-for="source in reformerLesson.sources" :key="source.url" class="source" @tap="copySource(source.url)"><text>{{ source.title }}</text><text>复制链接 ↗</text></view></view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { onShareAppMessage } from '@dcloudio/uni-app'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { reformerLesson, reformerParts } from '../../content/reformer'
|
||||
const { navBarHeight } = getSystemLayout()
|
||||
function copySource(url: string) { uni.setClipboardData({ data: url }) }
|
||||
onShareAppMessage(() => ({ title: '认识核心床 | Focus Core 静态图鉴', path: '/pages/learn/reformer-static' }))
|
||||
</script>
|
||||
<style scoped>
|
||||
.page{min-height:100vh;background:#f8f7f2;color:#44533c;box-sizing:border-box}.content{padding:48rpx 42rpx 70rpx}.eyebrow{font-size:20rpx;letter-spacing:3rpx;color:#8d9a7c}.title{display:block;font-family:'Songti SC',STSong,serif;font-size:47rpx;letter-spacing:4rpx;margin:23rpx 0 20rpx}.subtitle{font-size:24rpx;color:#8a957e}.illustration{width:100%;height:410rpx;background:#f0f2e8;border-radius:25rpx;margin:35rpx 0 24rpx}.diagram-note{display:block;font-size:21rpx;line-height:1.9;color:#a0a68f}.part{padding:36rpx 0;border-bottom:1rpx solid #e0e5d8}.heading{display:flex;align-items:center;gap:24rpx;margin-bottom:22rpx}.number{font:italic 55rpx Baskerville,serif;color:#b0bda0}.part-title{font-size:29rpx;display:block;letter-spacing:2rpx}.english{font:italic 23rpx Baskerville,serif;color:#98a28c;margin-top:8rpx;display:block}.description{display:block;font-size:24rpx;line-height:1.95;color:#858f79}.observation{display:block;font-size:21rpx;line-height:1.9;color:#9ba58e;margin-top:16rpx}.note{padding:30rpx;border-radius:22rpx;background:#eaf0e1;margin-top:38rpx}.note-title{display:block;font-size:26rpx;margin-bottom:18rpx}.sources{margin-top:48rpx}.source-title{display:block;font-size:27rpx;margin-bottom:20rpx}.source{display:flex;justify-content:space-between;align-items:center;gap:20rpx;padding:26rpx 0;border-bottom:1rpx solid #e0e5d8;font-size:20rpx;line-height:1.9;color:#879478}.source text:last-child{flex-shrink:0;color:#a1ab92}
|
||||
</style>
|
||||
35
packages/app/src/pages/learn/reformer.vue
Normal file
35
packages/app/src/pages/learn/reformer.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<web-view v-if="!loading && !failed" :src="REFORMER_LESSON_URL" @error="failed = true" />
|
||||
<view v-else class="fallback">
|
||||
<text class="eyebrow">FOCUS CORE · 器械图鉴</text><text class="title">认识核心床</text>
|
||||
<image class="illustration" src="/static/learn/reformer.png" mode="aspectFit" />
|
||||
<text class="message">{{ loading ? '正在准备交互图鉴…也可以先阅读静态图鉴。' : '交互页面暂时无法打开。你仍可阅读完整的静态图鉴。' }}</text>
|
||||
<button class="primary" @tap="openStatic">阅读静态图鉴</button><button v-if="!loading" class="retry" @tap="prepare">重新加载</button>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onUnload, onShareAppMessage } from '@dcloudio/uni-app'
|
||||
import { REFORMER_LESSON_URL } from '../../config/education'
|
||||
const failed = ref(false), loading = ref(true)
|
||||
let probe: UniApp.RequestTask | undefined
|
||||
function prepare() {
|
||||
loading.value = true; failed.value = false
|
||||
// A deployed 404 is a successful web-view load. Check status before opening it.
|
||||
probe = uni.request({
|
||||
url: REFORMER_LESSON_URL,
|
||||
method: 'HEAD',
|
||||
timeout: 8000,
|
||||
success: result => { failed.value = result.statusCode < 200 || result.statusCode >= 300 },
|
||||
fail: () => { failed.value = true },
|
||||
complete: () => { loading.value = false },
|
||||
})
|
||||
}
|
||||
onLoad(prepare)
|
||||
onUnload(() => probe?.abort())
|
||||
function openStatic() { uni.redirectTo({ url: '/pages/learn/reformer-static' }) }
|
||||
onShareAppMessage(() => ({ title: '认识核心床 | Focus Core 器械图鉴', path: '/pages/learn/index' }))
|
||||
</script>
|
||||
<style scoped>
|
||||
.fallback{min-height:100vh;padding:60rpx 42rpx;background:#f8f7f2;box-sizing:border-box;color:#4b5d43}.eyebrow{font-size:20rpx;letter-spacing:3rpx;color:#899679}.title{display:block;font-family:'Songti SC',STSong,serif;font-size:46rpx;margin-top:26rpx}.illustration{width:100%;height:420rpx}.message{display:block;line-height:1.9;font-size:26rpx;color:#8a927e}.primary,.retry{margin-top:32rpx;font-size:26rpx;border-radius:50rpx;line-height:88rpx}.primary{background:#566c4b;color:#fffdf4}.retry{background:transparent;color:#869478}.retry::after{border:0}
|
||||
</style>
|
||||
BIN
packages/app/src/static/learn/reformer.png
Normal file
BIN
packages/app/src/static/learn/reformer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
BIN
packages/app/src/static/tab/learn-active.png
Normal file
BIN
packages/app/src/static/tab/learn-active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
BIN
packages/app/src/static/tab/learn.png
Normal file
BIN
packages/app/src/static/tab/learn.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
81
packages/server/public/learn/reformer/index.html
Normal file
81
packages/server/public/learn/reformer/index.html
Normal file
File diff suppressed because one or more lines are too long
14
packages/server/public/learn/reformer/reformer.svg
Normal file
14
packages/server/public/learn/reformer/reformer.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 162 KiB |
@@ -1,4 +1,6 @@
|
||||
import { NestFactory } from '@nestjs/core'
|
||||
import { NestExpressApplication } from '@nestjs/platform-express'
|
||||
import { join } from 'path'
|
||||
import { Logger, ValidationPipe } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { WinstonModule } from 'nest-winston'
|
||||
@@ -11,9 +13,20 @@ import { ApiExceptionFilter } from './common/filters/api-exception.filter'
|
||||
async function bootstrap() {
|
||||
const logger = WinstonModule.createLogger(loggerConfig)
|
||||
|
||||
const app = await NestFactory.create(AppModule, { logger })
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, { logger })
|
||||
const configService = app.get(ConfigService)
|
||||
|
||||
// Public, dependency-free educational pages; keep them outside the JSON API prefix.
|
||||
app.useStaticAssets(join(__dirname, '..', 'public', 'learn'), {
|
||||
prefix: '/learn/',
|
||||
index: 'index.html',
|
||||
maxAge: 0,
|
||||
setHeaders: (response) => {
|
||||
response.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
response.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||
},
|
||||
})
|
||||
|
||||
app.setGlobalPrefix('api')
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
|
||||
Reference in New Issue
Block a user