mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27d2b79e1f | |||
| 1e291df647 | |||
| 1025e3c693 | |||
| ba27d48d10 | |||
| 6d20dc65de | |||
| d4c82ac07e |
@@ -0,0 +1,49 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
type pinnedLibrariesReq struct {
|
||||
LibraryIDs []string `json:"library_ids"`
|
||||
}
|
||||
|
||||
func getPinnedLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
ids, err := svc.Profile.GetPinnedLibraryIDs(c.Request.Context(), uid.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if ids == nil {
|
||||
ids = []string{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"library_ids": ids})
|
||||
}
|
||||
}
|
||||
|
||||
func setPinnedLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req pinnedLibrariesReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
ids, err := svc.Profile.SetPinnedLibraryIDs(c.Request.Context(), uid.(string), req.LibraryIDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if ids == nil {
|
||||
ids = []string{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"library_ids": ids})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/me", meHandler(svc))
|
||||
authed.PATCH("/me", updateProfileHandler(svc))
|
||||
authed.GET("/me/pinned-libraries", getPinnedLibrariesHandler(svc))
|
||||
authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(svc))
|
||||
authed.POST("/me/password", changePasswordHandler(svc))
|
||||
authed.POST("/me/logout", logoutHandler(svc))
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
|
||||
|
||||
for _, want := range []string{
|
||||
"GET /api/me",
|
||||
"GET /api/me/pinned-libraries",
|
||||
"PUT /api/me/pinned-libraries",
|
||||
"GET /api/auth/permissions",
|
||||
"GET /api/libraries",
|
||||
"GET /api/media",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -25,6 +25,9 @@ type User struct {
|
||||
// 为空时代表不限制(全库可访问)。
|
||||
AllowedLibraryIDs string `gorm:"type:text" json:"-"`
|
||||
AllowedLibraryList []string `gorm:"-" json:"allowed_library_ids,omitempty"`
|
||||
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
|
||||
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
|
||||
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
|
||||
// ExpiredAt is the account expiry time. Nil means the account never
|
||||
// expires. When set and in the past, the account is treated as expired
|
||||
// (login blocked) until an admin or a redemption code renews it.
|
||||
@@ -59,10 +62,30 @@ func (u *User) DecodeAllowedLibraryIDs() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// DecodePinnedLibraryIDs 解析 PinnedLibraryIDs 字段。
|
||||
func (u *User) DecodePinnedLibraryIDs() []string {
|
||||
if u == nil || strings.TrimSpace(u.PinnedLibraryIDs) == "" {
|
||||
return nil
|
||||
}
|
||||
var ids []string
|
||||
if err := json.Unmarshal([]byte(u.PinnedLibraryIDs), &ids); err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, id := range ids {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
if trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PopulateComputedFields 填充非 DB 虚拟计算字段(如 AllowedLibraryList)。
|
||||
func (u *User) PopulateComputedFields() {
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
u.AllowedLibraryList = u.DecodeAllowedLibraryIDs()
|
||||
u.PinnedLibraryList = u.DecodePinnedLibraryIDs()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
@@ -81,6 +82,106 @@ func (p *ProfileService) UpdateProfile(ctx context.Context, userID string, patch
|
||||
return p.repo.User.FindByID(ctx, userID)
|
||||
}
|
||||
|
||||
// GetPinnedLibraryIDs returns the user's pinned library IDs, filtered to libraries
|
||||
// they can still access.
|
||||
func (p *ProfileService) GetPinnedLibraryIDs(ctx context.Context, userID string) ([]string, error) {
|
||||
user, err := p.repo.User.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
visibility := UserDefaultMediaVisibility(ctx, p.repo, userID)
|
||||
accessible, err := p.accessibleLibraryIDSet(ctx, visibility)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filterPinnedLibraryIDs(user.DecodePinnedLibraryIDs(), accessible), nil
|
||||
}
|
||||
|
||||
// SetPinnedLibraryIDs persists the user's pinned library order after filtering to
|
||||
// accessible, enabled libraries.
|
||||
func (p *ProfileService) SetPinnedLibraryIDs(ctx context.Context, userID string, ids []string) ([]string, error) {
|
||||
if userID == "" {
|
||||
return nil, errors.New("missing user id")
|
||||
}
|
||||
user, err := p.repo.User.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
visibility := UserDefaultMediaVisibility(ctx, p.repo, userID)
|
||||
accessible, err := p.accessibleLibraryIDSet(ctx, visibility)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized := filterPinnedLibraryIDs(normalizePinnedLibraryIDs(ids), accessible)
|
||||
raw, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.repo.User.UpdateFields(ctx, userID, map[string]any{
|
||||
"pinned_library_ids": string(raw),
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func (p *ProfileService) accessibleLibraryIDSet(ctx context.Context, visibility MediaVisibility) (map[string]struct{}, error) {
|
||||
libs, err := p.repo.Library.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]struct{})
|
||||
for _, lib := range libs {
|
||||
if !lib.Enabled {
|
||||
continue
|
||||
}
|
||||
if !LibraryVisibleForUser(ctx, p.repo, lib, visibility) {
|
||||
continue
|
||||
}
|
||||
out[lib.ID] = struct{}{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizePinnedLibraryIDs(ids []string) []string {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
out := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterPinnedLibraryIDs(ids []string, accessible map[string]struct{}) []string {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if _, ok := accessible[id]; ok {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AdminUpdateRole lets administrators promote / demote another user. The
|
||||
// caller is expected to gate the route with AdminRequired.
|
||||
func (p *ProfileService) AdminUpdateRole(ctx context.Context, userID, role string) (*model.User, error) {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestProfilePinnedLibrariesFiltersInaccessibleAndPreservesOrder(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := NewProfileService(zap.NewNop(), repos)
|
||||
|
||||
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
|
||||
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
libA := &model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
libB := &model.Library{Name: "TV", Path: "/media/tv", Type: "tv", Enabled: true}
|
||||
libHidden := &model.Library{Name: "Adult", Path: "/media/adult", Type: "movie", Enabled: true}
|
||||
for _, lib := range []*model.Library{libA, libB, libHidden} {
|
||||
if err := repos.Library.Create(t.Context(), lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := repos.User.UpdateFields(t.Context(), user.ID, map[string]any{
|
||||
"allowed_library_ids": `["` + libA.ID + `","` + libB.ID + `"]`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := svc.SetPinnedLibraryIDs(t.Context(), user.ID, []string{
|
||||
libB.ID, libHidden.ID, libA.ID, libB.ID, "missing",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetPinnedLibraryIDs: %v", err)
|
||||
}
|
||||
want := []string{libB.ID, libA.ID}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("SetPinnedLibraryIDs = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
loaded, err := svc.GetPinnedLibraryIDs(t.Context(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPinnedLibraryIDs: %v", err)
|
||||
}
|
||||
if len(loaded) != len(want) || loaded[0] != want[0] || loaded[1] != want[1] {
|
||||
t.Fatalf("GetPinnedLibraryIDs = %v, want %v", loaded, want)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,12 @@ export const profileAPI = {
|
||||
}) =>
|
||||
api.patch<User>('/me', patch).then((r) => r.data),
|
||||
|
||||
getPinnedLibraries: () =>
|
||||
api.get<{ library_ids: string[] }>('/me/pinned-libraries').then((r) => r.data.library_ids ?? []),
|
||||
|
||||
setPinnedLibraries: (libraryIds: string[]) =>
|
||||
api.put<{ library_ids: string[] }>('/me/pinned-libraries', { library_ids: libraryIds }).then((r) => r.data.library_ids ?? []),
|
||||
|
||||
adminUpdateRole: (id: string, role: 'admin' | 'user') =>
|
||||
api.patch<User>(`/admin/users/${id}/role`, { role }).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import {
|
||||
loadPinnedLibraryIds,
|
||||
savePinnedLibraryIds,
|
||||
togglePinnedLibraryId,
|
||||
} from '../utils/pinnedLibraries'
|
||||
|
||||
export function usePinnedLibraries() {
|
||||
const [pinnedIds, setPinnedIds] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
loadPinnedLibraryIds()
|
||||
.then((ids) => {
|
||||
if (!cancelled) setPinnedIds(ids)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPinnedIds([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const togglePin = useCallback(async (libraryId: string) => {
|
||||
let previous: string[] = []
|
||||
let optimistic: string[] = []
|
||||
setPinnedIds((current) => {
|
||||
previous = current
|
||||
optimistic = togglePinnedLibraryId(current, libraryId)
|
||||
return optimistic
|
||||
})
|
||||
setSyncing(true)
|
||||
try {
|
||||
const saved = await savePinnedLibraryIds(optimistic)
|
||||
setPinnedIds(saved)
|
||||
} catch {
|
||||
setPinnedIds(previous)
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { pinnedIds, loading, syncing, togglePin }
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { historyAPI } from '../api/history'
|
||||
import type { HistoryItem } from '../api/playback'
|
||||
import type { Library, Media } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||
import { sortByPinnedIds } from '../utils/pinnedLibraries'
|
||||
import {
|
||||
ContinueWatchingSection,
|
||||
HomeCarouselSection,
|
||||
@@ -22,6 +24,7 @@ export function HomePage() {
|
||||
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>>({})
|
||||
const [history, setHistory] = useState<HistoryItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { pinnedIds } = usePinnedLibraries()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -126,6 +129,8 @@ export function HomePage() {
|
||||
return candidateMedia.slice(0, 10)
|
||||
}, [libraries, libraryData])
|
||||
|
||||
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
|
||||
|
||||
const empty =
|
||||
!loading &&
|
||||
libraries.length === 0 &&
|
||||
@@ -150,9 +155,9 @@ export function HomePage() {
|
||||
{history.length > 0 && <ContinueWatchingSection history={history} />}
|
||||
|
||||
{/* 3. 媒体库卡片区 */}
|
||||
{libraries.length > 0 && (
|
||||
{sortedLibraries.length > 0 && (
|
||||
<HomeLibrariesSection
|
||||
libraries={libraries}
|
||||
libraries={sortedLibraries}
|
||||
libraryData={libraryData}
|
||||
libraryCounts={libraryCounts}
|
||||
/>
|
||||
@@ -160,7 +165,7 @@ export function HomePage() {
|
||||
|
||||
{/* 4. 各媒体库内容展示行 */}
|
||||
<div className="space-y-10">
|
||||
{libraries.map((lib) => {
|
||||
{sortedLibraries.map((lib) => {
|
||||
const cards = libraryData[lib.id]?.cards || []
|
||||
if (cards.length === 0) return null
|
||||
return (
|
||||
|
||||
@@ -3,17 +3,18 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { toolsAPI } from '../api/tools'
|
||||
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
|
||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||
import {
|
||||
LibrariesContent,
|
||||
LibrariesEmptyState,
|
||||
LibrariesHeader,
|
||||
} from './LibrariesPageSections'
|
||||
import type { LibraryPreview } from './librariesPageModel'
|
||||
import { readPinnedLibraryIds, sortLibraryPreviews, togglePinnedLibraryId } from '../utils/pinnedLibraries'
|
||||
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
|
||||
|
||||
export function LibrariesPage() {
|
||||
const [previews, setPreviews] = useState<LibraryPreview[]>([])
|
||||
const [pinnedIds, setPinnedIds] = useState<string[]>(() => readPinnedLibraryIds())
|
||||
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [repairing, setRepairing] = useState(false)
|
||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false)
|
||||
@@ -62,12 +63,12 @@ export function LibrariesPage() {
|
||||
const sortedPreviews = useMemo(() => sortLibraryPreviews(previews, pinnedIds), [previews, pinnedIds])
|
||||
|
||||
const handleTogglePin = useCallback((libraryId: string) => {
|
||||
setPinnedIds(togglePinnedLibraryId(libraryId))
|
||||
}, [])
|
||||
void togglePin(libraryId)
|
||||
}, [togglePin])
|
||||
|
||||
const total = useMemo(() => previews.reduce((sum, preview) => sum + preview.total, 0), [previews])
|
||||
|
||||
if (loading) {
|
||||
if (loading || pinnedLoading) {
|
||||
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface User {
|
||||
force_password_reset: boolean
|
||||
is_active: boolean
|
||||
allowed_library_ids?: string[]
|
||||
pinned_library_ids?: string[]
|
||||
is_default_admin?: boolean
|
||||
is_protected?: boolean
|
||||
realtime_online?: boolean
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { profileAPI } from '../api/profile'
|
||||
|
||||
const STORAGE_KEY = 'mebox_pinned_libraries'
|
||||
const MIGRATION_KEY = 'mebox_pinned_libraries_migrated'
|
||||
|
||||
function parsePinnedIds(raw: string | null): string[] {
|
||||
if (!raw) return []
|
||||
@@ -11,28 +14,74 @@ function parsePinnedIds(raw: string | null): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
export function readPinnedLibraryIds(): string[] {
|
||||
export function readLegacyPinnedLibraryIds(): string[] {
|
||||
if (typeof window === 'undefined') return []
|
||||
return parsePinnedIds(window.localStorage.getItem(STORAGE_KEY))
|
||||
}
|
||||
|
||||
export function writePinnedLibraryIds(ids: string[]): void {
|
||||
function writeLegacyPinnedLibraryIds(ids: string[]): void {
|
||||
if (typeof window === 'undefined') return
|
||||
if (ids.length === 0) {
|
||||
window.localStorage.removeItem(STORAGE_KEY)
|
||||
return
|
||||
}
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(ids))
|
||||
}
|
||||
|
||||
export function togglePinnedLibraryId(id: string): string[] {
|
||||
const current = readPinnedLibraryIds()
|
||||
function markPinnedLibrariesMigrated(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(MIGRATION_KEY, '1')
|
||||
window.localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
function hasPinnedLibrariesMigrated(): boolean {
|
||||
if (typeof window === 'undefined') return true
|
||||
return window.localStorage.getItem(MIGRATION_KEY) === '1'
|
||||
}
|
||||
|
||||
export async function loadPinnedLibraryIds(): Promise<string[]> {
|
||||
const remote = await profileAPI.getPinnedLibraries()
|
||||
if (!hasPinnedLibrariesMigrated()) {
|
||||
const legacy = readLegacyPinnedLibraryIds()
|
||||
if (legacy.length > 0 && remote.length === 0) {
|
||||
const migrated = await profileAPI.setPinnedLibraries(legacy)
|
||||
markPinnedLibrariesMigrated()
|
||||
return migrated
|
||||
}
|
||||
markPinnedLibrariesMigrated()
|
||||
}
|
||||
return remote
|
||||
}
|
||||
|
||||
export async function savePinnedLibraryIds(ids: string[]): Promise<string[]> {
|
||||
const saved = await profileAPI.setPinnedLibraries(ids)
|
||||
writeLegacyPinnedLibraryIds(saved)
|
||||
return saved
|
||||
}
|
||||
|
||||
export function togglePinnedLibraryId(current: string[], id: string): string[] {
|
||||
const index = current.indexOf(id)
|
||||
const next = index >= 0 ? current.filter((item) => item !== id) : [...current, id]
|
||||
writePinnedLibraryIds(next)
|
||||
return next
|
||||
return index >= 0 ? current.filter((item) => item !== id) : [...current, id]
|
||||
}
|
||||
|
||||
export function isLibraryPinned(id: string, pinnedIds: string[]): boolean {
|
||||
return pinnedIds.includes(id)
|
||||
}
|
||||
|
||||
export function sortByPinnedIds<T extends { id: string }>(items: T[], pinnedIds: string[]): T[] {
|
||||
if (pinnedIds.length === 0) return items
|
||||
const rank = new Map(pinnedIds.map((pinnedId, index) => [pinnedId, index]))
|
||||
return [...items].sort((a, b) => {
|
||||
const aRank = rank.get(a.id)
|
||||
const bRank = rank.get(b.id)
|
||||
const aPinned = aRank !== undefined
|
||||
const bPinned = bRank !== undefined
|
||||
if (aPinned !== bPinned) return aPinned ? -1 : 1
|
||||
if (aPinned && bPinned) return aRank - bRank
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
export function sortLibraryPreviews<T extends { library: { id: string } }>(items: T[], pinnedIds: string[]): T[] {
|
||||
if (pinnedIds.length === 0) return items
|
||||
const rank = new Map(pinnedIds.map((id, index) => [id, index]))
|
||||
|
||||
Reference in New Issue
Block a user