diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index b02aa0a..540f3ef 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -189,7 +189,8 @@ - [x] 敏感词:`forum_settings.filter_words` 读写 + 热更 — SSR `/admin/settings` - [x] 基础限流(post/comment/register/login/window)— SSR;完整 Limits 字数等未迁 - [x] SMTP 配置与测试信 — SSR `/admin/settings` 邮件区 -- [x] 站点品牌文案:名称、标语、简介、keywords、Logo 字标、ICP — SSR(Logo/Favicon/OG 上传未迁) +- [x] 站点品牌文案:名称、标语、简介、keywords、Logo 字标、ICP — SSR `/admin/settings` +- [x] Logo / Favicon / 默认 OG 图上传与清除 — SSR `/admin/settings/brand/upload|clear`;`` favicon + og:image - [x] SQLite 一键备份与下载 — SSR `/admin/settings`(仅 sqlite;写入 data 目录后下载) --- @@ -199,7 +200,7 @@ - [x] `GET /health` — `routers/setup.go` - [x] `robots.txt` / `sitemap.xml` — SSR 同源路径 - [x] 静态上传文件可达 — `Static /uploads` → data/uploads -- [x] 限流:发帖、注册、登录、举报、私信、友链 — SSR 写路径已挂 `RateLimiter`(评论动作键服务层有、web 发评尚未挂,可后补) +- [x] 限流:发帖、评论、注册、登录、举报、私信、友链 — SSR 写路径已挂 `RateLimiter` --- diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index 1099ec7..5c7d3b3 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -48,7 +48,7 @@ | `/admin/dashboard` | 概览计数 | 已迁 | | `/admin/boards` | 板块 CRUD | 已迁 | | `/admin/moderation` | 待审帖/评 通过/拒绝 | 已迁 | -| `/admin/settings` | 品牌 + 基础限流 + 敏感词 + SMTP | 已迁 | +| `/admin/settings` | 品牌(含 Logo/Favicon/OG 上传)+ 基础限流 + 敏感词 + SMTP + 备份 | 已迁 | | `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 | | `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 | | `/admin/reports` | 举报处理 | 已迁 | @@ -200,7 +200,7 @@ | Badges | 定义自动/限定徽章;颁发/收回限定 — SSR `/admin/badges` | | Media | 分类浏览;单删/批量删;同步索引 — SSR `/admin/media` | | Media | 分类浏览;批量删 | -| Settings | Tab:论坛限制、侧栏组件、伪静态、邮件、OIDC+客户端、Gitea、存储、品牌、敏感词、备份 — SSR `/admin/settings` 已迁品牌/限流/侧栏/敏感词/邮件/SQLite 备份(OIDC/Gitea/存储/伪静态未迁) | +| Settings | Tab:论坛限制、侧栏组件、伪静态、邮件、OIDC+客户端、Gitea、存储、品牌、敏感词、备份 — SSR `/admin/settings` 已迁品牌(含 Logo/Favicon/OG)/限流/侧栏/敏感词/邮件/SQLite 备份(OIDC/Gitea/存储/伪静态未迁) | --- diff --git a/docs/rebuild-spec/09-ssr-progress.md b/docs/rebuild-spec/09-ssr-progress.md index 336c72d..abe3a03 100644 --- a/docs/rebuild-spec/09-ssr-progress.md +++ b/docs/rebuild-spec/09-ssr-progress.md @@ -12,59 +12,36 @@ | 项 | 值 | |----|-----| -| **上一刀** | P2:Admin 设等级 + 用户页徽章;勾选 Exp / 防刷 / 自动徽章 | -| **下一刀** | **P3 体验**(主题 / 伪静态 / 编辑器)或 **后置**(OIDC / 存储热切换 / Gitea);需点名 | -| **工作区** | 应干净;有未提交改动时先处理再开新刀 | +| **上一刀** | P0:发评限流 + 品牌 Logo/Favicon/OG 上传与 head meta | +| **下一刀** | **P3 / 后置**(需点名:伪静态、完整 Limits、OIDC、S3 热切换、编辑器、嵌套评论等) | +| **工作区** | 应干净 | --- ## 一句话状态 -核心论坛 + Admin P1 + §H 成长/防刷(服务层 + Admin 设等级)**已齐**。余量:P3 体验打磨与后置产品化(OIDC / S3 热切换 / Gitea)。 - -```mermaid -flowchart LR - core[核心论坛已迁] - admin[Admin用户徽章媒体] - growth[等级徽章防刷] - polish[主题伪静态编辑器] - deferred[OIDC存储Gitea] - core --> admin --> growth --> polish - deferred -.-> polish -``` +核心论坛、Admin P1、§H 成长/防刷、P0 品牌图与发评限流 **已齐**。余量主要是体验打磨与后置产品化。 --- -## 已完成里程碑 - -### 公开站 / Admin / §H +## 已完成(摘) | 域 | 状态 | |----|------| -| 五种帖类型、评论、私信、友链、单页、§O | 已迁 | -| Admin:users / badges / media / settings 基础 | 已迁 | -| Exp→Lv、Admin 设等级、用户页徽章展示 | 已迁 | -| 短龄同 IP 互刷拒绝分成 | 已落地(unlock 服务) | -| 自动徽章 EvaluateAuto | 已落地(访问用户页触发) | - -### 近期提交(摘) - -media → **等级设定 + 徽章展示 / §H 勾选** +| 五种帖 + Admin users/badges/media | 已迁 | +| §H Exp/等级/徽章/防刷 | 已迁 | +| 发评 `RateLimiter` comment | 已挂 | +| 品牌 Logo/Favicon/OG 上传 + favicon/og meta | 已迁 | --- ## 未完成分层 -### P0 — 小清理(可夹带) +### P0 余量 -- 发评路径挂 `RateLimiter` `comment` 动作 -- Logo/Favicon/OG 上传、头像裁剪 +- 头像裁剪等边角 -### P2 — 成长与防刷 - -~~已完成主路径~~;无独立「自动徽章定时任务」Admin UI(可接受)。 - -### P3 — 体验 / 编辑器 ← 当前候选 +### P3 — 体验 / 编辑器 - 主题、侧栏折叠、虚拟滚动、`feed_list_style`、伪静态 Admin - TipTap / Markdown 双模 @@ -79,19 +56,9 @@ media → **等级设定 + 徽章展示 / §H 勾选** --- -## 默认「继续」刀序 +## 默认「继续」 -| 序 | 刀 | 产出 | -|----|----|------| -| ✓ | 核心闭环 + Admin P1 | … | -| ✓ | §H 等级/防刷/徽章展示 | … | -| → | 需点名 P3 或后置 | … | - ---- - -## 已完成计划 - -- [admin-users](plans/admin-users.md) · [admin-badges](plans/admin-badges.md) · [admin-media](plans/admin-media.md) +需点名下一刀;未点名时优先小清理或明确产品偏好。 --- @@ -99,7 +66,6 @@ media → **等级设定 + 徽章展示 / §H 勾选** | 问题 | 看哪里 | |------|--------| -| 某功能做没做? | [02-features.md](02-features.md) 复选框 | +| 某功能做没做? | [02-features.md](02-features.md) | | 现在该干什么? | 本文「当前指针」 | | 路由迁没迁? | [06-pages-ux.md](06-pages-ux.md) §1 | -| 目录约定? | [08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md) | diff --git a/public/assets/site.css b/public/assets/site.css index 9047b2a..a63a5c0 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -53,6 +53,12 @@ body.j13-body { place-items: center; font-weight: 700; } +.j13-brand__logo { + width: 2rem; + height: 2rem; + object-fit: contain; + border-radius: var(--j13-radius); +} .j13-brand__text { display: flex; flex-direction: column; } .j13-brand__text strong { font-size: 1rem; } .j13-brand__slogan { font-size: 0.75rem; color: var(--j13-muted); } @@ -1073,6 +1079,27 @@ points-only[data-locked="false"] { gap: 0.5rem; align-items: center; } +.j13-admin-brand-assets { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); + gap: 1rem; + margin: 0 0 1.5rem; +} +.j13-admin-brand-asset { + border: 1px solid var(--j13-border); + border-radius: 6px; + padding: 0.75rem; +} +.j13-admin-brand-preview { + max-width: 8rem; + max-height: 4rem; + object-fit: contain; + display: block; +} +.j13-admin-brand-preview--sm { + max-width: 2rem; + max-height: 2rem; +} .j13-admin-media-toolbar { display: flex; flex-wrap: wrap; diff --git a/routers/install/install.go b/routers/install/install.go index 19c065d..17f0834 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -18,11 +18,19 @@ type Deps struct { } type pageData struct { - Title string - Description string - SiteName string - Slogan string - LogoMark string + Title string + Description string + SiteName string + Slogan string + LogoMark string + LogoURL string + FaviconURL string + OGImageURL string + ShowFriendLinksFooter bool + FooterPages []struct { + Title string + Slug string + } CSRF string Error string Flash string diff --git a/routers/web/admin.go b/routers/web/admin.go index d1c0e8c..3950398 100644 --- a/routers/web/admin.go +++ b/routers/web/admin.go @@ -520,6 +520,90 @@ func (d Deps) AdminSettingsBrandPost(c *gin.Context) { ctx.Redirect("/admin/settings") } +// AdminSettingsBrandUploadPost 上传 Logo / Favicon / OG 图 +func (d Deps) AdminSettingsBrandUploadPost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderAdminSettings(ctx, "无效请求,请重试") + return + } + if d.Store == nil { + d.renderAdminSettings(ctx, "上传存储未就绪") + return + } + kind := strings.TrimSpace(c.PostForm("kind")) + if kind != "logo" && kind != "favicon" && kind != "og_image" { + d.renderAdminSettings(ctx, "请选择 Logo、Favicon 或 OG 图") + return + } + file, err := c.FormFile("file") + if err != nil { + d.renderAdminSettings(ctx, "请选择图片文件") + return + } + const maxBytes = 2 * 1024 * 1024 + if file.Size > maxBytes { + d.renderAdminSettings(ctx, "图片不能超过 2MB") + return + } + url, err := services.SaveUploadedImage(d.Store, file, services.UploadCategorySite, kind) + if err != nil { + d.renderAdminSettings(ctx, err.Error()) + return + } + prev := d.Settings.SiteBranding() + switch kind { + case "logo": + _ = d.Settings.SetSiteLogo(url) + d.Store.DeleteByURL(prev.Logo) + ctx.SetFlash("Logo 已上传") + case "favicon": + _ = d.Settings.SetSiteFavicon(url) + d.Store.DeleteByURL(prev.Favicon) + ctx.SetFlash("Favicon 已上传") + case "og_image": + _ = d.Settings.SetSiteOGImage(url) + d.Store.DeleteByURL(prev.OGImage) + ctx.SetFlash("默认 OG 图已上传") + } + ctx.Redirect("/admin/settings") +} + +// AdminSettingsBrandClearPost 清除品牌图片 +func (d Deps) AdminSettingsBrandClearPost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderAdminSettings(ctx, "无效请求,请重试") + return + } + kind := strings.TrimSpace(c.PostForm("kind")) + brand := d.Settings.SiteBranding() + switch kind { + case "logo": + _ = d.Settings.SetSiteLogo("") + if d.Store != nil { + d.Store.DeleteByURL(brand.Logo) + } + ctx.SetFlash("已清除 Logo") + case "favicon": + _ = d.Settings.SetSiteFavicon("") + if d.Store != nil { + d.Store.DeleteByURL(brand.Favicon) + } + ctx.SetFlash("已清除 Favicon") + case "og_image": + _ = d.Settings.SetSiteOGImage("") + if d.Store != nil { + d.Store.DeleteByURL(brand.OGImage) + } + ctx.SetFlash("已清除 OG 图") + default: + d.renderAdminSettings(ctx, "无效的资源类型") + return + } + ctx.Redirect("/admin/settings") +} + // AdminSettingsLimitsPost 限流 func (d Deps) AdminSettingsLimitsPost(c *gin.Context) { ctx := d.ctx(c) diff --git a/routers/web/chrome.go b/routers/web/chrome.go index eda295d..39f3167 100644 --- a/routers/web/chrome.go +++ b/routers/web/chrome.go @@ -108,6 +108,9 @@ type PageChrome struct { SiteName string Slogan string LogoMark string + LogoURL string + FaviconURL string + OGImageURL string LoggedIn bool IsAdmin bool ViewerName string @@ -170,6 +173,9 @@ func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome SiteName: brand.Name, Slogan: brand.Slogan, LogoMark: firstRuneOr(brand.LogoMark, "姜"), + LogoURL: strings.TrimSpace(brand.Logo), + FaviconURL: strings.TrimSpace(brand.Favicon), + OGImageURL: strings.TrimSpace(brand.DefaultShareImage()), LoggedIn: ctx.IsSigned(), IsAdmin: ctx.IsAdmin(), ViewerName: name, diff --git a/routers/web/home.go b/routers/web/home.go index f29d00c..e5dfa2d 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -75,6 +75,8 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { admin.POST("/comments/:id/reject", deps.AdminCommentReject) admin.GET("/settings", deps.AdminSettingsGet) admin.POST("/settings/brand", deps.AdminSettingsBrandPost) + admin.POST("/settings/brand/upload", deps.AdminSettingsBrandUploadPost) + admin.POST("/settings/brand/clear", deps.AdminSettingsBrandClearPost) admin.POST("/settings/limits", deps.AdminSettingsLimitsPost) admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost) admin.POST("/settings/mail", deps.AdminSettingsMailPost) diff --git a/routers/web/post.go b/routers/web/post.go index e69906a..ca72194 100644 --- a/routers/web/post.go +++ b/routers/web/post.go @@ -391,6 +391,11 @@ func (d Deps) PostComment(c *gin.Context) { } } isPrivate := c.PostForm("is_private") == "1" || c.PostForm("is_private") == "on" + if d.Limiter != nil && !d.Limiter.Allow("comment", fmt.Sprintf("%d", ctx.UserID())) { + ctx.SetFlash("操作过于频繁,请稍后再试") + ctx.Redirect(fmt.Sprintf("/post/%d#comments", id)) + return + } safe := "

