fix(fe): 修社区图片撑破网格;发帖限 3 张;上传偶发失败自动重试 #5

Merged
Blizzard merged 33 commits from feat/dev into main 2026-07-31 10:43:15 +00:00
42 changed files with 870 additions and 296 deletions
+17
View File
@@ -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})
}
+15
View File
@@ -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
}
+5
View File
@@ -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"` // 备注信息(照护提醒)
}
+1
View File
@@ -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)
+8 -17
View File
@@ -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)
// 发布前过内容安全:文本同步判、图片提交异步查。检测不了或有图 → 待人工/待回调
+4 -1
View File
@@ -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
+12 -4
View File
@@ -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)
// 任务是 PlanTaskDailyTask 已空)。取宠物的 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))
}
+89 -4
View File
@@ -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/221/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: "肉垫计划",
}
// 小程序码取不到不算错——未发布/配额用尽时卡照样出,只是没码
+6
View File
@@ -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,
}
+10 -1
View File
@@ -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 天计划(未保存)
+21 -8
View File
@@ -26,9 +26,10 @@ func (s *Service) GetWeeklyReport(userID, petID string) (*WeeklyReport, error) {
}
weekAgo := time.Now().AddDate(0, 0, -7)
// 任务已并成 PlanTaskDailyTask 表已空),要经 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
}
+21 -4
View File
@@ -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
+37
View File
@@ -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
View File
@@ -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;
}
+42 -13
View File
@@ -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>
+7 -14
View File
@@ -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)}
+1
View File
@@ -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: {
+23 -27
View File
@@ -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>
<!-- 头图:只有设了自定义头图才显示,没设就是干净白卡(不再留一块空色块) -->
<image wx:if="{{card.bg_url}}" class="pf-bg-img" src="{{card.bg_url}}" mode="aspectFill"></image>
<view class="pf-body">
<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 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-name">{{card.nickname || '毛孩子家长'}}<text wx:if="{{card.is_bot}}" class="ai-tag">AI</text></view>
<view class="pf-bio">{{card.bio || '这个人很懒,还没写签名'}}</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">
<pt-icon wx:if="{{!card.followed}}" name="plus" size="{{28}}"></pt-icon>{{card.followed ? '已关注' : '关注 TA'}}
</view>
</block>
<!-- 别人主页的关注键(装扮按钮已挪到「我的」页) -->
<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>
</view>
</view>
+21 -17
View File
@@ -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)}
+7 -1
View File
@@ -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) => {
+7
View File
@@ -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];
+2 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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' });
},
+3 -5
View File
@@ -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>
+4 -4
View File
@@ -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)}
+240 -84
View File
@@ -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;
+32 -16
View File
@@ -2,24 +2,40 @@
<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 本身是离屏的(挪到屏幕外),只用来生成图 -->
<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>下载到相册
<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>
<button class="btn btn-dark ic-btn" open-type="share">
<pt-icon name="share" size="{{32}}"></pt-icon>分享给好友
</button>
</view>
<view class="ic-actions">
<view class="btn btn-soft ic-btn" bindtap="onSave">
<pt-icon name="photo" size="{{32}}"></pt-icon>下载到相册
</view>
<button class="btn btn-dark ic-btn" open-type="share">
<pt-icon name="share" size="{{32}}"></pt-icon>分享给好友
</button>
</view>
</block>
</view>
</scroll-view>
+18 -1
View File
@@ -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}
+6
View File
@@ -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' });
},
+15 -4
View File
@@ -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>
+3 -1
View File
@@ -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}
+27
View File
@@ -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),
};
+7
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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">
+4
View File
@@ -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);
+3
View File
@@ -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">
+13
View File
@@ -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)}
+1
View File
@@ -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 }),
// 用户
+37 -19
View File
@@ -106,19 +106,34 @@ function request(options, _retried) {
});
}
// 文件上传(multiparttoken 过期同样先续期再重试一次
// 文件上传(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();
});
}