diff --git a/.gitignore b/.gitignore index af1c246..55ce362 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,13 @@ __pycache__/ *.pyc # Local scratch images +/*.png +/*.jpg +/*.jpeg +/*.gif +/*.webp +/*.bmp +/*.ico /image.jpg /image003.jpg /image004.jpg diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index a3412ff..c428176 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -131,6 +131,13 @@ func main() { OnVideoUploaded: func(v *catalog.Video) { app.enqueueUploadedVideo(ctx, v) }, + // 前台「不再展示」走拉黑逻辑:删记录 + 删本地封面/预览 + 写墓碑, + // 保留网盘源文件(deleteSource=false)。下次扫盘不再入库;如需恢复, + // 在后台「拉黑视频」移出黑名单即可,扫盘时会重新添加回来。 + OnHideVideo: func(reqCtx context.Context, videoID string) error { + _, err := app.deleteVideo(reqCtx, videoID, false) + return err + }, GetTheme: func() string { return app.Theme() }, } @@ -312,6 +319,7 @@ func main() { } }() go app.attachExistingDrives(ctx) + go app.migrateHiddenVideosToTombstone(ctx) // 等待退出信号 sigs := make(chan os.Signal, 1) @@ -1972,6 +1980,33 @@ func (a *App) cleanupMissingDriveVideos(ctx context.Context, driveID string, liv return removed, nil } +// migrateHiddenVideosToTombstone 把历史「隐藏」视频一次性迁移为黑名单墓碑。 +// 隐藏机制已废弃——前台「不再展示」改走拉黑逻辑。迁移=删库记录 + 删本地 +// 封面/预览 + 写墓碑,保留网盘源文件。迁移后无 hidden=1 记录,重复执行为空操作。 +func (a *App) migrateHiddenVideosToTombstone(ctx context.Context) { + if a == nil || a.cat == nil { + return + } + hidden, err := a.cat.ListHiddenVideos(ctx) + if err != nil { + log.Printf("[migrate] list hidden videos: %v", err) + return + } + if len(hidden) == 0 { + return + } + log.Printf("[migrate] converting %d hidden video(s) to blacklist tombstones", len(hidden)) + migrated := 0 + for _, v := range hidden { + if _, err := a.deleteVideo(ctx, v.ID, false); err != nil { + log.Printf("[migrate] hidden->tombstone %s: %v", v.ID, err) + continue + } + migrated++ + } + log.Printf("[migrate] hidden->tombstone done: %d/%d", migrated, len(hidden)) +} + func (a *App) deleteVideo(ctx context.Context, videoID string, deleteSource bool) (api.DeleteVideoResult, error) { if a == nil || a.cat == nil { return api.DeleteVideoResult{}, sql.ErrNoRows diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go index b4efe7d..f9c1eec 100644 --- a/backend/internal/api/admin.go +++ b/backend/internal/api/admin.go @@ -191,10 +191,14 @@ func (a *AdminServer) Register(r chi.Router) { // 视频 r.Get("/videos", a.handleAdminListVideos) + r.Get("/videos/stats", a.handleVideoStats) r.Put("/videos/{id}", a.handleUpdateVideo) r.Delete("/videos/{id}", a.handleDeleteVideo) r.Post("/videos/regen-preview", a.handleRegenAllPreviews) r.Post("/videos/{id}/regen-preview", a.handleRegenPreview) + // 黑名单(被拉黑/手动删除、扫盘不再入库的视频) + r.Get("/blacklist", a.handleListBlacklist) + r.Delete("/blacklist/{id}", a.handleRemoveBlacklist) // 标签 r.Get("/tags", a.handleListTags) @@ -1886,6 +1890,57 @@ func (a *AdminServer) handleAdminListVideos(w http.ResponseWriter, r *http.Reque }) } +// handleVideoStats 返回后台视频管理两个标签页的计数(当前/拉黑)。 +func (a *AdminServer) handleVideoStats(w http.ResponseWriter, r *http.Request) { + current, blacklisted, err := a.Catalog.VideoManagementCounts(r.Context()) + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "current": current, + "blacklisted": blacklisted, + }) +} + +// handleListBlacklist 分页返回黑名单(墓碑)视频。 +func (a *AdminServer) handleListBlacklist(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + page, _ := strconv.Atoi(q.Get("page")) + size, _ := strconv.Atoi(q.Get("size")) + if page <= 0 { + page = 1 + } + if size <= 0 || size > 100 { + size = 100 + } + items, total, err := a.Catalog.ListDeletedVideos(r.Context(), q.Get("keyword"), page, size) + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": items, + "total": total, + "page": page, + "size": size, + }) +} + +// handleRemoveBlacklist 把视频移出黑名单(删除墓碑),下次扫盘会重新入库。 +func (a *AdminServer) handleRemoveBlacklist(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if err := a.Catalog.RemoveDeletedVideo(r.Context(), id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeErr(w, http.StatusNotFound, err) + return + } + writeErr(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + func (a *AdminServer) handleListTags(w http.ResponseWriter, r *http.Request) { tags, err := a.Catalog.ListTags(r.Context()) if err != nil { diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 62d4bbf..eff1f0b 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -55,6 +55,10 @@ type Server struct { LocalDir string UploadDir string OnVideoUploaded func(*catalog.Video) + // OnHideVideo 处理前台「不再展示」。隐藏机制已废弃,改走拉黑逻辑: + // 删除库中记录 + 本地封面/预览,保留网盘源文件,并写黑名单墓碑 + // (扫盘不再入库)。未注入时回退为旧的 hidden 标记。 + OnHideVideo func(ctx context.Context, videoID string) error tagCacheMu sync.Mutex tagCacheUntil time.Time @@ -687,7 +691,14 @@ func (s *Server) handleView(w http.ResponseWriter, r *http.Request) { func (s *Server) handleHideVideo(w http.ResponseWriter, r *http.Request) { id := routeParam(r, "id") - if err := s.Catalog.HideVideo(r.Context(), id); err != nil { + var err error + if s.OnHideVideo != nil { + // 走拉黑逻辑:删记录 + 删本地封面/预览 + 写墓碑,保留网盘源文件。 + err = s.OnHideVideo(r.Context(), id) + } else { + err = s.Catalog.HideVideo(r.Context(), id) + } + if err != nil { if errors.Is(err, sql.ErrNoRows) { writeErr(w, http.StatusNotFound, err) return diff --git a/backend/internal/catalog/catalog.go b/backend/internal/catalog/catalog.go index e924f28..19fd939 100644 --- a/backend/internal/catalog/catalog.go +++ b/backend/internal/catalog/catalog.go @@ -287,6 +287,27 @@ func (c *Catalog) HideVideo(ctx context.Context, id string) error { return nil } +// ListHiddenVideos 返回所有被标记隐藏(hidden=1)的视频。 +// 仅用于一次性把历史「隐藏」视频迁移为黑名单墓碑——隐藏机制已废弃, +// 前台「不再展示」改走拉黑逻辑。 +func (c *Catalog) ListHiddenVideos(ctx context.Context) ([]*Video, error) { + rows, err := c.db.QueryContext(ctx, + `SELECT `+allVideoCols+` FROM videos WHERE COALESCE(hidden, 0) = 1`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*Video + for rows.Next() { + v, err := scanVideo(rows) + if err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + // MigrateVideoToDrive 把 catalog 里 id=videoID 这条视频迁移到另一个 drive。 // 用于 spider91 → PikPak 的迁移:上传成功后改写 drive_id / file_id / // content_hash,保留视频自身的 id(spider91--),这样 @@ -982,6 +1003,92 @@ func (c *Catalog) DeleteVideo(ctx context.Context, id string) error { return tx.Commit() } +// DeletedVideo 是黑名单(墓碑)表里的一条记录。原始视频行已删除, +// 这里只保留扫盘去重和后台展示需要的最小字段;没有 title/封面/作者。 +type DeletedVideo struct { + ID string `json:"id"` + DriveID string `json:"driveId"` + FileID string `json:"fileId"` + FileName string `json:"fileName"` + Size int64 `json:"size"` + DeletedAt int64 `json:"deletedAt"` // unix 毫秒 +} + +// ListDeletedVideos 分页列出黑名单视频,按拉黑时间倒序。 +// keyword 非空时按文件名模糊匹配。 +func (c *Catalog) ListDeletedVideos(ctx context.Context, keyword string, page, size int) ([]*DeletedVideo, int, error) { + if size <= 0 { + size = 50 + } + if page <= 0 { + page = 1 + } + var where []string + var args []any + if kw := strings.TrimSpace(keyword); kw != "" { + where = append(where, "file_name LIKE ?") + args = append(args, "%"+kw+"%") + } + whereSQL := "" + if len(where) > 0 { + whereSQL = " WHERE " + strings.Join(where, " AND ") + } + + var total int + if err := c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM deleted_videos`+whereSQL, args...).Scan(&total); err != nil { + return nil, 0, err + } + + offset := (page - 1) * size + rows, err := c.db.QueryContext(ctx, + `SELECT id, COALESCE(drive_id, ''), COALESCE(file_id, ''), COALESCE(file_name, ''), COALESCE(size_bytes, 0), deleted_at + FROM deleted_videos`+whereSQL+` + ORDER BY deleted_at DESC + LIMIT ? OFFSET ?`, + append(args, size, offset)...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var out []*DeletedVideo + for rows.Next() { + v := &DeletedVideo{} + if err := rows.Scan(&v.ID, &v.DriveID, &v.FileID, &v.FileName, &v.Size, &v.DeletedAt); err != nil { + return nil, 0, err + } + out = append(out, v) + } + return out, total, rows.Err() +} + +// RemoveDeletedVideo 把视频移出黑名单(删除墓碑)。移除后该视频会在 +// 下次扫盘/凌晨流水线时被重新发现并入库,本函数不主动触发扫描。 +func (c *Catalog) RemoveDeletedVideo(ctx context.Context, id string) error { + res, err := c.db.ExecContext(ctx, `DELETE FROM deleted_videos WHERE id = ?`, id) + if err != nil { + return err + } + if rows, err := res.RowsAffected(); err == nil && rows == 0 { + return sql.ErrNoRows + } + return nil +} + +// VideoManagementCounts 返回后台视频管理两个标签的计数: +// current=当前可见(与「当前视频」页一致的去重+在线盘+hidden=0 口径), +// blacklisted=黑名单墓碑总数。 +func (c *Catalog) VideoManagementCounts(ctx context.Context) (current, blacklisted int, err error) { + currentSQL := `SELECT COUNT(*) FROM videos WHERE COALESCE(hidden, 0) = 0 AND ` + activeDriveWhereSQL + ` AND ` + uniqueVideoWhereSQL + if err = c.db.QueryRowContext(ctx, currentSQL).Scan(¤t); err != nil { + return 0, 0, err + } + if err = c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM deleted_videos`).Scan(&blacklisted); err != nil { + return 0, 0, err + } + return current, blacklisted, nil +} + func (c *Catalog) IsVideoDeleted(ctx context.Context, id string) (bool, error) { id = strings.TrimSpace(id) if id == "" { diff --git a/backend/internal/catalog/video_management_test.go b/backend/internal/catalog/video_management_test.go new file mode 100644 index 0000000..40378b7 --- /dev/null +++ b/backend/internal/catalog/video_management_test.go @@ -0,0 +1,133 @@ +package catalog + +import ( + "context" + "testing" + "time" +) + +// TestListHiddenVideosForMigration 验证:隐藏的视频不进可见列表, +// 但能被 ListHiddenVideos 拿到(供一次性迁移为墓碑)。 +func TestListHiddenVideosForMigration(t *testing.T) { + ctx := context.Background() + cat, err := Open(t.TempDir() + "/catalog.db") + if err != nil { + t.Fatalf("open catalog: %v", err) + } + t.Cleanup(func() { _ = cat.Close() }) + + now := time.Now() + for _, id := range []string{"v1", "v2", "v3"} { + if err := cat.UpsertVideo(ctx, &Video{ + ID: id, DriveID: "drive", FileID: "f-" + id, Title: id, + PublishedAt: now, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("seed %s: %v", id, err) + } + } + if err := cat.HideVideo(ctx, "v2"); err != nil { + t.Fatalf("hide v2: %v", err) + } + + visible, total, err := cat.ListVideos(ctx, ListParams{Page: 1, PageSize: 50}) + if err != nil { + t.Fatalf("list visible: %v", err) + } + if total != 2 || len(visible) != 2 { + t.Fatalf("visible total/len = %d/%d, want 2/2", total, len(visible)) + } + for _, v := range visible { + if v.ID == "v2" { + t.Fatalf("hidden v2 leaked into visible list") + } + } + + hidden, err := cat.ListHiddenVideos(ctx) + if err != nil { + t.Fatalf("list hidden: %v", err) + } + if len(hidden) != 1 || hidden[0].ID != "v2" { + t.Fatalf("ListHiddenVideos = %v, want only v2", hidden) + } + + current, blacklisted, err := cat.VideoManagementCounts(ctx) + if err != nil { + t.Fatalf("counts: %v", err) + } + if current != 2 || blacklisted != 0 { + t.Fatalf("counts = current %d blacklisted %d, want 2/0", current, blacklisted) + } +} + +// TestBlacklistListAndRemove 验证墓碑表的列出、关键字过滤和移除。 +func TestBlacklistListAndRemove(t *testing.T) { + ctx := context.Background() + cat, err := Open(t.TempDir() + "/catalog.db") + if err != nil { + t.Fatalf("open catalog: %v", err) + } + t.Cleanup(func() { _ = cat.Close() }) + + now := time.Now() + seed := []struct{ id, file string }{ + {"d1", "movie-alpha.avi"}, + {"d2", "movie-beta.mp4"}, + {"d3", "clip-gamma.wmv"}, + } + for _, s := range seed { + if err := cat.UpsertVideo(ctx, &Video{ + ID: s.id, DriveID: "drive", FileID: "f-" + s.id, FileName: s.file, + Title: s.id, PublishedAt: now, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("seed %s: %v", s.id, err) + } + if err := cat.DeleteVideoWithTombstone(ctx, s.id); err != nil { + t.Fatalf("tombstone %s: %v", s.id, err) + } + } + + items, total, err := cat.ListDeletedVideos(ctx, "", 1, 50) + if err != nil { + t.Fatalf("list deleted: %v", err) + } + if total != 3 || len(items) != 3 { + t.Fatalf("deleted total/len = %d/%d, want 3/3", total, len(items)) + } + + // 关键字过滤 + filtered, ftotal, err := cat.ListDeletedVideos(ctx, "movie", 1, 50) + if err != nil { + t.Fatalf("list deleted filtered: %v", err) + } + if ftotal != 2 || len(filtered) != 2 { + t.Fatalf("filtered total/len = %d/%d, want 2/2", ftotal, len(filtered)) + } + + // 移出黑名单 + if err := cat.RemoveDeletedVideo(ctx, "d1"); err != nil { + t.Fatalf("remove d1: %v", err) + } + if deleted, err := cat.IsVideoDeleted(ctx, "d1"); err != nil || deleted { + t.Fatalf("d1 should no longer be blacklisted (deleted=%v err=%v)", deleted, err) + } + _, total, err = cat.ListDeletedVideos(ctx, "", 1, 50) + if err != nil { + t.Fatalf("list deleted after remove: %v", err) + } + if total != 2 { + t.Fatalf("deleted total after remove = %d, want 2", total) + } + + if err := cat.RemoveDeletedVideo(ctx, "does-not-exist"); err == nil { + t.Fatalf("remove missing id should return error") + } + + // counts: 删完一个还剩 2 个黑名单;可见视频已全部被墓碑删除 + current, blacklisted, err := cat.VideoManagementCounts(ctx) + if err != nil { + t.Fatalf("counts: %v", err) + } + if current != 0 || blacklisted != 2 { + t.Fatalf("counts = current %d blacklisted %d, want 0/2", current, blacklisted) + } +} diff --git a/src/admin/VideosPage.tsx b/src/admin/VideosPage.tsx index c9551f4..9ebaaba 100644 --- a/src/admin/VideosPage.tsx +++ b/src/admin/VideosPage.tsx @@ -1,5 +1,17 @@ import { useEffect, useId, useState } from "react"; -import { ChevronDown, Edit, RefreshCw, Search, CheckSquare, Square, Image, Trash2 } from "lucide-react"; +import { useSearchParams } from "react-router-dom"; +import { + ChevronDown, + Edit, + RefreshCw, + Search, + CheckSquare, + Square, + Image, + Trash2, + Ban, + RotateCcw, +} from "lucide-react"; import * as api from "./api"; import { useToast } from "./ToastContext"; import { Modal } from "./Modal"; @@ -10,7 +22,85 @@ const DESKTOP_VIDEOS_PAGE_SIZE = 50; const MOBILE_VIDEOS_PAGE_SIZE = 20; const VIDEOS_MOBILE_QUERY = "(max-width: 640px)"; +type TabKey = "current" | "blacklist"; + +const TABS: { key: TabKey; label: string }[] = [ + { key: "current", label: "当前视频" }, + { key: "blacklist", label: "拉黑视频" }, +]; + +/** + * 视频管理容器:顶部分段标签在「当前 / 隐藏 / 拉黑」三个视图间切换, + * 激活标签同步到 URL ?tab=,标签上的计数来自 /videos/stats。 + */ export function VideosPage() { + const [searchParams, setSearchParams] = useSearchParams(); + const rawTab = searchParams.get("tab"); + const activeTab: TabKey = rawTab === "blacklist" ? "blacklist" : "current"; + const [stats, setStats] = useState(null); + + async function refreshStats() { + try { + setStats(await api.getVideoStats()); + } catch { + // 计数仅用于标签徽标,失败不阻塞主流程。 + } + } + + useEffect(() => { + refreshStats(); + }, []); + + function selectTab(key: TabKey) { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (key === "current") next.delete("tab"); + else next.set("tab", key); + return next; + }, + { replace: true } + ); + } + + const counts: Record = { + current: stats?.current, + blacklist: stats?.blacklisted, + }; + + return ( +
+
+

