可信源码风险提示:保存或下载前请阅读源码和权限声明。
const DIR = 'internal://files/activity/';
const MAX_FILES = 8;
const MAX_HISTORY = 20;
const MIN_STEP_GAP = 260;
const SENS = { 低: 1.4, 标准: 1.0, 高: 0.7 };
const S = {
mode: 'idle',
startedAt: 0, pausedMs: 0, pausedAt: 0,
steps: 0, goal: 3000, sens: '标准',
gravity: 0, rms: 0, smooth: 0, prevDyn: 0, lastStepAt: 0,
stepTimes: [], samples: 0, peakDyn: 0,
p0: null, p1: null,
log: [],
history: [],
goalHit: false, ticker: null,
};
const tick = ui.signal(0);
const touch = () => tick.update(n => n + 1);
const alertBox = msg => dialog.alert({ title: '运动实验室', message: String(msg) });
const confirmBox = async msg => {
const r = await dialog.confirm({ title: '运动实验室', message: msg });
return r === true || !!(r && r.action && r.action !== 'cancel' && r.action !== 'dismiss');
};
const pad = n => (n < 10 ? '0' : '') + n;
const clock = ms => {
const s = Math.floor(ms / 1000);
return pad(Math.floor(s / 60)) + ':' + pad(s % 60);
};
const elapsed = () => {
if (!S.startedAt) return 0;
const end = S.mode === 'paused' ? S.pausedAt : Date.now();
return Math.max(0, end - S.startedAt - S.pausedMs);
};
const bar = (v, max, w) => {
const n = Math.max(0, Math.min(w, Math.round((v / max) * w)));
return '#'.repeat(n) + '-'.repeat(w - n);
};
const cadence = () => {
const t = S.stepTimes;
if (t.length < 4) return 0;
const span = t[t.length - 1] - t[0];
return span > 0 ? Math.round(((t.length - 1) * 60000) / span) : 0;
};
const intensity = c =>
c === 0 ? '静止' : c < 70 ? '慢走' : c < 110 ? '步行' : c < 140 ? '快走' : '跑步';
const altitudeDelta = () =>
S.p0 && S.p1 ? ((S.p0 - S.p1) * 8.43).toFixed(1) + ' 米' : '无数据';
const can = name => typeof script.canUse === 'function' && script.canUse(name);
const buzz = mode => { try { system.vibration.vibrate({ mode }); } catch (e) {} };
function onSample(d) {
const now = Date.now();
const m = Math.sqrt(d.x * d.x + d.y * d.y + d.z * d.z);
S.samples++;
if (!S.gravity) S.gravity = m;
S.gravity += 0.02 * (m - S.gravity);
const dyn = m - S.gravity;
S.smooth += 0.35 * (dyn - S.smooth);
S.rms += 0.01 * (S.smooth * S.smooth - S.rms);
const thr = Math.max(0.05 * S.gravity, Math.sqrt(S.rms) * 1.1) * SENS[S.sens];
if (S.prevDyn < thr && S.smooth >= thr && now - S.lastStepAt > MIN_STEP_GAP) {
S.steps++;
S.lastStepAt = now;
S.stepTimes.push(now);
if (S.stepTimes.length > 12) S.stepTimes.shift();
S.peakDyn = Math.max(S.peakDyn, S.smooth);
if (S.steps % 100 === 0) buzz('short');
if (!S.goalHit && S.steps >= S.goal) { S.goalHit = true; buzz('long'); script.toast('已达成目标!', 2000); }
}
S.prevDyn = S.smooth;
if (S.samples % 5 === 0 && S.log.length < 1500) {
S.log.push([now - S.startedAt, S.smooth.toFixed(3), S.steps]);
}
if (S.stepTimes.length && now - S.lastStepAt > 3000) S.stepTimes = [];
}
function start() {
if (!can('@system.sensor.subscribeAccelerometer')) {
return alertBox('当前设备不支持加速度传感器。');
}
Object.assign(S, {
mode: 'running', startedAt: Date.now(), pausedMs: 0, steps: 0, gravity: 0,
rms: 0, smooth: 0, prevDyn: 0, lastStepAt: 0, stepTimes: [], samples: 0,
peakDyn: 0, p0: null, p1: null, log: [], goalHit: false,
});
system.sensors.subscribeAccelerometer({
interval: 'ui',
callback: onSample,
fail: (msg, code) => console.error('加速度传感器异常', code, msg),
});
if (can('@system.sensor.subscribePressure')) {
system.sensors.subscribePressure({
callback: r => { if (S.p0 === null) S.p0 = r.pressure; S.p1 = r.pressure; },
});
}
S.ticker = setInterval(touch, 1000);
buzz('short');
touch();
}
function pauseResume() {
if (S.mode === 'running') {
S.mode = 'paused'; S.pausedAt = Date.now();
system.sensors.unsubscribeAccelerometer();
} else if (S.mode === 'paused') {
S.pausedMs += Date.now() - S.pausedAt; S.mode = 'running';
S.prevDyn = 0; S.smooth = 0;
system.sensors.subscribeAccelerometer({ interval: 'ui', callback: onSample });
}
touch();
}
async function stop() {
const ok = await confirmBox('结束并保存本次运动记录?');
if (!ok) return;
if (S.mode === 'running') system.sensors.unsubscribeAccelerometer();
if (can('@system.sensor.subscribePressure')) system.sensors.unsubscribePressure();
clearInterval(S.ticker);
const dur = elapsed();
const c = dur > 0 ? Math.round((S.steps * 60000) / dur) : 0;
const rec = {
id: system.crypto.hashDigest({ data: S.startedAt + ':' + S.steps, algo: 'SHA256' }).slice(0, 8),
at: S.startedAt, dur, steps: S.steps, avgCadence: c,
kind: intensity(c), alt: altitudeDelta(),
};
S.mode = 'idle';
await saveHistory(rec);
await exportCsv(rec);
syncToPhone(rec);
await alertBox(`${rec.steps} 步\n${clock(dur)} ${c}/分钟\n${rec.kind}`);
touch();
}
async function loadState() {
S.history = JSON.parse(await script.data.get('history', '[]'));
S.goal = Number(await script.data.get('goal', 3000)) || 3000;
const sv = await script.data.get('sens', '标准');
S.sens = sv in SENS ? sv : '标准';
}
async function saveHistory(rec) {
S.history.unshift(rec);
S.history = S.history.slice(0, MAX_HISTORY);
await script.data.set('history', JSON.stringify(S.history));
}
function call(fn, opts) {
return new Promise(res => fn(Object.assign({}, opts, {
success: d => res({ ok: true, d }), fail: (m, c) => res({ ok: false, m, c }),
})));
}
async function exportCsv(rec) {
await call(system.files.mkdir, { uri: DIR, recursive: true });
const head = 't_ms,dyn,steps\n';
const body = S.log.map(r => r.join(',')).join('\n');
const r = await call(system.files.writeText, {
uri: DIR + rec.id + '.csv', text: head + body + '\n',
});
if (!r.ok) console.warn('CSV文件写入失败', r.c);
const l = await call(system.files.list, { uri: DIR });
if (l.ok && l.d.fileList.length > MAX_FILES) {
const old = l.d.fileList.sort((a, b) => a.lastModifiedTime - b.lastModifiedTime)
.slice(0, l.d.fileList.length - MAX_FILES);
for (const f of old) await call(system.files.delete, { uri: f.uri });
}
}
function syncToPhone(rec) {
try {
const conn = system.companion.instance();
conn.send({ data: { type: 'session', rec }, fail: () => {} });
} catch (e) {}
}
async function settings() {
const g = await dialog.number({ title: '每日步数目标', value: S.goal, min: 100, max: 100000, decimals: 0 });
if (g && g.action !== 'cancel' && g.value > 0) {
S.goal = Math.round(g.value);
await script.data.set('goal', String(S.goal));
}
const r = await dialog.select({
title: '灵敏度',
items: Object.keys(SENS).map(k => ({ label: k, value: k })),
});
const pick = r && r.action !== 'cancel' ? (Array.isArray(r.value) ? r.value[0] : r.value) : null;
if (pick && SENS[pick]) { S.sens = pick; await script.data.set('sens', pick); }
touch();
}
async function showHistory() {
if (!S.history.length) return alertBox('暂无运动记录。');
const total = S.history.reduce((a, r) => a + r.steps, 0);
const best = S.history.reduce((a, r) => (r.steps > a.steps ? r : a), S.history[0]);
const lines = S.history.slice(0, 5).map(r => {
const d = new Date(r.at);
return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${r.steps} ${r.kind}`;
});
await alertBox(lines.join('\n') + `\n总步数 ${total}\n最高单次 ${best.steps}`);
}
function screen() {
tick.get();
const c = cadence();
const live = S.mode !== 'idle';
const pct = Math.min(100, Math.round((S.steps / S.goal) * 100));
const nodes = [
ui.heading(live ? S.steps + ' 步' : '运动实验室', { id: 'title' }),
ui.text(live
? `${clock(elapsed())} ${c}/分钟 ${intensity(c)}`
: `目标 ${S.goal} 灵敏度 ${S.sens}`, { id: 'line1', lines: 1 }),
ui.progress('目标 ' + pct + '%', pct, { id: 'goal-progress' }),
];
if (live) nodes.push(ui.text('海拔变化 ' + altitudeDelta() + ' 峰值 ' + S.peakDyn.toFixed(2), { id: 'line2', lines: 1 }));
if (S.mode === 'idle') {
nodes.push(ui.button('开始', start, { id: 'start', tone: 'success' }));
nodes.push(ui.button('历史记录', showHistory, { id: 'history' }));
nodes.push(ui.button('设置', settings, { id: 'settings' }));
} else {
nodes.push(ui.button(S.mode === 'running' ? '暂停' : '继续', pauseResume, { id: 'pause', tone: 'warning' }));
nodes.push(ui.button('结束', stop, { id: 'finish', tone: 'danger' }));
}
return nodes;
}
(async function main() {
ui.setTitle('运动实验室');
ui.render(screen);
try { await loadState(); touch(); } catch (e) { console.error('读取存储失败', e); }
})();