diff --git a/pets-be/internal/handler/auth.go b/pets-be/internal/handler/auth.go index 96696e9..d730872 100644 --- a/pets-be/internal/handler/auth.go +++ b/pets-be/internal/handler/auth.go @@ -210,3 +210,20 @@ func (h *Handler) UpdateProfile(c *gin.Context) { } response.OK(c, user) } + +// BindPhone POST /api/user/phone 用小程序 getPhoneNumber 的 code 绑定手机号 +func (h *Handler) BindPhone(c *gin.Context) { + var req struct { + Code string `json:"code"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.Code == "" { + response.FailParams(c, "缺少 code") + return + } + phone, err := h.svc.BindPhoneByCode(middleware.UserID(c), req.Code) + if err != nil { + response.FailErr(c, err) + return + } + response.OK(c, gin.H{"phone": phone}) +} diff --git a/pets-be/internal/handler/pet.go b/pets-be/internal/handler/pet.go index 6aa8546..b49c187 100644 --- a/pets-be/internal/handler/pet.go +++ b/pets-be/internal/handler/pet.go @@ -26,6 +26,9 @@ type petReq struct { Breed string `json:"breed"` AvatarFileID string `json:"avatar_file_id"` Goals []string `json:"goals"` + Personality string `json:"personality"` + Allergy string `json:"allergy"` + Notes string `json:"notes"` } func (r petReq) toInput() service.PetInput { @@ -33,6 +36,7 @@ func (r petReq) toInput() service.PetInput { Name: r.Name, Emoji: r.Emoji, Type: r.Type, Gender: r.Gender, Weight: r.Weight, Stage: r.Stage, Age: r.Age, Color: r.Color, Breed: r.Breed, AvatarFileID: r.AvatarFileID, + Personality: r.Personality, Allergy: r.Allergy, Notes: r.Notes, } if r.Birthday != "" { if t, err := time.ParseInLocation("2006-01-02", r.Birthday, time.Local); err == nil { @@ -123,6 +127,17 @@ func (h *Handler) UpdatePet(c *gin.Context) { if req.Color != "" { fields["color"] = req.Color } + // 性格/过敏/备注:空串也允许写(用户可能想清空),用指针语义太重, + // 这里约定前端总是回传当前值,非空才更新;清空场景暂用「无」占位 + if req.Personality != "" { + fields["personality"] = req.Personality + } + if req.Allergy != "" { + fields["allergy"] = req.Allergy + } + if req.Notes != "" { + fields["notes"] = req.Notes + } if req.Breed != "" { fields["breed"] = req.Breed } diff --git a/pets-be/internal/model/pet.go b/pets-be/internal/model/pet.go index d8e6566..9d85e0a 100644 --- a/pets-be/internal/model/pet.go +++ b/pets-be/internal/model/pet.go @@ -27,4 +27,9 @@ type Pet struct { Breed string `gorm:"size:64" json:"breed"` // 品种 HealthStatus string `gorm:"size:16;default:正常" json:"health_status"` Goals datatypes.JSON `json:"goals"` // onboarding 目标多选 + + // —— 身份卡用的补充信息 —— + Personality string `gorm:"size:64" json:"personality"` // 性格标签,逗号分隔,如 "活泼,亲人,贪吃" + Allergy string `gorm:"size:128" json:"allergy"` // 过敏情况,没有就填「无」 + Notes string `gorm:"size:255" json:"notes"` // 备注信息(照护提醒) } diff --git a/pets-be/internal/router/router.go b/pets-be/internal/router/router.go index b7394f0..058ed77 100644 --- a/pets-be/internal/router/router.go +++ b/pets-be/internal/router/router.go @@ -49,6 +49,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage g.GET("/user/profile", h.Profile) g.PUT("/user/profile", h.UpdateProfile) + g.POST("/user/phone", h.BindPhone) g.GET("/user/summary", h.UserSummary) g.PUT("/user/decoration", h.UpdateDecoration) diff --git a/pets-be/internal/service/community.go b/pets-be/internal/service/community.go index 03aeca2..38d750e 100644 --- a/pets-be/internal/service/community.go +++ b/pets-be/internal/service/community.go @@ -48,7 +48,7 @@ func (s *Service) attachPostImages(posts []model.Post) { func tabTag(tab string) string { switch tab { case "新手求助": - return "求助" + return "新手求助" case "晒宠": return "晒宠" case "经验": @@ -111,23 +111,14 @@ type PostInput struct { // CreatePost 发帖 func (s *Service) CreatePost(userID string, in PostInput) (*model.Post, error) { - authorName := "匿名宠友" - authorEmoji := "🐾" - switch in.Identity { - case "official": - authorName = "肉垫计划官方" - case "petName": - if in.PetID != nil { - var pet model.Pet - if err := s.db.First(&pet, *in.PetID).Error; err == nil { - authorName = pet.Name + "的铲屎官" - authorEmoji = pet.Emoji - } - } - } - // 取作者 openid(内容安全接口 v2 要求带上),并把图片 file id 解析成可访问 URL + // 取作者信息:统一用发布者昵称(去掉了发布身份选择),openid 给内容安全接口用 var user model.User - s.db.Select("id, open_id").First(&user, userID) + s.db.Select("id, open_id, nickname").First(&user, userID) + authorName := user.Nickname + if authorName == "" { + authorName = "宠友" + } + authorEmoji := "🐾" imgURLs := s.resolveImageURLs(in.ImageFileIDs) // 发布前过内容安全:文本同步判、图片提交异步查。检测不了或有图 → 待人工/待回调 diff --git a/pets-be/internal/service/file.go b/pets-be/internal/service/file.go index e108749..4c187cd 100644 --- a/pets-be/internal/service/file.go +++ b/pets-be/internal/service/file.go @@ -8,6 +8,7 @@ import ( "io" "path" "strings" + "time" "github.com/sundynix/pets-be/internal/model" ) @@ -41,7 +42,9 @@ func (s *Service) UploadFile(reader io.Reader, filename, contentType string) (*m if contentType == "" { contentType = "application/octet-stream" } - objectName := "files/" + md5hex + ext + // 按上传月份分文件夹:uploads/2026-07/.jpg。文件名仍用 md5 保证唯一。 + // 同内容命中上面的去重直接复用,不会因为跨月再传一份 + objectName := "uploads/" + time.Now().Format("2006-01") + "/" + md5hex + ext url, err := s.storage.Upload(objectName, bytes.NewReader(data), int64(len(data)), contentType) if err != nil { return nil, err diff --git a/pets-be/internal/service/home.go b/pets-be/internal/service/home.go index e9d6a6f..304c330 100644 --- a/pets-be/internal/service/home.go +++ b/pets-be/internal/service/home.go @@ -180,14 +180,22 @@ func (s *Service) datesWithActivity(petID string, start, end time.Time) map[stri } func (s *Service) todayCompletionPct(petID string, now time.Time) int { - start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - end := start.AddDate(0, 0, 1) + // 任务是 PlanTask(DailyTask 已空)。取宠物的 active 30 天计划,算今天那天的完成占比 + var plan model.Plan + if s.db.Where("pet_id = ? AND kind = ? AND status = ?", petID, model.PlanThirtyDay, "active"). + Order("id desc").First(&plan).Error != nil { + return 0 + } + day := dayIndexOf(&plan, now) + if day < 0 { + return 0 + } var total, done int64 - s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).Count(&total) + s.db.Model(&model.PlanTask{}).Where("plan_id = ? AND day = ?", plan.ID, day).Count(&total) if total == 0 { return 0 } - s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND task_date >= ? AND task_date < ? AND done = ?", petID, start, end, true).Count(&done) + s.db.Model(&model.PlanTask{}).Where("plan_id = ? AND day = ? AND done = ?", plan.ID, day, true).Count(&done) return int(math.Round(float64(done) / float64(total) * 100)) } diff --git a/pets-be/internal/service/idcard.go b/pets-be/internal/service/idcard.go index 81c191d..493057a 100644 --- a/pets-be/internal/service/idcard.go +++ b/pets-be/internal/service/idcard.go @@ -3,6 +3,7 @@ package service import ( "log" "strings" + "time" "github.com/sundynix/pets-be/internal/model" ) @@ -14,12 +15,28 @@ type IDCard struct { Gender string `json:"gender"` Breed string `json:"breed"` Birthday string `json:"birthday"` // "2023 年 12 月 15 日" + Age string `json:"age"` // 年龄,如 "2岁7个月" + Days int `json:"days"` // 一起生活的天数(按到家日) + Zodiac string `json:"zodiac"` // 星座,按生日算 + Weight string `json:"weight"` // 体重,如 "13.5kg" Intro string `json:"intro"` + Color string `json:"color"` // 毛色 + Personality string `json:"personality"` // 性格标签,逗号分隔 AvatarURL string `json:"avatar_url"` IDNo string `json:"id_no"` // 玩具身份号,不入库 Validity string `json:"validity"` // "2023.12.15 - 永远" - QRURL string `json:"qr_url"` // 小程序码,未发布时为空 - QRReady bool `json:"qr_ready"` // 码有没有生成出来 + + // —— 背面:照护信息 —— + Owner string `json:"owner"` // 主人昵称 + Phone string `json:"phone"` // 联系电话(脱敏) + Allergy string `json:"allergy"` // 过敏情况 + Notes string `json:"notes"` // 备注 + VaccineState string `json:"vaccine_state"` // 疫苗情况 + DewormState string `json:"deworm_state"` // 驱虫情况 + Org string `json:"org"` // 发证机构 + + QRURL string `json:"qr_url"` // 小程序码,未发布时为空 + QRReady bool `json:"qr_ready"` // 码有没有生成出来 } // petIDNo 造一个稳定的「身份号」。纯装饰,不入库、不做校验。 @@ -51,6 +68,34 @@ func petIDNo(pet *model.Pet) string { return b.String() } +// zodiacOf 按月日算西方星座。按各星座起始日(含)顺序判定, +// 落在 12/22–1/19 的默认摩羯座 +func zodiacOf(month, day int) string { + starts := []struct { + m, d int + name string + }{ + {1, 20, "水瓶座"}, {2, 19, "双鱼座"}, {3, 21, "白羊座"}, {4, 20, "金牛座"}, + {5, 21, "双子座"}, {6, 22, "巨蟹座"}, {7, 23, "狮子座"}, {8, 23, "处女座"}, + {9, 23, "天秤座"}, {10, 24, "天蝎座"}, {11, 22, "射手座"}, {12, 22, "摩羯座"}, + } + name := "摩羯座" + for _, s := range starts { + if month > s.m || (month == s.m && day >= s.d) { + name = s.name + } + } + return name +} + +// maskPhone 脱敏手机号:138****8888。空或非 11 位原样返回 +func maskPhone(p string) string { + if len(p) != 11 { + return p + } + return p[:3] + "****" + p[7:] +} + // GetIDCard 组装身份卡。小程序码是 best-effort:拿不到(未发布/配额)也照出卡 func (s *Service) GetIDCard(userID, petID string) (*IDCard, error) { pet, err := s.GetPet(userID, petID) @@ -72,10 +117,50 @@ func (s *Service) GetIDCard(userID, petID string) (*IDCard, error) { validity = pet.Birthday.Format("2006.01.02") + " - 永远" } + zodiac := "" + if pet.Birthday != nil && !pet.Birthday.IsZero() { + zodiac = zodiacOf(int(pet.Birthday.Month()), pet.Birthday.Day()) + } + + // 主人昵称 + 脱敏电话 + var user model.User + s.db.First(&user, userID) + owner := user.Nickname + if owner == "" { + owner = "铲屎官" + } + + // 疫苗:有记录算已接种;驱虫:取下一次到期提醒的日期 + var vaccineCnt int64 + s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordVaccine).Count(&vaccineCnt) + vaccineState := "待接种" + if vaccineCnt > 0 { + vaccineState = "已接种" + } + dewormState := "—" + var dw model.Reminder + if err := s.db.Where("pet_id = ? AND type = ?", petID, model.ReminderDeworm).Order("next_due_date asc").First(&dw).Error; err == nil && dw.NextDueDate != nil { + dewormState = dw.NextDueDate.Format("2006.01.02") + } + + allergy := pet.Allergy + if allergy == "" { + allergy = "无" + } + + // 一起生活的天数:按到家日算,没填就 0 + days := 0 + if pet.ArrivedAt != nil && !pet.ArrivedAt.IsZero() { + days = int(time.Since(*pet.ArrivedAt).Hours()/24) + 1 + } + card := &IDCard{ Name: pet.Name, Species: pet.Type, Gender: pet.Gender, Breed: pet.Breed, - Birthday: bd, Intro: intro, AvatarURL: pet.AvatarURL, - IDNo: petIDNo(pet), Validity: validity, + Birthday: bd, Age: pet.Age, Days: days, Zodiac: zodiac, Weight: pet.Weight, Intro: intro, + Color: pet.Color, Personality: pet.Personality, + AvatarURL: pet.AvatarURL, IDNo: petIDNo(pet), Validity: validity, + Owner: owner, Phone: user.Phone, Allergy: allergy, Notes: pet.Notes, + VaccineState: vaccineState, DewormState: dewormState, Org: "肉垫计划", } // 小程序码取不到不算错——未发布/配额用尽时卡照样出,只是没码 diff --git a/pets-be/internal/service/pet.go b/pets-be/internal/service/pet.go index 5cf8d22..e70c3d2 100644 --- a/pets-be/internal/service/pet.go +++ b/pets-be/internal/service/pet.go @@ -27,6 +27,9 @@ type PetInput struct { Breed string AvatarFileID string Goals datatypes.JSON + Personality string + Allergy string + Notes string } func normalizeWeight(w string) string { @@ -86,6 +89,9 @@ func (s *Service) CreatePet(userID string, in PetInput) (*model.Pet, error) { Color: in.Color, Breed: in.Breed, AvatarFileID: in.AvatarFileID, + Personality: in.Personality, + Allergy: in.Allergy, + Notes: in.Notes, HealthStatus: "正常", Goals: in.Goals, } diff --git a/pets-be/internal/service/plan.go b/pets-be/internal/service/plan.go index 129a46e..3776e40 100644 --- a/pets-be/internal/service/plan.go +++ b/pets-be/internal/service/plan.go @@ -23,7 +23,16 @@ func (s *Service) TogglePlanTask(userID, taskID string) error { if err := s.db.Where("id = ? AND user_id = ?", pt.PlanID, userID).First(&plan).Error; err != nil { return ErrNotFound } - return s.db.Model(&model.PlanTask{}).Where("id = ?", taskID).Update("done", !pt.Done).Error + newDone := !pt.Done + if err := s.db.Model(&model.PlanTask{}).Where("id = ?", taskID).Update("done", newDone).Error; err != nil { + return err + } + // 勾选完成、且没关联记录类型的任务(自定义/无 sheet_type),补一条 note 记录, + // 让最近记录里留痕。有 sheet_type 的任务是走记录表单单独落记录的,这里不重复。 + if newDone && pt.SheetType == "" { + _, _ = s.CreateRecord(userID, plan.PetID, RecordInput{Type: "note", Title: "完成:" + pt.Title}) + } + return nil } // newStagePlan 按宠物当前阶段的养护模板生成一份 30 天计划(未保存) diff --git a/pets-be/internal/service/report.go b/pets-be/internal/service/report.go index 88db0cb..4f53507 100644 --- a/pets-be/internal/service/report.go +++ b/pets-be/internal/service/report.go @@ -26,9 +26,10 @@ func (s *Service) GetWeeklyReport(userID, petID string) (*WeeklyReport, error) { } weekAgo := time.Now().AddDate(0, 0, -7) + // 任务已并成 PlanTask(DailyTask 表已空),要经 plan 关联到宠物来统计本周完成数 var tasksCompleted int64 - s.db.Model(&model.DailyTask{}). - Where("pet_id = ? AND done = ? AND updated_at >= ?", petID, true, weekAgo). + s.db.Model(&model.PlanTask{}). + Where("plan_id IN (SELECT id FROM sundynix_plans WHERE pet_id = ?) AND done = ? AND updated_at >= ?", petID, true, weekAgo). Count(&tasksCompleted) // 体重增长:最近 7 天最新 - 最早 @@ -198,9 +199,11 @@ func (s *Service) GetHealthSummary(userID, petID string) (*HealthSummary, error) type Poster struct { PetName string `json:"pet_name"` PetEmoji string `json:"pet_emoji"` + PetAvatarURL string `json:"pet_avatar_url"` // 上传过照片就有,海报优先用它,没有才用 emoji Age string `json:"age"` Weight string `json:"weight"` Stage string `json:"stage"` + Period string `json:"period"` // 报告周期,如 "07.25 - 07.31" TasksCompleted int64 `json:"tasks_completed"` WeightRecords int64 `json:"weight_records"` VaccineRecords int64 `json:"vaccine_records"` @@ -214,14 +217,24 @@ func (s *Service) GetPoster(userID, petID string) (*Poster, error) { if err != nil { return nil, err } - var tasksDone, weightRecs, vaccineRecs int64 - s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND done = ?", petID, true).Count(&tasksDone) - s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordWeight).Count(&weightRecs) - s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordVaccine).Count(&vaccineRecs) + // 这是周报告,所有统计按近 7 天算(和报告页「本周概览」口径一致) + now := time.Now() + weekAgo := now.AddDate(0, 0, -7) + var tasksDone, weightRecs, vaccineRecs, highRisk int64 + s.db.Model(&model.PlanTask{}). + Where("plan_id IN (SELECT id FROM sundynix_plans WHERE pet_id = ?) AND done = ? AND updated_at >= ?", petID, true, weekAgo). + Count(&tasksDone) + s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).Count(&weightRecs) + s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordVaccine, weekAgo).Count(&vaccineRecs) + s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ? AND category = ? AND occurred_at >= ?", petID, model.RecordSymptom, "高", weekAgo).Count(&highRisk) + + // ownedPet 只填了 Age,头像 URL 得在这里从 file id 解析(否则是空的) return &Poster{ - PetName: pet.Name, PetEmoji: pet.Emoji, Age: pet.Age, Weight: pet.Weight, Stage: pet.Stage, + PetName: pet.Name, PetEmoji: pet.Emoji, PetAvatarURL: s.fileURL(pet.AvatarFileID), + Age: pet.Age, Weight: pet.Weight, Stage: pet.Stage, + Period: weekAgo.Format("01.02") + " - " + now.Format("01.02"), TasksCompleted: tasksDone, WeightRecords: weightRecs, VaccineRecords: vaccineRecs, - HighRiskCount: 0, Headline: "稳定成长", + HighRiskCount: highRisk, Headline: "稳定成长", }, nil } diff --git a/pets-be/internal/service/user_profile.go b/pets-be/internal/service/user_profile.go index e2233b3..e330f2f 100644 --- a/pets-be/internal/service/user_profile.go +++ b/pets-be/internal/service/user_profile.go @@ -123,9 +123,19 @@ func (s *Service) keepStats(userID string, withPets bool) (*KeepStats, []PetCard cards = nil } - // 养宠天数按最早那只的建档日算。用建档日而不是生日:这里要表达的是 - // 「你在这个 App 上照顾它多久了」,不是宠物活了多久 - st.Days = int(time.Since(pets[0].CreatedAt).Hours()/24) + 1 + // 养宠天数按最早那只的到家日算(没填到家日就退回建档日),和首页 + // 「一起生活的第 N 天」口径一致。用建档日会因为重新建档而被重置成 1,很怪 + earliest := time.Now() + for _, p := range pets { + start := p.CreatedAt + if p.ArrivedAt != nil && !p.ArrivedAt.IsZero() { + start = *p.ArrivedAt + } + if start.Before(earliest) { + earliest = start + } + } + st.Days = int(time.Since(earliest).Hours()/24) + 1 var total int64 s.db.Model(&model.HealthRecord{}).Where("pet_id IN ?", ids).Count(&total) @@ -188,7 +198,14 @@ func (s *Service) UpdateDecoration(userID string, bio, bgFileID, theme *string, // ListUserPosts 某人发布的帖子 func (s *Service) ListUserPosts(viewerID, userID string, offset, limit int) ([]model.Post, int64, error) { - q := s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, "published") + q := s.db.Model(&model.Post{}).Where("user_id = ?", userID) + if viewerID == userID { + // 看自己:待审核/未通过的也要能看到(带状态展示),只藏软删除的 + q = q.Where("status <> ?", model.PostDeleted) + } else { + // 看别人:只放已过审的 + q = q.Where("status = ?", model.PostPublished) + } var total int64 if err := q.Count(&total).Error; err != nil { return nil, 0, err diff --git a/pets-be/internal/service/wxacode.go b/pets-be/internal/service/wxacode.go index 769c368..12b3e6d 100644 --- a/pets-be/internal/service/wxacode.go +++ b/pets-be/internal/service/wxacode.go @@ -11,6 +11,8 @@ import ( "net/url" "sync" "time" + + "github.com/sundynix/pets-be/internal/model" ) // 小程序码(wxacode)。身份卡右下角那个码,扫了能进小程序。 @@ -123,3 +125,38 @@ func (s *Service) PetQRCode(petID string) (string, error) { // 41030 = page 不在已发布版本里。开发期常见,往上层透传让它降级 return "", fmt.Errorf("微信生成小程序码失败(%d): %s", e.ErrCode, e.ErrMsg) } + +// BindPhoneByCode 用小程序 getPhoneNumber 拿到的 code 换手机号,存到用户上。 +// 返回脱敏后的手机号。开发期/未认证时微信会报错,往上层透传。 +func (s *Service) BindPhoneByCode(userID, code string) (string, error) { + token, err := s.wechatAccessToken() + if err != nil { + return "", err + } + payload, _ := json.Marshal(map[string]string{"code": code}) + var out struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + PhoneInfo struct { + PhoneNumber string `json:"phoneNumber"` + PurePhoneNumber string `json:"purePhoneNumber"` + } `json:"phone_info"` + } + if err := postJSON("https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token="+token, payload, &out); err != nil { + return "", err + } + if out.ErrCode != 0 { + return "", fmt.Errorf("获取手机号失败(%d): %s", out.ErrCode, out.ErrMsg) + } + phone := out.PhoneInfo.PurePhoneNumber + if phone == "" { + phone = out.PhoneInfo.PhoneNumber + } + if phone == "" { + return "", fmt.Errorf("微信没返回手机号") + } + if err := s.db.Model(&model.User{}).Where("id = ?", userID).Update("phone", phone).Error; err != nil { + return "", err + } + return maskPhone(phone), nil +} diff --git a/pets-fe/app.wxss b/pets-fe/app.wxss index b418ae5..5a04c51 100644 --- a/pets-fe/app.wxss +++ b/pets-fe/app.wxss @@ -21,6 +21,10 @@ page{ --primary-soft:#FFF4E4; /* 唯一的浅橙底(收编了 9 种近似值) */ --primary-line:#FFD6A1; /* 浅橙块的描边 */ --primary-ink:#A96500; /* 浅橙底上的文字 */ + /* 门面品牌色带(首页/记录页顶部,方案 A)*/ + --band-a:#FFC768; + --band-b:#F5A947; + --band-ink:#7A4A08; /* 色带上的深棕文字 */ /* ── 语义色:soft 底 / base 主体 / ink 文字,三件套配齐 ── */ --green:#73BE9D; --green-soft:#EAF7F1; --green-ink:#2E7D5B; @@ -80,11 +84,15 @@ page{ color:var(--text); font-weight:var(--fw); font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","PingFang SC","Microsoft YaHei",sans-serif; + /* 整页暖橘竖向淡出:顶部品牌橘暖调 → 约半屏处褪到米白。 + 渐变铺在 page 上(不滚),内容在 .page-scroll 里滚过它,顶部常暖。 */ background: - radial-gradient(circle at 10% 8%, rgba(245,169,71,.26), transparent 30%), - radial-gradient(circle at 88% 10%, rgba(115,190,157,.20), transparent 26%), - radial-gradient(circle at 72% 88%, rgba(120,166,232,.14), transparent 28%), - linear-gradient(135deg,#FBF5EA,#EEF6F1 55%,#F8F1E7); + linear-gradient(180deg, + #FFC66E 0%, + #FFD489 10%, + #FCE6C4 28%, + var(--bg) 48%, + var(--bg) 100%); /* 页面自身不滚,滚动交给 .page-scroll,这样 nav-bar 和 tabBar 才能固定住。 这 4 行原本在 9 个页面的 wxss 里逐字重复。 */ @@ -92,6 +100,7 @@ page{ overflow:hidden; display:flex; flex-direction:column; + position:relative; /* 门面色带 .brand-band 绝对定位锚在页面上 */ } view,text{box-sizing:border-box} @@ -218,6 +227,10 @@ page{scrollbar-width:none;-ms-overflow-style:none} width:100%;border:1rpx solid var(--line);background:var(--surface-2);height:184rpx;border-radius:var(--r-md); padding:var(--sp-4);color:var(--text);font-size:var(--fs-lg);box-sizing:border-box; } +/* 上面那条共用规则的 flex+align-items:center 是给单行 input 竖向居中用的, + 但选择器带了 textarea,导致多行输入的占位/文字被竖向居中、不从第一行开始。 + 这里把 textarea 改回块级顶部对齐,各自的高度仍由具体 class 决定 */ +textarea{display:block;padding:var(--sp-4);line-height:1.6} .placeholder{color:var(--muted2)} /* 分段选择 */ @@ -326,14 +339,43 @@ page{scrollbar-width:none;-ms-overflow-style:none} linear-gradient(160deg,#FFF7E8,#FFFFFF); border:1rpx solid #F2DFC3;border-radius:var(--r-lg);padding:var(--sp-5);text-align:center;overflow:hidden;position:relative; } +/* 圆头像 + 白边,和保存的图一致 */ .poster-avatar{ - width:152rpx;height:152rpx;margin:var(--sp-3) auto var(--sp-3);border-radius:var(--r-lg); + width:160rpx;height:160rpx;margin:var(--sp-4) auto var(--sp-3);border-radius:50%; background:linear-gradient(135deg,#FFE5B7,#FFC06B);display:flex;align-items:center;justify-content:center; - font-size:80rpx;box-shadow:var(--sd-1); + font-size:80rpx;box-shadow:var(--sd-1);overflow:hidden;border:6rpx solid #fff; } +.poster-avatar-img{width:100%;height:100%;display:block} +.poster-meta{font-size:var(--fs-md);color:var(--muted)} +/* 周期做成小胶囊 */ +.poster-period{ + display:inline-block;margin-top:12rpx;padding:4rpx var(--sp-3);border-radius:var(--r-full); + background:var(--primary-soft);color:var(--primary-ink); + font-size:var(--fs-cap);font-weight:var(--fw-b); +} +/* 数据清单:每行 打勾 + 项目 + 数值 右对齐 */ .poster-list{ - background:rgba(255,255,255,.78);border-radius:var(--r-md);padding:var(--sp-4);margin:var(--sp-4) 0;text-align:left;line-height:1.8;font-size:var(--fs-md); + background:rgba(255,255,255,.78);border-radius:var(--r-md); + padding:var(--sp-3) var(--sp-4);margin:var(--sp-5) 0 var(--sp-4); } +.poster-row{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-2) 0} +.poster-tick{ + width:36rpx;height:36rpx;flex:none;border-radius:50%; + background:var(--green-soft);color:var(--green-ink); + display:flex;align-items:center;justify-content:center; +} +.poster-rt{flex:1;text-align:left;font-size:var(--fs-md);color:var(--text-2)} +.poster-rn{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} +/* 健康状态徽章 */ +.poster-status{ + display:flex;align-items:center;justify-content:center;gap:var(--sp-2); + font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text);margin-top:var(--sp-2); +} +.poster-badge{ + padding:4rpx var(--sp-3);border-radius:var(--r-full); + background:var(--green-soft);color:var(--green-ink);font-size:var(--fs-sm); +} +.poster-from{margin-top:var(--sp-4);font-size:var(--fs-cap);color:var(--muted2)} /* 风险盒 */ .risk-box{ @@ -373,6 +415,13 @@ page{scrollbar-width:none;-ms-overflow-style:none} .top-bar .pet-switch{padding-bottom:var(--sp-2)} .top-bar .st-pill{padding-bottom:var(--sp-2)} +/* 切换条压在暖色顶部:chip 改成半透明白 + 深棕字,选中的实白 */ +.top-bar.on-band .pet-chip{ + background:rgba(255,255,255,.42);border-color:transparent;color:var(--band-ink); +} +.top-bar.on-band .pet-chip.active{background:#fff;color:var(--text);border-color:transparent} +.top-bar.on-band .pet-chip-add{color:rgba(122,74,8,.75)} + /* 帖子卡。社区和用户主页都要用,页面级 wxss 不跨页生效,所以放全局 */ .post-card{background:#fff;border-radius:var(--r-lg);box-shadow:var(--sd-1);padding:var(--sp-4);margin-bottom:var(--sp-4)} .post-head{display:flex;align-items:center;gap:var(--sp-3);margin-bottom:var(--sp-3)} @@ -389,9 +438,12 @@ page{scrollbar-width:none;-ms-overflow-style:none} .pu-s{font-size:var(--fs-sm);color:var(--muted)} .post-content{font-size:var(--fs-md);line-height:1.62;color:var(--text-2);margin-bottom:var(--sp-3)} -.photo-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp-1);margin-bottom:var(--sp-3)} +.photo-grid{display:flex;flex-wrap:wrap;gap:var(--sp-1);margin-bottom:var(--sp-3)} .photo-tile{ - aspect-ratio:1;border-radius:var(--r-sm);background:var(--primary-soft); + /* 固定尺寸小缩略图,正方形。不用 aspect-ratio(微信渲染不生效,竖图会被撑长), + 显式 width/height 最稳;mode=aspectFill 裁成正方,点击再走 previewImage 放大 */ + width:210rpx;height:210rpx;flex:none;overflow:hidden; + border-radius:var(--r-sm);background:var(--primary-soft); display:flex;align-items:center;justify-content:center;font-size:56rpx; } diff --git a/pets-fe/components/bottom-sheet/bottom-sheet.js b/pets-fe/components/bottom-sheet/bottom-sheet.js index e059234..9fe6f36 100644 --- a/pets-fe/components/bottom-sheet/bottom-sheet.js +++ b/pets-fe/components/bottom-sheet/bottom-sheet.js @@ -43,8 +43,8 @@ const REMINDER_TYPES = [ const REMINDER_LABELS = REMINDER_TYPES.map((t) => t.label); -const IDENTITY = ['petName', 'anonymous', 'official']; -const PTAG = ['晒宠', '求助', '经验', '避坑']; +// 标签和社区顶部筛选保持一致(晒宠 / 新手求助 / 经验) +const PTAG = ['晒宠', '新手求助', '经验']; Component({ options: { addGlobalClass: true }, @@ -61,6 +61,7 @@ Component({ optSel: {}, // 通用简易记录(洗护/清洁那 15 种共用) postContent: '', + pfFocus: false, // 发帖内容框聚焦标记(点整块 field 聚焦用) postImages: [], remForm: { id: '', typeIdx: 0, title: '', date: '', freq: '' }, remTypeLabels: REMINDER_LABELS, @@ -208,16 +209,18 @@ Component({ const pet = this.data.pet || {}; const p = this.data.poster || {}; + // 优先用后端海报数据里的头像(和身份卡同一路径,可靠),store 兜底 + const avatarUrl = p.pet_avatar_url || pet.avatar_url || ''; // 头像是远程图,异步加载。加载完(或没有头像)再往下画, // 否则头像会画不上。加载失败当没有头像处理 const loadAvatar = () => new Promise((res) => { - if (!pet.avatar_url) return res(null); + if (!avatarUrl) return res(null); const img = node.createImage(); img.onload = () => res(img); img.onerror = () => res(null); - img.src = pet.avatar_url; + img.src = avatarUrl; }); loadAvatar().then((avatar) => { @@ -242,7 +245,13 @@ Component({ ctx.arc(W / 2, avY + avD / 2, avD / 2, 0, Math.PI * 2); ctx.closePath(); ctx.clip(); - ctx.drawImage(avatar, avX, avY, avD, avD); + // 居中裁剪成正方形再画,竖图/横图都不压扁 + const iw = avatar.width || avD; + const ih = avatar.height || avD; + const side = Math.min(iw, ih); + const sx = (iw - side) / 2; + const sy = (ih - side) / 2; + ctx.drawImage(avatar, sx, sy, side, side, avX, avY, avD, avD); ctx.restore(); } else { ctx.font = '96px sans-serif'; @@ -252,10 +261,17 @@ Component({ ctx.textAlign = 'center'; ctx.fillStyle = '#8D8277'; ctx.font = '24px sans-serif'; - ctx.fillText([pet.age, pet.weight, pet.stage].filter(Boolean).join(' | '), W / 2, 268); + ctx.fillText([pet.age, pet.weight, pet.stage].filter(Boolean).join(' | '), W / 2, 262); + + // 周报告:显示统计周期,让人知道这四个数是近 7 天的 + if (p.period) { + ctx.fillStyle = '#C7A36A'; + ctx.font = '22px sans-serif'; + ctx.fillText('近 7 天 · ' + p.period, W / 2, 298); + } ctx.fillStyle = 'rgba(255,255,255,.8)'; - this.roundRect(ctx, 60, 310, W - 120, 232, 24); + this.roundRect(ctx, 60, 316, W - 120, 232, 24); ctx.fill(); const lines = [ '完成任务 ' + (p.tasks_completed || 0) + ' 项', @@ -345,6 +361,9 @@ Component({ this.setData({ optSel }); }, onPostInput(e) { this.setData({ postContent: e.detail.value }); }, + // 点内容 field 任意处都聚焦(原生 textarea 在 scroll-view 里命中区域会偏) + onPostFieldTap() { this.setData({ pfFocus: true }); }, + onPostBlur() { this.setData({ pfFocus: false }); }, loadComments() { if (!this.data.postId) return; api.listComments(this.data.postId) @@ -539,8 +558,8 @@ Component({ api.getTasks(id).then((tasks) => this.setData({ manageTasks: tasks || [] })).catch(() => {}); }, onPickPostImages() { - const left = 9 - this.data.postImages.length; - if (left <= 0) return wx.showToast({ title: '最多 9 张', icon: 'none' }); + const left = 2 - this.data.postImages.length; + if (left <= 0) return wx.showToast({ title: '最多 2 张', icon: 'none' }); upload .chooseAndUploadImages(left) .then((list) => this.setData({ postImages: this.data.postImages.concat(list) })) @@ -604,20 +623,30 @@ Component({ async onCreatePost() { const content = (this.data.postContent || '').trim(); if (!content) return wx.showToast({ title: '写点什么吧', icon: 'none' }); - const identity = IDENTITY[this.segIdx('ident')]; const tag = PTAG[this.data.optSel.ptag === undefined ? 0 : this.data.optSel.ptag]; if (this.data.saving) return; this.setData({ saving: true }); try { - await api.createPost({ - pet_id: identity === 'petName' ? store.currentPetId() : null, - identity, + // 去掉发布身份,统一用发布者昵称(后端按 user 取昵称) + const post = await api.createPost({ + identity: 'user', content, tags: [tag], image_file_ids: this.data.postImages.map((i) => i.id), }); this.triggerEvent('posted'); this.close(); + // 内容安全审核:没直接过审的,明确告诉用户在审核,别以为发失败/是 bug + if (post && post.status && post.status !== 'published') { + wx.showModal({ + title: '发布成功,审核中', + content: '内容需要通过安全审核后才会出现在社区。可在「我的 → 我的帖子」查看审核状态。', + showCancel: false, + confirmText: '知道了', + }); + } else { + wx.showToast({ title: '已发布', icon: 'success' }); + } } catch (e) { wx.showToast({ title: e.message || '发布失败', icon: 'none' }); this.setData({ saving: false }); diff --git a/pets-fe/components/bottom-sheet/bottom-sheet.wxml b/pets-fe/components/bottom-sheet/bottom-sheet.wxml index 0d7c241..96e5314 100644 --- a/pets-fe/components/bottom-sheet/bottom-sheet.wxml +++ b/pets-fe/components/bottom-sheet/bottom-sheet.wxml @@ -155,14 +155,20 @@ module.exports.sel = function (map, key, index, def) { {{pet.name}} 的成长报告 - {{pet.emoji}} - {{pet.age}}|{{pet.weight}}|{{pet.stage}} - ✓ 完成任务 {{poster ? poster.tasks_completed : 0}} 项 -✓ 体重记录 {{poster ? poster.weight_records : 0}} 次 -✓ 疫苗记录 {{poster ? poster.vaccine_records : 0}} 次 -✓ 高风险异常 {{poster ? poster.high_risk_count : 0}} 次 - 健康状态:{{poster ? poster.headline : '稳定成长'}} - 生成自:肉垫计划 + + + {{pet.emoji}} + + {{pet.age}}|{{pet.weight}}|{{pet.stage}} + 近 7 天 · {{poster.period}} + + 完成任务{{poster ? poster.tasks_completed : 0}} 项 + 体重记录{{poster ? poster.weight_records : 0}} 次 + 疫苗记录{{poster ? poster.vaccine_records : 0}} 次 + 高风险异常{{poster ? poster.high_risk_count : 0}} 次 + + 健康状态{{poster ? poster.headline : '稳定成长'}} + 生成自 · 肉垫计划 @@ -201,27 +207,22 @@ module.exports.sel = function (map, key, index, def) { → 其他资料),品种从后端 breeds 表来、按首字母索引。弹层塞不下这些 --> 发布图文 - 选择发布身份,分享你的养宠日常。 - - {{pet.name}} - 匿名宠友 - 官方笔记 - - - + 用你的昵称分享养宠日常。 + + + 晒宠 - 求助 + 新手求助 经验 - 避坑 - + - + diff --git a/pets-fe/components/nav-bar/nav-bar.wxss b/pets-fe/components/nav-bar/nav-bar.wxss index b6fc5ec..ca96ab0 100644 --- a/pets-fe/components/nav-bar/nav-bar.wxss +++ b/pets-fe/components/nav-bar/nav-bar.wxss @@ -1,24 +1,17 @@ :host{display:block} -/* 原来是一整条半透明色块,底边和页面渐变硬碰硬,屏幕上横着一道接缝。 - 改成上实下虚的渐变,并且用同一条 mask 把毛玻璃也一起淡出—— - 只淡化背景色而不淡化 blur 的话,接缝会从「色差」变成「糊边」,一样难看。 */ +/* 整页暖橘渐变后,顶部本身就是暖色,nav 不再铺奶白/毛玻璃,透明即可, + 文字用深棕压在暖底上。内容在 .page-scroll 里滚,不会跑到 nav 背后, + 所以不需要毛玻璃遮挡。 */ .nav-wrap{ position:fixed;top:0;left:0;right:0;z-index:100; - background:linear-gradient(180deg, - rgba(250,247,242,.94) 0%, - rgba(250,247,242,.88) 58%, - rgba(250,247,242,0) 100%); - backdrop-filter:blur(20rpx); - -webkit-backdrop-filter:blur(20rpx); - -webkit-mask-image:linear-gradient(180deg,#000 0%,#000 62%,transparent 100%); - mask-image:linear-gradient(180deg,#000 0%,#000 62%,transparent 100%); + background:none; } .nav-inner{ position:relative;display:flex;align-items:center;justify-content:center; } .nav-title{ - font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text); + font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--band-ink); letter-spacing:.5rpx; max-width:56%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; } @@ -27,7 +20,7 @@ position:absolute;left:var(--sp-3);top:50%;transform:translateY(-50%); width:60rpx;height:60rpx;border-radius:50%; display:flex;align-items:center;justify-content:center; - color:var(--text);background:rgba(255,255,255,.72); + color:var(--band-ink);background:rgba(255,255,255,.6); box-shadow:0 4rpx 12rpx rgba(70,48,25,.06); } -.nav-back:active{background:rgba(255,255,255,.95)} +.nav-back:active{background:rgba(255,255,255,.92)} diff --git a/pets-fe/components/profile-head/index.js b/pets-fe/components/profile-head/index.js index e02950b..9b32445 100644 --- a/pets-fe/components/profile-head/index.js +++ b/pets-fe/components/profile-head/index.js @@ -7,6 +7,7 @@ Component({ card: { type: Object, value: null }, // 装扮页的实时预览:只看效果,不要底部那排按钮 preview: { type: Boolean, value: false }, + hideWall: { type: Boolean, value: false }, // 「我的」页藏掉社交宠物墙,避免和「我的毛孩子」卡重复 }, data: { initial: '', theme: 'warm' }, observers: { diff --git a/pets-fe/components/profile-head/index.wxml b/pets-fe/components/profile-head/index.wxml index 4507357..8028bc3 100644 --- a/pets-fe/components/profile-head/index.wxml +++ b/pets-fe/components/profile-head/index.wxml @@ -1,36 +1,36 @@ - - - - + + - - - {{initial}} + + + + + {{initial}} + + + {{card.nickname || '毛孩子家长'}}AI + {{card.bio || '这个人很懒,还没写签名'}} + - {{card.nickname || '毛孩子家长'}}AI - {{card.bio || '这个人很懒,还没写签名'}} - + + {{card.post_count || 0}}帖子 - - {{card.following_count || 0}}关注 - - - {{card.follower_count || 0}}粉丝 - + {{card.following_count || 0}}关注 + {{card.follower_count || 0}}粉丝 - + {{card.stats.days || 0}}养宠天数 {{card.stats.records || 0}}累计记录 {{card.stats.streak || 0}}连续打卡 - - + @@ -42,13 +42,9 @@ - - - 装扮我的主页 - - - {{card.followed ? '已关注' : '关注 TA'}} - - + + + {{card.followed ? '已关注' : '关注 TA'}} + diff --git a/pets-fe/components/profile-head/index.wxss b/pets-fe/components/profile-head/index.wxss index 8c268d6..33e24ff 100644 --- a/pets-fe/components/profile-head/index.wxss +++ b/pets-fe/components/profile-head/index.wxss @@ -11,41 +11,43 @@ background:#fff;border-radius:var(--r-lg);overflow:hidden; box-shadow:var(--sd-1);margin-bottom:var(--sp-5); } -.pf-bg{height:180rpx;background:linear-gradient(135deg,var(--pf-a),var(--pf-b))} -.pf-bg-img{width:100%;height:100%;display:block} +/* 头图:设了才显示 */ +.pf-bg-img{width:100%;height:200rpx;display:block} -.pf-body{padding:0 var(--sp-5) var(--sp-5);text-align:center} -/* 头像压在头图上,负 margin 提上去一半 */ +.pf-body{padding:var(--sp-5)} + +/* 身份行:头像 + 昵称/签名 左对齐 */ +.pf-top{display:flex;align-items:center;gap:var(--sp-4)} .pf-av{ - /* 必须定位:头像要压在头图上面,靠 DOM 顺序不够——只要头图是定位元素就会反盖过来 */ - position:relative;z-index:1; - width:144rpx;height:144rpx;margin:-72rpx auto var(--sp-3); - border-radius:50%;background:var(--pf-b);color:var(--pf-ink); + flex:none;width:120rpx;height:120rpx;border-radius:50%; + background:var(--pf-b);color:var(--pf-ink);overflow:hidden; display:flex;align-items:center;justify-content:center; font-size:var(--fs-xl);font-weight:var(--fw-b); - border:6rpx solid #fff;overflow:hidden;box-shadow:var(--sd-1); } .pf-av-img{width:100%;height:100%;display:block} -.pf-name{font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text)} +.pf-id{flex:1;min-width:0} +.pf-name{font-size:var(--fs-xl);font-weight:var(--fw-b);color:var(--text)} .pf-bio{ - font-size:var(--fs-sm);color:var(--muted);margin-top:6rpx; + font-size:var(--fs-sm);color:var(--muted);margin-top:8rpx; line-height:1.5;word-break:break-all; } -.pf-stats{display:flex;margin:var(--sp-4) 0} -.pf-stat{flex:1;display:flex;flex-direction:column;align-items:center;gap:4rpx} -.pf-n{font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text)} +/* 社交数:小一行,数字+标签内联,左对齐 */ +.pf-social{display:flex;gap:var(--sp-6);margin-top:var(--sp-4)} +.pf-stat{display:flex;align-items:baseline;gap:6rpx} +.pf-n{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} .pf-l{font-size:var(--fs-cap);color:var(--muted)} +/* 养宠数据高亮带 */ .pf-keep{ display:flex;background:var(--pf-b);border-radius:var(--r-md); - padding:var(--sp-3) 0;margin-bottom:var(--sp-4); + padding:var(--sp-4) 0;margin-top:var(--sp-4); } .pf-keep-i{flex:1;display:flex;flex-direction:column;align-items:center;gap:2rpx} -.pf-kn{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--pf-ink)} +.pf-kn{font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--pf-ink)} .pf-kl{font-size:var(--fs-cap);color:var(--pf-ink);opacity:.75} -.pf-wall{white-space:nowrap;margin-bottom:var(--sp-4)} +.pf-wall{white-space:nowrap;margin-top:var(--sp-4)} .pf-pet{ display:inline-block;width:150rpx;vertical-align:top; margin-right:var(--sp-3);white-space:normal; @@ -59,3 +61,5 @@ .pf-pet-img{width:100%;height:100%;display:block} .pf-pet-n{font-size:var(--fs-cap);font-weight:var(--fw-b);color:var(--text);text-align:center} .pf-pet-s{font-size:var(--fs-cap);color:var(--muted);text-align:center} + +.pf-follow{margin-top:var(--sp-4)} diff --git a/pets-fe/pages/addrecord/addrecord.js b/pets-fe/pages/addrecord/addrecord.js index 5b6843e..bd7d4f6 100644 --- a/pets-fe/pages/addrecord/addrecord.js +++ b/pets-fe/pages/addrecord/addrecord.js @@ -41,6 +41,8 @@ Page({ }, onLoad(q) { const code = (q && q.type) || ''; + // 从首页「今日任务」勾选进来会带 taskId:这一笔记录保存后要把那条任务标完成 + this.taskId = (q && q.taskId) || ''; const d = today(); this.setData({ code, date: d, dateLabel: fmtDate(d) }); store @@ -151,9 +153,13 @@ Page({ this.setData({ saving: true }); api .createRecord(id, this.buildBody()) + .then(() => { + // 从任务勾选进来的:记录存好后把那条任务标完成(失败不影响记录已保存) + if (this.taskId) return api.toggleTask(this.taskId).catch(() => {}); + }) .then(() => { this.setData({ saving: false }); - wx.showToast({ title: '已记录', icon: 'success' }); + wx.showToast({ title: this.taskId ? '已完成' : '已记录', icon: 'success' }); setTimeout(() => wx.navigateBack(), 600); }) .catch((e) => { diff --git a/pets-fe/pages/community/community.js b/pets-fe/pages/community/community.js index 001371b..c487a37 100644 --- a/pets-fe/pages/community/community.js +++ b/pets-fe/pages/community/community.js @@ -132,6 +132,13 @@ Page({ onCreatePost() { this.setData({ sheetType: 'createPost', sheetShow: true }); }, + // 点缩略图放大预览。只取 http 图(机器人帖的 emoji 占位不参与) + onPreviewImage(e) { + const { urls, cur } = e.currentTarget.dataset; + const list = (urls || []).filter((u) => typeof u === 'string' && u.indexOf('http') === 0); + if (!list.length) return; + wx.previewImage({ current: cur, urls: list }); + }, // 长按自己的帖子可删除 onLongPressPost(e) { const p = this.data.posts[e.currentTarget.dataset.index]; diff --git a/pets-fe/pages/community/community.wxml b/pets-fe/pages/community/community.wxml index e68084c..aaf8166 100644 --- a/pets-fe/pages/community/community.wxml +++ b/pets-fe/pages/community/community.wxml @@ -25,7 +25,8 @@ module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; } {{item.content}} - + {{img}} diff --git a/pets-fe/pages/history/history.wxml b/pets-fe/pages/history/history.wxml index d0db908..473f1bf 100644 --- a/pets-fe/pages/history/history.wxml +++ b/pets-fe/pages/history/history.wxml @@ -1,6 +1,6 @@ - + diff --git a/pets-fe/pages/home/home.js b/pets-fe/pages/home/home.js index ee6953c..c7491b6 100644 --- a/pets-fe/pages/home/home.js +++ b/pets-fe/pages/home/home.js @@ -23,7 +23,8 @@ function fmtWhen(iso) { return d.getMonth() + 1 + '月' + d.getDate() + '日'; } -const TL_PAGE = 10; +// 首页最近记录只是速览,固定最新几条,翻全量去「全部记录」页,避免首页无限滚 +const TL_LIMIT = 6; Page({ data: { @@ -38,7 +39,6 @@ Page({ needNextPlan: false, monthCost: 0, timeline: [], - tlPage: 1, hasMore: false, }, onLoad() { @@ -127,11 +127,11 @@ Page({ .then((b) => this.setData({ monthCost: (b && b.total) || 0 })) .catch(() => this.setData({ monthCost: 0 })); }, - loadTimeline(page) { + loadTimeline() { const id = store.currentPetId(); if (!id) return; api - .getRecords(id, { page, pageSize: TL_PAGE }) + .getRecords(id, { page: 1, pageSize: TL_LIMIT }) .then((res) => { const list = (res.list || []).map((r) => ({ id: r.id, @@ -142,13 +142,13 @@ Page({ image: r.image_url || '', timeText: fmtWhen(r.occurred_at), })); - const merged = page === 1 ? list : this.data.timeline.concat(list); - this.setData({ timeline: merged, tlPage: page, hasMore: merged.length < (res.total || 0) }); + // 有更多就露出「查看全部」入口,跳到能分页筛选的全部记录页 + this.setData({ timeline: list, hasMore: (res.total || 0) > list.length }); }) .catch(() => {}); }, - loadMore() { - if (this.data.hasMore) this.loadTimeline(this.data.tlPage + 1); + goHistory() { + wx.navigateTo({ url: '/pages/history/history' }); }, previewImage(e) { const url = e.currentTarget.dataset.url; @@ -172,28 +172,26 @@ Page({ onTapTask(e) { const i = e.currentTarget.dataset.index; const t = this.data.tasks[i]; - // 任务关联了记录类型就直接去记那一笔 + // 任务关联了记录类型:去记那一笔。未完成的带上 taskId, + // 在记录页保存成功后才回填这条任务完成(onShow 回来自动刷新任务+最近记录) if (t.sheet) { - wx.navigateTo({ url: '/pages/addrecord/addrecord?type=' + t.sheet }); + let url = '/pages/addrecord/addrecord?type=' + t.sheet; + if (!t.done) url += '&taskId=' + t.id; + wx.navigateTo({ url }); return; } + // 没有关联记录类型的任务(自定义/无 sheet_type):直接切换完成。 + // 后端 toggle 会在完成时补一条 note 记录,这里完成后刷新最近记录即可 api .toggleTask(t.id) .then((res) => { const tasks = this.data.tasks.slice(); tasks[i] = Object.assign({}, tasks[i], { done: res.done }); this.setData({ tasks }); + if (res.done) this.loadTimeline(1); }) .catch((err) => toastErr(err, '操作失败')); }, - completeTasks() { - const id = store.currentPetId(); - if (!id) return; - api - .completeAllTasks(id) - .then((tasks) => this.setData({ tasks: (tasks || []).map(mapTask) })) - .catch((e) => toastErr(e, '操作失败')); - }, goPlan() { wx.navigateTo({ url: '/pages/plan/plan' }); }, diff --git a/pets-fe/pages/home/home.wxml b/pets-fe/pages/home/home.wxml index dc86625..7ac843e 100644 --- a/pets-fe/pages/home/home.wxml +++ b/pets-fe/pages/home/home.wxml @@ -1,9 +1,8 @@ - + - + @@ -71,7 +70,6 @@ 管理 - 全部完成 @@ -111,7 +109,7 @@ 还没有记录。点上面的「记一笔」开始, 体重趋势和健康洞察都会从这些记录里长出来。 - 加载更多 + 查看全部记录 › diff --git a/pets-fe/pages/home/home.wxss b/pets-fe/pages/home/home.wxss index 543326f..b0b0914 100644 --- a/pets-fe/pages/home/home.wxss +++ b/pets-fe/pages/home/home.wxss @@ -44,11 +44,11 @@ .hd-n{font-size:var(--fs-lg);margin:0 4rpx} /* ===== 两个入口 ===== */ -.entry-card{display:flex;padding:var(--sp-5) var(--sp-3)} -.entry{flex:1;display:flex;flex-direction:column;align-items:center;gap:6rpx} +.entry-card{display:flex;padding:var(--sp-4) var(--sp-3)} +.entry{flex:1;display:flex;flex-direction:column;align-items:center;gap:4rpx} .entry-ic{ - width:96rpx;height:96rpx;border-radius:var(--r-md); - display:flex;align-items:center;justify-content:center;margin-bottom:4rpx; + width:76rpx;height:76rpx;border-radius:var(--r-md); + display:flex;align-items:center;justify-content:center;margin-bottom:2rpx; } .entry-b{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} .entry-p{font-size:var(--fs-cap);color:var(--muted)} diff --git a/pets-fe/pages/idcard/idcard.js b/pets-fe/pages/idcard/idcard.js index b0ebe3f..d3bae6c 100644 --- a/pets-fe/pages/idcard/idcard.js +++ b/pets-fe/pages/idcard/idcard.js @@ -4,7 +4,7 @@ const { toastErr } = require('../../utils/ui.js'); // 卡片画布尺寸(逻辑像素)。真实导出会按 dpr 放大 const W = 620; -const H = 760; +const H = 1480; // 在 canvas 2d 上加载一张图(远程或本地),resolve 出可直接 drawImage 的 image 对象。 // 加载失败 resolve(null)——头像或小程序码缺一张不该让整张卡画不出来 @@ -25,6 +25,10 @@ Page({ cardImg: '', qrReady: false, saving: false, + needPhone: false, // 没手机号:门槛,先验证才生成 + phoneRefused: false, // 用户拒绝了授权 + manualMode: false, // 手动填手机号(getPhoneNumber 唤不起时的兜底) + manualPhone: '', }, onLoad(q) { store @@ -41,6 +45,11 @@ Page({ .then((card) => { if (!card) return; this.setData({ card, qrReady: !!card.qr_ready }); + // 门槛:没手机号不生成,先引导用微信验证 + if (!card.phone) { + this.setData({ needPhone: true }); + return; + } // 布局要等页面渲染完 canvas 节点才拿得到,放 nextTick wx.nextTick(() => this.draw()); }) @@ -60,6 +69,7 @@ Page({ } catch (e) { dpr = 2; } + this._dpr = dpr; node.width = W * dpr; node.height = H * dpr; const ctx = node.getContext('2d'); @@ -83,131 +93,173 @@ Page({ const pad = 30; const cardW = W - pad * 2; - // ── 上半张:资料区(蓝紫渐变)── - const topH = 380; - const g1 = ctx.createLinearGradient(pad, pad, W - pad, topH); - g1.addColorStop(0, '#FFF0D6'); - g1.addColorStop(0.5, '#FFE0EC'); - g1.addColorStop(1, '#E6EEFF'); - this.roundRect(ctx, pad, pad, cardW, topH - pad, 28); - ctx.fillStyle = g1; + const fx = pad, cardW2 = cardW; + + // ══════════ 正面卡(暖黄)══════════ + // 正反两张卡等高(都用 cardH),中间内容垂直居中,避免正面留空 + const cardH = 510; + const fH = cardH; + const fY = pad; + const gf = ctx.createLinearGradient(fx, fY, fx, fY + fH); + gf.addColorStop(0, '#FFF7E0'); + gf.addColorStop(1, '#FFE7AE'); + this.roundRect(ctx, fx, fY, cardW2, fH, 30); + ctx.fillStyle = gf; ctx.fill(); - // 头像圆 - const avX = pad + 34, avY = pad + 34, avD = 108; + // 头像圆 + 白边 + const avD = 150, avX = fx + 36, avY = fY + 52; ctx.save(); ctx.beginPath(); ctx.arc(avX + avD / 2, avY + avD / 2, avD / 2, 0, Math.PI * 2); - ctx.closePath(); ctx.clip(); if (avatar) { - ctx.drawImage(avatar, avX, avY, avD, avD); + const iw = avatar.width || avD, ih = avatar.height || avD, side = Math.min(iw, ih); + ctx.drawImage(avatar, (iw - side) / 2, (ih - side) / 2, side, side, avX, avY, avD, avD); } else { ctx.fillStyle = '#FFD9A3'; ctx.fillRect(avX, avY, avD, avD); ctx.fillStyle = '#8A5A18'; - ctx.font = '52px sans-serif'; + ctx.font = '64px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(c.species === '狗狗' ? '🐶' : '🐱', avX + avD / 2, avY + avD / 2); } ctx.restore(); ctx.textBaseline = 'alphabetic'; + ctx.beginPath(); + ctx.arc(avX + avD / 2, avY + avD / 2, avD / 2, 0, Math.PI * 2); + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 8; + ctx.stroke(); - // 资料行 - const infoX = avX + avD + 28; - let y = pad + 52; - const line = (label, value) => { + // 名字 + 性别(头像右侧)。性别符号必须用「名字的字号」量宽度再定位, + // 否则换成小字号后 measureText 量出的宽度偏小,符号会怼到名字上 + const nx = avX + avD + 28; + const nm = c.name || '毛孩子'; + ctx.textAlign = 'left'; + ctx.fillStyle = '#2D2925'; + ctx.font = '700 52px sans-serif'; + ctx.fillText(nm, nx, avY + 46); + const nmW = ctx.measureText(nm).width; + if (c.gender) { + const isF = c.gender === '女孩'; + ctx.fillStyle = isF ? '#E86A9B' : '#5B9BE8'; + ctx.font = '600 34px sans-serif'; + ctx.fillText(isF ? '♀' : '♂', nx + nmW + 16, avY + 44); + } + // 头像右的资料行 + const kvF = (label, value, yy) => { ctx.textAlign = 'left'; ctx.font = '24px sans-serif'; - ctx.fillStyle = '#9A8F82'; - ctx.fillText(label, infoX, y); - ctx.fillStyle = '#2D2925'; + ctx.fillStyle = '#A98C5A'; + ctx.fillText(label, nx, yy); + ctx.fillStyle = '#3D3427'; ctx.font = '600 26px sans-serif'; - ctx.fillText(value || '—', infoX + ctx.measureText(label).width + 14, y); - y += 46; + ctx.fillText(value || '—', nx + 78, yy); }; - line('姓名', c.name); - // 性别 + 品种放一行 - ctx.textAlign = 'left'; - ctx.font = '24px sans-serif'; - ctx.fillStyle = '#9A8F82'; - ctx.fillText('性别', infoX, y); - ctx.fillStyle = '#2D2925'; - ctx.font = '600 26px sans-serif'; - const gw = ctx.measureText(c.gender || '—').width; - ctx.fillText(c.gender || '—', infoX + 62, y); - if (c.breed) { - ctx.fillStyle = '#9A8F82'; - ctx.font = '24px sans-serif'; - ctx.fillText('品种', infoX + 62 + gw + 24, y); - ctx.fillStyle = '#2D2925'; - ctx.font = '600 26px sans-serif'; - ctx.fillText(c.breed, infoX + 62 + gw + 24 + 62, y); + kvF('性别', c.gender, avY + 92); + kvF('品种', c.breed, avY + 130); + kvF('毛色', c.color, avY + 168); + kvF('年龄', c.age, avY + 206); + + // 介绍 quote(在 info 和底栏之间垂直居中) + if (c.intro) { + const ty = fY + 300 + Math.max(0, Math.floor(((fY + fH - 92) - (fY + 300) - 60) / 2)); + ctx.fillStyle = 'rgba(255,255,255,.6)'; + this.roundRect(ctx, fx + 34, ty, cardW2 - 68, 60, 16); + ctx.fill(); + ctx.fillStyle = '#7A5A2E'; + ctx.font = '25px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('“ ' + c.intro + ' ”', fx + 54, ty + 40); } - y += 46; - line('出生', c.birthday); - line('介绍', c.intro); - // 身份 ID:虚线 + 号 - const idY = pad + 300; - ctx.strokeStyle = 'rgba(122,74,8,.22)'; - ctx.lineWidth = 1; - ctx.setLineDash([6, 6]); - ctx.beginPath(); - ctx.moveTo(pad + 34, idY); - ctx.lineTo(W - pad - 34, idY); - ctx.stroke(); - ctx.setLineDash([]); + // 底部:身份ID + 一起生活天数(贴卡片底边) ctx.textAlign = 'left'; + ctx.fillStyle = '#9C7A3E'; ctx.font = '22px sans-serif'; - ctx.fillStyle = '#9A8F82'; - ctx.fillText('身份 ID', pad + 34, idY + 34); - ctx.font = '600 26px sans-serif'; - ctx.fillStyle = '#2D2925'; - ctx.fillText(c.id_no || '', pad + 34 + 90, idY + 34); + ctx.fillText('ID ' + (c.id_no || ''), fx + 34, fY + fH - 56); + if (c.days > 0) { + ctx.fillText('和主人一起生活的第 ' + c.days + ' 天', fx + 34, fY + fH - 22); + } - // ── 下半张:证件标题 + 小程序码 ── - const botY = topH + 12; - const botH = H - botY - pad; - const g2 = ctx.createLinearGradient(pad, botY, W - pad, botY + botH); - g2.addColorStop(0, '#E8F0FF'); - g2.addColorStop(1, '#F0E8FF'); - this.roundRect(ctx, pad, botY, cardW, botH, 28); - ctx.fillStyle = g2; + // 正面印章(右上角) + this.drawStamp(ctx, fx + cardW2 - 78, fY + 82); + + // ══════════ 照护卡(淡紫,仿证件版式)══════════ + const bY = fY + fH + 40, bH = cardH; + const gbc = ctx.createLinearGradient(fx, bY, fx, bY + bH); + gbc.addColorStop(0, '#E6E7FB'); + gbc.addColorStop(1, '#F2F0FA'); + this.roundRect(ctx, fx, bY, cardW2, bH, 30); + ctx.fillStyle = gbc; ctx.fill(); + // 大标题(两行左对齐):小字「X星人地球移民」+ 大字「居民身份卡」 + // 汪星人=狗、喵星人=猫,其它兜底宠星人 + const star = c.species === '狗狗' ? '汪' : c.species === '猫猫' ? '喵' : '宠'; ctx.textAlign = 'left'; - ctx.fillStyle = '#7D89A8'; - ctx.font = '22px sans-serif'; - ctx.fillText('宠 星 人 地 球 移 民', pad + 40, botY + 60); - ctx.fillStyle = '#4A5878'; - ctx.font = '700 52px sans-serif'; - ctx.fillText('居民身份卡', pad + 40, botY + 128); + ctx.fillStyle = '#8A90AE'; + ctx.font = '600 27px sans-serif'; + this.spacedText(ctx, star + '星人地球移民', fx + 44, bY + 64, 8); + ctx.fillStyle = '#3E4A63'; + ctx.font = '800 58px sans-serif'; + ctx.fillText('居民身份卡', fx + 44, bY + 140); - ctx.fillStyle = '#8A93A8'; - ctx.font = '22px sans-serif'; - ctx.fillText('签发机构 肉垫计划', pad + 40, botY + 180); - ctx.fillText('有效期限 ' + (c.validity || '永久有效'), pad + 40, botY + 214); + // 资料行(标签 + 值) + const rowX = fx + 44; + const kvB = (label, value, yy) => { + ctx.textAlign = 'left'; + ctx.font = '25px sans-serif'; + ctx.fillStyle = '#9A9FB5'; + ctx.fillText(label, rowX, yy); + ctx.font = '600 26px sans-serif'; + ctx.fillStyle = '#3E4A63'; + ctx.fillText(value || '—', rowX + 160, yy); + }; + let ry = bY + 198; + kvB('主人', c.owner, ry); ry += 46; + kvB('联系电话', c.phone || '未验证', ry); ry += 46; + kvB('签发机构', c.org || '肉垫计划', ry); ry += 46; + kvB('有效期限', c.validity || '永久有效', ry); - // 小程序码(未发布时 qr 为 null,画个占位框) - const qrD = 128, qrX = W - pad - 40 - qrD, qrY = botY + botH - 40 - qrD; + // 小程序码(右下角,直接落在淡紫底上) + const qrD = 114, qrX = fx + cardW2 - 44 - qrD, qrY = bY + bH - 30 - qrD; ctx.fillStyle = '#fff'; - this.roundRect(ctx, qrX, qrY, qrD, qrD, 12); + this.roundRect(ctx, qrX, qrY, qrD, qrD, 14); ctx.fill(); if (qr) { - ctx.drawImage(qr, qrX + 8, qrY + 8, qrD - 16, qrD - 16); + ctx.drawImage(qr, qrX + 9, qrY + 9, qrD - 18, qrD - 18); } else { - ctx.fillStyle = '#B5A99D'; - ctx.font = '20px sans-serif'; + ctx.fillStyle = '#A7ABC4'; + ctx.font = '18px sans-serif'; ctx.textAlign = 'center'; - ctx.fillText('小程序码', qrX + qrD / 2, qrY + qrD / 2 - 4); - ctx.fillText('待发布', qrX + qrD / 2, qrY + qrD / 2 + 26); + ctx.fillText('小程序码', qrX + qrD / 2, qrY + qrD / 2 - 2); + ctx.fillText('待发布', qrX + qrD / 2, qrY + qrD / 2 + 24); } + // 扫码说明(左侧,和小程序码同高) + ctx.textAlign = 'left'; + ctx.fillStyle = '#8A90AE'; + ctx.font = '22px sans-serif'; + ctx.fillText('微信扫一扫', fx + 44, qrY + qrD / 2 - 6); + ctx.fillText('查看' + (c.name || '') + '的身份信息', fx + 44, qrY + qrD / 2 + 28); + // 背面印章(右上角) + this.drawStamp(ctx, fx + cardW2 - 84, bY + 92); + + // 只导出到实际内容底部,避免卡片下面拖一条空白 + const dpr = this._dpr || 2; + const contentH = bY + bH + pad; wx.canvasToTempFilePath( { canvas: node, + x: 0, + y: 0, + width: W, + height: contentH, + destWidth: W * dpr, + destHeight: contentH * dpr, fileType: 'png', success: (r) => this.setData({ cardImg: r.tempFilePath }), fail: () => wx.showToast({ title: '生成失败,稍后再试', icon: 'none' }), @@ -226,6 +278,110 @@ Page({ ctx.closePath(); }, + // 逐字加字距地画一行(canvas 的 letterSpacing 各端支持不一,手动排最稳) + spacedText(ctx, text, x, y, gap) { + let cx = x; + for (const ch of text) { + ctx.fillText(ch, cx, y); + cx += ctx.measureText(ch).width + gap; + } + }, + + // 五角星路径(调用方负责 fill/stroke) + star(ctx, cx, cy, rO, rI, n) { + ctx.beginPath(); + for (let i = 0; i < n * 2; i++) { + const r = i % 2 === 0 ? rO : rI; + const a = -Math.PI / 2 + (i * Math.PI) / n; + const x = cx + r * Math.cos(a), y = cy + r * Math.sin(a); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.closePath(); + }, + + // 红色公章:双圈 + 顶部五角星 + 「肉垫计划 / 官方认证」,略微旋转、半透明 + drawStamp(ctx, cx, cy) { + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(-0.16); + ctx.globalAlpha = 0.68; + ctx.strokeStyle = '#CE4534'; + ctx.fillStyle = '#CE4534'; + ctx.lineWidth = 4; + ctx.beginPath(); + ctx.arc(0, 0, 52, 0, Math.PI * 2); + ctx.stroke(); + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(0, 0, 43, 0, Math.PI * 2); + ctx.stroke(); + this.star(ctx, 0, -23, 11, 4.6, 5); + ctx.fill(); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = '700 25px sans-serif'; + ctx.fillText('肉垫计划', 0, 10); + ctx.font = '13px sans-serif'; + ctx.fillText('官方认证', 0, 32); + ctx.restore(); + ctx.globalAlpha = 1; + ctx.textAlign = 'left'; + ctx.textBaseline = 'alphabetic'; + }, + + // 微信取手机号:新版 getPhoneNumber 返回动态 code,后端换成真实手机号存库, + // 拿回后重新取卡(此时电话就有了)并重画 + onGetPhone(e) { + const d = e.detail || {}; + if (!d.code) { + // 用户拒绝授权:不给生成,明确提示 + this.setData({ phoneRefused: true }); + return wx.showToast({ title: '没有手机号,无法生成身份卡', icon: 'none' }); + } + wx.showLoading({ title: '验证中…', mask: true }); + api + .bindPhone(d.code) + .then(() => api.petIDCard(this.data.petId)) + .then((card) => { + wx.hideLoading(); + if (!card) return; + this.setData({ card, qrReady: !!card.qr_ready, needPhone: false, phoneRefused: false }); + wx.nextTick(() => this.draw()); + wx.showToast({ title: '手机号已验证', icon: 'success' }); + }) + .catch((err) => { + wx.hideLoading(); + toastErr(err, '验证失败'); + }); + }, + + // 手动填手机号兜底(getPhoneNumber 需企业认证,唤不起时用这个) + showManual() { + this.setData({ manualMode: true }); + }, + onManualPhone(e) { + this.setData({ manualPhone: e.detail.value }); + }, + submitManual() { + const p = (this.data.manualPhone || '').trim(); + if (!/^1\d{10}$/.test(p)) return wx.showToast({ title: '手机号格式不对', icon: 'none' }); + wx.showLoading({ title: '保存中…', mask: true }); + api + .updateProfile({ phone: p }) + .then(() => api.petIDCard(this.data.petId)) + .then((card) => { + wx.hideLoading(); + if (!card) return; + this.setData({ card, qrReady: !!card.qr_ready, needPhone: false, manualMode: false }); + wx.nextTick(() => this.draw()); + }) + .catch((err) => { + wx.hideLoading(); + toastErr(err, '保存失败'); + }); + }, + onSave() { if (!this.data.cardImg) return wx.showToast({ title: '还在生成,稍等一下', icon: 'none' }); if (this.data.saving) return; diff --git a/pets-fe/pages/idcard/idcard.wxml b/pets-fe/pages/idcard/idcard.wxml index 8b85f45..972a709 100644 --- a/pets-fe/pages/idcard/idcard.wxml +++ b/pets-fe/pages/idcard/idcard.wxml @@ -2,24 +2,40 @@ + + + + 生成身份卡需要手机号 + 身份卡背面的「联系电话」用于走失时联系你,验证后才能生成 + + + + 没有手机号,无法生成身份卡 + 唤不起来?手动填写手机号 + + + + + + + + - - - 正在生成身份卡… - - - - 小程序码要在小程序发布后才会出现,现在先放一个占位。 - - - - - 下载到相册 + + + + 正在生成身份卡… - - + + + + 下载到相册 + + + + diff --git a/pets-fe/pages/idcard/idcard.wxss b/pets-fe/pages/idcard/idcard.wxss index be0963d..6cd7fe7 100644 --- a/pets-fe/pages/idcard/idcard.wxss +++ b/pets-fe/pages/idcard/idcard.wxss @@ -18,4 +18,21 @@ button.ic-btn{padding:0;line-height:normal} button.ic-btn::after{border:none} /* 离屏 canvas:挪出可视区,只用来生成图 */ -.ic-canvas{position:fixed;left:-9999rpx;top:0;width:620px;height:760px} +.ic-canvas{position:fixed;left:-9999rpx;top:0;width:620px;height:1480px} + +/* 手机号门槛卡 */ +.ic-gate{ + background:#fff;border-radius:var(--r-lg);box-shadow:var(--sd-1); + padding:var(--sp-6) var(--sp-5);margin-top:var(--sp-5);text-align:center; +} +.ic-gate-ic{ + width:140rpx;height:140rpx;margin:0 auto var(--sp-4);border-radius:50%; + background:var(--primary-soft);color:var(--primary); + display:flex;align-items:center;justify-content:center; +} +.ic-gate-t{font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text)} +.ic-gate-p{font-size:var(--fs-sm);color:var(--muted);line-height:1.6;margin:var(--sp-2) 0 var(--sp-5)} +.ic-gate-refuse{color:var(--red-ink);font-weight:var(--fw-b);font-size:var(--fs-md)} +.ic-gate-btn{margin-top:var(--sp-2)} +.ic-gate-manual{margin-top:var(--sp-4);color:var(--muted);font-size:var(--fs-sm);text-decoration:underline} +.ic-gate-input{margin-bottom:var(--sp-3);text-align:center} diff --git a/pets-fe/pages/mine/mine.js b/pets-fe/pages/mine/mine.js index 1bab4e1..9b41413 100644 --- a/pets-fe/pages/mine/mine.js +++ b/pets-fe/pages/mine/mine.js @@ -39,6 +39,12 @@ Page({ const uid = this.data.user && this.data.user.id; if (uid) wx.navigateTo({ url: `/pages/user/user?id=${uid}` }); }, + // 我的帖子:进自己的主页,能看到待审核/未通过的帖子及状态 + goMyPosts() { + const uid = this.data.user && this.data.user.id; + if (!uid) return wx.showToast({ title: '请先登录', icon: 'none' }); + wx.navigateTo({ url: '/pages/user/user?id=' + uid }); + }, goSettings() { wx.navigateTo({ url: '/pages/settings/settings' }); }, diff --git a/pets-fe/pages/mine/mine.wxml b/pets-fe/pages/mine/mine.wxml index ea1d2ad..29857bb 100644 --- a/pets-fe/pages/mine/mine.wxml +++ b/pets-fe/pages/mine/mine.wxml @@ -3,11 +3,17 @@ - - - 看看别人眼里的我 + + + + 装扮主页 + + + 别人眼里的我 + @@ -29,8 +35,13 @@ 还没有毛孩子,先建一份档案 - + + + + 我的帖子看审核状态 + + 设置 diff --git a/pets-fe/pages/mine/mine.wxss b/pets-fe/pages/mine/mine.wxss index f6c2de0..51f4c4f 100644 --- a/pets-fe/pages/mine/mine.wxss +++ b/pets-fe/pages/mine/mine.wxss @@ -8,5 +8,7 @@ } .pet-info{flex:1;min-width:0} -.me-preview{margin-bottom:var(--sp-5)} +/* 装扮 + 预览:一行两列,文字缩小、图标也小一号 */ +.me-actions{display:flex;gap:var(--sp-3);margin-bottom:var(--sp-5)} +.me-act{flex:1;height:var(--h-md);padding:0 var(--sp-2);font-size:var(--fs-sm);gap:var(--sp-1)} .pet-emoji-img{width:100%;height:100%;display:block} diff --git a/pets-fe/pages/petform/petform.js b/pets-fe/pages/petform/petform.js index 0137fc7..0c6c725 100644 --- a/pets-fe/pages/petform/petform.js +++ b/pets-fe/pages/petform/petform.js @@ -4,6 +4,8 @@ const upload = require('../../utils/upload.js'); const { toastErr } = require('../../utils/ui.js'); const GENDERS = ['男孩', '女孩', '不确定']; +// 性格标签预设,多选,存成逗号分隔串 +const PERSONA_TAGS = ['活泼', '亲人', '粘人', '高冷', '贪吃', '胆小', '好奇', '温顺']; // 阶段列表和划分标准都从后端来。原来这里写死 4 个,后端细分成 5 段 // (而且狗还按体型走不同阈值)之后就对不上了 —— 而且不会报错,只是少两个选项。 // 「刚到家 0-30 天」在后端列表的第一个,它和年龄正交,是叠加层 @@ -54,10 +56,13 @@ Page({ species: 'cat', breed: '', gender: '不确定', birthday: '', arrived_at: '', weight: '', color: '', stage: '', + personality: '', allergy: '', notes: '', // 第四步挑的方案。'' 是「先空着自己排」,不是「没选」—— // 所以初始值给 null,用来区分「还没走到第四步」 template_id: null, }, + personaTags: PERSONA_TAGS, + personaSel: {}, // 性格标签选中态 map tplOptions: [], breedShow: false, breedGroups: [], @@ -88,8 +93,12 @@ Page({ arrived_at: p.arrived_at ? String(p.arrived_at).slice(0, 10) : '', weight: (p.weight || '').replace('kg', ''), color: p.color || '', + personality: p.personality || '', + allergy: p.allergy || '', + notes: p.notes || '', stage: p.stage || '', }, + personaSel: (p.personality || '').split(',').filter(Boolean).reduce((m, t) => ((m[t] = true), m), {}), }); }) .catch((e) => toastErr(e)); @@ -170,6 +179,21 @@ Page({ onColor(e) { this.setData({ 'form.color': e.detail.value }); }, + onAllergy(e) { + this.setData({ 'form.allergy': e.detail.value }); + }, + onNotes(e) { + this.setData({ 'form.notes': e.detail.value }); + }, + // 性格标签多选:切换选中态,再拼回逗号串存进 form.personality + togglePersona(e) { + const tag = e.currentTarget.dataset.v; + const sel = Object.assign({}, this.data.personaSel); + if (sel[tag]) delete sel[tag]; + else sel[tag] = true; + const personality = this.data.personaTags.filter((t) => sel[t]).join(','); + this.setData({ personaSel: sel, 'form.personality': personality }); + }, // ── 品种选择器 ── openBreed() { @@ -258,6 +282,9 @@ Page({ weight: f.weight, color: f.color.trim(), breed: f.breed, + personality: f.personality, + allergy: f.allergy.trim(), + notes: f.notes.trim(), stage: f.stage || stageFromBirthday(f.birthday) || '成年期', age: ageLabel(f.birthday), }; diff --git a/pets-fe/pages/petform/petform.wxml b/pets-fe/pages/petform/petform.wxml index cd65a06..8b3d69c 100644 --- a/pets-fe/pages/petform/petform.wxml +++ b/pets-fe/pages/petform/petform.wxml @@ -74,6 +74,13 @@ + + {{item}} + + + {{item}} diff --git a/pets-fe/pages/plan/plan.wxml b/pets-fe/pages/plan/plan.wxml index 30393e3..530b6c8 100644 --- a/pets-fe/pages/plan/plan.wxml +++ b/pets-fe/pages/plan/plan.wxml @@ -1,6 +1,6 @@ - + diff --git a/pets-fe/pages/report/report.wxml b/pets-fe/pages/report/report.wxml index f897697..2e5239c 100644 --- a/pets-fe/pages/report/report.wxml +++ b/pets-fe/pages/report/report.wxml @@ -1,6 +1,6 @@ - + diff --git a/pets-fe/pages/user/user.js b/pets-fe/pages/user/user.js index 60c66be..a4da6c5 100644 --- a/pets-fe/pages/user/user.js +++ b/pets-fe/pages/user/user.js @@ -3,6 +3,8 @@ const store = require('../../utils/store.js'); const { toastErr } = require('../../utils/ui.js'); const TAG_CLASS = { 求助: 'warn', 经验: 'blue', 精选: 'purple', 避坑: 'red', 晒宠: '' }; +// 审核状态 → 展示文案(只在看自己主页时出现) +const STATUS_TEXT = { pending: '审核中', rejected: '未通过' }; function fmtAgo(iso) { if (!iso) return ''; @@ -47,6 +49,8 @@ Page({ author_emoji: p.author_emoji || '🐾', tag, tagClass: TAG_CLASS[tag] || '', + // 只有看自己主页才会拿到非「已发布」的帖子,给个审核状态标 + statusText: STATUS_TEXT[p.status] || '', }; }); const merged = page === 1 ? list : this.data.posts.concat(list); diff --git a/pets-fe/pages/user/user.wxml b/pets-fe/pages/user/user.wxml index 39b4d14..fee2bff 100644 --- a/pets-fe/pages/user/user.wxml +++ b/pets-fe/pages/user/user.wxml @@ -20,8 +20,11 @@ module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; } {{item.author_name}} {{item.timeText}} + {{item.statusText}} {{item.tag}} + 内容审核中,通过后其他人才能看到 + 未通过内容安全审核,仅你可见 {{item.content}} diff --git a/pets-fe/pages/user/user.wxss b/pets-fe/pages/user/user.wxss index dba8ff6..3e9ff8c 100644 --- a/pets-fe/pages/user/user.wxss +++ b/pets-fe/pages/user/user.wxss @@ -1,2 +1,15 @@ /* 头部整块(头像/昵称/签名/计数/养宠数据/宠物墙)已抽成 components/profile-head, 帖子卡走 app.wxss 的 .post-card。这里没有页面独有样式了。 */ + +/* 审核状态标(只在看自己主页出现) */ +.post-status{ + padding:2rpx var(--sp-2);border-radius:var(--r-full); + font-size:var(--fs-cap);font-weight:var(--fw-b);margin-right:var(--sp-2); +} +.post-status.st-pending{background:#FDECC8;color:#B26A00} +.post-status.st-rejected{background:var(--red-soft);color:var(--red-ink)} +.status-note{ + margin:0 0 var(--sp-2);padding:var(--sp-2) var(--sp-3);border-radius:var(--r-sm); + background:var(--surface-2);color:var(--muted);font-size:var(--fs-cap);line-height:1.5; +} +.status-note.st-note-red{background:var(--red-soft);color:var(--red-ink)} diff --git a/pets-fe/utils/api.js b/pets-fe/utils/api.js index 20461f5..4a90407 100644 --- a/pets-fe/utils/api.js +++ b/pets-fe/utils/api.js @@ -42,6 +42,7 @@ const api = { // 用户 getProfile: () => request({ url: '/api/user/profile' }), + bindPhone: (code) => request({ url: '/api/user/phone', method: 'POST', data: { code } }), updateProfile: (body) => request({ url: '/api/user/profile', method: 'PUT', data: body }), // 用户 diff --git a/pets-fe/utils/request.js b/pets-fe/utils/request.js index bbfe6a5..4a62872 100644 --- a/pets-fe/utils/request.js +++ b/pets-fe/utils/request.js @@ -106,19 +106,34 @@ function request(options, _retried) { }); } -// 文件上传(multipart);token 过期同样先续期再重试一次 +// 文件上传(multipart)。三重兜底,应对「第一次报错、重试就好」的偶发: +// 1) 上传前若还没 token(冷启动竞态),先续期拿到再传; +// 2) token 过期(40100)→ 续期后重试; +// 3) 网络 fail / 网关 5xx(响应非 JSON)等瞬时错误 → 自动重试一次。 +// 后端按 MD5 去重,重复上传同一张图返回同一条记录,重试是安全的。 function uploadFile(filePath, _retried) { return new Promise((resolve, reject) => { - const token = getToken(); - wx.uploadFile({ - url: BASE_URL + '/api/upload', - filePath, - name: 'file', - timeout: 60000, - header: token ? { Authorization: 'Bearer ' + token } : {}, - success(res) { - try { - const body = JSON.parse(res.data); + // 瞬时错误:还没重试过就延后再传一次,否则直接失败 + const softRetry = (err) => { + if (_retried) return reject(err); + setTimeout(() => uploadFile(filePath, true).then(resolve, reject), 500); + }; + const fire = () => { + const token = getToken(); + wx.uploadFile({ + url: BASE_URL + '/api/upload', + filePath, + name: 'file', + timeout: 60000, + header: token ? { Authorization: 'Bearer ' + token } : {}, + success(res) { + let body; + try { + body = JSON.parse(res.data); + } catch (e) { + // 非 JSON 多为网关 5xx / 服务重启窗口,按瞬时错误重试 + return softRetry(new Error('上传失败,请重试')); + } if (body.code === 0) return resolve(body.data); if (body.code === 40100 && !_retried) { return recoverAuth() @@ -126,14 +141,17 @@ function uploadFile(filePath, _retried) { .catch(() => reject(new Error('登录已过期,请重进小程序'))); } reject(new Error(body.message || '上传失败')); - } catch (e) { - reject(new Error('上传响应解析失败')); - } - }, - fail(err) { - reject(new Error((err && err.errMsg) || '上传失败')); - }, - }); + }, + fail(err) { + softRetry(new Error((err && err.errMsg) || '上传失败')); + }, + }); + }; + // 冷启动竞态:点得太快、登录还没完成就上传,先把 token 拿到再传 + if (!getToken() && !_retried) { + return recoverAuth().then(fire, fire); + } + fire(); }); }