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 @@
+
手机 · 电脑,实时联动的小工具集合
+手机 · 电脑,实时联动的小工具集合 · 完全独立 · 零共享