feat: 记录删除与筛选、用户侧内容删除、AI 聊天历史
修复雪花 ID 精度丢失(真 bug) - bottom-sheet 的 postId 声明为 Number,而 ID 是 18 位雪花字符串, 超出 JS 安全整数范围:339346584095428608 会被转成 ...600, 评论弹层实际查的是不存在的帖子。改为 String 记录 - 后端 DELETE /records/:id 早已就绪但前端从未调用,记错了删不掉。 时间轴支持长按删除 - 时间轴加类型筛选(体重/便便/饮食/异常/用药/疫苗/消费/照片), 后端 ?type= 本就支持 用户侧内容删除(UGC 合规:用户应能删除自己发布的内容) - 新增 DELETE /posts/:id 与 DELETE /comments/:id,仅限本人, 越权返回 40300;帖子软删除,评论删除同步递减 comment_count - 评论列表返回 is_self;社区长按自己的帖子、长按自己的评论可删 AI 聊天历史 - ai_messages 一直在写但没有查询接口,每次打开弹层都是空白。 新增 GET /api/ai/messages,弹层打开时接在开场白后加载 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -104,7 +104,7 @@ func (h *Handler) ListComments(c *gin.Context) {
|
|||||||
_ = c.ShouldBindQuery(&req)
|
_ = c.ShouldBindQuery(&req)
|
||||||
req.Normalize()
|
req.Normalize()
|
||||||
|
|
||||||
comments, total, err := h.svc.ListComments(idParam(c, "id"), req.Offset(), req.Limit())
|
comments, total, err := h.svc.ListComments(middleware.UserID(c), idParam(c, "id"), req.Offset(), req.Limit())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.FailErr(c, err)
|
response.FailErr(c, err)
|
||||||
return
|
return
|
||||||
@@ -152,3 +152,21 @@ func (h *Handler) UnfollowUser(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
response.OK(c, gin.H{"followed": false})
|
response.OK(c, gin.H{"followed": false})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteOwnPost DELETE /api/posts/:id (仅限本人)
|
||||||
|
func (h *Handler) DeleteOwnPost(c *gin.Context) {
|
||||||
|
if err := h.svc.DeleteOwnPost(middleware.UserID(c), idParam(c, "id")); err != nil {
|
||||||
|
respondErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"deleted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOwnComment DELETE /api/comments/:id (仅限本人)
|
||||||
|
func (h *Handler) DeleteOwnComment(c *gin.Context) {
|
||||||
|
if err := h.svc.DeleteOwnComment(middleware.UserID(c), idParam(c, "id")); err != nil {
|
||||||
|
respondErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"deleted": true})
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,5 +27,9 @@ func respondErr(c *gin.Context, err error) {
|
|||||||
response.Fail(c, errcode.ErrNotFound, "")
|
response.Fail(c, errcode.ErrNotFound, "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, service.ErrForbidden) {
|
||||||
|
response.Fail(c, errcode.ErrForbidden, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
response.FailErr(c, err)
|
response.FailErr(c, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -173,3 +174,17 @@ func (h *Handler) AdminSetFeedbackHandled(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
response.OK(c, gin.H{"ok": true})
|
response.OK(c, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAIMessages GET /api/ai/messages?session=&limit=
|
||||||
|
func (h *Handler) ListAIMessages(c *gin.Context) {
|
||||||
|
limit := 0
|
||||||
|
if v := c.Query("limit"); v != "" {
|
||||||
|
limit, _ = strconv.Atoi(v)
|
||||||
|
}
|
||||||
|
msgs, err := h.svc.ListAIMessages(middleware.UserID(c), c.Query("session"), limit)
|
||||||
|
if err != nil {
|
||||||
|
response.FailErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, msgs)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,4 +8,6 @@ type Comment struct {
|
|||||||
AuthorName string `gorm:"size:64" json:"author_name"`
|
AuthorName string `gorm:"size:64" json:"author_name"`
|
||||||
Content string `gorm:"size:512" json:"content"`
|
Content string `gorm:"size:512" json:"content"`
|
||||||
Status string `gorm:"size:16;default:published" json:"status"`
|
Status string `gorm:"size:16;default:published" json:"status"`
|
||||||
|
|
||||||
|
IsSelf bool `gorm:"-" json:"is_self"` // 计算字段:是不是我自己发的
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
|
|||||||
g.DELETE("/posts/:id/like", h.UnlikePost)
|
g.DELETE("/posts/:id/like", h.UnlikePost)
|
||||||
g.GET("/posts/:id/comments", h.ListComments)
|
g.GET("/posts/:id/comments", h.ListComments)
|
||||||
g.POST("/posts/:id/comments", h.CreateComment)
|
g.POST("/posts/:id/comments", h.CreateComment)
|
||||||
|
g.DELETE("/posts/:id", h.DeleteOwnPost)
|
||||||
|
g.DELETE("/comments/:id", h.DeleteOwnComment)
|
||||||
|
|
||||||
g.GET("/articles", h.ListArticles)
|
g.GET("/articles", h.ListArticles)
|
||||||
g.POST("/feedback", h.CreateFeedback)
|
g.POST("/feedback", h.CreateFeedback)
|
||||||
@@ -103,6 +105,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
|
|||||||
g.POST("/pro/activate", h.ActivatePro)
|
g.POST("/pro/activate", h.ActivatePro)
|
||||||
|
|
||||||
g.POST("/ai/chat", h.AIChat)
|
g.POST("/ai/chat", h.AIChat)
|
||||||
|
g.GET("/ai/messages", h.ListAIMessages)
|
||||||
g.POST("/pets/:id/ai/assess-symptom", h.AssessSymptom)
|
g.POST("/pets/:id/ai/assess-symptom", h.AssessSymptom)
|
||||||
g.POST("/upload", h.Upload)
|
g.POST("/upload", h.Upload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,3 +29,23 @@ func (s *Service) AIChat(userID string, petID *string, session, text string) (st
|
|||||||
func (s *Service) Upload(objectName string, reader io.Reader, size int64, contentType string) (string, error) {
|
func (s *Service) Upload(objectName string, reader io.Reader, size int64, contentType string) (string, error) {
|
||||||
return s.storage.Upload(objectName, reader, size, contentType)
|
return s.storage.Upload(objectName, reader, size, contentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAIMessages 取当前用户的聊天历史(按会话,默认最近 N 条,返回时间正序)
|
||||||
|
func (s *Service) ListAIMessages(userID, session string, limit int) ([]model.AIMessage, error) {
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 30
|
||||||
|
}
|
||||||
|
q := s.db.Where("user_id = ?", userID)
|
||||||
|
if session != "" {
|
||||||
|
q = q.Where("session = ?", session)
|
||||||
|
}
|
||||||
|
var msgs []model.AIMessage
|
||||||
|
if err := q.Order("id desc").Limit(limit).Find(&msgs).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 反转成时间正序,前端直接按顺序渲染
|
||||||
|
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
msgs[i], msgs[j] = msgs[j], msgs[i]
|
||||||
|
}
|
||||||
|
return msgs, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ func (s *Service) postLikeCount(postID string) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListComments 评论分页
|
// ListComments 评论分页
|
||||||
func (s *Service) ListComments(postID string, offset, limit int) ([]model.Comment, int64, error) {
|
func (s *Service) ListComments(userID, postID string, offset, limit int) ([]model.Comment, int64, error) {
|
||||||
q := s.db.Model(&model.Comment{}).Where("post_id = ? AND status = ?", postID, "published")
|
q := s.db.Model(&model.Comment{}).Where("post_id = ? AND status = ?", postID, "published")
|
||||||
var total int64
|
var total int64
|
||||||
if err := q.Count(&total).Error; err != nil {
|
if err := q.Count(&total).Error; err != nil {
|
||||||
@@ -202,6 +202,9 @@ func (s *Service) ListComments(postID string, offset, limit int) ([]model.Commen
|
|||||||
if err := q.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error; err != nil {
|
if err := q.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
for i := range comments {
|
||||||
|
comments[i].IsSelf = comments[i].UserID == userID
|
||||||
|
}
|
||||||
return comments, total, nil
|
return comments, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,3 +230,38 @@ func (s *Service) CreateComment(userID, postID string, content string) (*model.C
|
|||||||
}
|
}
|
||||||
return &comment, nil
|
return &comment, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteOwnPost 用户删除自己的帖子(软删除,保留数据可追溯)
|
||||||
|
func (s *Service) DeleteOwnPost(userID, postID string) error {
|
||||||
|
var p model.Post
|
||||||
|
if err := s.db.Where("id = ?", postID).First(&p).Error; err != nil {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if p.UserID != userID {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
return s.db.Model(&model.Post{}).Where("id = ?", postID).
|
||||||
|
Update("status", model.PostDeleted).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOwnComment 用户删除自己的评论,并同步帖子评论数
|
||||||
|
func (s *Service) DeleteOwnComment(userID, commentID string) error {
|
||||||
|
var c model.Comment
|
||||||
|
if err := s.db.Where("id = ?", commentID).First(&c).Error; err != nil {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if c.UserID != userID {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
if c.Status == "deleted" {
|
||||||
|
return nil // 幂等
|
||||||
|
}
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&model.Comment{}).Where("id = ?", commentID).
|
||||||
|
Update("status", "deleted").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&model.Post{}).Where("id = ? AND comment_count > 0", c.PostID).
|
||||||
|
UpdateColumn("comment_count", gorm.Expr("comment_count - 1")).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ var ErrNotFound = errors.New("not found")
|
|||||||
// 避免「点一下白拿会员」。
|
// 避免「点一下白拿会员」。
|
||||||
var ErrPayNotReady = errors.New("支付功能即将开放,敬请期待")
|
var ErrPayNotReady = errors.New("支付功能即将开放,敬请期待")
|
||||||
|
|
||||||
|
// ErrForbidden 越权操作(如删除别人的内容)
|
||||||
|
var ErrForbidden = errors.New("无权操作")
|
||||||
|
|
||||||
// Service 业务逻辑聚合,方法按领域分散在各文件
|
// Service 业务逻辑聚合,方法按领域分散在各文件
|
||||||
type Service struct {
|
type Service struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ Component({
|
|||||||
properties: {
|
properties: {
|
||||||
show: { type: Boolean, value: false },
|
show: { type: Boolean, value: false },
|
||||||
type: { type: String, value: '' },
|
type: { type: String, value: '' },
|
||||||
postId: { type: Number, value: 0 },
|
// 雪花 ID 是 18 位字符串,声明成 Number 会超出 JS 安全整数范围而丢精度
|
||||||
|
postId: { type: String, value: '' },
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
innerType: '',
|
innerType: '',
|
||||||
@@ -182,13 +183,15 @@ Component({
|
|||||||
} else if (type === 'exportData') {
|
} else if (type === 'exportData') {
|
||||||
this.buildExport();
|
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(() => {});
|
this.loadComments();
|
||||||
} else if (type === 'reminders' && id) {
|
} else if (type === 'reminders' && id) {
|
||||||
this.loadReminders();
|
this.loadReminders();
|
||||||
} 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) {
|
||||||
api.weeklyReport(id).then((r) => this.setData({ reportDetail: r })).catch(() => {});
|
api.weeklyReport(id).then((r) => this.setData({ reportDetail: r })).catch(() => {});
|
||||||
|
} else if (type === 'ai') {
|
||||||
|
this.loadAIHistory();
|
||||||
} else if (type === 'pro') {
|
} else if (type === 'pro') {
|
||||||
api.getPro().then((p) => this.setData({ proInfo: p })).catch(() => {});
|
api.getPro().then((p) => this.setData({ proInfo: p })).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -244,6 +247,28 @@ Component({
|
|||||||
onCostNote(e) { this.setData({ costNote: 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 }); },
|
||||||
|
loadComments() {
|
||||||
|
if (!this.data.postId) return;
|
||||||
|
api.listComments(this.data.postId)
|
||||||
|
.then((page) => this.setData({ comments: page.list || [] }))
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
// 长按删除自己的评论
|
||||||
|
onDeleteComment(e) {
|
||||||
|
const c = this.data.comments[e.currentTarget.dataset.index];
|
||||||
|
if (!c || !c.is_self) return;
|
||||||
|
wx.showModal({
|
||||||
|
title: '删除评论',
|
||||||
|
content: '确定删除这条评论?',
|
||||||
|
confirmColor: '#D9534F',
|
||||||
|
success: (res) => {
|
||||||
|
if (!res.confirm) return;
|
||||||
|
api.deleteComment(c.id)
|
||||||
|
.then(() => { this.loadComments(); this.triggerEvent('commented'); })
|
||||||
|
.catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
// ---- 多宠物管理 ----
|
// ---- 多宠物管理 ----
|
||||||
loadMyPets() {
|
loadMyPets() {
|
||||||
const cur = store.currentPetId();
|
const cur = store.currentPetId();
|
||||||
@@ -722,6 +747,17 @@ Component({
|
|||||||
|
|
||||||
// AI 聊天
|
// AI 聊天
|
||||||
onAiInput(e) { this.setData({ aiInput: e.detail.value }); },
|
onAiInput(e) { this.setData({ aiInput: e.detail.value }); },
|
||||||
|
// 拉取历史对话,接在开场白之后;没有历史就保持开场白
|
||||||
|
loadAIHistory() {
|
||||||
|
api
|
||||||
|
.aiMessages('sheet', 30)
|
||||||
|
.then((list) => {
|
||||||
|
if (!list || !list.length) return;
|
||||||
|
const history = list.map((m) => ({ role: m.role === 'user' ? 'user' : 'ai', text: m.text }));
|
||||||
|
this.setData({ aiMessages: this.data.aiMessages.concat(history) });
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
sendAI(e) {
|
sendAI(e) {
|
||||||
const preset = e.currentTarget.dataset.text;
|
const preset = e.currentTarget.dataset.text;
|
||||||
const text = (preset || this.data.aiInput || '').trim();
|
const text = (preset || this.data.aiInput || '').trim();
|
||||||
|
|||||||
@@ -331,8 +331,12 @@ module.exports.sel = function (map, key, index, def) {
|
|||||||
<!-- 评论 -->
|
<!-- 评论 -->
|
||||||
<block wx:elif="{{innerType === 'comments'}}">
|
<block wx:elif="{{innerType === 'comments'}}">
|
||||||
<view class="sheet-h3">评论</view>
|
<view class="sheet-h3">评论</view>
|
||||||
<view wx:for="{{comments}}" wx:key="id" class="record-row"><text class="bold">{{item.author_name}}</text><text class="muted">{{item.content}}</text></view>
|
<view wx:for="{{comments}}" wx:key="id" class="record-row" bindlongpress="onDeleteComment" data-index="{{index}}">
|
||||||
|
<text class="bold">{{item.author_name}}<text wx:if="{{item.is_self}}" class="mine-tag">我</text></text>
|
||||||
|
<text class="muted">{{item.content}}</text>
|
||||||
|
</view>
|
||||||
<view wx:if="{{!comments.length}}" class="sheet-p">还没有评论,来抢沙发~</view>
|
<view wx:if="{{!comments.length}}" class="sheet-p">还没有评论,来抢沙发~</view>
|
||||||
|
<view wx:if="{{comments.length}}" class="sheet-p" style="font-size:22rpx;color:var(--muted)">长按自己的评论可删除</view>
|
||||||
<view class="ai-input-row" style="margin-top:24rpx">
|
<view class="ai-input-row" style="margin-top:24rpx">
|
||||||
<input class="input" placeholder="友善交流,分享经验..." placeholder-class="placeholder" value="{{commentText}}" bindinput="onCommentInput"/>
|
<input class="input" placeholder="友善交流,分享经验..." placeholder-class="placeholder" value="{{commentText}}" bindinput="onCommentInput"/>
|
||||||
<button class="btn btn-primary ai-send" bindtap="onSendComment">发</button>
|
<button class="btn btn-primary ai-send" bindtap="onSendComment">发</button>
|
||||||
|
|||||||
@@ -61,3 +61,4 @@
|
|||||||
border:1rpx solid var(--line);margin-bottom:24rpx;
|
border:1rpx solid var(--line);margin-bottom:24rpx;
|
||||||
}
|
}
|
||||||
.export-box::-webkit-scrollbar{width:0;height:0;display:none}
|
.export-box::-webkit-scrollbar{width:0;height:0;display:none}
|
||||||
|
.mine-tag{margin-left:10rpx;font-size:20rpx;font-weight:700;color:var(--primary-dark);background:#FFF2DC;padding:2rpx 10rpx;border-radius:999rpx}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Page({
|
|||||||
posts: [],
|
posts: [],
|
||||||
sheetShow: false,
|
sheetShow: false,
|
||||||
sheetType: '',
|
sheetType: '',
|
||||||
sheetPostId: 0,
|
sheetPostId: '',
|
||||||
},
|
},
|
||||||
onLoad() {
|
onLoad() {
|
||||||
this._unsub = store.subscribe((pet) => this.setData({ pet }));
|
this._unsub = store.subscribe((pet) => this.setData({ pet }));
|
||||||
@@ -77,7 +77,7 @@ Page({
|
|||||||
this.setData({ sheetPostId: e.currentTarget.dataset.id, sheetType: 'comments', sheetShow: true });
|
this.setData({ sheetPostId: e.currentTarget.dataset.id, sheetType: 'comments', sheetShow: true });
|
||||||
},
|
},
|
||||||
openSheet(e) {
|
openSheet(e) {
|
||||||
this.setData({ sheetType: e.currentTarget.dataset.type, sheetPostId: 0, sheetShow: true });
|
this.setData({ sheetType: e.currentTarget.dataset.type, sheetPostId: '', sheetShow: true });
|
||||||
},
|
},
|
||||||
closeSheet() {
|
closeSheet() {
|
||||||
this.setData({ sheetShow: false });
|
this.setData({ sheetShow: false });
|
||||||
@@ -93,7 +93,23 @@ Page({
|
|||||||
wx.navigateTo({ url: '/pages/learn/learn' });
|
wx.navigateTo({ url: '/pages/learn/learn' });
|
||||||
},
|
},
|
||||||
onFab() {
|
onFab() {
|
||||||
this.setData({ sheetType: 'ai', sheetPostId: 0, sheetShow: true });
|
this.setData({ sheetType: 'ai', sheetPostId: '', sheetShow: true });
|
||||||
|
},
|
||||||
|
// 长按自己的帖子可删除
|
||||||
|
onLongPressPost(e) {
|
||||||
|
const p = this.data.posts[e.currentTarget.dataset.index];
|
||||||
|
if (!p || !p.is_self) return;
|
||||||
|
wx.showModal({
|
||||||
|
title: '删除帖子',
|
||||||
|
content: '确定删除这条帖子?删除后其他人将看不到。',
|
||||||
|
confirmColor: '#D9534F',
|
||||||
|
success: (res) => {
|
||||||
|
if (!res.confirm) return;
|
||||||
|
api.deletePost(p.id)
|
||||||
|
.then(() => { wx.showToast({ title: '已删除', icon: 'success' }); this.loadPosts(); })
|
||||||
|
.catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' }));
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
// 关注 / 取关帖子作者
|
// 关注 / 取关帖子作者
|
||||||
toggleFollow(e) {
|
toggleFollow(e) {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; }
|
|||||||
<button class="btn btn-primary btn-block" bindtap="openSheet" data-type="createPost">+ 发布图文</button>
|
<button class="btn btn-primary btn-block" bindtap="openSheet" data-type="createPost">+ 发布图文</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view wx:for="{{posts}}" wx:key="id" class="post-card">
|
<view wx:for="{{posts}}" wx:key="id" class="post-card" bindlongpress="onLongPressPost" data-index="{{index}}">
|
||||||
<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>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function fmtTime(iso) {
|
|||||||
}
|
}
|
||||||
function mapRecord(r) {
|
function mapRecord(r) {
|
||||||
return {
|
return {
|
||||||
|
id: r.id,
|
||||||
icon: r.icon || '✍️',
|
icon: r.icon || '✍️',
|
||||||
title: r.title,
|
title: r.title,
|
||||||
desc: fmtTime(r.occurred_at) + (r.description ? '|' + r.description : ''),
|
desc: fmtTime(r.occurred_at) + (r.description ? '|' + r.description : ''),
|
||||||
@@ -23,6 +24,19 @@ function mapRecord(r) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 时间轴类型筛选
|
||||||
|
const REC_FILTERS = [
|
||||||
|
{ key: '', label: '全部' },
|
||||||
|
{ key: 'weight', label: '体重' },
|
||||||
|
{ key: 'poop', label: '便便' },
|
||||||
|
{ key: 'food', label: '饮食' },
|
||||||
|
{ key: 'symptom', label: '异常' },
|
||||||
|
{ key: 'medicine', label: '用药' },
|
||||||
|
{ key: 'vaccine', label: '疫苗' },
|
||||||
|
{ key: 'cost', label: '消费' },
|
||||||
|
{ key: 'photo', label: '照片' },
|
||||||
|
];
|
||||||
|
|
||||||
function fmtDay(iso) {
|
function fmtDay(iso) {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -63,6 +77,8 @@ Page({
|
|||||||
recPage: 1,
|
recPage: 1,
|
||||||
recTotal: 0,
|
recTotal: 0,
|
||||||
recHasMore: false,
|
recHasMore: false,
|
||||||
|
recFilters: REC_FILTERS,
|
||||||
|
recFilterIdx: 0,
|
||||||
trendSvg: '',
|
trendSvg: '',
|
||||||
trendStats: null,
|
trendStats: null,
|
||||||
trendDots: [],
|
trendDots: [],
|
||||||
@@ -92,11 +108,39 @@ Page({
|
|||||||
})
|
})
|
||||||
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
|
.catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' }));
|
||||||
},
|
},
|
||||||
|
curFilter() {
|
||||||
|
return this.data.recFilters[this.data.recFilterIdx].key;
|
||||||
|
},
|
||||||
|
onFilterTap(e) {
|
||||||
|
const i = Number(e.currentTarget.dataset.index);
|
||||||
|
if (i === this.data.recFilterIdx) return;
|
||||||
|
this.setData({ recFilterIdx: i }, () => this.loadRecords());
|
||||||
|
},
|
||||||
|
// 长按删除一条记录
|
||||||
|
onDeleteRecord(e) {
|
||||||
|
const r = this.data.timeline[e.currentTarget.dataset.index];
|
||||||
|
if (!r || !r.id) return;
|
||||||
|
wx.showModal({
|
||||||
|
title: '删除记录',
|
||||||
|
content: '确定删除「' + r.title + '」?删除后不可恢复。',
|
||||||
|
confirmColor: '#D9534F',
|
||||||
|
success: (res) => {
|
||||||
|
if (!res.confirm) return;
|
||||||
|
api
|
||||||
|
.deleteRecord(r.id)
|
||||||
|
.then(() => {
|
||||||
|
wx.showToast({ title: '已删除', icon: 'success' });
|
||||||
|
this.loadRecords();
|
||||||
|
})
|
||||||
|
.catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
loadRecords() {
|
loadRecords() {
|
||||||
const id = store.currentPetId();
|
const id = store.currentPetId();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
api
|
api
|
||||||
.getRecords(id, { page: 1, pageSize: REC_PAGE })
|
.getRecords(id, { page: 1, pageSize: REC_PAGE, type: this.curFilter() })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const list = (res.list || []).map(mapRecord);
|
const list = (res.list || []).map(mapRecord);
|
||||||
this.setData({ timeline: list, recPage: 1, recTotal: res.total || 0, recHasMore: list.length < (res.total || 0) });
|
this.setData({ timeline: list, recPage: 1, recTotal: res.total || 0, recHasMore: list.length < (res.total || 0) });
|
||||||
@@ -109,7 +153,7 @@ Page({
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
const next = this.data.recPage + 1;
|
const next = this.data.recPage + 1;
|
||||||
api
|
api
|
||||||
.getRecords(id, { page: next, pageSize: REC_PAGE })
|
.getRecords(id, { page: next, pageSize: REC_PAGE, type: this.curFilter() })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const merged = this.data.timeline.concat((res.list || []).map(mapRecord));
|
const merged = this.data.timeline.concat((res.list || []).map(mapRecord));
|
||||||
this.setData({ timeline: merged, recPage: next, recTotal: res.total || 0, recHasMore: merged.length < (res.total || 0) });
|
this.setData({ timeline: merged, recPage: next, recTotal: res.total || 0, recHasMore: merged.length < (res.total || 0) });
|
||||||
|
|||||||
@@ -50,16 +50,24 @@
|
|||||||
|
|
||||||
<view class="card">
|
<view class="card">
|
||||||
<view class="section-head"><view class="sh-title">健康时间轴</view><view class="link" data-type="vetSummary" bindtap="openSheet">摘要</view></view>
|
<view class="section-head"><view class="sh-title">健康时间轴</view><view class="link" data-type="vetSummary" bindtap="openSheet">摘要</view></view>
|
||||||
|
<scroll-view class="rec-filter" scroll-x="true" enhanced="{{true}}" show-scrollbar="{{false}}">
|
||||||
|
<view wx:for="{{recFilters}}" wx:key="key"
|
||||||
|
class="rf-chip {{recFilterIdx === index ? 'on' : ''}}"
|
||||||
|
data-index="{{index}}" bindtap="onFilterTap">{{item.label}}</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
<view class="health-timeline">
|
<view class="health-timeline">
|
||||||
<view wx:for="{{timeline}}" wx:key="index" class="health-event">
|
<view wx:for="{{timeline}}" wx:key="id" class="health-event"
|
||||||
|
bindlongpress="onDeleteRecord" data-index="{{index}}">
|
||||||
<view class="event-dot">{{item.icon}}</view>
|
<view class="event-dot">{{item.icon}}</view>
|
||||||
<view class="he-body">
|
<view class="he-body">
|
||||||
<text class="he-b">{{item.title}}</text><view class="he-p">{{item.desc}}</view>
|
<text class="he-b">{{item.title}}</text><view class="he-p">{{item.desc}}</view>
|
||||||
<image wx:if="{{item.image}}" class="he-img" src="{{item.image}}" mode="aspectFill" bindtap="previewImage" data-src="{{item.image}}"></image>
|
<image wx:if="{{item.image}}" class="he-img" src="{{item.image}}" mode="aspectFill" catchtap="previewImage" data-src="{{item.image}}"></image>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view wx:if="{{timeline.length === 0}}" class="tl-empty">还没有记录,用上面的按钮记一条吧</view>
|
<view wx:if="{{timeline.length === 0}}" class="tl-empty">这个分类下还没有记录</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view wx:if="{{timeline.length}}" class="tl-tip">长按任意一条可删除</view>
|
||||||
<view wx:if="{{recHasMore}}" class="tl-more" bindtap="loadMoreRecords">查看更多(共 {{recTotal}} 条)</view>
|
<view wx:if="{{recHasMore}}" class="tl-more" bindtap="loadMoreRecords">查看更多(共 {{recTotal}} 条)</view>
|
||||||
<view wx:elif="{{recPage > 1}}" class="tl-more" bindtap="collapseRecords">收起</view>
|
<view wx:elif="{{recPage > 1}}" class="tl-more" bindtap="collapseRecords">收起</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -50,3 +50,12 @@
|
|||||||
/* 页面不自身滚动,改由 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}
|
||||||
|
|
||||||
|
/* 时间轴类型筛选 */
|
||||||
|
.rec-filter{white-space:nowrap;margin-bottom:20rpx}
|
||||||
|
.rf-chip{
|
||||||
|
display:inline-block;padding:10rpx 26rpx;margin-right:14rpx;border-radius:999rpx;
|
||||||
|
background:#fff;border:1rpx solid var(--line);color:var(--muted);font-size:24rpx;font-weight:700;
|
||||||
|
}
|
||||||
|
.rf-chip.on{background:#FFF2DC;color:var(--primary-dark);border-color:#FFD39A}
|
||||||
|
.tl-tip{margin-top:16rpx;text-align:center;color:var(--muted);font-size:22rpx}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const api = {
|
|||||||
return request({ url: `/api/pets/${id}/records${qs.length ? '?' + qs.join('&') : ''}` });
|
return request({ url: `/api/pets/${id}/records${qs.length ? '?' + qs.join('&') : ''}` });
|
||||||
},
|
},
|
||||||
createRecord: (id, body) => request({ url: `/api/pets/${id}/records`, method: 'POST', data: body }),
|
createRecord: (id, body) => request({ url: `/api/pets/${id}/records`, method: 'POST', data: body }),
|
||||||
|
deleteRecord: (recordId) => request({ url: `/api/records/${recordId}`, method: 'DELETE' }),
|
||||||
weightTrend: (id) => request({ url: `/api/pets/${id}/records/weight-trend` }),
|
weightTrend: (id) => request({ url: `/api/pets/${id}/records/weight-trend` }),
|
||||||
|
|
||||||
// 任务
|
// 任务
|
||||||
@@ -92,6 +93,8 @@ const api = {
|
|||||||
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' }),
|
followUser: (userId) => request({ url: `/api/users/${userId}/follow`, method: 'POST' }),
|
||||||
unfollowUser: (userId) => request({ url: `/api/users/${userId}/follow`, method: 'DELETE' }),
|
unfollowUser: (userId) => request({ url: `/api/users/${userId}/follow`, method: 'DELETE' }),
|
||||||
|
deletePost: (id) => request({ url: `/api/posts/${id}`, method: 'DELETE' }),
|
||||||
|
deleteComment: (id) => request({ url: `/api/comments/${id}`, 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 } }),
|
||||||
@@ -106,6 +109,8 @@ const api = {
|
|||||||
activatePro: () => request({ url: '/api/pro/activate', method: 'POST' }),
|
activatePro: () => request({ url: '/api/pro/activate', method: 'POST' }),
|
||||||
|
|
||||||
// AI
|
// AI
|
||||||
|
aiMessages: (session, limit) =>
|
||||||
|
request({ url: `/api/ai/messages?session=${session || ''}&limit=${limit || 30}` }),
|
||||||
aiChat: (body) => request({ url: '/api/ai/chat', method: 'POST', data: body }),
|
aiChat: (body) => request({ url: '/api/ai/chat', method: 'POST', data: body }),
|
||||||
assessSymptom: (id, body) =>
|
assessSymptom: (id, body) =>
|
||||||
request({ url: `/api/pets/${id}/ai/assess-symptom`, method: 'POST', data: body }),
|
request({ url: `/api/pets/${id}/ai/assess-symptom`, method: 'POST', data: body }),
|
||||||
|
|||||||
Reference in New Issue
Block a user