From cb4ffa13e97e56878e2775ed4dd92ffca5ba6889 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 5 Oct 2026 11:32:32 +0800 Subject: [PATCH] Add one-way task completion and exportable pet identity cards --- pets-be/internal/httpapi/server.go | 1 + pets-be/internal/httpapi/tasks.go | 86 +++++++-- pets-be/internal/httpapi/tasks_test.go | 91 ++++++++++ pets-fe/app.wxss | 43 ++++- pets-fe/components/pet-id-card/index.js | 42 +++++ pets-fe/components/pet-id-card/index.json | 4 + pets-fe/components/pet-id-card/index.wxml | 10 ++ pets-fe/components/pet-id-card/index.wxss | 164 +++++++++++++++++ pets-fe/pages/home/home.js | 27 +-- pets-fe/pages/home/home.wxml | 11 +- pets-fe/pages/pet-edit/pet-edit.wxss | 22 +++ pets-fe/pages/pets/pets.js | 2 +- pets-fe/pages/pets/pets.json | 3 +- pets-fe/pages/pets/pets.wxml | 26 +-- pets-fe/pages/pets/pets.wxss | 37 ---- pets-fe/pages/tasks/tasks.js | 48 +++-- pets-fe/pages/tasks/tasks.wxml | 11 +- pets-fe/utils/pet-card.js | 207 ++++++++++++++++++++++ pets-fe/utils/task-completion.js | 73 ++++++++ tests/miniprogram-completion.test.cjs | 104 +++++++++++ tests/miniprogram-pet-card.test.cjs | 104 +++++++++++ 21 files changed, 999 insertions(+), 117 deletions(-) create mode 100644 pets-be/internal/httpapi/tasks_test.go create mode 100644 pets-fe/components/pet-id-card/index.js create mode 100644 pets-fe/components/pet-id-card/index.json create mode 100644 pets-fe/components/pet-id-card/index.wxml create mode 100644 pets-fe/components/pet-id-card/index.wxss create mode 100644 pets-fe/utils/pet-card.js create mode 100644 pets-fe/utils/task-completion.js create mode 100644 tests/miniprogram-completion.test.cjs create mode 100644 tests/miniprogram-pet-card.test.cjs 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); +});