mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Add library covers and configurable footer visibility
This commit is contained in:
@@ -18,6 +18,7 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
||||
{
|
||||
api.GET("/health", healthCheck)
|
||||
api.GET("/version", versionInfo)
|
||||
api.GET("/public/ui-config", publicUIConfigHandler(svc))
|
||||
|
||||
// Telegram Bot webhook — called by Telegram servers, no auth.
|
||||
api.POST("/telegram/webhook", telegramWebhookHandler(svc))
|
||||
|
||||
@@ -16,11 +16,12 @@ import (
|
||||
)
|
||||
|
||||
type createLibraryReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Path string `json:"path"`
|
||||
Paths []string `json:"paths"`
|
||||
Roots []service.LibraryRootInput `json:"roots"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Path string `json:"path"`
|
||||
Paths []string `json:"paths"`
|
||||
Roots []service.LibraryRootInput `json:"roots"`
|
||||
Type string `json:"type"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
}
|
||||
|
||||
func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
@@ -98,7 +99,7 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if len(roots) == 0 && strings.TrimSpace(req.Path) != "" {
|
||||
roots = append(roots, service.LibraryRootInput{Path: req.Path})
|
||||
}
|
||||
l, err := svc.Media.CreateLibraryWithRoots(c.Request.Context(), req.Name, req.Type, roots)
|
||||
l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -111,6 +112,30 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
type updateLibraryReq struct {
|
||||
CoverURL string `json:"cover_url"`
|
||||
}
|
||||
|
||||
func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req updateLibraryReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
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
|
||||
}
|
||||
lib, err := svc.Repo.Library.FindByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || lib == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "library not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, lib)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
const hideCommunityLinksSettingKey = "ui.hide_community_links_for_users"
|
||||
|
||||
func publicUIConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
hide := false
|
||||
if svc != nil && svc.Repo != nil && svc.Repo.Setting != nil {
|
||||
if value, err := svc.Repo.Setting.Get(c.Request.Context(), hideCommunityLinksSettingKey); err == nil {
|
||||
hide = value == "true" || value == "1"
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"hide_community_links_for_users": hide})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
func TestPublicUIConfigExposesCommunityLinkVisibility(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Setting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.Setting.Set(t.Context(), hideCommunityLinksSettingKey, "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
|
||||
r := gin.New()
|
||||
r.GET("/api/public/ui-config", publicUIConfigHandler(svc))
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/public/ui-config", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), `"hide_community_links_for_users":true`) {
|
||||
t.Fatalf("status=%d body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
|
||||
authed.GET("/libraries", listLibrariesHandler(svc))
|
||||
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
|
||||
authed.GET("/libraries/:id", getLibraryHandler(svc))
|
||||
authed.PATCH("/libraries/:id", middleware.AdminRequired(), updateLibraryHandler(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))
|
||||
|
||||
@@ -3,11 +3,12 @@ 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
|
||||
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"`
|
||||
Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"`
|
||||
}
|
||||
|
||||
// LibraryRoot 是逻辑媒体库下的一条真实物理/挂载路径。
|
||||
|
||||
@@ -24,6 +24,10 @@ func (s *MediaService) CreateLibrary(ctx context.Context, name, path, kind strin
|
||||
}
|
||||
|
||||
func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind string, inputs []LibraryRootInput) (*model.Library, error) {
|
||||
return s.CreateLibraryWithRootsAndCover(ctx, name, kind, "", inputs)
|
||||
}
|
||||
|
||||
func (s *MediaService) CreateLibraryWithRootsAndCover(ctx context.Context, name, kind, coverURL string, inputs []LibraryRootInput) (*model.Library, error) {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return nil, errors.New("name required")
|
||||
}
|
||||
@@ -35,6 +39,11 @@ func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind st
|
||||
if existing, err := s.findLogicalLibrary(ctx, name, kind); err != nil {
|
||||
return nil, err
|
||||
} else if existing != nil {
|
||||
if strings.TrimSpace(coverURL) != "" {
|
||||
if err := s.UpdateLibraryCover(ctx, existing.ID, coverURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
lib, err := s.appendLibraryRoots(ctx, existing, roots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -42,7 +51,7 @@ func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind st
|
||||
s.invalidateMediaCache(ctx)
|
||||
return lib, nil
|
||||
}
|
||||
lib := &model.Library{Name: strings.TrimSpace(name), Path: roots[0].Path, Type: kind, Enabled: true}
|
||||
lib := &model.Library{Name: strings.TrimSpace(name), Path: roots[0].Path, Type: kind, CoverURL: strings.TrimSpace(coverURL), Enabled: true}
|
||||
if err := s.repo.Library.CreateWithRoots(ctx, lib, roots); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -50,6 +59,11 @@ func (s *MediaService) CreateLibraryWithRoots(ctx context.Context, name, kind st
|
||||
return lib, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) UpdateLibraryCover(ctx context.Context, libraryID, coverURL string) error {
|
||||
return s.repo.DB.WithContext(ctx).Model(&model.Library{}).Where("id = ?", libraryID).
|
||||
Update("cover_url", strings.TrimSpace(coverURL)).Error
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -53,6 +53,30 @@ func TestCreateLibraryWithRootsAppendsToExistingLogicalLibrary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateLibraryWithRootsStoresAndUpdatesCustomCover(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||
|
||||
lib, err := svc.CreateLibraryWithRootsAndCover(t.Context(), "收藏", "movie", "https://example.com/cover.jpg", []LibraryRootInput{{Path: t.TempDir()}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lib.CoverURL != "https://example.com/cover.jpg" {
|
||||
t.Fatalf("cover_url = %q", lib.CoverURL)
|
||||
}
|
||||
if err := svc.UpdateLibraryCover(t.Context(), lib.ID, "https://example.com/new.jpg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := repos.Library.FindByID(t.Context(), lib.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.CoverURL != "https://example.com/new.jpg" {
|
||||
t.Fatalf("updated cover_url = %q", updated.CoverURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateLibraryWithRootsKeepsDifferentTypesSeparate(t *testing.T) {
|
||||
rootMovie := t.TempDir()
|
||||
rootTV := t.TempDir()
|
||||
|
||||
@@ -99,8 +99,11 @@ export const libraryAPI = {
|
||||
create: (name: string, path: string, type: string) =>
|
||||
api.post<Library>('/libraries', { name, path, type }).then((r) => r.data),
|
||||
|
||||
createWithRoots: (name: string, type: string, roots: LibraryRootInput[]) =>
|
||||
api.post<Library>('/libraries', { name, type, roots }).then((r) => r.data),
|
||||
createWithRoots: (name: string, type: string, roots: LibraryRootInput[], coverURL = '') =>
|
||||
api.post<Library>('/libraries', { name, type, roots, cover_url: coverURL }).then((r) => r.data),
|
||||
|
||||
update: (id: string, payload: { cover_url: string }) =>
|
||||
api.patch<Library>(`/libraries/${id}`, payload).then((r) => r.data),
|
||||
|
||||
remove: (id: string) => api.delete(`/libraries/${id}`).then((r) => r.data),
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { api } from './client'
|
||||
|
||||
export interface PublicUIConfig {
|
||||
hide_community_links_for_users: boolean
|
||||
}
|
||||
|
||||
export const publicAPI = {
|
||||
uiConfig: () => api.get<PublicUIConfig>('/public/ui-config').then((response) => response.data),
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Github, Globe, Send } from 'lucide-react'
|
||||
|
||||
import { publicAPI } from '../api/public'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const LINKS = [
|
||||
{ href: 'https://github.com/ShukeBta/MediaStationGo', icon: Github, label: '开源仓库' },
|
||||
{ href: 'https://github.com/ShukeBta', icon: Globe, label: '作者主页' },
|
||||
@@ -7,6 +11,14 @@ const LINKS = [
|
||||
]
|
||||
|
||||
export function AppFooter({ className = '' }: { className?: string }) {
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
const [hideForUsers, setHideForUsers] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
publicAPI.uiConfig().then((config) => setHideForUsers(config.hide_community_links_for_users)).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
if (hideForUsers && role !== 'admin') return null
|
||||
return (
|
||||
<footer className={`flex items-center justify-center gap-1 ${className}`}>
|
||||
{LINKS.map((link, i) => (
|
||||
|
||||
@@ -10,9 +10,11 @@ export function AdminLibraryPanel() {
|
||||
<AdminLibraryCreateForm
|
||||
name={createForm.name}
|
||||
type={createForm.type}
|
||||
coverURL={createForm.coverURL}
|
||||
roots={createForm.roots}
|
||||
onNameChange={createForm.setName}
|
||||
onTypeChange={createForm.setType}
|
||||
onCoverURLChange={createForm.setCoverURL}
|
||||
onRootChange={createForm.updateRoot}
|
||||
onAddRoot={createForm.addRoot}
|
||||
onRemoveRoot={createForm.removeRoot}
|
||||
@@ -28,6 +30,8 @@ export function AdminLibraryPanel() {
|
||||
onRemoveRoot={rootActions.removeLibraryRoot}
|
||||
onScanLibrary={libraryActions.scanLibrary}
|
||||
onRemoveLibrary={libraryActions.removeLibrary}
|
||||
onAddLibraryRoot={libraryActions.addLibraryRoot}
|
||||
onEditLibraryCover={libraryActions.editLibraryCover}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,9 +6,11 @@ import type { RootDraft } from './adminLibraryPanelModel'
|
||||
type CreateFormProps = {
|
||||
name: string
|
||||
type: string
|
||||
coverURL: string
|
||||
roots: RootDraft[]
|
||||
onNameChange: (value: string) => void
|
||||
onTypeChange: (value: string) => void
|
||||
onCoverURLChange: (value: string) => void
|
||||
onRootChange: (index: number, patch: Partial<RootDraft>) => void
|
||||
onAddRoot: () => void
|
||||
onRemoveRoot: (index: number) => void
|
||||
@@ -18,9 +20,11 @@ type CreateFormProps = {
|
||||
export function AdminLibraryCreateForm({
|
||||
name,
|
||||
type,
|
||||
coverURL,
|
||||
roots,
|
||||
onNameChange,
|
||||
onTypeChange,
|
||||
onCoverURLChange,
|
||||
onRootChange,
|
||||
onAddRoot,
|
||||
onRemoveRoot,
|
||||
@@ -42,6 +46,12 @@ export function AdminLibraryCreateForm({
|
||||
<option value="anime">动漫</option>
|
||||
<option value="music">音乐</option>
|
||||
</select>
|
||||
<input
|
||||
className="input-base md:col-span-2"
|
||||
placeholder="自定义封面 URL(可选)"
|
||||
value={coverURL}
|
||||
onChange={(e) => onCoverURLChange(e.target.value)}
|
||||
/>
|
||||
<div className="md:col-span-4 space-y-2">
|
||||
{roots.map((root, index) => (
|
||||
<CreateRootRow
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MouseEvent, ReactNode } from 'react'
|
||||
import { MoreVertical, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
|
||||
import { Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
|
||||
|
||||
import type { Library, LibraryRoot } from '../types'
|
||||
import type { RootDraft } from './adminLibraryPanelModel'
|
||||
@@ -15,6 +15,8 @@ type LibraryTableProps = {
|
||||
onRemoveRoot: (library: Library, root: LibraryRoot) => void
|
||||
onScanLibrary: (library: Library) => void
|
||||
onRemoveLibrary: (library: Library) => void
|
||||
onAddLibraryRoot: (library: Library) => void
|
||||
onEditLibraryCover: (library: Library) => void
|
||||
}
|
||||
|
||||
export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
|
||||
@@ -46,7 +48,12 @@ type LibraryTableRowProps = Omit<LibraryTableProps, 'libs'> & {
|
||||
function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
|
||||
return (
|
||||
<tr className="border-t border-gray-200">
|
||||
<td className="py-2 pr-3 font-medium text-ink-600">{library.name}</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" />}
|
||||
<span>{library.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 text-ink-100">
|
||||
<LibraryRootsCell library={library} {...actions} />
|
||||
</td>
|
||||
@@ -170,12 +177,18 @@ function RootActionButtons({ library, root, draft, ...actions }: RootEditorProps
|
||||
)
|
||||
}
|
||||
|
||||
function LibraryActionsCell({ library, onScanLibrary, onRemoveLibrary }: LibraryTableRowProps) {
|
||||
function LibraryActionsCell({ library, onScanLibrary, onRemoveLibrary, onAddLibraryRoot, onEditLibraryCover }: LibraryTableRowProps) {
|
||||
return (
|
||||
<ActionMenu label="媒体库操作">
|
||||
<MenuButton icon={<RefreshCw size={14} />} label="扫描" onClick={() => onScanLibrary(library)}>
|
||||
扫描
|
||||
</MenuButton>
|
||||
<MenuButton icon={<Plus size={14} />} label="添加来源" onClick={() => onAddLibraryRoot(library)}>
|
||||
添加来源
|
||||
</MenuButton>
|
||||
<MenuButton icon={<Image size={14} />} label="自定义封面" onClick={() => onEditLibraryCover(library)}>
|
||||
自定义封面
|
||||
</MenuButton>
|
||||
<MenuButton danger icon={<Trash2 size={14} />} label="删除" onClick={() => onRemoveLibrary(library)}>
|
||||
删除
|
||||
</MenuButton>
|
||||
|
||||
@@ -129,7 +129,9 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) {
|
||||
|
||||
function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
||||
const library = preview.library
|
||||
const artwork = libraryArtworkItems(preview.cards)
|
||||
const artwork = library.cover_url
|
||||
? [{ src: library.cover_url, version: library.updated_at }]
|
||||
: libraryArtworkItems(preview.cards)
|
||||
const displayPath = libraryDisplayPath(library.path)
|
||||
|
||||
return (
|
||||
@@ -137,7 +139,7 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
||||
to={`/library/${library.id}`}
|
||||
className="group flex overflow-hidden rounded-3xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover"
|
||||
>
|
||||
<div className="grid h-24 w-36 shrink-0 grid-cols-2 gap-1 overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)]">
|
||||
<div className={`grid h-24 w-36 shrink-0 gap-1 overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)] ${artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{artwork.length > 0 ? (
|
||||
artwork.map(({ src, version }, index) => (
|
||||
<img
|
||||
|
||||
@@ -5,6 +5,13 @@ export const generalSettingsGroup: SettingGroup = {
|
||||
label: '常规',
|
||||
description: '语言 / 转码引擎参数(API 密钥请在管理后台 → 外部API 配置)',
|
||||
items: [
|
||||
{
|
||||
key: 'ui.hide_community_links_for_users',
|
||||
label: '对普通用户隐藏社区页脚链接',
|
||||
type: 'toggle',
|
||||
hint: '开启后,登录页及普通用户登录后的页脚不显示 TG 群组、开源仓库和作者主页;管理员仍可见。',
|
||||
defaultValue: 'false',
|
||||
},
|
||||
{
|
||||
key: 'tmdb.language',
|
||||
label: 'TMDb 元数据语言',
|
||||
|
||||
@@ -31,6 +31,7 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
|
||||
const [name, setName] = useState('')
|
||||
const [roots, setRoots] = useState<RootDraft[]>([emptyRootDraft()])
|
||||
const [type, setType] = useState('movie')
|
||||
const [coverURL, setCoverURL] = useState('')
|
||||
|
||||
const handleCreate = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -40,10 +41,11 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
|
||||
toast.error('请至少填写一个路径')
|
||||
return
|
||||
}
|
||||
await libraryAPI.createWithRoots(name, type, payload)
|
||||
await libraryAPI.createWithRoots(name, type, payload, coverURL.trim())
|
||||
toast.success('媒体库已保存')
|
||||
setName('')
|
||||
setRoots([emptyRootDraft()])
|
||||
setCoverURL('')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
toast.error(apiErrorMessage(err, '创建失败'))
|
||||
@@ -57,9 +59,11 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
coverURL,
|
||||
roots,
|
||||
setName,
|
||||
setType,
|
||||
setCoverURL,
|
||||
updateRoot,
|
||||
addRoot: () => setRoots((prev) => [...prev, emptyRootDraft()]),
|
||||
removeRoot: (index: number) => setRoots((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index))),
|
||||
@@ -153,5 +157,22 @@ function useLibraryActions(refresh: () => Promise<void>) {
|
||||
await refresh()
|
||||
}
|
||||
|
||||
return { scanLibrary, removeLibrary }
|
||||
const addLibraryRoot = async (library: Library) => {
|
||||
const path = window.prompt(`为「${library.name}」添加来源目录:`)
|
||||
if (!path?.trim()) return
|
||||
const name = window.prompt('来源名称(可选):') ?? ''
|
||||
await libraryAPI.addRoot(library.id, { path: path.trim(), name: name.trim(), enabled: true })
|
||||
toast.success('来源目录已添加')
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const editLibraryCover = async (library: Library) => {
|
||||
const coverURL = window.prompt('自定义封面 URL(留空可清除):', library.cover_url ?? '')
|
||||
if (coverURL === null) return
|
||||
await libraryAPI.update(library.id, { cover_url: coverURL.trim() })
|
||||
toast.success(coverURL.trim() ? '媒体库封面已保存' : '媒体库封面已清除')
|
||||
await refresh()
|
||||
}
|
||||
|
||||
return { scanLibrary, removeLibrary, addLibraryRoot, editLibraryCover }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface Library {
|
||||
name: string
|
||||
path: string
|
||||
type: string
|
||||
cover_url?: string
|
||||
enabled: boolean
|
||||
roots?: LibraryRoot[]
|
||||
created_at: string
|
||||
|
||||
Reference in New Issue
Block a user