feat: SSR 三栏右栏热门帖与 aside widgets

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 15:19:32 +08:00
parent f76d3e3bf8
commit 959a2c3a2e
12 changed files with 386 additions and 35 deletions

View File

@@ -11,7 +11,7 @@
## A. 浏览与布局 ## A. 浏览与布局
- [ ] 三栏布局:左导航 / 中 Feed / 右栏小组件 - [x] 三栏布局:左导航 / 中 Feed / 右栏小组件 — SSR桌面≤900px 隐藏右栏)
- [ ] 浅色 / 暗色主题;跟随系统偏好并本地记忆 - [ ] 浅色 / 暗色主题;跟随系统偏好并本地记忆
- [ ] 响应式:平板/手机收起侧栏 - [ ] 响应式:平板/手机收起侧栏
- [ ] 长列表虚拟滚动或等价流畅方案 - [ ] 长列表虚拟滚动或等价流畅方案
@@ -19,7 +19,7 @@
- [x] 板块筛选:全部 + 单板块 - [x] 板块筛选:全部 + 单板块
- [ ] 列表样式可配:`title` | `excerpt` | `thumbnail` - [ ] 列表样式可配:`title` | `excerpt` | `thumbnail`
- [x] 搜索:关键词、标签、作者、仅标题(`title_only`)— SSR Feed 面板(`/` / `/board/:id` - [x] 搜索:关键词、标签、作者、仅标题(`title_only`)— SSR Feed 面板(`/` / `/board/:id`
- [ ] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序) - [x] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)— SSR 热门固定 + `aside_widgets`Admin 仅友链侧栏开关
- [ ] 登录用户右栏/侧边:签到与抽奖入口(入口暂在 `/profile` 钱包区) - [ ] 登录用户右栏/侧边:签到与抽奖入口(入口暂在 `/profile` 钱包区)
- [ ] 下拉刷新(移动端) - [ ] 下拉刷新(移动端)
- [ ] 可选伪静态:`/post/123.html` 等形式(后缀后台可配) - [ ] 可选伪静态:`/post/123.html` 等形式(后缀后台可配)

View File

@@ -86,9 +86,10 @@
### 2.3 右栏 RightPanel ### 2.3 右栏 RightPanel
- 登录用户:`AsideCheckInStrip`(签到 + 抽奖 + 积分入口) - 登录用户:`AsideCheckInStrip`(签到 + 抽奖 + 积分入口)**未迁**(入口仍在 `/profile`
- 热门帖 - 热门帖**已迁** SSR固定块
- 可配置 widgets`tag_cloud` / `recent_comments` / `recent_users` / `friend_links`(顺序与开关来自 settings - 可配置 widgets`tag_cloud` / `recent_comments` / `recent_users` / `friend_links`(顺序与开关来自 `aside_widgets`)— **已迁** SSRAdmin 完整排序编辑器未迁,友链侧栏开关在 `/admin/friend-links`
- 桌面三列≤900px 隐藏右栏
### 2.4 移动端 ### 2.4 移动端
@@ -184,7 +185,7 @@
| Dashboard | 看计数与待办;点进对应列表 | | Dashboard | 看计数与待办;点进对应列表 |
| Boards | 拖拽或数字排序;图标/色板选择;增删改 | | Boards | 拖拽或数字排序;图标/色板选择;增删改 |
| Pages | 列表发布开关进编辑器写正文nav/footer 勾选 | | Pages | 列表发布开关进编辑器写正文nav/footer 勾选 |
| Links | 品牌友链增删;申请通过/拒绝回链检测开关nav/footer 开关 — SSR `/admin/friend-links`aside / 复检按钮未迁) | | Links | 品牌友链增删;申请通过/拒绝回链检测开关nav/footer/aside 开关 — SSR `/admin/friend-links`完整侧栏组件编辑 / 复检按钮未迁) |
| Posts | 按状态筛;通过/拒绝;置顶/版顶/精华/锁编/锁评;进回收站恢复/清除 | | Posts | 按状态筛;通过/拒绝;置顶/版顶/精华/锁编/锁评;进回收站恢复/清除 |
| Comments | 审核;修订查看;回收站 | | Comments | 审核;修订查看;回收站 |
| Reports | 处理动作四选一 | | Reports | 处理动作四选一 |

