Merge pull request 'fix(fe): 修社区图片撑破网格;发帖限 3 张;上传偶发失败自动重试' (#5) from feat/dev into main
deploy / deploy (push) Successful in 3m26s
deploy / deploy (push) Successful in 3m26s
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"` // 备注信息(照护提醒)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
// 发布前过内容安全:文本同步判、图片提交异步查。检测不了或有图 → 待人工/待回调
|
||||
|
||||
@@ -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/<md5>.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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
@@ -14,10 +15,26 @@ 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 - 永远"
|
||||
|
||||
// —— 背面:照护信息 ——
|
||||
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"` // 码有没有生成出来
|
||||
}
|
||||
@@ -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: "肉垫计划",
|
||||
}
|
||||
|
||||
// 小程序码取不到不算错——未发布/配额用尽时卡照样出,只是没码
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 天计划(未保存)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+61
-9
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -155,14 +155,20 @@ module.exports.sel = function (map, key, index, def) {
|
||||
<block wx:elif="{{innerType === 'poster'}}">
|
||||
<view class="poster">
|
||||
<view class="poster-h3">{{pet.name}} 的成长报告</view>
|
||||
<view class="poster-avatar">{{pet.emoji}}</view>
|
||||
<view class="muted">{{pet.age}}|{{pet.weight}}|{{pet.stage}}</view>
|
||||
<view class="poster-list pre">✓ 完成任务 {{poster ? poster.tasks_completed : 0}} 项
|
||||
✓ 体重记录 {{poster ? poster.weight_records : 0}} 次
|
||||
✓ 疫苗记录 {{poster ? poster.vaccine_records : 0}} 次
|
||||
✓ 高风险异常 {{poster ? poster.high_risk_count : 0}} 次</view>
|
||||
<view class="bold">健康状态:{{poster ? poster.headline : '稳定成长'}}</view>
|
||||
<view class="muted" style="font-size:24rpx;margin-top:20rpx">生成自:肉垫计划</view>
|
||||
<view class="poster-avatar">
|
||||
<image wx:if="{{poster.pet_avatar_url || pet.avatar_url}}" class="poster-avatar-img" src="{{poster.pet_avatar_url || pet.avatar_url}}" mode="aspectFill"></image>
|
||||
<block wx:else>{{pet.emoji}}</block>
|
||||
</view>
|
||||
<view class="poster-meta">{{pet.age}}|{{pet.weight}}|{{pet.stage}}</view>
|
||||
<view wx:if="{{poster.period}}" class="poster-period">近 7 天 · {{poster.period}}</view>
|
||||
<view class="poster-list">
|
||||
<view class="poster-row"><view class="poster-tick"><pt-icon name="check" size="{{22}}"></pt-icon></view><text class="poster-rt">完成任务</text><text class="poster-rn">{{poster ? poster.tasks_completed : 0}} 项</text></view>
|
||||
<view class="poster-row"><view class="poster-tick"><pt-icon name="check" size="{{22}}"></pt-icon></view><text class="poster-rt">体重记录</text><text class="poster-rn">{{poster ? poster.weight_records : 0}} 次</text></view>
|
||||
<view class="poster-row"><view class="poster-tick"><pt-icon name="check" size="{{22}}"></pt-icon></view><text class="poster-rt">疫苗记录</text><text class="poster-rn">{{poster ? poster.vaccine_records : 0}} 次</text></view>
|
||||
<view class="poster-row"><view class="poster-tick"><pt-icon name="check" size="{{22}}"></pt-icon></view><text class="poster-rt">高风险异常</text><text class="poster-rn">{{poster ? poster.high_risk_count : 0}} 次</text></view>
|
||||
</view>
|
||||
<view class="poster-status">健康状态<text class="poster-badge">{{poster ? poster.headline : '稳定成长'}}</text></view>
|
||||
<view class="poster-from">生成自 · 肉垫计划</view>
|
||||
</view>
|
||||
<view class="sheet-actions" style="margin-top:28rpx">
|
||||
<button class="btn btn-ghost" open-type="share">分享给朋友</button>
|
||||
@@ -201,27 +207,22 @@ module.exports.sel = function (map, key, index, def) {
|
||||
→ 其他资料),品种从后端 breeds 表来、按首字母索引。弹层塞不下这些 -->
|
||||
<block wx:elif="{{innerType === 'createPost'}}">
|
||||
<view class="sheet-h3">发布图文</view>
|
||||
<view class="sheet-p">选择发布身份,分享你的养宠日常。</view>
|
||||
<view class="field"><label>发布身份</label><view class="seg">
|
||||
<view class="seg-btn {{u.sel(segSel,'ident',0,0)?'selected':''}}" data-group="ident" data-index="0" bindtap="onSeg">{{pet.name}}</view>
|
||||
<view class="seg-btn {{u.sel(segSel,'ident',1,0)?'selected':''}}" data-group="ident" data-index="1" bindtap="onSeg">匿名宠友</view>
|
||||
<view class="seg-btn {{u.sel(segSel,'ident',2,0)?'selected':''}}" data-group="ident" data-index="2" bindtap="onSeg">官方笔记</view>
|
||||
</view></view>
|
||||
<view class="field"><label>内容</label>
|
||||
<textarea class="textarea" placeholder="分享养宠日常、求助问题、经验笔记..." placeholder-class="placeholder" value="{{postContent}}" bindinput="onPostInput"></textarea></view>
|
||||
<view class="sheet-p">用你的昵称分享养宠日常。</view>
|
||||
<!-- 点整块 field 都能聚焦:原生 textarea 在 scroll-view 里命中区域会偏 -->
|
||||
<view class="field" bindtap="onPostFieldTap"><label>内容</label>
|
||||
<textarea class="textarea" focus="{{pfFocus}}" placeholder="分享养宠日常、求助问题、经验笔记..." placeholder-class="placeholder" value="{{postContent}}" bindinput="onPostInput" bindblur="onPostBlur"></textarea></view>
|
||||
<view class="mini-options">
|
||||
<view class="option {{u.sel(optSel,'ptag',0,0)?'selected':''}}" data-group="ptag" data-index="0" bindtap="onOpt">晒宠</view>
|
||||
<view class="option {{u.sel(optSel,'ptag',1,0)?'selected':''}}" data-group="ptag" data-index="1" bindtap="onOpt">求助</view>
|
||||
<view class="option {{u.sel(optSel,'ptag',1,0)?'selected':''}}" data-group="ptag" data-index="1" bindtap="onOpt">新手求助</view>
|
||||
<view class="option {{u.sel(optSel,'ptag',2,0)?'selected':''}}" data-group="ptag" data-index="2" bindtap="onOpt">经验</view>
|
||||
<view class="option {{u.sel(optSel,'ptag',3,0)?'selected':''}}" data-group="ptag" data-index="3" bindtap="onOpt">避坑</view>
|
||||
</view>
|
||||
<view class="field"><label>图片(可选,仅图片)</label>
|
||||
<view class="field"><label>图片(可选,最多 2 张)</label>
|
||||
<view class="img-picker">
|
||||
<view wx:for="{{postImages}}" wx:key="id" class="img-thumb">
|
||||
<image src="{{item.url}}" mode="aspectFill"></image>
|
||||
<view class="img-del" catchtap="onRemovePostImage" data-index="{{index}}"><pt-icon name="close" size="{{24}}"></pt-icon></view>
|
||||
</view>
|
||||
<view wx:if="{{postImages.length < 9}}" class="img-add" bindtap="onPickPostImages"><pt-icon name="plus" size="{{48}}"></pt-icon></view>
|
||||
<view wx:if="{{postImages.length < 2}}" class="img-add" bindtap="onPickPostImages"><pt-icon name="plus" size="{{48}}"></pt-icon></view>
|
||||
</view>
|
||||
</view>
|
||||
<button class="btn btn-primary btn-block" bindtap="onCreatePost">发布到宠友圈</button>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
<view class="pf th-{{theme}}">
|
||||
<!-- 头图。没传背景图就用主题色渐变,不留空白块 -->
|
||||
<view class="pf-bg">
|
||||
<!-- 头图:只有设了自定义头图才显示,没设就是干净白卡(不再留一块空色块) -->
|
||||
<image wx:if="{{card.bg_url}}" class="pf-bg-img" src="{{card.bg_url}}" mode="aspectFill"></image>
|
||||
</view>
|
||||
|
||||
<view class="pf-body">
|
||||
<!-- 身份行:头像 + 昵称/签名,左对齐 -->
|
||||
<view class="pf-top">
|
||||
<view class="pf-av">
|
||||
<image wx:if="{{card.avatar_url}}" class="pf-av-img" src="{{card.avatar_url}}" mode="aspectFill"></image>
|
||||
<block wx:else>{{initial}}</block>
|
||||
</view>
|
||||
<view class="pf-id">
|
||||
<view class="pf-name">{{card.nickname || '毛孩子家长'}}<text wx:if="{{card.is_bot}}" class="ai-tag">AI</text></view>
|
||||
<view class="pf-bio">{{card.bio || '这个人很懒,还没写签名'}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="pf-stats">
|
||||
<!-- 社交数:小一行,数字+标签内联 -->
|
||||
<view class="pf-social">
|
||||
<view class="pf-stat"><text class="pf-n">{{card.post_count || 0}}</text><text class="pf-l">帖子</text></view>
|
||||
<view class="pf-stat" data-kind="following" bindtap="onRelations">
|
||||
<text class="pf-n">{{card.following_count || 0}}</text><text class="pf-l">关注</text>
|
||||
</view>
|
||||
<view class="pf-stat" data-kind="followers" bindtap="onRelations">
|
||||
<text class="pf-n">{{card.follower_count || 0}}</text><text class="pf-l">粉丝</text>
|
||||
</view>
|
||||
<view class="pf-stat" data-kind="following" bindtap="onRelations"><text class="pf-n">{{card.following_count || 0}}</text><text class="pf-l">关注</text></view>
|
||||
<view class="pf-stat" data-kind="followers" bindtap="onRelations"><text class="pf-n">{{card.follower_count || 0}}</text><text class="pf-l">粉丝</text></view>
|
||||
</view>
|
||||
|
||||
<!-- 养宠数据。这是这个 App 独有的东西,比帖子数更能说明「这人是个认真的铲屎官」 -->
|
||||
<!-- 养宠数据:这个 App 独有,做成高亮带,比帖子数更能说明是个认真的铲屎官 -->
|
||||
<view wx:if="{{card.stats}}" class="pf-keep">
|
||||
<view class="pf-keep-i"><text class="pf-kn">{{card.stats.days || 0}}</text><text class="pf-kl">养宠天数</text></view>
|
||||
<view class="pf-keep-i"><text class="pf-kn">{{card.stats.records || 0}}</text><text class="pf-kl">累计记录</text></view>
|
||||
<view class="pf-keep-i"><text class="pf-kn">{{card.stats.streak || 0}}</text><text class="pf-kl">连续打卡</text></view>
|
||||
</view>
|
||||
|
||||
<!-- 宠物名片墙。可以在装扮里关掉 -->
|
||||
<scroll-view wx:if="{{card.show_pets && card.pets.length}}" class="pf-wall"
|
||||
<!-- 宠物名片墙:给别人看的(受开关控制)。「我的」页 hideWall 藏掉 -->
|
||||
<scroll-view wx:if="{{card.show_pets && card.pets.length && !hideWall}}" class="pf-wall"
|
||||
scroll-x="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
|
||||
<view wx:for="{{card.pets}}" wx:key="id" class="pf-pet" data-id="{{item.id}}" bindtap="onPet">
|
||||
<view class="pf-pet-av">
|
||||
@@ -42,13 +42,9 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<block wx:if="{{!preview}}">
|
||||
<view wx:if="{{card.is_self}}" class="btn btn-ghost btn-block" bindtap="onDecorate">
|
||||
<pt-icon name="edit" size="{{30}}"></pt-icon>装扮我的主页
|
||||
</view>
|
||||
<view wx:else class="btn {{card.followed ? 'btn-ghost' : 'btn-dark'}} btn-block" bindtap="onFollow">
|
||||
<!-- 别人主页的关注键(装扮按钮已挪到「我的」页) -->
|
||||
<view wx:if="{{!preview && !card.is_self}}" class="btn {{card.followed ? 'btn-ghost' : 'btn-dark'}} btn-block pf-follow" bindtap="onFollow">
|
||||
<pt-icon wx:if="{{!card.followed}}" name="plus" size="{{28}}"></pt-icon>{{card.followed ? '已关注' : '关注 TA'}}
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -25,7 +25,8 @@ module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; }
|
||||
<view class="post-content">{{item.content}}</view>
|
||||
<view wx:if="{{item.images.length}}" class="photo-grid">
|
||||
<block wx:for="{{item.images}}" wx:for-item="img" wx:key="*this">
|
||||
<image wx:if="{{im.isUrl(img)}}" class="photo-tile" src="{{img}}" mode="aspectFill"></image>
|
||||
<image wx:if="{{im.isUrl(img)}}" class="photo-tile" src="{{img}}" mode="aspectFill"
|
||||
catchtap="onPreviewImage" data-urls="{{item.images}}" data-cur="{{img}}"></image>
|
||||
<view wx:else class="photo-tile">{{img}}</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<nav-bar title="全部记录" show-back="{{true}}"></nav-bar>
|
||||
|
||||
<view class="top-bar"><pet-switch bind:add="onAddPet"></pet-switch></view>
|
||||
<view class="top-bar on-band"><pet-switch bind:add="onAddPet"></pet-switch></view>
|
||||
|
||||
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
|
||||
<view class="page-body no-fab">
|
||||
|
||||
+16
-18
@@ -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' });
|
||||
},
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<nav-bar title="肉垫计划"></nav-bar>
|
||||
|
||||
<view class="top-bar"><pet-switch bind:add="onAddPet"></pet-switch></view>
|
||||
<view class="top-bar on-band"><pet-switch bind:add="onAddPet"></pet-switch></view>
|
||||
|
||||
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}"
|
||||
bindscrolltolower="loadMore">
|
||||
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
|
||||
<view class="page-body">
|
||||
<!-- 门面:宠物名片。点进去编辑档案 -->
|
||||
<view class="card hero-card" bindtap="onHeroTap">
|
||||
@@ -71,7 +70,6 @@
|
||||
<!-- 任务的增删改都在计划页 —— 合并之后首页这个「管理」不该再开一个弹层 -->
|
||||
<view class="head-links">
|
||||
<view class="link" bindtap="goPlan">管理</view>
|
||||
<view class="link" bindtap="completeTasks">全部完成</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="task-list">
|
||||
@@ -111,7 +109,7 @@
|
||||
<view class="empty lg">还没有记录。点上面的「记一笔」开始,
|
||||
体重趋势和健康洞察都会从这些记录里长出来。</view>
|
||||
</view>
|
||||
<view wx:if="{{hasMore}}" class="tl-more" bindtap="loadMore">加载更多</view>
|
||||
<view wx:if="{{hasMore}}" class="tl-more" bindtap="goHistory">查看全部记录 ›</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
+247
-91
@@ -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';
|
||||
|
||||
// 资料行
|
||||
const infoX = avX + avD + 28;
|
||||
let y = pad + 52;
|
||||
const line = (label, value) => {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.font = '24px sans-serif';
|
||||
ctx.fillStyle = '#9A8F82';
|
||||
ctx.fillText(label, infoX, y);
|
||||
ctx.fillStyle = '#2D2925';
|
||||
ctx.font = '600 26px sans-serif';
|
||||
ctx.fillText(value || '—', infoX + ctx.measureText(label).width + 14, y);
|
||||
y += 46;
|
||||
};
|
||||
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);
|
||||
}
|
||||
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.arc(avX + avD / 2, avY + avD / 2, avD / 2, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 8;
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.textAlign = 'left';
|
||||
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);
|
||||
|
||||
// ── 下半张:证件标题 + 小程序码 ──
|
||||
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;
|
||||
// 名字 + 性别(头像右侧)。性别符号必须用「名字的字号」量宽度再定位,
|
||||
// 否则换成小字号后 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 = '#A98C5A';
|
||||
ctx.fillText(label, nx, yy);
|
||||
ctx.fillStyle = '#3D3427';
|
||||
ctx.font = '600 26px sans-serif';
|
||||
ctx.fillText(value || '—', nx + 78, yy);
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
// 底部:身份ID + 一起生活天数(贴卡片底边)
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = '#9C7A3E';
|
||||
ctx.font = '22px sans-serif';
|
||||
ctx.fillText('ID ' + (c.id_no || ''), fx + 34, fY + fH - 56);
|
||||
if (c.days > 0) {
|
||||
ctx.fillText('和主人一起生活的第 ' + c.days + ' 天', fx + 34, fY + fH - 22);
|
||||
}
|
||||
|
||||
// 正面印章(右上角)
|
||||
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;
|
||||
|
||||
@@ -2,16 +2,31 @@
|
||||
|
||||
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
|
||||
<view class="page-body ic-body">
|
||||
<!-- 手机号门槛:没验证不生成,先唤起微信取号 -->
|
||||
<view wx:if="{{needPhone}}" class="ic-gate">
|
||||
<view class="ic-gate-ic"><pt-icon name="user" size="{{72}}"></pt-icon></view>
|
||||
<view class="ic-gate-t">生成身份卡需要手机号</view>
|
||||
<view class="ic-gate-p">身份卡背面的「联系电话」用于走失时联系你,验证后才能生成</view>
|
||||
|
||||
<block wx:if="{{!manualMode}}">
|
||||
<button wx:if="{{!phoneRefused}}" class="btn btn-primary btn-block ic-gate-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhone">用微信验证手机号</button>
|
||||
<view wx:else class="ic-gate-refuse">没有手机号,无法生成身份卡</view>
|
||||
<view class="ic-gate-manual" bindtap="showManual">唤不起来?手动填写手机号</view>
|
||||
</block>
|
||||
|
||||
<block wx:else>
|
||||
<input class="input ic-gate-input" type="number" maxlength="11" placeholder="输入 11 位手机号" placeholder-class="placeholder" value="{{manualPhone}}" bindinput="onManualPhone"/>
|
||||
<button class="btn btn-primary btn-block ic-gate-btn" bindtap="submitManual">保存并生成</button>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- 画好的卡当成图片展示。canvas 本身是离屏的(挪到屏幕外),只用来生成图 -->
|
||||
<block wx:else>
|
||||
<view class="ic-stage">
|
||||
<image wx:if="{{cardImg}}" class="ic-img" src="{{cardImg}}" mode="widthFix" show-menu-by-longpress="{{true}}"></image>
|
||||
<view wx:else class="ic-loading">正在生成身份卡…</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{!qrReady}}" class="ic-tip">
|
||||
小程序码要在小程序发布后才会出现,现在先放一个占位。
|
||||
</view>
|
||||
|
||||
<view class="ic-actions">
|
||||
<view class="btn btn-soft ic-btn" bindtap="onSave">
|
||||
<pt-icon name="photo" size="{{32}}"></pt-icon>下载到相册
|
||||
@@ -20,6 +35,7 @@
|
||||
<pt-icon name="share" size="{{32}}"></pt-icon>分享给好友
|
||||
</button>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' });
|
||||
},
|
||||
|
||||
@@ -3,11 +3,17 @@
|
||||
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
|
||||
<view class="page-body tab-nofab">
|
||||
<!-- 和「宠友主页」同一个组件。自己看到的就是别人看到的,装扮才有意义 -->
|
||||
<profile-head card="{{card}}" bind:relations="goRelations"
|
||||
<profile-head card="{{card}}" hide-wall="{{true}}" bind:relations="goRelations"
|
||||
bind:decorate="goDecorate" bind:pet="onPickPet"></profile-head>
|
||||
|
||||
<view class="btn btn-ghost btn-block me-preview" bindtap="previewPublic">
|
||||
<pt-icon name="user" size="{{30}}"></pt-icon>看看别人眼里的我
|
||||
<!-- 装扮 + 预览,一行两列 -->
|
||||
<view class="me-actions">
|
||||
<view class="btn btn-ghost me-act" bindtap="goDecorate">
|
||||
<pt-icon name="edit" size="{{28}}"></pt-icon>装扮主页
|
||||
</view>
|
||||
<view class="btn btn-ghost me-act" bindtap="previewPublic">
|
||||
<pt-icon name="user" size="{{28}}"></pt-icon>别人眼里的我
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的毛孩子 -->
|
||||
@@ -29,8 +35,13 @@
|
||||
<view wx:if="{{!pets.length}}" class="empty">还没有毛孩子,先建一份档案</view>
|
||||
</view>
|
||||
|
||||
<!-- 档案/计划/多宠都从首页和宠物卡进,这里只留设置 -->
|
||||
<!-- 档案/计划/多宠都从首页和宠物卡进,这里留「我的帖子」+ 设置 -->
|
||||
<view class="profile-list">
|
||||
<view class="profile-row" bindtap="goMyPosts">
|
||||
<view class="pr-ic"><pt-icon name="community" size="{{34}}"></pt-icon></view>
|
||||
<text class="pr-label">我的帖子</text><text class="pr-val">看审核状态</text>
|
||||
<view class="pr-arrow"><pt-icon name="next" size="{{28}}"></pt-icon></view>
|
||||
</view>
|
||||
<view class="profile-row" bindtap="goSettings">
|
||||
<view class="pr-ic"><pt-icon name="settings" size="{{34}}"></pt-icon></view>
|
||||
<text class="pr-label">设置</text><view class="pr-arrow"><pt-icon name="next" size="{{28}}"></pt-icon></view>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -74,6 +74,13 @@
|
||||
<view class="field"><label>毛色</label>
|
||||
<input class="input" placeholder="例如:橘白 / 奶牛 / 黑" placeholder-class="placeholder"
|
||||
value="{{form.color}}" maxlength="16" bindinput="onColor"/></view>
|
||||
<view class="field"><label>性格标签</label><view class="mini-options">
|
||||
<view wx:for="{{personaTags}}" wx:key="*this" class="option {{personaSel[item] ? 'selected' : ''}}"
|
||||
data-v="{{item}}" bindtap="togglePersona">{{item}}</view>
|
||||
</view></view>
|
||||
<view class="field"><label>备注(身份卡背面显示)</label>
|
||||
<textarea class="textarea" placeholder="如:肠胃敏感,请勿喂陌生食物" placeholder-class="placeholder"
|
||||
value="{{form.notes}}" maxlength="80" bindinput="onNotes"></textarea></view>
|
||||
<view class="field"><label>当前阶段</label><view class="mini-options">
|
||||
<view wx:for="{{stages}}" wx:key="*this" class="option {{form.stage === item ? 'selected' : ''}}"
|
||||
data-v="{{item}}" bindtap="pickStage">{{item}}</view>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<nav-bar title="养宠计划" show-back="{{true}}"></nav-bar>
|
||||
|
||||
<view class="top-bar"><pet-switch show-add="{{false}}"></pet-switch></view>
|
||||
<view class="top-bar on-band"><pet-switch show-add="{{false}}"></pet-switch></view>
|
||||
|
||||
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
|
||||
<view class="page-body">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<nav-bar title="成长报告"></nav-bar>
|
||||
|
||||
<view class="top-bar"><pet-switch show-add="{{false}}"></pet-switch></view>
|
||||
<view class="top-bar on-band"><pet-switch show-add="{{false}}"></pet-switch></view>
|
||||
|
||||
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
|
||||
<view class="page-body tab-nofab">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -20,8 +20,11 @@ module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; }
|
||||
<view class="pu-b">{{item.author_name}}</view>
|
||||
<view class="pu-s">{{item.timeText}}</view>
|
||||
</view>
|
||||
<view wx:if="{{item.statusText}}" class="post-status st-{{item.status}}">{{item.statusText}}</view>
|
||||
<view wx:if="{{item.tag}}" class="tag {{item.tagClass}}">{{item.tag}}</view>
|
||||
</view>
|
||||
<view wx:if="{{item.status === 'pending'}}" class="status-note">内容审核中,通过后其他人才能看到</view>
|
||||
<view wx:elif="{{item.status === 'rejected'}}" class="status-note st-note-red">未通过内容安全审核,仅你可见</view>
|
||||
<view class="post-content">{{item.content}}</view>
|
||||
<view wx:if="{{item.images.length}}" class="photo-grid">
|
||||
<block wx:for="{{item.images}}" wx:for-item="img" wx:key="*this">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
// 用户
|
||||
|
||||
@@ -106,9 +106,19 @@ 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 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',
|
||||
@@ -117,8 +127,13 @@ function uploadFile(filePath, _retried) {
|
||||
timeout: 60000,
|
||||
header: token ? { Authorization: 'Bearer ' + token } : {},
|
||||
success(res) {
|
||||
let body;
|
||||
try {
|
||||
const body = JSON.parse(res.data);
|
||||
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) || '上传失败'));
|
||||
softRetry(new Error((err && err.errMsg) || '上传失败'));
|
||||
},
|
||||
});
|
||||
};
|
||||
// 冷启动竞态:点得太快、登录还没完成就上传,先把 token 拿到再传
|
||||
if (!getToken() && !_retried) {
|
||||
return recoverAuth().then(fire, fire);
|
||||
}
|
||||
fire();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user