Compare commits

...

2 Commits

Author SHA1 Message Date
github-actions[bot] d3051eaffe chore: bump version to 0.0.47 [skip ci] 2026-08-27 13:24:18 +00:00
truewhile 496a897782 优化 2026-08-27 21:24:01 +08:00
22 changed files with 1334 additions and 607 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.46
0.0.47
+33 -4
View File
@@ -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")
+4
View File
@@ -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))
+57
View File
@@ -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 {
+8 -6
View File
@@ -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 是逻辑媒体库下的一条真实物理/挂载路径。
+18 -1
View File
@@ -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
+120
View File
@@ -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
+21
View File
@@ -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
+38
View File
@@ -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
View File
@@ -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
View File
@@ -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),
}
+2
View File
@@ -35,6 +35,8 @@ export function AdminLibraryPanel() {
onRemoveLibrary={libraryActions.removeLibrary}
onAddLibraryRoot={libraryActions.addLibraryRoot}
onEditLibraryCover={libraryActions.editLibraryCover}
onToggleCarousel={libraryActions.toggleCarouselLibrary}
onReorder={libraryActions.reorderLibraries}
/>
<LibraryStorageStats />
</div>
+188 -20
View File
@@ -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>
+9 -29
View File
@@ -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 &&
-305
View File
@@ -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>
)
}
-2
View File
@@ -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 />}
File diff suppressed because it is too large Load Diff
-8
View File
@@ -1,8 +0,0 @@
import type { SettingGroup } from './settingsGroupTypes'
export const librarySettingsGroup: SettingGroup = {
key: 'library',
label: '媒体库',
description: '配置首页海报轮播等媒体库展示与偏好',
items: [],
}
-2
View File
@@ -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,
+13 -1
View File
@@ -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 }
}
+2
View File
@@ -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