diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index 10a3601..54e8142 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -175,11 +175,11 @@ ## M. 媒体与存储 -- [ ] 本地 uploads 或 S3 兼容存储(可热切换配置) -- [ ] 头像 / 帖图 / 站点品牌资源分类 -- [ ] 图片展示可选 WebP 转换(`/media/thumb/...`) -- [ ] 管理后台媒体列表与删除 -- [ ] 媒体索引表同步 +- [ ] 本地 uploads 或 S3 兼容存储(可热切换配置)— 后端已支持;Admin 热切换 UI 未迁 +- [x] 头像 / 帖图 / 站点品牌资源分类 — 上传路径 + Admin 分类筛选 +- [ ] 图片展示可选 WebP 转换(`/media/thumb/...`)— 路由已有;全站默认策略未产品化 +- [x] 管理后台媒体列表与删除 — SSR `/admin/media` +- [x] 媒体索引表同步 — 启动扫盘 + Admin「同步索引」 --- diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index 98df4ea..a70fb1b 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -54,10 +54,11 @@ | `/admin/reports` | 举报处理 | 已迁 | | `/admin/users` | 用户列表、禁言、认证、调积分 | 已迁 | | `/admin/badges` | 徽章定义 CRUD、限定颁发/收回 | 已迁 | +| `/admin/media` | 媒体库分类浏览、删除、同步索引 | 已迁 | | `/admin/trash` | 帖回收站 | 已迁 | | `/admin/login` | 重定向前台登录 | 已迁 | -未迁(原 SPA):media / 完整 Limits 等。 +未迁(原 SPA):完整 Limits / OIDC·Gitea·存储热切换 Admin 等。 --- @@ -197,6 +198,7 @@ | Reports | 处理动作:dismiss / resolve / reject_post / reject_comment — SSR `/admin/reports` | | Users | 搜索;禁言;认证;调积分 — SSR `/admin/users`(设等级 / 授徽章挂在徽章页) | | Badges | 定义自动/限定徽章;颁发/收回限定 — SSR `/admin/badges` | +| Media | 分类浏览;单删/批量删;同步索引 — SSR `/admin/media` | | Media | 分类浏览;批量删 | | Settings | Tab:论坛限制、侧栏组件、伪静态、邮件、OIDC+客户端、Gitea、存储、品牌、敏感词、备份 — SSR `/admin/settings` 已迁品牌/限流/侧栏/敏感词/邮件/SQLite 备份(OIDC/Gitea/存储/伪静态未迁) | diff --git a/docs/rebuild-spec/09-ssr-progress.md b/docs/rebuild-spec/09-ssr-progress.md index 2669556..fc4b5cf 100644 --- a/docs/rebuild-spec/09-ssr-progress.md +++ b/docs/rebuild-spec/09-ssr-progress.md @@ -12,15 +12,15 @@ | 项 | 值 | |----|-----| -| **上一刀** | Admin `/admin/badges`(定义 CRUD + 限定颁发/收回) | -| **下一刀** | **Admin `/admin/media`**(媒体列表 / 删除) | +| **上一刀** | Admin `/admin/media`(列表 / 删除 / 同步索引) | +| **下一刀** | **等级/自动徽章 UI** 或 **防刷分成**(`02` §H P2;开刀前确认偏好) | | **工作区** | 应干净;有未提交改动时先处理再开新刀 | --- ## 一句话状态 -论坛核心闭环(安装、认证、Feed、五种帖类型、评论、私信、友链、审核、回收站、基础设置、备份)**已迁完**。Admin 用户与徽章已齐;余量主要是媒体、成长体系、OIDC/存储产品化与体验打磨。 +论坛核心闭环 + Admin 用户/徽章/媒体 **已迁完**。余量主要是成长体系与防刷、OIDC/存储产品化与体验打磨。 ```mermaid flowchart LR @@ -59,14 +59,12 @@ flowchart LR | dashboard / boards / moderation / reports / trash | 已迁 | | settings:品牌、限流、敏感词、SMTP、侧栏、SQLite 备份 | 已迁 | | friend-links / pages | 已迁 | -| **users**(禁言 / 认证 / 调积分) | **已迁** | -| **badges**(定义 CRUD + 限定颁发/收回) | **已迁** | -| **media** | **未迁** | +| **users / badges / media** | **已迁** | | settings:OIDC / Gitea / 存储 / 伪静态 Tab | **未迁** | ### 近期提交(摘) -`lottery` → Admin users → **Admin badges** +Admin users → badges → **media** --- @@ -77,15 +75,15 @@ flowchart LR - 发评路径挂 `RateLimiter` `comment` 动作(服务层已有键,web 未挂) - Logo/Favicon/OG 上传、头像裁剪等声明为未做的边角 -### P1 — 推荐刀序(服务层多半已齐) +### P1 — Admin 核心 1. ~~Admin 用户~~ ✓ 2. ~~Admin 徽章~~ ✓ -3. **Admin 媒体** ← 当前指针 +3. ~~Admin 媒体~~ ✓ -### P2 — 成长与防刷 +### P2 — 成长与防刷 ← 当前指针候选 -- Exp → Lv1–10、自动徽章调度 UI、短龄同 IP 互刷拒绝分成(`02` §H) +- Exp → Lv1–10 设定 UI、自动徽章调度说明、短龄同 IP 互刷拒绝分成(`02` §H) ### P3 — 体验 / 编辑器(可砍或长期) @@ -98,7 +96,7 @@ flowchart LR - K:Gitea `/projects` - L:OIDC 产品化 Admin CRUD -- M:S3 热切换、WebP thumb 全链路 +- M:S3 热切换、WebP thumb 全链路产品化 --- @@ -106,39 +104,39 @@ flowchart LR | 序 | 刀 | 产出 | |----|----|------| -| ✓ | 五种帖类型 + 备份/复检 + §O 勾选 | 核心闭环 | -| ✓ | 本文 `09` | 可查阅进度 | -| ✓ | Admin `/admin/users` | 禁言/认证/调积分 | -| ✓ | Admin `/admin/badges` | 徽章 CRUD + 授予 | -| → | Admin `/admin/media` | 媒体列表删除 | -| 5 | 等级/自动徽章 **或** 防刷分成 | 按当时偏好 | +| ✓ | 五种帖类型 + 备份/复检 + §O | 核心闭环 | +| ✓ | Admin users / badges / media | Admin P1 | +| → | 等级设定 **或** 防刷分成 | 需偏好 | | … | P3 / 后置 | 需明确点名再开 | --- -## 下一刀实现提纲:Admin 媒体 +## 下一刀实现提纲:P2 候选 -> 确认「继续」后实现媒体库列表与删除;对齐既有上传/存储服务。 +> 「继续」前请点名:**等级/Exp UI**、**防刷分成**,或其它(OIDC/伪静态等)。 -### 范围(草案) +### A. 等级 -- `GET /admin/media`:按时间分页列表(缩略图 / 路径 / 引用粗览) -- `POST /admin/media/:id/delete`:删库文件 + 记录 -- 回写 `02` / `06`;指针 → 成长或按偏好 +- Admin 设用户 Exp/Lv(`SetUserLevel` 已有) +- 公开页等级展示对齐 -### 不做 +### B. 防刷分成 -S3 热切换产品化、全站未引用扫描清理器。 +- 短龄同 IP 互刷拒绝创作分成(`02` §H) --- +## 已完成:Admin 媒体 + +见 [plans/admin-media.md](plans/admin-media.md)。 + ## 已完成:Admin 徽章 -见 [plans/admin-badges.md](plans/admin-badges.md);路由 `/admin/badges` + award / revoke。 +见 [plans/admin-badges.md](plans/admin-badges.md)。 ## 已完成:Admin 用户管理 -见 [plans/admin-users.md](plans/admin-users.md);路由 `/admin/users` + ban / verify / points。 +见 [plans/admin-users.md](plans/admin-users.md)。 --- diff --git a/docs/rebuild-spec/plans/admin-media.md b/docs/rebuild-spec/plans/admin-media.md new file mode 100644 index 0000000..2bfaf8e --- /dev/null +++ b/docs/rebuild-spec/plans/admin-media.md @@ -0,0 +1,21 @@ +# Admin 媒体库(SSR)— 实现计划 + +> 状态:已完成(SSR `/admin/media`) +> 分支:`rebuild/gitea-ssr` + +## 范围 + +| 能力 | 服务 | 路由 | +|------|------|------| +| 分类/搜索/分页列表 | `UploadStore.ListMedia` | `GET /admin/media` | +| 单删 | `DeleteMediaByIDs` | `POST /admin/media/:id/delete` | +| 批量删 | 同上 | `POST /admin/media/delete` | +| 同步索引 | `SyncMediaIndex` | `POST /admin/media/sync` | + +## 不做 + +S3 热切换 Admin、全站未引用扫描清理、WebP 产品化默认策略。 + +## 验证 + +`build.bat`;冒烟:写入 uploads → 同步索引 → 列表可见 → 删除。 diff --git a/public/assets/site.css b/public/assets/site.css index f40e437..119f225 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -1046,6 +1046,72 @@ points-only[data-locked="false"] { gap: 0.5rem; align-items: center; } +.j13-admin-media-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1.25rem; + align-items: flex-end; + margin-bottom: 0.75rem; +} +.j13-admin-media-batch { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin: 0 0 0.75rem; +} +.j13-admin-media-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); + gap: 0.85rem; +} +.j13-admin-media-card { + position: relative; + border: 1px solid var(--j13-border); + border-radius: 6px; + overflow: hidden; + background: var(--j13-surface, #fff); +} +.j13-admin-media-check { + position: absolute; + top: 0.4rem; + left: 0.4rem; + z-index: 1; + background: rgba(255,255,255,0.85); + border-radius: 3px; + padding: 0.1rem 0.2rem; +} +.j13-admin-media-thumb { + display: block; + aspect-ratio: 1; + background: var(--j13-bg, #f5f5f5); +} +.j13-admin-media-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.j13-admin-media-meta { + padding: 0.45rem 0.55rem 0.6rem; + font-size: 0.85rem; +} +.j13-admin-media-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; +} +.j13-admin-media-ops { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin-top: 0.35rem; +} .j13-post__admin { display: flex; flex-wrap: wrap; diff --git a/routers/web/admin_media.go b/routers/web/admin_media.go new file mode 100644 index 0000000..20c8a56 --- /dev/null +++ b/routers/web/admin_media.go @@ -0,0 +1,255 @@ +package web + +import ( + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "git.iioio.com/freefire/jiang13-forum/modules/webctx" + "git.iioio.com/freefire/jiang13-forum/services" + "github.com/gin-gonic/gin" +) + +type adminMediaRow struct { + ID uint + Category string + CategoryLabel string + Name string + URL string + ThumbURL string + SizeLabel string + Modified string + StorageType string +} + +type adminMediaData struct { + AdminChrome + Files []adminMediaRow + Keyword string + Category string + Page int + Total int + HasPrev bool + HasMore bool + PrevPage int + NextPage int + QuerySuffix string + StorageType string + CountAll int + CountAvatars int + CountPosts int + CountSite int +} + +// AdminMediaGet 媒体库列表 +func (d Deps) AdminMediaGet(c *gin.Context) { + ctx := d.ctx(c) + d.renderAdminMedia(ctx, "") +} + +func (d Deps) renderAdminMedia(ctx *webctx.Context, errMsg string) { + chrome := d.adminChrome(ctx, "媒体", "media") + chrome.Error = errMsg + page, _ := strconv.Atoi(ctx.C.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + keyword := strings.TrimSpace(ctx.C.Query("q")) + category := strings.TrimSpace(ctx.C.DefaultQuery("category", "all")) + if category == "" { + category = "all" + } + size := 24 + data := adminMediaData{ + AdminChrome: chrome, + Keyword: keyword, + Category: category, + Page: page, + PrevPage: page - 1, + NextPage: page + 1, + HasPrev: page > 1, + Files: []adminMediaRow{}, + } + q := url.Values{} + if keyword != "" { + q.Set("q", keyword) + } + if category != "" && category != "all" { + q.Set("category", category) + } + data.QuerySuffix = q.Encode() + + if d.Store == nil { + chrome.Error = "上传存储未就绪" + data.AdminChrome = chrome + ctx.HTML(http.StatusOK, "admin/media", data) + return + } + result, err := d.Store.ListMedia(category, keyword, page, size) + if err != nil { + chrome.Error = err.Error() + data.AdminChrome = chrome + ctx.HTML(http.StatusOK, "admin/media", data) + return + } + data.Total = result.Total + data.Page = result.Page + data.HasPrev = result.Page > 1 + data.HasMore = result.Page < result.TotalPages + data.PrevPage = result.Page - 1 + data.NextPage = result.Page + 1 + data.StorageType = result.StorageType + data.CountAvatars = result.CategoryCounts[services.UploadCategoryAvatars] + data.CountPosts = result.CategoryCounts[services.UploadCategoryPosts] + data.CountSite = result.CategoryCounts[services.UploadCategorySite] + data.CountAll = data.CountAvatars + data.CountPosts + data.CountSite + data.Files = make([]adminMediaRow, 0, len(result.Files)) + for _, f := range result.Files { + thumb := services.ThumbURLFromUpload(f.URL) + if thumb == "" { + thumb = f.URL + } + data.Files = append(data.Files, adminMediaRow{ + ID: f.ID, Category: f.Category, CategoryLabel: adminMediaCategoryLabel(f.Category), + Name: f.Name, URL: f.URL, ThumbURL: thumb, + SizeLabel: formatByteSize(f.Size), Modified: f.ModifiedAt.Local().Format("2006-01-02 15:04"), + StorageType: f.StorageType, + }) + } + ctx.HTML(http.StatusOK, "admin/media", data) +} + +func adminMediaCategoryLabel(cat string) string { + switch cat { + case services.UploadCategoryAvatars: + return "头像" + case services.UploadCategoryPosts: + return "帖图" + case services.UploadCategorySite: + return "站点" + default: + return cat + } +} + +func formatByteSize(n int64) string { + if n < 1024 { + return fmt.Sprintf("%d B", n) + } + if n < 1024*1024 { + return fmt.Sprintf("%.1f KB", float64(n)/1024) + } + return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024)) +} + +func (d Deps) mediaRedirectQuery(c *gin.Context) string { + q := url.Values{} + if v := strings.TrimSpace(c.Query("q")); v != "" { + q.Set("q", v) + } + if v := strings.TrimSpace(c.Query("category")); v != "" && v != "all" { + q.Set("category", v) + } + if v := strings.TrimSpace(c.Query("page")); v != "" && v != "1" { + q.Set("page", v) + } + // POST 表单也可带回筛选 + if v := strings.TrimSpace(c.PostForm("q")); v != "" { + q.Set("q", v) + } + if v := strings.TrimSpace(c.PostForm("category")); v != "" && v != "all" { + q.Set("category", v) + } + if v := strings.TrimSpace(c.PostForm("page")); v != "" && v != "1" { + q.Set("page", v) + } + s := q.Encode() + if s == "" { + return "/admin/media" + } + return "/admin/media?" + s +} + +// AdminMediaDeletePost 按 ID 删除单条 +func (d Deps) AdminMediaDeletePost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderAdminMedia(ctx, "无效请求,请重试") + return + } + if d.Store == nil { + d.renderAdminMedia(ctx, "上传存储未就绪") + return + } + id64, _ := strconv.ParseUint(c.Param("id"), 10, 64) + n, err := d.Store.DeleteMediaByIDs([]uint{uint(id64)}) + if err != nil { + d.renderAdminMedia(ctx, err.Error()) + return + } + if n == 0 { + d.renderAdminMedia(ctx, "未找到可删除的媒体") + return + } + ctx.SetFlash("已删除 1 项媒体") + ctx.Redirect(d.mediaRedirectQuery(c)) +} + +// AdminMediaBatchDeletePost 批量删除 +func (d Deps) AdminMediaBatchDeletePost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderAdminMedia(ctx, "无效请求,请重试") + return + } + if d.Store == nil { + d.renderAdminMedia(ctx, "上传存储未就绪") + return + } + raw := c.PostFormArray("ids") + ids := make([]uint, 0, len(raw)) + for _, s := range raw { + id64, err := strconv.ParseUint(strings.TrimSpace(s), 10, 64) + if err != nil || id64 == 0 { + continue + } + ids = append(ids, uint(id64)) + } + if len(ids) == 0 { + d.renderAdminMedia(ctx, "请选择要删除的文件") + return + } + if len(ids) > 100 { + d.renderAdminMedia(ctx, "单次最多删除 100 个文件") + return + } + n, err := d.Store.DeleteMediaByIDs(ids) + if err != nil { + d.renderAdminMedia(ctx, err.Error()) + return + } + ctx.SetFlash(fmt.Sprintf("已删除 %d 项媒体", n)) + ctx.Redirect(d.mediaRedirectQuery(c)) +} + +// AdminMediaSyncPost 扫盘回填索引 +func (d Deps) AdminMediaSyncPost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderAdminMedia(ctx, "无效请求,请重试") + return + } + if d.Store == nil { + d.renderAdminMedia(ctx, "上传存储未就绪") + return + } + n, err := d.Store.SyncMediaIndex() + if err != nil { + d.renderAdminMedia(ctx, err.Error()) + return + } + ctx.SetFlash(fmt.Sprintf("索引已同步(处理 %d 条)", n)) + ctx.Redirect("/admin/media") +} diff --git a/routers/web/home.go b/routers/web/home.go index aa871c0..ef39562 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -108,6 +108,10 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { admin.GET("/badges/:id/edit", deps.AdminBadgeEditGet) admin.POST("/badges/:id", deps.AdminBadgeUpdate) admin.POST("/badges/:id/delete", deps.AdminBadgeDelete) + admin.GET("/media", deps.AdminMediaGet) + admin.POST("/media/delete", deps.AdminMediaBatchDeletePost) + admin.POST("/media/sync", deps.AdminMediaSyncPost) + admin.POST("/media/:id/delete", deps.AdminMediaDeletePost) admin.GET("/trash", deps.AdminTrashGet) admin.POST("/trash/:id/restore", deps.AdminTrashRestorePost) admin.POST("/trash/:id/purge", deps.AdminTrashPurgePost) diff --git a/services/media.go b/services/media.go index 4d1546b..f4f9793 100644 --- a/services/media.go +++ b/services/media.go @@ -17,6 +17,7 @@ import ( // MediaItem 管理端媒体资源条目 type MediaItem struct { + ID uint `json:"id"` Category string `json:"category"` Name string `json:"name"` URL string `json:"url"` @@ -128,6 +129,7 @@ func (s *UploadStore) ListMedia(category, query string, page, size int) (*MediaL mod = r.CreatedAt } files = append(files, MediaItem{ + ID: r.ID, Category: r.Category, Name: r.Name, URL: r.URL, @@ -176,6 +178,37 @@ func (s *UploadStore) DeleteMedia(urls []string) (int, error) { return n, nil } +// DeleteMediaByIDs 按索引 ID 删除媒体 +func (s *UploadStore) DeleteMediaByIDs(ids []uint) (int, error) { + if s == nil { + return 0, errors.New("上传存储未初始化") + } + if models.DB == nil || len(ids) == 0 { + return 0, nil + } + clean := make([]uint, 0, len(ids)) + seen := map[uint]bool{} + for _, id := range ids { + if id == 0 || seen[id] { + continue + } + seen[id] = true + clean = append(clean, id) + } + if len(clean) == 0 { + return 0, nil + } + var rows []models.Media + if err := models.DB.Where("id IN ?", clean).Find(&rows).Error; err != nil { + return 0, err + } + urls := make([]string, 0, len(rows)) + for _, r := range rows { + urls = append(urls, r.URL) + } + return s.DeleteMedia(urls) +} + // SyncMediaIndex 扫描当前存储后端,回填/校正媒体索引;返回写入或更新条数 func (s *UploadStore) SyncMediaIndex() (int, error) { if s == nil || models.DB == nil { diff --git a/templates/admin/media.tmpl b/templates/admin/media.tmpl new file mode 100644 index 0000000..37ab98b --- /dev/null +++ b/templates/admin/media.tmpl @@ -0,0 +1,90 @@ +{{define "admin/media"}} +{{template "base/head" .}} +{{template "base/navbar" .}} +
+

