feat: Admin 媒体库列表、删除与同步索引
SSR /admin/media 复用 ListMedia/DeleteMedia,并支持按 ID 批量删与扫盘回填。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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「同步索引」
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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/存储/伪静态未迁) |
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
21
docs/rebuild-spec/plans/admin-media.md
Normal file
21
docs/rebuild-spec/plans/admin-media.md
Normal file
@@ -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 → 同步索引 → 列表可见 → 删除。
|
||||
@@ -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;
|
||||
|
||||
255
routers/web/admin_media.go
Normal file
255
routers/web/admin_media.go
Normal file
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
90
templates/admin/media.tmpl
Normal file
90
templates/admin/media.tmpl
Normal file
@@ -0,0 +1,90 @@
|
||||
{{define "admin/media"}}
|
||||
{{template "base/head" .}}
|
||||
{{template "base/navbar" .}}
|
||||
<main class="j13-main j13-admin">
|
||||
<h1>媒体库</h1>
|
||||
{{template "admin/nav" .}}
|
||||
{{template "base/alert" .}}
|
||||
|
||||
<div class="j13-admin-media-toolbar">
|
||||
<form method="get" action="/admin/media" class="j13-form j13-admin-form j13-admin-search">
|
||||
<label>搜索 <input name="q" value="{{.Keyword}}" placeholder="文件名 / URL"/></label>
|
||||
<label>分类
|
||||
<select name="category">
|
||||
<option value="all"{{if eq .Category "all"}} selected{{end}}>全部 ({{.CountAll}})</option>
|
||||
<option value="avatars"{{if eq .Category "avatars"}} selected{{end}}>头像 ({{.CountAvatars}})</option>
|
||||
<option value="posts"{{if eq .Category "posts"}} selected{{end}}>帖图 ({{.CountPosts}})</option>
|
||||
<option value="site"{{if eq .Category "site"}} selected{{end}}>站点 ({{.CountSite}})</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">查询</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/media/sync" class="j13-inline-form">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
|
||||
<button type="submit" class="j13-btn-secondary">同步索引</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p class="j13-admin-filters">
|
||||
<a href="/admin/media"{{if eq .Category "all"}} class="is-active"{{end}}>全部 {{.CountAll}}</a>
|
||||
<a href="/admin/media?category=avatars"{{if eq .Category "avatars"}} class="is-active"{{end}}>头像 {{.CountAvatars}}</a>
|
||||
<a href="/admin/media?category=posts"{{if eq .Category "posts"}} class="is-active"{{end}}>帖图 {{.CountPosts}}</a>
|
||||
<a href="/admin/media?category=site"{{if eq .Category "site"}} class="is-active"{{end}}>站点 {{.CountSite}}</a>
|
||||
{{if .StorageType}}<span class="j13-muted">存储:{{.StorageType}}</span>{{end}}
|
||||
</p>
|
||||
|
||||
{{if .Files}}
|
||||
<form method="post" action="/admin/media/delete" id="admin-media-batch" class="j13-admin-media-batch-form">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
|
||||
<input type="hidden" name="q" value="{{.Keyword}}"/>
|
||||
<input type="hidden" name="category" value="{{.Category}}"/>
|
||||
<input type="hidden" name="page" value="{{.Page}}"/>
|
||||
<p class="j13-admin-media-batch">
|
||||
<button type="submit" class="j13-btn-secondary" onclick="return confirm('确认删除所选媒体?');">删除所选</button>
|
||||
<span class="j13-muted">共 {{.Total}} 项</span>
|
||||
</p>
|
||||
</form>
|
||||
<ul class="j13-admin-media-grid">
|
||||
{{range .Files}}
|
||||
<li class="j13-admin-media-card">
|
||||
<label class="j13-admin-media-check">
|
||||
<input form="admin-media-batch" type="checkbox" name="ids" value="{{.ID}}"/>
|
||||
</label>
|
||||
<a class="j13-admin-media-thumb" href="{{.URL}}" target="_blank" rel="noopener">
|
||||
<img src="{{.ThumbURL}}" alt="{{.Name}}" loading="lazy"/>
|
||||
</a>
|
||||
<div class="j13-admin-media-meta">
|
||||
<div class="j13-admin-media-name" title="{{.Name}}">{{.Name}}</div>
|
||||
<div class="j13-muted">{{.CategoryLabel}} · {{.SizeLabel}}</div>
|
||||
<div class="j13-muted">{{.Modified}}</div>
|
||||
<div class="j13-admin-media-ops">
|
||||
<a href="{{.URL}}" target="_blank" rel="noopener">打开</a>
|
||||
<form method="post" action="/admin/media/{{.ID}}/delete" class="j13-inline-form" onsubmit="return confirm('确认删除该文件?');">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
|
||||
<input type="hidden" name="q" value="{{$.Keyword}}"/>
|
||||
<input type="hidden" name="category" value="{{$.Category}}"/>
|
||||
<input type="hidden" name="page" value="{{$.Page}}"/>
|
||||
<button type="submit" class="j13-linkbtn">删除</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{if or .HasPrev .HasMore}}
|
||||
<nav class="j13-pager">
|
||||
{{if .HasPrev}}
|
||||
<a href="/admin/media?{{if .QuerySuffix}}{{.QuerySuffix}}&{{end}}page={{.PrevPage}}">上一页</a>
|
||||
{{end}}
|
||||
<span>第 {{.Page}} 页</span>
|
||||
{{if .HasMore}}
|
||||
<a href="/admin/media?{{if .QuerySuffix}}{{.QuerySuffix}}&{{end}}page={{.NextPage}}">下一页</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="j13-empty">暂无媒体。上传头像/帖图后会出现在此;也可点「同步索引」扫盘回填。</p>
|
||||
{{end}}
|
||||
</main>
|
||||
{{template "base/footer" .}}
|
||||
{{end}}
|
||||
@@ -6,6 +6,7 @@
|
||||
<a href="/admin/reports"{{if eq .NavActive "reports"}} class="is-active"{{end}}>举报</a>
|
||||
<a href="/admin/users"{{if eq .NavActive "users"}} class="is-active"{{end}}>用户</a>
|
||||
<a href="/admin/badges"{{if eq .NavActive "badges"}} class="is-active"{{end}}>徽章</a>
|
||||
<a href="/admin/media"{{if eq .NavActive "media"}} class="is-active"{{end}}>媒体</a>
|
||||
<a href="/admin/trash"{{if eq .NavActive "trash"}} class="is-active"{{end}}>回收站</a>
|
||||
<a href="/admin/friend-links"{{if eq .NavActive "friend-links"}} class="is-active"{{end}}>友链</a>
|
||||
<a href="/admin/pages"{{if eq .NavActive "pages"}} class="is-active"{{end}}>单页</a>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user