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
+79 -10
View File
@@ -12,13 +12,16 @@ const request = (options) => {
return new Promise((resolve, reject) => {
const { url, method = 'GET', data = {}, header = {}, timeout = 30000 } = options
const fullUrl = url.indexOf('http') === 0 ? url : BASE_URL() + url
const h = Object.assign({
'content-type': 'application/json; charset=utf-8',
Accept: 'application/json; charset=utf-8',
'X-Mp-Timeassistant': 'mp_time_assistant_v1_' + Math.random().toString(36).slice(2, 10),
}, header)
wx.request({
url: fullUrl,
method,
data,
header: Object.assign({
'content-type': 'application/json',
}, header),
header: h,
timeout,
success: (res) => {
const body = res.data || {}
@@ -26,7 +29,12 @@ const request = (options) => {
resolve(body)
} else {
const msg = body && body.error ? body.error : `请求失败 (HTTP ${res.statusCode})`
reject(new Error(msg))
const err = new Error(msg)
err.statusCode = res.statusCode
if (body && typeof body === 'object') {
try { Object.assign(err, body) } catch (_) {}
}
reject(err)
}
},
fail: (err) => {
@@ -45,6 +53,9 @@ const uploadFile = (options) => {
filePath,
name,
formData,
header: {
'X-Mp-Timeassistant': 'mp_time_assistant_v1_' + Math.random().toString(36).slice(2, 10),
},
timeout,
success: (res) => {
try {
@@ -78,37 +89,94 @@ const core = {
const personalCollect = {
list: (keyword = '') => request({
url: '/api/mp_time_assistant/kits/personal_collect/items',
url: '/api/mp_personal_collect/kits/personal_collect/items',
method: 'GET',
data: { keyword: keyword || '' },
}),
get: (id) => request({
url: `/api/mp_time_assistant/kits/personal_collect/items/${encodeURIComponent(id)}`,
url: `/api/mp_personal_collect/kits/personal_collect/items/${encodeURIComponent(id)}`,
method: 'GET',
}),
create: (payload) => request({
url: '/api/mp_time_assistant/kits/personal_collect/items',
url: '/api/mp_personal_collect/kits/personal_collect/items',
method: 'POST',
data: payload,
}),
update: (payload) => request({
url: '/api/mp_time_assistant/kits/personal_collect/items',
url: '/api/mp_personal_collect/kits/personal_collect/items',
method: 'PUT',
data: payload,
}),
remove: (id) => request({
url: '/api/mp_time_assistant/kits/personal_collect/items',
url: '/api/mp_personal_collect/kits/personal_collect/items',
method: 'DELETE',
data: { id },
}),
upload: (filePath, groupId = '') => uploadFile({
url: '/api/mp_time_assistant/kits/personal_collect/upload',
url: '/api/mp_personal_collect/kits/personal_collect/upload',
filePath,
name: 'file',
formData: groupId ? { groupId } : {},
}),
}
const repClock = {
meta: () => request({ url: '/api/mp_rep_clock/meta', method: 'GET' }),
state: () => request({ url: '/api/mp_rep_clock/state', method: 'GET' }),
resetRunning: (sessionId) => request({
url: '/api/mp_rep_clock/kits/rep_clock/state/reset-running',
method: 'POST',
data: { sessionId },
}),
listCards: () => request({ url: '/api/mp_rep_clock/kits/rep_clock/cards', method: 'GET' }),
getCard: (id) => request({ url: `/api/mp_rep_clock/kits/rep_clock/cards/${encodeURIComponent(id)}`, method: 'GET' }),
createCard: (payload) => request({
url: '/api/mp_rep_clock/kits/rep_clock/cards',
method: 'POST',
data: payload,
}),
updateCard: (payload) => request({
url: '/api/mp_rep_clock/kits/rep_clock/cards',
method: 'PUT',
data: payload,
}),
removeCard: (id) => request({
url: '/api/mp_rep_clock/kits/rep_clock/cards',
method: 'DELETE',
data: { id },
}),
history: (limit = 200) => request({
url: '/api/mp_rep_clock/kits/rep_clock/history',
method: 'GET',
data: { limit },
}),
sessionStart: (payload) => request({
url: '/api/mp_rep_clock/kits/rep_clock/sessions/start',
method: 'POST',
data: payload,
}),
sessionSync: (payload) => request({
url: '/api/mp_rep_clock/kits/rep_clock/sessions/sync',
method: 'POST',
data: payload,
}),
sessionFinish: (payload) => request({
url: '/api/mp_rep_clock/kits/rep_clock/sessions/finish',
method: 'POST',
data: payload,
}),
ttsPreview: (text, voice = 'female_01') => request({
url: '/api/mp_rep_clock/kits/rep_clock/tts/preview',
method: 'GET',
data: { text, voice },
}),
ttsBatch: (cardId, card, voice = 'female_01') => request({
url: '/api/mp_rep_clock/kits/rep_clock/tts/batch',
method: 'POST',
data: { cardId, card, voice },
}),
}
const chooseChatAttachments = (count = 9) => {
return new Promise((resolve, reject) => {
try {
@@ -141,5 +209,6 @@ module.exports = {
uploadFile,
core,
personalCollect,
repClock,
chooseChatAttachments,
}
+6 -2
View File
@@ -1,9 +1,14 @@
{
"pages": [
"pages/hub/hub",
"pages/subhub/subhub",
"kits/personal_collect/pages/list/list",
"kits/personal_collect/pages/edit/edit",
"kits/personal_collect/pages/detail/detail"
"kits/personal_collect/pages/detail/detail",
"kits/rep_clock/pages/cards/cards",
"kits/rep_clock/pages/card_edit/card_edit",
"kits/rep_clock/pages/runner/runner",
"kits/rep_clock/pages/history/history"
],
"window": {
"backgroundTextStyle": "light",
@@ -14,6 +19,5 @@
},
"style": "v2",
"sitemapLocation": "sitemap.json",
"__comment_requiredPrivateInfos_chooseMessageFile": "chooseMessageFile 不再需要在 requiredPrivateInfos 中静态声明,调用时微信自动弹窗授权即可。",
"lazyCodeLoading": "requiredComponents"
}
@@ -0,0 +1,238 @@
const api = require('../../../../api.js')
function uuid() {
return 'step_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8)
}
function clamp(v, a, b) { const x = Number(v) || 0; return Math.max(a, Math.min(b, x)) }
const DEFAULT_CARD = {
title: '',
notes: '',
loopMode: 'sequential_once',
loopTimes: 3,
enableCountdownAnnounce: true,
announceLeadSeconds: '5,3,1',
voice: 'female_01',
steps: [
{ key: uuid(), name: '动作一', durationSec: 30, restSec: 10, voiceText: '' },
{ key: uuid(), name: '动作二', durationSec: 30, restSec: 10, voiceText: '' },
],
}
Page({
data: Object.assign({}, DEFAULT_CARD, {
mode: 'new',
cardId: '',
dirty: false,
saving: false,
originSnapshot: '',
}),
onLoad(options) {
const mode = (options && options.mode === 'edit') ? 'edit' : 'new'
const id = (options && options.id) || ''
this.setData({ mode, cardId: id })
if (mode === 'edit' && id) {
wx.showLoading({ title: '加载中…', mask: true })
api.repClock.getCard(id).then(res => {
const card = (res && res.card) ? res.card : null
if (!card) {
try { wx.hideLoading() } catch (_) {}
wx.showToast({ title: '卡片不存在', icon: 'none' })
wx.navigateBack()
return
}
const steps = (Array.isArray(card.steps) ? card.steps : []).map(s => Object.assign({ key: uuid() }, s))
this.setData({
title: String(card.title || ''),
notes: String(card.notes || ''),
loopMode: card.loopMode || 'sequential_once',
loopTimes: card.loopTimes || card.loopCount || 3,
enableCountdownAnnounce: !!card.enableCountdownAnnounce !== false && !!card.enableCountdownAnnounce,
announceLeadSeconds: String(card.announceLeadSeconds || '5,3,1'),
voice: card.voice || 'female_01',
steps,
}, () => {
this.setData({ originSnapshot: this._toSnapshot(), dirty: false })
try { wx.hideLoading() } catch (_) {}
})
}).catch(e => {
try { wx.hideLoading() } catch (_) {}
wx.showToast({ title: e && e.message ? e.message : '加载失败', icon: 'none' })
wx.navigateBack()
})
} else {
this.setData({ originSnapshot: this._toSnapshot() })
}
},
onUnload() { },
_toSnapshot() {
try { return JSON.stringify(this._payload()) } catch (e) { return '' }
},
_payload() {
const d = this.data
return {
title: String(d.title || '').trim(),
notes: String(d.notes || '').trim(),
loopMode: d.loopMode,
loopTimes: clamp(d.loopTimes, 1, 999),
enableCountdownAnnounce: !!d.enableCountdownAnnounce,
announceLeadSeconds: String(d.announceLeadSeconds || '').trim(),
voice: d.voice || 'female_01',
steps: (d.steps || []).map(s => ({
name: String(s.name || '').trim(),
durationSec: clamp(s.durationSec, 1, 3600),
restSec: clamp(s.restSec, 0, 3600),
voiceText: String(s.voiceText || '').trim(),
})),
}
},
markDirty() {
const snap = this._toSnapshot()
this.setData({ dirty: snap !== this.data.originSnapshot })
},
onTitleInput(e) { this.setData({ title: e.detail.value }, () => this.markDirty()) },
onNotesInput(e) { this.setData({ notes: e.detail.value }, () => this.markDirty()) },
onLoopMode(e) { this.setData({ loopMode: e.currentTarget.dataset.v }, () => this.markDirty()) },
onLoopTimesInput(e) { this.setData({ loopTimes: e.detail.value }, () => this.markDirty()) },
onEnableAnnounce(e) { this.setData({ enableCountdownAnnounce: !!e.detail.value }, () => this.markDirty()) },
onLeadSecInput(e) { this.setData({ announceLeadSeconds: e.detail.value }, () => this.markDirty()) },
onVoice(e) { this.setData({ voice: e.currentTarget.dataset.v }, () => this.markDirty()) },
_stepIndexByKey(key) {
return (this.data.steps || []).findIndex(s => s.key === key)
},
onStepNameInput(e) {
const key = e.currentTarget.dataset.key
const i = this._stepIndexByKey(key); if (i < 0) return
const steps = this.data.steps.slice()
steps[i] = Object.assign({}, steps[i], { name: e.detail.value })
this.setData({ steps }, () => this.markDirty())
},
onStepDurationInput(e) {
const key = e.currentTarget.dataset.key
const i = this._stepIndexByKey(key); if (i < 0) return
const steps = this.data.steps.slice()
steps[i] = Object.assign({}, steps[i], { durationSec: e.detail.value })
this.setData({ steps }, () => this.markDirty())
},
onStepRestInput(e) {
const key = e.currentTarget.dataset.key
const i = this._stepIndexByKey(key); if (i < 0) return
const steps = this.data.steps.slice()
steps[i] = Object.assign({}, steps[i], { restSec: e.detail.value })
this.setData({ steps }, () => this.markDirty())
},
onStepVoiceInput(e) {
const key = e.currentTarget.dataset.key
const i = this._stepIndexByKey(key); if (i < 0) return
const steps = this.data.steps.slice()
steps[i] = Object.assign({}, steps[i], { voiceText: e.detail.value })
this.setData({ steps }, () => this.markDirty())
},
onAddStep() {
const steps = this.data.steps.slice()
steps.push({ key: uuid(), name: `动作${steps.length + 1}`, durationSec: 30, restSec: 10, voiceText: '' })
this.setData({ steps }, () => this.markDirty())
},
onMoveUp(e) {
const key = e.currentTarget.dataset.key
const i = this._stepIndexByKey(key); if (i <= 0) return
const steps = this.data.steps.slice()
;[steps[i - 1], steps[i]] = [steps[i], steps[i - 1]]
this.setData({ steps }, () => this.markDirty())
},
onMoveDown(e) {
const key = e.currentTarget.dataset.key
const steps = this.data.steps || []
const i = this._stepIndexByKey(key); if (i < 0 || i >= steps.length - 1) return
const arr = steps.slice()
;[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]]
this.setData({ steps: arr }, () => this.markDirty())
},
onDuplicate(e) {
const key = e.currentTarget.dataset.key
const i = this._stepIndexByKey(key); if (i < 0) return
const steps = this.data.steps.slice()
const clone = Object.assign({}, steps[i], { key: uuid() })
steps.splice(i + 1, 0, clone)
this.setData({ steps }, () => this.markDirty())
},
onStepDelete(e) {
const key = e.currentTarget.dataset.key
const i = this._stepIndexByKey(key); if (i < 0) return
wx.showModal({
title: '删除该步骤?',
content: '删除后可再次添加。',
confirmText: '删除',
cancelText: '取消',
confirmColor: '#e05561',
success: (r) => {
if (!r.confirm) return
const steps = this.data.steps.slice()
steps.splice(i, 1)
this.setData({ steps }, () => this.markDirty())
}
})
},
_validate(payload) {
if (!payload.title) return '请填写卡片标题'
if (!payload.steps.length) return '请至少添加一个步骤'
for (let i = 0; i < payload.steps.length; i++) {
const s = payload.steps[i]
if (!s.name) return `第${i + 1}步:请填写动作名称`
if (s.durationSec < 1 || s.durationSec > 3600) return `第${i + 1}步:动作时长 1~3600 秒`
if (s.restSec < 0 || s.restSec > 3600) return `第${i + 1}步:休息时长 0~3600 秒`
}
if (payload.loopMode === 'loop_n_times' && (payload.loopTimes < 1 || payload.loopTimes > 999)) return '循环次数 1~999'
if (payload.enableCountdownAnnounce) {
const raw = (payload.announceLeadSeconds || '').split(',').map(x => Number(x.trim())).filter(n => Number.isFinite(n) && n > 0 && n <= 30)
if (!raw.length) return '至少配置 1~30 秒播报秒点'
}
return null
},
async onSave() {
if (this.data.saving) return
const payload = this._payload()
const err = this._validate(payload)
if (err) { wx.showToast({ title: err, icon: 'none' }); return }
this.setData({ saving: true })
wx.showLoading({ title: '保存中…', mask: true })
try {
let saved = null
if (this.data.mode === 'edit') {
payload.id = this.data.cardId
saved = await api.repClock.updateCard(payload)
} else {
saved = await api.repClock.createCard(payload)
}
const cardId = saved && saved.card && saved.card.id ? saved.card.id : this.data.cardId
wx.showLoading({ title: '已保存。合成语音…', mask: true })
try {
await api.repClock.ttsBatch(cardId, (saved && saved.card) ? saved.card : payload, payload.voice)
} catch (e) {
// 语音合成失败不阻塞保存成功
}
try { wx.hideLoading() } catch (_) {}
wx.showToast({ title: '已保存', icon: 'success' })
this.setData({ originSnapshot: this._toSnapshot(), dirty: false })
setTimeout(() => wx.navigateBack(), 600)
} catch (e) {
try { wx.hideLoading() } catch (_) {}
wx.showToast({ title: e && e.message ? e.message : '保存失败', icon: 'none' })
} finally {
this.setData({ saving: false })
}
},
})
@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "编辑运动卡片",
"navigationBarBackgroundColor": "#1a1a2e",
"navigationBarTextStyle": "white",
"backgroundColor": "#0f0f1a"
}
@@ -0,0 +1,98 @@
<view class="container">
<view class="page-header">
<view class="page-title">{{mode === 'edit' ? '编辑卡片' : '新建卡片'}}</view>
<view class="page-subtitle">保存时会自动合成标题 / 步骤名 / 倒计时播报。</view>
</view>
<view class="card">
<view class="input-group">
<text class="input-label">卡片标题</text>
<input class="input" placeholder="例:瑜伽垫上腰部运动" value="{{title}}" bindinput="onTitleInput" maxlength="80" />
</view>
<view class="input-group">
<text class="input-label">备注(可选)</text>
<textarea class="textarea" placeholder="可以写练习要点、注意事项等" value="{{notes}}" bindinput="onNotesInput" maxlength="500" auto-height />
</view>
<view class="input-group">
<text class="input-label">循环模式</text>
<view class="segmented">
<view class="seg-item {{loopMode === 'sequential_once' ? 'seg-active' : ''}}" bindtap="onLoopMode" data-v="sequential_once">顺序单次</view>
<view class="seg-item {{loopMode === 'loop_till_stop' ? 'seg-active' : ''}}" bindtap="onLoopMode" data-v="loop_till_stop">循环至停止</view>
<view class="seg-item {{loopMode === 'loop_n_times' ? 'seg-active' : ''}}" bindtap="onLoopMode" data-v="loop_n_times">指定循环</view>
</view>
<view wx:if="{{loopMode === 'loop_n_times'}}" class="row" style="margin-top: 22rpx; gap: 20rpx;">
<text class="input-label" style="margin: 0; flex: none;">循环次数</text>
<input class="input" style="flex: 1; padding: 18rpx 22rpx;" type="number" value="{{loopTimes}}" bindinput="onLoopTimesInput" />
</view>
</view>
<view class="input-group">
<text class="input-label">倒计时播报</text>
<view class="row" style="gap: 16rpx; flex-wrap: wrap;">
<switch checked="{{enableCountdownAnnounce}}" bindchange="onEnableAnnounce" color="#6c7ae0" style="transform: scale(0.85);" />
<text class="muted small" style="margin-left: 6rpx;">启用倒计时临近播报</text>
</view>
<view wx:if="{{enableCountdownAnnounce}}" style="margin-top: 22rpx;">
<text class="input-label">播报秒点(逗号分隔,1~30)</text>
<input class="input" placeholder="例:5,3,1" value="{{announceLeadSeconds}}" bindinput="onLeadSecInput" />
</view>
</view>
<view class="input-group">
<text class="input-label">默认播报音色</text>
<view class="segmented">
<view class="seg-item {{voice === 'female_01' ? 'seg-active' : ''}}" bindtap="onVoice" data-v="female_01">🌸 女声 Ruoxi</view>
<view class="seg-item {{voice === 'male_01' ? 'seg-active' : ''}}" bindtap="onVoice" data-v="male_01">🎙 男声 Longlaobo</view>
</view>
</view>
</view>
<view class="row-between" style="padding: 6rpx 6rpx 24rpx;">
<view class="section-title">步骤列表</view>
<view class="btn btn-primary mini-btn" style="padding: 14rpx 28rpx;" bindtap="onAddStep">+ 新增步骤</view>
</view>
<block wx:for="{{steps}}" wx:key="key">
<view class="card step-card">
<view class="row-between step-head">
<view class="step-idx">第 {{index + 1}} 步</view>
<view class="row" style="gap: 12rpx; flex-wrap: wrap;">
<view wx:if="{{index > 0}}" class="mini-btn btn-ghost" catchtap="onMoveUp" data-key="{{item.key}}">↑ 上移</view>
<view wx:if="{{index < steps.length - 1}}" class="mini-btn btn-ghost" catchtap="onMoveDown" data-key="{{item.key}}">↓ 下移</view>
<view class="mini-btn btn-ghost" catchtap="onDuplicate" data-key="{{item.key}}">⎘ 复制</view>
<view class="mini-btn btn-danger" catchtap="onStepDelete" data-key="{{item.key}}">🗑 删除</view>
</view>
</view>
<view class="input-group" style="margin-top: 18rpx;">
<text class="input-label">动作名称</text>
<input class="input" placeholder="例:卷腹 30 次" value="{{item.name}}" bindinput="onStepNameInput" data-key="{{item.key}}" maxlength="60" />
</view>
<view class="row" style="gap: 22rpx;">
<view class="input-group" style="flex: 1;">
<text class="input-label">动作时长(秒)</text>
<input class="input" type="number" value="{{item.durationSec}}" bindinput="onStepDurationInput" data-key="{{item.key}}" />
</view>
<view class="input-group" style="flex: 1;">
<text class="input-label">休息时长(秒)</text>
<input class="input" type="number" value="{{item.restSec}}" bindinput="onStepRestInput" data-key="{{item.key}}" />
</view>
</view>
<view class="input-group">
<text class="input-label">自定义播报文案(可选,留空则播报动作名称)</text>
<input class="input" placeholder="例:加油,保持节奏" value="{{item.voiceText}}" bindinput="onStepVoiceInput" data-key="{{item.key}}" maxlength="120" />
</view>
</view>
</block>
<view wx:if="{{!steps.length}}" class="empty muted small" style="padding: 70rpx 32rpx;">还没有步骤,点右上角「+ 新增步骤」开始添加。</view>
<view style="height: 40rpx;"></view>
<view class="btn btn-primary btn-block" bindtap="onSave" style="margin-bottom: 30rpx; padding: 28rpx 0; font-size: 30rpx;">
{{saving ? '保存并合成中…' : (mode === 'edit' ? '💾 保存并重新合成' : '💾 保存并合成语音')}}
</view>
</view>
@@ -0,0 +1,62 @@
.page-header { padding: 70rpx 0 40rpx 0; }
.page-title { font-size: 48rpx; font-weight: 700; color: #ffffff; margin-bottom: 12rpx; }
.page-subtitle { font-size: 26rpx; color: #8888a8; line-height: 1.6; }
.section-title { font-size: 30rpx; font-weight: 700; color: #ffffff; letter-spacing: 0.5rpx; }
.row { display: flex; flex-direction: row; align-items: center; }
.row-between { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.muted { color: #8888a8; }
.small { font-size: 24rpx; }
.segmented { display: flex; background: #12121f; border: 1rpx solid #2a2a44; border-radius: 18rpx; overflow: hidden; }
.seg-item { flex: 1; padding: 22rpx 16rpx; text-align: center; font-size: 26rpx; color: #a0a4c4; border-right: 1rpx solid #232338; }
.seg-item:last-child { border-right: none; }
.seg-active { background: linear-gradient(135deg, #6c7ae0, #b06be0); color: #ffffff; font-weight: 600; }
.mini-btn { padding: 14rpx 22rpx; border-radius: 14rpx; font-size: 24rpx; line-height: 1.3; }
.step-card { padding: 28rpx; }
.step-head { margin-bottom: 14rpx; }
.step-idx { font-size: 28rpx; font-weight: 700; color: #c9cdff; letter-spacing: 0.5rpx; }
.container { padding: 28rpx 32rpx 160rpx; min-height: 100vh; box-sizing: border-box; }
.card { background: #1a1a2e; border: 1rpx solid #2a2a44; border-radius: 24rpx; padding: 30rpx; margin-bottom: 24rpx; box-sizing: border-box; }
.input-group { margin-bottom: 28rpx; }
.input-label { font-size: 26rpx; color: #9a9ec0; margin-bottom: 14rpx; display: block; font-weight: 500; }
.input {
width: 100%;
background: #0d0d18 !important;
border: 1rpx solid #4a4a72 !important;
border-radius: 18rpx !important;
padding: 26rpx 26rpx !important;
min-height: 88rpx !important;
height: 88rpx !important;
color: #ffffff !important;
font-size: 30rpx !important;
box-sizing: border-box !important;
line-height: 1.5 !important;
}
.textarea {
width: 100%;
background: #0d0d18 !important;
border: 1rpx solid #4a4a72 !important;
border-radius: 18rpx !important;
padding: 26rpx 26rpx !important;
min-height: 220rpx !important;
color: #ffffff !important;
font-size: 30rpx !important;
box-sizing: border-box !important;
line-height: 1.6 !important;
}
.btn { display: inline-block; padding: 20rpx 48rpx; border-radius: 16rpx; font-size: 28rpx; font-weight: 500; text-align: center; box-sizing: border-box; line-height: 1.4; }
.btn-primary { background: linear-gradient(135deg, #6c7ae0, #b06be0); color: #ffffff; }
.btn-primary:active { opacity: 0.88; }
.btn-ghost { background: transparent; border: 1rpx solid #3a3a5a; color: #b8b8d0; }
.btn-danger { background: #e05561; color: #ffffff; }
.btn-block { display: block; width: 100%; }
.empty { padding: 80rpx 32rpx; text-align: center; color: #606080; font-size: 26rpx; }
@@ -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; }
@@ -0,0 +1,91 @@
const api = require('../../../../api.js')
function pad2(n) { const x = Math.max(0, Math.floor(Number(n) || 0)); return x < 10 ? '0' + x : String(x) }
function toBeijingDate(iso) {
try {
const d = iso ? new Date(iso) : new Date()
const tzMs = 8 * 3600 * 1000
const utc = d.getTime() + (d.getTimezoneOffset() * 60 * 1000)
const bj = new Date(utc + tzMs)
return bj
} catch (e) { return new Date() }
}
function formatBjLong(iso) {
const d = toBeijingDate(iso)
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
}
function isSameBjDay(iso1, iso2) {
const a = toBeijingDate(iso1), b = toBeijingDate(iso2)
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
}
Page({
data: {
loading: true,
sessions: [],
summary: {
totalCount: 0,
todayMin: 0,
totalMin: 0,
completionAvg: 0,
},
},
onShow() { this.refresh() },
onPullDownRefresh() {
this.refresh().finally(() => wx.stopPullDownRefresh())
},
async refresh() {
this.setData({ loading: true })
try {
const res = await api.repClock.history(500)
const rows = res && Array.isArray(res.sessions) ? res.sessions : []
const sessions = rows.map(s => {
const plannedMin = Math.max(0, Math.round((Number(s.plannedDurationSec) || 0) / 60))
const actualMin = Math.max(0, Math.round((Number(s.actualDurationSec) || 0) / 60))
const comp = Math.round(100 * (Number(s.completionRatio) || 0))
return Object.assign({}, s, {
cardTitle: String(s.cardTitle || s.card_title || '未命名卡片'),
plannedMin,
actualMin,
completed: !!s.completed,
completionDisplay: comp,
startedAtText: formatBjLong(s.startedAt || s.started_at),
})
}).sort((a, b) => {
const ta = (a.startedAt || a.started_at) ? new Date(a.startedAt || a.started_at).getTime() : 0
const tb = (b.startedAt || b.started_at) ? new Date(b.startedAt || b.started_at).getTime() : 0
return tb - ta
})
const now = new Date().toISOString()
let totalCount = sessions.length
let todayMin = 0
let totalMin = 0
let compSum = 0
sessions.forEach(s => {
totalMin += Number(s.actualMin) || 0
if (isSameBjDay(s.startedAt || s.started_at, now)) todayMin += Number(s.actualMin) || 0
compSum += Number(s.completionDisplay) || 0
})
const completionAvg = totalCount ? Math.round(compSum / totalCount) : 0
this.setData({
sessions,
summary: {
totalCount,
todayMin,
totalMin,
completionAvg,
},
})
} catch (e) {
wx.showToast({ title: e && e.message ? e.message : '加载失败', icon: 'none' })
} finally {
this.setData({ loading: false })
}
},
})
@@ -0,0 +1,8 @@
{
"navigationBarTitleText": "运动历史",
"navigationBarBackgroundColor": "#1a1a2e",
"navigationBarTextStyle": "white",
"backgroundColor": "#0f0f1a",
"enablePullDownRefresh": true,
"backgroundTextStyle": "light"
}
@@ -0,0 +1,63 @@
<view class="container">
<view class="page-header">
<view class="page-title">运动历史</view>
<view class="page-subtitle">每次运动都有记录,完成率一目了然。</view>
</view>
<view class="summary-card">
<view class="sum-item">
<view class="sum-num">{{summary.totalCount}}</view>
<view class="sum-label">总次数</view>
</view>
<view class="sum-item">
<view class="sum-num">{{summary.todayMin}}</view>
<view class="sum-label">今日(分钟)</view>
</view>
<view class="sum-item">
<view class="sum-num">{{summary.totalMin}}</view>
<view class="sum-label">累计(分钟)</view>
</view>
<view class="sum-item">
<view class="sum-num">{{summary.completionAvg}}%</view>
<view class="sum-label">平均完成率</view>
</view>
</view>
<view wx:if="{{loading}}" class="empty">加载中...</view>
<view wx:elif="{{!sessions.length}}" class="empty">还没有运动记录,去开始你的第一次吧。</view>
<block wx:for="{{sessions}}" wx:key="id">
<view class="card ses-card">
<view class="row-between">
<view class="ses-title">{{item.cardTitle || '未命名卡片'}}</view>
<view class="ses-result-tag {{item.completed ? 'ses-ok' : 'ses-no'}}">
{{item.completed ? '✅ 完成' : '⏹ 中断'}}
</view>
</view>
<view class="row" style="gap: 28rpx; margin-top: 20rpx; flex-wrap: wrap;">
<view class="ses-meta muted small">
<text class="meta-label">开始时间</text>
<text class="meta-value">{{item.startedAtText}}</text>
</view>
<view class="ses-meta muted small">
<text class="meta-label">计划时长</text>
<text class="meta-value">{{item.plannedMin}} 分钟</text>
</view>
<view class="ses-meta muted small">
<text class="meta-label">实际时长</text>
<text class="meta-value">{{item.actualMin}} 分钟</text>
</view>
</view>
<view class="row-between" style="margin-top: 24rpx;">
<view class="progress-wrap">
<view class="progress-bar">
<view class="progress-fill" style="width: {{item.completionDisplay}}%;"></view>
</view>
</view>
<view class="ses-completion {{item.completionDisplay >= 90 ? 'c-ok' : (item.completionDisplay >= 50 ? 'c-mid' : 'c-low')}}">
{{item.completionDisplay}}%
</view>
</view>
</view>
</block>
</view>
@@ -0,0 +1,37 @@
.summary-card {
display: flex;
background: linear-gradient(145deg, rgba(108,122,224,0.22), rgba(176,107,224,0.14));
border: 1rpx solid rgba(140,150,240,0.28);
border-radius: 28rpx;
padding: 30rpx 20rpx;
margin-bottom: 30rpx;
}
.sum-item { flex: 1; text-align: center; }
.sum-num { font-size: 44rpx; font-weight: 800; color: #ffffff; letter-spacing: 0.5rpx; }
.sum-label { margin-top: 8rpx; color: #a7aad1; font-size: 22rpx; }
.ses-card { padding: 30rpx; }
.ses-title { font-size: 32rpx; font-weight: 700; color: #ffffff; }
.ses-result-tag { padding: 8rpx 20rpx; border-radius: 999rpx; font-size: 22rpx; }
.ses-ok { background: rgba(72,187,120,0.15); color: #76d79a; border: 1rpx solid rgba(72,187,120,0.3); }
.ses-no { background: rgba(224,85,97,0.14); color: #f19ea7; border: 1rpx solid rgba(224,85,97,0.28); }
.ses-meta { display: flex; flex-direction: column; gap: 6rpx; min-width: 180rpx; }
.meta-label { color: #7a7e9f; font-size: 22rpx; letter-spacing: 0.5rpx; }
.meta-value { color: #e2e4ff; font-size: 26rpx; font-weight: 600; }
.progress-wrap { flex: 1; margin-right: 24rpx; }
.progress-bar {
height: 16rpx;
background: rgba(255,255,255,0.06);
border-radius: 999rpx;
overflow: hidden;
border: 1rpx solid rgba(255,255,255,0.06);
}
.progress-fill {
height: 100%;
background: linear-gradient(135deg, #6c7ae0, #b06be0);
border-radius: 999rpx;
}
.ses-completion { font-size: 30rpx; font-weight: 800; }
.c-ok { color: #76d79a; }
.c-mid { color: #f4cf7c; }
.c-low { color: #f19ea7; }
@@ -0,0 +1,610 @@
const api = require('../../../../api.js')
const PHASE_ACTION = 'action'
const PHASE_REST = 'rest'
const PHASE_IDLE = 'idle'
const BASE_URL_SAFE = function () {
try {
const a = getApp()
if (a && a.globalData && a.globalData.apiBase) return a.globalData.apiBase
} catch (e) {}
return 'https://traesite.umersoft.com:8975'
}
function clamp(v, a, b) { const x = Number(v) || 0; return Math.max(a, Math.min(b, x)) }
function pad2(n) { const x = Math.max(0, Math.floor(Number(n) || 0)); return x < 10 ? '0' + x : String(x) }
function expandPlan(card) {
const steps = Array.isArray(card.steps) ? card.steps : []
const mode = card.loopMode || 'sequential_once'
const times = mode === 'loop_n_times' ? clamp(card.loopTimes || 1, 1, 999) : 1
const plan = []
const loopUntil = mode === 'loop_till_stop'
const loopCount = loopUntil ? 1 : times
for (let round = 1; round <= loopCount; round++) {
steps.forEach((step, idx) => {
plan.push({
round,
roundTotal: loopCount,
loopUntil,
phase: PHASE_ACTION,
stepIndex: idx,
stepName: step.name || '动作',
voiceText: step.voiceText || step.name || '动作',
durationSec: clamp(step.durationSec, 1, 3600),
restSec: clamp(step.restSec, 0, 3600),
})
})
}
return { plan, loopUntil }
}
function sanitizeAnnounceLead(raw) {
const list = String(raw || '').split(/[,,\s]+/).map(x => Number(x.trim())).filter(n => Number.isFinite(n) && n > 0 && n <= 30)
const s = Array.from(new Set(list)).sort((a, b) => b - a)
return s.length ? s : [5, 3, 1]
}
function normalizeAudioUrl(raw) {
const url = String(raw || '').trim()
if (!url) return ''
if (/^https?:\/\//i.test(url)) return url
if (url.startsWith('/')) return BASE_URL_SAFE() + url
return `${BASE_URL_SAFE()}/${url.replace(/^\/+/, '')}`
}
Page({
data: {
cardId: '',
cardTitle: '运动节拍',
started: false,
paused: false,
phaseLabel: '准备开始',
stepSeq: '',
remainingText: '00:00',
stepName: '',
nextName: '',
elapsedMin: '00',
elapsedSecPad: '00',
remainingMin: '00',
remainingSecPad: '00',
completion: 0,
hasCountdown: true,
countdownText: '5,3,1',
},
_i: null,
onLoad(options) {
this._i = {
card: null,
plan: [],
loopUntil: false,
cursor: 0,
phase: PHASE_IDLE,
durationMs: 0,
phaseStartAt: 0,
phaseEndAt: 0,
pausedAt: 0,
pausedTotalMs: 0,
globalStartAt: 0,
announcedSet: null,
countdownSet: [],
voice: 'female_01',
sessionId: '',
lastTickEmit: 0,
lastServerSyncAt: 0,
ttsQueue: [],
ttsCtx: null,
ttsSrc: {},
lastAnnouncedText: '',
tickTimer: null,
hiddenAt: 0,
finishing: false,
}
const cardId = String((options && options.cardId) || '').trim()
this.data.cardId = cardId
wx.showLoading({ title: '加载卡片…', mask: true })
Promise.resolve().then(async () => {
if (!cardId) throw new Error('缺少卡片ID')
const res = await api.repClock.getCard(cardId)
const card = res && res.card ? res.card : null
if (!card) throw new Error('卡片不存在')
const { plan, loopUntil } = expandPlan(card)
if (!plan.length) throw new Error('卡片没有步骤')
const i = this._i
i.card = card
i.plan = plan
i.loopUntil = loopUntil
i.voice = card.voice || 'female_01'
i.announcedSet = new Set()
i.countdownSet = sanitizeAnnounceLead(card.announceLeadSeconds || '5,3,1')
this._preloadTtsCache(plan, i.voice).catch(() => {})
this._ensureAudioCtx()
const totalDurationMs = plan.reduce((acc, p) => acc + (p.durationSec + p.restSec) * 1000, 0)
const lead = i.countdownSet.length ? i.countdownSet.join(',') : '5,3,1'
this.setData({
cardTitle: String(card.title || '运动节拍'),
countdownText: lead,
hasCountdown: !!card.enableCountdownAnnounce !== false,
remainingMin: pad2(Math.floor(totalDurationMs / 60000)),
remainingSecPad: pad2(Math.floor((totalDurationMs % 60000) / 1000)),
})
try { wx.hideLoading() } catch (_) {}
}).catch(e => {
try { wx.hideLoading() } catch (_) {}
const msg = e && e.message ? String(e.message) : '加载失败'
wx.showToast({ title: msg, icon: 'none', duration: 1500 })
setTimeout(() => wx.navigateBack(), 1500)
})
},
onUnload() {
this._clearTickTimer()
this._destroyAudioCtx()
if (this._i && this._i.sessionId && !this._i.finishing) {
const sid = this._i.sessionId
api.repClock.sessionFinish({ sessionId: sid, completed: false, force: true }).catch(() => {})
}
},
onShow() {
const i = this._i
if (!this.data.started || !i.hiddenAt) return
const now = Date.now()
const hiddenMs = Math.max(0, now - i.hiddenAt)
i.hiddenAt = 0
if (!this.data.paused) this._applyBgCompensation(hiddenMs)
},
onHide() {
this._i.hiddenAt = Date.now()
},
_ensureAudioCtx() {
if (this._i.ttsCtx) return
try {
// iOS 微信 InnerAudioContext 默认 obeyMuteSwitch=true,iPhone 开启静音键会无声;运动播报必须忽略静音键
if (wx.setInnerAudioOption) {
wx.setInnerAudioOption({ obeyMuteSwitch: false, mixWithOther: true, speakerOn: true })
}
const ctx = wx.createInnerAudioContext()
ctx.src = ''
ctx.volume = 1.0
ctx.obeyMuteSwitch = false
ctx.speakerOn = true
ctx.onEnded(() => this._onTtsEnded())
ctx.onError((err) => this._onTtsFailed(err))
this._i.ttsCtx = ctx
} catch (e) {
this._i.ttsCtx = null
}
},
_destroyAudioCtx() {
try {
if (this._i.ttsCtx && this._i.ttsCtx.destroy) this._i.ttsCtx.destroy()
} catch (e) {}
this._i.ttsCtx = null
},
async _preloadTtsCache(plan, voice) {
try {
const r = await api.repClock.ttsBatch(this.data.cardId, this._i.card, voice)
const map = (r && r.data && Array.isArray(r.data.items)) ? r.data.items : []
map.forEach(it => {
if (it && it.text) this._i.ttsSrc[String(it.text).trim()] = normalizeAudioUrl(it.url || '')
})
} catch (e) {}
},
_onTtsEnded() {
this._i.lastAnnouncedText = ''
this._playNextTtsOrBeep()
},
_onTtsFailed(err) {
this._i.lastAnnouncedText = ''
// iOS 上 InnerAudioContext 常见 errCode:10003=格式不支持、10001=系统级失败;用于排查无声问题
try { console.error('[rep_clock] TTS 播放失败:', JSON.stringify(err || {})) } catch (e) {}
this._fallbackBeep()
this._playNextTtsOrBeep()
},
_enqueueTts(text, priority = 0) {
if (!text) return
const i = this._i
i.ttsQueue.push({ text, priority: Number(priority) || 0 })
i.ttsQueue.sort((a, b) => (b.priority - a.priority))
if (!i.ttsCtx) this._ensureAudioCtx()
if (i.ttsCtx && !this._isPlayingNow()) {
this._playNextTtsOrBeep()
}
},
_isPlayingNow() {
try { return this._i.ttsCtx && this._i.ttsCtx.src && this._i.lastAnnouncedText ? true : false } catch (e) { return false }
},
_playNextTtsOrBeep() {
const i = this._i
if (!i.ttsCtx) { this._fallbackBeep(); return }
if (!i.ttsQueue.length) return
const item = i.ttsQueue.shift()
const text = item.text
const url = normalizeAudioUrl(i.ttsSrc[String(text).trim()] || '')
if (!url) {
this._fallbackBeep()
this._vibrate()
if (!this._isPlayingNow()) this._playNextTtsOrBeep()
return
}
try {
i.lastAnnouncedText = text
i.ttsCtx.stop()
// encodeURI:iOS 微信对含中文/特殊字符的音频 URL 加载容易静默失败
i.ttsCtx.src = encodeURI(url)
i.ttsCtx.play()
} catch (e) {
this._fallbackBeep()
this._vibrate()
i.lastAnnouncedText = ''
this._playNextTtsOrBeep()
}
},
_fallbackBeep() {
try {
if (!this._fallbackBeepCtx) {
const ctx = wx.createInnerAudioContext()
ctx.volume = 1.0
this._fallbackBeepCtx = ctx
const list = [{ text: '__beep__', src: '' }]
this._fallbackBeepList = list
}
this._fallbackBeepCtx.stop()
// 微信 InnerAudioContext 无 src 时不一定会触发 error,但会静默失败;使用震动兜底
this._vibrate()
} catch (e) {
this._vibrate()
}
},
_vibrate() {
try { wx.vibrateShort({ type: 'medium' }) } catch (e) {}
},
async onStart() {
if (this.data.started) return
const i = this._i
const now = Date.now()
wx.showLoading({ title: '启动中…', mask: true })
try {
const r = await api.repClock.sessionStart({ cardId: this.data.cardId })
if (!r || !r.session || !r.session.id) throw new Error('启动失败')
i.sessionId = r.session.id
i.globalStartAt = now
i.pausedTotalMs = 0
i.cursor = 0
this._announceStart()
this._enterPhase(PHASE_ACTION, now)
this._startTickLoop()
this.setData({ started: true, paused: false })
wx.hideLoading()
} catch (e) {
wx.hideLoading()
const msg = e && e.message ? String(e.message) : ''
if (/409|冲突|running session exists/.test(msg)) {
const waitSec = e && e.expiresInMs ? Math.ceil(Number(e.expiresInMs) / 1000) : 300
wx.showModal({
title: '已有运动进行中',
content: `检测到另一会话正在进行,${waitSec} 秒后可自动解锁。或选择立即强制抢占(建议先去其他页面结束后再启动)。`,
confirmText: '立即强制开始',
cancelText: '等待自动解锁',
confirmColor: '#e05561',
showCancel: true,
success: async (mr) => {
if (mr.confirm) {
try { await api.repClock.resetRunning('') } catch (_) {}
wx.showLoading({ title: '启动中…', mask: true })
try {
const r2 = await api.repClock.sessionStart({ cardId: this.data.cardId })
if (!r2 || !r2.session || !r2.session.id) throw new Error('启动失败')
i.sessionId = r2.session.id
i.globalStartAt = Date.now()
i.pausedTotalMs = 0
i.cursor = 0
this._announceStart()
this._enterPhase(PHASE_ACTION, Date.now())
this._startTickLoop()
this.setData({ started: true, paused: false })
wx.hideLoading()
} catch (e2) {
wx.hideLoading()
wx.showModal({ title: '仍无法启动', content: (e2 && e2.message ? e2.message : '未知错误') + ',请稍后重试或重启服务。', showCancel: false, confirmText: '知道了' })
}
}
},
})
} else {
wx.showModal({ title: '启动失败', content: msg || '请检查网络或重启服务后重试。', showCancel: false, confirmText: '知道了' })
}
}
},
onPause() {
if (!this.data.started || this.data.paused) return
const now = Date.now()
this._i.pausedAt = now
this.setData({ paused: true })
},
onResume() {
if (!this.data.started || !this.data.paused) return
const now = Date.now()
const pMs = Math.max(0, now - (this._i.pausedAt || now))
this._i.pausedTotalMs += pMs
this._i.phaseStartAt += pMs
this._i.phaseEndAt += pMs
this._i.pausedAt = 0
this.setData({ paused: false })
},
onStepNext() {
if (!this.data.started) return
const now = Date.now()
this._advanceToNextPhase(true, now)
},
async onStop() {
if (!this.data.started) return
wx.showModal({
title: '结束运动?',
content: '会保存本次运动记录并返回。',
confirmText: '结束',
cancelText: '取消',
confirmColor: '#e05561',
success: async (r) => {
if (!r.confirm) return
await this._finishSession(false, Date.now())
}
})
},
_startTickLoop() {
this._clearTickTimer()
this._i.tickTimer = setInterval(() => this._tick(), 100)
},
_clearTickTimer() {
if (this._i.tickTimer) { clearInterval(this._i.tickTimer); this._i.tickTimer = null }
},
_applyBgCompensation(hiddenMs) {
const i = this._i
if (!this.data.started || hiddenMs < 1000) return
// 短档:<20秒,直接扣剩余时间
if (hiddenMs < 20 * 1000) {
i.phaseStartAt += hiddenMs
i.phaseEndAt += hiddenMs
return
}
// 中档:20秒~10分钟,快进完整步
if (hiddenMs < 10 * 60 * 1000) {
let jumpRemain = hiddenMs
while (jumpRemain > 2000 && !this._isPlanFinished()) {
const remainInPhase = Math.max(0, i.phaseEndAt - Date.now())
if (jumpRemain >= remainInPhase) {
jumpRemain -= remainInPhase
this._advanceToNextPhase(false, Date.now() + remainInPhase)
} else {
i.phaseStartAt += jumpRemain
i.phaseEndAt += jumpRemain
jumpRemain = 0
}
}
return
}
// 长档:>10分钟,直接结束
this._finishSession(false, Date.now()).catch(() => {})
},
_tick() {
const i = this._i
if (!this.data.started || this.data.paused) return
const now = Date.now()
this._renderState(now)
this._maybeAnnounceCountdown(now)
if (now >= i.phaseEndAt) {
this._advanceToNextPhase(false, now)
}
if (now - (i.lastServerSyncAt || 0) >= 6000) {
i.lastServerSyncAt = now
this._syncToServer(now).catch(() => {})
}
},
_renderState(now) {
const i = this._i
const phaseRemainMs = Math.max(0, i.phaseEndAt - now)
const totalPlanMs = (i.plan || []).reduce((a, p) => a + (p.durationSec + p.restSec) * 1000, 0)
const completedBefore = Math.min(i.cursor, i.plan.length) * 1000
const finishedBeforeMs = i.plan.slice(0, i.cursor).reduce((a, p) => a + (p.durationSec + p.restSec) * 1000, 0)
const phaseTotalMs = i.durationMs
const phasePassedMs = Math.max(0, Math.min(phaseTotalMs, phaseTotalMs - phaseRemainMs))
const elapsedMs = (now - i.globalStartAt) - i.pausedTotalMs
const totalPassedMs = finishedBeforeMs + phasePassedMs
const remainMs = Math.max(0, totalPlanMs - totalPassedMs)
const rMin = Math.floor(remainMs / 60000), rSec = Math.floor((remainMs % 60000) / 1000)
const eMin = Math.floor(elapsedMs / 60000), eSec = Math.floor((elapsedMs % 60000) / 1000)
const compPct = totalPlanMs <= 0 ? 0 : Math.min(100, Math.round((totalPassedMs / totalPlanMs) * 100))
const cur = i.plan[i.cursor]
const nextIdx = i.cursor + 1
const next = nextIdx < i.plan.length ? i.plan[nextIdx] : null
let phaseLabel = '准备开始', stepSeq = '', stepName = ''
if (cur) {
const round = cur.loopUntil ? '循环' : `${cur.round}/${cur.roundTotal}`
phaseLabel = i.phase === PHASE_ACTION ? '动作' : '休息'
stepSeq = `${round} · 第${cur.stepIndex + 1}步`
stepName = i.phase === PHASE_ACTION ? (cur.voiceText || cur.stepName || '') : '休息一下'
}
const phaseSec = Math.max(0, Math.round(phaseRemainMs / 1000))
const phaseMin = Math.floor(phaseSec / 60)
const phaseSec2 = phaseSec - phaseMin * 60
const remainingText = `${String(phaseMin)}:${pad2(phaseSec2)}`
this.setData({
remainingText,
phaseLabel,
stepSeq,
stepName,
nextName: next ? (next.phase === PHASE_ACTION ? (next.stepName || '') : '休息') : '',
remainingMin: pad2(rMin),
remainingSecPad: pad2(rSec),
elapsedMin: pad2(eMin),
elapsedSecPad: pad2(eSec),
completion: compPct,
})
},
_enterPhase(phase, nowMs) {
const i = this._i
const cur = i.plan[i.cursor]
if (!cur) return
i.phase = phase
const sec = phase === PHASE_ACTION ? cur.durationSec : cur.restSec
i.durationMs = sec * 1000
i.phaseStartAt = nowMs
i.phaseEndAt = nowMs + i.durationMs
i.announcedSet = new Set()
if (sec <= 0 && phase === PHASE_REST) {
this._advanceToNextPhase(false, nowMs)
return
}
// 播报:步名/休息。注意文本必须与服务端 ttsBatch 预合成缓存的 key 完全一致,
// 否则(如带“第X步:”前缀)命中不了缓存导致无声
let speak = ''
if (phase === PHASE_ACTION) {
speak = cur.voiceText || cur.stepName || '开始'
} else {
speak = `休息${cur.restSec}秒`
}
this._enqueueTts(speak, 10)
},
_isPlanFinished() {
const i = this._i
if (i.loopUntil) return false
return i.cursor >= i.plan.length
},
_announceStart() {
const title = String((this._i.card && this._i.card.title) || this.data.cardTitle || '').trim()
if (!title) return
this._enqueueTts(`开始训练,${title}`, 9)
},
// 切换动作前的过渡语:新的一轮(第N轮)播“第N轮”,同一轮内播“下一组”。
// 过渡语不内嵌动作名,动作名由进入步骤时单独播报,避免“平板支撑 → 平板支撑”重复。
// 缓存里没合成过的文本不播,避免只震动不出声
_announceNextStep() {
const i = this._i
const nxt = i.plan[i.cursor]
if (!nxt) return
const isNewRound = nxt.round > 1 && Number(nxt.stepIndex) === 0
const text = isNewRound ? `第${nxt.round}轮` : '下一组'
if (!i.ttsSrc[text]) return
this._enqueueTts(text, 11)
},
_advanceToNextPhase(manualSkip, nowMs) {
const i = this._i
if (i.phase === PHASE_ACTION) {
const cur = i.plan[i.cursor]
if (cur && cur.restSec > 0) {
this._enterPhase(PHASE_REST, nowMs)
return
}
}
// 结束当前 rest/action ,推进到下一条 plan 条目
i.cursor += 1
if (i.loopUntil) {
// 循环至停止:如果到达末尾,回到第一条(永不 finish,除非手动停止)
if (i.cursor >= i.plan.length) i.cursor = 0
this._announceNextStep()
this._enterPhase(PHASE_ACTION, nowMs)
return
}
if (i.cursor >= i.plan.length) {
this._finishSession(true, nowMs).catch(() => {})
return
}
this._announceNextStep()
this._enterPhase(PHASE_ACTION, nowMs)
},
_maybeAnnounceCountdown(now) {
const i = this._i
if (!this.data.hasCountdown) return
if (i.phase !== PHASE_ACTION && i.phase !== PHASE_REST) return
const remainSec = Math.round((i.phaseEndAt - now) / 1000)
if (remainSec <= 0 || remainSec > 60) return
if (!i.countdownSet.includes(remainSec)) return
if (i.announcedSet.has(i.cursor + '|' + i.phase + '|' + remainSec)) return
i.announcedSet.add(i.cursor + '|' + i.phase + '|' + remainSec)
// 最后 3 秒以内,使用更短播报
if (remainSec <= 3) {
this._enqueueTts(`还有${remainSec}秒`, 5)
} else {
this._enqueueTts(`还有${remainSec}秒`, 5)
}
},
async _syncToServer(nowMs) {
const i = this._i
if (!i.sessionId || i.finishing) return
const cur = i.plan[i.cursor]
await api.repClock.sessionSync({
sessionId: i.sessionId,
currentStepIndex: cur ? cur.stepIndex : -1,
currentRound: cur ? cur.round : 1,
phase: i.phase,
cursor: i.cursor,
phaseRemainingMs: Math.max(0, i.phaseEndAt - nowMs),
paused: !!this.data.paused,
pausedTotalMs: i.pausedTotalMs,
})
},
async _finishSession(completed, nowMs) {
const i = this._i
if (i.finishing) return
i.finishing = true
this._clearTickTimer()
wx.showLoading({ title: '保存记录…', mask: true })
try {
await api.repClock.sessionSync({
sessionId: i.sessionId,
currentStepIndex: -1,
currentRound: 1,
phase: 'finish',
cursor: i.cursor,
phaseRemainingMs: 0,
paused: !!this.data.paused,
pausedTotalMs: i.pausedTotalMs,
})
await api.repClock.sessionFinish({
sessionId: i.sessionId,
completed: !!completed,
force: false,
})
wx.hideLoading()
wx.showToast({ title: completed ? '完成!真棒 🎉' : '已保存', icon: 'success' })
setTimeout(() => wx.navigateBack(), 900)
} catch (e) {
wx.hideLoading()
wx.showToast({ title: '记录已在服务端,返回', icon: 'none' })
setTimeout(() => wx.navigateBack(), 900)
}
},
})
@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "节拍中…",
"navigationBarBackgroundColor": "#1a1a2e",
"navigationBarTextStyle": "white",
"backgroundColor": "#0f0f1a",
"disableScroll": true
}
@@ -0,0 +1,57 @@
<view class="runner-root">
<view class="runner-bg"></view>
<view class="runner-layout">
<view class="runner-topbar">
<view class="topbar-title">{{cardTitle}}</view>
<view class="topbar-sub muted small">
<text wx:if="{{phaseLabel}}">{{phaseLabel}}</text>
<text wx:if="{{phaseLabel && stepSeq}}"> · </text>
<text wx:if="{{stepSeq}}">{{stepSeq}}</text>
</view>
</view>
<view class="center-clock-wrap">
<view class="ring">
<view class="ring-left"></view>
<view class="ring-right"></view>
<view class="ring-mask-left"></view>
<view class="ring-mask-right"></view>
</view>
<view class="clock-inner">
<view class="clock-text">{{remainingText}}</view>
<view class="clock-sub muted">{{stepName}}</view>
</view>
</view>
<view wx:if="{{nextName}}" class="next-card muted small">
下一步:<text class="next-name">{{nextName}}</text>
</view>
<view class="stats-row">
<view class="stat">
<view class="stat-num">{{elapsedMin}}:{{elapsedSecPad}}</view>
<view class="stat-label">已过</view>
</view>
<view class="stat">
<view class="stat-num">{{remainingMin}}:{{remainingSecPad}}</view>
<view class="stat-label">剩余</view>
</view>
<view class="stat">
<view class="stat-num">{{completion}}%</view>
<view class="stat-label">完成</view>
</view>
</view>
<view class="actions">
<view wx:if="{{!started}}" class="btn btn-primary btn-block act-btn" bindtap="onStart">▶ 开始</view>
<block wx:else>
<view wx:if="{{paused}}" class="btn btn-primary btn-block act-btn" bindtap="onResume">▶ 继续</view>
<view wx:else class="btn btn-ghost btn-block act-btn" bindtap="onPause">⏸ 暂停</view>
<view class="btn btn-ghost btn-block act-btn act-btn-secondary" bindtap="onStepNext">⏭ 下一步</view>
<view class="btn btn-danger btn-block act-btn" bindtap="onStop">⏹ 结束</view>
</block>
</view>
<view wx:if="{{hasCountdown}}" class="broadcasts muted small">倒计时播报:{{countdownText}}</view>
</view>
</view>
@@ -0,0 +1,143 @@
.runner-root {
min-height: 100vh;
position: relative;
color: #ffffff;
overflow-x: hidden;
overflow-y: auto;
}
.runner-bg {
position: fixed; inset: 0;
background: radial-gradient(circle at 30% 20%, #1f1f3d, #0a0a14 65%);
z-index: 0;
}
.runner-layout {
position: relative;
z-index: 1;
padding: 36rpx 28rpx calc(24rpx + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
min-height: 100vh;
box-sizing: border-box;
}
.runner-topbar { margin-bottom: 18rpx; }
.topbar-title { font-size: 40rpx; font-weight: 800; letter-spacing: 1rpx; }
.topbar-sub { margin-top: 6rpx; }
.center-clock-wrap {
position: relative;
width: 468rpx;
height: 468rpx;
margin: 18rpx auto 0;
}
.ring {
position: absolute; inset: 0;
border-radius: 50%;
background: linear-gradient(135deg, #6c7ae0, #b06be0);
opacity: 0.18;
}
.ring-left, .ring-right {
position: absolute;
width: 50%;
height: 100%;
left: 0; top: 0;
border-radius: 280rpx 0 0 280rpx;
background: conic-gradient(from 0deg, #6c7ae0 0deg 180deg, rgba(108,122,224,0.15) 180deg 360deg);
transform-origin: 100% 50%;
}
.ring-right {
left: 50%;
border-radius: 0 280rpx 280rpx 0;
background: conic-gradient(from 180deg, #b06be0 180deg 360deg, rgba(176,107,224,0.15) 360deg 360deg);
transform-origin: 0% 50%;
}
.ring-mask-left, .ring-mask-right {
position: absolute;
width: 50%;
height: 100%;
background: #0e0e19;
z-index: 2;
top: 0; left: 0;
border-radius: 280rpx 0 0 280rpx;
transform-origin: 100% 50%;
}
.ring-mask-right {
left: 50%;
border-radius: 0 280rpx 280rpx 0;
background: #0e0e19;
transform-origin: 0% 50%;
}
.clock-inner {
position: absolute;
inset: 48rpx;
border-radius: 50%;
background: #101025;
border: 1rpx solid rgba(255,255,255,0.08);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 3;
box-shadow: 0 0 60rpx rgba(108,122,224,0.15), inset 0 0 80rpx rgba(0,0,0,0.6);
}
.clock-text {
font-size: 148rpx;
font-weight: 800;
letter-spacing: 4rpx;
font-variant-numeric: tabular-nums;
background: linear-gradient(135deg, #ffffff, #d6dbff);
color: transparent;
-webkit-background-clip: text;
background-clip: text;
}
.clock-sub { margin-top: 12rpx; font-size: 28rpx; color: #c2c6f4; }
.next-card {
margin: 22rpx auto 0;
padding: 14rpx 24rpx;
background: rgba(255,255,255,0.04);
border: 1rpx solid rgba(255,255,255,0.06);
border-radius: 20rpx;
text-align: center;
}
.next-name { color: #ffffff; margin-left: 12rpx; font-weight: 600; }
.stats-row {
margin-top: 26rpx;
display: flex;
gap: 14rpx;
}
.stat {
flex: 1;
padding: 18rpx 8rpx;
background: rgba(255,255,255,0.04);
border: 1rpx solid rgba(255,255,255,0.06);
border-radius: 22rpx;
text-align: center;
}
.stat-num {
font-size: 34rpx;
font-weight: 700;
color: #e9eaff;
font-variant-numeric: tabular-nums;
}
.stat-label {
margin-top: 6rpx;
color: #7a7ea4;
font-size: 22rpx;
}
.actions {
margin-top: auto;
padding: 24rpx 0 8rpx;
display: flex;
gap: 12rpx;
flex-direction: column;
}
.act-btn { padding: 22rpx 0; font-size: 30rpx; font-weight: 600; border-radius: 20rpx; }
.act-btn-secondary { background: rgba(255,255,255,0.04); color: #e2e4ff; border: 1rpx solid rgba(255,255,255,0.08); }
.broadcasts {
margin: 10rpx auto 0;
text-align: center;
letter-spacing: 0.5rpx;
}
+50 -80
View File
@@ -5,8 +5,6 @@ Page({
loading: true,
kits: [],
appNameZh: 'MilesYang时光助手',
subtitle: '个人工具集合,记录时光点滴。',
error: '',
clipVisible: false,
clipText: '',
clipCount: 0,
@@ -14,18 +12,12 @@ Page({
clipPreview: '',
},
onLoad() {
this.loadMeta()
},
onLoad() { this.refresh() },
onShow() {
this.checkClipboard()
},
onShow() { this.checkClipboard() },
onPullDownRefresh() {
this.loadMeta().finally(() => {
wx.stopPullDownRefresh()
})
this.refresh().finally(() => wx.stopPullDownRefresh())
},
simpleHash(str) {
@@ -42,6 +34,50 @@ Page({
return h1.toString(36) + h2.toString(36)
},
async refresh() {
this.setData({ loading: true })
try {
const res = await api.core.getMeta()
if (res && res.appNameZh) this.setData({ appNameZh: res.appNameZh })
} catch (e) {}
this.setData({
kits: this.buildKits(),
loading: false,
})
},
buildKits() {
return [
{
id: 'personal_collect',
name: '微信收藏夹',
description: '聊天图片 / 文件 / 文字收藏,标签搜索,软删除可追溯。',
icon: '📁',
path: '/kits/personal_collect/pages/list/list',
meta: 'API: /api/mp_personal_collect/ · DB: mp_personal_collect.db',
},
{
id: 'rep_clock',
name: '运动节拍 RepClock',
description: 'HIIT / Yoga / 拉伸节奏倒计时,锚点时钟 + 女声播报。',
icon: '⏱️',
path: '/kits/rep_clock/pages/cards/cards',
meta: 'API: /api/mp_rep_clock/ · DB: mp_rep_clock.db',
},
]
},
async onKitTap(e) {
const id = (e.currentTarget.dataset.id || '').trim()
const path = (e.currentTarget.dataset.path || '').trim()
if (!path) { wx.showToast({ title: '功能开发中', icon: 'none' }); return }
try { await api.core.setActiveKit(id) } catch (e) {}
wx.navigateTo({
url: path,
fail: () => wx.showToast({ title: '打开失败', icon: 'none' }),
})
},
checkClipboard() {
try {
const enable = wx.getStorageSync('mp_ta_clip_enable')
@@ -72,15 +108,13 @@ Page({
})
},
onClipCollect(e) {
const mode = (e && e.currentTarget && e.currentTarget.dataset.mode) || 'new'
onClipCollect() {
const text = this.data.clipText || ''
if (!text) { this.setData({ clipVisible: false }); return }
try { wx.setStorageSync('mp_ta_clip_text', text) } catch (e) {}
try { wx.setStorageSync('mp_ta_clip_mode', mode) } catch (e) {}
this.setData({ clipVisible: false })
wx.navigateTo({
url: '/kits/personal_collect/pages/edit/edit',
url: '/kits/personal_collect/pages/edit/edit?mode=new',
fail: () => { wx.showToast({ title: '打开失败', icon: 'none' }) }
})
},
@@ -105,69 +139,5 @@ Page({
})
},
async loadMeta() {
this.setData({ loading: true, error: '' })
try {
const res = await api.core.getMeta()
if (res && res.appNameZh) {
this.setData({ appNameZh: res.appNameZh })
}
if (res && Array.isArray(res.kits) && res.kits.length) {
this.setData({ kits: this.decorateKits(res.kits) })
try { getApp().setMeta(res) } catch (e) {}
} else {
this.setData({ kits: this.fallbackKits() })
}
} catch (e) {
this.setData({
error: e && e.message ? e.message : '加载失败,请检查网络',
kits: this.fallbackKits(),
})
} finally {
this.setData({ loading: false })
}
},
fallbackKits() {
return this.decorateKits([{
id: 'personal_collect',
name: '微信收藏夹',
description: '聊天图片、文件收藏管理,标签搜索,软删除可追溯。',
}])
},
decorateKits(list) {
const icons = {
personal_collect: '📁',
}
return list.map(item => Object.assign({}, item, {
icon: icons[item.id] || '🛠️',
path: this.getKitPath(item.id),
}))
},
getKitPath(id) {
const map = {
personal_collect: '/kits/personal_collect/pages/list/list',
}
return map[id] || ''
},
async onKitTap(e) {
const id = e.currentTarget.dataset.id
const path = e.currentTarget.dataset.path
if (!path) {
wx.showToast({ title: '功能开发中', icon: 'none' })
return
}
try {
await api.core.setActiveKit(id)
} catch (e) {}
wx.navigateTo({
url: path,
fail: () => {
wx.showToast({ title: '打开失败', icon: 'none' })
},
})
},
noop() {},
})
@@ -1,5 +1,8 @@
{
"navigationBarTitleText": "MilesYang时光助手",
"navigationBarTitleText": "时光助手",
"navigationBarBackgroundColor": "#1a1a2e",
"navigationBarTextStyle": "white",
"backgroundColor": "#0f0f1a",
"enablePullDownRefresh": true,
"backgroundTextStyle": "light"
}
@@ -1,8 +1,7 @@
<view class="container">
<view class="page-header">
<view class="page-title">{{appNameZh}}</view>
<view class="page-subtitle">{{subtitle}}</view>
<view wx:if="{{error}}" class="muted small" style="margin-top: 16rpx; color: #e05561;">⚠️ {{error}}</view>
<view class="page-subtitle">小工具集合 · 完全独立 · 零共享</view>
</view>
<view wx:if="{{clipVisible}}" class="clip-card" catchtouchmove="noop">
@@ -14,12 +13,22 @@
<view class="clip-actions">
<view class="clip-btn clip-btn-ghost" bindtap="onClipDismissForever">不再提示</view>
<view class="clip-btn clip-btn-ghost" bindtap="onClipCancel">取消</view>
<view class="clip-btn clip-btn-primary" bindtap="onClipCollect" data-mode="new">📌 收藏为文字</view>
<view class="clip-btn clip-btn-primary" bindtap="onClipCollect">📌 收藏为文字</view>
</view>
</view>
<view wx:if="{{loading}}" class="empty">加载中...</view>
<view class="hero-card">
<view class="hero-icon">
<text class="hero-emoji">🧘</text>
</view>
<view class="hero-body">
<view class="hero-title">每一次节奏,都是一次呼吸。</view>
<view class="hero-desc">独立工具集合,互不干扰,可独立部署。</view>
</view>
</view>
<block wx:for="{{kits}}" wx:key="id">
<view class="kit-card" bindtap="onKitTap" data-id="{{item.id}}" data-path="{{item.path}}">
<view class="kit-icon">
@@ -28,8 +37,13 @@
<view class="kit-body">
<view class="kit-title">{{item.name}}</view>
<view class="kit-desc">{{item.description}}</view>
<view wx:if="{{item.meta}}" class="kit-meta">{{item.meta}}</view>
</view>
<view class="chevron">›</view>
</view>
</block>
<view class="footer muted small">
独立部署 · 独立数据库 · 独立运行锁
</view>
</view>
@@ -1,4 +1,26 @@
/* 首页独立样式,大部分复用 app.wxss 全局样式 */
.hero-card {
margin: 4rpx 24rpx 36rpx;
padding: 32rpx 30rpx;
background: linear-gradient(145deg, rgba(108, 122, 224, 0.22), rgba(176, 107, 224, 0.16));
border: 1px solid rgba(140, 150, 240, 0.24);
border-radius: 28rpx;
display: flex;
align-items: center;
gap: 26rpx;
box-shadow: 0 14rpx 36rpx rgba(0,0,0,0.28);
}
.hero-icon { width: 96rpx; height: 96rpx; flex: none; border-radius: 24rpx; background: rgba(255,255,255,0.08); display: flex; align-items: center; justify-content: center; }
.hero-emoji { font-size: 54rpx; }
.hero-title { font-size: 30rpx; font-weight: 700; color: #efeffe; letter-spacing: 0.5rpx; margin-bottom: 8rpx; }
.hero-desc { font-size: 24rpx; color: #a6a9d0; }
.footer {
margin: 80rpx 0 40rpx;
text-align: center;
letter-spacing: 1rpx;
color: #5a5e82;
font-size: 22rpx;
}
.clip-card {
margin: 20rpx 24rpx 12rpx;
@@ -59,11 +81,6 @@
color: #b8b8d8;
border: 1px solid rgba(255,255,255,0.08);
}
.clip-btn-secondary {
background: rgba(108, 122, 224, 0.16);
color: #cfd4ff;
border: 1px solid rgba(108, 122, 224, 0.32);
}
.clip-btn-primary {
background: linear-gradient(135deg, #6c7ae0, #b06be0);
color: #fff;
@@ -0,0 +1,60 @@
const api = require('../../api.js')
Page({
data: {
loading: true,
kits: [],
appNameZh: 'MilesYang时光助手',
subtitle: '小工具集合 · 完全独立 · 零共享',
},
onLoad() { this.refresh() },
onPullDownRefresh() {
this.refresh().finally(() => wx.stopPullDownRefresh())
},
async refresh() {
this.setData({ loading: true })
try {
const res = await api.core.getMeta()
if (res && res.appNameZh) this.setData({ appNameZh: res.appNameZh })
} catch (e) {}
this.setData({
kits: this.buildKits(),
loading: false,
})
},
buildKits() {
return [
{
id: 'personal_collect',
name: '微信收藏夹',
description: '聊天图片 / 文件 / 文字收藏,标签搜索,软删除可追溯。',
icon: '📁',
path: '/kits/personal_collect/pages/list/list',
meta: 'API: /api/mp_personal_collect/ · DB: mp_personal_collect.db',
},
{
id: 'rep_clock',
name: '运动节拍 RepClock',
description: 'HIIT / Yoga / 拉伸节奏倒计时,锚点时钟 + 女声播报。',
icon: '⏱️',
path: '/kits/rep_clock/pages/cards/cards',
meta: 'API: /api/mp_rep_clock/ · DB: mp_rep_clock.db',
},
]
},
async onKitTap(e) {
const id = (e.currentTarget.dataset.id || '').trim()
const path = (e.currentTarget.dataset.path || '').trim()
if (!path) { wx.showToast({ title: '功能开发中', icon: 'none' }); return }
try { await api.core.setActiveKit(id) } catch (e) {}
wx.navigateTo({
url: path,
fail: () => wx.showToast({ title: '打开失败', icon: 'none' }),
})
},
})
@@ -0,0 +1,8 @@
{
"navigationBarTitleText": "时光助手",
"navigationBarBackgroundColor": "#1a1a2e",
"navigationBarTextStyle": "white",
"backgroundColor": "#0f0f1a",
"enablePullDownRefresh": true,
"backgroundTextStyle": "light"
}
@@ -0,0 +1,36 @@
<view class="container">
<view class="page-header">
<view class="page-title">{{appNameZh}}</view>
<view class="page-subtitle">{{subtitle}}</view>
</view>
<view wx:if="{{loading}}" class="empty">加载中...</view>
<view class="hero-card">
<view class="hero-icon">
<text class="hero-emoji">🧘</text>
</view>
<view class="hero-body">
<view class="hero-title">每一次节奏,都是一次呼吸。</view>
<view class="hero-desc">独立工具集合,互不干扰,可独立部署。</view>
</view>
</view>
<block wx:for="{{kits}}" wx:key="id">
<view class="kit-card" bindtap="onKitTap" data-id="{{item.id}}" data-path="{{item.path}}">
<view class="kit-icon">
<text class="kit-icon-text">{{item.icon}}</text>
</view>
<view class="kit-body">
<view class="kit-title">{{item.name}}</view>
<view class="kit-desc">{{item.description}}</view>
<view wx:if="{{item.meta}}" class="kit-meta">{{item.meta}}</view>
</view>
<view class="chevron">›</view>
</view>
</block>
<view class="footer muted small">
独立部署 · 独立数据库 · 独立运行锁
</view>
</view>
@@ -0,0 +1,23 @@
.hero-card {
margin: 4rpx 24rpx 36rpx;
padding: 32rpx 30rpx;
background: linear-gradient(145deg, rgba(108, 122, 224, 0.22), rgba(176, 107, 224, 0.16));
border: 1px solid rgba(140, 150, 240, 0.24);
border-radius: 28rpx;
display: flex;
align-items: center;
gap: 26rpx;
box-shadow: 0 14rpx 36rpx rgba(0,0,0,0.28);
}
.hero-icon { width: 96rpx; height: 96rpx; flex: none; border-radius: 24rpx; background: rgba(255,255,255,0.08); display: flex; align-items: center; justify-content: center; }
.hero-emoji { font-size: 54rpx; }
.hero-title { font-size: 30rpx; font-weight: 700; color: #efeffe; letter-spacing: 0.5rpx; margin-bottom: 8rpx; }
.hero-desc { font-size: 24rpx; color: #a6a9d0; }
.footer {
margin: 80rpx 0 40rpx;
text-align: center;
letter-spacing: 1rpx;
color: #5a5e82;
font-size: 22rpx;
}