feat: 实现关注系统 + 通知侧边栏分类 TAB + 点赞聚合通知

- 新增关注系统:UserFollow 模型、FollowService(toggle/status/列表隐私控制)
- User 新增 FollowersCount/FollowingCount/FollowListPublic/NotifyPrefs 字段
- Space 页面增加四态关注按钮(关注/已关注/回关/已互粉)+ 粉丝/关注数链接
- 新增 /space/:uid/followers 和 /space/:uid/following 列表页
- 新增 NotifyFollow/NotifyLikeAggregated 通知类型
- ReactionService 点赞时写入 daily_like_summary,访问时生成聚合通知
- FollowService 关注时触发 NotifyFollow 通知
- 消息中心侧边栏升级为分类 TAB(全部/系统通知/@艾特/点赞/关注)
- NotificationService 新增 ListByCategory 按分类分页查询
This commit is contained in:
2026-06-01 16:30:46 +08:00
parent 2b47380ac5
commit 680df7371a
26 changed files with 1251 additions and 23 deletions

View File

@ -0,0 +1,47 @@
{{template "layout/header.html" .}}
<meta name="robots" content="noindex,nofollow">
<body>
{{template "layout/nav.html" .}}
<div class="follow-list-page">
<h1>粉丝列表</h1>
{{if not .Result.Accessible}}
<div class="follow-list-private">
<p>该用户设置了关注列表不公开</p>
</div>
{{else if eq (len .Result.Items) 0}}
<div class="follow-list-empty">
<p>暂无粉丝</p>
</div>
{{else}}
{{range .Result.Items}}
<div class="follow-list-item">
<a href="/space/{{.FollowerID}}" class="follow-list-user">
<div class="follow-list-avatar">
{{slice .FollowerUsername 0 1}}
</div>
<span class="follow-list-name">{{.FollowerUsername}}</span>
</a>
</div>
{{end}}
{{if gt .TotalPages 1}}
<div class="pagination">
{{if .HasPrev}}
<a href="?page={{.PrevPage}}" class="page-link">上一页</a>
{{end}}
<span class="page-info">第 {{.Page}} / {{.TotalPages}} 页</span>
{{if .HasNext}}
<a href="?page={{.NextPage}}" class="page-link">下一页</a>
{{end}}
</div>
{{end}}
{{end}}
</div>
{{template "layout/footer.html" .}}
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
</body>
</html>

View File

@ -0,0 +1,47 @@
{{template "layout/header.html" .}}
<meta name="robots" content="noindex,nofollow">
<body>
{{template "layout/nav.html" .}}
<div class="follow-list-page">
<h1>关注列表</h1>
{{if not .Result.Accessible}}
<div class="follow-list-private">
<p>该用户设置了关注列表不公开</p>
</div>
{{else if eq (len .Result.Items) 0}}
<div class="follow-list-empty">
<p>暂未关注任何人</p>
</div>
{{else}}
{{range .Result.Items}}
<div class="follow-list-item">
<a href="/space/{{.FolloweeID}}" class="follow-list-user">
<div class="follow-list-avatar">
{{slice .FolloweeUsername 0 1}}
</div>
<span class="follow-list-name">{{.FolloweeUsername}}</span>
</a>
</div>
{{end}}
{{if gt .TotalPages 1}}
<div class="pagination">
{{if .HasPrev}}
<a href="?page={{.PrevPage}}" class="page-link">上一页</a>
{{end}}
<span class="page-info">第 {{.Page}} / {{.TotalPages}} 页</span>
{{if .HasNext}}
<a href="?page={{.NextPage}}" class="page-link">下一页</a>
{{end}}
</div>
{{end}}
{{end}}
</div>
{{template "layout/footer.html" .}}
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
</body>
</html>

View File

