diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md
index 561ca60..d0bc546 100644
--- a/docs/rebuild-spec/02-features.md
+++ b/docs/rebuild-spec/02-features.md
@@ -11,7 +11,7 @@
## A. 浏览与布局
-- [ ] 三栏布局:左导航 / 中 Feed / 右栏小组件
+- [x] 三栏布局:左导航 / 中 Feed / 右栏小组件 — SSR(桌面;≤900px 隐藏右栏)
- [ ] 浅色 / 暗色主题;跟随系统偏好并本地记忆
- [ ] 响应式:平板/手机收起侧栏
- [ ] 长列表虚拟滚动或等价流畅方案
@@ -19,7 +19,7 @@
- [x] 板块筛选:全部 + 单板块
- [ ] 列表样式可配:`title` | `excerpt` | `thumbnail`
- [x] 搜索:关键词、标签、作者、仅标题(`title_only`)— SSR Feed 面板(`/` / `/board/:id`)
-- [ ] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)
+- [x] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)— SSR 热门固定 + `aside_widgets`;Admin 仅友链侧栏开关
- [ ] 登录用户右栏/侧边:签到与抽奖入口(入口暂在 `/profile` 钱包区)
- [ ] 下拉刷新(移动端)
- [ ] 可选伪静态:`/post/123.html` 等形式(后缀后台可配)
diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md
index 439eaa4..2d1d72f 100644
--- a/docs/rebuild-spec/06-pages-ux.md
+++ b/docs/rebuild-spec/06-pages-ux.md
@@ -86,9 +86,10 @@
### 2.3 右栏 RightPanel
-- 登录用户:`AsideCheckInStrip`(签到 + 抽奖 + 积分入口)
-- 热门帖
-- 可配置 widgets:`tag_cloud` / `recent_comments` / `recent_users` / `friend_links`(顺序与开关来自 settings)
+- 登录用户:`AsideCheckInStrip`(签到 + 抽奖 + 积分入口)— **未迁**(入口仍在 `/profile`)
+- 热门帖 — **已迁** SSR(固定块)
+- 可配置 widgets:`tag_cloud` / `recent_comments` / `recent_users` / `friend_links`(顺序与开关来自 `aside_widgets`)— **已迁** SSR;Admin 完整排序编辑器未迁,友链侧栏开关在 `/admin/friend-links`
+- 桌面三列;≤900px 隐藏右栏
### 2.4 移动端
@@ -184,7 +185,7 @@
| Dashboard | 看计数与待办;点进对应列表 |
| Boards | 拖拽或数字排序;图标/色板选择;增删改 |
| Pages | 列表发布开关;进编辑器写正文;nav/footer 勾选 |
-| Links | 品牌友链增删;申请通过/拒绝;回链检测开关;nav/footer 开关 — SSR `/admin/friend-links`(aside / 复检按钮未迁) |
+| Links | 品牌友链增删;申请通过/拒绝;回链检测开关;nav/footer/aside 开关 — SSR `/admin/friend-links`(完整侧栏组件编辑 / 复检按钮未迁) |
| Posts | 按状态筛;通过/拒绝;置顶/版顶/精华/锁编/锁评;进回收站恢复/清除 |
| Comments | 审核;修订查看;回收站 |
| Reports | 处理动作四选一 |
diff --git a/docs/rebuild-spec/08-gitea-ssr-architecture.md b/docs/rebuild-spec/08-gitea-ssr-architecture.md
index c4ab303..77c22e3 100644
--- a/docs/rebuild-spec/08-gitea-ssr-architecture.md
+++ b/docs/rebuild-spec/08-gitea-ssr-architecture.md
@@ -92,6 +92,8 @@ web_src/ → public/assets/
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`。
\ No newline at end of file
diff --git a/modules/webrender/render.go b/modules/webrender/render.go
index 07b089b..2616dbd 100644
--- a/modules/webrender/render.go
+++ b/modules/webrender/render.go
@@ -54,6 +54,7 @@ func funcMap() template.FuncMap {
"postURL": func(id uint) string {
return fmt.Sprintf("/post/%d", id)
},
+ "queryEscape": url.QueryEscape,
}
}
diff --git a/public/assets/site.css b/public/assets/site.css
index e84b921..9c2c423 100644
--- a/public/assets/site.css
+++ b/public/assets/site.css
@@ -61,13 +61,17 @@ body.j13-body {
.j13-nav a:hover { text-decoration: underline; }
.j13-layout {
- max-width: 1100px;
+ max-width: 1200px;
margin: 0 auto;
padding: 1rem;
display: grid;
- grid-template-columns: 200px 1fr;
+ grid-template-columns: 200px 1fr 240px;
gap: 1.25rem;
}
+@media (max-width: 900px) {
+ .j13-layout { grid-template-columns: 200px 1fr; }
+ .j13-aside--right { display: none; }
+}
@media (max-width: 800px) {
.j13-layout { grid-template-columns: 1fr; }
}
@@ -77,6 +81,9 @@ body.j13-body {
flex-direction: column;
gap: 0.25rem;
}
+.j13-aside--right {
+ gap: 1rem;
+}
.j13-aside__link {
padding: 0.45rem 0.65rem;
border-radius: var(--j13-radius);
@@ -90,6 +97,76 @@ body.j13-body {
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 {
background: var(--j13-surface);
border: 1px solid var(--j13-border);
diff --git a/routers/web/admin_friend_links.go b/routers/web/admin_friend_links.go
index 6848ac3..131da4d 100644
--- a/routers/web/admin_friend_links.go
+++ b/routers/web/admin_friend_links.go
@@ -36,15 +36,16 @@ type adminFriendLinkApplyRow struct {
type adminFriendLinksData struct {
AdminChrome
- BrandLinks []adminFriendLinkBrandRow
- Applies []adminFriendLinkApplyRow
- PendingCount int64
- NavShow bool
- FooterShow bool
- ReciprocalCheck bool
- BrandName string
- BrandURL string
- BrandLogo string
+ BrandLinks []adminFriendLinkBrandRow
+ Applies []adminFriendLinkApplyRow
+ PendingCount int64
+ NavShow bool
+ FooterShow bool
+ AsideShow bool
+ ReciprocalCheck bool
+ BrandName string
+ BrandURL string
+ BrandLogo string
}
// AdminFriendLinksGet 友链管理
@@ -60,6 +61,7 @@ func (d Deps) renderAdminFriendLinks(ctx *webctx.Context, errMsg string) {
AdminChrome: chrome,
NavShow: d.Settings.NavShowFriendLinks(),
FooterShow: d.Settings.FooterShowFriendLinks(),
+ AsideShow: d.Settings.AsideShowFriendLinks(),
ReciprocalCheck: d.Settings.FriendLinkReciprocalCheckEnabled(),
}
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.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")
ctx.SetFlash("友链入口设置已保存")
ctx.Redirect("/admin/friend-links")
diff --git a/routers/web/chrome.go b/routers/web/chrome.go
index 9d6fc72..37de8ee 100644
--- a/routers/web/chrome.go
+++ b/routers/web/chrome.go
@@ -34,27 +34,76 @@ type BoardView struct {
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 布局公共字段
type PageChrome struct {
- Title string
- Description string
- SiteName string
- Slogan string
- LogoMark string
- LoggedIn bool
- IsAdmin bool
- ViewerName string
- Boards []BoardView
- ActiveBoard uint
- Path string // 当前请求路径,供侧栏高亮
- Inner string // 保留字段;入口模板已固定组合,不再动态 template
- CSRF string
- Flash string
- Error string
+ Title string
+ Description string
+ SiteName string
+ Slogan string
+ LogoMark string
+ LoggedIn bool
+ IsAdmin bool
+ ViewerName string
+ Boards []BoardView
+ ActiveBoard uint
+ Path string // 当前请求路径,供侧栏高亮
+ Inner string // 保留字段;入口模板已固定组合,不再动态 template
+ CSRF string
+ Flash string
+ Error string
UnreadCount int64 // 登录用户未读私信/通知总数;未登录为 0
ViewerPoints int // 登录用户当前积分;未登录为 0
ShowFriendLinksNav bool
ShowFriendLinksFooter bool
+ RightAside RightAsideData
}
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,
ShowFriendLinksNav: d.Settings.NavShowFriendLinks(),
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 {
s = strings.TrimSpace(s)
if s == "" {
diff --git a/templates/admin/friend_links.tmpl b/templates/admin/friend_links.tmpl
index 9ee430f..3085ba6 100644
--- a/templates/admin/friend_links.tmpl
+++ b/templates/admin/friend_links.tmpl
@@ -12,6 +12,7 @@
+
diff --git a/templates/home/home.tmpl b/templates/home/home.tmpl
index 2967e9b..c0fb44d 100644
--- a/templates/home/home.tmpl
+++ b/templates/home/home.tmpl
@@ -7,6 +7,7 @@
{{template "home/feed" .}}
+ {{template "shared/right_aside" .}}
{{template "base/footer" .}}
{{end}}
diff --git a/templates/post/view.tmpl b/templates/post/view.tmpl
index 71984b8..d8d2e17 100644
--- a/templates/post/view.tmpl
+++ b/templates/post/view.tmpl
@@ -7,6 +7,7 @@
{{template "post/body" .}}
+ {{template "shared/right_aside" .}}
{{template "base/footer" .}}
{{end}}
diff --git a/templates/shared/right_aside.tmpl b/templates/shared/right_aside.tmpl
new file mode 100644
index 0000000..dee4ddf
--- /dev/null
+++ b/templates/shared/right_aside.tmpl
@@ -0,0 +1,74 @@
+{{define "shared/right_aside"}}
+
+{{end}}
diff --git a/web_src/css/site.css b/web_src/css/site.css
index e84b921..9c2c423 100644
--- a/web_src/css/site.css
+++ b/web_src/css/site.css
@@ -61,13 +61,17 @@ body.j13-body {
.j13-nav a:hover { text-decoration: underline; }
.j13-layout {
- max-width: 1100px;
+ max-width: 1200px;
margin: 0 auto;
padding: 1rem;
display: grid;
- grid-template-columns: 200px 1fr;
+ grid-template-columns: 200px 1fr 240px;
gap: 1.25rem;
}
+@media (max-width: 900px) {
+ .j13-layout { grid-template-columns: 200px 1fr; }
+ .j13-aside--right { display: none; }
+}
@media (max-width: 800px) {
.j13-layout { grid-template-columns: 1fr; }
}
@@ -77,6 +81,9 @@ body.j13-body {
flex-direction: column;
gap: 0.25rem;
}
+.j13-aside--right {
+ gap: 1rem;
+}
.j13-aside__link {
padding: 0.45rem 0.65rem;
border-radius: var(--j13-radius);
@@ -90,6 +97,76 @@ body.j13-body {
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 {
background: var(--j13-surface);
border: 1px solid var(--j13-border);