feat: 完整内容 Limits 与伪静态 Admin
设置页可配字数/编辑窗/分页与伪静态后缀;公开帖/板/用户链接走规范路径并 301。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 等。
|
||||
未迁(原 SPA):OIDC·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/存储热切换未迁) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
> **读者**:产品方 / 实现 AI
|
||||
> **分支**:`rebuild/gitea-ssr`(`main` = SPA 对照)
|
||||
> **验收清单**: [02-features.md](02-features.md)
|
||||
> **架构**: [08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md)
|
||||
> **维护约定**:每次「继续」开新刀前更新本文「当前指针」;功能勾选以 `02` 为准,本文管阶段与刀序。
|
||||
> **架构**: [08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
### 后置
|
||||
|
||||
- K:Gitea `/projects`
|
||||
- L:OIDC 产品化 Admin CRUD
|
||||
- M:S3 热切换、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 |
|
||||
|
||||
@@ -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, "伪静态后缀无效(1–16 位字母数字)")
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("伪静态设置已保存")
|
||||
ctx.Redirect("/admin/settings")
|
||||
}
|
||||
|
||||
// AdminSettingsFilterWordsPost 敏感词
|
||||
func (d Deps) AdminSettingsFilterWordsPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user