视频管理

+
+ +
+ {TABS.map((t) => ( + + ))} +
+ + {activeTab === "current" && } + {activeTab === "blacklist" && } +
+ ); +} + +// ---------- 当前视频 ---------- + +function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) { const [list, setList] = useState([]); const [drives, setDrives] = useState([]); const [loading, setLoading] = useState(true); @@ -74,9 +164,7 @@ export function VideosPage() { return () => window.clearTimeout(timer); }, [keyword]); - const driveNameMap = new Map( - drives.map((d) => [d.id, d.name || d.id]) - ); + const driveNameMap = new Map(drives.map((d) => [d.id, d.name || d.id])); const listItems = list; const totalPages = Math.max(1, Math.ceil(total / pageSize)); @@ -111,13 +199,14 @@ export function VideosPage() { setBatchRegening(true); let success = 0; try { - const results = await Promise.allSettled( - ids.map((id) => api.regenPreview(id)) - ); + const results = await Promise.allSettled(ids.map((id) => api.regenPreview(id))); for (const r of results) { if (r.status === "fulfilled") success++; } - show(`批量触发完成,成功 ${success} / ${ids.length} 个`, success === ids.length ? "success" : "info"); + show( + `批量触发完成,成功 ${success} / ${ids.length} 个`, + success === ids.length ? "success" : "info" + ); setSelectedIds(new Set()); setBatchRegenOpen(false); } finally { @@ -139,6 +228,7 @@ export function VideosPage() { return next; }); show(result.deletedSource ? "已删除视频,并清理源文件" : "已删除视频", "success"); + onStatsChanged(); if (listItems.length === 1 && page > 1) { setPage((p) => Math.max(1, p - 1)); } else { @@ -172,11 +262,15 @@ export function VideosPage() { const extra = deletedSources > 0 ? `,其中 ${deletedSources} 个清理了源文件` : ""; show(`批量删除完成,成功 ${success} 个${extra}`, "success"); } else { - show(`批量删除完成,成功 ${success} / ${ids.length} 个,失败 ${failed} 个`, success > 0 ? "info" : "error"); + show( + `批量删除完成,成功 ${success} / ${ids.length} 个,失败 ${failed} 个`, + success > 0 ? "info" : "error" + ); } setSelectedIds(new Set()); setBatchDeleteOpen(false); setBatchDeleteSource(false); + onStatsChanged(); if (success >= listItems.length && page > 1) { setPage((p) => Math.max(1, p - 1)); } else { @@ -191,7 +285,7 @@ export function VideosPage() { if (selectedIds.size === listItems.length && listItems.length > 0) { setSelectedIds(new Set()); } else { - setSelectedIds(new Set(listItems.map(v => v.id))); + setSelectedIds(new Set(listItems.map((v) => v.id))); } }; @@ -209,52 +303,21 @@ export function VideosPage() { } return ( -
-
-

