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}}」,可以手改
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 选择品种
+ 不确定
+
+
+
+
+ {{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' }),