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
+104
View File
@@ -0,0 +1,104 @@
const { secure, toast } = require("../../utils/api");
const today = () => new Date().toLocaleDateString("sv-SE");
Page({
data: {
pets: [],
tasks: [],
date: today(),
showForm: false,
title: "",
petIndex: 0,
category: "food",
time_of_day: "08:30",
repeat_rule: "daily",
done: 0,
},
onShow() {
this.load();
},
async load() {
try {
const [pets, result] = await Promise.all([
secure("/pets"),
secure("/tasks?date=" + this.data.date),
]);
const tasks = result.items.map((t) => ({
...t,
petName: (pets.find((p) => p.id === t.pet_id) || {}).name || "宠物",
}));
this.setData({
pets,
tasks,
done: tasks.filter((t) => t.completed).length,
});
} catch (e) {
toast(e);
}
},
async toggle(e) {
const t = this.data.tasks[e.currentTarget.dataset.index];
try {
await secure("/tasks/" + t.id + "/completion", "PUT", {
date: this.data.date,
completed: !t.completed,
});
this.load();
} catch (err) {
toast(err);
}
},
open() {
this.setData({ showForm: true });
},
close() {
this.setData({ showForm: false });
},
field(e) {
this.setData({ [e.currentTarget.dataset.key]: e.detail.value });
},
petChange(e) {
this.setData({ petIndex: Number(e.detail.value) });
},
timeChange(e) {
this.setData({ time_of_day: e.detail.value });
},
categoryChange(e) {
this.setData({ category: e.detail.value });
},
repeatChange(e) {
this.setData({ repeat_rule: e.detail.value });
},
async save() {
if (!this.data.pets.length || !this.data.title.trim()) {
wx.showToast({ title: "请选择宠物并填写任务名", icon: "none" });
return;
}
try {
await secure("/tasks", "POST", {
pet_id: this.data.pets[this.data.petIndex].id,
title: this.data.title,
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();
} catch (e) {
toast(e);
}
},
async remove(e) {
const t = this.data.tasks[e.currentTarget.dataset.index];
const result = await wx.showModal({
title: "删除任务",
content: "确定删除「" + t.title + "」吗?",
});
if (!result.confirm) return;
try {
await secure("/tasks/" + t.id, "DELETE");
this.load();
} catch (err) {
toast(err);
}
},
});
+1
View File
@@ -0,0 +1 @@
{ "usingComponents": { "tab-bar": "/components/tab-bar/index" } }
+134
View File
@@ -0,0 +1,134 @@
<view class="screen">
<view class="eyebrow">LITTLE THINGS, BIG LOVE</view>
<view class="title">今日小任务 ☑</view>
<view class="summary">
<view>今天也要照顾好它们</view>
<strong>
{{done}}
<text>/ {{tasks.length}}</text>
</strong>
<view class="progress">
<view style="width:{{tasks.length?done/tasks.length*100:0}}%"></view>
</view>
</view>
<view class="row">
<text class="section-title">{{date}} 的日常</text>
<text class="add" bindtap="open">+ 添加任务</text>
</view>
<view class="card list">
<view wx:for="{{tasks}}" wx:key="id" class="task">
<view
class="check {{item.completed?'done':''}}"
data-index="{{index}}"
bindtap="toggle"
>
{{item.completed?'✓':''}}
</view>
<view class="icon">
{{item.category==='water'?'💧':item.category==='food'?'🥣':'🐾'}}
</view>
<view class="info" data-index="{{index}}" bindtap="toggle">
<strong>{{item.title}}</strong>
<text>
{{item.petName}} · {{item.time_of_day}} ·
{{item.repeat_rule==='daily'?'每天':item.repeat_rule==='weekdays'?'工作日':'周末'}}
</text>
</view>
<text class="remove" data-index="{{index}}" bindtap="remove">×</text>
</view>
<view wx:if="{{!tasks.length}}" class="empty">
从喝水、喂食开始,安排温柔的小日常 ✨
</view>
</view>
<button class="primary" bindtap="open">+ 新建日常任务</button>
</view>
<view wx:if="{{showForm}}" class="overlay">
<view class="sheet">
<view class="row">
<strong>新建小任务</strong>
<text bindtap="close">×</text>
</view>
<view class="field">
<view class="field-label">任务名称</view>
<input
placeholder="比如:更换饮水"
value="{{title}}"
data-key="title"
bindinput="field"
/>
</view>
<view class="field">
<view class="field-label">照顾谁</view>
<picker
range="{{pets}}"
range-key="name"
value="{{petIndex}}"
bindchange="petChange"
>
<view class="picker">{{pets[petIndex].name||'请先添加宠物'}}</view>
</picker>
</view>
<view class="field">
<view class="field-label">类型</view>
<radio-group bindchange="categoryChange">
<label>
<radio value="food" checked="{{category==='food'}}" color="#194f42" />
喂食
</label>
<label>
<radio
value="water"
checked="{{category==='water'}}"
color="#194f42"
/>
喝水
</label>
<label>
<radio
value="other"
checked="{{category==='other'}}"
color="#194f42"
/>
其他
</label>
</radio-group>
</view>
<view class="field">
<view class="field-label">时间</view>
<picker mode="time" value="{{time_of_day}}" bindchange="timeChange">
<view class="picker">{{time_of_day}}</view>
</picker>
</view>
<view class="field">
<view class="field-label">重复</view>
<radio-group bindchange="repeatChange">
<label>
<radio
value="daily"
checked="{{repeat_rule==='daily'}}"
color="#194f42"
/>
每天
</label>
<label>
<radio
value="weekdays"
checked="{{repeat_rule==='weekdays'}}"
color="#194f42"
/>
工作日
</label>
<label>
<radio
value="weekends"
checked="{{repeat_rule==='weekends'}}"
color="#194f42"
/>
周末
</label>
</radio-group>
</view>
<button class="primary" bindtap="save">保存任务</button>
</view>
</view>
<tab-bar current="/pages/tasks/tasks" />
+123
View File
@@ -0,0 +1,123 @@
.summary {
background: #194f42;
color: white;
border-radius: 34rpx;
padding: 34rpx;
}
.summary > view:first-child {
font-size: 24rpx;
color: #bbe3c4;
}
.summary strong {
display: block;
font-size: 55rpx;
margin: 10rpx 0;
}
.summary strong text {
font-size: 30rpx;
color: #b4d8be;
}
.progress {
height: 13rpx;
background: #3f7566;
border-radius: 99rpx;
overflow: hidden;
}
.progress view {
height: 100%;
background: #e9ad6a;
border-radius: 99rpx;
}
.add {
color: #2b8a63;
font-size: 24rpx;
font-weight: 700;
}
.list {
padding: 8rpx 26rpx;
}
.task {
display: flex;
align-items: center;
gap: 15rpx;
padding: 22rpx 0;
border-bottom: 1rpx solid #eef2ee;
}
.task:last-child {
border: 0;
}
.check {
width: 42rpx;
height: 42rpx;
border: 3rpx solid #cbdcd1;
border-radius: 13rpx;
text-align: center;
line-height: 37rpx;
color: white;
}
.check.done {
background: #389867;
border-color: #389867;
}
.icon {
width: 65rpx;
height: 65rpx;
background: #eff5ee;
border-radius: 18rpx;
display: grid;
place-items: center;
font-size: 34rpx;
}
.info {
flex: 1;
}
.info strong,
.info text {
display: block;
}
.info text {
font-size: 21rpx;
color: #92a498;
margin-top: 5rpx;
}
.remove {
font-size: 36rpx;
color: #c4d0c7;
}
.overlay {
position: fixed;
inset: 0;
background: #102a24a0;
z-index: 30;
display: flex;
align-items: flex-end;
}
.sheet {
width: 100%;
background: #f6f9f6;
border-radius: 35rpx 35rpx 0 0;
padding: 32rpx 32rpx calc(env(safe-area-inset-bottom) + 30rpx);
max-height: 85vh;
overflow: auto;
}
.sheet > .row strong {
font-size: 33rpx;
}
.sheet > .row text {
font-size: 43rpx;
}
.sheet radio-group {
display: flex;
flex-wrap: wrap;
gap: 15rpx;
}
.sheet label {
font-size: 23rpx;
display: flex;
align-items: center;
}
.picker {
background: white;
padding: 23rpx;
border-radius: 20rpx;
}