feat: 新增运动节拍训练工具套件与子工具页面
新增运动节拍RepClock全套小程序页面、后端API与数据库服务,新增subhub子工具汇总页面,优化全局主页样式与配置,为web静态资源添加版本缓存参数,补充配套调试脚本
This commit is contained in:
@@ -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()
|
||||||
@@ -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) }
|
||||||
|
})()
|
||||||
@@ -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))
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
})()
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
})()
|
||||||
@@ -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))
|
||||||
@@ -12,13 +12,16 @@ const request = (options) => {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const { url, method = 'GET', data = {}, header = {}, timeout = 30000 } = options
|
const { url, method = 'GET', data = {}, header = {}, timeout = 30000 } = options
|
||||||
const fullUrl = url.indexOf('http') === 0 ? url : BASE_URL() + url
|
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({
|
wx.request({
|
||||||
url: fullUrl,
|
url: fullUrl,
|
||||||
method,
|
method,
|
||||||
data,
|
data,
|
||||||
header: Object.assign({
|
header: h,
|
||||||
'content-type': 'application/json',
|
|
||||||
}, header),
|
|
||||||
timeout,
|
timeout,
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
const body = res.data || {}
|
const body = res.data || {}
|
||||||
@@ -26,7 +29,12 @@ const request = (options) => {
|
|||||||
resolve(body)
|
resolve(body)
|
||||||
} else {
|
} else {
|
||||||
const msg = body && body.error ? body.error : `请求失败 (HTTP ${res.statusCode})`
|
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) => {
|
fail: (err) => {
|
||||||
@@ -45,6 +53,9 @@ const uploadFile = (options) => {
|
|||||||
filePath,
|
filePath,
|
||||||
name,
|
name,
|
||||||
formData,
|
formData,
|
||||||
|
header: {
|
||||||
|
'X-Mp-Timeassistant': 'mp_time_assistant_v1_' + Math.random().toString(36).slice(2, 10),
|
||||||
|
},
|
||||||
timeout,
|
timeout,
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
try {
|
try {
|
||||||
@@ -78,37 +89,94 @@ const core = {
|
|||||||
|
|
||||||
const personalCollect = {
|
const personalCollect = {
|
||||||
list: (keyword = '') => request({
|
list: (keyword = '') => request({
|
||||||
url: '/api/mp_time_assistant/kits/personal_collect/items',
|
url: '/api/mp_personal_collect/kits/personal_collect/items',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
data: { keyword: keyword || '' },
|
data: { keyword: keyword || '' },
|
||||||
}),
|
}),
|
||||||
get: (id) => request({
|
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',
|
method: 'GET',
|
||||||
}),
|
}),
|
||||||
create: (payload) => request({
|
create: (payload) => request({
|
||||||
url: '/api/mp_time_assistant/kits/personal_collect/items',
|
url: '/api/mp_personal_collect/kits/personal_collect/items',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: payload,
|
data: payload,
|
||||||
}),
|
}),
|
||||||
update: (payload) => request({
|
update: (payload) => request({
|
||||||
url: '/api/mp_time_assistant/kits/personal_collect/items',
|
url: '/api/mp_personal_collect/kits/personal_collect/items',
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
data: payload,
|
data: payload,
|
||||||
}),
|
}),
|
||||||
remove: (id) => request({
|
remove: (id) => request({
|
||||||
url: '/api/mp_time_assistant/kits/personal_collect/items',
|
url: '/api/mp_personal_collect/kits/personal_collect/items',
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
data: { id },
|
data: { id },
|
||||||
}),
|
}),
|
||||||
upload: (filePath, groupId = '') => uploadFile({
|
upload: (filePath, groupId = '') => uploadFile({
|
||||||
url: '/api/mp_time_assistant/kits/personal_collect/upload',
|
url: '/api/mp_personal_collect/kits/personal_collect/upload',
|
||||||
filePath,
|
filePath,
|
||||||
name: 'file',
|
name: 'file',
|
||||||
formData: groupId ? { groupId } : {},
|
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) => {
|
const chooseChatAttachments = (count = 9) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
@@ -141,5 +209,6 @@ module.exports = {
|
|||||||
uploadFile,
|
uploadFile,
|
||||||
core,
|
core,
|
||||||
personalCollect,
|
personalCollect,
|
||||||
|
repClock,
|
||||||
chooseChatAttachments,
|
chooseChatAttachments,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
{
|
{
|
||||||
"pages": [
|
"pages": [
|
||||||
"pages/hub/hub",
|
"pages/hub/hub",
|
||||||
|
"pages/subhub/subhub",
|
||||||
"kits/personal_collect/pages/list/list",
|
"kits/personal_collect/pages/list/list",
|
||||||
"kits/personal_collect/pages/edit/edit",
|
"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": {
|
"window": {
|
||||||
"backgroundTextStyle": "light",
|
"backgroundTextStyle": "light",
|
||||||
@@ -14,6 +19,5 @@
|
|||||||
},
|
},
|
||||||
"style": "v2",
|
"style": "v2",
|
||||||
"sitemapLocation": "sitemap.json",
|
"sitemapLocation": "sitemap.json",
|
||||||
"__comment_requiredPrivateInfos_chooseMessageFile": "chooseMessageFile 不再需要在 requiredPrivateInfos 中静态声明,调用时微信自动弹窗授权即可。",
|
|
||||||
"lazyCodeLoading": "requiredComponents"
|
"lazyCodeLoading": "requiredComponents"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
const api = require('../../../../api.js')
|
||||||
|
|
||||||
|
function uuid() {
|
||||||
|
return 'step_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(v, a, b) { const x = Number(v) || 0; return Math.max(a, Math.min(b, x)) }
|
||||||
|
|
||||||
|
const DEFAULT_CARD = {
|
||||||
|
title: '',
|
||||||
|
notes: '',
|
||||||
|
loopMode: 'sequential_once',
|
||||||
|
loopTimes: 3,
|
||||||
|
enableCountdownAnnounce: true,
|
||||||
|
announceLeadSeconds: '5,3,1',
|
||||||
|
voice: 'female_01',
|
||||||
|
steps: [
|
||||||
|
{ key: uuid(), name: '动作一', durationSec: 30, restSec: 10, voiceText: '' },
|
||||||
|
{ key: uuid(), name: '动作二', durationSec: 30, restSec: 10, voiceText: '' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: Object.assign({}, DEFAULT_CARD, {
|
||||||
|
mode: 'new',
|
||||||
|
cardId: '',
|
||||||
|
dirty: false,
|
||||||
|
saving: false,
|
||||||
|
originSnapshot: '',
|
||||||
|
}),
|
||||||
|
|
||||||
|
onLoad(options) {
|
||||||
|
const mode = (options && options.mode === 'edit') ? 'edit' : 'new'
|
||||||
|
const id = (options && options.id) || ''
|
||||||
|
this.setData({ mode, cardId: id })
|
||||||
|
if (mode === 'edit' && id) {
|
||||||
|
wx.showLoading({ title: '加载中…', mask: true })
|
||||||
|
api.repClock.getCard(id).then(res => {
|
||||||
|
const card = (res && res.card) ? res.card : null
|
||||||
|
if (!card) {
|
||||||
|
try { wx.hideLoading() } catch (_) {}
|
||||||
|
wx.showToast({ title: '卡片不存在', icon: 'none' })
|
||||||
|
wx.navigateBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const steps = (Array.isArray(card.steps) ? card.steps : []).map(s => Object.assign({ key: uuid() }, s))
|
||||||
|
this.setData({
|
||||||
|
title: String(card.title || ''),
|
||||||
|
notes: String(card.notes || ''),
|
||||||
|
loopMode: card.loopMode || 'sequential_once',
|
||||||
|
loopTimes: card.loopTimes || card.loopCount || 3,
|
||||||
|
enableCountdownAnnounce: !!card.enableCountdownAnnounce !== false && !!card.enableCountdownAnnounce,
|
||||||
|
announceLeadSeconds: String(card.announceLeadSeconds || '5,3,1'),
|
||||||
|
voice: card.voice || 'female_01',
|
||||||
|
steps,
|
||||||
|
}, () => {
|
||||||
|
this.setData({ originSnapshot: this._toSnapshot(), dirty: false })
|
||||||
|
try { wx.hideLoading() } catch (_) {}
|
||||||
|
})
|
||||||
|
}).catch(e => {
|
||||||
|
try { wx.hideLoading() } catch (_) {}
|
||||||
|
wx.showToast({ title: e && e.message ? e.message : '加载失败', icon: 'none' })
|
||||||
|
wx.navigateBack()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.setData({ originSnapshot: this._toSnapshot() })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload() { },
|
||||||
|
|
||||||
|
_toSnapshot() {
|
||||||
|
try { return JSON.stringify(this._payload()) } catch (e) { return '' }
|
||||||
|
},
|
||||||
|
|
||||||
|
_payload() {
|
||||||
|
const d = this.data
|
||||||
|
return {
|
||||||
|
title: String(d.title || '').trim(),
|
||||||
|
notes: String(d.notes || '').trim(),
|
||||||
|
loopMode: d.loopMode,
|
||||||
|
loopTimes: clamp(d.loopTimes, 1, 999),
|
||||||
|
enableCountdownAnnounce: !!d.enableCountdownAnnounce,
|
||||||
|
announceLeadSeconds: String(d.announceLeadSeconds || '').trim(),
|
||||||
|
voice: d.voice || 'female_01',
|
||||||
|
steps: (d.steps || []).map(s => ({
|
||||||
|
name: String(s.name || '').trim(),
|
||||||
|
durationSec: clamp(s.durationSec, 1, 3600),
|
||||||
|
restSec: clamp(s.restSec, 0, 3600),
|
||||||
|
voiceText: String(s.voiceText || '').trim(),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
markDirty() {
|
||||||
|
const snap = this._toSnapshot()
|
||||||
|
this.setData({ dirty: snap !== this.data.originSnapshot })
|
||||||
|
},
|
||||||
|
|
||||||
|
onTitleInput(e) { this.setData({ title: e.detail.value }, () => this.markDirty()) },
|
||||||
|
onNotesInput(e) { this.setData({ notes: e.detail.value }, () => this.markDirty()) },
|
||||||
|
onLoopMode(e) { this.setData({ loopMode: e.currentTarget.dataset.v }, () => this.markDirty()) },
|
||||||
|
onLoopTimesInput(e) { this.setData({ loopTimes: e.detail.value }, () => this.markDirty()) },
|
||||||
|
onEnableAnnounce(e) { this.setData({ enableCountdownAnnounce: !!e.detail.value }, () => this.markDirty()) },
|
||||||
|
onLeadSecInput(e) { this.setData({ announceLeadSeconds: e.detail.value }, () => this.markDirty()) },
|
||||||
|
onVoice(e) { this.setData({ voice: e.currentTarget.dataset.v }, () => this.markDirty()) },
|
||||||
|
|
||||||
|
_stepIndexByKey(key) {
|
||||||
|
return (this.data.steps || []).findIndex(s => s.key === key)
|
||||||
|
},
|
||||||
|
|
||||||
|
onStepNameInput(e) {
|
||||||
|
const key = e.currentTarget.dataset.key
|
||||||
|
const i = this._stepIndexByKey(key); if (i < 0) return
|
||||||
|
const steps = this.data.steps.slice()
|
||||||
|
steps[i] = Object.assign({}, steps[i], { name: e.detail.value })
|
||||||
|
this.setData({ steps }, () => this.markDirty())
|
||||||
|
},
|
||||||
|
onStepDurationInput(e) {
|
||||||
|
const key = e.currentTarget.dataset.key
|
||||||
|
const i = this._stepIndexByKey(key); if (i < 0) return
|
||||||
|
const steps = this.data.steps.slice()
|
||||||
|
steps[i] = Object.assign({}, steps[i], { durationSec: e.detail.value })
|
||||||
|
this.setData({ steps }, () => this.markDirty())
|
||||||
|
},
|
||||||
|
onStepRestInput(e) {
|
||||||
|
const key = e.currentTarget.dataset.key
|
||||||
|
const i = this._stepIndexByKey(key); if (i < 0) return
|
||||||
|
const steps = this.data.steps.slice()
|
||||||
|
steps[i] = Object.assign({}, steps[i], { restSec: e.detail.value })
|
||||||
|
this.setData({ steps }, () => this.markDirty())
|
||||||
|
},
|
||||||
|
onStepVoiceInput(e) {
|
||||||
|
const key = e.currentTarget.dataset.key
|
||||||
|
const i = this._stepIndexByKey(key); if (i < 0) return
|
||||||
|
const steps = this.data.steps.slice()
|
||||||
|
steps[i] = Object.assign({}, steps[i], { voiceText: e.detail.value })
|
||||||
|
this.setData({ steps }, () => this.markDirty())
|
||||||
|
},
|
||||||
|
|
||||||
|
onAddStep() {
|
||||||
|
const steps = this.data.steps.slice()
|
||||||
|
steps.push({ key: uuid(), name: `动作${steps.length + 1}`, durationSec: 30, restSec: 10, voiceText: '' })
|
||||||
|
this.setData({ steps }, () => this.markDirty())
|
||||||
|
},
|
||||||
|
|
||||||
|
onMoveUp(e) {
|
||||||
|
const key = e.currentTarget.dataset.key
|
||||||
|
const i = this._stepIndexByKey(key); if (i <= 0) return
|
||||||
|
const steps = this.data.steps.slice()
|
||||||
|
;[steps[i - 1], steps[i]] = [steps[i], steps[i - 1]]
|
||||||
|
this.setData({ steps }, () => this.markDirty())
|
||||||
|
},
|
||||||
|
onMoveDown(e) {
|
||||||
|
const key = e.currentTarget.dataset.key
|
||||||
|
const steps = this.data.steps || []
|
||||||
|
const i = this._stepIndexByKey(key); if (i < 0 || i >= steps.length - 1) return
|
||||||
|
const arr = steps.slice()
|
||||||
|
;[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]]
|
||||||
|
this.setData({ steps: arr }, () => this.markDirty())
|
||||||
|
},
|
||||||
|
onDuplicate(e) {
|
||||||
|
const key = e.currentTarget.dataset.key
|
||||||
|
const i = this._stepIndexByKey(key); if (i < 0) return
|
||||||
|
const steps = this.data.steps.slice()
|
||||||
|
const clone = Object.assign({}, steps[i], { key: uuid() })
|
||||||
|
steps.splice(i + 1, 0, clone)
|
||||||
|
this.setData({ steps }, () => this.markDirty())
|
||||||
|
},
|
||||||
|
onStepDelete(e) {
|
||||||
|
const key = e.currentTarget.dataset.key
|
||||||
|
const i = this._stepIndexByKey(key); if (i < 0) return
|
||||||
|
wx.showModal({
|
||||||
|
title: '删除该步骤?',
|
||||||
|
content: '删除后可再次添加。',
|
||||||
|
confirmText: '删除',
|
||||||
|
cancelText: '取消',
|
||||||
|
confirmColor: '#e05561',
|
||||||
|
success: (r) => {
|
||||||
|
if (!r.confirm) return
|
||||||
|
const steps = this.data.steps.slice()
|
||||||
|
steps.splice(i, 1)
|
||||||
|
this.setData({ steps }, () => this.markDirty())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
_validate(payload) {
|
||||||
|
if (!payload.title) return '请填写卡片标题'
|
||||||
|
if (!payload.steps.length) return '请至少添加一个步骤'
|
||||||
|
for (let i = 0; i < payload.steps.length; i++) {
|
||||||
|
const s = payload.steps[i]
|
||||||
|
if (!s.name) return `第${i + 1}步:请填写动作名称`
|
||||||
|
if (s.durationSec < 1 || s.durationSec > 3600) return `第${i + 1}步:动作时长 1~3600 秒`
|
||||||
|
if (s.restSec < 0 || s.restSec > 3600) return `第${i + 1}步:休息时长 0~3600 秒`
|
||||||
|
}
|
||||||
|
if (payload.loopMode === 'loop_n_times' && (payload.loopTimes < 1 || payload.loopTimes > 999)) return '循环次数 1~999'
|
||||||
|
if (payload.enableCountdownAnnounce) {
|
||||||
|
const raw = (payload.announceLeadSeconds || '').split(',').map(x => Number(x.trim())).filter(n => Number.isFinite(n) && n > 0 && n <= 30)
|
||||||
|
if (!raw.length) return '至少配置 1~30 秒播报秒点'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
async onSave() {
|
||||||
|
if (this.data.saving) return
|
||||||
|
const payload = this._payload()
|
||||||
|
const err = this._validate(payload)
|
||||||
|
if (err) { wx.showToast({ title: err, icon: 'none' }); return }
|
||||||
|
this.setData({ saving: true })
|
||||||
|
wx.showLoading({ title: '保存中…', mask: true })
|
||||||
|
try {
|
||||||
|
let saved = null
|
||||||
|
if (this.data.mode === 'edit') {
|
||||||
|
payload.id = this.data.cardId
|
||||||
|
saved = await api.repClock.updateCard(payload)
|
||||||
|
} else {
|
||||||
|
saved = await api.repClock.createCard(payload)
|
||||||
|
}
|
||||||
|
const cardId = saved && saved.card && saved.card.id ? saved.card.id : this.data.cardId
|
||||||
|
wx.showLoading({ title: '已保存。合成语音…', mask: true })
|
||||||
|
try {
|
||||||
|
await api.repClock.ttsBatch(cardId, (saved && saved.card) ? saved.card : payload, payload.voice)
|
||||||
|
} catch (e) {
|
||||||
|
// 语音合成失败不阻塞保存成功
|
||||||
|
}
|
||||||
|
try { wx.hideLoading() } catch (_) {}
|
||||||
|
wx.showToast({ title: '已保存', icon: 'success' })
|
||||||
|
this.setData({ originSnapshot: this._toSnapshot(), dirty: false })
|
||||||
|
setTimeout(() => wx.navigateBack(), 600)
|
||||||
|
} catch (e) {
|
||||||
|
try { wx.hideLoading() } catch (_) {}
|
||||||
|
wx.showToast({ title: e && e.message ? e.message : '保存失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ saving: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "编辑运动卡片",
|
||||||
|
"navigationBarBackgroundColor": "#1a1a2e",
|
||||||
|
"navigationBarTextStyle": "white",
|
||||||
|
"backgroundColor": "#0f0f1a"
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<view class="container">
|
||||||
|
<view class="page-header">
|
||||||
|
<view class="page-title">{{mode === 'edit' ? '编辑卡片' : '新建卡片'}}</view>
|
||||||
|
<view class="page-subtitle">保存时会自动合成标题 / 步骤名 / 倒计时播报。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="input-group">
|
||||||
|
<text class="input-label">卡片标题</text>
|
||||||
|
<input class="input" placeholder="例:瑜伽垫上腰部运动" value="{{title}}" bindinput="onTitleInput" maxlength="80" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="input-group">
|
||||||
|
<text class="input-label">备注(可选)</text>
|
||||||
|
<textarea class="textarea" placeholder="可以写练习要点、注意事项等" value="{{notes}}" bindinput="onNotesInput" maxlength="500" auto-height />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="input-group">
|
||||||
|
<text class="input-label">循环模式</text>
|
||||||
|
<view class="segmented">
|
||||||
|
<view class="seg-item {{loopMode === 'sequential_once' ? 'seg-active' : ''}}" bindtap="onLoopMode" data-v="sequential_once">顺序单次</view>
|
||||||
|
<view class="seg-item {{loopMode === 'loop_till_stop' ? 'seg-active' : ''}}" bindtap="onLoopMode" data-v="loop_till_stop">循环至停止</view>
|
||||||
|
<view class="seg-item {{loopMode === 'loop_n_times' ? 'seg-active' : ''}}" bindtap="onLoopMode" data-v="loop_n_times">指定循环</view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{loopMode === 'loop_n_times'}}" class="row" style="margin-top: 22rpx; gap: 20rpx;">
|
||||||
|
<text class="input-label" style="margin: 0; flex: none;">循环次数</text>
|
||||||
|
<input class="input" style="flex: 1; padding: 18rpx 22rpx;" type="number" value="{{loopTimes}}" bindinput="onLoopTimesInput" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="input-group">
|
||||||
|
<text class="input-label">倒计时播报</text>
|
||||||
|
<view class="row" style="gap: 16rpx; flex-wrap: wrap;">
|
||||||
|
<switch checked="{{enableCountdownAnnounce}}" bindchange="onEnableAnnounce" color="#6c7ae0" style="transform: scale(0.85);" />
|
||||||
|
<text class="muted small" style="margin-left: 6rpx;">启用倒计时临近播报</text>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{enableCountdownAnnounce}}" style="margin-top: 22rpx;">
|
||||||
|
<text class="input-label">播报秒点(逗号分隔,1~30)</text>
|
||||||
|
<input class="input" placeholder="例:5,3,1" value="{{announceLeadSeconds}}" bindinput="onLeadSecInput" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="input-group">
|
||||||
|
<text class="input-label">默认播报音色</text>
|
||||||
|
<view class="segmented">
|
||||||
|
<view class="seg-item {{voice === 'female_01' ? 'seg-active' : ''}}" bindtap="onVoice" data-v="female_01">🌸 女声 Ruoxi</view>
|
||||||
|
<view class="seg-item {{voice === 'male_01' ? 'seg-active' : ''}}" bindtap="onVoice" data-v="male_01">🎙 男声 Longlaobo</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="row-between" style="padding: 6rpx 6rpx 24rpx;">
|
||||||
|
<view class="section-title">步骤列表</view>
|
||||||
|
<view class="btn btn-primary mini-btn" style="padding: 14rpx 28rpx;" bindtap="onAddStep">+ 新增步骤</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<block wx:for="{{steps}}" wx:key="key">
|
||||||
|
<view class="card step-card">
|
||||||
|
<view class="row-between step-head">
|
||||||
|
<view class="step-idx">第 {{index + 1}} 步</view>
|
||||||
|
<view class="row" style="gap: 12rpx; flex-wrap: wrap;">
|
||||||
|
<view wx:if="{{index > 0}}" class="mini-btn btn-ghost" catchtap="onMoveUp" data-key="{{item.key}}">↑ 上移</view>
|
||||||
|
<view wx:if="{{index < steps.length - 1}}" class="mini-btn btn-ghost" catchtap="onMoveDown" data-key="{{item.key}}">↓ 下移</view>
|
||||||
|
<view class="mini-btn btn-ghost" catchtap="onDuplicate" data-key="{{item.key}}">⎘ 复制</view>
|
||||||
|
<view class="mini-btn btn-danger" catchtap="onStepDelete" data-key="{{item.key}}">🗑 删除</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="input-group" style="margin-top: 18rpx;">
|
||||||
|
<text class="input-label">动作名称</text>
|
||||||
|
<input class="input" placeholder="例:卷腹 30 次" value="{{item.name}}" bindinput="onStepNameInput" data-key="{{item.key}}" maxlength="60" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="row" style="gap: 22rpx;">
|
||||||
|
<view class="input-group" style="flex: 1;">
|
||||||
|
<text class="input-label">动作时长(秒)</text>
|
||||||
|
<input class="input" type="number" value="{{item.durationSec}}" bindinput="onStepDurationInput" data-key="{{item.key}}" />
|
||||||
|
</view>
|
||||||
|
<view class="input-group" style="flex: 1;">
|
||||||
|
<text class="input-label">休息时长(秒)</text>
|
||||||
|
<input class="input" type="number" value="{{item.restSec}}" bindinput="onStepRestInput" data-key="{{item.key}}" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="input-group">
|
||||||
|
<text class="input-label">自定义播报文案(可选,留空则播报动作名称)</text>
|
||||||
|
<input class="input" placeholder="例:加油,保持节奏" value="{{item.voiceText}}" bindinput="onStepVoiceInput" data-key="{{item.key}}" maxlength="120" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<view wx:if="{{!steps.length}}" class="empty muted small" style="padding: 70rpx 32rpx;">还没有步骤,点右上角「+ 新增步骤」开始添加。</view>
|
||||||
|
|
||||||
|
<view style="height: 40rpx;"></view>
|
||||||
|
<view class="btn btn-primary btn-block" bindtap="onSave" style="margin-bottom: 30rpx; padding: 28rpx 0; font-size: 30rpx;">
|
||||||
|
{{saving ? '保存并合成中…' : (mode === 'edit' ? '💾 保存并重新合成' : '💾 保存并合成语音')}}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
.page-header { padding: 70rpx 0 40rpx 0; }
|
||||||
|
.page-title { font-size: 48rpx; font-weight: 700; color: #ffffff; margin-bottom: 12rpx; }
|
||||||
|
.page-subtitle { font-size: 26rpx; color: #8888a8; line-height: 1.6; }
|
||||||
|
|
||||||
|
.section-title { font-size: 30rpx; font-weight: 700; color: #ffffff; letter-spacing: 0.5rpx; }
|
||||||
|
|
||||||
|
.row { display: flex; flex-direction: row; align-items: center; }
|
||||||
|
.row-between { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
|
||||||
|
.muted { color: #8888a8; }
|
||||||
|
.small { font-size: 24rpx; }
|
||||||
|
|
||||||
|
.segmented { display: flex; background: #12121f; border: 1rpx solid #2a2a44; border-radius: 18rpx; overflow: hidden; }
|
||||||
|
.seg-item { flex: 1; padding: 22rpx 16rpx; text-align: center; font-size: 26rpx; color: #a0a4c4; border-right: 1rpx solid #232338; }
|
||||||
|
.seg-item:last-child { border-right: none; }
|
||||||
|
.seg-active { background: linear-gradient(135deg, #6c7ae0, #b06be0); color: #ffffff; font-weight: 600; }
|
||||||
|
|
||||||
|
.mini-btn { padding: 14rpx 22rpx; border-radius: 14rpx; font-size: 24rpx; line-height: 1.3; }
|
||||||
|
|
||||||
|
.step-card { padding: 28rpx; }
|
||||||
|
.step-head { margin-bottom: 14rpx; }
|
||||||
|
.step-idx { font-size: 28rpx; font-weight: 700; color: #c9cdff; letter-spacing: 0.5rpx; }
|
||||||
|
|
||||||
|
.container { padding: 28rpx 32rpx 160rpx; min-height: 100vh; box-sizing: border-box; }
|
||||||
|
.card { background: #1a1a2e; border: 1rpx solid #2a2a44; border-radius: 24rpx; padding: 30rpx; margin-bottom: 24rpx; box-sizing: border-box; }
|
||||||
|
|
||||||
|
.input-group { margin-bottom: 28rpx; }
|
||||||
|
.input-label { font-size: 26rpx; color: #9a9ec0; margin-bottom: 14rpx; display: block; font-weight: 500; }
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
background: #0d0d18 !important;
|
||||||
|
border: 1rpx solid #4a4a72 !important;
|
||||||
|
border-radius: 18rpx !important;
|
||||||
|
padding: 26rpx 26rpx !important;
|
||||||
|
min-height: 88rpx !important;
|
||||||
|
height: 88rpx !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-size: 30rpx !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
line-height: 1.5 !important;
|
||||||
|
}
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
background: #0d0d18 !important;
|
||||||
|
border: 1rpx solid #4a4a72 !important;
|
||||||
|
border-radius: 18rpx !important;
|
||||||
|
padding: 26rpx 26rpx !important;
|
||||||
|
min-height: 220rpx !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-size: 30rpx !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
line-height: 1.6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn { display: inline-block; padding: 20rpx 48rpx; border-radius: 16rpx; font-size: 28rpx; font-weight: 500; text-align: center; box-sizing: border-box; line-height: 1.4; }
|
||||||
|
.btn-primary { background: linear-gradient(135deg, #6c7ae0, #b06be0); color: #ffffff; }
|
||||||
|
.btn-primary:active { opacity: 0.88; }
|
||||||
|
.btn-ghost { background: transparent; border: 1rpx solid #3a3a5a; color: #b8b8d0; }
|
||||||
|
.btn-danger { background: #e05561; color: #ffffff; }
|
||||||
|
.btn-block { display: block; width: 100%; }
|
||||||
|
|
||||||
|
.empty { padding: 80rpx 32rpx; text-align: center; color: #606080; font-size: 26rpx; }
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
const api = require('../../../../api.js')
|
||||||
|
|
||||||
|
const STEPS_TEXT = {
|
||||||
|
sequential_once: '顺序单次',
|
||||||
|
loop_till_stop: '循环至手动停止',
|
||||||
|
loop_n_times: '指定循环次数',
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(v, a, b) { const x = Number(v) || 0; return Math.max(a, Math.min(b, x)) }
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
loading: true,
|
||||||
|
cards: [],
|
||||||
|
stepsTextMap: STEPS_TEXT,
|
||||||
|
submitting: false,
|
||||||
|
lockActive: false,
|
||||||
|
lockSessionId: '',
|
||||||
|
lockUpdatedAt: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() { this.refresh() },
|
||||||
|
|
||||||
|
onPullDownRefresh() {
|
||||||
|
this.refresh().finally(() => wx.stopPullDownRefresh())
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
this.setData({ loading: true })
|
||||||
|
try {
|
||||||
|
const [cardsRes, stateRes] = await Promise.all([
|
||||||
|
api.repClock.listCards(),
|
||||||
|
api.repClock.state().catch(() => null),
|
||||||
|
])
|
||||||
|
const cards = this.decorateList(cardsRes && Array.isArray(cardsRes.cards) ? cardsRes.cards : [])
|
||||||
|
const lock = stateRes && stateRes.lock ? stateRes.lock : {}
|
||||||
|
const runningSessionId = String(lock.runningSessionId || lock.running_session_id || '').trim()
|
||||||
|
this.setData({
|
||||||
|
cards,
|
||||||
|
lockActive: !!runningSessionId,
|
||||||
|
lockSessionId: runningSessionId,
|
||||||
|
lockUpdatedAt: String(lock.updatedAt || lock.updated_at || '').trim(),
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e && e.message ? String(e.message) : '加载失败'
|
||||||
|
if (/unauthorized|401|未授权/i.test(msg)) {
|
||||||
|
wx.showModal({
|
||||||
|
title: '访问被拒绝 (401)',
|
||||||
|
content: '可能是本地服务重启或守卫配置问题。点击重试再次拉取。\n\n详情:' + msg,
|
||||||
|
confirmText: '重试',
|
||||||
|
cancelText: '返回',
|
||||||
|
success: (r) => { if (r.confirm) this.refresh() }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
wx.showToast({ title: msg, icon: 'none' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
decorateList(raw) {
|
||||||
|
return (raw || []).map(item => {
|
||||||
|
const steps = Array.isArray(item.steps) ? item.steps : []
|
||||||
|
const loopMode = item.loopMode || 'sequential_once'
|
||||||
|
const totalSteps = loopMode === 'loop_n_times' ? steps.length * (item.loopTimes || 1) : steps.length
|
||||||
|
let totalSec = 0
|
||||||
|
steps.forEach(s => { totalSec += (Number(s.durationSec) || 0) + (Number(s.restSec) || 0) })
|
||||||
|
if (loopMode === 'loop_n_times') totalSec *= (Number(item.loopTimes) || 1)
|
||||||
|
const plannedMin = Math.max(1, Math.round(totalSec / 60))
|
||||||
|
const previewSteps = steps.slice(0, 6).map((s, i) => ({
|
||||||
|
idx: i,
|
||||||
|
display: `${i + 1}. ${s.name || '未命名动作'} ${s.durationSec || 0}s${s.restSec ? ` · 休${s.restSec}s` : ''}`,
|
||||||
|
}))
|
||||||
|
const moreSteps = steps.length > 6
|
||||||
|
return Object.assign({}, item, {
|
||||||
|
totalSteps,
|
||||||
|
plannedMin,
|
||||||
|
previewSteps,
|
||||||
|
moreSteps,
|
||||||
|
ttsReady: !!item.ttsReady,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
goHistory() {
|
||||||
|
wx.navigateTo({ url: '/kits/rep_clock/pages/history/history' })
|
||||||
|
},
|
||||||
|
|
||||||
|
goCreate() {
|
||||||
|
wx.navigateTo({ url: '/kits/rep_clock/pages/card_edit/card_edit?mode=new' })
|
||||||
|
},
|
||||||
|
|
||||||
|
onCardTap(e) {
|
||||||
|
if (this._guardNotReady(e)) return
|
||||||
|
const id = e.currentTarget.dataset.id
|
||||||
|
this.onEdit({ currentTarget: { dataset: { id } } })
|
||||||
|
},
|
||||||
|
|
||||||
|
// 语音合成中:禁用开始/编辑/重新合成/删除,避免在无语音或重复合成状态下操作
|
||||||
|
_guardNotReady(e) {
|
||||||
|
const ready = e.currentTarget && e.currentTarget.dataset ? e.currentTarget.dataset.ready : undefined
|
||||||
|
if (ready === false || ready === 'false' || ready === 0 || ready === '0') {
|
||||||
|
wx.showToast({ title: '语音合成中,请稍后再操作', icon: 'none' })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
|
||||||
|
onStart(e) {
|
||||||
|
const id = e.currentTarget.dataset.id
|
||||||
|
if (this._guardNotReady(e)) return
|
||||||
|
if (this.data.submitting) return
|
||||||
|
if (this.data.lockActive) {
|
||||||
|
wx.showModal({
|
||||||
|
title: '已有节拍进行中',
|
||||||
|
content: '当前已有一个运动节拍会话在运行。请先使用顶部“清理解锁”,再开始新的节拍。',
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '知道了',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ submitting: true })
|
||||||
|
wx.navigateTo({
|
||||||
|
url: `/kits/rep_clock/pages/runner/runner?cardId=${encodeURIComponent(id)}`,
|
||||||
|
complete: () => this.setData({ submitting: false }),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async onClearLock() {
|
||||||
|
if (this.data.submitting) return
|
||||||
|
wx.showModal({
|
||||||
|
title: '清理解锁?',
|
||||||
|
content: '一次只能运行一个节拍。清理解锁会释放当前运行中的会话占用,适合页面异常退出或卡住时使用。',
|
||||||
|
confirmText: '清理解锁',
|
||||||
|
cancelText: '取消',
|
||||||
|
confirmColor: '#e05561',
|
||||||
|
success: async (r) => {
|
||||||
|
if (!r.confirm) return
|
||||||
|
this.setData({ submitting: true })
|
||||||
|
wx.showLoading({ title: '解锁中…', mask: true })
|
||||||
|
try {
|
||||||
|
await api.repClock.resetRunning(this.data.lockSessionId || '')
|
||||||
|
wx.showToast({ title: '已清理解锁', icon: 'success' })
|
||||||
|
await this.refresh()
|
||||||
|
} catch (e) {
|
||||||
|
wx.showToast({ title: e && e.message ? e.message : '解锁失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
try { wx.hideLoading() } catch (_) {}
|
||||||
|
this.setData({ submitting: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onEdit(e) {
|
||||||
|
if (this._guardNotReady(e)) return
|
||||||
|
const id = e.currentTarget.dataset.id
|
||||||
|
wx.navigateTo({
|
||||||
|
url: `/kits/rep_clock/pages/card_edit/card_edit?mode=edit&id=${encodeURIComponent(id)}`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async onRetts(e) {
|
||||||
|
const id = e.currentTarget.dataset.id
|
||||||
|
// 合成中/失败都可点「重新合成」作为重试出口;submitting 防连点
|
||||||
|
if (this.data.submitting) return
|
||||||
|
this.setData({ submitting: true })
|
||||||
|
wx.showLoading({ title: '准备合成…', mask: true })
|
||||||
|
try {
|
||||||
|
let card = null
|
||||||
|
try {
|
||||||
|
const r = await api.repClock.getCard(id)
|
||||||
|
card = r && r.card ? r.card : null
|
||||||
|
} catch (e) {}
|
||||||
|
if (!card) { wx.showToast({ title: '卡片不存在', icon: 'none' }); return }
|
||||||
|
await api.repClock.ttsBatch(id, card, 'female_01')
|
||||||
|
wx.showToast({ title: '已开始合成', icon: 'success' })
|
||||||
|
setTimeout(() => this.refresh(), 1500)
|
||||||
|
} catch (e) {
|
||||||
|
wx.showToast({ title: e && e.message ? e.message : '合成失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
this.setData({ submitting: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onDelete(e) {
|
||||||
|
const id = e.currentTarget.dataset.id
|
||||||
|
const title = (e.currentTarget.dataset.title || '').trim() || '未命名卡片'
|
||||||
|
if (this._guardNotReady(e)) return
|
||||||
|
if (this.data.submitting) return
|
||||||
|
wx.showModal({
|
||||||
|
title: '删除运动卡片?',
|
||||||
|
content: `《${title}》将被逻辑删除(音频与记录保留,仅不显示)。可随时恢复。`,
|
||||||
|
confirmText: '删除',
|
||||||
|
cancelText: '取消',
|
||||||
|
confirmColor: '#e05561',
|
||||||
|
success: async (r) => {
|
||||||
|
if (!r.confirm) return
|
||||||
|
this.setData({ submitting: true })
|
||||||
|
try {
|
||||||
|
await api.repClock.removeCard(id)
|
||||||
|
wx.showToast({ title: '已删除(逻辑)', icon: 'success' })
|
||||||
|
this.refresh()
|
||||||
|
} catch (e) {
|
||||||
|
wx.showToast({ title: e && e.message ? e.message : '删除失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ submitting: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "运动节拍",
|
||||||
|
"navigationBarBackgroundColor": "#1a1a2e",
|
||||||
|
"navigationBarTextStyle": "white",
|
||||||
|
"backgroundColor": "#0f0f1a",
|
||||||
|
"enablePullDownRefresh": true,
|
||||||
|
"backgroundTextStyle": "light"
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<view class="container">
|
||||||
|
<view class="page-header">
|
||||||
|
<view class="page-title">运动节拍</view>
|
||||||
|
<view class="page-subtitle">自定义动作节拍倒计时,支持 HIIT / Yoga / 拉伸。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{lockActive}}" class="lock-banner">
|
||||||
|
<view class="lock-banner-title">当前已有 1 个节拍在运行</view>
|
||||||
|
<view class="lock-banner-desc">
|
||||||
|
<text>一次只能运行一个节拍。</text>
|
||||||
|
<text wx:if="{{lockSessionId}}"> 会话:{{lockSessionId}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="lock-banner-actions">
|
||||||
|
<view class="mini-btn btn-ghost" bindtap="refresh">刷新状态</view>
|
||||||
|
<view class="mini-btn btn-danger" bindtap="onClearLock">清理解锁</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="row" style="gap: 20rpx; margin-bottom: 28rpx;">
|
||||||
|
<view class="btn btn-ghost btn-block" bindtap="goHistory" style="flex: 1;">🏆 历史记录</view>
|
||||||
|
<view class="btn btn-primary btn-block" bindtap="goCreate" style="flex: 1;">+ 新增卡片</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{loading}}" class="empty">加载中...</view>
|
||||||
|
<view wx:elif="{{!cards.length}}" class="empty">还没有运动卡片,点右上角「+ 新增卡片」开始吧。</view>
|
||||||
|
|
||||||
|
<block wx:for="{{cards}}" wx:key="id">
|
||||||
|
<view class="card rc-card" bindtap="onCardTap" data-id="{{item.id}}" data-ready="{{item.ttsReady}}">
|
||||||
|
<view class="rc-head">
|
||||||
|
<view class="rc-title">{{item.title || '未命名卡片'}}</view>
|
||||||
|
<view class="rc-tags">
|
||||||
|
<text wx:if="{{item.ttsReady}}" class="tag tag-ok">🔊 已合成</text>
|
||||||
|
<text wx:else class="tag tag-warn">⏳ 合成中</text>
|
||||||
|
<text class="tag">{{stepsTextMap[item.loopMode]}}</text>
|
||||||
|
<text class="tag">{{item.totalSteps}} 步</text>
|
||||||
|
<text class="tag">{{item.plannedMin}} 分钟</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view wx:if="{{item.notes}}" class="rc-notes muted small">{{item.notes}}</view>
|
||||||
|
<view class="rc-steps">
|
||||||
|
<block wx:for="{{item.previewSteps}}" wx:for-item="s" wx:for-index="si" wx:key="idx">
|
||||||
|
<view class="rc-step-chip">{{s.display}}</view>
|
||||||
|
</block>
|
||||||
|
<view wx:if="{{item.moreSteps}}" class="rc-step-chip muted small">…… 共 {{item.totalSteps}} 步</view>
|
||||||
|
</view>
|
||||||
|
<view class="item-actions">
|
||||||
|
<view class="mini-btn btn-ghost {{lockActive || !item.ttsReady ? 'mini-btn-disabled' : ''}}" catchtap="onStart" data-id="{{item.id}}" data-ready="{{item.ttsReady}}">{{lockActive ? '🔒 已占用' : (item.ttsReady ? '▶ 开始' : '⏳ 合成中')}}</view>
|
||||||
|
<view class="mini-btn btn-ghost {{!item.ttsReady ? 'mini-btn-disabled' : ''}}" catchtap="onEdit" data-id="{{item.id}}" data-ready="{{item.ttsReady}}">✏ 编辑</view>
|
||||||
|
<view class="mini-btn btn-ghost" catchtap="onRetts" data-id="{{item.id}}" data-ready="{{item.ttsReady}}">🔊 重新合成</view>
|
||||||
|
<view class="mini-btn btn-danger {{!item.ttsReady ? 'mini-btn-disabled' : ''}}" catchtap="onDelete" data-id="{{item.id}}" data-title="{{item.title}}" data-ready="{{item.ttsReady}}">🗑 删除</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="fab" bindtap="goCreate">+</view>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
.lock-banner {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
background: rgba(224, 85, 97, 0.12);
|
||||||
|
border: 1rpx solid rgba(224, 85, 97, 0.3);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
}
|
||||||
|
.lock-banner-title {
|
||||||
|
color: #ffd4d8;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.lock-banner-desc {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
color: #ffd4d8;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.lock-banner-actions {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rc-card { padding: 32rpx; }
|
||||||
|
.rc-head { margin-bottom: 14rpx; }
|
||||||
|
.rc-title { font-size: 34rpx; font-weight: 700; color: #ffffff; letter-spacing: 0.5rpx; }
|
||||||
|
.rc-tags { margin-top: 14rpx; }
|
||||||
|
.tag-ok { background: rgba(72, 187, 120, 0.15); color: #76d79a; }
|
||||||
|
.tag-warn { background: rgba(237, 188, 88, 0.15); color: #f4cf7c; }
|
||||||
|
.rc-notes { margin: 16rpx 0 8rpx; line-height: 1.7; }
|
||||||
|
.rc-steps { margin-top: 22rpx; display: flex; flex-wrap: wrap; gap: 14rpx; }
|
||||||
|
.rc-step-chip { padding: 10rpx 20rpx; background: rgba(108, 122, 224, 0.12); color: #b8bfff; border: 1rpx solid rgba(108, 122, 224, 0.3); border-radius: 999rpx; font-size: 24rpx; }
|
||||||
|
.mini-btn-disabled { opacity: 0.65; }
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
const api = require('../../../../api.js')
|
||||||
|
|
||||||
|
function pad2(n) { const x = Math.max(0, Math.floor(Number(n) || 0)); return x < 10 ? '0' + x : String(x) }
|
||||||
|
|
||||||
|
function toBeijingDate(iso) {
|
||||||
|
try {
|
||||||
|
const d = iso ? new Date(iso) : new Date()
|
||||||
|
const tzMs = 8 * 3600 * 1000
|
||||||
|
const utc = d.getTime() + (d.getTimezoneOffset() * 60 * 1000)
|
||||||
|
const bj = new Date(utc + tzMs)
|
||||||
|
return bj
|
||||||
|
} catch (e) { return new Date() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBjLong(iso) {
|
||||||
|
const d = toBeijingDate(iso)
|
||||||
|
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameBjDay(iso1, iso2) {
|
||||||
|
const a = toBeijingDate(iso1), b = toBeijingDate(iso2)
|
||||||
|
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
loading: true,
|
||||||
|
sessions: [],
|
||||||
|
summary: {
|
||||||
|
totalCount: 0,
|
||||||
|
todayMin: 0,
|
||||||
|
totalMin: 0,
|
||||||
|
completionAvg: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() { this.refresh() },
|
||||||
|
|
||||||
|
onPullDownRefresh() {
|
||||||
|
this.refresh().finally(() => wx.stopPullDownRefresh())
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
this.setData({ loading: true })
|
||||||
|
try {
|
||||||
|
const res = await api.repClock.history(500)
|
||||||
|
const rows = res && Array.isArray(res.sessions) ? res.sessions : []
|
||||||
|
const sessions = rows.map(s => {
|
||||||
|
const plannedMin = Math.max(0, Math.round((Number(s.plannedDurationSec) || 0) / 60))
|
||||||
|
const actualMin = Math.max(0, Math.round((Number(s.actualDurationSec) || 0) / 60))
|
||||||
|
const comp = Math.round(100 * (Number(s.completionRatio) || 0))
|
||||||
|
return Object.assign({}, s, {
|
||||||
|
cardTitle: String(s.cardTitle || s.card_title || '未命名卡片'),
|
||||||
|
plannedMin,
|
||||||
|
actualMin,
|
||||||
|
completed: !!s.completed,
|
||||||
|
completionDisplay: comp,
|
||||||
|
startedAtText: formatBjLong(s.startedAt || s.started_at),
|
||||||
|
})
|
||||||
|
}).sort((a, b) => {
|
||||||
|
const ta = (a.startedAt || a.started_at) ? new Date(a.startedAt || a.started_at).getTime() : 0
|
||||||
|
const tb = (b.startedAt || b.started_at) ? new Date(b.startedAt || b.started_at).getTime() : 0
|
||||||
|
return tb - ta
|
||||||
|
})
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
let totalCount = sessions.length
|
||||||
|
let todayMin = 0
|
||||||
|
let totalMin = 0
|
||||||
|
let compSum = 0
|
||||||
|
sessions.forEach(s => {
|
||||||
|
totalMin += Number(s.actualMin) || 0
|
||||||
|
if (isSameBjDay(s.startedAt || s.started_at, now)) todayMin += Number(s.actualMin) || 0
|
||||||
|
compSum += Number(s.completionDisplay) || 0
|
||||||
|
})
|
||||||
|
const completionAvg = totalCount ? Math.round(compSum / totalCount) : 0
|
||||||
|
this.setData({
|
||||||
|
sessions,
|
||||||
|
summary: {
|
||||||
|
totalCount,
|
||||||
|
todayMin,
|
||||||
|
totalMin,
|
||||||
|
completionAvg,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
wx.showToast({ title: e && e.message ? e.message : '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "运动历史",
|
||||||
|
"navigationBarBackgroundColor": "#1a1a2e",
|
||||||
|
"navigationBarTextStyle": "white",
|
||||||
|
"backgroundColor": "#0f0f1a",
|
||||||
|
"enablePullDownRefresh": true,
|
||||||
|
"backgroundTextStyle": "light"
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<view class="container">
|
||||||
|
<view class="page-header">
|
||||||
|
<view class="page-title">运动历史</view>
|
||||||
|
<view class="page-subtitle">每次运动都有记录,完成率一目了然。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="summary-card">
|
||||||
|
<view class="sum-item">
|
||||||
|
<view class="sum-num">{{summary.totalCount}}</view>
|
||||||
|
<view class="sum-label">总次数</view>
|
||||||
|
</view>
|
||||||
|
<view class="sum-item">
|
||||||
|
<view class="sum-num">{{summary.todayMin}}</view>
|
||||||
|
<view class="sum-label">今日(分钟)</view>
|
||||||
|
</view>
|
||||||
|
<view class="sum-item">
|
||||||
|
<view class="sum-num">{{summary.totalMin}}</view>
|
||||||
|
<view class="sum-label">累计(分钟)</view>
|
||||||
|
</view>
|
||||||
|
<view class="sum-item">
|
||||||
|
<view class="sum-num">{{summary.completionAvg}}%</view>
|
||||||
|
<view class="sum-label">平均完成率</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{loading}}" class="empty">加载中...</view>
|
||||||
|
<view wx:elif="{{!sessions.length}}" class="empty">还没有运动记录,去开始你的第一次吧。</view>
|
||||||
|
|
||||||
|
<block wx:for="{{sessions}}" wx:key="id">
|
||||||
|
<view class="card ses-card">
|
||||||
|
<view class="row-between">
|
||||||
|
<view class="ses-title">{{item.cardTitle || '未命名卡片'}}</view>
|
||||||
|
<view class="ses-result-tag {{item.completed ? 'ses-ok' : 'ses-no'}}">
|
||||||
|
{{item.completed ? '✅ 完成' : '⏹ 中断'}}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="row" style="gap: 28rpx; margin-top: 20rpx; flex-wrap: wrap;">
|
||||||
|
<view class="ses-meta muted small">
|
||||||
|
<text class="meta-label">开始时间</text>
|
||||||
|
<text class="meta-value">{{item.startedAtText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="ses-meta muted small">
|
||||||
|
<text class="meta-label">计划时长</text>
|
||||||
|
<text class="meta-value">{{item.plannedMin}} 分钟</text>
|
||||||
|
</view>
|
||||||
|
<view class="ses-meta muted small">
|
||||||
|
<text class="meta-label">实际时长</text>
|
||||||
|
<text class="meta-value">{{item.actualMin}} 分钟</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="row-between" style="margin-top: 24rpx;">
|
||||||
|
<view class="progress-wrap">
|
||||||
|
<view class="progress-bar">
|
||||||
|
<view class="progress-fill" style="width: {{item.completionDisplay}}%;"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="ses-completion {{item.completionDisplay >= 90 ? 'c-ok' : (item.completionDisplay >= 50 ? 'c-mid' : 'c-low')}}">
|
||||||
|
{{item.completionDisplay}}%
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
.summary-card {
|
||||||
|
display: flex;
|
||||||
|
background: linear-gradient(145deg, rgba(108,122,224,0.22), rgba(176,107,224,0.14));
|
||||||
|
border: 1rpx solid rgba(140,150,240,0.28);
|
||||||
|
border-radius: 28rpx;
|
||||||
|
padding: 30rpx 20rpx;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
}
|
||||||
|
.sum-item { flex: 1; text-align: center; }
|
||||||
|
.sum-num { font-size: 44rpx; font-weight: 800; color: #ffffff; letter-spacing: 0.5rpx; }
|
||||||
|
.sum-label { margin-top: 8rpx; color: #a7aad1; font-size: 22rpx; }
|
||||||
|
|
||||||
|
.ses-card { padding: 30rpx; }
|
||||||
|
.ses-title { font-size: 32rpx; font-weight: 700; color: #ffffff; }
|
||||||
|
.ses-result-tag { padding: 8rpx 20rpx; border-radius: 999rpx; font-size: 22rpx; }
|
||||||
|
.ses-ok { background: rgba(72,187,120,0.15); color: #76d79a; border: 1rpx solid rgba(72,187,120,0.3); }
|
||||||
|
.ses-no { background: rgba(224,85,97,0.14); color: #f19ea7; border: 1rpx solid rgba(224,85,97,0.28); }
|
||||||
|
.ses-meta { display: flex; flex-direction: column; gap: 6rpx; min-width: 180rpx; }
|
||||||
|
.meta-label { color: #7a7e9f; font-size: 22rpx; letter-spacing: 0.5rpx; }
|
||||||
|
.meta-value { color: #e2e4ff; font-size: 26rpx; font-weight: 600; }
|
||||||
|
.progress-wrap { flex: 1; margin-right: 24rpx; }
|
||||||
|
.progress-bar {
|
||||||
|
height: 16rpx;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border-radius: 999rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1rpx solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #6c7ae0, #b06be0);
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
.ses-completion { font-size: 30rpx; font-weight: 800; }
|
||||||
|
.c-ok { color: #76d79a; }
|
||||||
|
.c-mid { color: #f4cf7c; }
|
||||||
|
.c-low { color: #f19ea7; }
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
const api = require('../../../../api.js')
|
||||||
|
|
||||||
|
const PHASE_ACTION = 'action'
|
||||||
|
const PHASE_REST = 'rest'
|
||||||
|
const PHASE_IDLE = 'idle'
|
||||||
|
|
||||||
|
const BASE_URL_SAFE = function () {
|
||||||
|
try {
|
||||||
|
const a = getApp()
|
||||||
|
if (a && a.globalData && a.globalData.apiBase) return a.globalData.apiBase
|
||||||
|
} catch (e) {}
|
||||||
|
return 'https://traesite.umersoft.com:8975'
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(v, a, b) { const x = Number(v) || 0; return Math.max(a, Math.min(b, x)) }
|
||||||
|
function pad2(n) { const x = Math.max(0, Math.floor(Number(n) || 0)); return x < 10 ? '0' + x : String(x) }
|
||||||
|
|
||||||
|
function expandPlan(card) {
|
||||||
|
const steps = Array.isArray(card.steps) ? card.steps : []
|
||||||
|
const mode = card.loopMode || 'sequential_once'
|
||||||
|
const times = mode === 'loop_n_times' ? clamp(card.loopTimes || 1, 1, 999) : 1
|
||||||
|
const plan = []
|
||||||
|
const loopUntil = mode === 'loop_till_stop'
|
||||||
|
const loopCount = loopUntil ? 1 : times
|
||||||
|
for (let round = 1; round <= loopCount; round++) {
|
||||||
|
steps.forEach((step, idx) => {
|
||||||
|
plan.push({
|
||||||
|
round,
|
||||||
|
roundTotal: loopCount,
|
||||||
|
loopUntil,
|
||||||
|
phase: PHASE_ACTION,
|
||||||
|
stepIndex: idx,
|
||||||
|
stepName: step.name || '动作',
|
||||||
|
voiceText: step.voiceText || step.name || '动作',
|
||||||
|
durationSec: clamp(step.durationSec, 1, 3600),
|
||||||
|
restSec: clamp(step.restSec, 0, 3600),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { plan, loopUntil }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeAnnounceLead(raw) {
|
||||||
|
const list = String(raw || '').split(/[,,\s]+/).map(x => Number(x.trim())).filter(n => Number.isFinite(n) && n > 0 && n <= 30)
|
||||||
|
const s = Array.from(new Set(list)).sort((a, b) => b - a)
|
||||||
|
return s.length ? s : [5, 3, 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAudioUrl(raw) {
|
||||||
|
const url = String(raw || '').trim()
|
||||||
|
if (!url) return ''
|
||||||
|
if (/^https?:\/\//i.test(url)) return url
|
||||||
|
if (url.startsWith('/')) return BASE_URL_SAFE() + url
|
||||||
|
return `${BASE_URL_SAFE()}/${url.replace(/^\/+/, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
cardId: '',
|
||||||
|
cardTitle: '运动节拍',
|
||||||
|
started: false,
|
||||||
|
paused: false,
|
||||||
|
phaseLabel: '准备开始',
|
||||||
|
stepSeq: '',
|
||||||
|
remainingText: '00:00',
|
||||||
|
stepName: '',
|
||||||
|
nextName: '',
|
||||||
|
elapsedMin: '00',
|
||||||
|
elapsedSecPad: '00',
|
||||||
|
remainingMin: '00',
|
||||||
|
remainingSecPad: '00',
|
||||||
|
completion: 0,
|
||||||
|
hasCountdown: true,
|
||||||
|
countdownText: '5,3,1',
|
||||||
|
},
|
||||||
|
|
||||||
|
_i: null,
|
||||||
|
|
||||||
|
onLoad(options) {
|
||||||
|
this._i = {
|
||||||
|
card: null,
|
||||||
|
plan: [],
|
||||||
|
loopUntil: false,
|
||||||
|
cursor: 0,
|
||||||
|
phase: PHASE_IDLE,
|
||||||
|
durationMs: 0,
|
||||||
|
phaseStartAt: 0,
|
||||||
|
phaseEndAt: 0,
|
||||||
|
pausedAt: 0,
|
||||||
|
pausedTotalMs: 0,
|
||||||
|
globalStartAt: 0,
|
||||||
|
announcedSet: null,
|
||||||
|
countdownSet: [],
|
||||||
|
voice: 'female_01',
|
||||||
|
sessionId: '',
|
||||||
|
lastTickEmit: 0,
|
||||||
|
lastServerSyncAt: 0,
|
||||||
|
ttsQueue: [],
|
||||||
|
ttsCtx: null,
|
||||||
|
ttsSrc: {},
|
||||||
|
lastAnnouncedText: '',
|
||||||
|
tickTimer: null,
|
||||||
|
hiddenAt: 0,
|
||||||
|
finishing: false,
|
||||||
|
}
|
||||||
|
const cardId = String((options && options.cardId) || '').trim()
|
||||||
|
this.data.cardId = cardId
|
||||||
|
wx.showLoading({ title: '加载卡片…', mask: true })
|
||||||
|
Promise.resolve().then(async () => {
|
||||||
|
if (!cardId) throw new Error('缺少卡片ID')
|
||||||
|
const res = await api.repClock.getCard(cardId)
|
||||||
|
const card = res && res.card ? res.card : null
|
||||||
|
if (!card) throw new Error('卡片不存在')
|
||||||
|
const { plan, loopUntil } = expandPlan(card)
|
||||||
|
if (!plan.length) throw new Error('卡片没有步骤')
|
||||||
|
const i = this._i
|
||||||
|
i.card = card
|
||||||
|
i.plan = plan
|
||||||
|
i.loopUntil = loopUntil
|
||||||
|
i.voice = card.voice || 'female_01'
|
||||||
|
i.announcedSet = new Set()
|
||||||
|
i.countdownSet = sanitizeAnnounceLead(card.announceLeadSeconds || '5,3,1')
|
||||||
|
this._preloadTtsCache(plan, i.voice).catch(() => {})
|
||||||
|
this._ensureAudioCtx()
|
||||||
|
const totalDurationMs = plan.reduce((acc, p) => acc + (p.durationSec + p.restSec) * 1000, 0)
|
||||||
|
const lead = i.countdownSet.length ? i.countdownSet.join(',') : '5,3,1'
|
||||||
|
this.setData({
|
||||||
|
cardTitle: String(card.title || '运动节拍'),
|
||||||
|
countdownText: lead,
|
||||||
|
hasCountdown: !!card.enableCountdownAnnounce !== false,
|
||||||
|
remainingMin: pad2(Math.floor(totalDurationMs / 60000)),
|
||||||
|
remainingSecPad: pad2(Math.floor((totalDurationMs % 60000) / 1000)),
|
||||||
|
})
|
||||||
|
try { wx.hideLoading() } catch (_) {}
|
||||||
|
}).catch(e => {
|
||||||
|
try { wx.hideLoading() } catch (_) {}
|
||||||
|
const msg = e && e.message ? String(e.message) : '加载失败'
|
||||||
|
wx.showToast({ title: msg, icon: 'none', duration: 1500 })
|
||||||
|
setTimeout(() => wx.navigateBack(), 1500)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload() {
|
||||||
|
this._clearTickTimer()
|
||||||
|
this._destroyAudioCtx()
|
||||||
|
if (this._i && this._i.sessionId && !this._i.finishing) {
|
||||||
|
const sid = this._i.sessionId
|
||||||
|
api.repClock.sessionFinish({ sessionId: sid, completed: false, force: true }).catch(() => {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
const i = this._i
|
||||||
|
if (!this.data.started || !i.hiddenAt) return
|
||||||
|
const now = Date.now()
|
||||||
|
const hiddenMs = Math.max(0, now - i.hiddenAt)
|
||||||
|
i.hiddenAt = 0
|
||||||
|
if (!this.data.paused) this._applyBgCompensation(hiddenMs)
|
||||||
|
},
|
||||||
|
|
||||||
|
onHide() {
|
||||||
|
this._i.hiddenAt = Date.now()
|
||||||
|
},
|
||||||
|
|
||||||
|
_ensureAudioCtx() {
|
||||||
|
if (this._i.ttsCtx) return
|
||||||
|
try {
|
||||||
|
// iOS 微信 InnerAudioContext 默认 obeyMuteSwitch=true,iPhone 开启静音键会无声;运动播报必须忽略静音键
|
||||||
|
if (wx.setInnerAudioOption) {
|
||||||
|
wx.setInnerAudioOption({ obeyMuteSwitch: false, mixWithOther: true, speakerOn: true })
|
||||||
|
}
|
||||||
|
const ctx = wx.createInnerAudioContext()
|
||||||
|
ctx.src = ''
|
||||||
|
ctx.volume = 1.0
|
||||||
|
ctx.obeyMuteSwitch = false
|
||||||
|
ctx.speakerOn = true
|
||||||
|
ctx.onEnded(() => this._onTtsEnded())
|
||||||
|
ctx.onError((err) => this._onTtsFailed(err))
|
||||||
|
this._i.ttsCtx = ctx
|
||||||
|
} catch (e) {
|
||||||
|
this._i.ttsCtx = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_destroyAudioCtx() {
|
||||||
|
try {
|
||||||
|
if (this._i.ttsCtx && this._i.ttsCtx.destroy) this._i.ttsCtx.destroy()
|
||||||
|
} catch (e) {}
|
||||||
|
this._i.ttsCtx = null
|
||||||
|
},
|
||||||
|
|
||||||
|
async _preloadTtsCache(plan, voice) {
|
||||||
|
try {
|
||||||
|
const r = await api.repClock.ttsBatch(this.data.cardId, this._i.card, voice)
|
||||||
|
const map = (r && r.data && Array.isArray(r.data.items)) ? r.data.items : []
|
||||||
|
map.forEach(it => {
|
||||||
|
if (it && it.text) this._i.ttsSrc[String(it.text).trim()] = normalizeAudioUrl(it.url || '')
|
||||||
|
})
|
||||||
|
} catch (e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
_onTtsEnded() {
|
||||||
|
this._i.lastAnnouncedText = ''
|
||||||
|
this._playNextTtsOrBeep()
|
||||||
|
},
|
||||||
|
_onTtsFailed(err) {
|
||||||
|
this._i.lastAnnouncedText = ''
|
||||||
|
// iOS 上 InnerAudioContext 常见 errCode:10003=格式不支持、10001=系统级失败;用于排查无声问题
|
||||||
|
try { console.error('[rep_clock] TTS 播放失败:', JSON.stringify(err || {})) } catch (e) {}
|
||||||
|
this._fallbackBeep()
|
||||||
|
this._playNextTtsOrBeep()
|
||||||
|
},
|
||||||
|
|
||||||
|
_enqueueTts(text, priority = 0) {
|
||||||
|
if (!text) return
|
||||||
|
const i = this._i
|
||||||
|
i.ttsQueue.push({ text, priority: Number(priority) || 0 })
|
||||||
|
i.ttsQueue.sort((a, b) => (b.priority - a.priority))
|
||||||
|
if (!i.ttsCtx) this._ensureAudioCtx()
|
||||||
|
if (i.ttsCtx && !this._isPlayingNow()) {
|
||||||
|
this._playNextTtsOrBeep()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_isPlayingNow() {
|
||||||
|
try { return this._i.ttsCtx && this._i.ttsCtx.src && this._i.lastAnnouncedText ? true : false } catch (e) { return false }
|
||||||
|
},
|
||||||
|
|
||||||
|
_playNextTtsOrBeep() {
|
||||||
|
const i = this._i
|
||||||
|
if (!i.ttsCtx) { this._fallbackBeep(); return }
|
||||||
|
if (!i.ttsQueue.length) return
|
||||||
|
const item = i.ttsQueue.shift()
|
||||||
|
const text = item.text
|
||||||
|
const url = normalizeAudioUrl(i.ttsSrc[String(text).trim()] || '')
|
||||||
|
if (!url) {
|
||||||
|
this._fallbackBeep()
|
||||||
|
this._vibrate()
|
||||||
|
if (!this._isPlayingNow()) this._playNextTtsOrBeep()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
i.lastAnnouncedText = text
|
||||||
|
i.ttsCtx.stop()
|
||||||
|
// encodeURI:iOS 微信对含中文/特殊字符的音频 URL 加载容易静默失败
|
||||||
|
i.ttsCtx.src = encodeURI(url)
|
||||||
|
i.ttsCtx.play()
|
||||||
|
} catch (e) {
|
||||||
|
this._fallbackBeep()
|
||||||
|
this._vibrate()
|
||||||
|
i.lastAnnouncedText = ''
|
||||||
|
this._playNextTtsOrBeep()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_fallbackBeep() {
|
||||||
|
try {
|
||||||
|
if (!this._fallbackBeepCtx) {
|
||||||
|
const ctx = wx.createInnerAudioContext()
|
||||||
|
ctx.volume = 1.0
|
||||||
|
this._fallbackBeepCtx = ctx
|
||||||
|
const list = [{ text: '__beep__', src: '' }]
|
||||||
|
this._fallbackBeepList = list
|
||||||
|
}
|
||||||
|
this._fallbackBeepCtx.stop()
|
||||||
|
// 微信 InnerAudioContext 无 src 时不一定会触发 error,但会静默失败;使用震动兜底
|
||||||
|
this._vibrate()
|
||||||
|
} catch (e) {
|
||||||
|
this._vibrate()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_vibrate() {
|
||||||
|
try { wx.vibrateShort({ type: 'medium' }) } catch (e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async onStart() {
|
||||||
|
if (this.data.started) return
|
||||||
|
const i = this._i
|
||||||
|
const now = Date.now()
|
||||||
|
wx.showLoading({ title: '启动中…', mask: true })
|
||||||
|
try {
|
||||||
|
const r = await api.repClock.sessionStart({ cardId: this.data.cardId })
|
||||||
|
if (!r || !r.session || !r.session.id) throw new Error('启动失败')
|
||||||
|
i.sessionId = r.session.id
|
||||||
|
i.globalStartAt = now
|
||||||
|
i.pausedTotalMs = 0
|
||||||
|
i.cursor = 0
|
||||||
|
this._announceStart()
|
||||||
|
this._enterPhase(PHASE_ACTION, now)
|
||||||
|
this._startTickLoop()
|
||||||
|
this.setData({ started: true, paused: false })
|
||||||
|
wx.hideLoading()
|
||||||
|
} catch (e) {
|
||||||
|
wx.hideLoading()
|
||||||
|
const msg = e && e.message ? String(e.message) : ''
|
||||||
|
if (/409|冲突|running session exists/.test(msg)) {
|
||||||
|
const waitSec = e && e.expiresInMs ? Math.ceil(Number(e.expiresInMs) / 1000) : 300
|
||||||
|
wx.showModal({
|
||||||
|
title: '已有运动进行中',
|
||||||
|
content: `检测到另一会话正在进行,${waitSec} 秒后可自动解锁。或选择立即强制抢占(建议先去其他页面结束后再启动)。`,
|
||||||
|
confirmText: '立即强制开始',
|
||||||
|
cancelText: '等待自动解锁',
|
||||||
|
confirmColor: '#e05561',
|
||||||
|
showCancel: true,
|
||||||
|
success: async (mr) => {
|
||||||
|
if (mr.confirm) {
|
||||||
|
try { await api.repClock.resetRunning('') } catch (_) {}
|
||||||
|
wx.showLoading({ title: '启动中…', mask: true })
|
||||||
|
try {
|
||||||
|
const r2 = await api.repClock.sessionStart({ cardId: this.data.cardId })
|
||||||
|
if (!r2 || !r2.session || !r2.session.id) throw new Error('启动失败')
|
||||||
|
i.sessionId = r2.session.id
|
||||||
|
i.globalStartAt = Date.now()
|
||||||
|
i.pausedTotalMs = 0
|
||||||
|
i.cursor = 0
|
||||||
|
this._announceStart()
|
||||||
|
this._enterPhase(PHASE_ACTION, Date.now())
|
||||||
|
this._startTickLoop()
|
||||||
|
this.setData({ started: true, paused: false })
|
||||||
|
wx.hideLoading()
|
||||||
|
} catch (e2) {
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showModal({ title: '仍无法启动', content: (e2 && e2.message ? e2.message : '未知错误') + ',请稍后重试或重启服务。', showCancel: false, confirmText: '知道了' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
wx.showModal({ title: '启动失败', content: msg || '请检查网络或重启服务后重试。', showCancel: false, confirmText: '知道了' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onPause() {
|
||||||
|
if (!this.data.started || this.data.paused) return
|
||||||
|
const now = Date.now()
|
||||||
|
this._i.pausedAt = now
|
||||||
|
this.setData({ paused: true })
|
||||||
|
},
|
||||||
|
onResume() {
|
||||||
|
if (!this.data.started || !this.data.paused) return
|
||||||
|
const now = Date.now()
|
||||||
|
const pMs = Math.max(0, now - (this._i.pausedAt || now))
|
||||||
|
this._i.pausedTotalMs += pMs
|
||||||
|
this._i.phaseStartAt += pMs
|
||||||
|
this._i.phaseEndAt += pMs
|
||||||
|
this._i.pausedAt = 0
|
||||||
|
this.setData({ paused: false })
|
||||||
|
},
|
||||||
|
|
||||||
|
onStepNext() {
|
||||||
|
if (!this.data.started) return
|
||||||
|
const now = Date.now()
|
||||||
|
this._advanceToNextPhase(true, now)
|
||||||
|
},
|
||||||
|
|
||||||
|
async onStop() {
|
||||||
|
if (!this.data.started) return
|
||||||
|
wx.showModal({
|
||||||
|
title: '结束运动?',
|
||||||
|
content: '会保存本次运动记录并返回。',
|
||||||
|
confirmText: '结束',
|
||||||
|
cancelText: '取消',
|
||||||
|
confirmColor: '#e05561',
|
||||||
|
success: async (r) => {
|
||||||
|
if (!r.confirm) return
|
||||||
|
await this._finishSession(false, Date.now())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
_startTickLoop() {
|
||||||
|
this._clearTickTimer()
|
||||||
|
this._i.tickTimer = setInterval(() => this._tick(), 100)
|
||||||
|
},
|
||||||
|
_clearTickTimer() {
|
||||||
|
if (this._i.tickTimer) { clearInterval(this._i.tickTimer); this._i.tickTimer = null }
|
||||||
|
},
|
||||||
|
|
||||||
|
_applyBgCompensation(hiddenMs) {
|
||||||
|
const i = this._i
|
||||||
|
if (!this.data.started || hiddenMs < 1000) return
|
||||||
|
// 短档:<20秒,直接扣剩余时间
|
||||||
|
if (hiddenMs < 20 * 1000) {
|
||||||
|
i.phaseStartAt += hiddenMs
|
||||||
|
i.phaseEndAt += hiddenMs
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 中档:20秒~10分钟,快进完整步
|
||||||
|
if (hiddenMs < 10 * 60 * 1000) {
|
||||||
|
let jumpRemain = hiddenMs
|
||||||
|
while (jumpRemain > 2000 && !this._isPlanFinished()) {
|
||||||
|
const remainInPhase = Math.max(0, i.phaseEndAt - Date.now())
|
||||||
|
if (jumpRemain >= remainInPhase) {
|
||||||
|
jumpRemain -= remainInPhase
|
||||||
|
this._advanceToNextPhase(false, Date.now() + remainInPhase)
|
||||||
|
} else {
|
||||||
|
i.phaseStartAt += jumpRemain
|
||||||
|
i.phaseEndAt += jumpRemain
|
||||||
|
jumpRemain = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 长档:>10分钟,直接结束
|
||||||
|
this._finishSession(false, Date.now()).catch(() => {})
|
||||||
|
},
|
||||||
|
|
||||||
|
_tick() {
|
||||||
|
const i = this._i
|
||||||
|
if (!this.data.started || this.data.paused) return
|
||||||
|
const now = Date.now()
|
||||||
|
this._renderState(now)
|
||||||
|
this._maybeAnnounceCountdown(now)
|
||||||
|
if (now >= i.phaseEndAt) {
|
||||||
|
this._advanceToNextPhase(false, now)
|
||||||
|
}
|
||||||
|
if (now - (i.lastServerSyncAt || 0) >= 6000) {
|
||||||
|
i.lastServerSyncAt = now
|
||||||
|
this._syncToServer(now).catch(() => {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderState(now) {
|
||||||
|
const i = this._i
|
||||||
|
const phaseRemainMs = Math.max(0, i.phaseEndAt - now)
|
||||||
|
const totalPlanMs = (i.plan || []).reduce((a, p) => a + (p.durationSec + p.restSec) * 1000, 0)
|
||||||
|
const completedBefore = Math.min(i.cursor, i.plan.length) * 1000
|
||||||
|
const finishedBeforeMs = i.plan.slice(0, i.cursor).reduce((a, p) => a + (p.durationSec + p.restSec) * 1000, 0)
|
||||||
|
const phaseTotalMs = i.durationMs
|
||||||
|
const phasePassedMs = Math.max(0, Math.min(phaseTotalMs, phaseTotalMs - phaseRemainMs))
|
||||||
|
const elapsedMs = (now - i.globalStartAt) - i.pausedTotalMs
|
||||||
|
const totalPassedMs = finishedBeforeMs + phasePassedMs
|
||||||
|
const remainMs = Math.max(0, totalPlanMs - totalPassedMs)
|
||||||
|
|
||||||
|
const rMin = Math.floor(remainMs / 60000), rSec = Math.floor((remainMs % 60000) / 1000)
|
||||||
|
const eMin = Math.floor(elapsedMs / 60000), eSec = Math.floor((elapsedMs % 60000) / 1000)
|
||||||
|
const compPct = totalPlanMs <= 0 ? 0 : Math.min(100, Math.round((totalPassedMs / totalPlanMs) * 100))
|
||||||
|
|
||||||
|
const cur = i.plan[i.cursor]
|
||||||
|
const nextIdx = i.cursor + 1
|
||||||
|
const next = nextIdx < i.plan.length ? i.plan[nextIdx] : null
|
||||||
|
let phaseLabel = '准备开始', stepSeq = '', stepName = ''
|
||||||
|
if (cur) {
|
||||||
|
const round = cur.loopUntil ? '循环' : `${cur.round}/${cur.roundTotal}`
|
||||||
|
phaseLabel = i.phase === PHASE_ACTION ? '动作' : '休息'
|
||||||
|
stepSeq = `${round} · 第${cur.stepIndex + 1}步`
|
||||||
|
stepName = i.phase === PHASE_ACTION ? (cur.voiceText || cur.stepName || '') : '休息一下'
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseSec = Math.max(0, Math.round(phaseRemainMs / 1000))
|
||||||
|
const phaseMin = Math.floor(phaseSec / 60)
|
||||||
|
const phaseSec2 = phaseSec - phaseMin * 60
|
||||||
|
const remainingText = `${String(phaseMin)}:${pad2(phaseSec2)}`
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
remainingText,
|
||||||
|
phaseLabel,
|
||||||
|
stepSeq,
|
||||||
|
stepName,
|
||||||
|
nextName: next ? (next.phase === PHASE_ACTION ? (next.stepName || '') : '休息') : '',
|
||||||
|
remainingMin: pad2(rMin),
|
||||||
|
remainingSecPad: pad2(rSec),
|
||||||
|
elapsedMin: pad2(eMin),
|
||||||
|
elapsedSecPad: pad2(eSec),
|
||||||
|
completion: compPct,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
_enterPhase(phase, nowMs) {
|
||||||
|
const i = this._i
|
||||||
|
const cur = i.plan[i.cursor]
|
||||||
|
if (!cur) return
|
||||||
|
i.phase = phase
|
||||||
|
const sec = phase === PHASE_ACTION ? cur.durationSec : cur.restSec
|
||||||
|
i.durationMs = sec * 1000
|
||||||
|
i.phaseStartAt = nowMs
|
||||||
|
i.phaseEndAt = nowMs + i.durationMs
|
||||||
|
i.announcedSet = new Set()
|
||||||
|
if (sec <= 0 && phase === PHASE_REST) {
|
||||||
|
this._advanceToNextPhase(false, nowMs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 播报:步名/休息。注意文本必须与服务端 ttsBatch 预合成缓存的 key 完全一致,
|
||||||
|
// 否则(如带“第X步:”前缀)命中不了缓存导致无声
|
||||||
|
let speak = ''
|
||||||
|
if (phase === PHASE_ACTION) {
|
||||||
|
speak = cur.voiceText || cur.stepName || '开始'
|
||||||
|
} else {
|
||||||
|
speak = `休息${cur.restSec}秒`
|
||||||
|
}
|
||||||
|
this._enqueueTts(speak, 10)
|
||||||
|
},
|
||||||
|
|
||||||
|
_isPlanFinished() {
|
||||||
|
const i = this._i
|
||||||
|
if (i.loopUntil) return false
|
||||||
|
return i.cursor >= i.plan.length
|
||||||
|
},
|
||||||
|
|
||||||
|
_announceStart() {
|
||||||
|
const title = String((this._i.card && this._i.card.title) || this.data.cardTitle || '').trim()
|
||||||
|
if (!title) return
|
||||||
|
this._enqueueTts(`开始训练,${title}`, 9)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 切换动作前的过渡语:新的一轮(第N轮)播“第N轮”,同一轮内播“下一组”。
|
||||||
|
// 过渡语不内嵌动作名,动作名由进入步骤时单独播报,避免“平板支撑 → 平板支撑”重复。
|
||||||
|
// 缓存里没合成过的文本不播,避免只震动不出声
|
||||||
|
_announceNextStep() {
|
||||||
|
const i = this._i
|
||||||
|
const nxt = i.plan[i.cursor]
|
||||||
|
if (!nxt) return
|
||||||
|
const isNewRound = nxt.round > 1 && Number(nxt.stepIndex) === 0
|
||||||
|
const text = isNewRound ? `第${nxt.round}轮` : '下一组'
|
||||||
|
if (!i.ttsSrc[text]) return
|
||||||
|
this._enqueueTts(text, 11)
|
||||||
|
},
|
||||||
|
|
||||||
|
_advanceToNextPhase(manualSkip, nowMs) {
|
||||||
|
const i = this._i
|
||||||
|
if (i.phase === PHASE_ACTION) {
|
||||||
|
const cur = i.plan[i.cursor]
|
||||||
|
if (cur && cur.restSec > 0) {
|
||||||
|
this._enterPhase(PHASE_REST, nowMs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 结束当前 rest/action ,推进到下一条 plan 条目
|
||||||
|
i.cursor += 1
|
||||||
|
if (i.loopUntil) {
|
||||||
|
// 循环至停止:如果到达末尾,回到第一条(永不 finish,除非手动停止)
|
||||||
|
if (i.cursor >= i.plan.length) i.cursor = 0
|
||||||
|
this._announceNextStep()
|
||||||
|
this._enterPhase(PHASE_ACTION, nowMs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (i.cursor >= i.plan.length) {
|
||||||
|
this._finishSession(true, nowMs).catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this._announceNextStep()
|
||||||
|
this._enterPhase(PHASE_ACTION, nowMs)
|
||||||
|
},
|
||||||
|
|
||||||
|
_maybeAnnounceCountdown(now) {
|
||||||
|
const i = this._i
|
||||||
|
if (!this.data.hasCountdown) return
|
||||||
|
if (i.phase !== PHASE_ACTION && i.phase !== PHASE_REST) return
|
||||||
|
const remainSec = Math.round((i.phaseEndAt - now) / 1000)
|
||||||
|
if (remainSec <= 0 || remainSec > 60) return
|
||||||
|
if (!i.countdownSet.includes(remainSec)) return
|
||||||
|
if (i.announcedSet.has(i.cursor + '|' + i.phase + '|' + remainSec)) return
|
||||||
|
i.announcedSet.add(i.cursor + '|' + i.phase + '|' + remainSec)
|
||||||
|
// 最后 3 秒以内,使用更短播报
|
||||||
|
if (remainSec <= 3) {
|
||||||
|
this._enqueueTts(`还有${remainSec}秒`, 5)
|
||||||
|
} else {
|
||||||
|
this._enqueueTts(`还有${remainSec}秒`, 5)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async _syncToServer(nowMs) {
|
||||||
|
const i = this._i
|
||||||
|
if (!i.sessionId || i.finishing) return
|
||||||
|
const cur = i.plan[i.cursor]
|
||||||
|
await api.repClock.sessionSync({
|
||||||
|
sessionId: i.sessionId,
|
||||||
|
currentStepIndex: cur ? cur.stepIndex : -1,
|
||||||
|
currentRound: cur ? cur.round : 1,
|
||||||
|
phase: i.phase,
|
||||||
|
cursor: i.cursor,
|
||||||
|
phaseRemainingMs: Math.max(0, i.phaseEndAt - nowMs),
|
||||||
|
paused: !!this.data.paused,
|
||||||
|
pausedTotalMs: i.pausedTotalMs,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async _finishSession(completed, nowMs) {
|
||||||
|
const i = this._i
|
||||||
|
if (i.finishing) return
|
||||||
|
i.finishing = true
|
||||||
|
this._clearTickTimer()
|
||||||
|
wx.showLoading({ title: '保存记录…', mask: true })
|
||||||
|
try {
|
||||||
|
await api.repClock.sessionSync({
|
||||||
|
sessionId: i.sessionId,
|
||||||
|
currentStepIndex: -1,
|
||||||
|
currentRound: 1,
|
||||||
|
phase: 'finish',
|
||||||
|
cursor: i.cursor,
|
||||||
|
phaseRemainingMs: 0,
|
||||||
|
paused: !!this.data.paused,
|
||||||
|
pausedTotalMs: i.pausedTotalMs,
|
||||||
|
})
|
||||||
|
await api.repClock.sessionFinish({
|
||||||
|
sessionId: i.sessionId,
|
||||||
|
completed: !!completed,
|
||||||
|
force: false,
|
||||||
|
})
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({ title: completed ? '完成!真棒 🎉' : '已保存', icon: 'success' })
|
||||||
|
setTimeout(() => wx.navigateBack(), 900)
|
||||||
|
} catch (e) {
|
||||||
|
wx.hideLoading()
|
||||||
|
wx.showToast({ title: '记录已在服务端,返回', icon: 'none' })
|
||||||
|
setTimeout(() => wx.navigateBack(), 900)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"navigationBarTitleText": "节拍中…",
|
||||||
|
"navigationBarBackgroundColor": "#1a1a2e",
|
||||||
|
"navigationBarTextStyle": "white",
|
||||||
|
"backgroundColor": "#0f0f1a",
|
||||||
|
"disableScroll": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<view class="runner-root">
|
||||||
|
<view class="runner-bg"></view>
|
||||||
|
<view class="runner-layout">
|
||||||
|
<view class="runner-topbar">
|
||||||
|
<view class="topbar-title">{{cardTitle}}</view>
|
||||||
|
<view class="topbar-sub muted small">
|
||||||
|
<text wx:if="{{phaseLabel}}">{{phaseLabel}}</text>
|
||||||
|
<text wx:if="{{phaseLabel && stepSeq}}"> · </text>
|
||||||
|
<text wx:if="{{stepSeq}}">{{stepSeq}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="center-clock-wrap">
|
||||||
|
<view class="ring">
|
||||||
|
<view class="ring-left"></view>
|
||||||
|
<view class="ring-right"></view>
|
||||||
|
<view class="ring-mask-left"></view>
|
||||||
|
<view class="ring-mask-right"></view>
|
||||||
|
</view>
|
||||||
|
<view class="clock-inner">
|
||||||
|
<view class="clock-text">{{remainingText}}</view>
|
||||||
|
<view class="clock-sub muted">{{stepName}}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{nextName}}" class="next-card muted small">
|
||||||
|
下一步:<text class="next-name">{{nextName}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="stats-row">
|
||||||
|
<view class="stat">
|
||||||
|
<view class="stat-num">{{elapsedMin}}:{{elapsedSecPad}}</view>
|
||||||
|
<view class="stat-label">已过</view>
|
||||||
|
</view>
|
||||||
|
<view class="stat">
|
||||||
|
<view class="stat-num">{{remainingMin}}:{{remainingSecPad}}</view>
|
||||||
|
<view class="stat-label">剩余</view>
|
||||||
|
</view>
|
||||||
|
<view class="stat">
|
||||||
|
<view class="stat-num">{{completion}}%</view>
|
||||||
|
<view class="stat-label">完成</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="actions">
|
||||||
|
<view wx:if="{{!started}}" class="btn btn-primary btn-block act-btn" bindtap="onStart">▶ 开始</view>
|
||||||
|
<block wx:else>
|
||||||
|
<view wx:if="{{paused}}" class="btn btn-primary btn-block act-btn" bindtap="onResume">▶ 继续</view>
|
||||||
|
<view wx:else class="btn btn-ghost btn-block act-btn" bindtap="onPause">⏸ 暂停</view>
|
||||||
|
<view class="btn btn-ghost btn-block act-btn act-btn-secondary" bindtap="onStepNext">⏭ 下一步</view>
|
||||||
|
<view class="btn btn-danger btn-block act-btn" bindtap="onStop">⏹ 结束</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{hasCountdown}}" class="broadcasts muted small">倒计时播报:{{countdownText}}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
.runner-root {
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
color: #ffffff;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.runner-bg {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: radial-gradient(circle at 30% 20%, #1f1f3d, #0a0a14 65%);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.runner-layout {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 36rpx 28rpx calc(24rpx + env(safe-area-inset-bottom));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.runner-topbar { margin-bottom: 18rpx; }
|
||||||
|
.topbar-title { font-size: 40rpx; font-weight: 800; letter-spacing: 1rpx; }
|
||||||
|
.topbar-sub { margin-top: 6rpx; }
|
||||||
|
|
||||||
|
.center-clock-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 468rpx;
|
||||||
|
height: 468rpx;
|
||||||
|
margin: 18rpx auto 0;
|
||||||
|
}
|
||||||
|
.ring {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #6c7ae0, #b06be0);
|
||||||
|
opacity: 0.18;
|
||||||
|
}
|
||||||
|
.ring-left, .ring-right {
|
||||||
|
position: absolute;
|
||||||
|
width: 50%;
|
||||||
|
height: 100%;
|
||||||
|
left: 0; top: 0;
|
||||||
|
border-radius: 280rpx 0 0 280rpx;
|
||||||
|
background: conic-gradient(from 0deg, #6c7ae0 0deg 180deg, rgba(108,122,224,0.15) 180deg 360deg);
|
||||||
|
transform-origin: 100% 50%;
|
||||||
|
}
|
||||||
|
.ring-right {
|
||||||
|
left: 50%;
|
||||||
|
border-radius: 0 280rpx 280rpx 0;
|
||||||
|
background: conic-gradient(from 180deg, #b06be0 180deg 360deg, rgba(176,107,224,0.15) 360deg 360deg);
|
||||||
|
transform-origin: 0% 50%;
|
||||||
|
}
|
||||||
|
.ring-mask-left, .ring-mask-right {
|
||||||
|
position: absolute;
|
||||||
|
width: 50%;
|
||||||
|
height: 100%;
|
||||||
|
background: #0e0e19;
|
||||||
|
z-index: 2;
|
||||||
|
top: 0; left: 0;
|
||||||
|
border-radius: 280rpx 0 0 280rpx;
|
||||||
|
transform-origin: 100% 50%;
|
||||||
|
}
|
||||||
|
.ring-mask-right {
|
||||||
|
left: 50%;
|
||||||
|
border-radius: 0 280rpx 280rpx 0;
|
||||||
|
background: #0e0e19;
|
||||||
|
transform-origin: 0% 50%;
|
||||||
|
}
|
||||||
|
.clock-inner {
|
||||||
|
position: absolute;
|
||||||
|
inset: 48rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #101025;
|
||||||
|
border: 1rpx solid rgba(255,255,255,0.08);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 3;
|
||||||
|
box-shadow: 0 0 60rpx rgba(108,122,224,0.15), inset 0 0 80rpx rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
.clock-text {
|
||||||
|
font-size: 148rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 4rpx;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: linear-gradient(135deg, #ffffff, #d6dbff);
|
||||||
|
color: transparent;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
.clock-sub { margin-top: 12rpx; font-size: 28rpx; color: #c2c6f4; }
|
||||||
|
|
||||||
|
.next-card {
|
||||||
|
margin: 22rpx auto 0;
|
||||||
|
padding: 14rpx 24rpx;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border: 1rpx solid rgba(255,255,255,0.06);
|
||||||
|
border-radius: 20rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.next-name { color: #ffffff; margin-left: 12rpx; font-weight: 600; }
|
||||||
|
|
||||||
|
.stats-row {
|
||||||
|
margin-top: 26rpx;
|
||||||
|
display: flex;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
flex: 1;
|
||||||
|
padding: 18rpx 8rpx;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border: 1rpx solid rgba(255,255,255,0.06);
|
||||||
|
border-radius: 22rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat-num {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #e9eaff;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: #7a7ea4;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 24rpx 0 8rpx;
|
||||||
|
display: flex;
|
||||||
|
gap: 12rpx;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.act-btn { padding: 22rpx 0; font-size: 30rpx; font-weight: 600; border-radius: 20rpx; }
|
||||||
|
.act-btn-secondary { background: rgba(255,255,255,0.04); color: #e2e4ff; border: 1rpx solid rgba(255,255,255,0.08); }
|
||||||
|
|
||||||
|
.broadcasts {
|
||||||
|
margin: 10rpx auto 0;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
@@ -5,8 +5,6 @@ Page({
|
|||||||
loading: true,
|
loading: true,
|
||||||
kits: [],
|
kits: [],
|
||||||
appNameZh: 'MilesYang时光助手',
|
appNameZh: 'MilesYang时光助手',
|
||||||
subtitle: '个人工具集合,记录时光点滴。',
|
|
||||||
error: '',
|
|
||||||
clipVisible: false,
|
clipVisible: false,
|
||||||
clipText: '',
|
clipText: '',
|
||||||
clipCount: 0,
|
clipCount: 0,
|
||||||
@@ -14,18 +12,12 @@ Page({
|
|||||||
clipPreview: '',
|
clipPreview: '',
|
||||||
},
|
},
|
||||||
|
|
||||||
onLoad() {
|
onLoad() { this.refresh() },
|
||||||
this.loadMeta()
|
|
||||||
},
|
|
||||||
|
|
||||||
onShow() {
|
onShow() { this.checkClipboard() },
|
||||||
this.checkClipboard()
|
|
||||||
},
|
|
||||||
|
|
||||||
onPullDownRefresh() {
|
onPullDownRefresh() {
|
||||||
this.loadMeta().finally(() => {
|
this.refresh().finally(() => wx.stopPullDownRefresh())
|
||||||
wx.stopPullDownRefresh()
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
simpleHash(str) {
|
simpleHash(str) {
|
||||||
@@ -42,6 +34,50 @@ Page({
|
|||||||
return h1.toString(36) + h2.toString(36)
|
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() {
|
checkClipboard() {
|
||||||
try {
|
try {
|
||||||
const enable = wx.getStorageSync('mp_ta_clip_enable')
|
const enable = wx.getStorageSync('mp_ta_clip_enable')
|
||||||
@@ -72,15 +108,13 @@ Page({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
onClipCollect(e) {
|
onClipCollect() {
|
||||||
const mode = (e && e.currentTarget && e.currentTarget.dataset.mode) || 'new'
|
|
||||||
const text = this.data.clipText || ''
|
const text = this.data.clipText || ''
|
||||||
if (!text) { this.setData({ clipVisible: false }); return }
|
if (!text) { this.setData({ clipVisible: false }); return }
|
||||||
try { wx.setStorageSync('mp_ta_clip_text', text) } catch (e) {}
|
try { wx.setStorageSync('mp_ta_clip_text', text) } catch (e) {}
|
||||||
try { wx.setStorageSync('mp_ta_clip_mode', mode) } catch (e) {}
|
|
||||||
this.setData({ clipVisible: false })
|
this.setData({ clipVisible: false })
|
||||||
wx.navigateTo({
|
wx.navigateTo({
|
||||||
url: '/kits/personal_collect/pages/edit/edit',
|
url: '/kits/personal_collect/pages/edit/edit?mode=new',
|
||||||
fail: () => { wx.showToast({ title: '打开失败', icon: 'none' }) }
|
fail: () => { wx.showToast({ title: '打开失败', icon: 'none' }) }
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -105,69 +139,5 @@ Page({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadMeta() {
|
noop() {},
|
||||||
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' })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"navigationBarTitleText": "MilesYang时光助手",
|
"navigationBarTitleText": "时光助手",
|
||||||
|
"navigationBarBackgroundColor": "#1a1a2e",
|
||||||
|
"navigationBarTextStyle": "white",
|
||||||
|
"backgroundColor": "#0f0f1a",
|
||||||
"enablePullDownRefresh": true,
|
"enablePullDownRefresh": true,
|
||||||
"backgroundTextStyle": "light"
|
"backgroundTextStyle": "light"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<view class="container">
|
<view class="container">
|
||||||
<view class="page-header">
|
<view class="page-header">
|
||||||
<view class="page-title">{{appNameZh}}</view>
|
<view class="page-title">{{appNameZh}}</view>
|
||||||
<view class="page-subtitle">{{subtitle}}</view>
|
<view class="page-subtitle">小工具集合 · 完全独立 · 零共享</view>
|
||||||
<view wx:if="{{error}}" class="muted small" style="margin-top: 16rpx; color: #e05561;">⚠️ {{error}}</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view wx:if="{{clipVisible}}" class="clip-card" catchtouchmove="noop">
|
<view wx:if="{{clipVisible}}" class="clip-card" catchtouchmove="noop">
|
||||||
@@ -14,12 +13,22 @@
|
|||||||
<view class="clip-actions">
|
<view class="clip-actions">
|
||||||
<view class="clip-btn clip-btn-ghost" bindtap="onClipDismissForever">不再提示</view>
|
<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-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>
|
</view>
|
||||||
|
|
||||||
<view wx:if="{{loading}}" class="empty">加载中...</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">
|
<block wx:for="{{kits}}" wx:key="id">
|
||||||
<view class="kit-card" bindtap="onKitTap" data-id="{{item.id}}" data-path="{{item.path}}">
|
<view class="kit-card" bindtap="onKitTap" data-id="{{item.id}}" data-path="{{item.path}}">
|
||||||
<view class="kit-icon">
|
<view class="kit-icon">
|
||||||
@@ -28,8 +37,13 @@
|
|||||||
<view class="kit-body">
|
<view class="kit-body">
|
||||||
<view class="kit-title">{{item.name}}</view>
|
<view class="kit-title">{{item.name}}</view>
|
||||||
<view class="kit-desc">{{item.description}}</view>
|
<view class="kit-desc">{{item.description}}</view>
|
||||||
|
<view wx:if="{{item.meta}}" class="kit-meta">{{item.meta}}</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="chevron">›</view>
|
<view class="chevron">›</view>
|
||||||
</view>
|
</view>
|
||||||
</block>
|
</block>
|
||||||
|
|
||||||
|
<view class="footer muted small">
|
||||||
|
独立部署 · 独立数据库 · 独立运行锁
|
||||||
|
</view>
|
||||||
</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 {
|
.clip-card {
|
||||||
margin: 20rpx 24rpx 12rpx;
|
margin: 20rpx 24rpx 12rpx;
|
||||||
@@ -59,11 +81,6 @@
|
|||||||
color: #b8b8d8;
|
color: #b8b8d8;
|
||||||
border: 1px solid rgba(255,255,255,0.08);
|
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 {
|
.clip-btn-primary {
|
||||||
background: linear-gradient(135deg, #6c7ae0, #b06be0);
|
background: linear-gradient(135deg, #6c7ae0, #b06be0);
|
||||||
color: #fff;
|
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;
|
||||||
|
}
|
||||||
@@ -13,14 +13,20 @@
|
|||||||
const url = buildUrl(path)
|
const url = buildUrl(path)
|
||||||
const init = {
|
const init = {
|
||||||
method: method || 'GET',
|
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'
|
credentials: 'same-origin'
|
||||||
}
|
}
|
||||||
if (body !== null && body !== undefined) {
|
if (body !== null && body !== undefined) {
|
||||||
if (body instanceof FormData) {
|
if (body instanceof FormData) {
|
||||||
init.body = body
|
init.body = body
|
||||||
} else {
|
} else {
|
||||||
init.headers['Content-Type'] = 'application/json'
|
init.headers['Content-Type'] = 'application/json; charset=utf-8'
|
||||||
init.body = JSON.stringify(body)
|
init.body = JSON.stringify(body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,17 +54,17 @@
|
|||||||
async meta() {
|
async meta() {
|
||||||
const res = await request('GET', '/api/mp_time_assistant/meta')
|
const res = await request('GET', '/api/mp_time_assistant/meta')
|
||||||
const payload = (res && res.data) || {}
|
const payload = (res && res.data) || {}
|
||||||
return (payload && payload.ok) ? payload.data || {} : (payload || {})
|
return (payload && payload.ok) ? payload : (payload || {})
|
||||||
},
|
},
|
||||||
async getState() {
|
async getState() {
|
||||||
const res = await request('GET', '/api/mp_time_assistant/state')
|
const res = await request('GET', '/api/mp_time_assistant/state')
|
||||||
const payload = (res && res.data) || {}
|
const payload = (res && res.data) || {}
|
||||||
return (payload && payload.ok) ? payload.data || {} : (payload || {})
|
return (payload && payload.ok) ? payload : (payload || {})
|
||||||
},
|
},
|
||||||
async setActiveKit(kitId) {
|
async setActiveKit(kitId) {
|
||||||
const res = await request('PUT', '/api/mp_time_assistant/state/active-kit', { kitId })
|
const res = await request('PUT', '/api/mp_time_assistant/state/active-kit', { kitId })
|
||||||
const payload = (res && res.data) || {}
|
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()
|
const q = new URLSearchParams()
|
||||||
if (keyword) q.set('keyword', keyword)
|
if (keyword) q.set('keyword', keyword)
|
||||||
const suffix = q.toString() ? '?' + q.toString() : ''
|
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) || {}
|
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 : []
|
return Array.isArray(payload) ? payload : []
|
||||||
},
|
},
|
||||||
async get(id) {
|
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) || {}
|
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) {
|
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) || {}
|
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) || '创建失败')
|
throw new Error((body && body.error) || '创建失败')
|
||||||
},
|
},
|
||||||
async update(payload) {
|
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) || {}
|
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) || '更新失败')
|
throw new Error((body && body.error) || '更新失败')
|
||||||
},
|
},
|
||||||
async remove(id) {
|
async remove(id) {
|
||||||
const q = new URLSearchParams({ 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) || {}
|
const body = (res && res.data) || {}
|
||||||
if (body && body.ok) return body
|
if (body && body.ok) return body
|
||||||
throw new Error((body && body.error) || '删除失败')
|
throw new Error((body && body.error) || '删除失败')
|
||||||
@@ -100,7 +106,7 @@
|
|||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
if (groupId) form.append('groupId', groupId)
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const xhr = new XMLHttpRequest()
|
const xhr = new XMLHttpRequest()
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 48px;
|
margin-bottom: 36px;
|
||||||
padding: 24px 32px;
|
padding: 24px 32px;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
border: 1px solid rgba(108, 122, 224, 0.15);
|
border: 1px solid rgba(108, 122, 224, 0.15);
|
||||||
@@ -64,9 +64,85 @@ body {
|
|||||||
border-color: rgba(255, 80, 100, 0.3);
|
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 {
|
.kit-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,10 +156,13 @@ body {
|
|||||||
|
|
||||||
.kit-card {
|
.kit-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 20px;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
padding: 28px;
|
padding: 24px 22px 24px 26px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1),
|
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
border-color 0.3s ease,
|
border-color 0.3s ease,
|
||||||
@@ -101,10 +180,23 @@ body {
|
|||||||
transition: opacity 0.4s ease;
|
transition: opacity 0.4s ease;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
z-index: 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 {
|
.kit-card:hover {
|
||||||
transform: translateY(-6px);
|
transform: translateY(-5px);
|
||||||
border-color: rgba(108, 122, 224, 0.35);
|
border-color: rgba(108, 122, 224, 0.35);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
box-shadow: 0 20px 40px rgba(108, 122, 224, 0.15);
|
box-shadow: 0 20px 40px rgba(108, 122, 224, 0.15);
|
||||||
@@ -125,46 +217,72 @@ body {
|
|||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
background: linear-gradient(135deg, rgba(108, 122, 224, 0.22) 0%, rgba(176, 107, 224, 0.18) 100%);
|
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);
|
border: 1px solid rgba(108, 122, 224, 0.3);
|
||||||
margin-bottom: 18px;
|
flex-shrink: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kit-name {
|
.kit-body {
|
||||||
font-size: 19px;
|
flex: 1 1 auto;
|
||||||
font-weight: 600;
|
min-width: 0;
|
||||||
color: #f0f0f8;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kit-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f0f0f8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kit-desc {
|
.kit-desc {
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
color: #9b9bb5;
|
color: #9b9bb5;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.kit-arrow {
|
.kit-meta {
|
||||||
margin-top: 18px;
|
margin-top: 4px;
|
||||||
display: flex;
|
padding: 6px 10px;
|
||||||
align-items: center;
|
border-radius: 10px;
|
||||||
gap: 6px;
|
background: rgba(108, 122, 224, 0.08);
|
||||||
font-size: 13px;
|
border: 1px solid rgba(108, 122, 224, 0.18);
|
||||||
|
font-size: 11.5px;
|
||||||
color: #a8b4ff;
|
color: #a8b4ff;
|
||||||
position: relative;
|
font-family: 'JetBrains Mono', Consolas, 'Courier New', monospace;
|
||||||
z-index: 1;
|
letter-spacing: 0.2px;
|
||||||
opacity: 0.75;
|
align-self: flex-start;
|
||||||
transition: gap 0.3s ease, opacity 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.kit-card:hover .kit-arrow {
|
.chevron {
|
||||||
gap: 12px;
|
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;
|
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) {
|
@media (max-width: 600px) {
|
||||||
.app-root { padding: 20px 16px 60px; }
|
.app-root { padding: 20px 16px 60px; }
|
||||||
.app-header {
|
.app-header {
|
||||||
@@ -174,6 +292,15 @@ body {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.app-header-left .app-title { font-size: 24px; }
|
.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-grid { grid-template-columns: 1fr; gap: 16px; }
|
||||||
.kit-card { padding: 22px; }
|
.kit-card { padding: 20px 18px 20px 24px; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,15 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>MilesYang时光助手</title>
|
<title>MilesYang时光助手</title>
|
||||||
<link rel="stylesheet" href="/common/common.css">
|
<link rel="stylesheet" href="/common/common.css?v=1.10">
|
||||||
<link rel="stylesheet" href="hub.css">
|
<link rel="stylesheet" href="hub.css?v=1.10">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-root">
|
<div class="app-root">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="app-header-left">
|
<div class="app-header-left">
|
||||||
<h1 class="app-title">MilesYang时光助手</h1>
|
<h1 class="app-title">MilesYang时光助手</h1>
|
||||||
<p class="app-subtitle">手机 · 电脑,实时联动的小工具集合</p>
|
<p class="app-subtitle">手机 · 电脑,实时联动的小工具集合 · 完全独立 · 零共享</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="app-header-right">
|
<div class="app-header-right">
|
||||||
<span id="status-tag" class="status-tag">加载中...</span>
|
<span id="status-tag" class="status-tag">加载中...</span>
|
||||||
@@ -20,13 +20,27 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="app-main">
|
<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 id="kit-grid" class="kit-grid">
|
||||||
<div class="kit-empty">⏳ 工具列表加载中...</div>
|
<div class="kit-empty">⏳ 工具列表加载中...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer muted small">
|
||||||
|
独立部署 · 独立数据库 · 独立运行锁
|
||||||
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="api.js"></script>
|
<script src="api.js?v=1.10"></script>
|
||||||
<script src="hub.js"></script>
|
<script src="hub.js?v=1.10"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,18 +4,34 @@
|
|||||||
const $status = document.getElementById('status-tag')
|
const $status = document.getElementById('status-tag')
|
||||||
|
|
||||||
const KIT_PAGES = {
|
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 = {
|
const KIT_ICONS = {
|
||||||
personal_collect: '📁'
|
personal_collect: '📁',
|
||||||
|
rep_clock: '⏱️',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HERO_EMOJI = '🧘'
|
||||||
|
const HERO_TITLE = '每一次节奏,都是一次呼吸。'
|
||||||
|
const HERO_DESC = '独立工具集合,互不干扰,可独立部署,可独立关闭。'
|
||||||
|
|
||||||
function setStatus(text, cls) {
|
function setStatus(text, cls) {
|
||||||
$status.textContent = text
|
$status.textContent = text
|
||||||
$status.className = 'status-tag' + (cls ? ' ' + cls : '')
|
$status.className = 'status-tag' + (cls ? ' ' + cls : '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
if (s === null || s === undefined) return ''
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
function renderKits(kits) {
|
function renderKits(kits) {
|
||||||
if (!kits || !kits.length) {
|
if (!kits || !kits.length) {
|
||||||
$grid.innerHTML = '<div class="kit-empty">暂无可用工具</div>'
|
$grid.innerHTML = '<div class="kit-empty">暂无可用工具</div>'
|
||||||
@@ -23,13 +39,17 @@
|
|||||||
}
|
}
|
||||||
$grid.innerHTML = kits.map((kit) => {
|
$grid.innerHTML = kits.map((kit) => {
|
||||||
const page = KIT_PAGES[kit.id] || '#'
|
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 (
|
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-icon">${icon}</div>` +
|
||||||
|
`<div class="kit-body">` +
|
||||||
`<div class="kit-name">${escapeHtml(kit.name || kit.id)}</div>` +
|
`<div class="kit-name">${escapeHtml(kit.name || kit.id)}</div>` +
|
||||||
`<div class="kit-desc">${escapeHtml(kit.description || '')}</div>` +
|
`<div class="kit-desc">${escapeHtml(kit.description || '')}</div>` +
|
||||||
`<div class="kit-arrow"><span>进入</span><span>→</span></div>` +
|
meta +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="chevron">›</div>` +
|
||||||
`</div>`
|
`</div>`
|
||||||
)
|
)
|
||||||
}).join('')
|
}).join('')
|
||||||
@@ -47,16 +67,6 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
|
||||||
if (s === null || s === undefined) return ''
|
|
||||||
return String(s)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
try {
|
try {
|
||||||
if (!api || !api.hub) throw new Error('API 未加载')
|
if (!api || !api.hub) throw new Error('API 未加载')
|
||||||
|
|||||||
@@ -4,15 +4,15 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>MilesYang时光助手</title>
|
<title>MilesYang时光助手</title>
|
||||||
<link rel="stylesheet" href="/common/common.css">
|
<link rel="stylesheet" href="/common/common.css?v=1.10">
|
||||||
<link rel="stylesheet" href="hub.css">
|
<link rel="stylesheet" href="hub.css?v=1.10">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-root">
|
<div class="app-root">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="app-header-left">
|
<div class="app-header-left">
|
||||||
<h1 class="app-title">MilesYang时光助手</h1>
|
<h1 class="app-title">MilesYang时光助手</h1>
|
||||||
<p class="app-subtitle">手机 · 电脑,实时联动的小工具集合</p>
|
<p class="app-subtitle">手机 · 电脑,实时联动的小工具集合 · 完全独立 · 零共享</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="app-header-right">
|
<div class="app-header-right">
|
||||||
<span id="status-tag" class="status-tag">加载中...</span>
|
<span id="status-tag" class="status-tag">加载中...</span>
|
||||||
@@ -20,13 +20,27 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="app-main">
|
<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 id="kit-grid" class="kit-grid">
|
||||||
<div class="kit-empty">⏳ 工具列表加载中...</div>
|
<div class="kit-empty">⏳ 工具列表加载中...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer muted small">
|
||||||
|
独立部署 · 独立数据库 · 独立运行锁
|
||||||
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="api.js"></script>
|
<script src="api.js?v=1.10"></script>
|
||||||
<script src="hub.js"></script>
|
<script src="hub.js?v=1.10"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,41 +1,80 @@
|
|||||||
.detail-section {
|
.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 {
|
.detail-section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #8a8aa8;
|
color: #9a9abd;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.8px;
|
letter-spacing: 1px;
|
||||||
margin-bottom: 10px;
|
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 {
|
.detail-title {
|
||||||
font-size: 26px;
|
font-size: 30px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: #f4f4fa;
|
color: #fafaff;
|
||||||
letter-spacing: 0.2px;
|
letter-spacing: 0.3px;
|
||||||
|
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
.detail-title.empty {
|
.detail-title.empty {
|
||||||
color: #6a6a88;
|
color: #787898;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 20px;
|
font-size: 22px;
|
||||||
font-style: italic;
|
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 {
|
.detail-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
font-size: 13px;
|
font-size: 13.5px;
|
||||||
color: #8a8aa8;
|
color: #9a9abd;
|
||||||
margin-top: 14px;
|
|
||||||
}
|
}
|
||||||
.detail-meta span b {
|
.detail-meta span b {
|
||||||
color: #c8c8e0;
|
color: #c8c8e0;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-tags {
|
.detail-tags {
|
||||||
@@ -45,24 +84,30 @@
|
|||||||
margin-top: 4px;
|
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 {
|
.detail-remark {
|
||||||
background: rgba(0, 0, 0, 0.2);
|
color: #e4e4f0;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
font-size: 15.5px;
|
||||||
border-radius: 16px;
|
line-height: 2;
|
||||||
padding: 24px;
|
|
||||||
color: #dcdceb;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.9;
|
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
}
|
}
|
||||||
.detail-remark.empty {
|
.detail-remark.empty {
|
||||||
color: #6a6a88;
|
color: #787898;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-style: italic;
|
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 {
|
.image-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
@@ -74,27 +119,36 @@
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: zoom-in;
|
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);
|
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 img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
.image-item .name-badge {
|
.image-item .name-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 8px; bottom: 8px;
|
left: 8px; bottom: 8px;
|
||||||
max-width: calc(100% - 16px);
|
max-width: calc(100% - 16px);
|
||||||
padding: 4px 10px;
|
padding: 5px 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 11px;
|
font-size: 11.5px;
|
||||||
background: rgba(0, 0, 0, 0.55);
|
background: rgba(0, 0, 0, 0.65);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(6px);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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 {
|
.file-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -104,51 +158,58 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 14px 18px;
|
padding: 14px 18px;
|
||||||
background: rgba(108, 122, 224, 0.06);
|
background: rgba(40, 44, 72, 0.5);
|
||||||
border: 1px solid rgba(108, 122, 224, 0.16);
|
border: 1px solid rgba(108, 122, 224, 0.14);
|
||||||
border-radius: 14px;
|
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;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.file-item:hover {
|
.file-item:hover {
|
||||||
background: rgba(108, 122, 224, 0.12);
|
background: rgba(52, 58, 96, 0.6);
|
||||||
border-color: rgba(108, 122, 224, 0.32);
|
border-color: rgba(108, 122, 224, 0.34);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
.file-icon {
|
.file-icon {
|
||||||
width: 44px; height: 44px;
|
width: 46px; height: 46px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
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;
|
flex-shrink: 0;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
|
border: 1px solid rgba(108, 122, 224, 0.25);
|
||||||
}
|
}
|
||||||
.file-info { flex: 1; min-width: 0; }
|
.file-info { flex: 1; min-width: 0; }
|
||||||
.file-name {
|
.file-name {
|
||||||
font-size: 14.5px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: #e8e8f4;
|
color: #ececf6;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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 {
|
.file-download {
|
||||||
padding: 8px 12px;
|
padding: 9px 14px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: rgba(108, 122, 224, 0.2);
|
background: linear-gradient(135deg, rgba(108, 122, 224, 0.35) 0%, rgba(160, 110, 224, 0.3) 100%);
|
||||||
color: #a8b4ff;
|
color: #d0d8ff;
|
||||||
font-size: 12px;
|
font-size: 12.5px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border: 1px solid rgba(108, 122, 224, 0.3);
|
border: 1px solid rgba(108, 122, 224, 0.4);
|
||||||
flex-shrink: 0;
|
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 {
|
.lightbox-mask {
|
||||||
position: fixed; inset: 0; z-index: 99999;
|
position: fixed; inset: 0; z-index: 99999;
|
||||||
background: rgba(0, 0, 0, 0.88);
|
background: rgba(0, 0, 0, 0.9);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(10px);
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
@@ -162,48 +223,55 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 30px 100px rgba(0,0,0,0.6);
|
box-shadow: 0 30px 100px rgba(0,0,0,0.7);
|
||||||
}
|
}
|
||||||
.lightbox-close, .lightbox-nav {
|
.lightbox-close, .lightbox-nav {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 44px; height: 44px;
|
width: 46px; height: 46px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(255,255,255,0.08);
|
background: rgba(255,255,255,0.1);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
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;
|
cursor: pointer;
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(8px);
|
||||||
transition: background 0.2s ease, transform 0.2s ease;
|
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:hover, .lightbox-nav:hover { background: rgba(255,255,255,0.22); transform: scale(1.08); }
|
||||||
.lightbox-close { top: 16px; right: 16px; }
|
.lightbox-close { top: 18px; right: 18px; }
|
||||||
.lightbox-nav { top: 50%; transform: translateY(-50%); }
|
.lightbox-nav { top: 50%; transform: translateY(-50%); }
|
||||||
.lightbox-nav:hover { transform: translateY(-50%) scale(1.06); }
|
.lightbox-nav:hover { transform: translateY(-50%) scale(1.08); }
|
||||||
.lightbox-nav.prev { left: 16px; }
|
.lightbox-nav.prev { left: 18px; }
|
||||||
.lightbox-nav.next { right: 16px; }
|
.lightbox-nav.next { right: 18px; }
|
||||||
.lightbox-caption {
|
.lightbox-caption {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 20px;
|
bottom: 24px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
max-width: 80vw;
|
max-width: 80vw;
|
||||||
padding: 8px 18px;
|
padding: 10px 22px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(0,0,0,0.65);
|
background: rgba(0,0,0,0.7);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(8px);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.detail-title { font-size: 20px; }
|
.detail-card { padding: 18px 18px; border-radius: 15px; }
|
||||||
.detail-remark { padding: 18px; font-size: 14.5px; }
|
.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; }
|
.image-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||||
.lightbox-close, .lightbox-nav { width: 40px; height: 40px; font-size: 18px; }
|
.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 charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>收藏详情 | MilesYang时光助手</title>
|
<title>收藏详情 | MilesYang时光助手</title>
|
||||||
<link rel="stylesheet" href="/common/common.css">
|
<link rel="stylesheet" href="/common/common.css?v=1.10">
|
||||||
<link rel="stylesheet" href="../../shared.css">
|
<link rel="stylesheet" href="../../shared.css?v=1.10">
|
||||||
<link rel="stylesheet" href="detail.css">
|
<link rel="stylesheet" href="detail.css?v=1.10">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
@@ -43,8 +43,8 @@
|
|||||||
<div id="modal-slot"></div>
|
<div id="modal-slot"></div>
|
||||||
<div id="interaction-dialog" class="interaction-dialog hidden"></div>
|
<div id="interaction-dialog" class="interaction-dialog hidden"></div>
|
||||||
|
|
||||||
<script src="../../api.js"></script>
|
<script src="../../api.js?v=1.10"></script>
|
||||||
<script src="../../kits/common/dialog.js"></script>
|
<script src="../../kits/common/dialog.js?v=1.10"></script>
|
||||||
<script src="detail.js"></script>
|
<script src="detail.js?v=1.10"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -21,6 +21,101 @@
|
|||||||
const formatTime = dlg.formatTime
|
const formatTime = dlg.formatTime
|
||||||
const humanSize = dlg.humanSize
|
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 q = new URLSearchParams(location.search)
|
||||||
const id = q.get('id')
|
const id = q.get('id')
|
||||||
let current = null
|
let current = null
|
||||||
@@ -98,17 +193,17 @@
|
|||||||
const filePath = a.filePath || a.file_path
|
const filePath = a.filePath || a.file_path
|
||||||
return {
|
return {
|
||||||
src: a.url || api.fileUrl(filePath),
|
src: a.url || api.fileUrl(filePath),
|
||||||
name: a.fileName || a.file_name || ''
|
name: fixFileName(a.fileName || a.file_name || '')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
`<section class="detail-section">` +
|
`<section class="detail-section detail-card detail-card-images">` +
|
||||||
`<div class="detail-section-title">图片</div>` +
|
`<div class="detail-section-title">图片</div>` +
|
||||||
`<div class="image-grid">` +
|
`<div class="image-grid">` +
|
||||||
images.map((a, i) => {
|
images.map((a, i) => {
|
||||||
const filePath = a.filePath || a.file_path
|
const filePath = a.filePath || a.file_path
|
||||||
const src = a.url || api.fileUrl(filePath)
|
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)
|
const badge = (name && name.length > 4)
|
||||||
? `<div class="name-badge">${escapeHtml(name)}</div>` : ''
|
? `<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>`
|
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) {
|
function renderFileSection(files) {
|
||||||
if (!files || !files.length) return ''
|
if (!files || !files.length) return ''
|
||||||
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="detail-section-title">文件 (${files.length})</div>` +
|
||||||
`<div class="file-list">` +
|
`<div class="file-list">` +
|
||||||
files.map(a => {
|
files.map(a => {
|
||||||
const filePath = a.filePath || a.file_path
|
const filePath = a.filePath || a.file_path
|
||||||
const src = a.url || api.fileUrl(filePath)
|
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 size = Number(a.fileSize || a.file_size || 0)
|
||||||
const mimeType = a.mimeType || a.mime_type || ''
|
const mimeType = a.mimeType || a.mime_type || ''
|
||||||
return (
|
return (
|
||||||
@@ -161,15 +256,15 @@
|
|||||||
: ''
|
: ''
|
||||||
|
|
||||||
const remarkBlock = (item.remark || '').trim()
|
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 detail-card detail-card-remark"><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 empty">(无备注)</div></section>`
|
||||||
|
|
||||||
const titleBlock = dispTitle
|
const titleBlock = dispTitle
|
||||||
? `<section class="detail-section"><div class="detail-title">${escapeHtml(dispTitle)}</div></section>`
|
? `<section class="detail-section detail-card detail-card-title"><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 empty">(该条收藏未设置标题)</div></section>`
|
||||||
|
|
||||||
const metaBlock =
|
const metaBlock =
|
||||||
`<section class="detail-section">` +
|
`<section class="detail-section detail-card detail-card-meta">` +
|
||||||
`<div class="detail-meta">` +
|
`<div class="detail-meta">` +
|
||||||
`<span><b>创建时间</b>${formatTime(createdAt)}</span>` +
|
`<span><b>创建时间</b>${formatTime(createdAt)}</span>` +
|
||||||
`<span><b>更新时间</b>${formatTime(updatedAt)}</span>` +
|
`<span><b>更新时间</b>${formatTime(updatedAt)}</span>` +
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>新增 / 编辑收藏 | MilesYang时光助手</title>
|
<title>新增 / 编辑收藏 | MilesYang时光助手</title>
|
||||||
<link rel="stylesheet" href="/common/common.css">
|
<link rel="stylesheet" href="/common/common.css?v=1.10">
|
||||||
<link rel="stylesheet" href="../../shared.css">
|
<link rel="stylesheet" href="../../shared.css?v=1.10">
|
||||||
<link rel="stylesheet" href="edit.css">
|
<link rel="stylesheet" href="edit.css?v=1.10">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
@@ -70,8 +70,8 @@
|
|||||||
<div id="modal-slot"></div>
|
<div id="modal-slot"></div>
|
||||||
<div id="interaction-dialog" class="interaction-dialog hidden"></div>
|
<div id="interaction-dialog" class="interaction-dialog hidden"></div>
|
||||||
|
|
||||||
<script src="../../api.js"></script>
|
<script src="../../api.js?v=1.10"></script>
|
||||||
<script src="../../kits/common/dialog.js"></script>
|
<script src="../../kits/common/dialog.js?v=1.10"></script>
|
||||||
<script src="edit.js"></script>
|
<script src="edit.js?v=1.10"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -20,6 +20,100 @@
|
|||||||
const escapeHtml = dlg.escapeHtml
|
const escapeHtml = dlg.escapeHtml
|
||||||
const humanSize = dlg.humanSize
|
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 q = new URLSearchParams(location.search)
|
||||||
const editId = q.get('id')
|
const editId = q.get('id')
|
||||||
const isEdit = !!editId
|
const isEdit = !!editId
|
||||||
@@ -81,17 +175,18 @@
|
|||||||
const tempId = a.tempId
|
const tempId = a.tempId
|
||||||
const isImg = !!a.isImage
|
const isImg = !!a.isImage
|
||||||
const displayUrl = a.url || a.previewUrl || ''
|
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 statusTextMap = { pending: '等待上传', uploading: '上传中', ok: '上传成功', err: '上传失败' }
|
||||||
const statusText = statusTextMap[a.status] || a.status
|
const statusText = statusTextMap[a.status] || a.status
|
||||||
const showProgress = a.status === 'uploading'
|
const showProgress = a.status === 'uploading'
|
||||||
return (
|
return (
|
||||||
`<div class="attach-item ${isImg ? 'image' : 'file'}" data-temp="${escapeHtml(tempId)}">` +
|
`<div class="attach-item ${isImg ? 'image' : 'file'}" data-temp="${escapeHtml(tempId)}">` +
|
||||||
(isImg
|
(isImg
|
||||||
? `<div class="preview"><img src="${displayUrl}" alt="${escapeHtml(a.fileName || '')}" loading="lazy"></div>`
|
? `<div class="preview"><img src="${displayUrl}" alt="${escapeHtml(fn || '')}" loading="lazy"></div>`
|
||||||
: `<div class="preview"><span class="big">${fileIcon(a.fileName)}</span><span class="ext">${ext || 'FILE'}</span></div>`) +
|
: `<div class="preview"><span class="big">${fileIcon(fn)}</span><span class="ext">${ext || 'FILE'}</span></div>`) +
|
||||||
`<div class="attach-info">` +
|
`<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">` +
|
`<div class="attach-meta">` +
|
||||||
`<span>${humanSize(a.fileSize)}</span>` +
|
`<span>${humanSize(a.fileSize)}</span>` +
|
||||||
`<span class="attach-status ${a.status}">${statusText}</span>` +
|
`<span class="attach-status ${a.status}">${statusText}</span>` +
|
||||||
@@ -212,7 +307,7 @@
|
|||||||
let title = ''
|
let title = ''
|
||||||
if (total === 1) {
|
if (total === 1) {
|
||||||
const only = atts[0]
|
const only = atts[0]
|
||||||
const raw = only.fileName || '附件'
|
const raw = fixFileName(only.fileName || '附件')
|
||||||
const dot = raw.lastIndexOf('.')
|
const dot = raw.lastIndexOf('.')
|
||||||
const stem = dot > 0 ? raw.slice(0, dot) : raw
|
const stem = dot > 0 ? raw.slice(0, dot) : raw
|
||||||
if (only.isImage) title = `图片 - ${ts}`
|
if (only.isImage) title = `图片 - ${ts}`
|
||||||
@@ -220,7 +315,7 @@
|
|||||||
} else if (ic > 0 && fc === 0) title = `${ic} 张图片 - ${ts}`
|
} else if (ic > 0 && fc === 0) title = `${ic} 张图片 - ${ts}`
|
||||||
else if (fc > 0 && ic === 0) {
|
else if (fc > 0 && ic === 0) {
|
||||||
if (fc === 1) {
|
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
|
const dot = raw.lastIndexOf('.'); const stem = dot > 0 ? raw.slice(0, dot) : raw
|
||||||
title = stem || raw
|
title = stem || raw
|
||||||
} else title = `${fc} 个文件 - ${ts}`
|
} else title = `${fc} 个文件 - ${ts}`
|
||||||
@@ -230,7 +325,7 @@
|
|||||||
if (ic) lines.push(`图片:${ic} 张`)
|
if (ic) lines.push(`图片:${ic} 张`)
|
||||||
if (fc) lines.push(`文件:${fc} 个`)
|
if (fc) lines.push(`文件:${fc} 个`)
|
||||||
lines.push('', '附件清单:')
|
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} 个附件未列出`)
|
if (atts.length > 9) lines.push(` ... 另有 ${atts.length - 9} 个附件未列出`)
|
||||||
lines.push('', `整理时间:${ts}`)
|
lines.push('', `整理时间:${ts}`)
|
||||||
return { title, remark: lines.join('\n') }
|
return { title, remark: lines.join('\n') }
|
||||||
@@ -440,7 +535,7 @@
|
|||||||
filePath: a.filePath || a.file_path || '',
|
filePath: a.filePath || a.file_path || '',
|
||||||
mimeType: a.mimeType || a.mime_type || '',
|
mimeType: a.mimeType || a.mime_type || '',
|
||||||
isImage: typeof a.isImage !== 'undefined' ? !!a.isImage : !!a.is_image,
|
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),
|
fileSize: Number(a.fileSize || a.file_size || 0),
|
||||||
previewUrl: '',
|
previewUrl: '',
|
||||||
url: a.url || api.fileUrl(a.filePath || a.file_path || ''),
|
url: a.url || api.fileUrl(a.filePath || a.file_path || ''),
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>微信收藏夹 | MilesYang时光助手</title>
|
<title>微信收藏夹 | MilesYang时光助手</title>
|
||||||
<link rel="stylesheet" href="/common/common.css">
|
<link rel="stylesheet" href="/common/common.css?v=1.10">
|
||||||
<link rel="stylesheet" href="../../shared.css">
|
<link rel="stylesheet" href="../../shared.css?v=1.10">
|
||||||
<link rel="stylesheet" href="list.css">
|
<link rel="stylesheet" href="list.css?v=1.10">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
@@ -38,8 +38,8 @@
|
|||||||
<div id="modal-slot"></div>
|
<div id="modal-slot"></div>
|
||||||
<div id="interaction-dialog" class="interaction-dialog hidden"></div>
|
<div id="interaction-dialog" class="interaction-dialog hidden"></div>
|
||||||
|
|
||||||
<script src="../../api.js"></script>
|
<script src="../../api.js?v=1.10"></script>
|
||||||
<script src="../../kits/common/dialog.js"></script>
|
<script src="../../kits/common/dialog.js?v=1.10"></script>
|
||||||
<script src="list.js"></script>
|
<script src="list.js?v=1.10"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -16,6 +16,100 @@
|
|||||||
const formatTime = dlg.formatTime
|
const formatTime = dlg.formatTime
|
||||||
const humanSize = dlg.humanSize
|
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 goBack() { window.location.href = '../../index.html' }
|
||||||
function goAdd() { window.location.href = 'edit.html' }
|
function goAdd() { window.location.href = 'edit.html' }
|
||||||
function goEdit(id) { window.location.href = 'edit.html?id=' + encodeURIComponent(id) }
|
function goEdit(id) { window.location.href = 'edit.html?id=' + encodeURIComponent(id) }
|
||||||
@@ -43,14 +137,16 @@
|
|||||||
const tiles = []
|
const tiles = []
|
||||||
images.slice(0, 4).forEach((a) => {
|
images.slice(0, 4).forEach((a) => {
|
||||||
const url = api.fileUrl(a.url || a.filePath)
|
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)
|
const fileSlots = Math.max(0, 4 - tiles.length)
|
||||||
files.slice(0, fileSlots).forEach((a) => {
|
files.slice(0, fileSlots).forEach((a) => {
|
||||||
|
const fn = fixFileName(a.fileName)
|
||||||
tiles.push(
|
tiles.push(
|
||||||
`<div class="thumb file" title="${escapeHtml(a.fileName || '')}">` +
|
`<div class="thumb file" title="${escapeHtml(fn || '')}">` +
|
||||||
`<span class="icon">${fileIcon(a.fileName)}</span>` +
|
`<span class="icon">${fileIcon(fn)}</span>` +
|
||||||
`<span class="name">${escapeHtml(a.fileName || '未命名')}</span>` +
|
`<span class="name">${escapeHtml(fn || '未命名')}</span>` +
|
||||||
`</div>`
|
`</div>`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -150,6 +150,22 @@ app.use((req, res, next) => {
|
|||||||
})
|
})
|
||||||
app.use(express.json({ limit: '50mb' }))
|
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 = () => {
|
const readLocalCreds = () => {
|
||||||
try {
|
try {
|
||||||
const os = require('os')
|
const os = require('os')
|
||||||
@@ -318,6 +334,34 @@ const ensureMpTimeAssistantLoaded = () => {
|
|||||||
}
|
}
|
||||||
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 CALENDAR_VOICE_AUTH_COOKIE = 'calendar_voice_gate'
|
||||||
const readCalendarVoiceAuthConfig = () => {
|
const readCalendarVoiceAuthConfig = () => {
|
||||||
try {
|
try {
|
||||||
@@ -903,6 +947,11 @@ const hasWebMpTimeAssistantAuth = (req) => {
|
|||||||
try {
|
try {
|
||||||
const config = readWebMpTimeAssistantAuthConfig()
|
const config = readWebMpTimeAssistantAuthConfig()
|
||||||
if (config.enable_auth === false) return true
|
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 || '')
|
const cookies = parseCookie(req.headers.cookie || '')
|
||||||
if (cookies[getWebMpTimeAssistantCookieName()] === '1') return true
|
if (cookies[getWebMpTimeAssistantCookieName()] === '1') return true
|
||||||
return false
|
return false
|
||||||
@@ -972,10 +1021,29 @@ app.use('/api/mp_time_assistant', (req, res, next) => {
|
|||||||
if (hasWebMpTimeAssistantAuth(req)) return next()
|
if (hasWebMpTimeAssistantAuth(req)) return next()
|
||||||
return res.status(401).json({ ok: false, error: 'unauthorized' })
|
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) => {
|
app.use('/uploads/mp_time_assistant', (req, res, next) => {
|
||||||
if (hasWebMpTimeAssistantAuth(req)) return next()
|
if (hasWebMpTimeAssistantAuth(req)) return next()
|
||||||
return res.status(401).send('未授权')
|
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 PLANT_HOME_AUTH_COOKIE = 'plant_home_gate'
|
||||||
const readPlantHomeAuthConfig = () => {
|
const readPlantHomeAuthConfig = () => {
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
const Database = require('better-sqlite3')
|
const Database = require('better-sqlite3')
|
||||||
const path = require('path')
|
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')
|
const CORE_DB_PATH = path.join(process.cwd(), 'data', 'mp_time_assistant_core.db')
|
||||||
|
|
||||||
@@ -10,7 +9,16 @@ const KIT_META = [
|
|||||||
{
|
{
|
||||||
id: 'personal_collect',
|
id: 'personal_collect',
|
||||||
name: '微信收藏夹',
|
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
activeKit: row.active_kit || null,
|
activeKit: row.active_kit || null,
|
||||||
|
active_kit: row.active_kit || null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +74,10 @@ const bindRoutes = app => {
|
|||||||
appName: 'MpTimeAssistant',
|
appName: 'MpTimeAssistant',
|
||||||
appNameZh: 'MilesYang时光助手',
|
appNameZh: 'MilesYang时光助手',
|
||||||
kits: KIT_META,
|
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) => {
|
app.put('/api/mp_time_assistant/state/active-kit', (req, res) => {
|
||||||
try {
|
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
|
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)
|
setActiveKit(nextKit)
|
||||||
return res.json({ ok: true, activeKit: nextKit })
|
return res.json({ ok: true, activeKit: nextKit, active_kit: nextKit })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return res.status(500).json({ ok: false, error: String(e.message || e) })
|
return res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
bindPersonalCollectRoutes(app)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { bindRoutes }
|
module.exports = { bindRoutes }
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,131 @@ const crypto = require('crypto')
|
|||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const multer = require('multer')
|
const multer = require('multer')
|
||||||
|
const iconv = require('iconv-lite')
|
||||||
const store = require('./store')
|
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 UPLOAD_ROOT = path.join(process.cwd(), 'uploads', 'mp_time_assistant', 'personal_collect')
|
||||||
const API_PREFIX = '/api/mp_time_assistant/kits/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 relPath = path.join(relDir, f.filename).replace(/\\/g, '/')
|
||||||
const urlPath = '/' + relPath
|
const urlPath = '/' + relPath
|
||||||
const mimeType = String(f.mimetype || '').trim()
|
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({
|
const saved = store.insertAttachment({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
favoriteId: null,
|
favoriteId: null,
|
||||||
fileName: f.originalname,
|
fileName: originalName,
|
||||||
storedName: f.filename,
|
storedName: f.filename,
|
||||||
filePath: relPath,
|
filePath: relPath,
|
||||||
fileSize: f.size || 0,
|
fileSize: f.size || 0,
|
||||||
|
|||||||
@@ -1,5 +1,130 @@
|
|||||||
const Database = require('better-sqlite3')
|
const Database = require('better-sqlite3')
|
||||||
const path = require('path')
|
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')
|
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,
|
updatedAt: row.updated_at,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mapAttachment = row => ({
|
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,
|
id: row.id,
|
||||||
favoriteId: row.favorite_id,
|
favoriteId: row.favorite_id,
|
||||||
fileName: row.file_name,
|
fileName: fixed,
|
||||||
storedName: row.stored_name,
|
storedName: row.stored_name,
|
||||||
filePath: row.file_path,
|
filePath: row.file_path,
|
||||||
fileSize: Number(row.file_size || 0),
|
fileSize: Number(row.file_size || 0),
|
||||||
mimeType: row.mime_type,
|
mimeType: row.mime_type,
|
||||||
isImage: !!row.is_image,
|
isImage: !!row.is_image,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const listItems = ({ keyword = '' } = {}) => {
|
const listItems = ({ keyword = '' } = {}) => {
|
||||||
const kw = String(keyword || '').trim()
|
const kw = String(keyword || '').trim()
|
||||||
|
|||||||
Reference in New Issue
Block a user