Merge pull request 'feat: 记录删除与筛选、用户侧内容删除、AI 聊天历史' (#2) from feat/dev into main
deploy / deploy (push) Successful in 3m11s

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-07-28 09:59:28 +00:00
26 changed files with 326 additions and 28 deletions
+19 -1
View File
@@ -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})
}
+4
View File
@@ -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)
}
+15
View File
@@ -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)
}
+2
View File
@@ -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"` // 计算字段:是不是我自己发的
}
+3
View File
@@ -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)
}
+20
View File
@@ -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
}
+39 -1
View File
@@ -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
})
}
+3
View File
@@ -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}
+29 -5
View File
@@ -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) {
+8 -2
View File
@@ -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>
+1
View File
@@ -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}
+8 -3
View File
@@ -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();
+1 -1
View File
@@ -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>
+2
View File
@@ -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();
})
+50 -3
View File
@@ -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) });
+11 -3
View File
@@ -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>
+9
View File
@@ -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}
+2
View File
@@ -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();
})
+5
View File
@@ -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 }),
+3 -1
View File
@@ -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 };
+26 -5
View File
@@ -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 };
+7
View File
@@ -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 () => {
+15
View File
@@ -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 };