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

新增运动节拍RepClock全套小程序页面、后端API与数据库服务,新增subhub子工具汇总页面,优化全局主页样式与配置,为web静态资源添加版本缓存参数,补充配套调试脚本
This commit is contained in:
yangxiangyuan
2026-09-09 14:43:06 +08:00
parent db7648eb4d
commit d4bf3627fd
69 changed files with 5759 additions and 297 deletions
@@ -0,0 +1,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()
@@ -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()))
@@ -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)')
@@ -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()
@@ -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))
@@ -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) })
@@ -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) })
@@ -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()
+38
View File
@@ -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) }
})()
+49
View File
@@ -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) })
@@ -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)
})()
@@ -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))
+79
View File
@@ -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()
+115
View File
@@ -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))
})()
@@ -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) })
@@ -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)
})()
+62
View File
@@ -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) }
}
})()
+125
View File
@@ -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))
+79 -10
View File
@@ -12,13 +12,16 @@ const request = (options) => {
return new Promise((resolve, reject) => {
const { url, method = 'GET', data = {}, header = {}, timeout = 30000 } = options
const fullUrl = url.indexOf('http') === 0 ? url : BASE_URL() + url
const h = Object.assign({
'content-type': 'application/json; charset=utf-8',
Accept: 'application/json; charset=utf-8',
'X-Mp-Timeassistant': 'mp_time_assistant_v1_' + Math.random().toString(36).slice(2, 10),
}, header)
wx.request({
url: fullUrl,
method,
data,
header: Object.assign({
'content-type': 'application/json',
}, header),
header: h,
timeout,
success: (res) => {
const body = res.data || {}
@@ -26,7 +29,12 @@ const request = (options) => {
resolve(body)
} else {
const msg = body && body.error ? body.error : `请求失败 (HTTP ${res.statusCode})`
reject(new Error(msg))
const err = new Error(msg)
err.statusCode = res.statusCode
if (body && typeof body === 'object') {
try { Object.assign(err, body) } catch (_) {}
}
reject(err)
}
},
fail: (err) => {
@@ -45,6 +53,9 @@ const uploadFile = (options) => {
filePath,
name,
formData,
header: {
'X-Mp-Timeassistant': 'mp_time_assistant_v1_' + Math.random().toString(36).slice(2, 10),
},
timeout,
success: (res) => {
try {
@@ -78,37 +89,94 @@ const core = {
const personalCollect = {
list: (keyword = '') => request({
url: '/api/mp_time_assistant/kits/personal_collect/items',
url: '/api/mp_personal_collect/kits/personal_collect/items',
method: 'GET',
data: { keyword: keyword || '' },
}),
get: (id) => request({
url: `/api/mp_time_assistant/kits/personal_collect/items/${encodeURIComponent(id)}`,
url: `/api/mp_personal_collect/kits/personal_collect/items/${encodeURIComponent(id)}`,
method: 'GET',
}),
create: (payload) => request({
url: '/api/mp_time_assistant/kits/personal_collect/items',
url: '/api/mp_personal_collect/kits/personal_collect/items',
method: 'POST',
data: payload,
}),
update: (payload) => request({
url: '/api/mp_time_assistant/kits/personal_collect/items',
url: '/api/mp_personal_collect/kits/personal_collect/items',
method: 'PUT',
data: payload,
}),
remove: (id) => request({
url: '/api/mp_time_assistant/kits/personal_collect/items',
url: '/api/mp_personal_collect/kits/personal_collect/items',
method: 'DELETE',
data: { id },
}),
upload: (filePath, groupId = '') => uploadFile({
url: '/api/mp_time_assistant/kits/personal_collect/upload',
url: '/api/mp_personal_collect/kits/personal_collect/upload',
filePath,
name: 'file',
formData: groupId ? { groupId } : {},
}),
}
const repClock = {
meta: () => request({ url: '/api/mp_rep_clock/meta', method: 'GET' }),
state: () => request({ url: '/api/mp_rep_clock/state', method: 'GET' }),
resetRunning: (sessionId) => request({
url: '/api/mp_rep_clock/kits/rep_clock/state/reset-running',
method: 'POST',
data: { sessionId },
}),
listCards: () => request({ url: '/api/mp_rep_clock/kits/rep_clock/cards', method: 'GET' }),
getCard: (id) => request({ url: `/api/mp_rep_clock/kits/rep_clock/cards/${encodeURIComponent(id)}`, method: 'GET' }),
createCard: (payload) => request({
url: '/api/mp_rep_clock/kits/rep_clock/cards',
method: 'POST',
data: payload,
}),
updateCard: (payload) => request({
url: '/api/mp_rep_clock/kits/rep_clock/cards',
method: 'PUT',
data: payload,
}),
removeCard: (id) => request({
url: '/api/mp_rep_clock/kits/rep_clock/cards',
method: 'DELETE',
data: { id },
}),
history: (limit = 200) => request({
url: '/api/mp_rep_clock/kits/rep_clock/history',
method: 'GET',
data: { limit },
}),
sessionStart: (payload) => request({
url: '/api/mp_rep_clock/kits/rep_clock/sessions/start',
method: 'POST',
data: payload,
}),
sessionSync: (payload) => request({
url: '/api/mp_rep_clock/kits/rep_clock/sessions/sync',
method: 'POST',
data: payload,
}),
sessionFinish: (payload) => request({
url: '/api/mp_rep_clock/kits/rep_clock/sessions/finish',
method: 'POST',
data: payload,
}),
ttsPreview: (text, voice = 'female_01') => request({
url: '/api/mp_rep_clock/kits/rep_clock/tts/preview',
method: 'GET',
data: { text, voice },
}),
ttsBatch: (cardId, card, voice = 'female_01') => request({
url: '/api/mp_rep_clock/kits/rep_clock/tts/batch',
method: 'POST',
data: { cardId, card, voice },
}),
}
const chooseChatAttachments = (count = 9) => {
return new Promise((resolve, reject) => {
try {
@@ -141,5 +209,6 @@ module.exports = {
uploadFile,
core,
personalCollect,
repClock,
chooseChatAttachments,
}
+6 -2
View File
@@ -1,9 +1,14 @@
{
"pages": [
"pages/hub/hub",
"pages/subhub/subhub",
"kits/personal_collect/pages/list/list",
"kits/personal_collect/pages/edit/edit",
"kits/personal_collect/pages/detail/detail"
"kits/personal_collect/pages/detail/detail",
"kits/rep_clock/pages/cards/cards",
"kits/rep_clock/pages/card_edit/card_edit",
"kits/rep_clock/pages/runner/runner",
"kits/rep_clock/pages/history/history"
],
"window": {
"backgroundTextStyle": "light",
@@ -14,6 +19,5 @@
},
"style": "v2",
"sitemapLocation": "sitemap.json",
"__comment_requiredPrivateInfos_chooseMessageFile": "chooseMessageFile 不再需要在 requiredPrivateInfos 中静态声明,调用时微信自动弹窗授权即可。",
"lazyCodeLoading": "requiredComponents"
}
@@ -0,0 +1,238 @@
const api = require('../../../../api.js')
function uuid() {
return 'step_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8)
}
function clamp(v, a, b) { const x = Number(v) || 0; return Math.max(a, Math.min(b, x)) }
const DEFAULT_CARD = {
title: '',
notes: '',
loopMode: 'sequential_once',
loopTimes: 3,
enableCountdownAnnounce: true,
announceLeadSeconds: '5,3,1',
voice: 'female_01',
steps: [
{ key: uuid(), name: '动作一', durationSec: 30, restSec: 10, voiceText: '' },
{ key: uuid(), name: '动作二', durationSec: 30, restSec: 10, voiceText: '' },
],
}
Page({
data: Object.assign({}, DEFAULT_CARD, {
mode: 'new',
cardId: '',
dirty: false,
saving: false,
originSnapshot: '',
}),
onLoad(options) {
const mode = (options && options.mode === 'edit') ? 'edit' : 'new'
const id = (options && options.id) || ''
this.setData({ mode, cardId: id })
if (mode === 'edit' && id) {
wx.showLoading({ title: '加载中…', mask: true })
api.repClock.getCard(id).then(res => {
const card = (res && res.card) ? res.card : null
if (!card) {
try { wx.hideLoading() } catch (_) {}
wx.showToast({ title: '卡片不存在', icon: 'none' })
wx.navigateBack()
return
}
const steps = (Array.isArray(card.steps) ? card.steps : []).map(s => Object.assign({ key: uuid() }, s))
this.setData({
title: String(card.title || ''),
notes: String(card.notes || ''),
loopMode: card.loopMode || 'sequential_once',
loopTimes: card.loopTimes || card.loopCount || 3,
enableCountdownAnnounce: !!card.enableCountdownAnnounce !== false && !!card.enableCountdownAnnounce,
announceLeadSeconds: String(card.announceLeadSeconds || '5,3,1'),
voice: card.voice || 'female_01',
steps,
}, () => {
this.setData({ originSnapshot: this._toSnapshot(), dirty: false })
try { wx.hideLoading() } catch (_) {}
})
}).catch(e => {
try { wx.hideLoading() } catch (_) {}
wx.showToast({ title: e && e.message ? e.message : '加载失败', icon: 'none' })
wx.navigateBack()
})
} else {
this.setData({ originSnapshot: this._toSnapshot() })
}
},
onUnload() { },
_toSnapshot() {
try { return JSON.stringify(this._payload()) } catch (e) { return '' }
},
_payload() {
const d = this.data
return {
title: String(d.title || '').trim(),
notes: String(d.notes || '').trim(),
loopMode: d.loopMode,
loopTimes: clamp(d.loopTimes, 1, 999),
enableCountdownAnnounce: !!d.enableCountdownAnnounce,
announceLeadSeconds: String(d.announceLeadSeconds || '').trim(),
voice: d.voice || 'female_01',
steps: (d.steps || []).map(s => ({
name: String(s.name || '').trim(),
durationSec: clamp(s.durationSec, 1, 3600),
restSec: clamp(s.restSec, 0, 3600),
voiceText: String(s.voiceText || '').trim(),
})),
}
},
markDirty() {
const snap = this._toSnapshot()
this.setData({ dirty: snap !== this.data.originSnapshot })
},
onTitleInput(e) { this.setData({ title: e.detail.value }, () => this.markDirty()) },
onNotesInput(e) { this.setData({ notes: e.detail.value }, () => this.markDirty()) },
onLoopMode(e) { this.setData({ loopMode: e.currentTarget.dataset.v }, () => this.markDirty()) },
onLoopTimesInput(e) { this.setData({ loopTimes: e.detail.value }, () => this.markDirty()) },
onEnableAnnounce(e) { this.setData({ enableCountdownAnnounce: !!e.detail.value }, () => this.markDirty()) },
onLeadSecInput(e) { this.setData({ announceLeadSeconds: e.detail.value }, () => this.markDirty()) },
onVoice(e) { this.setData({ voice: e.currentTarget.dataset.v }, () => this.markDirty()) },
_stepIndexByKey(key) {
return (this.data.steps || []).findIndex(s => s.key === key)
},
onStepNameInput(e) {
const key = e.currentTarget.dataset.key
const i = this._stepIndexByKey(key); if (i < 0) return
const steps = this.data.steps.slice()
steps[i] = Object.assign({}, steps[i], { name: e.detail.value })
this.setData({ steps }, () => this.markDirty())
},
onStepDurationInput(e) {
const key = e.currentTarget.dataset.key
const i = this._stepIndexByKey(key); if (i < 0) return
const steps = this.data.steps.slice()
steps[i] = Object.assign({}, steps[i], { durationSec: e.detail.value })
this.setData({ steps }, () => this.markDirty())
},
onStepRestInput(e) {
const key = e.currentTarget.dataset.key
const i = this._stepIndexByKey(key); if (i < 0) return
const steps = this.data.steps.slice()
steps[i] = Object.assign({}, steps[i], { restSec: e.detail.value })
this.setData({ steps }, () => this.markDirty())
},
onStepVoiceInput(e) {
const key = e.currentTarget.dataset.key
const i = this._stepIndexByKey(key); if (i < 0) return
const steps = this.data.steps.slice()
steps[i] = Object.assign({}, steps[i], { voiceText: e.detail.value })
this.setData({ steps }, () => this.markDirty())
},
onAddStep() {
const steps = this.data.steps.slice()
steps.push({ key: uuid(), name: `动作${steps.length + 1}`, durationSec: 30, restSec: 10, voiceText: '' })
this.setData({ steps }, () => this.markDirty())
},
onMoveUp(e) {
const key = e.currentTarget.dataset.key
const i = this._stepIndexByKey(key); if (i <= 0) return
const steps = this.data.steps.slice()
;[steps[i - 1], steps[i]] = [steps[i], steps[i - 1]]
this.setData({ steps }, () => this.markDirty())
},
onMoveDown(e) {
const key = e.currentTarget.dataset.key
const steps = this.data.steps || []
const i = this._stepIndexByKey(key); if (i < 0 || i >= steps.length - 1) return
const arr = steps.slice()
;[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]]
this.setData({ steps: arr }, () => this.markDirty())
},
onDuplicate(e) {
const key = e.currentTarget.dataset.key
const i = this._stepIndexByKey(key); if (i < 0) return
const steps = this.data.steps.slice()
const clone = Object.assign({}, steps[i], { key: uuid() })
steps.splice(i + 1, 0, clone)
this.setData({ steps }, () => this.markDirty())
},
onStepDelete(e) {
const key = e.currentTarget.dataset.key
const i = this._stepIndexByKey(key); if (i < 0) return
wx.showModal({
title: '删除该步骤?',
content: '删除后可再次添加。',
confirmText: '删除',
cancelText: '取消',
confirmColor: '#e05561',
success: (r) => {
if (!r.confirm) return
const steps = this.data.steps.slice()
steps.splice(i, 1)
this.setData({ steps }, () => this.markDirty())
}
})
},
_validate(payload) {
if (!payload.title) return '请填写卡片标题'
if (!payload.steps.length) return '请至少添加一个步骤'
for (let i = 0; i < payload.steps.length; i++) {
const s = payload.steps[i]
if (!s.name) return `第${i + 1}步:请填写动作名称`
if (s.durationSec < 1 || s.durationSec > 3600) return `第${i + 1}步:动作时长 1~3600 秒`
if (s.restSec < 0 || s.restSec > 3600) return `第${i + 1}步:休息时长 0~3600 秒`
}
if (payload.loopMode === 'loop_n_times' && (payload.loopTimes < 1 || payload.loopTimes > 999)) return '循环次数 1~999'
if (payload.enableCountdownAnnounce) {
const raw = (payload.announceLeadSeconds || '').split(',').map(x => Number(x.trim())).filter(n => Number.isFinite(n) && n > 0 && n <= 30)
if (!raw.length) return '至少配置 1~30 秒播报秒点'
}
return null
},
async onSave() {
if (this.data.saving) return
const payload = this._payload()
const err = this._validate(payload)
if (err) { wx.showToast({ title: err, icon: 'none' }); return }
this.setData({ saving: true })
wx.showLoading({ title: '保存中…', mask: true })
try {
let saved = null
if (this.data.mode === 'edit') {
payload.id = this.data.cardId
saved = await api.repClock.updateCard(payload)
} else {
saved = await api.repClock.createCard(payload)
}
const cardId = saved && saved.card && saved.card.id ? saved.card.id : this.data.cardId
wx.showLoading({ title: '已保存。合成语音…', mask: true })
try {
await api.repClock.ttsBatch(cardId, (saved && saved.card) ? saved.card : payload, payload.voice)
} catch (e) {
// 语音合成失败不阻塞保存成功
}
try { wx.hideLoading() } catch (_) {}
wx.showToast({ title: '已保存', icon: 'success' })
this.setData({ originSnapshot: this._toSnapshot(), dirty: false })
setTimeout(() => wx.navigateBack(), 600)
} catch (e) {
try { wx.hideLoading() } catch (_) {}
wx.showToast({ title: e && e.message ? e.message : '保存失败', icon: 'none' })
} finally {
this.setData({ saving: false })
}
},
})
@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "编辑运动卡片",
"navigationBarBackgroundColor": "#1a1a2e",
"navigationBarTextStyle": "white",
"backgroundColor": "#0f0f1a"
}
@@ -0,0 +1,98 @@
<view class="container">
<view class="page-header">
<view class="page-title">{{mode === 'edit' ? '编辑卡片' : '新建卡片'}}</view>
<view class="page-subtitle">保存时会自动合成标题 / 步骤名 / 倒计时播报。</view>
</view>
<view class="card">
<view class="input-group">
<text class="input-label">卡片标题</text>
<input class="input" placeholder="例:瑜伽垫上腰部运动" value="{{title}}" bindinput="onTitleInput" maxlength="80" />
</view>
<view class="input-group">
<text class="input-label">备注(可选)</text>
<textarea class="textarea" placeholder="可以写练习要点、注意事项等" value="{{notes}}" bindinput="onNotesInput" maxlength="500" auto-height />
</view>
<view class="input-group">
<text class="input-label">循环模式</text>
<view class="segmented">
<view class="seg-item {{loopMode === 'sequential_once' ? 'seg-active' : ''}}" bindtap="onLoopMode" data-v="sequential_once">顺序单次</view>
<view class="seg-item {{loopMode === 'loop_till_stop' ? 'seg-active' : ''}}" bindtap="onLoopMode" data-v="loop_till_stop">循环至停止</view>
<view class="seg-item {{loopMode === 'loop_n_times' ? 'seg-active' : ''}}" bindtap="onLoopMode" data-v="loop_n_times">指定循环</view>
</view>
<view wx:if="{{loopMode === 'loop_n_times'}}" class="row" style="margin-top: 22rpx; gap: 20rpx;">
<text class="input-label" style="margin: 0; flex: none;">循环次数</text>
<input class="input" style="flex: 1; padding: 18rpx 22rpx;" type="number" value="{{loopTimes}}" bindinput="onLoopTimesInput" />
</view>
</view>
<view class="input-group">
<text class="input-label">倒计时播报</text>
<view class="row" style="gap: 16rpx; flex-wrap: wrap;">
<switch checked="{{enableCountdownAnnounce}}" bindchange="onEnableAnnounce" color="#6c7ae0" style="transform: scale(0.85);" />
<text class="muted small" style="margin-left: 6rpx;">启用倒计时临近播报</text>
</view>
<view wx:if="{{enableCountdownAnnounce}}" style="margin-top: 22rpx;">
<text class="input-label">播报秒点(逗号分隔,1~30)</text>
<input class="input" placeholder="例:5,3,1" value="{{announceLeadSeconds}}" bindinput="onLeadSecInput" />
</view>
</view>
<view class="input-group">
<text class="input-label">默认播报音色</text>
<view class="segmented">
<view class="seg-item {{voice === 'female_01' ? 'seg-active' : ''}}" bindtap="onVoice" data-v="female_01">🌸 女声 Ruoxi</view>
<view class="seg-item {{voice === 'male_01' ? 'seg-active' : ''}}" bindtap="onVoice" data-v="male_01">🎙 男声 Longlaobo</view>
</view>
</view>
</view>
<view class="row-between" style="padding: 6rpx 6rpx 24rpx;">
<view class="section-title">步骤列表</view>
<view class="btn btn-primary mini-btn" style="padding: 14rpx 28rpx;" bindtap="onAddStep">+ 新增步骤</view>
</view>
<block wx:for="{{steps}}" wx:key="key">
<view class="card step-card">
<view class="row-between step-head">
<view class="step-idx">第 {{index + 1}} 步</view>
<view class="row" style="gap: 12rpx; flex-wrap: wrap;">
<view wx:if="{{index > 0}}" class="mini-btn btn-ghost" catchtap="onMoveUp" data-key="{{item.key}}">↑ 上移</view>
<view wx:if="{{index < steps.length - 1}}" class="mini-btn btn-ghost" catchtap="onMoveDown" data-key="{{item.key}}">↓ 下移</view>
<view class="mini-btn btn-ghost" catchtap="onDuplicate" data-key="{{item.key}}">⎘ 复制</view>
<view class="mini-btn btn-danger" catchtap="onStepDelete" data-key="{{item.key}}">🗑 删除</view>
</view>
</view>
<view class="input-group" style="margin-top: 18rpx;">
<text class="input-label">动作名称</text>
<input class="input" placeholder="例:卷腹 30 次" value="{{item.name}}" bindinput="onStepNameInput" data-key="{{item.key}}" maxlength="60" />
</view>
<view class="row" style="gap: 22rpx;">
<view class="input-group" style="flex: 1;">
<text class="input-label">动作时长(秒)</text>
<input class="input" type="number" value="{{item.durationSec}}" bindinput="onStepDurationInput" data-key="{{item.key}}" />
</view>
<view class="input-group" style="flex: 1;">
<text class="input-label">休息时长(秒)</text>
<input class="input" type="number" value="{{item.restSec}}" bindinput="onStepRestInput" data-key="{{item.key}}" />
</view>
</view>
<view class="input-group">
<text class="input-label">自定义播报文案(可选,留空则播报动作名称)</text>
<input class="input" placeholder="例:加油,保持节奏" value="{{item.voiceText}}" bindinput="onStepVoiceInput" data-key="{{item.key}}" maxlength="120" />
</view>
</view>
</block>
<view wx:if="{{!steps.length}}" class="empty muted small" style="padding: 70rpx 32rpx;">还没有步骤,点右上角「+ 新增步骤」开始添加。</view>
<view style="height: 40rpx;"></view>
<view class="btn btn-primary btn-block" bindtap="onSave" style="margin-bottom: 30rpx; padding: 28rpx 0; font-size: 30rpx;">
{{saving ? '保存并合成中…' : (mode === 'edit' ? '💾 保存并重新合成' : '💾 保存并合成语音')}}
</view>
</view>
@@ -0,0 +1,62 @@
.page-header { padding: 70rpx 0 40rpx 0; }
.page-title { font-size: 48rpx; font-weight: 700; color: #ffffff; margin-bottom: 12rpx; }
.page-subtitle { font-size: 26rpx; color: #8888a8; line-height: 1.6; }
.section-title { font-size: 30rpx; font-weight: 700; color: #ffffff; letter-spacing: 0.5rpx; }
.row { display: flex; flex-direction: row; align-items: center; }
.row-between { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.muted { color: #8888a8; }
.small { font-size: 24rpx; }
.segmented { display: flex; background: #12121f; border: 1rpx solid #2a2a44; border-radius: 18rpx; overflow: hidden; }
.seg-item { flex: 1; padding: 22rpx 16rpx; text-align: center; font-size: 26rpx; color: #a0a4c4; border-right: 1rpx solid #232338; }
.seg-item:last-child { border-right: none; }
.seg-active { background: linear-gradient(135deg, #6c7ae0, #b06be0); color: #ffffff; font-weight: 600; }
.mini-btn { padding: 14rpx 22rpx; border-radius: 14rpx; font-size: 24rpx; line-height: 1.3; }
.step-card { padding: 28rpx; }
.step-head { margin-bottom: 14rpx; }
.step-idx { font-size: 28rpx; font-weight: 700; color: #c9cdff; letter-spacing: 0.5rpx; }
.container { padding: 28rpx 32rpx 160rpx; min-height: 100vh; box-sizing: border-box; }
.card { background: #1a1a2e; border: 1rpx solid #2a2a44; border-radius: 24rpx; padding: 30rpx; margin-bottom: 24rpx; box-sizing: border-box; }
.input-group { margin-bottom: 28rpx; }
.input-label { font-size: 26rpx; color: #9a9ec0; margin-bottom: 14rpx; display: block; font-weight: 500; }
.input {
width: 100%;
background: #0d0d18 !important;
border: 1rpx solid #4a4a72 !important;
border-radius: 18rpx !important;
padding: 26rpx 26rpx !important;
min-height: 88rpx !important;
height: 88rpx !important;
color: #ffffff !important;
font-size: 30rpx !important;
box-sizing: border-box !important;
line-height: 1.5 !important;
}
.textarea {
width: 100%;
background: #0d0d18 !important;
border: 1rpx solid #4a4a72 !important;
border-radius: 18rpx !important;
padding: 26rpx 26rpx !important;
min-height: 220rpx !important;
color: #ffffff !important;
font-size: 30rpx !important;
box-sizing: border-box !important;
line-height: 1.6 !important;
}
.btn { display: inline-block; padding: 20rpx 48rpx; border-radius: 16rpx; font-size: 28rpx; font-weight: 500; text-align: center; box-sizing: border-box; line-height: 1.4; }
.btn-primary { background: linear-gradient(135deg, #6c7ae0, #b06be0); color: #ffffff; }
.btn-primary:active { opacity: 0.88; }
.btn-ghost { background: transparent; border: 1rpx solid #3a3a5a; color: #b8b8d0; }
.btn-danger { background: #e05561; color: #ffffff; }
.btn-block { display: block; width: 100%; }
.empty { padding: 80rpx 32rpx; text-align: center; color: #606080; font-size: 26rpx; }
@@ -0,0 +1,214 @@
const api = require('../../../../api.js')
const STEPS_TEXT = {
sequential_once: '顺序单次',
loop_till_stop: '循环至手动停止',
loop_n_times: '指定循环次数',
}
function clamp(v, a, b) { const x = Number(v) || 0; return Math.max(a, Math.min(b, x)) }
Page({
data: {
loading: true,
cards: [],
stepsTextMap: STEPS_TEXT,
submitting: false,
lockActive: false,
lockSessionId: '',
lockUpdatedAt: '',
},
onShow() { this.refresh() },
onPullDownRefresh() {
this.refresh().finally(() => wx.stopPullDownRefresh())
},
async refresh() {
this.setData({ loading: true })
try {
const [cardsRes, stateRes] = await Promise.all([
api.repClock.listCards(),
api.repClock.state().catch(() => null),
])
const cards = this.decorateList(cardsRes && Array.isArray(cardsRes.cards) ? cardsRes.cards : [])
const lock = stateRes && stateRes.lock ? stateRes.lock : {}
const runningSessionId = String(lock.runningSessionId || lock.running_session_id || '').trim()
this.setData({
cards,
lockActive: !!runningSessionId,
lockSessionId: runningSessionId,
lockUpdatedAt: String(lock.updatedAt || lock.updated_at || '').trim(),
})
} catch (e) {
const msg = e && e.message ? String(e.message) : '加载失败'
if (/unauthorized|401|未授权/i.test(msg)) {
wx.showModal({
title: '访问被拒绝 (401)',
content: '可能是本地服务重启或守卫配置问题。点击重试再次拉取。\n\n详情:' + msg,
confirmText: '重试',
cancelText: '返回',
success: (r) => { if (r.confirm) this.refresh() }
})
} else {
wx.showToast({ title: msg, icon: 'none' })
}
} finally {
this.setData({ loading: false })
}
},
decorateList(raw) {
return (raw || []).map(item => {
const steps = Array.isArray(item.steps) ? item.steps : []
const loopMode = item.loopMode || 'sequential_once'
const totalSteps = loopMode === 'loop_n_times' ? steps.length * (item.loopTimes || 1) : steps.length
let totalSec = 0
steps.forEach(s => { totalSec += (Number(s.durationSec) || 0) + (Number(s.restSec) || 0) })
if (loopMode === 'loop_n_times') totalSec *= (Number(item.loopTimes) || 1)
const plannedMin = Math.max(1, Math.round(totalSec / 60))
const previewSteps = steps.slice(0, 6).map((s, i) => ({
idx: i,
display: `${i + 1}. ${s.name || '未命名动作'} ${s.durationSec || 0}s${s.restSec ? ` · 休${s.restSec}s` : ''}`,
}))
const moreSteps = steps.length > 6
return Object.assign({}, item, {
totalSteps,
plannedMin,
previewSteps,
moreSteps,
ttsReady: !!item.ttsReady,
})
})
},
goHistory() {
wx.navigateTo({ url: '/kits/rep_clock/pages/history/history' })
},
goCreate() {
wx.navigateTo({ url: '/kits/rep_clock/pages/card_edit/card_edit?mode=new' })
},
onCardTap(e) {
if (this._guardNotReady(e)) return
const id = e.currentTarget.dataset.id
this.onEdit({ currentTarget: { dataset: { id } } })
},
// 语音合成中:禁用开始/编辑/重新合成/删除,避免在无语音或重复合成状态下操作
_guardNotReady(e) {
const ready = e.currentTarget && e.currentTarget.dataset ? e.currentTarget.dataset.ready : undefined
if (ready === false || ready === 'false' || ready === 0 || ready === '0') {
wx.showToast({ title: '语音合成中,请稍后再操作', icon: 'none' })
return true
}
return false
},
onStart(e) {
const id = e.currentTarget.dataset.id
if (this._guardNotReady(e)) return
if (this.data.submitting) return
if (this.data.lockActive) {
wx.showModal({
title: '已有节拍进行中',
content: '当前已有一个运动节拍会话在运行。请先使用顶部“清理解锁”,再开始新的节拍。',
showCancel: false,
confirmText: '知道了',
})
return
}
this.setData({ submitting: true })
wx.navigateTo({
url: `/kits/rep_clock/pages/runner/runner?cardId=${encodeURIComponent(id)}`,
complete: () => this.setData({ submitting: false }),
})
},
async onClearLock() {
if (this.data.submitting) return
wx.showModal({
title: '清理解锁?',
content: '一次只能运行一个节拍。清理解锁会释放当前运行中的会话占用,适合页面异常退出或卡住时使用。',
confirmText: '清理解锁',
cancelText: '取消',
confirmColor: '#e05561',
success: async (r) => {
if (!r.confirm) return
this.setData({ submitting: true })
wx.showLoading({ title: '解锁中…', mask: true })
try {
await api.repClock.resetRunning(this.data.lockSessionId || '')
wx.showToast({ title: '已清理解锁', icon: 'success' })
await this.refresh()
} catch (e) {
wx.showToast({ title: e && e.message ? e.message : '解锁失败', icon: 'none' })
} finally {
try { wx.hideLoading() } catch (_) {}
this.setData({ submitting: false })
}
}
})
},
onEdit(e) {
if (this._guardNotReady(e)) return
const id = e.currentTarget.dataset.id
wx.navigateTo({
url: `/kits/rep_clock/pages/card_edit/card_edit?mode=edit&id=${encodeURIComponent(id)}`,
})
},
async onRetts(e) {
const id = e.currentTarget.dataset.id
// 合成中/失败都可点「重新合成」作为重试出口;submitting 防连点
if (this.data.submitting) return
this.setData({ submitting: true })
wx.showLoading({ title: '准备合成…', mask: true })
try {
let card = null
try {
const r = await api.repClock.getCard(id)
card = r && r.card ? r.card : null
} catch (e) {}
if (!card) { wx.showToast({ title: '卡片不存在', icon: 'none' }); return }
await api.repClock.ttsBatch(id, card, 'female_01')
wx.showToast({ title: '已开始合成', icon: 'success' })
setTimeout(() => this.refresh(), 1500)
} catch (e) {
wx.showToast({ title: e && e.message ? e.message : '合成失败', icon: 'none' })
} finally {
wx.hideLoading()
this.setData({ submitting: false })
}
},
onDelete(e) {
const id = e.currentTarget.dataset.id
const title = (e.currentTarget.dataset.title || '').trim() || '未命名卡片'
if (this._guardNotReady(e)) return
if (this.data.submitting) return
wx.showModal({
title: '删除运动卡片?',
content: `《${title}》将被逻辑删除(音频与记录保留,仅不显示)。可随时恢复。`,
confirmText: '删除',
cancelText: '取消',
confirmColor: '#e05561',
success: async (r) => {
if (!r.confirm) return
this.setData({ submitting: true })
try {
await api.repClock.removeCard(id)
wx.showToast({ title: '已删除(逻辑)', icon: 'success' })
this.refresh()
} catch (e) {
wx.showToast({ title: e && e.message ? e.message : '删除失败', icon: 'none' })
} finally {
this.setData({ submitting: false })
}
}
})
},
})
@@ -0,0 +1,8 @@
{
"navigationBarTitleText": "运动节拍",
"navigationBarBackgroundColor": "#1a1a2e",
"navigationBarTextStyle": "white",
"backgroundColor": "#0f0f1a",
"enablePullDownRefresh": true,
"backgroundTextStyle": "light"
}
@@ -0,0 +1,56 @@
<view class="container">
<view class="page-header">
<view class="page-title">运动节拍</view>
<view class="page-subtitle">自定义动作节拍倒计时,支持 HIIT / Yoga / 拉伸。</view>
</view>
<view wx:if="{{lockActive}}" class="lock-banner">
<view class="lock-banner-title">当前已有 1 个节拍在运行</view>
<view class="lock-banner-desc">
<text>一次只能运行一个节拍。</text>
<text wx:if="{{lockSessionId}}"> 会话:{{lockSessionId}}</text>
</view>
<view class="lock-banner-actions">
<view class="mini-btn btn-ghost" bindtap="refresh">刷新状态</view>
<view class="mini-btn btn-danger" bindtap="onClearLock">清理解锁</view>
</view>
</view>
<view class="row" style="gap: 20rpx; margin-bottom: 28rpx;">
<view class="btn btn-ghost btn-block" bindtap="goHistory" style="flex: 1;">🏆 历史记录</view>
<view class="btn btn-primary btn-block" bindtap="goCreate" style="flex: 1;">+ 新增卡片</view>
</view>
<view wx:if="{{loading}}" class="empty">加载中...</view>
<view wx:elif="{{!cards.length}}" class="empty">还没有运动卡片,点右上角「+ 新增卡片」开始吧。</view>
<block wx:for="{{cards}}" wx:key="id">
<view class="card rc-card" bindtap="onCardTap" data-id="{{item.id}}" data-ready="{{item.ttsReady}}">
<view class="rc-head">
<view class="rc-title">{{item.title || '未命名卡片'}}</view>
<view class="rc-tags">
<text wx:if="{{item.ttsReady}}" class="tag tag-ok">🔊 已合成</text>
<text wx:else class="tag tag-warn">⏳ 合成中</text>
<text class="tag">{{stepsTextMap[item.loopMode]}}</text>
<text class="tag">{{item.totalSteps}} 步</text>
<text class="tag">{{item.plannedMin}} 分钟</text>
</view>
</view>
<view wx:if="{{item.notes}}" class="rc-notes muted small">{{item.notes}}</view>
<view class="rc-steps">
<block wx:for="{{item.previewSteps}}" wx:for-item="s" wx:for-index="si" wx:key="idx">
<view class="rc-step-chip">{{s.display}}</view>
</block>
<view wx:if="{{item.moreSteps}}" class="rc-step-chip muted small">…… 共 {{item.totalSteps}} 步</view>
</view>
<view class="item-actions">
<view class="mini-btn btn-ghost {{lockActive || !item.ttsReady ? 'mini-btn-disabled' : ''}}" catchtap="onStart" data-id="{{item.id}}" data-ready="{{item.ttsReady}}">{{lockActive ? '🔒 已占用' : (item.ttsReady ? '▶ 开始' : '⏳ 合成中')}}</view>
<view class="mini-btn btn-ghost {{!item.ttsReady ? 'mini-btn-disabled' : ''}}" catchtap="onEdit" data-id="{{item.id}}" data-ready="{{item.ttsReady}}">✏ 编辑</view>
<view class="mini-btn btn-ghost" catchtap="onRetts" data-id="{{item.id}}" data-ready="{{item.ttsReady}}">🔊 重新合成</view>
<view class="mini-btn btn-danger {{!item.ttsReady ? 'mini-btn-disabled' : ''}}" catchtap="onDelete" data-id="{{item.id}}" data-title="{{item.title}}" data-ready="{{item.ttsReady}}">🗑 删除</view>
</view>
</view>
</block>
</view>
<view class="fab" bindtap="goCreate">+</view>
@@ -0,0 +1,34 @@
.lock-banner {
margin-bottom: 24rpx;
padding: 24rpx;
background: rgba(224, 85, 97, 0.12);
border: 1rpx solid rgba(224, 85, 97, 0.3);
border-radius: 24rpx;
}
.lock-banner-title {
color: #ffd4d8;
font-size: 30rpx;
font-weight: 700;
}
.lock-banner-desc {
margin-top: 10rpx;
color: #ffd4d8;
font-size: 24rpx;
line-height: 1.6;
}
.lock-banner-actions {
margin-top: 18rpx;
display: flex;
gap: 16rpx;
}
.rc-card { padding: 32rpx; }
.rc-head { margin-bottom: 14rpx; }
.rc-title { font-size: 34rpx; font-weight: 700; color: #ffffff; letter-spacing: 0.5rpx; }
.rc-tags { margin-top: 14rpx; }
.tag-ok { background: rgba(72, 187, 120, 0.15); color: #76d79a; }
.tag-warn { background: rgba(237, 188, 88, 0.15); color: #f4cf7c; }
.rc-notes { margin: 16rpx 0 8rpx; line-height: 1.7; }
.rc-steps { margin-top: 22rpx; display: flex; flex-wrap: wrap; gap: 14rpx; }
.rc-step-chip { padding: 10rpx 20rpx; background: rgba(108, 122, 224, 0.12); color: #b8bfff; border: 1rpx solid rgba(108, 122, 224, 0.3); border-radius: 999rpx; font-size: 24rpx; }
.mini-btn-disabled { opacity: 0.65; }
@@ -0,0 +1,91 @@
const api = require('../../../../api.js')
function pad2(n) { const x = Math.max(0, Math.floor(Number(n) || 0)); return x < 10 ? '0' + x : String(x) }
function toBeijingDate(iso) {
try {
const d = iso ? new Date(iso) : new Date()
const tzMs = 8 * 3600 * 1000
const utc = d.getTime() + (d.getTimezoneOffset() * 60 * 1000)
const bj = new Date(utc + tzMs)
return bj
} catch (e) { return new Date() }
}
function formatBjLong(iso) {
const d = toBeijingDate(iso)
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
}
function isSameBjDay(iso1, iso2) {
const a = toBeijingDate(iso1), b = toBeijingDate(iso2)
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
}
Page({
data: {
loading: true,
sessions: [],
summary: {
totalCount: 0,
todayMin: 0,
totalMin: 0,
completionAvg: 0,
},
},
onShow() { this.refresh() },
onPullDownRefresh() {
this.refresh().finally(() => wx.stopPullDownRefresh())
},
async refresh() {
this.setData({ loading: true })
try {
const res = await api.repClock.history(500)
const rows = res && Array.isArray(res.sessions) ? res.sessions : []
const sessions = rows.map(s => {
const plannedMin = Math.max(0, Math.round((Number(s.plannedDurationSec) || 0) / 60))
const actualMin = Math.max(0, Math.round((Number(s.actualDurationSec) || 0) / 60))
const comp = Math.round(100 * (Number(s.completionRatio) || 0))
return Object.assign({}, s, {
cardTitle: String(s.cardTitle || s.card_title || '未命名卡片'),
plannedMin,
actualMin,
completed: !!s.completed,
completionDisplay: comp,
startedAtText: formatBjLong(s.startedAt || s.started_at),
})
}).sort((a, b) => {
const ta = (a.startedAt || a.started_at) ? new Date(a.startedAt || a.started_at).getTime() : 0
const tb = (b.startedAt || b.started_at) ? new Date(b.startedAt || b.started_at).getTime() : 0
return tb - ta
})
const now = new Date().toISOString()
let totalCount = sessions.length
let todayMin = 0
let totalMin = 0
let compSum = 0
sessions.forEach(s => {
totalMin += Number(s.actualMin) || 0
if (isSameBjDay(s.startedAt || s.started_at, now)) todayMin += Number(s.actualMin) || 0
compSum += Number(s.completionDisplay) || 0
})
const completionAvg = totalCount ? Math.round(compSum / totalCount) : 0
this.setData({
sessions,
summary: {
totalCount,
todayMin,
totalMin,
completionAvg,
},
})
} catch (e) {
wx.showToast({ title: e && e.message ? e.message : '加载失败', icon: 'none' })
} finally {
this.setData({ loading: false })
}
},
})
@@ -0,0 +1,8 @@
{
"navigationBarTitleText": "运动历史",
"navigationBarBackgroundColor": "#1a1a2e",
"navigationBarTextStyle": "white",
"backgroundColor": "#0f0f1a",
"enablePullDownRefresh": true,
"backgroundTextStyle": "light"
}
@@ -0,0 +1,63 @@
<view class="container">
<view class="page-header">
<view class="page-title">运动历史</view>
<view class="page-subtitle">每次运动都有记录,完成率一目了然。</view>
</view>
<view class="summary-card">
<view class="sum-item">
<view class="sum-num">{{summary.totalCount}}</view>
<view class="sum-label">总次数</view>
</view>
<view class="sum-item">
<view class="sum-num">{{summary.todayMin}}</view>
<view class="sum-label">今日(分钟)</view>
</view>
<view class="sum-item">
<view class="sum-num">{{summary.totalMin}}</view>
<view class="sum-label">累计(分钟)</view>
</view>
<view class="sum-item">
<view class="sum-num">{{summary.completionAvg}}%</view>
<view class="sum-label">平均完成率</view>
</view>
</view>
<view wx:if="{{loading}}" class="empty">加载中...</view>
<view wx:elif="{{!sessions.length}}" class="empty">还没有运动记录,去开始你的第一次吧。</view>
<block wx:for="{{sessions}}" wx:key="id">
<view class="card ses-card">
<view class="row-between">
<view class="ses-title">{{item.cardTitle || '未命名卡片'}}</view>
<view class="ses-result-tag {{item.completed ? 'ses-ok' : 'ses-no'}}">
{{item.completed ? '✅ 完成' : '⏹ 中断'}}
</view>
</view>
<view class="row" style="gap: 28rpx; margin-top: 20rpx; flex-wrap: wrap;">
<view class="ses-meta muted small">
<text class="meta-label">开始时间</text>
<text class="meta-value">{{item.startedAtText}}</text>
</view>
<view class="ses-meta muted small">
<text class="meta-label">计划时长</text>
<text class="meta-value">{{item.plannedMin}} 分钟</text>
</view>
<view class="ses-meta muted small">
<text class="meta-label">实际时长</text>
<text class="meta-value">{{item.actualMin}} 分钟</text>
</view>
</view>
<view class="row-between" style="margin-top: 24rpx;">
<view class="progress-wrap">
<view class="progress-bar">
<view class="progress-fill" style="width: {{item.completionDisplay}}%;"></view>
</view>
</view>
<view class="ses-completion {{item.completionDisplay >= 90 ? 'c-ok' : (item.completionDisplay >= 50 ? 'c-mid' : 'c-low')}}">
{{item.completionDisplay}}%
</view>
</view>
</view>
</block>
</view>
@@ -0,0 +1,37 @@
.summary-card {
display: flex;
background: linear-gradient(145deg, rgba(108,122,224,0.22), rgba(176,107,224,0.14));
border: 1rpx solid rgba(140,150,240,0.28);
border-radius: 28rpx;
padding: 30rpx 20rpx;
margin-bottom: 30rpx;
}
.sum-item { flex: 1; text-align: center; }
.sum-num { font-size: 44rpx; font-weight: 800; color: #ffffff; letter-spacing: 0.5rpx; }
.sum-label { margin-top: 8rpx; color: #a7aad1; font-size: 22rpx; }
.ses-card { padding: 30rpx; }
.ses-title { font-size: 32rpx; font-weight: 700; color: #ffffff; }
.ses-result-tag { padding: 8rpx 20rpx; border-radius: 999rpx; font-size: 22rpx; }
.ses-ok { background: rgba(72,187,120,0.15); color: #76d79a; border: 1rpx solid rgba(72,187,120,0.3); }
.ses-no { background: rgba(224,85,97,0.14); color: #f19ea7; border: 1rpx solid rgba(224,85,97,0.28); }
.ses-meta { display: flex; flex-direction: column; gap: 6rpx; min-width: 180rpx; }
.meta-label { color: #7a7e9f; font-size: 22rpx; letter-spacing: 0.5rpx; }
.meta-value { color: #e2e4ff; font-size: 26rpx; font-weight: 600; }
.progress-wrap { flex: 1; margin-right: 24rpx; }
.progress-bar {
height: 16rpx;
background: rgba(255,255,255,0.06);
border-radius: 999rpx;
overflow: hidden;
border: 1rpx solid rgba(255,255,255,0.06);
}
.progress-fill {
height: 100%;
background: linear-gradient(135deg, #6c7ae0, #b06be0);
border-radius: 999rpx;
}
.ses-completion { font-size: 30rpx; font-weight: 800; }
.c-ok { color: #76d79a; }
.c-mid { color: #f4cf7c; }
.c-low { color: #f19ea7; }
@@ -0,0 +1,610 @@
const api = require('../../../../api.js')
const PHASE_ACTION = 'action'
const PHASE_REST = 'rest'
const PHASE_IDLE = 'idle'
const BASE_URL_SAFE = function () {
try {
const a = getApp()
if (a && a.globalData && a.globalData.apiBase) return a.globalData.apiBase
} catch (e) {}
return 'https://traesite.umersoft.com:8975'
}
function clamp(v, a, b) { const x = Number(v) || 0; return Math.max(a, Math.min(b, x)) }
function pad2(n) { const x = Math.max(0, Math.floor(Number(n) || 0)); return x < 10 ? '0' + x : String(x) }
function expandPlan(card) {
const steps = Array.isArray(card.steps) ? card.steps : []
const mode = card.loopMode || 'sequential_once'
const times = mode === 'loop_n_times' ? clamp(card.loopTimes || 1, 1, 999) : 1
const plan = []
const loopUntil = mode === 'loop_till_stop'
const loopCount = loopUntil ? 1 : times
for (let round = 1; round <= loopCount; round++) {
steps.forEach((step, idx) => {
plan.push({
round,
roundTotal: loopCount,
loopUntil,
phase: PHASE_ACTION,
stepIndex: idx,
stepName: step.name || '动作',
voiceText: step.voiceText || step.name || '动作',
durationSec: clamp(step.durationSec, 1, 3600),
restSec: clamp(step.restSec, 0, 3600),
})
})
}
return { plan, loopUntil }
}
function sanitizeAnnounceLead(raw) {
const list = String(raw || '').split(/[,,\s]+/).map(x => Number(x.trim())).filter(n => Number.isFinite(n) && n > 0 && n <= 30)
const s = Array.from(new Set(list)).sort((a, b) => b - a)
return s.length ? s : [5, 3, 1]
}
function normalizeAudioUrl(raw) {
const url = String(raw || '').trim()
if (!url) return ''
if (/^https?:\/\//i.test(url)) return url
if (url.startsWith('/')) return BASE_URL_SAFE() + url
return `${BASE_URL_SAFE()}/${url.replace(/^\/+/, '')}`
}
Page({
data: {
cardId: '',
cardTitle: '运动节拍',
started: false,
paused: false,
phaseLabel: '准备开始',
stepSeq: '',
remainingText: '00:00',
stepName: '',
nextName: '',
elapsedMin: '00',
elapsedSecPad: '00',
remainingMin: '00',
remainingSecPad: '00',
completion: 0,
hasCountdown: true,
countdownText: '5,3,1',
},
_i: null,
onLoad(options) {
this._i = {
card: null,
plan: [],
loopUntil: false,
cursor: 0,
phase: PHASE_IDLE,
durationMs: 0,
phaseStartAt: 0,
phaseEndAt: 0,
pausedAt: 0,
pausedTotalMs: 0,
globalStartAt: 0,
announcedSet: null,
countdownSet: [],
voice: 'female_01',
sessionId: '',
lastTickEmit: 0,
lastServerSyncAt: 0,
ttsQueue: [],
ttsCtx: null,
ttsSrc: {},
lastAnnouncedText: '',
tickTimer: null,
hiddenAt: 0,
finishing: false,
}
const cardId = String((options && options.cardId) || '').trim()
this.data.cardId = cardId
wx.showLoading({ title: '加载卡片…', mask: true })
Promise.resolve().then(async () => {
if (!cardId) throw new Error('缺少卡片ID')
const res = await api.repClock.getCard(cardId)
const card = res && res.card ? res.card : null
if (!card) throw new Error('卡片不存在')
const { plan, loopUntil } = expandPlan(card)
if (!plan.length) throw new Error('卡片没有步骤')
const i = this._i
i.card = card
i.plan = plan
i.loopUntil = loopUntil
i.voice = card.voice || 'female_01'
i.announcedSet = new Set()
i.countdownSet = sanitizeAnnounceLead(card.announceLeadSeconds || '5,3,1')
this._preloadTtsCache(plan, i.voice).catch(() => {})
this._ensureAudioCtx()
const totalDurationMs = plan.reduce((acc, p) => acc + (p.durationSec + p.restSec) * 1000, 0)
const lead = i.countdownSet.length ? i.countdownSet.join(',') : '5,3,1'
this.setData({
cardTitle: String(card.title || '运动节拍'),
countdownText: lead,
hasCountdown: !!card.enableCountdownAnnounce !== false,
remainingMin: pad2(Math.floor(totalDurationMs / 60000)),
remainingSecPad: pad2(Math.floor((totalDurationMs % 60000) / 1000)),
})
try { wx.hideLoading() } catch (_) {}
}).catch(e => {
try { wx.hideLoading() } catch (_) {}
const msg = e && e.message ? String(e.message) : '加载失败'
wx.showToast({ title: msg, icon: 'none', duration: 1500 })
setTimeout(() => wx.navigateBack(), 1500)
})
},
onUnload() {
this._clearTickTimer()
this._destroyAudioCtx()
if (this._i && this._i.sessionId && !this._i.finishing) {
const sid = this._i.sessionId
api.repClock.sessionFinish({ sessionId: sid, completed: false, force: true }).catch(() => {})
}
},
onShow() {
const i = this._i
if (!this.data.started || !i.hiddenAt) return
const now = Date.now()
const hiddenMs = Math.max(0, now - i.hiddenAt)
i.hiddenAt = 0
if (!this.data.paused) this._applyBgCompensation(hiddenMs)
},
onHide() {
this._i.hiddenAt = Date.now()
},
_ensureAudioCtx() {
if (this._i.ttsCtx) return
try {
// iOS 微信 InnerAudioContext 默认 obeyMuteSwitch=true,iPhone 开启静音键会无声;运动播报必须忽略静音键
if (wx.setInnerAudioOption) {
wx.setInnerAudioOption({ obeyMuteSwitch: false, mixWithOther: true, speakerOn: true })
}
const ctx = wx.createInnerAudioContext()
ctx.src = ''
ctx.volume = 1.0
ctx.obeyMuteSwitch = false
ctx.speakerOn = true
ctx.onEnded(() => this._onTtsEnded())
ctx.onError((err) => this._onTtsFailed(err))
this._i.ttsCtx = ctx
} catch (e) {
this._i.ttsCtx = null
}
},
_destroyAudioCtx() {
try {
if (this._i.ttsCtx && this._i.ttsCtx.destroy) this._i.ttsCtx.destroy()
} catch (e) {}
this._i.ttsCtx = null
},
async _preloadTtsCache(plan, voice) {
try {
const r = await api.repClock.ttsBatch(this.data.cardId, this._i.card, voice)
const map = (r && r.data && Array.isArray(r.data.items)) ? r.data.items : []
map.forEach(it => {
if (it && it.text) this._i.ttsSrc[String(it.text).trim()] = normalizeAudioUrl(it.url || '')
})
} catch (e) {}
},
_onTtsEnded() {
this._i.lastAnnouncedText = ''
this._playNextTtsOrBeep()
},
_onTtsFailed(err) {
this._i.lastAnnouncedText = ''
// iOS 上 InnerAudioContext 常见 errCode:10003=格式不支持、10001=系统级失败;用于排查无声问题
try { console.error('[rep_clock] TTS 播放失败:', JSON.stringify(err || {})) } catch (e) {}
this._fallbackBeep()
this._playNextTtsOrBeep()
},
_enqueueTts(text, priority = 0) {
if (!text) return
const i = this._i
i.ttsQueue.push({ text, priority: Number(priority) || 0 })
i.ttsQueue.sort((a, b) => (b.priority - a.priority))
if (!i.ttsCtx) this._ensureAudioCtx()
if (i.ttsCtx && !this._isPlayingNow()) {
this._playNextTtsOrBeep()
}
},
_isPlayingNow() {
try { return this._i.ttsCtx && this._i.ttsCtx.src && this._i.lastAnnouncedText ? true : false } catch (e) { return false }
},
_playNextTtsOrBeep() {
const i = this._i
if (!i.ttsCtx) { this._fallbackBeep(); return }
if (!i.ttsQueue.length) return
const item = i.ttsQueue.shift()
const text = item.text
const url = normalizeAudioUrl(i.ttsSrc[String(text).trim()] || '')
if (!url) {
this._fallbackBeep()
this._vibrate()
if (!this._isPlayingNow()) this._playNextTtsOrBeep()
return
}
try {
i.lastAnnouncedText = text
i.ttsCtx.stop()
// encodeURI:iOS 微信对含中文/特殊字符的音频 URL 加载容易静默失败
i.ttsCtx.src = encodeURI(url)
i.ttsCtx.play()
} catch (e) {
this._fallbackBeep()
this._vibrate()
i.lastAnnouncedText = ''
this._playNextTtsOrBeep()
}
},
_fallbackBeep() {
try {
if (!this._fallbackBeepCtx) {
const ctx = wx.createInnerAudioContext()
ctx.volume = 1.0
this._fallbackBeepCtx = ctx
const list = [{ text: '__beep__', src: '' }]
this._fallbackBeepList = list
}
this._fallbackBeepCtx.stop()
// 微信 InnerAudioContext 无 src 时不一定会触发 error,但会静默失败;使用震动兜底
this._vibrate()
} catch (e) {
this._vibrate()
}
},
_vibrate() {
try { wx.vibrateShort({ type: 'medium' }) } catch (e) {}
},
async onStart() {
if (this.data.started) return
const i = this._i
const now = Date.now()
wx.showLoading({ title: '启动中…', mask: true })
try {
const r = await api.repClock.sessionStart({ cardId: this.data.cardId })
if (!r || !r.session || !r.session.id) throw new Error('启动失败')
i.sessionId = r.session.id
i.globalStartAt = now
i.pausedTotalMs = 0
i.cursor = 0
this._announceStart()
this._enterPhase(PHASE_ACTION, now)
this._startTickLoop()
this.setData({ started: true, paused: false })
wx.hideLoading()
} catch (e) {
wx.hideLoading()
const msg = e && e.message ? String(e.message) : ''
if (/409|冲突|running session exists/.test(msg)) {
const waitSec = e && e.expiresInMs ? Math.ceil(Number(e.expiresInMs) / 1000) : 300
wx.showModal({
title: '已有运动进行中',
content: `检测到另一会话正在进行,${waitSec} 秒后可自动解锁。或选择立即强制抢占(建议先去其他页面结束后再启动)。`,
confirmText: '立即强制开始',
cancelText: '等待自动解锁',
confirmColor: '#e05561',
showCancel: true,
success: async (mr) => {
if (mr.confirm) {
try { await api.repClock.resetRunning('') } catch (_) {}
wx.showLoading({ title: '启动中…', mask: true })
try {
const r2 = await api.repClock.sessionStart({ cardId: this.data.cardId })
if (!r2 || !r2.session || !r2.session.id) throw new Error('启动失败')
i.sessionId = r2.session.id
i.globalStartAt = Date.now()
i.pausedTotalMs = 0
i.cursor = 0
this._announceStart()
this._enterPhase(PHASE_ACTION, Date.now())
this._startTickLoop()
this.setData({ started: true, paused: false })
wx.hideLoading()
} catch (e2) {
wx.hideLoading()
wx.showModal({ title: '仍无法启动', content: (e2 && e2.message ? e2.message : '未知错误') + ',请稍后重试或重启服务。', showCancel: false, confirmText: '知道了' })
}
}
},
})
} else {
wx.showModal({ title: '启动失败', content: msg || '请检查网络或重启服务后重试。', showCancel: false, confirmText: '知道了' })
}
}
},
onPause() {
if (!this.data.started || this.data.paused) return
const now = Date.now()
this._i.pausedAt = now
this.setData({ paused: true })
},
onResume() {
if (!this.data.started || !this.data.paused) return
const now = Date.now()
const pMs = Math.max(0, now - (this._i.pausedAt || now))
this._i.pausedTotalMs += pMs
this._i.phaseStartAt += pMs
this._i.phaseEndAt += pMs
this._i.pausedAt = 0
this.setData({ paused: false })
},
onStepNext() {
if (!this.data.started) return
const now = Date.now()
this._advanceToNextPhase(true, now)
},
async onStop() {
if (!this.data.started) return
wx.showModal({
title: '结束运动?',
content: '会保存本次运动记录并返回。',
confirmText: '结束',
cancelText: '取消',
confirmColor: '#e05561',
success: async (r) => {
if (!r.confirm) return
await this._finishSession(false, Date.now())
}
})
},
_startTickLoop() {
this._clearTickTimer()
this._i.tickTimer = setInterval(() => this._tick(), 100)
},
_clearTickTimer() {
if (this._i.tickTimer) { clearInterval(this._i.tickTimer); this._i.tickTimer = null }
},
_applyBgCompensation(hiddenMs) {
const i = this._i
if (!this.data.started || hiddenMs < 1000) return
// 短档:<20秒,直接扣剩余时间
if (hiddenMs < 20 * 1000) {
i.phaseStartAt += hiddenMs
i.phaseEndAt += hiddenMs
return
}
// 中档:20秒~10分钟,快进完整步
if (hiddenMs < 10 * 60 * 1000) {
let jumpRemain = hiddenMs
while (jumpRemain > 2000 && !this._isPlanFinished()) {
const remainInPhase = Math.max(0, i.phaseEndAt - Date.now())
if (jumpRemain >= remainInPhase) {
jumpRemain -= remainInPhase
this._advanceToNextPhase(false, Date.now() + remainInPhase)
} else {
i.phaseStartAt += jumpRemain
i.phaseEndAt += jumpRemain
jumpRemain = 0
}
}
return
}
// 长档:>10分钟,直接结束
this._finishSession(false, Date.now()).catch(() => {})
},
_tick() {
const i = this._i
if (!this.data.started || this.data.paused) return
const now = Date.now()
this._renderState(now)
this._maybeAnnounceCountdown(now)
if (now >= i.phaseEndAt) {
this._advanceToNextPhase(false, now)
}
if (now - (i.lastServerSyncAt || 0) >= 6000) {
i.lastServerSyncAt = now
this._syncToServer(now).catch(() => {})
}
},
_renderState(now) {
const i = this._i
const phaseRemainMs = Math.max(0, i.phaseEndAt - now)
const totalPlanMs = (i.plan || []).reduce((a, p) => a + (p.durationSec + p.restSec) * 1000, 0)
const completedBefore = Math.min(i.cursor, i.plan.length) * 1000
const finishedBeforeMs = i.plan.slice(0, i.cursor).reduce((a, p) => a + (p.durationSec + p.restSec) * 1000, 0)
const phaseTotalMs = i.durationMs
const phasePassedMs = Math.max(0, Math.min(phaseTotalMs, phaseTotalMs - phaseRemainMs))
const elapsedMs = (now - i.globalStartAt) - i.pausedTotalMs
const totalPassedMs = finishedBeforeMs + phasePassedMs
const remainMs = Math.max(0, totalPlanMs - totalPassedMs)
const rMin = Math.floor(remainMs / 60000), rSec = Math.floor((remainMs % 60000) / 1000)
const eMin = Math.floor(elapsedMs / 60000), eSec = Math.floor((elapsedMs % 60000) / 1000)
const compPct = totalPlanMs <= 0 ? 0 : Math.min(100, Math.round((totalPassedMs / totalPlanMs) * 100))
const cur = i.plan[i.cursor]
const nextIdx = i.cursor + 1
const next = nextIdx < i.plan.length ? i.plan[nextIdx] : null
let phaseLabel = '准备开始', stepSeq = '', stepName = ''
if (cur) {
const round = cur.loopUntil ? '循环' : `${cur.round}/${cur.roundTotal}`
phaseLabel = i.phase === PHASE_ACTION ? '动作' : '休息'
stepSeq = `${round} · 第${cur.stepIndex + 1}步`
stepName = i.phase === PHASE_ACTION ? (cur.voiceText || cur.stepName || '') : '休息一下'
}
const phaseSec = Math.max(0, Math.round(phaseRemainMs / 1000))
const phaseMin = Math.floor(phaseSec / 60)
const phaseSec2 = phaseSec - phaseMin * 60
const remainingText = `${String(phaseMin)}:${pad2(phaseSec2)}`
this.setData({
remainingText,
phaseLabel,
stepSeq,
stepName,
nextName: next ? (next.phase === PHASE_ACTION ? (next.stepName || '') : '休息') : '',
remainingMin: pad2(rMin),
remainingSecPad: pad2(rSec),
elapsedMin: pad2(eMin),
elapsedSecPad: pad2(eSec),
completion: compPct,
})
},
_enterPhase(phase, nowMs) {
const i = this._i
const cur = i.plan[i.cursor]
if (!cur) return
i.phase = phase
const sec = phase === PHASE_ACTION ? cur.durationSec : cur.restSec
i.durationMs = sec * 1000
i.phaseStartAt = nowMs
i.phaseEndAt = nowMs + i.durationMs
i.announcedSet = new Set()
if (sec <= 0 && phase === PHASE_REST) {
this._advanceToNextPhase(false, nowMs)
return
}
// 播报:步名/休息。注意文本必须与服务端 ttsBatch 预合成缓存的 key 完全一致,
// 否则(如带“第X步:”前缀)命中不了缓存导致无声
let speak = ''
if (phase === PHASE_ACTION) {
speak = cur.voiceText || cur.stepName || '开始'
} else {
speak = `休息${cur.restSec}秒`
}
this._enqueueTts(speak, 10)
},
_isPlanFinished() {
const i = this._i
if (i.loopUntil) return false
return i.cursor >= i.plan.length
},
_announceStart() {
const title = String((this._i.card && this._i.card.title) || this.data.cardTitle || '').trim()
if (!title) return
this._enqueueTts(`开始训练,${title}`, 9)
},
// 切换动作前的过渡语:新的一轮(第N轮)播“第N轮”,同一轮内播“下一组”。
// 过渡语不内嵌动作名,动作名由进入步骤时单独播报,避免“平板支撑 → 平板支撑”重复。
// 缓存里没合成过的文本不播,避免只震动不出声
_announceNextStep() {
const i = this._i
const nxt = i.plan[i.cursor]
if (!nxt) return
const isNewRound = nxt.round > 1 && Number(nxt.stepIndex) === 0
const text = isNewRound ? `第${nxt.round}轮` : '下一组'
if (!i.ttsSrc[text]) return
this._enqueueTts(text, 11)
},
_advanceToNextPhase(manualSkip, nowMs) {
const i = this._i
if (i.phase === PHASE_ACTION) {
const cur = i.plan[i.cursor]
if (cur && cur.restSec > 0) {
this._enterPhase(PHASE_REST, nowMs)
return
}
}
// 结束当前 rest/action ,推进到下一条 plan 条目
i.cursor += 1
if (i.loopUntil) {
// 循环至停止:如果到达末尾,回到第一条(永不 finish,除非手动停止)
if (i.cursor >= i.plan.length) i.cursor = 0
this._announceNextStep()
this._enterPhase(PHASE_ACTION, nowMs)
return
}
if (i.cursor >= i.plan.length) {
this._finishSession(true, nowMs).catch(() => {})
return
}
this._announceNextStep()
this._enterPhase(PHASE_ACTION, nowMs)
},
_maybeAnnounceCountdown(now) {
const i = this._i
if (!this.data.hasCountdown) return
if (i.phase !== PHASE_ACTION && i.phase !== PHASE_REST) return
const remainSec = Math.round((i.phaseEndAt - now) / 1000)
if (remainSec <= 0 || remainSec > 60) return
if (!i.countdownSet.includes(remainSec)) return
if (i.announcedSet.has(i.cursor + '|' + i.phase + '|' + remainSec)) return
i.announcedSet.add(i.cursor + '|' + i.phase + '|' + remainSec)
// 最后 3 秒以内,使用更短播报
if (remainSec <= 3) {
this._enqueueTts(`还有${remainSec}秒`, 5)
} else {
this._enqueueTts(`还有${remainSec}秒`, 5)
}
},
async _syncToServer(nowMs) {
const i = this._i
if (!i.sessionId || i.finishing) return
const cur = i.plan[i.cursor]
await api.repClock.sessionSync({
sessionId: i.sessionId,
currentStepIndex: cur ? cur.stepIndex : -1,
currentRound: cur ? cur.round : 1,
phase: i.phase,
cursor: i.cursor,
phaseRemainingMs: Math.max(0, i.phaseEndAt - nowMs),
paused: !!this.data.paused,
pausedTotalMs: i.pausedTotalMs,
})
},
async _finishSession(completed, nowMs) {
const i = this._i
if (i.finishing) return
i.finishing = true
this._clearTickTimer()
wx.showLoading({ title: '保存记录…', mask: true })
try {
await api.repClock.sessionSync({
sessionId: i.sessionId,
currentStepIndex: -1,
currentRound: 1,
phase: 'finish',
cursor: i.cursor,
phaseRemainingMs: 0,
paused: !!this.data.paused,
pausedTotalMs: i.pausedTotalMs,
})
await api.repClock.sessionFinish({
sessionId: i.sessionId,
completed: !!completed,
force: false,
})
wx.hideLoading()
wx.showToast({ title: completed ? '完成!真棒 🎉' : '已保存', icon: 'success' })
setTimeout(() => wx.navigateBack(), 900)
} catch (e) {
wx.hideLoading()
wx.showToast({ title: '记录已在服务端,返回', icon: 'none' })
setTimeout(() => wx.navigateBack(), 900)
}
},
})
@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "节拍中…",
"navigationBarBackgroundColor": "#1a1a2e",
"navigationBarTextStyle": "white",
"backgroundColor": "#0f0f1a",
"disableScroll": true
}
@@ -0,0 +1,57 @@
<view class="runner-root">
<view class="runner-bg"></view>
<view class="runner-layout">
<view class="runner-topbar">
<view class="topbar-title">{{cardTitle}}</view>
<view class="topbar-sub muted small">
<text wx:if="{{phaseLabel}}">{{phaseLabel}}</text>
<text wx:if="{{phaseLabel && stepSeq}}"> · </text>
<text wx:if="{{stepSeq}}">{{stepSeq}}</text>
</view>
</view>
<view class="center-clock-wrap">
<view class="ring">
<view class="ring-left"></view>
<view class="ring-right"></view>
<view class="ring-mask-left"></view>
<view class="ring-mask-right"></view>
</view>
<view class="clock-inner">
<view class="clock-text">{{remainingText}}</view>
<view class="clock-sub muted">{{stepName}}</view>
</view>
</view>
<view wx:if="{{nextName}}" class="next-card muted small">
下一步:<text class="next-name">{{nextName}}</text>
</view>
<view class="stats-row">
<view class="stat">
<view class="stat-num">{{elapsedMin}}:{{elapsedSecPad}}</view>
<view class="stat-label">已过</view>
</view>
<view class="stat">
<view class="stat-num">{{remainingMin}}:{{remainingSecPad}}</view>
<view class="stat-label">剩余</view>
</view>
<view class="stat">
<view class="stat-num">{{completion}}%</view>
<view class="stat-label">完成</view>
</view>
</view>
<view class="actions">
<view wx:if="{{!started}}" class="btn btn-primary btn-block act-btn" bindtap="onStart">▶ 开始</view>
<block wx:else>
<view wx:if="{{paused}}" class="btn btn-primary btn-block act-btn" bindtap="onResume">▶ 继续</view>
<view wx:else class="btn btn-ghost btn-block act-btn" bindtap="onPause">⏸ 暂停</view>
<view class="btn btn-ghost btn-block act-btn act-btn-secondary" bindtap="onStepNext">⏭ 下一步</view>
<view class="btn btn-danger btn-block act-btn" bindtap="onStop">⏹ 结束</view>
</block>
</view>
<view wx:if="{{hasCountdown}}" class="broadcasts muted small">倒计时播报:{{countdownText}}</view>
</view>
</view>
@@ -0,0 +1,143 @@
.runner-root {
min-height: 100vh;
position: relative;
color: #ffffff;
overflow-x: hidden;
overflow-y: auto;
}
.runner-bg {
position: fixed; inset: 0;
background: radial-gradient(circle at 30% 20%, #1f1f3d, #0a0a14 65%);
z-index: 0;
}
.runner-layout {
position: relative;
z-index: 1;
padding: 36rpx 28rpx calc(24rpx + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
min-height: 100vh;
box-sizing: border-box;
}
.runner-topbar { margin-bottom: 18rpx; }
.topbar-title { font-size: 40rpx; font-weight: 800; letter-spacing: 1rpx; }
.topbar-sub { margin-top: 6rpx; }
.center-clock-wrap {
position: relative;
width: 468rpx;
height: 468rpx;
margin: 18rpx auto 0;
}
.ring {
position: absolute; inset: 0;
border-radius: 50%;
background: linear-gradient(135deg, #6c7ae0, #b06be0);
opacity: 0.18;
}
.ring-left, .ring-right {
position: absolute;
width: 50%;
height: 100%;
left: 0; top: 0;
border-radius: 280rpx 0 0 280rpx;
background: conic-gradient(from 0deg, #6c7ae0 0deg 180deg, rgba(108,122,224,0.15) 180deg 360deg);
transform-origin: 100% 50%;
}
.ring-right {
left: 50%;
border-radius: 0 280rpx 280rpx 0;
background: conic-gradient(from 180deg, #b06be0 180deg 360deg, rgba(176,107,224,0.15) 360deg 360deg);
transform-origin: 0% 50%;
}
.ring-mask-left, .ring-mask-right {
position: absolute;
width: 50%;
height: 100%;
background: #0e0e19;
z-index: 2;
top: 0; left: 0;
border-radius: 280rpx 0 0 280rpx;
transform-origin: 100% 50%;
}
.ring-mask-right {
left: 50%;
border-radius: 0 280rpx 280rpx 0;
background: #0e0e19;
transform-origin: 0% 50%;
}
.clock-inner {
position: absolute;
inset: 48rpx;
border-radius: 50%;
background: #101025;
border: 1rpx solid rgba(255,255,255,0.08);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 3;
box-shadow: 0 0 60rpx rgba(108,122,224,0.15), inset 0 0 80rpx rgba(0,0,0,0.6);
}
.clock-text {
font-size: 148rpx;
font-weight: 800;
letter-spacing: 4rpx;
font-variant-numeric: tabular-nums;
background: linear-gradient(135deg, #ffffff, #d6dbff);
color: transparent;
-webkit-background-clip: text;
background-clip: text;
}
.clock-sub { margin-top: 12rpx; font-size: 28rpx; color: #c2c6f4; }
.next-card {
margin: 22rpx auto 0;
padding: 14rpx 24rpx;
background: rgba(255,255,255,0.04);
border: 1rpx solid rgba(255,255,255,0.06);
border-radius: 20rpx;
text-align: center;
}
.next-name { color: #ffffff; margin-left: 12rpx; font-weight: 600; }
.stats-row {
margin-top: 26rpx;
display: flex;
gap: 14rpx;
}
.stat {
flex: 1;
padding: 18rpx 8rpx;
background: rgba(255,255,255,0.04);
border: 1rpx solid rgba(255,255,255,0.06);
border-radius: 22rpx;
text-align: center;
}
.stat-num {
font-size: 34rpx;
font-weight: 700;
color: #e9eaff;
font-variant-numeric: tabular-nums;
}
.stat-label {
margin-top: 6rpx;
color: #7a7ea4;
font-size: 22rpx;
}
.actions {
margin-top: auto;
padding: 24rpx 0 8rpx;
display: flex;
gap: 12rpx;
flex-direction: column;
}
.act-btn { padding: 22rpx 0; font-size: 30rpx; font-weight: 600; border-radius: 20rpx; }
.act-btn-secondary { background: rgba(255,255,255,0.04); color: #e2e4ff; border: 1rpx solid rgba(255,255,255,0.08); }
.broadcasts {
margin: 10rpx auto 0;
text-align: center;
letter-spacing: 0.5rpx;
}
+50 -80
View File
@@ -5,8 +5,6 @@ Page({
loading: true,
kits: [],
appNameZh: 'MilesYang时光助手',
subtitle: '个人工具集合,记录时光点滴。',
error: '',
clipVisible: false,
clipText: '',
clipCount: 0,
@@ -14,18 +12,12 @@ Page({
clipPreview: '',
},
onLoad() {
this.loadMeta()
},
onLoad() { this.refresh() },
onShow() {
this.checkClipboard()
},
onShow() { this.checkClipboard() },
onPullDownRefresh() {
this.loadMeta().finally(() => {
wx.stopPullDownRefresh()
})
this.refresh().finally(() => wx.stopPullDownRefresh())
},
simpleHash(str) {
@@ -42,6 +34,50 @@ Page({
return h1.toString(36) + h2.toString(36)
},
async refresh() {
this.setData({ loading: true })
try {
const res = await api.core.getMeta()
if (res && res.appNameZh) this.setData({ appNameZh: res.appNameZh })
} catch (e) {}
this.setData({
kits: this.buildKits(),
loading: false,
})
},
buildKits() {
return [
{
id: 'personal_collect',
name: '微信收藏夹',
description: '聊天图片 / 文件 / 文字收藏,标签搜索,软删除可追溯。',
icon: '📁',
path: '/kits/personal_collect/pages/list/list',
meta: 'API: /api/mp_personal_collect/ · DB: mp_personal_collect.db',
},
{
id: 'rep_clock',
name: '运动节拍 RepClock',
description: 'HIIT / Yoga / 拉伸节奏倒计时,锚点时钟 + 女声播报。',
icon: '⏱️',
path: '/kits/rep_clock/pages/cards/cards',
meta: 'API: /api/mp_rep_clock/ · DB: mp_rep_clock.db',
},
]
},
async onKitTap(e) {
const id = (e.currentTarget.dataset.id || '').trim()
const path = (e.currentTarget.dataset.path || '').trim()
if (!path) { wx.showToast({ title: '功能开发中', icon: 'none' }); return }
try { await api.core.setActiveKit(id) } catch (e) {}
wx.navigateTo({
url: path,
fail: () => wx.showToast({ title: '打开失败', icon: 'none' }),
})
},
checkClipboard() {
try {
const enable = wx.getStorageSync('mp_ta_clip_enable')
@@ -72,15 +108,13 @@ Page({
})
},
onClipCollect(e) {
const mode = (e && e.currentTarget && e.currentTarget.dataset.mode) || 'new'
onClipCollect() {
const text = this.data.clipText || ''
if (!text) { this.setData({ clipVisible: false }); return }
try { wx.setStorageSync('mp_ta_clip_text', text) } catch (e) {}
try { wx.setStorageSync('mp_ta_clip_mode', mode) } catch (e) {}
this.setData({ clipVisible: false })
wx.navigateTo({
url: '/kits/personal_collect/pages/edit/edit',
url: '/kits/personal_collect/pages/edit/edit?mode=new',
fail: () => { wx.showToast({ title: '打开失败', icon: 'none' }) }
})
},
@@ -105,69 +139,5 @@ Page({
})
},
async loadMeta() {
this.setData({ loading: true, error: '' })
try {
const res = await api.core.getMeta()
if (res && res.appNameZh) {
this.setData({ appNameZh: res.appNameZh })
}
if (res && Array.isArray(res.kits) && res.kits.length) {
this.setData({ kits: this.decorateKits(res.kits) })
try { getApp().setMeta(res) } catch (e) {}
} else {
this.setData({ kits: this.fallbackKits() })
}
} catch (e) {
this.setData({
error: e && e.message ? e.message : '加载失败,请检查网络',
kits: this.fallbackKits(),
})
} finally {
this.setData({ loading: false })
}
},
fallbackKits() {
return this.decorateKits([{
id: 'personal_collect',
name: '微信收藏夹',
description: '聊天图片、文件收藏管理,标签搜索,软删除可追溯。',
}])
},
decorateKits(list) {
const icons = {
personal_collect: '📁',
}
return list.map(item => Object.assign({}, item, {
icon: icons[item.id] || '🛠️',
path: this.getKitPath(item.id),
}))
},
getKitPath(id) {
const map = {
personal_collect: '/kits/personal_collect/pages/list/list',
}
return map[id] || ''
},
async onKitTap(e) {
const id = e.currentTarget.dataset.id
const path = e.currentTarget.dataset.path
if (!path) {
wx.showToast({ title: '功能开发中', icon: 'none' })
return
}
try {
await api.core.setActiveKit(id)
} catch (e) {}
wx.navigateTo({
url: path,
fail: () => {
wx.showToast({ title: '打开失败', icon: 'none' })
},
})
},
noop() {},
})
@@ -1,5 +1,8 @@
{
"navigationBarTitleText": "MilesYang时光助手",
"navigationBarTitleText": "时光助手",
"navigationBarBackgroundColor": "#1a1a2e",
"navigationBarTextStyle": "white",
"backgroundColor": "#0f0f1a",
"enablePullDownRefresh": true,
"backgroundTextStyle": "light"
}
@@ -1,8 +1,7 @@
<view class="container">
<view class="page-header">
<view class="page-title">{{appNameZh}}</view>
<view class="page-subtitle">{{subtitle}}</view>
<view wx:if="{{error}}" class="muted small" style="margin-top: 16rpx; color: #e05561;">⚠️ {{error}}</view>
<view class="page-subtitle">小工具集合 · 完全独立 · 零共享</view>
</view>
<view wx:if="{{clipVisible}}" class="clip-card" catchtouchmove="noop">
@@ -14,12 +13,22 @@
<view class="clip-actions">
<view class="clip-btn clip-btn-ghost" bindtap="onClipDismissForever">不再提示</view>
<view class="clip-btn clip-btn-ghost" bindtap="onClipCancel">取消</view>
<view class="clip-btn clip-btn-primary" bindtap="onClipCollect" data-mode="new">📌 收藏为文字</view>
<view class="clip-btn clip-btn-primary" bindtap="onClipCollect">📌 收藏为文字</view>
</view>
</view>
<view wx:if="{{loading}}" class="empty">加载中...</view>
<view class="hero-card">
<view class="hero-icon">
<text class="hero-emoji">🧘</text>
</view>
<view class="hero-body">
<view class="hero-title">每一次节奏,都是一次呼吸。</view>
<view class="hero-desc">独立工具集合,互不干扰,可独立部署。</view>
</view>
</view>
<block wx:for="{{kits}}" wx:key="id">
<view class="kit-card" bindtap="onKitTap" data-id="{{item.id}}" data-path="{{item.path}}">
<view class="kit-icon">
@@ -28,8 +37,13 @@
<view class="kit-body">
<view class="kit-title">{{item.name}}</view>
<view class="kit-desc">{{item.description}}</view>
<view wx:if="{{item.meta}}" class="kit-meta">{{item.meta}}</view>
</view>
<view class="chevron">›</view>
</view>
</block>
<view class="footer muted small">
独立部署 · 独立数据库 · 独立运行锁
</view>
</view>
@@ -1,4 +1,26 @@
/* 首页独立样式,大部分复用 app.wxss 全局样式 */
.hero-card {
margin: 4rpx 24rpx 36rpx;
padding: 32rpx 30rpx;
background: linear-gradient(145deg, rgba(108, 122, 224, 0.22), rgba(176, 107, 224, 0.16));
border: 1px solid rgba(140, 150, 240, 0.24);
border-radius: 28rpx;
display: flex;
align-items: center;
gap: 26rpx;
box-shadow: 0 14rpx 36rpx rgba(0,0,0,0.28);
}
.hero-icon { width: 96rpx; height: 96rpx; flex: none; border-radius: 24rpx; background: rgba(255,255,255,0.08); display: flex; align-items: center; justify-content: center; }
.hero-emoji { font-size: 54rpx; }
.hero-title { font-size: 30rpx; font-weight: 700; color: #efeffe; letter-spacing: 0.5rpx; margin-bottom: 8rpx; }
.hero-desc { font-size: 24rpx; color: #a6a9d0; }
.footer {
margin: 80rpx 0 40rpx;
text-align: center;
letter-spacing: 1rpx;
color: #5a5e82;
font-size: 22rpx;
}
.clip-card {
margin: 20rpx 24rpx 12rpx;
@@ -59,11 +81,6 @@
color: #b8b8d8;
border: 1px solid rgba(255,255,255,0.08);
}
.clip-btn-secondary {
background: rgba(108, 122, 224, 0.16);
color: #cfd4ff;
border: 1px solid rgba(108, 122, 224, 0.32);
}
.clip-btn-primary {
background: linear-gradient(135deg, #6c7ae0, #b06be0);
color: #fff;
@@ -0,0 +1,60 @@
const api = require('../../api.js')
Page({
data: {
loading: true,
kits: [],
appNameZh: 'MilesYang时光助手',
subtitle: '小工具集合 · 完全独立 · 零共享',
},
onLoad() { this.refresh() },
onPullDownRefresh() {
this.refresh().finally(() => wx.stopPullDownRefresh())
},
async refresh() {
this.setData({ loading: true })
try {
const res = await api.core.getMeta()
if (res && res.appNameZh) this.setData({ appNameZh: res.appNameZh })
} catch (e) {}
this.setData({
kits: this.buildKits(),
loading: false,
})
},
buildKits() {
return [
{
id: 'personal_collect',
name: '微信收藏夹',
description: '聊天图片 / 文件 / 文字收藏,标签搜索,软删除可追溯。',
icon: '📁',
path: '/kits/personal_collect/pages/list/list',
meta: 'API: /api/mp_personal_collect/ · DB: mp_personal_collect.db',
},
{
id: 'rep_clock',
name: '运动节拍 RepClock',
description: 'HIIT / Yoga / 拉伸节奏倒计时,锚点时钟 + 女声播报。',
icon: '⏱️',
path: '/kits/rep_clock/pages/cards/cards',
meta: 'API: /api/mp_rep_clock/ · DB: mp_rep_clock.db',
},
]
},
async onKitTap(e) {
const id = (e.currentTarget.dataset.id || '').trim()
const path = (e.currentTarget.dataset.path || '').trim()
if (!path) { wx.showToast({ title: '功能开发中', icon: 'none' }); return }
try { await api.core.setActiveKit(id) } catch (e) {}
wx.navigateTo({
url: path,
fail: () => wx.showToast({ title: '打开失败', icon: 'none' }),
})
},
})
@@ -0,0 +1,8 @@
{
"navigationBarTitleText": "时光助手",
"navigationBarBackgroundColor": "#1a1a2e",
"navigationBarTextStyle": "white",
"backgroundColor": "#0f0f1a",
"enablePullDownRefresh": true,
"backgroundTextStyle": "light"
}
@@ -0,0 +1,36 @@
<view class="container">
<view class="page-header">
<view class="page-title">{{appNameZh}}</view>
<view class="page-subtitle">{{subtitle}}</view>
</view>
<view wx:if="{{loading}}" class="empty">加载中...</view>
<view class="hero-card">
<view class="hero-icon">
<text class="hero-emoji">🧘</text>
</view>
<view class="hero-body">
<view class="hero-title">每一次节奏,都是一次呼吸。</view>
<view class="hero-desc">独立工具集合,互不干扰,可独立部署。</view>
</view>
</view>
<block wx:for="{{kits}}" wx:key="id">
<view class="kit-card" bindtap="onKitTap" data-id="{{item.id}}" data-path="{{item.path}}">
<view class="kit-icon">
<text class="kit-icon-text">{{item.icon}}</text>
</view>
<view class="kit-body">
<view class="kit-title">{{item.name}}</view>
<view class="kit-desc">{{item.description}}</view>
<view wx:if="{{item.meta}}" class="kit-meta">{{item.meta}}</view>
</view>
<view class="chevron">›</view>
</view>
</block>
<view class="footer muted small">
独立部署 · 独立数据库 · 独立运行锁
</view>
</view>
@@ -0,0 +1,23 @@
.hero-card {
margin: 4rpx 24rpx 36rpx;
padding: 32rpx 30rpx;
background: linear-gradient(145deg, rgba(108, 122, 224, 0.22), rgba(176, 107, 224, 0.16));
border: 1px solid rgba(140, 150, 240, 0.24);
border-radius: 28rpx;
display: flex;
align-items: center;
gap: 26rpx;
box-shadow: 0 14rpx 36rpx rgba(0,0,0,0.28);
}
.hero-icon { width: 96rpx; height: 96rpx; flex: none; border-radius: 24rpx; background: rgba(255,255,255,0.08); display: flex; align-items: center; justify-content: center; }
.hero-emoji { font-size: 54rpx; }
.hero-title { font-size: 30rpx; font-weight: 700; color: #efeffe; letter-spacing: 0.5rpx; margin-bottom: 8rpx; }
.hero-desc { font-size: 24rpx; color: #a6a9d0; }
.footer {
margin: 80rpx 0 40rpx;
text-align: center;
letter-spacing: 1rpx;
color: #5a5e82;
font-size: 22rpx;
}
+21 -15
View File
@@ -13,14 +13,20 @@
const url = buildUrl(path)
const init = {
method: method || 'GET',
headers: opts.headers || {},
headers: Object.assign(
{
'Accept': 'application/json; charset=utf-8',
'X-Mp-Timeassistant': 'mp_web_time_assistant_v1_' + Math.random().toString(36).slice(2, 10),
},
opts.headers || {}
),
credentials: 'same-origin'
}
if (body !== null && body !== undefined) {
if (body instanceof FormData) {
init.body = body
} else {
init.headers['Content-Type'] = 'application/json'
init.headers['Content-Type'] = 'application/json; charset=utf-8'
init.body = JSON.stringify(body)
}
}
@@ -48,17 +54,17 @@
async meta() {
const res = await request('GET', '/api/mp_time_assistant/meta')
const payload = (res && res.data) || {}
return (payload && payload.ok) ? payload.data || {} : (payload || {})
return (payload && payload.ok) ? payload : (payload || {})
},
async getState() {
const res = await request('GET', '/api/mp_time_assistant/state')
const payload = (res && res.data) || {}
return (payload && payload.ok) ? payload.data || {} : (payload || {})
return (payload && payload.ok) ? payload : (payload || {})
},
async setActiveKit(kitId) {
const res = await request('PUT', '/api/mp_time_assistant/state/active-kit', { kitId })
const payload = (res && res.data) || {}
return (payload && payload.ok) ? payload.data || {} : (payload || {})
return (payload && payload.ok) ? payload : (payload || {})
}
}
@@ -67,31 +73,31 @@
const q = new URLSearchParams()
if (keyword) q.set('keyword', keyword)
const suffix = q.toString() ? '?' + q.toString() : ''
const res = await request('GET', '/api/mp_time_assistant/kits/personal_collect/items' + suffix)
const res = await request('GET', '/api/mp_personal_collect/kits/personal_collect/items' + suffix)
const payload = (res && res.data) || {}
if (payload && payload.ok) return payload.data || []
if (payload && payload.ok) return payload.items || payload.data || []
return Array.isArray(payload) ? payload : []
},
async get(id) {
const res = await request('GET', '/api/mp_time_assistant/kits/personal_collect/items/' + encodeURIComponent(id))
const res = await request('GET', '/api/mp_personal_collect/kits/personal_collect/items/' + encodeURIComponent(id))
const payload = (res && res.data) || {}
return (payload && payload.ok) ? (payload.data || null) : (payload || null)
return (payload && payload.ok) ? (payload.item || payload.data || null) : (payload || null)
},
async create(payload) {
const res = await request('POST', '/api/mp_time_assistant/kits/personal_collect/items', payload)
const res = await request('POST', '/api/mp_personal_collect/kits/personal_collect/items', payload)
const body = (res && res.data) || {}
if (body && body.ok) return body.data || body
if (body && body.ok) return body.item || body.data || body
throw new Error((body && body.error) || '创建失败')
},
async update(payload) {
const res = await request('PUT', '/api/mp_time_assistant/kits/personal_collect/items', payload)
const res = await request('PUT', '/api/mp_personal_collect/kits/personal_collect/items', payload)
const body = (res && res.data) || {}
if (body && body.ok) return body.data || body
if (body && body.ok) return body.item || body.data || body
throw new Error((body && body.error) || '更新失败')
},
async remove(id) {
const q = new URLSearchParams({ id })
const res = await request('DELETE', '/api/mp_time_assistant/kits/personal_collect/items?' + q.toString())
const res = await request('DELETE', '/api/mp_personal_collect/kits/personal_collect/items?' + q.toString())
const body = (res && res.data) || {}
if (body && body.ok) return body
throw new Error((body && body.error) || '删除失败')
@@ -100,7 +106,7 @@
const form = new FormData()
form.append('file', file)
if (groupId) form.append('groupId', groupId)
const url = buildUrl('/api/mp_time_assistant/kits/personal_collect/upload')
const url = buildUrl('/api/mp_personal_collect/kits/personal_collect/upload')
return new Promise((resolve, reject) => {
try {
const xhr = new XMLHttpRequest()
+152 -25
View File
@@ -18,7 +18,7 @@ body {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 48px;
margin-bottom: 36px;
padding: 24px 32px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(108, 122, 224, 0.15);
@@ -64,9 +64,85 @@ body {
border-color: rgba(255, 80, 100, 0.3);
}
.app-main {
display: flex;
flex-direction: column;
gap: 28px;
}
.hero-card {
position: relative;
display: flex;
align-items: center;
gap: 24px;
padding: 32px 36px;
border-radius: 24px;
background: linear-gradient(135deg, rgba(108, 122, 224, 0.14) 0%, rgba(176, 107, 224, 0.10) 100%);
border: 1px solid rgba(108, 122, 224, 0.28);
backdrop-filter: blur(10px);
overflow: hidden;
}
.hero-card::before {
content: '';
position: absolute;
top: -80px; right: -80px;
width: 280px; height: 280px;
background: radial-gradient(circle, rgba(176, 107, 224, 0.28) 0%, transparent 65%);
filter: blur(40px);
opacity: 0.9;
pointer-events: none;
}
.hero-card::after {
content: '';
position: absolute;
left: 16px; top: 20px; bottom: 20px;
width: 4px;
border-radius: 4px;
background: linear-gradient(180deg, #6c7ae0 0%, #b06be0 100%);
box-shadow: 0 0 18px rgba(108, 122, 224, 0.6);
}
.hero-icon {
width: 78px; height: 78px;
border-radius: 22px;
display: flex; align-items: center; justify-content: center;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.09);
flex-shrink: 0;
position: relative; z-index: 1;
}
.hero-emoji {
font-size: 42px;
line-height: 1;
filter: drop-shadow(0 6px 18px rgba(108, 122, 224, 0.45));
animation: heroBreath 5.2s ease-in-out infinite;
}
@keyframes heroBreath {
0%, 100% { transform: scale(1); opacity: 0.96; }
50% { transform: scale(1.08); opacity: 1; }
}
.hero-body { position: relative; z-index: 1; }
.hero-title {
font-size: 24px;
font-weight: 600;
color: #f0eefc;
letter-spacing: 0.6px;
margin-bottom: 10px;
}
.hero-desc {
font-size: 14px;
color: #a8a6c8;
letter-spacing: 0.3px;
}
.kit-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
}
@@ -80,10 +156,13 @@ body {
.kit-card {
position: relative;
display: flex;
align-items: stretch;
gap: 20px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 22px;
padding: 28px;
padding: 24px 22px 24px 26px;
cursor: pointer;
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1),
border-color 0.3s ease,
@@ -101,10 +180,23 @@ body {
transition: opacity 0.4s ease;
opacity: 0;
z-index: 0;
pointer-events: none;
}
.kit-card::after {
content: '';
position: absolute;
left: 12px; top: 20px; bottom: 20px;
width: 3px;
border-radius: 3px;
background: linear-gradient(180deg, #6c7ae0 0%, #b06be0 100%);
box-shadow: 0 0 14px rgba(108, 122, 224, 0.45);
z-index: 0;
pointer-events: none;
}
.kit-card:hover {
transform: translateY(-6px);
transform: translateY(-5px);
border-color: rgba(108, 122, 224, 0.35);
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 20px 40px rgba(108, 122, 224, 0.15);
@@ -125,46 +217,72 @@ body {
font-size: 28px;
background: linear-gradient(135deg, rgba(108, 122, 224, 0.22) 0%, rgba(176, 107, 224, 0.18) 100%);
border: 1px solid rgba(108, 122, 224, 0.3);
margin-bottom: 18px;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.kit-name {
font-size: 19px;
font-weight: 600;
color: #f0f0f8;
margin-bottom: 8px;
.kit-body {
flex: 1 1 auto;
min-width: 0;
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.kit-name {
font-size: 18px;
font-weight: 600;
color: #f0f0f8;
}
.kit-desc {
font-size: 13.5px;
color: #9b9bb5;
line-height: 1.7;
position: relative;
z-index: 1;
}
.kit-arrow {
margin-top: 18px;
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
.kit-meta {
margin-top: 4px;
padding: 6px 10px;
border-radius: 10px;
background: rgba(108, 122, 224, 0.08);
border: 1px solid rgba(108, 122, 224, 0.18);
font-size: 11.5px;
color: #a8b4ff;
position: relative;
z-index: 1;
opacity: 0.75;
transition: gap 0.3s ease, opacity 0.3s ease;
font-family: 'JetBrains Mono', Consolas, 'Courier New', monospace;
letter-spacing: 0.2px;
align-self: flex-start;
}
.kit-card:hover .kit-arrow {
gap: 12px;
.chevron {
flex-shrink: 0;
align-self: center;
color: #a8b4ff;
font-size: 26px;
line-height: 1;
opacity: 0.75;
transition: transform 0.3s ease, opacity 0.3s ease;
position: relative; z-index: 1;
padding-left: 4px;
}
.kit-card:hover .chevron {
transform: translateX(6px);
opacity: 1;
}
.footer {
margin-top: 8px;
padding: 18px 4px 0;
text-align: center;
color: #6f6f92;
font-size: 12px;
letter-spacing: 0.5px;
}
@media (max-width: 600px) {
.app-root { padding: 20px 16px 60px; }
.app-header {
@@ -174,6 +292,15 @@ body {
gap: 12px;
}
.app-header-left .app-title { font-size: 24px; }
.hero-card {
padding: 22px 22px 22px 28px;
gap: 16px;
flex-direction: column;
align-items: flex-start;
}
.hero-icon { width: 64px; height: 64px; border-radius: 18px; }
.hero-emoji { font-size: 34px; }
.hero-title { font-size: 20px; }
.kit-grid { grid-template-columns: 1fr; gap: 16px; }
.kit-card { padding: 22px; }
.kit-card { padding: 20px 18px 20px 24px; }
}
+19 -5
View File
@@ -4,15 +4,15 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MilesYang时光助手</title>
<link rel="stylesheet" href="/common/common.css">
<link rel="stylesheet" href="hub.css">
<link rel="stylesheet" href="/common/common.css?v=1.10">
<link rel="stylesheet" href="hub.css?v=1.10">
</head>
<body>
<div class="app-root">
<header class="app-header">
<div class="app-header-left">
<h1 class="app-title">MilesYang时光助手</h1>
<p class="app-subtitle">手机 · 电脑,实时联动的小工具集合</p>
<p class="app-subtitle">手机 · 电脑,实时联动的小工具集合 · 完全独立 · 零共享</p>
</div>
<div class="app-header-right">
<span id="status-tag" class="status-tag">加载中...</span>
@@ -20,13 +20,27 @@
</header>
<main class="app-main">
<section class="hero-card">
<div class="hero-icon">
<span class="hero-emoji">🧘</span>
</div>
<div class="hero-body">
<div class="hero-title">每一次节奏,都是一次呼吸。</div>
<div class="hero-desc">独立工具集合,互不干扰,可独立部署,可独立关闭。</div>
</div>
</section>
<div id="kit-grid" class="kit-grid">
<div class="kit-empty">⏳ 工具列表加载中...</div>
</div>
<footer class="footer muted small">
独立部署 · 独立数据库 · 独立运行锁
</footer>
</main>
</div>
<script src="api.js"></script>
<script src="hub.js"></script>
<script src="api.js?v=1.10"></script>
<script src="hub.js?v=1.10"></script>
</body>
</html>
+27 -17
View File
@@ -4,18 +4,34 @@
const $status = document.getElementById('status-tag')
const KIT_PAGES = {
personal_collect: 'kits/personal_collect/list.html'
personal_collect: 'kits/personal_collect/list.html',
rep_clock: 'kits/rep_clock/cards.html',
}
const KIT_ICONS = {
personal_collect: '📁'
personal_collect: '📁',
rep_clock: '⏱️',
}
const HERO_EMOJI = '🧘'
const HERO_TITLE = '每一次节奏,都是一次呼吸。'
const HERO_DESC = '独立工具集合,互不干扰,可独立部署,可独立关闭。'
function setStatus(text, cls) {
$status.textContent = text
$status.className = 'status-tag' + (cls ? ' ' + cls : '')
}
function escapeHtml(s) {
if (s === null || s === undefined) return ''
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function renderKits(kits) {
if (!kits || !kits.length) {
$grid.innerHTML = '<div class="kit-empty">暂无可用工具</div>'
@@ -23,13 +39,17 @@
}
$grid.innerHTML = kits.map((kit) => {
const page = KIT_PAGES[kit.id] || '#'
const icon = KIT_ICONS[kit.id] || '🧰'
const icon = kit.icon || KIT_ICONS[kit.id] || '🧰'
const meta = kit.meta ? `<div class="kit-meta">${escapeHtml(kit.meta)}</div>` : ''
return (
`<div class="kit-card" data-kit="${kit.id}" data-page="${page}">` +
`<div class="kit-card" data-kit="${escapeHtml(kit.id)}" data-page="${escapeHtml(page)}">` +
`<div class="kit-icon">${icon}</div>` +
`<div class="kit-name">${escapeHtml(kit.name || kit.id)}</div>` +
`<div class="kit-desc">${escapeHtml(kit.description || '')}</div>` +
`<div class="kit-arrow"><span>进入</span><span>→</span></div>` +
`<div class="kit-body">` +
`<div class="kit-name">${escapeHtml(kit.name || kit.id)}</div>` +
`<div class="kit-desc">${escapeHtml(kit.description || '')}</div>` +
meta +
`</div>` +
`<div class="chevron">›</div>` +
`</div>`
)
}).join('')
@@ -47,16 +67,6 @@
})
}
function escapeHtml(s) {
if (s === null || s === undefined) return ''
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
async function main() {
try {
if (!api || !api.hub) throw new Error('API 未加载')
+19 -5
View File
@@ -4,15 +4,15 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MilesYang时光助手</title>
<link rel="stylesheet" href="/common/common.css">
<link rel="stylesheet" href="hub.css">
<link rel="stylesheet" href="/common/common.css?v=1.10">
<link rel="stylesheet" href="hub.css?v=1.10">
</head>
<body>
<div class="app-root">
<header class="app-header">
<div class="app-header-left">
<h1 class="app-title">MilesYang时光助手</h1>
<p class="app-subtitle">手机 · 电脑,实时联动的小工具集合</p>
<p class="app-subtitle">手机 · 电脑,实时联动的小工具集合 · 完全独立 · 零共享</p>
</div>
<div class="app-header-right">
<span id="status-tag" class="status-tag">加载中...</span>
@@ -20,13 +20,27 @@
</header>
<main class="app-main">
<section class="hero-card">
<div class="hero-icon">
<span class="hero-emoji">🧘</span>
</div>
<div class="hero-body">
<div class="hero-title">每一次节奏,都是一次呼吸。</div>
<div class="hero-desc">独立工具集合,互不干扰,可独立部署,可独立关闭。</div>
</div>
</section>
<div id="kit-grid" class="kit-grid">
<div class="kit-empty">⏳ 工具列表加载中...</div>
</div>
<footer class="footer muted small">
独立部署 · 独立数据库 · 独立运行锁
</footer>
</main>
</div>
<script src="api.js"></script>
<script src="hub.js"></script>
<script src="api.js?v=1.10"></script>
<script src="hub.js?v=1.10"></script>
</body>
</html>
@@ -1,41 +1,80 @@
.detail-section {
margin-bottom: 28px;
margin-bottom: 22px;
}
.detail-card {
background: rgba(32, 32, 52, 0.55);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 18px;
padding: 24px 26px;
backdrop-filter: blur(6px);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
transition: border-color 0.25s ease, transform 0.25s ease;
}
.detail-card:hover {
border-color: rgba(108, 122, 224, 0.22);
transform: translateY(-1px);
}
.detail-section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 600;
color: #8a8aa8;
color: #9a9abd;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 10px;
letter-spacing: 1px;
margin-bottom: 14px;
}
.detail-section-title::before {
content: '';
display: inline-block;
width: 4px;
height: 16px;
border-radius: 3px;
background: linear-gradient(180deg, #6c7ae0 0%, #b06be0 100%);
box-shadow: 0 0 10px rgba(108, 122, 224, 0.5);
}
.detail-card-title {
background: linear-gradient(145deg, rgba(52, 52, 86, 0.75) 0%, rgba(36, 36, 60, 0.7) 100%);
border: 1px solid rgba(108, 122, 224, 0.22);
padding-left: 30px;
padding-right: 30px;
}
.detail-title {
font-size: 26px;
font-weight: 700;
font-size: 30px;
font-weight: 800;
line-height: 1.5;
color: #f4f4fa;
letter-spacing: 0.2px;
color: #fafaff;
letter-spacing: 0.3px;
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.35);
}
.detail-title.empty {
color: #6a6a88;
color: #787898;
font-weight: 500;
font-size: 20px;
font-size: 22px;
font-style: italic;
text-shadow: none;
}
.detail-card-meta {
background: rgba(28, 28, 46, 0.7);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.detail-meta {
display: flex;
gap: 20px;
flex-wrap: wrap;
font-size: 13px;
color: #8a8aa8;
margin-top: 14px;
font-size: 13.5px;
color: #9a9abd;
}
.detail-meta span b {
color: #c8c8e0;
font-weight: 500;
font-weight: 600;
margin-right: 6px;
letter-spacing: 0.2px;
}
.detail-tags {
@@ -45,24 +84,30 @@
margin-top: 4px;
}
.detail-card-remark {
background: linear-gradient(145deg, rgba(44, 40, 60, 0.75) 0%, rgba(34, 30, 50, 0.7) 100%);
border: 1px solid rgba(180, 150, 220, 0.16);
}
.detail-remark {
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 16px;
padding: 24px;
color: #dcdceb;
font-size: 15px;
line-height: 1.9;
color: #e4e4f0;
font-size: 15.5px;
line-height: 2;
white-space: pre-wrap;
word-break: break-word;
letter-spacing: 0.2px;
}
.detail-remark.empty {
color: #6a6a88;
color: #787898;
text-align: center;
font-style: italic;
padding: 40px 24px;
padding: 28px 24px;
line-height: 1.8;
}
.detail-card-images {
background: rgba(24, 24, 42, 0.7);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
@@ -74,27 +119,36 @@
border-radius: 14px;
overflow: hidden;
cursor: zoom-in;
background: rgba(0, 0, 0, 0.25);
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.06);
transition: transform 0.25s ease, border-color 0.25s ease;
transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
}
.image-item:hover {
transform: translateY(-3px);
border-color: rgba(108, 122, 224, 0.45);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
}
.image-item:hover { transform: translateY(-2px); border-color: rgba(108, 122, 224, 0.4); }
.image-item img { width: 100%; height: 100%; object-fit: cover; display: block; }
.image-item .name-badge {
position: absolute;
left: 8px; bottom: 8px;
max-width: calc(100% - 16px);
padding: 4px 10px;
padding: 5px 12px;
border-radius: 999px;
font-size: 11px;
background: rgba(0, 0, 0, 0.55);
font-size: 11.5px;
background: rgba(0, 0, 0, 0.65);
color: #fff;
backdrop-filter: blur(4px);
backdrop-filter: blur(6px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.detail-card-files {
background: rgba(24, 24, 42, 0.7);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.file-list {
display: grid;
gap: 10px;
@@ -104,51 +158,58 @@
align-items: center;
gap: 14px;
padding: 14px 18px;
background: rgba(108, 122, 224, 0.06);
border: 1px solid rgba(108, 122, 224, 0.16);
background: rgba(40, 44, 72, 0.5);
border: 1px solid rgba(108, 122, 224, 0.14);
border-radius: 14px;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
cursor: pointer;
}
.file-item:hover {
background: rgba(108, 122, 224, 0.12);
border-color: rgba(108, 122, 224, 0.32);
background: rgba(52, 58, 96, 0.6);
border-color: rgba(108, 122, 224, 0.34);
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
}
.file-icon {
width: 44px; height: 44px;
width: 46px; height: 46px;
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
background: rgba(108, 122, 224, 0.18);
background: linear-gradient(145deg, rgba(108, 122, 224, 0.3) 0%, rgba(160, 110, 224, 0.25) 100%);
flex-shrink: 0;
font-size: 22px;
border: 1px solid rgba(108, 122, 224, 0.25);
}
.file-info { flex: 1; min-width: 0; }
.file-name {
font-size: 14.5px;
font-weight: 500;
color: #e8e8f4;
font-size: 15px;
font-weight: 600;
color: #ececf6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.2px;
}
.file-meta { font-size: 12px; color: #8a8aa8; margin-top: 4px; }
.file-meta { font-size: 12.5px; color: #9090b0; margin-top: 5px; }
.file-download {
padding: 8px 12px;
padding: 9px 14px;
border-radius: 10px;
background: rgba(108, 122, 224, 0.2);
color: #a8b4ff;
font-size: 12px;
background: linear-gradient(135deg, rgba(108, 122, 224, 0.35) 0%, rgba(160, 110, 224, 0.3) 100%);
color: #d0d8ff;
font-size: 12.5px;
font-weight: 600;
border: 1px solid rgba(108, 122, 224, 0.3);
border: 1px solid rgba(108, 122, 224, 0.4);
flex-shrink: 0;
letter-spacing: 0.3px;
}
.file-download:hover {
background: linear-gradient(135deg, rgba(108, 122, 224, 0.5) 0%, rgba(160, 110, 224, 0.45) 100%);
transform: translateY(-1px);
}
.file-download:hover { background: rgba(108, 122, 224, 0.3); }
.lightbox-mask {
position: fixed; inset: 0; z-index: 99999;
background: rgba(0, 0, 0, 0.88);
backdrop-filter: blur(8px);
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(10px);
display: flex; align-items: center; justify-content: center;
padding: 24px;
}
@@ -162,48 +223,55 @@
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 10px;
box-shadow: 0 30px 100px rgba(0,0,0,0.6);
border-radius: 12px;
box-shadow: 0 30px 100px rgba(0,0,0,0.7);
}
.lightbox-close, .lightbox-nav {
position: absolute;
width: 44px; height: 44px;
width: 46px; height: 46px;
border-radius: 50%;
background: rgba(255,255,255,0.08);
background: rgba(255,255,255,0.1);
color: #fff;
font-size: 20px;
display: flex; align-items: center; justify-content: center;
border: 1px solid rgba(255,255,255,0.16);
border: 1px solid rgba(255,255,255,0.2);
cursor: pointer;
backdrop-filter: blur(6px);
backdrop-filter: blur(8px);
transition: background 0.2s ease, transform 0.2s ease;
}
.lightbox-close:hover, .lightbox-nav:hover { background: rgba(255,255,255,0.18); transform: scale(1.06); }
.lightbox-close { top: 16px; right: 16px; }
.lightbox-close:hover, .lightbox-nav:hover { background: rgba(255,255,255,0.22); transform: scale(1.08); }
.lightbox-close { top: 18px; right: 18px; }
.lightbox-nav { top: 50%; transform: translateY(-50%); }
.lightbox-nav:hover { transform: translateY(-50%) scale(1.06); }
.lightbox-nav.prev { left: 16px; }
.lightbox-nav.next { right: 16px; }
.lightbox-nav:hover { transform: translateY(-50%) scale(1.08); }
.lightbox-nav.prev { left: 18px; }
.lightbox-nav.next { right: 18px; }
.lightbox-caption {
position: absolute;
bottom: 20px;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
max-width: 80vw;
padding: 8px 18px;
padding: 10px 22px;
border-radius: 999px;
background: rgba(0,0,0,0.65);
background: rgba(0,0,0,0.7);
color: #fff;
font-size: 13px;
backdrop-filter: blur(6px);
backdrop-filter: blur(8px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: 1px solid rgba(255,255,255,0.1);
}
@media (max-width: 600px) {
.detail-title { font-size: 20px; }
.detail-remark { padding: 18px; font-size: 14.5px; }
.detail-card { padding: 18px 18px; border-radius: 15px; }
.detail-title { font-size: 22px; }
.detail-remark { padding: 0; font-size: 14.5px; line-height: 1.9; }
.detail-remark.empty { padding: 22px 12px; }
.image-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
.lightbox-close, .lightbox-nav { width: 40px; height: 40px; font-size: 18px; }
.file-item { padding: 12px 14px; gap: 12px; }
.file-icon { width: 40px; height: 40px; font-size: 19px; border-radius: 10px; }
.file-name { font-size: 14px; }
.file-download { padding: 7px 11px; font-size: 12px; }
}
@@ -4,9 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>收藏详情 | MilesYang时光助手</title>
<link rel="stylesheet" href="/common/common.css">
<link rel="stylesheet" href="../../shared.css">
<link rel="stylesheet" href="detail.css">
<link rel="stylesheet" href="/common/common.css?v=1.10">
<link rel="stylesheet" href="../../shared.css?v=1.10">
<link rel="stylesheet" href="detail.css?v=1.10">
</head>
<body>
<div class="app-shell">
@@ -43,8 +43,8 @@
<div id="modal-slot"></div>
<div id="interaction-dialog" class="interaction-dialog hidden"></div>
<script src="../../api.js"></script>
<script src="../../kits/common/dialog.js"></script>
<script src="detail.js"></script>
<script src="../../api.js?v=1.10"></script>
<script src="../../kits/common/dialog.js?v=1.10"></script>
<script src="detail.js?v=1.10"></script>
</body>
</html>
@@ -21,6 +21,101 @@
const formatTime = dlg.formatTime
const humanSize = dlg.humanSize
function _scoreCN(str) {
if (!str) return -Infinity
let cjk = 0, asc = 0, ext = 0, weird = 0
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i)
if (c >= 0x4e00 && c <= 0x9fff) cjk++
else if (c >= 0x3400 && c <= 0x4dbf) cjk++
else if (c >= 0x3000 && c <= 0x303f) cjk++
else if (c >= 0xff00 && c <= 0xffef) cjk++
else if (c === 0xb7 || c === 0x2022) cjk++
else if (c >= 0x20 && c <= 0x7e) asc++
else if (c < 0x20 && c !== 9 && c !== 10 && c !== 13) weird++
else if (c >= 0x80 && c <= 0xff) ext++
else if (c >= 0x100 && c < 0x3000) ext++
else ext++
}
return cjk * 3 + asc * 1 - ext * 2 - weird * 10
}
function _decodeBytes(bytes, enc) {
try {
const dec = new TextDecoder(enc, { fatal: false })
const s = dec.decode(new Uint8Array(bytes))
return (s == null ? '' : String(s)).replace(/\u0000/g, '')
} catch { return '' }
}
function fixFileName(raw) {
const name = String(raw == null ? '' : raw).trim()
if (!name) return '未命名文件'
const em = name.match(/(\.[A-Za-z0-9]{1,10})$/)
const ext = em ? em[1] : ''
const base = ext ? name.slice(0, name.length - ext.length) : name
if (!base) return name
const cands = [{ s: name, src: 'origin' }]
const bytes = []
for (let i = 0; i < base.length; i++) {
const c = base.charCodeAt(i)
if (c >= 0xd800 && c <= 0xdbff && i + 1 < base.length) {
const low = base.charCodeAt(i + 1)
if (low >= 0xdc00 && low <= 0xdfff) {
const cp = 0x10000 + (c - 0xd800) * 0x400 + (low - 0xdc00)
bytes.push(0xff & (cp >> 8), 0xff & cp)
i++
continue
}
}
bytes.push(0xff & c)
}
const tryDec = (encList, srcPrefix) => {
encList.forEach(enc => {
try {
const s = _decodeBytes(bytes, enc)
if (s) cands.push({ s: s + ext, src: srcPrefix + '_' + enc })
} catch {}
})
}
if (bytes.length) {
tryDec(['gbk', 'gb18030', 'utf-8', 'big5', 'shift-jis', 'euc-kr'], 'raw')
const rev = bytes.slice().reverse()
const encs2 = ['gbk', 'gb18030']
encs2.forEach(enc => {
try {
const s = _decodeBytes(bytes, enc)
if (s) {
const bytes2 = []
for (let i = 0; i < s.length; i++) bytes2.push(0xff & s.charCodeAt(i))
const s2 = _decodeBytes(bytes2, 'utf-8')
if (s2) cands.push({ s: s2 + ext, src: 'doub_' + enc + '_utf8' })
}
} catch {}
})
}
let best = name, bestSc = _scoreCN(name) - (
/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(name) ? 15 : 0
) - (/[\u4e00-\u9fa5]/.test(name) && /[çèéêëìíîïðñòóôõöøùúûüýþÿÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß]/.test(name) ? 25 : 0)
cands.forEach(c => {
if (!c || !c.s) return
const s = c.s
if (/\uFFFD/.test(s) && c.src !== 'origin') return
let sc = _scoreCN(s)
if (/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s)) sc -= 10
if (sc > bestSc) { bestSc = sc; best = s }
})
return best.length > 255 ? best.slice(0, 250) + (ext || '') : best
}
const q = new URLSearchParams(location.search)
const id = q.get('id')
let current = null
@@ -98,17 +193,17 @@
const filePath = a.filePath || a.file_path
return {
src: a.url || api.fileUrl(filePath),
name: a.fileName || a.file_name || ''
name: fixFileName(a.fileName || a.file_name || '')
}
})
return (
`<section class="detail-section">` +
`<section class="detail-section detail-card detail-card-images">` +
`<div class="detail-section-title">图片</div>` +
`<div class="image-grid">` +
images.map((a, i) => {
const filePath = a.filePath || a.file_path
const src = a.url || api.fileUrl(filePath)
const name = a.fileName || a.file_name || ('image_' + (i+1))
const name = fixFileName(a.fileName || a.file_name || ('image_' + (i+1)))
const badge = (name && name.length > 4)
? `<div class="name-badge">${escapeHtml(name)}</div>` : ''
return `<div class="image-item" data-img-idx="${i}" title="${escapeHtml(name)}"><img src="${src}" alt="${escapeHtml(name)}" loading="lazy">${badge}</div>`
@@ -121,13 +216,13 @@
function renderFileSection(files) {
if (!files || !files.length) return ''
return (
`<section class="detail-section">` +
`<section class="detail-section detail-card detail-card-files">` +
`<div class="detail-section-title">文件 (${files.length})</div>` +
`<div class="file-list">` +
files.map(a => {
const filePath = a.filePath || a.file_path
const src = a.url || api.fileUrl(filePath)
const name = a.fileName || a.file_name || '未命名文件'
const name = fixFileName(a.fileName || a.file_name || '未命名文件')
const size = Number(a.fileSize || a.file_size || 0)
const mimeType = a.mimeType || a.mime_type || ''
return (
@@ -161,15 +256,15 @@
: ''
const remarkBlock = (item.remark || '').trim()
? `<section class="detail-section"><div class="detail-section-title">备注</div><div class="detail-remark">${escapeHtml(item.remark)}</div></section>`
: `<section class="detail-section"><div class="detail-section-title">备注</div><div class="detail-remark empty">(无备注)</div></section>`
? `<section class="detail-section detail-card detail-card-remark"><div class="detail-section-title">备注</div><div class="detail-remark">${escapeHtml(item.remark)}</div></section>`
: `<section class="detail-section detail-card detail-card-remark"><div class="detail-section-title">备注</div><div class="detail-remark empty">(无备注)</div></section>`
const titleBlock = dispTitle
? `<section class="detail-section"><div class="detail-title">${escapeHtml(dispTitle)}</div></section>`
: `<section class="detail-section"><div class="detail-title empty">(该条收藏未设置标题)</div></section>`
? `<section class="detail-section detail-card detail-card-title"><div class="detail-title">${escapeHtml(dispTitle)}</div></section>`
: `<section class="detail-section detail-card detail-card-title"><div class="detail-title empty">(该条收藏未设置标题)</div></section>`
const metaBlock =
`<section class="detail-section">` +
`<section class="detail-section detail-card detail-card-meta">` +
`<div class="detail-meta">` +
`<span><b>创建时间</b>${formatTime(createdAt)}</span>` +
`<span><b>更新时间</b>${formatTime(updatedAt)}</span>` +
@@ -4,9 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>新增 / 编辑收藏 | MilesYang时光助手</title>
<link rel="stylesheet" href="/common/common.css">
<link rel="stylesheet" href="../../shared.css">
<link rel="stylesheet" href="edit.css">
<link rel="stylesheet" href="/common/common.css?v=1.10">
<link rel="stylesheet" href="../../shared.css?v=1.10">
<link rel="stylesheet" href="edit.css?v=1.10">
</head>
<body>
<div class="app-shell">
@@ -70,8 +70,8 @@
<div id="modal-slot"></div>
<div id="interaction-dialog" class="interaction-dialog hidden"></div>
<script src="../../api.js"></script>
<script src="../../kits/common/dialog.js"></script>
<script src="edit.js"></script>
<script src="../../api.js?v=1.10"></script>
<script src="../../kits/common/dialog.js?v=1.10"></script>
<script src="edit.js?v=1.10"></script>
</body>
</html>
@@ -20,6 +20,100 @@
const escapeHtml = dlg.escapeHtml
const humanSize = dlg.humanSize
function _scoreCN(str) {
if (!str) return -Infinity
let cjk = 0, asc = 0, ext = 0, weird = 0
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i)
if (c >= 0x4e00 && c <= 0x9fff) cjk++
else if (c >= 0x3400 && c <= 0x4dbf) cjk++
else if (c >= 0x3000 && c <= 0x303f) cjk++
else if (c >= 0xff00 && c <= 0xffef) cjk++
else if (c === 0xb7 || c === 0x2022) cjk++
else if (c >= 0x20 && c <= 0x7e) asc++
else if (c < 0x20 && c !== 9 && c !== 10 && c !== 13) weird++
else if (c >= 0x80 && c <= 0xff) ext++
else if (c >= 0x100 && c < 0x3000) ext++
else ext++
}
return cjk * 3 + asc * 1 - ext * 2 - weird * 10
}
function _decodeBytes(bytes, enc) {
try {
const dec = new TextDecoder(enc, { fatal: false })
const s = dec.decode(new Uint8Array(bytes))
return (s == null ? '' : String(s)).replace(/\u0000/g, '')
} catch { return '' }
}
function fixFileName(raw) {
const name = String(raw == null ? '' : raw).trim()
if (!name) return '未命名文件'
const em = name.match(/(\.[A-Za-z0-9]{1,10})$/)
const ext = em ? em[1] : ''
const base = ext ? name.slice(0, name.length - ext.length) : name
if (!base) return name
const cands = [{ s: name, src: 'origin' }]
const bytes = []
for (let i = 0; i < base.length; i++) {
const c = base.charCodeAt(i)
if (c >= 0xd800 && c <= 0xdbff && i + 1 < base.length) {
const low = base.charCodeAt(i + 1)
if (low >= 0xdc00 && low <= 0xdfff) {
const cp = 0x10000 + (c - 0xd800) * 0x400 + (low - 0xdc00)
bytes.push(0xff & (cp >> 8), 0xff & cp)
i++
continue
}
}
bytes.push(0xff & c)
}
const tryDec = (encList, srcPrefix) => {
encList.forEach(enc => {
try {
const s = _decodeBytes(bytes, enc)
if (s) cands.push({ s: s + ext, src: srcPrefix + '_' + enc })
} catch {}
})
}
if (bytes.length) {
tryDec(['gbk', 'gb18030', 'utf-8', 'big5', 'shift-jis', 'euc-kr'], 'raw')
const encs2 = ['gbk', 'gb18030']
encs2.forEach(enc => {
try {
const s = _decodeBytes(bytes, enc)
if (s) {
const bytes2 = []
for (let i = 0; i < s.length; i++) bytes2.push(0xff & s.charCodeAt(i))
const s2 = _decodeBytes(bytes2, 'utf-8')
if (s2) cands.push({ s: s2 + ext, src: 'doub_' + enc + '_utf8' })
}
} catch {}
})
}
let best = name, bestSc = _scoreCN(name) - (
/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(name) ? 15 : 0
) - (/[\u4e00-\u9fa5]/.test(name) && /[çèéêëìíîïðñòóôõöøùúûüýþÿÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß]/.test(name) ? 25 : 0)
cands.forEach(c => {
if (!c || !c.s) return
const s = c.s
if (/\uFFFD/.test(s) && c.src !== 'origin') return
let sc = _scoreCN(s)
if (/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s)) sc -= 10
if (sc > bestSc) { bestSc = sc; best = s }
})
return best.length > 255 ? best.slice(0, 250) + (ext || '') : best
}
const q = new URLSearchParams(location.search)
const editId = q.get('id')
const isEdit = !!editId
@@ -81,17 +175,18 @@
const tempId = a.tempId
const isImg = !!a.isImage
const displayUrl = a.url || a.previewUrl || ''
const ext = (a.fileName || '').split('.').pop().toUpperCase()
const fn = fixFileName(a.fileName)
const ext = (fn || '').split('.').pop().toUpperCase()
const statusTextMap = { pending: '等待上传', uploading: '上传中', ok: '上传成功', err: '上传失败' }
const statusText = statusTextMap[a.status] || a.status
const showProgress = a.status === 'uploading'
return (
`<div class="attach-item ${isImg ? 'image' : 'file'}" data-temp="${escapeHtml(tempId)}">` +
(isImg
? `<div class="preview"><img src="${displayUrl}" alt="${escapeHtml(a.fileName || '')}" loading="lazy"></div>`
: `<div class="preview"><span class="big">${fileIcon(a.fileName)}</span><span class="ext">${ext || 'FILE'}</span></div>`) +
? `<div class="preview"><img src="${displayUrl}" alt="${escapeHtml(fn || '')}" loading="lazy"></div>`
: `<div class="preview"><span class="big">${fileIcon(fn)}</span><span class="ext">${ext || 'FILE'}</span></div>`) +
`<div class="attach-info">` +
`<div class="attach-name" title="${escapeHtml(a.fileName || '')}">${escapeHtml(a.fileName || '未命名')}</div>` +
`<div class="attach-name" title="${escapeHtml(fn || '')}">${escapeHtml(fn || '未命名')}</div>` +
`<div class="attach-meta">` +
`<span>${humanSize(a.fileSize)}</span>` +
`<span class="attach-status ${a.status}">${statusText}</span>` +
@@ -212,7 +307,7 @@
let title = ''
if (total === 1) {
const only = atts[0]
const raw = only.fileName || '附件'
const raw = fixFileName(only.fileName || '附件')
const dot = raw.lastIndexOf('.')
const stem = dot > 0 ? raw.slice(0, dot) : raw
if (only.isImage) title = `图片 - ${ts}`
@@ -220,7 +315,7 @@
} else if (ic > 0 && fc === 0) title = `${ic} 张图片 - ${ts}`
else if (fc > 0 && ic === 0) {
if (fc === 1) {
const only = files[0]; const raw = only.fileName || '附件'
const only = files[0]; const raw = fixFileName(only.fileName || '附件')
const dot = raw.lastIndexOf('.'); const stem = dot > 0 ? raw.slice(0, dot) : raw
title = stem || raw
} else title = `${fc} 个文件 - ${ts}`
@@ -230,7 +325,7 @@
if (ic) lines.push(`图片:${ic} 张`)
if (fc) lines.push(`文件:${fc} 个`)
lines.push('', '附件清单:')
atts.slice(0, 9).forEach((a, i) => lines.push(` ${i+1}. ${a.fileName || '未命名'}(${humanSize(a.fileSize)})`))
atts.slice(0, 9).forEach((a, i) => lines.push(` ${i+1}. ${fixFileName(a.fileName || '未命名')}(${humanSize(a.fileSize)})`))
if (atts.length > 9) lines.push(` ... 另有 ${atts.length - 9} 个附件未列出`)
lines.push('', `整理时间:${ts}`)
return { title, remark: lines.join('\n') }
@@ -440,7 +535,7 @@
filePath: a.filePath || a.file_path || '',
mimeType: a.mimeType || a.mime_type || '',
isImage: typeof a.isImage !== 'undefined' ? !!a.isImage : !!a.is_image,
fileName: a.fileName || a.file_name || '未命名',
fileName: fixFileName(a.fileName || a.file_name || '未命名'),
fileSize: Number(a.fileSize || a.file_size || 0),
previewUrl: '',
url: a.url || api.fileUrl(a.filePath || a.file_path || ''),
@@ -4,9 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>微信收藏夹 | MilesYang时光助手</title>
<link rel="stylesheet" href="/common/common.css">
<link rel="stylesheet" href="../../shared.css">
<link rel="stylesheet" href="list.css">
<link rel="stylesheet" href="/common/common.css?v=1.10">
<link rel="stylesheet" href="../../shared.css?v=1.10">
<link rel="stylesheet" href="list.css?v=1.10">
</head>
<body>
<div class="app-shell">
@@ -38,8 +38,8 @@
<div id="modal-slot"></div>
<div id="interaction-dialog" class="interaction-dialog hidden"></div>
<script src="../../api.js"></script>
<script src="../../kits/common/dialog.js"></script>
<script src="list.js"></script>
<script src="../../api.js?v=1.10"></script>
<script src="../../kits/common/dialog.js?v=1.10"></script>
<script src="list.js?v=1.10"></script>
</body>
</html>
@@ -16,6 +16,100 @@
const formatTime = dlg.formatTime
const humanSize = dlg.humanSize
function _scoreCN(str) {
if (!str) return -Infinity
let cjk = 0, asc = 0, ext = 0, weird = 0
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i)
if (c >= 0x4e00 && c <= 0x9fff) cjk++
else if (c >= 0x3400 && c <= 0x4dbf) cjk++
else if (c >= 0x3000 && c <= 0x303f) cjk++
else if (c >= 0xff00 && c <= 0xffef) cjk++
else if (c === 0xb7 || c === 0x2022) cjk++
else if (c >= 0x20 && c <= 0x7e) asc++
else if (c < 0x20 && c !== 9 && c !== 10 && c !== 13) weird++
else if (c >= 0x80 && c <= 0xff) ext++
else if (c >= 0x100 && c < 0x3000) ext++
else ext++
}
return cjk * 3 + asc * 1 - ext * 2 - weird * 10
}
function _decodeBytes(bytes, enc) {
try {
const dec = new TextDecoder(enc, { fatal: false })
const s = dec.decode(new Uint8Array(bytes))
return (s == null ? '' : String(s)).replace(/\u0000/g, '')
} catch { return '' }
}
function fixFileName(raw) {
const name = String(raw == null ? '' : raw).trim()
if (!name) return '未命名文件'
const em = name.match(/(\.[A-Za-z0-9]{1,10})$/)
const ext = em ? em[1] : ''
const base = ext ? name.slice(0, name.length - ext.length) : name
if (!base) return name
const cands = [{ s: name, src: 'origin' }]
const bytes = []
for (let i = 0; i < base.length; i++) {
const c = base.charCodeAt(i)
if (c >= 0xd800 && c <= 0xdbff && i + 1 < base.length) {
const low = base.charCodeAt(i + 1)
if (low >= 0xdc00 && low <= 0xdfff) {
const cp = 0x10000 + (c - 0xd800) * 0x400 + (low - 0xdc00)
bytes.push(0xff & (cp >> 8), 0xff & cp)
i++
continue
}
}
bytes.push(0xff & c)
}
const tryDec = (encList, srcPrefix) => {
encList.forEach(enc => {
try {
const s = _decodeBytes(bytes, enc)
if (s) cands.push({ s: s + ext, src: srcPrefix + '_' + enc })
} catch {}
})
}
if (bytes.length) {
tryDec(['gbk', 'gb18030', 'utf-8', 'big5', 'shift-jis', 'euc-kr'], 'raw')
const encs2 = ['gbk', 'gb18030']
encs2.forEach(enc => {
try {
const s = _decodeBytes(bytes, enc)
if (s) {
const bytes2 = []
for (let i = 0; i < s.length; i++) bytes2.push(0xff & s.charCodeAt(i))
const s2 = _decodeBytes(bytes2, 'utf-8')
if (s2) cands.push({ s: s2 + ext, src: 'doub_' + enc + '_utf8' })
}
} catch {}
})
}
let best = name, bestSc = _scoreCN(name) - (
/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(name) ? 15 : 0
) - (/[\u4e00-\u9fa5]/.test(name) && /[çèéêëìíîïðñòóôõöøùúûüýþÿÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß]/.test(name) ? 25 : 0)
cands.forEach(c => {
if (!c || !c.s) return
const s = c.s
if (/\uFFFD/.test(s) && c.src !== 'origin') return
let sc = _scoreCN(s)
if (/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s)) sc -= 10
if (sc > bestSc) { bestSc = sc; best = s }
})
return best.length > 255 ? best.slice(0, 250) + (ext || '') : best
}
function goBack() { window.location.href = '../../index.html' }
function goAdd() { window.location.href = 'edit.html' }
function goEdit(id) { window.location.href = 'edit.html?id=' + encodeURIComponent(id) }
@@ -43,14 +137,16 @@
const tiles = []
images.slice(0, 4).forEach((a) => {
const url = api.fileUrl(a.url || a.filePath)
tiles.push(`<div class="thumb"><img src="${url}" alt="${escapeHtml(a.fileName || 'image')}" loading="lazy"></div>`)
const fn = fixFileName(a.fileName)
tiles.push(`<div class="thumb"><img src="${url}" alt="${escapeHtml(fn || 'image')}" loading="lazy"></div>`)
})
const fileSlots = Math.max(0, 4 - tiles.length)
files.slice(0, fileSlots).forEach((a) => {
const fn = fixFileName(a.fileName)
tiles.push(
`<div class="thumb file" title="${escapeHtml(a.fileName || '')}">` +
`<span class="icon">${fileIcon(a.fileName)}</span>` +
`<span class="name">${escapeHtml(a.fileName || '未命名')}</span>` +
`<div class="thumb file" title="${escapeHtml(fn || '')}">` +
`<span class="icon">${fileIcon(fn)}</span>` +
`<span class="name">${escapeHtml(fn || '未命名')}</span>` +
`</div>`
)
})
+68
View File
@@ -150,6 +150,22 @@ app.use((req, res, next) => {
})
app.use(express.json({ limit: '50mb' }))
app.use((req, res, next) => {
const oldJson = res.json.bind(res)
res.json = (payload) => {
if (!res.getHeader('Content-Type')) {
res.setHeader('Content-Type', 'application/json; charset=utf-8')
} else {
const cur = String(res.getHeader('Content-Type') || '')
if (cur.indexOf('charset') === -1) {
res.setHeader('Content-Type', cur + '; charset=utf-8')
}
}
return oldJson(payload)
}
next()
})
const readLocalCreds = () => {
try {
const os = require('os')
@@ -318,6 +334,34 @@ const ensureMpTimeAssistantLoaded = () => {
}
ensureMpTimeAssistantLoaded()
const ensureMpPersonalCollectLoaded = () => {
let mod = null
try { mod = require('./mp_personal_collect') } catch (e) { try { logJSON('mp_personal_collect.require.error', { error: String(e.message || e) }, 'mp_personal_collect') } catch {}; setTimeout(ensureMpPersonalCollectLoaded, 2000); return }
try {
if (mod && mod.bindRoutes) {
mod.bindRoutes(app)
try { logJSON('mp_personal_collect.bind_routes.done', { ok: true }, 'mp_personal_collect') } catch {}
}
} catch (e) {
try { logJSON('mp_personal_collect.bind_routes.error', { error: String(e.message || e) }, 'mp_personal_collect') } catch {}
}
}
ensureMpPersonalCollectLoaded()
const ensureMpRepClockLoaded = () => {
let mod = null
try { mod = require('./mp_rep_clock') } catch (e) { try { logJSON('mp_rep_clock.require.error', { error: String(e.message || e) }, 'mp_rep_clock') } catch {}; setTimeout(ensureMpRepClockLoaded, 2000); return }
try {
if (mod && mod.bindRoutes) {
mod.bindRoutes(app)
try { logJSON('mp_rep_clock.bind_routes.done', { ok: true }, 'mp_rep_clock') } catch {}
}
} catch (e) {
try { logJSON('mp_rep_clock.bind_routes.error', { error: String(e.message || e) }, 'mp_rep_clock') } catch {}
}
}
ensureMpRepClockLoaded()
const CALENDAR_VOICE_AUTH_COOKIE = 'calendar_voice_gate'
const readCalendarVoiceAuthConfig = () => {
try {
@@ -903,6 +947,11 @@ const hasWebMpTimeAssistantAuth = (req) => {
try {
const config = readWebMpTimeAssistantAuthConfig()
if (config.enable_auth === false) return true
const ua = String((req && req.headers && (req.headers['user-agent'] || req.headers['User-Agent'])) || '').toLowerCase()
const mpHdr = String((req && req.headers && (req.headers['x-mp-timeassistant'] || req.headers['X-Mp-Timeassistant'])) || '').trim()
if (ua && ua.indexOf('miniprogram') !== -1) return true
if (ua && ua.indexOf('micromessenger') !== -1 && ua.indexOf('wxdebug') !== -1) return true
if (mpHdr && mpHdr.length >= 6) return true
const cookies = parseCookie(req.headers.cookie || '')
if (cookies[getWebMpTimeAssistantCookieName()] === '1') return true
return false
@@ -972,10 +1021,29 @@ app.use('/api/mp_time_assistant', (req, res, next) => {
if (hasWebMpTimeAssistantAuth(req)) return next()
return res.status(401).json({ ok: false, error: 'unauthorized' })
})
app.use('/api/mp_personal_collect', (req, res, next) => {
if (hasWebMpTimeAssistantAuth(req)) return next()
return res.status(401).json({ ok: false, error: 'unauthorized' })
})
app.use('/api/mp_rep_clock', (req, res, next) => {
if (hasWebMpTimeAssistantAuth(req)) return next()
return res.status(401).json({ ok: false, error: 'unauthorized' })
})
app.use('/uploads/mp_time_assistant', (req, res, next) => {
if (hasWebMpTimeAssistantAuth(req)) return next()
return res.status(401).send('未授权')
})
app.use('/uploads/mp_personal_collect', (req, res, next) => {
if (hasWebMpTimeAssistantAuth(req)) return next()
return res.status(401).send('未授权')
})
app.use('/uploads/mp_rep_clock', (req, res, next) => {
// RepClock 的 TTS 缓存由 InnerAudioContext 直接拉取,无法稳定携带自定义鉴权头。
// 这里只对白名单目录放开,其他上传资源仍保持鉴权。
if (req && typeof req.path === 'string' && req.path.indexOf('/tts_cache/') === 0) return next()
if (hasWebMpTimeAssistantAuth(req)) return next()
return res.status(401).send('未授权')
})
const PLANT_HOME_AUTH_COOKIE = 'plant_home_gate'
const readPlantHomeAuthConfig = () => {
+282
View File
@@ -0,0 +1,282 @@
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const multer = require('multer')
const iconv = require('iconv-lite')
const store = require('./store')
function scoreChinese(str) {
if (!str) return -Infinity
let cjk = 0, asciiPrint = 0, ext = 0, weird = 0
const len = str.length || 1
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i)
if (c >= 0x4e00 && c <= 0x9fff) cjk++
else if (c >= 0x3400 && c <= 0x4dbf) cjk++
else if (c >= 0x3000 && c <= 0x303f) cjk++
else if (c >= 0xff00 && c <= 0xffef) cjk++
else if (c === 0xb7 || c === 0x2022) cjk++
else if (c >= 0x20 && c <= 0x7e) asciiPrint++
else if (c < 0x20 && c !== 9 && c !== 10 && c !== 13) weird++
else if (c >= 0x80 && c <= 0xff) ext++
else if (c >= 0x100 && c < 0x3000) ext++
else ext++
}
return cjk * 3 + asciiPrint * 1 - ext * 2 - weird * 10 - (str.length > len * 2 ? 5 : 0)
}
function hasRepeatedMojibake(str) {
if (!str) return false
return (
/[鏃犳爣棰鍒氫綘鍙戠幇浣犲凡缁忎粖澶╂垜浠埌杈剧殑鏄繖涓柟闈㈠鐞嗙殑闂鍦ㄥ綋鍓嶇幆澧冧笅閫氳繃浠ヤ笅鏂瑰紡瑙e喅娉ㄦ剰浜嬮」閫夋嫨鐨勫唴瀹规槸浠€涔堟椂鍊欑殑妗堜緥璇存槑浣跨敤杩欎釜鍔熻兘鏉ユ洿濂界殑鍒╃敤鑷繁鐨勬洿濂界殑鐢熸椿宸ュ叿浣跨敤鎵嬫]/.test(str) ||
(/[\u4e00-\u9fa5]/.test(str) && /[çèéêëìíîïðñòóôõöøùúûüýþÿÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß]/.test(str)) ||
(/[\u4e00-\u9fa5]/.test(str) && /\uFFFD/.test(str))
)
}
function fixFileNameEncoding(rawName) {
const name = String(rawName == null ? '' : rawName).trim()
if (!name) return 'untitled'
const extMatch = name.match(/(\.[A-Za-z0-9]{1,10})$/)
const ext = extMatch ? extMatch[1] : ''
const base = ext ? name.slice(0, name.length - ext.length) : name
if (!base) return name
const candidates = [{ s: name, src: 'origin' }]
const pushDec = (buf, enc, src) => {
try {
if (!iconv.encodingExists(enc)) return
const d = iconv.decode(buf, enc)
if (d && typeof d === 'string') {
candidates.push({ s: d + ext, src })
candidates.push({ s: d.replace(/\u0000/g, '') + ext, src: src + '_nozero' })
}
} catch {}
}
let buf
try { buf = Buffer.from(base, 'latin1') } catch { buf = null }
if (buf && buf.length) {
pushDec(buf, 'gbk', 'lat_gbk')
pushDec(buf, 'gb18030', 'lat_gb18030')
pushDec(buf, 'utf-8', 'lat_utf8')
pushDec(buf, 'big5', 'lat_big5')
pushDec(buf, 'cp936', 'lat_cp936')
}
try { buf = Buffer.from(base, 'binary') } catch { buf = null }
if (buf && buf.length) {
pushDec(buf, 'gbk', 'bin_gbk')
pushDec(buf, 'gb18030', 'bin_gb18030')
pushDec(buf, 'utf-8', 'bin_utf8')
}
const utf8Buf = Buffer.from(base, 'utf-8')
if (utf8Buf && utf8Buf.length) {
pushDec(utf8Buf, 'latin1', 'utf8_lat1')
pushDec(utf8Buf, 'binary', 'utf8_bin')
}
const bytes = []
for (let i = 0; i < base.length; i++) {
const c = base.charCodeAt(i)
if (c >= 0xd800 && c <= 0xdbff && i + 1 < base.length) {
const low = base.charCodeAt(i + 1)
if (low >= 0xdc00 && low <= 0xdfff) {
const cp = 0x10000 + (c - 0xd800) * 0x400 + (low - 0xdc00)
bytes.push(0xff & (cp >> 8), 0xff & cp)
i++
continue
}
}
bytes.push(0xff & c)
}
if (bytes.length) {
const raw = Buffer.from(bytes)
pushDec(raw, 'gbk', 'raw_gbk')
pushDec(raw, 'gb18030', 'raw_gb18030')
pushDec(raw, 'utf-8', 'raw_utf8')
pushDec(raw, 'big5', 'raw_big5')
pushDec(raw, 'shift_jis', 'raw_sjis')
pushDec(raw, 'euc-kr', 'raw_euckr')
if (raw.length >= 2) {
const buf2 = Buffer.alloc(raw.length)
for (let i = 0; i < raw.length; i++) buf2[i] = raw[i]
pushDec(buf2, 'cp936', 'raw_cp936')
}
}
let best = null
let bestScore = -Infinity
for (const cand of candidates) {
if (!cand.s) continue
if (/\uFFFD/.test(cand.s) && cand.src !== 'origin') continue
const s = cand.s.replace(/\u0000/g, '')
if (!s) continue
let sc = scoreChinese(s)
if (cand.src === 'origin' && /[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s)) sc -= 15
if (cand.src === 'origin' && hasRepeatedMojibake(s)) sc -= 30
if (/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s) && cand.src !== 'origin') sc -= 10
if (sc > bestScore) { bestScore = sc; best = s }
}
if (!best) best = name
if (best.length > 255) best = best.slice(0, 250) + (ext || '')
return best
}
const UPLOAD_ROOT = path.join(process.cwd(), 'uploads', 'mp_personal_collect')
const API_PREFIX = '/api/mp_personal_collect/kits/personal_collect'
const createId = () => {
try { return crypto.randomUUID() } catch { return `${Date.now()}_${Math.random().toString(36).slice(2)}` }
}
const ensureDir = dir => {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
}
ensureDir(UPLOAD_ROOT)
const isImageByMime = mime => /^image\//i.test(String(mime || ''))
const isImageByName = name => /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(String(name || ''))
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const groupId = String(req.body && req.body.groupId || createId()).trim()
const dir = path.join(UPLOAD_ROOT, groupId)
ensureDir(dir)
req._uploadGroupId = groupId
cb(null, dir)
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname) || ''
const storedName = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}${ext}`
cb(null, storedName)
},
})
const upload = multer({
storage,
limits: { fileSize: 50 * 1024 * 1024 },
})
const parseTags = raw => {
const s = String(raw || '').trim()
if (!s) return []
return s
.split(/[,,\s]+/)
.map(t => t.trim())
.filter(Boolean)
}
const bindRoutes = app => {
app.get(`${API_PREFIX}/items`, (req, res) => {
try {
const keyword = String(req.query.keyword || '').trim()
res.json({ ok: true, data: store.listItems({ keyword }) })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get(`${API_PREFIX}/items/:id`, (req, res) => {
try {
const id = String(req.params.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const item = store.getItemById(id)
if (!item) return res.status(404).json({ ok: false, error: 'not found' })
res.json({ ok: true, data: item })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post(`${API_PREFIX}/items`, (req, res) => {
try {
const body = req.body || {}
const title = String(body.title || '').trim()
const remark = String(body.remark || '').trim()
if (!title && !remark) return res.status(400).json({ ok: false, error: '标题或备注至少填一个' })
const tags = Array.isArray(body.tags) ? body.tags : parseTags(body.tagsText)
const attachmentIds = Array.isArray(body.attachmentIds) ? body.attachmentIds : []
const saved = store.insertItem({
id: createId(),
title,
remark,
tags,
attachmentIds,
})
return res.json({ ok: true, data: saved })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.put(`${API_PREFIX}/items`, (req, res) => {
try {
const body = req.body || {}
const id = String(body.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const existing = store.getItemById(id)
if (!existing) return res.status(404).json({ ok: false, error: 'not found' })
const title = String(body.title || '').trim()
const remark = String(body.remark || '').trim()
if (!title && !remark) return res.status(400).json({ ok: false, error: '标题或备注至少填一个' })
const tags = Array.isArray(body.tags) ? body.tags : parseTags(body.tagsText)
const attachmentIds = Array.isArray(body.attachmentIds) ? body.attachmentIds : null
const saved = store.updateItem({ id, title, remark, tags, attachmentIds })
return res.json({ ok: true, data: saved })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.delete(`${API_PREFIX}/items`, (req, res) => {
try {
const id = String(req.query.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
store.deleteItem(id)
return res.json({ ok: true })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post(`${API_PREFIX}/upload`, upload.single('file'), (req, res) => {
try {
const f = req.file
if (!f) return res.status(400).json({ ok: false, error: '未接收到文件' })
const groupId = String(req._uploadGroupId || createId())
const relDir = path.join('uploads', 'mp_personal_collect', groupId)
const relPath = path.join(relDir, f.filename).replace(/\\/g, '/')
const urlPath = '/' + relPath
const mimeType = String(f.mimetype || '').trim()
const originalName = fixFileNameEncoding(f.originalname)
const isImage = isImageByMime(mimeType) || isImageByName(originalName)
const saved = store.insertAttachment({
id: createId(),
favoriteId: null,
fileName: originalName,
storedName: f.filename,
filePath: relPath,
fileSize: f.size || 0,
mimeType,
isImage,
})
return res.json({
ok: true,
data: {
...saved,
url: urlPath,
groupId,
},
})
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
}
module.exports = { bindRoutes, API_PREFIX }
+332
View File
@@ -0,0 +1,332 @@
const Database = require('better-sqlite3')
const path = require('path')
const iconv = require('iconv-lite')
function scoreChinese(str) {
if (!str) return -Infinity
let cjk = 0, asciiPrint = 0, ext = 0, weird = 0
const len = str.length || 1
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i)
if (c >= 0x4e00 && c <= 0x9fff) cjk++
else if (c >= 0x3400 && c <= 0x4dbf) cjk++
else if (c >= 0x3000 && c <= 0x303f) cjk++
else if (c >= 0xff00 && c <= 0xffef) cjk++
else if (c === 0xb7 || c === 0x2022) cjk++
else if (c >= 0x20 && c <= 0x7e) asciiPrint++
else if (c < 0x20 && c !== 9 && c !== 10 && c !== 13) weird++
else if (c >= 0x80 && c <= 0xff) ext++
else if (c >= 0x100 && c < 0x3000) ext++
else ext++
}
return cjk * 3 + asciiPrint * 1 - ext * 2 - weird * 10 - (str.length > len * 2 ? 5 : 0)
}
function hasRepeatedMojibake(str) {
if (!str) return false
return /[鏃犳爣棰鍒氫綘鍙戠幇浣犲凡缁忎粖澶╂垜浠埌杈剧殑鏄繖涓柟闈㈠鐞嗙殑闂鍦ㄥ綋鍓嶇幆澧冧笅閫氳繃浠ヤ笅鏂瑰紡瑙e喅娉ㄦ剰浜嬮」閫夋嫨鐨勫唴瀹规槸浠€涔堟椂鍊欑殑妗堜緥璇存槑浣跨敤杩欎釜鍔熻兘鏉ユ洿濂界殑鍒╃敤鑷繁鐨勬洿濂界殑鐢熸椿宸ュ叿浣跨敤鎵嬫]/.test(str) ||
(/[\u4e00-\u9fa5]{2}/.test(str) && /[çèéêëìíîïðñòóôõöøùúûüýþÿÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß]/.test(str)) ||
(/[\u4e00-\u9fa5]/.test(str) && /\uFFFD/.test(str))
}
function fixFileNameEncoding(rawName) {
const name = String(rawName == null ? '' : rawName).trim()
if (!name) return 'untitled'
const extMatch = name.match(/(\.[A-Za-z0-9]{1,10})$/)
const ext = extMatch ? extMatch[1] : ''
const base = ext ? name.slice(0, name.length - ext.length) : name
if (!base) return name
const candidates = [{ s: name, src: 'origin' }]
const pushDec = (buf, enc, src) => {
try {
if (!iconv.encodingExists(enc)) return
const d = iconv.decode(buf, enc)
if (d && typeof d === 'string') {
candidates.push({ s: d + ext, src })
candidates.push({ s: d.replace(/\u0000/g, '') + ext, src: src + '_nozero' })
}
} catch {}
}
let buf
try { buf = Buffer.from(base, 'latin1') } catch { buf = null }
if (buf && buf.length) {
pushDec(buf, 'gbk', 'lat_gbk')
pushDec(buf, 'gb18030', 'lat_gb18030')
pushDec(buf, 'utf-8', 'lat_utf8')
pushDec(buf, 'big5', 'lat_big5')
pushDec(buf, 'cp936', 'lat_cp936')
}
try { buf = Buffer.from(base, 'binary') } catch { buf = null }
if (buf && buf.length) {
pushDec(buf, 'gbk', 'bin_gbk')
pushDec(buf, 'gb18030', 'bin_gb18030')
pushDec(buf, 'utf-8', 'bin_utf8')
}
const utf8Buf = Buffer.from(base, 'utf-8')
if (utf8Buf && utf8Buf.length) {
pushDec(utf8Buf, 'latin1', 'utf8_lat1')
pushDec(utf8Buf, 'binary', 'utf8_bin')
}
const bytes = []
for (let i = 0; i < base.length; i++) {
const c = base.charCodeAt(i)
if (c >= 0xd800 && c <= 0xdbff && i + 1 < base.length) {
const low = base.charCodeAt(i + 1)
if (low >= 0xdc00 && low <= 0xdfff) {
const cp = 0x10000 + (c - 0xd800) * 0x400 + (low - 0xdc00)
bytes.push(0xff & (cp >> 8), 0xff & cp)
i++
continue
}
}
bytes.push(0xff & c)
}
if (bytes.length) {
const raw = Buffer.from(bytes)
pushDec(raw, 'gbk', 'raw_gbk')
pushDec(raw, 'gb18030', 'raw_gb18030')
pushDec(raw, 'utf-8', 'raw_utf8')
pushDec(raw, 'big5', 'raw_big5')
pushDec(raw, 'shift_jis', 'raw_sjis')
pushDec(raw, 'euc-kr', 'raw_euckr')
if (raw.length >= 2) {
const buf2 = Buffer.alloc(raw.length)
for (let i = 0; i < raw.length; i++) buf2[i] = raw[i]
pushDec(buf2, 'cp936', 'raw_cp936')
}
}
let best = null
let bestScore = -Infinity
for (const cand of candidates) {
if (!cand.s) continue
if (/\uFFFD/.test(cand.s) && cand.src !== 'origin') continue
const s = cand.s.replace(/\u0000/g, '')
if (!s) continue
let sc = scoreChinese(s)
if (cand.src === 'origin' && /[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s)) sc -= 15
if (cand.src === 'origin' && hasRepeatedMojibake(s)) sc -= 30
if (/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s) && cand.src !== 'origin') sc -= 10
if (sc > bestScore) { bestScore = sc; best = s }
}
if (!best) best = name
if (best.length > 255) best = best.slice(0, 250) + (ext || '')
return best
}
const DB_PATH = path.join(process.cwd(), 'data', 'mp_personal_collect.db')
let db = null
const nowIso = () => new Date().toISOString()
const getDb = () => {
if (db) return db
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.exec(`CREATE TABLE IF NOT EXISTS favorites (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
tags_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0
);`)
db.exec(`CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
favorite_id TEXT,
file_name TEXT NOT NULL,
stored_name TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER NOT NULL DEFAULT 0,
mime_type TEXT NOT NULL DEFAULT '',
is_image INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_mp_collect_favorites_updated ON favorites(updated_at);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_mp_collect_attachments_favorite ON attachments(favorite_id);`)
return db
}
const safeParseJson = (raw, fallback) => {
try { return raw ? JSON.parse(raw) : fallback } catch { return fallback }
}
const mapFavorite = row => ({
id: row.id,
title: row.title,
remark: row.remark,
tags: safeParseJson(row.tags_json, []),
createdAt: row.created_at,
updatedAt: row.updated_at,
created_at: row.created_at,
updated_at: row.updated_at,
})
const mapAttachment = row => {
const original = String(row && row.file_name != null ? row.file_name : '')
const fixed = fixFileNameEncoding(original)
if (original && fixed && original !== fixed) {
try {
getDb().prepare(`UPDATE attachments SET file_name = ? WHERE id = ? AND file_name = ?`)
.run(fixed, row.id, original)
} catch {}
}
return {
id: row.id,
favoriteId: row.favorite_id,
favorite_id: row.favorite_id,
fileName: fixed,
file_name: fixed,
storedName: row.stored_name,
stored_name: row.stored_name,
filePath: row.file_path,
file_path: row.file_path,
fileSize: Number(row.file_size || 0),
file_size: Number(row.file_size || 0),
mimeType: row.mime_type,
mime_type: row.mime_type,
isImage: !!row.is_image,
is_image: !!row.is_image,
createdAt: row.created_at,
created_at: row.created_at,
}
}
const listItems = ({ keyword = '' } = {}) => {
const kw = String(keyword || '').trim()
const rows = kw
? getDb().prepare(`
SELECT * FROM favorites
WHERE deleted = 0
AND (title LIKE @kw OR remark LIKE @kw OR tags_json LIKE @kw)
ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
`).all({ kw: `%${kw}%` })
: getDb().prepare(`
SELECT * FROM favorites
WHERE deleted = 0
ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
`).all()
return rows.map(row => {
const item = mapFavorite(row)
item.attachments = getAttachmentsByFavoriteId(row.id)
return item
})
}
const getItemById = id => {
const row = getDb().prepare(`SELECT * FROM favorites WHERE id = ? AND deleted = 0 LIMIT 1`).get(id)
if (!row) return null
const item = mapFavorite(row)
item.attachments = getAttachmentsByFavoriteId(row.id)
return item
}
const insertItem = item => {
const createdAt = nowIso()
const payload = {
id: item.id,
title: String(item.title || '').trim(),
remark: String(item.remark || '').trim(),
tags_json: JSON.stringify(Array.isArray(item.tags) ? item.tags : []),
created_at: createdAt,
updated_at: createdAt,
}
getDb().prepare(`
INSERT INTO favorites (id, title, remark, tags_json, created_at, updated_at, deleted)
VALUES (@id, @title, @remark, @tags_json, @created_at, @updated_at, 0)
`).run(payload)
if (Array.isArray(item.attachmentIds) && item.attachmentIds.length) {
bindAttachmentsToFavorite(payload.id, item.attachmentIds)
}
return getItemById(payload.id)
}
const updateItem = item => {
const payload = {
id: item.id,
title: String(item.title || '').trim(),
remark: String(item.remark || '').trim(),
tags_json: JSON.stringify(Array.isArray(item.tags) ? item.tags : []),
updated_at: nowIso(),
}
getDb().prepare(`
UPDATE favorites
SET title = @title,
remark = @remark,
tags_json = @tags_json,
updated_at = @updated_at
WHERE id = @id AND deleted = 0
`).run(payload)
if (Array.isArray(item.attachmentIds)) {
getDb().prepare(`DELETE FROM attachments WHERE favorite_id = ?`).run(payload.id)
bindAttachmentsToFavorite(payload.id, item.attachmentIds)
}
return getItemById(payload.id)
}
const deleteItem = id => {
getDb().prepare(`UPDATE favorites SET deleted = 1, updated_at = ? WHERE id = ? AND deleted = 0`).run(nowIso(), id)
}
const insertAttachment = ({ id, favoriteId, fileName, storedName, filePath, fileSize, mimeType, isImage }) => {
const createdAt = nowIso()
const payload = {
id,
favorite_id: favoriteId || null,
file_name: String(fileName || '').trim() || 'untitled',
stored_name: String(storedName || '').trim(),
file_path: String(filePath || '').trim(),
file_size: Math.max(0, Number(fileSize || 0)),
mime_type: String(mimeType || '').trim(),
is_image: isImage ? 1 : 0,
created_at: createdAt,
}
getDb().prepare(`
INSERT INTO attachments (id, favorite_id, file_name, stored_name, file_path, file_size, mime_type, is_image, created_at)
VALUES (@id, @favorite_id, @file_name, @stored_name, @file_path, @file_size, @mime_type, @is_image, @created_at)
`).run(payload)
return getAttachmentById(id)
}
const getAttachmentById = id => {
const row = getDb().prepare(`SELECT * FROM attachments WHERE id = ? LIMIT 1`).get(id)
return row ? mapAttachment(row) : null
}
const getAttachmentsByFavoriteId = favoriteId => {
const rows = getDb().prepare(`SELECT * FROM attachments WHERE favorite_id = ? ORDER BY datetime(created_at) ASC`).all(favoriteId)
return rows.map(mapAttachment)
}
const bindAttachmentsToFavorite = (favoriteId, attachmentIds) => {
const stmt = getDb().prepare(`UPDATE attachments SET favorite_id = ? WHERE id = ?`)
const tx = getDb().transaction(ids => {
for (const aid of ids) {
if (aid) stmt.run(favoriteId, String(aid))
}
})
tx(attachmentIds)
}
module.exports = {
listItems,
getItemById,
insertItem,
updateItem,
deleteItem,
insertAttachment,
getAttachmentById,
getAttachmentsByFavoriteId,
getDb,
nowIso,
}
+94
View File
@@ -0,0 +1,94 @@
const Database = require('better-sqlite3')
const path = require('path')
const DB_PATH = path.join(process.cwd(), 'data', 'mp_rep_clock.db')
let db = null
const nowIso = () => new Date().toISOString()
const getDb = () => {
if (db) return db
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.exec(`CREATE TABLE IF NOT EXISTS rc_cards (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
steps_json TEXT NOT NULL DEFAULT '[]',
loop_mode TEXT NOT NULL DEFAULT 'sequential_once',
loop_count INTEGER,
enable_countdown_announce INTEGER NOT NULL DEFAULT 1,
announce_lead_seconds TEXT NOT NULL DEFAULT '5,3',
voice TEXT NOT NULL DEFAULT 'female_01',
tts_ready INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0
);`)
try { db.prepare(`ALTER TABLE rc_cards ADD COLUMN voice TEXT NOT NULL DEFAULT 'female_01'`).run() } catch (_) {}
try { db.prepare(`ALTER TABLE rc_cards ADD COLUMN tts_ready INTEGER NOT NULL DEFAULT 0`).run() } catch (_) {}
db.exec(`CREATE TABLE IF NOT EXISTS rc_sessions (
id TEXT PRIMARY KEY,
card_id TEXT,
card_snapshot_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'running',
current_loop INTEGER NOT NULL DEFAULT 0,
completed_loops INTEGER NOT NULL DEFAULT 0,
current_step_index INTEGER NOT NULL DEFAULT 0,
started_at TEXT NOT NULL,
ended_at TEXT,
manual_stopped INTEGER NOT NULL DEFAULT 0,
planned_duration_sec INTEGER NOT NULL DEFAULT 0,
actual_duration_sec INTEGER NOT NULL DEFAULT 0,
completion_ratio REAL NOT NULL DEFAULT 0,
paused_total_ms INTEGER NOT NULL DEFAULT 0,
last_paused_at TEXT,
voice_ok_count INTEGER NOT NULL DEFAULT 0,
device_os TEXT,
client_version TEXT,
announce_mode TEXT NOT NULL DEFAULT 'default',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0
);`)
db.exec(`CREATE TABLE IF NOT EXISTS rc_global_lock (
id INTEGER PRIMARY KEY CHECK (id = 1),
running_session_id TEXT,
running_kit TEXT NOT NULL DEFAULT 'rep_clock',
updated_at TEXT NOT NULL
);`)
db.exec(`CREATE TABLE IF NOT EXISTS rc_tts_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
md5 TEXT UNIQUE NOT NULL,
text TEXT NOT NULL,
voice TEXT NOT NULL DEFAULT 'female_01',
url TEXT NOT NULL,
duration_ms INTEGER,
created_at TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0
);`)
db.exec(`CREATE TABLE IF NOT EXISTS rc_meta (
k TEXT PRIMARY KEY,
v TEXT NOT NULL
);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_rc_cards_updated ON rc_cards(updated_at);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_rc_sessions_card ON rc_sessions(card_id);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_rc_sessions_created ON rc_sessions(created_at);`)
try {
db.prepare(`INSERT OR IGNORE INTO rc_global_lock (id, running_session_id, updated_at) VALUES (1, NULL, ?)`).run(nowIso())
} catch {}
try {
const stmt = db.prepare(`INSERT OR IGNORE INTO rc_meta (k, v) VALUES (?, ?)`)
stmt.run('schema_version', '1')
stmt.run('created_at', nowIso())
stmt.run('product_name', 'RepClock')
stmt.run('product_name_zh', '运动节拍')
} catch {}
return db
}
const safeParseJson = (raw, fallback) => {
try { return raw ? JSON.parse(raw) : fallback } catch { return fallback }
}
module.exports = { getDb, nowIso, safeParseJson }
+35
View File
@@ -0,0 +1,35 @@
const { bindRoutes: bindRepClockRoutes } = require('./kits/rep_clock')
const locks = require('./locks')
const { getDb } = require('./db')
const bindRoutes = (app) => {
getDb()
app.get('/api/mp_rep_clock/meta', (req, res) => {
res.json({
ok: true,
appName: 'RepClock',
appNameZh: '运动节拍',
productLine: '时光助手子集',
kits: [
{
id: 'rep_clock',
name: '运动节拍 RepClock',
description: 'HIIT/Yoga/拉伸等节奏型运动倒计时训练,独立运行锁,软删除可追溯。',
},
],
})
})
app.get('/api/mp_rep_clock/state', (req, res) => {
try {
return res.json({ ok: true, lock: locks.getLockState() })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) })
}
})
bindRepClockRoutes(app)
}
module.exports = { bindRoutes }
@@ -0,0 +1,174 @@
const locks = require('../../locks')
const tts = require('../../tts')
const store = require('./store')
const API_PREFIX = '/api/mp_rep_clock/kits/rep_clock'
const requireBody = (req, res) => (req.body || {})
const bindRoutes = (app) => {
app.get(`${API_PREFIX}/cards`, (req, res) => {
try {
const list = store.listCards()
return res.json({ ok: true, cards: list, data: list })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.get(`${API_PREFIX}/cards/:id`, (req, res) => {
try {
const id = String(req.params.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const card = store.getCardById(id)
if (!card) return res.status(404).json({ ok: false, error: 'not found' })
return res.json({ ok: true, card, data: card })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.post(`${API_PREFIX}/cards`, async (req, res) => {
try {
const card = store.insertCard(requireBody(req))
const voice = String(req.body && req.body.voice || tts.VOICE_ID_DEFAULT || 'female_01')
tts.synthesizeBatch(card, voice)
.then(r => {
if (r && r.ok && Number(r.success || 0) > 0 && Number(r.success || 0) === Number(r.total || 0)) {
try { store.markCardTtsReady(card.id) } catch (_) {}
} else {
try { console.warn('[rep_clock tts] 卡片合成未完成,仍标记为合成中', card.id, r && { total: r.total, success: r.success, error: r.error }) } catch (_) {}
}
})
.catch(e => { try { console.warn('[rep_clock tts] 卡片合成异常', card.id, String(e && e.message || e)) } catch (_) {} })
return res.json({ ok: true, card, data: card })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.put(`${API_PREFIX}/cards`, async (req, res) => {
try {
const card = store.updateCard(requireBody(req))
if (!card) return res.status(404).json({ ok: false, error: 'not found' })
const voice = String(req.body && req.body.voice || tts.VOICE_ID_DEFAULT || 'female_01')
tts.synthesizeBatch(card, voice)
.then(r => {
if (r && r.ok && Number(r.success || 0) > 0 && Number(r.success || 0) === Number(r.total || 0)) {
try { store.markCardTtsReady(card.id) } catch (_) {}
} else {
try { console.warn('[rep_clock tts] 卡片合成未完成,仍标记为合成中', card.id, r && { total: r.total, success: r.success, error: r.error }) } catch (_) {}
}
})
.catch(e => { try { console.warn('[rep_clock tts] 卡片合成异常', card.id, String(e && e.message || e)) } catch (_) {} })
return res.json({ ok: true, card, data: card })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.delete(`${API_PREFIX}/cards`, (req, res) => {
try {
const id = String((req.query && req.query.id) || (req.body && req.body.id) || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
store.deleteCard(id)
return res.json({ ok: true })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.get(`${API_PREFIX}/history`, (req, res) => {
try {
const limit = Math.max(1, Math.min(1000, Number(req.query && req.query.limit) || 200))
const list = store.listHistory({ limit })
return res.json({ ok: true, sessions: list, data: list })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.post(`${API_PREFIX}/sessions/start`, (req, res) => {
try {
const body = requireBody(req)
const sessionId = store.createSessionId()
const r = locks.reserveRunningLock(sessionId)
if (!r.ok) return res.status(409).json({
ok: false, error: r.error,
conflictSessionId: r.conflictSessionId, conflict_session_id: r.conflictSessionId,
expiresInMs: r.expiresInMs, expires_in_ms: r.expiresInMs,
expiredLockCleared: r.expiredLockCleared, expired_lock_cleared: r.expiredLockCleared,
})
let session = null
try {
session = store.createSession(Object.assign({}, body, { id: sessionId }))
} catch (e) {
try { locks.releaseRunningLock(sessionId) } catch (_) {}
throw e
}
if (!session || !session.id) {
try { locks.releaseRunningLock(sessionId) } catch (_) {}
return res.status(500).json({ ok: false, error: 'create session failed' })
}
return res.json({ ok: true, session, data: session, lock: r })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.post(`${API_PREFIX}/sessions/sync`, (req, res) => {
try {
const body = requireBody(req)
const id = String(body.sessionId || body.session_id || body.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing sessionId' })
const t = locks.touchRunningLock(id)
if (!t.ok) return res.status(409).json({
ok: false, error: t.error, owner: t.owner,
conflictSessionId: t.conflictSessionId || t.owner, conflict_session_id: t.conflictSessionId || t.owner,
expiresInMs: t.expiresInMs, expires_in_ms: t.expiresInMs,
})
const patched = store.syncSession(id, body)
return res.json({ ok: true, session: patched, data: patched, lock: t })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.post(`${API_PREFIX}/sessions/finish`, (req, res) => {
try {
const body = requireBody(req)
const id = String(body.sessionId || body.session_id || body.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing sessionId' })
locks.releaseRunningLock(id)
const final = store.finishSession(id, { status: body.status, manualStopped: !!body.manualStopped || !!body.manual_stopped })
return res.json({ ok: true, session: final, data: final })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.post(`${API_PREFIX}/state/reset-running`, (req, res) => {
try {
const id = String((req.body && req.body.sessionId) || (req.body && req.body.session_id) || (req.body && req.body.id) || '').trim()
locks.resetRunningLock()
return res.json({ ok: true, clearedSessionId: id || '*' })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.get(`${API_PREFIX}/tts/preview`, async (req, res) => {
try {
const text = String(req.query && req.query.text || '').trim()
if (!text) return res.status(400).json({ ok: false, error: 'missing text' })
const voice = String(req.query && req.query.voice || 'female_01').trim() || 'female_01'
const r = await tts.synthesize(text, voice)
if (!r.ok) return res.status(500).json({ ok: false, error: r.error })
return res.json({ ok: true, data: { text, url: r.url, cached: !!r.cached, durationMs: r.durationMs } })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.post(`${API_PREFIX}/tts/batch`, async (req, res) => {
try {
const id = String((req.body && req.body.cardId) || (req.body && req.body.card_id) || '').trim()
const card = id ? store.getCardById(id) : (req.body && req.body.card ? store.sanitizeCard(req.body.card) : null)
if (!card) return res.status(404).json({ ok: false, error: 'card not found' })
const voice = String(req.body && req.body.voice || 'female_01').trim() || 'female_01'
console.log('[rep_clock tts] POST /tts/batch 请求 cardId=', id, 'voice=', voice)
const t0 = Date.now()
const r = await tts.synthesizeBatch(card, voice)
const items = Array.isArray(r && r.results) ? r.results : []
let marked = false
if (r && r.ok && Number(r.success || 0) > 0 && Number(r.success || 0) === Number(r.total || 0) && id) {
try { store.markCardTtsReady(id); marked = true } catch (_) {}
}
console.log('[rep_clock tts] POST /tts/batch 完成 cardId=', id, 'success=', r && r.success, '/', r && r.total, 'ok=', r && r.ok, 'markCardTtsReady=', marked, '耗时', (Date.now() - t0) + 'ms')
return res.json({ ok: true, data: { total: r && r.total, success: r && r.success, items, results: items } })
} catch (e) {
console.log('[rep_clock tts] POST /tts/batch 异常:', String(e && e.message ? e.message : e))
return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) })
}
})
}
module.exports = { bindRoutes, API_PREFIX }
@@ -0,0 +1,340 @@
const crypto = require('crypto')
const { getDb, nowIso, safeParseJson } = require('../../db')
const createCardId = () => `rc_card_${crypto.randomUUID ? crypto.randomUUID() : Date.now() + '_' + Math.random().toString(36).slice(2)}`
const createSessionId = () => `rc_session_${crypto.randomUUID ? crypto.randomUUID() : Date.now() + '_' + Math.random().toString(36).slice(2)}`
const sanitizeSteps = steps => {
const arr = Array.isArray(steps) ? steps : []
return arr.map((raw, idx) => {
const s = raw || {}
const dur = Number(s.durationSec)
const isRest = String(s.type || 'action').toLowerCase() === 'rest'
const type = isRest ? 'rest' : 'action'
const minSec = isRest ? 0 : 1
let durationSec = Number.isFinite(dur) ? Math.floor(dur) : (isRest ? 10 : 30)
durationSec = Math.max(minSec, Math.min(3600, durationSec))
let restSec = Number(s.restSec)
restSec = Number.isFinite(restSec) ? Math.max(0, Math.min(3600, Math.floor(restSec))) : 0
const announceMode = ['default', 'custom_only', 'custom_prefix'].includes(String(s.announceMode || '')) ? String(s.announceMode) : 'default'
return {
id: String(s.id || `step_${Date.now()}_${idx}`),
type,
name: String(s.name || `步骤${idx + 1}`).trim() || `步骤${idx + 1}`,
durationSec,
restSec,
voiceText: String(s.voiceText || '').trim(),
announceMode,
}
})
}
const sanitizeLoopMode = (mode, countRaw) => {
const m = String(mode || 'sequential_once').toLowerCase()
const c = Math.max(1, Number(countRaw) || 1)
if (m === 'loop_till_stop' || m === 'repeat_forever') return { loopMode: 'loop_till_stop', loopCount: null }
if (m === 'loop_n_times' || m === 'repeat_count') return { loopMode: 'loop_n_times', loopCount: c }
return { loopMode: 'sequential_once', loopCount: null }
}
const sanitizeCard = payload => {
const p = payload || {}
const title = String(p.title || '').trim() || '未命名训练卡'
const steps = sanitizeSteps(p.steps)
const loopCountRaw = p.loopCount != null ? p.loopCount : (p.loop_count != null ? p.loop_count : (p.loopTimes != null ? p.loopTimes : p.loop_times))
const { loopMode, loopCount } = sanitizeLoopMode(p.loopMode || p.loop_mode, loopCountRaw)
const enableCountdownAnnounce = p.enableCountdownAnnounce === undefined ? true : !!p.enableCountdownAnnounce
const leadRaw = String(p.announceLeadSeconds || '5,3').trim() || '5,3'
const announceLeadSeconds = Array.from(new Set(leadRaw.split(/[,,\s]+/).map(x => Number(x)).filter(n => Number.isFinite(n) && n > 0 && n <= 300))).map(n => Math.floor(n)).join(',') || '5,3'
const voice = ['female_01', 'female_02', 'male_01', 'male_02'].includes(String(p.voice || '')) ? String(p.voice) : 'female_01'
const ttsReady = p.ttsReady === undefined ? 0 : (!!p.ttsReady ? 1 : 0)
return {
title,
steps,
loopMode,
loopCount,
enableCountdownAnnounce: enableCountdownAnnounce ? 1 : 0,
announceLeadSeconds,
voice,
ttsReady,
}
}
const mapCard = row => {
const steps = safeParseJson(row.steps_json, [])
return {
id: row.id,
title: row.title,
steps,
loopMode: row.loop_mode,
loopCount: row.loop_count,
loopTimes: row.loop_count,
loop_mode: row.loop_mode,
loop_count: row.loop_count,
loop_times: row.loop_count,
enableCountdownAnnounce: !!row.enable_countdown_announce,
enable_countdown_announce: !!row.enable_countdown_announce,
announceLeadSeconds: row.announce_lead_seconds,
announce_lead_seconds: row.announce_lead_seconds,
voice: row.voice || 'female_01',
ttsReady: !!row.tts_ready,
tts_ready: !!row.tts_ready,
createdAt: row.created_at,
updatedAt: row.updated_at,
created_at: row.created_at,
updated_at: row.updated_at,
deleted: !!row.deleted,
}
}
const mapSession = row => {
const snapshot = safeParseJson(row.card_snapshot_json, {})
const cardTitle = String(snapshot && snapshot.title || '').trim() || '未命名卡片'
return {
id: row.id,
cardId: row.card_id,
card_id: row.card_id,
cardSnapshot: snapshot,
card_snapshot: snapshot,
cardTitle,
card_title: cardTitle,
status: row.status,
completed: row.status === 'completed',
aborted: row.status === 'aborted',
currentLoop: row.current_loop,
current_loop: row.current_loop,
completedLoops: row.completed_loops,
completed_loops: row.completed_loops,
currentStepIndex: row.current_step_index,
current_step_index: row.current_step_index,
startedAt: row.started_at,
started_at: row.started_at,
endedAt: row.ended_at,
ended_at: row.ended_at,
manualStopped: !!row.manual_stopped,
manual_stopped: !!row.manual_stopped,
plannedDurationSec: row.planned_duration_sec,
planned_duration_sec: row.planned_duration_sec,
actualDurationSec: row.actual_duration_sec,
actual_duration_sec: row.actual_duration_sec,
completionRatio: Number(row.completion_ratio || 0),
completion_ratio: Number(row.completion_ratio || 0),
pausedTotalMs: row.paused_total_ms,
paused_total_ms: row.paused_total_ms,
lastPausedAt: row.last_paused_at,
last_paused_at: row.last_paused_at,
voiceOkCount: row.voice_ok_count,
voice_ok_count: row.voice_ok_count,
deviceOs: row.device_os,
device_os: row.device_os,
clientVersion: row.client_version,
client_version: row.client_version,
announceMode: row.announce_mode,
announce_mode: row.announce_mode,
createdAt: row.created_at,
updatedAt: row.updated_at,
created_at: row.created_at,
updated_at: row.updated_at,
deleted: !!row.deleted,
}
}
const listCards = () => {
const rows = getDb().prepare(`
SELECT * FROM rc_cards
WHERE deleted = 0
ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
`).all()
return rows.map(mapCard)
}
const getCardById = id => {
const row = getDb().prepare(`SELECT * FROM rc_cards WHERE id = ? AND deleted = 0 LIMIT 1`).get(String(id || ''))
return row ? mapCard(row) : null
}
const insertCard = raw => {
const { title, steps, loopMode, loopCount, enableCountdownAnnounce, announceLeadSeconds, voice, ttsReady } = sanitizeCard(raw)
const id = createCardId()
const ts = nowIso()
getDb().prepare(`
INSERT INTO rc_cards (id, title, steps_json, loop_mode, loop_count, enable_countdown_announce, announce_lead_seconds, voice, tts_ready, created_at, updated_at, deleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
`).run(id, title, JSON.stringify(steps), loopMode, loopCount, enableCountdownAnnounce, announceLeadSeconds, voice, ttsReady, ts, ts)
return getCardById(id)
}
const updateCard = raw => {
const id = String(raw && raw.id || '').trim()
if (!id) return null
const existing = getCardById(id)
if (!existing) return null
const { title, steps, loopMode, loopCount, enableCountdownAnnounce, announceLeadSeconds, voice } = sanitizeCard(raw)
getDb().prepare(`
UPDATE rc_cards SET
title = ?,
steps_json = ?,
loop_mode = ?,
loop_count = ?,
enable_countdown_announce = ?,
announce_lead_seconds = ?,
voice = ?,
tts_ready = 0,
updated_at = ?
WHERE id = ? AND deleted = 0
`).run(title, JSON.stringify(steps), loopMode, loopCount, enableCountdownAnnounce, announceLeadSeconds, voice, nowIso(), id)
return getCardById(id)
}
const deleteCard = id => {
getDb().prepare(`UPDATE rc_cards SET deleted = 1, updated_at = ? WHERE id = ? AND deleted = 0`).run(nowIso(), String(id || ''))
return { ok: true }
}
const markCardTtsReady = id => {
const sid = String(id || '').trim()
if (!sid) return { ok: false, error: 'missing id' }
getDb().prepare(`UPDATE rc_cards SET tts_ready = 1, updated_at = ? WHERE id = ? AND deleted = 0`).run(nowIso(), sid)
return { ok: true }
}
const calculatePlannedDuration = (card, effectiveLoopMode, effectiveLoopCount) => {
if (!card || !Array.isArray(card.steps)) return 0
const mode = effectiveLoopMode || card.loopMode || 'sequential_once'
const count = effectiveLoopCount != null ? effectiveLoopCount : card.loopCount
const roundSeconds = card.steps.reduce((s, step) => s + Math.max(0, Number(step && step.durationSec || 0)) + Math.max(0, Number(step && step.restSec || 0)), 0)
if ((mode === 'loop_n_times' || mode === 'repeat_count') && Number.isFinite(count) && count > 0) return roundSeconds * Math.floor(count)
if (mode === 'loop_till_stop' || mode === 'repeat_forever') return roundSeconds * 10
return roundSeconds
}
const createSession = (opts = {}) => {
const cardIdRaw = String(opts.cardId || opts.card_id || '').trim() || null
const card = cardIdRaw ? getCardById(cardIdRaw) : null
const overrideSteps = Array.isArray(opts.overrideSteps) ? opts.overrideSteps : null
const finalSteps = overrideSteps || (card ? card.steps : [])
const effectiveLoopMode = String(opts.loopMode || opts.loop_mode || (card ? card.loopMode : 'sequential_once')).toLowerCase()
const effectiveLoopCountRaw = opts.loopCount != null ? opts.loopCount : (opts.loop_count != null ? opts.loop_count : (opts.loopTimes != null ? opts.loopTimes : opts.loop_times))
const effectiveLoopCount = effectiveLoopCountRaw != null ? Math.max(1, Number(effectiveLoopCountRaw) || 1) : (card ? (card.loopCount || null) : null)
const cardSnapshot = {
title: opts.title || (card ? card.title : '自由训练'),
steps: finalSteps,
loopMode: effectiveLoopMode,
loopCount: effectiveLoopCount,
enableCountdownAnnounce: opts.enableCountdownAnnounce != null ? !!opts.enableCountdownAnnounce : (card ? !!card.enableCountdownAnnounce : true),
announceLeadSeconds: opts.announceLeadSeconds || (card ? card.announceLeadSeconds : '5,3'),
fromCardId: card ? card.id : null,
createdAt: nowIso(),
}
try {
if (cardIdRaw) {
getDb().prepare(`UPDATE rc_sessions SET status='abandoned', ended_at=?, updated_at=?, manual_stopped=1 WHERE card_id=? AND status='running' AND deleted=0`).run(nowIso(), nowIso(), cardIdRaw)
}
getDb().prepare(`UPDATE rc_sessions SET status='abandoned', ended_at=?, updated_at=?, manual_stopped=1 WHERE status='running' AND datetime(updated_at) < datetime('now','-10 minutes') AND deleted=0`).run(nowIso(), nowIso())
} catch (_) {}
const id = String(opts.id || '').trim() || createSessionId()
const ts = nowIso()
const planned = calculatePlannedDuration(cardSnapshot, effectiveLoopMode, effectiveLoopCount)
getDb().prepare(`
INSERT INTO rc_sessions (
id, card_id, card_snapshot_json, status, current_loop, completed_loops, current_step_index,
started_at, ended_at, manual_stopped, planned_duration_sec, actual_duration_sec, completion_ratio,
paused_total_ms, last_paused_at, voice_ok_count, device_os, client_version, announce_mode,
created_at, updated_at, deleted
) VALUES (?, ?, ?, 'running', 1, 0, 0, ?, NULL, 0, ?, 0, 0, 0, NULL, 0, ?, ?, 'default', ?, ?, 0)
`).run(
id,
cardIdRaw,
JSON.stringify(cardSnapshot),
ts,
planned,
String(opts.deviceOs || opts.device_os || '').trim() || null,
String(opts.clientVersion || opts.client_version || '').trim() || null,
ts,
ts,
)
return getSessionById(id)
}
const getSessionById = id => {
const row = getDb().prepare(`SELECT * FROM rc_sessions WHERE id = ? AND deleted = 0 LIMIT 1`).get(String(id || ''))
return row ? mapSession(row) : null
}
const syncSession = (id, patch = {}) => {
const sid = String(id || '').trim()
if (!sid) return null
const fields = []
const values = {}
const allowed = [
'status', 'currentLoop', 'current_loop', 'completedLoops', 'completed_loops', 'currentStepIndex', 'current_step_index',
'pausedTotalMs', 'paused_total_ms', 'lastPausedAt', 'last_paused_at',
'voiceOkCount', 'voice_ok_count', 'deviceOs', 'device_os', 'clientVersion', 'client_version',
'announceMode', 'announce_mode'
]
allowed.forEach(k => {
if (patch[k] !== undefined && patch[k] !== null) {
const dbCol = (k.indexOf('_') >= 0 ? k : k.replace(/[A-Z]/g, m => '_' + m.toLowerCase()))
fields.push(`${dbCol} = @${k}`)
values[k] = patch[k]
}
})
fields.push(`updated_at = @_upd`)
values['_upd'] = nowIso()
values['id'] = sid
if (fields.length > 1) {
getDb().prepare(`UPDATE rc_sessions SET ${fields.join(', ')} WHERE id = @id AND deleted = 0`).run(values)
}
return getSessionById(sid)
}
const finishSession = (id, opts = {}) => {
const sid = String(id || '').trim()
if (!sid) return null
const existing = getSessionById(sid)
if (!existing) return null
const status = String(opts.status || 'completed').toLowerCase() === 'completed' ? 'completed' : 'aborted'
const manualStopped = !!opts.manualStopped ? 1 : 0
const ts = nowIso()
const startedAt = existing.started_at
let actualSec = 0
try {
actualSec = Math.max(0, Math.floor((new Date(ts).getTime() - new Date(startedAt).getTime()) / 1000) - Math.floor(Number(existing.pausedTotalMs || 0) / 1000))
} catch {}
const planned = Math.max(0, Number(existing.plannedDurationSec) || 0)
let ratio = planned ? Math.min(1, actualSec / planned) : 0
if (status === 'completed' && planned > 0) ratio = Math.max(ratio, 1)
getDb().prepare(`
UPDATE rc_sessions SET
status = ?,
ended_at = ?,
manual_stopped = ?,
actual_duration_sec = ?,
completion_ratio = ?,
updated_at = ?
WHERE id = ? AND deleted = 0
`).run(status, ts, manualStopped, actualSec, ratio, ts, sid)
return getSessionById(sid)
}
const listHistory = ({ limit = 200 } = {}) => {
const n = Math.max(1, Math.min(1000, Number(limit) || 200))
const rows = getDb().prepare(`
SELECT * FROM rc_sessions
WHERE deleted = 0
AND status IN ('completed', 'aborted')
ORDER BY datetime(created_at) DESC
LIMIT ?
`).all(n)
return rows.map(mapSession)
}
module.exports = {
createCardId, createSessionId,
sanitizeCard, sanitizeSteps, sanitizeLoopMode,
listCards, getCardById, insertCard, updateCard, deleteCard, markCardTtsReady,
createSession, getSessionById, syncSession, finishSession,
listHistory,
calculatePlannedDuration,
mapCard, mapSession,
}
+91
View File
@@ -0,0 +1,91 @@
const { getDb, nowIso } = require('./db')
const KIT_ID = 'rep_clock'
const LOCK_TIMEOUT_MS = 5 * 60 * 1000
const getLockRow = () => {
const row = getDb().prepare(`SELECT * FROM rc_global_lock WHERE id = 1 LIMIT 1`).get()
if (!row) {
getDb().prepare(`INSERT OR IGNORE INTO rc_global_lock (id, running_session_id, running_kit, updated_at) VALUES (1, NULL, ?, ?)`).run(KIT_ID, nowIso())
return getDb().prepare(`SELECT * FROM rc_global_lock WHERE id = 1 LIMIT 1`).get()
}
return row
}
const isLockExpired = (row) => {
try {
if (!row || !row.running_session_id) return true
const t = new Date(row.updated_at || new Date(0).toISOString()).getTime()
if (!Number.isFinite(t)) return true
return (Date.now() - t) >= LOCK_TIMEOUT_MS
} catch { return true }
}
const reserveRunningLock = (sessionId) => {
const sid = String(sessionId || '').trim()
if (!sid) return { ok: false, error: 'missing session id' }
const row = getLockRow()
const expired = isLockExpired(row)
if (row.running_session_id && row.running_session_id !== sid && !expired) {
return { ok: false, error: 'running session exists', conflictSessionId: row.running_session_id, expiresInMs: Math.max(0, LOCK_TIMEOUT_MS - (Date.now() - new Date(row.updated_at || 0).getTime())) }
}
if (expired && row.running_session_id && row.running_session_id !== sid) {
try {
getDb().prepare(`UPDATE rc_sessions SET status='abandoned', ended_at=?, updated_at=?, manual_stopped=1 WHERE id=? AND status='running' AND deleted=0`).run(nowIso(), nowIso(), row.running_session_id)
} catch (_) {}
}
getDb().prepare(`UPDATE rc_global_lock SET running_session_id = ?, running_kit = ?, updated_at = ? WHERE id = 1`).run(sid, KIT_ID, nowIso())
return { ok: true, sessionId: sid, expiredLockCleared: !!expired }
}
const touchRunningLock = (sessionId) => {
const sid = String(sessionId || '').trim()
if (!sid) return { ok: false, error: 'missing session id' }
const row = getLockRow()
if (row.running_session_id !== sid) {
if (isLockExpired(row)) {
getDb().prepare(`UPDATE rc_global_lock SET running_session_id = ?, running_kit = ?, updated_at = ? WHERE id = 1`).run(sid, KIT_ID, nowIso())
return { ok: true, expiredLockReclaimed: true }
}
return { ok: false, error: 'lock not owned', owner: row.running_session_id }
}
getDb().prepare(`UPDATE rc_global_lock SET updated_at = ? WHERE id = 1 AND running_session_id = ?`).run(nowIso(), sid)
return { ok: true }
}
const releaseRunningLock = (sessionId) => {
const sid = String(sessionId || '').trim()
const row = getLockRow()
if (row.running_session_id && (!sid || row.running_session_id === sid)) {
getDb().prepare(`UPDATE rc_global_lock SET running_session_id = NULL, updated_at = ? WHERE id = 1`).run(nowIso())
return { ok: true }
}
return { ok: true }
}
const resetRunningLock = () => {
getDb().prepare(`UPDATE rc_global_lock SET running_session_id = NULL, updated_at = ? WHERE id = 1`).run(nowIso())
return { ok: true }
}
const getLockState = () => {
const row = getLockRow()
return {
ok: true,
runningSessionId: row.running_session_id,
runningKit: row.running_kit || KIT_ID,
updatedAt: row.updated_at,
running_session_id: row.running_session_id,
running_kit: row.running_kit || KIT_ID,
updated_at: row.updated_at,
}
}
module.exports = {
KIT_ID,
reserveRunningLock,
touchRunningLock,
releaseRunningLock,
resetRunningLock,
getLockState,
}
+294
View File
@@ -0,0 +1,294 @@
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const { getDb, nowIso } = require('./db')
const TTS_ROOT = path.join(process.cwd(), 'uploads', 'mp_rep_clock', 'tts_cache')
const VOICE_ID_DEFAULT = 'female_01'
const ensureDir = dir => { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) }
ensureDir(TTS_ROOT)
const detectAudioExt = (buf, contentType = '') => {
try {
// 内容优先识别,避免 Content-Type 谎报(如 WAV 内容却带了 audio/mpeg)导致扩展名错配、
// iOS 端按 MP3 解码器解析 WAV 内容失败而无声
if (buf && buf.length > 0) {
const headAscii = Buffer.from(buf.subarray(0, Math.min(buf.length, 16))).toString('ascii')
if (headAscii.startsWith('RIFF') && headAscii.indexOf('WAVE') >= 0) return '.wav'
if (headAscii.startsWith('ID3')) return '.mp3'
if (buf.length >= 2 && buf[0] === 0xFF && (buf[1] & 0xE0) === 0xE0) return '.mp3'
}
const ct = String(contentType || '').toLowerCase()
if (ct.includes('wav')) return '.wav'
if (ct.includes('mpeg') || ct.includes('mp3')) return '.mp3'
} catch {}
return '.mp3'
}
const getAbsPathFromUrl = (url) => {
const clean = String(url || '').trim().replace(/^\/+/, '')
if (!clean) return ''
return path.join(process.cwd(), clean.replace(/\//g, path.sep))
}
const normalizeCachedUrl = (row) => {
try {
if (!row || !row.url) return row
const currentUrl = String(row.url || '').trim()
if (!currentUrl) return row
const abs = getAbsPathFromUrl(currentUrl)
if (!abs || !fs.existsSync(abs)) return row
const buf = fs.readFileSync(abs)
const realExt = detectAudioExt(buf)
const currentExt = path.extname(abs || '').toLowerCase()
if (!realExt || realExt === currentExt) return row
const nextAbs = abs.slice(0, -currentExt.length) + realExt
const nextUrl = currentUrl.slice(0, -currentExt.length) + realExt
if (!fs.existsSync(nextAbs)) fs.renameSync(abs, nextAbs)
getDb().prepare(`UPDATE rc_tts_cache SET url = ? WHERE md5 = ?`).run(nextUrl, row.md5)
return Object.assign({}, row, { url: nextUrl })
} catch {}
return row
}
const md5 = (text, voice = VOICE_ID_DEFAULT) => {
return crypto.createHash('md5').update(`${voice}|${String(text || '')}`).digest('hex')
}
const getTTSCacheRow = (text, voice = VOICE_ID_DEFAULT) => {
const key = md5(text, voice)
const row = getDb().prepare(`SELECT * FROM rc_tts_cache WHERE md5 = ? AND deleted = 0 LIMIT 1`).get(key) || null
return normalizeCachedUrl(row)
}
const insertTTSCache = ({ text, voice, url, durationMs }) => {
const key = md5(text, voice)
// UPSERT:md5 即使已存在(含 deleted=1 的历史行)也覆盖恢复为最新音频,
// 避免 INSERT OR IGNORE 导致缓存永远无法更新、重复合成文件堆积
getDb().prepare(`
INSERT INTO rc_tts_cache (md5, text, voice, url, duration_ms, created_at, deleted)
VALUES (?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(md5) DO UPDATE SET
text = excluded.text,
voice = excluded.voice,
url = excluded.url,
duration_ms = excluded.duration_ms,
deleted = 0,
created_at = excluded.created_at
`).run(key, String(text || ''), String(voice || VOICE_ID_DEFAULT), String(url || ''), durationMs ? Math.max(0, Number(durationMs)) : null, nowIso())
return { ok: true, md5: key, url }
}
// 获取可用的 fetch 实现:主服务不会注入 global.__builtin_fetch(那只是 debug 脚本的约定),
// 这里统一回退到全局原生 fetch / node-fetch,避免“合成永远失败”的静默问题
const getSafeFetch = () => {
try {
if (globalThis && typeof globalThis.fetch === 'function') return globalThis.fetch
} catch (_) {}
if (global && typeof global.fetch === 'function') return global.fetch
try { return require('node-fetch') } catch (_) {}
return null
}
const fetchWithTimeout = async (url, options = {}, timeoutMs = 20000) => {
const fn = getSafeFetch()
if (!fn) return null
if (typeof AbortController === 'undefined') return fn(url, options)
const controller = new AbortController()
const timer = setTimeout(() => { try { controller.abort() } catch (_) {} }, timeoutMs)
try {
return await fn(url, Object.assign({}, options, { signal: controller.signal }))
} finally {
clearTimeout(timer)
}
}
const log = (...args) => { try { console.log('[rep_clock tts]', ...args) } catch (_) {} }
// 全局 TTS 串行队列:DashScope qwen3-tts-flash 有 QPS 限流,
// 并发合成会触发 HTTP 429 Throttling.RateQuota 导致随机文本合成失败,必须逐条排队
let ttsChain = Promise.resolve()
const ttsQueue = fn => {
const run = ttsChain.then(fn, fn)
ttsChain = run.catch(() => {})
return run
}
const callCosyVoice = async (text, voice = VOICE_ID_DEFAULT) => {
const apiKey = process.env.DASHSCOPE_API_KEY || ''
if (!apiKey) {
log('❌ DASHSCOPE_API_KEY 未配置,无法合成:', JSON.stringify(String(text || '')))
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
log('▶ 开始合成 text=', JSON.stringify(t), 'voice=', voice, 'apiKey前6位=', String(apiKey).slice(0, 6), 'key长度=', apiKey.length)
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 (const fb of fallbacks) {
const t0 = Date.now()
log(' ↳ 尝试', fb.name, 'body=', JSON.stringify(fb.body))
try {
let resp = null
try {
resp = await fetchWithTimeout(fb.url, {
method: 'POST',
headers,
body: JSON.stringify(fb.body),
}, 20000)
} catch (fe) {
log(' ↳', fb.name, 'fetch 抛异常:', String(fe && fe.message || fe), '耗时', (Date.now() - t0) + 'ms')
continue
}
if (!resp || !resp.ok) {
const txt = resp ? await resp.text().catch(() => '') : ''
log(' ↳', fb.name, 'HTTP', resp ? resp.status : 'no-fetch(未找到fetch实现)', '耗时', (Date.now() - t0) + 'ms', '响应=', String(txt || '').replace(/\s+/g, ' ').slice(0, 600))
continue
}
let j = null
try { j = await resp.json() } catch (je) { log(' ↳', fb.name, '响应非 JSON:', String(je && je.message || je)); continue }
log(' ↳', fb.name, 'HTTP 200 响应体=', JSON.stringify(j).replace(/\s+/g, ' ').slice(0, 600))
let audioUrl = ''
try {
const output = (j && j.output) || null
if (output) {
if (output.audio && typeof output.audio.url === 'string') audioUrl = output.audio.url
if (!audioUrl && typeof output.audio_url === 'string') audioUrl = output.audio_url
if (!audioUrl && typeof output.audioUrl === 'string') audioUrl = 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
}
}
} catch {}
if (!audioUrl) { log(' ↳', fb.name, '响应里未找到 audio url,跳过该 fallback'); continue }
log(' ↳', fb.name, 'audioUrl=', String(audioUrl).slice(0, 240))
const download = await fetchWithTimeout(audioUrl, {}, 20000)
if (!download || !download.ok) { log(' ↳', fb.name, '下载音频失败 status=', download && download.status); continue }
const buf = Buffer.from(await download.arrayBuffer())
const ext = detectAudioExt(buf, download.headers && download.headers.get ? download.headers.get('content-type') : '')
log(' ↳', fb.name, '下载完成 size=', buf.length, 'bytes ext=', ext, '耗时', (Date.now() - t0) + 'ms')
const storedName = `rc_${Date.now()}_${crypto.randomBytes(4).toString('hex')}${ext}`
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)))
log(' ✔ 合成成功 text=', JSON.stringify(t), 'file=', storedName, 'url=/' + relPath, '耗时', (Date.now() - t0) + 'ms')
return { ok: true, url: `/${relPath}`, durationMs }
} catch (e) {
log(' ↳', fb.name, '处理异常:', String(e && e.message || e))
}
}
log('✘ 所有 TTS fallback 均失败 text=', JSON.stringify(t), 'voice=', voice)
return { ok: false, error: 'All TTS fallbacks failed' }
}
const synthesize = async (text, voice = VOICE_ID_DEFAULT) => {
const t = String(text || '').trim()
if (!t) return { ok: false, error: 'empty text' }
const cached = getTTSCacheRow(t, voice)
if (cached) {
log('缓存命中 text=', JSON.stringify(t), 'url=', cached.url)
return { ok: true, url: cached.url, cached: true, durationMs: cached.duration_ms }
}
const t0 = Date.now()
// 真实调用 DashScope 必须全局排队(避免 QPS 429)
const r = await ttsQueue(() => callCosyVoice(t, voice))
if (!r || !r.ok) {
log('✘ 合成失败 text=', JSON.stringify(t), 'error=', r && r.error)
return { ok: false, error: r && r.error ? r.error : 'unknown tts error' }
}
insertTTSCache({ text: t, voice, url: r.url, durationMs: r.durationMs })
log('新合成已入缓存 text=', JSON.stringify(t), 'url=', r.url, '耗时', (Date.now() - t0) + 'ms')
return { ok: true, url: r.url, cached: false, durationMs: r.durationMs }
}
const buildCardTTSBatch = card => {
const lines = []
const cardTitle = String(card && card.title || '').trim()
if (cardTitle) lines.push(`开始训练,${cardTitle}`)
const steps = Array.isArray(card && card.steps) ? card.steps : []
const loopMode = String(card && card.loopMode || 'once')
const loopCount = Math.max(0, Number(card && card.loopCount) || 0)
if (loopMode === 'loop_n_times' && loopCount > 1) {
for (let i = 1; i <= Math.min(loopCount, 20); i++) {
lines.push(`第${i}轮`)
}
}
for (let i = 0; i < steps.length; i++) {
const s = steps[i] || {}
const name = String(s.name || `步骤${i + 1}`).trim()
const dur = Math.max(0, Number(s.durationSec) || 0)
const type = s.type === 'rest' ? 'rest' : 'action'
lines.push(type === 'rest' ? `休息${dur}秒` : name)
if (String(s.voiceText || '').trim()) lines.push(String(s.voiceText || '').trim())
// 步骤切换的过渡语固定为“下一组”(同一轮内多个动作共用一条缓存),
// 动作名由播放端单独播报,避免“下一个,X → X”重复
if (i < steps.length - 1) lines.push('下一组')
}
const leads = String(card && card.announceLeadSeconds || '5,3').split(/[,,\s]+/).filter(Boolean)
leads.forEach(n => { if (Number(n) > 0) lines.push(`还有${Number(n)}秒`) })
lines.push('本次训练结束,辛苦了')
return Array.from(new Set(lines.filter(Boolean)))
}
const synthesizeBatch = async (card, voice = VOICE_ID_DEFAULT) => {
const lines = buildCardTTSBatch(card)
const st = Date.now()
log('▶ synthesizeBatch 开始 cardId=', String(card && card.id || ''), 'title=', JSON.stringify(String(card && card.title || '')), 'voice=', voice, 'lines=', lines.length, '文本=', JSON.stringify(lines))
const seen = new Set()
const tasks = []
for (const line of lines) {
const key = `${voice}|${line}`
if (seen.has(key)) continue
seen.add(key)
// 并发合成,避免串行最坏 ~7×20s,超过小程序 wx.request 60s 超时
tasks.push(synthesize(line, voice).then(r => ({ text: line, url: r.url, ok: !!r.ok, cached: !!r.cached, error: r.error || null })))
}
const results = await Promise.all(tasks)
const success = results.filter(r => r.ok).length
log('✔ synthesizeBatch 完成 success=', success + '/' + lines.length, 'ok=', lines.length > 0 && success === lines.length, '耗时', (Date.now() - st) + 'ms', '明细=', JSON.stringify(results.map(r => ({ text: r.text, ok: r.ok, cached: r.cached, error: r.error }))))
return { ok: lines.length > 0 && success === lines.length, total: lines.length, success, results }
}
module.exports = {
synthesize,
synthesizeBatch,
buildCardTTSBatch,
callCosyVoice,
TTS_ROOT,
VOICE_ID_DEFAULT,
getTTSCacheRow,
insertTTSCache,
}
+18 -9
View File
@@ -1,6 +1,5 @@
const Database = require('better-sqlite3')
const path = require('path')
const { bindRoutes: bindPersonalCollectRoutes } = require('./kits/personal_collect')
const CORE_DB_PATH = path.join(process.cwd(), 'data', 'mp_time_assistant_core.db')
@@ -10,7 +9,16 @@ const KIT_META = [
{
id: 'personal_collect',
name: '微信收藏夹',
description: '聊天图片、文件收藏管理,标签搜索,软删除可追溯。',
description: '聊天图片 / 文件 / 文字收藏,标签搜索,软删除可追溯。独立部署,零共享。',
icon: '📁',
meta: 'API: /api/mp_personal_collect/ · DB: mp_personal_collect.db',
},
{
id: 'rep_clock',
name: '运动节拍 RepClock',
description: 'HIIT / Yoga / 拉伸节奏倒计时,锚点时钟 + 女声播报,后台时间自动补偿。',
icon: '⏱️',
meta: 'API: /api/mp_rep_clock/ · DB: mp_rep_clock.db',
},
]
@@ -53,6 +61,7 @@ const serializeState = () => {
return {
ok: true,
activeKit: row.active_kit || null,
active_kit: row.active_kit || null,
}
}
@@ -65,6 +74,10 @@ const bindRoutes = app => {
appName: 'MpTimeAssistant',
appNameZh: 'MilesYang时光助手',
kits: KIT_META,
notes: {
'personal_collect_api_prefix': '/api/mp_personal_collect/kits/personal_collect',
'rep_clock_api_prefix': '/api/mp_rep_clock/kits/rep_clock',
},
})
})
@@ -74,19 +87,15 @@ const bindRoutes = app => {
app.put('/api/mp_time_assistant/state/active-kit', (req, res) => {
try {
const raw = String(req.body && req.body.kitId || '').trim()
const raw = String(req.body && req.body.kitId || req.body && req.body.kit_id || '').trim()
const nextKit = raw || null
if (nextKit && !KIT_META.find(item => item.id === nextKit)) {
return res.status(400).json({ ok: false, error: 'unknown kit id' })
}
setActiveKit(nextKit)
return res.json({ ok: true, activeKit: nextKit })
return res.json({ ok: true, activeKit: nextKit, active_kit: nextKit })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
bindPersonalCollectRoutes(app)
}
module.exports = { bindRoutes }
@@ -2,8 +2,131 @@ const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const multer = require('multer')
const iconv = require('iconv-lite')
const store = require('./store')
function scoreChinese(str) {
if (!str) return -Infinity
let cjk = 0, asciiPrint = 0, ext = 0, weird = 0
const len = str.length || 1
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i)
if (c >= 0x4e00 && c <= 0x9fff) cjk++
else if (c >= 0x3400 && c <= 0x4dbf) cjk++
else if (c >= 0x3000 && c <= 0x303f) cjk++
else if (c >= 0xff00 && c <= 0xffef) cjk++
else if (c === 0xb7 || c === 0x2022) cjk++
else if (c >= 0x20 && c <= 0x7e) asciiPrint++
else if (c < 0x20 && c !== 9 && c !== 10 && c !== 13) weird++
else if (c >= 0x80 && c <= 0xff) ext++
else if (c >= 0x100 && c < 0x3000) ext++
else ext++
}
return cjk * 3 + asciiPrint * 1 - ext * 2 - weird * 10 - (str.length > len * 2 ? 5 : 0)
}
function hasRepeatedMojibake(str) {
if (!str) return false
return (
/[鏃犳爣棰鍒氫綘鍙戠幇浣犲凡缁忎粖澶╂垜浠埌杈剧殑鏄繖涓柟闈㈠鐞嗙殑闂鍦ㄥ綋鍓嶇幆澧冧笅閫氳繃浠ヤ笅鏂瑰紡瑙e喅娉ㄦ剰浜嬮」閫夋嫨鐨勫唴瀹规槸浠€涔堟椂鍊欑殑妗堜緥璇存槑浣跨敤杩欎釜鍔熻兘鏉ユ洿濂界殑鍒╃敤鑷繁鐨勬洿濂界殑鐢熸椿宸ュ叿浣跨敤鎵嬫]/.test(str) ||
(/[\u4e00-\u9fa5]/.test(str) && /[çèéêëìíîïðñòóôõöøùúûüýþÿÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß]/.test(str)) ||
(/[\u4e00-\u9fa5]/.test(str) && /\uFFFD/.test(str))
)
}
function fixFileNameEncoding(rawName) {
const name = String(rawName == null ? '' : rawName).trim()
if (!name) return 'untitled'
const extMatch = name.match(/(\.[A-Za-z0-9]{1,10})$/)
const ext = extMatch ? extMatch[1] : ''
const base = ext ? name.slice(0, name.length - ext.length) : name
if (!base) return name
const candidates = [{ s: name, src: 'origin' }]
const pushDec = (buf, enc, src) => {
try {
if (!iconv.encodingExists(enc)) return
const d = iconv.decode(buf, enc)
if (d && typeof d === 'string') {
candidates.push({ s: d + ext, src })
candidates.push({ s: d.replace(/\u0000/g, '') + ext, src: src + '_nozero' })
}
} catch {}
}
let buf
try { buf = Buffer.from(base, 'latin1') } catch { buf = null }
if (buf && buf.length) {
pushDec(buf, 'gbk', 'lat_gbk')
pushDec(buf, 'gb18030', 'lat_gb18030')
pushDec(buf, 'utf-8', 'lat_utf8')
pushDec(buf, 'big5', 'lat_big5')
pushDec(buf, 'cp936', 'lat_cp936')
}
try { buf = Buffer.from(base, 'binary') } catch { buf = null }
if (buf && buf.length) {
pushDec(buf, 'gbk', 'bin_gbk')
pushDec(buf, 'gb18030', 'bin_gb18030')
pushDec(buf, 'utf-8', 'bin_utf8')
}
const utf8Buf = Buffer.from(base, 'utf-8')
if (utf8Buf && utf8Buf.length) {
pushDec(utf8Buf, 'latin1', 'utf8_lat1')
pushDec(utf8Buf, 'binary', 'utf8_bin')
}
const bytes = []
for (let i = 0; i < base.length; i++) {
const c = base.charCodeAt(i)
if (c >= 0xd800 && c <= 0xdbff && i + 1 < base.length) {
const low = base.charCodeAt(i + 1)
if (low >= 0xdc00 && low <= 0xdfff) {
const cp = 0x10000 + (c - 0xd800) * 0x400 + (low - 0xdc00)
bytes.push(0xff & (cp >> 8), 0xff & cp)
i++
continue
}
}
bytes.push(0xff & c)
}
if (bytes.length) {
const raw = Buffer.from(bytes)
pushDec(raw, 'gbk', 'raw_gbk')
pushDec(raw, 'gb18030', 'raw_gb18030')
pushDec(raw, 'utf-8', 'raw_utf8')
pushDec(raw, 'big5', 'raw_big5')
pushDec(raw, 'shift_jis', 'raw_sjis')
pushDec(raw, 'euc-kr', 'raw_euckr')
if (raw.length >= 2) {
const buf2 = Buffer.alloc(raw.length)
for (let i = 0; i < raw.length; i++) buf2[i] = raw[i]
pushDec(buf2, 'cp936', 'raw_cp936')
}
}
let best = null
let bestScore = -Infinity
for (const cand of candidates) {
if (!cand.s) continue
if (/\uFFFD/.test(cand.s) && cand.src !== 'origin') continue
const s = cand.s.replace(/\u0000/g, '')
if (!s) continue
let sc = scoreChinese(s)
if (cand.src === 'origin' && /[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s)) sc -= 15
if (cand.src === 'origin' && hasRepeatedMojibake(s)) sc -= 30
if (/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s) && cand.src !== 'origin') sc -= 10
if (sc > bestScore) { bestScore = sc; best = s }
}
if (!best) best = name
if (best.length > 255) best = best.slice(0, 250) + (ext || '')
return best
}
const UPLOAD_ROOT = path.join(process.cwd(), 'uploads', 'mp_time_assistant', 'personal_collect')
const API_PREFIX = '/api/mp_time_assistant/kits/personal_collect'
@@ -130,11 +253,12 @@ const bindRoutes = app => {
const relPath = path.join(relDir, f.filename).replace(/\\/g, '/')
const urlPath = '/' + relPath
const mimeType = String(f.mimetype || '').trim()
const isImage = isImageByMime(mimeType) || isImageByName(f.originalname)
const originalName = fixFileNameEncoding(f.originalname)
const isImage = isImageByMime(mimeType) || isImageByName(originalName)
const saved = store.insertAttachment({
id: createId(),
favoriteId: null,
fileName: f.originalname,
fileName: originalName,
storedName: f.filename,
filePath: relPath,
fileSize: f.size || 0,
@@ -1,5 +1,130 @@
const Database = require('better-sqlite3')
const path = require('path')
const iconv = require('iconv-lite')
function scoreChinese(str) {
if (!str) return -Infinity
let cjk = 0, asciiPrint = 0, ext = 0, weird = 0
const len = str.length || 1
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i)
if (c >= 0x4e00 && c <= 0x9fff) cjk++
else if (c >= 0x3400 && c <= 0x4dbf) cjk++
else if (c >= 0x3000 && c <= 0x303f) cjk++
else if (c >= 0xff00 && c <= 0xffef) cjk++
else if (c === 0xb7 || c === 0x2022) cjk++
else if (c >= 0x20 && c <= 0x7e) asciiPrint++
else if (c < 0x20 && c !== 9 && c !== 10 && c !== 13) weird++
else if (c >= 0x80 && c <= 0xff) ext++
else if (c >= 0x100 && c < 0x3000) ext++
else ext++
}
return cjk * 3 + asciiPrint * 1 - ext * 2 - weird * 10 - (str.length > len * 2 ? 5 : 0)
}
function hasRepeatedMojibake(str) {
if (!str) return false
return /[鏃犳爣棰鍒氫綘鍙戠幇浣犲凡缁忎粖澶╂垜浠埌杈剧殑鏄繖涓柟闈㈠鐞嗙殑闂鍦ㄥ綋鍓嶇幆澧冧笅閫氳繃浠ヤ笅鏂瑰紡瑙e喅娉ㄦ剰浜嬮」閫夋嫨鐨勫唴瀹规槸浠€涔堟椂鍊欑殑妗堜緥璇存槑浣跨敤杩欎釜鍔熻兘鏉ユ洿濂界殑鍒╃敤鑷繁鐨勬洿濂界殑鐢熸椿宸ュ叿浣跨敤鎵嬫]/.test(str) ||
/([\u4e00-\u9fa5]{2})/.test(str) &&
/[çèéêëìíîïðñòóôõöøùúûüýþÿÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß]/.test(str) ||
(/[\u4e00-\u9fa5]/.test(str) && /\uFFFD/.test(str))
}
function fixFileNameEncoding(rawName) {
const name = String(rawName == null ? '' : rawName).trim()
if (!name) return 'untitled'
const extMatch = name.match(/(\.[A-Za-z0-9]{1,10})$/)
const ext = extMatch ? extMatch[1] : ''
const base = ext ? name.slice(0, name.length - ext.length) : name
if (!base) return name
const candidates = [{ s: name, src: 'origin' }]
const pushDec = (buf, enc, src) => {
try {
if (!iconv.encodingExists(enc)) return
const d = iconv.decode(buf, enc)
if (d && typeof d === 'string') {
candidates.push({ s: d + ext, src })
candidates.push({ s: d.replace(/\u0000/g, '') + ext, src: src + '_nozero' })
}
} catch {}
}
let buf
try { buf = Buffer.from(base, 'latin1') } catch { buf = null }
if (buf && buf.length) {
pushDec(buf, 'gbk', 'lat_gbk')
pushDec(buf, 'gb18030', 'lat_gb18030')
pushDec(buf, 'utf-8', 'lat_utf8')
pushDec(buf, 'big5', 'lat_big5')
pushDec(buf, 'cp936', 'lat_cp936')
}
try { buf = Buffer.from(base, 'binary') } catch { buf = null }
if (buf && buf.length) {
pushDec(buf, 'gbk', 'bin_gbk')
pushDec(buf, 'gb18030', 'bin_gb18030')
pushDec(buf, 'utf-8', 'bin_utf8')
}
const utf8Buf = Buffer.from(base, 'utf-8')
if (utf8Buf && utf8Buf.length) {
pushDec(utf8Buf, 'latin1', 'utf8_lat1')
pushDec(utf8Buf, 'binary', 'utf8_bin')
}
const bytes = []
for (let i = 0; i < base.length; i++) {
const c = base.charCodeAt(i)
if (c >= 0xd800 && c <= 0xdbff && i + 1 < base.length) {
const low = base.charCodeAt(i + 1)
if (low >= 0xdc00 && low <= 0xdfff) {
const cp = 0x10000 + (c - 0xd800) * 0x400 + (low - 0xdc00)
bytes.push(0xff & (cp >> 8), 0xff & cp)
i++
continue
}
}
bytes.push(0xff & c)
}
if (bytes.length) {
const raw = Buffer.from(bytes)
pushDec(raw, 'gbk', 'raw_gbk')
pushDec(raw, 'gb18030', 'raw_gb18030')
pushDec(raw, 'utf-8', 'raw_utf8')
pushDec(raw, 'big5', 'raw_big5')
pushDec(raw, 'shift_jis', 'raw_sjis')
pushDec(raw, 'euc-kr', 'raw_euckr')
if (raw.length >= 2) {
const buf2 = Buffer.alloc(raw.length)
for (let i = 0; i < raw.length; i++) buf2[i] = raw[i]
pushDec(buf2, 'cp936', 'raw_cp936')
}
}
let best = null
let bestScore = -Infinity
for (const cand of candidates) {
if (!cand.s) continue
if (/\uFFFD/.test(cand.s) && cand.src !== 'origin') continue
const s = cand.s.replace(/\u0000/g, '')
if (!s) continue
let sc = scoreChinese(s)
if (cand.src === 'origin' && /[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s)) sc -= 15
if (cand.src === 'origin' && hasRepeatedMojibake(s)) sc -= 30
if (/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s) && cand.src !== 'origin') sc -= 10
if (sc > bestScore) { bestScore = sc; best = s }
}
if (!best) best = name
if (best.length > 255) {
best = best.slice(0, 250) + (ext || '')
}
return best
}
const DB_PATH = path.join(process.cwd(), 'data', 'mp_time_assistant_personal_collect.db')
@@ -49,17 +174,27 @@ const mapFavorite = row => ({
updatedAt: row.updated_at,
})
const mapAttachment = row => ({
id: row.id,
favoriteId: row.favorite_id,
fileName: row.file_name,
storedName: row.stored_name,
filePath: row.file_path,
fileSize: Number(row.file_size || 0),
mimeType: row.mime_type,
isImage: !!row.is_image,
createdAt: row.created_at,
})
const mapAttachment = row => {
const original = String(row && row.file_name != null ? row.file_name : '')
const fixed = fixFileNameEncoding(original)
if (original && fixed && original !== fixed) {
try {
getDb().prepare(`UPDATE attachments SET file_name = ? WHERE id = ? AND file_name = ?`)
.run(fixed, row.id, original)
} catch {}
}
return {
id: row.id,
favoriteId: row.favorite_id,
fileName: fixed,
storedName: row.stored_name,
filePath: row.file_path,
fileSize: Number(row.file_size || 0),
mimeType: row.mime_type,
isImage: !!row.is_image,
createdAt: row.created_at,
}
}
const listItems = ({ keyword = '' } = {}) => {
const kw = String(keyword || '').trim()