媒体库

+ {{template "admin/nav" .}} + {{template "base/alert" .}} + +
+ +
+ + +
+
+ +

+ 全部 {{.CountAll}} + 头像 {{.CountAvatars}} + 帖图 {{.CountPosts}} + 站点 {{.CountSite}} + {{if .StorageType}}存储:{{.StorageType}}{{end}} +

+ + {{if .Files}} +
+ + + + +

+ + 共 {{.Total}} 项 +

+
+ + {{if or .HasPrev .HasMore}} + + {{end}} + {{else}} +

暂无媒体。上传头像/帖图后会出现在此;也可点「同步索引」扫盘回填。

+ {{end}} +
+{{template "base/footer" .}} +{{end}} diff --git a/templates/admin/nav.tmpl b/templates/admin/nav.tmpl index 1df125e..c4d7121 100644 --- a/templates/admin/nav.tmpl +++ b/templates/admin/nav.tmpl @@ -6,6 +6,7 @@ 举报 用户 徽章 + 媒体 回收站 友链 单页 diff --git a/web_src/css/site.css b/web_src/css/site.css index f40e437..119f225 100644 --- a/web_src/css/site.css +++ b/web_src/css/site.css @@ -1046,6 +1046,72 @@ points-only[data-locked="false"] { gap: 0.5rem; align-items: center; } +.j13-admin-media-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1.25rem; + align-items: flex-end; + margin-bottom: 0.75rem; +} +.j13-admin-media-batch { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin: 0 0 0.75rem; +} +.j13-admin-media-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); + gap: 0.85rem; +} +.j13-admin-media-card { + position: relative; + border: 1px solid var(--j13-border); + border-radius: 6px; + overflow: hidden; + background: var(--j13-surface, #fff); +} +.j13-admin-media-check { + position: absolute; + top: 0.4rem; + left: 0.4rem; + z-index: 1; + background: rgba(255,255,255,0.85); + border-radius: 3px; + padding: 0.1rem 0.2rem; +} +.j13-admin-media-thumb { + display: block; + aspect-ratio: 1; + background: var(--j13-bg, #f5f5f5); +} +.j13-admin-media-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.j13-admin-media-meta { + padding: 0.45rem 0.55rem 0.6rem; + font-size: 0.85rem; +} +.j13-admin-media-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; +} +.j13-admin-media-ops { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin-top: 0.35rem; +} .j13-post__admin { display: flex; flex-wrap: wrap;