@ -10,13 +10,29 @@
<h2>消息中心</h2>
</div>
<nav class="msg-sidebar-nav">
<span class="msg-sidebar-item active">
<a href="/messages?tab=all" class="msg-sidebar-item{{if or (eq .CurrentTab "all") (eq .CurrentTab "")}} active{{end}}">
<svg class="msg-sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
全部消息
{{if .UnreadCount}}
<span class="msg-sidebar-count">{{.UnreadCount}}</span>
{{end}}
</span>
</a>
<a href="/messages?tab=system" class="msg-sidebar-item{{if eq .CurrentTab "system"}} active{{end}}">
<svg class="msg-sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
系统通知
</a>
<a href="/messages?tab=mention" class="msg-sidebar-item{{if eq .CurrentTab "mention"}} active{{end}}">
<svg class="msg-sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
@艾特我的
</a>
<a href="/messages?tab=like" class="msg-sidebar-item{{if eq .CurrentTab "like"}} active{{end}}">
<svg class="msg-sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
点赞通知
</a>
<a href="/messages?tab=follow" class="msg-sidebar-item{{if eq .CurrentTab "follow"}} active{{end}}">
<svg class="msg-sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
关注通知
</a>
</nav>
</aside>
@ -78,13 +94,13 @@
{{if .TotalPages}}
<div class="msg-pagination">
{{if .HasPrev}}
<a href="/messages?page={{.PrevPage}}" class="msg-page-btn">上一页</a>
<a href="/messages?tab={{.CurrentTab}}&page={{.PrevPage}}" class="msg-page-btn">上一页</a>
{{else}}
<span class="msg-page-btn disabled">上一页</span>
{{end}}
<span class="msg-page-info">{{.Page}} / {{.TotalPages}}</span>
{{if .HasNext}}
<a href="/messages?page={{.NextPage}}" class="msg-page-btn">下一页</a>
<a href="/messages?tab={{.CurrentTab}}&page={{.NextPage}}" class="msg-page-btn">下一页</a>
{{else}}
<span class="msg-page-btn disabled">下一页</span>
{{end}}

View File

@ -30,6 +30,15 @@
<span class="space-divider">·</span>
<span class="space-joined">加入于 {{.SpaceUser.CreatedAt.Format "2006-01-02"}}</span>
</div>
<div class="follow-links">
<a href="/space/{{.SpaceUser.ID}}/following"><strong>{{.SpaceUser.FollowingCount}}</strong> 关注</a>
<a href="/space/{{.SpaceUser.ID}}/followers"><strong>{{.SpaceUser.FollowersCount}}</strong> 粉丝</a>
</div>
{{if not .IsOwnSpace}}
<div class="follow-btn-wrapper">
<button id="followBtn" class="follow-btn" data-uid="{{.SpaceUser.ID}}">关注</button>
</div>
{{end}}
</div>
</div>
</div>
@ -89,5 +98,75 @@
{{template "layout/footer.html" .}}
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
{{if not .IsOwnSpace}}
<script>
(function() {
var followBtn = document.getElementById('followBtn');
if (!followBtn) return;
var targetUid = followBtn.getAttribute('data-uid');
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
// 四个按钮状态文案
var labels = {
none: '关注',
following: '已关注',
followBack: '回关',
mutual: '已互粉'
};
var classes = {
none: '',
following: 'following',
followBack: '',
mutual: 'mutual'
};
function buttonState(status) {
if (status.i_follow && status.they_follow) return 'mutual';
if (status.i_follow) return 'following';
if (status.they_follow) return 'followBack';
return 'none';
}
function updateBtn(status) {
var state = buttonState(status);
followBtn.textContent = labels[state];
followBtn.className = 'follow-btn ' + (classes[state] || '');
}
// 获取初始状态
fetch('/api/users/' + targetUid + '/follow-status')
.then(function(res) { return res.json(); })
.then(function(data) {
if (data && data.success && data.data) {
updateBtn(data.data);
}
});
// 点击切换
followBtn.addEventListener('click', function() {
followBtn.classList.add('waiting');
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/users/' + targetUid + '/follow', true);
xhr.setRequestHeader('X-CSRF-Token', csrfToken);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
followBtn.classList.remove('waiting');
if (xhr.status === 200) {
try {
var resp = JSON.parse(xhr.responseText);
if (resp.data && resp.data.status) {
updateBtn(resp.data.status);
}
} catch(e) {}
}
}
};
xhr.send();
});
})();
</script>
{{end}}
</body>
</html>