feat: add video blacklist management

Add backend blacklist tombstone APIs and hidden-video migration support.

Update the admin video management UI with blacklist tabs, restore actions, alignment fixes, responsive layout polish, and regression coverage.
This commit is contained in:
nianzhibai
2026-06-13 14:34:00 +08:00
parent 0f111b846d
commit 738406162a
10 changed files with 1101 additions and 179 deletions
+7
View File
@@ -37,6 +37,13 @@ __pycache__/
*.pyc
# Local scratch images
/*.png
/*.jpg
/*.jpeg
/*.gif
/*.webp
/*.bmp
/*.ico
/image.jpg
/image003.jpg
/image004.jpg
+35
View File
@@ -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
+55
View File
@@ -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 {
+12 -1
View File
@@ -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
+107
View File
@@ -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,保留视频自身的 idspider91-<driveID>-<sourceID>),这样
@@ -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(&current); 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 == "" {
@@ -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)
}
}
+478 -161
View File
@@ -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<api.VideoStats | null>(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<TabKey, number | undefined> = {
current: stats?.current,
blacklist: stats?.blacklisted,
};
return (
<section>
<header className="admin-page__header">
<h1 className="admin-page__title"></h1>
</header>
<div className="admin-video-tabs" role="tablist" aria-label="视频管理分类">
{TABS.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={activeTab === t.key}
className={`admin-video-tab ${activeTab === t.key ? "is-active" : ""}`}
onClick={() => selectTab(t.key)}
>
<span>{t.label}</span>
{counts[t.key] !== undefined && (
<span className="admin-video-tab__count">{counts[t.key]}</span>
)}
</button>
))}
</div>
{activeTab === "current" && <CurrentVideosTab onStatsChanged={refreshStats} />}
{activeTab === "blacklist" && <BlacklistTab onStatsChanged={refreshStats} />}
</section>
);
}
// ---------- 当前视频 ----------
function CurrentVideosTab({ onStatsChanged }: { onStatsChanged: () => void }) {
const [list, setList] = useState<api.AdminVideo[]>([]);
const [drives, setDrives] = useState<api.AdminDrive[]>([]);
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 (
<section>
<header className="admin-page__header">
<h1 className="admin-page__title"></h1>
<>
<div className="admin-page__actions admin-videos-filter">
<div className="admin-videos-filter__select-wrap">
<select
className="admin-videos-filter__select"
value={driveId}
onChange={(e) => {
setDriveId(e.target.value);
setPage(1);
}}
>
<option value=""></option>
{drives.map((d) => (
<option key={d.id} value={d.id}>
{d.name || d.id} {d.teaserReadyCount ?? 0}{" "}
{d.teaserPendingCount ?? 0}
</option>
))}
</select>
<ChevronDown size={15} className="admin-videos-filter__select-icon" aria-hidden="true" />
</div>
<form className="admin-videos-filter__search" onSubmit={handleSearchSubmit}>
<Search size={14} className="admin-videos-filter__search-icon" />
<input
aria-label="搜索标题或作者"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索标题 / 作者"
/>
</form>
<DriveFilter drives={drives} driveId={driveId} onChange={(id) => { setDriveId(id); setPage(1); }} withCounts />
<SearchBox keyword={keyword} onChange={setKeyword} onSubmit={handleSearchSubmit} />
<button type="button" className="admin-btn" onClick={refresh}>
<RefreshCw size={13} />
</button>
</div>
</header>
{!loading && (
<div className="admin-videos-list-toolbar">
<div className="admin-videos-summary">{listSummary}</div>
{selectedIds.size > 0 && (
<div className="admin-videos-bulk-actions">
<span className="admin-videos-bulk-actions__count">
{selectedIds.size}
</span>
<span className="admin-videos-bulk-actions__count"> {selectedIds.size} </span>
<button type="button" className="admin-btn is-primary admin-videos-bulk-actions__btn" onClick={handleBatchRegen}>
<RefreshCw size={13} />
</button>
@@ -267,18 +330,9 @@ export function VideosPage() {
)}
{loading ? (
<div className="admin-loading-state">
<RefreshCw size={20} className="admin-spin" />
<span>...</span>
</div>
<LoadingState />
) : loadError ? (
<div className="admin-error-state">
<strong></strong>
<span>{loadError}</span>
<button type="button" className="admin-btn" onClick={refresh}>
<RefreshCw size={13} />
</button>
</div>
<ErrorState message={loadError} onRetry={refresh} />
) : listItems.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-state__icon">
@@ -295,14 +349,22 @@ export function VideosPage() {
<table className="admin-table is-selectable admin-videos-table">
<thead>
<tr>
<th className="is-checkbox" style={{ width: '40px' }}>
<th className="is-checkbox" style={{ width: "40px" }}>
<button
type="button"
className="admin-table-checkbox-btn"
onClick={toggleSelectAll}
aria-label={selectedIds.size > 0 && selectedIds.size === listItems.length ? "清空当前页选择" : "选择当前页视频"}
aria-label={
selectedIds.size > 0 && selectedIds.size === listItems.length
? "清空当前页选择"
: "选择当前页视频"
}
>
{selectedIds.size > 0 && selectedIds.size === listItems.length ? <CheckSquare size={16} /> : <Square size={16} />}
{selectedIds.size > 0 && selectedIds.size === listItems.length ? (
<CheckSquare size={16} />
) : (
<Square size={16} />
)}
</button>
</th>
<th></th>
@@ -323,35 +385,15 @@ export function VideosPage() {
onClick={() => toggleSelect(v.id)}
aria-label={`${selectedIds.has(v.id) ? "取消选择" : "选择"}视频 ${v.title}`}
>
{selectedIds.has(v.id) ? <CheckSquare size={16} color="var(--accent)" /> : <Square size={16} color="var(--border-strong)" />}
{selectedIds.has(v.id) ? (
<CheckSquare size={16} color="var(--accent)" />
) : (
<Square size={16} color="var(--border-strong)" />
)}
</button>
</td>
<td data-label="标题">
<div className="admin-video-title-cell">
<div className="admin-video-thumb-wrap" aria-hidden="true">
{v.thumbnailUrl ? (
<img className="admin-video-thumb" src={v.thumbnailUrl} alt="" />
) : (
<div className="admin-video-thumb-placeholder">
<Image size={14} />
</div>
)}
</div>
<div className="admin-video-title-body">
<div className="admin-video-title">{v.title}</div>
{fileMeta(v) && (
<div className="admin-video-filemeta">{fileMeta(v)}</div>
)}
{(v.tags ?? []).length > 0 && (
<div className="admin-pills admin-video-title-tags">
{(v.tags ?? []).map((t) => (
<span key={t} className="admin-pill">{t}</span>
))}
</div>
)}
<VideoFileMetaPills video={v} />
</div>
</div>
<VideoTitleCell video={v} />
</td>
<td data-label="作者">{v.author || <span className="admin-text-faint"></span>}</td>
<td data-label="时长">{formatDur(v.durationSeconds)}</td>
@@ -384,43 +426,7 @@ export function VideosPage() {
))}
</tbody>
</table>
<div className="admin-table-pagination">
<button
type="button"
className="admin-btn"
onClick={() => setPage(1)}
disabled={page <= 1}
>
</button>
<button
type="button"
className="admin-btn"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
</button>
<span className="admin-table-pagination__info">
{page} / {totalPages} {pageSize}
</span>
<button
type="button"
className="admin-btn"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
</button>
<button
type="button"
className="admin-btn"
onClick={() => setPage(totalPages)}
disabled={page >= totalPages}
>
</button>
</div>
<Pagination page={page} totalPages={totalPages} pageSize={pageSize} onPage={setPage} />
</>
)}
@@ -463,18 +469,7 @@ export function VideosPage() {
}}
onConfirm={confirmDeleteVideo}
>
<label className="admin-delete-source-option">
<input
type="checkbox"
checked={deleteSource}
disabled={deleting}
onChange={(e) => setDeleteSource(e.target.checked)}
/>
<span>
<strong></strong>
<small></small>
</span>
</label>
<DeleteSourceOption checked={deleteSource} disabled={deleting} onChange={setDeleteSource} note="开启后会先删除源文件,失败则不会删除管理库记录。" />
</ConfirmModal>
<ConfirmModal
open={batchDeleteOpen}
@@ -493,20 +488,346 @@ export function VideosPage() {
}}
onConfirm={confirmBatchDelete}
>
<label className="admin-delete-source-option">
<input
type="checkbox"
checked={batchDeleteSource}
disabled={batchDeleting}
onChange={(e) => setBatchDeleteSource(e.target.checked)}
<DeleteSourceOption checked={batchDeleteSource} disabled={batchDeleting} onChange={setBatchDeleteSource} note="开启后会先删除源文件,失败的视频会保留管理库记录。" />
</ConfirmModal>
</>
);
}
// ---------- 拉黑视频 ----------
function BlacklistTab({ onStatsChanged }: { onStatsChanged: () => void }) {
const [list, setList] = useState<api.AdminDeletedVideo[]>([]);
const [drives, setDrives] = useState<api.AdminDrive[]>([]);
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<api.AdminDeletedVideo | null>(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 (
<>
<div className="admin-tab-intro">
</div>
<div className="admin-page__actions admin-videos-filter">
<SearchBox keyword={keyword} onChange={setKeyword} onSubmit={handleSearchSubmit} placeholder="搜索文件名" />
<button type="button" className="admin-btn" onClick={refresh}>
<RefreshCw size={13} />
</button>
</div>
{loading ? (
<LoadingState />
) : loadError ? (
<ErrorState message={loadError} onRetry={refresh} />
) : list.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-state__icon">
<Ban size={48} />
</div>
<div className="admin-empty-state__text"></div>
</div>
) : (
<>
<div className="admin-videos-list-toolbar">
<div className="admin-videos-summary"> {total} </div>
</div>
<table className="admin-table admin-blacklist-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th className="is-actions"></th>
</tr>
</thead>
<tbody>
{list.map((v) => (
<tr key={v.id}>
<td data-label="文件名">
<span className="admin-blacklist-filename">{v.fileName || <span className="admin-text-faint"></span>}</span>
</td>
<td data-label="来源" className="admin-mono-cell">
{driveNameMap.get(v.driveId) ?? v.driveId}
</td>
<td data-label="大小">{v.size > 0 ? formatBytes(v.size) : <span className="admin-text-faint"></span>}</td>
<td data-label="拉黑时间">{formatDateTime(v.deletedAt)}</td>
<td className="is-actions" data-label="操作">
<button
type="button"
className="admin-btn admin-blacklist-restore-btn"
onClick={() => setRemoveTarget(v)}
title="移出黑名单"
>
<RotateCcw size={13} />
</button>
</td>
</tr>
))}
</tbody>
</table>
<Pagination page={page} totalPages={totalPages} pageSize={pageSize} onPage={setPage} />
</>
)}
<ConfirmModal
open={removeTarget !== null}
title="移出黑名单"
message={
removeTarget
? `确定把「${removeTarget.fileName || removeTarget.id}」移出黑名单吗?移出后它会在下次扫盘时被重新发现并入库。`
: ""
}
confirmText="移出黑名单"
centerMessage
loading={removing}
onCancel={() => {
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 (
<div className="admin-videos-filter__select-wrap">
<select
className="admin-videos-filter__select"
value={driveId}
onChange={(e) => onChange(e.target.value)}
>
<option value=""></option>
{drives.map((d) => (
<option key={d.id} value={d.id}>
{d.name || d.id}
{withCounts ? `(已生成 ${d.teaserReadyCount ?? 0},待生成 ${d.teaserPendingCount ?? 0}` : ""}
</option>
))}
</select>
<ChevronDown size={15} className="admin-videos-filter__select-icon" aria-hidden="true" />
</div>
);
}
function SearchBox({
keyword,
onChange,
onSubmit,
placeholder = "搜索标题 / 作者",
}: {
keyword: string;
onChange: (v: string) => void;
onSubmit: (e: React.FormEvent) => void;
placeholder?: string;
}) {
return (
<form className="admin-videos-filter__search" onSubmit={onSubmit}>
<Search size={14} className="admin-videos-filter__search-icon" />
<input
aria-label={placeholder}
value={keyword}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
/>
</form>
);
}
function Pagination({
page,
totalPages,
pageSize,
onPage,
}: {
page: number;
totalPages: number;
pageSize: number;
onPage: React.Dispatch<React.SetStateAction<number>>;
}) {
return (
<div className="admin-table-pagination">
<button type="button" className="admin-btn" onClick={() => onPage(() => 1)} disabled={page <= 1}>
</button>
<button type="button" className="admin-btn" onClick={() => onPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>
</button>
<span className="admin-table-pagination__info">
{page} / {totalPages} {pageSize}
</span>
<button
type="button"
className="admin-btn"
onClick={() => onPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
</button>
<button type="button" className="admin-btn" onClick={() => onPage(() => totalPages)} disabled={page >= totalPages}>
</button>
</div>
);
}
function LoadingState() {
return (
<div className="admin-loading-state">
<RefreshCw size={20} className="admin-spin" />
<span>...</span>
</div>
);
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="admin-error-state">
<strong></strong>
<span>{message}</span>
<button type="button" className="admin-btn" onClick={onRetry}>
<RefreshCw size={13} />
</button>
</div>
);
}
function DeleteSourceOption({
checked,
disabled,
onChange,
note,
}: {
checked: boolean;
disabled: boolean;
onChange: (v: boolean) => void;
note: string;
}) {
return (
<label className="admin-delete-source-option">
<input type="checkbox" checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} />
<span>
<strong></strong>
<small></small>
<small>{note}</small>
</span>
</label>
</ConfirmModal>
</section>
);
}
function VideoTitleCell({ video: v }: { video: api.AdminVideo }) {
return (
<div className="admin-video-title-cell">
<div className="admin-video-thumb-wrap" aria-hidden="true">
{v.thumbnailUrl ? (
<img className="admin-video-thumb" src={v.thumbnailUrl} alt="" />
) : (
<div className="admin-video-thumb-placeholder">
<Image size={14} />
</div>
)}
</div>
<div className="admin-video-title-body">
<div className="admin-video-title">{v.title}</div>
{fileMeta(v) && <div className="admin-video-filemeta">{fileMeta(v)}</div>}
{(v.tags ?? []).length > 0 && (
<div className="admin-pills admin-video-title-tags">
{(v.tags ?? []).map((t) => (
<span key={t} className="admin-pill">
{t}
</span>
))}
</div>
)}
<VideoFileMetaPills video={v} />
</div>
</div>
);
}
@@ -529,11 +850,7 @@ function VideoFileMetaPills({ video }: { video: api.AdminVideo }) {
{part}
</span>
))}
{category && (
<span className="admin-video-filemeta-pill is-category">
{category}
</span>
)}
{category && <span className="admin-video-filemeta-pill is-category">{category}</span>}
</div>
);
}
@@ -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(() => {
@@ -687,8 +1010,8 @@ function EditVideoModal({
src={thumbnail}
alt="封面预览"
className="admin-thumbnail-img"
onError={(e) => (e.currentTarget.style.display = 'none')}
onLoad={(e) => (e.currentTarget.style.display = 'block')}
onError={(e) => (e.currentTarget.style.display = "none")}
onLoad={(e) => (e.currentTarget.style.display = "block")}
/>
)}
</div>
@@ -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];
}
+47 -1
View File
@@ -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<AdminVideoList>(`/videos${suffix}`);
}
// 后台视频管理两个标签页的计数。
export type VideoStats = {
current: number;
blacklisted: number;
};
export function getVideoStats() {
return request<VideoStats>("/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<AdminBlacklistList>(`/blacklist${suffix}`);
}
// 把视频移出黑名单(删除墓碑),下次扫盘会重新入库。
export function removeBlacklist(id: string) {
return request<{ ok: boolean }>(`/blacklist/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
export type UpdateVideoInput = Partial<{
title: string;
author: string;
+151 -3
View File
@@ -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;
}
}
/* =========================================================
+63
View File
@@ -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");