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 右栏条(钱包详情仍在 `/profile#wallet`
- [ ] 下拉刷新(移动端)
- [ ] 可选伪静态:`/post/123.html` 等形式(后缀后台可配)
- [x] 可选伪静态:`/post/123.html` 等形式(后缀后台可配)— SSR `/admin/settings` 伪静态;公开路径 301 规范 URL
- [x] 404 页
---
@@ -187,7 +187,8 @@
- [x] 仪表盘:用户/帖/板块计数 + 待审帖/评 + 待处理举报 — SSR `/admin/dashboard`
- [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] 站点品牌文案名称、标语、简介、keywords、Logo 字标、ICP — SSR `/admin/settings`
- [x] Logo / Favicon / 默认 OG 图上传与清除 — SSR `/admin/settings/brand/upload|clear``<head>` favicon + og:image

View File

@@ -48,7 +48,7 @@
| `/admin/dashboard` | 概览计数 | 已迁 |
| `/admin/boards` | 板块 CRUD | 已迁 |
| `/admin/moderation` | 待审帖/评 通过/拒绝 | 已迁 |
| `/admin/settings` | 品牌(含 Logo/Favicon/OG 上传)+ 基础限流 + 敏感词 + SMTP + 备份 | 已迁 |
| `/admin/settings` | 品牌(含 Logo/Favicon/OG+ 限流 + 内容限制 + 伪静态 + 敏感词 + SMTP + 备份 | 已迁 |
| `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 |
| `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 |
| `/admin/reports` | 举报处理 | 已迁 |
@@ -58,7 +58,7 @@
| `/admin/trash` | 帖回收站 | 已迁 |
| `/admin/login` | 重定向前台登录 | 已迁 |
未迁(原 SPA完整 Limits / OIDC·Gitea·存储热切换 Admin 等。
未迁(原 SPAOIDC·Gitea·存储热切换 Admin 等。
---
@@ -200,7 +200,7 @@
| Badges | 定义自动/限定徽章;颁发/收回限定 — SSR `/admin/badges` |
| Media | 分类浏览;单删/批量删;同步索引 — SSR `/admin/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 对照)
> **验收清单** [02-features.md](02-features.md)
> **架构** [08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md)
> **维护约定**:每次「继续」开新刀前更新本文「当前指针」;功能勾选以 `02` 为准,本文管阶段与刀序。
---
@@ -12,15 +11,15 @@
| 项 | 值 |
|----|-----|
| **上一刀** | P0发评限流 + 品牌 Logo/Favicon/OG 上传与 head meta |
| **下一刀** | **P3 / 后置**(需点名:伪静态、完整 Limits、OIDC、S3 热切换、编辑器、嵌套评论 |
| **上一刀** | 完整 Limits + 伪静态 Admin / 规范 URL |
| **下一刀** | **后置或体验**需点名OIDC、S3 热切换、编辑器、嵌套评论、Gitea… |
| **工作区** | 应干净 |
---
## 一句话状态
核心论坛、Admin P1、§H 成长/防刷、P0 品牌图发评限流 **已齐**。余量主要是体验打磨与后置产品化
核心论坛、Admin、§H、品牌图发评限流、**完整 Limits 与伪静态**已齐。余量多为后置产品化与体验打磨
---
@@ -28,37 +27,22 @@
| 域 | 状态 |
|----|------|
| 五种帖 + Admin users/badges/media | 已迁 |
| §H Exp/等级/徽章/防刷 | 已迁 |
| 发评 `RateLimiter` comment | 已 |
| 品牌 Logo/Favicon/OG 上传 + favicon/og meta | 已迁 |
| Admin users / badges / media | 已迁 |
| §H 等级 / 徽章 / 防刷 | 已迁 |
| 品牌 Logo/Favicon/OG + 发评限流 | 已 |
| 内容 Limits + 伪静态 | 已迁 |
---
## 未完成分层
## 未完成
### P0 余量
### P3 体验
- 头像裁剪等边角
### P3 — 体验 / 编辑器
- 主题、侧栏折叠、虚拟滚动、`feed_list_style`、伪静态 Admin
- TipTap / Markdown 双模
- 游客评论、评论嵌套树
- 修订 diff、完整 Limits 字数
- 主题、侧栏折叠、虚拟滚动、TipTap、游客评论、嵌套树、修订 diff
### 后置
- KGitea `/projects`
- LOIDC 产品化 Admin CRUD
- MS3 热切换、WebP 全链路产品化
---
## 默认「继续」
需点名下一刀;未点名时优先小清理或明确产品偏好。
- OIDC Admin CRUD、S3 热切换 UI、Gitea `/projects`、WebP 产品化
---
@@ -68,4 +52,3 @@
|------|--------|
| 某功能做没做? | [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
RateLogin int
RateWindow int
Limits services.ForumLimits
FilterWords string
FilterCount int
Mail services.MailConfig
@@ -465,6 +466,7 @@ func (d Deps) renderAdminSettings(ctx *webctx.Context, errMsg string) {
RateReg: lim.RateLimitRegister,
RateLogin: lim.RateLimitLogin,
RateWindow: lim.RateLimitWindowSec,
Limits: lim,
FilterWords: words,
FilterCount: services.CountFilterWords(words),
Mail: mail,
@@ -624,6 +626,51 @@ func (d Deps) AdminSettingsLimitsPost(c *gin.Context) {
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 敏感词
func (d Deps) AdminSettingsFilterWordsPost(c *gin.Context) {
ctx := d.ctx(c)

View File

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

View File

@@ -1,6 +1,7 @@
package web
import (
"net/http"
"strings"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
@@ -44,12 +45,14 @@ type SitePageLink struct {
type BoardView struct {
ID uint
Name string
Href string
}
// RightAsideHotPost 右栏热门帖
type RightAsideHotPost struct {
ID uint
Title string
Href string
}
// RightAsideTag 右栏标签
@@ -61,6 +64,7 @@ type RightAsideTag struct {
// RightAsideComment 右栏最新评论
type RightAsideComment struct {
PostID uint
PostHref string
Author string
Excerpt string
PostTitle string
@@ -70,6 +74,7 @@ type RightAsideComment struct {
type RightAsideUser struct {
ID uint
Nickname string
Href string
}
// RightAsideFriendLink 右栏友链
@@ -111,6 +116,8 @@ type PageChrome struct {
LogoURL string
FaviconURL string
OGImageURL string
PermalinkSuffix string // 如 ".html";未启用为空
OpenPostsInNewTab bool
LoggedIn bool
IsAdmin bool
ViewerName string
@@ -151,8 +158,9 @@ func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome
}
boards, _ := d.Board.List()
bv := make([]BoardView, 0, len(boards))
pl := d.permalink()
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
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),
FaviconURL: strings.TrimSpace(brand.Favicon),
OGImageURL: strings.TrimSpace(brand.DefaultShareImage()),
PermalinkSuffix: d.Settings.Permalink().Suffix(),
OpenPostsInNewTab: d.Settings.OpenPostsInNewTab(),
LoggedIn: ctx.IsSigned(),
IsAdmin: ctx.IsAdmin(),
ViewerName: name,
@@ -242,8 +252,9 @@ func (d Deps) loadRightAside(ctx *webctx.Context, brand services.SiteBranding) R
}
if d.Post != nil {
if items, err := d.Post.HotPosts(rightAsideHotLimit); err == nil {
pl := d.permalink()
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:
if d.Comment != nil {
if list, err := d.Comment.ListRecentPublic(rightAsideCommentLimit); err == nil {
pl := d.permalink()
for _, c := range list {
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:
if d.User != nil {
if list, err := d.User.ListRecentRegistered(rightAsideUserLimit); err == nil {
pl := d.permalink()
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")
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 {
ctx.SetFlash("发帖成功")
}
ctx.Redirect(fmt.Sprintf("/post/%d", post.ID))
ctx.Redirect(d.postHref(post.ID))
}
// PostEditGet 编辑帖
@@ -152,7 +152,7 @@ func (d Deps) PostEditPost(c *gin.Context) {
return
}
ctx.SetFlash("已保存")
ctx.Redirect(fmt.Sprintf("/post/%d", post.ID))
ctx.Redirect(d.postHref(post.ID))
}
// ComposeUpload 帖图上传JSON供 compose 页 fetch

View File

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

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/clear", deps.AdminSettingsBrandClearPost)
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/mail", deps.AdminSettingsMailPost)
admin.POST("/settings/mail/test", deps.AdminSettingsMailTestPost)
@@ -165,6 +167,7 @@ type HomePageData struct {
type PostListItem struct {
ID uint
Title string
Href string
AuthorName string
BoardName string
Pinned bool
@@ -176,22 +179,22 @@ type PostListItem struct {
// FormAction 搜索表单提交路径(当前 Feed
func (d HomePageData) FormAction() string {
if d.ActiveBoard > 0 {
return fmt.Sprintf("/board/%d", d.ActiveBoard)
return fmt.Sprintf("/board/%d%s", d.ActiveBoard, d.PermalinkSuffix)
}
return "/"
}
// SortHref 排序链接,保留搜索参数
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 分页链接,保留搜索与排序
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{}
if sort != "" && sort != "latest" {
q.Set("sort", sort)
@@ -213,7 +216,7 @@ func buildFeedURL(boardID uint, sort string, page int, keyword, tag, author stri
}
path := "/"
if boardID > 0 {
path = fmt.Sprintf("/board/%d", boardID)
path = fmt.Sprintf("/board/%d%s", boardID, permalinkSuffix)
}
if enc := q.Encode(); 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 {
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")
@@ -309,6 +318,7 @@ func (d Deps) Home(c *gin.Context) {
}
posts := make([]PostListItem, 0, len(items))
pl := d.permalink()
for _, it := range items {
authorName := strings.TrimSpace(it.User.Nickname)
if authorName == "" {
@@ -319,7 +329,7 @@ func (d Deps) Home(c *gin.Context) {
bname = it.Board.Name
}
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,
CreatedLabel: it.CreatedAt.Local().Format("2006-01-02 15:04"),
})

View File

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

View File

@@ -42,7 +42,8 @@ type userPublicData struct {
// UserPublic 公开用户主页(不含邮箱)
func (d Deps) UserPublic(c *gin.Context) {
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 {
d.render404(ctx)
return
@@ -52,6 +53,10 @@ func (d Deps) UserPublic(c *gin.Context) {
d.render404(ctx)
return
}
match := d.permalink().MatchUserPath(c.Request.URL.Path)
if d.redirectIfNotCanonical(c, match) {
return
}
st, _ := d.User.ActivityStats(user.ID)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
@@ -67,13 +72,14 @@ func (d Deps) UserPublic(c *gin.Context) {
ViewerIsAdmin: ctx.IsAdmin(),
})
items := make([]PostListItem, 0, len(posts))
pl := d.permalink()
for _, p := range posts {
board := ""
if p.Board.ID > 0 {
board = p.Board.Name
}
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,
CreatedLabel: formatTime(p.CreatedAt),
})

View File

@@ -614,6 +614,73 @@ func (s *ForumSettingsService) UpdateRateLimits(post, comment, register, login,
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 {
updates := map[string]int{
SettingPostEditWindowHours: in.PostEditWindowHours,

View File

@@ -94,6 +94,43 @@
<button type="submit">保存限流</button>
</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>
<p class="j13-muted">控制首页与帖详情右栏可选块的显示与顺序(热门帖为固定块,不在此列)。友链页「侧栏显示」与此共用同一配置。</p>
<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">
{{range .Items}}
<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__body">
<strong>{{.Name}}</strong>

View File

@@ -9,7 +9,7 @@
<ul class="j13-post-list">
{{range .Items}}
<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">
{{if .AuthorName}}<span>{{.AuthorName}}</span>{{end}}
{{if .BoardName}}<span>{{.BoardName}}</span>{{end}}

View File

@@ -37,7 +37,7 @@
<ul class="j13-post-list">
{{range .Posts}}
<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">
{{if .Pinned}}<span class="j13-tag j13-tag--pin">置顶</span>{{end}}
{{if .Featured}}<span class="j13-tag j13-tag--feat">精华</span>{{end}}

View File

@@ -17,8 +17,8 @@
{{if .IsQuestion}}
{{if .QuestionResolved}}<span class="j13-tag j13-tag--resolved">已解决</span>{{else}}<span class="j13-tag j13-tag--unresolved">未解决</span>{{end}}
{{end}}
{{if .BoardName}}<a class="j13-tag" href="/board/{{.BoardID}}">{{.BoardName}}</a>{{end}}
{{if .AuthorID}}<a href="/user/{{.AuthorID}}">{{.AuthorName}}</a>{{else}}<span>{{.AuthorName}}</span>{{end}}
{{if .BoardName}}<a class="j13-tag" href="{{.BoardHref}}">{{.BoardName}}</a>{{end}}
{{if .AuthorID}}<a href="{{.AuthorHref}}">{{.AuthorName}}</a>{{else}}<span>{{.AuthorName}}</span>{{end}}
<span>{{.CreatedLabel}}</span>
<span>{{.ViewCount}} 阅读</span>
</div>
@@ -44,8 +44,8 @@
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<button type="submit">{{if .Favorited}}取消收藏{{else}}收藏{{end}}</button>
</form>
{{if .CanEdit}}<a class="j13-linkbtn" href="/post/{{.PostID}}/edit">编辑</a>{{end}}
{{if .CanViewRevisions}}<a class="j13-linkbtn" href="/post/{{.PostID}}/revisions">修订历史</a>{{end}}
{{if .CanEdit}}<a class="j13-linkbtn" href="{{.PostHref}}/edit">编辑</a>{{end}}
{{if .CanViewRevisions}}<a class="j13-linkbtn" href="{{.PostHref}}/revisions">修订历史</a>{{end}}
{{if .CanReportPost}}
<details class="j13-report">
<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 and (eq .ActiveBoard 0) (ne .Path "/boards")}} is-active{{end}}" href="/">全部帖子</a>
{{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}}
</aside>
{{end}}

View File

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

View File

@@ -36,7 +36,7 @@
<ul class="j13-post-list">
{{range .Posts}}
<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">
{{if .BoardName}}<span>{{.BoardName}}</span>{{end}}
<span>{{.CreatedLabel}}</span>