Merge pull request 'feat: 记录删除与筛选、用户侧内容删除、AI 聊天历史' (#2) from feat/dev into main
deploy / deploy (push) Successful in 3m11s
deploy / deploy (push) Successful in 3m11s
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -104,7 +104,7 @@ func (h *Handler) ListComments(c *gin.Context) {
|
||||
_ = c.ShouldBindQuery(&req)
|
||||
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 {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
@@ -152,3 +152,21 @@ func (h *Handler) UnfollowUser(c *gin.Context) {
|
||||
}
|
||||
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, "")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrForbidden) {
|
||||
response.Fail(c, errcode.ErrForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
response.FailErr(c, err)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -173,3 +174,17 @@ func (h *Handler) AdminSetFeedbackHandled(c *gin.Context) {
|
||||
}
|
||||
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"`
|
||||
Content string `gorm:"size:512" json:"content"`
|
||||
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.GET("/posts/:id/comments", h.ListComments)
|
||||
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.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("/ai/chat", h.AIChat)
|
||||
g.GET("/ai/messages", h.ListAIMessages)
|
||||
g.POST("/pets/:id/ai/assess-symptom", h.AssessSymptom)
|
||||
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) {
|
||||
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 评论分页
|
||||
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")
|
||||
var total int64
|
||||
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 {
|
||||
return nil, 0, err
|
||||
}
|
||||
for i := range comments {
|
||||
comments[i].IsSelf = comments[i].UserID == userID
|
||||
}
|
||||
return comments, total, nil
|
||||
}
|
||||
|
||||
@@ -227,3 +230,38 @@ func (s *Service) CreateComment(userID, postID string, content string) (*model.C
|
||||
}
|
||||
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("支付功能即将开放,敬请期待")
|
||||
|
||||
// ErrForbidden 越权操作(如删除别人的内容)
|
||||
var ErrForbidden = errors.New("无权操作")
|
||||
|
||||
// Service 业务逻辑聚合,方法按领域分散在各文件
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
|
||||
@@ -52,7 +52,8 @@ Component({
|
||||
properties: {
|
||||
show: { type: Boolean, value: false },
|
||||
type: { type: String, value: '' },
|
||||
postId: { type: Number, value: 0 },
|
||||
// 雪花 ID 是 18 位字符串,声明成 Number 会超出 JS 安全整数范围而丢精度
|
||||
postId: { type: String, value: '' },
|
||||
},
|
||||
data: {
|
||||
innerType: '',
|
||||
@@ -182,13 +183,15 @@ Component({
|
||||
} else if (type === 'exportData') {
|
||||
this.buildExport();
|
||||
} 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) {
|
||||
this.loadReminders();
|
||||
} else if (type === 'poster' && id) {
|
||||
api.getPoster(id).then((p) => this.setData({ poster: p })).catch(() => {});
|
||||
} else if (type === 'reportDetail' && id) {
|
||||
api.weeklyReport(id).then((r) => this.setData({ reportDetail: r })).catch(() => {});
|
||||
} else if (type === 'ai') {
|
||||
this.loadAIHistory();
|
||||
} else if (type === 'pro') {
|
||||
api.getPro().then((p) => this.setData({ proInfo: p })).catch(() => {});
|
||||
}
|
||||
@@ -244,6 +247,28 @@ Component({
|
||||
onCostNote(e) { this.setData({ costNote: e.detail.value }); },
|
||||
onCostInput(e) { this.setData({ costAmount: 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() {
|
||||
const cur = store.currentPetId();
|
||||
@@ -722,6 +747,17 @@ Component({
|
||||
|
||||
// AI 聊天
|
||||
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) {
|
||||
const preset = e.currentTarget.dataset.text;
|
||||
const text = (preset || this.data.aiInput || '').trim();
|
||||
|
||||
@@ -331,8 +331,12 @@ module.exports.sel = function (map, key, index, def) {
|
||||
<!-- 评论 -->
|
||||
<block wx:elif="{{innerType === 'comments'}}">
|
||||
<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" style="font-size:22rpx;color:var(--muted)">长按自己的评论可删除</view>
|
||||
<view class="ai-input-row" style="margin-top:24rpx">
|
||||
<input class="input" placeholder="友善交流,分享经验..." placeholder-class="placeholder" value="{{commentText}}" bindinput="onCommentInput"/>
|
||||
<button class="btn btn-primary ai-send" bindtap="onSendComment">发</button>
|
||||
|
||||
@@ -61,3 +61,4 @@
|
||||
border:1rpx solid var(--line);margin-bottom:24rpx;
|
||||
}
|
||||
.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}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const store = require('../../utils/store.js');
|
||||
const api = require('../../utils/api.js');
|
||||
const { toastErr } = require('../../utils/ui.js');
|
||||
|
||||
const TAG_CLASS = { 求助: 'warn', 经验: 'blue', 精选: 'purple', 避坑: 'red', 晒宠: '' };
|
||||
|
||||
@@ -32,7 +33,9 @@ Page({
|
||||
posts: [],
|
||||
sheetShow: false,
|
||||
sheetType: '',
|
||||
sheetPostId: 0,
|
||||
sheetPostId: '',
|
||||
loaded: false,
|
||||
loadErr: '',
|
||||
},
|
||||
onLoad() {
|
||||
this._unsub = store.subscribe((pet) => this.setData({ pet }));
|
||||
@@ -54,10 +57,15 @@ Page({
|
||||
},
|
||||
loadPosts() {
|
||||
const tab = this.data.feedTabs[this.data.feedIdx];
|
||||
this.setData({ loadErr: '' });
|
||||
api
|
||||
.listPosts(tab, 1)
|
||||
.then((page) => this.setData({ posts: (page.list || []).map(mapPost) }))
|
||||
.catch(() => {});
|
||||
.then((page) => this.setData({ posts: (page.list || []).map(mapPost), loaded: true }))
|
||||
.catch((e) => {
|
||||
// 不能让「加载失败」显示成「还没有帖子」,那是在骗用户
|
||||
this.setData({ loadErr: e.message || '加载失败', loaded: true });
|
||||
toastErr(e);
|
||||
});
|
||||
},
|
||||
switchFeedTab(e) {
|
||||
this.setData({ feedIdx: e.currentTarget.dataset.index });
|
||||
@@ -77,7 +85,7 @@ Page({
|
||||
this.setData({ sheetPostId: e.currentTarget.dataset.id, sheetType: 'comments', sheetShow: true });
|
||||
},
|
||||
openSheet(e) {
|
||||
this.setData({ sheetType: e.currentTarget.dataset.type, sheetPostId: 0, sheetShow: true });
|
||||
this.setData({ sheetType: e.currentTarget.dataset.type, sheetPostId: '', sheetShow: true });
|
||||
},
|
||||
closeSheet() {
|
||||
this.setData({ sheetShow: false });
|
||||
@@ -93,7 +101,23 @@ Page({
|
||||
wx.navigateTo({ url: '/pages/learn/learn' });
|
||||
},
|
||||
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) {
|
||||
|
||||
@@ -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>
|
||||
</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-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>
|
||||
@@ -41,7 +41,13 @@ module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; }
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{posts.length === 0}}" class="empty-hint">还没有帖子,来发第一条吧 🐾</view>
|
||||
<view wx:if="{{loadErr}}" class="empty-hint">
|
||||
{{loadErr}}
|
||||
<view class="retry-btn" bindtap="loadPosts">点击重试</view>
|
||||
</view>
|
||||
<view wx:elif="{{loaded && posts.length === 0}}" class="empty-hint">
|
||||
{{feedTabs[feedIdx] === '关注' ? '还没有关注的人,去「推荐」里关注几个宠友吧 🐾' : '还没有帖子,来发第一条吧 🐾'}}
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
|
||||
@@ -53,3 +53,4 @@ page{height:100vh;overflow:hidden;display:flex;flex-direction:column}
|
||||
color:var(--primary-dark);background:#FFF2DC;border:1rpx solid #FFD39A;
|
||||
}
|
||||
.follow-btn.on{color:var(--muted);background:#F4F1EC;border-color:var(--line)}
|
||||
.retry-btn{margin-top:20rpx;display:inline-block;padding:12rpx 36rpx;border-radius:999rpx;background:#FFF2DC;color:var(--primary-dark);font-weight:700;font-size:26rpx}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const store = require('../../utils/store.js');
|
||||
const api = require('../../utils/api.js');
|
||||
const { toastErr } = require('../../utils/ui.js');
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date();
|
||||
@@ -34,6 +35,8 @@ Page({
|
||||
this.setData({ todayDate: todayStr() });
|
||||
this._unsub = store.subscribe((pet) => {
|
||||
this.setData({ pet });
|
||||
// 首次由 onShow 统一加载,这里只处理之后的「切换宠物 / 数据变更」,避免重复请求
|
||||
if (!this._inited) return;
|
||||
this.loadAll();
|
||||
});
|
||||
},
|
||||
@@ -47,6 +50,7 @@ Page({
|
||||
store
|
||||
.ready()
|
||||
.then(() => {
|
||||
this._inited = true;
|
||||
this.setData({ pet: store.getPet() });
|
||||
this.loadAll();
|
||||
})
|
||||
@@ -72,7 +76,7 @@ Page({
|
||||
api
|
||||
.homeSummary(id)
|
||||
.then((summary) => this.setData({ summary }))
|
||||
.catch(() => {});
|
||||
.catch((e) => toastErr(e));
|
||||
},
|
||||
loadTasks() {
|
||||
const id = store.currentPetId();
|
||||
@@ -81,7 +85,7 @@ Page({
|
||||
api
|
||||
.getTasks(id, date)
|
||||
.then((tasks) => this.setData({ tasks: (tasks || []).map(mapTask) }))
|
||||
.catch(() => {});
|
||||
.catch((e) => toastErr(e));
|
||||
},
|
||||
onTapDay(e) {
|
||||
const { date, active } = e.currentTarget.dataset;
|
||||
@@ -130,8 +134,9 @@ Page({
|
||||
this.setData({ sheetType: type, sheetShow: true });
|
||||
},
|
||||
closeSheet() {
|
||||
// 只关闭。数据变更由 bind:saved 或 store 的宠物变更通知触发刷新,
|
||||
// 不必每次关弹层(哪怕只是看了眼 AI)都全量重拉一遍
|
||||
this.setData({ sheetShow: false });
|
||||
this.loadAll();
|
||||
},
|
||||
onSheetSave() {
|
||||
this.loadTasks();
|
||||
|
||||
@@ -97,4 +97,4 @@
|
||||
</scroll-view>
|
||||
|
||||
<fab bind:tap="onFab"></fab>
|
||||
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" bind:close="closeSheet" bind:save="onSheetSave"></bottom-sheet>
|
||||
<bottom-sheet show="{{sheetShow}}" type="{{sheetType}}" bind:close="closeSheet" bind:saved="onSheetSave"></bottom-sheet>
|
||||
|
||||
@@ -46,6 +46,7 @@ Page({
|
||||
onLoad() {
|
||||
this._unsub = store.subscribe((pet) => {
|
||||
this.setData({ pet, calLoaded: false });
|
||||
if (!this._inited) return; // 首次由 onShow 加载
|
||||
this.loadTimeline();
|
||||
if (this.data.planView === 'calendar') this.loadCalendar();
|
||||
});
|
||||
@@ -60,6 +61,7 @@ Page({
|
||||
store
|
||||
.ready()
|
||||
.then(() => {
|
||||
this._inited = true;
|
||||
this.setData({ pet: store.getPet() });
|
||||
this.loadTimeline();
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const store = require('../../utils/store.js');
|
||||
const api = require('../../utils/api.js');
|
||||
const { toastErr } = require('../../utils/ui.js');
|
||||
|
||||
function pad(n) {
|
||||
return n < 10 ? '0' + n : '' + n;
|
||||
@@ -16,6 +17,7 @@ function fmtTime(iso) {
|
||||
}
|
||||
function mapRecord(r) {
|
||||
return {
|
||||
id: r.id,
|
||||
icon: r.icon || '✍️',
|
||||
title: r.title,
|
||||
desc: fmtTime(r.occurred_at) + (r.description ? '|' + r.description : ''),
|
||||
@@ -23,6 +25,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) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
@@ -63,6 +78,8 @@ Page({
|
||||
recPage: 1,
|
||||
recTotal: 0,
|
||||
recHasMore: false,
|
||||
recFilters: REC_FILTERS,
|
||||
recFilterIdx: 0,
|
||||
trendSvg: '',
|
||||
trendStats: null,
|
||||
trendDots: [],
|
||||
@@ -74,6 +91,7 @@ Page({
|
||||
onLoad() {
|
||||
this._unsub = store.subscribe((pet) => {
|
||||
this.setData({ pet });
|
||||
if (!this._inited) return; // 首次由 onShow 加载
|
||||
this.loadRecords();
|
||||
});
|
||||
},
|
||||
@@ -87,21 +105,50 @@ Page({
|
||||
store
|
||||
.ready()
|
||||
.then(() => {
|
||||
this._inited = true;
|
||||
this.setData({ pet: store.getPet() });
|
||||
this.loadRecords();
|
||||
})
|
||||
.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() {
|
||||
const id = store.currentPetId();
|
||||
if (!id) return;
|
||||
api
|
||||
.getRecords(id, { page: 1, pageSize: REC_PAGE })
|
||||
.getRecords(id, { page: 1, pageSize: REC_PAGE, type: this.curFilter() })
|
||||
.then((res) => {
|
||||
const list = (res.list || []).map(mapRecord);
|
||||
this.setData({ timeline: list, recPage: 1, recTotal: res.total || 0, recHasMore: list.length < (res.total || 0) });
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((e) => toastErr(e));
|
||||
this.loadTrend();
|
||||
},
|
||||
loadMoreRecords() {
|
||||
@@ -109,7 +156,7 @@ Page({
|
||||
if (!id) return;
|
||||
const next = this.data.recPage + 1;
|
||||
api
|
||||
.getRecords(id, { page: next, pageSize: REC_PAGE })
|
||||
.getRecords(id, { page: next, pageSize: REC_PAGE, type: this.curFilter() })
|
||||
.then((res) => {
|
||||
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) });
|
||||
|
||||
@@ -50,16 +50,24 @@
|
||||
|
||||
<view class="card">
|
||||
<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 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="he-body">
|
||||
<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 wx:if="{{timeline.length === 0}}" class="tl-empty">还没有记录,用上面的按钮记一条吧</view>
|
||||
<view wx:if="{{timeline.length === 0}}" class="tl-empty">这个分类下还没有记录</view>
|
||||
</view>
|
||||
<view wx:if="{{timeline.length}}" class="tl-tip">长按任意一条可删除</view>
|
||||
<view wx:if="{{recHasMore}}" class="tl-more" bindtap="loadMoreRecords">查看更多(共 {{recTotal}} 条)</view>
|
||||
<view wx:elif="{{recPage > 1}}" class="tl-more" bindtap="collapseRecords">收起</view>
|
||||
</view>
|
||||
|
||||
@@ -50,3 +50,12 @@
|
||||
/* 页面不自身滚动,改由 page-scroll 承载滚动(隐藏滚动条)*/
|
||||
page{height:100vh;overflow:hidden;display:flex;flex-direction:column}
|
||||
.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}
|
||||
|
||||
@@ -14,6 +14,7 @@ Page({
|
||||
onLoad() {
|
||||
this._unsub = store.subscribe((pet) => {
|
||||
this.setData({ pet });
|
||||
if (!this._inited) return; // 首次由 onShow 加载
|
||||
this.loadData();
|
||||
});
|
||||
},
|
||||
@@ -27,6 +28,7 @@ Page({
|
||||
store
|
||||
.ready()
|
||||
.then(() => {
|
||||
this._inited = true;
|
||||
this.setData({ pet: store.getPet() });
|
||||
this.loadData();
|
||||
})
|
||||
|
||||
@@ -54,6 +54,7 @@ const api = {
|
||||
return request({ url: `/api/pets/${id}/records${qs.length ? '?' + qs.join('&') : ''}` });
|
||||
},
|
||||
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` }),
|
||||
|
||||
// 任务
|
||||
@@ -92,6 +93,8 @@ const api = {
|
||||
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' }),
|
||||
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` }),
|
||||
createComment: (id, 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' }),
|
||||
|
||||
// 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 }),
|
||||
assessSymptom: (id, body) =>
|
||||
request({ url: `/api/pets/${id}/ai/assess-symptom`, method: 'POST', data: body }),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// 后端地址。本地联调:微信开发者工具需在「详情 → 本地设置」勾选「不校验合法域名」。
|
||||
// 上线时改为你的 https 域名,并在微信公众平台配置 request 合法域名。
|
||||
const BASE_URL = 'http://192.168.31.4:9090';
|
||||
//const BASE_URL = 'http://192.168.31.4:9090';
|
||||
const BASE_URL = 'https://pet.sundynix.cn';
|
||||
|
||||
|
||||
module.exports = { BASE_URL };
|
||||
|
||||
@@ -12,14 +12,25 @@ function clearToken() {
|
||||
wx.removeStorageSync(TOKEN_KEY);
|
||||
}
|
||||
|
||||
// 统一请求:注入 token,解析 {code,message,data},成功返回 data,失败 reject(Error)
|
||||
function request({ url, method = 'GET', data, header = {} }) {
|
||||
// token 失效时的重新登录钩子,由 store 注册(放在这里避免 request ← api ← store 循环依赖)
|
||||
let reAuth = null;
|
||||
function setReAuthHandler(fn) {
|
||||
reAuth = fn;
|
||||
}
|
||||
|
||||
const TIMEOUT = 15000;
|
||||
|
||||
// 统一请求:注入 token,解析 {code,message,data},成功返回 data,失败 reject(Error)。
|
||||
// token 失效(40100)时自动重新登录并重试一次,避免 JWT 过期后小程序静默白屏。
|
||||
function request(options, _retried) {
|
||||
const { url, method = 'GET', data, header = {} } = options;
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = getToken();
|
||||
wx.request({
|
||||
url: BASE_URL + url,
|
||||
method,
|
||||
data,
|
||||
timeout: TIMEOUT,
|
||||
header: Object.assign(
|
||||
{ 'Content-Type': 'application/json' },
|
||||
token ? { Authorization: 'Bearer ' + token } : {},
|
||||
@@ -29,13 +40,22 @@ function request({ url, method = 'GET', data, header = {} }) {
|
||||
const body = res.data;
|
||||
if (body && typeof body === 'object' && 'code' in body) {
|
||||
if (body.code === 0) return resolve(body.data);
|
||||
if (body.code === 40100) clearToken();
|
||||
if (body.code === 40100) {
|
||||
clearToken();
|
||||
// 只重试一次,防止登录接口本身出问题时无限循环
|
||||
if (!_retried && reAuth) {
|
||||
return reAuth()
|
||||
.then(() => request(options, true).then(resolve, reject))
|
||||
.catch(() => reject(new Error(body.message || '登录已过期,请重进小程序')));
|
||||
}
|
||||
}
|
||||
return reject(new Error(body.message || '请求失败'));
|
||||
}
|
||||
resolve(body);
|
||||
},
|
||||
fail(err) {
|
||||
reject(new Error((err && err.errMsg) || '网络错误'));
|
||||
const msg = (err && err.errMsg) || '网络错误';
|
||||
reject(new Error(/timeout/i.test(msg) ? '网络超时,请稍后重试' : msg));
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -49,6 +69,7 @@ function uploadFile(filePath) {
|
||||
url: BASE_URL + '/api/upload',
|
||||
filePath,
|
||||
name: 'file',
|
||||
timeout: 60000,
|
||||
header: token ? { Authorization: 'Bearer ' + token } : {},
|
||||
success(res) {
|
||||
try {
|
||||
@@ -66,4 +87,4 @@ function uploadFile(filePath) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { request, uploadFile, getToken, setToken, clearToken };
|
||||
module.exports = { request, uploadFile, getToken, setToken, clearToken, setReAuthHandler };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 全局状态:登录、宠物列表、当前宠物;服务端驱动 + 发布订阅
|
||||
const api = require('./api.js');
|
||||
const { setReAuthHandler } = require('./request.js');
|
||||
|
||||
const state = {
|
||||
user: null,
|
||||
@@ -38,6 +39,12 @@ function subscribe(fn) {
|
||||
}
|
||||
|
||||
// 登录 + 拉宠物列表(只跑一次)
|
||||
// token 失效时清掉引导缓存并重新登录一次,让 request 层能自动重试
|
||||
setReAuthHandler(async () => {
|
||||
bootstrapPromise = null;
|
||||
await ready();
|
||||
});
|
||||
|
||||
function ready() {
|
||||
if (!bootstrapPromise) {
|
||||
bootstrapPromise = (async () => {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// 轻量 UI 提示:统一错误反馈,避免请求失败时页面一片空白、用户不知道发生了什么。
|
||||
let lastMsg = '';
|
||||
let lastAt = 0;
|
||||
|
||||
// 提示错误。相同文案 3 秒内不重复弹,避免多个并发请求同时失败时刷屏。
|
||||
function toastErr(e, fallback) {
|
||||
const msg = (e && e.message) || fallback || '加载失败';
|
||||
const now = Date.now();
|
||||
if (msg === lastMsg && now - lastAt < 3000) return;
|
||||
lastMsg = msg;
|
||||
lastAt = now;
|
||||
wx.showToast({ title: msg, icon: 'none', duration: 2000 });
|
||||
}
|
||||
|
||||
module.exports = { toastErr };
|
||||
Reference in New Issue
Block a user