Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abd64d1cdd | ||
|
|
1d8c1693ac | ||
|
|
25c2596ce9 | ||
|
|
5649ffe0df | ||
|
|
e27adfe64c |
@@ -360,9 +360,7 @@ func (a *API) adminTasks(c *gin.Context) {
|
|||||||
ids, petIDs, taskIDs := []string{}, []string{}, []string{}
|
ids, petIDs, taskIDs := []string{}, []string{}, []string{}
|
||||||
for _, t := range rows {
|
for _, t := range rows {
|
||||||
ids = append(ids, t.UserID)
|
ids = append(ids, t.UserID)
|
||||||
if t.PetID != nil {
|
petIDs = append(petIDs, t.PetID)
|
||||||
petIDs = append(petIDs, *t.PetID)
|
|
||||||
}
|
|
||||||
taskIDs = append(taskIDs, t.ID)
|
taskIDs = append(taskIDs, t.ID)
|
||||||
}
|
}
|
||||||
names, err := a.userNames(ids)
|
names, err := a.userNames(ids)
|
||||||
@@ -390,11 +388,7 @@ func (a *API) adminTasks(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
items := make([]gin.H, 0, len(rows))
|
items := make([]gin.H, 0, len(rows))
|
||||||
for _, t := range rows {
|
for _, t := range rows {
|
||||||
petName := "全部宠物"
|
items = append(items, gin.H{"id": t.ID, "user_id": t.UserID, "user_name": names[t.UserID], "pet_id": t.PetID, "pet_name": petNames[t.PetID], "title": t.Title, "category": t.Category, "time_of_day": t.TimeOfDay, "repeat_rule": t.RepeatRule, "reminder_enabled": t.ReminderEnabled, "completed": done[t.ID], "created_at": t.CreatedAt})
|
||||||
if t.PetID != nil {
|
|
||||||
petName = petNames[*t.PetID]
|
|
||||||
}
|
|
||||||
items = append(items, gin.H{"id": t.ID, "user_id": t.UserID, "user_name": names[t.UserID], "pet_id": t.PetID, "all_pets": t.PetID == nil, "pet_name": petName, "title": t.Title, "category": t.Category, "time_of_day": t.TimeOfDay, "repeat_rule": t.RepeatRule, "reminder_enabled": t.ReminderEnabled, "completed": done[t.ID], "created_at": t.CreatedAt})
|
|
||||||
}
|
}
|
||||||
page.Items = items
|
page.Items = items
|
||||||
ok(c, page)
|
ok(c, page)
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ import (
|
|||||||
var clockPattern = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
|
var clockPattern = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
|
||||||
|
|
||||||
type taskInput struct {
|
type taskInput struct {
|
||||||
PetID string `json:"pet_id"`
|
PetID string `json:"pet_id" binding:"required"`
|
||||||
AllPets bool `json:"all_pets"`
|
|
||||||
Title string `json:"title" binding:"required"`
|
Title string `json:"title" binding:"required"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
TimeOfDay string `json:"time_of_day"`
|
TimeOfDay string `json:"time_of_day"`
|
||||||
@@ -51,7 +50,7 @@ func (a *API) listTasks(c *gin.Context) {
|
|||||||
fail(c, http.StatusNotFound, "宠物不存在")
|
fail(c, http.StatusNotFound, "宠物不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
query = query.Where("(pet_id = ? OR pet_id IS NULL)", petID)
|
query = query.Where("pet_id = ?", petID)
|
||||||
}
|
}
|
||||||
var tasks []model.CareTask
|
var tasks []model.CareTask
|
||||||
if err := query.Order("time_of_day ASC").Find(&tasks).Error; err != nil {
|
if err := query.Order("time_of_day ASC").Find(&tasks).Error; err != nil {
|
||||||
@@ -77,7 +76,7 @@ func (a *API) listTasks(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func taskView(task model.CareTask, completed bool) gin.H {
|
func taskView(task model.CareTask, completed bool) gin.H {
|
||||||
return gin.H{"id": task.ID, "pet_id": task.PetID, "all_pets": task.PetID == nil, "title": task.Title,
|
return gin.H{"id": task.ID, "pet_id": task.PetID, "title": task.Title,
|
||||||
"category": task.Category, "time_of_day": task.TimeOfDay,
|
"category": task.Category, "time_of_day": task.TimeOfDay,
|
||||||
"repeat_rule": task.RepeatRule, "reminder_enabled": task.ReminderEnabled,
|
"repeat_rule": task.RepeatRule, "reminder_enabled": task.ReminderEnabled,
|
||||||
"completed": completed}
|
"completed": completed}
|
||||||
@@ -85,26 +84,9 @@ func taskView(task model.CareTask, completed bool) gin.H {
|
|||||||
|
|
||||||
func (a *API) buildTask(c *gin.Context, in taskInput) (model.CareTask, bool) {
|
func (a *API) buildTask(c *gin.Context, in taskInput) (model.CareTask, bool) {
|
||||||
task := model.CareTask{}
|
task := model.CareTask{}
|
||||||
var petID *string
|
if _, err := a.ownedPet(actor(c), in.PetID); err != nil {
|
||||||
if in.AllPets {
|
|
||||||
if in.PetID != "" {
|
|
||||||
fail(c, http.StatusBadRequest, "全部宠物与单只宠物不能同时选择")
|
|
||||||
return task, false
|
|
||||||
}
|
|
||||||
var count int64
|
|
||||||
if err := a.db.Model(&model.Pet{}).Where("user_id = ? AND archived = false", actor(c)).Count(&count).Error; err != nil {
|
|
||||||
fail(c, http.StatusInternalServerError, "读取宠物失败")
|
|
||||||
return task, false
|
|
||||||
}
|
|
||||||
if count == 0 {
|
|
||||||
fail(c, http.StatusBadRequest, "请先添加宠物")
|
|
||||||
return task, false
|
|
||||||
}
|
|
||||||
} else if _, err := a.ownedPet(actor(c), in.PetID); err != nil {
|
|
||||||
fail(c, http.StatusNotFound, "宠物不存在")
|
fail(c, http.StatusNotFound, "宠物不存在")
|
||||||
return task, false
|
return task, false
|
||||||
} else {
|
|
||||||
petID = &in.PetID
|
|
||||||
}
|
}
|
||||||
in.Title = strings.TrimSpace(in.Title)
|
in.Title = strings.TrimSpace(in.Title)
|
||||||
if in.Title == "" || len([]rune(in.Title)) > 128 {
|
if in.Title == "" || len([]rune(in.Title)) > 128 {
|
||||||
@@ -128,7 +110,7 @@ func (a *API) buildTask(c *gin.Context, in taskInput) (model.CareTask, bool) {
|
|||||||
fail(c, http.StatusBadRequest, "重复规则无效")
|
fail(c, http.StatusBadRequest, "重复规则无效")
|
||||||
return task, false
|
return task, false
|
||||||
}
|
}
|
||||||
task = model.CareTask{UserID: actor(c), PetID: petID, Title: in.Title,
|
task = model.CareTask{UserID: actor(c), PetID: in.PetID, Title: in.Title,
|
||||||
Category: in.Category, TimeOfDay: in.TimeOfDay, RepeatRule: in.RepeatRule,
|
Category: in.Category, TimeOfDay: in.TimeOfDay, RepeatRule: in.RepeatRule,
|
||||||
ReminderEnabled: in.ReminderEnabled}
|
ReminderEnabled: in.ReminderEnabled}
|
||||||
return task, true
|
return task, true
|
||||||
|
|||||||
@@ -47,10 +47,6 @@ func TestTaskCompletionPostgres(t *testing.T) {
|
|||||||
t.Fatal(tx.Error)
|
t.Fatal(tx.Error)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
// Verify the nullable pet association migration without changing live schema.
|
|
||||||
if err := tx.AutoMigrate(&model.CareTask{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
user := model.User{OpenID: "completion-test:" + uuid.NewString(), Nickname: "测试家长"}
|
user := model.User{OpenID: "completion-test:" + uuid.NewString(), Nickname: "测试家长"}
|
||||||
if err := tx.Create(&user).Error; err != nil {
|
if err := tx.Create(&user).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -59,54 +55,11 @@ func TestTaskCompletionPostgres(t *testing.T) {
|
|||||||
if err := tx.Create(&pet).Error; err != nil {
|
if err := tx.Create(&pet).Error; err != nil {
|
||||||
t.Fatal(err)
|
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"}}
|
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 {
|
if err := tx.Create(&tasks).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
api := New(tx, nil, cfg)
|
api := New(tx, nil, cfg)
|
||||||
request := func(method, route, id string, body gin.H, handler gin.HandlerFunc) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
encoded, _ := json.Marshal(body)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
c, _ := gin.CreateTestContext(rec)
|
|
||||||
c.Set("actor_id", user.ID)
|
|
||||||
c.Params = gin.Params{{Key: "id", Value: id}}
|
|
||||||
c.Request = httptest.NewRequest(method, route, bytes.NewReader(encoded))
|
|
||||||
handler(c)
|
|
||||||
return rec
|
|
||||||
}
|
|
||||||
allBody := gin.H{"all_pets": true, "title": "全部换水", "category": "water", "time_of_day": "10:00", "repeat_rule": "daily"}
|
|
||||||
rec := request("POST", "/tasks", "", allBody, api.createTask)
|
|
||||||
if rec.Code != 201 {
|
|
||||||
t.Fatalf("all-pet creation: %d %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
var allTask model.CareTask
|
|
||||||
if err := tx.Where("user_id = ? AND title = ?", user.ID, "全部换水").First(&allTask).Error; err != nil || allTask.PetID != nil {
|
|
||||||
t.Fatalf("all-pet association should be null: %v", err)
|
|
||||||
}
|
|
||||||
// Single-pet filters include shared tasks; other users' tasks remain isolated.
|
|
||||||
rec = request("GET", "/tasks?date=2026-10-05&pet_id="+pet.ID, "", nil, api.listTasks)
|
|
||||||
var listing struct {
|
|
||||||
Data struct {
|
|
||||||
Items []struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
AllPets bool `json:"all_pets"`
|
|
||||||
} `json:"items"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &listing); err != nil || rec.Code != 200 || len(listing.Data.Items) != 3 {
|
|
||||||
t.Fatalf("pet filter omitted shared task: %s", rec.Body.String())
|
|
||||||
}
|
|
||||||
if rec = request("PUT", "/tasks/"+allTask.ID, allTask.ID, gin.H{"pet_id": pet.ID, "title": "单只换水"}, api.updateTask); rec.Code != 200 {
|
|
||||||
t.Fatalf("switch to single pet: %s", rec.Body.String())
|
|
||||||
}
|
|
||||||
if rec = request("PUT", "/tasks/"+allTask.ID, allTask.ID, allBody, api.updateTask); rec.Code != 200 {
|
|
||||||
t.Fatalf("switch to all pets: %s", rec.Body.String())
|
|
||||||
}
|
|
||||||
conflicting := gin.H{"all_pets": true, "pet_id": pet.ID, "title": "冲突选择"}
|
|
||||||
if rec = request("POST", "/tasks", "", conflicting, api.createTask); rec.Code != 400 {
|
|
||||||
t.Fatalf("ambiguous selection accepted: %s", rec.Body.String())
|
|
||||||
}
|
|
||||||
call := func(ids []string) int {
|
call := func(ids []string) int {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
body, _ := json.Marshal(gin.H{"date": "2026-10-05", "task_ids": ids})
|
body, _ := json.Marshal(gin.H{"date": "2026-10-05", "task_ids": ids})
|
||||||
@@ -135,11 +88,4 @@ func TestTaskCompletionPostgres(t *testing.T) {
|
|||||||
if count != 2 {
|
if count != 2 {
|
||||||
t.Fatalf("retry duplicated completions: %d", count)
|
t.Fatalf("retry duplicated completions: %d", count)
|
||||||
}
|
}
|
||||||
if status := call([]string{allTask.ID}); status != 200 {
|
|
||||||
t.Fatalf("all-pet completion status %d", status)
|
|
||||||
}
|
|
||||||
tx.Model(&model.TaskCompletion{}).Where("task_id = ?", allTask.ID).Count(&count)
|
|
||||||
if count != 1 {
|
|
||||||
t.Fatalf("shared task should have one completion: %d", count)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ type Pet struct {
|
|||||||
type CareTask struct {
|
type CareTask struct {
|
||||||
Base
|
Base
|
||||||
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
|
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
|
||||||
PetID *string `gorm:"type:uuid;index" json:"pet_id"` // nil applies to all of the user's pets.
|
PetID string `gorm:"type:uuid;index;not null" json:"pet_id"`
|
||||||
Title string `gorm:"size:128;not null" json:"title"`
|
Title string `gorm:"size:128;not null" json:"title"`
|
||||||
Category string `gorm:"size:32;not null" json:"category"`
|
Category string `gorm:"size:32;not null" json:"category"`
|
||||||
TimeOfDay string `gorm:"size:5;not null" json:"time_of_day"`
|
TimeOfDay string `gorm:"size:5;not null" json:"time_of_day"`
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Component({});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "component": true }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<view class="skeleton" aria-label="正在准备首页内容">
|
||||||
|
<view class="hero-placeholder"><view class="bone line short"></view><view class="bone line title"></view><view class="bone line"></view><view class="bone art"></view></view>
|
||||||
|
<view class="bone heading"></view>
|
||||||
|
<view class="card-placeholder"><view class="bone line title"></view><view class="bone line"></view><view class="bone action"></view></view>
|
||||||
|
<view class="stats-placeholder"><view class="bone"></view><view class="bone"></view><view class="bone"></view></view>
|
||||||
|
<view class="bone heading"></view>
|
||||||
|
<view class="card-placeholder"><view class="bone line"></view><view class="bone line short"></view></view>
|
||||||
|
<view class="loading-copy">正在准备今天的小日常…</view>
|
||||||
|
</view>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
.bone {
|
||||||
|
background: linear-gradient(100deg, #f3e7dc 20%, #fff8ef 45%, #f3e7dc 70%);
|
||||||
|
background-size: 220% 100%;
|
||||||
|
animation: shimmer 1.6s ease-in-out infinite;
|
||||||
|
border-radius: 15rpx;
|
||||||
|
}
|
||||||
|
.hero-placeholder {
|
||||||
|
position: relative;
|
||||||
|
min-height: 330rpx;
|
||||||
|
background: #fff0e2;
|
||||||
|
border-radius: 38rpx;
|
||||||
|
padding: 45rpx 35rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.line {
|
||||||
|
height: 22rpx;
|
||||||
|
width: 65%;
|
||||||
|
margin: 24rpx 0;
|
||||||
|
}
|
||||||
|
.short {
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
height: 38rpx;
|
||||||
|
width: 48%;
|
||||||
|
}
|
||||||
|
.art {
|
||||||
|
position: absolute;
|
||||||
|
right: 35rpx;
|
||||||
|
top: 62rpx;
|
||||||
|
width: 170rpx;
|
||||||
|
height: 190rpx;
|
||||||
|
border-radius: 40rpx;
|
||||||
|
}
|
||||||
|
.heading {
|
||||||
|
width: 230rpx;
|
||||||
|
height: 33rpx;
|
||||||
|
margin: 48rpx 0 28rpx;
|
||||||
|
}
|
||||||
|
.card-placeholder {
|
||||||
|
padding: 28rpx;
|
||||||
|
background: white;
|
||||||
|
border-radius: 35rpx;
|
||||||
|
}
|
||||||
|
.action {
|
||||||
|
height: 80rpx;
|
||||||
|
margin-top: 42rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
}
|
||||||
|
.stats-placeholder {
|
||||||
|
display: flex;
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
.stats-placeholder view {
|
||||||
|
height: 140rpx;
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
}
|
||||||
|
.loading-copy {
|
||||||
|
text-align: center;
|
||||||
|
color: #a48d7e;
|
||||||
|
font-size: 22rpx;
|
||||||
|
margin-top: 32rpx;
|
||||||
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 100% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -100% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,6 @@ function dateLabel() {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
return `${now.getMonth() + 1}月${now.getDate()}日 周${"日一二三四五六"[now.getDay()]}`;
|
return `${now.getMonth() + 1}月${now.getDate()}日 周${"日一二三四五六"[now.getDay()]}`;
|
||||||
}
|
}
|
||||||
function accountKey() {
|
|
||||||
const state = getApp().globalData || {};
|
|
||||||
return (state.user && state.user.id) || state.token || "";
|
|
||||||
}
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
locationName: "当前位置",
|
locationName: "当前位置",
|
||||||
@@ -27,32 +23,13 @@ Page({
|
|||||||
phase: "auth",
|
phase: "auth",
|
||||||
error: "",
|
error: "",
|
||||||
},
|
},
|
||||||
onLoad() {
|
|
||||||
const cached = getApp().homeSnapshot;
|
|
||||||
if (cached && cached.account === accountKey() && cached.date === localDate()) {
|
|
||||||
this.setData({ ...cached.data, ready: true, phase: "ready", loading: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onShow() {
|
onShow() {
|
||||||
this.applyEnvironment(environment.cached());
|
this.applyEnvironment(environment.cached());
|
||||||
this.load();
|
this.load();
|
||||||
},
|
},
|
||||||
onHide() {
|
|
||||||
this.rememberHome();
|
|
||||||
},
|
|
||||||
onUnload() {
|
onUnload() {
|
||||||
this.rememberHome();
|
|
||||||
this.disposed = true;
|
this.disposed = true;
|
||||||
},
|
},
|
||||||
rememberHome() {
|
|
||||||
if (!this.data.ready || !accountKey()) return;
|
|
||||||
const { pets, selected, tasks, undoneCount, walkCount, name } = this.data;
|
|
||||||
getApp().homeSnapshot = {
|
|
||||||
account: accountKey(),
|
|
||||||
date: localDate(),
|
|
||||||
data: { pets, selected, tasks, undoneCount, walkCount, name },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
updateEnvironmentLabel() {
|
updateEnvironmentLabel() {
|
||||||
this.setData({
|
this.setData({
|
||||||
headerSubtitle:
|
headerSubtitle:
|
||||||
@@ -135,7 +112,6 @@ Page({
|
|||||||
ready: true,
|
ready: true,
|
||||||
phase: "ready",
|
phase: "ready",
|
||||||
});
|
});
|
||||||
this.rememberHome();
|
|
||||||
// Location permission and weather never hold up the first usable screen.
|
// Location permission and weather never hold up the first usable screen.
|
||||||
this.refreshEnvironment();
|
this.refreshEnvironment();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"usingComponents": {
|
"usingComponents": {
|
||||||
"tab-bar": "/components/tab-bar/index",
|
"tab-bar": "/components/tab-bar/index",
|
||||||
"pet-picker": "/components/pet-picker/index",
|
"pet-picker": "/components/pet-picker/index",
|
||||||
"startup-state": "/components/startup-state/index"
|
"startup-state": "/components/startup-state/index",
|
||||||
|
"home-skeleton": "/components/home-skeleton/index"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<startup-state wx:if="{{!ready && (!error || phase==='auth-error')}}" error="{{error}}" bind:retry="load" />
|
<startup-state wx:if="{{phase==='auth' || phase==='auth-error'}}" error="{{error}}" bind:retry="load" />
|
||||||
<page-layout wx:else id="page-layout" title="{{locationName}}" subtitle="{{headerSubtitle}}" location="{{true}}" bind:locationtap="refreshEnvironment">
|
<page-layout wx:else id="page-layout" title="{{locationName}}" subtitle="{{headerSubtitle}}" location="{{true}}" bind:locationtap="refreshEnvironment">
|
||||||
<view class="screen">
|
<view class="screen">
|
||||||
<view wx:if="{{!ready && error}}" class="card home-error">
|
<home-skeleton wx:if="{{loading && !ready}}" />
|
||||||
|
<view wx:elif="{{!ready && error}}" class="card home-error">
|
||||||
<app-icon kind="paw" size="54" />
|
<app-icon kind="paw" size="54" />
|
||||||
<view class="error-title">暂时没能准备好首页</view>
|
<view class="error-title">暂时没能准备好首页</view>
|
||||||
<view class="muted">{{error}}</view>
|
<view class="muted">{{error}}</view>
|
||||||
@@ -9,6 +10,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<block wx:else>
|
<block wx:else>
|
||||||
<view wx:if="{{error}}" class="refresh-error"><text>更新失败,保留上次的日常记录</text><text class="link" bindtap="load">重试</text></view>
|
<view wx:if="{{error}}" class="refresh-error"><text>更新失败,保留上次的日常记录</text><text class="link" bindtap="load">重试</text></view>
|
||||||
|
<view wx:elif="{{loading}}" class="refresh-hint">正在更新今天的小日常…</view>
|
||||||
<view class="hero">
|
<view class="hero">
|
||||||
<view class="hero-copy">
|
<view class="hero-copy">
|
||||||
<text class="hero-small">今日的主角</text>
|
<text class="hero-small">今日的主角</text>
|
||||||
@@ -89,7 +91,7 @@
|
|||||||
</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.all_pets?'全部宠物 · ':''}}{{item.time_of_day}} · {{item.completed?'已完成':taskBusyIds[0]===item.id?'正在记录…':'点击完成'}}</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">
|
||||||
|
|||||||
@@ -189,6 +189,12 @@
|
|||||||
border-radius: 22rpx;
|
border-radius: 22rpx;
|
||||||
margin-bottom: 20rpx;
|
margin-bottom: 20rpx;
|
||||||
}
|
}
|
||||||
|
.refresh-hint {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #a48d7e;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
.first-pet {
|
.first-pet {
|
||||||
padding-top: 30rpx;
|
padding-top: 30rpx;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,18 +41,9 @@ Page({
|
|||||||
this.setData({ gender: e.detail.value });
|
this.setData({ gender: e.detail.value });
|
||||||
},
|
},
|
||||||
async choose() {
|
async choose() {
|
||||||
let selection;
|
|
||||||
try {
|
try {
|
||||||
selection = await wx.chooseMedia({ count: 1, mediaType: ["image"] });
|
const r = await wx.chooseMedia({ count: 1, mediaType: ["image"] });
|
||||||
} catch (e) {
|
const file = await upload(r.tempFiles[0].tempFilePath);
|
||||||
if (/^chooseMedia:fail cancel\b/i.test((e && e.errMsg) || "")) return;
|
|
||||||
toast(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const photo = selection && selection.tempFiles && selection.tempFiles[0];
|
|
||||||
if (!photo || !photo.tempFilePath) return;
|
|
||||||
try {
|
|
||||||
const file = await upload(photo.tempFilePath);
|
|
||||||
this.setData({ avatar_file_id: file.id, avatar_url: file.url });
|
this.setData({ avatar_file_id: file.id, avatar_url: file.url });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e);
|
toast(e);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ const { localDate, shiftDate } = require("../../utils/date");
|
|||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
pets: [],
|
pets: [],
|
||||||
petOptions: [],
|
|
||||||
tasks: [],
|
tasks: [],
|
||||||
taskBusyIds: [],
|
taskBusyIds: [],
|
||||||
completingAll: false,
|
completingAll: false,
|
||||||
@@ -33,11 +32,10 @@ Page({
|
|||||||
if (date !== this.data.date) return;
|
if (date !== this.data.date) return;
|
||||||
const tasks = result.items.map((t) => ({
|
const tasks = result.items.map((t) => ({
|
||||||
...t,
|
...t,
|
||||||
petName: t.all_pets ? "全部宠物" : (pets.find((p) => p.id === t.pet_id) || {}).name || "宠物",
|
petName: (pets.find((p) => p.id === t.pet_id) || {}).name || "宠物",
|
||||||
}));
|
}));
|
||||||
this.setData({
|
this.setData({
|
||||||
pets,
|
pets,
|
||||||
petOptions: [...pets, { id: "", name: "全部宠物" }],
|
|
||||||
tasks,
|
tasks,
|
||||||
undoneCount: tasks.filter((t) => !t.completed).length,
|
undoneCount: tasks.filter((t) => !t.completed).length,
|
||||||
done: tasks.filter((t) => t.completed).length,
|
done: tasks.filter((t) => t.completed).length,
|
||||||
@@ -80,7 +78,7 @@ Page({
|
|||||||
showForm: true,
|
showForm: true,
|
||||||
editingId: task.id,
|
editingId: task.id,
|
||||||
title: task.title,
|
title: task.title,
|
||||||
petIndex: task.all_pets ? this.data.pets.length : Math.max(petIndex, 0),
|
petIndex: Math.max(petIndex, 0),
|
||||||
category: task.category,
|
category: task.category,
|
||||||
time_of_day: task.time_of_day,
|
time_of_day: task.time_of_day,
|
||||||
repeat_rule: task.repeat_rule,
|
repeat_rule: task.repeat_rule,
|
||||||
@@ -127,9 +125,7 @@ Page({
|
|||||||
this.data.editingId ? "/tasks/" + this.data.editingId : "/tasks",
|
this.data.editingId ? "/tasks/" + this.data.editingId : "/tasks",
|
||||||
this.data.editingId ? "PUT" : "POST",
|
this.data.editingId ? "PUT" : "POST",
|
||||||
{
|
{
|
||||||
...(this.data.petIndex === this.data.pets.length
|
pet_id: this.data.pets[this.data.petIndex].id,
|
||||||
? { all_pets: true }
|
|
||||||
: { pet_id: this.data.pets[this.data.petIndex].id }),
|
|
||||||
title: this.data.title.trim(),
|
title: this.data.title.trim(),
|
||||||
category: this.data.category,
|
category: this.data.category,
|
||||||
time_of_day: this.data.time_of_day,
|
time_of_day: this.data.time_of_day,
|
||||||
|
|||||||
@@ -71,12 +71,12 @@
|
|||||||
<view class="field">
|
<view class="field">
|
||||||
<view class="field-label">照顾谁</view>
|
<view class="field-label">照顾谁</view>
|
||||||
<picker
|
<picker
|
||||||
range="{{petOptions}}"
|
range="{{pets}}"
|
||||||
range-key="name"
|
range-key="name"
|
||||||
value="{{petIndex}}"
|
value="{{petIndex}}"
|
||||||
bindchange="petChange"
|
bindchange="petChange"
|
||||||
>
|
>
|
||||||
<view class="picker">{{petOptions[petIndex].name||'请先添加宠物'}}</view>
|
<view class="picker">{{pets[petIndex].name||'请先添加宠物'}}</view>
|
||||||
</picker>
|
</picker>
|
||||||
</view>
|
</view>
|
||||||
<view class="field">
|
<view class="field">
|
||||||
|
|||||||
@@ -57,33 +57,6 @@ test("编辑任务走 PUT,并保留选中的宠物", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("新建任务可选择全部宠物,编辑后保留范围", async () => {
|
|
||||||
const writes = [];
|
|
||||||
const pets = [{ id: "a", name: "奶盖" }, { id: "b", name: "布丁" }];
|
|
||||||
const task = { id: "task-all", all_pets: true, pet_id: null, title: "换水", category: "water", time_of_day: "09:00", repeat_rule: "daily" };
|
|
||||||
const screen = page("tasks", async (route, method, body) => {
|
|
||||||
if (method === "POST" || method === "PUT") writes.push({ route, method, body });
|
|
||||||
if (route === "/pets") return pets;
|
|
||||||
if (route.startsWith("/tasks?")) return { items: [task] };
|
|
||||||
return task;
|
|
||||||
});
|
|
||||||
await screen.load();
|
|
||||||
assert.equal(screen.data.petOptions[2].name, "全部宠物");
|
|
||||||
assert.equal(screen.data.tasks[0].petName, "全部宠物");
|
|
||||||
screen.open();
|
|
||||||
screen.petChange({ detail: { value: "2" } });
|
|
||||||
screen.setData({ title: "换水" });
|
|
||||||
await screen.save();
|
|
||||||
assert.equal(writes[0].method, "POST");
|
|
||||||
assert.equal(writes[0].body.all_pets, true);
|
|
||||||
assert.equal(writes[0].body.pet_id, undefined);
|
|
||||||
screen.edit({ currentTarget: { dataset: { index: 0 } } });
|
|
||||||
assert.equal(screen.data.petIndex, 2);
|
|
||||||
await screen.save();
|
|
||||||
assert.equal(writes[1].method, "PUT");
|
|
||||||
assert.equal(writes[1].body.all_pets, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("多宠物遛狗发送全部 ID,结束后可再次开始", async () => {
|
test("多宠物遛狗发送全部 ID,结束后可再次开始", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const active = { id: "walk-1", status: "active", started_at: new Date().toISOString(), pet_ids: ["a", "b"], distance_meters: 0, points: [] };
|
const active = { id: "walk-1", status: "active", started_at: new Date().toISOString(), pet_ids: ["a", "b"], distance_meters: 0, points: [] };
|
||||||
|
|||||||
@@ -88,13 +88,13 @@ test("缓存 token 过期时只重新登录一次,延迟的旧 401 不清除
|
|||||||
assert.equal(r.logins.length, 1);
|
assert.equal(r.logins.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
function home(login, secure, app = { login }) {
|
function home(login, secure) {
|
||||||
let definition;
|
let definition;
|
||||||
const wx = { getStorageSync: () => [], navigateTo: () => {} };
|
const wx = { getStorageSync: () => [], navigateTo: () => {} };
|
||||||
vm.runInNewContext(fs.readFileSync("pets-fe/pages/home/home.js", "utf8"), {
|
vm.runInNewContext(fs.readFileSync("pets-fe/pages/home/home.js", "utf8"), {
|
||||||
wx,
|
wx,
|
||||||
Date,
|
Date,
|
||||||
getApp: () => app,
|
getApp: () => ({ login }),
|
||||||
Page: (value) => {
|
Page: (value) => {
|
||||||
definition = value;
|
definition = value;
|
||||||
},
|
},
|
||||||
@@ -193,40 +193,3 @@ test("首页请求失败不显示空数据状态,刷新失败保留已有记
|
|||||||
assert.equal(page.data.pets[0].id, "existing");
|
assert.equal(page.data.pets[0].id, "existing");
|
||||||
assert.equal(page.data.error, "网络异常");
|
assert.equal(page.data.error, "网络异常");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("从其他页面回到日常立即显示已有数据,后台刷新不清空内容", async () => {
|
|
||||||
const app = { login: async () => "token", globalData: { token: "token", user: { id: "user-1" } } };
|
|
||||||
const first = home(app.login, data, app);
|
|
||||||
await first.load();
|
|
||||||
first.setData({ pets: [{ id: "pet-1" }], tasks: [{ id: "task-1", completed: true }], undoneCount: 0 });
|
|
||||||
first.onUnload();
|
|
||||||
let finish;
|
|
||||||
const pending = new Promise((resolve) => { finish = resolve; });
|
|
||||||
const next = home(app.login, async (route) => { await pending; return data(route); }, app);
|
|
||||||
next.onLoad();
|
|
||||||
assert.equal(next.data.ready, true);
|
|
||||||
assert.equal(next.data.phase, "ready");
|
|
||||||
assert.equal(next.data.pets[0].id, "pet-1");
|
|
||||||
assert.equal(next.data.tasks[0].completed, true);
|
|
||||||
const refreshing = next.load();
|
|
||||||
await tick();
|
|
||||||
assert.equal(next.data.ready, true);
|
|
||||||
assert.equal(next.data.pets[0].id, "pet-1");
|
|
||||||
finish();
|
|
||||||
await refreshing;
|
|
||||||
assert.equal(next.data.loading, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("日常缓存不跨账号、不显示上一天的任务", async () => {
|
|
||||||
const app = { login: async () => "token", globalData: { user: { id: "new-user" } }, homeSnapshot: {
|
|
||||||
account: "old-user", date: "2026-10-05", data: { pets: [{ id: "private" }] },
|
|
||||||
} };
|
|
||||||
const page = home(app.login, data, app);
|
|
||||||
page.onLoad();
|
|
||||||
assert.equal(page.data.ready, false);
|
|
||||||
assert.equal(page.data.pets.length, 0);
|
|
||||||
app.homeSnapshot.account = "new-user";
|
|
||||||
app.homeSnapshot.date = "2026-10-04";
|
|
||||||
page.onLoad();
|
|
||||||
assert.equal(page.data.ready, false);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
const test = require("node:test");
|
|
||||||
const assert = require("node:assert/strict");
|
|
||||||
const fs = require("node:fs");
|
|
||||||
const vm = require("node:vm");
|
|
||||||
const path = require("node:path");
|
|
||||||
|
|
||||||
function screen(chooseMedia, upload) {
|
|
||||||
let definition;
|
|
||||||
const errors = [];
|
|
||||||
vm.runInNewContext(
|
|
||||||
fs.readFileSync(path.join(__dirname, "../pets-fe/pages/pet-edit/pet-edit.js"), "utf8"),
|
|
||||||
{
|
|
||||||
Page: (value) => { definition = value; },
|
|
||||||
wx: { chooseMedia },
|
|
||||||
require: () => ({ upload, toast: (error) => errors.push(error) }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...definition,
|
|
||||||
data: { ...definition.data, avatar_file_id: "original-id", avatar_url: "original-photo" },
|
|
||||||
errors,
|
|
||||||
setData(changes) { Object.assign(this.data, changes); },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test("取消选择宠物照片不上传、不提示错误,保留原头像", async () => {
|
|
||||||
let uploads = 0;
|
|
||||||
const page = screen(
|
|
||||||
async () => { throw { errMsg: "chooseMedia:fail cancel" }; },
|
|
||||||
async () => { uploads++; },
|
|
||||||
);
|
|
||||||
await page.choose();
|
|
||||||
assert.equal(uploads, 0);
|
|
||||||
assert.equal(page.errors.length, 0);
|
|
||||||
assert.equal(page.data.avatar_file_id, "original-id");
|
|
||||||
assert.equal(page.data.avatar_url, "original-photo");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("空的照片选择结果不上传", async () => {
|
|
||||||
const page = screen(async () => ({ tempFiles: [] }), async () => assert.fail("不应上传"));
|
|
||||||
await page.choose();
|
|
||||||
assert.equal(page.errors.length, 0);
|
|
||||||
assert.equal(page.data.avatar_file_id, "original-id");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("实际选择失败仍提示错误", async () => {
|
|
||||||
const error = { errMsg: "chooseMedia:fail permission denied" };
|
|
||||||
const page = screen(async () => { throw error; }, async () => assert.fail("不应上传"));
|
|
||||||
await page.choose();
|
|
||||||
assert.equal(page.errors[0], error);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("实际上传失败仍提示错误并保留原头像", async () => {
|
|
||||||
const error = new Error("上传失败");
|
|
||||||
const page = screen(
|
|
||||||
async () => ({ tempFiles: [{ tempFilePath: "/tmp/photo.jpg" }] }),
|
|
||||||
async () => { throw error; },
|
|
||||||
);
|
|
||||||
await page.choose();
|
|
||||||
assert.equal(page.errors[0], error);
|
|
||||||
assert.equal(page.data.avatar_file_id, "original-id");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("上传成功后更新宠物头像", async () => {
|
|
||||||
const page = screen(
|
|
||||||
async () => ({ tempFiles: [{ tempFilePath: "/tmp/photo.jpg" }] }),
|
|
||||||
async (filePath) => {
|
|
||||||
assert.equal(filePath, "/tmp/photo.jpg");
|
|
||||||
return { id: "new-id", url: "new-photo" };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await page.choose();
|
|
||||||
assert.equal(page.errors.length, 0);
|
|
||||||
assert.equal(page.data.avatar_file_id, "new-id");
|
|
||||||
assert.equal(page.data.avatar_url, "new-photo");
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user