mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 00:44:30 +08:00
feat: add admin video deletion and mobile UI polish
Adds tombstone-backed video deletion with generated asset cleanup, plus responsive video management actions and centered confirmation dialogs.
This commit is contained in:
@@ -202,6 +202,9 @@ func main() {
|
||||
OnRegenFailedFingerprints: func(driveID string) {
|
||||
go app.regenFailedFingerprints(ctx, driveID)
|
||||
},
|
||||
OnDeleteVideo: func(reqCtx context.Context, videoID string) (api.DeleteVideoResult, error) {
|
||||
return app.deleteVideo(reqCtx, videoID)
|
||||
},
|
||||
GetDriveGenerationStatuses: func() map[string]api.DriveGenerationStatuses {
|
||||
return app.driveGenerationStatuses()
|
||||
},
|
||||
@@ -1499,6 +1502,148 @@ func (a *App) cleanupMissingDriveVideos(ctx context.Context, driveID string, liv
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
func (a *App) deleteVideo(ctx context.Context, videoID string) (api.DeleteVideoResult, error) {
|
||||
if a == nil || a.cat == nil {
|
||||
return api.DeleteVideoResult{}, sql.ErrNoRows
|
||||
}
|
||||
v, err := a.cat.GetVideo(ctx, videoID)
|
||||
if err != nil {
|
||||
return api.DeleteVideoResult{}, err
|
||||
}
|
||||
|
||||
localDir := ""
|
||||
if a.cfg != nil {
|
||||
localDir = a.cfg.Storage.LocalPreviewDir
|
||||
}
|
||||
if err := removeLocalVideoAssets(localDir, v); err != nil {
|
||||
return api.DeleteVideoResult{}, fmt.Errorf("remove local assets for %s: %w", v.ID, err)
|
||||
}
|
||||
deletedSource, err := a.removeSpider91SourceFile(ctx, v)
|
||||
if err != nil {
|
||||
return api.DeleteVideoResult{}, err
|
||||
}
|
||||
if err := a.cat.DeleteVideoWithTombstone(ctx, v.ID); err != nil {
|
||||
return api.DeleteVideoResult{}, err
|
||||
}
|
||||
return api.DeleteVideoResult{OK: true, DeletedSource: deletedSource}, nil
|
||||
}
|
||||
|
||||
func (a *App) removeSpider91SourceFile(ctx context.Context, v *catalog.Video) (bool, error) {
|
||||
if a == nil || a.cfg == nil || v == nil || !strings.HasPrefix(v.ID, "spider91-") {
|
||||
return false, nil
|
||||
}
|
||||
driveID, sourceID := a.spider91OriginFromVideo(ctx, v)
|
||||
if driveID == "" || sourceID == "" {
|
||||
return false, nil
|
||||
}
|
||||
src := spider91.New(spider91.Config{
|
||||
ID: driveID,
|
||||
RootDir: a.spider91DriveDir(driveID),
|
||||
})
|
||||
deleted := false
|
||||
for _, fileID := range spider91SourceFileCandidates(v, driveID, sourceID) {
|
||||
videoPath, err := src.VideoPath(fileID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(videoPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return deleted, fmt.Errorf("stat spider91 source %s: %w", videoPath, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(videoPath); err != nil && !os.IsNotExist(err) {
|
||||
return deleted, fmt.Errorf("remove spider91 source %s: %w", videoPath, err)
|
||||
}
|
||||
deleted = true
|
||||
removeSpider91ThumbCandidates(src, strings.TrimSuffix(fileID, filepath.Ext(fileID)))
|
||||
}
|
||||
if !deleted {
|
||||
removeSpider91ThumbCandidates(src, sourceID)
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (a *App) spider91OriginFromVideo(ctx context.Context, v *catalog.Video) (string, string) {
|
||||
if a == nil || v == nil {
|
||||
return "", ""
|
||||
}
|
||||
if d, err := a.cat.GetDrive(ctx, v.DriveID); err == nil && d != nil && d.Kind == spider91.Kind {
|
||||
prefix := "spider91-" + d.ID + "-"
|
||||
if strings.HasPrefix(v.ID, prefix) {
|
||||
return d.ID, strings.TrimPrefix(v.ID, prefix)
|
||||
}
|
||||
}
|
||||
drives, err := a.cat.ListDrives(ctx)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
bestDriveID := ""
|
||||
bestSourceID := ""
|
||||
for _, d := range drives {
|
||||
if d == nil || d.Kind != spider91.Kind {
|
||||
continue
|
||||
}
|
||||
prefix := "spider91-" + d.ID + "-"
|
||||
if !strings.HasPrefix(v.ID, prefix) {
|
||||
continue
|
||||
}
|
||||
if len(d.ID) > len(bestDriveID) {
|
||||
bestDriveID = d.ID
|
||||
bestSourceID = strings.TrimPrefix(v.ID, prefix)
|
||||
}
|
||||
}
|
||||
return bestDriveID, bestSourceID
|
||||
}
|
||||
|
||||
func spider91SourceFileCandidates(v *catalog.Video, originDriveID, sourceID string) []string {
|
||||
candidates := []string{}
|
||||
if v != nil && v.DriveID == originDriveID && strings.TrimSpace(v.FileID) != "" {
|
||||
candidates = append(candidates, strings.TrimSpace(v.FileID))
|
||||
}
|
||||
if ext := strings.Trim(strings.TrimSpace(v.Ext), "."); ext != "" {
|
||||
candidates = append(candidates, sourceID+"."+ext)
|
||||
}
|
||||
for _, ext := range []string{".mp4", ".mkv", ".mov", ".webm", ".avi"} {
|
||||
candidates = append(candidates, sourceID+ext)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
out := make([]string, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[candidate]; ok {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = struct{}{}
|
||||
out = append(out, candidate)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func removeSpider91ThumbCandidates(src *spider91.Driver, stem string) {
|
||||
if src == nil {
|
||||
return
|
||||
}
|
||||
stem = strings.TrimSpace(stem)
|
||||
if stem == "" {
|
||||
return
|
||||
}
|
||||
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} {
|
||||
thumbPath, err := src.ThumbPath(stem + ext)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(thumbPath)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) cleanupDriveVideosForDelete(ctx context.Context, driveID string) (int, error) {
|
||||
if a == nil || a.cat == nil {
|
||||
return 0, nil
|
||||
|
||||
@@ -943,6 +943,177 @@ func TestCleanupDriveVideosForDeleteRemovesRowsAndGeneratedAssetsOnly(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteVideoRemovesGeneratedAssetsKeepsLocalOriginalAndTombstones(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
localDir := filepath.Join(root, "previews")
|
||||
originalDir := filepath.Join(root, "local-videos")
|
||||
originalVideo := filepath.Join(originalDir, "clip.mp4")
|
||||
if err := os.MkdirAll(originalDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir original dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(originalVideo, []byte("original"), 0o644); err != nil {
|
||||
t.Fatalf("write original: %v", err)
|
||||
}
|
||||
|
||||
cat, err := catalog.Open(filepath.Join(t.TempDir(), "catalog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open catalog: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = cat.Close() })
|
||||
if err := cat.UpsertDrive(ctx, &catalog.Drive{
|
||||
ID: "local-main",
|
||||
Kind: "localstorage",
|
||||
Name: "Local",
|
||||
RootID: "/",
|
||||
Credentials: map[string]string{"path": originalDir},
|
||||
TeaserEnabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed drive: %v", err)
|
||||
}
|
||||
|
||||
previewPath := filepath.Join(localDir, "localstorage-local-main-file.mp4")
|
||||
thumbPath := filepath.Join(localDir, "thumbs", "localstorage-local-main-file.jpg")
|
||||
for _, path := range []string{previewPath, thumbPath} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("generated"), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := cat.UpsertVideo(ctx, &catalog.Video{
|
||||
ID: "localstorage-local-main-file",
|
||||
DriveID: "local-main",
|
||||
FileID: "file",
|
||||
FileName: "clip.mp4",
|
||||
SampledSHA256: "sampled",
|
||||
FingerprintStatus: "ready",
|
||||
Title: "Local File",
|
||||
PreviewLocal: previewPath,
|
||||
PreviewStatus: "ready",
|
||||
ThumbnailURL: "/p/thumb/localstorage-local-main-file",
|
||||
Size: 123,
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed video: %v", err)
|
||||
}
|
||||
|
||||
app := &App{
|
||||
cfg: &config.Config{Storage: config.Storage{LocalPreviewDir: localDir}},
|
||||
cat: cat,
|
||||
}
|
||||
result, err := app.deleteVideo(ctx, "localstorage-local-main-file")
|
||||
if err != nil {
|
||||
t.Fatalf("delete video: %v", err)
|
||||
}
|
||||
if !result.OK || result.DeletedSource {
|
||||
t.Fatalf("delete result = %#v, want ok without source deletion", result)
|
||||
}
|
||||
if _, err := cat.GetVideo(ctx, "localstorage-local-main-file"); err != sql.ErrNoRows {
|
||||
t.Fatalf("deleted video lookup error = %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
deleted, err := cat.IsDeletedVideoCandidate(ctx, "localstorage-local-main-file", "local-main", "file", "", "clip.mp4", 123)
|
||||
if err != nil {
|
||||
t.Fatalf("check tombstone: %v", err)
|
||||
}
|
||||
if !deleted {
|
||||
t.Fatal("deleted video tombstone missing")
|
||||
}
|
||||
for _, path := range []string{previewPath, thumbPath} {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("generated asset %s still exists, stat err=%v", path, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(originalVideo); err != nil {
|
||||
t.Fatalf("original local video was removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteVideoRemovesSpider91SourceFile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
localDir := filepath.Join(root, "previews")
|
||||
cat, err := catalog.Open(filepath.Join(t.TempDir(), "catalog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open catalog: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = cat.Close() })
|
||||
|
||||
if err := cat.UpsertDrive(ctx, &catalog.Drive{
|
||||
ID: "spider-main",
|
||||
Kind: spider91.Kind,
|
||||
Name: "Spider",
|
||||
RootID: "/",
|
||||
TeaserEnabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed drive: %v", err)
|
||||
}
|
||||
app := &App{
|
||||
cfg: &config.Config{Storage: config.Storage{LocalPreviewDir: localDir}},
|
||||
cat: cat,
|
||||
}
|
||||
sourceDir := app.spider91DriveDir("spider-main")
|
||||
sourceVideo := filepath.Join(sourceDir, "videos", "source.mp4")
|
||||
sourceThumb := filepath.Join(sourceDir, "thumbs", "source.jpg")
|
||||
previewPath := filepath.Join(localDir, "spider91-spider-main-source.mp4")
|
||||
commonThumb := filepath.Join(localDir, "thumbs", "spider91-spider-main-source.jpg")
|
||||
for _, path := range []string{sourceVideo, sourceThumb, previewPath, commonThumb} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("file"), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := cat.UpsertVideo(ctx, &catalog.Video{
|
||||
ID: "spider91-spider-main-source",
|
||||
DriveID: "spider-main",
|
||||
FileID: "source.mp4",
|
||||
FileName: "source.mp4",
|
||||
Ext: "mp4",
|
||||
Title: "Spider Source",
|
||||
PreviewLocal: previewPath,
|
||||
PreviewStatus: "ready",
|
||||
ThumbnailURL: "/p/thumb/spider91-spider-main-source",
|
||||
Size: 456,
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed video: %v", err)
|
||||
}
|
||||
|
||||
result, err := app.deleteVideo(ctx, "spider91-spider-main-source")
|
||||
if err != nil {
|
||||
t.Fatalf("delete spider video: %v", err)
|
||||
}
|
||||
if !result.OK || !result.DeletedSource {
|
||||
t.Fatalf("delete result = %#v, want source deleted", result)
|
||||
}
|
||||
for _, path := range []string{sourceVideo, sourceThumb, previewPath, commonThumb} {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("deleted file %s still exists, stat err=%v", path, err)
|
||||
}
|
||||
}
|
||||
if _, err := cat.GetVideo(ctx, "spider91-spider-main-source"); err != sql.ErrNoRows {
|
||||
t.Fatalf("deleted video lookup error = %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
deleted, err := cat.IsVideoDeleted(ctx, "spider91-spider-main-source")
|
||||
if err != nil {
|
||||
t.Fatalf("check tombstone: %v", err)
|
||||
}
|
||||
if !deleted {
|
||||
t.Fatal("deleted spider91 video tombstone missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupDriveVideosForDeleteSpider91RemovesCrawledDirAndOriginRecords(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
|
||||
@@ -53,6 +53,7 @@ type AdminServer struct {
|
||||
OnRegenFailedPreviews func(driveID string)
|
||||
OnRegenFailedThumbnails func(driveID string)
|
||||
OnRegenFailedFingerprints func(driveID string)
|
||||
OnDeleteVideo func(ctx context.Context, videoID string) (DeleteVideoResult, error)
|
||||
GetDriveGenerationStatuses func() map[string]DriveGenerationStatuses
|
||||
// OnTeaserEnabledChanged 在 per-drive 预览视频开关被切换后调用。
|
||||
// enabled=true 时上层应该重新把 pending 预览视频入队(类似旧的全局开关从关到开);
|
||||
@@ -107,6 +108,11 @@ type NightlyJobStatus struct {
|
||||
LastFinishedAt string `json:"lastFinishedAt,omitempty"`
|
||||
}
|
||||
|
||||
type DeleteVideoResult struct {
|
||||
OK bool `json:"ok"`
|
||||
DeletedSource bool `json:"deletedSource"`
|
||||
}
|
||||
|
||||
func (a *AdminServer) Register(r chi.Router) {
|
||||
r.Route("/admin/api", func(r chi.Router) {
|
||||
// 登录、登出和首次部署初始化不需要鉴权
|
||||
@@ -139,6 +145,7 @@ func (a *AdminServer) Register(r chi.Router) {
|
||||
// 视频
|
||||
r.Get("/videos", a.handleAdminListVideos)
|
||||
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)
|
||||
|
||||
@@ -1031,6 +1038,36 @@ func (a *AdminServer) handleUpdateVideo(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if id == "" {
|
||||
writeErr(w, http.StatusBadRequest, errors.New("invalid video id"))
|
||||
return
|
||||
}
|
||||
var (
|
||||
result DeleteVideoResult
|
||||
err error
|
||||
)
|
||||
if a.OnDeleteVideo != nil {
|
||||
result, err = a.OnDeleteVideo(r.Context(), id)
|
||||
} else {
|
||||
err = a.Catalog.DeleteVideoWithTombstone(r.Context(), id)
|
||||
result = DeleteVideoResult{OK: err == nil}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if !result.OK {
|
||||
result.OK = true
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (a *AdminServer) handleRegenPreview(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if a.OnRegenPreview != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -707,8 +708,10 @@ func (c *Catalog) ListVideoFileIDsByDrive(ctx context.Context, driveID string) (
|
||||
func (c *Catalog) ListSpider91Viewkeys(ctx context.Context, driveID string) ([]string, error) {
|
||||
prefix := "spider91-" + driveID + "-"
|
||||
rows, err := c.db.QueryContext(ctx,
|
||||
`SELECT SUBSTR(id, ?) FROM videos WHERE id LIKE ? || '%'`,
|
||||
len(prefix)+1, prefix)
|
||||
`SELECT SUBSTR(id, ?) FROM videos WHERE id LIKE ? || '%'
|
||||
UNION
|
||||
SELECT SUBSTR(id, ?) FROM deleted_videos WHERE id LIKE ? || '%'`,
|
||||
len(prefix)+1, prefix, len(prefix)+1, prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -726,6 +729,69 @@ func (c *Catalog) ListSpider91Viewkeys(ctx context.Context, driveID string) ([]s
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteVideoWithTombstone records that an administrator explicitly deleted a
|
||||
// video, then removes the visible catalog row. The tombstone is used by
|
||||
// scanners/crawlers to avoid importing the same source file again.
|
||||
func (c *Catalog) DeleteVideoWithTombstone(ctx context.Context, id string) error {
|
||||
tx, err := c.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var v struct {
|
||||
ID string
|
||||
DriveID string
|
||||
FileID string
|
||||
ContentHash string
|
||||
FileName string
|
||||
Size int64
|
||||
}
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
SELECT id, drive_id, file_id, COALESCE(content_hash, ''), COALESCE(file_name, ''), size_bytes
|
||||
FROM videos
|
||||
WHERE id = ?`, id)
|
||||
if err := row.Scan(&v.ID, &v.DriveID, &v.FileID, &v.ContentHash, &v.FileName, &v.Size); err != nil {
|
||||
return err
|
||||
}
|
||||
v.ContentHash = normalizeContentHash(v.ContentHash)
|
||||
|
||||
// 先记录这次视频关联的 tag_id,便于事务末尾清理孤儿 collection 标签。
|
||||
tagIDs, err := collectVideoTagIDs(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO deleted_videos (id, drive_id, file_id, content_hash, file_name, size_bytes, deleted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
drive_id = excluded.drive_id,
|
||||
file_id = excluded.file_id,
|
||||
content_hash = excluded.content_hash,
|
||||
file_name = excluded.file_name,
|
||||
size_bytes = excluded.size_bytes,
|
||||
deleted_at = excluded.deleted_at`,
|
||||
v.ID, v.DriveID, v.FileID, v.ContentHash, v.FileName, v.Size, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM video_tags WHERE video_id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `DELETE FROM videos WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
if err := pruneOrphanCollectionTagsByID(ctx, tx, tagIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (c *Catalog) DeleteVideo(ctx context.Context, id string) error {
|
||||
tx, err := c.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -759,6 +825,55 @@ func (c *Catalog) DeleteVideo(ctx context.Context, id string) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (c *Catalog) IsVideoDeleted(ctx context.Context, id string) (bool, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return false, nil
|
||||
}
|
||||
var found int
|
||||
err := c.db.QueryRowContext(ctx, `SELECT 1 FROM deleted_videos WHERE id = ? LIMIT 1`, id).Scan(&found)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) IsDeletedVideoCandidate(ctx context.Context, id, driveID, fileID, contentHash, fileName string, size int64) (bool, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
driveID = strings.TrimSpace(driveID)
|
||||
fileID = strings.TrimSpace(fileID)
|
||||
contentHash = normalizeContentHash(contentHash)
|
||||
fileName = strings.TrimSpace(fileName)
|
||||
if id == "" && driveID == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var found int
|
||||
err := c.db.QueryRowContext(ctx, `
|
||||
SELECT 1
|
||||
FROM deleted_videos
|
||||
WHERE id = ?
|
||||
OR (drive_id = ? AND ? != '' AND file_id = ?)
|
||||
OR (drive_id = ? AND ? != '' AND content_hash = ?)
|
||||
OR (drive_id = ? AND ? != '' AND ? > 0 AND file_name = ? AND size_bytes = ?)
|
||||
LIMIT 1`,
|
||||
id,
|
||||
driveID, fileID, fileID,
|
||||
driveID, contentHash, contentHash,
|
||||
driveID, fileName, size, fileName, size,
|
||||
).Scan(&found)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) FindVideoByContentHash(ctx context.Context, hash string) (*Video, error) {
|
||||
hash = normalizeContentHash(hash)
|
||||
if hash == "" {
|
||||
|
||||
@@ -2,6 +2,7 @@ package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -126,3 +127,50 @@ func TestListSpider91ViewkeysFindsMigratedVideos(t *testing.T) {
|
||||
t.Fatalf("non-existent drive: got %v, want empty", other)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteVideoWithTombstonePreventsReimport(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()
|
||||
if err := cat.UpsertVideo(ctx, &Video{
|
||||
ID: "spider91-91Spider-vk004",
|
||||
DriveID: "91Spider",
|
||||
FileID: "vk004.mp4",
|
||||
FileName: "vk004.mp4",
|
||||
ContentHash: "ABCDEF",
|
||||
Title: "Deleted Spider",
|
||||
Size: 2048,
|
||||
PreviewStatus: "ready",
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
|
||||
if err := cat.DeleteVideoWithTombstone(ctx, "spider91-91Spider-vk004"); err != nil {
|
||||
t.Fatalf("delete with tombstone: %v", err)
|
||||
}
|
||||
if _, err := cat.GetVideo(ctx, "spider91-91Spider-vk004"); err != sql.ErrNoRows {
|
||||
t.Fatalf("get deleted video error = %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
deleted, err := cat.IsDeletedVideoCandidate(ctx, "spider91-91Spider-vk004", "91Spider", "vk004.mp4", "abcdef", "vk004.mp4", 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("check deleted candidate: %v", err)
|
||||
}
|
||||
if !deleted {
|
||||
t.Fatal("deleted candidate was not recognized")
|
||||
}
|
||||
viewkeys, err := cat.ListSpider91Viewkeys(ctx, "91Spider")
|
||||
if err != nil {
|
||||
t.Fatalf("ListSpider91Viewkeys: %v", err)
|
||||
}
|
||||
if len(viewkeys) != 1 || viewkeys[0] != "vk004" {
|
||||
t.Fatalf("viewkeys = %#v, want [vk004]", viewkeys)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,25 @@ CREATE TABLE IF NOT EXISTS deleted_tags (
|
||||
deleted_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- 管理员显式删除过的视频。用于防止后续扫描 / spider91 爬虫把同一个源文件
|
||||
-- 再次入库;不代表原始云盘文件已被删除。
|
||||
CREATE TABLE IF NOT EXISTS deleted_videos (
|
||||
id TEXT PRIMARY KEY,
|
||||
drive_id TEXT NOT NULL DEFAULT '',
|
||||
file_id TEXT NOT NULL DEFAULT '',
|
||||
content_hash TEXT NOT NULL DEFAULT '',
|
||||
file_name TEXT NOT NULL DEFAULT '',
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_deleted_videos_drive_file
|
||||
ON deleted_videos(drive_id, file_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_deleted_videos_drive_hash
|
||||
ON deleted_videos(drive_id, content_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_deleted_videos_drive_signature
|
||||
ON deleted_videos(drive_id, file_name, size_bytes);
|
||||
|
||||
-- 网盘账户
|
||||
CREATE TABLE IF NOT EXISTS drives (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -79,6 +79,18 @@ func (c *Catalog) migrate(ctx context.Context) error {
|
||||
if err := c.addColumnIfMissing(ctx, "drives", "skip_dir_ids", "TEXT NOT NULL DEFAULT '[]'"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS deleted_videos (
|
||||
id TEXT PRIMARY KEY,
|
||||
drive_id TEXT NOT NULL DEFAULT '',
|
||||
file_id TEXT NOT NULL DEFAULT '',
|
||||
content_hash TEXT NOT NULL DEFAULT '',
|
||||
file_name TEXT NOT NULL DEFAULT '',
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at INTEGER NOT NULL
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.syncDriveScanRootIDToRootID(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -121,6 +133,15 @@ func (c *Catalog) migrate(ctx context.Context) error {
|
||||
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_videos_file_name_size_created ON videos(file_name, size_bytes, created_at, id)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_deleted_videos_drive_file ON deleted_videos(drive_id, file_id)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_deleted_videos_drive_hash ON deleted_videos(drive_id, content_hash)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.db.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS idx_deleted_videos_drive_signature ON deleted_videos(drive_id, file_name, size_bytes)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.seedSystemTags(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -331,6 +331,16 @@ func (c *Crawler) RunOnce(ctx context.Context, targetNew int) (*CrawlResult, err
|
||||
break
|
||||
}
|
||||
videoID := buildVideoID(c.cfg.Driver.ID(), sourceID)
|
||||
deleted, err := c.cfg.Catalog.IsVideoDeleted(ctx, videoID)
|
||||
if err != nil {
|
||||
log.Printf("[spider91] drive=%s viewkey=%s source_id=%s check deleted: %v", c.cfg.Driver.ID(), item.Viewkey, sourceID, err)
|
||||
result.Failed++
|
||||
continue
|
||||
}
|
||||
if deleted {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
if existing, _ := c.cfg.Catalog.GetVideo(ctx, videoID); existing != nil {
|
||||
result.Skipped++
|
||||
continue
|
||||
|
||||
@@ -1362,12 +1362,19 @@ func (w *ThumbWorker) Run(ctx context.Context) {
|
||||
|
||||
func (w *Worker) processQueued(ctx context.Context, v *catalog.Video) {
|
||||
defer w.queue.release(v)
|
||||
w.activity.start(v)
|
||||
if w.Catalog == nil || v == nil || v.ID == "" {
|
||||
return
|
||||
}
|
||||
current, err := w.Catalog.GetVideo(ctx, v.ID)
|
||||
if err != nil || current.Hidden {
|
||||
return
|
||||
}
|
||||
w.activity.start(current)
|
||||
defer w.activity.done()
|
||||
if !waitForRateLimitCooldown(ctx, &w.rateLimit, "preview", w.Drive) {
|
||||
return
|
||||
}
|
||||
w.process(ctx, v)
|
||||
w.process(ctx, current)
|
||||
}
|
||||
|
||||
func (w *ThumbWorker) processQueued(ctx context.Context, v *catalog.Video) {
|
||||
@@ -1562,18 +1569,22 @@ func (w *ThumbWorker) process(ctx context.Context, v *catalog.Video) bool {
|
||||
if w.skipIfRateLimited(v) {
|
||||
return false
|
||||
}
|
||||
if w.Catalog == nil || v == nil || v.ID == "" {
|
||||
return false
|
||||
}
|
||||
queued := v
|
||||
current := v
|
||||
if loaded, err := w.Catalog.GetVideo(ctx, v.ID); err == nil {
|
||||
if loaded.PreviewLocal == "" {
|
||||
loaded.PreviewLocal = queued.PreviewLocal
|
||||
}
|
||||
current = loaded
|
||||
v = loaded
|
||||
if loaded.ThumbnailURL != "" && loaded.DurationSeconds > 0 {
|
||||
_ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{ThumbnailStatus: "ready"})
|
||||
return false
|
||||
}
|
||||
loaded, err := w.Catalog.GetVideo(ctx, v.ID)
|
||||
if err != nil || loaded.Hidden {
|
||||
return false
|
||||
}
|
||||
if loaded.PreviewLocal == "" {
|
||||
loaded.PreviewLocal = queued.PreviewLocal
|
||||
}
|
||||
current := loaded
|
||||
v = loaded
|
||||
if loaded.ThumbnailURL != "" && loaded.DurationSeconds > 0 {
|
||||
_ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{ThumbnailStatus: "ready"})
|
||||
return false
|
||||
}
|
||||
if current.ThumbnailURL != "" {
|
||||
durationBackfillFailed := false
|
||||
@@ -1670,13 +1681,18 @@ func (w *ThumbWorker) probeDuration(ctx context.Context, v *catalog.Video, link
|
||||
}
|
||||
|
||||
func (w *ThumbWorker) generateThumbnailFromLink(ctx context.Context, v *catalog.Video, link *drives.StreamLink) error {
|
||||
if _, err := w.Gen.GenerateThumbnail(ctx, link, v.ID, float64(v.DurationSeconds)); err != nil {
|
||||
local, err := w.Gen.GenerateThumbnail(ctx, link, v.ID, float64(v.DurationSeconds))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{
|
||||
if err := w.Catalog.UpdateVideoMeta(ctx, v.ID, catalog.VideoMetaPatch{
|
||||
ThumbnailURL: "/p/thumb/" + v.ID,
|
||||
ThumbnailStatus: "ready",
|
||||
})
|
||||
}); err != nil {
|
||||
_ = os.Remove(local)
|
||||
log.Printf("[thumb] update %s after generate: %v", v.Title, err)
|
||||
return nil
|
||||
}
|
||||
log.Printf("[thumb] ready %s", v.Title)
|
||||
return nil
|
||||
}
|
||||
@@ -1751,7 +1767,11 @@ func (w *Worker) process(ctx context.Context, v *catalog.Video) {
|
||||
}
|
||||
|
||||
removePreviousLocalTeaser(v.PreviewLocal, local)
|
||||
w.Catalog.UpdatePreview(ctx, v.ID, local, "ready")
|
||||
if err := w.Catalog.UpdatePreview(ctx, v.ID, local, "ready"); err != nil {
|
||||
removePreviousLocalTeaser(local, "")
|
||||
log.Printf("[preview] update %s after generate: %v", v.Title, err)
|
||||
return
|
||||
}
|
||||
log.Printf("[preview] ready %s (duration=%.1fs)", v.Title, duration)
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,14 @@ func (s *Scanner) walk(ctx context.Context, dirID, dirName string, stats *Stats,
|
||||
stats.SeenFileIDs[e.ID] = struct{}{}
|
||||
|
||||
id := s.Drive.Kind() + "-" + s.Drive.ID() + "-" + e.ID
|
||||
if deleted, err := s.Catalog.IsDeletedVideoCandidate(ctx, id, s.Drive.ID(), e.ID, e.Hash, e.Name, e.Size); err != nil {
|
||||
stats.Errors++
|
||||
log.Printf("[scanner] check deleted video %s error: %v", id, err)
|
||||
continue
|
||||
} else if deleted {
|
||||
continue
|
||||
}
|
||||
|
||||
parsed := Parse(e.Name)
|
||||
if parsed.Title == "" {
|
||||
parsed.Title = strings.TrimSuffix(e.Name, ext)
|
||||
|
||||
@@ -90,6 +90,61 @@ func TestRunIgnoresZeroSizeVideoFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkipsAdminDeletedVideo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open catalog: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := cat.Close(); err != nil {
|
||||
t.Fatalf("close catalog: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
if err := cat.UpsertVideo(ctx, &catalog.Video{
|
||||
ID: "fake-drive-file-1",
|
||||
DriveID: "drive",
|
||||
FileID: "file-1",
|
||||
FileName: "clip.mp4",
|
||||
ContentHash: "HASH-1",
|
||||
Title: "Deleted Clip",
|
||||
Size: 123,
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed video: %v", err)
|
||||
}
|
||||
if err := cat.DeleteVideoWithTombstone(ctx, "fake-drive-file-1"); err != nil {
|
||||
t.Fatalf("delete with tombstone: %v", err)
|
||||
}
|
||||
|
||||
drv := &scannerFakeDrive{
|
||||
entries: []drives.Entry{{
|
||||
ID: "file-1",
|
||||
Name: "clip.mp4",
|
||||
Size: 123,
|
||||
Hash: "hash-1",
|
||||
MimeType: "video/mp4",
|
||||
ModTime: now,
|
||||
}},
|
||||
}
|
||||
sc := New(cat, drv, []string{".mp4"}, nil, nil)
|
||||
|
||||
stats, err := sc.Run(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
if stats.Added != 0 {
|
||||
t.Fatalf("added = %d, want 0", stats.Added)
|
||||
}
|
||||
if _, err := cat.GetVideo(ctx, "fake-drive-file-1"); err != sql.ErrNoRows {
|
||||
t.Fatalf("deleted video was recreated, get error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotBackfillRemoteThumbnailForExistingVideo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
|
||||
|
||||
@@ -9,6 +9,8 @@ type ConfirmModalProps = {
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
danger?: boolean;
|
||||
centerMessage?: boolean;
|
||||
modalClassName?: string;
|
||||
loading?: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
@@ -22,6 +24,8 @@ export function ConfirmModal({
|
||||
confirmText = "确认",
|
||||
cancelText = "取消",
|
||||
danger = false,
|
||||
centerMessage = false,
|
||||
modalClassName = "",
|
||||
loading = false,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
@@ -31,6 +35,7 @@ export function ConfirmModal({
|
||||
open={open}
|
||||
title={title}
|
||||
onClose={onCancel}
|
||||
className={modalClassName}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="admin-btn" onClick={onCancel} disabled={loading}>
|
||||
@@ -47,8 +52,8 @@ export function ConfirmModal({
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="admin-confirm">
|
||||
<div className={`admin-confirm__icon${danger ? " is-danger" : ""}`}>
|
||||
<div className={`admin-confirm${centerMessage ? " is-message-centered" : ""}`}>
|
||||
<div className={`admin-confirm__icon${danger ? " is-danger" : ""}`} aria-hidden={centerMessage}>
|
||||
<AlertTriangle size={20} />
|
||||
</div>
|
||||
<div className="admin-confirm__content">
|
||||
|
||||
@@ -650,6 +650,8 @@ export function DrivesPage() {
|
||||
message="当前网盘配置有未保存的更改,确定要放弃吗?"
|
||||
confirmText="放弃更改"
|
||||
danger
|
||||
centerMessage
|
||||
modalClassName="admin-modal--delete-confirm"
|
||||
onCancel={() => setDiscardConfirmOpen(false)}
|
||||
onConfirm={discardDriveChanges}
|
||||
/>
|
||||
@@ -787,6 +789,8 @@ export function DrivesPage() {
|
||||
message="当前网盘配置有未保存的更改,确定要放弃吗?"
|
||||
confirmText="放弃更改"
|
||||
danger
|
||||
centerMessage
|
||||
modalClassName="admin-modal--delete-confirm"
|
||||
onCancel={() => setDiscardConfirmOpen(false)}
|
||||
onConfirm={discardDriveChanges}
|
||||
/>
|
||||
|
||||
+3
-2
@@ -7,9 +7,10 @@ type Props = {
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Modal({ open, title, onClose, children, footer }: Props) {
|
||||
export function Modal({ open, title, onClose, children, footer, className = "" }: Props) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
|
||||
@@ -76,7 +77,7 @@ export function Modal({ open, title, onClose, children, footer }: Props) {
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="admin-modal"
|
||||
className={`admin-modal${className ? ` ${className}` : ""}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
|
||||
@@ -239,9 +239,6 @@ export function TagsPage() {
|
||||
onChange={(e) => setAliases(e.target.value)}
|
||||
placeholder="逗号分隔,例如:纯欲, 清新"
|
||||
/>
|
||||
<div className="admin-form__help">
|
||||
新增后会按别名和标签名匹配已有视频的标题、作者和目录并自动归类。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -495,12 +492,13 @@ export function TagsPage() {
|
||||
title={deleteConfirm?.kind === "bulk" ? "删除选中标签" : "删除标签"}
|
||||
message={
|
||||
deleteConfirm?.kind === "bulk"
|
||||
? `确定删除选中的 ${deleteConfirm.ids.length} 个标签吗?`
|
||||
: `确定删除标签「${deleteConfirm?.tag.label ?? ""}」吗?`
|
||||
? `确定要删除选中的 ${deleteConfirm.ids.length} 个标签吗?`
|
||||
: `确定要删除标签「${deleteConfirm?.tag.label ?? ""}」吗?`
|
||||
}
|
||||
details={["此操作会从所有视频上移除相关标签。"]}
|
||||
confirmText="确认删除"
|
||||
danger
|
||||
centerMessage
|
||||
modalClassName="admin-modal--delete-confirm"
|
||||
loading={deletingId !== null || bulkDeleting}
|
||||
onCancel={() => {
|
||||
if (deletingId === null && !bulkDeleting) setDeleteConfirm(null);
|
||||
|
||||
@@ -103,9 +103,6 @@ export function ThemePage() {
|
||||
<div className="theme-page">
|
||||
<header className="theme-page__head">
|
||||
<h1 className="theme-page__title">主题外观</h1>
|
||||
<p className="theme-page__sub">
|
||||
切换全站主题。所有访客都会看到这里选定的主题,本设置会写入数据库永久保存。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="theme-grid">
|
||||
|
||||
+185
-59
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { Edit, RefreshCw, Search, CheckSquare, Square, Image } from "lucide-react";
|
||||
import { ChevronDown, Edit, RefreshCw, Search, CheckSquare, Square, Image, Trash2 } from "lucide-react";
|
||||
import * as api from "./api";
|
||||
import { useToast } from "./ToastContext";
|
||||
import { Modal } from "./Modal";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { formatBytes } from "./storageFormat";
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
const DESKTOP_VIDEOS_PAGE_SIZE = 50;
|
||||
const MOBILE_VIDEOS_PAGE_SIZE = 20;
|
||||
const VIDEOS_MOBILE_QUERY = "(max-width: 640px)";
|
||||
|
||||
export function VideosPage() {
|
||||
const [list, setList] = useState<api.AdminVideo[]>([]);
|
||||
@@ -23,6 +25,11 @@ export function VideosPage() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [batchRegenOpen, setBatchRegenOpen] = useState(false);
|
||||
const [batchRegening, setBatchRegening] = useState(false);
|
||||
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
|
||||
const [batchDeleting, setBatchDeleting] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<api.AdminVideo | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const pageSize = useVideosPageSize();
|
||||
const { show } = useToast();
|
||||
|
||||
async function refresh() {
|
||||
@@ -30,7 +37,7 @@ export function VideosPage() {
|
||||
setLoadError("");
|
||||
try {
|
||||
const [r, tagList, driveList] = await Promise.all([
|
||||
api.listVideos({ driveId, page, size: PAGE_SIZE, keyword: searchKeyword }),
|
||||
api.listVideos({ driveId, page, size: pageSize, keyword: searchKeyword }),
|
||||
api.listTags(),
|
||||
api.listDrives(),
|
||||
]);
|
||||
@@ -50,7 +57,11 @@ export function VideosPage() {
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [driveId, page, searchKeyword]);
|
||||
}, [driveId, page, searchKeyword, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (keyword === searchKeyword) return;
|
||||
@@ -66,9 +77,9 @@ export function VideosPage() {
|
||||
);
|
||||
|
||||
const listItems = list;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const pageStart = total === 0 ? 0 : (page - 1) * PAGE_SIZE + 1;
|
||||
const pageEnd = Math.min(total, page * PAGE_SIZE);
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const pageStart = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const pageEnd = Math.min(total, page * pageSize);
|
||||
const listSummary = driveId
|
||||
? `${driveNameMap.get(driveId) ?? driveId}:共 ${total} 个视频,第 ${page} / ${totalPages} 页,显示 ${pageStart}-${pageEnd}`
|
||||
: `全部网盘:共 ${total} 个视频,第 ${page} / ${totalPages} 页,显示 ${pageStart}-${pageEnd}`;
|
||||
@@ -87,6 +98,11 @@ export function VideosPage() {
|
||||
setBatchRegenOpen(true);
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (selectedIds.size === 0) return;
|
||||
setBatchDeleteOpen(true);
|
||||
}
|
||||
|
||||
async function confirmBatchRegen() {
|
||||
const ids = [...selectedIds];
|
||||
setBatchRegening(true);
|
||||
@@ -106,6 +122,65 @@ export function VideosPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteVideo() {
|
||||
if (!deleteTarget) return;
|
||||
const target = deleteTarget;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const result = await api.deleteVideo(target.id);
|
||||
setDeleteTarget(null);
|
||||
setSelectedIds((ids) => {
|
||||
const next = new Set(ids);
|
||||
next.delete(target.id);
|
||||
return next;
|
||||
});
|
||||
show(result.deletedSource ? "已删除视频,并清理 91Spider 源文件" : "已删除视频", "success");
|
||||
if (listItems.length === 1 && page > 1) {
|
||||
setPage((p) => Math.max(1, p - 1));
|
||||
} else {
|
||||
refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
show(e instanceof Error ? e.message : "删除失败", "error");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBatchDelete() {
|
||||
const ids = [...selectedIds];
|
||||
if (ids.length === 0) return;
|
||||
setBatchDeleting(true);
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => api.deleteVideo(id))
|
||||
);
|
||||
let success = 0;
|
||||
let deletedSources = 0;
|
||||
for (const r of results) {
|
||||
if (r.status !== "fulfilled") continue;
|
||||
success++;
|
||||
if (r.value.deletedSource) deletedSources++;
|
||||
}
|
||||
const failed = ids.length - success;
|
||||
if (failed === 0) {
|
||||
const extra = deletedSources > 0 ? `,其中 ${deletedSources} 个清理了 91Spider 源文件` : "";
|
||||
show(`批量删除完成,成功 ${success} 个${extra}`, "success");
|
||||
} else {
|
||||
show(`批量删除完成,成功 ${success} / ${ids.length} 个,失败 ${failed} 个`, success > 0 ? "info" : "error");
|
||||
}
|
||||
setSelectedIds(new Set());
|
||||
setBatchDeleteOpen(false);
|
||||
if (success >= listItems.length && page > 1) {
|
||||
setPage((p) => Math.max(1, p - 1));
|
||||
} else {
|
||||
refresh();
|
||||
}
|
||||
} finally {
|
||||
setBatchDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === listItems.length && listItems.length > 0) {
|
||||
setSelectedIds(new Set());
|
||||
@@ -132,22 +207,25 @@ export function VideosPage() {
|
||||
<header className="admin-page__header">
|
||||
<h1 className="admin-page__title">视频管理</h1>
|
||||
<div className="admin-page__actions admin-videos-filter">
|
||||
<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>
|
||||
<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
|
||||
@@ -163,37 +241,6 @@ export function VideosPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{drives.length > 0 && (
|
||||
<div className="admin-drive-teasers" aria-label="网盘预览视频统计">
|
||||
{drives.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
className={`admin-drive-teaser${
|
||||
driveId === d.id ? " is-active" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
setDriveId(d.id);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<span className="admin-drive-teaser__name">{d.name || d.id}</span>
|
||||
<span className="admin-drive-teaser__metric is-ready">
|
||||
已生成 {d.teaserReadyCount ?? 0}
|
||||
</span>
|
||||
<span className="admin-drive-teaser__metric is-pending">
|
||||
待生成 {d.teaserPendingCount ?? 0}
|
||||
</span>
|
||||
{(d.teaserFailedCount ?? 0) > 0 && (
|
||||
<span className="admin-drive-teaser__metric is-failed">
|
||||
失败 {d.teaserFailedCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<div className="admin-videos-list-toolbar">
|
||||
<div className="admin-videos-summary">{listSummary}</div>
|
||||
@@ -202,9 +249,12 @@ export function VideosPage() {
|
||||
<span className="admin-videos-bulk-actions__count">
|
||||
已选择 {selectedIds.size} 项
|
||||
</span>
|
||||
<button type="button" className="admin-btn is-primary" onClick={handleBatchRegen}>
|
||||
<button type="button" className="admin-btn is-primary admin-videos-bulk-actions__btn" onClick={handleBatchRegen}>
|
||||
<RefreshCw size={13} /> 批量重生预览视频
|
||||
</button>
|
||||
<button type="button" className="admin-btn is-danger admin-videos-bulk-actions__btn" onClick={handleBatchDelete}>
|
||||
<Trash2 size={13} /> 批量删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -236,7 +286,7 @@ export function VideosPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="admin-table is-selectable">
|
||||
<table className="admin-table is-selectable admin-videos-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="is-checkbox" style={{ width: '40px' }}>
|
||||
@@ -278,6 +328,7 @@ export function VideosPage() {
|
||||
{fileMeta(v)}
|
||||
</div>
|
||||
)}
|
||||
<VideoFileMetaPills video={v} />
|
||||
</td>
|
||||
<td data-label="作者">{v.author || <span className="admin-text-faint">—</span>}</td>
|
||||
<td data-label="标签">
|
||||
@@ -302,6 +353,9 @@ export function VideosPage() {
|
||||
</button>{" "}
|
||||
<button type="button" className="admin-btn" onClick={() => handleRegen(v)} title="重生预览视频">
|
||||
<RefreshCw size={13} />
|
||||
</button>{" "}
|
||||
<button type="button" className="admin-btn is-danger" onClick={() => setDeleteTarget(v)} title="删除视频">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -326,7 +380,7 @@ export function VideosPage() {
|
||||
上一页
|
||||
</button>
|
||||
<span className="admin-table-pagination__info">
|
||||
第 {page} / {totalPages} 页,每页 {PAGE_SIZE} 个
|
||||
第 {page} / {totalPages} 页,每页 {pageSize} 个
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -370,6 +424,34 @@ export function VideosPage() {
|
||||
}}
|
||||
onConfirm={confirmBatchRegen}
|
||||
/>
|
||||
<ConfirmModal
|
||||
open={deleteTarget !== null}
|
||||
title="删除视频"
|
||||
message={deleteTarget ? `确定要删除「${deleteTarget.title}」吗?` : ""}
|
||||
confirmText="删除视频"
|
||||
danger
|
||||
centerMessage
|
||||
modalClassName="admin-modal--delete-confirm"
|
||||
loading={deleting}
|
||||
onCancel={() => {
|
||||
if (!deleting) setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={confirmDeleteVideo}
|
||||
/>
|
||||
<ConfirmModal
|
||||
open={batchDeleteOpen}
|
||||
title="批量删除视频"
|
||||
message={`确定要删除当前页选中的 ${selectedIds.size} 个视频吗?`}
|
||||
confirmText="批量删除"
|
||||
danger
|
||||
centerMessage
|
||||
modalClassName="admin-modal--delete-confirm"
|
||||
loading={batchDeleting}
|
||||
onCancel={() => {
|
||||
if (!batchDeleting) setBatchDeleteOpen(false);
|
||||
}}
|
||||
onConfirm={confirmBatchDelete}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -381,6 +463,27 @@ function PreviewStatus({ s }: { s: string }) {
|
||||
return <span className="admin-status is-pending">待生成</span>;
|
||||
}
|
||||
|
||||
function VideoFileMetaPills({ video }: { video: api.AdminVideo }) {
|
||||
const parts = fileMetaParts(video);
|
||||
const category = (video.category ?? "").trim();
|
||||
if (parts.length === 0 && !category) return null;
|
||||
|
||||
return (
|
||||
<div className="admin-video-filemeta-pills" aria-label="视频文件信息">
|
||||
{parts.map((part, index) => (
|
||||
<span key={`${part}-${index}`} className="admin-video-filemeta-pill">
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
{category && (
|
||||
<span className="admin-video-filemeta-pill is-category">
|
||||
{category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDur(sec: number): string {
|
||||
if (!sec) return "—";
|
||||
const m = Math.floor(sec / 60);
|
||||
@@ -388,6 +491,26 @@ function formatDur(sec: number): string {
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function useVideosPageSize() {
|
||||
const [pageSize, setPageSize] = useState(() =>
|
||||
window.matchMedia(VIDEOS_MOBILE_QUERY).matches
|
||||
? MOBILE_VIDEOS_PAGE_SIZE
|
||||
: DESKTOP_VIDEOS_PAGE_SIZE
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(VIDEOS_MOBILE_QUERY);
|
||||
const update = () => {
|
||||
setPageSize(media.matches ? MOBILE_VIDEOS_PAGE_SIZE : DESKTOP_VIDEOS_PAGE_SIZE);
|
||||
};
|
||||
update();
|
||||
media.addEventListener("change", update);
|
||||
return () => media.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
return pageSize;
|
||||
}
|
||||
|
||||
function EditVideoModal({
|
||||
video,
|
||||
availableTags,
|
||||
@@ -549,12 +672,15 @@ function EditVideoModal({
|
||||
}
|
||||
|
||||
function fileMeta(v: api.AdminVideo): string {
|
||||
const parts = [
|
||||
return fileMetaParts(v).join(" · ");
|
||||
}
|
||||
|
||||
function fileMetaParts(v: api.AdminVideo): string[] {
|
||||
return [
|
||||
normalizeExt(v.ext),
|
||||
v.quality,
|
||||
v.size > 0 ? formatBytes(v.size) : "",
|
||||
].filter(Boolean);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function normalizeExt(ext: string): string {
|
||||
|
||||
@@ -353,6 +353,13 @@ export function updateVideo(id: string, body: UpdateVideoInput) {
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteVideo(id: string) {
|
||||
return request<{ ok: boolean; deletedSource: boolean }>(
|
||||
`/videos/${encodeURIComponent(id)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
}
|
||||
|
||||
export function regenPreview(id: string) {
|
||||
return request<{ ok: boolean }>(
|
||||
`/videos/${encodeURIComponent(id)}/regen-preview`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlertTriangle, Trash2 } from "lucide-react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import * as api from "../api";
|
||||
import { Modal } from "../Modal";
|
||||
|
||||
@@ -15,15 +15,15 @@ export function DeleteDriveModal({
|
||||
}) {
|
||||
const name = drive?.name || drive?.id || "";
|
||||
const isSpider91 = drive?.kind === "spider91";
|
||||
const isLocalStorage = drive?.kind === "localstorage";
|
||||
const title = isSpider91 ? "删除 91Spider" : "删除存储";
|
||||
const primaryText = deleting ? "删除中..." : "确认删除并清理";
|
||||
const primaryText = deleting ? "删除中..." : "确认删除";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={!!drive}
|
||||
title={title}
|
||||
onClose={onCancel}
|
||||
className="admin-modal--delete-confirm"
|
||||
footer={
|
||||
<>
|
||||
<button className="admin-btn" onClick={onCancel} disabled={deleting}>
|
||||
@@ -36,37 +36,11 @@ export function DeleteDriveModal({
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="admin-delete-confirm">
|
||||
<div className="admin-delete-confirm__icon">
|
||||
<AlertTriangle size={20} />
|
||||
</div>
|
||||
<div className="admin-delete-confirm__content">
|
||||
<p className="admin-delete-confirm__title">
|
||||
{isSpider91
|
||||
? `确定删除「${name}」吗?`
|
||||
: `确定删除「${name}」并清理该存储的视频数据吗?`}
|
||||
</p>
|
||||
<p className="admin-delete-confirm__text">
|
||||
取消或关闭此弹窗不会删除存储配置,也不会清理任何文件。
|
||||
</p>
|
||||
<ul className="admin-delete-confirm__list">
|
||||
<li>删除该存储配置</li>
|
||||
<li>删除数据库中的相关视频记录,网站首页、列表、标签页和详情页不再展示这些视频</li>
|
||||
<li>删除本机保存的封面图和预览视频</li>
|
||||
{isSpider91 && (
|
||||
<li>删除通过 91Spider 爬取到的本机 91 视频文件</li>
|
||||
)}
|
||||
{isLocalStorage && (
|
||||
<li>不会删除用户配置的本地目录中的原始视频文件</li>
|
||||
)}
|
||||
</ul>
|
||||
{!isSpider91 && !isLocalStorage && (
|
||||
<p className="admin-delete-confirm__text">
|
||||
此操作只清理本项目生成和记录的数据,不会删除云盘上的原始视频文件。
|
||||
</p>
|
||||
)}
|
||||
<div className="admin-confirm is-message-centered">
|
||||
<div className="admin-confirm__content">
|
||||
<p className="admin-confirm__message">{`确定要删除「${name}」吗?`}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,4 +139,4 @@ export function P123QRCodeLogin({ onToken }: { onToken: (token: string) => void
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+425
-42
@@ -890,48 +890,64 @@
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.admin-delete-confirm {
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.admin-delete-confirm__icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
border: 1px solid rgba(241, 85, 108, 0.34);
|
||||
}
|
||||
|
||||
.admin-delete-confirm__content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-delete-confirm__title {
|
||||
margin: 0 0 8px;
|
||||
.admin-modal--delete-confirm {
|
||||
background: var(--bg-surface);
|
||||
border-color: var(--border-default);
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.admin-delete-confirm__text {
|
||||
margin: 0 0 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.6;
|
||||
.admin-modal--delete-confirm .admin-modal__header,
|
||||
.admin-modal--delete-confirm .admin-modal__footer {
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
.admin-delete-confirm__list {
|
||||
margin: 0 0 10px;
|
||||
padding-left: 18px;
|
||||
color: var(--text-default);
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.7;
|
||||
.admin-modal--delete-confirm .admin-modal__header,
|
||||
.admin-modal--delete-confirm .admin-confirm__message {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.admin-modal--delete-confirm .admin-modal__header .admin-btn {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--accent);
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
|
||||
.admin-modal--delete-confirm .admin-modal__header .admin-btn:hover:not(:disabled) {
|
||||
background: var(--bg-surface);
|
||||
color: var(--accent-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
:root[data-theme="pink"] .admin-modal--delete-confirm {
|
||||
background: #ffffff;
|
||||
border-color: rgba(255, 91, 138, 0.18);
|
||||
color: #2a1820;
|
||||
box-shadow: 0 18px 54px rgba(42, 24, 32, 0.18);
|
||||
}
|
||||
|
||||
:root[data-theme="pink"] .admin-modal--delete-confirm .admin-modal__header,
|
||||
:root[data-theme="pink"] .admin-modal--delete-confirm .admin-modal__footer {
|
||||
background: #ffffff;
|
||||
border-color: rgba(255, 91, 138, 0.12);
|
||||
}
|
||||
|
||||
:root[data-theme="pink"] .admin-modal--delete-confirm .admin-modal__header,
|
||||
:root[data-theme="pink"] .admin-modal--delete-confirm .admin-confirm__message {
|
||||
color: #2a1820;
|
||||
}
|
||||
|
||||
:root[data-theme="pink"] .admin-modal--delete-confirm .admin-modal__header .admin-btn {
|
||||
background: #ffffff;
|
||||
color: #ff5b8a;
|
||||
border-color: rgba(255, 91, 138, 0.26);
|
||||
}
|
||||
|
||||
:root[data-theme="pink"] .admin-modal--delete-confirm .admin-modal__header .admin-btn:hover:not(:disabled) {
|
||||
background: #fff6f9;
|
||||
color: #f43d75;
|
||||
border-color: rgba(255, 91, 138, 0.45);
|
||||
}
|
||||
|
||||
.admin-confirm {
|
||||
@@ -962,6 +978,19 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-confirm.is-message-centered {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-confirm.is-message-centered .admin-confirm__icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-confirm.is-message-centered .admin-confirm__message {
|
||||
margin-bottom: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-confirm__message {
|
||||
margin: 0 0 10px;
|
||||
color: var(--text-strong);
|
||||
@@ -1596,6 +1625,11 @@
|
||||
max-height: calc(100vh - 16px);
|
||||
}
|
||||
|
||||
.admin-modal--delete-confirm {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.admin-modal__header,
|
||||
.admin-modal__body,
|
||||
.admin-modal__footer {
|
||||
@@ -1648,6 +1682,298 @@
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.admin-video-filemeta-pills {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-videos-table:not(.admin-drives-table) tbody {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) tr {
|
||||
--admin-video-card-bg: var(--bg-surface);
|
||||
--admin-video-card-border: var(--border-default);
|
||||
--admin-video-card-line: var(--border-subtle);
|
||||
--admin-video-card-shadow: var(--shadow-sm);
|
||||
--admin-video-card-selected-shadow: 0 0 0 1px var(--border-accent), var(--shadow-md);
|
||||
--admin-video-card-main: var(--text-strong);
|
||||
--admin-video-card-text: var(--text-default);
|
||||
--admin-video-card-muted: var(--text-muted);
|
||||
--admin-video-card-faint: var(--text-faint);
|
||||
--admin-video-card-pill-bg: var(--bg-elevated);
|
||||
--admin-video-card-pill-border: var(--border-subtle);
|
||||
--admin-video-card-pill-text: var(--text-muted);
|
||||
--admin-video-card-category-bg: var(--accent-soft);
|
||||
--admin-video-card-category-border: var(--border-accent);
|
||||
--admin-video-card-category-text: var(--accent);
|
||||
--admin-video-card-button-bg: var(--bg-elevated);
|
||||
--admin-video-card-button-hover-bg: var(--bg-surface);
|
||||
--admin-video-card-button-text: var(--text-default);
|
||||
--admin-video-card-danger: var(--danger);
|
||||
--admin-video-card-danger-border: rgba(241, 85, 108, 0.4);
|
||||
position: relative;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
padding: 12px 14px;
|
||||
background: var(--admin-video-card-bg);
|
||||
border: 1px solid var(--admin-video-card-border);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--admin-video-card-shadow);
|
||||
color: var(--admin-video-card-text);
|
||||
}
|
||||
|
||||
:root:not([data-theme="pink"]) .admin-videos-table:not(.admin-drives-table) tr {
|
||||
--admin-video-card-bg: #1e1e1e;
|
||||
--admin-video-card-border: rgba(255, 255, 255, 0.06);
|
||||
--admin-video-card-line: rgba(255, 255, 255, 0.08);
|
||||
--admin-video-card-shadow: 0 8px 20px rgba(0, 0, 0, 0.22);
|
||||
--admin-video-card-selected-shadow:
|
||||
0 0 0 1px rgba(255, 138, 60, 0.28),
|
||||
0 8px 20px rgba(0, 0, 0, 0.24);
|
||||
--admin-video-card-main: #f0f0f0;
|
||||
--admin-video-card-text: #e0e0e0;
|
||||
--admin-video-card-muted: rgba(255, 255, 255, 0.62);
|
||||
--admin-video-card-faint: rgba(255, 255, 255, 0.3);
|
||||
--admin-video-card-pill-bg: rgba(255, 255, 255, 0.06);
|
||||
--admin-video-card-pill-border: rgba(255, 255, 255, 0.05);
|
||||
--admin-video-card-pill-text: rgba(255, 255, 255, 0.62);
|
||||
--admin-video-card-category-bg: rgba(255, 255, 255, 0.11);
|
||||
--admin-video-card-category-border: rgba(255, 255, 255, 0.14);
|
||||
--admin-video-card-category-text: #f0f0f0;
|
||||
--admin-video-card-button-bg: rgba(255, 255, 255, 0.06);
|
||||
--admin-video-card-button-hover-bg: rgba(255, 255, 255, 0.1);
|
||||
--admin-video-card-button-text: #e0e0e0;
|
||||
--admin-video-card-danger: #f08080;
|
||||
--admin-video-card-danger-border: rgba(240, 80, 80, 0.4);
|
||||
}
|
||||
|
||||
:root[data-theme="pink"] .admin-videos-table:not(.admin-drives-table) tr {
|
||||
--admin-video-card-danger-border: rgba(228, 59, 92, 0.36);
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) tr.is-selected {
|
||||
background: var(--admin-video-card-bg);
|
||||
border-color: var(--border-accent);
|
||||
box-shadow: var(--admin-video-card-selected-shadow);
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td {
|
||||
gap: 3px;
|
||||
color: var(--admin-video-card-text);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td::before {
|
||||
color: var(--admin-video-card-faint);
|
||||
font-size: 10px;
|
||||
font-weight: var(--weight-semibold);
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td.is-checkbox {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 14px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td.is-checkbox::before,
|
||||
.admin-videos-table:not(.admin-drives-table) td[data-label="标题"]::before {
|
||||
content: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) .admin-table-checkbox-btn {
|
||||
border-radius: 8px;
|
||||
color: var(--admin-video-card-muted);
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) .admin-table-checkbox-btn:hover,
|
||||
.admin-videos-table:not(.admin-drives-table) .admin-table-checkbox-btn:focus-visible {
|
||||
background: var(--admin-video-card-button-hover-bg);
|
||||
color: var(--admin-video-card-main);
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td[data-label="标题"] {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
min-height: 28px;
|
||||
padding-left: 36px;
|
||||
gap: 6px;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) .admin-video-title {
|
||||
color: var(--admin-video-card-main);
|
||||
font-size: 14px;
|
||||
font-weight: var(--weight-bold);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) .admin-video-filemeta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) .admin-video-filemeta-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) .admin-video-filemeta-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 19px;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid var(--admin-video-card-pill-border);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--admin-video-card-pill-bg);
|
||||
color: var(--admin-video-card-pill-text);
|
||||
font-size: 10px;
|
||||
font-weight: var(--weight-semibold);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) .admin-video-filemeta-pill.is-category {
|
||||
border-color: var(--admin-video-card-category-border);
|
||||
background: var(--admin-video-card-category-bg);
|
||||
color: var(--admin-video-card-category-text);
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td[data-label="标签"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td[data-label="作者"],
|
||||
.admin-videos-table:not(.admin-drives-table) td[data-label="来源"],
|
||||
.admin-videos-table:not(.admin-drives-table) td[data-label="时长"],
|
||||
.admin-videos-table:not(.admin-drives-table) td[data-label="预览视频"] {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--admin-video-card-line);
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td[data-label="作者"],
|
||||
.admin-videos-table:not(.admin-drives-table) td[data-label="时长"] {
|
||||
grid-column: 1;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td[data-label="来源"],
|
||||
.admin-videos-table:not(.admin-drives-table) td[data-label="预览视频"] {
|
||||
grid-column: 2;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td[data-label="作者"],
|
||||
.admin-videos-table:not(.admin-drives-table) td[data-label="来源"] {
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td[data-label="时长"],
|
||||
.admin-videos-table:not(.admin-drives-table) td[data-label="预览视频"] {
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) .admin-mono-cell {
|
||||
color: var(--admin-video-card-text);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) .admin-text-faint {
|
||||
color: var(--admin-video-card-faint);
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) .admin-status {
|
||||
width: max-content;
|
||||
min-height: 22px;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) .admin-status.is-ok {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
:root:not([data-theme="pink"]) .admin-videos-table:not(.admin-drives-table) .admin-status.is-ok {
|
||||
background: rgba(110, 231, 183, 0.12);
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td.is-actions {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 4;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--admin-video-card-line);
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td.is-actions::before {
|
||||
flex: 0 0 auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td.is-actions .admin-btn {
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
padding: 0 9px;
|
||||
border-color: transparent;
|
||||
border-radius: 8px;
|
||||
background: var(--admin-video-card-button-bg);
|
||||
box-shadow: none;
|
||||
color: var(--admin-video-card-button-text);
|
||||
font-size: 12px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td.is-actions .admin-btn + .admin-btn {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td.is-actions .admin-btn:not(:first-of-type) {
|
||||
width: 28px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td.is-actions .admin-btn:hover:not(:disabled) {
|
||||
background: var(--admin-video-card-button-hover-bg);
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
color: var(--admin-video-card-main);
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td.is-actions .admin-btn.is-danger {
|
||||
border-color: var(--admin-video-card-danger-border);
|
||||
background: transparent;
|
||||
color: var(--admin-video-card-danger);
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td.is-actions .admin-btn.is-danger:hover:not(:disabled) {
|
||||
border-color: var(--admin-video-card-danger);
|
||||
background: var(--danger-soft);
|
||||
color: var(--admin-video-card-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-loading-screen {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
@@ -1672,24 +1998,50 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-videos-filter__select {
|
||||
height: 38px;
|
||||
.admin-videos-filter__select-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
min-width: 200px;
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.admin-videos-filter__select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0 36px 0 var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-sunken);
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-sm);
|
||||
line-height: 38px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-videos-filter__select::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-videos-filter__select:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.admin-videos-filter__select-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 12px;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-faint);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.admin-videos-filter__select:focus + .admin-videos-filter__select-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.admin-videos-filter__select option {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-strong);
|
||||
@@ -1758,6 +2110,35 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-videos-bulk-actions__btn {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.admin-videos-bulk-actions__btn.is-primary {
|
||||
box-shadow: 0 2px 10px var(--accent-glow);
|
||||
}
|
||||
|
||||
.admin-videos-bulk-actions__btn.is-primary:hover:not(:disabled) {
|
||||
box-shadow: 0 3px 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
.admin-videos-bulk-actions__btn.is-danger {
|
||||
background: var(--danger-soft);
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.admin-videos-bulk-actions__btn.is-danger:hover:not(:disabled) {
|
||||
background: var(--danger);
|
||||
border-color: var(--danger);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 3px 12px var(--danger-soft);
|
||||
}
|
||||
|
||||
.admin-videos-bulk-actions__btn.is-danger:focus-visible {
|
||||
outline-color: var(--danger);
|
||||
}
|
||||
|
||||
.admin-table-pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1790,7 +2171,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-videos-filter__select,
|
||||
.admin-videos-filter__select-wrap,
|
||||
.admin-videos-filter__search {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
@@ -2971,4 +3352,6 @@
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.admin-table tbody tr:hover td { background: var(--bg-elevated) !important; }
|
||||
}
|
||||
.admin-table tr.is-selected td { background: var(--accent-softer) !important; }
|
||||
@media (min-width: 769px) {
|
||||
.admin-table tr.is-selected td { background: var(--accent-softer) !important; }
|
||||
}
|
||||
|
||||
@@ -179,6 +179,20 @@ test("drive detail selection is stored in the URL history", () => {
|
||||
assert.doesNotMatch(drivesPageSource, /setSelectedDriveId/);
|
||||
});
|
||||
|
||||
test("drive discard confirmation matches delete confirmation modal styling", () => {
|
||||
const discardModals = Array.from(
|
||||
drivesPageSource.matchAll(/<ConfirmModal[\s\S]*?title="放弃未保存更改"[\s\S]*?\/>/g),
|
||||
(match) => match[0]
|
||||
);
|
||||
|
||||
assert.equal(discardModals.length, 2);
|
||||
for (const modal of discardModals) {
|
||||
assert.match(modal, /danger/);
|
||||
assert.match(modal, /centerMessage/);
|
||||
assert.match(modal, /modalClassName="admin-modal--delete-confirm"/);
|
||||
}
|
||||
});
|
||||
|
||||
test("drive generation actions can resume pending work after stop", () => {
|
||||
assert.match(driveComponentsSource, /thumbnailPendingCount/);
|
||||
assert.match(driveComponentsSource, /teaserPendingCount/);
|
||||
|
||||
@@ -6,6 +6,10 @@ const adminCss = readFileSync(
|
||||
new URL("../src/styles/admin.css", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const videosPageSource = readFileSync(
|
||||
new URL("../src/admin/VideosPage.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
function ruleBody(css: string, selector: string): string {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
@@ -66,6 +70,63 @@ test("admin tables scroll inside the mobile viewport", () => {
|
||||
assert.match(body, /display\s*:\s*block/);
|
||||
});
|
||||
|
||||
test("admin video filter select uses an aligned custom arrow", () => {
|
||||
const select = ruleBody(adminCss, ".admin-videos-filter__select");
|
||||
const icon = ruleBody(adminCss, ".admin-videos-filter__select-icon");
|
||||
const mobileWrap = ruleBodyByContains(mobileCss(), ".admin-videos-filter__select-wrap");
|
||||
|
||||
assert.match(select, /appearance\s*:\s*none/);
|
||||
assert.match(select, /padding\s*:\s*0\s+36px\s+0\s+var\(--space-3\)/);
|
||||
assert.match(icon, /top\s*:\s*50%/);
|
||||
assert.match(icon, /right\s*:\s*12px/);
|
||||
assert.match(icon, /transform\s*:\s*translateY\(-50%\)/);
|
||||
assert.match(mobileWrap, /flex\s*:\s*1\s+1\s+100%/);
|
||||
});
|
||||
|
||||
test("admin video bulk actions use semantic theme colors", () => {
|
||||
const base = ruleBody(adminCss, ".admin-videos-bulk-actions__btn");
|
||||
const primary = ruleBody(adminCss, ".admin-videos-bulk-actions__btn.is-primary");
|
||||
const danger = ruleBody(adminCss, ".admin-videos-bulk-actions__btn.is-danger");
|
||||
const dangerHover = ruleBody(adminCss, ".admin-videos-bulk-actions__btn.is-danger:hover:not(:disabled)");
|
||||
const bulkBodies = [base, primary, danger, dangerHover].join("\n");
|
||||
|
||||
assert.match(videosPageSource, /className="admin-btn is-primary admin-videos-bulk-actions__btn"/);
|
||||
assert.match(videosPageSource, /className="admin-btn is-danger admin-videos-bulk-actions__btn"/);
|
||||
assert.match(primary, /var\(--accent-glow\)/);
|
||||
assert.match(danger, /background\s*:\s*var\(--danger-soft\)/);
|
||||
assert.match(danger, /border-color\s*:\s*var\(--danger\)/);
|
||||
assert.match(danger, /color\s*:\s*var\(--danger\)/);
|
||||
assert.match(dangerHover, /background\s*:\s*var\(--danger\)/);
|
||||
assert.doesNotMatch(bulkBodies, /#ff5b8a|#fff6f9|rgba\(255,\s*91,\s*138/);
|
||||
});
|
||||
|
||||
test("mobile video management uses compact theme-aware video cards", () => {
|
||||
const css = mobileCss();
|
||||
const card = ruleBody(css, ".admin-videos-table:not(.admin-drives-table) tr");
|
||||
const title = ruleBody(css, ".admin-videos-table:not(.admin-drives-table) td[data-label=\"标题\"]");
|
||||
const label = ruleBody(css, ".admin-videos-table:not(.admin-drives-table) td::before");
|
||||
const pills = ruleBody(css, ".admin-videos-table:not(.admin-drives-table) .admin-video-filemeta-pills");
|
||||
const sourceColumn = ruleBodyByContains(css, ".admin-videos-table:not(.admin-drives-table) td[data-label=\"来源\"]");
|
||||
const actionButton = ruleBody(css, ".admin-videos-table:not(.admin-drives-table) td.is-actions .admin-btn");
|
||||
const dangerButton = ruleBody(css, ".admin-videos-table:not(.admin-drives-table) td.is-actions .admin-btn.is-danger");
|
||||
|
||||
assert.match(card, /--admin-video-card-bg\s*:\s*var\(--bg-surface\)/);
|
||||
assert.match(card, /background\s*:\s*var\(--admin-video-card-bg\)/);
|
||||
assert.match(card, /border-radius\s*:\s*14px/);
|
||||
assert.match(card, /padding\s*:\s*12px\s+14px/);
|
||||
assert.match(css, /:root:not\(\[data-theme="pink"\]\)\s+\.admin-videos-table:not\(\.admin-drives-table\)\s+tr\s*\{[^}]*--admin-video-card-bg\s*:\s*#1e1e1e/s);
|
||||
assert.match(css, /:root\[data-theme="pink"\]\s+\.admin-videos-table:not\(\.admin-drives-table\)\s+tr\s*\{/);
|
||||
assert.match(title, /padding-left\s*:\s*36px/);
|
||||
assert.match(label, /font-size\s*:\s*10px/);
|
||||
assert.match(label, /letter-spacing\s*:\s*0\.06em/);
|
||||
assert.match(pills, /display\s*:\s*flex/);
|
||||
assert.doesNotMatch(sourceColumn, /border-left/);
|
||||
assert.match(actionButton, /height\s*:\s*28px/);
|
||||
assert.match(actionButton, /border-radius\s*:\s*8px/);
|
||||
assert.match(dangerButton, /border-color\s*:\s*var\(--admin-video-card-danger-border\)/);
|
||||
assert.match(dangerButton, /color\s*:\s*var\(--admin-video-card-danger\)/);
|
||||
});
|
||||
|
||||
test("admin modals and action footers adapt on mobile", () => {
|
||||
const css = mobileCss();
|
||||
|
||||
@@ -74,6 +135,10 @@ test("admin modals and action footers adapt on mobile", () => {
|
||||
assert.match(ruleBody(adminCss, ".admin-modal"), /width\s*:\s*min\(\d+px,\s*100%\)/);
|
||||
// 多按钮 footer 在 mobile 下要换行避免溢出。
|
||||
assert.match(allRuleBodies(css, ".admin-modal__footer"), /flex-wrap\s*:\s*wrap/);
|
||||
// 删除/放弃类确认弹窗在 mobile 下不能跟随通用 modal stretch 到顶部。
|
||||
const confirmModal = ruleBody(css, ".admin-modal--delete-confirm");
|
||||
assert.match(confirmModal, /align-self\s*:\s*center/);
|
||||
assert.match(confirmModal, /justify-self\s*:\s*center/);
|
||||
// 表单 input/select/textarea 在 mobile 下铺满。规则用逗号合并写法(多 selector
|
||||
// 共享 body),所以走 ruleBodyByContains 而不是简单正则。
|
||||
assert.match(ruleBodyByContains(css, ".admin-form__row input"), /width\s*:\s*100%/);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
|
||||
const videosPageSource = readFileSync(new URL("../src/admin/VideosPage.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("admin videos page uses responsive page size", () => {
|
||||
assert.match(videosPageSource, /const DESKTOP_VIDEOS_PAGE_SIZE = 50;/);
|
||||
assert.match(videosPageSource, /const MOBILE_VIDEOS_PAGE_SIZE = 20;/);
|
||||
assert.match(videosPageSource, /const VIDEOS_MOBILE_QUERY = "\(max-width: 640px\)";/);
|
||||
assert.match(videosPageSource, /window\.matchMedia\(VIDEOS_MOBILE_QUERY\)/);
|
||||
assert.match(videosPageSource, /api\.listVideos\(\{ driveId, page, size: pageSize, keyword: searchKeyword \}\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user