feat: Admin 媒体库列表、删除与同步索引

SSR /admin/media 复用 ListMedia/DeleteMedia,并支持按 ID 批量删与扫盘回填。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 03:47:20 +08:00
parent 0c5fe8a1b4
commit 9e403a7178
11 changed files with 570 additions and 34 deletions

View File

@@ -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「同步索引」
--- ---

View File

@@ -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` | 重定向前台登录 | 已迁 |
未迁(原 SPAmedia / 完整 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/存储/伪静态未迁) |

View File

@@ -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** | **未迁** |
| settingsOIDC / Gitea / 存储 / 伪静态 Tab | **未迁** | | settingsOIDC / 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 → Lv110、自动徽章调度 UI、短龄同 IP 互刷拒绝分成(`02` §H - Exp → Lv110 设定 UI、自动徽章调度说明、短龄同 IP 互刷拒绝分成(`02` §H
### P3 — 体验 / 编辑器(可砍或长期) ### P3 — 体验 / 编辑器(可砍或长期)
@@ -98,7 +96,7 @@ flowchart LR
- KGitea `/projects` - KGitea `/projects`
- LOIDC 产品化 Admin CRUD - LOIDC 产品化 Admin CRUD
- MS3 热切换、WebP thumb 全链路 - MS3 热切换、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)。
--- ---

View 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 → 同步索引 → 列表可见 → 删除。

View File

@@ -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
View 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")
}

View File

@@ -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)

View File

@@ -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 {

View 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}}&amp;{{end}}page={{.PrevPage}}">上一页</a>
{{end}}
<span>第 {{.Page}} 页</span>
{{if .HasMore}}
<a href="/admin/media?{{if .QuerySuffix}}{{.QuerySuffix}}&amp;{{end}}page={{.NextPage}}">下一页</a>
{{end}}
</nav>
{{end}}
{{else}}
<p class="j13-empty">暂无媒体。上传头像/帖图后会出现在此;也可点「同步索引」扫盘回填。</p>
{{end}}
</main>
{{template "base/footer" .}}
{{end}}

View File

@@ -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>

View File

@@ -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;