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. 媒体与存储
|
## M. 媒体与存储
|
||||||
|
|
||||||
- [ ] 本地 uploads 或 S3 兼容存储(可热切换配置)
|
- [ ] 本地 uploads 或 S3 兼容存储(可热切换配置)— 后端已支持;Admin 热切换 UI 未迁
|
||||||
- [ ] 头像 / 帖图 / 站点品牌资源分类
|
- [x] 头像 / 帖图 / 站点品牌资源分类 — 上传路径 + Admin 分类筛选
|
||||||
- [ ] 图片展示可选 WebP 转换(`/media/thumb/...`)
|
- [ ] 图片展示可选 WebP 转换(`/media/thumb/...`)— 路由已有;全站默认策略未产品化
|
||||||
- [ ] 管理后台媒体列表与删除
|
- [x] 管理后台媒体列表与删除 — SSR `/admin/media`
|
||||||
- [ ] 媒体索引表同步
|
- [x] 媒体索引表同步 — 启动扫盘 + Admin「同步索引」
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -54,10 +54,11 @@
|
|||||||
| `/admin/reports` | 举报处理 | 已迁 |
|
| `/admin/reports` | 举报处理 | 已迁 |
|
||||||
| `/admin/users` | 用户列表、禁言、认证、调积分 | 已迁 |
|
| `/admin/users` | 用户列表、禁言、认证、调积分 | 已迁 |
|
||||||
| `/admin/badges` | 徽章定义 CRUD、限定颁发/收回 | 已迁 |
|
| `/admin/badges` | 徽章定义 CRUD、限定颁发/收回 | 已迁 |
|
||||||
|
| `/admin/media` | 媒体库分类浏览、删除、同步索引 | 已迁 |
|
||||||
| `/admin/trash` | 帖回收站 | 已迁 |
|
| `/admin/trash` | 帖回收站 | 已迁 |
|
||||||
| `/admin/login` | 重定向前台登录 | 已迁 |
|
| `/admin/login` | 重定向前台登录 | 已迁 |
|
||||||
|
|
||||||
未迁(原 SPA):media / 完整 Limits 等。
|
未迁(原 SPA):完整 Limits / OIDC·Gitea·存储热切换 Admin 等。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -197,6 +198,7 @@
|
|||||||
| Reports | 处理动作:dismiss / resolve / reject_post / reject_comment — SSR `/admin/reports` |
|
| Reports | 处理动作:dismiss / resolve / reject_post / reject_comment — SSR `/admin/reports` |
|
||||||
| Users | 搜索;禁言;认证;调积分 — SSR `/admin/users`(设等级 / 授徽章挂在徽章页) |
|
| Users | 搜索;禁言;认证;调积分 — SSR `/admin/users`(设等级 / 授徽章挂在徽章页) |
|
||||||
| Badges | 定义自动/限定徽章;颁发/收回限定 — SSR `/admin/badges` |
|
| Badges | 定义自动/限定徽章;颁发/收回限定 — SSR `/admin/badges` |
|
||||||
|
| Media | 分类浏览;单删/批量删;同步索引 — SSR `/admin/media` |
|
||||||
| Media | 分类浏览;批量删 |
|
| Media | 分类浏览;批量删 |
|
||||||
| Settings | Tab:论坛限制、侧栏组件、伪静态、邮件、OIDC+客户端、Gitea、存储、品牌、敏感词、备份 — SSR `/admin/settings` 已迁品牌/限流/侧栏/敏感词/邮件/SQLite 备份(OIDC/Gitea/存储/伪静态未迁) |
|
| 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
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -59,14 +59,12 @@ flowchart LR
|
|||||||
| dashboard / boards / moderation / reports / trash | 已迁 |
|
| dashboard / boards / moderation / reports / trash | 已迁 |
|
||||||
| settings:品牌、限流、敏感词、SMTP、侧栏、SQLite 备份 | 已迁 |
|
| settings:品牌、限流、敏感词、SMTP、侧栏、SQLite 备份 | 已迁 |
|
||||||
| friend-links / pages | 已迁 |
|
| friend-links / pages | 已迁 |
|
||||||
| **users**(禁言 / 认证 / 调积分) | **已迁** |
|
| **users / badges / media** | **已迁** |
|
||||||
| **badges**(定义 CRUD + 限定颁发/收回) | **已迁** |
|
|
||||||
| **media** | **未迁** |
|
|
||||||
| settings:OIDC / Gitea / 存储 / 伪静态 Tab | **未迁** |
|
| settings:OIDC / Gitea / 存储 / 伪静态 Tab | **未迁** |
|
||||||
|
|
||||||
### 近期提交(摘)
|
### 近期提交(摘)
|
||||||
|
|
||||||
`lottery` → Admin users → **Admin badges**
|
Admin users → badges → **media**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -77,15 +75,15 @@ flowchart LR
|
|||||||
- 发评路径挂 `RateLimiter` `comment` 动作(服务层已有键,web 未挂)
|
- 发评路径挂 `RateLimiter` `comment` 动作(服务层已有键,web 未挂)
|
||||||
- Logo/Favicon/OG 上传、头像裁剪等声明为未做的边角
|
- Logo/Favicon/OG 上传、头像裁剪等声明为未做的边角
|
||||||
|
|
||||||
### P1 — 推荐刀序(服务层多半已齐)
|
### P1 — Admin 核心
|
||||||
|
|
||||||
1. ~~Admin 用户~~ ✓
|
1. ~~Admin 用户~~ ✓
|
||||||
2. ~~Admin 徽章~~ ✓
|
2. ~~Admin 徽章~~ ✓
|
||||||
3. **Admin 媒体** ← 当前指针
|
3. ~~Admin 媒体~~ ✓
|
||||||
|
|
||||||
### P2 — 成长与防刷
|
### P2 — 成长与防刷 ← 当前指针候选
|
||||||
|
|
||||||
- Exp → Lv1–10、自动徽章调度 UI、短龄同 IP 互刷拒绝分成(`02` §H)
|
- Exp → Lv1–10 设定 UI、自动徽章调度说明、短龄同 IP 互刷拒绝分成(`02` §H)
|
||||||
|
|
||||||
### P3 — 体验 / 编辑器(可砍或长期)
|
### P3 — 体验 / 编辑器(可砍或长期)
|
||||||
|
|
||||||
@@ -98,7 +96,7 @@ flowchart LR
|
|||||||
|
|
||||||
- K:Gitea `/projects`
|
- K:Gitea `/projects`
|
||||||
- L:OIDC 产品化 Admin CRUD
|
- L:OIDC 产品化 Admin CRUD
|
||||||
- M:S3 热切换、WebP thumb 全链路
|
- M:S3 热切换、WebP thumb 全链路产品化
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -106,39 +104,39 @@ flowchart LR
|
|||||||
|
|
||||||
| 序 | 刀 | 产出 |
|
| 序 | 刀 | 产出 |
|
||||||
|----|----|------|
|
|----|----|------|
|
||||||
| ✓ | 五种帖类型 + 备份/复检 + §O 勾选 | 核心闭环 |
|
| ✓ | 五种帖类型 + 备份/复检 + §O | 核心闭环 |
|
||||||
| ✓ | 本文 `09` | 可查阅进度 |
|
| ✓ | Admin users / badges / media | Admin P1 |
|
||||||
| ✓ | Admin `/admin/users` | 禁言/认证/调积分 |
|
| → | 等级设定 **或** 防刷分成 | 需偏好 |
|
||||||
| ✓ | Admin `/admin/badges` | 徽章 CRUD + 授予 |
|
|
||||||
| → | Admin `/admin/media` | 媒体列表删除 |
|
|
||||||
| 5 | 等级/自动徽章 **或** 防刷分成 | 按当时偏好 |
|
|
||||||
| … | P3 / 后置 | 需明确点名再开 |
|
| … | P3 / 后置 | 需明确点名再开 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 下一刀实现提纲:Admin 媒体
|
## 下一刀实现提纲:P2 候选
|
||||||
|
|
||||||
> 确认「继续」后实现媒体库列表与删除;对齐既有上传/存储服务。
|
> 「继续」前请点名:**等级/Exp UI**、**防刷分成**,或其它(OIDC/伪静态等)。
|
||||||
|
|
||||||
### 范围(草案)
|
### A. 等级
|
||||||
|
|
||||||
- `GET /admin/media`:按时间分页列表(缩略图 / 路径 / 引用粗览)
|
- Admin 设用户 Exp/Lv(`SetUserLevel` 已有)
|
||||||
- `POST /admin/media/:id/delete`:删库文件 + 记录
|
- 公开页等级展示对齐
|
||||||
- 回写 `02` / `06`;指针 → 成长或按偏好
|
|
||||||
|
|
||||||
### 不做
|
### B. 防刷分成
|
||||||
|
|
||||||
S3 热切换产品化、全站未引用扫描清理器。
|
- 短龄同 IP 互刷拒绝创作分成(`02` §H)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 已完成:Admin 媒体
|
||||||
|
|
||||||
|
见 [plans/admin-media.md](plans/admin-media.md)。
|
||||||
|
|
||||||
## 已完成:Admin 徽章
|
## 已完成:Admin 徽章
|
||||||
|
|
||||||
见 [plans/admin-badges.md](plans/admin-badges.md);路由 `/admin/badges` + award / revoke。
|
见 [plans/admin-badges.md](plans/admin-badges.md)。
|
||||||
|
|
||||||
## 已完成:Admin 用户管理
|
## 已完成: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;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
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 {
|
.j13-post__admin {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
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.GET("/badges/:id/edit", deps.AdminBadgeEditGet)
|
||||||
admin.POST("/badges/:id", deps.AdminBadgeUpdate)
|
admin.POST("/badges/:id", deps.AdminBadgeUpdate)
|
||||||
admin.POST("/badges/:id/delete", deps.AdminBadgeDelete)
|
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.GET("/trash", deps.AdminTrashGet)
|
||||||
admin.POST("/trash/:id/restore", deps.AdminTrashRestorePost)
|
admin.POST("/trash/:id/restore", deps.AdminTrashRestorePost)
|
||||||
admin.POST("/trash/:id/purge", deps.AdminTrashPurgePost)
|
admin.POST("/trash/:id/purge", deps.AdminTrashPurgePost)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
// MediaItem 管理端媒体资源条目
|
// MediaItem 管理端媒体资源条目
|
||||||
type MediaItem struct {
|
type MediaItem struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
@@ -128,6 +129,7 @@ func (s *UploadStore) ListMedia(category, query string, page, size int) (*MediaL
|
|||||||
mod = r.CreatedAt
|
mod = r.CreatedAt
|
||||||
}
|
}
|
||||||
files = append(files, MediaItem{
|
files = append(files, MediaItem{
|
||||||
|
ID: r.ID,
|
||||||
Category: r.Category,
|
Category: r.Category,
|
||||||
Name: r.Name,
|
Name: r.Name,
|
||||||
URL: r.URL,
|
URL: r.URL,
|
||||||
@@ -176,6 +178,37 @@ func (s *UploadStore) DeleteMedia(urls []string) (int, error) {
|
|||||||
return n, nil
|
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 扫描当前存储后端,回填/校正媒体索引;返回写入或更新条数
|
// SyncMediaIndex 扫描当前存储后端,回填/校正媒体索引;返回写入或更新条数
|
||||||
func (s *UploadStore) SyncMediaIndex() (int, error) {
|
func (s *UploadStore) SyncMediaIndex() (int, error) {
|
||||||
if s == nil || models.DB == nil {
|
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/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/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/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/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/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>
|
<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;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
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 {
|
.j13-post__admin {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
Reference in New Issue
Block a user