diff --git a/pets-be/internal/handler/community.go b/pets-be/internal/handler/community.go
index c34e0de..9aa7168 100644
--- a/pets-be/internal/handler/community.go
+++ b/pets-be/internal/handler/community.go
@@ -182,3 +182,40 @@ func (h *Handler) ListReplies(c *gin.Context) {
}
response.OK(c, list)
}
+
+// UserCard GET /api/users/:id/profile 某人的公开资料
+func (h *Handler) UserCard(c *gin.Context) {
+ card, err := h.svc.GetUserCard(middleware.UserID(c), idParam(c, "id"))
+ if err != nil {
+ respondErr(c, err)
+ return
+ }
+ response.OK(c, card)
+}
+
+// UserPosts GET /api/users/:id/posts 某人发布的帖子
+func (h *Handler) UserPosts(c *gin.Context) {
+ var req adminListReq
+ _ = c.ShouldBindQuery(&req)
+ req.Normalize()
+ posts, total, err := h.svc.ListUserPosts(middleware.UserID(c), idParam(c, "id"), req.Offset(), req.Limit())
+ if err != nil {
+ response.FailErr(c, err)
+ return
+ }
+ response.OK(c, response.NewPage(posts, total, req.PageQuery))
+}
+
+// Relations GET /api/users/:id/relations?kind=following|followers
+func (h *Handler) Relations(c *gin.Context) {
+ var req adminListReq
+ _ = c.ShouldBindQuery(&req)
+ req.Normalize()
+ kind := c.Query("kind")
+ list, total, err := h.svc.ListRelations(middleware.UserID(c), idParam(c, "id"), kind, req.Offset(), req.Limit())
+ if err != nil {
+ response.FailErr(c, err)
+ return
+ }
+ response.OK(c, response.NewPage(list, total, req.PageQuery))
+}
diff --git a/pets-be/internal/router/router.go b/pets-be/internal/router/router.go
index c3b2b10..d0915de 100644
--- a/pets-be/internal/router/router.go
+++ b/pets-be/internal/router/router.go
@@ -88,6 +88,9 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
g.GET("/pets/:id/health-summary", h.HealthSummary)
g.GET("/pets/:id/poster", h.Poster)
+ g.GET("/users/:id/profile", h.UserCard)
+ g.GET("/users/:id/posts", h.UserPosts)
+ g.GET("/users/:id/relations", h.Relations)
g.POST("/users/:id/follow", h.FollowUser)
g.DELETE("/users/:id/follow", h.UnfollowUser)
diff --git a/pets-be/internal/service/home.go b/pets-be/internal/service/home.go
index 90985c4..e9d6a6f 100644
--- a/pets-be/internal/service/home.go
+++ b/pets-be/internal/service/home.go
@@ -216,6 +216,8 @@ type UserSummary struct {
Pets int64 `json:"pets"`
Records int64 `json:"records"`
Reminders int64 `json:"reminders"`
+ Following int64 `json:"following"` // 我关注了多少人
+ Followers int64 `json:"followers"` // 多少人关注我
ProStatus string `json:"pro_status"`
}
@@ -225,6 +227,8 @@ func (s *Service) GetUserSummary(userID string) (*UserSummary, error) {
s.db.Model(&model.Pet{}).Where("user_id = ?", userID).Count(&sum.Pets)
s.db.Model(&model.HealthRecord{}).Where("user_id = ?", userID).Count(&sum.Records)
s.db.Model(&model.Reminder{}).Where("user_id = ?", userID).Count(&sum.Reminders)
+ s.db.Model(&model.Follow{}).Where("follower_id = ?", userID).Count(&sum.Following)
+ s.db.Model(&model.Follow{}).Where("followee_id = ?", userID).Count(&sum.Followers)
sum.ProStatus = model.ProNone
var m model.ProMembership
diff --git a/pets-be/internal/service/user_profile.go b/pets-be/internal/service/user_profile.go
new file mode 100644
index 0000000..8ab6723
--- /dev/null
+++ b/pets-be/internal/service/user_profile.go
@@ -0,0 +1,123 @@
+package service
+
+import (
+ "github.com/sundynix/pets-be/internal/model"
+)
+
+// UserCard 用户公开资料。关注这件事之前只做了「点一下关注」,
+// 关注完既看不到对方主页、也看不到自己关注了谁——等于关注完就石沉大海。
+type UserCard struct {
+ ID string `json:"id"`
+ Nickname string `json:"nickname"`
+ AvatarURL string `json:"avatar_url"`
+ IsBot bool `json:"is_bot"`
+ IsSelf bool `json:"is_self"`
+ Followed bool `json:"followed"` // 我有没有关注 TA
+ Posts int64 `json:"post_count"`
+ Following int64 `json:"following_count"`
+ Followers int64 `json:"follower_count"`
+}
+
+// GetUserCard 取某人的公开资料 + 三个计数 + 我是否已关注
+func (s *Service) GetUserCard(viewerID, userID string) (*UserCard, error) {
+ var u model.User
+ if err := s.db.First(&u, "id = ?", userID).Error; err != nil {
+ return nil, ErrNotFound
+ }
+ card := &UserCard{
+ ID: u.ID, Nickname: u.Nickname, IsBot: u.IsBot,
+ IsSelf: u.ID == viewerID,
+ AvatarURL: s.fileURL(u.AvatarFileID),
+ }
+ if card.AvatarURL == "" {
+ card.AvatarURL = u.Avatar
+ }
+ s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, "published").Count(&card.Posts)
+ s.db.Model(&model.Follow{}).Where("follower_id = ?", userID).Count(&card.Following)
+ s.db.Model(&model.Follow{}).Where("followee_id = ?", userID).Count(&card.Followers)
+
+ if !card.IsSelf {
+ var n int64
+ s.db.Model(&model.Follow{}).
+ Where("follower_id = ? AND followee_id = ?", viewerID, userID).Count(&n)
+ card.Followed = n > 0
+ }
+ return card, nil
+}
+
+// ListUserPosts 某人发布的帖子
+func (s *Service) ListUserPosts(viewerID, userID string, offset, limit int) ([]model.Post, int64, error) {
+ q := s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, "published")
+ var total int64
+ if err := q.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+ var posts []model.Post
+ if err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error; err != nil {
+ return nil, 0, err
+ }
+ s.attachPostImages(posts)
+ s.markFollowed(viewerID, posts)
+ return posts, total, nil
+}
+
+// ListRelations 关注列表 / 粉丝列表。
+// kind = "following" 我关注的人;"followers" 关注我的人
+func (s *Service) ListRelations(viewerID, userID, kind string, offset, limit int) ([]UserCard, int64, error) {
+ q := s.db.Model(&model.Follow{})
+ var idCol string
+ if kind == "followers" {
+ q = q.Where("followee_id = ?", userID)
+ idCol = "follower_id"
+ } else {
+ q = q.Where("follower_id = ?", userID)
+ idCol = "followee_id"
+ }
+ var total int64
+ if err := q.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+ var ids []string
+ if err := q.Order("id desc").Offset(offset).Limit(limit).Pluck(idCol, &ids).Error; err != nil {
+ return nil, 0, err
+ }
+ if len(ids) == 0 {
+ return []UserCard{}, total, nil
+ }
+
+ var users []model.User
+ s.db.Where("id IN ?", ids).Find(&users)
+ byID := map[string]model.User{}
+ for _, u := range users {
+ byID[u.ID] = u
+ }
+
+ // 一次查出我关注了这批人里的哪些,不要每行一次查询
+ var mine []string
+ s.db.Model(&model.Follow{}).Where("follower_id = ? AND followee_id IN ?", viewerID, ids).
+ Pluck("followee_id", &mine)
+ followed := map[string]bool{}
+ for _, id := range mine {
+ followed[id] = true
+ }
+
+ // 按 ids 的顺序输出,保持「最近关注的在前」
+ out := make([]UserCard, 0, len(ids))
+ for _, id := range ids {
+ u, ok := byID[id]
+ if !ok {
+ continue
+ }
+ avatar := s.fileURL(u.AvatarFileID)
+ if avatar == "" {
+ avatar = u.Avatar
+ }
+ out = append(out, UserCard{
+ ID: u.ID, Nickname: u.Nickname, IsBot: u.IsBot,
+ AvatarURL: avatar,
+ IsSelf: u.ID == viewerID,
+ Followed: followed[u.ID],
+ })
+ }
+ return out, total, nil
+}
diff --git a/pets-fe/app.json b/pets-fe/app.json
index 15629f2..c117869 100644
--- a/pets-fe/app.json
+++ b/pets-fe/app.json
@@ -9,7 +9,9 @@
"pages/learn/learn",
"pages/article/article",
"pages/profile/profile",
- "pages/ai/ai"
+ "pages/ai/ai",
+ "pages/user/user",
+ "pages/relations/relations"
],
"window": {
"navigationStyle": "custom",
diff --git a/pets-fe/pages/community/community.js b/pets-fe/pages/community/community.js
index 3b72d7f..1a23d51 100644
--- a/pets-fe/pages/community/community.js
+++ b/pets-fe/pages/community/community.js
@@ -121,6 +121,11 @@ Page({
onCommented() {
this.loadPosts();
},
+ // 点头像或昵称进 TA 的主页。关注做了却没有主页,关注完就石沉大海
+ goUser(e) {
+ const id = e.currentTarget.dataset.id;
+ if (id) wx.navigateTo({ url: `/pages/user/user?id=${id}` });
+ },
goLearn() {
wx.navigateTo({ url: '/pages/learn/learn' });
},
diff --git a/pets-fe/pages/community/community.wxml b/pets-fe/pages/community/community.wxml
index a3f7d96..780bfd5 100644
--- a/pets-fe/pages/community/community.wxml
+++ b/pets-fe/pages/community/community.wxml
@@ -15,8 +15,8 @@ module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; }
- {{item.author_emoji}}
-
+ {{item.author_emoji}}
+
{{item.author_name}}AI
{{item.timeText}}
diff --git a/pets-fe/pages/profile/profile.js b/pets-fe/pages/profile/profile.js
index 7f6d451..50c989b 100644
--- a/pets-fe/pages/profile/profile.js
+++ b/pets-fe/pages/profile/profile.js
@@ -40,6 +40,12 @@ Page({
openSheet(e) {
this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true });
},
+ // 自己的关注/粉丝。之前只有帖子上的「+关注」按钮,关注完没有任何地方能看
+ goRelations(e) {
+ const uid = (this.data.user && this.data.user.id) || '';
+ if (!uid) return wx.showToast({ title: '登录信息还没就绪', icon: 'none' });
+ wx.navigateTo({ url: `/pages/relations/relations?id=${uid}&kind=${e.currentTarget.dataset.kind}` });
+ },
goRecord() {
wx.switchTab({ url: '/pages/record/record' });
},
diff --git a/pets-fe/pages/profile/profile.wxml b/pets-fe/pages/profile/profile.wxml
index 881e725..ae5ed9b 100644
--- a/pets-fe/pages/profile/profile.wxml
+++ b/pets-fe/pages/profile/profile.wxml
@@ -20,6 +20,8 @@
提醒设置{{summary.reminders}} 条
健康记录{{summary.records}} 条
多宠物管理{{summary.pets}} 只
+ 我的关注{{summary.following || 0}} 人
+ 我的粉丝{{summary.followers || 0}} 人
diff --git a/pets-fe/pages/relations/relations.js b/pets-fe/pages/relations/relations.js
new file mode 100644
index 0000000..da59e20
--- /dev/null
+++ b/pets-fe/pages/relations/relations.js
@@ -0,0 +1,41 @@
+const api = require('../../utils/api.js');
+const { toastErr } = require('../../utils/ui.js');
+
+Page({
+ data: { uid: '', kind: 'following', title: '关注', list: [], page: 1, hasMore: false, loaded: false },
+ onLoad(q) {
+ const kind = q && q.kind === 'followers' ? 'followers' : 'following';
+ this.setData({ uid: (q && q.id) || '', kind, title: kind === 'followers' ? '粉丝' : '关注' });
+ this.load(1);
+ },
+ load(page) {
+ api
+ .relations(this.data.uid, this.data.kind, page)
+ .then((res) => {
+ const merged = page === 1 ? res.list || [] : this.data.list.concat(res.list || []);
+ this.setData({ list: merged, page, loaded: true, hasMore: merged.length < (res.total || 0) });
+ })
+ .catch((e) => {
+ this.setData({ loaded: true });
+ toastErr(e);
+ });
+ },
+ loadMore() {
+ if (this.data.hasMore) this.load(this.data.page + 1);
+ },
+ goUser(e) {
+ wx.navigateTo({ url: `/pages/user/user?id=${e.currentTarget.dataset.id}` });
+ },
+ // 同样先本地翻转再发请求,失败回滚
+ toggleFollow(e) {
+ const i = e.currentTarget.dataset.index;
+ const u = this.data.list[i];
+ if (!u || u.is_self) return;
+ const next = !u.followed;
+ this.setData({ [`list[${i}].followed`]: next });
+ (next ? api.followUser(u.id) : api.unfollowUser(u.id)).catch((err) => {
+ this.setData({ [`list[${i}].followed`]: !next });
+ toastErr(err, '操作失败');
+ });
+ },
+});
diff --git a/pets-fe/pages/relations/relations.json b/pets-fe/pages/relations/relations.json
new file mode 100644
index 0000000..6594cd8
--- /dev/null
+++ b/pets-fe/pages/relations/relations.json
@@ -0,0 +1,6 @@
+{
+ "usingComponents": {
+ "nav-bar": "/components/nav-bar/nav-bar",
+ "pt-icon": "/components/pt-icon/index"
+ }
+}
diff --git a/pets-fe/pages/relations/relations.wxml b/pets-fe/pages/relations/relations.wxml
new file mode 100644
index 0000000..7e77f44
--- /dev/null
+++ b/pets-fe/pages/relations/relations.wxml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+ {{item.nickname[0]}}
+
+ {{item.nickname}}AI
+ {{item.followed ? '已关注' : '+ 关注'}}
+
+
+
+ {{kind === 'followers' ? '还没有人关注 TA' : '还没有关注任何人,去宠友圈逛逛'}}
+
+
+
diff --git a/pets-fe/pages/relations/relations.wxss b/pets-fe/pages/relations/relations.wxss
new file mode 100644
index 0000000..1ac6fad
--- /dev/null
+++ b/pets-fe/pages/relations/relations.wxss
@@ -0,0 +1,12 @@
+.rel{
+ display:flex;align-items:center;gap:var(--sp-3);
+ background:#fff;border-radius:var(--r-md);box-shadow:var(--sd-1);
+ padding:var(--sp-3) var(--sp-4);margin-bottom:var(--sp-2);
+}
+.rel-av{
+ width:80rpx;height:80rpx;border-radius:50%;flex:none;overflow:hidden;
+ display:flex;align-items:center;justify-content:center;
+ background:var(--primary-soft);color:var(--primary-ink);font-size:var(--fs-lg);font-weight:var(--fw-b);
+}
+.rel-av-img{width:100%;height:100%}
+.rel-name{flex:1;min-width:0;font-size:var(--fs-md);font-weight:var(--fw-b)}
diff --git a/pets-fe/pages/user/user.js b/pets-fe/pages/user/user.js
new file mode 100644
index 0000000..461bfc8
--- /dev/null
+++ b/pets-fe/pages/user/user.js
@@ -0,0 +1,74 @@
+const api = require('../../utils/api.js');
+const { toastErr } = require('../../utils/ui.js');
+
+function fmtAgo(iso) {
+ if (!iso) return '';
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return '';
+ const min = Math.floor((Date.now() - d.getTime()) / 60000);
+ if (min < 1) return '刚刚';
+ if (min < 60) return min + ' 分钟前';
+ if (min < 60 * 24) return Math.floor(min / 60) + ' 小时前';
+ if (min < 60 * 24 * 7) return Math.floor(min / 1440) + ' 天前';
+ const p = (n) => (n < 10 ? '0' + n : '' + n);
+ return p(d.getMonth() + 1) + '-' + p(d.getDate());
+}
+
+Page({
+ data: { uid: '', card: null, posts: [], page: 1, hasMore: false, loading: true },
+ onLoad(q) {
+ const uid = q && q.id;
+ if (!uid) return wx.showToast({ title: '缺少用户', icon: 'none' });
+ this.setData({ uid });
+ this.loadCard();
+ this.loadPosts(1);
+ },
+ loadCard() {
+ api
+ .userCard(this.data.uid)
+ .then((card) => this.setData({ card, loading: false }))
+ .catch((e) => {
+ this.setData({ loading: false });
+ toastErr(e);
+ });
+ },
+ loadPosts(page) {
+ api
+ .userPosts(this.data.uid, page)
+ .then((res) => {
+ const list = (res.list || []).map((p) => ({ ...p, timeText: fmtAgo(p.created_at) }));
+ const merged = page === 1 ? list : this.data.posts.concat(list);
+ this.setData({ posts: merged, page, hasMore: merged.length < (res.total || 0) });
+ })
+ .catch(() => {});
+ },
+ loadMore() {
+ if (this.data.hasMore) this.loadPosts(this.data.page + 1);
+ },
+ // 关注状态先本地翻转,请求失败再翻回去——等接口返回按钮才变会显得很迟钝
+ toggleFollow() {
+ const c = this.data.card;
+ if (!c || c.is_self) return;
+ const next = !c.followed;
+ this.setData({
+ 'card.followed': next,
+ 'card.follower_count': Math.max(0, c.follower_count + (next ? 1 : -1)),
+ });
+ (next ? api.followUser(c.id) : api.unfollowUser(c.id)).catch((e) => {
+ this.setData({ 'card.followed': !next, 'card.follower_count': c.follower_count });
+ toastErr(e, '操作失败');
+ });
+ },
+ goRelations(e) {
+ const kind = e.currentTarget.dataset.kind;
+ wx.navigateTo({ url: `/pages/relations/relations?id=${this.data.uid}&kind=${kind}` });
+ },
+ previewImage(e) {
+ const { urls, cur } = e.currentTarget.dataset;
+ if (urls && urls.length) wx.previewImage({ urls, current: cur });
+ },
+ onShareAppMessage() {
+ const n = (this.data.card && this.data.card.nickname) || '宠友';
+ return { title: `${n} 的主页`, path: `/pages/user/user?id=${this.data.uid}` };
+ },
+});
diff --git a/pets-fe/pages/user/user.json b/pets-fe/pages/user/user.json
new file mode 100644
index 0000000..6594cd8
--- /dev/null
+++ b/pets-fe/pages/user/user.json
@@ -0,0 +1,6 @@
+{
+ "usingComponents": {
+ "nav-bar": "/components/nav-bar/nav-bar",
+ "pt-icon": "/components/pt-icon/index"
+ }
+}
diff --git a/pets-fe/pages/user/user.wxml b/pets-fe/pages/user/user.wxml
new file mode 100644
index 0000000..d7a5403
--- /dev/null
+++ b/pets-fe/pages/user/user.wxml
@@ -0,0 +1,55 @@
+
+module.exports.isUrl = function (s) { return s && s.indexOf('http') === 0; }
+
+
+
+
+
+ 加载中…
+
+
+
+
+
+ {{card.nickname[0]}}
+
+ {{card.nickname}}AI
+
+
+ {{card.post_count}}帖子
+
+ {{card.following_count}}关注
+
+
+ {{card.follower_count}}粉丝
+
+
+
+
+ {{card.followed ? '已关注' : '关注 TA'}}
+
+
+
+
+
+ {{item.timeText}}
+
+ {{item.content}}
+
+
+
+ {{img}}
+
+
+
+ {{item.like_count || 0}}
+ {{item.comment_count || 0}}
+
+
+
+ TA 还没有发过帖子
+
+
+
diff --git a/pets-fe/pages/user/user.wxss b/pets-fe/pages/user/user.wxss
new file mode 100644
index 0000000..23bb3b5
--- /dev/null
+++ b/pets-fe/pages/user/user.wxss
@@ -0,0 +1,12 @@
+.u-head{text-align:center;padding:var(--sp-6) var(--sp-5)}
+.u-av{
+ width:140rpx;height:140rpx;border-radius:50%;margin:0 auto var(--sp-3);overflow:hidden;
+ display:flex;align-items:center;justify-content:center;
+ background:var(--primary-soft);color:var(--primary-ink);font-size:56rpx;font-weight:var(--fw-b);
+}
+.u-av-img{width:100%;height:100%}
+.u-name{font-size:var(--fs-lg);font-weight:var(--fw-b)}
+.u-stats{display:flex;justify-content:center;gap:var(--sp-6);margin:var(--sp-4) 0}
+.u-stat{display:flex;flex-direction:column;align-items:center;gap:4rpx}
+.u-n{font-size:var(--fs-lg);font-weight:var(--fw-b)}
+.u-l{font-size:var(--fs-cap);color:var(--muted)}
diff --git a/pets-fe/utils/api.js b/pets-fe/utils/api.js
index 2690012..3649017 100644
--- a/pets-fe/utils/api.js
+++ b/pets-fe/utils/api.js
@@ -103,6 +103,10 @@ const api = {
request({ url: `/api/posts?tab=${encodeURIComponent(tab || '')}&page=${page || 1}&page_size=10` }),
createPost: (body) => request({ url: '/api/posts', method: 'POST', data: body }),
likePost: (id) => request({ url: `/api/posts/${id}/like`, method: 'POST' }),
+ userCard: (userId) => request({ url: `/api/users/${userId}/profile` }),
+ userPosts: (userId, page) => request({ url: `/api/users/${userId}/posts?page=${page || 1}&page_size=10` }),
+ relations: (userId, kind, page) =>
+ request({ url: `/api/users/${userId}/relations?kind=${kind}&page=${page || 1}&page_size=20` }),
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' }),