视频管理

-
-
- -
-
- - setKeyword(e.target.value)} - placeholder="搜索标题 / 作者" - /> - - -
-
+ <> +
+ { setDriveId(id); setPage(1); }} withCounts /> + + +
{!loading && (
{listSummary}
{selectedIds.size > 0 && (
- - 已选择 {selectedIds.size} 项 - + 已选择 {selectedIds.size} 项 @@ -267,18 +330,9 @@ export function VideosPage() { )} {loading ? ( -
- - 加载中... -
+ ) : loadError ? ( -
- 视频加载失败 - {loadError} - -
+ ) : listItems.length === 0 ? (
@@ -295,14 +349,22 @@ export function VideosPage() { - @@ -323,35 +385,15 @@ export function VideosPage() { onClick={() => toggleSelect(v.id)} aria-label={`${selectedIds.has(v.id) ? "取消选择" : "选择"}视频 ${v.title}`} > - {selectedIds.has(v.id) ? : } + {selectedIds.has(v.id) ? ( + + ) : ( + + )} @@ -384,43 +426,7 @@ export function VideosPage() { ))}
+ 标题 -
- -
-
{v.title}
- {fileMeta(v) && ( -
{fileMeta(v)}
- )} - {(v.tags ?? []).length > 0 && ( -
- {(v.tags ?? []).map((t) => ( - {t} - ))} -
- )} - -
-
+
{v.author || } {formatDur(v.durationSeconds)}
-
- - - - 第 {page} / {totalPages} 页,每页 {pageSize} 个 - - - -
+ )} @@ -463,18 +469,7 @@ export function VideosPage() { }} onConfirm={confirmDeleteVideo} > - + - + -
+ + ); +} + +// ---------- 拉黑视频 ---------- + +function BlacklistTab({ onStatsChanged }: { onStatsChanged: () => void }) { + const [list, setList] = useState([]); + const [drives, setDrives] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(""); + const [keyword, setKeyword] = useState(""); + const [searchKeyword, setSearchKeyword] = useState(""); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [removeTarget, setRemoveTarget] = useState(null); + const [removing, setRemoving] = useState(false); + const pageSize = useVideosPageSize(); + const { show } = useToast(); + + async function refresh() { + setLoading(true); + setLoadError(""); + try { + const [r, driveList] = await Promise.all([ + api.listBlacklist({ page, size: pageSize, keyword: searchKeyword }), + api.listDrives(), + ]); + setList(r.items ?? []); + setTotal(r.total ?? 0); + setDrives(driveList ?? []); + } catch (e) { + const message = e instanceof Error ? e.message : "加载失败"; + setLoadError(message); + show(message, "error"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + refresh(); + }, [page, searchKeyword, pageSize]); + + useEffect(() => { + setPage(1); + }, [pageSize]); + + useEffect(() => { + if (keyword === searchKeyword) return; + const timer = window.setTimeout(() => { + setSearchKeyword(keyword); + setPage(1); + }, 300); + return () => window.clearTimeout(timer); + }, [keyword]); + + const driveNameMap = new Map(drives.map((d) => [d.id, d.name || d.id])); + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + async function confirmRemove() { + if (!removeTarget) return; + const target = removeTarget; + setRemoving(true); + try { + await api.removeBlacklist(target.id); + setRemoveTarget(null); + show("已移出黑名单,下次扫盘会重新入库", "success"); + onStatsChanged(); + if (list.length === 1 && page > 1) { + setPage((p) => Math.max(1, p - 1)); + } else { + refresh(); + } + } catch (e) { + show(e instanceof Error ? e.message : "操作失败", "error"); + } finally { + setRemoving(false); + } + } + + function handleSearchSubmit(e: React.FormEvent) { + e.preventDefault(); + setSearchKeyword(keyword); + setPage(1); + } + + return ( + <> +
+ 被删除和被隐藏的视频会进入黑名单,扫盘时不再重新入库。这里只保留文件名等基本信息(原始记录、封面、预览已删除)。移出黑名单后,视频会在下次扫盘时被重新发现并入库 +
+
+ + +
+ + {loading ? ( + + ) : loadError ? ( + + ) : list.length === 0 ? ( +
+
+ +
+
黑名单为空。
+
+ ) : ( + <> +
+
共 {total} 个拉黑视频
+
+ + + + + + + + + + + + {list.map((v) => ( + + + + + + + + ))} + +
文件名来源大小拉黑时间操作
+ {v.fileName || (无文件名)} + + {driveNameMap.get(v.driveId) ?? v.driveId} + {v.size > 0 ? formatBytes(v.size) : }{formatDateTime(v.deletedAt)} + +
+ + + )} + + { + if (!removing) setRemoveTarget(null); + }} + onConfirm={confirmRemove} + /> + + ); +} + +// ---------- 共享小组件 ---------- + +function DriveFilter({ + drives, + driveId, + onChange, + withCounts = false, +}: { + drives: api.AdminDrive[]; + driveId: string; + onChange: (id: string) => void; + withCounts?: boolean; +}) { + return ( +
+ +
+ ); +} + +function SearchBox({ + keyword, + onChange, + onSubmit, + placeholder = "搜索标题 / 作者", +}: { + keyword: string; + onChange: (v: string) => void; + onSubmit: (e: React.FormEvent) => void; + placeholder?: string; +}) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + /> + + ); +} + +function Pagination({ + page, + totalPages, + pageSize, + onPage, +}: { + page: number; + totalPages: number; + pageSize: number; + onPage: React.Dispatch>; +}) { + return ( +
+ + + + 第 {page} / {totalPages} 页,每页 {pageSize} 个 + + + +
+ ); +} + +function LoadingState() { + return ( +
+ + 加载中... +
+ ); +} + +function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( +
+ 加载失败 + {message} + +
+ ); +} + +function DeleteSourceOption({ + checked, + disabled, + onChange, + note, +}: { + checked: boolean; + disabled: boolean; + onChange: (v: boolean) => void; + note: string; +}) { + return ( + + ); +} + +function VideoTitleCell({ video: v }: { video: api.AdminVideo }) { + return ( +
+ +
+
{v.title}
+ {fileMeta(v) &&
{fileMeta(v)}
} + {(v.tags ?? []).length > 0 && ( +
+ {(v.tags ?? []).map((t) => ( + + {t} + + ))} +
+ )} + +
+
); } @@ -529,11 +850,7 @@ function VideoFileMetaPills({ video }: { video: api.AdminVideo }) { {part} ))} - {category && ( - - {category} - - )} + {category && {category}} ); } @@ -545,11 +862,17 @@ function formatDur(sec: number): string { return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; } +function formatDateTime(ms: number): string { + if (!ms) return "—"; + const d = new Date(ms); + if (Number.isNaN(d.getTime())) return "—"; + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + function useVideosPageSize() { const [pageSize, setPageSize] = useState(() => - window.matchMedia(VIDEOS_MOBILE_QUERY).matches - ? MOBILE_VIDEOS_PAGE_SIZE - : DESKTOP_VIDEOS_PAGE_SIZE + window.matchMedia(VIDEOS_MOBILE_QUERY).matches ? MOBILE_VIDEOS_PAGE_SIZE : DESKTOP_VIDEOS_PAGE_SIZE ); useEffect(() => { @@ -683,12 +1006,12 @@ function EditVideoModal({
setThumbnail(e.target.value)} /> {thumbnail && ( - 封面预览 (e.currentTarget.style.display = 'none')} - onLoad={(e) => (e.currentTarget.style.display = 'block')} + 封面预览 (e.currentTarget.style.display = "none")} + onLoad={(e) => (e.currentTarget.style.display = "block")} /> )}
@@ -730,11 +1053,7 @@ function fileMeta(v: api.AdminVideo): string { } function fileMetaParts(v: api.AdminVideo): string[] { - return [ - normalizeExt(v.ext), - v.quality, - v.size > 0 ? formatBytes(v.size) : "", - ].filter(Boolean); + return [normalizeExt(v.ext), v.quality, v.size > 0 ? formatBytes(v.size) : ""].filter(Boolean); } function normalizeExt(ext: string): string { @@ -750,7 +1069,5 @@ function splitList(s: string): string[] { } function toggleTag(tags: string[], label: string): string[] { - return tags.includes(label) - ? tags.filter((tag) => tag !== label) - : [...tags, label]; + return tags.includes(label) ? tags.filter((tag) => tag !== label) : [...tags, label]; } diff --git a/src/admin/api.ts b/src/admin/api.ts index 3556a61..e942b4f 100644 --- a/src/admin/api.ts +++ b/src/admin/api.ts @@ -510,7 +510,9 @@ export type AdminVideoList = { size: number; }; -export function listVideos(params: { driveId?: string; page?: number; size?: number; keyword?: string } = {}) { +export function listVideos( + params: { driveId?: string; page?: number; size?: number; keyword?: string } = {} +) { const qs = new URLSearchParams(); if (params.driveId) qs.set("driveId", params.driveId); if (params.page) qs.set("page", String(params.page)); @@ -520,6 +522,50 @@ export function listVideos(params: { driveId?: string; page?: number; size?: num return request(`/videos${suffix}`); } +// 后台视频管理两个标签页的计数。 +export type VideoStats = { + current: number; + blacklisted: number; +}; + +export function getVideoStats() { + return request("/videos/stats"); +} + +// 黑名单(被拉黑/手动删除、扫盘不再入库的视频)。原始记录已删除, +// 只剩文件名/来源盘/大小/拉黑时间。 +export type AdminDeletedVideo = { + id: string; + driveId: string; + fileId: string; + fileName: string; + size: number; + deletedAt: number; +}; + +export type AdminBlacklistList = { + items: AdminDeletedVideo[]; + total: number; + page: number; + size: number; +}; + +export function listBlacklist(params: { page?: number; size?: number; keyword?: string } = {}) { + const qs = new URLSearchParams(); + if (params.page) qs.set("page", String(params.page)); + if (params.size) qs.set("size", String(params.size)); + if (params.keyword) qs.set("keyword", params.keyword); + const suffix = qs.toString() ? `?${qs.toString()}` : ""; + return request(`/blacklist${suffix}`); +} + +// 把视频移出黑名单(删除墓碑),下次扫盘会重新入库。 +export function removeBlacklist(id: string) { + return request<{ ok: boolean }>(`/blacklist/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} + export type UpdateVideoInput = Partial<{ title: string; author: string; diff --git a/src/styles/admin.css b/src/styles/admin.css index bf61cc8..deef311 100644 --- a/src/styles/admin.css +++ b/src/styles/admin.css @@ -1819,6 +1819,10 @@ letter-spacing: 0.06em; } +.admin-table th.is-actions { + text-align: center; +} + .admin-table tr:last-child td { border-bottom: 0; } @@ -1830,7 +1834,7 @@ } .admin-table td.is-actions { - text-align: right; + text-align: center; white-space: nowrap; } @@ -3334,12 +3338,104 @@ box-shadow: 0 0 0 3px var(--accent-soft); } +/* 视频管理:当前 / 隐藏 / 拉黑 分段标签 */ +.admin-video-tabs { + display: flex; + gap: var(--space-1); + padding: 4px; + margin-bottom: var(--space-4); + background: var(--surface-2, rgba(127, 127, 127, 0.08)); + border: 1px solid var(--border-default); + border-radius: 10px; + width: fit-content; + max-width: 100%; + overflow-x: auto; +} + +.admin-video-tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 16px; + border: none; + background: transparent; + color: var(--text-faint); + font-size: var(--font-sm); + font-weight: 500; + border-radius: 7px; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s, color 0.15s; +} + +.admin-video-tab:hover { + color: var(--text-default); +} + +.admin-video-tab.is-active { + background: var(--accent); + color: #fff; +} + +.admin-video-tab__count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 18px; + padding: 0 6px; + font-size: 11px; + font-weight: 600; + line-height: 1; + border-radius: 9px; + background: var(--border-default); + color: var(--text-default); +} + +.admin-video-tab.is-active .admin-video-tab__count { + background: rgba(255, 255, 255, 0.25); + color: #fff; +} + +/* 标签页顶部说明文字 */ +.admin-tab-intro { + font-size: var(--font-xs); + color: var(--text-faint); + line-height: 1.6; + margin-bottom: var(--space-3); + padding: 10px 12px; + background: var(--surface-2, rgba(127, 127, 127, 0.06)); + border: 1px solid var(--border-default); + border-radius: 8px; +} + +.admin-blacklist-filename { + display: block; + min-width: 0; + word-break: break-all; + overflow-wrap: anywhere; +} + +.admin-blacklist-restore-btn { + border-color: var(--border-accent); + background: var(--accent-softer); + color: var(--accent); + box-shadow: none; +} + +.admin-blacklist-restore-btn:hover:not(:disabled) { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--accent-strong); + box-shadow: none; +} + .admin-videos-list-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); - margin: -8px 0 var(--space-4); + margin: var(--space-2) 0 var(--space-4); } .admin-videos-summary { @@ -3352,6 +3448,7 @@ display: inline-flex; align-items: center; gap: var(--space-2); + min-width: 0; flex: none; } @@ -3401,9 +3498,11 @@ } .admin-table-pagination__info { + min-width: 0; font-size: var(--font-xs); color: var(--text-faint); margin: 0 var(--space-2); + text-align: center; } .admin-form__divider { @@ -3433,15 +3532,64 @@ justify-content: center; } + .admin-table-pagination__info { + order: -1; + flex: 1 0 100%; + margin: 0 0 2px; + } + .admin-videos-list-toolbar { align-items: stretch; flex-direction: column; } .admin-videos-bulk-actions { - justify-content: space-between; + flex-wrap: wrap; + justify-content: flex-start; width: 100%; } + + .admin-videos-bulk-actions__count { + flex: 1 0 100%; + } + + .admin-videos-bulk-actions__btn { + flex: 1 1 136px; + justify-content: center; + min-width: 0; + } + + .admin-blacklist-table:not(.admin-drives-table) td[data-label="文件名"] { + grid-column: 1 / -1; + } + + .admin-blacklist-table:not(.admin-drives-table) td[data-label="拉黑时间"] { + grid-column: 1; + align-content: center; + } + + .admin-blacklist-table:not(.admin-drives-table) td.is-actions { + grid-column: 2; + align-content: center; + align-items: center; + justify-content: flex-end; + text-align: right; + } + + .admin-blacklist-table:not(.admin-drives-table) td.is-actions::before { + content: none; + display: none; + } + + .admin-blacklist-table:not(.admin-drives-table) td.is-actions .admin-btn { + justify-content: center; + flex: 0 1 auto; + min-width: 0; + max-width: 100%; + min-height: 32px; + padding: 6px 10px; + white-space: normal; + } } /* ========================================================= diff --git a/tests/adminResponsive.test.ts b/tests/adminResponsive.test.ts index 6468654..62653b2 100644 --- a/tests/adminResponsive.test.ts +++ b/tests/adminResponsive.test.ts @@ -100,6 +100,69 @@ test("admin video bulk actions use semantic theme colors", () => { assert.doesNotMatch(bulkBodies, /#ff5b8a|#fff6f9|rgba\(255,\s*91,\s*138/); }); +test("admin video list summary stays below filter controls", () => { + const toolbar = ruleBody(adminCss, ".admin-videos-list-toolbar"); + + assert.match(toolbar, /margin\s*:\s*var\(--space-2\)\s+0\s+var\(--space-4\)/); + assert.doesNotMatch(toolbar, /margin\s*:\s*-/); +}); + +test("admin table action headers center-align with action buttons", () => { + const actionHeader = ruleBody(adminCss, ".admin-table th.is-actions"); + const actionCell = ruleBody(adminCss, ".admin-table td.is-actions"); + + assert.match(actionHeader, /text-align\s*:\s*center/); + assert.match(actionCell, /text-align\s*:\s*center/); +}); + +test("blacklist restore action uses a light button style", () => { + const restoreButton = ruleBody(adminCss, ".admin-blacklist-restore-btn"); + + assert.match(videosPageSource, /className="admin-btn admin-blacklist-restore-btn"/); + assert.match(restoreButton, /background\s*:\s*var\(--accent-softer\)/); + assert.match(restoreButton, /color\s*:\s*var\(--accent\)/); + assert.doesNotMatch(restoreButton, /background\s*:\s*var\(--accent\)/); +}); + +test("admin video management controls wrap instead of covering text on mobile", () => { + const css = mobileCss(); + const paginationInfo = allRuleBodies(css, ".admin-table-pagination__info"); + const bulkActions = allRuleBodies(css, ".admin-videos-bulk-actions"); + const bulkCount = allRuleBodies(css, ".admin-videos-bulk-actions__count"); + const bulkButton = allRuleBodies(css, ".admin-videos-bulk-actions__btn"); + const blacklistName = ruleBody( + css, + '.admin-blacklist-table:not(.admin-drives-table) td[data-label="文件名"]' + ); + const blacklistTime = ruleBody( + css, + '.admin-blacklist-table:not(.admin-drives-table) td[data-label="拉黑时间"]' + ); + const blacklistActions = ruleBody( + css, + ".admin-blacklist-table:not(.admin-drives-table) td.is-actions" + ); + const blacklistActionsLabel = ruleBody( + css, + ".admin-blacklist-table:not(.admin-drives-table) td.is-actions::before" + ); + const blacklistActionButton = ruleBody( + css, + ".admin-blacklist-table:not(.admin-drives-table) td.is-actions .admin-btn" + ); + + assert.match(paginationInfo, /flex\s*:\s*1\s+0\s+100%/); + assert.match(bulkActions, /flex-wrap\s*:\s*wrap/); + assert.match(bulkCount, /flex\s*:\s*1\s+0\s+100%/); + assert.match(bulkButton, /min-width\s*:\s*0/); + assert.match(blacklistName, /grid-column\s*:\s*1\s*\/\s*-1/); + assert.match(blacklistTime, /grid-column\s*:\s*1/); + assert.match(blacklistActions, /grid-column\s*:\s*2/); + assert.match(blacklistActions, /justify-content\s*:\s*flex-end/); + assert.match(blacklistActionsLabel, /content\s*:\s*none/); + assert.match(blacklistActionButton, /white-space\s*:\s*normal/); +}); + test("admin loading spinner rotates around icon center", () => { const spinner = ruleBody(adminCss, ".admin-spin"); const reducedMotion = ruleBodyByContains(adminCss, ".admin-sidebar__check-update:disabled svg");