View File

@@ -92,6 +92,8 @@ web_src/ → public/assets/
Feed 搜索:`/` / `/board/:id` 面板(`keyword` / `tag` / `author` / `title_only`);排序与分页 URL 保参;顶栏链到 `/#search` Feed 搜索:`/` / `/board/:id` 面板(`keyword` / `tag` / `author` / `title_only`);排序与分页 URL 保参;顶栏链到 `/#search`
友链:`/links`(列表、登录申请/取消、Logo 上传Admin `/admin/friend-links`(品牌增删、审核、入口开关) 右栏:首页与帖详情三栏;固定热门帖 + `aside_widgets`(标签云 / 最新评论 / 最新用户 / 友链Admin 友链页可开关侧栏友链
友链:`/links`(列表、登录申请/取消、Logo 上传Admin `/admin/friend-links`品牌增删、审核、nav/footer/aside 入口开关)。
Admin`/admin/dashboard``/admin/boards``/admin/moderation``/admin/settings`(品牌/限流/敏感词/SMTP`/admin/friend-links` Admin`/admin/dashboard``/admin/boards``/admin/moderation``/admin/settings`(品牌/限流/敏感词/SMTP`/admin/friend-links`

View File

@@ -54,6 +54,7 @@ func funcMap() template.FuncMap {
"postURL": func(id uint) string { "postURL": func(id uint) string {
return fmt.Sprintf("/post/%d", id) return fmt.Sprintf("/post/%d", id)
}, },
"queryEscape": url.QueryEscape,
} }
} }

View File

@@ -61,13 +61,17 @@ body.j13-body {
.j13-nav a:hover { text-decoration: underline; } .j13-nav a:hover { text-decoration: underline; }
.j13-layout { .j13-layout {
max-width: 1100px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
display: grid; display: grid;
grid-template-columns: 200px 1fr; grid-template-columns: 200px 1fr 240px;
gap: 1.25rem; gap: 1.25rem;
} }
@media (max-width: 900px) {
.j13-layout { grid-template-columns: 200px 1fr; }
.j13-aside--right { display: none; }
}
@media (max-width: 800px) { @media (max-width: 800px) {
.j13-layout { grid-template-columns: 1fr; } .j13-layout { grid-template-columns: 1fr; }
} }
@@ -77,6 +81,9 @@ body.j13-body {
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
} }
.j13-aside--right {
gap: 1rem;
}
.j13-aside__link { .j13-aside__link {
padding: 0.45rem 0.65rem; padding: 0.45rem 0.65rem;
border-radius: var(--j13-radius); border-radius: var(--j13-radius);
@@ -90,6 +97,76 @@ body.j13-body {
color: var(--j13-accent); color: var(--j13-accent);
} }
.j13-widget__title {
margin: 0 0 0.5rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--j13-muted);
text-transform: none;
letter-spacing: 0.02em;
}
.j13-widget__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.j13-widget__list a {
color: var(--j13-text);
text-decoration: none;
font-size: 0.875rem;
line-height: 1.35;
}
.j13-widget__list a:hover { color: var(--j13-accent); }
.j13-widget__meta {
display: block;
font-size: 0.75rem;
color: var(--j13-muted);
margin-top: 0.1rem;
}
.j13-widget__empty {
margin: 0;
font-size: 0.8rem;
color: var(--j13-muted);
}
.j13-widget__tags {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.5rem;
}
.j13-widget__tags a {
font-size: 0.8rem;
color: var(--j13-accent);
text-decoration: none;
}
.j13-widget__tags a:hover { text-decoration: underline; }
.j13-widget__count {
font-size: 0.7rem;
color: var(--j13-muted);
margin-left: 0.15rem;
}
.j13-widget__links a {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.j13-widget__logo {
width: 1rem;
height: 1rem;
object-fit: contain;
border-radius: 2px;
}
.j13-widget__more {
margin: 0.5rem 0 0;
font-size: 0.8rem;
}
.j13-widget__more a { color: var(--j13-accent); }
.j13-main { .j13-main {
background: var(--j13-surface); background: var(--j13-surface);
border: 1px solid var(--j13-border); border: 1px solid var(--j13-border);

View File

@@ -36,15 +36,16 @@ type adminFriendLinkApplyRow struct {
type adminFriendLinksData struct { type adminFriendLinksData struct {
AdminChrome AdminChrome
BrandLinks []adminFriendLinkBrandRow BrandLinks []adminFriendLinkBrandRow
Applies []adminFriendLinkApplyRow Applies []adminFriendLinkApplyRow
PendingCount int64 PendingCount int64
NavShow bool NavShow bool
FooterShow bool FooterShow bool
ReciprocalCheck bool AsideShow bool
BrandName string ReciprocalCheck bool
BrandURL string BrandName string
BrandLogo string BrandURL string
BrandLogo string
} }
// AdminFriendLinksGet 友链管理 // AdminFriendLinksGet 友链管理
@@ -60,6 +61,7 @@ func (d Deps) renderAdminFriendLinks(ctx *webctx.Context, errMsg string) {
AdminChrome: chrome, AdminChrome: chrome,
NavShow: d.Settings.NavShowFriendLinks(), NavShow: d.Settings.NavShowFriendLinks(),
FooterShow: d.Settings.FooterShowFriendLinks(), FooterShow: d.Settings.FooterShowFriendLinks(),
AsideShow: d.Settings.AsideShowFriendLinks(),
ReciprocalCheck: d.Settings.FriendLinkReciprocalCheckEnabled(), ReciprocalCheck: d.Settings.FriendLinkReciprocalCheckEnabled(),
} }
brand := d.Settings.SiteBranding() brand := d.Settings.SiteBranding()
@@ -105,6 +107,7 @@ func (d Deps) AdminFriendLinksSettingsPost(c *gin.Context) {
} }
_ = d.Settings.SetNavShowFriendLinks(c.PostForm("nav_show") == "1" || c.PostForm("nav_show") == "on") _ = d.Settings.SetNavShowFriendLinks(c.PostForm("nav_show") == "1" || c.PostForm("nav_show") == "on")
_ = d.Settings.SetFooterShowFriendLinks(c.PostForm("footer_show") == "1" || c.PostForm("footer_show") == "on") _ = d.Settings.SetFooterShowFriendLinks(c.PostForm("footer_show") == "1" || c.PostForm("footer_show") == "on")
_ = d.Settings.SetAsideFriendLinksEnabled(c.PostForm("aside_show") == "1" || c.PostForm("aside_show") == "on")
_ = d.Settings.SetFriendLinkReciprocalCheckEnabled(c.PostForm("reciprocal_check") == "1" || c.PostForm("reciprocal_check") == "on") _ = d.Settings.SetFriendLinkReciprocalCheckEnabled(c.PostForm("reciprocal_check") == "1" || c.PostForm("reciprocal_check") == "on")
ctx.SetFlash("友链入口设置已保存") ctx.SetFlash("友链入口设置已保存")
ctx.Redirect("/admin/friend-links") ctx.Redirect("/admin/friend-links")

View File

@@ -34,27 +34,76 @@ type BoardView struct {
Name string Name string
} }
// RightAsideHotPost 右栏热门帖
type RightAsideHotPost struct {
ID uint
Title string
}
// RightAsideTag 右栏标签
type RightAsideTag struct {
Name string
Count int
}
// RightAsideComment 右栏最新评论
type RightAsideComment struct {
PostID uint
Author string
Excerpt string
PostTitle string
}
// RightAsideUser 右栏最新用户
type RightAsideUser struct {
ID uint
Nickname string
}
// RightAsideFriendLink 右栏友链
type RightAsideFriendLink struct {
Name string
URL string
Logo string
}
// RightAsideWidget 按 aside_widgets 顺序的一块(仅已开启)
type RightAsideWidget struct {
Kind string // tag_cloud | recent_comments | recent_users | friend_links
Tags []RightAsideTag
Comments []RightAsideComment
Users []RightAsideUser
FriendLinks []RightAsideFriendLink
}
// RightAsideData 右栏:固定热门 + 可配置 widgets
type RightAsideData struct {
HotPosts []RightAsideHotPost
Widgets []RightAsideWidget
}
// PageChrome 布局公共字段 // PageChrome 布局公共字段
type PageChrome struct { type PageChrome struct {
Title string Title string
Description string Description string
SiteName string SiteName string
Slogan string Slogan string
LogoMark string LogoMark string
LoggedIn bool LoggedIn bool
IsAdmin bool IsAdmin bool
ViewerName string ViewerName string
Boards []BoardView Boards []BoardView
ActiveBoard uint ActiveBoard uint
Path string // 当前请求路径,供侧栏高亮 Path string // 当前请求路径,供侧栏高亮
Inner string // 保留字段;入口模板已固定组合,不再动态 template Inner string // 保留字段;入口模板已固定组合,不再动态 template
CSRF string CSRF string
Flash string Flash string
Error string Error string
UnreadCount int64 // 登录用户未读私信/通知总数;未登录为 0 UnreadCount int64 // 登录用户未读私信/通知总数;未登录为 0
ViewerPoints int // 登录用户当前积分;未登录为 0 ViewerPoints int // 登录用户当前积分;未登录为 0
ShowFriendLinksNav bool ShowFriendLinksNav bool
ShowFriendLinksFooter bool ShowFriendLinksFooter bool
RightAside RightAsideData
} }
func (d Deps) ctx(c *gin.Context) *webctx.Context { func (d Deps) ctx(c *gin.Context) *webctx.Context {
@@ -111,9 +160,73 @@ func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome
ViewerPoints: viewerPoints, ViewerPoints: viewerPoints,
ShowFriendLinksNav: d.Settings.NavShowFriendLinks(), ShowFriendLinksNav: d.Settings.NavShowFriendLinks(),
ShowFriendLinksFooter: d.Settings.FooterShowFriendLinks(), ShowFriendLinksFooter: d.Settings.FooterShowFriendLinks(),
RightAside: d.loadRightAside(brand),
} }
} }
const (
rightAsideHotLimit = 5
rightAsideTagLimit = 24
rightAsideCommentLimit = 8
rightAsideUserLimit = 8
)
func (d Deps) loadRightAside(brand services.SiteBranding) RightAsideData {
out := RightAsideData{
HotPosts: []RightAsideHotPost{},
Widgets: []RightAsideWidget{},
}
if d.Post != nil {
if items, err := d.Post.HotPosts(rightAsideHotLimit); err == nil {
for _, it := range items {
out.HotPosts = append(out.HotPosts, RightAsideHotPost{ID: it.ID, Title: it.Title})
}
}
}
for _, w := range d.Settings.AsideWidgets() {
if !w.Enabled {
continue
}
block := RightAsideWidget{Kind: w.ID}
switch w.ID {
case services.AsideWidgetTagCloud:
if d.Post != nil {
if tags, err := d.Post.PopularTags(rightAsideTagLimit); err == nil {
for _, t := range tags {
block.Tags = append(block.Tags, RightAsideTag{Name: t.Name, Count: t.Count})
}
}
}
case services.AsideWidgetRecentComments:
if d.Comment != nil {
if list, err := d.Comment.ListRecentPublic(rightAsideCommentLimit); err == nil {
for _, c := range list {
block.Comments = append(block.Comments, RightAsideComment{
PostID: c.PostID, Author: c.Author, Excerpt: c.Excerpt, PostTitle: c.PostTitle,
})
}
}
}
case services.AsideWidgetRecentUsers:
if d.User != nil {
if list, err := d.User.ListRecentRegistered(rightAsideUserLimit); err == nil {
for _, u := range list {
block.Users = append(block.Users, RightAsideUser{ID: u.ID, Nickname: u.Nickname})
}
}
}
case services.AsideWidgetFriendLinks:
for _, l := range brand.FriendLinks {
block.FriendLinks = append(block.FriendLinks, RightAsideFriendLink{Name: l.Name, URL: l.URL, Logo: l.Logo})
}
default:
continue
}
out.Widgets = append(out.Widgets, block)
}
return out
}
func firstRuneOr(s, fallback string) string { func firstRuneOr(s, fallback string) string {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
if s == "" { if s == "" {

View File

@@ -12,6 +12,7 @@
<input type="hidden" name="_csrf" value="{{.CSRF}}"/> <input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<label class="j13-check"><input type="checkbox" name="nav_show" value="1"{{if .NavShow}} checked{{end}}/> 导航显示友链入口</label> <label class="j13-check"><input type="checkbox" name="nav_show" value="1"{{if .NavShow}} checked{{end}}/> 导航显示友链入口</label>
<label class="j13-check"><input type="checkbox" name="footer_show" value="1"{{if .FooterShow}} checked{{end}}/> 页脚显示友链入口</label> <label class="j13-check"><input type="checkbox" name="footer_show" value="1"{{if .FooterShow}} checked{{end}}/> 页脚显示友链入口</label>
<label class="j13-check"><input type="checkbox" name="aside_show" value="1"{{if .AsideShow}} checked{{end}}/> 侧栏显示友链</label>
<label class="j13-check"><input type="checkbox" name="reciprocal_check" value="1"{{if .ReciprocalCheck}} checked{{end}}/> 启用回链检测</label> <label class="j13-check"><input type="checkbox" name="reciprocal_check" value="1"{{if .ReciprocalCheck}} checked{{end}}/> 启用回链检测</label>
<button type="submit">保存设置</button> <button type="submit">保存设置</button>
</form> </form>

View File

@@ -7,6 +7,7 @@
<main class="j13-main"> <main class="j13-main">
{{template "home/feed" .}} {{template "home/feed" .}}
</main> </main>
{{template "shared/right_aside" .}}
</div> </div>
{{template "base/footer" .}} {{template "base/footer" .}}
{{end}} {{end}}

View File

@@ -7,6 +7,7 @@
<main class="j13-main"> <main class="j13-main">
{{template "post/body" .}} {{template "post/body" .}}
</main> </main>
{{template "shared/right_aside" .}}
</div> </div>
{{template "base/footer" .}} {{template "base/footer" .}}
{{end}} {{end}}

View File

@@ -0,0 +1,74 @@
{{define "shared/right_aside"}}
<aside class="j13-aside j13-aside--right" aria-label="侧栏">
<section class="j13-widget">
<h2 class="j13-widget__title">热门讨论</h2>
{{if .RightAside.HotPosts}}
<ul class="j13-widget__list">
{{range .RightAside.HotPosts}}
<li><a href="{{postURL .ID}}">{{.Title}}</a></li>
{{end}}
</ul>
{{else}}
<p class="j13-widget__empty">暂无热门帖</p>
{{end}}
</section>
{{range .RightAside.Widgets}}
<section class="j13-widget">
{{if eq .Kind "tag_cloud"}}
<h2 class="j13-widget__title">标签</h2>
{{if .Tags}}
<ul class="j13-widget__tags">
{{range .Tags}}
<li><a href="/?tag={{queryEscape .Name}}">{{.Name}}</a><span class="j13-widget__count">{{.Count}}</span></li>
{{end}}
</ul>
{{else}}
<p class="j13-widget__empty">暂无标签</p>
{{end}}
{{else if eq .Kind "recent_comments"}}
<h2 class="j13-widget__title">最新评论</h2>
{{if .Comments}}
<ul class="j13-widget__list">
{{range .Comments}}
<li>
<a href="{{postURL .PostID}}">{{.Excerpt}}</a>
<span class="j13-widget__meta">{{.Author}} · {{.PostTitle}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="j13-widget__empty">暂无评论</p>
{{end}}
{{else if eq .Kind "recent_users"}}
<h2 class="j13-widget__title">新用户</h2>
{{if .Users}}
<ul class="j13-widget__list">
{{range .Users}}
<li><a href="/user/{{.ID}}">{{.Nickname}}</a></li>
{{end}}
</ul>
{{else}}
<p class="j13-widget__empty">暂无用户</p>
{{end}}
{{else if eq .Kind "friend_links"}}
<h2 class="j13-widget__title">友链</h2>
{{if .FriendLinks}}
<ul class="j13-widget__list j13-widget__links">
{{range .FriendLinks}}
<li>
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer">
{{if .Logo}}<img src="{{.Logo}}" alt="" class="j13-widget__logo"/>{{end}}
{{.Name}}
</a>
</li>
{{end}}
</ul>
<p class="j13-widget__more"><a href="/links">全部友链</a></p>
{{else}}
<p class="j13-widget__empty">暂无友链 · <a href="/links">去看看</a></p>
{{end}}
{{end}}
</section>
{{end}}
</aside>
{{end}}

View File

@@ -61,13 +61,17 @@ body.j13-body {
.j13-nav a:hover { text-decoration: underline; } .j13-nav a:hover { text-decoration: underline; }
.j13-layout { .j13-layout {
max-width: 1100px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
display: grid; display: grid;
grid-template-columns: 200px 1fr; grid-template-columns: 200px 1fr 240px;
gap: 1.25rem; gap: 1.25rem;
} }
@media (max-width: 900px) {
.j13-layout { grid-template-columns: 200px 1fr; }
.j13-aside--right { display: none; }
}
@media (max-width: 800px) { @media (max-width: 800px) {
.j13-layout { grid-template-columns: 1fr; } .j13-layout { grid-template-columns: 1fr; }
} }
@@ -77,6 +81,9 @@ body.j13-body {
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
} }
.j13-aside--right {
gap: 1rem;
}
.j13-aside__link { .j13-aside__link {
padding: 0.45rem 0.65rem; padding: 0.45rem 0.65rem;
border-radius: var(--j13-radius); border-radius: var(--j13-radius);
@@ -90,6 +97,76 @@ body.j13-body {
color: var(--j13-accent); color: var(--j13-accent);
} }
.j13-widget__title {
margin: 0 0 0.5rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--j13-muted);
text-transform: none;
letter-spacing: 0.02em;
}
.j13-widget__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.j13-widget__list a {
color: var(--j13-text);
text-decoration: none;
font-size: 0.875rem;
line-height: 1.35;
}
.j13-widget__list a:hover { color: var(--j13-accent); }
.j13-widget__meta {
display: block;
font-size: 0.75rem;
color: var(--j13-muted);
margin-top: 0.1rem;
}
.j13-widget__empty {
margin: 0;
font-size: 0.8rem;
color: var(--j13-muted);
}
.j13-widget__tags {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.5rem;
}
.j13-widget__tags a {
font-size: 0.8rem;
color: var(--j13-accent);
text-decoration: none;
}
.j13-widget__tags a:hover { text-decoration: underline; }
.j13-widget__count {
font-size: 0.7rem;
color: var(--j13-muted);
margin-left: 0.15rem;
}
.j13-widget__links a {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.j13-widget__logo {
width: 1rem;
height: 1rem;
object-fit: contain;
border-radius: 2px;
}
.j13-widget__more {
margin: 0.5rem 0 0;
font-size: 0.8rem;
}
.j13-widget__more a { color: var(--j13-accent); }
.j13-main { .j13-main {
background: var(--j13-surface); background: var(--j13-surface);
border: 1px solid var(--j13-border); border: 1px solid var(--j13-border);