feat: 新增运动节拍训练工具套件与子工具页面

新增运动节拍RepClock全套小程序页面、后端API与数据库服务,新增subhub子工具汇总页面,优化全局主页样式与配置,为web静态资源添加版本缓存参数,补充配套调试脚本
This commit is contained in:
yangxiangyuan
2026-09-09 14:43:06 +08:00
parent db7648eb4d
commit d4bf3627fd
69 changed files with 5759 additions and 297 deletions
@@ -0,0 +1,214 @@
const api = require('../../../../api.js')
const STEPS_TEXT = {
sequential_once: '顺序单次',
loop_till_stop: '循环至手动停止',
loop_n_times: '指定循环次数',
}
function clamp(v, a, b) { const x = Number(v) || 0; return Math.max(a, Math.min(b, x)) }
Page({
data: {
loading: true,
cards: [],
stepsTextMap: STEPS_TEXT,
submitting: false,
lockActive: false,
lockSessionId: '',
lockUpdatedAt: '',
},
onShow() { this.refresh() },
onPullDownRefresh() {
this.refresh().finally(() => wx.stopPullDownRefresh())
},
async refresh() {
this.setData({ loading: true })
try {
const [cardsRes, stateRes] = await Promise.all([
api.repClock.listCards(),
api.repClock.state().catch(() => null),
])
const cards = this.decorateList(cardsRes && Array.isArray(cardsRes.cards) ? cardsRes.cards : [])
const lock = stateRes && stateRes.lock ? stateRes.lock : {}
const runningSessionId = String(lock.runningSessionId || lock.running_session_id || '').trim()
this.setData({
cards,
lockActive: !!runningSessionId,
lockSessionId: runningSessionId,
lockUpdatedAt: String(lock.updatedAt || lock.updated_at || '').trim(),
})
} catch (e) {
const msg = e && e.message ? String(e.message) : '加载失败'
if (/unauthorized|401|未授权/i.test(msg)) {
wx.showModal({
title: '访问被拒绝 (401)',
content: '可能是本地服务重启或守卫配置问题。点击重试再次拉取。\n\n详情:' + msg,
confirmText: '重试',
cancelText: '返回',
success: (r) => { if (r.confirm) this.refresh() }
})
} else {
wx.showToast({ title: msg, icon: 'none' })
}
} finally {
this.setData({ loading: false })
}
},
decorateList(raw) {
return (raw || []).map(item => {
const steps = Array.isArray(item.steps) ? item.steps : []
const loopMode = item.loopMode || 'sequential_once'
const totalSteps = loopMode === 'loop_n_times' ? steps.length * (item.loopTimes || 1) : steps.length
let totalSec = 0
steps.forEach(s => { totalSec += (Number(s.durationSec) || 0) + (Number(s.restSec) || 0) })
if (loopMode === 'loop_n_times') totalSec *= (Number(item.loopTimes) || 1)
const plannedMin = Math.max(1, Math.round(totalSec / 60))
const previewSteps = steps.slice(0, 6).map((s, i) => ({
idx: i,
display: `${i + 1}. ${s.name || '未命名动作'} ${s.durationSec || 0}s${s.restSec ? ` · 休${s.restSec}s` : ''}`,
}))
const moreSteps = steps.length > 6
return Object.assign({}, item, {
totalSteps,
plannedMin,
previewSteps,
moreSteps,
ttsReady: !!item.ttsReady,
})
})
},
goHistory() {
wx.navigateTo({ url: '/kits/rep_clock/pages/history/history' })
},
goCreate() {
wx.navigateTo({ url: '/kits/rep_clock/pages/card_edit/card_edit?mode=new' })
},
onCardTap(e) {
if (this._guardNotReady(e)) return
const id = e.currentTarget.dataset.id
this.onEdit({ currentTarget: { dataset: { id } } })
},
// 语音合成中:禁用开始/编辑/重新合成/删除,避免在无语音或重复合成状态下操作
_guardNotReady(e) {
const ready = e.currentTarget && e.currentTarget.dataset ? e.currentTarget.dataset.ready : undefined
if (ready === false || ready === 'false' || ready === 0 || ready === '0') {
wx.showToast({ title: '语音合成中,请稍后再操作', icon: 'none' })
return true
}
return false
},
onStart(e) {
const id = e.currentTarget.dataset.id
if (this._guardNotReady(e)) return
if (this.data.submitting) return
if (this.data.lockActive) {
wx.showModal({
title: '已有节拍进行中',
content: '当前已有一个运动节拍会话在运行。请先使用顶部“清理解锁”,再开始新的节拍。',
showCancel: false,
confirmText: '知道了',
})
return
}
this.setData({ submitting: true })
wx.navigateTo({
url: `/kits/rep_clock/pages/runner/runner?cardId=${encodeURIComponent(id)}`,
complete: () => this.setData({ submitting: false }),
})
},
async onClearLock() {
if (this.data.submitting) return
wx.showModal({
title: '清理解锁?',
content: '一次只能运行一个节拍。清理解锁会释放当前运行中的会话占用,适合页面异常退出或卡住时使用。',
confirmText: '清理解锁',
cancelText: '取消',
confirmColor: '#e05561',
success: async (r) => {
if (!r.confirm) return
this.setData({ submitting: true })
wx.showLoading({ title: '解锁中…', mask: true })
try {
await api.repClock.resetRunning(this.data.lockSessionId || '')
wx.showToast({ title: '已清理解锁', icon: 'success' })
await this.refresh()
} catch (e) {
wx.showToast({ title: e && e.message ? e.message : '解锁失败', icon: 'none' })
} finally {
try { wx.hideLoading() } catch (_) {}
this.setData({ submitting: false })
}
}
})
},
onEdit(e) {
if (this._guardNotReady(e)) return
const id = e.currentTarget.dataset.id
wx.navigateTo({
url: `/kits/rep_clock/pages/card_edit/card_edit?mode=edit&id=${encodeURIComponent(id)}`,
})
},
async onRetts(e) {
const id = e.currentTarget.dataset.id
// 合成中/失败都可点「重新合成」作为重试出口;submitting 防连点
if (this.data.submitting) return
this.setData({ submitting: true })
wx.showLoading({ title: '准备合成…', mask: true })
try {
let card = null
try {
const r = await api.repClock.getCard(id)
card = r && r.card ? r.card : null
} catch (e) {}
if (!card) { wx.showToast({ title: '卡片不存在', icon: 'none' }); return }
await api.repClock.ttsBatch(id, card, 'female_01')
wx.showToast({ title: '已开始合成', icon: 'success' })
setTimeout(() => this.refresh(), 1500)
} catch (e) {
wx.showToast({ title: e && e.message ? e.message : '合成失败', icon: 'none' })
} finally {
wx.hideLoading()
this.setData({ submitting: false })
}
},
onDelete(e) {
const id = e.currentTarget.dataset.id
const title = (e.currentTarget.dataset.title || '').trim() || '未命名卡片'
if (this._guardNotReady(e)) return
if (this.data.submitting) return
wx.showModal({
title: '删除运动卡片?',
content: `《${title}》将被逻辑删除(音频与记录保留,仅不显示)。可随时恢复。`,
confirmText: '删除',
cancelText: '取消',
confirmColor: '#e05561',
success: async (r) => {
if (!r.confirm) return
this.setData({ submitting: true })
try {
await api.repClock.removeCard(id)
wx.showToast({ title: '已删除(逻辑)', icon: 'success' })
this.refresh()
} catch (e) {
wx.showToast({ title: e && e.message ? e.message : '删除失败', icon: 'none' })
} finally {
this.setData({ submitting: false })
}
}
})
},
})
@@ -0,0 +1,8 @@
{
"navigationBarTitleText": "运动节拍",
"navigationBarBackgroundColor": "#1a1a2e",
"navigationBarTextStyle": "white",
"backgroundColor": "#0f0f1a",
"enablePullDownRefresh": true,
"backgroundTextStyle": "light"
}
@@ -0,0 +1,56 @@
<view class="container">
<view class="page-header">
<view class="page-title">运动节拍</view>
<view class="page-subtitle">自定义动作节拍倒计时,支持 HIIT / Yoga / 拉伸。</view>
</view>
<view wx:if="{{lockActive}}" class="lock-banner">
<view class="lock-banner-title">当前已有 1 个节拍在运行</view>
<view class="lock-banner-desc">
<text>一次只能运行一个节拍。</text>
<text wx:if="{{lockSessionId}}"> 会话:{{lockSessionId}}</text>
</view>
<view class="lock-banner-actions">
<view class="mini-btn btn-ghost" bindtap="refresh">刷新状态</view>
<view class="mini-btn btn-danger" bindtap="onClearLock">清理解锁</view>
</view>
</view>
<view class="row" style="gap: 20rpx; margin-bottom: 28rpx;">
<view class="btn btn-ghost btn-block" bindtap="goHistory" style="flex: 1;">🏆 历史记录</view>
<view class="btn btn-primary btn-block" bindtap="goCreate" style="flex: 1;">+ 新增卡片</view>
</view>
<view wx:if="{{loading}}" class="empty">加载中...</view>
<view wx:elif="{{!cards.length}}" class="empty">还没有运动卡片,点右上角「+ 新增卡片」开始吧。</view>
<block wx:for="{{cards}}" wx:key="id">
<view class="card rc-card" bindtap="onCardTap" data-id="{{item.id}}" data-ready="{{item.ttsReady}}">
<view class="rc-head">
<view class="rc-title">{{item.title || '未命名卡片'}}</view>
<view class="rc-tags">
<text wx:if="{{item.ttsReady}}" class="tag tag-ok">🔊 已合成</text>
<text wx:else class="tag tag-warn">⏳ 合成中</text>
<text class="tag">{{stepsTextMap[item.loopMode]}}</text>
<text class="tag">{{item.totalSteps}} 步</text>
<text class="tag">{{item.plannedMin}} 分钟</text>
</view>
</view>
<view wx:if="{{item.notes}}" class="rc-notes muted small">{{item.notes}}</view>
<view class="rc-steps">
<block wx:for="{{item.previewSteps}}" wx:for-item="s" wx:for-index="si" wx:key="idx">
<view class="rc-step-chip">{{s.display}}</view>
</block>
<view wx:if="{{item.moreSteps}}" class="rc-step-chip muted small">…… 共 {{item.totalSteps}} 步</view>
</view>
<view class="item-actions">
<view class="mini-btn btn-ghost {{lockActive || !item.ttsReady ? 'mini-btn-disabled' : ''}}" catchtap="onStart" data-id="{{item.id}}" data-ready="{{item.ttsReady}}">{{lockActive ? '🔒 已占用' : (item.ttsReady ? '▶ 开始' : '⏳ 合成中')}}</view>
<view class="mini-btn btn-ghost {{!item.ttsReady ? 'mini-btn-disabled' : ''}}" catchtap="onEdit" data-id="{{item.id}}" data-ready="{{item.ttsReady}}">✏ 编辑</view>
<view class="mini-btn btn-ghost" catchtap="onRetts" data-id="{{item.id}}" data-ready="{{item.ttsReady}}">🔊 重新合成</view>
<view class="mini-btn btn-danger {{!item.ttsReady ? 'mini-btn-disabled' : ''}}" catchtap="onDelete" data-id="{{item.id}}" data-title="{{item.title}}" data-ready="{{item.ttsReady}}">🗑 删除</view>
</view>
</view>
</block>
</view>
<view class="fab" bindtap="goCreate">+</view>
@@ -0,0 +1,34 @@
.lock-banner {
margin-bottom: 24rpx;
padding: 24rpx;
background: rgba(224, 85, 97, 0.12);
border: 1rpx solid rgba(224, 85, 97, 0.3);
border-radius: 24rpx;
}
.lock-banner-title {
color: #ffd4d8;
font-size: 30rpx;
font-weight: 700;
}
.lock-banner-desc {
margin-top: 10rpx;
color: #ffd4d8;
font-size: 24rpx;
line-height: 1.6;
}
.lock-banner-actions {
margin-top: 18rpx;
display: flex;
gap: 16rpx;
}
.rc-card { padding: 32rpx; }
.rc-head { margin-bottom: 14rpx; }
.rc-title { font-size: 34rpx; font-weight: 700; color: #ffffff; letter-spacing: 0.5rpx; }
.rc-tags { margin-top: 14rpx; }
.tag-ok { background: rgba(72, 187, 120, 0.15); color: #76d79a; }
.tag-warn { background: rgba(237, 188, 88, 0.15); color: #f4cf7c; }
.rc-notes { margin: 16rpx 0 8rpx; line-height: 1.7; }
.rc-steps { margin-top: 22rpx; display: flex; flex-wrap: wrap; gap: 14rpx; }
.rc-step-chip { padding: 10rpx 20rpx; background: rgba(108, 122, 224, 0.12); color: #b8bfff; border: 1rpx solid rgba(108, 122, 224, 0.3); border-radius: 999rpx; font-size: 24rpx; }
.mini-btn-disabled { opacity: 0.65; }