Feat/dev #3

Merged
Blizzard merged 9 commits from feat/dev into main 2026-10-04 03:48:28 +00:00
28 changed files with 490 additions and 65 deletions
Showing only changes of commit c72cf24f56 - Show all commits
+20
View File
@@ -0,0 +1,20 @@
# 肉垫计划小程序
用微信开发者工具导入 `pets-fe`,AppID 见 `project.config.json`。接口基础地址在 `utils/config.js`,正式环境为 `https://pet.sundynix.cn/api`。请求封装会追加 `/pets`、`/walks` 等路径,不会再追加一层 `/api`。
## 核心流程
- 宠物:添加、编辑、上传头像、归档;档案使用文件 ID,服务端返回可显示的头像地址。
- 日常任务:按日期查看完成情况,新增、编辑、删除任务,记录或取消当天完成状态。
- 遛狗:可一次选择多只宠物,记录轨迹、暂停、继续、结束;历史页可查看路线和同行宠物。
- 位置分享:开始遛狗后主动开启,分享给微信好友;结束遛狗自动关闭。定位更新发生在小程序打开期间,后台持续定位还需微信对应类目与接口权限。
- 约遛:发起活动、选择地点和同行宠物、报名、查看报名状态、退出或取消活动。
## 真机检查
1. 登录后添加至少两只宠物,确认头像上传与档案编辑。
2. 新建喝水、喂食任务,编辑时间并切换日期,验证完成状态。
3. 同时选两只宠物开始遛狗,确认定位、暂停/继续、分享和结束;在历史页查看记录。
4. 用另一个微信账号报名约遛,确认已报名和退出状态。
小程序源码约 600 KB,低于微信主包 2 MB 限制。真机调试前请确认小程序后台已配置 `pet.sundynix.cn` 为合法请求域名。
+2 -13
View File
@@ -7,21 +7,10 @@ App({
if (this.globalData.token) return this.globalData.token;
if (!this.loginPromise)
this.loginPromise = (async () => {
const { isSimulator } = require("./utils/config");
const { request } = require("./utils/api");
let data;
try {
const { code } = await wx.login();
data = await request("/auth/login", "POST", { code }, false);
} catch (error) {
if (!isSimulator) throw error;
data = await request(
"/auth/dev",
"POST",
{ key: "wechat-devtools", nickname: "本地铲屎官" },
false,
);
}
if (!code) throw new Error("微信登录未取得凭证,请重试");
const data = await request("/auth/login", "POST", { code }, false);
this.globalData.token = data.token;
this.globalData.user = data.user;
wx.setStorageSync("pet_token", data.token);
+1 -1
View File
@@ -1,5 +1,5 @@
{
"pages": ["pages/home/home", "pages/meetups/meetups", "pages/walk/walk", "pages/tasks/tasks", "pages/pets/pets", "pages/pet-edit/pet-edit", "pages/meetup-edit/meetup-edit", "pages/share/share"],
"pages": ["pages/home/home", "pages/meetups/meetups", "pages/walk/walk", "pages/tasks/tasks", "pages/pets/pets", "pages/pet-edit/pet-edit", "pages/meetup-edit/meetup-edit", "pages/share/share", "pages/walk-records/walk-records"],
"window": { "navigationStyle": "custom", "backgroundColor": "#f6f9f6", "backgroundTextStyle": "dark" },
"permission": { "scope.userLocation": { "desc": "遛狗时记录轨迹,并在你主动开启分享后展示实时位置" } },
"requiredPrivateInfos": ["getLocation", "chooseLocation"],
+8
View File
@@ -2,6 +2,7 @@ Component({
properties: {
pets: { type: Array, value: [] },
selected: { type: Array, value: [] },
disabled: { type: Boolean, value: false },
},
data: { viewPets: [] },
observers: {
@@ -16,11 +17,18 @@ Component({
},
methods: {
toggle(e) {
if (this.data.disabled) return;
const id = e.currentTarget.dataset.id;
const selected = this.data.selected.includes(id)
? this.data.selected.filter((x) => x !== id)
: [...this.data.selected, id];
this.triggerEvent("change", { selected });
},
selectAll() {
if (this.data.disabled) return;
const ids = this.data.pets.map((pet) => pet.id);
const allSelected = ids.every((id) => this.data.selected.includes(id));
this.triggerEvent("change", { selected: allSelected ? [] : ids });
},
},
});
+3
View File
@@ -1,4 +1,7 @@
<view class="picker">
<view wx:if="{{pets.length>1&&!disabled}}" class="chip select-all" bindtap="selectAll">
<text>✓</text><text>全选 / 清空</text>
</view>
<view
wx:for="{{viewPets}}"
wx:key="id"
+4
View File
@@ -29,3 +29,7 @@
margin-left: 3rpx;
font-weight: 800;
}
.select-all {
border-style: dashed;
color: #2c8663;
}
+4 -3
View File
@@ -1,4 +1,5 @@
const { secure, toast } = require("../../utils/api");
const { localDate } = require("../../utils/date");
Page({
data: {
pets: [],
@@ -20,7 +21,7 @@ Page({
secure("/me"),
]);
const chosen = wx.getStorageSync("walk_pet_ids") || [],
month = new Date().toISOString().slice(0, 7);
month = localDate().slice(0, 7);
this.setData({
pets,
selected: chosen.filter((id) => pets.some((p) => p.id === id)),
@@ -50,14 +51,14 @@ Page({
wx.redirectTo({ url: "/pages/tasks/tasks" });
},
goWalks() {
wx.redirectTo({ url: "/pages/walk/walk" });
wx.navigateTo({ url: "/pages/walk-records/walk-records" });
},
async complete(e) {
const id = e.currentTarget.dataset.id,
task = this.data.tasks.find((t) => t.id === id);
try {
await secure("/tasks/" + id + "/completion", "PUT", {
date: new Date().toLocaleDateString("sv-SE"),
date: localDate(),
completed: !task.completed,
});
await this.load();
+15 -1
View File
@@ -11,6 +11,7 @@ Page({
date: "",
time: "",
max_people: 8,
saving: false,
},
onLoad() {
this.load();
@@ -47,6 +48,7 @@ Page({
}
},
async save() {
if (this.data.saving) return;
const d = this.data;
if (
!d.title.trim() ||
@@ -58,6 +60,16 @@ Page({
wx.showToast({ title: "请填完时间地点并选择狗狗", icon: "none" });
return;
}
const startsAt = new Date(d.date + "T" + d.time + ":00+08:00");
if (startsAt <= new Date()) {
wx.showToast({ title: "请选择未来的时间", icon: "none" });
return;
}
if (Number(d.max_people) < 2 || Number(d.max_people) > 100) {
wx.showToast({ title: "人数应为 2 到 100", icon: "none" });
return;
}
this.setData({ saving: true });
try {
await secure("/meetups", "POST", {
title: d.title,
@@ -65,13 +77,15 @@ Page({
place_name: d.place_name,
latitude: d.latitude,
longitude: d.longitude,
starts_at: new Date(d.date + "T" + d.time + ":00+08:00").toISOString(),
starts_at: startsAt.toISOString(),
max_people: Number(d.max_people),
pet_ids: d.selected,
});
wx.navigateBack();
} catch (e) {
toast(e);
} finally {
this.setData({ saving: false });
}
},
});
+1 -1
View File
@@ -49,5 +49,5 @@
<view class="field-label">带哪些狗狗?可以多选</view>
<pet-picker pets="{{pets}}" selected="{{selected}}" bind:change="pick" />
</view>
<button class="primary" bindtap="save">发布约遛 →</button>
<button class="primary" bindtap="save" disabled="{{saving}}">发布约遛 →</button>
</view>
+44 -7
View File
@@ -1,6 +1,6 @@
const { secure, toast } = require("../../utils/api");
Page({
data: { meetups: [], pets: [], selected: [], active: null, userId: "" },
data: { meetups: [], pets: [], selected: [], active: null, userId: "", joined: false, busy: false },
onShow() {
this.load();
},
@@ -26,46 +26,83 @@ Page({
create() {
wx.navigateTo({ url: "/pages/meetup-edit/meetup-edit" });
},
open(e) {
async open(e) {
const summary = this.data.meetups[e.currentTarget.dataset.index];
try {
const active = await secure("/meetups/" + summary.id);
const participant = (active.people || []).find((person) => person.user_id === this.data.userId);
this.setData({
active: this.data.meetups[e.currentTarget.dataset.index],
selected: [],
active: { ...active, date: new Date(active.starts_at).toLocaleString("zh-CN") },
joined: !!participant,
selected: participant ? participant.pet_ids : [],
});
} catch (error) {
toast(error);
}
},
close() {
this.setData({ active: null });
this.setData({ active: null, joined: false, selected: [] });
},
pick(e) {
this.setData({ selected: e.detail.selected });
},
async join() {
if (this.data.busy) return;
if (!this.data.selected.length) {
wx.showToast({ title: "请选择同行的狗狗", icon: "none" });
return;
}
this.setData({ busy: true });
try {
await secure("/meetups/" + this.data.active.id + "/join", "POST", {
pet_ids: this.data.selected,
});
wx.showToast({ title: "报名成功", icon: "success" });
this.close();
this.load();
await this.load();
} catch (e) {
toast(e);
} finally {
this.setData({ busy: false });
}
},
async leave() {
if (this.data.busy) return;
const result = await wx.showModal({ title: "退出约遛", content: "确定不参加这场约遛了吗?" });
if (!result.confirm) return;
this.setData({ busy: true });
try {
await secure("/meetups/" + this.data.active.id + "/join", "DELETE");
this.close();
await this.load();
} catch (error) {
toast(error);
} finally {
this.setData({ busy: false });
}
},
location() {
const active = this.data.active;
if (active && active.latitude && active.longitude) {
wx.openLocation({ latitude: active.latitude, longitude: active.longitude, name: active.place_name, scale: 16 });
}
},
async cancel() {
if (this.data.busy) return;
const r = await wx.showModal({
title: "取消约遛",
content: "确定取消这场约遛吗?",
});
if (!r.confirm) return;
this.setData({ busy: true });
try {
await secure("/meetups/" + this.data.active.id + "/cancel", "POST");
this.close();
this.load();
await this.load();
} catch (e) {
toast(e);
} finally {
this.setData({ busy: false });
}
},
});
+6 -5
View File
@@ -11,7 +11,7 @@
<image src="/assets/corgi-family-five.png" mode="aspectFit" />
</view>
<view class="row">
<text class="section-title">附近的约遛</text>
<text class="section-title">即将开始的约遛</text>
<text class="add" bindtap="create">+ 发起约遛</text>
</view>
<view
@@ -44,19 +44,20 @@
<text bindtap="close">×</text>
</view>
<view class="detail">
📍 {{active.place_name}}
<view bindtap="location">📍 {{active.place_name}} {{active.latitude?'· 点开地图':''}}</view>
<br />
🗓 {{active.date}}
<br />
🐕 {{active.participants}} / {{active.max_people}} 人已报名
</view>
<view class="desc">{{active.description}}</view>
<view wx:if="{{active.host_user_id!==userId}}">
<view wx:if="{{active.host_user_id!==userId&&!joined}}">
<view class="select-title">带哪几只?可以多选</view>
<pet-picker pets="{{pets}}" selected="{{selected}}" bind:change="pick" />
<button class="primary" bindtap="join">带狗狗报名</button>
<button class="primary" bindtap="join" disabled="{{busy||active.participants>=active.max_people}}">{{active.participants>=active.max_people?'人数已满':'带狗狗报名'}}</button>
</view>
<button wx:else class="secondary" bindtap="cancel">取消这场约遛</button>
<button wx:elif="{{joined&&active.host_user_id!==userId}}" class="secondary" bindtap="leave" disabled="{{busy}}">已报名 · 退出约遛</button>
<button wx:else class="secondary" bindtap="cancel" disabled="{{busy}}">取消这场约遛</button>
</view>
</view>
<tab-bar current="/pages/meetups/meetups" />
+5
View File
@@ -12,6 +12,7 @@ Page({
care_notes: "",
avatar_file_id: "",
avatar_url: "",
saving: false,
},
onLoad(options) {
if (options.id) {
@@ -45,6 +46,7 @@ Page({
}
},
async save() {
if (this.data.saving) return;
if (!this.data.name.trim()) {
wx.showToast({ title: "请输入名字", icon: "none" });
return;
@@ -61,6 +63,7 @@ Page({
care_notes: this.data.care_notes,
avatar_file_id: this.data.avatar_file_id || null,
};
this.setData({ saving: true });
try {
await secure(
"/pets" + (this.data.id ? "/" + this.data.id : ""),
@@ -70,6 +73,8 @@ Page({
wx.navigateBack();
} catch (e) {
toast(e);
} finally {
this.setData({ saving: false });
}
},
async archive() {
+1 -1
View File
@@ -59,6 +59,6 @@
<view class="field-label">照顾备注</view>
<textarea value="{{care_notes}}" data-key="care_notes" bindinput="field" />
</view>
<button class="primary" bindtap="save">保存档案</button>
<button class="primary" bindtap="save" disabled="{{saving}}">保存档案</button>
<button wx:if="{{id}}" class="archive" bindtap="archive">归档这只宠物</button>
</view>
+9 -1
View File
@@ -9,15 +9,23 @@ Page({
},
onLoad(options) {
this.token = options.token;
if (!this.token) this.setData({ status: "分享链接无效" });
},
onShow() {
if (!this.token) return;
this.load();
clearInterval(this.timer);
this.timer = setInterval(() => this.load(), 15000);
},
onHide() {
clearInterval(this.timer);
},
onUnload() {
clearInterval(this.timer);
},
async load() {
try {
const p = await request("/share/walk/" + this.token);
const p = await request("/share/walk/" + this.token, "GET", null, false);
this.setData({
latitude: p.latitude,
longitude: p.longitude,
+1 -1
View File
@@ -1,7 +1,7 @@
<view class="screen">
<view class="eyebrow">肉垫计划 · 位置分享</view>
<view class="title">找到小短腿的脚印 🐾</view>
<map
<map wx:if="{{markers.length}}"
latitude="{{latitude}}"
longitude="{{longitude}}"
scale="16"
+50 -9
View File
@@ -1,11 +1,13 @@
const { secure, toast } = require("../../utils/api");
const today = () => new Date().toLocaleDateString("sv-SE");
const { localDate, shiftDate } = require("../../utils/date");
Page({
data: {
pets: [],
tasks: [],
date: today(),
date: localDate(),
showForm: false,
editingId: "",
saving: false,
title: "",
petIndex: 0,
category: "food",
@@ -17,11 +19,13 @@ Page({
this.load();
},
async load() {
const date = this.data.date;
try {
const [pets, result] = await Promise.all([
secure("/pets"),
secure("/tasks?date=" + this.data.date),
secure("/tasks?date=" + date),
]);
if (date !== this.data.date) return;
const tasks = result.items.map((t) => ({
...t,
petName: (pets.find((p) => p.id === t.pet_id) || {}).name || "宠物",
@@ -42,17 +46,50 @@ Page({
date: this.data.date,
completed: !t.completed,
});
this.load();
await this.load();
} catch (err) {
toast(err);
}
},
open() {
this.setData({ showForm: true });
if (!this.data.pets.length) {
wx.showToast({ title: "请先添加宠物", icon: "none" });
return;
}
this.setData({
showForm: true,
editingId: "",
title: "",
petIndex: 0,
category: "food",
time_of_day: "08:30",
repeat_rule: "daily",
});
},
close() {
this.setData({ showForm: false });
},
edit(e) {
const task = this.data.tasks[e.currentTarget.dataset.index];
const petIndex = this.data.pets.findIndex((pet) => pet.id === task.pet_id);
this.setData({
showForm: true,
editingId: task.id,
title: task.title,
petIndex: Math.max(petIndex, 0),
category: task.category,
time_of_day: task.time_of_day,
repeat_rule: task.repeat_rule,
});
},
changeDay(e) {
this.setData({ date: shiftDate(this.data.date, Number(e.currentTarget.dataset.days)) });
this.load();
},
showToday() {
this.setData({ date: localDate() });
this.load();
},
field(e) {
this.setData({ [e.currentTarget.dataset.key]: e.detail.value });
},
@@ -69,22 +106,26 @@ Page({
this.setData({ repeat_rule: e.detail.value });
},
async save() {
if (this.data.saving) return;
if (!this.data.pets.length || !this.data.title.trim()) {
wx.showToast({ title: "请选择宠物并填写任务名", icon: "none" });
return;
}
this.setData({ saving: true });
try {
await secure("/tasks", "POST", {
await secure(this.data.editingId ? "/tasks/" + this.data.editingId : "/tasks", this.data.editingId ? "PUT" : "POST", {
pet_id: this.data.pets[this.data.petIndex].id,
title: this.data.title,
title: this.data.title.trim(),
category: this.data.category,
time_of_day: this.data.time_of_day,
repeat_rule: this.data.repeat_rule,
});
this.setData({ showForm: false, title: "" });
this.load();
this.setData({ showForm: false, editingId: "" });
await this.load();
} catch (e) {
toast(e);
} finally {
this.setData({ saving: false });
}
},
async remove(e) {
+10 -4
View File
@@ -2,7 +2,7 @@
<view class="eyebrow">LITTLE THINGS, BIG LOVE</view>
<view class="title">今日小任务 ☑</view>
<view class="summary">
<view>今天也要照顾好它们</view>
<view>这一天也要照顾好它们</view>
<strong>
{{done}}
<text>/ {{tasks.length}}</text>
@@ -15,6 +15,11 @@
<text class="section-title">{{date}} 的日常</text>
<text class="add" bindtap="open">+ 添加任务</text>
</view>
<view class="date-nav">
<text data-days="-1" bindtap="changeDay">‹ 前一天</text>
<text bindtap="showToday">回到今天</text>
<text data-days="1" bindtap="changeDay">后一天 ›</text>
</view>
<view class="card list">
<view wx:for="{{tasks}}" wx:key="id" class="task">
<view
@@ -27,12 +32,13 @@
<view class="icon">
{{item.category==='water'?'💧':item.category==='food'?'🥣':'🐾'}}
</view>
<view class="info" data-index="{{index}}" bindtap="toggle">
<view class="info" data-index="{{index}}" bindtap="edit">
<strong>{{item.title}}</strong>
<text>
{{item.petName}} · {{item.time_of_day}} ·
{{item.repeat_rule==='daily'?'每天':item.repeat_rule==='weekdays'?'工作日':'周末'}}
</text>
<text>点此编辑</text>
</view>
<text class="remove" data-index="{{index}}" bindtap="remove">×</text>
</view>
@@ -45,7 +51,7 @@
<view wx:if="{{showForm}}" class="overlay">
<view class="sheet">
<view class="row">
<strong>新建小任务</strong>
<strong>{{editingId?'编辑小任务':'新建小任务'}}</strong>
<text bindtap="close">×</text>
</view>
<view class="field">
@@ -128,7 +134,7 @@
</label>
</radio-group>
</view>
<button class="primary" bindtap="save">保存任务</button>
<button class="primary" bindtap="save" disabled="{{saving}}">保存任务</button>
</view>
</view>
<tab-bar current="/pages/tasks/tasks" />
+7
View File
@@ -33,6 +33,13 @@
font-size: 24rpx;
font-weight: 700;
}
.date-nav {
display: flex;
justify-content: space-between;
color: #327c5e;
font-size: 23rpx;
margin: 0 0 22rpx;
}
.list {
padding: 8rpx 26rpx;
}
@@ -0,0 +1,47 @@
const { secure, toast } = require("../../utils/api");
function duration(seconds) {
const minutes = Math.floor((seconds || 0) / 60);
return `${minutes} 分 ${String((seconds || 0) % 60).padStart(2, "0")} 秒`;
}
Page({
data: { records: [], selected: null, loading: true },
onLoad() {
this.load();
},
async load() {
try {
const [walks, pets] = await Promise.all([secure("/walks"), secure("/pets")]);
const names = Object.fromEntries(pets.map((pet) => [pet.id, pet.name]));
this.setData({
records: walks.filter((walk) => walk.status === "finished").map((walk) => ({
...walk,
date: new Date(walk.started_at).toLocaleString("zh-CN"),
duration: duration(walk.duration_seconds),
distance: ((walk.distance_meters || 0) / 1000).toFixed(2),
petNames: (walk.pet_ids || []).map((id) => names[id] || "已归档宠物").join("、"),
polyline: walk.points && walk.points.length > 1 ? [{
points: walk.points.map((point) => ({ latitude: point.latitude, longitude: point.longitude })),
color: "#268e6a",
width: 5,
}] : [],
lastPoint: walk.points && walk.points.length ? walk.points[walk.points.length - 1] : null,
})),
loading: false,
});
} catch (error) {
this.setData({ loading: false });
toast(error);
}
},
open(e) {
this.setData({ selected: this.data.records[e.currentTarget.dataset.index] });
},
close() {
this.setData({ selected: null });
},
back() {
wx.navigateBack();
},
});
@@ -0,0 +1 @@
{}
@@ -0,0 +1,29 @@
<view class="screen">
<text wx:if="{{selected}}" class="back" bindtap="close">‹ 全部记录</text>
<text wx:else class="back" bindtap="back">‹ 返回</text>
<view class="eyebrow">OUR LITTLE FOOTPRINTS</view>
<view class="title">{{selected?'这次的脚印':'遛狗记录'}} 🐾</view>
<view wx:if="{{selected}}">
<view class="card detail">
<strong>{{selected.date}}</strong>
<text>{{selected.petNames}}</text>
<view class="metrics">
<view><strong>{{selected.duration}}</strong><text>时长</text></view>
<view><strong>{{selected.distance}} km</strong><text>距离</text></view>
</view>
</view>
<map wx:if="{{selected.lastPoint}}" class="route-map"
latitude="{{selected.lastPoint.latitude}}"
longitude="{{selected.lastPoint.longitude}}"
polyline="{{selected.polyline}}" scale="15" />
<view wx:else class="empty">这次没有留下定位轨迹</view>
</view>
<view wx:else>
<view wx:for="{{records}}" wx:key="id" class="card record" data-index="{{index}}" bindtap="open">
<view class="row"><strong>{{item.date}}</strong><text>›</text></view>
<view class="pet-names">🐕 {{item.petNames}}</view>
<view class="record-stats">{{item.duration}} · {{item.distance}} km · {{item.points.length}} 个足迹</view>
</view>
<view wx:if="{{!loading&&!records.length}}" class="empty">完成一次遛狗,脚印就会出现在这里 🐾</view>
</view>
</view>
@@ -0,0 +1,10 @@
.back { display: block; color: #327c5e; margin-bottom: 34rpx; font-size: 26rpx; }
.record strong, .detail strong { font-size: 28rpx; }
.record .row text { font-size: 38rpx; color: #8da89a; }
.pet-names, .detail > text { display: block; color: #547b65; margin-top: 16rpx; }
.record-stats { color: #91a59a; font-size: 24rpx; margin-top: 12rpx; }
.metrics { display: flex; gap: 28rpx; margin-top: 28rpx; border-top: 1rpx solid #e8efe9; padding-top: 22rpx; }
.metrics view { flex: 1; }
.metrics strong, .metrics text { display: block; }
.metrics text { color: #8da89a; font-size: 22rpx; margin-top: 7rpx; }
.route-map { width: 100%; height: 600rpx; border-radius: 30rpx; overflow: hidden; }
+54 -6
View File
@@ -11,9 +11,12 @@ Page({
duration: "00:00",
distance: "0.00",
share: false,
busy: false,
locationError: "",
},
onShow() {
this.load();
clearInterval(this.clock);
this.clock = setInterval(() => this.tick(), 1000);
},
onHide() {
@@ -25,6 +28,8 @@ Page({
stopTimers() {
clearInterval(this.clock);
clearInterval(this.tracker);
this.clock = null;
this.tracker = null;
},
async load() {
try {
@@ -41,7 +46,13 @@ Page({
this.setData({ pets, selected, walk });
if (walk) {
this.renderWalk(walk);
if (walk.status === "active") {
this.track();
this.point();
}
} else {
clearInterval(this.tracker);
this.setData({ points: [], polyline: [], duration: "00:00", distance: "0.00", share: false });
}
} catch (e) {
toast(e);
@@ -53,19 +64,33 @@ Page({
wx.setStorageSync("walk_pet_ids", e.detail.selected);
},
async start() {
if (this.data.busy) return;
if (!this.data.selected.length) {
wx.showToast({ title: "请选择同行的狗狗", icon: "none" });
return;
}
this.setData({ busy: true, locationError: "" });
let position;
try {
position = await wx.getLocation({ type: "gcj02" });
} catch (error) {
this.setData({ busy: false, locationError: "请检查小程序定位权限,开启后再试" });
toast(new Error("无法取得位置,请检查定位权限"));
return;
}
this.setData({ latitude: position.latitude, longitude: position.longitude });
try {
const walk = await secure("/walks", "POST", {
pet_ids: this.data.selected,
});
this.setData({ walk });
this.track();
this.point();
await this.savePoint(position);
} catch (e) {
if (this.data.walk) this.setData({ locationError: "轨迹保存失败,请点击重试" });
toast(e);
} finally {
this.setData({ busy: false });
}
},
track() {
@@ -73,17 +98,27 @@ Page({
this.tracker = setInterval(() => this.point(), 15000);
},
async point() {
if (!this.data.walk || this.data.walk.status !== "active") return;
if (!this.data.walk || this.data.walk.status !== "active" || this.locating) return;
this.locating = true;
try {
const pos = await wx.getLocation({ type: "gcj02" });
await this.savePoint(pos);
this.setData({ locationError: "" });
} catch (e) {
if (this.data.walk) this.setData({ locationError: "定位更新失败,请检查定位权限和网络" });
} finally {
this.locating = false;
}
},
async savePoint(pos) {
const walkId = this.data.walk.id;
const walk = await secure(
"/walks/" + this.data.walk.id + "/points",
"/walks/" + walkId + "/points",
"POST",
{ latitude: pos.latitude, longitude: pos.longitude },
);
if (this.data.walk && this.data.walk.id === walkId && this.data.walk.status === "active") {
this.renderWalk(walk);
} catch (e) {
console.warn(e);
}
},
renderWalk(walk) {
@@ -99,7 +134,7 @@ Page({
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),
distance: ((walk.distance_meters || 0) / 1000).toFixed(2),
share: walk.share_enabled,
});
this.tick();
@@ -125,7 +160,9 @@ Page({
});
},
async action(e) {
if (this.data.busy) return;
const kind = e.currentTarget.dataset.kind;
this.setData({ busy: true });
try {
const walk = await secure(
"/walks/" + this.data.walk.id + "/" + kind,
@@ -134,12 +171,23 @@ Page({
this.renderWalk(walk);
if (kind === "finish") {
clearInterval(this.tracker);
this.setData({ walk: null, points: [], polyline: [], duration: "00:00", distance: "0.00", share: false });
wx.showToast({ title: "今天的快乐已记录", icon: "success" });
} else if (kind === "resume") {
this.track();
this.point();
} else {
clearInterval(this.tracker);
}
} catch (err) {
toast(err);
} finally {
this.setData({ busy: false });
}
},
records() {
wx.navigateTo({ url: "/pages/walk-records/walk-records" });
},
async shareChange(e) {
try {
const walk = await secure(
+7 -2
View File
@@ -38,6 +38,7 @@
<pet-picker
pets="{{pets}}"
selected="{{walk?walk.pet_ids:selected}}"
disabled="{{walk!==null}}"
bind:change="pick"
/>
<view wx:if="{{!pets.length}}" class="empty">
@@ -47,6 +48,7 @@
wx:if="{{!walk||walk.status==='finished'}}"
class="primary main-button"
bindtap="start"
disabled="{{busy}}"
>
开始遛狗 →
</button>
@@ -55,10 +57,11 @@
class="secondary"
data-kind="{{walk.status==='active'?'pause':'resume'}}"
bindtap="action"
disabled="{{busy}}"
>
{{walk.status==='active'?'暂停':'继续'}}
</button>
<button class="primary" data-kind="finish" bindtap="action">
<button class="primary" data-kind="finish" bindtap="action" disabled="{{busy}}">
结束遛狗
</button>
</view>
@@ -72,9 +75,11 @@
<button wx:if="{{share}}" class="share-button" open-type="share">
发给朋友看位置 ↗
</button>
<view wx:if="{{locationError}}" class="location-error" bindtap="point">{{locationError}} · 点击重试</view>
</view>
<button class="records-button" bindtap="records">查看最近的脚印 ›</button>
<view class="privacy">
🔒 只有你主动打开分享时,朋友才能查看这次遛狗的位置。
🔒 只有你主动打开分享时,朋友才能查看位置。保持小程序打开,位置才会持续更新。
</view>
</view>
<tab-bar current="/pages/walk/walk" />
+12
View File
@@ -103,3 +103,15 @@
padding: 22rpx;
margin: 20rpx 25rpx;
}
.location-error {
color: #a4513f;
font-size: 23rpx;
margin-top: 22rpx;
}
.records-button {
margin: 28rpx 25rpx 0;
color: #2d8261;
background: #e6f1e8;
border-radius: 20rpx;
font-size: 26rpx;
}
+18 -2
View File
@@ -38,7 +38,7 @@ async function secure(path, method, data) {
function toast(error) {
wx.showToast({ title: error.message || "操作失败", icon: "none" });
}
function upload(path) {
function uploadOnce(path) {
return new Promise((resolve, reject) => {
const token = getApp().globalData.token;
wx.uploadFile({
@@ -50,7 +50,11 @@ function upload(path) {
try {
const body = JSON.parse(res.data);
if (body.code === 0) resolve(body.data);
else reject(new Error(body.message));
else {
const error = new Error(body.message || "上传失败");
error.status = res.statusCode;
reject(error);
}
} catch (e) {
reject(e);
}
@@ -59,4 +63,16 @@ function upload(path) {
});
});
}
async function upload(path) {
await getApp().login();
try {
return await uploadOnce(path);
} catch (error) {
if (error.status !== 401) throw error;
wx.removeStorageSync("pet_token");
getApp().globalData.token = "";
await getApp().login();
return uploadOnce(path);
}
}
module.exports = { request, secure, toast, upload };
+14
View File
@@ -0,0 +1,14 @@
function localDate(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function shiftDate(value, days) {
const date = new Date(`${value}T12:00:00`);
date.setDate(date.getDate() + days);
return localDate(date);
}
module.exports = { localDate, shiftDate };
+99
View File
@@ -0,0 +1,99 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const apiPath = require.resolve("../pets-fe/utils/api");
function page(name, secure, wx = {}) {
global.wx = {
getStorageSync: () => [],
setStorageSync: () => {},
showToast: () => {},
showModal: async () => ({ confirm: true }),
navigateBack: () => {},
getLocation: async () => ({ latitude: 31.2, longitude: 121.4 }),
...wx,
};
require.cache[apiPath] = {
id: apiPath,
filename: apiPath,
loaded: true,
exports: { secure, toast: (error) => { throw error; } },
};
let definition;
global.Page = (value) => { definition = value; };
const filename = path.resolve(__dirname, `../pets-fe/pages/${name}/${name}.js`);
delete require.cache[filename];
require(filename);
return {
...definition,
data: { ...definition.data },
setData(changes) { Object.assign(this.data, changes); },
};
}
test("编辑任务走 PUT,并保留选中的宠物", async () => {
const calls = [];
const pet = { id: "pet-1", name: "奶盖" };
const task = { id: "task-1", pet_id: pet.id, title: "换水", category: "water", time_of_day: "09:00", repeat_rule: "daily" };
const screen = page("tasks", async (route, method, body) => {
calls.push({ route, method, body });
if (route === "/pets") return [pet];
if (route.startsWith("/tasks?")) return { items: [task] };
return task;
});
await screen.load();
screen.edit({ currentTarget: { dataset: { index: 0 } } });
screen.setData({ title: "更换饮水" });
await screen.save();
assert.deepEqual(calls.find((call) => call.method === "PUT"), {
route: "/tasks/task-1",
method: "PUT",
body: { pet_id: pet.id, title: "更换饮水", category: "water", time_of_day: "09:00", repeat_rule: "daily" },
});
});
test("多宠物遛狗发送全部 ID,结束后可再次开始", async () => {
const calls = [];
const active = { id: "walk-1", status: "active", started_at: new Date().toISOString(), pet_ids: ["a", "b"], distance_meters: 0, points: [] };
const screen = page("walk", async (route, method, body) => {
calls.push({ route, method, body });
if (route === "/walks" && method === "POST") return active;
if (route.endsWith("/points")) return { ...active, points: [{ latitude: 31.2, longitude: 121.4 }] };
if (route.endsWith("/finish")) return { ...active, status: "finished" };
});
screen.track = () => {};
screen.setData({ selected: ["a", "b"] });
await screen.start();
assert.deepEqual(calls[0].body.pet_ids, ["a", "b"]);
assert.equal(calls[1].route, "/walks/walk-1/points");
await screen.action({ currentTarget: { dataset: { kind: "finish" } } });
assert.equal(screen.data.walk, null);
assert.deepEqual(screen.data.selected, ["a", "b"]);
});
test("足迹列表保留同行宠物和轨迹", async () => {
const screen = page("walk-records", async (route) => route === "/pets"
? [{ id: "a", name: "奶盖" }, { id: "b", name: "布丁" }]
: [{ id: "walk-1", status: "finished", started_at: "2026-10-01T08:00:00+08:00", duration_seconds: 120, distance_meters: 500, pet_ids: ["a", "b"], points: [{ latitude: 31.2, longitude: 121.4 }, { latitude: 31.3, longitude: 121.5 }] }]);
await screen.load();
assert.equal(screen.data.records[0].petNames, "奶盖、布丁");
assert.equal(screen.data.records[0].distance, "0.50");
assert.equal(screen.data.records[0].polyline[0].points.length, 2);
});
test("约遛详情识别已报名并能退出", async () => {
const calls = [];
const screen = page("meetups", async (route, method) => {
calls.push({ route, method });
if (route === "/meetups/event-1") return { id: "event-1", starts_at: "2026-10-05T08:00:00+08:00", people: [{ user_id: "user-1", pet_ids: ["pet-1"] }] };
return {};
});
screen.setData({ userId: "user-1", meetups: [{ id: "event-1" }] });
screen.load = async () => {};
await screen.open({ currentTarget: { dataset: { index: 0 } } });
assert.equal(screen.data.joined, true);
assert.deepEqual(screen.data.selected, ["pet-1"]);
await screen.leave();
assert.deepEqual(calls[1], { route: "/meetups/event-1/join", method: "DELETE" });
});