Add one-way task completion and exportable pet identity cards
ci-cd / verify (push) Successful in 1m31s
ci-cd / deploy (push) Skipped

This commit is contained in:
2026-10-05 11:32:32 +08:00
parent f0ee63e642
commit cb4ffa13e9
21 changed files with 999 additions and 117 deletions
+1
View File
@@ -54,6 +54,7 @@ func (a *API) Register(r *gin.Engine) {
user.PUT("/tasks/:id", a.updateTask) user.PUT("/tasks/:id", a.updateTask)
user.DELETE("/tasks/:id", a.deleteTask) user.DELETE("/tasks/:id", a.deleteTask)
user.PUT("/tasks/:id/completion", a.setTaskCompletion) user.PUT("/tasks/:id/completion", a.setTaskCompletion)
user.PUT("/tasks/completions", a.completeTasks)
user.GET("/walks", a.listWalks) user.GET("/walks", a.listWalks)
user.POST("/walks", a.startWalk) user.POST("/walks", a.startWalk)
user.GET("/walks/:id", a.getWalk) user.GET("/walks/:id", a.getWalk)
+72 -14
View File
@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/sundynix/pets-be/internal/model" "github.com/sundynix/pets-be/internal/model"
) )
@@ -179,11 +180,6 @@ func (a *API) deleteTask(c *gin.Context) {
} }
func (a *API) setTaskCompletion(c *gin.Context) { func (a *API) setTaskCompletion(c *gin.Context) {
var task model.CareTask
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&task).Error != nil {
fail(c, http.StatusNotFound, "任务不存在")
return
}
var in struct { var in struct {
Date string `json:"date" binding:"required"` Date string `json:"date" binding:"required"`
Completed bool `json:"completed"` Completed bool `json:"completed"`
@@ -192,20 +188,82 @@ func (a *API) setTaskCompletion(c *gin.Context) {
fail(c, http.StatusBadRequest, "完成状态参数无效") fail(c, http.StatusBadRequest, "完成状态参数无效")
return return
} }
if !in.Completed {
fail(c, http.StatusBadRequest, "已完成的任务不能取消完成")
return
}
var task model.CareTask
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&task).Error != nil {
fail(c, http.StatusNotFound, "任务不存在")
return
}
date, err := parseDay(in.Date) date, err := parseDay(in.Date)
if err != nil || !due(task, date) { if err != nil || !due(task, date) {
fail(c, http.StatusBadRequest, "该日期没有这项任务") fail(c, http.StatusBadRequest, "该日期没有这项任务")
return return
} }
if in.Completed { item := model.TaskCompletion{TaskID: task.ID, UserID: actor(c), Date: date, CompletedAt: time.Now()}
item := model.TaskCompletion{TaskID: task.ID, UserID: actor(c), Date: date, CompletedAt: time.Now()} if err := a.db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "task_id"}, {Name: "date"}}, DoNothing: true}).Create(&item).Error; err != nil {
if err := a.db.Where("task_id = ? AND date = ?", task.ID, date).FirstOrCreate(&item).Error; err != nil { fail(c, http.StatusInternalServerError, "保存完成状态失败")
fail(c, http.StatusInternalServerError, "保存完成状态失败")
return
}
} else if err := a.db.Where("task_id = ? AND date = ?", task.ID, date).Delete(&model.TaskCompletion{}).Error; err != nil {
fail(c, http.StatusInternalServerError, "取消完成失败")
return return
} }
ok(c, gin.H{"id": task.ID, "date": in.Date, "completed": in.Completed}) ok(c, gin.H{"id": task.ID, "date": in.Date, "completed": true})
}
// Complete the displayed tasks atomically, allowing safe retries and concurrent taps.
func (a *API) completeTasks(c *gin.Context) {
var in struct {
Date string `json:"date" binding:"required"`
TaskIDs []string `json:"task_ids" binding:"required,min=1,max=200"`
}
if c.ShouldBindJSON(&in) != nil {
fail(c, http.StatusBadRequest, "请选择 1 至 200 项任务")
return
}
date, err := parseDay(in.Date)
if err != nil {
fail(c, http.StatusBadRequest, "日期格式应为 YYYY-MM-DD")
return
}
ids := make([]string, 0, len(in.TaskIDs))
seen := map[string]bool{}
for _, id := range in.TaskIDs {
if !validID(id) {
fail(c, http.StatusBadRequest, "任务 ID 无效")
return
}
if !seen[id] {
ids = append(ids, id)
seen[id] = true
}
}
invalid := false
err = a.db.Transaction(func(tx *gorm.DB) error {
var tasks []model.CareTask
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ? AND user_id = ?", ids, actor(c)).Order("id").Find(&tasks).Error; err != nil {
return err
}
if len(tasks) != len(ids) {
invalid = true
return gorm.ErrRecordNotFound
}
items := make([]model.TaskCompletion, 0, len(tasks))
for _, task := range tasks {
if !due(task, date) {
invalid = true
return gorm.ErrRecordNotFound
}
items = append(items, model.TaskCompletion{TaskID: task.ID, UserID: actor(c), Date: date, CompletedAt: time.Now()})
}
return tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "task_id"}, {Name: "date"}}, DoNothing: true}).Create(&items).Error
})
if err != nil {
if invalid {
fail(c, http.StatusBadRequest, "包含不存在或该日期无需完成的任务")
} else {
fail(c, http.StatusInternalServerError, "完成任务失败,请重试")
}
return
}
ok(c, gin.H{"task_ids": ids, "date": in.Date, "completed": true})
} }
+91
View File
@@ -0,0 +1,91 @@
package httpapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/database"
"github.com/sundynix/pets-be/internal/model"
)
func TestTaskCompletionCannotBeUndone(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPut, "/completion", bytes.NewBufferString(`{"date":"2026-10-05","completed":false}`))
api := New(nil, nil, &config.Config{})
api.setTaskCompletion(c)
if rec.Code != http.StatusBadRequest {
t.Fatalf("undo status %d", rec.Code)
}
}
// All fixtures and writes are rolled back, including inner batch transactions.
func TestTaskCompletionPostgres(t *testing.T) {
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
t.Skip("PostgreSQL integration disabled")
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
db, err := database.Open(cfg.Postgres, true)
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
tx := db.Begin()
if tx.Error != nil {
t.Fatal(tx.Error)
}
defer tx.Rollback()
user := model.User{OpenID: "completion-test:" + uuid.NewString(), Nickname: "测试家长"}
if err := tx.Create(&user).Error; err != nil {
t.Fatal(err)
}
pet := model.Pet{UserID: user.ID, Name: "测试毛孩", Species: "dog", Gender: "male"}
if err := tx.Create(&pet).Error; err != nil {
t.Fatal(err)
}
tasks := []model.CareTask{{UserID: user.ID, PetID: pet.ID, Title: "喝水", Category: "water", TimeOfDay: "08:00", RepeatRule: "daily"}, {UserID: user.ID, PetID: pet.ID, Title: "喂食", Category: "food", TimeOfDay: "09:00", RepeatRule: "daily"}}
if err := tx.Create(&tasks).Error; err != nil {
t.Fatal(err)
}
api := New(tx, nil, cfg)
call := func(ids []string) int {
t.Helper()
body, _ := json.Marshal(gin.H{"date": "2026-10-05", "task_ids": ids})
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Set("actor_id", user.ID)
c.Request = httptest.NewRequest(http.MethodPut, "/tasks/completions", bytes.NewReader(body))
api.completeTasks(c)
return rec.Code
}
if status := call([]string{tasks[0].ID, uuid.NewString()}); status != 400 {
t.Fatalf("invalid batch status %d", status)
}
var count int64
tx.Model(&model.TaskCompletion{}).Where("user_id = ?", user.ID).Count(&count)
if count != 0 {
t.Fatal("invalid batch partially completed")
}
ids := []string{tasks[0].ID, tasks[1].ID}
for range 2 {
if status := call(ids); status != 200 {
t.Fatalf("batch status %d", status)
}
}
tx.Model(&model.TaskCompletion{}).Where("user_id = ?", user.ID).Count(&count)
if count != 2 {
t.Fatalf("retry duplicated completions: %d", count)
}
}
+42 -1
View File
@@ -3,7 +3,8 @@ page {
overflow: hidden; overflow: hidden;
background: #fff9f3; background: #fff9f3;
color: #604a40; color: #604a40;
font-family: ui-rounded, "Hiragino Maru Gothic ProN", "PingFang SC", sans-serif; font-family:
ui-rounded, "Hiragino Maru Gothic ProN", "PingFang SC", sans-serif;
font-size: 28rpx; font-size: 28rpx;
line-height: 1.5; line-height: 1.5;
} }
@@ -112,3 +113,43 @@ button::after {
.field textarea { .field textarea {
height: 170rpx; height: 170rpx;
} }
.completion-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin: 8rpx 0 20rpx;
font-size: 23rpx;
color: #886c5f;
}
.finish-all {
margin: 0;
padding: 12rpx 22rpx;
background: #fff0e2;
color: #b95738;
font-size: 24rpx;
line-height: 1.4;
border-radius: 99rpx;
}
.all-done {
color: #b95738;
}
.task-completed .check {
animation: completion-pop 0.35s ease-out;
}
.task-completed .task-name,
.task-completed .info strong {
color: #9d8779;
}
@keyframes completion-pop {
0% {
transform: scale(0.7);
}
60% {
transform: scale(1.18);
}
100% {
transform: scale(1);
}
}
+42
View File
@@ -0,0 +1,42 @@
const { profile, renderCard, saveToAlbum } = require("../../utils/pet-card");
const { toast } = require("../../utils/api");
Component({
properties: { pet: { type: Object, value: {} } },
data: { card: {}, saving: false },
observers: {
pet(value) {
this.setData({ card: profile(value || {}) });
},
},
methods: {
edit() {
this.triggerEvent("edit", { id: this.data.pet.id });
},
async generate() {
const canvas = await new Promise((resolve, reject) =>
this.createSelectorQuery()
.select("#card-canvas")
.fields({ node: true, size: true })
.exec((result) =>
result[0] && result[0].node
? resolve(result[0].node)
: reject(new Error("卡片还未准备好,请重试")),
),
);
return renderCard(canvas, this.data.pet);
},
async save() {
if (this.data.saving) return;
this.setData({ saving: true });
try {
const path = await this.generate();
if (await saveToAlbum(path))
wx.showToast({ title: "身份卡已保存到相册", icon: "success" });
} catch (error) {
toast(error);
} finally {
this.setData({ saving: false });
}
},
},
});
@@ -0,0 +1,4 @@
{
"component": true,
"usingComponents": { "app-icon": "/components/app-icon/index" }
}
+10
View File
@@ -0,0 +1,10 @@
<view class="identity">
<view class="id-top"><view class="brand"><image src="/assets/logo-corgi-v2.png" /><view><text class="brand-name">肉垫计划</text><text class="brand-en">PAWPRINT ID</text></view></view><text class="id-title">毛孩身份卡</text></view>
<view class="id-body" bindtap="edit" aria-role="button" aria-label="编辑宠物档案">
<view class="portrait"><image wx:if="{{card.avatar_url}}" src="{{card.avatar_url}}" mode="aspectFill" /><app-icon wx:else size="80" color="#d18b67" /></view>
<view class="details"><text class="pet-name">{{card.name}}</text><text class="breed">{{card.breed||'品种未填写'}} · {{card.genderLabel}}</text><view class="facts"><view><text class="label">生日</text><text class="value">{{card.birthday}}</text></view><view><text class="label">体重</text><text class="value">{{card.weightLabel}}</text></view></view><view class="coat"><text class="label">毛色</text><text class="value">{{card.coat||'未填写'}}</text></view></view>
</view>
<view class="id-bottom"><view><text class="label">陪伴编号</text><text class="number">{{card.cardNumber}}</text></view><text class="motto">把陪伴,认真记下来</text></view>
</view>
<view class="card-actions"><button bindtap="edit">编辑档案</button><button class="save" bindtap="save" loading="{{saving}}" disabled="{{saving}}">{{saving?'正在生成…':'保存身份卡'}}</button></view>
<canvas type="2d" id="card-canvas" class="export-canvas" />
+164
View File
@@ -0,0 +1,164 @@
:host {
display: block;
margin-bottom: 30rpx;
}
.identity {
position: relative;
overflow: hidden;
border-radius: 28rpx;
border: 1rpx solid #ead8ca;
padding: 25rpx;
background: linear-gradient(125deg, #fff3e5, #f0e9f8);
box-shadow: 0 14rpx 35rpx #79543a0b;
color: #604a40;
}
.id-top {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #dfcdbf;
}
.brand {
display: flex;
gap: 9rpx;
align-items: center;
}
.brand image {
width: 44rpx;
height: 44rpx;
}
.brand-name,
.brand-en {
display: block;
}
.brand-name {
font-size: 18rpx;
font-weight: 600;
color: #795a4b;
}
.brand-en {
font-size: 11rpx;
letter-spacing: 2rpx;
color: #a48d7e;
}
.id-title {
font-size: 23rpx;
letter-spacing: 3rpx;
font-weight: 600;
}
.id-body {
display: flex;
gap: 24rpx;
padding: 24rpx 0;
}
.portrait {
width: 166rpx;
height: 180rpx;
flex: none;
border: 6rpx solid #fffaf4;
border-radius: 18rpx;
overflow: hidden;
background: #fffaf4;
display: flex;
align-items: center;
justify-content: center;
}
.portrait image {
width: 100%;
height: 100%;
}
.details {
flex: 1;
min-width: 0;
}
.pet-name {
display: block;
font-size: 34rpx;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.breed {
display: block;
font-size: 21rpx;
color: #886c5f;
margin: 2rpx 0 12rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.facts {
display: flex;
gap: 22rpx;
}
.facts > view {
flex: 1;
min-width: 0;
}
.label {
display: block;
font-size: 17rpx;
color: #a48d7e;
}
.value {
display: block;
font-size: 21rpx;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.coat {
margin-top: 9rpx;
}
.id-bottom {
border-top: 1rpx solid #dfcdbf;
padding-top: 15rpx;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 10rpx;
}
.number {
display: block;
font-size: 18rpx;
letter-spacing: 1rpx;
color: #795a4b;
}
.motto {
font-size: 16rpx;
color: #886c5f;
max-width: 200rpx;
text-align: right;
}
.card-actions {
display: flex;
justify-content: flex-end;
gap: 16rpx;
margin-top: 12rpx;
}
.card-actions button {
margin: 0;
padding: 9rpx 20rpx;
border: 1rpx solid #ead8ca;
border-radius: 99rpx;
background: #fffaf4;
color: #886c5f;
font-size: 22rpx;
line-height: 1.5;
}
.card-actions .save {
background: #fff0e2;
color: #b95738;
border-color: #f1d6c4;
}
.export-canvas {
position: fixed;
left: -2000px;
top: 0;
width: 600px;
height: 380px;
pointer-events: none;
}
+14 -13
View File
@@ -1,4 +1,5 @@
const { secure, toast } = require("../../utils/api"); const completion = require("../../utils/task-completion");
const { secure } = require("../../utils/api");
const { localDate } = require("../../utils/date"); const { localDate } = require("../../utils/date");
const environment = require("../../utils/environment"); const environment = require("../../utils/environment");
function dateLabel() { function dateLabel() {
@@ -12,6 +13,9 @@ Page({
pets: [], pets: [],
selected: [], selected: [],
tasks: [], tasks: [],
taskBusyIds: [],
completingAll: false,
undoneCount: 0,
walkCount: 0, walkCount: 0,
name: "铲屎官", name: "铲屎官",
loading: true, loading: true,
@@ -100,6 +104,7 @@ Page({
pets, pets,
selected: chosen.filter((id) => pets.some((p) => p.id === id)), selected: chosen.filter((id) => pets.some((p) => p.id === id)),
tasks: tasks.items, tasks: tasks.items,
undoneCount: tasks.items.filter((t) => !t.completed).length,
walkCount: walks.filter((w) => w.started_at.slice(0, 7) === month) walkCount: walks.filter((w) => w.started_at.slice(0, 7) === month)
.length, .length,
name: me.nickname || "毛孩家长", name: me.nickname || "毛孩家长",
@@ -138,17 +143,13 @@ Page({
goWalks() { goWalks() {
wx.navigateTo({ url: "/pages/walk-records/walk-records" }); wx.navigateTo({ url: "/pages/walk-records/walk-records" });
}, },
async complete(e) { complete(e) {
const id = e.currentTarget.dataset.id, return completion.complete(
task = this.data.tasks.find((t) => t.id === id); this,
try { this.data.tasks.find((t) => t.id === e.currentTarget.dataset.id),
await secure("/tasks/" + id + "/completion", "PUT", { );
date: localDate(), },
completed: !task.completed, completeAll() {
}); return completion.completeAll(this);
await this.load();
} catch (err) {
toast(err);
}
}, },
}); });
+8 -3
View File
@@ -70,23 +70,28 @@
<text class="section-title">今天要做的事</text> <text class="section-title">今天要做的事</text>
<text class="link" bindtap="goTasks">全部任务 ›</text> <text class="link" bindtap="goTasks">全部任务 ›</text>
</view> </view>
<view wx:if="{{tasks.length}}" class="completion-toolbar">
<text>{{undoneCount ? '还有 '+undoneCount+' 项小事' : '这一份日常,已全部照顾好'}}</text>
<button wx:if="{{undoneCount}}" class="finish-all" bindtap="completeAll" loading="{{completingAll}}" disabled="{{completingAll || taskBusyIds.length}}">一键完成</button>
<text wx:else class="all-done">全部完成</text>
</view>
<view class="card tasks"> <view class="card tasks">
<view <view
wx:for="{{tasks}}" wx:for="{{tasks}}"
wx:key="id" wx:key="id"
class="task" class="task {{item.completed?'task-completed':''}}"
data-id="{{item.id}}" data-id="{{item.id}}"
bindtap="complete" bindtap="complete"
> >
<view class="check {{item.completed?'done':''}}"> <view class="check {{item.completed?'done':''}}">
{{item.completed?'✓':''}} {{item.completed?'✓':taskBusyIds[0]===item.id?'…':''}}
</view> </view>
<view class="task-icon"> <view class="task-icon">
<app-icon kind="{{item.category}}" size="36" /> <app-icon kind="{{item.category}}" size="36" />
</view> </view>
<view class="task-main"> <view class="task-main">
<text class="task-name">{{item.title}}</text> <text class="task-name">{{item.title}}</text>
<text class="muted">{{item.time_of_day}}</text> <text class="muted">{{item.time_of_day}} · {{item.completed?'已完成':taskBusyIds[0]===item.id?'正在记录…':'点击完成'}}</text>
</view> </view>
</view> </view>
<view wx:if="{{!tasks.length}}" class="empty"> <view wx:if="{{!tasks.length}}" class="empty">
+22
View File
@@ -48,3 +48,25 @@
font-size: 24rpx; font-size: 24rpx;
margin-top: 20rpx; margin-top: 20rpx;
} }
.field input {
height: 104rpx;
min-height: 104rpx;
padding: 0 26rpx;
line-height: normal;
}
.picker {
min-height: 104rpx;
padding: 30rpx 26rpx;
}
.field radio-group {
min-height: 104rpx;
padding: 26rpx;
}
.field textarea {
height: 210rpx;
padding: 26rpx;
}
.field {
margin: 28rpx 0;
}
+1 -1
View File
@@ -16,7 +16,7 @@ Page({
}, },
edit(e) { edit(e) {
wx.navigateTo({ wx.navigateTo({
url: "/pages/pet-edit/pet-edit?id=" + e.currentTarget.dataset.id, url: "/pages/pet-edit/pet-edit?id=" + e.detail.id,
}); });
}, },
}); });
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"disableScroll": true, "disableScroll": true,
"usingComponents": { "usingComponents": {
"tab-bar": "/components/tab-bar/index" "tab-bar": "/components/tab-bar/index",
"pet-id-card": "/components/pet-id-card/index"
} }
} }
+1 -25
View File
@@ -11,31 +11,7 @@
<text class="section-title">宠物档案 · {{pets.length}}</text> <text class="section-title">宠物档案 · {{pets.length}}</text>
<text class="add" bindtap="add">+ 添加</text> <text class="add" bindtap="add">+ 添加</text>
</view> </view>
<view <pet-id-card wx:for="{{pets}}" wx:key="id" pet="{{item}}" bind:edit="edit" />
wx:for="{{pets}}"
wx:key="id"
class="card pet"
data-id="{{item.id}}"
bindtap="edit"
>
<view class="pet-avatar">
<image
wx:if="{{item.avatar_url}}"
src="{{item.avatar_url}}"
mode="aspectFill"
/>
<app-icon wx:else size="70" />
</view>
<view class="pet-info">
<strong>{{item.name}}</strong>
<text>
{{item.breed||'小可爱'}} ·
{{item.gender==='male'?'男孩':item.gender==='female'?'女孩':'未知'}}
</text>
<text>体重 {{item.weight_kg||'—'}} kg</text>
</view>
<text class="arrow">›</text>
</view>
<view wx:if="{{!pets.length}}" class="empty">给第一只狗狗建个档案吧 </view> <view wx:if="{{!pets.length}}" class="empty">给第一只狗狗建个档案吧 </view>
<button class="primary add-button" bindtap="add">+ 新建宠物档案</button> <button class="primary add-button" bindtap="add">+ 新建宠物档案</button>
</view> </view>
-37
View File
@@ -33,43 +33,6 @@
font-weight: 600; font-weight: 600;
color: #b95738; color: #b95738;
} }
.pet {
display: flex;
align-items: center;
gap: 22rpx;
}
.pet-avatar {
width: 125rpx;
height: 125rpx;
flex: none;
border-radius: 28rpx;
background: #f1d2a3;
display: grid;
place-items: center;
font-size: 63rpx;
overflow: hidden;
}
.pet-avatar image {
width: 100%;
height: 100%;
}
.pet-info {
display: flex;
flex-direction: column;
gap: 6rpx;
flex: 1;
}
.pet-info strong {
font-size: 32rpx;
}
.pet-info text {
font-size: 23rpx;
color: #886c5f;
}
.arrow {
font-size: 42rpx;
color: #886c5f;
}
.add-button { .add-button {
margin-top: 25rpx; margin-top: 25rpx;
} }
+29 -19
View File
@@ -1,9 +1,13 @@
const completion = require("../../utils/task-completion");
const { secure, toast } = require("../../utils/api"); const { secure, toast } = require("../../utils/api");
const { localDate, shiftDate } = require("../../utils/date"); const { localDate, shiftDate } = require("../../utils/date");
Page({ Page({
data: { data: {
pets: [], pets: [],
tasks: [], tasks: [],
taskBusyIds: [],
completingAll: false,
undoneCount: 0,
date: localDate(), date: localDate(),
showForm: false, showForm: false,
editingId: "", editingId: "",
@@ -33,23 +37,21 @@ Page({
this.setData({ this.setData({
pets, pets,
tasks, tasks,
undoneCount: tasks.filter((t) => !t.completed).length,
done: tasks.filter((t) => t.completed).length, done: tasks.filter((t) => t.completed).length,
}); });
} catch (e) { } catch (e) {
toast(e); toast(e);
} }
}, },
async toggle(e) { toggle(e) {
const t = this.data.tasks[e.currentTarget.dataset.index]; return completion.complete(
try { this,
await secure("/tasks/" + t.id + "/completion", "PUT", { this.data.tasks[e.currentTarget.dataset.index],
date: this.data.date, );
completed: !t.completed, },
}); completeAll() {
await this.load(); return completion.completeAll(this);
} catch (err) {
toast(err);
}
}, },
open() { open() {
if (!this.data.pets.length) { if (!this.data.pets.length) {
@@ -83,10 +85,14 @@ Page({
}); });
}, },
changeDay(e) { changeDay(e) {
this.setData({ date: shiftDate(this.data.date, Number(e.currentTarget.dataset.days)) }); if (this.data.completingAll || this.data.taskBusyIds.length) return;
this.setData({
date: shiftDate(this.data.date, Number(e.currentTarget.dataset.days)),
});
this.load(); this.load();
}, },
showToday() { showToday() {
if (this.data.completingAll || this.data.taskBusyIds.length) return;
this.setData({ date: localDate() }); this.setData({ date: localDate() });
this.load(); this.load();
}, },
@@ -113,13 +119,17 @@ Page({
} }
this.setData({ saving: true }); this.setData({ saving: true });
try { try {
await secure(this.data.editingId ? "/tasks/" + this.data.editingId : "/tasks", this.data.editingId ? "PUT" : "POST", { await secure(
pet_id: this.data.pets[this.data.petIndex].id, this.data.editingId ? "/tasks/" + this.data.editingId : "/tasks",
title: this.data.title.trim(), this.data.editingId ? "PUT" : "POST",
category: this.data.category, {
time_of_day: this.data.time_of_day, pet_id: this.data.pets[this.data.petIndex].id,
repeat_rule: this.data.repeat_rule, 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, editingId: "" }); this.setData({ showForm: false, editingId: "" });
await this.load(); await this.load();
} catch (e) { } catch (e) {
+8 -3
View File
@@ -19,14 +19,19 @@
<text bindtap="showToday">回到今天</text> <text bindtap="showToday">回到今天</text>
<text data-days="1" bindtap="changeDay">后一天 ›</text> <text data-days="1" bindtap="changeDay">后一天 ›</text>
</view> </view>
<view wx:if="{{tasks.length}}" class="completion-toolbar">
<text>{{undoneCount ? '还有 '+undoneCount+' 项小事' : '这一份日常,已全部照顾好'}}</text>
<button wx:if="{{undoneCount}}" class="finish-all" bindtap="completeAll" loading="{{completingAll}}" disabled="{{completingAll || taskBusyIds.length}}">一键完成</button>
<text wx:else class="all-done">全部完成</text>
</view>
<view class="card list"> <view class="card list">
<view wx:for="{{tasks}}" wx:key="id" class="task"> <view wx:for="{{tasks}}" wx:key="id" class="task {{item.completed?'task-completed':''}}">
<view <view
class="check {{item.completed?'done':''}}" class="check {{item.completed?'done':''}}"
data-index="{{index}}" data-index="{{index}}"
bindtap="toggle" bindtap="toggle"
> >
{{item.completed?'✓':''}} {{item.completed?'✓':taskBusyIds[0]===item.id?'…':''}}
</view> </view>
<view class="icon"> <view class="icon">
<app-icon kind="{{item.category}}" size="36" /> <app-icon kind="{{item.category}}" size="36" />
@@ -37,7 +42,7 @@
{{item.petName}} · {{item.time_of_day}} · {{item.petName}} · {{item.time_of_day}} ·
{{item.repeat_rule==='daily'?'每天':item.repeat_rule==='weekdays'?'工作日':'周末'}} {{item.repeat_rule==='daily'?'每天':item.repeat_rule==='weekdays'?'工作日':'周末'}}
</text> </text>
<text>点此编辑</text> <text>{{item.completed?'已完成 · 点此编辑任务':taskBusyIds[0]===item.id?'正在记录…':'点击左侧完成 · 点此编辑'}}</text>
</view> </view>
<text class="remove" data-index="{{index}}" bindtap="remove">×</text> <text class="remove" data-index="{{index}}" bindtap="remove">×</text>
</view> </view>
+207
View File
@@ -0,0 +1,207 @@
function profile(pet) {
return {
...pet,
genderLabel:
pet.gender === "male"
? "男孩"
: pet.gender === "female"
? "女孩"
: "未填写",
birthday: pet.birth_date ? pet.birth_date.slice(0, 10) : "未填写",
weightLabel: pet.weight_kg ? `${pet.weight_kg} kg` : "未填写",
cardNumber:
"PET-" + (pet.id || "").replace(/-/g, "").slice(0, 12).toUpperCase(),
};
}
function round(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function text(ctx, value, x, y, size, color, width, bold = false) {
ctx.font = `${bold ? "600 " : ""}${size}px sans-serif`;
ctx.fillStyle = color;
let label = String(value || "未填写");
if (width && ctx.measureText(label).width > width) {
while (label && ctx.measureText(label + "…").width > width)
label = label.slice(0, -1);
label += "…";
}
ctx.fillText(label, x, y);
}
function image(canvas, path) {
return new Promise((resolve, reject) => {
const img = canvas.createImage();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("照片加载失败,请重试"));
img.src = path;
});
}
async function renderCard(canvas, pet) {
const p = profile(pet);
canvas.width = 1200;
canvas.height = 760;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, 1200, 760);
const gradient = ctx.createLinearGradient(0, 0, 1200, 760);
gradient.addColorStop(0, "#fff3e5");
gradient.addColorStop(1, "#f0e9f8");
ctx.fillStyle = gradient;
round(ctx, 0, 0, 1200, 760, 48);
ctx.fill();
ctx.save();
round(ctx, 0, 0, 1200, 760, 48);
ctx.clip();
ctx.fillStyle = "#ffffff66";
ctx.beginPath();
ctx.arc(1080, 140, 180, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(1020, 700, 210, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.strokeStyle = "#e9d6c7";
ctx.lineWidth = 2;
round(ctx, 20, 20, 1160, 720, 34);
ctx.stroke();
const logo = await image(canvas, "/assets/logo-corgi-v2.png");
ctx.drawImage(logo, 54, 52, 70, 70);
text(ctx, "肉垫计划", 140, 82, 27, "#795a4b", 220, true);
text(ctx, "PAWPRINT ID", 140, 113, 18, "#a48d7e", 220);
text(ctx, "毛孩身份卡", 842, 103, 38, "#604a40", 300, true);
ctx.strokeStyle = "#dfcdbf";
ctx.beginPath();
ctx.moveTo(54, 155);
ctx.lineTo(1146, 155);
ctx.stroke();
ctx.fillStyle = "#fffaf4";
round(ctx, 58, 200, 310, 340, 28);
ctx.fill();
if (p.avatar_url) {
const info = await new Promise((resolve, reject) =>
wx.getImageInfo({
src: p.avatar_url,
success: resolve,
fail: () => reject(new Error("照片下载失败,请检查网络后重试")),
}),
);
const photo = await image(canvas, info.path);
ctx.save();
round(ctx, 70, 212, 286, 316, 20);
ctx.clip();
// Aspect fill, with a centered crop matching the card's portrait window.
const ratio = 286 / 316;
const sw = Math.min(photo.width, photo.height * ratio),
sh = sw / ratio;
ctx.drawImage(
photo,
(photo.width - sw) / 2,
(photo.height - sh) / 2,
sw,
sh,
70,
212,
286,
316,
);
ctx.restore();
} else {
ctx.fillStyle = "#d18b67";
ctx.beginPath();
ctx.ellipse(213, 399, 53, 42, 0, 0, Math.PI * 2);
ctx.fill();
for (const [x, y, r] of [
[145, 335, 20],
[188, 309, 22],
[235, 309, 22],
[278, 335, 20],
]) {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
text(ctx, "等待一张可爱照片", 114, 491, 23, "#9b7966", 230);
}
text(ctx, p.name, 410, 250, 58, "#604a40", 700, true);
text(
ctx,
`${p.breed || "品种未填写"} · ${p.genderLabel}`,
412,
298,
28,
"#886c5f",
700,
);
const fields = [
["生日", p.birthday, 412, 368],
["体重", p.weightLabel, 805, 368],
["毛色", p.coat || "未填写", 412, 465],
];
for (const [label, value, x, y] of fields) {
text(ctx, label, x, y, 23, "#a48d7e", 290);
text(
ctx,
value,
x,
y + 43,
31,
"#604a40",
x === 412 && y === 465 ? 680 : 300,
true,
);
}
ctx.strokeStyle = "#dfcdbf";
ctx.beginPath();
ctx.moveTo(54, 585);
ctx.lineTo(1146, 585);
ctx.stroke();
text(ctx, "陪伴编号", 58, 635, 20, "#a48d7e", 200);
text(ctx, p.cardNumber, 58, 676, 29, "#795a4b", 650, true);
text(ctx, "每一位家人,都值得被好好照顾", 650, 669, 24, "#886c5f", 490);
return new Promise((resolve, reject) =>
wx.canvasToTempFilePath({
canvas,
fileType: "png",
success: (res) => resolve(res.tempFilePath),
fail: () => reject(new Error("档案图片生成失败,请重试")),
}),
);
}
async function saveToAlbum(filePath) {
try {
await new Promise((resolve, reject) =>
wx.saveImageToPhotosAlbum({ filePath, success: resolve, fail: reject }),
);
} catch (error) {
if (/cancel/.test(error.errMsg || "")) return false;
const settings = await new Promise((resolve, reject) =>
wx.getSetting({ success: resolve, fail: reject }),
);
if (settings.authSetting["scope.writePhotosAlbum"] !== false)
throw new Error("保存失败,请在真机上重试");
const confirm = await wx.showModal({
title: "允许保存档案卡",
content: "需要相册权限,将这张毛孩身份卡保存到你的手机。",
confirmText: "去设置",
});
if (!confirm.confirm) return false;
const enabled = await new Promise((resolve, reject) =>
wx.openSetting({ success: resolve, fail: reject }),
);
if (!enabled.authSetting["scope.writePhotosAlbum"]) return false;
await new Promise((resolve, reject) =>
wx.saveImageToPhotosAlbum({
filePath,
success: resolve,
fail: () => reject(new Error("保存失败,请重试")),
}),
);
}
return true;
}
module.exports = { profile, renderCard, saveToAlbum };
+73
View File
@@ -0,0 +1,73 @@
const { secure, toast } = require("./api");
const { localDate } = require("./date");
function apply(page, ids) {
const tasks = page.data.tasks.map((task) =>
ids.includes(task.id) ? { ...task, completed: true } : task,
);
page.setData({
tasks,
done: tasks.filter((t) => t.completed).length,
undoneCount: tasks.filter((t) => !t.completed).length,
});
if (wx.vibrateShort) wx.vibrateShort({ type: "light", fail() {} });
}
async function complete(page, task) {
if (
!task ||
task.completed ||
page.data.completingAll ||
page.data.taskBusyIds.length
)
return;
const date = page.data.date || localDate();
page.setData({ taskBusyIds: [task.id] });
try {
await secure("/tasks/" + task.id + "/completion", "PUT", {
date,
completed: true,
});
if (page.data.date && page.data.date !== date) return;
apply(page, [task.id]);
wx.showToast({ title: "照顾好了,记下一份陪伴", icon: "success" });
} catch (error) {
toast(error);
} finally {
page.setData({ taskBusyIds: [] });
}
}
async function completeAll(page) {
if (page.data.completingAll || page.data.taskBusyIds.length) return;
const tasks = page.data.tasks.filter((t) => !t.completed);
if (!tasks.length) return;
const date = page.data.date || localDate();
page.setData({ completingAll: true });
try {
const confirmation = await wx.showModal({
title: "都照顾好了吗?",
content: `将 ${date} 的 ${tasks.length} 项待办记为完成,完成后不能撤销。`,
confirmText: "全部完成",
confirmColor: "#b95738",
});
if (!confirmation.confirm) return;
// The API accepts bounded batches; avoid partial completion of larger lists.
if (tasks.length > 200) throw new Error("任务较多,请分项完成后再试");
await secure("/tasks/completions", "PUT", {
date,
task_ids: tasks.map((t) => t.id),
});
if (page.data.date && page.data.date !== date) return;
apply(
page,
tasks.map((t) => t.id),
);
wx.showToast({ title: "日常照顾全部完成", icon: "success" });
} catch (error) {
toast(error);
} finally {
page.setData({ completingAll: false });
}
}
module.exports = { complete, completeAll };
+104
View File
@@ -0,0 +1,104 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const vm = require("node:vm");
function runtime() {
const calls = [],
notices = [];
let confirm = true,
failure = false,
release;
const module = { exports: {} };
vm.runInNewContext(
fs.readFileSync("pets-fe/utils/task-completion.js", "utf8"),
{
module,
require: (name) =>
name.includes("date")
? { localDate: () => "2026-10-05" }
: {
secure: async (...args) => {
calls.push(args);
if (release) await release;
if (failure) throw new Error("offline");
},
toast: (error) => notices.push(error.message),
},
wx: {
showModal: async () => ({ confirm }),
showToast: (opts) => notices.push(opts.title),
vibrateShort() {},
},
},
);
const page = {
data: {
tasks: [
{ id: "a", completed: false },
{ id: "b", completed: true },
],
taskBusyIds: [],
completingAll: false,
},
setData(value) {
Object.assign(this.data, value);
},
};
return {
api: module.exports,
page,
calls,
notices,
cancel: () => {
confirm = false;
},
fail: () => {
failure = true;
},
hold: (p) => {
release = p;
},
};
}
test("完成即时更新且不能撤销,快速重复点击只发送一次", async () => {
const r = runtime();
let release;
r.hold(
new Promise((resolve) => {
release = resolve;
}),
);
const action = r.api.complete(r.page, r.page.data.tasks[0]);
await r.api.complete(r.page, r.page.data.tasks[0]);
assert.equal(r.calls.length, 1);
release();
await action;
assert.equal(r.page.data.tasks[0].completed, true);
assert.equal(r.page.data.undoneCount, 0);
await r.api.complete(r.page, r.page.data.tasks[0]);
assert.equal(r.calls.length, 1);
assert.equal(r.calls[0][2].completed, true);
});
test("一键完成只发送未完成项,确认取消不发送请求", async () => {
const r = runtime();
r.cancel();
await r.api.completeAll(r.page);
assert.equal(r.calls.length, 0);
assert.equal(r.page.data.completingAll, false);
const s = runtime();
await s.api.completeAll(s.page);
assert.equal(s.calls[0][0], "/tasks/completions");
assert.equal(JSON.stringify(s.calls[0][2].task_ids), '["a"]');
assert.equal(s.page.data.done, 2);
});
test("完成请求失败保留待办,释放按钮可重试", async () => {
const r = runtime();
r.fail();
await r.api.completeAll(r.page);
assert.equal(r.page.data.tasks[0].completed, false);
assert.equal(r.page.data.completingAll, false);
await r.api.complete(r.page, r.page.data.tasks[0]);
assert.equal(r.page.data.taskBusyIds.length, 0);
assert.equal(r.page.data.tasks[0].completed, false);
});
+104
View File
@@ -0,0 +1,104 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const vm = require("node:vm");
function library(wx) {
const module = { exports: {} };
vm.runInNewContext(fs.readFileSync("pets-fe/utils/pet-card.js", "utf8"), {
wx,
module,
});
return module.exports;
}
test("保存卡片被拒绝授权后,确认去设置并授权则重试同一图片", async () => {
const saves = [];
const api = library({
saveImageToPhotosAlbum(options) {
saves.push(options.filePath);
if (saves.length === 1) options.fail({ errMsg: "auth deny" });
else options.success();
},
getSetting: (options) =>
options.success({ authSetting: { "scope.writePhotosAlbum": false } }),
showModal: async () => ({ confirm: true }),
openSetting: (options) =>
options.success({ authSetting: { "scope.writePhotosAlbum": true } }),
});
assert.equal(await api.saveToAlbum("/tmp/card.png"), true);
assert.deepEqual(saves, ["/tmp/card.png", "/tmp/card.png"]);
});
test("拒绝开启相册权限不报告保存成功,也不重复保存", async () => {
let saves = 0;
const api = library({
saveImageToPhotosAlbum: (options) => {
saves++;
options.fail({ errMsg: "auth deny" });
},
getSetting: (options) =>
options.success({ authSetting: { "scope.writePhotosAlbum": false } }),
showModal: async () => ({ confirm: false }),
});
assert.equal(await api.saveToAlbum("/tmp/card.png"), false);
assert.equal(saves, 1);
});
test("导出等待照片读取后生成 PNG,远程照片失败不会导出错误头像", async () => {
let exported = 0,
imageOptions;
const ctx = new Proxy(
{},
{
get(target, key) {
if (key === "measureText")
return (text) => ({ width: text.length * 10 });
if (key === "createLinearGradient")
return () => ({ addColorStop() {} });
return () => {};
},
set() {
return true;
},
},
);
const canvas = {
getContext: () => ctx,
createImage() {
const img = { width: 200, height: 300 };
Object.defineProperty(img, "src", {
set() {
queueMicrotask(() => img.onload());
},
});
return img;
},
};
const api = library({
getImageInfo: (options) => {
imageOptions = options;
},
canvasToTempFilePath: (options) => {
exported++;
assert.equal(options.fileType, "png");
options.success({ tempFilePath: "/tmp/card.png" });
},
});
const waiting = api.renderCard(canvas, {
id: "1234",
name: "毛孩",
avatar_url: "https://s3.test/pet.png",
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(exported, 0);
imageOptions.success({ path: "/tmp/photo.png" });
assert.equal(await waiting, "/tmp/card.png");
assert.equal(canvas.width, 1200);
assert.equal(canvas.height, 760);
const failed = api.renderCard(canvas, {
name: "毛孩",
avatar_url: "https://s3.test/missing.png",
});
await new Promise((resolve) => setImmediate(resolve));
imageOptions.fail();
await assert.rejects(failed, /照片下载失败/);
assert.equal(exported, 1);
});