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

@@ -41,6 +41,7 @@ type adminFriendLinksData struct {
PendingCount int64 PendingCount int64
NavShow bool NavShow bool
FooterShow bool FooterShow bool
AsideShow bool
ReciprocalCheck bool ReciprocalCheck bool
BrandName string BrandName string
BrandURL string BrandURL string
@@ -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,6 +34,54 @@ 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
@@ -55,6 +103,7 @@ type PageChrome struct {
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);