diff --git a/pets-be/internal/httpapi/server.go b/pets-be/internal/httpapi/server.go
index 005bd8e..ffd253c 100644
--- a/pets-be/internal/httpapi/server.go
+++ b/pets-be/internal/httpapi/server.go
@@ -54,6 +54,7 @@ func (a *API) Register(r *gin.Engine) {
user.PUT("/tasks/:id", a.updateTask)
user.DELETE("/tasks/:id", a.deleteTask)
user.PUT("/tasks/:id/completion", a.setTaskCompletion)
+ user.PUT("/tasks/completions", a.completeTasks)
user.GET("/walks", a.listWalks)
user.POST("/walks", a.startWalk)
user.GET("/walks/:id", a.getWalk)
diff --git a/pets-be/internal/httpapi/tasks.go b/pets-be/internal/httpapi/tasks.go
index 0cf2bfe..226483a 100644
--- a/pets-be/internal/httpapi/tasks.go
+++ b/pets-be/internal/httpapi/tasks.go
@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
+ "gorm.io/gorm/clause"
"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) {
- 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 {
Date string `json:"date" binding:"required"`
Completed bool `json:"completed"`
@@ -192,20 +188,82 @@ func (a *API) setTaskCompletion(c *gin.Context) {
fail(c, http.StatusBadRequest, "完成状态参数无效")
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)
if err != nil || !due(task, date) {
fail(c, http.StatusBadRequest, "该日期没有这项任务")
return
}
- if in.Completed {
- item := model.TaskCompletion{TaskID: task.ID, UserID: actor(c), Date: date, CompletedAt: time.Now()}
- if err := a.db.Where("task_id = ? AND date = ?", task.ID, date).FirstOrCreate(&item).Error; err != nil {
- 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, "取消完成失败")
+ 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 {
+ fail(c, http.StatusInternalServerError, "保存完成状态失败")
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})
}
diff --git a/pets-be/internal/httpapi/tasks_test.go b/pets-be/internal/httpapi/tasks_test.go
new file mode 100644
index 0000000..6813d78
--- /dev/null
+++ b/pets-be/internal/httpapi/tasks_test.go
@@ -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)
+ }
+}
diff --git a/pets-fe/app.wxss b/pets-fe/app.wxss
index dcb90c1..7d4ed68 100644
--- a/pets-fe/app.wxss
+++ b/pets-fe/app.wxss
@@ -3,7 +3,8 @@ page {
overflow: hidden;
background: #fff9f3;
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;
line-height: 1.5;
}
@@ -112,3 +113,43 @@ button::after {
.field textarea {
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);
+ }
+}
diff --git a/pets-fe/components/pet-id-card/index.js b/pets-fe/components/pet-id-card/index.js
new file mode 100644
index 0000000..a7f8ab2
--- /dev/null
+++ b/pets-fe/components/pet-id-card/index.js
@@ -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 });
+ }
+ },
+ },
+});
diff --git a/pets-fe/components/pet-id-card/index.json b/pets-fe/components/pet-id-card/index.json
new file mode 100644
index 0000000..9173e85
--- /dev/null
+++ b/pets-fe/components/pet-id-card/index.json
@@ -0,0 +1,4 @@
+{
+ "component": true,
+ "usingComponents": { "app-icon": "/components/app-icon/index" }
+}
diff --git a/pets-fe/components/pet-id-card/index.wxml b/pets-fe/components/pet-id-card/index.wxml
new file mode 100644
index 0000000..dc1c817
--- /dev/null
+++ b/pets-fe/components/pet-id-card/index.wxml
@@ -0,0 +1,10 @@
+
+ 肉垫计划PAWPRINT ID毛孩身份卡
+
+
+ {{card.name}}{{card.breed||'品种未填写'}} · {{card.genderLabel}}生日{{card.birthday}}体重{{card.weightLabel}}毛色{{card.coat||'未填写'}}
+
+ 陪伴编号{{card.cardNumber}}把陪伴,认真记下来
+
+
+
diff --git a/pets-fe/components/pet-id-card/index.wxss b/pets-fe/components/pet-id-card/index.wxss
new file mode 100644
index 0000000..f0f2a1e
--- /dev/null
+++ b/pets-fe/components/pet-id-card/index.wxss
@@ -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;
+}
diff --git a/pets-fe/pages/home/home.js b/pets-fe/pages/home/home.js
index 2a9a98f..65b4190 100644
--- a/pets-fe/pages/home/home.js
+++ b/pets-fe/pages/home/home.js
@@ -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 environment = require("../../utils/environment");
function dateLabel() {
@@ -12,6 +13,9 @@ Page({
pets: [],
selected: [],
tasks: [],
+ taskBusyIds: [],
+ completingAll: false,
+ undoneCount: 0,
walkCount: 0,
name: "铲屎官",
loading: true,
@@ -100,6 +104,7 @@ Page({
pets,
selected: chosen.filter((id) => pets.some((p) => p.id === id)),
tasks: tasks.items,
+ undoneCount: tasks.items.filter((t) => !t.completed).length,
walkCount: walks.filter((w) => w.started_at.slice(0, 7) === month)
.length,
name: me.nickname || "毛孩家长",
@@ -138,17 +143,13 @@ Page({
goWalks() {
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: localDate(),
- completed: !task.completed,
- });
- await this.load();
- } catch (err) {
- toast(err);
- }
+ complete(e) {
+ return completion.complete(
+ this,
+ this.data.tasks.find((t) => t.id === e.currentTarget.dataset.id),
+ );
+ },
+ completeAll() {
+ return completion.completeAll(this);
},
});
diff --git a/pets-fe/pages/home/home.wxml b/pets-fe/pages/home/home.wxml
index 9acbdb1..c3cffff 100644
--- a/pets-fe/pages/home/home.wxml
+++ b/pets-fe/pages/home/home.wxml
@@ -70,23 +70,28 @@
今天要做的事
全部任务 ›
+
+ {{undoneCount ? '还有 '+undoneCount+' 项小事' : '这一份日常,已全部照顾好'}}
+
+ 全部完成
+
- {{item.completed?'✓':''}}
+ {{item.completed?'✓':taskBusyIds[0]===item.id?'…':''}}
{{item.title}}
- {{item.time_of_day}}
+ {{item.time_of_day}} · {{item.completed?'已完成':taskBusyIds[0]===item.id?'正在记录…':'点击完成'}}
diff --git a/pets-fe/pages/pet-edit/pet-edit.wxss b/pets-fe/pages/pet-edit/pet-edit.wxss
index 3c90e94..5ef0497 100644
--- a/pets-fe/pages/pet-edit/pet-edit.wxss
+++ b/pets-fe/pages/pet-edit/pet-edit.wxss
@@ -48,3 +48,25 @@
font-size: 24rpx;
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;
+}
diff --git a/pets-fe/pages/pets/pets.js b/pets-fe/pages/pets/pets.js
index f1738ce..af8fae1 100644
--- a/pets-fe/pages/pets/pets.js
+++ b/pets-fe/pages/pets/pets.js
@@ -16,7 +16,7 @@ Page({
},
edit(e) {
wx.navigateTo({
- url: "/pages/pet-edit/pet-edit?id=" + e.currentTarget.dataset.id,
+ url: "/pages/pet-edit/pet-edit?id=" + e.detail.id,
});
},
});
diff --git a/pets-fe/pages/pets/pets.json b/pets-fe/pages/pets/pets.json
index e2b624a..b1a77fb 100644
--- a/pets-fe/pages/pets/pets.json
+++ b/pets-fe/pages/pets/pets.json
@@ -1,6 +1,7 @@
{
"disableScroll": true,
"usingComponents": {
- "tab-bar": "/components/tab-bar/index"
+ "tab-bar": "/components/tab-bar/index",
+ "pet-id-card": "/components/pet-id-card/index"
}
}
diff --git a/pets-fe/pages/pets/pets.wxml b/pets-fe/pages/pets/pets.wxml
index 96d6659..bb4af51 100644
--- a/pets-fe/pages/pets/pets.wxml
+++ b/pets-fe/pages/pets/pets.wxml
@@ -11,31 +11,7 @@
宠物档案 · {{pets.length}}
+ 添加
-
-
-
-
-
-
- {{item.name}}
-
- {{item.breed||'小可爱'}} ·
- {{item.gender==='male'?'男孩':item.gender==='female'?'女孩':'未知'}}
-
- 体重 {{item.weight_kg||'—'}} kg
-
- ›
-
+
给第一只狗狗建个档案吧
diff --git a/pets-fe/pages/pets/pets.wxss b/pets-fe/pages/pets/pets.wxss
index b1ab14d..4ee1e29 100644
--- a/pets-fe/pages/pets/pets.wxss
+++ b/pets-fe/pages/pets/pets.wxss
@@ -33,43 +33,6 @@
font-weight: 600;
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 {
margin-top: 25rpx;
}
diff --git a/pets-fe/pages/tasks/tasks.js b/pets-fe/pages/tasks/tasks.js
index 3aad5ac..7d54472 100644
--- a/pets-fe/pages/tasks/tasks.js
+++ b/pets-fe/pages/tasks/tasks.js
@@ -1,9 +1,13 @@
+const completion = require("../../utils/task-completion");
const { secure, toast } = require("../../utils/api");
const { localDate, shiftDate } = require("../../utils/date");
Page({
data: {
pets: [],
tasks: [],
+ taskBusyIds: [],
+ completingAll: false,
+ undoneCount: 0,
date: localDate(),
showForm: false,
editingId: "",
@@ -33,23 +37,21 @@ Page({
this.setData({
pets,
tasks,
+ undoneCount: tasks.filter((t) => !t.completed).length,
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,
- });
- await this.load();
- } catch (err) {
- toast(err);
- }
+ toggle(e) {
+ return completion.complete(
+ this,
+ this.data.tasks[e.currentTarget.dataset.index],
+ );
+ },
+ completeAll() {
+ return completion.completeAll(this);
},
open() {
if (!this.data.pets.length) {
@@ -83,10 +85,14 @@ Page({
});
},
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();
},
showToday() {
+ if (this.data.completingAll || this.data.taskBusyIds.length) return;
this.setData({ date: localDate() });
this.load();
},
@@ -113,13 +119,17 @@ Page({
}
this.setData({ saving: true });
try {
- 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.trim(),
- category: this.data.category,
- time_of_day: this.data.time_of_day,
- repeat_rule: this.data.repeat_rule,
- });
+ 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.trim(),
+ category: this.data.category,
+ time_of_day: this.data.time_of_day,
+ repeat_rule: this.data.repeat_rule,
+ },
+ );
this.setData({ showForm: false, editingId: "" });
await this.load();
} catch (e) {
diff --git a/pets-fe/pages/tasks/tasks.wxml b/pets-fe/pages/tasks/tasks.wxml
index d92a094..a5c4e47 100644
--- a/pets-fe/pages/tasks/tasks.wxml
+++ b/pets-fe/pages/tasks/tasks.wxml
@@ -19,14 +19,19 @@
回到今天
后一天 ›
+
+ {{undoneCount ? '还有 '+undoneCount+' 项小事' : '这一份日常,已全部照顾好'}}
+
+ 全部完成
+
-
+
- {{item.completed?'✓':''}}
+ {{item.completed?'✓':taskBusyIds[0]===item.id?'…':''}}
@@ -37,7 +42,7 @@
{{item.petName}} · {{item.time_of_day}} ·
{{item.repeat_rule==='daily'?'每天':item.repeat_rule==='weekdays'?'工作日':'周末'}}
- 点此编辑
+ {{item.completed?'已完成 · 点此编辑任务':taskBusyIds[0]===item.id?'正在记录…':'点击左侧完成 · 点此编辑'}}
×
diff --git a/pets-fe/utils/pet-card.js b/pets-fe/utils/pet-card.js
new file mode 100644
index 0000000..7ef0ddf
--- /dev/null
+++ b/pets-fe/utils/pet-card.js
@@ -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 };
diff --git a/pets-fe/utils/task-completion.js b/pets-fe/utils/task-completion.js
new file mode 100644
index 0000000..730f734
--- /dev/null
+++ b/pets-fe/utils/task-completion.js
@@ -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 };
diff --git a/tests/miniprogram-completion.test.cjs b/tests/miniprogram-completion.test.cjs
new file mode 100644
index 0000000..9ca58d3
--- /dev/null
+++ b/tests/miniprogram-completion.test.cjs
@@ -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);
+});
diff --git a/tests/miniprogram-pet-card.test.cjs b/tests/miniprogram-pet-card.test.cjs
new file mode 100644
index 0000000..b6104e2
--- /dev/null
+++ b/tests/miniprogram-pet-card.test.cjs
@@ -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);
+});