Feat/dev #5
@@ -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)
|
||||||
|
|||||||
@@ -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})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
@@ -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
@@ -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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user