Compare commits

...

4 Commits

Author SHA1 Message Date
github-actions[bot] 1025e3c693 chore: bump version to 0.0.88 [skip ci] 2026-09-02 15:05:55 +00:00
truewhile ba27d48d10 优化 2026-09-02 23:05:38 +08:00
github-actions[bot] 6d20dc65de chore: bump version to 0.0.87 [skip ci] 2026-09-02 14:15:40 +00:00
truewhile d4c82ac07e 优化 2026-09-02 22:15:24 +08:00
17 changed files with 566 additions and 15 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.86
0.0.88
+3
View File
@@ -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))
}
+11
View File
@@ -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())
+22
View File
@@ -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())
@@ -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 {
+22
View File
@@ -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
+4
View File
@@ -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)
}
+10
View File
@@ -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)
+7 -3
View File
@@ -1,4 +1,4 @@
import { api } from './client'
import { api, MIGRATION_REQUEST_TIMEOUT } from './client'
import type { AccessLog, Setting, User } from '../types'
export interface DatabaseStatus {
@@ -137,8 +137,12 @@ export const adminAPI = {
testDatabaseConnection: (payload: DatabaseConnectionPayload) =>
api.post<PostgresTestResult>('/admin/database/test', payload).then((r) => r.data),
migrateDatabase: (payload: DatabaseConnectionPayload) =>
api.post<DatabaseMigrationResult>('/admin/database/migrate', payload).then((r) => r.data),
migrateDatabase: (payload: DatabaseConnectionPayload) =>
api
.post<DatabaseMigrationResult>('/admin/database/migrate', payload, {
timeout: MIGRATION_REQUEST_TIMEOUT,
})
.then((r) => r.data),
saveDatabaseConfig: (payload: DatabaseConnectionPayload) =>
api.post<{ message: string; type: string }>('/admin/database/save-config', payload).then((r) => r.data),
+1
View File
@@ -12,6 +12,7 @@ export const api = axios.create({
export const LONG_REQUEST_TIMEOUT = 120_000
export const BATCH_REQUEST_TIMEOUT = 300_000
export const MIGRATION_REQUEST_TIMEOUT = 600_000
// Flag to prevent multiple simultaneous refresh attempts
let isRefreshing = false
+3
View File
@@ -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),
+6
View File
@@ -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),
}
+1 -1
View File
@@ -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) {
+194 -2
View File
@@ -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 }) {
)}
</div>
{selectedIds.size > 0 && (
{selectedIds.size > 0 ? (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-brand-500/30 bg-primary-400/10 px-3 py-2 text-xs animate-in fade-in zoom-in-95">
<span className="font-bold text-brand-500">已选中 {selectedIds.size} 项</span>
<div className="h-3.5 w-px bg-brand-300/40 mx-1" />
@@ -490,6 +495,193 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
<X size={13} />
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
{/* 当前状态专属快捷批量按钮 */}
{filter === 'all' && (
<>
{failedCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={() => runGlobalBatch(() => scraperAPI.retryFailed(), '确定重新入队所有失败任务?')}
className="inline-flex items-center gap-1 rounded-xl border border-brand-500/40 bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 hover:bg-brand-50 disabled:opacity-50"
>
<RefreshCw size={12} />
全部重试 ({failedCount})
</button>
)}
{activeTaskCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={() => runGlobalBatch(() => scraperAPI.cancelPending(), '确定取消所有排队及进行中的刮削任务?')}
className="inline-flex items-center gap-1 rounded-xl border border-amber-300 bg-white px-3 py-1.5 text-xs font-semibold text-amber-600 hover:bg-amber-50 disabled:opacity-50"
>
<Ban size={12} />
全部取消 ({activeTaskCount})
</button>
)}
{finishedCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={() => runGlobalBatch(() => scraperAPI.clearFinished(), '确定清空所有已完成、失败及取消的历史记录?')}
className="inline-flex items-center gap-1 rounded-xl border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={12} />
全部删除 ({finishedCount})
</button>
)}
</>
)}
{(filter === 'pending' || filter === 'running') && (
<button
type="button"
disabled={batchBusy || activeTaskCount === 0}
onClick={() => runGlobalBatch(() => scraperAPI.cancelPending(), '确定取消所有排队及进行中的刮削任务?')}
className="inline-flex items-center gap-1 rounded-xl border border-amber-300 bg-white px-3 py-1.5 text-xs font-semibold text-amber-600 hover:bg-amber-50 disabled:opacity-50"
>
<Ban size={12} />
全部取消{activeTaskCount > 0 ? ` (${activeTaskCount})` : ''}
</button>
)}
{filter === 'done' && (
<button
type="button"
disabled={batchBusy || doneCount === 0}
onClick={() => runGlobalBatch(() => scraperAPI.clearDone(), '确定清空所有已匹配完成的记录?')}
className="inline-flex items-center gap-1 rounded-xl border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={12} />
全部删除{doneCount > 0 ? ` (${doneCount})` : ''}
</button>
)}
{filter === 'failed' && (
<>
<button
type="button"
disabled={batchBusy || failedCount === 0}
onClick={() => runGlobalBatch(() => scraperAPI.retryFailed(), '确定重新入队所有失败任务?')}
className="inline-flex items-center gap-1 rounded-xl border border-brand-500/40 bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 hover:bg-brand-50 disabled:opacity-50"
>
<RefreshCw size={12} />
全部重试{failedCount > 0 ? ` (${failedCount})` : ''}
</button>
<button
type="button"
disabled={batchBusy || failedCount === 0}
onClick={() => runGlobalBatch(() => scraperAPI.clearFailed(), '确定清空所有失败记录?')}
className="inline-flex items-center gap-1 rounded-xl border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={12} />
全部删除{failedCount > 0 ? ` (${failedCount})` : ''}
</button>
</>
)}
{filter === 'canceled' && (
<button
type="button"
disabled={batchBusy || canceledCount === 0}
onClick={() => runGlobalBatch(() => scraperAPI.clearCanceled(), '确定清空所有已取消的任务记录?')}
className="inline-flex items-center gap-1 rounded-xl border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={12} />
全部删除{canceledCount > 0 ? ` (${canceledCount})` : ''}
</button>
)}
{/* 下拉批量操作菜单:随时可做任意全局操作 */}
<details className="relative inline-block">
<summary className="inline-flex cursor-pointer list-none items-center gap-1.5 rounded-xl border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 shadow-sm transition hover:border-gray-300 hover:bg-gray-50 [&::-webkit-details-marker]:hidden">
<Trash2 size={12} className="text-sand-500" />
<span>批量清理</span>
</summary>
<div className="absolute right-0 top-9 z-30 min-w-44 rounded-xl border border-gray-200 bg-white p-1.5 shadow-xl backdrop-blur">
{failedCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(() => scraperAPI.retryFailed(), '确定重新入队所有失败任务?')
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-brand-500 hover:bg-brand-50"
>
<RefreshCw size={13} />
<span>重试所有失败 ({failedCount})</span>
</button>
)}
{activeTaskCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(() => scraperAPI.cancelPending(), '确定取消所有排队及进行中的刮削任务?')
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-amber-600 hover:bg-amber-50"
>
<Ban size={13} />
<span>取消所有进行中 ({activeTaskCount})</span>
</button>
)}
<div className="my-1 border-t border-gray-100" />
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(() => scraperAPI.clearDone(), '确定清空所有已匹配完成的记录?')
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-ink-100 hover:bg-gray-50"
>
<CheckCircle2 size={13} className="text-emerald-500" />
<span>清空已完成记录 ({doneCount})</span>
</button>
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(() => scraperAPI.clearFailed(), '确定清空所有失败的记录?')
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-rose-500 hover:bg-rose-50"
>
<AlertCircle size={13} />
<span>清空失败记录 ({failedCount})</span>
</button>
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(() => scraperAPI.clearCanceled(), '确定清空所有已取消的任务记录?')
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-ink-100 hover:bg-gray-50"
>
<Ban size={13} className="text-amber-500" />
<span>清空已取消记录 ({canceledCount})</span>
</button>
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(() => scraperAPI.clearFinished(), '确定清空所有已完成、失败及取消的历史记录?')
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-rose-500 hover:bg-rose-50"
>
<Trash2 size={13} />
<span>清空全部历史记录 ({finishedCount})</span>
</button>
</div>
</details>
</div>
)}
</div>
+253 -3
View File
@@ -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({
)}
</div>
{/* Selected Batch Toolbar */}
{selectedIds.size > 0 && (
{/* Batch Actions Toolbar */}
{selectedIds.size > 0 ? (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-brand-500/30 bg-primary-400/10 px-3 py-2 text-xs animate-in fade-in zoom-in-95">
<span className="font-bold text-brand-500">已选中 {selectedIds.size} 项</span>
<div className="h-3.5 w-px bg-brand-300/40 mx-1" />
@@ -516,6 +521,251 @@ export function StrmQueuePanel({
<X size={13} />
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
{/* 当前状态专属快捷批量按钮 */}
{filter === 'all' && (
<>
{failedCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={() =>
runGlobalBatch(
() => (isDownload ? strmAPI.retryFailedDownloads() : strmAPI.retryFailedUploads()),
'确定重新入队所有失败任务?',
)
}
className="inline-flex items-center gap-1 rounded-xl border border-brand-500/40 bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 hover:bg-brand-50 disabled:opacity-50"
>
<RefreshCw size={12} />
全部重试 ({failedCount})
</button>
)}
{activeTaskCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={() =>
runGlobalBatch(
() => (isDownload ? strmAPI.cancelPendingDownloads() : strmAPI.cancelPendingUploads()),
'确定取消所有排队及进行中的任务?',
)
}
className="inline-flex items-center gap-1 rounded-xl border border-amber-300 bg-white px-3 py-1.5 text-xs font-semibold text-amber-600 hover:bg-amber-50 disabled:opacity-50"
>
<Ban size={12} />
全部取消 ({activeTaskCount})
</button>
)}
{finishedCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={() =>
runGlobalBatch(
() => (isDownload ? strmAPI.clearFinishedDownloads() : strmAPI.clearFinishedUploads()),
'确定清空所有已完成、失败及取消的历史记录?',
)
}
className="inline-flex items-center gap-1 rounded-xl border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={12} />
全部删除 ({finishedCount})
</button>
)}
</>
)}
{(filter === 'pending' || filter === 'running') && (
<button
type="button"
disabled={batchBusy || activeTaskCount === 0}
onClick={() =>
runGlobalBatch(
() => (isDownload ? strmAPI.cancelPendingDownloads() : strmAPI.cancelPendingUploads()),
'确定取消所有排队及进行中的任务?',
)
}
className="inline-flex items-center gap-1 rounded-xl border border-amber-300 bg-white px-3 py-1.5 text-xs font-semibold text-amber-600 hover:bg-amber-50 disabled:opacity-50"
>
<Ban size={12} />
全部取消{activeTaskCount > 0 ? ` (${activeTaskCount})` : ''}
</button>
)}
{filter === 'done' && (
<button
type="button"
disabled={batchBusy || doneCount === 0}
onClick={() =>
runGlobalBatch(
() => (isDownload ? strmAPI.clearDoneDownloads() : strmAPI.clearDoneUploads()),
'确定清空所有已完成记录?',
)
}
className="inline-flex items-center gap-1 rounded-xl border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={12} />
全部删除{doneCount > 0 ? ` (${doneCount})` : ''}
</button>
)}
{filter === 'failed' && (
<>
<button
type="button"
disabled={batchBusy || failedCount === 0}
onClick={() =>
runGlobalBatch(
() => (isDownload ? strmAPI.retryFailedDownloads() : strmAPI.retryFailedUploads()),
'确定重新入队所有失败任务?',
)
}
className="inline-flex items-center gap-1 rounded-xl border border-brand-500/40 bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 hover:bg-brand-50 disabled:opacity-50"
>
<RefreshCw size={12} />
全部重试{failedCount > 0 ? ` (${failedCount})` : ''}
</button>
<button
type="button"
disabled={batchBusy || failedCount === 0}
onClick={() =>
runGlobalBatch(
() => (isDownload ? strmAPI.clearFailedDownloads() : strmAPI.clearFailedUploads()),
'确定清空所有失败记录?',
)
}
className="inline-flex items-center gap-1 rounded-xl border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={12} />
全部删除{failedCount > 0 ? ` (${failedCount})` : ''}
</button>
</>
)}
{filter === 'canceled' && (
<button
type="button"
disabled={batchBusy || canceledCount === 0}
onClick={() =>
runGlobalBatch(
() => (isDownload ? strmAPI.clearCanceledDownloads() : strmAPI.clearCanceledUploads()),
'确定清空所有已取消的任务记录?',
)
}
className="inline-flex items-center gap-1 rounded-xl border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
>
<Trash2 size={12} />
全部删除{canceledCount > 0 ? ` (${canceledCount})` : ''}
</button>
)}
{/* 下拉批量操作菜单:随时可做任意全局操作 */}
<details className="relative inline-block">
<summary className="inline-flex cursor-pointer list-none items-center gap-1.5 rounded-xl border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 shadow-sm transition hover:border-gray-300 hover:bg-gray-50 [&::-webkit-details-marker]:hidden">
<Trash2 size={12} className="text-sand-500" />
<span>批量清理</span>
</summary>
<div className="absolute right-0 top-9 z-30 min-w-44 rounded-xl border border-gray-200 bg-white p-1.5 shadow-xl backdrop-blur">
{failedCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() => (isDownload ? strmAPI.retryFailedDownloads() : strmAPI.retryFailedUploads()),
'确定重新入队所有失败任务?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-brand-500 hover:bg-brand-50"
>
<RefreshCw size={13} />
<span>重试所有失败 ({failedCount})</span>
</button>
)}
{activeTaskCount > 0 && (
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() => (isDownload ? strmAPI.cancelPendingDownloads() : strmAPI.cancelPendingUploads()),
'确定取消所有排队及进行中的任务?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-amber-600 hover:bg-amber-50"
>
<Ban size={13} />
<span>取消所有进行中 ({activeTaskCount})</span>
</button>
)}
<div className="my-1 border-t border-gray-100" />
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() => (isDownload ? strmAPI.clearDoneDownloads() : strmAPI.clearDoneUploads()),
'确定清空所有已完成记录?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-ink-100 hover:bg-gray-50"
>
<CheckCircle2 size={13} className="text-emerald-500" />
<span>清空已完成记录 ({doneCount})</span>
</button>
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() => (isDownload ? strmAPI.clearFailedDownloads() : strmAPI.clearFailedUploads()),
'确定清空所有失败记录?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-rose-500 hover:bg-rose-50"
>
<AlertCircle size={13} />
<span>清空失败记录 ({failedCount})</span>
</button>
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() => (isDownload ? strmAPI.clearCanceledDownloads() : strmAPI.clearCanceledUploads()),
'确定清空所有已取消的任务记录?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-ink-100 hover:bg-gray-50"
>
<Ban size={13} className="text-amber-500" />
<span>清空已取消记录 ({canceledCount})</span>
</button>
<button
type="button"
disabled={batchBusy}
onClick={(e) => {
e.currentTarget.closest('details')?.removeAttribute('open')
runGlobalBatch(
() => (isDownload ? strmAPI.clearFinishedDownloads() : strmAPI.clearFinishedUploads()),
'确定清空所有已完成、失败及取消的历史记录?',
)
}}
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-rose-500 hover:bg-rose-50"
>
<Trash2 size={13} />
<span>清空全部历史记录 ({finishedCount})</span>
</button>
</div>
</details>
</div>
)}
</div>
+2 -2
View File
@@ -163,8 +163,8 @@ function useEditableLibraryRootActions(refresh: () => Promise<void>, drafts: Edi
function useLibraryActions(refresh: () => Promise<void>) {
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) => {
+16 -3
View File
@@ -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])