Merge pull request 'feat: 补齐五块缺失功能(记录丢数据/文章正文/提醒管理/我的页入口/分享/关注)' (#1) from feat/dev into main
deploy / deploy (push) Successful in 1m50s

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-07-25 10:16:27 +00:00
44 changed files with 1052 additions and 119 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title> <title>admin</title>
<script type="module" crossorigin src="/admin/assets/index-1LtHYasw.js"></script> <script type="module" crossorigin src="/admin/assets/index-BmWfHKYa.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-CIpysjXR.css"> <link rel="stylesheet" crossorigin href="/admin/assets/index-BjLxu16U.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+152 -8
View File
@@ -13,7 +13,11 @@ func Seed(db *gorm.DB, cfg *config.Config) error {
if err := seedAdmin(db, cfg); err != nil { if err := seedAdmin(db, cfg); err != nil {
return err return err
} }
return seedArticles(db) if err := seedArticles(db); err != nil {
return err
}
// 早期版本 seed 出来的文章没有正文,这里补上(不覆盖已在后台编辑过的)
return backfillArticleContent(db)
} }
func seedAdmin(db *gorm.DB, cfg *config.Config) error { func seedAdmin(db *gorm.DB, cfg *config.Config) error {
@@ -43,11 +47,151 @@ func seedArticles(db *gorm.DB) error {
if count > 0 { if count > 0 {
return nil return nil
} }
articles := []model.Article{ return db.Create(builtinArticles()).Error
{Icon: "💉", Title: "幼猫疫苗和驱虫怎么安排?", Description: "适合搜索流量和社群传播,引导生成提醒。", Category: "vaccine", RelatedSheetType: "vaccine", Published: true}, }
{Icon: "💩", Title: "猫咪软便要不要去医院?", Description: "接异常记录和 AI 观察建议。", Category: "symptom", RelatedSheetType: "symptom", Published: true},
{Icon: "🍽️", Title: "7 天换粮计划怎么做?", Description: "接计划模板,适合一次性付费。", Category: "food", RelatedSheetType: "applyPlan", Published: true}, // backfillArticleContent 给早期 seed 出来的空正文文章补内容(按标题匹配,只补空的,不覆盖已编辑过的)
{Icon: "💰", Title: "一个月养猫大概花多少钱?", Description: "接养宠账本和年度账单。", Category: "cost", RelatedSheetType: "cost", Published: true}, func backfillArticleContent(db *gorm.DB) error {
} for _, a := range builtinArticles() {
return db.Create(&articles).Error if err := db.Model(&model.Article{}).
Where("title = ? AND (content IS NULL OR content = '')", a.Title).
Updates(map[string]any{"content": a.Content, "description": a.Description}).Error; err != nil {
return err
}
}
return nil
}
// builtinArticles 内置新手文章。内容遵循与 AI 助手一致的安全护栏:
// 不做诊断、不给药物剂量,异常一律说明何时就医。
func builtinArticles() []model.Article {
return []model.Article{
{
Icon: "💉", Title: "幼猫疫苗和驱虫怎么安排?",
Description: "疫苗针次、驱虫间隔和常见误区,看完顺手设个提醒。",
Category: "vaccine", RelatedSheetType: "vaccine", Published: true,
Content: `刚接回家的猫,疫苗和驱虫是最容易漏掉、也最不能漏的两件事。
【核心疫苗:猫三联】
· 一般从 8 周龄左右开始,共打 3 针,每针间隔 3-4 周。
· 之后每年加强 1 针。
· 狂犬疫苗按当地规定接种,多数地区建议满 3 月龄后接种。
【接种前后注意】
· 只有健康的猫才能打疫苗。有腹泻、打喷嚏、精神差时先缓一缓。
· 接种前后一周不要洗澡、不要换粮、不要长途出行,减少应激。
· 打完在医院观察 20-30 分钟,回家后再留意 24 小时。
· 少数猫会精神略差或轻微发热,通常一两天缓解。
【驱虫】
· 体内驱虫:幼猫期约每月 1 次,成年后每 3 个月 1 次。
· 体外驱虫:出门或家里有狗时更要做,按产品说明周期。
· 完全室内、不接触其他动物的成年猫可适当拉长间隔,但不要长期不做。
【常见误区】
· 「猫不出门就不用打疫苗」——你的鞋底、衣物都可能带病原回家。
· 「三针打完就一劳永逸」——每年仍需加强。
· 「驱虫药随便买」——务必按体重选择,剂量以医生或说明书为准。
【什么时候要去医院】
接种后出现持续呕吐、面部肿胀、呼吸急促、精神明显萎靡,请立即就医。
小程序里可以直接把下一针疫苗和驱虫时间设成提醒,到期会通知你。`,
},
{
Icon: "💩", Title: "猫咪软便要不要去医院?",
Description: "先分清程度,学会记录观察,明确什么情况必须就医。",
Category: "symptom", RelatedSheetType: "symptom", Published: true,
Content: `软便很常见,但「常见」不等于「不用管」。关键是分清程度和持续时间。
【先看程度】
· 成形但偏软、能捡起来:多数是饮食或轻微应激,可以先观察。
· 糊状、不成形:需要留意,记录次数和颜色。
· 水样、喷射状:脱水风险高,别拖。
【常见诱因】
· 换粮太快(最常见):新旧粮要 7 天以上过渡。
· 应激:刚到家、搬家、家里来客人。
· 吃多了、吃了人食或零食过量。
· 寄生虫:尤其是没做过驱虫的幼猫。
· 着凉、毛球、食物不耐受。
【在家可以做的】
· 记录:什么时候开始、一天几次、颜色形状、有没有带血或黏液。
· 暂停新食物和零食,回到之前吃惯的粮。
· 保证饮水,注意保暖。
· 不要自行喂人用止泻药。
【出现这些情况,尽快就医】
· 持续超过 24 小时没有好转;
· 便中带血、发黑,或有大量黏液;
· 伴随呕吐、不吃不喝、精神明显变差;
· 幼猫、老年猫或本身有基础病的猫——它们脱水很快,标准要更严。
【就医时带上什么】
排便照片、开始时间、次数、近期饮食变化、驱虫和疫苗记录。小程序里的「异常观察」可以直接生成一份就医前摘要。
本文只提供观察思路,不能替代医生诊断。`,
},
{
Icon: "🍽️", Title: "7 天换粮计划怎么做?",
Description: "按比例过渡,配合排便记录,把换粮的肠胃风险降到最低。",
Category: "food", RelatedSheetType: "applyPlan", Published: true,
Content: `直接换粮是软便的头号原因。正确做法是按比例、分阶段过渡。
【标准 7 天方案】
· 第 1-2 天:新粮 25% + 旧粮 75%
· 第 3-4 天:新粮 50% + 旧粮 50%
· 第 5-6 天:新粮 75% + 旧粮 25%
· 第 7 天起:新粮 100%
肠胃敏感、幼猫或老年猫,把周期拉长到 10-14 天更稳。
【过渡期要盯什么】
· 每天记录排便状态:成形 / 软便 / 拉稀。
· 食欲有没有下降,有没有呕吐。
· 一旦出现软便:退回上一个比例,稳定 2-3 天再往前推,别硬推。
【几个细节】
· 一次只换一种变量。别同时换粮又换猫砂、又带去洗澡。
· 新旧粮要拌匀,不然它会挑出爱吃的。
· 换粮期间别加新零食,否则出问题分不清是谁的锅。
· 湿粮开封后冷藏并尽快吃完,别放到下一顿还常温摆着。
【什么时候暂停换粮去看医生】
拉稀持续超过 24 小时、便中带血、频繁呕吐或精神变差,先就医,别继续过渡。
在小程序里每天记一次「饮食」和「便便」,7 天后能直接看到这次换粮顺不顺。`,
},
{
Icon: "💰", Title: "一个月养猫大概花多少钱?",
Description: "拆开算固定支出和一次性支出,重点是留出医疗备用金。",
Category: "cost", RelatedSheetType: "cost", Published: true,
Content: `养猫的花销差异很大,但结构是固定的。看清结构再决定预算。
【每月固定支出】
· 猫粮:口粮占大头,按体重和粮的档次浮动。
· 猫砂:用量比想象中大,膨润土和豆腐砂消耗速度不同。
· 零食 / 营养品:可选,不是必需。
【周期性支出】
· 驱虫:体内体外,按周期。
· 疫苗:幼猫第一年针次多,之后每年一次。
· 体检:成年猫每年 1 次,老年猫建议每半年 1 次。
【一次性支出(第一年)】
猫砂盆、猫窝、food/水碗、猫爬架、航空箱、指甲剪、梳子;绝育是第一年最大的一笔。
【最容易被低估的一项:医疗】
真正让预算失控的从来不是猫粮,是突发医疗。常见的口炎、尿闭、异物、肾病,一次就可能超过全年口粮开销。
建议:单独留一笔医疗备用金,或了解一下宠物医疗保险。这比省几十块钱的粮重要得多。
【怎么省得聪明】
· 该省:花哨玩具、频繁换的网红零食。
· 不该省:口粮质量、驱虫疫苗、该做的体检——省这些通常是把钱挪到医疗账单上。
在小程序里每次消费顺手记一笔,月底就能看到自己的真实结构,比拍脑袋估算准得多。`,
},
}
} }
+19 -1
View File
@@ -20,7 +20,7 @@ func (h *Handler) ListPosts(c *gin.Context) {
_ = c.ShouldBindQuery(&req) _ = c.ShouldBindQuery(&req)
req.Normalize() req.Normalize()
posts, total, err := h.svc.ListPosts(req.Tab, req.Offset(), req.Limit()) posts, total, err := h.svc.ListPosts(middleware.UserID(c), req.Tab, req.Offset(), req.Limit())
if err != nil { if err != nil {
response.FailErr(c, err) response.FailErr(c, err)
return return
@@ -134,3 +134,21 @@ func (h *Handler) CreateComment(c *gin.Context) {
} }
response.OK(c, comment) response.OK(c, comment)
} }
// FollowUser POST /api/users/:id/follow
func (h *Handler) FollowUser(c *gin.Context) {
if err := h.svc.FollowUser(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"followed": true})
}
// UnfollowUser DELETE /api/users/:id/follow
func (h *Handler) UnfollowUser(c *gin.Context) {
if err := h.svc.UnfollowUser(middleware.UserID(c), idParam(c, "id")); err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"followed": false})
}
+58
View File
@@ -1,6 +1,8 @@
package handler package handler
import ( import (
"errors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware" "github.com/sundynix/pets-be/internal/middleware"
@@ -42,6 +44,11 @@ func (h *Handler) GetPro(c *gin.Context) {
func (h *Handler) ActivatePro(c *gin.Context) { func (h *Handler) ActivatePro(c *gin.Context) {
info, err := h.svc.ActivatePro(middleware.UserID(c)) info, err := h.svc.ActivatePro(middleware.UserID(c))
if err != nil { if err != nil {
// 支付未接入不是服务器错误,按参数错返回,前端直接展示提示文案
if errors.Is(err, service.ErrPayNotReady) {
response.FailParams(c, err.Error())
return
}
response.FailErr(c, err) response.FailErr(c, err)
return return
} }
@@ -115,3 +122,54 @@ func (h *Handler) AssessSymptom(c *gin.Context) {
} }
response.OK(c, res) response.OK(c, res)
} }
type feedbackReq struct {
Content string `json:"content"`
Contact string `json:"contact"`
}
// CreateFeedback POST /api/feedback
func (h *Handler) CreateFeedback(c *gin.Context) {
var req feedbackReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
f, err := h.svc.CreateFeedback(middleware.UserID(c), req.Content, req.Contact)
if err != nil {
response.FailParams(c, err.Error())
return
}
response.OK(c, f)
}
// AdminListFeedback GET /api/admin/feedback
func (h *Handler) AdminListFeedback(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
list, total, err := h.svc.ListFeedbackAdmin(req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(list, total, req.PageQuery))
}
type handledReq struct {
Handled bool `json:"handled"`
}
// AdminSetFeedbackHandled PUT /api/admin/feedback/:id/handled
func (h *Handler) AdminSetFeedbackHandled(c *gin.Context) {
var req handledReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.SetFeedbackHandled(idParam(c, "id"), req.Handled); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
+3
View File
@@ -56,6 +56,9 @@ func (h *Handler) UpdateReminder(c *gin.Context) {
return return
} }
fields := map[string]any{} fields := map[string]any{}
if req.Type != "" {
fields["type"] = req.Type
}
if req.Title != "" { if req.Title != "" {
fields["title"] = req.Title fields["title"] = req.Title
} }
+2
View File
@@ -45,5 +45,7 @@ func AllModels() []any {
&CareTemplateItem{}, &CareTemplateItem{},
&CommunityBotConfig{}, &CommunityBotConfig{},
&File{}, &File{},
&Feedback{},
&Follow{},
} }
} }
+11
View File
@@ -0,0 +1,11 @@
package model
// Feedback 用户意见反馈
type Feedback struct {
Base
UserID string `gorm:"size:24;index" json:"user_id"`
Nickname string `gorm:"size:64" json:"nickname"`
Content string `gorm:"type:text" json:"content"`
Contact string `gorm:"size:128" json:"contact"` // 可选,方便回访
Handled bool `gorm:"index" json:"handled"`
}
+8
View File
@@ -0,0 +1,8 @@
package model
// Follow 关注关系:FollowerID 关注了 FolloweeID
type Follow struct {
Base
FollowerID string `gorm:"size:24;uniqueIndex:idx_follower_followee" json:"follower_id"`
FolloweeID string `gorm:"size:24;uniqueIndex:idx_follower_followee;index" json:"followee_id"`
}
+4
View File
@@ -25,4 +25,8 @@ type Post struct {
CommentCount int `json:"comment_count"` CommentCount int `json:"comment_count"`
Status string `gorm:"size:16;default:published;index" json:"status"` Status string `gorm:"size:16;default:published;index" json:"status"`
IsAI bool `gorm:"size:24;index" json:"is_ai"` // AI 运营生成的帖子,前端打「AI」标 IsAI bool `gorm:"size:24;index" json:"is_ai"` // AI 运营生成的帖子,前端打「AI」标
// 以下为计算字段,按当前登录用户填充
Followed bool `gorm:"-" json:"followed"` // 我是否已关注该作者
IsSelf bool `gorm:"-" json:"is_self"` // 是不是我自己发的
} }
+7
View File
@@ -84,6 +84,9 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
g.GET("/pets/:id/health-summary", h.HealthSummary) g.GET("/pets/:id/health-summary", h.HealthSummary)
g.GET("/pets/:id/poster", h.Poster) g.GET("/pets/:id/poster", h.Poster)
g.POST("/users/:id/follow", h.FollowUser)
g.DELETE("/users/:id/follow", h.UnfollowUser)
g.GET("/posts", h.ListPosts) g.GET("/posts", h.ListPosts)
g.POST("/posts", h.CreatePost) g.POST("/posts", h.CreatePost)
g.GET("/posts/:id", h.GetPost) g.GET("/posts/:id", h.GetPost)
@@ -93,6 +96,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
g.POST("/posts/:id/comments", h.CreateComment) g.POST("/posts/:id/comments", h.CreateComment)
g.GET("/articles", h.ListArticles) g.GET("/articles", h.ListArticles)
g.POST("/feedback", h.CreateFeedback)
g.GET("/articles/:id", h.GetArticle) g.GET("/articles/:id", h.GetArticle)
g.GET("/pro", h.GetPro) g.GET("/pro", h.GetPro)
@@ -137,5 +141,8 @@ func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manag
g.PUT("/community-bot", h.AdminSaveCommunityBot) g.PUT("/community-bot", h.AdminSaveCommunityBot)
g.POST("/community-bot/generate", h.AdminGenerateCommunityPosts) g.POST("/community-bot/generate", h.AdminGenerateCommunityPosts)
g.GET("/feedback", h.AdminListFeedback)
g.PUT("/feedback/:id/handled", h.AdminSetFeedbackHandled)
g.GET("/memberships", h.AdminListPro) g.GET("/memberships", h.AdminListPro)
} }
+11 -2
View File
@@ -58,12 +58,20 @@ func tabTag(tab string) string {
} }
} }
// ListPosts 帖子分页列表 // ListPosts 帖子分页列表。userID 用于「关注」tab 过滤和标记关注状态(可为空)。
func (s *Service) ListPosts(tab string, offset, limit int) ([]model.Post, int64, error) { func (s *Service) ListPosts(userID, tab string, offset, limit int) ([]model.Post, int64, error) {
q := s.db.Model(&model.Post{}).Where("status = ?", model.PostPublished) q := s.db.Model(&model.Post{}).Where("status = ?", model.PostPublished)
if tag := tabTag(tab); tag != "" { if tag := tabTag(tab); tag != "" {
q = q.Where("JSON_CONTAINS(tags, ?)", `"`+tag+`"`) q = q.Where("JSON_CONTAINS(tags, ?)", `"`+tag+`"`)
} }
if tab == "关注" {
ids := s.followeeIDs(userID)
if len(ids) == 0 {
// 一个都没关注:返回空列表,而不是退化成「推荐」
return []model.Post{}, 0, nil
}
q = q.Where("user_id IN ?", ids)
}
var total int64 var total int64
if err := q.Count(&total).Error; err != nil { if err := q.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
@@ -73,6 +81,7 @@ func (s *Service) ListPosts(tab string, offset, limit int) ([]model.Post, int64,
return nil, 0, err return nil, 0, err
} }
s.attachPostImages(posts) s.attachPostImages(posts)
s.markFollowed(userID, posts)
return posts, total, nil return posts, total, nil
} }
+50
View File
@@ -0,0 +1,50 @@
package service
import (
"errors"
"strings"
"github.com/sundynix/pets-be/internal/model"
)
// CreateFeedback 提交意见反馈
func (s *Service) CreateFeedback(userID, content, contact string) (*model.Feedback, error) {
content = strings.TrimSpace(content)
if content == "" {
return nil, errors.New("反馈内容不能为空")
}
if len([]rune(content)) > 1000 {
return nil, errors.New("反馈内容太长了")
}
name := ""
var u model.User
if err := s.db.Select("nickname").First(&u, "id = ?", userID).Error; err == nil {
name = u.Nickname
}
f := model.Feedback{UserID: userID, Nickname: name, Content: content, Contact: strings.TrimSpace(contact)}
if err := s.db.Create(&f).Error; err != nil {
return nil, err
}
return &f, nil
}
// ListFeedbackAdmin 后台反馈分页
func (s *Service) ListFeedbackAdmin(offset, limit int) ([]model.Feedback, int64, error) {
var total int64
s.db.Model(&model.Feedback{}).Count(&total)
var list []model.Feedback
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&list).Error
return list, total, err
}
// SetFeedbackHandled 标记反馈已处理
func (s *Service) SetFeedbackHandled(id string, handled bool) error {
res := s.db.Model(&model.Feedback{}).Where("id = ?", id).Update("handled", handled)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
+70
View File
@@ -0,0 +1,70 @@
package service
import (
"errors"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// FollowUser 关注某人(幂等;不能关注自己)
func (s *Service) FollowUser(followerID, followeeID string) error {
if followerID == "" || followeeID == "" {
return errors.New("参数不完整")
}
if followerID == followeeID {
return errors.New("不能关注自己")
}
var u model.User
if err := s.db.Select("id").First(&u, "id = ?", followeeID).Error; err != nil {
return ErrNotFound
}
f := model.Follow{FollowerID: followerID, FolloweeID: followeeID}
return s.db.Where("follower_id = ? AND followee_id = ?", followerID, followeeID).
FirstOrCreate(&f).Error
}
// UnfollowUser 取消关注(幂等)
func (s *Service) UnfollowUser(followerID, followeeID string) error {
return s.db.Where("follower_id = ? AND followee_id = ?", followerID, followeeID).
Delete(&model.Follow{}).Error
}
// followeeIDs 我关注的所有人
func (s *Service) followeeIDs(userID string) []string {
var ids []string
s.db.Model(&model.Follow{}).Where("follower_id = ?", userID).Pluck("followee_id", &ids)
return ids
}
// markFollowed 标记帖子作者是否已被我关注(用于前端显示「关注」按钮)
func (s *Service) markFollowed(userID string, posts []model.Post) {
if userID == "" || len(posts) == 0 {
return
}
authors := make([]string, 0, len(posts))
for i := range posts {
if posts[i].UserID != "" && posts[i].UserID != userID {
authors = append(authors, posts[i].UserID)
}
}
if len(authors) == 0 {
return
}
var followed []string
s.db.Model(&model.Follow{}).
Where("follower_id = ? AND followee_id IN ?", userID, authors).
Pluck("followee_id", &followed)
set := make(map[string]bool, len(followed))
for _, id := range followed {
set[id] = true
}
for i := range posts {
posts[i].IsSelf = posts[i].UserID == userID
posts[i].Followed = set[posts[i].UserID]
}
}
// ensureNotFound 供 handler 区分错误类型
var _ = gorm.ErrRecordNotFound
+8
View File
@@ -40,7 +40,15 @@ func (s *Service) GetPro(userID string) (*ProInfo, error) {
} }
// ActivatePro 开通年费会员 // ActivatePro 开通年费会员
// ActivatePro 开通会员。
// ⚠️ 支付尚未接入:在接上微信支付、且校验到真实支付成功之前,这里必须直接拒绝,
// 否则任何人调一次接口就白拿一年会员。接支付时把下面这段换成「校验支付单据后再写库」。
func (s *Service) ActivatePro(userID string) (*ProInfo, error) { func (s *Service) ActivatePro(userID string) (*ProInfo, error) {
return nil, ErrPayNotReady
}
// activateProAfterPaid 支付成功后真正写入会员状态(当前无人调用,等接支付时使用)
func (s *Service) activateProAfterPaid(userID string) (*ProInfo, error) {
now := time.Now() now := time.Now()
end := now.AddDate(1, 0, 0) end := now.AddDate(1, 0, 0)
var m model.ProMembership var m model.ProMembership
+4
View File
@@ -14,6 +14,10 @@ import (
// ErrNotFound 资源不存在(handler 据此返回 40400 // ErrNotFound 资源不存在(handler 据此返回 40400
var ErrNotFound = errors.New("not found") var ErrNotFound = errors.New("not found")
// ErrPayNotReady 支付未接入。会员开通必须走真实支付,未接入前一律拒绝,
// 避免「点一下白拿会员」。
var ErrPayNotReady = errors.New("支付功能即将开放,敬请期待")
// Service 业务逻辑聚合,方法按领域分散在各文件 // Service 业务逻辑聚合,方法按领域分散在各文件
type Service struct { type Service struct {
db *gorm.DB db *gorm.DB
+2
View File
@@ -11,6 +11,7 @@ import Comments from '@/pages/Comments'
import Articles from '@/pages/Articles' import Articles from '@/pages/Articles'
import CareTemplates from '@/pages/CareTemplates' import CareTemplates from '@/pages/CareTemplates'
import CommunityBot from '@/pages/CommunityBot' import CommunityBot from '@/pages/CommunityBot'
import Feedback from '@/pages/Feedback'
import Members from '@/pages/Members' import Members from '@/pages/Members'
function RequireAuth({ children }: { children: JSX.Element }) { function RequireAuth({ children }: { children: JSX.Element }) {
@@ -37,6 +38,7 @@ export default function App() {
<Route path="articles" element={<Articles />} /> <Route path="articles" element={<Articles />} />
<Route path="care-templates" element={<CareTemplates />} /> <Route path="care-templates" element={<CareTemplates />} />
<Route path="community-bot" element={<CommunityBot />} /> <Route path="community-bot" element={<CommunityBot />} />
<Route path="feedback" element={<Feedback />} />
<Route path="members" element={<Members />} /> <Route path="members" element={<Members />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
+2 -1
View File
@@ -1,5 +1,5 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom' import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { LayoutDashboard, Users, PawPrint, MessageSquare, MessagesSquare, FileText, ClipboardList, Sparkles, Crown, LogOut } from 'lucide-react' import { LayoutDashboard, Users, PawPrint, MessageSquare, MessagesSquare, FileText, ClipboardList, Sparkles, MessageCircle, Crown, LogOut } from 'lucide-react'
import { clearToken } from '@/lib/api' import { clearToken } from '@/lib/api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -13,6 +13,7 @@ const nav = [
{ to: '/articles', label: '文章管理', icon: FileText }, { to: '/articles', label: '文章管理', icon: FileText },
{ to: '/care-templates', label: '养护模板', icon: ClipboardList }, { to: '/care-templates', label: '养护模板', icon: ClipboardList },
{ to: '/community-bot', label: '社区运营', icon: Sparkles }, { to: '/community-bot', label: '社区运营', icon: Sparkles },
{ to: '/feedback', label: '意见反馈', icon: MessageCircle },
{ to: '/members', label: '会员管理', icon: Crown }, { to: '/members', label: '会员管理', icon: Crown },
] ]
+4
View File
@@ -81,6 +81,10 @@ export const api = {
stage, stage,
}), }),
feedback: (params: any) => http.get<any, Page<any>>('/admin/feedback', { params }),
setFeedbackHandled: (id: string, handled: boolean) =>
http.put(`/admin/feedback/${id}/handled`, { handled }),
communityBot: () => communityBot: () =>
http.get<any, { enabled: boolean; daily_count: number; start_hour: number; end_hour: number }>( http.get<any, { enabled: boolean; daily_count: number; start_hour: number; end_hour: number }>(
'/admin/community-bot', '/admin/community-bot',
+69
View File
@@ -0,0 +1,69 @@
import { api } from '@/lib/api'
import { usePaged } from '@/lib/usePaged'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Pager } from '@/components/Pager'
export default function Feedback() {
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.feedback)
async function toggle(f: any) {
await api.setFeedbackHandled(f.id, !f.handled)
reload()
}
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground mt-1"> </p>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.list?.map((f) => (
<TableRow key={f.id}>
<TableCell className="whitespace-nowrap text-muted-foreground">
{String(f.created_at).slice(0, 16).replace('T', ' ')}
</TableCell>
<TableCell>{f.nickname || '—'}</TableCell>
<TableCell className="max-w-md whitespace-pre-wrap">{f.content}</TableCell>
<TableCell>{f.contact || '—'}</TableCell>
<TableCell>
{f.handled ? <Badge variant="success"></Badge> : <Badge variant="muted"></Badge>}
</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="sm" onClick={() => toggle(f)}>
{f.handled ? '标为待处理' : '标为已处理'}
</Button>
</TableCell>
</TableRow>
))}
{!data?.list?.length && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />
</CardContent>
</Card>
</div>
)
}
+1
View File
@@ -7,6 +7,7 @@
"pages/report/report", "pages/report/report",
"pages/community/community", "pages/community/community",
"pages/learn/learn", "pages/learn/learn",
"pages/article/article",
"pages/profile/profile" "pages/profile/profile"
], ],
"window": { "window": {
+216 -18
View File
@@ -32,6 +32,15 @@ const TASK_SHEETS = [
]; ];
const TASK_SHEET_LABELS = TASK_SHEETS.map((s) => s.label); const TASK_SHEET_LABELS = TASK_SHEETS.map((s) => s.label);
// 提醒类型(与后端 model.Reminder* 常量对应)
const REMINDER_TYPES = [
{ key: 'vaccine', label: '疫苗' },
{ key: 'deworm', label: '驱虫' },
{ key: 'weight', label: '体重' },
{ key: 'monthlyReport', label: '月度报告' },
];
const REMINDER_LABELS = REMINDER_TYPES.map((t) => t.label);
const POOP = ['正常', '软便', '拉稀']; const POOP = ['正常', '软便', '拉稀'];
const FOOD = ['正常', '偏少', '不吃']; const FOOD = ['正常', '偏少', '不吃'];
const COST = ['食品', '医疗', '用品']; const COST = ['食品', '医疗', '用品'];
@@ -53,12 +62,24 @@ Component({
dateVals: { vaccine: '2026-07-21', deworm: '2026-07-15' }, dateVals: { vaccine: '2026-07-21', deworm: '2026-07-15' },
wInput: '', wInput: '',
wNote: '', wNote: '',
poopNote: '',
foodText: '',
medName: '',
medDose: '',
costNote: '',
costAmount: '', costAmount: '',
postContent: '', postContent: '',
postImages: [], postImages: [],
manageTasks: [], manageTasks: [],
taskForm: { id: '', title: '', description: '', priority: '', sheetIdx: 0 }, taskForm: { id: '', title: '', description: '', priority: '', sheetIdx: 0 },
taskSheetLabels: TASK_SHEET_LABELS, taskSheetLabels: TASK_SHEET_LABELS,
remForm: { id: '', typeIdx: 0, title: '', date: '', freq: '' },
remTypeLabels: REMINDER_LABELS,
myPets: [],
exportText: '',
fbContent: '',
fbContact: '',
fbDone: false,
addName: '', addName: '',
addBirthday: '', addBirthday: '',
addWeight: '', addWeight: '',
@@ -100,8 +121,18 @@ Component({
patch.costAmount = ''; patch.costAmount = '';
patch.postContent = ''; patch.postContent = '';
patch.postImages = []; patch.postImages = [];
patch.poopNote = '';
patch.foodText = '';
patch.medName = '';
patch.medDose = '';
patch.costNote = '';
patch.manageTasks = []; patch.manageTasks = [];
patch.taskForm = { id: '', title: '', description: '', priority: '', sheetIdx: 0 }; patch.taskForm = { id: '', title: '', description: '', priority: '', sheetIdx: 0 };
patch.remForm = { id: '', typeIdx: 0, title: '', date: '', freq: '' };
patch.exportText = '';
patch.fbContent = '';
patch.fbContact = '';
patch.fbDone = false;
patch.addName = ''; patch.addName = '';
patch.addBirthday = ''; patch.addBirthday = '';
patch.addWeight = ''; patch.addWeight = '';
@@ -146,20 +177,14 @@ Component({
const id = store.currentPetId(); const id = store.currentPetId();
if (type === 'manageTasks') { if (type === 'manageTasks') {
this.loadManageTasks(); this.loadManageTasks();
} else if (type === 'managePets') {
this.loadMyPets();
} else if (type === 'exportData') {
this.buildExport();
} else if (type === 'comments' && this.data.postId) { } else if (type === 'comments' && this.data.postId) {
api.listComments(this.data.postId).then((page) => this.setData({ comments: page.list || [] })).catch(() => {}); api.listComments(this.data.postId).then((page) => this.setData({ comments: page.list || [] })).catch(() => {});
} else if (type === 'reminders' && id) { } else if (type === 'reminders' && id) {
api this.loadReminders();
.getReminders(id)
.then((list) =>
this.setData({
reminders: (list || []).map((r) => ({
title: r.title,
when: r.next_due_date ? fmtDate(r.next_due_date) : r.frequency || '',
})),
}),
)
.catch(() => {});
} else if (type === 'poster' && id) { } else if (type === 'poster' && id) {
api.getPoster(id).then((p) => this.setData({ poster: p })).catch(() => {}); api.getPoster(id).then((p) => this.setData({ poster: p })).catch(() => {});
} else if (type === 'reportDetail' && id) { } else if (type === 'reportDetail' && id) {
@@ -212,8 +237,157 @@ Component({
}, },
onWInput(e) { this.setData({ wInput: e.detail.value }); }, onWInput(e) { this.setData({ wInput: e.detail.value }); },
onWNote(e) { this.setData({ wNote: e.detail.value }); }, onWNote(e) { this.setData({ wNote: e.detail.value }); },
onPoopNote(e) { this.setData({ poopNote: e.detail.value }); },
onFoodText(e) { this.setData({ foodText: e.detail.value }); },
onMedName(e) { this.setData({ medName: e.detail.value }); },
onMedDose(e) { this.setData({ medDose: e.detail.value }); },
onCostNote(e) { this.setData({ costNote: e.detail.value }); },
onCostInput(e) { this.setData({ costAmount: e.detail.value }); }, onCostInput(e) { this.setData({ costAmount: e.detail.value }); },
onPostInput(e) { this.setData({ postContent: e.detail.value }); }, onPostInput(e) { this.setData({ postContent: e.detail.value }); },
// ---- 多宠物管理 ----
loadMyPets() {
const cur = store.currentPetId();
this.setData({
myPets: (store.getPets() || []).map((p) => ({
id: p.id, name: p.name, emoji: p.emoji,
meta: [p.type, p.stage, p.weight].filter(Boolean).join(' · '),
current: p.id === cur,
})),
});
},
onSwitchPet(e) {
const id = e.currentTarget.dataset.id;
store.switchPet(id);
this.loadMyPets();
wx.showToast({ title: '已切换', icon: 'success' });
setTimeout(() => this.close(), 500);
},
onEditPetFromList(e) {
store.switchPet(e.currentTarget.dataset.id);
this.setType('editPet', true);
},
// ---- 导出健康档案 ----
buildExport() {
const id = store.currentPetId();
const pet = store.getPet() || {};
if (!id) {
this.setData({ exportText: '还没有建档,先添加一只毛孩子吧。' });
return;
}
Promise.all([
api.getRecords(id, { page: 1, pageSize: 50 }).catch(() => ({ list: [], total: 0 })),
api.getReminders(id).catch(() => []),
]).then(([recPage, rems]) => {
const lines = [];
lines.push('【肉垫计划 · 健康档案】');
lines.push(`宠物:${pet.name || ''}${pet.type || ''}${pet.gender || ''}`);
lines.push(`阶段:${pet.stage || '-'} 年龄:${pet.age || '-'} 体重:${pet.weight || '-'}`);
if (pet.breed || pet.color) lines.push(`品种/毛色:${pet.breed || '-'} / ${pet.color || '-'}`);
lines.push('');
lines.push(`— 健康记录(共 ${recPage.total || 0} 条,导出最近 ${(recPage.list || []).length} 条)—`);
(recPage.list || []).forEach((r) => {
lines.push(`· ${fmtDate(r.occurred_at)} ${r.title}${r.description ? '' + r.description : ''}`);
});
lines.push('');
lines.push('— 提醒 —');
(rems || []).forEach((r) => {
lines.push(`· ${r.title}${r.next_due_date ? fmtDate(r.next_due_date) : r.frequency || '未设置'}`);
});
lines.push('');
lines.push('(本档案仅供日常照护与就医参考,不构成诊断意见)');
this.setData({ exportText: lines.join('\n') });
});
},
onCopyExport() {
const t = this.data.exportText;
if (!t) return;
wx.setClipboardData({
data: t,
success: () => wx.showToast({ title: '已复制到剪贴板', icon: 'success' }),
});
},
// ---- 意见反馈 ----
onFbContent(e) { this.setData({ fbContent: e.detail.value }); },
onFbContact(e) { this.setData({ fbContact: e.detail.value }); },
onSubmitFeedback() {
const content = (this.data.fbContent || '').trim();
if (!content) return wx.showToast({ title: '写点什么再提交', icon: 'none' });
if (this.data.saving) return;
this.setData({ saving: true });
api
.submitFeedback({ content, contact: (this.data.fbContact || '').trim() })
.then(() => this.setData({ fbDone: true, saving: false }))
.catch((e) => {
wx.showToast({ title: e.message || '提交失败', icon: 'none' });
this.setData({ saving: false });
});
},
// ---- 提醒增删改 ----
loadReminders() {
const id = store.currentPetId();
if (!id) return;
api
.getReminders(id)
.then((list) =>
this.setData({
reminders: (list || []).map((r) => ({
id: r.id,
type: r.type,
title: r.title,
date: r.next_due_date ? String(r.next_due_date).slice(0, 10) : '',
freq: r.frequency || '',
when: r.next_due_date ? fmtDate(r.next_due_date) : r.frequency || '未设置',
})),
}),
)
.catch(() => {});
},
onRemTitle(e) { this.setData({ 'remForm.title': e.detail.value }); },
onRemFreq(e) { this.setData({ 'remForm.freq': e.detail.value }); },
onRemType(e) { this.setData({ 'remForm.typeIdx': Number(e.detail.value) }); },
onRemDate(e) { this.setData({ 'remForm.date': e.detail.value }); },
onEditReminder(e) {
const r = this.data.reminders[e.currentTarget.dataset.index];
let idx = REMINDER_TYPES.findIndex((t) => t.key === r.type);
if (idx < 0) idx = 0;
this.setData({ remForm: { id: r.id, typeIdx: idx, title: r.title, date: r.date, freq: r.freq } });
},
onSaveReminder() {
const f = this.data.remForm;
const title = (f.title || '').trim();
if (!title) return wx.showToast({ title: '填个提醒名', icon: 'none' });
const body = {
type: REMINDER_TYPES[f.typeIdx].key,
title,
next_due_date: f.date || '',
frequency: (f.freq || '').trim(),
};
const petId = store.currentPetId();
const p = f.id ? api.updateReminder(f.id, body) : api.createReminder(petId, body);
p.then(() => {
this.setData({ remForm: { id: '', typeIdx: 0, title: '', date: '', freq: '' } });
this.loadReminders();
this.triggerEvent('saved');
}).catch((e) => wx.showToast({ title: e.message || '保存失败', icon: 'none' }));
},
onDeleteReminder(e) {
const r = this.data.reminders[e.currentTarget.dataset.index];
wx.showModal({
title: '删除提醒',
content: '确定删除「' + r.title + '」?',
success: (res) => {
if (!res.confirm) return;
api.deleteReminder(r.id).then(() => {
this.loadReminders();
this.triggerEvent('saved');
}).catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' }));
},
});
},
// ---- 管理任务 ---- // ---- 管理任务 ----
loadManageTasks() { loadManageTasks() {
const id = store.currentPetId(); const id = store.currentPetId();
@@ -328,25 +502,49 @@ Component({
switch (t) { switch (t) {
case 'poop': { case 'poop': {
const s = POOP[this.segIdx('poopState')]; const s = POOP[this.segIdx('poopState')];
return { type: 'poop', icon: '💩', title: '排便记录:' + s, category: s }; return {
type: 'poop', icon: '💩', title: '排便记录:' + s, category: s,
description: (this.data.poopNote || '').trim(),
};
} }
case 'food': { case 'food': {
const a = FOOD[this.segIdx('food')]; const a = FOOD[this.segIdx('food')];
return { type: 'food', icon: '🍽️', title: '饮食记录:食欲' + a }; const what = (this.data.foodText || '').trim();
return {
type: 'food', icon: '🍽️',
title: what ? '饮食记录:' + what : '饮食记录:食欲' + a,
category: a,
description: what ? '食欲' + a : '',
};
}
case 'medicine': {
const name = (this.data.medName || '').trim();
const dose = (this.data.medDose || '').trim();
return {
type: 'medicine', icon: '💊',
title: name ? '用药:' + name : '用药记录',
description: dose,
};
} }
case 'medicine':
return { type: 'medicine', icon: '💊', title: '用药记录' };
case 'cost': { case 'cost': {
const c = COST[this.segIdx('cost')]; const c = COST[this.segIdx('cost')];
const amt = parseFloat(this.data.costAmount) || 0; const amt = parseFloat(this.data.costAmount) || 0;
return { type: 'cost', icon: '💰', title: '消费记录:¥' + amt, num_value: amt, category: c }; return {
type: 'cost', icon: '💰', title: '消费记录:¥' + amt, num_value: amt, category: c,
description: (this.data.costNote || '').trim(),
};
} }
case 'vaccine': case 'vaccine':
return { type: 'vaccine', icon: '💉', title: '疫苗提醒:' + this.data.dateVals.vaccine }; return { type: 'vaccine', icon: '💉', title: '疫苗提醒:' + this.data.dateVals.vaccine };
case 'deworm': case 'deworm':
return { type: 'deworm', icon: '🛡️', title: '驱虫提醒:' + this.data.dateVals.deworm }; return { type: 'deworm', icon: '🛡️', title: '驱虫提醒:' + this.data.dateVals.deworm };
case 'risk': case 'risk': {
return { type: 'symptom', icon: '🤒', title: '异常观察:风险中', category: '中' }; // 用 AI 返回的真实风险等级,而不是写死「中」
const r = this.data.riskData || {};
const lv = r.risk_level || '中';
const desc = [r.causes, r.suggestion].filter(Boolean).join('');
return { type: 'symptom', icon: '🤒', title: '异常观察:风险' + lv, category: lv, description: desc };
}
case 'vetSummary': case 'vetSummary':
return { type: 'note', icon: '📄', title: '生成就医前摘要' }; return { type: 'note', icon: '📄', title: '生成就医前摘要' };
default: default:
@@ -32,7 +32,7 @@ module.exports.sel = function (map, key, index, def) {
<view class="seg-btn {{u.sel(segSel,'poopState',2,0)?'selected':''}}" data-group="poopState" data-index="2" bindtap="onSeg">拉稀</view> <view class="seg-btn {{u.sel(segSel,'poopState',2,0)?'selected':''}}" data-group="poopState" data-index="2" bindtap="onSeg">拉稀</view>
</view></view> </view></view>
<view class="field"><label>颜色/备注</label> <view class="field"><label>颜色/备注</label>
<textarea class="textarea" placeholder="颜色、次数、是否带血、是否有异味等" placeholder-class="placeholder"></textarea></view> <textarea class="textarea" placeholder="颜色、次数、是否带血、是否有异味等" placeholder-class="placeholder" value="{{poopNote}}" bindinput="onPoopNote"></textarea></view>
<button class="btn btn-primary btn-block" data-text="排便记录:正常" data-icon="💩" bindtap="onSave">保存记录</button> <button class="btn btn-primary btn-block" data-text="排便记录:正常" data-icon="💩" bindtap="onSave">保存记录</button>
</block> </block>
@@ -40,7 +40,7 @@ module.exports.sel = function (map, key, index, def) {
<block wx:elif="{{innerType === 'food'}}"> <block wx:elif="{{innerType === 'food'}}">
<view class="sheet-h3">记录饮食</view> <view class="sheet-h3">记录饮食</view>
<view class="field"><label>今天吃了什么?</label> <view class="field"><label>今天吃了什么?</label>
<input class="input" placeholder="例如:幼猫粮 45g" placeholder-class="placeholder"/></view> <input class="input" placeholder="例如:幼猫粮 45g" placeholder-class="placeholder" value="{{foodText}}" bindinput="onFoodText"/></view>
<view class="field"><label>食欲</label><view class="seg"> <view class="field"><label>食欲</label><view class="seg">
<view class="seg-btn {{u.sel(segSel,'food',0,0)?'selected':''}}" data-group="food" data-index="0" bindtap="onSeg">正常</view> <view class="seg-btn {{u.sel(segSel,'food',0,0)?'selected':''}}" data-group="food" data-index="0" bindtap="onSeg">正常</view>
<view class="seg-btn {{u.sel(segSel,'food',1,0)?'selected':''}}" data-group="food" data-index="1" bindtap="onSeg">偏少</view> <view class="seg-btn {{u.sel(segSel,'food',1,0)?'selected':''}}" data-group="food" data-index="1" bindtap="onSeg">偏少</view>
@@ -99,8 +99,8 @@ module.exports.sel = function (map, key, index, def) {
<!-- 记录用药 --> <!-- 记录用药 -->
<block wx:elif="{{innerType === 'medicine'}}"> <block wx:elif="{{innerType === 'medicine'}}">
<view class="sheet-h3">记录用药</view> <view class="sheet-h3">记录用药</view>
<view class="field"><label>药品名称</label><input class="input" placeholder="例如:体内驱虫药" placeholder-class="placeholder"/></view> <view class="field"><label>药品名称</label><input class="input" placeholder="例如:体内驱虫药" placeholder-class="placeholder" value="{{medName}}" bindinput="onMedName"/></view>
<view class="field"><label>剂量/备注</label><input class="input" placeholder="例如:按 2.8kg 剂量" placeholder-class="placeholder"/></view> <view class="field"><label>剂量/备注</label><input class="input" placeholder="例如:按 2.8kg 剂量" placeholder-class="placeholder" value="{{medDose}}" bindinput="onMedDose"/></view>
<button class="btn btn-primary btn-block" data-text="用药记录" data-icon="💊" bindtap="onSave">保存记录</button> <button class="btn btn-primary btn-block" data-text="用药记录" data-icon="💊" bindtap="onSave">保存记录</button>
</block> </block>
@@ -113,7 +113,7 @@ module.exports.sel = function (map, key, index, def) {
<view class="seg-btn {{u.sel(segSel,'cost',1,0)?'selected':''}}" data-group="cost" data-index="1" bindtap="onSeg">医疗</view> <view class="seg-btn {{u.sel(segSel,'cost',1,0)?'selected':''}}" data-group="cost" data-index="1" bindtap="onSeg">医疗</view>
<view class="seg-btn {{u.sel(segSel,'cost',2,0)?'selected':''}}" data-group="cost" data-index="2" bindtap="onSeg">用品</view> <view class="seg-btn {{u.sel(segSel,'cost',2,0)?'selected':''}}" data-group="cost" data-index="2" bindtap="onSeg">用品</view>
</view></view> </view></view>
<view class="field"><label>备注</label><input class="input" placeholder="例如:幼猫粮" placeholder-class="placeholder"/></view> <view class="field"><label>备注</label><input class="input" placeholder="例如:幼猫粮" placeholder-class="placeholder" value="{{costNote}}" bindinput="onCostNote"/></view>
<button class="btn btn-primary btn-block" data-text="消费记录:¥168" data-icon="💰" bindtap="onSave">保存记录</button> <button class="btn btn-primary btn-block" data-text="消费记录:¥168" data-icon="💰" bindtap="onSave">保存记录</button>
</block> </block>
@@ -154,9 +154,33 @@ module.exports.sel = function (map, key, index, def) {
<!-- 提醒中心 --> <!-- 提醒中心 -->
<block wx:elif="{{innerType === 'reminders'}}"> <block wx:elif="{{innerType === 'reminders'}}">
<view class="sheet-h3">提醒中心</view> <view class="sheet-h3">提醒中心</view>
<view wx:for="{{reminders}}" wx:key="title" class="record-row"><text class="bold">{{item.title}}</text><text class="muted">{{item.when}}</text></view> <view class="sheet-p">疫苗、驱虫这些别靠记,设好日期到时候提醒你。</view>
<view wx:if="{{!reminders.length}}" class="sheet-p">暂无提醒</view>
<button class="btn btn-primary btn-block" style="margin-top:24rpx" bindtap="close">完成</button> <view wx:for="{{reminders}}" wx:key="id" class="mtask-row">
<view class="mtask-body">
<text class="mtask-b">{{item.title}}</text>
<view class="mtask-p">{{item.when}}</view>
</view>
<view class="mtask-op" catchtap="onEditReminder" data-index="{{index}}">改</view>
<view class="mtask-op del" catchtap="onDeleteReminder" data-index="{{index}}">删</view>
</view>
<view wx:if="{{!reminders.length}}" class="sheet-p">还没有提醒,下面加一条吧。</view>
<view class="field" style="margin-top:12rpx"><label>{{remForm.id ? '修改提醒' : '新增提醒'}}</label>
<input class="input" placeholder="提醒名,例如:第 3 针疫苗" placeholder-class="placeholder" value="{{remForm.title}}" bindinput="onRemTitle"/></view>
<view class="field"><label>类型 / 到期日</label>
<view class="mtask-form-row">
<picker mode="selector" range="{{remTypeLabels}}" value="{{remForm.typeIdx}}" bindchange="onRemType">
<view class="picker-box">{{remTypeLabels[remForm.typeIdx]}}</view>
</picker>
<picker mode="date" value="{{remForm.date}}" bindchange="onRemDate">
<view class="picker-box">{{remForm.date || '选择到期日'}}</view>
</picker>
</view>
</view>
<view class="field"><label>重复频率(可选)</label>
<input class="input" placeholder="例如:每月一次;填了可不选到期日" placeholder-class="placeholder" value="{{remForm.freq}}" bindinput="onRemFreq"/></view>
<button class="btn btn-primary btn-block" bindtap="onSaveReminder">{{remForm.id ? '保存修改' : '添加提醒'}}</button>
</block> </block>
<!-- 任务详情 --> <!-- 任务详情 -->
@@ -211,7 +235,10 @@ module.exports.sel = function (map, key, index, def) {
<view wx:for="{{proInfo.features}}" wx:key="*this" class="check selected"><view class="box">✓</view>{{item}}</view> <view wx:for="{{proInfo.features}}" wx:key="*this" class="check selected"><view class="box">✓</view>{{item}}</view>
</view> </view>
<button wx:if="{{proInfo.status === 'active'}}" class="btn btn-soft btn-block" bindtap="close">已开通 Pro ✓</button> <button wx:if="{{proInfo.status === 'active'}}" class="btn btn-soft btn-block" bindtap="close">已开通 Pro ✓</button>
<button wx:else class="btn btn-primary btn-block" bindtap="onActivatePro">¥29.9 / 年 开通 Pro</button> <button wx:else class="btn btn-soft btn-block" disabled="true">即将开放</button>
<view wx:if="{{proInfo.status !== 'active'}}" class="sheet-p" style="text-align:center;margin-top:16rpx;font-size:24rpx">
会员功能还在准备中,暂未开放购买。
</view>
</block> </block>
<!-- 添加宠物 --> <!-- 添加宠物 -->
@@ -312,12 +339,59 @@ module.exports.sel = function (map, key, index, def) {
</view> </view>
</block> </block>
<!-- 分享帖子 --> <!-- 多宠物管理 -->
<block wx:elif="{{innerType === 'sharePost'}}"> <block wx:elif="{{innerType === 'managePets'}}">
<view class="sheet-h3">分享帖子</view> <view class="sheet-h3">多宠物管理</view>
<view class="sheet-p">转发给微信好友、微信群,或生成一张分享卡片。</view> <view class="sheet-p">点一只切换为当前宠物,或编辑它的档案。</view>
<button class="btn btn-primary btn-block" bindtap="close">生成分享卡片</button> <view wx:for="{{myPets}}" wx:key="id" class="mtask-row">
<view class="mtask-body" catchtap="onSwitchPet" data-id="{{item.id}}">
<text class="mtask-b">{{item.emoji}} {{item.name}}<text wx:if="{{item.current}}" class="mtask-pri">当前</text></text>
<view class="mtask-p">{{item.meta}}</view>
</view>
<view class="mtask-op" catchtap="onEditPetFromList" data-id="{{item.id}}">编辑</view>
</view>
<view wx:if="{{!myPets.length}}" class="sheet-p">还没有毛孩子。</view>
<button class="btn btn-primary btn-block" style="margin-top:16rpx" data-type="addPet" bindtap="goSheet"> 添加毛孩子</button>
</block> </block>
<!-- 导出健康档案 -->
<block wx:elif="{{innerType === 'exportData'}}">
<view class="sheet-h3">导出健康档案</view>
<view class="sheet-p">整理成一段文字,可复制后发给医生或存到备忘录。</view>
<view class="export-box">{{exportText || '正在整理…'}}</view>
<button class="btn btn-primary btn-block" bindtap="onCopyExport">复制全部内容</button>
</block>
<!-- 意见反馈 -->
<block wx:elif="{{innerType === 'feedback'}}">
<view class="sheet-h3">意见反馈</view>
<block wx:if="{{!fbDone}}">
<view class="sheet-p">用着哪儿别扭、想要什么功能,都可以告诉我们。</view>
<view class="field"><label>你的反馈</label>
<textarea class="textarea" placeholder="例如:希望能记录洗澡时间" placeholder-class="placeholder" value="{{fbContent}}" bindinput="onFbContent"></textarea></view>
<view class="field"><label>联系方式(可选)</label>
<input class="input" placeholder="微信号或邮箱,方便回复你" placeholder-class="placeholder" value="{{fbContact}}" bindinput="onFbContact"/></view>
<button class="btn btn-primary btn-block" bindtap="onSubmitFeedback" disabled="{{saving}}">{{saving ? '提交中…' : '提交反馈'}}</button>
</block>
<block wx:else>
<view class="sheet-p">已收到,谢谢你的反馈 🐾</view>
<button class="btn btn-primary btn-block" bindtap="close">好的</button>
</block>
</block>
<!-- 关于我们 -->
<block wx:elif="{{innerType === 'about'}}">
<view class="sheet-h3">关于肉垫计划</view>
<view class="sheet-p pre">肉垫计划是给新手养宠人做的照护记录工具。
按猫狗的品类和成长阶段生成每日照护任务和养护计划,随手记录体重、饮食、排便、用药、疫苗和开支,疫苗驱虫到期前提醒你,并把这些整理成成长报告。
版本:v0.2
反馈:我的 → 意见反馈</view>
<view class="sheet-p" style="color:var(--muted);font-size:24rpx">本小程序提供的是记录与观察建议,不能替代医生诊断。宠物出现持续异常请及时就医。</view>
<button class="btn btn-primary btn-block" bindtap="close">知道了</button>
</block>
</scroll-view> </scroll-view>
</view> </view>
@@ -53,3 +53,11 @@
.img-thumb image{width:100%;height:100%} .img-thumb image{width:100%;height:100%}
.img-del{position:absolute;top:0;right:0;width:40rpx;height:40rpx;line-height:38rpx;text-align:center;background:rgba(0,0,0,.55);color:#fff;font-size:30rpx;border-radius:0 20rpx 0 20rpx} .img-del{position:absolute;top:0;right:0;width:40rpx;height:40rpx;line-height:38rpx;text-align:center;background:rgba(0,0,0,.55);color:#fff;font-size:30rpx;border-radius:0 20rpx 0 20rpx}
.img-add{width:160rpx;height:160rpx;border-radius:20rpx;border:2rpx dashed #D8CFC4;display:flex;align-items:center;justify-content:center;font-size:56rpx;color:#B9AE9F;background:#FBFAF8} .img-add{width:160rpx;height:160rpx;border-radius:20rpx;border:2rpx dashed #D8CFC4;display:flex;align-items:center;justify-content:center;font-size:56rpx;color:#B9AE9F;background:#FBFAF8}
/* 导出健康档案 */
.export-box{
max-height:560rpx;overflow-y:auto;background:#FBFAF8;border-radius:28rpx;
padding:24rpx;font-size:25rpx;line-height:1.7;color:#4A4A46;white-space:pre-line;
border:1rpx solid var(--line);margin-bottom:24rpx;
}
.export-box::-webkit-scrollbar{width:0;height:0;display:none}
+31
View File
@@ -0,0 +1,31 @@
const api = require('../../utils/api.js');
Page({
data: {
article: null,
loading: true,
error: '',
},
onLoad(options) {
const id = options.id;
if (!id) {
this.setData({ loading: false, error: '文章不存在' });
return;
}
api
.getArticle(id)
.then((a) => this.setData({ article: a, loading: false }))
.catch((e) => this.setData({ loading: false, error: e.message || '加载失败' }));
},
// 从文章跳到对应的记录入口
goRecord() {
wx.switchTab({ url: '/pages/record/record' });
},
onShareAppMessage() {
const a = this.data.article;
return {
title: a ? a.title : '肉垫计划 · 新手养宠知识',
path: a ? `/pages/article/article?id=${a.id}` : '/pages/home/home',
};
},
});
+7
View File
@@ -0,0 +1,7 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar"
},
"navigationStyle": "custom",
"navigationBarTitleText": "新手知识"
}
+24
View File
@@ -0,0 +1,24 @@
<nav-bar title="新手知识" show-back="{{true}}"></nav-bar>
<scroll-view class="page-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="page-body">
<view wx:if="{{loading}}" class="hint">加载中…</view>
<view wx:elif="{{error}}" class="hint">{{error}}</view>
<block wx:elif="{{article}}">
<view class="art-head">
<view class="art-icon">{{article.icon}}</view>
<view class="art-title">{{article.title}}</view>
<view wx:if="{{article.description}}" class="art-desc">{{article.description}}</view>
</view>
<view class="card">
<view wx:if="{{article.content}}" class="art-content">{{article.content}}</view>
<view wx:else class="hint">这篇文章还没有正文。</view>
</view>
<button class="btn btn-primary btn-block" bindtap="goRecord">去记一笔</button>
<view class="art-foot">内容仅供日常照护参考,不能替代医生诊断。</view>
</block>
</view>
</scroll-view>
+17
View File
@@ -0,0 +1,17 @@
page{height:100vh;overflow:hidden;display:flex;flex-direction:column}
.page-scroll{flex:1;min-height:0}
.page-body{padding:8rpx 36rpx calc(60rpx + env(safe-area-inset-bottom))}
.hint{padding:60rpx 0;text-align:center;color:var(--muted);font-size:28rpx}
.art-head{padding:12rpx 0 28rpx}
.art-icon{font-size:72rpx;line-height:1;margin-bottom:16rpx}
.art-title{font-size:44rpx;font-weight:800;line-height:1.35;letter-spacing:-.5rpx}
.art-desc{margin-top:14rpx;color:var(--muted);font-size:27rpx;line-height:1.6}
.art-content{
font-size:29rpx;line-height:1.85;color:#3D352E;
white-space:pre-line; /* 正文用真实换行排版 */
}
.art-foot{margin-top:28rpx;text-align:center;color:var(--muted);font-size:23rpx}
+32
View File
@@ -18,6 +18,9 @@ function mapPost(p) {
tag, tag,
tagClass: TAG_CLASS[tag] || '', tagClass: TAG_CLASS[tag] || '',
is_ai: !!p.is_ai, is_ai: !!p.is_ai,
user_id: p.user_id,
followed: !!p.followed,
is_self: !!p.is_self,
}; };
} }
@@ -92,4 +95,33 @@ Page({
onFab() { onFab() {
this.setData({ sheetType: 'ai', sheetPostId: 0, sheetShow: true }); this.setData({ sheetType: 'ai', sheetPostId: 0, sheetShow: true });
}, },
// 关注 / 取关帖子作者
toggleFollow(e) {
const i = e.currentTarget.dataset.index;
const p = this.data.posts[i];
if (!p || !p.user_id || p.is_self) return;
const fn = p.followed ? api.unfollowUser : api.followUser;
fn(p.user_id)
.then(() => {
// 同一作者的所有帖子一起更新状态
const posts = this.data.posts.map((x) =>
x.user_id === p.user_id ? { ...x, followed: !p.followed } : x,
);
this.setData({ posts });
wx.showToast({ title: p.followed ? '已取消关注' : '已关注', icon: 'none' });
if (this.data.feedTabs[this.data.feedIdx] === '关注') this.loadPosts();
})
.catch((err) => wx.showToast({ title: err.message || '操作失败', icon: 'none' }));
},
onShareAppMessage(e) {
// 从帖子上的分享按钮转发:带上该帖内容做标题
if (e && e.from === 'button') {
const c = (e.target.dataset.content || '').slice(0, 40);
return { title: c ? '宠友圈:' + c : '来宠友圈看看大家的毛孩子', path: '/pages/community/community' };
}
return { title: '宠友圈 · 和铲屎官们交流养宠经验', path: '/pages/community/community' };
},
onShareTimeline() {
return { title: '宠友圈 · 和铲屎官们交流养宠经验' };
},
}); });
+3 -1
View File
@@ -23,6 +23,8 @@ module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; }
<view class="post-head"> <view class="post-head">
<view class="post-avatar">{{item.author_emoji}}</view> <view class="post-avatar">{{item.author_emoji}}</view>
<view class="post-user"><text class="pu-b">{{item.author_name}}<text wx:if="{{item.is_ai}}" class="ai-tag">AI</text></text><text class="pu-s">宠友</text></view> <view class="post-user"><text class="pu-b">{{item.author_name}}<text wx:if="{{item.is_ai}}" class="ai-tag">AI</text></text><text class="pu-s">宠友</text></view>
<view wx:if="{{!item.is_self && item.user_id}}" class="follow-btn {{item.followed ? 'on' : ''}}"
catchtap="toggleFollow" data-index="{{index}}">{{item.followed ? '已关注' : '+ 关注'}}</view>
<view wx:if="{{item.tag}}" class="tag {{item.tagClass}}">{{item.tag}}</view> <view wx:if="{{item.tag}}" class="tag {{item.tagClass}}">{{item.tag}}</view>
</view> </view>
<view class="post-content">{{item.content}}</view> <view class="post-content">{{item.content}}</view>
@@ -35,7 +37,7 @@ module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; }
<view class="post-actions"> <view class="post-actions">
<view class="pa-btn {{item.liked ? 'liked' : ''}}" data-index="{{index}}" bindtap="likePost">{{item.liked ? '♥' : '♡'}} {{item.like_count}}</view> <view class="pa-btn {{item.liked ? 'liked' : ''}}" data-index="{{index}}" bindtap="likePost">{{item.liked ? '♥' : '♡'}} {{item.like_count}}</view>
<view class="pa-btn" data-id="{{item.id}}" bindtap="openComments">💬 {{item.comment_count}}</view> <view class="pa-btn" data-id="{{item.id}}" bindtap="openComments">💬 {{item.comment_count}}</view>
<view class="pa-btn" data-type="sharePost" bindtap="openSheet">↗ 分享</view> <button class="pa-btn pa-share" open-type="share" data-content="{{item.content}}">↗ 分享</button>
</view> </view>
</view> </view>
+14
View File
@@ -39,3 +39,17 @@
/* 页面不自身滚动,改由 page-scroll 承载滚动(隐藏滚动条)*/ /* 页面不自身滚动,改由 page-scroll 承载滚动(隐藏滚动条)*/
page{height:100vh;overflow:hidden;display:flex;flex-direction:column} page{height:100vh;overflow:hidden;display:flex;flex-direction:column}
.page-scroll{flex:1;min-height:0} .page-scroll{flex:1;min-height:0}
/* 帖子分享按钮:用微信原生 open-type=share,需抹掉 button 默认外观 */
.pa-share{
background:transparent;border:none;padding:0;margin:0;line-height:inherit;
font-size:26rpx;font-weight:900;color:var(--muted);
}
.pa-share::after{border:none}
/* 关注按钮 */
.follow-btn{
flex:none;font-size:22rpx;font-weight:700;padding:6rpx 18rpx;border-radius:999rpx;
color:var(--primary-dark);background:#FFF2DC;border:1rpx solid #FFD39A;
}
.follow-btn.on{color:var(--muted);background:#F4F1EC;border-color:var(--line)}
+13 -1
View File
@@ -60,7 +60,7 @@ Page({
.listArticles() .listArticles()
.then((list) => { .then((list) => {
if (list && list[0]) { if (list && list[0]) {
this.setData({ firstArticle: { icon: list[0].icon, title: list[0].title, desc: list[0].description } }); this.setData({ firstArticle: { id: list[0].id, icon: list[0].icon, title: list[0].title, desc: list[0].description } });
} }
}) })
.catch(() => {}); .catch(() => {});
@@ -152,7 +152,19 @@ Page({
goLearn() { goLearn() {
wx.navigateTo({ url: '/pages/learn/learn' }); wx.navigateTo({ url: '/pages/learn/learn' });
}, },
// 首页那张卡片直接打开这篇文章正文
openFirstArticle() {
const a = this.data.firstArticle;
if (a && a.id) wx.navigateTo({ url: `/pages/article/article?id=${a.id}` });
else this.goLearn();
},
goRecord() { goRecord() {
wx.switchTab({ url: '/pages/record/record' }); wx.switchTab({ url: '/pages/record/record' });
}, },
onShareAppMessage() {
return { title: '肉垫计划 · 每天照顾好毛孩子', path: '/pages/home/home' };
},
onShareTimeline() {
return { title: '肉垫计划 · 每天照顾好毛孩子' };
},
}); });
+1 -1
View File
@@ -88,7 +88,7 @@
<view class="sh-title">新手内容</view> <view class="sh-title">新手内容</view>
<view class="link" bindtap="goLearn">查看</view> <view class="link" bindtap="goLearn">查看</view>
</view> </view>
<view wx:if="{{firstArticle}}" class="article-card no-shadow" style="margin-bottom:0" bindtap="goLearn"> <view wx:if="{{firstArticle}}" class="article-card no-shadow" style="margin-bottom:0" bindtap="openFirstArticle">
<view class="article-icon">{{firstArticle.icon}}</view> <view class="article-icon">{{firstArticle.icon}}</view>
<view><text class="ac-b">{{firstArticle.title}}</text><view class="ac-p">{{firstArticle.desc}}</view></view> <view><text class="ac-b">{{firstArticle.title}}</text><view class="ac-p">{{firstArticle.desc}}</view></view>
</view> </view>
+12 -1
View File
@@ -14,15 +14,20 @@ Page({
.then((list) => { .then((list) => {
this.setData({ this.setData({
articles: (list || []).map((a) => ({ articles: (list || []).map((a) => ({
id: a.id,
icon: a.icon, icon: a.icon,
title: a.title, title: a.title,
desc: a.description, desc: a.description,
type: a.related_sheet_type || 'vaccine',
})), })),
}); });
}) })
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' })); .catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
}, },
// 打开文章正文详情页
openArticle(e) {
const id = e.currentTarget.dataset.id;
if (id) wx.navigateTo({ url: `/pages/article/article?id=${id}` });
},
openSheet(e) { openSheet(e) {
this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true }); this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true });
}, },
@@ -32,4 +37,10 @@ Page({
onFab() { onFab() {
this.setData({ sheetType: 'ai', sheetShow: true }); this.setData({ sheetType: 'ai', sheetShow: true });
}, },
onShareAppMessage() {
return { title: '新手养宠知识合集', path: '/pages/learn/learn' };
},
onShareTimeline() {
return { title: '新手养宠知识合集' };
},
}); });
+1 -1
View File
@@ -7,7 +7,7 @@
<view class="pt-p">新手养宠知识,看完顺手生成你的计划。</view> <view class="pt-p">新手养宠知识,看完顺手生成你的计划。</view>
</view> </view>
<view wx:for="{{articles}}" wx:key="type" class="article-card" data-type="{{item.type}}" bindtap="openSheet"> <view wx:for="{{articles}}" wx:key="id" class="article-card" data-id="{{item.id}}" bindtap="openArticle">
<view class="article-icon">{{item.icon}}</view> <view class="article-icon">{{item.icon}}</view>
<view><text class="ac-b">{{item.title}}</text><view class="ac-p">{{item.desc}}</view></view> <view><text class="ac-b">{{item.title}}</text><view class="ac-p">{{item.desc}}</view></view>
</view> </view>
+6
View File
@@ -172,4 +172,10 @@ Page({
onFab() { onFab() {
this.setData({ sheetType: 'ai', sheetShow: true }); this.setData({ sheetType: 'ai', sheetShow: true });
}, },
onShareAppMessage() {
return { title: '我给毛孩子做了份养护计划', path: '/pages/plan/plan' };
},
onShareTimeline() {
return { title: '我给毛孩子做了份养护计划' };
},
}); });
+6
View File
@@ -40,6 +40,9 @@ Page({
openSheet(e) { openSheet(e) {
this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true }); this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true });
}, },
goRecord() {
wx.switchTab({ url: '/pages/record/record' });
},
onPickAvatar() { onPickAvatar() {
upload upload
.chooseAndUploadImage() .chooseAndUploadImage()
@@ -67,4 +70,7 @@ Page({
onFab() { onFab() {
this.setData({ sheetType: 'ai', sheetShow: true }); this.setData({ sheetType: 'ai', sheetShow: true });
}, },
onShareAppMessage() {
return { title: '肉垫计划 · 新手养宠助手', path: '/pages/home/home' };
},
}); });
+8 -8
View File
@@ -17,19 +17,19 @@
<view class="profile-list"> <view class="profile-list">
<view class="profile-row" bindtap="onEditPet">宠物档案(编辑)<text class="pr-val">已完善 {{profilePct}}% </text></view> <view class="profile-row" bindtap="onEditPet">宠物档案(编辑)<text class="pr-val">已完善 {{profilePct}}% </text></view>
<view class="profile-row">提醒设置 <text class="pr-val">{{summary.reminders}} 条</text></view> <view class="profile-row" data-type="reminders" bindtap="openSheet">提醒设置 <text class="pr-val">{{summary.reminders}} 条 </text></view>
<view class="profile-row">健康记录 <text class="pr-val">{{summary.records}} 条</text></view> <view class="profile-row" bindtap="goRecord">健康记录 <text class="pr-val">{{summary.records}} 条 </text></view>
<view class="profile-row">多宠物管理 <text class="pr-val">{{summary.pets}} 只</text></view> <view class="profile-row" data-type="managePets" bindtap="openSheet">多宠物管理 <text class="pr-val">{{summary.pets}} 只 </text></view>
</view> </view>
<view class="profile-list"> <view class="profile-list">
<view class="profile-row">会员权益 <text class="pr-val">{{summary.pro_status === 'active' ? '已开通' : '未开通'}}</text></view> <view class="profile-row" data-type="pro" bindtap="openSheet">会员权益 <text class="pr-val">{{summary.pro_status === 'active' ? '已开通' : '未开通'}} </text></view>
<view class="profile-row">数据导出 <text class="pr-val">PDF</text></view> <view class="profile-row" data-type="exportData" bindtap="openSheet">数据导出 <text class="pr-val">文本 </text></view>
<view class="profile-row">意见反馈 <text class="pr-val">去填写</text></view> <view class="profile-row" data-type="feedback" bindtap="openSheet">意见反馈 <text class="pr-val">去填写 </text></view>
<view class="profile-row">关于我们 <text class="pr-val">v0.2</text></view> <view class="profile-row" data-type="about" bindtap="openSheet">关于我们 <text class="pr-val">v0.2 </text></view>
</view> </view>
<button class="btn btn-primary btn-block" data-type="pro" bindtap="openSheet">开通 Pro</button> <button class="btn btn-ghost btn-block" data-type="pro" bindtap="openSheet">Pro 会员(即将开放)</button>
</view> </view>
</scroll-view> </scroll-view>
+6
View File
@@ -174,4 +174,10 @@ Page({
onFab() { onFab() {
this.openSheetType('ai'); this.openSheetType('ai');
}, },
onShareAppMessage() {
return { title: '用肉垫计划记录毛孩子的成长', path: '/pages/record/record' };
},
onShareTimeline() {
return { title: '用肉垫计划记录毛孩子的成长' };
},
}); });
+6
View File
@@ -59,4 +59,10 @@ Page({
onFab() { onFab() {
this.setData({ sheetType: 'ai', sheetShow: true }); this.setData({ sheetType: 'ai', sheetShow: true });
}, },
onShareAppMessage() {
return { title: '看看我家毛孩子的成长报告', path: '/pages/report/report' };
},
onShareTimeline() {
return { title: '看看我家毛孩子的成长报告' };
},
}); });
+6
View File
@@ -75,6 +75,9 @@ const api = {
// 提醒 // 提醒
getReminders: (id) => request({ url: `/api/pets/${id}/reminders` }), getReminders: (id) => request({ url: `/api/pets/${id}/reminders` }),
createReminder: (id, body) => request({ url: `/api/pets/${id}/reminders`, method: 'POST', data: body }),
updateReminder: (remId, body) => request({ url: `/api/reminders/${remId}`, method: 'PUT', data: body }),
deleteReminder: (remId) => request({ url: `/api/reminders/${remId}`, method: 'DELETE' }),
// 报告 // 报告
weeklyReport: (id) => request({ url: `/api/pets/${id}/report/weekly` }), weeklyReport: (id) => request({ url: `/api/pets/${id}/report/weekly` }),
@@ -87,12 +90,15 @@ const api = {
request({ url: `/api/posts?tab=${encodeURIComponent(tab || '')}&page=${page || 1}&page_size=10` }), request({ url: `/api/posts?tab=${encodeURIComponent(tab || '')}&page=${page || 1}&page_size=10` }),
createPost: (body) => request({ url: '/api/posts', method: 'POST', data: body }), createPost: (body) => request({ url: '/api/posts', method: 'POST', data: body }),
likePost: (id) => request({ url: `/api/posts/${id}/like`, method: 'POST' }), likePost: (id) => request({ url: `/api/posts/${id}/like`, method: 'POST' }),
followUser: (userId) => request({ url: `/api/users/${userId}/follow`, method: 'POST' }),
unfollowUser: (userId) => request({ url: `/api/users/${userId}/follow`, method: 'DELETE' }),
listComments: (id) => request({ url: `/api/posts/${id}/comments` }), listComments: (id) => request({ url: `/api/posts/${id}/comments` }),
createComment: (id, content) => createComment: (id, content) =>
request({ url: `/api/posts/${id}/comments`, method: 'POST', data: { content } }), request({ url: `/api/posts/${id}/comments`, method: 'POST', data: { content } }),
// 文章 // 文章
listArticles: () => request({ url: '/api/articles' }), listArticles: () => request({ url: '/api/articles' }),
submitFeedback: (body) => request({ url: '/api/feedback', method: 'POST', data: body }),
getArticle: (id) => request({ url: `/api/articles/${id}` }), getArticle: (id) => request({ url: `/api/articles/${id}` }),
// Pro // Pro