feat: rebuild pet app and backend around core care flows

This commit is contained in:
2026-10-01 13:11:30 +08:00
parent 51ead8f411
commit bfcf8788a4
321 changed files with 4918 additions and 25199 deletions
+165
View File
@@ -0,0 +1,165 @@
const { secure, toast } = require("../../utils/api");
Page({
data: {
pets: [],
selected: [],
walk: null,
points: [],
polyline: [],
latitude: 31.23,
longitude: 121.47,
duration: "00:00",
distance: "0.00",
share: false,
},
onShow() {
this.load();
this.clock = setInterval(() => this.tick(), 1000);
},
onHide() {
this.stopTimers();
},
onUnload() {
this.stopTimers();
},
stopTimers() {
clearInterval(this.clock);
clearInterval(this.tracker);
},
async load() {
try {
const [pets, walks] = await Promise.all([
secure("/pets"),
secure("/walks"),
]);
const selected = (wx.getStorageSync("walk_pet_ids") || []).filter((id) =>
pets.some((p) => p.id === id),
);
const walk =
walks.find((w) => w.status === "active" || w.status === "paused") ||
null;
this.setData({ pets, selected, walk });
if (walk) {
this.renderWalk(walk);
this.track();
}
} catch (e) {
toast(e);
}
},
pick(e) {
if (this.data.walk) return;
this.setData({ selected: e.detail.selected });
wx.setStorageSync("walk_pet_ids", e.detail.selected);
},
async start() {
if (!this.data.selected.length) {
wx.showToast({ title: "请选择同行的狗狗", icon: "none" });
return;
}
try {
const walk = await secure("/walks", "POST", {
pet_ids: this.data.selected,
});
this.setData({ walk });
this.track();
this.point();
} catch (e) {
toast(e);
}
},
track() {
clearInterval(this.tracker);
this.tracker = setInterval(() => this.point(), 15000);
},
async point() {
if (!this.data.walk || this.data.walk.status !== "active") return;
try {
const pos = await wx.getLocation({ type: "gcj02" });
const walk = await secure(
"/walks/" + this.data.walk.id + "/points",
"POST",
{ latitude: pos.latitude, longitude: pos.longitude },
);
this.renderWalk(walk);
} catch (e) {
console.warn(e);
}
},
renderWalk(walk) {
const pts = (walk.points || []).map((p) => ({
latitude: p.latitude,
longitude: p.longitude,
}));
const last = pts[pts.length - 1];
this.setData({
walk,
points: pts,
polyline:
pts.length > 1 ? [{ points: pts, color: "#268e6a", width: 5 }] : [],
latitude: last ? last.latitude : this.data.latitude,
longitude: last ? last.longitude : this.data.longitude,
distance: (walk.distance_meters / 1000).toFixed(2),
share: walk.share_enabled,
});
this.tick();
},
tick() {
const walk = this.data.walk;
if (!walk) return;
const end = walk.ended_at
? new Date(walk.ended_at)
: walk.status === "paused"
? new Date(walk.paused_at || Date.now())
: new Date();
const sec = Math.max(
0,
Math.floor((end - new Date(walk.started_at)) / 1000) -
(walk.paused_seconds || 0),
);
this.setData({
duration:
String(Math.floor(sec / 60)).padStart(2, "0") +
":" +
String(sec % 60).padStart(2, "0"),
});
},
async action(e) {
const kind = e.currentTarget.dataset.kind;
try {
const walk = await secure(
"/walks/" + this.data.walk.id + "/" + kind,
"POST",
);
this.renderWalk(walk);
if (kind === "finish") {
clearInterval(this.tracker);
wx.showToast({ title: "今天的快乐已记录", icon: "success" });
}
} catch (err) {
toast(err);
}
},
async shareChange(e) {
try {
const walk = await secure(
"/walks/" + this.data.walk.id + "/share",
"PUT",
{ enabled: e.detail.value },
);
this.renderWalk(walk);
} catch (err) {
toast(err);
}
},
onShareAppMessage() {
const walk = this.data.walk;
return {
title: "肉垫计划 · 看看我们遛到哪儿了 🐾",
path:
walk && walk.share_enabled
? "/pages/share/share?token=" + walk.share_token
: "/pages/home/home",
};
},
});
+1
View File
@@ -0,0 +1 @@
{ "usingComponents": { "tab-bar": "/components/tab-bar/index", "pet-picker": "/components/pet-picker/index" } }
+80
View File
@@ -0,0 +1,80 @@
<view class="screen walk-screen">
<view class="eyebrow">PAWPRINT WALK</view>
<view class="title">一起去兜风 🐾</view>
<view class="map-box">
<map
id="walk-map"
latitude="{{latitude}}"
longitude="{{longitude}}"
scale="16"
show-location="{{walk!=null}}"
polyline="{{polyline}}"
style="width: 100%; height: 100%"
/>
<view class="map-tag">
{{walk?walk.status==='active'?'记录中':'已暂停':'等待出发'}}
</view>
</view>
<view class="card session">
<view class="eyebrow">{{walk?'快乐进行时':'出门走走吧'}}</view>
<view class="session-title">
{{walk?'跟小短腿一起探索':'选择今天同行的狗狗'}}
</view>
<view class="stats">
<view>
<strong>{{duration}}</strong>
<text>时长</text>
</view>
<view>
<strong>{{distance}}</strong>
<text>公里</text>
</view>
<view>
<strong>{{points.length}}</strong>
<text>足迹</text>
</view>
</view>
<view class="picker-title">同行伙伴 · 可多选</view>
<pet-picker
pets="{{pets}}"
selected="{{walk?walk.pet_ids:selected}}"
bind:change="pick"
/>
<view wx:if="{{!pets.length}}" class="empty">
先到「宠物」添加你的柯基吧 🐕
</view>
<button
wx:if="{{!walk||walk.status==='finished'}}"
class="primary main-button"
bindtap="start"
>
开始遛狗 →
</button>
<view wx:else class="actions">
<button
class="secondary"
data-kind="{{walk.status==='active'?'pause':'resume'}}"
bindtap="action"
>
{{walk.status==='active'?'暂停':'继续'}}
</button>
<button class="primary" data-kind="finish" bindtap="action">
结束遛狗
</button>
</view>
<view wx:if="{{walk&&walk.status!=='finished'}}" class="share-row">
<view>
<strong>分享实时位置</strong>
<text>打开后可分享给朋友,结束遛狗会自动关闭</text>
</view>
<switch color="#278465" checked="{{share}}" bindchange="shareChange" />
</view>
<button wx:if="{{share}}" class="share-button" open-type="share">
发给朋友看位置 ↗
</button>
</view>
<view class="privacy">
🔒 只有你主动打开分享时,朋友才能查看这次遛狗的位置。
</view>
</view>
<tab-bar current="/pages/walk/walk" />
+105
View File
@@ -0,0 +1,105 @@
.walk-screen {
padding-left: 0;
padding-right: 0;
}
.walk-screen > .eyebrow,
.walk-screen > .title {
margin-left: 32rpx;
}
.map-box {
height: 450rpx;
position: relative;
overflow: hidden;
}
.map-tag {
position: absolute;
top: 25rpx;
left: 30rpx;
background: white;
color: #288363;
padding: 12rpx 21rpx;
border-radius: 99rpx;
font-size: 23rpx;
box-shadow: 0 8rpx 25rpx #173e3622;
}
.session {
margin: -30rpx 25rpx 0;
position: relative;
z-index: 2;
padding: 32rpx;
}
.session-title {
font-size: 35rpx;
font-weight: 800;
margin: 12rpx 0 30rpx;
}
.stats {
display: flex;
border-bottom: 2rpx solid #edf1eb;
padding-bottom: 25rpx;
}
.stats > view {
flex: 1;
border-right: 2rpx solid #e9efea;
padding-left: 22rpx;
}
.stats > view:first-child {
padding-left: 0;
}
.stats > view:last-child {
border: 0;
}
.stats strong {
display: block;
font-size: 38rpx;
}
.stats text {
font-size: 22rpx;
color: #99aa9d;
}
.picker-title {
font-weight: 700;
margin: 26rpx 0 18rpx;
}
.main-button {
margin-top: 30rpx;
}
.actions {
display: flex;
gap: 14rpx;
margin-top: 28rpx;
}
.actions button {
flex: 1;
}
.share-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 28rpx;
}
.share-row strong,
.share-row text {
display: block;
}
.share-row text {
font-size: 21rpx;
color: #90a199;
max-width: 450rpx;
margin-top: 5rpx;
}
.share-button {
margin-top: 20rpx;
background: #e6f1e8;
color: #267d59;
border-radius: 20rpx;
font-size: 25rpx;
}
.privacy {
font-size: 22rpx;
color: #739180;
background: #e9f3e7;
border-radius: 22rpx;
padding: 22rpx;
margin: 20rpx 25rpx;
}