diff --git a/pets-be/internal/database/seed.go b/pets-be/internal/database/seed.go index aed89b5..d7f4e23 100644 --- a/pets-be/internal/database/seed.go +++ b/pets-be/internal/database/seed.go @@ -20,7 +20,112 @@ func Seed(db *gorm.DB, cfg *config.Config) error { if err := seedAdmin(db, cfg); err != nil { return err } - return seedRecordTypes(db) + if err := seedRecordTypes(db); err != nil { + return err + } + return seedBreeds(db) +} + +// seedBreeds 品种预置。同 record_types:只在表完全为空时铺一次。 +// +// 这也是「结构」不是「内容」——品种列表空着,建档第二步就选不了品种。 +// 首字母存字段不运行时算:Go 没有标准拼音库,而多音字(藏獒、柴犬) +// 自动转常出错,人来定比库来猜靠谱。 +func seedBreeds(db *gorm.DB) error { + var count int64 + if err := db.Model(&model.Breed{}).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return nil + } + breeds := []model.Breed{ + // 猫 34 种 + {Species: model.SpeciesCat, Initial: "A", Name: "阿比西尼亚猫", Sort: 10}, + {Species: model.SpeciesCat, Initial: "A", Name: "奥西猫", Sort: 20}, + {Species: model.SpeciesCat, Initial: "B", Name: "巴厘猫", Sort: 30}, + {Species: model.SpeciesCat, Initial: "B", Name: "波斯猫", Sort: 40}, + {Species: model.SpeciesCat, Initial: "B", Name: "布偶猫", Sort: 50}, + {Species: model.SpeciesCat, Initial: "B", Name: "伯曼猫", Sort: 60}, + {Species: model.SpeciesCat, Initial: "C", Name: "长毛折耳猫", Sort: 70}, + {Species: model.SpeciesCat, Initial: "D", Name: "德文卷毛猫", Sort: 80}, + {Species: model.SpeciesCat, Initial: "D", Name: "东方短毛猫", Sort: 90}, + {Species: model.SpeciesCat, Initial: "D", Name: "东奇尼猫", Sort: 100}, + {Species: model.SpeciesCat, Initial: "E", Name: "俄罗斯蓝猫", Sort: 110}, + {Species: model.SpeciesCat, Initial: "H", Name: "哈瓦那棕猫", Sort: 120}, + {Species: model.SpeciesCat, Initial: "K", Name: "科拉特猫", Sort: 130}, + {Species: model.SpeciesCat, Initial: "L", Name: "拉波猫", Sort: 140}, + {Species: model.SpeciesCat, Initial: "M", Name: "缅因猫", Sort: 150}, + {Species: model.SpeciesCat, Initial: "M", Name: "美国短毛猫", Sort: 160}, + {Species: model.SpeciesCat, Initial: "M", Name: "美国卷耳猫", Sort: 170}, + {Species: model.SpeciesCat, Initial: "M", Name: "曼赤肯猫", Sort: 180}, + {Species: model.SpeciesCat, Initial: "M", Name: "孟买猫", Sort: 190}, + {Species: model.SpeciesCat, Initial: "M", Name: "孟加拉豹猫", Sort: 200}, + {Species: model.SpeciesCat, Initial: "N", Name: "挪威森林猫", Sort: 210}, + {Species: model.SpeciesCat, Initial: "R", Name: "日本短尾猫", Sort: 220}, + {Species: model.SpeciesCat, Initial: "S", Name: "苏格兰折耳猫", Sort: 230}, + {Species: model.SpeciesCat, Initial: "S", Name: "索马里猫", Sort: 240}, + {Species: model.SpeciesCat, Initial: "S", Name: "塞尔凯克卷毛猫", Sort: 250}, + {Species: model.SpeciesCat, Initial: "S", Name: "斯芬克斯无毛猫", Sort: 260}, + {Species: model.SpeciesCat, Initial: "S", Name: "沙特尔猫", Sort: 270}, + {Species: model.SpeciesCat, Initial: "T", Name: "土耳其安哥拉猫", Sort: 280}, + {Species: model.SpeciesCat, Initial: "T", Name: "土耳其梵猫", Sort: 290}, + {Species: model.SpeciesCat, Initial: "X", Name: "暹罗猫", Sort: 300}, + {Species: model.SpeciesCat, Initial: "X", Name: "新加坡猫", Sort: 310}, + {Species: model.SpeciesCat, Initial: "Y", Name: "异国短毛猫", Sort: 320}, + {Species: model.SpeciesCat, Initial: "Y", Name: "英国短毛猫", Sort: 330}, + {Species: model.SpeciesCat, Initial: "Z", Name: "中华田园猫", Sort: 340}, + // 狗 46 种 + {Species: model.SpeciesDog, Initial: "A", Name: "阿拉斯加雪橇犬", Sort: 10}, + {Species: model.SpeciesDog, Initial: "A", Name: "阿富汗猎犬", Sort: 20}, + {Species: model.SpeciesDog, Initial: "A", Name: "澳洲牧羊犬", Sort: 30}, + {Species: model.SpeciesDog, Initial: "B", Name: "比熊犬", Sort: 40}, + {Species: model.SpeciesDog, Initial: "B", Name: "比格犬", Sort: 50}, + {Species: model.SpeciesDog, Initial: "B", Name: "边境牧羊犬", Sort: 60}, + {Species: model.SpeciesDog, Initial: "B", Name: "巴哥犬", Sort: 70}, + {Species: model.SpeciesDog, Initial: "B", Name: "博美犬", Sort: 80}, + {Species: model.SpeciesDog, Initial: "B", Name: "巴吉度猎犬", Sort: 90}, + {Species: model.SpeciesDog, Initial: "B", Name: "北京犬", Sort: 100}, + {Species: model.SpeciesDog, Initial: "C", Name: "柴犬", Sort: 110}, + {Species: model.SpeciesDog, Initial: "C", Name: "川东猎犬", Sort: 120}, + {Species: model.SpeciesDog, Initial: "D", Name: "德国牧羊犬", Sort: 130}, + {Species: model.SpeciesDog, Initial: "D", Name: "杜宾犬", Sort: 140}, + {Species: model.SpeciesDog, Initial: "D", Name: "大丹犬", Sort: 150}, + {Species: model.SpeciesDog, Initial: "D", Name: "斗牛犬", Sort: 160}, + {Species: model.SpeciesDog, Initial: "F", Name: "法国斗牛犬", Sort: 170}, + {Species: model.SpeciesDog, Initial: "G", Name: "贵宾犬(泰迪)", Sort: 180}, + {Species: model.SpeciesDog, Initial: "G", Name: "古代牧羊犬", Sort: 190}, + {Species: model.SpeciesDog, Initial: "H", Name: "哈士奇", Sort: 200}, + {Species: model.SpeciesDog, Initial: "H", Name: "蝴蝶犬", Sort: 210}, + {Species: model.SpeciesDog, Initial: "J", Name: "金毛寻回犬", Sort: 220}, + {Species: model.SpeciesDog, Initial: "J", Name: "吉娃娃", Sort: 230}, + {Species: model.SpeciesDog, Initial: "K", Name: "柯基犬", Sort: 240}, + {Species: model.SpeciesDog, Initial: "K", Name: "可卡犬", Sort: 250}, + {Species: model.SpeciesDog, Initial: "K", Name: "苏格兰柯利犬", Sort: 260}, + {Species: model.SpeciesDog, Initial: "L", Name: "拉布拉多寻回犬", Sort: 270}, + {Species: model.SpeciesDog, Initial: "L", Name: "罗威纳犬", Sort: 280}, + {Species: model.SpeciesDog, Initial: "L", Name: "灵缇犬", Sort: 290}, + {Species: model.SpeciesDog, Initial: "L", Name: "腊肠犬", Sort: 300}, + {Species: model.SpeciesDog, Initial: "M", Name: "马尔济斯犬", Sort: 310}, + {Species: model.SpeciesDog, Initial: "M", Name: "迷你雪纳瑞", Sort: 320}, + {Species: model.SpeciesDog, Initial: "N", Name: "牛头梗", Sort: 330}, + {Species: model.SpeciesDog, Initial: "Q", Name: "秋田犬", Sort: 340}, + {Species: model.SpeciesDog, Initial: "S", Name: "萨摩耶", Sort: 350}, + {Species: model.SpeciesDog, Initial: "S", Name: "松狮犬", Sort: 360}, + {Species: model.SpeciesDog, Initial: "S", Name: "圣伯纳犬", Sort: 370}, + {Species: model.SpeciesDog, Initial: "X", Name: "雪纳瑞", Sort: 390}, + {Species: model.SpeciesDog, Initial: "X", Name: "西高地白梗", Sort: 400}, + {Species: model.SpeciesDog, Initial: "X", Name: "喜乐蒂牧羊犬", Sort: 410}, + {Species: model.SpeciesDog, Initial: "Y", Name: "约克夏梗", Sort: 420}, + {Species: model.SpeciesDog, Initial: "Y", Name: "银狐犬", Sort: 430}, + {Species: model.SpeciesDog, Initial: "Z", Name: "中华田园犬", Sort: 440}, + {Species: model.SpeciesDog, Initial: "Z", Name: "中亚牧羊犬", Sort: 450}, + {Species: model.SpeciesDog, Initial: "Z", Name: "藏獒", Sort: 460}, + } + for i := range breeds { + breeds[i].Enabled = true + } + return db.Create(&breeds).Error } // seedRecordTypes 只在表为空时跑。已经有数据(哪怕只有一条)就完全不碰, diff --git a/pets-be/internal/handler/admin.go b/pets-be/internal/handler/admin.go index fda0c22..33686e7 100644 --- a/pets-be/internal/handler/admin.go +++ b/pets-be/internal/handler/admin.go @@ -280,6 +280,48 @@ func (h *Handler) AdminListPro(c *gin.Context) { // AdminCareTemplateMeta GET /api/admin/care-templates/meta // 给后台下拉用:物种、阶段、可选关联记录/弹层类型、提醒类型 +// ── 品种 ── + +type breedReq struct { + ID string `json:"id"` + Species string `json:"species"` + Name string `json:"name"` + Initial string `json:"initial"` + Sort int `json:"sort"` + Enabled bool `json:"enabled"` +} + +// AdminListBreeds GET /api/admin/breeds?species=cat 连停用的一起返回 +func (h *Handler) AdminListBreeds(c *gin.Context) { + response.OK(c, h.svc.AdminListBreeds(c.Query("species"))) +} + +// AdminSaveBreed POST /api/admin/breeds +func (h *Handler) AdminSaveBreed(c *gin.Context) { + var req breedReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailParams(c, err.Error()) + return + } + b := &model.Breed{Species: req.Species, Name: req.Name, + Initial: req.Initial, Sort: req.Sort, Enabled: req.Enabled} + b.ID = req.ID + if err := h.svc.SaveBreed(b); err != nil { + respondErr(c, err) + return + } + response.OK(c, b) +} + +// AdminDeleteBreed DELETE /api/admin/breeds/:id +func (h *Handler) AdminDeleteBreed(c *gin.Context) { + if err := h.svc.DeleteBreed(idParam(c, "id")); err != nil { + respondErr(c, err) + return + } + response.OK(c, nil) +} + // ── 记录类型 ── type recordTypeReq struct { diff --git a/pets-be/internal/handler/pet.go b/pets-be/internal/handler/pet.go index 1df6eeb..c5d8db8 100644 --- a/pets-be/internal/handler/pet.go +++ b/pets-be/internal/handler/pet.go @@ -158,3 +158,9 @@ func (h *Handler) Onboarding(c *gin.Context) { } response.OK(c, pet) } + +// Breeds GET /api/breeds?species=cat 品种,按首字母分组。 +// 建档第二步的选择器直接照这个渲染,前端不做分组也不做排序 +func (h *Handler) Breeds(c *gin.Context) { + response.OK(c, h.svc.ListBreeds(c.Query("species"))) +} diff --git a/pets-be/internal/model/base.go b/pets-be/internal/model/base.go index ebc7180..1905855 100644 --- a/pets-be/internal/model/base.go +++ b/pets-be/internal/model/base.go @@ -30,6 +30,7 @@ func AllModels() []any { &Pet{}, &HealthRecord{}, &RecordType{}, + &Breed{}, &DailyTask{}, &Plan{}, &PlanTask{}, diff --git a/pets-be/internal/model/breed.go b/pets-be/internal/model/breed.go new file mode 100644 index 0000000..8eba6be --- /dev/null +++ b/pets-be/internal/model/breed.go @@ -0,0 +1,19 @@ +package model + +// Breed 品种。原来建档时品种是个自由文本输入框,用户手打「柯基」「柯基犬」 +// 「威尔士柯基」都算不同品种,后面想按品种做体重基准、常见病提示这类事就没法做。 +type Breed struct { + Base + Species string `gorm:"size:16;index" json:"species"` // cat | dog + Name string `gorm:"size:64" json:"name"` + + // Initial 首字母,A-Z,用来做侧边索引。 + // + // 存字段而不是运行时算:Go 标准库没有拼音,引一个拼音库只为了取首字母不值得, + // 而且多音字(「柴犬」的柴、「藏獒」的藏)自动转常出错——「藏獒」按 cang + // 还是 zang 分组,人来定比库来猜靠谱。后台新增品种时手填这一位。 + Initial string `gorm:"size:1;index" json:"initial"` + + Sort int `json:"sort"` + Enabled bool `gorm:"default:true" json:"enabled"` +} diff --git a/pets-be/internal/router/router.go b/pets-be/internal/router/router.go index 135aad0..58f2902 100644 --- a/pets-be/internal/router/router.go +++ b/pets-be/internal/router/router.go @@ -58,6 +58,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage g.DELETE("/pets/:id", h.DeletePet) g.GET("/record-types", h.RecordTypes) + g.GET("/breeds", h.Breeds) g.GET("/pets/:id/records", h.ListRecords) g.POST("/pets/:id/records", h.CreateRecord) g.GET("/pets/:id/records/weight-trend", h.WeightTrend) @@ -153,6 +154,10 @@ func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manag g.POST("/articles", h.AdminSaveArticle) g.DELETE("/articles/:id", h.AdminDeleteArticle) + g.GET("/breeds", h.AdminListBreeds) + g.POST("/breeds", h.AdminSaveBreed) + g.DELETE("/breeds/:id", h.AdminDeleteBreed) + g.GET("/record-types", h.AdminListRecordTypes) g.POST("/record-types", h.AdminSaveRecordType) g.DELETE("/record-types/:id", h.AdminDeleteRecordType) diff --git a/pets-be/internal/service/breed.go b/pets-be/internal/service/breed.go new file mode 100644 index 0000000..07e2c13 --- /dev/null +++ b/pets-be/internal/service/breed.go @@ -0,0 +1,114 @@ +package service + +import ( + "errors" + "log" + "sort" + "strconv" + "strings" + + "github.com/sundynix/pets-be/internal/model" +) + +// BreedGroup 同一首字母下的品种。小程序端的 A-Z 索引直接照这个渲染 +type BreedGroup struct { + Initial string `json:"initial"` + Items []model.Breed `json:"items"` +} + +// ListBreeds 某个物种的品种,按首字母分组。只返回启用的 +func (s *Service) ListBreeds(species string) []BreedGroup { + if species != model.SpeciesCat && species != model.SpeciesDog { + return []BreedGroup{} + } + var all []model.Breed + s.db.Where("species = ? AND enabled = ?", species, true). + Order("initial asc, sort asc, id asc").Find(&all) + if len(all) == 0 { + // 不硬编码兜底一份:那等于把「后台可配」又变回代码常量, + // 改了配置的人会发现改了没用。宁可让选择器明显是空的 + log.Printf("[warn] %s 没有任何启用的品种,建档第二步会选不了品种", species) + return []BreedGroup{} + } + byInitial := map[string][]model.Breed{} + for _, b := range all { + k := strings.ToUpper(b.Initial) + if k == "" { + k = "#" // 没填首字母的兜到最后,别让它凭空消失 + } + byInitial[k] = append(byInitial[k], b) + } + keys := make([]string, 0, len(byInitial)) + for k := range byInitial { + keys = append(keys, k) + } + sort.Strings(keys) + out := make([]BreedGroup, 0, len(keys)) + for _, k := range keys { + out = append(out, BreedGroup{Initial: k, Items: byInitial[k]}) + } + return out +} + +// AdminListBreeds 后台用:连停用的一起返回 +func (s *Service) AdminListBreeds(species string) []model.Breed { + q := s.db.Order("species asc, initial asc, sort asc, id asc") + if species == model.SpeciesCat || species == model.SpeciesDog { + q = q.Where("species = ?", species) + } + var all []model.Breed + q.Find(&all) + if all == nil { + return []model.Breed{} + } + return all +} + +// SaveBreed 新增或更新 +func (s *Service) SaveBreed(in *model.Breed) error { + in.Name = strings.TrimSpace(in.Name) + in.Initial = strings.ToUpper(strings.TrimSpace(in.Initial)) + if in.Name == "" { + return ErrInvalidParam + } + if in.Species != model.SpeciesCat && in.Species != model.SpeciesDog { + return errors.New("物种只能是 cat 或 dog") + } + if len(in.Initial) != 1 || in.Initial[0] < 'A' || in.Initial[0] > 'Z' { + return errors.New("首字母要填一个 A-Z 的字母(用来做侧边索引)") + } + // 同物种下不允许同名:品种是拿来做统计口径的,重名会把同一种猫劈成两半 + var n int64 + q := s.db.Model(&model.Breed{}).Where("species = ? AND name = ?", in.Species, in.Name) + if in.ID != "" { + q = q.Where("id <> ?", in.ID) + } + q.Count(&n) + if n > 0 { + return errors.New("这个物种下已经有「" + in.Name + "」了") + } + + if in.ID == "" { + return s.db.Create(in).Error + } + return s.db.Model(&model.Breed{}).Where("id = ?", in.ID).Updates(map[string]any{ + "species": in.Species, "name": in.Name, "initial": in.Initial, + "sort": in.Sort, "enabled": in.Enabled, + }).Error +} + +// DeleteBreed 已经有宠物在用的品种不许删——删了那些档案的品种就变成一个 +// 查不到的字符串。想让它不再被选到应该用 enabled=false +func (s *Service) DeleteBreed(id string) error { + var b model.Breed + if err := s.db.First(&b, "id = ?", id).Error; err != nil { + return ErrNotFound + } + var used int64 + s.db.Model(&model.Pet{}).Where("breed = ?", b.Name).Count(&used) + if used > 0 { + return errors.New("已经有 " + strconv.FormatInt(used, 10) + " 个档案填了「" + + b.Name + "」,删了它们的品种就查不到了。想隐藏请改成停用") + } + return s.db.Delete(&model.Breed{}, "id = ?", id).Error +} diff --git a/pets-fe/app.json b/pets-fe/app.json index 6947644..4e39005 100644 --- a/pets-fe/app.json +++ b/pets-fe/app.json @@ -7,6 +7,7 @@ "pages/addrecord/addrecord", "pages/history/history", "pages/expense/expense", + "pages/petform/petform", "pages/report/report", "pages/community/community", "pages/learn/learn", diff --git a/pets-fe/components/bottom-sheet/bottom-sheet.js b/pets-fe/components/bottom-sheet/bottom-sheet.js index d8c235f..898eefe 100644 --- a/pets-fe/components/bottom-sheet/bottom-sheet.js +++ b/pets-fe/components/bottom-sheet/bottom-sheet.js @@ -33,18 +33,6 @@ function fmtAgo(iso) { return (sameYear ? '' : d.getFullYear() + '-') + p(d.getMonth() + 1) + '-' + p(d.getDate()); } -function ageFromBirthday(bday) { - if (!bday) return ''; - const b = new Date(bday); - const now = new Date(); - let months = (now.getFullYear() - b.getFullYear()) * 12 + (now.getMonth() - b.getMonth()); - if (months < 0) months = 0; - return months < 24 ? months + '个月' : Math.floor(months / 12) + '岁'; -} - -const GENDERS = ['男孩', '女孩', '不确定']; -const STAGES = ['刚到家 0-30 天', '幼年期', '成年期', '老年期']; - // 任务可关联的记录弹层类型 // 任务能关联的记录类型。原来这里也是写死的 6 种,后台加了类型这个下拉里看不到, // 于是「自定义任务关联新类型」这条路是断的。改成读同一份类型缓存 @@ -95,11 +83,6 @@ Component({ fbContent: '', fbContact: '', fbDone: false, - addName: '', - addBirthday: '', - addWeight: '', - addColor: '', - addBreed: '', commentText: '', comments: [], reminders: [], @@ -147,11 +130,6 @@ Component({ patch.fbContent = ''; patch.fbContact = ''; patch.fbDone = false; - patch.addName = ''; - patch.addBirthday = ''; - patch.addWeight = ''; - patch.addColor = ''; - patch.addBreed = ''; patch.commentText = ''; patch.commentImages = []; patch.replyTo = {}; @@ -159,19 +137,6 @@ Component({ patch.kbHeight = 0; patch.saving = false; } - if (type === 'editPet') { - // 预填当前宠物档案 - patch.addName = pet.name || ''; - patch.addColor = pet.color || ''; - patch.addBreed = pet.breed || ''; - patch.addWeight = (pet.weight || '').replace('kg', ''); - patch.addBirthday = pet.birthday ? String(pet.birthday).slice(0, 10) : ''; - patch.segSel = { - addType: pet.type === '狗狗' ? 1 : 0, - addGender: Math.max(0, GENDERS.indexOf(pet.gender)), - }; - patch.optSel = { addStage: Math.max(0, STAGES.indexOf(pet.stage)) }; - } this.setData(patch); this.loadSheetData(type); }, @@ -426,9 +391,13 @@ Component({ wx.showToast({ title: '已切换', icon: 'success' }); setTimeout(() => this.close(), 500); }, + // 多宠管理里点某只去编辑。editPet 那支已经搬到 pages/petform, + // 这里必须关掉弹层再跳页 —— 留着 setType('editPet') 会开一个空白弹层 onEditPetFromList(e) { - store.switchPet(e.currentTarget.dataset.id); - this.setType('editPet', true); + const id = e.currentTarget.dataset.id; + store.switchPet(id); + this.close(); + wx.navigateTo({ url: '/pages/petform/petform?id=' + id }); }, // ---- 导出健康档案 ---- @@ -618,11 +587,6 @@ Component({ arr.splice(i, 1); this.setData({ postImages: arr }); }, - onAddName(e) { this.setData({ addName: e.detail.value }); }, - onAddBirthday(e) { this.setData({ addBirthday: e.detail.value }); }, - onAddWeight(e) { this.setData({ addWeight: e.detail.value }); }, - onAddColor(e) { this.setData({ addColor: e.detail.value }); }, - onAddBreed(e) { this.setData({ addBreed: e.detail.value }); }, onCommentInput(e) { this.setData({ commentText: e.detail.value }); }, segIdx(group) { @@ -669,89 +633,6 @@ Component({ onSave() { this.createAndClose(this.buildRecord()); }, - petFormBody() { - const isDog = this.segIdx('addType') === 1; - return { - name: (this.data.addName || '').trim(), - type: isDog ? '狗狗' : '猫猫', - emoji: isDog ? '🐶' : '🐱', - gender: GENDERS[this.segIdx('addGender')], - birthday: this.data.addBirthday, - weight: this.data.addWeight, - color: (this.data.addColor || '').trim(), - breed: (this.data.addBreed || '').trim(), - stage: STAGES[this.optIdx('addStage')], - age: ageFromBirthday(this.data.addBirthday), - }; - }, - - // 添加/编辑 共用提交入口 - onPetSubmit() { - if (this.data.innerType === 'editPet') return this.onEditPetSubmit(); - return this.onAddPetSubmit(); - }, - - async onAddPetSubmit() { - const body = this.petFormBody(); - if (!body.name) return wx.showToast({ title: '请输入名字', icon: 'none' }); - if (this.data.saving) return; - this.setData({ saving: true }); - try { - await api.createPet(body); - await store.loadPets(); - this.triggerEvent('petadded'); - this.close(); - } catch (e) { - wx.showToast({ title: e.message || '添加失败', icon: 'none' }); - this.setData({ saving: false }); - } - }, - - async onEditPetSubmit() { - const body = this.petFormBody(); - if (!body.name) return wx.showToast({ title: '请输入名字', icon: 'none' }); - const id = store.currentPetId(); - if (!id) return this.close(); - if (this.data.saving) return; - this.setData({ saving: true }); - try { - await api.updatePet(id, body); - await store.loadPets(); - this.triggerEvent('petadded'); - this.close(); - } catch (e) { - wx.showToast({ title: e.message || '保存失败', icon: 'none' }); - this.setData({ saving: false }); - } - }, - - onDeletePet() { - const id = store.currentPetId(); - const name = this.data.pet.name || '这只宠物'; - if (!id) return; - wx.showModal({ - title: '删除宠物', - content: `删除「${name}」后,它的记录、计划、提醒都会一并移除,且不可恢复。确定删除吗?`, - confirmText: '删除', - confirmColor: '#EE7D73', - success: (res) => { - if (!res.confirm) return; - api - .deletePet(id) - .then(async () => { - await store.loadPets(); - this.triggerEvent('petadded'); - this.close(); - if (store.getPets().length === 0) { - wx.reLaunch({ url: '/pages/onboarding/onboarding' }); - } - }) - .catch((e) => wx.showToast({ title: e.message || '删除失败', icon: 'none' })); - }, - }); - }, - - // 发帖 async onCreatePost() { const content = (this.data.postContent || '').trim(); if (!content) return wx.showToast({ title: '写点什么吧', icon: 'none' }); diff --git a/pets-fe/components/bottom-sheet/bottom-sheet.wxml b/pets-fe/components/bottom-sheet/bottom-sheet.wxml index c56bae2..8940a1c 100644 --- a/pets-fe/components/bottom-sheet/bottom-sheet.wxml +++ b/pets-fe/components/bottom-sheet/bottom-sheet.wxml @@ -188,36 +188,8 @@ module.exports.sel = function (map, key, index, def) { - - {{innerType === 'editPet' ? '编辑档案' : '添加毛孩子'}} - - - 猫猫 - 狗狗 - - - 男孩 - 女孩 - 不确定 - - - - {{addBirthday || '选择日期'}} - - - - - - 刚到家 - 幼年期 - 成年期 - 老年期 - - - - - - + 发布图文 选择发布身份,分享你的养宠日常。 diff --git a/pets-fe/pages/history/history.js b/pets-fe/pages/history/history.js index 2ba7cc0..db97e94 100644 --- a/pets-fe/pages/history/history.js +++ b/pets-fe/pages/history/history.js @@ -253,7 +253,7 @@ Page({ this.loadRecords(); }, onAddPet() { - this.openSheetType('addPet'); + wx.navigateTo({ url: '/pages/petform/petform' }); }, onShareAppMessage() { return { title: '用肉垫计划记录毛孩子的成长', path: '/pages/record/record' }; diff --git a/pets-fe/pages/home/home.js b/pets-fe/pages/home/home.js index 6611c54..8b09272 100644 --- a/pets-fe/pages/home/home.js +++ b/pets-fe/pages/home/home.js @@ -207,10 +207,11 @@ Page({ this.loadTimeline(1); }, onAddPet() { - this.setData({ sheetType: 'addPet', sheetShow: true }); + wx.navigateTo({ url: '/pages/petform/petform' }); }, onHeroTap() { - this.setData({ sheetType: this.data.pet && this.data.pet.id ? 'editPet' : 'addPet', sheetShow: true }); + const id = this.data.pet && this.data.pet.id; + wx.navigateTo({ url: '/pages/petform/petform' + (id ? '?id=' + id : '') }); }, onShareAppMessage() { return { title: '肉垫计划 · 每天照顾好毛孩子', path: '/pages/home/home' }; diff --git a/pets-fe/pages/mine/mine.js b/pets-fe/pages/mine/mine.js index b1634aa..1bab4e1 100644 --- a/pets-fe/pages/mine/mine.js +++ b/pets-fe/pages/mine/mine.js @@ -47,6 +47,12 @@ Page({ if (!uid) return wx.showToast({ title: '登录信息还没就绪', icon: 'none' }); wx.navigateTo({ url: `/pages/relations/relations?id=${uid}&kind=${e.detail.kind}` }); }, + // 宠物档案:当前这只的编辑页 + goPetForm() { + const id = store.currentPetId(); + if (!id) return wx.navigateTo({ url: '/pages/petform/petform' }); + wx.navigateTo({ url: '/pages/petform/petform?id=' + id }); + }, goDecorate() { wx.navigateTo({ url: '/pages/decorate/decorate' }); }, @@ -67,7 +73,7 @@ Page({ this.setData({ sheetShow: false }); }, onAddPet() { - this.setData({ sheetType: 'addPet', sheetShow: true }); + wx.navigateTo({ url: '/pages/petform/petform' }); }, onShareAppMessage() { return { title: '肉垫计划 · 每天照顾好毛孩子', path: '/pages/home/home' }; diff --git a/pets-fe/pages/mine/mine.wxml b/pets-fe/pages/mine/mine.wxml index 88b3833..61275a0 100644 --- a/pets-fe/pages/mine/mine.wxml +++ b/pets-fe/pages/mine/mine.wxml @@ -29,7 +29,7 @@ - + 宠物档案 diff --git a/pets-fe/pages/petform/petform.js b/pets-fe/pages/petform/petform.js new file mode 100644 index 0000000..62f7dc7 --- /dev/null +++ b/pets-fe/pages/petform/petform.js @@ -0,0 +1,214 @@ +const store = require('../../utils/store.js'); +const api = require('../../utils/api.js'); +const upload = require('../../utils/upload.js'); +const { toastErr } = require('../../utils/ui.js'); + +const GENDERS = ['男孩', '女孩', '不确定']; +const STAGES = ['刚到家 0-30 天', '幼年期', '成年期', '老年期']; + +function pad2(n) { + return n < 10 ? '0' + n : '' + n; +} +function ymd(d) { + return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); +} +function monthsSince(bday) { + const b = new Date(String(bday).slice(0, 10) + 'T00:00:00'); + if (isNaN(b.getTime())) return -1; + const n = new Date(); + const m = (n.getFullYear() - b.getFullYear()) * 12 + (n.getMonth() - b.getMonth()); + return m < 0 ? 0 : m; +} +function ageLabel(bday) { + const m = monthsSince(bday); + if (m < 0) return ''; + return m < 24 ? m + '个月' : Math.floor(m / 12) + '岁'; +} +// 和后端 service.StageFromBirthday 一套口径:<12 月幼年,>=7 年老年,其余成年。 +// 前端算是为了在用户填完生日时就给出建议值,最终仍以用户选的为准 +function stageFromBirthday(bday) { + const m = monthsSince(bday); + if (m < 0) return ''; + if (m < 12) return '幼年期'; + if (m >= 84) return '老年期'; + return '成年期'; +} + +Page({ + data: { + editing: false, + petId: '', + step: 0, + steps: ['名字', '品种', '资料'], + genders: GENDERS, + stages: STAGES, + today: '', + stageAuto: '', + form: { + name: '', avatar_file_id: '', avatar_url: '', + species: 'cat', breed: '', + gender: '不确定', birthday: '', arrived_at: '', + weight: '', color: '', stage: '', + }, + breedShow: false, + breedGroups: [], + breedAnchor: '', + saving: false, + }, + onLoad(q) { + this.setData({ today: ymd(new Date()) }); + const id = (q && q.id) || ''; + if (!id) return; + // 编辑:不分步,一屏全放开——用户是来改某一项的,不该被按顺序走一遍 + store + .ready() + .then(() => { + const p = (store.getPets() || []).find((x) => x.id === id) || store.getPet() || {}; + this.setData({ + editing: true, + petId: id, + form: { + name: p.name || '', + avatar_file_id: '', + avatar_url: p.avatar_url || '', + species: p.type === '狗狗' ? 'dog' : 'cat', + breed: p.breed || '', + gender: GENDERS.indexOf(p.gender) >= 0 ? p.gender : '不确定', + birthday: p.birthday ? String(p.birthday).slice(0, 10) : '', + arrived_at: p.arrived_at ? String(p.arrived_at).slice(0, 10) : '', + weight: (p.weight || '').replace('kg', ''), + color: p.color || '', + stage: STAGES.indexOf(p.stage) >= 0 ? p.stage : '', + }, + }); + }) + .catch((e) => toastErr(e)); + }, + + onName(e) { + this.setData({ 'form.name': e.detail.value }); + }, + pickAvatar() { + upload + .chooseAndUploadImage() + .then((f) => this.setData({ 'form.avatar_file_id': f.id, 'form.avatar_url': f.url })) + .catch((e) => { + if (e && e.canceled) return; + toastErr(e, '上传失败'); + }); + }, + pickSpecies(e) { + const sp = e.currentTarget.dataset.sp; + if (sp === this.data.form.species) return; + // 换了物种,原来选的品种一定不对了 + this.setData({ 'form.species': sp, 'form.breed': '', breedGroups: [] }); + }, + pickGender(e) { + this.setData({ 'form.gender': e.currentTarget.dataset.v }); + }, + pickStage(e) { + this.setData({ 'form.stage': e.currentTarget.dataset.v }); + }, + onBirthday(e) { + const v = e.detail.value; + const auto = stageFromBirthday(v); + const patch = { 'form.birthday': v, stageAuto: auto }; + // 用户还没手选过阶段就跟着生日走;选过就不动他的选择 + if (auto && !this.data.form.stage) patch['form.stage'] = auto; + this.setData(patch); + }, + onArrived(e) { + this.setData({ 'form.arrived_at': e.detail.value }); + }, + onWeight(e) { + this.setData({ 'form.weight': e.detail.value }); + }, + onColor(e) { + this.setData({ 'form.color': e.detail.value }); + }, + + // ── 品种选择器 ── + openBreed() { + this.setData({ breedShow: true }); + if (this.data.breedGroups.length) return; + api + .breeds(this.data.form.species) + .then((groups) => this.setData({ breedGroups: groups || [] })) + .catch((e) => toastErr(e)); + }, + closeBreed() { + this.setData({ breedShow: false }); + }, + noop() {}, + pickBreed(e) { + this.setData({ 'form.breed': e.currentTarget.dataset.name, breedShow: false }); + }, + clearBreed() { + // 「不确定」要留:领养的串串确实说不出品种,逼着选一个只会得到假数据 + this.setData({ 'form.breed': '', breedShow: false }); + }, + jumpInitial(e) { + this.setData({ breedAnchor: 'bi-' + e.currentTarget.dataset.i }); + }, + + // ── 分步 ── + prev() { + if (this.data.step > 0) this.setData({ step: this.data.step - 1 }); + }, + // 每一步只校验这一步的东西。三步全填完才校验的话,用户在第三步才被告知 + // 第一步没填名字,还得翻回去 + stepError() { + const f = this.data.form; + if (this.data.step === 0 && !f.name.trim()) return '先给它起个名字'; + if (this.data.step === 1 && !f.species) return '选一下是猫还是狗'; + return ''; + }, + next() { + if (this.data.saving) return; + if (!this.data.editing) { + const err = this.stepError(); + if (err) return wx.showToast({ title: err, icon: 'none' }); + if (this.data.step < 2) return this.setData({ step: this.data.step + 1 }); + } else if (!this.data.form.name.trim()) { + return wx.showToast({ title: '名字不能空', icon: 'none' }); + } + this.submit(); + }, + body() { + const f = this.data.form; + const isDog = f.species === 'dog'; + const body = { + name: f.name.trim(), + type: isDog ? '狗狗' : '猫猫', + // emoji 是老字段,列表和海报还在用它兜底;有头像时它不显示 + emoji: isDog ? '🐶' : '🐱', + gender: f.gender, + birthday: f.birthday, + arrived_at: f.arrived_at, + weight: f.weight, + color: f.color.trim(), + breed: f.breed, + stage: f.stage || stageFromBirthday(f.birthday) || '成年期', + age: ageLabel(f.birthday), + }; + // 没重新传头像就不带这个字段,别把已有头像覆盖成空 + if (f.avatar_file_id) body.avatar_file_id = f.avatar_file_id; + return body; + }, + submit() { + this.setData({ saving: true }); + const b = this.body(); + const req = this.data.editing ? api.updatePet(this.data.petId, b) : api.createPet(b); + req + .then(() => store.loadPets()) + .then(() => { + this.setData({ saving: false }); + wx.showToast({ title: this.data.editing ? '已保存' : '建好啦', icon: 'success' }); + setTimeout(() => wx.navigateBack(), 700); + }) + .catch((e) => { + this.setData({ saving: false }); + toastErr(e, '保存失败'); + }); + }, +}); diff --git a/pets-fe/pages/petform/petform.json b/pets-fe/pages/petform/petform.json new file mode 100644 index 0000000..6594cd8 --- /dev/null +++ b/pets-fe/pages/petform/petform.json @@ -0,0 +1,6 @@ +{ + "usingComponents": { + "nav-bar": "/components/nav-bar/nav-bar", + "pt-icon": "/components/pt-icon/index" + } +} diff --git a/pets-fe/pages/petform/petform.wxml b/pets-fe/pages/petform/petform.wxml new file mode 100644 index 0000000..b0dcb58 --- /dev/null +++ b/pets-fe/pages/petform/petform.wxml @@ -0,0 +1,120 @@ + + + + + + {{index < step ? '✓' : index + 1}} + {{item}} + + + + + + + + + + 先给它起个名字 + + + + + + {{form.avatar_url ? '换一张' : '传张照片'}} + + + + + + + + + + 它是猫还是狗? + + + 猫猫 + + + 狗狗 + + + + + {{form.breed || '选一个品种'}} + + + + + + + + + 再补几项,之后都能改 + + {{item}} + + + + + + {{form.birthday || '不知道可以先跳过'}} + + + + {{form.arrived_at || '哪天接回家的'}} + + + + + + + + {{item}} + + 按生日推算是「{{stageAuto}}」,可以手改 + + + + + + + + + 上一步 + + {{saving ? '保存中…' : (editing ? '保存' : (step < 2 ? '下一步' : '完成建档'))}} + + + + + + + + 选择品种 + 不确定 + + + + + {{item.initial}} + {{b.name}} + + 还没有配置品种 + + + {{item.initial}} + + + + diff --git a/pets-fe/pages/petform/petform.wxss b/pets-fe/pages/petform/petform.wxss new file mode 100644 index 0000000..811a4c3 --- /dev/null +++ b/pets-fe/pages/petform/petform.wxss @@ -0,0 +1,86 @@ +.pf-body{padding-bottom:calc(200rpx + env(safe-area-inset-bottom))} + +/* 三步进度条。不用 seg-tabs:那是「可以随意切」的语义, + 这里是有顺序的,点第三步跳过前两步会拿到一份没名字的档案 */ +.pf-steps{ + flex:none;display:flex;align-items:flex-start;justify-content:center; + gap:var(--sp-6);padding:var(--sp-3) var(--pad-x) var(--sp-4); +} +.pf-step{display:flex;flex-direction:column;align-items:center;gap:6rpx} +.pf-dot{ + width:48rpx;height:48rpx;border-radius:50%; + display:flex;align-items:center;justify-content:center; + background:#fff;border:1rpx solid var(--line); + color:var(--muted2);font-size:var(--fs-sm);font-weight:var(--fw-b); +} +.pf-slabel{font-size:var(--fs-cap);color:var(--muted2)} +.pf-step.on .pf-dot{background:var(--primary);border-color:var(--primary);color:#fff} +.pf-step.on .pf-slabel{color:var(--primary-dark);font-weight:var(--fw-b)} + +.pf-h{font-size:var(--fs-lg);font-weight:var(--fw-b);margin-bottom:var(--sp-4)} + +/* 头像 */ +.pf-av-wrap{display:flex;flex-direction:column;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-5)} +.pf-av{ + width:176rpx;height:176rpx;border-radius:50%;overflow:hidden; + background:var(--primary-soft);color:var(--primary-dark); + display:flex;align-items:center;justify-content:center; +} +.pf-av-img{width:100%;height:100%} +.pf-av-tip{font-size:var(--fs-cap);color:var(--primary-dark)} + +/* 猫 / 狗 */ +.pf-species{display:flex;gap:var(--sp-3);margin-bottom:var(--sp-4)} +.pf-sp{ + flex:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-2); + padding:var(--sp-5) 0;border-radius:var(--r-md); + background:var(--surface-2);border:2rpx solid var(--line); + color:var(--muted);font-size:var(--fs-md); +} +.pf-sp.on{background:var(--primary-soft);border-color:var(--primary);color:var(--primary-ink);font-weight:var(--fw-b)} +.pf-empty{color:var(--muted2)} + +/* 底部按钮 */ +.pf-foot{ + position:fixed;left:0;right:0;bottom:0;z-index:50;display:flex;gap:var(--sp-3); + padding:var(--sp-4) var(--pad-x) calc(var(--sp-4) + env(safe-area-inset-bottom)); + background:#fff;box-shadow:0 -12rpx 32rpx rgba(70,48,25,.06); +} +.pf-back{flex:none;width:200rpx} +.pf-next{flex:1} +.pf-busy{opacity:.6} + +/* 品种选择器 */ +.pf-mask{position:fixed;inset:0;z-index:200;background:rgba(45,41,37,.45);display:flex;align-items:flex-end} +.pf-sheet{ + width:100%;background:var(--bg2);border-radius:var(--r-lg) var(--r-lg) 0 0; + padding:var(--sp-5) 0 calc(var(--sp-3) + env(safe-area-inset-bottom)); + box-shadow:var(--sd-3); +} +.pf-sheet-h{ + display:flex;align-items:center;justify-content:space-between; + padding:0 var(--pad-x) var(--sp-4); + font-size:var(--fs-lg);font-weight:var(--fw-b); +} +.pf-breed-wrap{display:flex;height:60vh} +.pf-breed-list{flex:1;min-width:0;padding-left:var(--pad-x)} +/* 首字母吸顶:滚长列表时得知道现在在哪一段 */ +.pf-bi{ + position:sticky;top:0;z-index:1; + background:var(--bg2);color:var(--primary-dark); + font-size:var(--fs-sm);font-weight:var(--fw-b);padding:var(--sp-2) 0; +} +.pf-brow{ + padding:var(--sp-3) 0;font-size:var(--fs-md);color:var(--text); + border-bottom:1rpx solid var(--line); +} +.pf-brow.on{color:var(--primary-dark);font-weight:var(--fw-b)} +/* 右侧 A-Z 索引 */ +.pf-index{ + flex:none;width:56rpx;display:flex;flex-direction:column; + align-items:center;justify-content:center;gap:2rpx; +} +.pf-ichar{ + font-size:var(--fs-cap);color:var(--muted); + padding:2rpx 8rpx;line-height:1.4; +} diff --git a/pets-fe/pages/plan/plan.js b/pets-fe/pages/plan/plan.js index 0f8440f..fb9a782 100644 --- a/pets-fe/pages/plan/plan.js +++ b/pets-fe/pages/plan/plan.js @@ -228,7 +228,7 @@ Page({ this.setData({ sheetShow: false }); }, onAddPet() { - this.setData({ sheetType: 'addPet', sheetShow: true }); + wx.navigateTo({ url: '/pages/petform/petform' }); }, onShareAppMessage() { return { title: '我给毛孩子做了份养护计划', path: '/pages/plan/plan' }; diff --git a/pets-fe/pages/record/record.js b/pets-fe/pages/record/record.js index 822c8a8..191692e 100644 --- a/pets-fe/pages/record/record.js +++ b/pets-fe/pages/record/record.js @@ -39,7 +39,7 @@ Page({ }, // 没建档时要能建档,所以这页留一个只用于 addPet 的弹层 onAddPet() { - this.setData({ sheetType: 'addPet', sheetShow: true }); + wx.navigateTo({ url: '/pages/petform/petform' }); }, closeSheet() { this.setData({ sheetShow: false }); diff --git a/pets-fe/pages/report/report.js b/pets-fe/pages/report/report.js index 69494e5..9972ee9 100644 --- a/pets-fe/pages/report/report.js +++ b/pets-fe/pages/report/report.js @@ -55,7 +55,7 @@ Page({ this.setData({ sheetShow: false }); }, onAddPet() { - this.setData({ sheetType: 'addPet', sheetShow: true }); + wx.navigateTo({ url: '/pages/petform/petform' }); }, goHistory() { wx.navigateTo({ url: '/pages/history/history' }); diff --git a/pets-fe/pages/settings/settings.js b/pets-fe/pages/settings/settings.js index 1fe4ca9..0d8ae85 100644 --- a/pets-fe/pages/settings/settings.js +++ b/pets-fe/pages/settings/settings.js @@ -64,10 +64,11 @@ Page({ }); }, onAddPet() { - this.setData({ sheetType: 'addPet', sheetShow: true }); + wx.navigateTo({ url: '/pages/petform/petform' }); }, onEditPet() { - this.setData({ sheetType: 'editPet', sheetShow: true }); + const id = store.currentPetId(); + wx.navigateTo({ url: '/pages/petform/petform' + (id ? '?id=' + id : '') }); }, closeSheet() { this.setData({ sheetShow: false }); diff --git a/pets-fe/utils/api.js b/pets-fe/utils/api.js index a89fe0e..1f80e57 100644 --- a/pets-fe/utils/api.js +++ b/pets-fe/utils/api.js @@ -54,6 +54,8 @@ const api = { getPets: () => request({ url: '/api/pets' }), homeSummary: (id) => request({ url: `/api/pets/${id}/home-summary` }), getPet: (id) => request({ url: `/api/pets/${id}` }), + // 品种(后台可配),按首字母分组 + breeds: (species) => request({ url: '/api/breeds?species=' + species }), createPet: (body) => request({ url: '/api/pets', method: 'POST', data: body }), updatePet: (id, body) => request({ url: `/api/pets/${id}`, method: 'PUT', data: body }), deletePet: (id) => request({ url: `/api/pets/${id}`, method: 'DELETE' }),