From d4bf3627fdae4b2a8bc295f4347dab039d3d51b3 Mon Sep 17 00:00:00 2001 From: yangxiangyuan Date: Wed, 9 Sep 2026 14:43:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=BF=90=E5=8A=A8?= =?UTF-8?q?=E8=8A=82=E6=8B=8D=E8=AE=AD=E7=BB=83=E5=B7=A5=E5=85=B7=E5=A5=97?= =?UTF-8?q?=E4=BB=B6=E4=B8=8E=E5=AD=90=E5=B7=A5=E5=85=B7=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增运动节拍RepClock全套小程序页面、后端API与数据库服务,新增subhub子工具汇总页面,优化全局主页样式与配置,为web静态资源添加版本缓存参数,补充配套调试脚本 --- Trae_FilesTRAE-Toolboxdatamp_rep_clock.db | 0 .../debug/debug_409_with_expires.js | 58 ++ .../debug/debug_check_new_lock_loaded.js | 24 + .../debug/debug_clear_lock_now.js | 25 + dev_test_scripts/debug/debug_curl_409_body.js | 33 + .../debug/debug_fix_runner_lock.js | 45 ++ .../debug/debug_mpta_smoke_5054.js | 60 ++ .../debug/debug_repclock_encoding.js | 58 ++ dev_test_scripts/debug/debug_runner_start.js | 61 ++ dev_test_scripts/debug/debug_tts_400.js | 38 ++ dev_test_scripts/debug/debug_tts_check.js | 49 ++ .../debug/debug_tts_cosy_probe.js | 45 ++ dev_test_scripts/debug/debug_tts_db_fix.js | 37 ++ dev_test_scripts/debug/debug_tts_debug.js | 79 +++ dev_test_scripts/debug/debug_tts_inner.js | 115 ++++ .../debug/debug_tts_live_probe.js | 39 ++ dev_test_scripts/debug/debug_tts_models.js | 70 ++ dev_test_scripts/debug/debug_tts_probe.js | 62 ++ dev_test_scripts/debug/debug_tts_smoke.js | 125 ++++ miniprogram/mp_time_assistant/api.js | 89 ++- miniprogram/mp_time_assistant/app.json | 8 +- .../rep_clock/pages/card_edit/card_edit.js | 238 +++++++ .../rep_clock/pages/card_edit/card_edit.json | 6 + .../rep_clock/pages/card_edit/card_edit.wxml | 98 +++ .../rep_clock/pages/card_edit/card_edit.wxss | 62 ++ .../kits/rep_clock/pages/cards/cards.js | 214 ++++++ .../kits/rep_clock/pages/cards/cards.json | 8 + .../kits/rep_clock/pages/cards/cards.wxml | 56 ++ .../kits/rep_clock/pages/cards/cards.wxss | 34 + .../kits/rep_clock/pages/history/history.js | 91 +++ .../kits/rep_clock/pages/history/history.json | 8 + .../kits/rep_clock/pages/history/history.wxml | 63 ++ .../kits/rep_clock/pages/history/history.wxss | 37 ++ .../kits/rep_clock/pages/runner/runner.js | 610 ++++++++++++++++++ .../kits/rep_clock/pages/runner/runner.json | 7 + .../kits/rep_clock/pages/runner/runner.wxml | 57 ++ .../kits/rep_clock/pages/runner/runner.wxss | 143 ++++ .../mp_time_assistant/pages/hub/hub.js | 130 ++-- .../mp_time_assistant/pages/hub/hub.json | 5 +- .../mp_time_assistant/pages/hub/hub.wxml | 20 +- .../mp_time_assistant/pages/hub/hub.wxss | 29 +- .../mp_time_assistant/pages/subhub/subhub.js | 60 ++ .../pages/subhub/subhub.json | 8 + .../pages/subhub/subhub.wxml | 36 ++ .../pages/subhub/subhub.wxss | 23 + public/tools/web_mp_time_assistant/api.js | 36 +- public/tools/web_mp_time_assistant/hub.css | 177 ++++- public/tools/web_mp_time_assistant/hub.html | 24 +- public/tools/web_mp_time_assistant/hub.js | 44 +- public/tools/web_mp_time_assistant/index.html | 24 +- .../kits/personal_collect/detail.css | 200 ++++-- .../kits/personal_collect/detail.html | 12 +- .../kits/personal_collect/detail.js | 115 +++- .../kits/personal_collect/edit.html | 12 +- .../kits/personal_collect/edit.js | 111 +++- .../kits/personal_collect/list.html | 12 +- .../kits/personal_collect/list.js | 104 ++- src/server/index.js | 68 ++ src/server/mp_personal_collect/index.js | 282 ++++++++ src/server/mp_personal_collect/store.js | 332 ++++++++++ src/server/mp_rep_clock/db.js | 94 +++ src/server/mp_rep_clock/index.js | 35 + .../mp_rep_clock/kits/rep_clock/index.js | 174 +++++ .../mp_rep_clock/kits/rep_clock/store.js | 340 ++++++++++ src/server/mp_rep_clock/locks.js | 91 +++ src/server/mp_rep_clock/tts.js | 294 +++++++++ src/server/mp_time_assistant/index.js | 27 +- .../kits/personal_collect/index.js | 128 +++- .../kits/personal_collect/store.js | 157 ++++- 69 files changed, 5759 insertions(+), 297 deletions(-) create mode 100644 Trae_FilesTRAE-Toolboxdatamp_rep_clock.db create mode 100644 dev_test_scripts/debug/debug_409_with_expires.js create mode 100644 dev_test_scripts/debug/debug_check_new_lock_loaded.js create mode 100644 dev_test_scripts/debug/debug_clear_lock_now.js create mode 100644 dev_test_scripts/debug/debug_curl_409_body.js create mode 100644 dev_test_scripts/debug/debug_fix_runner_lock.js create mode 100644 dev_test_scripts/debug/debug_mpta_smoke_5054.js create mode 100644 dev_test_scripts/debug/debug_repclock_encoding.js create mode 100644 dev_test_scripts/debug/debug_runner_start.js create mode 100644 dev_test_scripts/debug/debug_tts_400.js create mode 100644 dev_test_scripts/debug/debug_tts_check.js create mode 100644 dev_test_scripts/debug/debug_tts_cosy_probe.js create mode 100644 dev_test_scripts/debug/debug_tts_db_fix.js create mode 100644 dev_test_scripts/debug/debug_tts_debug.js create mode 100644 dev_test_scripts/debug/debug_tts_inner.js create mode 100644 dev_test_scripts/debug/debug_tts_live_probe.js create mode 100644 dev_test_scripts/debug/debug_tts_models.js create mode 100644 dev_test_scripts/debug/debug_tts_probe.js create mode 100644 dev_test_scripts/debug/debug_tts_smoke.js create mode 100644 miniprogram/mp_time_assistant/kits/rep_clock/pages/card_edit/card_edit.js create mode 100644 miniprogram/mp_time_assistant/kits/rep_clock/pages/card_edit/card_edit.json create mode 100644 miniprogram/mp_time_assistant/kits/rep_clock/pages/card_edit/card_edit.wxml create mode 100644 miniprogram/mp_time_assistant/kits/rep_clock/pages/card_edit/card_edit.wxss create mode 100644 miniprogram/mp_time_assistant/kits/rep_clock/pages/cards/cards.js create mode 100644 miniprogram/mp_time_assistant/kits/rep_clock/pages/cards/cards.json create mode 100644 miniprogram/mp_time_assistant/kits/rep_clock/pages/cards/cards.wxml create mode 100644 miniprogram/mp_time_assistant/kits/rep_clock/pages/cards/cards.wxss create mode 100644 miniprogram/mp_time_assistant/kits/rep_clock/pages/history/history.js create mode 100644 miniprogram/mp_time_assistant/kits/rep_clock/pages/history/history.json create mode 100644 miniprogram/mp_time_assistant/kits/rep_clock/pages/history/history.wxml create mode 100644 miniprogram/mp_time_assistant/kits/rep_clock/pages/history/history.wxss create mode 100644 miniprogram/mp_time_assistant/kits/rep_clock/pages/runner/runner.js create mode 100644 miniprogram/mp_time_assistant/kits/rep_clock/pages/runner/runner.json create mode 100644 miniprogram/mp_time_assistant/kits/rep_clock/pages/runner/runner.wxml create mode 100644 miniprogram/mp_time_assistant/kits/rep_clock/pages/runner/runner.wxss create mode 100644 miniprogram/mp_time_assistant/pages/subhub/subhub.js create mode 100644 miniprogram/mp_time_assistant/pages/subhub/subhub.json create mode 100644 miniprogram/mp_time_assistant/pages/subhub/subhub.wxml create mode 100644 miniprogram/mp_time_assistant/pages/subhub/subhub.wxss create mode 100644 src/server/mp_personal_collect/index.js create mode 100644 src/server/mp_personal_collect/store.js create mode 100644 src/server/mp_rep_clock/db.js create mode 100644 src/server/mp_rep_clock/index.js create mode 100644 src/server/mp_rep_clock/kits/rep_clock/index.js create mode 100644 src/server/mp_rep_clock/kits/rep_clock/store.js create mode 100644 src/server/mp_rep_clock/locks.js create mode 100644 src/server/mp_rep_clock/tts.js diff --git a/Trae_FilesTRAE-Toolboxdatamp_rep_clock.db b/Trae_FilesTRAE-Toolboxdatamp_rep_clock.db new file mode 100644 index 0000000..e69de29 diff --git a/dev_test_scripts/debug/debug_409_with_expires.js b/dev_test_scripts/debug/debug_409_with_expires.js new file mode 100644 index 0000000..60b2813 --- /dev/null +++ b/dev_test_scripts/debug/debug_409_with_expires.js @@ -0,0 +1,58 @@ +const path = require('path') +process.chdir('d:/Trae_Files/TRAE-Toolbox') +const http = require('http') +const db = require(path.join(process.cwd(), 'src/server/mp_rep_clock/db')) +const locks = require(path.join(process.cwd(), 'src/server/mp_rep_clock/locks')) +const store = require(path.join(process.cwd(), 'src/server/mp_rep_clock/kits/rep_clock/store')) + +// 清锁先 +locks.resetRunningLock() +const nowIso = () => new Date().toISOString() +db.getDb().prepare(`UPDATE rc_sessions SET status='abandoned', ended_at=?, updated_at=?, manual_stopped=1 WHERE status='running' AND deleted=0`).run(nowIso(), nowIso()) + +const cardId = 'rc_card_c6aebdfb-7330-43c1-b42c-a6a856a715f2' + +// 1. 直接 Node 层 reserve + createSession,模拟"第一发start成功" +const s1 = store.createSession({ cardId }) +const r1 = locks.reserveRunningLock(s1.id) +console.log('== 1st createSession OK ==') +console.log('session1 id:', s1.id, 'status=', s1.status) +console.log('lock reserve1:', JSON.stringify(r1)) + +// 2. 再调用一次 createSession(第二发 start 模拟冲突),但不清锁 +const s2 = store.createSession({ cardId }) +const r2 = locks.reserveRunningLock(s2.id) +console.log('\n== 2nd 故意冲突 ==') +console.log('session2 id:', s2.id) +console.log('lock reserve2:', JSON.stringify(r2)) +console.log('字段检查:expiresInMs 存在?', typeof r2.expiresInMs, r2.expiresInMs, '秒=', Math.ceil(r2.expiresInMs/1000)) + +// 3. 再用 HTTP 调 sessions/start 直接看 409 body(等价于用户 curl) +console.log('\n== 3rd HTTP POST sessions/start ==') +const payload = JSON.stringify({ cardId }) +const req = http.request({ + hostname: '127.0.0.1', port: 8976, + path: '/api/mp_rep_clock/kits/rep_clock/sessions/start', + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(payload), + 'X-Mp-Timeassistant': 'probe_v1', + } +}, (res) => { + let buf = '' + res.on('data', c => buf += c) + res.on('end', () => { + console.log('HTTP', res.statusCode) + console.log('BODY:', buf) + try { + const j = JSON.parse(buf) + console.log('字段检查 expiresInMs =', j.expiresInMs, '秒=', Math.ceil(j.expiresInMs/1000)) + console.log('字段检查 conflictSessionId =', j.conflictSessionId) + console.log('字段检查 expiredLockCleared =', j.expiredLockCleared) + } catch(e){ console.log('not json:', e.message) } + }) +}) +req.on('error', e => console.log('err', e.message)) +req.write(payload) +req.end() diff --git a/dev_test_scripts/debug/debug_check_new_lock_loaded.js b/dev_test_scripts/debug/debug_check_new_lock_loaded.js new file mode 100644 index 0000000..d2994da --- /dev/null +++ b/dev_test_scripts/debug/debug_check_new_lock_loaded.js @@ -0,0 +1,24 @@ +const fs = require('fs') +const path = require('path') +process.chdir('d:/Trae_Files/TRAE-Toolbox') + +const db = require(path.join(process.cwd(), 'src/server/mp_rep_clock/db')) +const locks = require(path.join(process.cwd(), 'src/server/mp_rep_clock/locks.js')) + +console.log('=== 1. 锁表当前值 ===') +console.log(JSON.stringify(locks.getLockState(), null, 2)) + +console.log('\n=== 2. rc_sessions 最近 5 条 ===') +const rows = db.getDb().prepare(`SELECT id, status, card_id, ended_at, created_at, updated_at FROM rc_sessions WHERE deleted=0 ORDER BY datetime(created_at) DESC LIMIT 5`).all() +rows.forEach(r => console.log(' -', JSON.stringify(r))) + +console.log('\n=== 3. 直接调 reserveRunningLock 看新版逻辑是否加载(看是否返回 expiresInMs + expiredLockCleared)===') +const testSid = 'rc_session_test_' + Math.random().toString(36).slice(2, 10) +const result = locks.reserveRunningLock(testSid, 'rep_clock') +console.log(JSON.stringify(result, null, 2)) +console.log('新版字段校验:result.expiresInMs =', result && result.expiresInMs, typeof result && result.expiresInMs) +console.log('新版字段校验:result.expiredLockCleared =', result && result.expiredLockCleared, typeof result && result.expiredLockCleared) + +console.log('\n=== 4. 回滚:删除测试锁(如果创建成功) ===') +if (result && result.ok) locks.resetRunningLock() +console.log('回滚后锁表:', JSON.stringify(locks.getLockState())) diff --git a/dev_test_scripts/debug/debug_clear_lock_now.js b/dev_test_scripts/debug/debug_clear_lock_now.js new file mode 100644 index 0000000..7e1e367 --- /dev/null +++ b/dev_test_scripts/debug/debug_clear_lock_now.js @@ -0,0 +1,25 @@ +const fs = require('fs') +const path = require('path') +process.chdir('d:/Trae_Files/TRAE-Toolbox') + +const db = require(path.join(process.cwd(), 'src/server/mp_rep_clock/db')) +const locks = require(path.join(process.cwd(), 'src/server/mp_rep_clock/locks.js')) + +console.log('=== 锁表当前值 ===') +console.log(JSON.stringify(locks.getLockState(), null, 2)) + +console.log('\n=== rc_sessions status=running 行数 ===') +const cnt = db.getDb().prepare(`SELECT COUNT(*) as c FROM rc_sessions WHERE status='running' AND deleted=0`).get() +console.log('running cnt=', cnt.c) +const rows = db.getDb().prepare(`SELECT id, status, card_id, created_at, updated_at FROM rc_sessions WHERE status='running' AND deleted=0 ORDER BY datetime(created_at) DESC LIMIT 5`).all() +rows.forEach(r => console.log(' -', JSON.stringify(r))) + +console.log('\n=== 立即操作:清锁 + 所有 running 转 abandoned ===') +locks.resetRunningLock() +const nowIso = () => new Date().toISOString() +db.getDb().prepare(`UPDATE rc_sessions SET status='abandoned', ended_at=?, updated_at=?, manual_stopped=1 WHERE status='running' AND deleted=0`).run(nowIso(), nowIso()) + +console.log('\n=== 操作后复检 ===') +console.log('锁表:', JSON.stringify(locks.getLockState(), null, 2)) +const cnt2 = db.getDb().prepare(`SELECT COUNT(*) as c FROM rc_sessions WHERE status='running' AND deleted=0`).get() +console.log('running cnt=', cnt2.c, '(应为 0)') diff --git a/dev_test_scripts/debug/debug_curl_409_body.js b/dev_test_scripts/debug/debug_curl_409_body.js new file mode 100644 index 0000000..5673482 --- /dev/null +++ b/dev_test_scripts/debug/debug_curl_409_body.js @@ -0,0 +1,33 @@ +const http = require('http') +const data = JSON.stringify({ cardId: 'rc_card_c6aebdfb-7330-43c1-b42c-a6a856a715f2' }) +const req = http.request({ + hostname: '127.0.0.1', port: 8976, + path: '/api/mp_rep_clock/kits/rep_clock/sessions/start', + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(data), + 'Accept': 'application/json; charset=utf-8', + 'X-Mp-Timeassistant': 'smoke_test_probe_xxx', + } +}, (res) => { + let buf = '' + res.on('data', c => buf += c) + res.on('end', () => { + console.log('HTTP', res.statusCode) + console.log('BODY:', buf) + try { + const j = JSON.parse(buf) + console.log('字段检查:') + console.log(' ok =', j.ok) + console.log(' error =', j.error) + console.log(' conflictSessionId =', j.conflictSessionId) + console.log(' conflict_session_id =', j.conflict_session_id) + console.log(' expiresInMs =', j.expiresInMs, typeof j.expiresInMs) + console.log(' expires_in_ms =', j.expires_in_ms, typeof j.expires_in_ms) + } catch (e) { console.log('not json', e.message) } + }) +}) +req.on('error', e => console.log('err:', e.message)) +req.write(data) +req.end() diff --git a/dev_test_scripts/debug/debug_fix_runner_lock.js b/dev_test_scripts/debug/debug_fix_runner_lock.js new file mode 100644 index 0000000..3109bc1 --- /dev/null +++ b/dev_test_scripts/debug/debug_fix_runner_lock.js @@ -0,0 +1,45 @@ +const fs = require('fs') +const path = require('path') +process.chdir('d:/Trae_Files/TRAE-Toolbox') + +const db = require(path.join(process.cwd(), 'src/server/mp_rep_clock/db')) +const locks = require(path.join(process.cwd(), 'src/server/mp_rep_clock/locks.js')) +const store = require(path.join(process.cwd(), 'src/server/mp_rep_clock/kits/rep_clock/store.js')) + +async function main() { + console.log('=== 修复前 ===') + console.log('锁表:', JSON.stringify(locks.getLockState(), null, 2)) + const list0 = db.getDb().prepare(`SELECT id, status, card_id, created_at, updated_at FROM rc_sessions WHERE deleted = 0 ORDER BY datetime(created_at) DESC LIMIT 5`).all() + console.log('rc_sessions 最近5条:') + list0.forEach(r => console.log(' -', JSON.stringify(r))) + console.log() + + console.log('=== 立即清空锁 + 把所有 status=running 的 session 转 abandoned ===') + locks.resetRunningLock() + db.getDb().prepare(`UPDATE rc_sessions SET status='abandoned', ended_at=?, updated_at=?, manual_stopped=1 WHERE status='running' AND deleted=0`).run(new Date().toISOString(), new Date().toISOString()) + console.log() + + console.log('=== 修复后再次尝试等价 sessionStart 链路 ===') + const list = store.listCards() + const card = (list && list[0]) || null + if (card) { + console.log('选中 card=', card.id, card.title) + const session = store.createSession({ cardId: card.id }) + console.log('创建 session id=', session.id) + const r = locks.reserveRunningLock(session.id) + console.log('reserveRunningLock=', JSON.stringify(r)) + console.log('锁表=', JSON.stringify(locks.getLockState())) + const sessionsAfter = db.getDb().prepare(`SELECT id, status, card_id, updated_at FROM rc_sessions WHERE deleted = 0 ORDER BY datetime(updated_at) DESC LIMIT 3`).all() + console.log('sessions 最后3条:') + sessionsAfter.forEach(s => console.log(' -', JSON.stringify(s))) + } + console.log() + + console.log('=== 额外验证:故意再调一次 sessionStart 制造 409 冲突,看 expiresInMs 是否带回 ===') + if (card) { + const otherSession = store.createSession({ cardId: card.id }) + const conflict = locks.reserveRunningLock(otherSession.id) + console.log('冲突时 reserveRunningLock=', JSON.stringify(conflict)) + } +} +main().catch(e => console.error(e)) diff --git a/dev_test_scripts/debug/debug_mpta_smoke_5054.js b/dev_test_scripts/debug/debug_mpta_smoke_5054.js new file mode 100644 index 0000000..0f72a77 --- /dev/null +++ b/dev_test_scripts/debug/debug_mpta_smoke_5054.js @@ -0,0 +1,60 @@ +const http = require('http') +const BASE = { host: 'localhost', port: 5054, headers: { 'X-Mp-Timeassistant': 'mp_node_smoke_ok', 'Content-Type': 'application/json; charset=utf-8', 'Accept': 'application/json; charset=utf-8' } } +function req(method, path, bodyObj) { + return new Promise((resolve, reject) => { + const bodyBuf = bodyObj ? Buffer.from(JSON.stringify(bodyObj), 'utf8') : null + const req = http.request({ ...BASE, method, path, headers: { ...BASE.headers, 'Content-Length': bodyBuf ? bodyBuf.length : 0 } }, (res) => { + let data = [] + res.on('data', (c) => data.push(c)) + res.on('end', () => { + const text = Buffer.concat(data).toString('utf8') + try { resolve({ status: res.statusCode, headers: res.headers, body: text ? JSON.parse(text) : null, text }) } catch (e) { resolve({ status: res.statusCode, headers: res.headers, text }) } + }) + }) + req.on('error', reject) + if (bodyBuf) req.write(bodyBuf) + req.end() + }) +} +;(async () => { + console.log('\n=== 1. GET /api/mp_time_assistant/meta (kits应=2 不应有hub_only中转) ===') + const r1 = await req('GET', '/api/mp_time_assistant/meta') + console.log('status', r1.status, 'CT', r1.headers['content-type']) + console.log('ok=', r1.body.ok, 'kits.Count=', r1.body.kits ? r1.body.kits.length : 0) + if (r1.body.kits) r1.body.kits.forEach((k) => console.log(' kit:', JSON.stringify(k))) + + console.log('\n=== 2. POST /cards 新建中文卡 title="今天练背日" (UTF-8 JSON) ===') + const r2 = await req('POST', '/api/mp_rep_clock/kits/rep_clock/cards', { + title: '今天练背日', + loopMode: 'loop_n_times', + loopCount: 3, + steps: [ + { name: '高位下拉', durationSec: 60, restSec: 20, type: 'action' }, + { name: '坐姿划船', durationSec: 60, restSec: 20, type: 'action' }, + ], + }) + console.log('status', r2.status, 'CT=', r2.headers['content-type']) + const c = r2.body.card + console.log('card.id=', c.id) + console.log('card.title (JSON)=', JSON.stringify(c.title), ' codePoints=', Array.from(c.title).map((ch) => ch.charCodeAt(0).toString(16)).join(' ')) + console.log('card.loopMode=', c.loopMode, 'loopTimes=', c.loopTimes, 'voice=', c.voice, 'ttsReady=', c.ttsReady) + console.log('card.steps[0]: name=', JSON.stringify(c.steps[0].name), 'restSec=', c.steps[0].restSec) + console.log('card.steps[1]: name=', JSON.stringify(c.steps[1].name), 'restSec=', c.steps[1].restSec) + const id = c.id + + console.log('\n=== 3. GET /cards 再查回刚写入的中文卡 ===') + const r3 = await req('GET', '/api/mp_rep_clock/kits/rep_clock/cards') + const found = (r3.body.cards || r3.body.data || []).find((x) => x.id === id) + console.log('found=', !!found) + if (found) { + console.log('title=', JSON.stringify(found.title)) + console.log('loopTimes=', found.loopTimes, ' voice=', found.voice, ' ttsReady=', found.ttsReady) + found.steps.forEach((s, i) => console.log(` step[${i}] name=${JSON.stringify(s.name)} restSec=${s.restSec}`)) + } + + console.log('\n=== 4. GET personal_collect 独立前缀 独立DB是否联通 ===') + const r4 = await req('GET', '/api/mp_personal_collect/kits/personal_collect/items') + console.log('status', r4.status, 'ok=', r4.body.ok, 'items/data.Count=', (r4.body.items || r4.body.data || []).length) + + console.log('\n=== ALL SMOKE PASSED if title=中文正常显示 ===') +})().catch((e) => { console.error('ERR', e); process.exit(1) }) diff --git a/dev_test_scripts/debug/debug_repclock_encoding.js b/dev_test_scripts/debug/debug_repclock_encoding.js new file mode 100644 index 0000000..816811e --- /dev/null +++ b/dev_test_scripts/debug/debug_repclock_encoding.js @@ -0,0 +1,58 @@ +const http = require('http') +const BASE = { host: 'localhost', port: 5053, headers: { 'Content-Type': 'application/json; charset=utf-8', 'X-Mp-Timeassistant': 'mp_node_client_enc_test_xxx' } } +const post = (path, obj) => new Promise((res, rej) => { + const body = Buffer.from(JSON.stringify(obj), 'utf8') + const req = http.request({ ...BASE, method: 'POST', path, headers: { ...BASE.headers, 'Content-Length': body.length } }, (r) => { + let data = [] + r.on('data', (c) => data.push(c)) + r.on('end', () => { try { res({ status: r.statusCode, headers: r.headers, body: JSON.parse(Buffer.concat(data).toString('utf8')) }) } catch (e) { rej(e) } }) + }) + req.on('error', rej) + req.write(body); req.end() +}) +const get = (path) => new Promise((res, rej) => { + http.get({ ...BASE, path }, (r) => { + let data = [] + r.on('data', (c) => data.push(c)) + r.on('end', () => { try { res({ status: r.statusCode, body: JSON.parse(Buffer.concat(data).toString('utf8')) }) } catch (e) { rej(e) } }) + }).on('error', rej) +}) +;(async () => { + console.log('=== Step1: POST /cards via UTF-8 JSON (like WeChat mini-program) ===') + const body = { + title: '瑜伽垫上腰部运动', + loopMode: 'loop_n_times', + loopCount: 2, + steps: [ + { name: '热身动作', durationSec: 30, restSec: 10, type: 'action' }, + { name: '平板支撑', durationSec: 45, restSec: 15, type: 'action' }, + ], + } + const pr = await post('/api/mp_rep_clock/kits/rep_clock/cards', body) + console.log('POST status=', pr.status) + const c = pr.body.card + console.log('card.id:', c.id) + console.log('card.title (JSON.stringify):', JSON.stringify(c.title), ' chars:', Array.from(c.title).map((ch) => ch.charCodeAt(0).toString(16).padStart(4, '0')).join(' ')) + console.log('card.loopTimes:', c.loopTimes, ' voice:', c.voice, ' ttsReady:', c.ttsReady) + console.log('card.steps:') + c.steps.forEach((s, i) => console.log(` [${i}] name=${JSON.stringify(s.name)} durationSec=${s.durationSec} restSec=${s.restSec} type=${s.type}`)) + + console.log('\n=== Step2: GET /cards ===') + const gr = await get('/api/mp_rep_clock/kits/rep_clock/cards') + const c0 = gr.body.cards[0] + console.log('cards[0].title=', JSON.stringify(c0.title)) + console.log('cards[0].ttsReady=', c0.ttsReady, ' loopTimes=', c0.loopTimes, ' voice=', c0.voice) + c0.steps.forEach((s, i) => console.log(` [${i}] name=${JSON.stringify(s.name)} restSec=${s.restSec}`)) + + console.log('\n=== Step3: HEX dump directly from DB to prove storage ===') + const Database = require('better-sqlite3') + const path = require('path') + const db = new Database(path.join(process.cwd(), 'data', 'mp_rep_clock.db')) + const row = db.prepare(`SELECT id, hex(title) ht, hex(steps_json) hsj FROM rc_cards WHERE id=? ORDER BY rowid DESC LIMIT 1`).get(c.id) + console.log('DB hex(title):', row.ht.substring(0, 200)) + const titleFromDb = Buffer.from(row.ht, 'hex').toString('utf8') + console.log('DB decoded title:', JSON.stringify(titleFromDb)) + const stepsParsed = JSON.parse(Buffer.from(row.hsj, 'hex').toString('utf8')) + stepsParsed.forEach((s, i) => console.log(` DB steps[${i}] name=${JSON.stringify(s.name)} restSec=${s.restSec}`)) + console.log('\n=== ALL ENCODING CHECKS PASSED if title decoded correctly above ===') +})().catch((e) => { console.error('ERR', e); process.exit(1) }) diff --git a/dev_test_scripts/debug/debug_runner_start.js b/dev_test_scripts/debug/debug_runner_start.js new file mode 100644 index 0000000..27abb11 --- /dev/null +++ b/dev_test_scripts/debug/debug_runner_start.js @@ -0,0 +1,61 @@ +const fs = require('fs') +const path = require('path') +const envPath = path.join(process.env.USERPROFILE || 'C:\\Users\\Administrator', 'Toolbox_local_creds.env.local') +if (fs.existsSync(envPath)) { + const text = fs.readFileSync(envPath, 'utf8') + for (const line of text.split(/\r?\n/)) { + if (!line || /^\s*#/.test(line)) continue + const i = line.indexOf('=') + if (i < 0) continue + const k = line.slice(0, i).trim(), v = line.slice(i + 1).trim().replace(/^["']|["']$/g, '') + if (k && !(k in process.env)) process.env[k] = v + } +} +process.chdir('d:/Trae_Files/TRAE-Toolbox') +global.__builtin_fetch = globalThis.fetch + +const db = require(path.join(process.cwd(), 'src/server/mp_rep_clock/db')) +const locks = require(path.join(process.cwd(), 'src/server/mp_rep_clock/locks.js')) +const store = require(path.join(process.cwd(), 'src/server/mp_rep_clock/kits/rep_clock/store.js')) + +async function main() { + console.log('=== 1. 当前锁表 rc_global_lock ===') + try { + const lock = locks.getLockState() + console.log(' lock=', JSON.stringify(lock, null, 2)) + } catch (e) { console.log(' err', e.message) } + console.log() + + console.log('=== 2. 找一个已有卡片(标题=测试一下)===') + const list = store.listCards() + console.log(' 卡片总数=', list && list.length) + let card = null + if (list && list.length) { + card = list.find(c => String(c.title || '').indexOf('测试一下') >= 0) || list[0] + console.log(' 选中 card=', card && card.id, card && card.title) + } + console.log() + + console.log('=== 3. 调 store.createSession + locks.reserveRunningLock(等价 sessionStart 接口)===') + try { + const session = store.createSession({ cardId: card && card.id }) + console.log(' createSession ok, id=', session && session.id) + const r = locks.reserveRunningLock(session.id) + console.log(' reserveRunningLock=', JSON.stringify(r)) + if (!r.ok) { + console.log(' ★★★ 锁冲突!!调用 resetRunningLock 清空后再试一次') + locks.resetRunningLock() + console.log(' 清空后锁状态=', JSON.stringify(locks.getLockState())) + const r2 = locks.reserveRunningLock(session.id) + console.log(' 第二次 reserveRunningLock=', JSON.stringify(r2)) + } + } catch (e) { console.log(' ex=', e.stack || e.message) } + console.log() + + console.log('=== 4. 查看 sessions 表最后 3 行 ===') + try { + const rows = db.getDb().prepare(`SELECT id, card_id, status, created_at FROM rc_sessions WHERE deleted = 0 ORDER BY datetime(created_at) DESC LIMIT 3`).all() + rows.forEach(r => console.log(' -', JSON.stringify(r))) + } catch (e) { console.log(' err', e.message) } +} +main() diff --git a/dev_test_scripts/debug/debug_tts_400.js b/dev_test_scripts/debug/debug_tts_400.js new file mode 100644 index 0000000..940d9e2 --- /dev/null +++ b/dev_test_scripts/debug/debug_tts_400.js @@ -0,0 +1,38 @@ +const fs = require('fs') +const path = require('path') +const envPath = path.join(process.env.USERPROFILE || 'C:\\Users\\Administrator', 'Toolbox_local_creds.env.local') +if (fs.existsSync(envPath)) { + const text = fs.readFileSync(envPath, 'utf8') + for (const line of text.split(/\r?\n/)) { + if (!line || /^\s*#/.test(line)) continue + const i = line.indexOf('=') + if (i < 0) continue + const k = line.slice(0, i).trim(), v = line.slice(i + 1).trim().replace(/^["']|["']$/g, '') + if (k && !(k in process.env)) process.env[k] = v + } +} +global.__builtin_fetch = globalThis.fetch +const API_KEY = process.env.DASHSCOPE_API_KEY +;(async () => { + const url = 'https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio/text-to-audio' + const body = { + model: 'cosyvoice-v1', + input: { text: '热身开合跳' }, + parameters: { voice: 'longruoxi_v1', format: 'mp3', sample_rate: 24000, volume: 1.0, speech_rate: 1.0 }, + } + try { + const resp = await global.__builtin_fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${API_KEY}`, + 'Content-Type': 'application/json', + 'X-DashScope-Async': 'enable', + }, + body: JSON.stringify(body), + }) + console.log('status=', resp.status) + console.log('headers=', [...resp.headers.entries()].filter(([k]) => k.startsWith('x-dashscope') || k.startsWith('content') || k.startsWith('x-request') || k === 'date').map(([k, v]) => k + '=' + v).join(' ')) + const txt = await resp.text() + console.log('body=\n' + txt) + } catch (e) { console.error('ERR', e && e.message ? e.message : e) } +})() diff --git a/dev_test_scripts/debug/debug_tts_check.js b/dev_test_scripts/debug/debug_tts_check.js new file mode 100644 index 0000000..8fb81c1 --- /dev/null +++ b/dev_test_scripts/debug/debug_tts_check.js @@ -0,0 +1,49 @@ +const path = require('path') +const envPath = path.join(process.env.USERPROFILE || 'C:\\Users\\Administrator', 'Toolbox_local_creds.env.local') +const fs = require('fs') +if (fs.existsSync(envPath)) { + process.env.USERPROFILE_CREDS = envPath + const text = fs.readFileSync(envPath, 'utf8') + for (const line of text.split(/\r?\n/)) { + if (!line || /^\s*#/.test(line)) continue + const i = line.indexOf('=') + if (i < 0) continue + const k = line.slice(0, i).trim(), v = line.slice(i + 1).trim().replace(/^["']|["']$/g, '') + if (k && !(k in process.env)) process.env[k] = v + } + console.log('[env] loaded Toolbox_local_creds.env.local from', envPath) +} else { + console.log('[env] NOT found', envPath) +} +console.log('[env] DASHSCOPE_API_KEY =', (process.env.DASHSCOPE_API_KEY || '').slice(0, 14) + '... len=' + (process.env.DASHSCOPE_API_KEY || '').length) +global.__builtin_fetch = globalThis.fetch +if (!global.__builtin_fetch) try { global.__builtin_fetch = require('node-fetch') } catch (_) { console.error('no fetch available') } +const tts = require('../../src/server/mp_rep_clock/tts') +;(async () => { + const card = { + title: '测试一下', + loopMode: 'loop_till_stop', + loopCount: 0, + announceLeadSeconds: '5,3,1', + steps: [ + { name: '热身开合跳', durationSec: 30, restSec: 10, type: 'action' }, + { name: '平板支撑', durationSec: 45, restSec: 15, type: 'action' }, + ], + } + console.log('\n=== 1. 看 buildCardTTSBatch lines ===') + const lines = tts.buildCardTTSBatch(card) + console.log('lines.length=', lines.length) + lines.forEach((l, i) => console.log(` [${i}] ${l}`)) + + console.log('\n=== 2. 合成第0条 (开始训练,测试一下) ===') + const s0 = await tts.synthesize(lines[0], 'female_01') + console.log('synthesize ok=', s0.ok, 'error=', s0.error || null, 'url=', s0.url || null, 'cached=', !!s0.cached, 'durationMs=', s0.durationMs) + + console.log('\n=== 3. 全量 synthesizeBatch ===') + const batch = await tts.synthesizeBatch(card, 'female_01') + console.log('batch total=', batch.total, 'success=', batch.success) + batch.results.forEach((r, i) => console.log(` [${i}] ok=${r.ok} cached=${!!r.cached} err=${r.error || null} text=${r.text.slice(0, 20)} url=${(r.url || '').slice(0, 60)}`)) + + console.log('\n=== 4. 检查 uploads 目录是否生成了 mp3 ===') + try { const f = fs.readdirSync(tts.TTS_ROOT); console.log('TTS_ROOT=', tts.TTS_ROOT, 'files.Count=', f.length); f.slice(0, 10).forEach((x) => console.log(' ', x, Math.round((fs.statSync(path.join(tts.TTS_ROOT, x)).size / 1024)), 'KB')) } catch (e) { console.log('ERR read dir', e && e.message) } +})().catch(e => { console.error('FATAL', e); process.exit(1) }) diff --git a/dev_test_scripts/debug/debug_tts_cosy_probe.js b/dev_test_scripts/debug/debug_tts_cosy_probe.js new file mode 100644 index 0000000..65226e5 --- /dev/null +++ b/dev_test_scripts/debug/debug_tts_cosy_probe.js @@ -0,0 +1,45 @@ +// 一次性探针:验证 DashScope CosyVoice(SpeechSynthesizer) 的正确 model/voice 参数组合 +// 用法:node dev_test_scripts/debug/debug_tts_cosy_probe.js +const fs = require('fs') +const path = require('path') +const userProfile = process.env.USERPROFILE || process.env.HOME || require('os').homedir() +const credsPath = path.join(userProfile, 'Toolbox_local_creds.env.local') +if (fs.existsSync(credsPath)) { + fs.readFileSync(credsPath, 'utf-8').split(/\r?\n/).forEach(line => { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) return + const idx = trimmed.indexOf('=') + if (idx <= 0) return + const key = trimmed.slice(0, idx).trim() + const val = trimmed.slice(idx + 1).trim() + if (key && !Object.prototype.hasOwnProperty.call(process.env, key)) process.env[key] = val + }) +} +const apiKey = process.env.DASHSCOPE_API_KEY || '' + +const CASES = [ + ['cosyvoice-v3-flash/longanyang (当前配置)', { model: 'cosyvoice-v3-flash', input: { text: '测试一下', voice: 'longanyang', format: 'mp3', sample_rate: 24000 } }], + ['cosyvoice-v2/Cherry', { model: 'cosyvoice-v2', input: { text: '测试一下', voice: 'Cherry', format: 'mp3', sample_rate: 24000 } }], + ['cosyvoice-v2/longxiaoxue', { model: 'cosyvoice-v2', input: { text: '测试一下', voice: 'longxiaoxue', format: 'mp3', sample_rate: 24000 } }], + ['cosyvoice-v1/Cherry', { model: 'cosyvoice-v1', input: { text: '测试一下', voice: 'Cherry', format: 'mp3', sample_rate: 24000 } }], + ['cosyvoice-v2-flash/Cherry', { model: 'cosyvoice-v2-flash', input: { text: '测试一下', voice: 'Cherry', format: 'mp3', sample_rate: 24000 } }], +] + +;(async () => { + for (const [name, body] of CASES) { + const t0 = Date.now() + try { + const resp = await fetch('https://dashscope.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer', { + method: 'POST', + headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const txt = await resp.text().catch(() => '') + const ok = resp.ok && txt.includes('http') + console.log(`[${ok ? '✔' : '✘'}]`, name, 'HTTP', resp.status, (Date.now() - t0) + 'ms', (resp.ok ? txt.slice(0, 120) : txt.slice(0, 200)).replace(/\s+/g, ' ')) + } catch (e) { + console.log('[✘]', name, 'fetch 异常:', String(e && e.message || e)) + } + } + process.exit(0) +})() \ No newline at end of file diff --git a/dev_test_scripts/debug/debug_tts_db_fix.js b/dev_test_scripts/debug/debug_tts_db_fix.js new file mode 100644 index 0000000..c7b3d74 --- /dev/null +++ b/dev_test_scripts/debug/debug_tts_db_fix.js @@ -0,0 +1,37 @@ +// 一次性 DB 维护:恢复被 INSERT OR IGNORE + deleted=1 阻塞的 3 条缓存, +// 清理无 DB 引用的孤儿音频文件。用法:node dev_test_scripts/debug/debug_tts_db_fix.js +const fs = require('fs') +const path = require('path') +const db = require('better-sqlite3')('data/mp_rep_clock.db') + +const FIX = { + '还有5秒': 'rc_1788857916346_c9bb0cc4.wav', + '还有3秒': 'rc_1788857916424_0d8f7e90.wav', + '本次训练结束,辛苦了': 'rc_1788857916507_e06bd852.wav', +} + +let recovered = 0 +for (const [text, file] of Object.entries(FIX)) { + const abs = path.join(process.cwd(), 'uploads', 'mp_rep_clock', 'tts_cache', file) + if (!fs.existsSync(abs)) { console.log('[skip 文件不存在]', file); continue } + const info = db.prepare('UPDATE rc_tts_cache SET deleted=0, url=? WHERE text=? AND voice=?') + .run('/uploads/mp_rep_clock/tts_cache/' + file, text, 'female_01') + console.log('[fix]', text, '->', file, 'affected rows:', info.changes) + recovered += info.changes +} + +// 清理有文件但没有任何 active 缓存行引用的孤儿 +const activeUrls = db.prepare('SELECT url FROM rc_tts_cache WHERE deleted=0').all().map(r => r.url) +const cacheDir = path.join(process.cwd(), 'uploads', 'mp_rep_clock', 'tts_cache') +let orphans = 0 +for (const f of fs.readdirSync(cacheDir)) { + const rel = '/uploads/mp_rep_clock/tts_cache/' + f + if (!activeUrls.includes(rel)) { + try { fs.unlinkSync(path.join(cacheDir, f)); orphans++; console.log('[orphan-del]', f) } catch (_) {} + } +} + +console.log('[result] 恢复缓存行:', recovered, '; 删除孤儿文件:', orphans) +console.log('[result] active 缓存条数:', db.prepare('SELECT COUNT(*) c FROM rc_tts_cache WHERE deleted=0').get().c) +console.log('[result] active 缓存列表:') +db.prepare('SELECT text, url FROM rc_tts_cache WHERE deleted=0 ORDER BY text').all().forEach(r => console.log(' ', r.text, '=>', r.url)) \ No newline at end of file diff --git a/dev_test_scripts/debug/debug_tts_debug.js b/dev_test_scripts/debug/debug_tts_debug.js new file mode 100644 index 0000000..bf977c6 --- /dev/null +++ b/dev_test_scripts/debug/debug_tts_debug.js @@ -0,0 +1,79 @@ +const fs = require('fs') +const path = require('path') +const envPath = path.join(process.env.USERPROFILE || 'C:\\Users\\Administrator', 'Toolbox_local_creds.env.local') +if (fs.existsSync(envPath)) { + const text = fs.readFileSync(envPath, 'utf8') + for (const line of text.split(/\r?\n/)) { + if (!line || /^\s*#/.test(line)) continue + const i = line.indexOf('=') + if (i < 0) continue + const k = line.slice(0, i).trim(), v = line.slice(i + 1).trim().replace(/^["']|["']$/g, '') + if (k && !(k in process.env)) process.env[k] = v + } +} +process.chdir('d:/Trae_Files/TRAE-Toolbox') +global.__builtin_fetch = globalThis.fetch + +const crypto = require('crypto') +const apiKey = process.env.DASHSCOPE_API_KEY +const t = '热身开合跳' +const headers = { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', +} + +const CANDIDATES = [] +CANDIDATES.push({ + name: 'Fallback-A:Qwen3-TTS', + url: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation', + body: { + model: 'qwen3-tts-flash', + input: { text: t, voice: 'Cherry', language_type: 'Chinese' }, + }, +}) +CANDIDATES.push({ + name: 'Fallback-B:CosyVoice-v3', + url: 'https://dashscope.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer', + body: { + model: 'cosyvoice-v3-flash', + input: { text: t, voice: 'longanyang', format: 'mp3', sample_rate: 24000 }, + }, +}) + +async function run() { + for (const c of CANDIDATES) { + console.log('\n======== try', c.name, '========') + try { + const resp = await global.__builtin_fetch(c.url, { + method: 'POST', + headers, + body: JSON.stringify(c.body), + }) + console.log('status=', resp.status, 'OK=', resp.ok) + const txt = await resp.text() + console.log('text head=', txt.slice(0, 800)) + if (resp.ok) { + try { + const j = JSON.parse(txt) + let audioUrl = '' + const output = (j && j.output) || null + if (output) { + if (output.audio && typeof output.audio.url === 'string') audioUrl = output.audio.url + } + console.log('audioUrl=', audioUrl) + if (audioUrl) { + const dl = await global.__builtin_fetch(audioUrl) + console.log('download status=', dl.status, 'len=', dl.headers && dl.headers.get ? dl.headers.get('content-length') : '?') + if (dl.ok) { + const buf = Buffer.from(await dl.arrayBuffer()) + const p = path.join(process.env.TEMP || '.', 'tts_dl_' + Date.now() + '.mp3') + fs.writeFileSync(p, buf) + console.log('saved to', p, 'bytes=', buf.length) + } + } + } catch (e) { console.log('json parse err', e.message) } + } + } catch (e) { console.log('ex', e && e.message ? e.message : e) } + } +} +run() diff --git a/dev_test_scripts/debug/debug_tts_inner.js b/dev_test_scripts/debug/debug_tts_inner.js new file mode 100644 index 0000000..e4f51f8 --- /dev/null +++ b/dev_test_scripts/debug/debug_tts_inner.js @@ -0,0 +1,115 @@ +const fs = require('fs') +const path = require('path') +const crypto = require('crypto') +const envPath = path.join(process.env.USERPROFILE || 'C:\\Users\\Administrator', 'Toolbox_local_creds.env.local') +if (fs.existsSync(envPath)) { + const text = fs.readFileSync(envPath, 'utf8') + for (const line of text.split(/\r?\n/)) { + if (!line || /^\s*#/.test(line)) continue + const i = line.indexOf('=') + if (i < 0) continue + const k = line.slice(0, i).trim(), v = line.slice(i + 1).trim().replace(/^["']|["']$/g, '') + if (k && !(k in process.env)) process.env[k] = v + } +} +process.chdir('d:/Trae_Files/TRAE-Toolbox') +global.__builtin_fetch = globalThis.fetch +const TTS_ROOT = path.join(process.cwd(), 'uploads', 'mp_rep_clock', 'tts_cache') +if (!fs.existsSync(TTS_ROOT)) fs.mkdirSync(TTS_ROOT, { recursive: true }) + +const VOICE_ID_DEFAULT = 'female_01' +const callCosyVoice = async (text, voice = VOICE_ID_DEFAULT) => { + const apiKey = process.env.DASHSCOPE_API_KEY || '' + console.log('[callCosyVoice] apiKey.len=', apiKey.length, 'text=', JSON.stringify(text), 'voice=', voice) + if (!apiKey) return { ok: false, error: 'DASHSCOPE_API_KEY 未配置' } + const t = String(text || '').trim() + if (!t) return { ok: false, error: 'empty text' } + const isMale = voice.indexOf('male') >= 0 + const headers = { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + } + const fallbacks = [] + fallbacks.push({ + name: 'Fallback-A:Qwen3-TTS', + url: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation', + body: { + model: 'qwen3-tts-flash', + input: { + text: t, + voice: isMale ? 'Cherry' : 'Cherry', + language_type: 'Chinese', + }, + }, + }) + fallbacks.push({ + name: 'Fallback-B:CosyVoice-v3', + url: 'https://dashscope.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer', + body: { + model: 'cosyvoice-v3-flash', + input: { + text: t, + voice: isMale ? 'longxiaocheng' : 'longanyang', + format: 'mp3', + sample_rate: 24000, + }, + }, + }) + for (let idx = 0; idx < fallbacks.length; idx++) { + const fb = fallbacks[idx] + console.log(' [fb ' + idx + '] try ' + fb.name + ' url=' + fb.url) + console.log(' [fb ' + idx + '] body=', JSON.stringify(fb.body)) + try { + const resp = await global.__builtin_fetch(fb.url, { + method: 'POST', + headers, + body: JSON.stringify(fb.body), + }) + console.log(' [fb ' + idx + '] resp.status=', resp ? resp.status : 'null', 'resp.ok=', resp ? resp.ok : 'null') + if (!resp || !resp.ok) { + const txt = resp ? await resp.text().catch(() => '') : '' + console.log(' [fb ' + idx + '] !ok resp.text head=', (txt || '').slice(0, 500)) + continue + } + const j = await resp.json() + console.log(' [fb ' + idx + '] json keys=', Object.keys(j || {}).join(',')) + let audioUrl = '' + try { + const output = (j && j.output) || null + console.log(' [fb ' + idx + '] output keys=', output ? Object.keys(output).join(',') : 'null') + if (output) { + if (output.audio && typeof output.audio.url === 'string') { audioUrl = output.audio.url; console.log(' [fb ' + idx + '] audioUrl via output.audio.url') } + if (!audioUrl && typeof output.audio_url === 'string') { audioUrl = output.audio_url; console.log(' [fb ' + idx + '] audioUrl via output.audio_url') } + if (!audioUrl && typeof output.audioUrl === 'string') { audioUrl = output.audioUrl; console.log(' [fb ' + idx + '] audioUrl via output.audioUrl') } + if (!audioUrl && Array.isArray(output.results) && output.results[0]) { + const first = output.results[0] || {} + if (first.audio && typeof first.audio.url === 'string') { audioUrl = first.audio.url; console.log(' [fb ' + idx + '] audioUrl via output.results[0].audio.url') } + } + } + } catch (e) { console.log(' [fb ' + idx + '] output ex=', e.message) } + console.log(' [fb ' + idx + '] audioUrl=', audioUrl) + if (!audioUrl) continue + const download = await global.__builtin_fetch(audioUrl) + console.log(' [fb ' + idx + '] download status=', download ? download.status : 'null') + if (!download || !download.ok) continue + const buf = Buffer.from(await download.arrayBuffer()) + const storedName = `rc_${Date.now()}_${crypto.randomBytes(4).toString('hex')}.mp3` + const absPath = path.join(TTS_ROOT, storedName) + fs.writeFileSync(absPath, buf) + const relPath = `uploads/mp_rep_clock/tts_cache/${storedName}` + const stat = fs.statSync(absPath) + const durationMs = Math.max(1000, Math.round(stat.size / (12 * 1024 / 8))) + console.log(' [fb ' + idx + '] OK storedName=' + storedName + ' size=' + stat.size + ' durationMs=' + durationMs) + return { ok: true, url: `/${relPath}`, durationMs } + } catch (e) { + console.log(' [fb ' + idx + '] EXCEPTION=', e && e.stack ? e.stack : String(e)) + } + } + return { ok: false, error: 'All TTS fallbacks failed' } +} + +;(async () => { + console.log('=== callCosyVoice(热身开合跳, female_01) ===') + const r = await callCosyVoice('热身开合跳', 'female_01') + console.log('\nFINAL RESULT=', JSON.stringify(r, null, 2)) +})() diff --git a/dev_test_scripts/debug/debug_tts_live_probe.js b/dev_test_scripts/debug/debug_tts_live_probe.js new file mode 100644 index 0000000..6e4e2da --- /dev/null +++ b/dev_test_scripts/debug/debug_tts_live_probe.js @@ -0,0 +1,39 @@ +// 一次性探针:本地直连 DashScope 复现 TTS 合成,定位“合成中”卡住/失败的真实原因 +// 用法:node dev_test_scripts/debug/debug_tts_live_probe.js +const fs = require('fs') +const path = require('path') + +// 与 src/server/index.js 一致的 creds 加载(只读 key 名不含值打印) +const userProfile = process.env.USERPROFILE || process.env.HOME || require('os').homedir() +const credsPath = path.join(userProfile, 'Toolbox_local_creds.env.local') +if (fs.existsSync(credsPath)) { + fs.readFileSync(credsPath, 'utf-8').split(/\r?\n/).forEach(line => { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) return + const idx = trimmed.indexOf('=') + if (idx <= 0) return + const key = trimmed.slice(0, idx).trim() + const val = trimmed.slice(idx + 1).trim() + if (key && !Object.prototype.hasOwnProperty.call(process.env, key)) process.env[key] = val + }) +} +console.log('[probe] DASHSCOPE_API_KEY 已加载:', !!process.env.DASHSCOPE_API_KEY, process.env.DASHSCOPE_API_KEY ? '(len=' + process.env.DASHSCOPE_API_KEY.length + ')' : '') + +const tts = require('../../src/server/mp_rep_clock/tts.js') + +const text = process.argv[2] || '下一组' +const voice = process.argv[3] || 'female_01' + +const timer = setTimeout(() => { console.log('[probe] 超时 60s 强制退出'); process.exit(2) }, 60000) + +;(async () => { + const t0 = Date.now() + const r = await tts.synthesize(text, voice) + console.log('[probe] synthesize(', JSON.stringify(text), ',', voice, ') 结果=', JSON.stringify(r), '耗时', Date.now() - t0, 'ms') + if (r && r.url) { + const abs = path.join(process.cwd(), String(r.url).replace(/^\/+/, '').replace(/\//g, path.sep)) + if (fs.existsSync(abs)) console.log('[probe] 文件存在:', abs, fs.statSync(abs).size, 'bytes') + } + clearTimeout(timer) + process.exit(r && r.ok ? 0 : 1) +})().catch(e => { console.error('[probe] 异常:', e); clearTimeout(timer); process.exit(1) }) \ No newline at end of file diff --git a/dev_test_scripts/debug/debug_tts_models.js b/dev_test_scripts/debug/debug_tts_models.js new file mode 100644 index 0000000..d3cf75b --- /dev/null +++ b/dev_test_scripts/debug/debug_tts_models.js @@ -0,0 +1,70 @@ +const fs = require('fs') +const path = require('path') +const envPath = path.join(process.env.USERPROFILE || 'C:\\Users\\Administrator', 'Toolbox_local_creds.env.local') +if (fs.existsSync(envPath)) { + const text = fs.readFileSync(envPath, 'utf8') + for (const line of text.split(/\r?\n/)) { + if (!line || /^\s*#/.test(line)) continue + const i = line.indexOf('=') + if (i < 0) continue + const k = line.slice(0, i).trim(), v = line.slice(i + 1).trim().replace(/^["']|["']$/g, '') + if (k && !(k in process.env)) process.env[k] = v + } +} +global.__builtin_fetch = globalThis.fetch +const API_KEY = process.env.DASHSCOPE_API_KEY + +const CASES = [ + { + name: 'T1 model=qwen-audio-3.0-tts-flash, voice=longanyang, text2audio/audio-synthesis', + url: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio/audio-synthesis', + headers: { 'X-DashScope-Async': 'enable' }, + body: { model: 'qwen-audio-3.0-tts-flash', input: { text: '热身开合跳' }, parameters: { voice: 'longanyang', format: 'mp3', sample_rate: 24000 } } + }, + { + name: 'T2 model=cosyvoice-v3-flash, voice=longanyang', + url: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio/audio-synthesis', + headers: { 'X-DashScope-Async': 'enable' }, + body: { model: 'cosyvoice-v3-flash', input: { text: '热身开合跳' }, parameters: { voice: 'longanyang', format: 'mp3', sample_rate: 24000 } } + }, + { + name: 'T3 model=cosyvoice-v3-plus, voice=longruoxi', + url: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio/audio-synthesis', + headers: { 'X-DashScope-Async': 'enable' }, + body: { model: 'cosyvoice-v3-plus', input: { text: '热身开合跳' }, parameters: { voice: 'longruoxi', format: 'mp3', sample_rate: 24000 } } + }, + { + name: 'T4 qwen-audio-3.0-tts-plus, voice=longruoxi_v1', + url: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio/audio-synthesis', + headers: { 'X-DashScope-Async': 'enable' }, + body: { model: 'qwen-audio-3.0-tts-plus', input: { text: '热身开合跳' }, parameters: { voice: 'longruoxi_v1', format: 'mp3', sample_rate: 24000 } } + }, +] + +async function runCase(c) { + console.log('\n======== ' + c.name + ' ========') + try { + const resp = await global.__builtin_fetch(c.url, { + method: 'POST', + headers: Object.assign({ 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' }, c.headers || {}), + body: JSON.stringify(c.body), + }) + console.log('status=', resp.status, 'CT=', resp.headers.get('content-type')) + const j = await resp.json() + console.log(JSON.stringify(j).slice(0, 600)) + if (j && j.output && j.output.task_id) { + console.log('task_id=', j.output.task_id, '— poll 2 次看看:') + for (let k = 0; k < 2; k++) { + await new Promise(r => setTimeout(r, 2000)) + const poll = await global.__builtin_fetch('https://dashscope.aliyuncs.com/api/v1/tasks/' + j.output.task_id, { headers: { 'Authorization': `Bearer ${API_KEY}` } }) + const p = await poll.json() + console.log(' poll k=', k, 'status=', p && p.output && p.output.task_status, 'url=', p && p.output && p.output.audio_url && String(p.output.audio_url).slice(0, 80), 'msg=', p && p.message && String(p.message).slice(0, 80)) + if (p && p.output && (p.output.task_status === 'SUCCEEDED' || p.output.task_status === 'FAILED' || p.output.audio_url)) break + } + } + } catch (e) { console.error('ERR', e && e.message ? e.message : e) } +} + +;(async () => { + for (const c of CASES) await runCase(c) +})() diff --git a/dev_test_scripts/debug/debug_tts_probe.js b/dev_test_scripts/debug/debug_tts_probe.js new file mode 100644 index 0000000..a5dfda1 --- /dev/null +++ b/dev_test_scripts/debug/debug_tts_probe.js @@ -0,0 +1,62 @@ +const fs = require('fs') +const path = require('path') +const envPath = path.join(process.env.USERPROFILE || 'C:\\Users\\Administrator', 'Toolbox_local_creds.env.local') +if (fs.existsSync(envPath)) { + const text = fs.readFileSync(envPath, 'utf8') + for (const line of text.split(/\r?\n/)) { + if (!line || /^\s*#/.test(line)) continue + const i = line.indexOf('=') + if (i < 0) continue + const k = line.slice(0, i).trim(), v = line.slice(i + 1).trim().replace(/^["']|["']$/g, '') + if (k && !(k in process.env)) process.env[k] = v + } +} +global.__builtin_fetch = globalThis.fetch +const API_KEY = process.env.DASHSCOPE_API_KEY + +const CANDIDATES = [] +CANDIDATES.push({ + name: 'A1 Qwen-TTS 推荐 Fallback-A:multimodal-generation/generation (model=qwen3-tts-flash, voice=Cherry, 同步audio.url)', + url: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation', + body: { model: 'qwen3-tts-flash', input: { text: '热身开合跳', voice: 'Cherry', language_type: 'Chinese' } } +}) +CANDIDATES.push({ + name: 'A2 qwen-audio-3.0-tts-flash Cherry', + url: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation', + body: { model: 'qwen-audio-3.0-tts-flash', input: { text: '热身开合跳', voice: 'Cherry', language_type: 'Chinese' } } +}) +CANDIDATES.push({ + name: 'A3 qwen3-tts-flash 中文女声 Xiaobei', + url: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation', + body: { model: 'qwen3-tts-flash', input: { text: '热身开合跳,加油坚持。', voice: 'Xiaobei', language_type: 'Chinese' } } +}) +CANDIDATES.push({ + name: 'A4 CosyVoice WorkspaceId 端点占位(有 WorkspaceId 就填,否则直接走 Fallback A)', + url: 'https://dashscope.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer', + body: { model: 'cosyvoice-v3-flash', input: { text: '热身开合跳', voice: 'longanyang', format: 'mp3', sample_rate: 24000 } } +}) + +;(async () => { + for (const c of CANDIDATES) { + console.log('\n======== 试 ' + c.name + ' ========') + try { + const resp = await global.__builtin_fetch(c.url, { + method: 'POST', + headers: Object.assign({ 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' }, c.headers || {}), + body: JSON.stringify(c.body), + }) + console.log('status=', resp.status, 'CT=', resp.headers.get('content-type')) + const ct = resp.headers.get('content-type') || '' + if (ct.indexOf('application/json') >= 0) { + const j = await resp.json() + console.log('json=', JSON.stringify(j).slice(0, 500)) + } else { + const buf = Buffer.from(await resp.arrayBuffer()) + console.log('bytes=', buf.length, 'HEAD=', [...buf.slice(0, 4)].map(b => b.toString(16)).join(' ')) + const p = path.join(process.env.TEMP || '.', 'tts_test_' + Date.now() + '.mp3') + fs.writeFileSync(p, buf) + console.log('saved mp3=', p, 'sizeKB=', Math.round(buf.length/1024)) + } + } catch (e) { console.error('ERR', e && e.message ? e.message : e) } + } +})() diff --git a/dev_test_scripts/debug/debug_tts_smoke.js b/dev_test_scripts/debug/debug_tts_smoke.js new file mode 100644 index 0000000..f39501a --- /dev/null +++ b/dev_test_scripts/debug/debug_tts_smoke.js @@ -0,0 +1,125 @@ +const fs = require('fs') +const path = require('path') +const envPath = 'C:/Users/Administrator/Toolbox_local_creds.env.local' +if (fs.existsSync(envPath)) { + const txt = fs.readFileSync(envPath, 'utf-8') + txt.split(/\r?\n/).forEach(line => { + const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/i) + if (!m) return + let v = m[2].trim() + if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1) + if (!(m[1] in process.env)) process.env[m[1]] = v + }) +} +process.chdir('d:/Trae_Files/TRAE-Toolbox') + +global.__builtin_fetch = globalThis.fetch +const API_KEY = process.env.DASHSCOPE_API_KEY + +const { getDb } = require(path.join(process.cwd(), 'src/server/mp_rep_clock/db')) +const tts = require(path.join(process.cwd(), 'src/server/mp_rep_clock/tts.js')) + +console.log('=== 0. 自检 global/环境 ===') +console.log(' global.__builtin_fetch === globalThis.fetch:', global.__builtin_fetch === globalThis.fetch) +console.log(' typeof global.__builtin_fetch:', typeof global.__builtin_fetch) +console.log(' process.cwd():', process.cwd()) +console.log(' TTS_ROOT:', tts.TTS_ROOT) +console.log(' TTS_ROOT exists:', fs.existsSync(tts.TTS_ROOT)) +console.log(' apiKey.len:', API_KEY.length) + +;(async () => {})() + +async function main() { + console.log('\n=== 0.5 INLINE callCosyVoice 对比(与 debug_tts_inner.js 相同) ===') + const crypto = require('crypto') + const TTS_ROOT = tts.TTS_ROOT + const callCosyVoiceInline = async (text, voice) => { + const apiKey = process.env.DASHSCOPE_API_KEY || '' + const t = String(text || '').trim() + const isMale = voice.indexOf('male') >= 0 + const headers = { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' } + const fallbacks = [ + { + name: 'Inline-A', + url: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation', + body: { model: 'qwen3-tts-flash', input: { text: t, voice: 'Cherry', language_type: 'Chinese' } }, + }, + ] + for (const fb of fallbacks) { + try { + const resp = await global.__builtin_fetch(fb.url, { + method: 'POST', headers, body: JSON.stringify(fb.body), + }) + console.log(' inline fb', fb.name, 'status=', resp && resp.status) + if (!resp || !resp.ok) continue + const j = await resp.json() + const output = (j && j.output) || null + const audioUrl = output && output.audio && output.audio.url || '' + console.log(' inline fb', fb.name, 'audioUrl=', audioUrl.slice(0, 80)) + if (!audioUrl) continue + const download = await global.__builtin_fetch(audioUrl) + if (!download || !download.ok) continue + const buf = Buffer.from(await download.arrayBuffer()) + const storedName = `inline_${Date.now()}_${crypto.randomBytes(4).toString('hex')}.mp3` + const absPath = path.join(TTS_ROOT, storedName) + fs.writeFileSync(absPath, buf) + const durationMs = Math.max(1000, Math.round(buf.length / (12 * 1024 / 8))) + return { ok: true, url: `/uploads/mp_rep_clock/tts_cache/${storedName}`, durationMs } + } catch (e) { console.log(' inline ex', e.message) } + } + return { ok: false } + } + const inlineR = await callCosyVoiceInline('热身开合跳', 'female_01') + console.log(' inline result ok=', inlineR.ok, 'url=', inlineR.url) + + console.log('\n=== 1. 检查 DASHSCOPE_API_KEY ===') + console.log(' len=', String(process.env.DASHSCOPE_API_KEY || '').length) + console.log() + + console.log('=== 2. 先跑单条 synthesize 验证 Fallback ===') + const single = await tts.synthesize('开始训练,测试一下', 'female_01') + console.log(' single=', JSON.stringify(single, null, 2)) + console.log() + + console.log('=== 3. 全量 synthesizeBatch(title=测试一下) ===') + const card = { + title: '测试一下', + loopMode: 'sequential_once', + loopCount: 1, + announceLeadSeconds: '5,3', + steps: [ + { name: '热身开合跳', durationSec: 30, type: 'action' }, + { name: '平板支撑', durationSec: 60, type: 'action', restDurationSec: 15, voiceText: '' }, + ], + } + const batch = await tts.synthesizeBatch(card, 'female_01') + console.log(' batch total=', batch.total, 'success=', batch.success) + batch.results.forEach((r, i) => { + console.log(` [${i}] ok=${r.ok} cached=${r.cached} text=${JSON.stringify(r.text)} url=${r.url || ''} err=${r.error || ''}`) + }) + console.log() + + console.log('=== 4. TTS_ROOT uploads/mp_rep_clock/tts_cache files.Count ===') + const root = tts.TTS_ROOT + const files = fs.existsSync(root) ? fs.readdirSync(root).filter(f => f.endsWith('.mp3')) : [] + console.log(' root=', root) + console.log(' files.Count=', files.length) + files.slice(-12).forEach(f => { + const stat = fs.statSync(path.join(root, f)) + console.log(' -', f, stat.size, 'bytes') + }) + console.log() + + console.log('=== 5. rc_tts_cache 表行数(非 deleted)===') + try { + const row = getDb().prepare(`SELECT COUNT(*) as c FROM rc_tts_cache WHERE deleted = 0`).get() + console.log(' count=', row && row.c) + } catch (e) { console.log(' err=', e.message) } + console.log() + + console.log('=== 6. 全部播报 lines 生成 ===') + const lines = tts.buildCardTTSBatch(card) + console.log(' lines=', lines.length) + lines.forEach((l, i) => console.log(` [${i}]`, l)) +} +main().catch(e => console.error('FATAL', e)) diff --git a/miniprogram/mp_time_assistant/api.js b/miniprogram/mp_time_assistant/api.js index 16936ef..c7ce533 100644 --- a/miniprogram/mp_time_assistant/api.js +++ b/miniprogram/mp_time_assistant/api.js @@ -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, } diff --git a/miniprogram/mp_time_assistant/app.json b/miniprogram/mp_time_assistant/app.json index af5e574..3b64eca 100644 --- a/miniprogram/mp_time_assistant/app.json +++ b/miniprogram/mp_time_assistant/app.json @@ -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" } diff --git a/miniprogram/mp_time_assistant/kits/rep_clock/pages/card_edit/card_edit.js b/miniprogram/mp_time_assistant/kits/rep_clock/pages/card_edit/card_edit.js new file mode 100644 index 0000000..5559bda --- /dev/null +++ b/miniprogram/mp_time_assistant/kits/rep_clock/pages/card_edit/card_edit.js @@ -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 }) + } + }, +}) diff --git a/miniprogram/mp_time_assistant/kits/rep_clock/pages/card_edit/card_edit.json b/miniprogram/mp_time_assistant/kits/rep_clock/pages/card_edit/card_edit.json new file mode 100644 index 0000000..ef9e470 --- /dev/null +++ b/miniprogram/mp_time_assistant/kits/rep_clock/pages/card_edit/card_edit.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "编辑运动卡片", + "navigationBarBackgroundColor": "#1a1a2e", + "navigationBarTextStyle": "white", + "backgroundColor": "#0f0f1a" +} diff --git a/miniprogram/mp_time_assistant/kits/rep_clock/pages/card_edit/card_edit.wxml b/miniprogram/mp_time_assistant/kits/rep_clock/pages/card_edit/card_edit.wxml new file mode 100644 index 0000000..5ef346e --- /dev/null +++ b/miniprogram/mp_time_assistant/kits/rep_clock/pages/card_edit/card_edit.wxml @@ -0,0 +1,98 @@ + + + {{mode === 'edit' ? '编辑卡片' : '新建卡片'}} + 保存时会自动合成标题 / 步骤名 / 倒计时播报。 + + + + + 卡片标题 + + + + + 备注(可选) +