" + html.EscapeString(content) + "

" cm, err := d.Comment.Create(services.CommentCreateInput{ PostID: id, UserID: ctx.UserID(), Content: safe, diff --git a/templates/admin/settings.tmpl b/templates/admin/settings.tmpl index e761bf2..4074b2b 100644 --- a/templates/admin/settings.tmpl +++ b/templates/admin/settings.tmpl @@ -19,6 +19,69 @@ +

品牌图片

+

Logo / Favicon / 默认 Open Graph 图;单文件不超过 2MB。

+
+
+ Logo + {{if .Brand.Logo}} +

Logo

+

{{.Brand.Logo}}

+
+ + + +
+ {{else}} +

未设置(导航使用字标)

+ {{end}} +
+ + + + +
+
+
+ Favicon + {{if .Brand.Favicon}} +

Favicon

+
+ + + +
+ {{else}} +

未设置

+ {{end}} +
+ + + + +
+
+
+ 默认 OG 图 + {{if .Brand.OGImage}} +

OG

+
+ + + +
+ {{else}} +

未设置(回落 Logo / Favicon)

+ {{end}} +
+ + + + +
+
+
+

基础限流

窗口内允许的操作次数(秒)。

diff --git a/templates/base/head.tmpl b/templates/base/head.tmpl index b3ce62c..421b72f 100644 --- a/templates/base/head.tmpl +++ b/templates/base/head.tmpl @@ -6,6 +6,10 @@ {{.Title}} {{if .Description}}{{end}} + {{if .FaviconURL}}{{end}} + + {{if .Description}}{{end}} + {{if .OGImageURL}}{{end}} diff --git a/templates/base/navbar.tmpl b/templates/base/navbar.tmpl index abba452..c3bcd85 100644 --- a/templates/base/navbar.tmpl +++ b/templates/base/navbar.tmpl @@ -2,7 +2,11 @@