diff --git a/internal/handler/media.go b/internal/handler/media.go index 2dcb94b..876fb22 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -147,7 +147,9 @@ roots = append(roots, service.LibraryRootInput{Path: req.Path}) } type updateLibraryReq struct { - CoverURL string `json:"cover_url"` + CoverURL *string `json:"cover_url"` + SortOrder *int `json:"sort_order"` + CarouselEnabled *bool `json:"carousel_enabled"` } func updateLibraryHandler(svc *service.Container) gin.HandlerFunc { @@ -157,9 +159,17 @@ func updateLibraryHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := svc.Media.UpdateLibraryCover(c.Request.Context(), c.Param("id"), req.CoverURL); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return + if req.CoverURL != nil { + if err := svc.Media.UpdateLibraryCover(c.Request.Context(), c.Param("id"), *req.CoverURL); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + if req.SortOrder != nil || req.CarouselEnabled != nil { + if err := svc.Media.UpdateLibraryFields(c.Request.Context(), c.Param("id"), req.SortOrder, req.CarouselEnabled); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } } lib, err := svc.Repo.Library.FindByID(c.Request.Context(), c.Param("id")) if err != nil || lib == nil { @@ -170,6 +180,25 @@ func updateLibraryHandler(svc *service.Container) gin.HandlerFunc { } } +type reorderLibrariesReq struct { + IDs []string `json:"ids" binding:"required"` +} + +func reorderLibrariesHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req reorderLibrariesReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := svc.Media.ReorderLibraries(c.Request.Context(), req.IDs); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"updated": len(req.IDs)}) + } +} + func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index ae806c8..4801052 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -50,6 +50,8 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) { admin.GET("/strm/downloads", downloadQueueHandler(svc)) admin.POST("/strm/downloads/:id/cancel", cancelStrmDownloadHandler(svc)) admin.POST("/strm/downloads/:id/retry", retryStrmDownloadHandler(svc)) + admin.DELETE("/strm/downloads/:id", deleteStrmDownloadHandler(svc)) + admin.POST("/strm/downloads/batch", batchActionDownloadsHandler(svc)) admin.POST("/strm/downloads/clear-done", clearDoneDownloadsHandler(svc)) admin.POST("/strm/downloads/clear-finished", clearFinishedDownloadsHandler(svc)) admin.POST("/strm/downloads/clear-canceled", clearCanceledDownloadsHandler(svc)) @@ -58,6 +60,8 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) { admin.GET("/strm/uploads", uploadQueueHandler(svc)) admin.POST("/strm/uploads/:id/cancel", cancelStrmUploadHandler(svc)) admin.POST("/strm/uploads/:id/retry", retryStrmUploadHandler(svc)) + admin.DELETE("/strm/uploads/:id", deleteStrmUploadHandler(svc)) + admin.POST("/strm/uploads/batch", batchActionUploadsHandler(svc)) admin.POST("/strm/uploads/cancel-pending", cancelPendingUploadsHandler(svc)) admin.POST("/strm/uploads/clear-canceled", clearCanceledUploadsHandler(svc)) } diff --git a/internal/handler/routes_authenticated_core.go b/internal/handler/routes_authenticated_core.go index 2cf2ced..3434a30 100644 --- a/internal/handler/routes_authenticated_core.go +++ b/internal/handler/routes_authenticated_core.go @@ -21,6 +21,7 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc)) authed.GET("/libraries/:id", getLibraryHandler(svc)) authed.PATCH("/libraries/:id", middleware.AdminRequired(), updateLibraryHandler(svc)) + authed.PUT("/libraries/reorder", middleware.AdminRequired(), reorderLibrariesHandler(svc)) authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc)) authed.GET("/libraries/:id/roots", middleware.AdminRequired(), listLibraryRootsHandler(svc)) authed.POST("/libraries/:id/roots", middleware.AdminRequired(), createLibraryRootHandler(svc)) diff --git a/internal/handler/strm.go b/internal/handler/strm.go index 09c3da1..8f897fc 100644 --- a/internal/handler/strm.go +++ b/internal/handler/strm.go @@ -393,6 +393,63 @@ func retryStrmUploadHandler(svc *service.Container) gin.HandlerFunc { } } +func deleteStrmDownloadHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + if err := svc.Strm.DeleteDownloadTask(c.Request.Context(), c.Param("id")); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) + } +} + +func deleteStrmUploadHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + if err := svc.Strm.DeleteUploadTask(c.Request.Context(), c.Param("id")); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) + } +} + +type queueBatchReq struct { + Action string `json:"action" binding:"required"` + IDs []string `json:"ids" binding:"required"` +} + +func batchActionDownloadsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req queueBatchReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + n, err := svc.Strm.BatchActionDownloadTasks(c.Request.Context(), req.Action, req.IDs) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"affected": n, "action": req.Action}) + } +} + +func batchActionUploadsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req queueBatchReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + n, err := svc.Strm.BatchActionUploadTasks(c.Request.Context(), req.Action, req.IDs) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"affected": n, "action": req.Action}) + } +} + // ─── 下载队列批量操作 ───────────────────────────────────────────────────────── func clearDoneDownloadsHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/model/library_media.go b/internal/model/library_media.go index a2805d7..0061aed 100644 --- a/internal/model/library_media.go +++ b/internal/model/library_media.go @@ -3,12 +3,14 @@ package model // Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。 type Library struct { Base - Name string `gorm:"size:128;not null" json:"name"` - Path string `gorm:"size:1024;not null" json:"path"` - Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music - CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"` - Enabled bool `gorm:"default:true" json:"enabled"` - Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"` + Name string `gorm:"size:128;not null" json:"name"` + Path string `gorm:"size:1024;not null" json:"path"` + Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music + CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"` + Enabled bool `gorm:"default:true" json:"enabled"` + SortOrder int `gorm:"index;default:0" json:"sort_order"` // 手动拖拽排序用,越小越靠前 + CarouselEnabled bool `gorm:"default:true" json:"carousel_enabled"` // 是否参与首页海报轮播 + Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"` } // LibraryRoot 是逻辑媒体库下的一条真实物理/挂载路径。 diff --git a/internal/repository/library_repository.go b/internal/repository/library_repository.go index 2d9d90e..4c0c714 100644 --- a/internal/repository/library_repository.go +++ b/internal/repository/library_repository.go @@ -50,7 +50,7 @@ func (r *LibraryRepository) CreateWithRoots(ctx context.Context, l *model.Librar // List returns all enabled+disabled libraries. func (r *LibraryRepository) List(ctx context.Context) ([]model.Library, error) { var ls []model.Library - q := r.db.WithContext(ctx).Order("created_at asc") + q := r.db.WithContext(ctx).Order("sort_order asc, created_at asc") if r.hasLibraryRootsTable() { q = q.Preload("Roots", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order asc, created_at asc") @@ -60,6 +60,23 @@ func (r *LibraryRepository) List(ctx context.Context) ([]model.Library, error) { return ls, err } +// SetSortOrder assigns sort_order to libraries, preserving position order for +// any library not present in the provided map. +func (r *LibraryRepository) SetSortOrder(ctx context.Context, ids []string) error { + if len(ids) == 0 { + return nil + } + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for i, id := range ids { + if err := tx.Model(&model.Library{}).Where("id = ?", id). + Update("sort_order", i).Error; err != nil { + return err + } + } + return nil + }) +} + // FindByID returns the library, or (nil, nil) when missing. func (r *LibraryRepository) FindByID(ctx context.Context, id string) (*model.Library, error) { var l model.Library diff --git a/internal/repository/strm_repository.go b/internal/repository/strm_repository.go index 838dd62..bdfacb2 100644 --- a/internal/repository/strm_repository.go +++ b/internal/repository/strm_repository.go @@ -308,6 +308,66 @@ func (r *StrmDownloadTaskRepository) Delete(ctx context.Context, id string) erro }) } +// DeleteBatch 批量删除指定 ID 的下载任务。 +func (r *StrmDownloadTaskRepository) DeleteBatch(ctx context.Context, ids []string) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + var count int64 + err := withSQLiteBusyRetry(ctx, func() error { + res := r.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Delete(&model.StrmDownloadTask{}) + count = res.RowsAffected + return res.Error + }) + return count, err +} + +// RetryBatch 批量重试指定 ID 的失败/已取消下载任务。 +func (r *StrmDownloadTaskRepository) RetryBatch(ctx context.Context, ids []string) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + var count int64 + err := withSQLiteBusyRetry(ctx, func() error { + res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}). + Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskFailed, model.StrmTaskCanceled}). + Updates(map[string]any{ + "status": model.StrmTaskPending, + "error": "", + "retry_count": 0, + "next_try_at": nil, + "started_at": nil, + "finished_at": nil, + "updated_at": time.Now(), + }) + count = res.RowsAffected + return res.Error + }) + return count, err +} + +// CancelBatch 批量取消指定 ID 的排队/进行中下载任务。 +func (r *StrmDownloadTaskRepository) CancelBatch(ctx context.Context, ids []string) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + now := time.Now() + var count int64 + err := withSQLiteBusyRetry(ctx, func() error { + res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}). + Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskPending, model.StrmTaskRunning}). + Updates(map[string]any{ + "status": model.StrmTaskCanceled, + "error": "已批量取消", + "finished_at": now, + "updated_at": now, + }) + count = res.RowsAffected + return res.Error + }) + return count, err +} + // ClearDone 清空全部已完成下载任务。 func (r *StrmDownloadTaskRepository) ClearDone(ctx context.Context) (int64, error) { var count int64 @@ -555,6 +615,66 @@ func (r *StrmUploadTaskRepository) Delete(ctx context.Context, id string) error }) } +// DeleteBatch 批量删除指定 ID 的上传任务。 +func (r *StrmUploadTaskRepository) DeleteBatch(ctx context.Context, ids []string) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + var count int64 + err := withSQLiteBusyRetry(ctx, func() error { + res := r.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Delete(&model.StrmUploadTask{}) + count = res.RowsAffected + return res.Error + }) + return count, err +} + +// RetryBatch 批量重试指定 ID 的失败/已取消上传任务。 +func (r *StrmUploadTaskRepository) RetryBatch(ctx context.Context, ids []string) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + var count int64 + err := withSQLiteBusyRetry(ctx, func() error { + res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}). + Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskFailed, model.StrmTaskCanceled}). + Updates(map[string]any{ + "status": model.StrmTaskPending, + "error": "", + "retry_count": 0, + "next_try_at": nil, + "started_at": nil, + "finished_at": nil, + "updated_at": time.Now(), + }) + count = res.RowsAffected + return res.Error + }) + return count, err +} + +// CancelBatch 批量取消指定 ID 的排队/进行中上传任务。 +func (r *StrmUploadTaskRepository) CancelBatch(ctx context.Context, ids []string) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + now := time.Now() + var count int64 + err := withSQLiteBusyRetry(ctx, func() error { + res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}). + Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskPending, model.StrmTaskRunning}). + Updates(map[string]any{ + "status": model.StrmTaskCanceled, + "error": "已批量取消", + "finished_at": now, + "updated_at": now, + }) + count = res.RowsAffected + return res.Error + }) + return count, err +} + // ClearCanceled 清空全部已取消上传任务。 func (r *StrmUploadTaskRepository) ClearCanceled(ctx context.Context) (int64, error) { var count int64 diff --git a/internal/service/media_library_roots.go b/internal/service/media_library_roots.go index 52c9a0f..7493456 100644 --- a/internal/service/media_library_roots.go +++ b/internal/service/media_library_roots.go @@ -105,6 +105,27 @@ func (s *MediaService) UpdateLibraryCover(ctx context.Context, libraryID, coverU Update("cover_url", strings.TrimSpace(coverURL)).Error } +// UpdateLibraryFields updates sort_order / carousel_enabled on a library. +func (s *MediaService) UpdateLibraryFields(ctx context.Context, libraryID string, sortOrder *int, carouselEnabled *bool) error { + updates := map[string]any{} + if sortOrder != nil { + updates["sort_order"] = *sortOrder + } + if carouselEnabled != nil { + updates["carousel_enabled"] = *carouselEnabled + } + if len(updates) == 0 || strings.TrimSpace(libraryID) == "" { + return nil + } + return s.repo.DB.WithContext(ctx).Model(&model.Library{}). + Where("id = ?", libraryID).Updates(updates).Error +} + +// ReorderLibraries persists a full media-library ordering. +func (s *MediaService) ReorderLibraries(ctx context.Context, ids []string) error { + return s.repo.Library.SetSortOrder(ctx, ids) +} + func (s *MediaService) findLogicalLibrary(ctx context.Context, name, kind string) (*model.Library, error) { if s == nil || s.repo == nil || s.repo.Library == nil { return nil, nil diff --git a/internal/service/strm_queue.go b/internal/service/strm_queue.go index 4e48de0..10148b5 100644 --- a/internal/service/strm_queue.go +++ b/internal/service/strm_queue.go @@ -551,6 +551,44 @@ func (s *StrmService) RetryUploadTask(ctx context.Context, id string) error { // ─── 下载队列批量操作(handler 使用) ───────────────────────────────────────── +// DeleteDownloadTask 删除一个下载任务记录。 +func (s *StrmService) DeleteDownloadTask(ctx context.Context, id string) error { + return s.repo.StrmDownload.Delete(ctx, id) +} + +// DeleteUploadTask 删除一个上传任务记录。 +func (s *StrmService) DeleteUploadTask(ctx context.Context, id string) error { + return s.repo.StrmUpload.Delete(ctx, id) +} + +// BatchActionDownloadTasks 对选中的下载任务执行批量操作(delete / retry / cancel)。 +func (s *StrmService) BatchActionDownloadTasks(ctx context.Context, action string, ids []string) (int64, error) { + switch action { + case "delete": + return s.repo.StrmDownload.DeleteBatch(ctx, ids) + case "retry": + return s.repo.StrmDownload.RetryBatch(ctx, ids) + case "cancel": + return s.repo.StrmDownload.CancelBatch(ctx, ids) + default: + return 0, fmt.Errorf("不支持的批量操作: %s", action) + } +} + +// BatchActionUploadTasks 对选中的上传任务执行批量操作(delete / retry / cancel)。 +func (s *StrmService) BatchActionUploadTasks(ctx context.Context, action string, ids []string) (int64, error) { + switch action { + case "delete": + return s.repo.StrmUpload.DeleteBatch(ctx, ids) + case "retry": + return s.repo.StrmUpload.RetryBatch(ctx, ids) + case "cancel": + return s.repo.StrmUpload.CancelBatch(ctx, ids) + default: + return 0, fmt.Errorf("不支持的批量操作: %s", action) + } +} + // ClearDoneDownloadTasks 清空全部已完成下载记录,返回删除数量。 func (s *StrmService) ClearDoneDownloadTasks(ctx context.Context) (int64, error) { return s.repo.StrmDownload.ClearDone(ctx) diff --git a/web/src/api/library.ts b/web/src/api/library.ts index 4348650..05d23d6 100644 --- a/web/src/api/library.ts +++ b/web/src/api/library.ts @@ -105,8 +105,16 @@ export const libraryAPI = { createPerSubfolder: (parentPath: string, type: string, coverURL = '') => api.post<{ libraries: Library[] }>('/libraries', { path: parentPath, type, cover_url: coverURL, create_per_subfolder: true }).then((r) => r.data), - update: (id: string, payload: { cover_url: string }) => - api.patch(`/libraries/${id}`, payload).then((r) => r.data), + update: ( + id: string, + payload: { + cover_url?: string + sort_order?: number | null + carousel_enabled?: boolean | null + }, + ) => api.patch(`/libraries/${id}`, payload).then((r) => r.data), + + reorder: (ids: string[]) => api.put('/libraries/reorder', { ids }).then((r) => r.data), remove: (id: string) => api.delete(`/libraries/${id}`).then((r) => r.data), diff --git a/web/src/api/strm.ts b/web/src/api/strm.ts index 9310d23..e29bda9 100644 --- a/web/src/api/strm.ts +++ b/web/src/api/strm.ts @@ -144,6 +144,12 @@ export const strmAPI = { retryDownload: (id: string) => api.post(`/admin/strm/downloads/${id}/retry`).then((r) => r.data), + deleteDownload: (id: string) => + api.delete(`/admin/strm/downloads/${id}`).then((r) => r.data), + + batchActionDownloads: (action: 'delete' | 'retry' | 'cancel', ids: string[]) => + api.post<{ affected: number; action: string }>('/admin/strm/downloads/batch', { action, ids }).then((r) => r.data), + clearDoneDownloads: () => api.post<{ deleted: number }>('/admin/strm/downloads/clear-done').then((r) => r.data), @@ -169,12 +175,18 @@ export const strmAPI = { cancelUpload: (id: string) => api.post(`/admin/strm/uploads/${id}/cancel`).then((r) => r.data), + retryUpload: (id: string) => + api.post(`/admin/strm/uploads/${id}/retry`).then((r) => r.data), + + deleteUpload: (id: string) => + api.delete(`/admin/strm/uploads/${id}`).then((r) => r.data), + + batchActionUploads: (action: 'delete' | 'retry' | 'cancel', ids: string[]) => + api.post<{ affected: number; action: string }>('/admin/strm/uploads/batch', { action, ids }).then((r) => r.data), + cancelPendingUploads: () => api.post<{ canceled: number }>('/admin/strm/uploads/cancel-pending').then((r) => r.data), clearCanceledUploads: () => api.post<{ deleted: number }>('/admin/strm/uploads/clear-canceled').then((r) => r.data), - - retryUpload: (id: string) => - api.post(`/admin/strm/uploads/${id}/retry`).then((r) => r.data), } \ No newline at end of file diff --git a/web/src/pages/AdminLibraryPanel.tsx b/web/src/pages/AdminLibraryPanel.tsx index ab8d649..d7017da 100644 --- a/web/src/pages/AdminLibraryPanel.tsx +++ b/web/src/pages/AdminLibraryPanel.tsx @@ -35,6 +35,8 @@ export function AdminLibraryPanel() { onRemoveLibrary={libraryActions.removeLibrary} onAddLibraryRoot={libraryActions.addLibraryRoot} onEditLibraryCover={libraryActions.editLibraryCover} + onToggleCarousel={libraryActions.toggleCarouselLibrary} + onReorder={libraryActions.reorderLibraries} /> diff --git a/web/src/pages/AdminLibraryTable.tsx b/web/src/pages/AdminLibraryTable.tsx index 57bda79..8e2e91b 100644 --- a/web/src/pages/AdminLibraryTable.tsx +++ b/web/src/pages/AdminLibraryTable.tsx @@ -1,5 +1,6 @@ -import { useState, type MouseEvent, type ReactNode } from 'react' -import { Folder, Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react' +import { useEffect, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { Folder, GripVertical, Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react' import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog' import type { Library, LibraryRoot } from '../types' @@ -18,11 +19,15 @@ type LibraryTableProps = { onRemoveLibrary: (library: Library) => void onAddLibraryRoot: (library: Library, path?: string, name?: string) => void onEditLibraryCover: (library: Library) => void + onToggleCarousel: (library: Library) => void + onReorder: (orderedLibs: Library[]) => void } export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) { const [browsingRoot, setBrowsingRoot] = useState<{ libraryID: string; root: LibraryRoot; initialPath?: string } | null>(null) const [addingRootLib, setAddingRootLib] = useState(null) + const [draggingId, setDraggingId] = useState(null) + const dragOverId = useRef(null) const handleSelectRootPath = (selectedPath: string) => { if (browsingRoot) { @@ -40,15 +45,39 @@ export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) { } } + const handleReorder = (fromId: string, overId: string) => { + if (fromId === overId) return + const copy = [...libs] + const fromIndex = copy.findIndex((l) => l.id === fromId) + const overIndex = copy.findIndex((l) => l.id === overId) + if (fromIndex < 0 || overIndex < 0) return + const [moved] = copy.splice(fromIndex, 1) + copy.splice(overIndex, 0, moved) + actions.onReorder(copy) + } + + const handleDrop = (e: DragEvent, overId: string) => { + e.preventDefault() + dragOverId.current = null + setDraggingId(null) + if (draggingId && overId !== draggingId) { + handleReorder(draggingId, overId) + } + } + return ( <>
- +
+ + @@ -59,6 +88,23 @@ export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) { library={library} onBrowseRoot={(root) => setBrowsingRoot({ libraryID: library.id, root, initialPath: root.path })} onOpenAddRoot={() => setAddingRootLib(library)} + dragging={draggingId === library.id} + dragOver={dragOverId.current === library.id} + onDragStart={(e) => { + e.dataTransfer.effectAllowed = 'move' + dragOverId.current = null + setDraggingId(library.id) + }} + onDragOver={(e) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + if (dragOverId.current !== library.id) dragOverId.current = library.id + }} + onDragEnd={() => { + dragOverId.current = null + setDraggingId(null) + }} + onDrop={(e) => handleDrop(e, library.id)} {...actions} /> ))} @@ -90,11 +136,37 @@ type LibraryTableRowProps = Omit & { library: Library onBrowseRoot: (root: LibraryRoot) => void onOpenAddRoot: () => void + dragging?: boolean + dragOver?: boolean + onDragStart?: (e: DragEvent) => void + onDragOver?: (e: DragEvent) => void + onDragEnd?: () => void + onDrop?: (e: DragEvent) => void } -function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) { +function LibraryTableRow({ library, dragging, dragOver, onDragStart, onDragOver, onDragEnd, onDrop, ...actions }: LibraryTableRowProps) { return ( - + + + @@ -112,6 +187,25 @@ function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) { ) } +function CarouselToggle({ library, onToggleCarousel }: { library: Library; onToggleCarousel: (library: Library) => void }) { + const on = library.carousel_enabled ?? true + return ( + + ) +} + function LibraryRootsCell({ library, ...actions }: LibraryTableRowProps) { const roots = library.roots?.length ? library.roots : [fallbackLibraryRoot(library)] return ( @@ -255,18 +349,90 @@ function LibraryActionsCell({ library, onScanLibrary, onRemoveLibrary, onOpenAdd } function ActionMenu({ label, children }: { label: string; children: ReactNode }) { + const [isOpen, setIsOpen] = useState(false) + const [coords, setCoords] = useState<{ top?: number; bottom?: number; right: number } | null>(null) + const triggerRef = useRef(null) + const menuRef = useRef(null) + + const toggleMenu = (e: MouseEvent) => { + e.stopPropagation() + if (isOpen) { + setIsOpen(false) + return + } + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect() + const spaceBelow = window.innerHeight - rect.bottom + const estimatedHeight = 180 + const openUpward = spaceBelow < estimatedHeight && rect.top > estimatedHeight + setCoords({ + top: openUpward ? undefined : rect.bottom + 4, + bottom: openUpward ? window.innerHeight - rect.top + 4 : undefined, + right: window.innerWidth - rect.right, + }) + setIsOpen(true) + } + } + + useEffect(() => { + if (!isOpen) return + const handleClickOutside = (e: globalThis.MouseEvent) => { + if ( + menuRef.current && + !menuRef.current.contains(e.target as Node) && + triggerRef.current && + !triggerRef.current.contains(e.target as Node) + ) { + setIsOpen(false) + } + } + const handleScrollOrResize = () => setIsOpen(false) + window.addEventListener('mousedown', handleClickOutside) + window.addEventListener('scroll', handleScrollOrResize, true) + window.addEventListener('resize', handleScrollOrResize) + return () => { + window.removeEventListener('mousedown', handleClickOutside) + window.removeEventListener('scroll', handleScrollOrResize, true) + window.removeEventListener('resize', handleScrollOrResize) + } + }, [isOpen]) + return ( -
- + -
- {children} -
-
+ + + {isOpen && + coords && + createPortal( +
setIsOpen(false)} + > + {children} +
, + document.body, + )} + ) } @@ -283,17 +449,19 @@ function MenuButton({ onClick: () => void children: ReactNode }) { - const handleClick = (event: MouseEvent) => { - event.currentTarget.closest('details')?.removeAttribute('open') - onClick() - } return ( - - - - - {/* 媒体库列表卡片 */} - {libraries.length === 0 ? ( -
- 暂无可用媒体库,请先添加媒体库后再配置海报轮播。 -
- ) : ( -
- {libraries.map((lib) => { - const isSelected = selectedIds.includes(lib.id) - const cards = libraryCards[lib.id] || [] - const artwork = getLibraryArtworks(lib, cards) - - return ( -
toggleLibrary(lib.id)} - className={`flex cursor-pointer items-center justify-between rounded-2xl border p-4 transition-all duration-200 select-none ${ - isSelected - ? 'border-brand-500/60 bg-primary-400/10 shadow-sm' - : 'border-gray-200 bg-white/70 hover:border-gray-300' - }`} - > -
-
1 ? 'grid-cols-2' : 'grid-cols-1' - } ${ - isSelected ? 'ring-2 ring-brand-500/30' : '' - }`} - > - {artwork.length > 0 ? ( - artwork.map(({ src, version }, index) => ( - { - e.currentTarget.style.display = 'none' - }} - /> - )) - ) : ( -
- {TYPE_ICONS[lib.type] || } -
- )} -
-
-
- - {lib.name} - - - {TYPE_LABELS[lib.type] || '自定义'} - -
-

- {isSelected ? '已启用轮播' : '未参与轮播'} -

-
-
- -
- -
-
- ) - })} -
- )} - - {/* 底部保存按钮 */} -
- - 已选择 {selectedIds.length} / {libraries.length} 个媒体库参与轮播 - - -
- - ) -} diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 3fd3596..266a336 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -8,7 +8,6 @@ import { libraryAPI } from '../api/library' import type { Library, Setting } from '../types' import { APIConfigsPanel } from '../components/APIConfigsPanel' import { AdultSettingsPanel } from './AdultSettingsPanel' -import { LibrarySettingsPanel } from './LibrarySettingsPanel' import { RecognitionWordsPanel } from './RecognitionWordsPanel' import { SettingRow } from './SettingsRow' import { ALL_KEYS, GROUPS } from './settingsGroups' @@ -168,7 +167,6 @@ export function SettingsPage() { {!loading && (
- {group.key === 'library' && } {group.key === 'api-configs' && } {group.key === 'recognition-words' && } {group.key === 'adult' && } diff --git a/web/src/pages/StrmQueuePage.tsx b/web/src/pages/StrmQueuePage.tsx index b911bc7..5cdd51d 100644 --- a/web/src/pages/StrmQueuePage.tsx +++ b/web/src/pages/StrmQueuePage.tsx @@ -1,62 +1,127 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' import toast from 'react-hot-toast' -import { Ban, Download, Loader2, RefreshCw, Trash2, Upload } from 'lucide-react' +import { + AlertCircle, + Ban, + CheckCircle2, + Clock, + Copy, + Download, + Eye, + File, + FileText, + Film, + Image as ImageIcon, + Loader2, + MessageSquare, + PlayCircle, + RefreshCw, + Search, + Trash2, + Upload, + X, +} from 'lucide-react' import { strmAPI } from '../api/strm' import type { StrmQueueSnapshot, StrmTask, StrmTaskStatus } from '../types/strm' import { STRM_PROVIDER_LABELS } from '../types/strm' import { apiErrorMessage, formatBytes, formatTime, taskStatusMeta } from './StrmManagePage' -const FILTERS: { key: 'all' | StrmTaskStatus; label: string }[] = [ - { key: 'all', label: '全部' }, - { key: 'pending', label: '排队中' }, - { key: 'running', label: '进行中' }, - { key: 'done', label: '已完成' }, - { key: 'failed', label: '失败' }, - { key: 'canceled', label: '已取消' }, +const FILTERS: { key: 'all' | StrmTaskStatus; label: string; icon: typeof Clock; color: string }[] = [ + { key: 'all', label: '全部', icon: Clock, color: 'text-ink-600' }, + { key: 'pending', label: '排队中', icon: Clock, color: 'text-gray-500' }, + { key: 'running', label: '进行中', icon: PlayCircle, color: 'text-brand-500' }, + { key: 'done', label: '已完成', icon: CheckCircle2, color: 'text-emerald-500' }, + { key: 'failed', label: '失败', icon: AlertCircle, color: 'text-rose-500' }, + { key: 'canceled', label: '已取消', icon: Ban, color: 'text-amber-500' }, ] const PAGE_SIZE = 50 +function getFileIcon(filename: string): ReactNode { + const ext = filename.split('.').pop()?.toLowerCase() ?? '' + if (['nfo', 'txt', 'xml', 'json'].includes(ext)) { + return + } + if (['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'svg'].includes(ext)) { + return + } + if (['srt', 'ass', 'ssa', 'sub', 'vtt'].includes(ext)) { + return + } + if (['mkv', 'mp4', 'avi', 'mov', 'wmv', 'ts', 'flv', 'iso', 'm4v', 'strm'].includes(ext)) { + return + } + return +} + function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) { const [snapshot, setSnapshot] = useState(null) const [filter, setFilter] = useState<'all' | StrmTaskStatus>('all') + const [search, setSearch] = useState('') const [page, setPage] = useState(1) const [totalPages, setTotalPages] = useState(1) const [loading, setLoading] = useState(true) + const [isRefreshing, setIsRefreshing] = useState(false) + const [autoRefresh, setAutoRefresh] = useState(true) const [batchBusy, setBatchBusy] = useState(false) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [detailTask, setDetailTask] = useState(null) - const refresh = useCallback(async () => { - try { - const status = filter === 'all' ? undefined : filter - const data = - kind === 'download' + const isDownload = kind === 'download' + const Icon = isDownload ? Download : Upload + + const refresh = useCallback( + async (showLoading = false) => { + if (showLoading) setIsRefreshing(true) + try { + const status = filter === 'all' ? undefined : filter + const data = isDownload ? await strmAPI.downloads(status, page, PAGE_SIZE) : await strmAPI.uploads(status, page, PAGE_SIZE) - const tp = Math.max(1, Math.ceil((data.total ?? data.tasks.length) / PAGE_SIZE)) - if (page > tp) { - // 当前页超出范围(数据被批量删除),回退到最后一页再刷新 - setPage(tp) - return + const tp = Math.max(1, Math.ceil((data.total ?? data.tasks.length) / PAGE_SIZE)) + if (page > tp) { + setPage(tp) + return + } + setTotalPages(tp) + setSnapshot(data) + } catch { + /* keep existing data */ + } finally { + setLoading(false) + if (showLoading) setIsRefreshing(false) } - setTotalPages(tp) - setSnapshot(data) - } catch { - /* keep last data */ - } finally { - setLoading(false) - } - }, [kind, filter, page]) + }, + [isDownload, filter, page], + ) useEffect(() => { refresh().catch(() => undefined) - const timer = setInterval(() => refresh().catch(() => undefined), 3000) - return () => clearInterval(timer) }, [refresh]) + useEffect(() => { + if (!autoRefresh) return + const timer = setInterval(() => { + refresh().catch(() => undefined) + }, 3000) + return () => clearInterval(timer) + }, [autoRefresh, refresh]) + + // Clear selections when changing filter or page + useEffect(() => { + setSelectedIds(new Set()) + }, [filter, page]) + + const copyText = (text: string, label: string) => { + navigator.clipboard.writeText(text) + toast.success(`已复制${label}`) + } + + // Task actions const cancelTask = async (task: StrmTask) => { try { - if (kind === 'download') await strmAPI.cancelDownload(task.id) + if (isDownload) await strmAPI.cancelDownload(task.id) else await strmAPI.cancelUpload(task.id) toast.success('已取消任务') await refresh() @@ -67,7 +132,7 @@ function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) { const retryTask = async (task: StrmTask) => { try { - if (kind === 'download') await strmAPI.retryDownload(task.id) + if (isDownload) await strmAPI.retryDownload(task.id) else await strmAPI.retryUpload(task.id) toast.success('已重新入队') await refresh() @@ -76,8 +141,44 @@ function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) { } } - // 批量操作:可选确认弹窗,操作中锁定按钮,结束后刷新。 - const runBatch = async ( + const deleteTask = async (task: StrmTask) => { + try { + if (isDownload) await strmAPI.deleteDownload(task.id) + else await strmAPI.deleteUpload(task.id) + toast.success('已删除记录') + if (detailTask?.id === task.id) setDetailTask(null) + await refresh() + } catch (err) { + toast.error(apiErrorMessage(err)) + } + } + + // Selected batch actions + const runSelectedBatch = async (action: 'retry' | 'cancel' | 'delete') => { + const ids = Array.from(selectedIds) + if (ids.length === 0) return + + const actionText = action === 'retry' ? '重试' : action === 'cancel' ? '取消' : '删除' + if (action === 'delete' && !window.confirm(`确定删除选中的 ${ids.length} 条记录?`)) return + if (action === 'cancel' && !window.confirm(`确定取消选中的 ${ids.length} 个任务?`)) return + + setBatchBusy(true) + try { + const res = isDownload + ? await strmAPI.batchActionDownloads(action, ids) + : await strmAPI.batchActionUploads(action, ids) + toast.success(`已成功${actionText} ${res.affected} 项`) + setSelectedIds(new Set()) + await refresh() + } catch (err) { + toast.error(apiErrorMessage(err)) + } finally { + setBatchBusy(false) + } + } + + // Global batch actions + const runGlobalBatch = async ( action: () => Promise<{ deleted?: number; retried?: number; canceled?: number }>, confirmMsg?: string, ) => { @@ -96,219 +197,488 @@ function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) { } } - const batchBtn = (title: string, icon: 'trash' | 'ban' | 'refresh', cls: string, onClick: () => void) => ( - - ) - - const cancelAllPendingAction = () => { - const action = isDownload - ? () => strmAPI.cancelPendingDownloads() - : () => strmAPI.cancelPendingUploads() - return runBatch(action, `确定取消所有排队中及进行中的${isDownload ? '下载' : '上传'}任务?`) - } - - const batchActionByFilter = () => { - if (filter === 'pending' || filter === 'running') - return batchBtn( - '全部取消', - 'ban', - 'border-amber-200 text-amber-600 hover:bg-amber-50', - cancelAllPendingAction, + // Filter and search tasks in memory + const tasks = snapshot?.tasks ?? [] + const filteredTasks = useMemo(() => { + let list = tasks + if (filter !== 'all') { + list = list.filter((t) => t.status === filter) + } + if (search.trim()) { + const q = search.trim().toLowerCase() + list = list.filter( + (t) => + t.file_name.toLowerCase().includes(q) || + t.local_path.toLowerCase().includes(q) || + t.remote_path.toLowerCase().includes(q) || + (t.error && t.error.toLowerCase().includes(q)), ) - if (filter === 'canceled') - return batchBtn( - '清空已取消记录', - 'trash', - 'border-gray-200 text-rose-500 hover:bg-rose-50', - () => - runBatch( - isDownload - ? () => strmAPI.clearCanceledDownloads() - : () => strmAPI.clearCanceledUploads(), - `确定清空所有已取消的${isDownload ? '下载' : '上传'}记录?`, - ), - ) - if (!isDownload) return null - if (filter === 'done') - return batchBtn( - '清空成功记录', - 'trash', - 'border-gray-200 text-rose-500 hover:bg-rose-50', - () => runBatch(() => strmAPI.clearDoneDownloads(), '确定清空所有已完成下载记录?'), - ) - if (filter === 'failed') - return batchBtn('批量重试', 'refresh', 'border-gray-200 text-brand-500 hover:bg-brand-50', () => - runBatch(() => strmAPI.retryFailedDownloads(), '确定重新入队所有失败下载任务?'), - ) - return null - } + } + return list + }, [tasks, filter, search]) const counts = snapshot?.counts const activeTaskCount = (counts?.pending ?? 0) + (counts?.running ?? 0) - const tasks = snapshot?.tasks.filter((t) => filter === 'all' || t.status === filter) ?? [] - const isDownload = kind === 'download' - const Icon = isDownload ? Download : Upload + const failedCount = counts?.failed ?? 0 + const allCurrentChecked = + filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id)) + + const toggleSelectAll = () => { + if (allCurrentChecked) { + setSelectedIds(new Set()) + } else { + setSelectedIds(new Set(filteredTasks.map((t) => t.id))) + } + } + + const toggleSelectRow = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } return ( -
-
-
- +
+ {/* 1. Header */} +
+
+
+ +
+
+
+

+ {isDownload ? '下载队列' : '上传队列'} +

+ {autoRefresh && ( + + + 实时同步 + + )} +
+

+ {isDownload + ? 'STRM 媒体元数据下载队列(远端网盘 → 本地存储目录)' + : 'STRM 媒体元数据上传队列(本地存储目录 → 远端网盘)'} +

+
-
-

- {isDownload ? '下载队列' : '上传队列'} -

-

- {isDownload - ? 'STRM 元数据下载情况(远端网盘 → 本地输出目录,3 秒自动刷新)' - : 'STRM 元数据上传情况(本地 → 远端网盘,3 秒自动刷新)'} -

-
-
- {activeTaskCount > 0 && ( - - )} - {isDownload && ( - - )} - + + + + {/* Quick Clean Actions */} +
+ + + 清理与重试 + +
+ {failedCount > 0 && ( + + )} + {activeTaskCount > 0 && ( + + )} +
+ {isDownload && ( + + )} + + {isDownload && ( + + )} +
+
-
- - - - - + {/* 2. Interactive Status Cards */} +
+ {FILTERS.map((item) => { + const count = + item.key === 'all' + ? (counts?.pending ?? 0) + + (counts?.running ?? 0) + + (counts?.done ?? 0) + + (counts?.failed ?? 0) + + (counts?.canceled ?? 0) + : counts?.[item.key] ?? 0 + const isActive = filter === item.key + const ItemIcon = item.icon + + return ( + + ) + })}
-
- {FILTERS.map((item) => ( - - ))} - {batchActionByFilter()} -
+ {/* 3. Search & Batch Toolbar */} +
+ {/* Search input */} +
+ + setSearch(e.target.value)} + placeholder="搜索文件名、本地/远端路径或错误信息…" + className="h-9 w-full rounded-xl border border-gray-200 bg-white pl-9 pr-8 text-xs text-ink-600 placeholder:text-gray-400 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100/60" + /> + {search && ( + + )} +
-
- {loading ? ( -
- + {/* Selected Batch Toolbar */} + {selectedIds.size > 0 && ( +
+ 已选中 {selectedIds.size} 项 +
+ + + + +
+ )} +
+ + {/* 4. Task Table */} +
+ {loading ? ( +
+ +
+ ) : filteredTasks.length === 0 ? ( +
+ {search + ? '没有找到符合搜索条件的任务' + : filter === 'all' + ? isDownload + ? '暂无元数据下载任务' + : '暂无元数据上传任务' + : `「${FILTERS.find((f) => f.key === filter)?.label}」状态下暂无任务`}
- ) : tasks.length === 0 ? ( -

- {filter === 'all' ? (isDownload ? '暂无元数据下载任务' : '暂无元数据上传任务') : '该状态下暂无任务'} -

) : (
+ + 名称 路径 类型轮播 操作
+ +
{library.cover_url && } @@ -105,6 +177,9 @@ function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
{library.type} + +
- + - - - - - - - - + + + + + + + + - - {tasks.map((task) => { + + {filteredTasks.map((task) => { const status = taskStatusMeta(task.status) + const isSelected = selectedIds.has(task.id) + return ( - - - + - - - - - - + + + + + ) @@ -317,17 +687,19 @@ function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
文件提供方{isDownload ? '本地目标' : '本地来源'}远端路径大小状态创建时间操作 + + 文件名称提供方{isDownload ? '本地目标目录' : '本地来源'}大小状态创建时间操作
{task.file_name} - {STRM_PROVIDER_LABELS[task.provider] ?? task.provider} - {task.retry_count > 0 && ( - 重试 {task.retry_count} - )} +
+ toggleSelectRow(task.id)} + className="h-3.5 w-3.5 rounded border-gray-300 text-brand-500 focus:ring-brand-400 cursor-pointer" + /> {task.local_path}{task.remote_path}{formatBytes(task.size)} - - {task.status === 'running' && } - {status.label} - - {task.error && ( -

- {task.error} -

- )} +
+
+ {getFileIcon(task.file_name)} + setDetailTask(task)} + className="cursor-pointer truncate font-medium text-ink-600 hover:text-brand-500 hover:underline" + title={task.file_name} + > + {task.file_name} + +
{formatTime(task.created_at)} - {(task.status === 'pending' || task.status === 'running') && ( + +
+ + {STRM_PROVIDER_LABELS[task.provider] ?? task.provider} + + {task.retry_count > 0 && ( + + 重试 {task.retry_count} + + )} +
+
+
+ + {task.local_path} + - )} - {(task.status === 'failed' || task.status === 'canceled') && ( +
+
+ {formatBytes(task.size)} + +
+ + {task.status === 'running' && ( + + )} + {status.label} + + {task.error && ( + setDetailTask(task)} + className="cursor-pointer truncate max-w-[200px] text-[10px] text-rose-500 hover:underline" + title={task.error} + > + {task.error} + + )} +
+
+ {formatTime(task.created_at)} + +
- )} + + {(task.status === 'pending' || task.status === 'running') && ( + + )} + + {(task.status === 'failed' || task.status === 'canceled') && ( + + )} + + {(task.status === 'done' || + task.status === 'failed' || + task.status === 'canceled') && ( + + )} +
)} + + {/* 5. Pagination */} {(snapshot?.total ?? 0) > 0 && ( -
- +
+ 共 {snapshot?.total ?? 0} 条 · 第 {page} / {totalPages} 页 -
+
@@ -335,7 +707,7 @@ function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) { type="button" disabled={page >= totalPages || loading} onClick={() => setPage((p) => p + 1)} - className="inline-flex items-center rounded-lg border border-gray-200 px-3 py-1.5 text-xs font-semibold text-ink-100 transition hover:bg-gray-50 disabled:opacity-40" + className="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 transition hover:bg-gray-50 disabled:opacity-40" > 下一页 @@ -343,15 +715,214 @@ function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
)}
+ + {/* 6. Task Detail Dialog */} + {detailTask && ( + setDetailTask(null)} + onRetry={retryTask} + onCancel={cancelTask} + onDelete={deleteTask} + onCopy={copyText} + /> + )}
) } -function StatPill({ label, value, cls }: { label: string; value: number; cls: string }) { +function TaskDetailModal({ + task, + isDownload, + onClose, + onRetry, + onCancel, + onDelete, + onCopy, +}: { + task: StrmTask + isDownload: boolean + onClose: () => void + onRetry: (t: StrmTask) => void + onCancel: (t: StrmTask) => void + onDelete: (t: StrmTask) => void + onCopy: (text: string, label: string) => void +}) { + const status = taskStatusMeta(task.status) + return ( -
- {label} - {value} +
+
e.stopPropagation()} + > +
+
+ {getFileIcon(task.file_name)} +

任务详情

+
+ +
+ +
+ {/* Main Info Box */} +
+
+ 任务 ID + {task.id} +
+
+ 文件名称 + {task.file_name} +
+
+ 文件大小 + {formatBytes(task.size)} +
+
+ 当前状态 + + {status.label} + +
+
+ 云盘提供方 + + {STRM_PROVIDER_LABELS[task.provider] ?? task.provider} + +
+ {task.retry_count > 0 && ( +
+ 已重试次数 + {task.retry_count} 次 +
+ )} +
+ + {/* Paths */} +
+
+ {isDownload ? '本地输出目标路径' : '本地来源路径'} + +
+
+ {task.local_path} +
+
+ +
+
+ 远端网盘路径 + +
+
+ {task.remote_path} +
+
+ + {/* Error Message Box */} + {task.error && ( +
+
+ + 错误详情 + + +
+
+ {task.error} +
+
+ )} + + {/* Timeline */} +
+
创建时间:{formatTime(task.created_at)}
+ {task.started_at &&
开始时间:{formatTime(task.started_at)}
} + {task.finished_at &&
结束时间:{formatTime(task.finished_at)}
} +
+
+ + {/* Footer Actions */} +
+
+ {(task.status === 'done' || + task.status === 'failed' || + task.status === 'canceled') && ( + + )} +
+ +
+ {(task.status === 'pending' || task.status === 'running') && ( + + )} + + {(task.status === 'failed' || task.status === 'canceled') && ( + + )} + + +
+
+
) } @@ -362,4 +933,4 @@ export function StrmDownloadQueuePage() { export function StrmUploadQueuePage() { return -} \ No newline at end of file +} diff --git a/web/src/pages/settingsGroupLibrary.ts b/web/src/pages/settingsGroupLibrary.ts deleted file mode 100644 index 680330e..0000000 --- a/web/src/pages/settingsGroupLibrary.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { SettingGroup } from './settingsGroupTypes' - -export const librarySettingsGroup: SettingGroup = { - key: 'library', - label: '媒体库', - description: '配置首页海报轮播等媒体库展示与偏好', - items: [], -} diff --git a/web/src/pages/settingsGroups.ts b/web/src/pages/settingsGroups.ts index 0ed5691..877f12c 100644 --- a/web/src/pages/settingsGroups.ts +++ b/web/src/pages/settingsGroups.ts @@ -2,7 +2,6 @@ import { adultSettingsGroup } from './settingsGroupAccess' import { apiConfigsSettingsGroup } from './settingsGroupAPIConfigs' import { danmakuSettingsGroup } from './settingsGroupDanmaku' import { generalSettingsGroup } from './settingsGroupGeneral' -import { librarySettingsGroup } from './settingsGroupLibrary' import { recognitionWordsSettingsGroup } from './settingsGroupRecognitionWords' import type { SettingGroup } from './settingsGroupTypes' @@ -10,7 +9,6 @@ export type { SettingGroup } from './settingsGroupTypes' export const GROUPS: SettingGroup[] = [ generalSettingsGroup, - librarySettingsGroup, apiConfigsSettingsGroup, recognitionWordsSettingsGroup, danmakuSettingsGroup, diff --git a/web/src/pages/useAdminLibraryPanel.ts b/web/src/pages/useAdminLibraryPanel.ts index 4ae33e1..0276f08 100644 --- a/web/src/pages/useAdminLibraryPanel.ts +++ b/web/src/pages/useAdminLibraryPanel.ts @@ -164,6 +164,18 @@ function useLibraryActions(refresh: () => Promise) { else toast.success(`扫描完成,新增 ${result.added},更新 ${result.updated ?? 0}`) } + const toggleCarouselLibrary = async (library: Library) => { + const next = !(library.carousel_enabled ?? true) + await libraryAPI.update(library.id, { carousel_enabled: next }) + toast.success(next ? `「${library.name}」已加入首页轮播` : `「${library.name}」已移出首页轮播`) + await refresh() + } + + const reorderLibraries = async (orderedLibs: Library[]) => { + await libraryAPI.reorder(orderedLibs.map((l) => l.id)) + await refresh() + } + const removeLibrary = async (library: Library) => { if (!(await confirmAction({ title: '删除媒体库', message: `确定删除「${library.name}」?`, confirmText: '删除' }))) return await libraryAPI.remove(library.id) @@ -191,5 +203,5 @@ function useLibraryActions(refresh: () => Promise) { await refresh() } - return { scanLibrary, removeLibrary, addLibraryRoot, editLibraryCover } + return { scanLibrary, removeLibrary, addLibraryRoot, editLibraryCover, toggleCarouselLibrary, reorderLibraries } } diff --git a/web/src/types/library.ts b/web/src/types/library.ts index 93b18fd..a4f0c8d 100644 --- a/web/src/types/library.ts +++ b/web/src/types/library.ts @@ -16,6 +16,8 @@ export interface Library { type: string cover_url?: string enabled: boolean + sort_order?: number + carousel_enabled?: boolean roots?: LibraryRoot[] created_at: string updated_at: string