From 959a2c3a2eeba6111d27dde1b6872040fb02b993 Mon Sep 17 00:00:00 2001 From: freefire Date: Sat, 29 Aug 2026 15:19:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SSR=20=E4=B8=89=E6=A0=8F=E5=8F=B3?= =?UTF-8?q?=E6=A0=8F=E7=83=AD=E9=97=A8=E5=B8=96=E4=B8=8E=20aside=20widgets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EOF Co-authored-by: Cursor --- docs/rebuild-spec/02-features.md | 4 +- docs/rebuild-spec/06-pages-ux.md | 9 +- .../rebuild-spec/08-gitea-ssr-architecture.md | 4 +- modules/webrender/render.go | 1 + public/assets/site.css | 81 +++++++++- routers/web/admin_friend_links.go | 21 +-- routers/web/chrome.go | 143 ++++++++++++++++-- templates/admin/friend_links.tmpl | 1 + templates/home/home.tmpl | 1 + templates/post/view.tmpl | 1 + templates/shared/right_aside.tmpl | 74 +++++++++ web_src/css/site.css | 81 +++++++++- 12 files changed, 386 insertions(+), 35 deletions(-) create mode 100644 templates/shared/right_aside.tmpl 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);