From ba27d48d102232393f3cb3ede85f5f58f5c9e723 Mon Sep 17 00:00:00 2001
From: truewhile <62226914+truewhile@users.noreply.github.com>
Date: Wed, 2 Sep 2026 23:05:38 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
internal/handler/routes_admin.go | 3 +
internal/handler/scraper_queue.go | 11 +
internal/handler/strm.go | 22 ++
internal/repository/scrape_task_repository.go | 10 +
internal/repository/strm_repository.go | 22 ++
internal/service/scraper_queue.go | 4 +
internal/service/strm_queue.go | 10 +
web/src/api/scraper.ts | 3 +
web/src/api/strm.ts | 6 +
web/src/components/GlobalEvents.tsx | 2 +-
web/src/pages/ScraperQueuePage.tsx | 196 +++++++++++++-
web/src/pages/StrmQueuePage.tsx | 256 +++++++++++++++++-
web/src/pages/useAdminLibraryPanel.ts | 4 +-
web/src/pages/useLibraryScanStatus.ts | 19 +-
14 files changed, 557 insertions(+), 11 deletions(-)
diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go
index 351bb35..262d501 100644
--- a/internal/handler/routes_admin.go
+++ b/internal/handler/routes_admin.go
@@ -37,6 +37,7 @@ func registerAdminScraperRoutes(admin *gin.RouterGroup, svc *service.Container)
admin.POST("/scraper/queue/clear-done", clearDoneScrapeTasksHandler(svc))
admin.POST("/scraper/queue/clear-finished", clearFinishedScrapeTasksHandler(svc))
admin.POST("/scraper/queue/clear-canceled", clearCanceledScrapeTasksHandler(svc))
+ admin.POST("/scraper/queue/clear-failed", clearFailedScrapeTasksHandler(svc))
admin.POST("/scraper/queue/retry-failed", retryAllFailedScrapeTasksHandler(svc))
admin.POST("/scraper/queue/cancel-pending", cancelPendingScrapeTasksHandler(svc))
admin.POST("/scraper/queue/enqueue-library/:id", enqueueLibraryScrapeHandler(svc))
@@ -87,6 +88,7 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.POST("/strm/downloads/clear-done", clearDoneDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-finished", clearFinishedDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-canceled", clearCanceledDownloadsHandler(svc))
+ admin.POST("/strm/downloads/clear-failed", clearFailedDownloadsHandler(svc))
admin.POST("/strm/downloads/retry-failed", retryAllFailedDownloadsHandler(svc))
admin.POST("/strm/downloads/cancel-pending", cancelPendingDownloadsHandler(svc))
admin.GET("/strm/uploads", uploadQueueHandler(svc))
@@ -97,6 +99,7 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.POST("/strm/uploads/clear-done", clearDoneUploadsHandler(svc))
admin.POST("/strm/uploads/clear-finished", clearFinishedUploadsHandler(svc))
admin.POST("/strm/uploads/clear-canceled", clearCanceledUploadsHandler(svc))
+ admin.POST("/strm/uploads/clear-failed", clearFailedUploadsHandler(svc))
admin.POST("/strm/uploads/retry-failed", retryAllFailedUploadsHandler(svc))
admin.POST("/strm/uploads/cancel-pending", cancelPendingUploadsHandler(svc))
}
diff --git a/internal/handler/scraper_queue.go b/internal/handler/scraper_queue.go
index 7f870db..0962b12 100644
--- a/internal/handler/scraper_queue.go
+++ b/internal/handler/scraper_queue.go
@@ -101,6 +101,17 @@ func clearCanceledScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
}
}
+func clearFailedScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ n, err := svc.Scraper.ClearFailedScrapeTasks(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"deleted": n})
+ }
+}
+
func retryAllFailedScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Scraper.RetryAllFailedScrapeTasks(c.Request.Context())
diff --git a/internal/handler/strm.go b/internal/handler/strm.go
index e92a03b..06fb741 100644
--- a/internal/handler/strm.go
+++ b/internal/handler/strm.go
@@ -526,6 +526,17 @@ func clearCanceledDownloadsHandler(svc *service.Container) gin.HandlerFunc {
}
}
+func clearFailedDownloadsHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ n, err := svc.Strm.ClearFailedDownloadTasks(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"deleted": n})
+ }
+}
+
func clearCanceledUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearCanceledUploadTasks(c.Request.Context())
@@ -537,6 +548,17 @@ func clearCanceledUploadsHandler(svc *service.Container) gin.HandlerFunc {
}
}
+func clearFailedUploadsHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ n, err := svc.Strm.ClearFailedUploadTasks(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"deleted": n})
+ }
+}
+
func clearDoneUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearDoneUploadTasks(c.Request.Context())
diff --git a/internal/repository/scrape_task_repository.go b/internal/repository/scrape_task_repository.go
index 6b50714..4140625 100644
--- a/internal/repository/scrape_task_repository.go
+++ b/internal/repository/scrape_task_repository.go
@@ -236,6 +236,16 @@ func (r *ScrapeTaskRepository) ClearCanceled(ctx context.Context) (int64, error)
return count, err
}
+func (r *ScrapeTaskRepository) ClearFailed(ctx context.Context) (int64, error) {
+ var count int64
+ err := withSQLiteBusyRetry(ctx, func() error {
+ res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.ScrapeTaskFailed).Delete(&model.ScrapeTask{})
+ count = res.RowsAffected
+ return res.Error
+ })
+ return count, err
+}
+
func (r *ScrapeTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
diff --git a/internal/repository/strm_repository.go b/internal/repository/strm_repository.go
index fe3de4e..bbf411e 100644
--- a/internal/repository/strm_repository.go
+++ b/internal/repository/strm_repository.go
@@ -402,6 +402,17 @@ func (r *StrmDownloadTaskRepository) ClearCanceled(ctx context.Context) (int64,
return count, err
}
+// ClearFailed 清空全部已失败下载任务。
+func (r *StrmDownloadTaskRepository) ClearFailed(ctx context.Context) (int64, error) {
+ var count int64
+ err := withSQLiteBusyRetry(ctx, func() error {
+ res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.StrmTaskFailed).Delete(&model.StrmDownloadTask{})
+ count = res.RowsAffected
+ return res.Error
+ })
+ return count, err
+}
+
// RetryAllFailed 把所有失败任务重置回待处理,清空错误与重试计数。
func (r *StrmDownloadTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) {
var count int64
@@ -709,6 +720,17 @@ func (r *StrmUploadTaskRepository) ClearCanceled(ctx context.Context) (int64, er
return count, err
}
+// ClearFailed 清空全部已失败上传任务。
+func (r *StrmUploadTaskRepository) ClearFailed(ctx context.Context) (int64, error) {
+ var count int64
+ err := withSQLiteBusyRetry(ctx, func() error {
+ res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.StrmTaskFailed).Delete(&model.StrmUploadTask{})
+ count = res.RowsAffected
+ return res.Error
+ })
+ return count, err
+}
+
// RetryAllFailed 把所有失败任务重置回待处理,清空错误与重试计数。
func (r *StrmUploadTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) {
var count int64
diff --git a/internal/service/scraper_queue.go b/internal/service/scraper_queue.go
index 143c826..04408c7 100644
--- a/internal/service/scraper_queue.go
+++ b/internal/service/scraper_queue.go
@@ -345,6 +345,10 @@ func (s *ScraperService) ClearCanceledScrapeTasks(ctx context.Context) (int64, e
return s.repo.ScrapeTask.ClearCanceled(ctx)
}
+func (s *ScraperService) ClearFailedScrapeTasks(ctx context.Context) (int64, error) {
+ return s.repo.ScrapeTask.ClearFailed(ctx)
+}
+
func (s *ScraperService) RetryAllFailedScrapeTasks(ctx context.Context) (int64, error) {
return s.repo.ScrapeTask.RetryAllFailed(ctx)
}
diff --git a/internal/service/strm_queue.go b/internal/service/strm_queue.go
index b656e62..fe5fac1 100644
--- a/internal/service/strm_queue.go
+++ b/internal/service/strm_queue.go
@@ -610,11 +610,21 @@ func (s *StrmService) ClearCanceledDownloadTasks(ctx context.Context) (int64, er
return s.repo.StrmDownload.ClearCanceled(ctx)
}
+// ClearFailedDownloadTasks 清空全部已失败的下载记录,返回删除数量。
+func (s *StrmService) ClearFailedDownloadTasks(ctx context.Context) (int64, error) {
+ return s.repo.StrmDownload.ClearFailed(ctx)
+}
+
// ClearCanceledUploadTasks 清空全部已取消的上传记录,返回删除数量。
func (s *StrmService) ClearCanceledUploadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmUpload.ClearCanceled(ctx)
}
+// ClearFailedUploadTasks 清空全部已失败的上传记录,返回删除数量。
+func (s *StrmService) ClearFailedUploadTasks(ctx context.Context) (int64, error) {
+ return s.repo.StrmUpload.ClearFailed(ctx)
+}
+
// ClearDoneUploadTasks 清空全部已完成上传记录,返回删除数量。
func (s *StrmService) ClearDoneUploadTasks(ctx context.Context) (int64, error) {
return s.repo.StrmUpload.ClearDone(ctx)
diff --git a/web/src/api/scraper.ts b/web/src/api/scraper.ts
index 5fd4108..80b1dbd 100644
--- a/web/src/api/scraper.ts
+++ b/web/src/api/scraper.ts
@@ -39,6 +39,9 @@ export const scraperAPI = {
clearCanceled: () =>
api.post<{ deleted: number }>('/admin/scraper/queue/clear-canceled').then((r) => r.data),
+ clearFailed: () =>
+ api.post<{ deleted: number }>('/admin/scraper/queue/clear-failed').then((r) => r.data),
+
retryFailed: () =>
api.post<{ retried: number }>('/admin/scraper/queue/retry-failed').then((r) => r.data),
diff --git a/web/src/api/strm.ts b/web/src/api/strm.ts
index 46680bb..26ab058 100644
--- a/web/src/api/strm.ts
+++ b/web/src/api/strm.ts
@@ -159,6 +159,9 @@ export const strmAPI = {
clearCanceledDownloads: () =>
api.post<{ deleted: number }>('/admin/strm/downloads/clear-canceled').then((r) => r.data),
+ clearFailedDownloads: () =>
+ api.post<{ deleted: number }>('/admin/strm/downloads/clear-failed').then((r) => r.data),
+
retryFailedDownloads: () =>
api.post<{ retried: number }>('/admin/strm/downloads/retry-failed').then((r) => r.data),
@@ -196,6 +199,9 @@ export const strmAPI = {
clearCanceledUploads: () =>
api.post<{ deleted: number }>('/admin/strm/uploads/clear-canceled').then((r) => r.data),
+ clearFailedUploads: () =>
+ api.post<{ deleted: number }>('/admin/strm/uploads/clear-failed').then((r) => r.data),
+
retryFailedUploads: () =>
api.post<{ retried: number }>('/admin/strm/uploads/retry-failed').then((r) => r.data),
}
\ No newline at end of file
diff --git a/web/src/components/GlobalEvents.tsx b/web/src/components/GlobalEvents.tsx
index 074a34f..9bc5ff5 100644
--- a/web/src/components/GlobalEvents.tsx
+++ b/web/src/components/GlobalEvents.tsx
@@ -26,7 +26,7 @@ export function GlobalEvents() {
return
}
if (p.queued) {
- toast.loading(String(p.message ?? '云盘扫描已加入后台队列,会自动入库'), { id })
+ toast.loading(String(p.message ?? '媒体库扫描已加入后台队列,会自动入库'), { id })
return
}
if (p.cloud && p.stage) {
diff --git a/web/src/pages/ScraperQueuePage.tsx b/web/src/pages/ScraperQueuePage.tsx
index b861b17..e852e0b 100644
--- a/web/src/pages/ScraperQueuePage.tsx
+++ b/web/src/pages/ScraperQueuePage.tsx
@@ -227,8 +227,13 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
}, [tasks, filter, search])
const counts = snapshot?.counts
- const activeTaskCount = (counts?.pending ?? 0) + (counts?.running ?? 0)
+ const pendingCount = counts?.pending ?? 0
+ const runningCount = counts?.running ?? 0
+ const activeTaskCount = pendingCount + runningCount
+ const doneCount = counts?.done ?? 0
const failedCount = counts?.failed ?? 0
+ const canceledCount = counts?.canceled ?? 0
+ const finishedCount = doneCount + failedCount + canceledCount
const allCurrentChecked =
filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id))
@@ -450,7 +455,7 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
)}
- {selectedIds.size > 0 && (
+ {selectedIds.size > 0 ? (
已选中 {selectedIds.size} 项
@@ -490,6 +495,193 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
+ ) : (
+
+ {/* 当前状态专属快捷批量按钮 */}
+ {filter === 'all' && (
+ <>
+ {failedCount > 0 && (
+
+ )}
+ {activeTaskCount > 0 && (
+
+ )}
+ {finishedCount > 0 && (
+
+ )}
+ >
+ )}
+
+ {(filter === 'pending' || filter === 'running') && (
+
+ )}
+
+ {filter === 'done' && (
+
+ )}
+
+ {filter === 'failed' && (
+ <>
+
+
+ >
+ )}
+
+ {filter === 'canceled' && (
+
+ )}
+
+ {/* 下拉批量操作菜单:随时可做任意全局操作 */}
+
+
+
+ 批量清理
+
+
+ {failedCount > 0 && (
+
+ )}
+ {activeTaskCount > 0 && (
+
+ )}
+
+
+
+
+
+
+
+
)}
diff --git a/web/src/pages/StrmQueuePage.tsx b/web/src/pages/StrmQueuePage.tsx
index 391b558..0d95114 100644
--- a/web/src/pages/StrmQueuePage.tsx
+++ b/web/src/pages/StrmQueuePage.tsx
@@ -224,8 +224,13 @@ export function StrmQueuePanel({
}, [tasks, filter, search])
const counts = snapshot?.counts
- const activeTaskCount = (counts?.pending ?? 0) + (counts?.running ?? 0)
+ const pendingCount = counts?.pending ?? 0
+ const runningCount = counts?.running ?? 0
+ const activeTaskCount = pendingCount + runningCount
+ const doneCount = counts?.done ?? 0
const failedCount = counts?.failed ?? 0
+ const canceledCount = counts?.canceled ?? 0
+ const finishedCount = doneCount + failedCount + canceledCount
const allCurrentChecked =
filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id))
@@ -475,8 +480,8 @@ export function StrmQueuePanel({
)}
- {/* Selected Batch Toolbar */}
- {selectedIds.size > 0 && (
+ {/* Batch Actions Toolbar */}
+ {selectedIds.size > 0 ? (
已选中 {selectedIds.size} 项
@@ -516,6 +521,251 @@ export function StrmQueuePanel({
+ ) : (
+
+ {/* 当前状态专属快捷批量按钮 */}
+ {filter === 'all' && (
+ <>
+ {failedCount > 0 && (
+
+ )}
+ {activeTaskCount > 0 && (
+
+ )}
+ {finishedCount > 0 && (
+
+ )}
+ >
+ )}
+
+ {(filter === 'pending' || filter === 'running') && (
+
+ )}
+
+ {filter === 'done' && (
+
+ )}
+
+ {filter === 'failed' && (
+ <>
+
+
+ >
+ )}
+
+ {filter === 'canceled' && (
+
+ )}
+
+ {/* 下拉批量操作菜单:随时可做任意全局操作 */}
+
+
+
+ 批量清理
+
+
+ {failedCount > 0 && (
+
+ )}
+ {activeTaskCount > 0 && (
+
+ )}
+
+
+
+
+
+
+
+
)}
diff --git a/web/src/pages/useAdminLibraryPanel.ts b/web/src/pages/useAdminLibraryPanel.ts
index f4bdf60..57b7c21 100644
--- a/web/src/pages/useAdminLibraryPanel.ts
+++ b/web/src/pages/useAdminLibraryPanel.ts
@@ -163,8 +163,8 @@ function useEditableLibraryRootActions(refresh: () => Promise, drafts: Edi
function useLibraryActions(refresh: () => Promise) {
const scanLibrary = async (library: Library) => {
const result = await libraryAPI.scan(library.id)
- if (result.queued) toast.success('云盘扫描已加入后台队列,会自动入库')
- else toast.success(`扫描完成,新增 ${result.added},更新 ${result.updated ?? 0}`)
+ if (result.queued) toast.success(result.message || '媒体库扫描已加入后台队列,会自动入库')
+ else toast.success(`扫描完成,新增 ${result.added ?? 0},更新 ${result.updated ?? 0}`)
}
const toggleCarouselLibrary = async (library: Library) => {
diff --git a/web/src/pages/useLibraryScanStatus.ts b/web/src/pages/useLibraryScanStatus.ts
index 648bec0..cfca84b 100644
--- a/web/src/pages/useLibraryScanStatus.ts
+++ b/web/src/pages/useLibraryScanStatus.ts
@@ -40,6 +40,13 @@ export function useLibraryScanStatus({
setScanProgress(String(event.message ?? '扫描已排队,后台会自动入库'))
return
}
+ if (typeof event.visited === 'number' && !event.finished) {
+ setScanning(true)
+ const added = Number(event.added ?? 0)
+ const updated = Number(event.updated ?? 0)
+ setScanProgress(`正在扫描:已扫描 ${event.visited} 项 · 新增 ${added} · 更新 ${updated}`)
+ return
+ }
}, [isAdmin, libraryID, onLibraryChanged])
useWebSocket(onRealtimeEvent)
@@ -49,13 +56,19 @@ export function useLibraryScanStatus({
setScanProgress('正在提交扫描任务…')
try {
const result = await libraryAPI.scan(libraryID)
- toast.success(`扫描完成:新增 ${result.added} 项,更新 ${result.updated ?? 0} 项`)
- setScanProgress(`扫描完成:新增 ${result.added} · 更新 ${result.updated ?? 0}`)
+ if (result.queued) {
+ const msg = result.message || '扫描任务已在后台启动,正在扫描…'
+ toast.success(msg)
+ setScanProgress(msg)
+ return
+ }
+ toast.success(`扫描完成:新增 ${result.added ?? 0} 项,更新 ${result.updated ?? 0} 项`)
+ setScanProgress(`扫描完成:新增 ${result.added ?? 0} · 更新 ${result.updated ?? 0}`)
+ setScanning(false)
onLibraryChanged()
} catch {
toast.error('扫描失败')
setScanProgress('扫描失败,请查看日志或稍后重试')
- } finally {
setScanning(false)
}
}, [libraryID, onLibraryChanged])