0.0.0
This commit is contained in:
226
miniprogram/pages/walking/walking.js
Normal file
226
miniprogram/pages/walking/walking.js
Normal file
@@ -0,0 +1,226 @@
|
||||
// pages/walking/walking — 2/2b 遛狗中(进行/暂停同页)
|
||||
// 计时基于时间戳差值(切后台不丢,R5);GPS 距离可选累计、按 stride 换算狗步(R3/R6)
|
||||
const { call } = require('../../utils/cloud.js');
|
||||
const store = require('../../utils/store.js');
|
||||
const { formatDuration } = require('../../utils/format.js');
|
||||
const { BASE_STRIDE, dogStepsFromMeters } = require('../../utils/dogStep.js');
|
||||
|
||||
// 两点球面距离(米)
|
||||
function haversine(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371000;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a = Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(a));
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
status: 'idle', // idle | selecting | walking | paused
|
||||
timeText: '00:00',
|
||||
pooped: false,
|
||||
dogs: [],
|
||||
selectedIds: [],
|
||||
selectedMap: {}, // id→true:供 WXML 判断选中(WXML 表达式不能调用 indexOf)
|
||||
activeDogNames: '',
|
||||
showEndConfirm: false,
|
||||
gps: 'off', // off | on | denied
|
||||
distanceText: '—',
|
||||
stepsText: '—',
|
||||
},
|
||||
|
||||
_timer: null,
|
||||
_locOn: false,
|
||||
_locCb: null,
|
||||
_lastPoint: null,
|
||||
_stride: 0.35,
|
||||
|
||||
onLoad() {
|
||||
const dogs = store.getState().dogs || [];
|
||||
this.setData({ dogs });
|
||||
|
||||
const session = store.getWalkSession();
|
||||
if (session) { this.resumeFrom(session); return; }
|
||||
if (dogs.length === 0) {
|
||||
wx.showToast({ title: '先添加一只狗狗', icon: 'none' });
|
||||
setTimeout(() => wx.navigateBack(), 1200);
|
||||
return;
|
||||
}
|
||||
if (dogs.length === 1) this.startSession([dogs[0]._id]);
|
||||
else {
|
||||
const def = store.getState().currentDogId || dogs[0]._id;
|
||||
this.setData({ status: 'selecting', selectedIds: [def], selectedMap: this.buildSelectMap([def]) });
|
||||
}
|
||||
},
|
||||
|
||||
onUnload() { this.clearTimer(); this.stopLocation(); },
|
||||
|
||||
// ===== 多狗选择 =====
|
||||
// WXML 表达式不支持方法调用(如 selectedIds.indexOf),用 id→true 映射给视图层判断选中
|
||||
buildSelectMap(ids) {
|
||||
const map = {};
|
||||
ids.forEach((id) => { map[id] = true; });
|
||||
return map;
|
||||
},
|
||||
toggleSelect(e) {
|
||||
const id = e.currentTarget.dataset.id;
|
||||
const ids = this.data.selectedIds.slice();
|
||||
const i = ids.indexOf(id);
|
||||
if (i >= 0) ids.splice(i, 1); else ids.push(id);
|
||||
this.setData({ selectedIds: ids, selectedMap: this.buildSelectMap(ids) });
|
||||
},
|
||||
confirmSelect() {
|
||||
if (!this.data.selectedIds.length) return wx.showToast({ title: '至少选一只', icon: 'none' });
|
||||
this.startSession(this.data.selectedIds);
|
||||
},
|
||||
|
||||
// ===== 会话生命周期 =====
|
||||
startSession(dogIds) {
|
||||
const session = { dogIds, startAt: Date.now(), pausedTotal: 0, pausedAt: null, pooped: false, meters: 0, status: 'walking' };
|
||||
store.setWalkSession(session);
|
||||
store.set({ currentDogId: dogIds[0] });
|
||||
this.setActiveNames(dogIds);
|
||||
this.setData({ status: 'walking', pooped: false });
|
||||
this.startTimer();
|
||||
this.startLocation();
|
||||
},
|
||||
|
||||
resumeFrom(session) {
|
||||
this.setActiveNames(session.dogIds);
|
||||
this.setData({ status: session.status, pooped: session.pooped });
|
||||
if (session.status === 'walking') { this.startTimer(); this.startLocation(); }
|
||||
else this.renderTime();
|
||||
this.refreshGps();
|
||||
},
|
||||
|
||||
setActiveNames(dogIds) {
|
||||
const map = {};
|
||||
(this.data.dogs || []).forEach((d) => { map[d._id] = d.name; });
|
||||
this.setData({ activeDogNames: dogIds.map((id) => map[id] || '狗狗').join('、') });
|
||||
},
|
||||
|
||||
// ===== 计时(时间戳差值)=====
|
||||
elapsedSec() {
|
||||
const s = store.getWalkSession();
|
||||
if (!s) return 0;
|
||||
const end = s.status === 'paused' ? s.pausedAt : Date.now();
|
||||
return Math.max(0, Math.floor((end - s.startAt - s.pausedTotal) / 1000));
|
||||
},
|
||||
renderTime() { this.setData({ timeText: formatDuration(this.elapsedSec()) }); },
|
||||
startTimer() { this.clearTimer(); this.renderTime(); this._timer = setInterval(() => this.renderTime(), 1000); },
|
||||
clearTimer() { if (this._timer) { clearInterval(this._timer); this._timer = null; } },
|
||||
|
||||
// ===== GPS 距离累计(可选增强,R6)=====
|
||||
startLocation() {
|
||||
if (this._locOn) return;
|
||||
const s = store.getWalkSession();
|
||||
const primary = (this.data.dogs || []).find((d) => s && d._id === s.dogIds[0]);
|
||||
this._stride = (primary && (primary.stride || BASE_STRIDE[primary.sizeLevel])) || 0.35;
|
||||
this._lastPoint = null;
|
||||
this._locCb = (res) => this.onLoc(res);
|
||||
wx.startLocationUpdate({
|
||||
success: () => {
|
||||
this._locOn = true;
|
||||
wx.onLocationChange(this._locCb);
|
||||
this.setData({ gps: 'on' });
|
||||
this.refreshGps();
|
||||
},
|
||||
fail: () => { this.setData({ gps: 'denied' }); }, // 未授权 → 降级,只保留时长
|
||||
});
|
||||
},
|
||||
onLoc(res) {
|
||||
const s = store.getWalkSession();
|
||||
if (!s || s.status !== 'walking') { this._lastPoint = null; return; } // 暂停不累计
|
||||
if (res.accuracy && res.accuracy > 50) return; // 精度差丢弃
|
||||
const p = { lat: res.latitude, lon: res.longitude };
|
||||
if (this._lastPoint) {
|
||||
const seg = haversine(this._lastPoint.lat, this._lastPoint.lon, p.lat, p.lon);
|
||||
if (seg >= 2 && seg <= 50) { // 过滤静止抖动(<2m)与跳点(>50m)
|
||||
s.meters = (s.meters || 0) + seg;
|
||||
store.setWalkSession(s);
|
||||
this.refreshGps();
|
||||
}
|
||||
}
|
||||
this._lastPoint = p;
|
||||
},
|
||||
refreshGps() {
|
||||
if (this.data.gps !== 'on') return;
|
||||
const s = store.getWalkSession();
|
||||
const m = (s && s.meters) || 0;
|
||||
this.setData({
|
||||
distanceText: (m / 1000).toFixed(2),
|
||||
stepsText: String(dogStepsFromMeters(m, this._stride) || 0),
|
||||
});
|
||||
},
|
||||
stopLocation() {
|
||||
if (this._locOn) {
|
||||
wx.offLocationChange(this._locCb);
|
||||
wx.stopLocationUpdate();
|
||||
this._locOn = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ===== 便便打卡 =====
|
||||
togglePoop() {
|
||||
const s = store.getWalkSession();
|
||||
if (!s) return;
|
||||
s.pooped = !s.pooped;
|
||||
store.setWalkSession(s);
|
||||
this.setData({ pooped: s.pooped });
|
||||
},
|
||||
|
||||
// ===== 暂停 / 继续 =====
|
||||
pause() {
|
||||
const s = store.getWalkSession();
|
||||
if (!s || s.status !== 'walking') return;
|
||||
s.status = 'paused';
|
||||
s.pausedAt = Date.now();
|
||||
store.setWalkSession(s);
|
||||
this.clearTimer();
|
||||
this._lastPoint = null;
|
||||
this.setData({ status: 'paused' });
|
||||
this.renderTime();
|
||||
},
|
||||
resume() {
|
||||
const s = store.getWalkSession();
|
||||
if (!s || s.status !== 'paused') return;
|
||||
s.pausedTotal += Date.now() - s.pausedAt;
|
||||
s.pausedAt = null;
|
||||
s.status = 'walking';
|
||||
store.setWalkSession(s);
|
||||
this.setData({ status: 'walking' });
|
||||
this.startTimer();
|
||||
},
|
||||
|
||||
// ===== 结束 =====
|
||||
askEnd() { this.setData({ showEndConfirm: true }); },
|
||||
cancelEnd() { this.setData({ showEndConfirm: false }); },
|
||||
|
||||
async confirmEnd() {
|
||||
const s = store.getWalkSession();
|
||||
if (!s) return;
|
||||
this.clearTimer();
|
||||
this.stopLocation();
|
||||
const duration = this.elapsedSec();
|
||||
const endAt = Date.now();
|
||||
const distanceMeters = this.data.gps === 'on' ? Math.round(s.meters || 0) : null;
|
||||
this.setData({ showEndConfirm: false });
|
||||
|
||||
const res = await call('walkSave', {
|
||||
action: 'finish',
|
||||
dogIds: s.dogIds,
|
||||
startAt: s.startAt,
|
||||
endAt,
|
||||
duration,
|
||||
distanceMeters, // 米;未授权定位时为 null(R6)
|
||||
pooped: s.pooped,
|
||||
}, { loading: true, loadingText: '生成成果卡' });
|
||||
if (!res.ok) return;
|
||||
|
||||
store.setWalkSession(null);
|
||||
getApp().globalData.lastSummary = res.data;
|
||||
wx.redirectTo({ url: '/pages/summary/summary' });
|
||||
},
|
||||
});
|
||||
4
miniprogram/pages/walking/walking.json
Normal file
4
miniprogram/pages/walking/walking.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"usingComponents": {},
|
||||
"navigationBarTitleText": "遛狗中"
|
||||
}
|
||||
63
miniprogram/pages/walking/walking.wxml
Normal file
63
miniprogram/pages/walking/walking.wxml
Normal file
@@ -0,0 +1,63 @@
|
||||
<!-- ===== 多狗选择 ===== -->
|
||||
<view wx:if="{{status==='selecting'}}" class="select-wrap">
|
||||
<view class="select-title">今天遛哪只?</view>
|
||||
<view class="select-sub">可多选,一起遛</view>
|
||||
<view class="select-list">
|
||||
<view wx:for="{{dogs}}" wx:key="_id"
|
||||
class="card sel-row {{selectedMap[item._id]?'on':''}}"
|
||||
data-id="{{item._id}}" bindtap="toggleSelect">
|
||||
<view class="sel-avatar">🐶</view>
|
||||
<view class="sel-meta">
|
||||
<view class="sel-name">{{item.name}}</view>
|
||||
<view class="sel-breed">{{item.breed}}<text class="sel-days">· 连续 {{item.stats.currentStreak || 0}} 天</text></view>
|
||||
</view>
|
||||
<view class="sel-check">{{selectedMap[item._id] ? '✓' : ''}}</view>
|
||||
</view>
|
||||
</view>
|
||||
<button class="btn primary" bindtap="confirmSelect"><text class="btn-ico">🐾</text>开始遛狗</button>
|
||||
</view>
|
||||
|
||||
<!-- ===== 遛狗中 / 暂停 ===== -->
|
||||
<view wx:else class="walk {{status==='paused'?'paused':''}}">
|
||||
<view class="status-pill {{status}}">
|
||||
<text class="dot"></text>{{status==='paused' ? '已暂停' : '遛狗中'}}
|
||||
</view>
|
||||
|
||||
<view class="map">{{status==='paused' ? '计时已停止' : (gps==='on' ? '定位中 · 累计距离' : '地图轨迹(首版可后置)')}}</view>
|
||||
|
||||
<view class="timer-block">
|
||||
<view class="timer-label">已遛时长</view>
|
||||
<view class="timer">{{timeText}}</view>
|
||||
</view>
|
||||
|
||||
<view class="stat-grid">
|
||||
<view class="stat"><view class="stat-v">{{distanceText}}<text wx:if="{{gps==='on'}}" class="stat-u"> km</text></view><view class="stat-k">距离</view></view>
|
||||
<view class="stat"><view class="stat-v">{{stepsText}}</view><view class="stat-k">狗步</view></view>
|
||||
</view>
|
||||
<view wx:if="{{gps==='denied'}}" class="degrade-tip">未开启定位,仅记录时长(距离/狗步不计)</view>
|
||||
|
||||
<view class="poop-row">
|
||||
<text class="poop-label">💩 便便打卡</text>
|
||||
<view class="toggle {{pooped?'on':''}}" bindtap="togglePoop"><view class="knob"></view></view>
|
||||
</view>
|
||||
|
||||
<view class="actions">
|
||||
<button wx:if="{{status==='walking'}}" class="btn ghost" bindtap="pause"><text class="btn-ico">‖</text>暂停</button>
|
||||
<button wx:else class="btn primary" bindtap="resume"><text class="btn-ico">▶</text>继续</button>
|
||||
<button class="btn danger" bindtap="askEnd"><text class="btn-ico">■</text>结束</button>
|
||||
</view>
|
||||
|
||||
<view class="active-dog">🐶 {{activeDogNames}}</view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 结束二次确认 ===== -->
|
||||
<view wx:if="{{showEndConfirm}}" class="mask" bindtap="cancelEnd">
|
||||
<view class="confirm" catchtap="">
|
||||
<view class="confirm-title">结束这次遛狗?</view>
|
||||
<view class="confirm-text">已遛 {{timeText}},确认结束并生成成果卡。</view>
|
||||
<view class="confirm-btns">
|
||||
<button class="btn ghost" bindtap="cancelEnd">再遛会</button>
|
||||
<button class="btn primary" bindtap="confirmEnd">结束</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
61
miniprogram/pages/walking/walking.wxss
Normal file
61
miniprogram/pages/walking/walking.wxss
Normal file
@@ -0,0 +1,61 @@
|
||||
/* ===== 多狗选择 ===== */
|
||||
.select-wrap { padding: 40rpx 32rpx; }
|
||||
.select-title { font-size: 34rpx; font-weight: 600; text-align: center; }
|
||||
.select-sub { font-size: 24rpx; color: var(--ink-2); text-align: center; margin-top: 8rpx; margin-bottom: 28rpx; }
|
||||
.select-list { display: flex; flex-direction: column; gap: 20rpx; margin-bottom: 32rpx; }
|
||||
.sel-row { display: flex; align-items: center; gap: 20rpx; padding: 22rpx; border: 2rpx solid transparent; }
|
||||
.sel-row.on { border-color: var(--accent); }
|
||||
.sel-avatar { width: 80rpx; height: 80rpx; border-radius: 50%; background: var(--surface-2); display: flex; align-items: center; justify-content: center; font-size: 40rpx; }
|
||||
.sel-meta { flex: 1; }
|
||||
.sel-name { font-size: 28rpx; font-weight: 600; }
|
||||
.sel-breed { font-size: 22rpx; color: var(--ink-2); margin-top: 4rpx; }
|
||||
.sel-days { margin-left: 8rpx; color: var(--success); }
|
||||
.sel-check {
|
||||
width: 44rpx; height: 44rpx; border-radius: 50%;
|
||||
border: 2rpx solid var(--line-2); color: #fff;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 28rpx; line-height: 1;
|
||||
}
|
||||
.sel-row.on .sel-check { background: var(--accent); border-color: var(--accent); }
|
||||
|
||||
/* ===== 遛狗中 ===== */
|
||||
.walk { min-height: 100vh; padding: 24rpx 32rpx calc(48rpx + env(safe-area-inset-bottom)); }
|
||||
.status-pill { display: flex; align-items: center; gap: 10rpx; justify-content: center; font-size: 24rpx; font-weight: 600; color: var(--success); }
|
||||
.status-pill.paused { color: var(--warn); }
|
||||
.status-pill .dot { width: 16rpx; height: 16rpx; border-radius: 50%; background: currentColor; }
|
||||
|
||||
.map {
|
||||
height: 260rpx; margin-top: 20rpx;
|
||||
border-radius: var(--radius); background: var(--surface-2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 24rpx; color: var(--ink-3);
|
||||
}
|
||||
|
||||
.timer-block { text-align: center; padding: 36rpx 0 12rpx; }
|
||||
.timer-label { font-size: 24rpx; color: var(--ink-2); }
|
||||
.timer { font-size: 96rpx; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.1; }
|
||||
|
||||
.stat-grid { display: flex; gap: 20rpx; }
|
||||
.stat { flex: 1; background: var(--surface-2); border-radius: var(--radius-sm); padding: 22rpx 0; text-align: center; }
|
||||
.stat-v { font-size: 38rpx; font-weight: 600; }
|
||||
.stat-u { font-size: 22rpx; color: var(--ink-2); }
|
||||
.stat-k { font-size: 22rpx; color: var(--ink-2); margin-top: 4rpx; }
|
||||
.degrade-tip { font-size: 21rpx; color: var(--ink-3); text-align: center; margin-top: 14rpx; }
|
||||
|
||||
.paused .timer-block, .paused .stat-grid { opacity: 0.45; }
|
||||
|
||||
.poop-row { display: flex; align-items: center; justify-content: space-between; padding: 28rpx 4rpx; margin-top: 8rpx; border-top: 1rpx solid var(--line); }
|
||||
.poop-label { font-size: 26rpx; }
|
||||
.toggle { width: 76rpx; height: 44rpx; border-radius: var(--radius-pill); background: var(--line-2); position: relative; transition: background .2s; }
|
||||
.toggle.on { background: var(--success); }
|
||||
.toggle .knob { position: absolute; top: 4rpx; left: 4rpx; width: 36rpx; height: 36rpx; border-radius: 50%; background: #fff; transition: transform .2s; }
|
||||
.toggle.on .knob { transform: translateX(32rpx); }
|
||||
|
||||
.actions { display: flex; gap: 20rpx; margin-top: 16rpx; }
|
||||
.active-dog { margin-top: 28rpx; padding-top: 20rpx; border-top: 1rpx solid var(--line); font-size: 24rpx; color: var(--ink-2); }
|
||||
|
||||
/* ===== 结束确认 ===== */
|
||||
.mask { position: fixed; inset: 0; background: rgba(46,42,36,0.42); display: flex; align-items: center; justify-content: center; z-index: 50; }
|
||||
.confirm { width: 520rpx; background: var(--surface); border-radius: 28rpx; padding: 44rpx 36rpx 32rpx; text-align: center; }
|
||||
.confirm-title { font-size: 32rpx; font-weight: 600; }
|
||||
.confirm-text { font-size: 26rpx; color: var(--ink-2); margin: 16rpx 0 32rpx; line-height: 1.6; }
|
||||
.confirm-btns { display: flex; gap: 20rpx; }
|
||||
Reference in New Issue
Block a user