feat: 新增运动节拍训练工具套件与子工具页面
新增运动节拍RepClock全套小程序页面、后端API与数据库服务,新增subhub子工具汇总页面,优化全局主页样式与配置,为web静态资源添加版本缓存参数,补充配套调试脚本
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user