Complete core mini program flows
This commit is contained in:
@@ -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
@@ -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
@@ -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"],
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -29,3 +29,7 @@
|
||||
margin-left: 3rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
.select-all {
|
||||
border-style: dashed;
|
||||
color: #2c8663;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,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,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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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; }
|
||||
@@ -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(
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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" });
|
||||
});
|
||||
Reference in New Issue
Block a user