feat: 完整内容 Limits 与伪静态 Admin

设置页可配字数/编辑窗/分页与伪静态后缀;公开帖/板/用户链接走规范路径并 301。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 04:12:43 +08:00
parent 118f967540
commit f0358e0f71
20 changed files with 277 additions and 63 deletions

View File

@@ -22,7 +22,7 @@
- [x] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)— SSR 热门固定 + `aside_widgets`Admin `/admin/settings` 侧栏组件编辑器 - [x] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)— SSR 热门固定 + `aside_widgets`Admin `/admin/settings` 侧栏组件编辑器
- [x] 登录用户右栏/侧边:签到与抽奖入口 — SSR 右栏条(钱包详情仍在 `/profile#wallet` - [x] 登录用户右栏/侧边:签到与抽奖入口 — SSR 右栏条(钱包详情仍在 `/profile#wallet`
- [ ] 下拉刷新(移动端) - [ ] 下拉刷新(移动端)
- [ ] 可选伪静态:`/post/123.html` 等形式(后缀后台可配) - [x] 可选伪静态:`/post/123.html` 等形式(后缀后台可配)— SSR `/admin/settings` 伪静态;公开路径 301 规范 URL
- [x] 404 页 - [x] 404 页
--- ---
@@ -187,7 +187,8 @@
- [x] 仪表盘:用户/帖/板块计数 + 待审帖/评 + 待处理举报 — SSR `/admin/dashboard` - [x] 仪表盘:用户/帖/板块计数 + 待审帖/评 + 待处理举报 — SSR `/admin/dashboard`
- [x] 敏感词:`forum_settings.filter_words` 读写 + 热更 — SSR `/admin/settings` - [x] 敏感词:`forum_settings.filter_words` 读写 + 热更 — SSR `/admin/settings`
- [x] 基础限流post/comment/register/login/window— SSR;完整 Limits 字数等未迁 - [x] 基础限流post/comment/register/login/window— SSR `/admin/settings`
- [x] 完整 Limits字数 / 编辑窗 / 分页 / 签名头像 / Feed 样式 / 新标签)— SSR `/admin/settings/content-limits`
- [x] SMTP 配置与测试信 — SSR `/admin/settings` 邮件区 - [x] SMTP 配置与测试信 — SSR `/admin/settings` 邮件区
- [x] 站点品牌文案名称、标语、简介、keywords、Logo 字标、ICP — SSR `/admin/settings` - [x] 站点品牌文案名称、标语、简介、keywords、Logo 字标、ICP — SSR `/admin/settings`
- [x] Logo / Favicon / 默认 OG 图上传与清除 — SSR `/admin/settings/brand/upload|clear``<head>` favicon + og:image - [x] Logo / Favicon / 默认 OG 图上传与清除 — SSR `/admin/settings/brand/upload|clear``<head>` favicon + og:image

View File

@@ -48,7 +48,7 @@
| `/admin/dashboard` | 概览计数 | 已迁 | | `/admin/dashboard` | 概览计数 | 已迁 |
| `/admin/boards` | 板块 CRUD | 已迁 | | `/admin/boards` | 板块 CRUD | 已迁 |
| `/admin/moderation` | 待审帖/评 通过/拒绝 | 已迁 | | `/admin/moderation` | 待审帖/评 通过/拒绝 | 已迁 |
| `/admin/settings` | 品牌(含 Logo/Favicon/OG 上传)+ 基础限流 + 敏感词 + SMTP + 备份 | 已迁 | | `/admin/settings` | 品牌(含 Logo/Favicon/OG+ 限流 + 内容限制 + 伪静态 + 敏感词 + SMTP + 备份 | 已迁 |
| `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 | | `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 |
| `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 | | `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 |
| `/admin/reports` | 举报处理 | 已迁 | | `/admin/reports` | 举报处理 | 已迁 |
@@ -58,7 +58,7 @@
| `/admin/trash` | 帖回收站 | 已迁 | | `/admin/trash` | 帖回收站 | 已迁 |
| `/admin/login` | 重定向前台登录 | 已迁 | | `/admin/login` | 重定向前台登录 | 已迁 |
未迁(原 SPA完整 Limits / OIDC·Gitea·存储热切换 Admin 等。 未迁(原 SPAOIDC·Gitea·存储热切换 Admin 等。
--- ---
@@ -200,7 +200,7 @@
| Badges | 定义自动/限定徽章;颁发/收回限定 — SSR `/admin/badges` | | Badges | 定义自动/限定徽章;颁发/收回限定 — SSR `/admin/badges` |
| Media | 分类浏览;单删/批量删;同步索引 — SSR `/admin/media` | | Media | 分类浏览;单删/批量删;同步索引 — SSR `/admin/media` |
| Media | 分类浏览;批量删 | | Media | 分类浏览;批量删 |
| Settings | Tab论坛限制、侧栏组件、伪静态、邮件、OIDC+客户端、Gitea、存储、品牌、敏感词、备份 — SSR `/admin/settings` 已迁品牌(含 Logo/Favicon/OG/限流/侧栏/敏感词/邮件/SQLite 备份OIDC/Gitea/存储/伪静态未迁) | | Settings | 品牌(含 Logo/Favicon/OG、限流、内容限制、伪静态、侧栏、敏感词、邮件、SQLite 备份 — SSR `/admin/settings`OIDC/Gitea/存储热切换未迁) |
--- ---

View File

@@ -4,7 +4,6 @@
> **分支**`rebuild/gitea-ssr``main` = SPA 对照) > **分支**`rebuild/gitea-ssr``main` = SPA 对照)
> **验收清单** [02-features.md](02-features.md) > **验收清单** [02-features.md](02-features.md)
> **架构** [08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md) > **架构** [08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md)
> **维护约定**:每次「继续」开新刀前更新本文「当前指针」;功能勾选以 `02` 为准,本文管阶段与刀序。
--- ---
@@ -12,15 +11,15 @@
| 项 | 值 | | 项 | 值 |
|----|-----| |----|-----|
| **上一刀** | P0发评限流 + 品牌 Logo/Favicon/OG 上传与 head meta | | **上一刀** | 完整 Limits + 伪静态 Admin / 规范 URL |
| **下一刀** | **P3 / 后置**(需点名:伪静态、完整 Limits、OIDC、S3 热切换、编辑器、嵌套评论 | | **下一刀** | **后置或体验**需点名OIDC、S3 热切换、编辑器、嵌套评论、Gitea… |
| **工作区** | 应干净 | | **工作区** | 应干净 |
--- ---
## 一句话状态 ## 一句话状态
核心论坛、Admin P1、§H 成长/防刷、P0 品牌图发评限流 **已齐**。余量主要是体验打磨与后置产品化 核心论坛、Admin、§H、品牌图发评限流、**完整 Limits 与伪静态**已齐。余量多为后置产品化与体验打磨
--- ---
@@ -28,37 +27,22 @@
| 域 | 状态 | | 域 | 状态 |
|----|------| |----|------|
| 五种帖 + Admin users/badges/media | 已迁 | | Admin users / badges / media | 已迁 |
| §H Exp/等级/徽章/防刷 | 已迁 | | §H 等级 / 徽章 / 防刷 | 已迁 |
| 发评 `RateLimiter` comment | 已 | | 品牌 Logo/Favicon/OG + 发评限流 | 已 |
| 品牌 Logo/Favicon/OG 上传 + favicon/og meta | 已迁 | | 内容 Limits + 伪静态 | 已迁 |
--- ---
## 未完成分层 ## 未完成
### P0 余量 ### P3 体验
- 头像裁剪等边角 - 主题、侧栏折叠、虚拟滚动、TipTap、游客评论、嵌套树、修订 diff
### P3 — 体验 / 编辑器
- 主题、侧栏折叠、虚拟滚动、`feed_list_style`、伪静态 Admin
- TipTap / Markdown 双模
- 游客评论、评论嵌套树
- 修订 diff、完整 Limits 字数
### 后置 ### 后置
- KGitea `/projects` - OIDC Admin CRUD、S3 热切换 UI、Gitea `/projects`、WebP 产品化
- LOIDC 产品化 Admin CRUD
- MS3 热切换、WebP 全链路产品化
---
## 默认「继续」
需点名下一刀;未点名时优先小清理或明确产品偏好。
--- ---
@@ -68,4 +52,3 @@
|------|--------| |------|--------|
| 某功能做没做? | [02-features.md](02-features.md) | | 某功能做没做? | [02-features.md](02-features.md) |
| 现在该干什么? | 本文「当前指针」 | | 现在该干什么? | 本文「当前指针」 |
| 路由迁没迁? | [06-pages-ux.md](06-pages-ux.md) §1 |

View File

@@ -429,6 +429,7 @@ type adminSettingsData struct {
RateReg int RateReg int
RateLogin int RateLogin int
RateWindow int RateWindow int
Limits services.ForumLimits
FilterWords string FilterWords string
FilterCount int FilterCount int
Mail services.MailConfig Mail services.MailConfig
@@ -465,6 +466,7 @@ func (d Deps) renderAdminSettings(ctx *webctx.Context, errMsg string) {
RateReg: lim.RateLimitRegister, RateReg: lim.RateLimitRegister,
RateLogin: lim.RateLimitLogin, RateLogin: lim.RateLimitLogin,
RateWindow: lim.RateLimitWindowSec, RateWindow: lim.RateLimitWindowSec,
Limits: lim,
FilterWords: words, FilterWords: words,
FilterCount: services.CountFilterWords(words), FilterCount: services.CountFilterWords(words),
Mail: mail, Mail: mail,
@@ -624,6 +626,51 @@ func (d Deps) AdminSettingsLimitsPost(c *gin.Context) {
ctx.Redirect("/admin/settings") ctx.Redirect("/admin/settings")
} }
// AdminSettingsContentLimitsPost 字数与编辑窗等
func (d Deps) AdminSettingsContentLimitsPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
atoi := func(name string) int {
n, _ := strconv.Atoi(strings.TrimSpace(c.PostForm(name)))
return n
}
err := d.Settings.UpdateContentLimits(
atoi("post_edit_window_hours"), atoi("comment_edit_window_minutes"),
atoi("post_title_max"), atoi("post_tags_max"), atoi("post_content_max"), atoi("comment_max"),
atoi("search_keyword_min"), atoi("search_keyword_max"), atoi("page_size_default"),
atoi("password_min_len"), atoi("avatar_max_mb"), atoi("signature_max"),
c.PostForm("open_posts_in_new_tab") == "1" || c.PostForm("open_posts_in_new_tab") == "on",
c.PostForm("open_content_links_in_new_tab") == "1" || c.PostForm("open_content_links_in_new_tab") == "on",
strings.TrimSpace(c.PostForm("feed_list_style")),
)
if err != nil {
d.renderAdminSettings(ctx, "内容限制无效,请检查数值范围")
return
}
ctx.SetFlash("内容限制已保存")
ctx.Redirect("/admin/settings")
}
// AdminSettingsPermalinkPost 伪静态
func (d Deps) AdminSettingsPermalinkPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
enabled := c.PostForm("permalink_enabled") == "1" || c.PostForm("permalink_enabled") == "on"
ext := strings.TrimSpace(c.PostForm("permalink_ext"))
if err := d.Settings.UpdatePermalinkSettings(enabled, ext); err != nil {
d.renderAdminSettings(ctx, "伪静态后缀无效116 位字母数字)")
return
}
ctx.SetFlash("伪静态设置已保存")
ctx.Redirect("/admin/settings")
}
// AdminSettingsFilterWordsPost 敏感词 // AdminSettingsFilterWordsPost 敏感词
func (d Deps) AdminSettingsFilterWordsPost(c *gin.Context) { func (d Deps) AdminSettingsFilterWordsPost(c *gin.Context) {
ctx := d.ctx(c) ctx := d.ctx(c)

View File

@@ -10,6 +10,7 @@ import (
type boardsItemView struct { type boardsItemView struct {
ID uint ID uint
Name string Name string
Href string
Description string Description string
Icon string Icon string
ColorIndex int ColorIndex int
@@ -32,6 +33,7 @@ func (d Deps) renderBoards(ctx *webctx.Context) {
chrome := d.chrome(ctx, "板块 · "+brand.Name, "", "") chrome := d.chrome(ctx, "板块 · "+brand.Name, "", "")
list, _ := d.Board.ListWithStats() list, _ := d.Board.ListWithStats()
items := make([]boardsItemView, 0, len(list)) items := make([]boardsItemView, 0, len(list))
pl := d.permalink()
for _, b := range list { for _, b := range list {
color := b.ColorIndex color := b.ColorIndex
if color < 0 { if color < 0 {
@@ -40,6 +42,7 @@ func (d Deps) renderBoards(ctx *webctx.Context) {
items = append(items, boardsItemView{ items = append(items, boardsItemView{
ID: b.ID, ID: b.ID,
Name: b.Name, Name: b.Name,
Href: pl.BoardPath(b.ID),
Description: b.Description, Description: b.Description,
Icon: b.Icon, Icon: b.Icon,
ColorIndex: color, ColorIndex: color,

View File

@@ -1,6 +1,7 @@
package web package web
import ( import (
"net/http"
"strings" "strings"
"git.iioio.com/freefire/jiang13-forum/modules/webctx" "git.iioio.com/freefire/jiang13-forum/modules/webctx"
@@ -44,12 +45,14 @@ type SitePageLink struct {
type BoardView struct { type BoardView struct {
ID uint ID uint
Name string Name string
Href string
} }
// RightAsideHotPost 右栏热门帖 // RightAsideHotPost 右栏热门帖
type RightAsideHotPost struct { type RightAsideHotPost struct {
ID uint ID uint
Title string Title string
Href string
} }
// RightAsideTag 右栏标签 // RightAsideTag 右栏标签
@@ -61,6 +64,7 @@ type RightAsideTag struct {
// RightAsideComment 右栏最新评论 // RightAsideComment 右栏最新评论
type RightAsideComment struct { type RightAsideComment struct {
PostID uint PostID uint
PostHref string
Author string Author string
Excerpt string Excerpt string
PostTitle string PostTitle string
@@ -70,6 +74,7 @@ type RightAsideComment struct {
type RightAsideUser struct { type RightAsideUser struct {
ID uint ID uint
Nickname string Nickname string
Href string
} }
// RightAsideFriendLink 右栏友链 // RightAsideFriendLink 右栏友链
@@ -111,6 +116,8 @@ type PageChrome struct {
LogoURL string LogoURL string
FaviconURL string FaviconURL string
OGImageURL string OGImageURL string
PermalinkSuffix string // 如 ".html";未启用为空
OpenPostsInNewTab bool
LoggedIn bool LoggedIn bool
IsAdmin bool IsAdmin bool
ViewerName string ViewerName string
@@ -151,8 +158,9 @@ func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome
} }
boards, _ := d.Board.List() boards, _ := d.Board.List()
bv := make([]BoardView, 0, len(boards)) bv := make([]BoardView, 0, len(boards))
pl := d.permalink()
for _, b := range boards { for _, b := range boards {
bv = append(bv, BoardView{ID: b.ID, Name: b.Name}) bv = append(bv, BoardView{ID: b.ID, Name: b.Name, Href: pl.BoardPath(b.ID)})
} }
var unread int64 var unread int64
if ctx.IsSigned() && d.Message != nil { if ctx.IsSigned() && d.Message != nil {
@@ -176,6 +184,8 @@ func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome
LogoURL: strings.TrimSpace(brand.Logo), LogoURL: strings.TrimSpace(brand.Logo),
FaviconURL: strings.TrimSpace(brand.Favicon), FaviconURL: strings.TrimSpace(brand.Favicon),
OGImageURL: strings.TrimSpace(brand.DefaultShareImage()), OGImageURL: strings.TrimSpace(brand.DefaultShareImage()),
PermalinkSuffix: d.Settings.Permalink().Suffix(),
OpenPostsInNewTab: d.Settings.OpenPostsInNewTab(),
LoggedIn: ctx.IsSigned(), LoggedIn: ctx.IsSigned(),
IsAdmin: ctx.IsAdmin(), IsAdmin: ctx.IsAdmin(),
ViewerName: name, ViewerName: name,
@@ -242,8 +252,9 @@ func (d Deps) loadRightAside(ctx *webctx.Context, brand services.SiteBranding) R
} }
if d.Post != nil { if d.Post != nil {
if items, err := d.Post.HotPosts(rightAsideHotLimit); err == nil { if items, err := d.Post.HotPosts(rightAsideHotLimit); err == nil {
pl := d.permalink()
for _, it := range items { for _, it := range items {
out.HotPosts = append(out.HotPosts, RightAsideHotPost{ID: it.ID, Title: it.Title}) out.HotPosts = append(out.HotPosts, RightAsideHotPost{ID: it.ID, Title: it.Title, Href: pl.PostPath(it.ID)})
} }
} }
} }
@@ -264,9 +275,10 @@ func (d Deps) loadRightAside(ctx *webctx.Context, brand services.SiteBranding) R
case services.AsideWidgetRecentComments: case services.AsideWidgetRecentComments:
if d.Comment != nil { if d.Comment != nil {
if list, err := d.Comment.ListRecentPublic(rightAsideCommentLimit); err == nil { if list, err := d.Comment.ListRecentPublic(rightAsideCommentLimit); err == nil {
pl := d.permalink()
for _, c := range list { for _, c := range list {
block.Comments = append(block.Comments, RightAsideComment{ block.Comments = append(block.Comments, RightAsideComment{
PostID: c.PostID, Author: c.Author, Excerpt: c.Excerpt, PostTitle: c.PostTitle, PostID: c.PostID, PostHref: pl.PostPath(c.PostID), Author: c.Author, Excerpt: c.Excerpt, PostTitle: c.PostTitle,
}) })
} }
} }
@@ -274,8 +286,9 @@ func (d Deps) loadRightAside(ctx *webctx.Context, brand services.SiteBranding) R
case services.AsideWidgetRecentUsers: case services.AsideWidgetRecentUsers:
if d.User != nil { if d.User != nil {
if list, err := d.User.ListRecentRegistered(rightAsideUserLimit); err == nil { if list, err := d.User.ListRecentRegistered(rightAsideUserLimit); err == nil {
pl := d.permalink()
for _, u := range list { for _, u := range list {
block.Users = append(block.Users, RightAsideUser{ID: u.ID, Nickname: u.Nickname}) block.Users = append(block.Users, RightAsideUser{ID: u.ID, Nickname: u.Nickname, Href: pl.UserPath(u.ID)})
} }
} }
} }
@@ -311,3 +324,39 @@ func stripIDParam(raw, permalinkExt string) string {
raw = strings.TrimSuffix(raw, ".htm") raw = strings.TrimSuffix(raw, ".htm")
return raw return raw
} }
func (d Deps) permalink() services.PermalinkConfig {
if d.Settings == nil {
return services.PermalinkConfig{}
}
return d.Settings.Permalink()
}
func (d Deps) postHref(id uint) string {
return d.permalink().PostPath(id)
}
func (d Deps) boardHref(id uint) string {
return d.permalink().BoardPath(id)
}
func (d Deps) userHref(id uint) string {
return d.permalink().UserPath(id)
}
// redirectIfNotCanonical 伪静态开启时把无后缀/错误后缀 301 到规范路径
func (d Deps) redirectIfNotCanonical(c *gin.Context, match services.PermalinkMatch) bool {
if !match.OK || !d.permalink().Enabled {
return false
}
path := c.Request.URL.Path
if !match.NeedsCanonicalRedirect(path) {
return false
}
loc := match.Canonical
if q := c.Request.URL.RawQuery; q != "" {
loc = loc + "?" + q
}
c.Redirect(http.StatusMovedPermanently, loc)
return true
}

View File

@@ -106,7 +106,7 @@ func (d Deps) ComposePost(c *gin.Context) {
} else { } else {
ctx.SetFlash("发帖成功") ctx.SetFlash("发帖成功")
} }
ctx.Redirect(fmt.Sprintf("/post/%d", post.ID)) ctx.Redirect(d.postHref(post.ID))
} }
// PostEditGet 编辑帖 // PostEditGet 编辑帖
@@ -152,7 +152,7 @@ func (d Deps) PostEditPost(c *gin.Context) {
return return
} }
ctx.SetFlash("已保存") ctx.SetFlash("已保存")
ctx.Redirect(fmt.Sprintf("/post/%d", post.ID)) ctx.Redirect(d.postHref(post.ID))
} }
// ComposeUpload 帖图上传JSON供 compose 页 fetch // ComposeUpload 帖图上传JSON供 compose 页 fetch

View File

@@ -11,6 +11,7 @@ import (
type favItem struct { type favItem struct {
PostID uint PostID uint
Title string Title string
Href string
AuthorName string AuthorName string
BoardName string BoardName string
CreatedLabel string CreatedLabel string
@@ -43,6 +44,7 @@ func (d Deps) FavoritesGet(c *gin.Context) {
return return
} }
items := make([]favItem, 0, len(favs)) items := make([]favItem, 0, len(favs))
pl := d.permalink()
for _, f := range favs { for _, f := range favs {
title := f.Post.Title title := f.Post.Title
author := strings.TrimSpace(f.Post.User.Nickname) author := strings.TrimSpace(f.Post.User.Nickname)
@@ -54,7 +56,7 @@ func (d Deps) FavoritesGet(c *gin.Context) {
board = f.Post.Board.Name board = f.Post.Board.Name
} }
items = append(items, favItem{ items = append(items, favItem{
PostID: f.PostID, Title: title, AuthorName: author, BoardName: board, PostID: f.PostID, Title: title, Href: pl.PostPath(f.PostID), AuthorName: author, BoardName: board,
CreatedLabel: formatTime(f.CreatedAt), CreatedLabel: formatTime(f.CreatedAt),
}) })
} }

View File

@@ -78,6 +78,8 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
admin.POST("/settings/brand/upload", deps.AdminSettingsBrandUploadPost) admin.POST("/settings/brand/upload", deps.AdminSettingsBrandUploadPost)
admin.POST("/settings/brand/clear", deps.AdminSettingsBrandClearPost) admin.POST("/settings/brand/clear", deps.AdminSettingsBrandClearPost)
admin.POST("/settings/limits", deps.AdminSettingsLimitsPost) admin.POST("/settings/limits", deps.AdminSettingsLimitsPost)
admin.POST("/settings/content-limits", deps.AdminSettingsContentLimitsPost)
admin.POST("/settings/permalink", deps.AdminSettingsPermalinkPost)
admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost) admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost)
admin.POST("/settings/mail", deps.AdminSettingsMailPost) admin.POST("/settings/mail", deps.AdminSettingsMailPost)
admin.POST("/settings/mail/test", deps.AdminSettingsMailTestPost) admin.POST("/settings/mail/test", deps.AdminSettingsMailTestPost)
@@ -165,6 +167,7 @@ type HomePageData struct {
type PostListItem struct { type PostListItem struct {
ID uint ID uint
Title string Title string
Href string
AuthorName string AuthorName string
BoardName string BoardName string
Pinned bool Pinned bool
@@ -176,22 +179,22 @@ type PostListItem struct {
// FormAction 搜索表单提交路径(当前 Feed // FormAction 搜索表单提交路径(当前 Feed
func (d HomePageData) FormAction() string { func (d HomePageData) FormAction() string {
if d.ActiveBoard > 0 { if d.ActiveBoard > 0 {
return fmt.Sprintf("/board/%d", d.ActiveBoard) return fmt.Sprintf("/board/%d%s", d.ActiveBoard, d.PermalinkSuffix)
} }
return "/" return "/"
} }
// SortHref 排序链接,保留搜索参数 // SortHref 排序链接,保留搜索参数
func (d HomePageData) SortHref(sort string) string { func (d HomePageData) SortHref(sort string) string {
return buildFeedURL(d.ActiveBoard, sort, 0, d.Keyword, d.Tag, d.Author, d.TitleOnly) return buildFeedURL(d.ActiveBoard, d.PermalinkSuffix, sort, 0, d.Keyword, d.Tag, d.Author, d.TitleOnly)
} }
// PageHref 分页链接,保留搜索与排序 // PageHref 分页链接,保留搜索与排序
func (d HomePageData) PageHref(page int) string { func (d HomePageData) PageHref(page int) string {
return buildFeedURL(d.ActiveBoard, d.Sort, page, d.Keyword, d.Tag, d.Author, d.TitleOnly) return buildFeedURL(d.ActiveBoard, d.PermalinkSuffix, d.Sort, page, d.Keyword, d.Tag, d.Author, d.TitleOnly)
} }
func buildFeedURL(boardID uint, sort string, page int, keyword, tag, author string, titleOnly bool) string { func buildFeedURL(boardID uint, permalinkSuffix, sort string, page int, keyword, tag, author string, titleOnly bool) string {
q := url.Values{} q := url.Values{}
if sort != "" && sort != "latest" { if sort != "" && sort != "latest" {
q.Set("sort", sort) q.Set("sort", sort)
@@ -213,7 +216,7 @@ func buildFeedURL(boardID uint, sort string, page int, keyword, tag, author stri
} }
path := "/" path := "/"
if boardID > 0 { if boardID > 0 {
path = fmt.Sprintf("/board/%d", boardID) path = fmt.Sprintf("/board/%d%s", boardID, permalinkSuffix)
} }
if enc := q.Encode(); enc != "" { if enc := q.Encode(); enc != "" {
return path + "?" + enc return path + "?" + enc
@@ -243,6 +246,12 @@ func (d Deps) Home(c *gin.Context) {
if n, err := strconv.ParseUint(idStr, 10, 64); err == nil { if n, err := strconv.ParseUint(idStr, 10, 64); err == nil {
boardID = uint(n) boardID = uint(n)
} }
if boardID > 0 {
match := d.permalink().MatchBoardPath(c.Request.URL.Path)
if d.redirectIfNotCanonical(c, match) {
return
}
}
} }
chrome := d.chrome(ctx, "", "", "home/feed") chrome := d.chrome(ctx, "", "", "home/feed")
@@ -309,6 +318,7 @@ func (d Deps) Home(c *gin.Context) {
} }
posts := make([]PostListItem, 0, len(items)) posts := make([]PostListItem, 0, len(items))
pl := d.permalink()
for _, it := range items { for _, it := range items {
authorName := strings.TrimSpace(it.User.Nickname) authorName := strings.TrimSpace(it.User.Nickname)
if authorName == "" { if authorName == "" {
@@ -319,7 +329,7 @@ func (d Deps) Home(c *gin.Context) {
bname = it.Board.Name bname = it.Board.Name
} }
posts = append(posts, PostListItem{ posts = append(posts, PostListItem{
ID: it.ID, Title: it.Title, AuthorName: authorName, BoardName: bname, ID: it.ID, Title: it.Title, Href: pl.PostPath(it.ID), AuthorName: authorName, BoardName: bname,
Pinned: it.Pinned, Featured: it.Featured, CommentCount: it.CommentCount, Pinned: it.Pinned, Featured: it.Featured, CommentCount: it.CommentCount,
CreatedLabel: it.CreatedAt.Local().Format("2006-01-02 15:04"), CreatedLabel: it.CreatedAt.Local().Format("2006-01-02 15:04"),
}) })

View File

@@ -18,11 +18,14 @@ import (
type PostPageData struct { type PostPageData struct {
PageChrome PageChrome
PostID uint PostID uint
PostHref string
PostPath string PostPath string
PostTitle string PostTitle string
AuthorName string AuthorName string
AuthorID uint AuthorID uint
AuthorHref string
BoardID uint BoardID uint
BoardHref string
BoardName string BoardName string
Pinned bool // 展示用:全局或版内置顶 Pinned bool // 展示用:全局或版内置顶
GlobalPinned bool GlobalPinned bool
@@ -132,6 +135,10 @@ func (d Deps) PostView(c *gin.Context) {
d.render404(ctx) d.render404(ctx)
return return
} }
match := d.permalink().MatchPostPath(c.Request.URL.Path)
if d.redirectIfNotCanonical(c, match) {
return
}
post, err := d.Post.FindByID(uint(id)) post, err := d.Post.FindByID(uint(id))
if err != nil || !services.CanViewPost(post, ctx.UserID(), ctx.IsAdmin()) { if err != nil || !services.CanViewPost(post, ctx.UserID(), ctx.IsAdmin()) {
d.render404(ctx) d.render404(ctx)
@@ -260,9 +267,11 @@ func (d Deps) PostView(c *gin.Context) {
ctx.HTML(http.StatusOK, "post", PostPageData{ ctx.HTML(http.StatusOK, "post", PostPageData{
PageChrome: chrome, PostID: post.ID, PageChrome: chrome, PostID: post.ID,
PostPath: url.QueryEscape(fmt.Sprintf("/post/%d", post.ID)), PostHref: d.postHref(post.ID),
PostPath: url.QueryEscape(d.postHref(post.ID)),
PostTitle: post.Title, AuthorName: author, AuthorID: post.UserID, PostTitle: post.Title, AuthorName: author, AuthorID: post.UserID,
BoardID: post.BoardID, BoardName: boardName, AuthorHref: d.userHref(post.UserID),
BoardID: post.BoardID, BoardHref: d.boardHref(post.BoardID), BoardName: boardName,
Pinned: post.Pinned || post.BoardPinned, GlobalPinned: post.Pinned, BoardPinned: post.BoardPinned, Pinned: post.Pinned || post.BoardPinned, GlobalPinned: post.Pinned, BoardPinned: post.BoardPinned,
Featured: post.Featured, EditLocked: post.EditLocked, Featured: post.Featured, EditLocked: post.EditLocked,
PostTypeLabel: postTypeLabel(post.PostType), CreatedLabel: formatTime(post.CreatedAt), PostTypeLabel: postTypeLabel(post.PostType), CreatedLabel: formatTime(post.CreatedAt),

View File

@@ -42,7 +42,8 @@ type userPublicData struct {
// UserPublic 公开用户主页(不含邮箱) // UserPublic 公开用户主页(不含邮箱)
func (d Deps) UserPublic(c *gin.Context) { func (d Deps) UserPublic(c *gin.Context) {
ctx := d.ctx(c) ctx := d.ctx(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64) idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext)
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil || id == 0 { if err != nil || id == 0 {
d.render404(ctx) d.render404(ctx)
return return
@@ -52,6 +53,10 @@ func (d Deps) UserPublic(c *gin.Context) {
d.render404(ctx) d.render404(ctx)
return return
} }
match := d.permalink().MatchUserPath(c.Request.URL.Path)
if d.redirectIfNotCanonical(c, match) {
return
}
st, _ := d.User.ActivityStats(user.ID) st, _ := d.User.ActivityStats(user.ID)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 { if page < 1 {
@@ -67,13 +72,14 @@ func (d Deps) UserPublic(c *gin.Context) {
ViewerIsAdmin: ctx.IsAdmin(), ViewerIsAdmin: ctx.IsAdmin(),
}) })
items := make([]PostListItem, 0, len(posts)) items := make([]PostListItem, 0, len(posts))
pl := d.permalink()
for _, p := range posts { for _, p := range posts {
board := "" board := ""
if p.Board.ID > 0 { if p.Board.ID > 0 {
board = p.Board.Name board = p.Board.Name
} }
items = append(items, PostListItem{ items = append(items, PostListItem{
ID: p.ID, Title: p.Title, AuthorName: displayName(user), ID: p.ID, Title: p.Title, Href: pl.PostPath(p.ID), AuthorName: displayName(user),
BoardName: board, Pinned: p.Pinned || p.BoardPinned, Featured: p.Featured, BoardName: board, Pinned: p.Pinned || p.BoardPinned, Featured: p.Featured,
CreatedLabel: formatTime(p.CreatedAt), CreatedLabel: formatTime(p.CreatedAt),
}) })

View File

@@ -614,6 +614,73 @@ func (s *ForumSettingsService) UpdateRateLimits(post, comment, register, login,
return nil return nil
} }
// UpdateContentLimits 更新字数/编辑窗/分页/账号相关限制(不改 aside / 伪静态 / 限流)
func (s *ForumSettingsService) UpdateContentLimits(
postEditHours, commentEditMinutes,
titleMax, tagsMax, contentMax, commentMax,
searchMin, searchMax, pageSize,
passwordMin, avatarMB, signatureMax int,
openPostsNewTab, openLinksNewTab bool,
feedListStyle string,
) error {
updates := map[string]int{
SettingPostEditWindowHours: postEditHours,
SettingCommentEditWindowMinutes: commentEditMinutes,
SettingPostTitleMax: titleMax,
SettingPostTagsMax: tagsMax,
SettingPostContentMax: contentMax,
SettingCommentMax: commentMax,
SettingSearchKeywordMin: searchMin,
SettingSearchKeywordMax: searchMax,
SettingPageSizeDefault: pageSize,
SettingPasswordMinLen: passwordMin,
SettingAvatarMaxMB: avatarMB,
SettingSignatureMax: signatureMax,
}
if searchMax > 0 && searchMin > searchMax {
return ErrInvalidSetting
}
for key, val := range updates {
if err := s.setInt(key, val); err != nil {
return err
}
}
boolUpdates := map[string]bool{
SettingOpenPostsInNewTab: openPostsNewTab,
SettingOpenContentLinksInNewTab: openLinksNewTab,
}
for key, on := range boolUpdates {
v := "0"
if on {
v = "1"
}
if err := s.setString(key, v); err != nil {
return err
}
}
style, ok := NormalizeFeedListStyle(feedListStyle)
if !ok {
return ErrInvalidSetting
}
return s.setString(SettingFeedListStyle, style)
}
// UpdatePermalinkSettings 更新伪静态开关与后缀
func (s *ForumSettingsService) UpdatePermalinkSettings(enabled bool, ext string) error {
extNorm, ok := NormalizePermalinkExt(ext)
if !ok {
return ErrInvalidSetting
}
v := "0"
if enabled {
v = "1"
}
if err := s.setString(SettingPermalinkEnabled, v); err != nil {
return err
}
return s.setString(SettingPermalinkExt, extNorm)
}
func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error { func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
updates := map[string]int{ updates := map[string]int{
SettingPostEditWindowHours: in.PostEditWindowHours, SettingPostEditWindowHours: in.PostEditWindowHours,

View File

@@ -94,6 +94,43 @@
<button type="submit">保存限流</button> <button type="submit">保存限流</button>
</form> </form>
<h2>内容限制</h2>
<p class="j13-muted">字数、编辑时间窗、分页与账号相关上限。</p>
<form method="post" action="/admin/settings/content-limits" class="j13-form j13-admin-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<label>帖标题最大字数 <input name="post_title_max" type="number" min="1" value="{{.Limits.PostTitleMax}}"/></label>
<label>标签最大字数 <input name="post_tags_max" type="number" min="0" value="{{.Limits.PostTagsMax}}"/></label>
<label>帖正文最大字数 <input name="post_content_max" type="number" min="0" value="{{.Limits.PostContentMax}}"/></label>
<label>评论最大字数 <input name="comment_max" type="number" min="1" value="{{.Limits.CommentMax}}"/></label>
<label>搜索关键词最短 <input name="search_keyword_min" type="number" min="0" value="{{.Limits.SearchKeywordMin}}"/></label>
<label>搜索关键词最长 <input name="search_keyword_max" type="number" min="1" value="{{.Limits.SearchKeywordMax}}"/></label>
<label>默认分页大小 <input name="page_size_default" type="number" min="1" value="{{.Limits.PageSizeDefault}}"/></label>
<label>发帖可编辑(小时) <input name="post_edit_window_hours" type="number" min="0" value="{{.Limits.PostEditWindowHours}}"/></label>
<label>评论可编辑(分钟) <input name="comment_edit_window_minutes" type="number" min="0" value="{{.Limits.CommentEditWindowMinutes}}"/></label>
<label>密码最短长度 <input name="password_min_len" type="number" min="4" value="{{.Limits.PasswordMinLen}}"/></label>
<label>头像最大 MB <input name="avatar_max_mb" type="number" min="1" value="{{.Limits.AvatarMaxMB}}"/></label>
<label>签名最大字数 <input name="signature_max" type="number" min="0" value="{{.Limits.SignatureMax}}"/></label>
<label>Feed 列表样式
<select name="feed_list_style">
<option value="title"{{if eq .Limits.FeedListStyle "title"}} selected{{end}}>仅标题</option>
<option value="excerpt"{{if eq .Limits.FeedListStyle "excerpt"}} selected{{end}}>摘要</option>
<option value="thumbnail"{{if eq .Limits.FeedListStyle "thumbnail"}} selected{{end}}>缩略图</option>
</select>
</label>
<label class="j13-check"><input type="checkbox" name="open_posts_in_new_tab" value="1"{{if .Limits.OpenPostsInNewTab}} checked{{end}}/> 帖子链接新标签打开</label>
<label class="j13-check"><input type="checkbox" name="open_content_links_in_new_tab" value="1"{{if .Limits.OpenContentLinksInNewTab}} checked{{end}}/> 正文外链新标签打开</label>
<button type="submit">保存内容限制</button>
</form>
<h2>伪静态</h2>
<p class="j13-muted">启用后公开路径形如 <code>/post/123.{{.Limits.PermalinkExt}}</code>;无后缀请求会 301 到规范 URL。</p>
<form method="post" action="/admin/settings/permalink" class="j13-form j13-admin-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<label class="j13-check"><input type="checkbox" name="permalink_enabled" value="1"{{if .Limits.PermalinkEnabled}} checked{{end}}/> 启用伪静态后缀</label>
<label>后缀(不含点) <input name="permalink_ext" maxlength="16" pattern="[A-Za-z0-9]{1,16}" value="{{.Limits.PermalinkExt}}" placeholder="html"/></label>
<button type="submit">保存伪静态</button>
</form>
<h2>侧栏组件</h2> <h2>侧栏组件</h2>
<p class="j13-muted">控制首页与帖详情右栏可选块的显示与顺序(热门帖为固定块,不在此列)。友链页「侧栏显示」与此共用同一配置。</p> <p class="j13-muted">控制首页与帖详情右栏可选块的显示与顺序(热门帖为固定块,不在此列)。友链页「侧栏显示」与此共用同一配置。</p>
<form method="post" action="/admin/settings/aside-widgets" class="j13-form j13-admin-form"> <form method="post" action="/admin/settings/aside-widgets" class="j13-form j13-admin-form">

View File

@@ -8,7 +8,7 @@
<ul class="j13-boards__grid"> <ul class="j13-boards__grid">
{{range .Items}} {{range .Items}}
<li> <li>
<a class="j13-boards__card" href="/board/{{.ID}}" data-color="{{.ColorIndex}}"> <a class="j13-boards__card" href="{{.Href}}" data-color="{{.ColorIndex}}">
<span class="j13-boards__dot" aria-hidden="true"></span> <span class="j13-boards__dot" aria-hidden="true"></span>
<span class="j13-boards__body"> <span class="j13-boards__body">
<strong>{{.Name}}</strong> <strong>{{.Name}}</strong>

View File

@@ -9,7 +9,7 @@
<ul class="j13-post-list"> <ul class="j13-post-list">
{{range .Items}} {{range .Items}}
<li class="j13-post-item"> <li class="j13-post-item">
<a class="j13-post-item__title" href="/post/{{.PostID}}">{{.Title}}</a> <a class="j13-post-item__title" href="{{.Href}}"{{if $.OpenPostsInNewTab}} target="_blank" rel="noopener"{{end}}>{{.Title}}</a>
<div class="j13-post-item__meta"> <div class="j13-post-item__meta">
{{if .AuthorName}}<span>{{.AuthorName}}</span>{{end}} {{if .AuthorName}}<span>{{.AuthorName}}</span>{{end}}
{{if .BoardName}}<span>{{.BoardName}}</span>{{end}} {{if .BoardName}}<span>{{.BoardName}}</span>{{end}}

View File

@@ -37,7 +37,7 @@
<ul class="j13-post-list"> <ul class="j13-post-list">
{{range .Posts}} {{range .Posts}}
<li class="j13-post-item"> <li class="j13-post-item">
<a class="j13-post-item__title" href="{{postURL .ID}}">{{.Title}}</a> <a class="j13-post-item__title" href="{{.Href}}"{{if $.OpenPostsInNewTab}} target="_blank" rel="noopener"{{end}}>{{.Title}}</a>
<div class="j13-post-item__meta"> <div class="j13-post-item__meta">
{{if .Pinned}}<span class="j13-tag j13-tag--pin">置顶</span>{{end}} {{if .Pinned}}<span class="j13-tag j13-tag--pin">置顶</span>{{end}}
{{if .Featured}}<span class="j13-tag j13-tag--feat">精华</span>{{end}} {{if .Featured}}<span class="j13-tag j13-tag--feat">精华</span>{{end}}

View File

@@ -17,8 +17,8 @@
{{if .IsQuestion}} {{if .IsQuestion}}
{{if .QuestionResolved}}<span class="j13-tag j13-tag--resolved">已解决</span>{{else}}<span class="j13-tag j13-tag--unresolved">未解决</span>{{end}} {{if .QuestionResolved}}<span class="j13-tag j13-tag--resolved">已解决</span>{{else}}<span class="j13-tag j13-tag--unresolved">未解决</span>{{end}}
{{end}} {{end}}
{{if .BoardName}}<a class="j13-tag" href="/board/{{.BoardID}}">{{.BoardName}}</a>{{end}} {{if .BoardName}}<a class="j13-tag" href="{{.BoardHref}}">{{.BoardName}}</a>{{end}}
{{if .AuthorID}}<a href="/user/{{.AuthorID}}">{{.AuthorName}}</a>{{else}}<span>{{.AuthorName}}</span>{{end}} {{if .AuthorID}}<a href="{{.AuthorHref}}">{{.AuthorName}}</a>{{else}}<span>{{.AuthorName}}</span>{{end}}
<span>{{.CreatedLabel}}</span> <span>{{.CreatedLabel}}</span>
<span>{{.ViewCount}} 阅读</span> <span>{{.ViewCount}} 阅读</span>
</div> </div>
@@ -44,8 +44,8 @@
<input type="hidden" name="_csrf" value="{{.CSRF}}"/> <input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<button type="submit">{{if .Favorited}}取消收藏{{else}}收藏{{end}}</button> <button type="submit">{{if .Favorited}}取消收藏{{else}}收藏{{end}}</button>
</form> </form>
{{if .CanEdit}}<a class="j13-linkbtn" href="/post/{{.PostID}}/edit">编辑</a>{{end}} {{if .CanEdit}}<a class="j13-linkbtn" href="{{.PostHref}}/edit">编辑</a>{{end}}
{{if .CanViewRevisions}}<a class="j13-linkbtn" href="/post/{{.PostID}}/revisions">修订历史</a>{{end}} {{if .CanViewRevisions}}<a class="j13-linkbtn" href="{{.PostHref}}/revisions">修订历史</a>{{end}}
{{if .CanReportPost}} {{if .CanReportPost}}
<details class="j13-report"> <details class="j13-report">
<summary class="j13-linkbtn">举报</summary> <summary class="j13-linkbtn">举报</summary>

View File

@@ -3,7 +3,7 @@
<a class="j13-aside__link{{if eq .Path "/boards"}} is-active{{end}}" href="/boards">全部板块</a> <a class="j13-aside__link{{if eq .Path "/boards"}} is-active{{end}}" href="/boards">全部板块</a>
<a class="j13-aside__link{{if and (eq .ActiveBoard 0) (ne .Path "/boards")}} is-active{{end}}" href="/">全部帖子</a> <a class="j13-aside__link{{if and (eq .ActiveBoard 0) (ne .Path "/boards")}} is-active{{end}}" href="/">全部帖子</a>
{{range .Boards}} {{range .Boards}}
<a class="j13-aside__link{{if eq $.ActiveBoard .ID}} is-active{{end}}" href="/board/{{.ID}}">{{.Name}}</a> <a class="j13-aside__link{{if eq $.ActiveBoard .ID}} is-active{{end}}" href="{{.Href}}">{{.Name}}</a>
{{end}} {{end}}
</aside> </aside>
{{end}} {{end}}

View File

@@ -33,7 +33,7 @@
{{if .RightAside.HotPosts}} {{if .RightAside.HotPosts}}
<ul class="j13-widget__list"> <ul class="j13-widget__list">
{{range .RightAside.HotPosts}} {{range .RightAside.HotPosts}}
<li><a href="{{postURL .ID}}">{{.Title}}</a></li> <li><a href="{{.Href}}">{{.Title}}</a></li>
{{end}} {{end}}
</ul> </ul>
{{else}} {{else}}
@@ -59,7 +59,7 @@
<ul class="j13-widget__list"> <ul class="j13-widget__list">
{{range .Comments}} {{range .Comments}}
<li> <li>
<a href="{{postURL .PostID}}">{{.Excerpt}}</a> <a href="{{.PostHref}}">{{.Excerpt}}</a>
<span class="j13-widget__meta">{{.Author}} · {{.PostTitle}}</span> <span class="j13-widget__meta">{{.Author}} · {{.PostTitle}}</span>
</li> </li>
{{end}} {{end}}
@@ -72,7 +72,7 @@
{{if .Users}} {{if .Users}}
<ul class="j13-widget__list"> <ul class="j13-widget__list">
{{range .Users}} {{range .Users}}
<li><a href="/user/{{.ID}}">{{.Nickname}}</a></li> <li><a href="{{.Href}}">{{.Nickname}}</a></li>
{{end}} {{end}}
</ul> </ul>
{{else}} {{else}}

View File

@@ -36,7 +36,7 @@
<ul class="j13-post-list"> <ul class="j13-post-list">
{{range .Posts}} {{range .Posts}}
<li class="j13-post-item"> <li class="j13-post-item">
<a class="j13-post-item__title" href="/post/{{.ID}}">{{.Title}}</a> <a class="j13-post-item__title" href="{{.Href}}"{{if $.OpenPostsInNewTab}} target="_blank" rel="noopener"{{end}}>{{.Title}}</a>
<div class="j13-post-item__meta"> <div class="j13-post-item__meta">
{{if .BoardName}}<span>{{.BoardName}}</span>{{end}} {{if .BoardName}}<span>{{.BoardName}}</span>{{end}}
<span>{{.CreatedLabel}}</span> <span>{{.CreatedLabel}}</span>