mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3051eaffe | |||
| 496a897782 |
@@ -147,7 +147,9 @@ roots = append(roots, service.LibraryRootInput{Path: req.Path})
|
||||
}
|
||||
|
||||
type updateLibraryReq struct {
|
||||
CoverURL string `json:"cover_url"`
|
||||
CoverURL *string `json:"cover_url"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
CarouselEnabled *bool `json:"carousel_enabled"`
|
||||
}
|
||||
|
||||
func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
@@ -157,9 +159,17 @@ func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.Media.UpdateLibraryCover(c.Request.Context(), c.Param("id"), req.CoverURL); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
if req.CoverURL != nil {
|
||||
if err := svc.Media.UpdateLibraryCover(c.Request.Context(), c.Param("id"), *req.CoverURL); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.SortOrder != nil || req.CarouselEnabled != nil {
|
||||
if err := svc.Media.UpdateLibraryFields(c.Request.Context(), c.Param("id"), req.SortOrder, req.CarouselEnabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
lib, err := svc.Repo.Library.FindByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || lib == nil {
|
||||
@@ -170,6 +180,25 @@ func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
type reorderLibrariesReq struct {
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
}
|
||||
|
||||
func reorderLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req reorderLibrariesReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.Media.ReorderLibraries(c.Request.Context(), req.IDs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"updated": len(req.IDs)})
|
||||
}
|
||||
}
|
||||
|
||||
func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
@@ -50,6 +50,8 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.GET("/strm/downloads", downloadQueueHandler(svc))
|
||||
admin.POST("/strm/downloads/:id/cancel", cancelStrmDownloadHandler(svc))
|
||||
admin.POST("/strm/downloads/:id/retry", retryStrmDownloadHandler(svc))
|
||||
admin.DELETE("/strm/downloads/:id", deleteStrmDownloadHandler(svc))
|
||||
admin.POST("/strm/downloads/batch", batchActionDownloadsHandler(svc))
|
||||
admin.POST("/strm/downloads/clear-done", clearDoneDownloadsHandler(svc))
|
||||
admin.POST("/strm/downloads/clear-finished", clearFinishedDownloadsHandler(svc))
|
||||
admin.POST("/strm/downloads/clear-canceled", clearCanceledDownloadsHandler(svc))
|
||||
@@ -58,6 +60,8 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.GET("/strm/uploads", uploadQueueHandler(svc))
|
||||
admin.POST("/strm/uploads/:id/cancel", cancelStrmUploadHandler(svc))
|
||||
admin.POST("/strm/uploads/:id/retry", retryStrmUploadHandler(svc))
|
||||
admin.DELETE("/strm/uploads/:id", deleteStrmUploadHandler(svc))
|
||||
admin.POST("/strm/uploads/batch", batchActionUploadsHandler(svc))
|
||||
admin.POST("/strm/uploads/cancel-pending", cancelPendingUploadsHandler(svc))
|
||||
admin.POST("/strm/uploads/clear-canceled", clearCanceledUploadsHandler(svc))
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
|
||||
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
|
||||
authed.GET("/libraries/:id", getLibraryHandler(svc))
|
||||
authed.PATCH("/libraries/:id", middleware.AdminRequired(), updateLibraryHandler(svc))
|
||||
authed.PUT("/libraries/reorder", middleware.AdminRequired(), reorderLibrariesHandler(svc))
|
||||
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
|
||||
authed.GET("/libraries/:id/roots", middleware.AdminRequired(), listLibraryRootsHandler(svc))
|
||||
authed.POST("/libraries/:id/roots", middleware.AdminRequired(), createLibraryRootHandler(svc))
|
||||
|
||||
@@ -393,6 +393,63 @@ func retryStrmUploadHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func deleteStrmDownloadHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Strm.DeleteDownloadTask(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func deleteStrmUploadHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Strm.DeleteUploadTask(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
type queueBatchReq struct {
|
||||
Action string `json:"action" binding:"required"`
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
}
|
||||
|
||||
func batchActionDownloadsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req queueBatchReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
n, err := svc.Strm.BatchActionDownloadTasks(c.Request.Context(), req.Action, req.IDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"affected": n, "action": req.Action})
|
||||
}
|
||||
}
|
||||
|
||||
func batchActionUploadsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req queueBatchReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
n, err := svc.Strm.BatchActionUploadTasks(c.Request.Context(), req.Action, req.IDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"affected": n, "action": req.Action})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 下载队列批量操作 ─────────────────────────────────────────────────────────
|
||||
|
||||
func clearDoneDownloadsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
@@ -3,12 +3,14 @@ package model
|
||||
// Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。
|
||||
type Library struct {
|
||||
Base
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
Path string `gorm:"size:1024;not null" json:"path"`
|
||||
Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music
|
||||
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"`
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
Path string `gorm:"size:1024;not null" json:"path"`
|
||||
Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music
|
||||
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
SortOrder int `gorm:"index;default:0" json:"sort_order"` // 手动拖拽排序用,越小越靠前
|
||||
CarouselEnabled bool `gorm:"default:true" json:"carousel_enabled"` // 是否参与首页海报轮播
|
||||
Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"`
|
||||
}
|
||||
|
||||
// LibraryRoot 是逻辑媒体库下的一条真实物理/挂载路径。
|
||||
|
||||
@@ -50,7 +50,7 @@ func (r *LibraryRepository) CreateWithRoots(ctx context.Context, l *model.Librar
|
||||
// List returns all enabled+disabled libraries.
|
||||
func (r *LibraryRepository) List(ctx context.Context) ([]model.Library, error) {
|
||||
var ls []model.Library
|
||||
q := r.db.WithContext(ctx).Order("created_at asc")
|
||||
q := r.db.WithContext(ctx).Order("sort_order asc, created_at asc")
|
||||
if r.hasLibraryRootsTable() {
|
||||
q = q.Preload("Roots", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("sort_order asc, created_at asc")
|
||||
@@ -60,6 +60,23 @@ func (r *LibraryRepository) List(ctx context.Context) ([]model.Library, error) {
|
||||
return ls, err
|
||||
}
|
||||
|
||||
// SetSortOrder assigns sort_order to libraries, preserving position order for
|
||||
// any library not present in the provided map.
|
||||
func (r *LibraryRepository) SetSortOrder(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for i, id := range ids {
|
||||
if err := tx.Model(&model.Library{}).Where("id = ?", id).
|
||||
Update("sort_order", i).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// FindByID returns the library, or (nil, nil) when missing.
|
||||
func (r *LibraryRepository) FindByID(ctx context.Context, id string) (*model.Library, error) {
|
||||
var l model.Library
|
||||
|
||||
@@ -308,6 +308,66 @@ func (r *StrmDownloadTaskRepository) Delete(ctx context.Context, id string) erro
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteBatch 批量删除指定 ID 的下载任务。
|
||||
func (r *StrmDownloadTaskRepository) DeleteBatch(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Delete(&model.StrmDownloadTask{})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// RetryBatch 批量重试指定 ID 的失败/已取消下载任务。
|
||||
func (r *StrmDownloadTaskRepository) RetryBatch(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
|
||||
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskFailed, model.StrmTaskCanceled}).
|
||||
Updates(map[string]any{
|
||||
"status": model.StrmTaskPending,
|
||||
"error": "",
|
||||
"retry_count": 0,
|
||||
"next_try_at": nil,
|
||||
"started_at": nil,
|
||||
"finished_at": nil,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// CancelBatch 批量取消指定 ID 的排队/进行中下载任务。
|
||||
func (r *StrmDownloadTaskRepository) CancelBatch(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
now := time.Now()
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
|
||||
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskPending, model.StrmTaskRunning}).
|
||||
Updates(map[string]any{
|
||||
"status": model.StrmTaskCanceled,
|
||||
"error": "已批量取消",
|
||||
"finished_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ClearDone 清空全部已完成下载任务。
|
||||
func (r *StrmDownloadTaskRepository) ClearDone(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
@@ -555,6 +615,66 @@ func (r *StrmUploadTaskRepository) Delete(ctx context.Context, id string) error
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteBatch 批量删除指定 ID 的上传任务。
|
||||
func (r *StrmUploadTaskRepository) DeleteBatch(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Delete(&model.StrmUploadTask{})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// RetryBatch 批量重试指定 ID 的失败/已取消上传任务。
|
||||
func (r *StrmUploadTaskRepository) RetryBatch(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
|
||||
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskFailed, model.StrmTaskCanceled}).
|
||||
Updates(map[string]any{
|
||||
"status": model.StrmTaskPending,
|
||||
"error": "",
|
||||
"retry_count": 0,
|
||||
"next_try_at": nil,
|
||||
"started_at": nil,
|
||||
"finished_at": nil,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// CancelBatch 批量取消指定 ID 的排队/进行中上传任务。
|
||||
func (r *StrmUploadTaskRepository) CancelBatch(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
now := time.Now()
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
|
||||
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskPending, model.StrmTaskRunning}).
|
||||
Updates(map[string]any{
|
||||
"status": model.StrmTaskCanceled,
|
||||
"error": "已批量取消",
|
||||
"finished_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ClearCanceled 清空全部已取消上传任务。
|
||||
func (r *StrmUploadTaskRepository) ClearCanceled(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
|
||||
@@ -105,6 +105,27 @@ func (s *MediaService) UpdateLibraryCover(ctx context.Context, libraryID, coverU
|
||||
Update("cover_url", strings.TrimSpace(coverURL)).Error
|
||||
}
|
||||
|
||||
// UpdateLibraryFields updates sort_order / carousel_enabled on a library.
|
||||
func (s *MediaService) UpdateLibraryFields(ctx context.Context, libraryID string, sortOrder *int, carouselEnabled *bool) error {
|
||||
updates := map[string]any{}
|
||||
if sortOrder != nil {
|
||||
updates["sort_order"] = *sortOrder
|
||||
}
|
||||
if carouselEnabled != nil {
|
||||
updates["carousel_enabled"] = *carouselEnabled
|
||||
}
|
||||
if len(updates) == 0 || strings.TrimSpace(libraryID) == "" {
|
||||
return nil
|
||||
}
|
||||
return s.repo.DB.WithContext(ctx).Model(&model.Library{}).
|
||||
Where("id = ?", libraryID).Updates(updates).Error
|
||||
}
|
||||
|
||||
// ReorderLibraries persists a full media-library ordering.
|
||||
func (s *MediaService) ReorderLibraries(ctx context.Context, ids []string) error {
|
||||
return s.repo.Library.SetSortOrder(ctx, ids)
|
||||
}
|
||||
|
||||
func (s *MediaService) findLogicalLibrary(ctx context.Context, name, kind string) (*model.Library, error) {
|
||||
if s == nil || s.repo == nil || s.repo.Library == nil {
|
||||
return nil, nil
|
||||
|
||||
@@ -551,6 +551,44 @@ func (s *StrmService) RetryUploadTask(ctx context.Context, id string) error {
|
||||
|
||||
// ─── 下载队列批量操作(handler 使用) ─────────────────────────────────────────
|
||||
|
||||
// DeleteDownloadTask 删除一个下载任务记录。
|
||||
func (s *StrmService) DeleteDownloadTask(ctx context.Context, id string) error {
|
||||
return s.repo.StrmDownload.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// DeleteUploadTask 删除一个上传任务记录。
|
||||
func (s *StrmService) DeleteUploadTask(ctx context.Context, id string) error {
|
||||
return s.repo.StrmUpload.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// BatchActionDownloadTasks 对选中的下载任务执行批量操作(delete / retry / cancel)。
|
||||
func (s *StrmService) BatchActionDownloadTasks(ctx context.Context, action string, ids []string) (int64, error) {
|
||||
switch action {
|
||||
case "delete":
|
||||
return s.repo.StrmDownload.DeleteBatch(ctx, ids)
|
||||
case "retry":
|
||||
return s.repo.StrmDownload.RetryBatch(ctx, ids)
|
||||
case "cancel":
|
||||
return s.repo.StrmDownload.CancelBatch(ctx, ids)
|
||||
default:
|
||||
return 0, fmt.Errorf("不支持的批量操作: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
// BatchActionUploadTasks 对选中的上传任务执行批量操作(delete / retry / cancel)。
|
||||
func (s *StrmService) BatchActionUploadTasks(ctx context.Context, action string, ids []string) (int64, error) {
|
||||
switch action {
|
||||
case "delete":
|
||||
return s.repo.StrmUpload.DeleteBatch(ctx, ids)
|
||||
case "retry":
|
||||
return s.repo.StrmUpload.RetryBatch(ctx, ids)
|
||||
case "cancel":
|
||||
return s.repo.StrmUpload.CancelBatch(ctx, ids)
|
||||
default:
|
||||
return 0, fmt.Errorf("不支持的批量操作: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
// ClearDoneDownloadTasks 清空全部已完成下载记录,返回删除数量。
|
||||
func (s *StrmService) ClearDoneDownloadTasks(ctx context.Context) (int64, error) {
|
||||
return s.repo.StrmDownload.ClearDone(ctx)
|
||||
|
||||
+10
-2
@@ -105,8 +105,16 @@ export const libraryAPI = {
|
||||
createPerSubfolder: (parentPath: string, type: string, coverURL = '') =>
|
||||
api.post<{ libraries: Library[] }>('/libraries', { path: parentPath, type, cover_url: coverURL, create_per_subfolder: true }).then((r) => r.data),
|
||||
|
||||
update: (id: string, payload: { cover_url: string }) =>
|
||||
api.patch<Library>(`/libraries/${id}`, payload).then((r) => r.data),
|
||||
update: (
|
||||
id: string,
|
||||
payload: {
|
||||
cover_url?: string
|
||||
sort_order?: number | null
|
||||
carousel_enabled?: boolean | null
|
||||
},
|
||||
) => api.patch<Library>(`/libraries/${id}`, payload).then((r) => r.data),
|
||||
|
||||
reorder: (ids: string[]) => api.put('/libraries/reorder', { ids }).then((r) => r.data),
|
||||
|
||||
remove: (id: string) => api.delete(`/libraries/${id}`).then((r) => r.data),
|
||||
|
||||
|
||||
+15
-3
@@ -144,6 +144,12 @@ export const strmAPI = {
|
||||
retryDownload: (id: string) =>
|
||||
api.post(`/admin/strm/downloads/${id}/retry`).then((r) => r.data),
|
||||
|
||||
deleteDownload: (id: string) =>
|
||||
api.delete(`/admin/strm/downloads/${id}`).then((r) => r.data),
|
||||
|
||||
batchActionDownloads: (action: 'delete' | 'retry' | 'cancel', ids: string[]) =>
|
||||
api.post<{ affected: number; action: string }>('/admin/strm/downloads/batch', { action, ids }).then((r) => r.data),
|
||||
|
||||
clearDoneDownloads: () =>
|
||||
api.post<{ deleted: number }>('/admin/strm/downloads/clear-done').then((r) => r.data),
|
||||
|
||||
@@ -169,12 +175,18 @@ export const strmAPI = {
|
||||
cancelUpload: (id: string) =>
|
||||
api.post(`/admin/strm/uploads/${id}/cancel`).then((r) => r.data),
|
||||
|
||||
retryUpload: (id: string) =>
|
||||
api.post(`/admin/strm/uploads/${id}/retry`).then((r) => r.data),
|
||||
|
||||
deleteUpload: (id: string) =>
|
||||
api.delete(`/admin/strm/uploads/${id}`).then((r) => r.data),
|
||||
|
||||
batchActionUploads: (action: 'delete' | 'retry' | 'cancel', ids: string[]) =>
|
||||
api.post<{ affected: number; action: string }>('/admin/strm/uploads/batch', { action, ids }).then((r) => r.data),
|
||||
|
||||
cancelPendingUploads: () =>
|
||||
api.post<{ canceled: number }>('/admin/strm/uploads/cancel-pending').then((r) => r.data),
|
||||
|
||||
clearCanceledUploads: () =>
|
||||
api.post<{ deleted: number }>('/admin/strm/uploads/clear-canceled').then((r) => r.data),
|
||||
|
||||
retryUpload: (id: string) =>
|
||||
api.post(`/admin/strm/uploads/${id}/retry`).then((r) => r.data),
|
||||
}
|
||||
@@ -35,6 +35,8 @@ export function AdminLibraryPanel() {
|
||||
onRemoveLibrary={libraryActions.removeLibrary}
|
||||
onAddLibraryRoot={libraryActions.addLibraryRoot}
|
||||
onEditLibraryCover={libraryActions.editLibraryCover}
|
||||
onToggleCarousel={libraryActions.toggleCarouselLibrary}
|
||||
onReorder={libraryActions.reorderLibraries}
|
||||
/>
|
||||
<LibraryStorageStats />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import { Folder, Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Folder, GripVertical, Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
|
||||
|
||||
import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog'
|
||||
import type { Library, LibraryRoot } from '../types'
|
||||
@@ -18,11 +19,15 @@ type LibraryTableProps = {
|
||||
onRemoveLibrary: (library: Library) => void
|
||||
onAddLibraryRoot: (library: Library, path?: string, name?: string) => void
|
||||
onEditLibraryCover: (library: Library) => void
|
||||
onToggleCarousel: (library: Library) => void
|
||||
onReorder: (orderedLibs: Library[]) => void
|
||||
}
|
||||
|
||||
export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
|
||||
const [browsingRoot, setBrowsingRoot] = useState<{ libraryID: string; root: LibraryRoot; initialPath?: string } | null>(null)
|
||||
const [addingRootLib, setAddingRootLib] = useState<Library | null>(null)
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null)
|
||||
const dragOverId = useRef<string | null>(null)
|
||||
|
||||
const handleSelectRootPath = (selectedPath: string) => {
|
||||
if (browsingRoot) {
|
||||
@@ -40,15 +45,39 @@ export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleReorder = (fromId: string, overId: string) => {
|
||||
if (fromId === overId) return
|
||||
const copy = [...libs]
|
||||
const fromIndex = copy.findIndex((l) => l.id === fromId)
|
||||
const overIndex = copy.findIndex((l) => l.id === overId)
|
||||
if (fromIndex < 0 || overIndex < 0) return
|
||||
const [moved] = copy.splice(fromIndex, 1)
|
||||
copy.splice(overIndex, 0, moved)
|
||||
actions.onReorder(copy)
|
||||
}
|
||||
|
||||
const handleDrop = (e: DragEvent, overId: string) => {
|
||||
e.preventDefault()
|
||||
dragOverId.current = null
|
||||
setDraggingId(null)
|
||||
if (draggingId && overId !== draggingId) {
|
||||
handleReorder(draggingId, overId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="glass-panel overflow-x-auto !p-3">
|
||||
<table className="w-full min-w-[900px] text-left text-sm">
|
||||
<table className="w-full min-w-[960px] text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="w-8 text-center" title="拖动排序">
|
||||
<GripVertical size={13} className="mx-auto text-gray-300" />
|
||||
</th>
|
||||
<th className="w-28 py-2">名称</th>
|
||||
<th>路径</th>
|
||||
<th className="w-20">类型</th>
|
||||
<th className="w-24">轮播</th>
|
||||
<th className="w-12 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -59,6 +88,23 @@ export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
|
||||
library={library}
|
||||
onBrowseRoot={(root) => setBrowsingRoot({ libraryID: library.id, root, initialPath: root.path })}
|
||||
onOpenAddRoot={() => setAddingRootLib(library)}
|
||||
dragging={draggingId === library.id}
|
||||
dragOver={dragOverId.current === library.id}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
dragOverId.current = null
|
||||
setDraggingId(library.id)
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
if (dragOverId.current !== library.id) dragOverId.current = library.id
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
dragOverId.current = null
|
||||
setDraggingId(null)
|
||||
}}
|
||||
onDrop={(e) => handleDrop(e, library.id)}
|
||||
{...actions}
|
||||
/>
|
||||
))}
|
||||
@@ -90,11 +136,37 @@ type LibraryTableRowProps = Omit<LibraryTableProps, 'libs'> & {
|
||||
library: Library
|
||||
onBrowseRoot: (root: LibraryRoot) => void
|
||||
onOpenAddRoot: () => void
|
||||
dragging?: boolean
|
||||
dragOver?: boolean
|
||||
onDragStart?: (e: DragEvent) => void
|
||||
onDragOver?: (e: DragEvent) => void
|
||||
onDragEnd?: () => void
|
||||
onDrop?: (e: DragEvent) => void
|
||||
}
|
||||
|
||||
function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
|
||||
function LibraryTableRow({ library, dragging, dragOver, onDragStart, onDragOver, onDragEnd, onDrop, ...actions }: LibraryTableRowProps) {
|
||||
return (
|
||||
<tr className="border-t border-gray-200">
|
||||
<tr
|
||||
draggable={false}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={onDragEnd}
|
||||
onDrop={onDrop}
|
||||
className={`border-t border-gray-200 transition-colors ${
|
||||
dragging ? 'bg-primary-400/10 opacity-60' : dragOver ? 'bg-primary-400/5' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
className="inline-flex cursor-grab items-center justify-center rounded p-1 text-gray-400 transition hover:bg-gray-100 hover:text-brand-500 active:cursor-grabbing"
|
||||
title="拖动以调整媒体库显示顺序"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-2 pr-3 font-medium text-ink-600">
|
||||
<div className="flex items-center gap-2">
|
||||
{library.cover_url && <img src={library.cover_url} alt="" className="h-10 w-8 rounded object-cover" />}
|
||||
@@ -105,6 +177,9 @@ function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
|
||||
<LibraryRootsCell library={library} {...actions} />
|
||||
</td>
|
||||
<td className="px-3 text-ink-100">{library.type}</td>
|
||||
<td className="py-2 text-ink-100">
|
||||
<CarouselToggle library={library} onToggleCarousel={actions.onToggleCarousel} />
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<LibraryActionsCell library={library} {...actions} />
|
||||
</td>
|
||||
@@ -112,6 +187,25 @@ function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselToggle({ library, onToggleCarousel }: { library: Library; onToggleCarousel: (library: Library) => void }) {
|
||||
const on = library.carousel_enabled ?? true
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleCarousel(library)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs font-semibold transition ${
|
||||
on
|
||||
? 'border-brand-500/50 bg-brand-500/10 text-brand-500'
|
||||
: 'border-gray-300 bg-white text-ink-50 hover:border-gray-400'
|
||||
}`}
|
||||
title={on ? '参与首页海报轮播(点击关闭)' : '未参与首页海报轮播(点击开启)'}
|
||||
>
|
||||
<span className={`h-3.5 w-3.5 rounded-full ${on ? 'bg-brand-500' : 'bg-gray-300'}`} />
|
||||
{on ? '参与轮播' : '未参与'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function LibraryRootsCell({ library, ...actions }: LibraryTableRowProps) {
|
||||
const roots = library.roots?.length ? library.roots : [fallbackLibraryRoot(library)]
|
||||
return (
|
||||
@@ -255,18 +349,90 @@ function LibraryActionsCell({ library, onScanLibrary, onRemoveLibrary, onOpenAdd
|
||||
}
|
||||
|
||||
function ActionMenu({ label, children }: { label: string; children: ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [coords, setCoords] = useState<{ top?: number; bottom?: number; right: number } | null>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const toggleMenu = (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isOpen) {
|
||||
setIsOpen(false)
|
||||
return
|
||||
}
|
||||
if (triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const estimatedHeight = 180
|
||||
const openUpward = spaceBelow < estimatedHeight && rect.top > estimatedHeight
|
||||
setCoords({
|
||||
top: openUpward ? undefined : rect.bottom + 4,
|
||||
bottom: openUpward ? window.innerHeight - rect.top + 4 : undefined,
|
||||
right: window.innerWidth - rect.right,
|
||||
})
|
||||
setIsOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleClickOutside = (e: globalThis.MouseEvent) => {
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(e.target as Node) &&
|
||||
triggerRef.current &&
|
||||
!triggerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
const handleScrollOrResize = () => setIsOpen(false)
|
||||
window.addEventListener('mousedown', handleClickOutside)
|
||||
window.addEventListener('scroll', handleScrollOrResize, true)
|
||||
window.addEventListener('resize', handleScrollOrResize)
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', handleClickOutside)
|
||||
window.removeEventListener('scroll', handleScrollOrResize, true)
|
||||
window.removeEventListener('resize', handleScrollOrResize)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<details className="group relative inline-flex justify-end">
|
||||
<summary
|
||||
className="flex h-8 w-8 cursor-pointer list-none items-center justify-center rounded-lg border border-gray-200 bg-white text-ink-50 transition hover:border-primary-400/50 hover:text-brand-500 [&::-webkit-details-marker]:hidden"
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={toggleMenu}
|
||||
className={`inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border transition ${
|
||||
isOpen
|
||||
? 'border-brand-500 bg-brand-500/10 text-brand-500'
|
||||
: 'border-gray-200 bg-white text-ink-50 hover:border-primary-400/50 hover:text-brand-500'
|
||||
}`}
|
||||
title={label}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</summary>
|
||||
<div className="absolute right-0 top-9 z-30 min-w-28 rounded-lg border border-gray-200 bg-white p-1 shadow-lg">
|
||||
{children}
|
||||
</div>
|
||||
</details>
|
||||
</button>
|
||||
|
||||
{isOpen &&
|
||||
coords &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: coords.top !== undefined ? `${coords.top}px` : undefined,
|
||||
bottom: coords.bottom !== undefined ? `${coords.bottom}px` : undefined,
|
||||
right: `${coords.right}px`,
|
||||
zIndex: 99999,
|
||||
}}
|
||||
className="min-w-32 rounded-xl border border-gray-200/90 bg-white p-1.5 shadow-2xl backdrop-blur"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -283,17 +449,19 @@ function MenuButton({
|
||||
onClick: () => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.currentTarget.closest('details')?.removeAttribute('open')
|
||||
onClick()
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition ${
|
||||
danger ? 'text-red-500 hover:bg-red-50' : 'text-ink-100 hover:bg-gray-50 hover:text-brand-500'
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-xs font-medium transition ${
|
||||
danger
|
||||
? 'text-red-500 hover:bg-red-50'
|
||||
: 'text-ink-100 hover:bg-gray-100 hover:text-brand-500'
|
||||
}`}
|
||||
title={label}
|
||||
onClick={handleClick}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClick()
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span>{children}</span>
|
||||
|
||||
@@ -13,21 +13,12 @@ import {
|
||||
HomeLoadingState,
|
||||
} from './HomePageSections'
|
||||
|
||||
const CAROUSEL_STORAGE_KEY = 'mmtl.home.carousel_libraries'
|
||||
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
|
||||
const asArray = <T,>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : [])
|
||||
|
||||
export function HomePage() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>>({})
|
||||
const [selectedLibraryIds, setSelectedLibraryIds] = useState<string[]>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(CAROUSEL_STORAGE_KEY)
|
||||
return saved ? JSON.parse(saved) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const [history, setHistory] = useState<HistoryItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@@ -46,18 +37,6 @@ export function HomePage() {
|
||||
setLibraries(libs)
|
||||
setHistory(hist.filter((h) => h && !h.completed && !!h.media))
|
||||
|
||||
// Set default selected libraries if none saved yet
|
||||
setSelectedLibraryIds((current) => {
|
||||
if (current.length > 0) return current
|
||||
const allIds = libs.map((l) => l.id)
|
||||
try {
|
||||
localStorage.setItem(CAROUSEL_STORAGE_KEY, JSON.stringify(allIds))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return allIds
|
||||
})
|
||||
|
||||
// Fetch media items for all libraries in parallel
|
||||
const isSeriesType = (type?: string) => type === 'tv' || type === 'anime' || type === 'variety'
|
||||
const results = await Promise.allSettled(
|
||||
@@ -132,16 +111,18 @@ export function HomePage() {
|
||||
return counts
|
||||
}, [libraries, libraryData])
|
||||
|
||||
// Compute items to show in the Hero Carousel
|
||||
// Compute items to show in the Hero Carousel. Only libraries flagged
|
||||
// carousel_enabled contribute; a library's items array is empty for
|
||||
// series-type libs (loaded via /series), so gate on cards instead.
|
||||
const carouselItems = useMemo(() => {
|
||||
const candidateMedia: Media[] = []
|
||||
const effectiveSelectedIds =
|
||||
selectedLibraryIds.length > 0 ? selectedLibraryIds : libraries.map((l) => l.id)
|
||||
const effectiveSelectedIds = libraries
|
||||
.filter((l) => l.carousel_enabled !== false)
|
||||
.map((l) => l.id)
|
||||
|
||||
for (const libId of effectiveSelectedIds) {
|
||||
const data = libraryData[libId]
|
||||
if (data && data.items.length > 0) {
|
||||
// Pick representative items from series cards or raw items
|
||||
if (data && data.cards.length > 0) {
|
||||
for (const card of data.cards) {
|
||||
if (hasArtwork(card.rep)) {
|
||||
candidateMedia.push(card.rep)
|
||||
@@ -150,9 +131,8 @@ export function HomePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by artwork score / rating or shuffle / interleave
|
||||
// Fallback to all loaded items with artwork
|
||||
if (candidateMedia.length === 0) {
|
||||
// Fallback to all loaded items with artwork
|
||||
for (const lib of libraries) {
|
||||
const data = libraryData[lib.id]
|
||||
if (data) {
|
||||
@@ -164,7 +144,7 @@ export function HomePage() {
|
||||
}
|
||||
|
||||
return candidateMedia.slice(0, 10)
|
||||
}, [selectedLibraryIds, libraries, libraryData])
|
||||
}, [libraries, libraryData])
|
||||
|
||||
const empty =
|
||||
!loading &&
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Check,
|
||||
CheckSquare,
|
||||
Film,
|
||||
FolderOpen,
|
||||
HeartHandshake,
|
||||
Layers,
|
||||
Loader2,
|
||||
Music,
|
||||
Save,
|
||||
SlidersHorizontal,
|
||||
Square,
|
||||
Tv,
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import { imageURL } from '../api/client'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library, Setting } from '../types'
|
||||
import { groupSeries, type SeriesCard } from '../utils/groupSeries'
|
||||
import { getLibraryArtworks } from './librariesPageModel'
|
||||
|
||||
const CAROUSEL_STORAGE_KEY = 'mmtl.home.carousel_libraries'
|
||||
const SETTING_KEY_CAROUSEL = 'home.carousel_libraries'
|
||||
|
||||
const TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
movie: <Film size={20} />,
|
||||
movies: <Film size={20} />,
|
||||
tv: <Tv size={20} />,
|
||||
series: <Tv size={20} />,
|
||||
anime: <Layers size={20} />,
|
||||
shows: <Tv size={20} />,
|
||||
variety: <Tv size={20} />,
|
||||
music: <Music size={20} />,
|
||||
adult: <HeartHandshake size={20} />,
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
movie: '电影',
|
||||
movies: '电影',
|
||||
tv: '剧集',
|
||||
series: '剧集',
|
||||
anime: '动漫',
|
||||
shows: '综艺',
|
||||
variety: '综艺',
|
||||
music: '音乐',
|
||||
adult: 'Adult',
|
||||
}
|
||||
|
||||
export function LibrarySettingsPanel() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [libraryCards, setLibraryCards] = useState<Record<string, SeriesCard[]>>({})
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [libs, settings] = await Promise.all([
|
||||
libraryAPI.list({ includeHidden: true }).catch(() => [] as Library[]),
|
||||
adminAPI.listSettings().catch(() => [] as Setting[]),
|
||||
])
|
||||
|
||||
const libList = Array.isArray(libs) ? libs : []
|
||||
setLibraries(libList)
|
||||
|
||||
// 异步拉取各个媒体库的前几个条目用于封面展示
|
||||
Promise.allSettled(
|
||||
libList.map(async (lib) => {
|
||||
const page = await libraryAPI.listMedia(lib.id, 1, 10)
|
||||
const items = Array.isArray(page?.items) ? page.items : []
|
||||
return { id: lib.id, cards: groupSeries(items) }
|
||||
}),
|
||||
).then((results) => {
|
||||
const map: Record<string, SeriesCard[]> = {}
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled' && r.value) {
|
||||
map[r.value.id] = r.value.cards
|
||||
}
|
||||
}
|
||||
setLibraryCards(map)
|
||||
})
|
||||
|
||||
// 优先读取系统配置,其次读取 localStorage,默认全选
|
||||
const settingItem = Array.isArray(settings)
|
||||
? settings.find((s) => s.key === SETTING_KEY_CAROUSEL)
|
||||
: undefined
|
||||
|
||||
let initialIds: string[] | null = null
|
||||
if (settingItem?.value) {
|
||||
try {
|
||||
initialIds = JSON.parse(settingItem.value)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!initialIds) {
|
||||
try {
|
||||
const saved = localStorage.getItem(CAROUSEL_STORAGE_KEY)
|
||||
if (saved) initialIds = JSON.parse(saved)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(initialIds)) {
|
||||
setSelectedIds(initialIds)
|
||||
} else {
|
||||
setSelectedIds(libList.map((l) => l.id))
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const toggleLibrary = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]
|
||||
setDirty(true)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedIds(libraries.map((l) => l.id))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const deselectAll = () => {
|
||||
setSelectedIds([])
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const jsonValue = JSON.stringify(selectedIds)
|
||||
await adminAPI.updateSetting(SETTING_KEY_CAROUSEL, jsonValue)
|
||||
try {
|
||||
localStorage.setItem(CAROUSEL_STORAGE_KEY, jsonValue)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
toast.success('海报轮播设置已保存')
|
||||
setDirty(false)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'保存设置失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12 text-ink-50">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-panel space-y-6">
|
||||
{/* 头部说明与快捷操作 */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between border-b border-gray-200/80 pb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-xl border border-primary-400/40 bg-primary-400/10 p-2 text-brand-500 mt-0.5">
|
||||
<SlidersHorizontal size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-display text-lg font-bold text-ink-600">首页海报轮播设置</h3>
|
||||
<p className="text-xs text-sand-500 mt-0.5">
|
||||
选择参与首页顶部大图海报轮播推荐的媒体库。勾选的媒体库内容将轮流展示在系统首页顶部。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectAll}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-ink-100 transition hover:border-primary-400/50 hover:text-brand-500"
|
||||
>
|
||||
<CheckSquare size={14} />
|
||||
<span>全选</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={deselectAll}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-ink-100 transition hover:border-primary-400/50 hover:text-brand-500"
|
||||
>
|
||||
<Square size={14} />
|
||||
<span>清空</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 媒体库列表卡片 */}
|
||||
{libraries.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-sand-500">
|
||||
暂无可用媒体库,请先添加媒体库后再配置海报轮播。
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{libraries.map((lib) => {
|
||||
const isSelected = selectedIds.includes(lib.id)
|
||||
const cards = libraryCards[lib.id] || []
|
||||
const artwork = getLibraryArtworks(lib, cards)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lib.id}
|
||||
onClick={() => toggleLibrary(lib.id)}
|
||||
className={`flex cursor-pointer items-center justify-between rounded-2xl border p-4 transition-all duration-200 select-none ${
|
||||
isSelected
|
||||
? 'border-brand-500/60 bg-primary-400/10 shadow-sm'
|
||||
: 'border-gray-200 bg-white/70 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className={`grid h-12 w-16 shrink-0 gap-0.5 overflow-hidden rounded-xl bg-gray-100 shadow-inner transition-colors ${
|
||||
artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'
|
||||
} ${
|
||||
isSelected ? 'ring-2 ring-brand-500/30' : ''
|
||||
}`}
|
||||
>
|
||||
{artwork.length > 0 ? (
|
||||
artwork.map(({ src, version }, index) => (
|
||||
<img
|
||||
key={`${src}-${index}`}
|
||||
src={imageURL(src, version)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-ink-50">
|
||||
{TYPE_ICONS[lib.type] || <FolderOpen size={20} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-display text-sm font-bold text-ink-600">
|
||||
{lib.name}
|
||||
</span>
|
||||
<span className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold border border-gray-200 bg-gray-50 text-ink-50">
|
||||
{TYPE_LABELS[lib.type] || '自定义'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-sand-500 mt-0.5">
|
||||
{isSelected ? '已启用轮播' : '未参与轮播'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border transition-colors ${
|
||||
isSelected
|
||||
? 'border-brand-500 bg-brand-500 text-white'
|
||||
: 'border-gray-300 bg-white text-transparent'
|
||||
}`}
|
||||
>
|
||||
<Check size={14} strokeWidth={3} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部保存按钮 */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-200/80">
|
||||
<span className="text-xs text-sand-500">
|
||||
已选择 {selectedIds.length} / {libraries.length} 个媒体库参与轮播
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
className="neon-button disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
保存设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { libraryAPI } from '../api/library'
|
||||
import type { Library, Setting } from '../types'
|
||||
import { APIConfigsPanel } from '../components/APIConfigsPanel'
|
||||
import { AdultSettingsPanel } from './AdultSettingsPanel'
|
||||
import { LibrarySettingsPanel } from './LibrarySettingsPanel'
|
||||
import { RecognitionWordsPanel } from './RecognitionWordsPanel'
|
||||
import { SettingRow } from './SettingsRow'
|
||||
import { ALL_KEYS, GROUPS } from './settingsGroups'
|
||||
@@ -168,7 +167,6 @@ export function SettingsPage() {
|
||||
|
||||
{!loading && (
|
||||
<div className="space-y-4">
|
||||
{group.key === 'library' && <LibrarySettingsPanel />}
|
||||
{group.key === 'api-configs' && <APIConfigsPanel />}
|
||||
{group.key === 'recognition-words' && <RecognitionWordsPanel />}
|
||||
{group.key === 'adult' && <AdultSettingsPanel />}
|
||||
|
||||
+794
-223
File diff suppressed because it is too large
Load Diff
@@ -1,8 +0,0 @@
|
||||
import type { SettingGroup } from './settingsGroupTypes'
|
||||
|
||||
export const librarySettingsGroup: SettingGroup = {
|
||||
key: 'library',
|
||||
label: '媒体库',
|
||||
description: '配置首页海报轮播等媒体库展示与偏好',
|
||||
items: [],
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { adultSettingsGroup } from './settingsGroupAccess'
|
||||
import { apiConfigsSettingsGroup } from './settingsGroupAPIConfigs'
|
||||
import { danmakuSettingsGroup } from './settingsGroupDanmaku'
|
||||
import { generalSettingsGroup } from './settingsGroupGeneral'
|
||||
import { librarySettingsGroup } from './settingsGroupLibrary'
|
||||
import { recognitionWordsSettingsGroup } from './settingsGroupRecognitionWords'
|
||||
import type { SettingGroup } from './settingsGroupTypes'
|
||||
|
||||
@@ -10,7 +9,6 @@ export type { SettingGroup } from './settingsGroupTypes'
|
||||
|
||||
export const GROUPS: SettingGroup[] = [
|
||||
generalSettingsGroup,
|
||||
librarySettingsGroup,
|
||||
apiConfigsSettingsGroup,
|
||||
recognitionWordsSettingsGroup,
|
||||
danmakuSettingsGroup,
|
||||
|
||||
@@ -164,6 +164,18 @@ function useLibraryActions(refresh: () => Promise<void>) {
|
||||
else toast.success(`扫描完成,新增 ${result.added},更新 ${result.updated ?? 0}`)
|
||||
}
|
||||
|
||||
const toggleCarouselLibrary = async (library: Library) => {
|
||||
const next = !(library.carousel_enabled ?? true)
|
||||
await libraryAPI.update(library.id, { carousel_enabled: next })
|
||||
toast.success(next ? `「${library.name}」已加入首页轮播` : `「${library.name}」已移出首页轮播`)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const reorderLibraries = async (orderedLibs: Library[]) => {
|
||||
await libraryAPI.reorder(orderedLibs.map((l) => l.id))
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const removeLibrary = async (library: Library) => {
|
||||
if (!(await confirmAction({ title: '删除媒体库', message: `确定删除「${library.name}」?`, confirmText: '删除' }))) return
|
||||
await libraryAPI.remove(library.id)
|
||||
@@ -191,5 +203,5 @@ function useLibraryActions(refresh: () => Promise<void>) {
|
||||
await refresh()
|
||||
}
|
||||
|
||||
return { scanLibrary, removeLibrary, addLibraryRoot, editLibraryCover }
|
||||
return { scanLibrary, removeLibrary, addLibraryRoot, editLibraryCover, toggleCarouselLibrary, reorderLibraries }
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface Library {
|
||||
type: string
|
||||
cover_url?: string
|
||||
enabled: boolean
|
||||
sort_order?: number
|
||||
carousel_enabled?: boolean
|
||||
roots?: LibraryRoot[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
Reference in New Issue
Block a user