From e610fcc2178c1c9437a40aeccf1f3de5208b35ec Mon Sep 17 00:00:00 2001 From: pikachuim Date: Mon, 9 Mar 2026 20:03:49 +0800 Subject: [PATCH 1/3] feat(func): media library --- src/app/App.tsx | 59 +- src/app/RootLayout.tsx | 241 ++++ src/components/GlobalSidebar.tsx | 538 ++++++++ src/lang/en/manage.json | 8 +- src/lang/en/settings.json | 11 +- src/pages/home/Body.tsx | 17 +- src/pages/home/Layout.tsx | 12 +- src/pages/manage/media/MediaManage.tsx | 858 ++++++++++++ src/pages/manage/sidemenu_items.tsx | 53 + src/pages/media/MediaBrowser.tsx | 511 ++++++++ src/pages/media/MediaLayout.tsx | 48 + src/pages/media/MediaSettings.tsx | 648 +++++++++ src/pages/media/book/BookLibrary.tsx | 1084 +++++++++++++++ src/pages/media/image/ImageLibrary.tsx | 378 ++++++ src/pages/media/music/MusicLibrary.tsx | 1669 ++++++++++++++++++++++++ src/pages/media/video/VideoLibrary.tsx | 690 ++++++++++ src/types/index.ts | 9 + src/types/media.ts | 111 ++ src/types/setting.ts | 1 + src/utils/media_api.ts | 103 ++ 20 files changed, 7027 insertions(+), 22 deletions(-) create mode 100644 src/app/RootLayout.tsx create mode 100644 src/components/GlobalSidebar.tsx create mode 100644 src/pages/manage/media/MediaManage.tsx create mode 100644 src/pages/media/MediaBrowser.tsx create mode 100644 src/pages/media/MediaLayout.tsx create mode 100644 src/pages/media/MediaSettings.tsx create mode 100644 src/pages/media/book/BookLibrary.tsx create mode 100644 src/pages/media/image/ImageLibrary.tsx create mode 100644 src/pages/media/music/MusicLibrary.tsx create mode 100644 src/pages/media/video/VideoLibrary.tsx create mode 100644 src/types/media.ts create mode 100644 src/utils/media_api.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index fcc5b25eb..aa8f5c495 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -19,11 +19,17 @@ import { base_path, bus, handleRespWithoutAuthAndNotify, r } from "~/utils" import { MustUser, UserOrGuest } from "./MustUser" import "./index.css" import { globalStyles } from "./theme" +import { MusicPlayer } from "~/pages/media/music/MusicLibrary" +import { RootLayout } from "./RootLayout" const Home = lazy(() => import("~/pages/home/Layout")) const Manage = lazy(() => import("~/pages/manage")) const Login = lazy(() => import("~/pages/login")) const Test = lazy(() => import("~/pages/test")) +const VideoLibrary = lazy(() => import("~/pages/media/video/VideoLibrary")) +const MusicLibrary = lazy(() => import("~/pages/media/music/MusicLibrary")) +const ImageLibrary = lazy(() => import("~/pages/media/image/ImageLibrary")) +const BookLibrary = lazy(() => import("~/pages/media/book/BookLibrary")) const App: Component = () => { const t = useT() @@ -91,11 +97,58 @@ const App: Component = () => { } /> + {/* 带侧边栏的路由:媒体库各页面 */} + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> - + + + } /> @@ -103,7 +156,9 @@ const App: Component = () => { path="*" element={ - + + + } /> diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx new file mode 100644 index 000000000..dd09decb7 --- /dev/null +++ b/src/app/RootLayout.tsx @@ -0,0 +1,241 @@ +import { + JSX, + createSignal, + onMount, + onCleanup, + createMemo, + Show, +} from "solid-js" +import { + GlobalSidebar, + sidebarCollapsed, + setSidebarCollapsed, +} from "~/components/GlobalSidebar" +import { useColorMode, Icon } from "@hope-ui/solid" +import { TbChevronLeft, TbChevronRight } from "solid-icons/tb" +import { Nav } from "~/pages/home/Nav" +import { Layout } from "~/pages/home/header/layout" +import { useRouter } from "~/hooks" +import { getSetting, objStore, State } from "~/store" +import { BsSearch } from "solid-icons/bs" +import { bus } from "~/utils" + +interface RootLayoutProps { + children: JSX.Element +} + +// ─── 顶栏组件 ──────────────────────────────────────────────── +const TopBar = () => { + const { colorMode } = useColorMode() + const isDark = createMemo(() => colorMode() === "dark") + const { pathname } = useRouter() + + // 只在文件浏览路由下显示面包屑和文件操作 + const isFileBrowser = createMemo(() => !pathname().startsWith("/@media")) + const isFolder = createMemo(() => objStore.state === State.Folder) + + const bg = createMemo(() => + isDark() ? "rgba(15,20,35,0.95)" : "rgba(250,251,253,0.97)", + ) + const borderColor = createMemo(() => + isDark() ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.07)", + ) + const textColor = createMemo(() => (isDark() ? "#e2e8f0" : "#1e293b")) + const mutedColor = createMemo(() => (isDark() ? "#64748b" : "#94a3b8")) + + return ( +
+ {/* 侧边栏收起/展开按钮 */} + + + {/* 面包屑导航 / 页面标题 */} +
+ + 媒体库 + + } + > +
+ + {/* 右侧工具区(仅文件浏览时显示) */} + +
+ {/* 搜索按钮 */} + + + + + {/* 布局切换 */} + + + +
+
+
+ ) +} + +// ─── 根布局 ────────────────────────────────────────────────── +export const RootLayout = (props: RootLayoutProps) => { + const [isMobile, setIsMobile] = createSignal( + typeof window !== "undefined" ? window.innerWidth < 768 : false, + ) + + onMount(() => { + const handler = () => setIsMobile(window.innerWidth < 768) + window.addEventListener("resize", handler) + onCleanup(() => window.removeEventListener("resize", handler)) + }) + + // 与 GlobalSidebar 中的 sidebarWidth 保持一致:180px / 56px + const marginLeft = createMemo(() => { + if (isMobile()) return "0px" + return sidebarCollapsed() ? "48px" : "120px" + }) + + return ( +
+ + {/* 右侧内容区:自动填充剩余空间 */} +
+ {/* 顶栏 */} + + {/* 页面内容 */} +
+ {props.children} +
+
+
+ ) +} diff --git a/src/components/GlobalSidebar.tsx b/src/components/GlobalSidebar.tsx new file mode 100644 index 000000000..fffb08b6c --- /dev/null +++ b/src/components/GlobalSidebar.tsx @@ -0,0 +1,538 @@ +import { + createSignal, + Show, + createMemo, + onMount, + onCleanup, + For, +} from "solid-js" +import { useLocation } from "@solidjs/router" +import { useColorMode, useColorModeValue, Icon, Image } from "@hope-ui/solid" +import { IconTypes } from "solid-icons" +import { + TbFolder, + TbMusic, + TbChevronLeft, + TbChevronRight, + TbMenu2, + TbLayersIntersect, + TbSettings, +} from "solid-icons/tb" +import { BsPlayCircleFill, BsCardImage } from "solid-icons/bs" +import { BiSolidBookContent } from "solid-icons/bi" +import { FiSun, FiMoon } from "solid-icons/fi" +import { joinBase } from "~/utils" +import { getSetting } from "~/store" + +// ─── 导航项定义 ─────────────────────────────────────────────── +interface NavItem { + label: string + path: string + icon: IconTypes + desc: string +} + +const navItems: NavItem[] = [ + { icon: TbFolder, label: "文件", path: "/", desc: "文件管理" }, + { + icon: BsPlayCircleFill, + label: "影视", + path: "/@media/video", + desc: "电影剧集", + }, + { icon: TbMusic, label: "音乐", path: "/@media/music", desc: "专辑歌曲" }, + { icon: BsCardImage, label: "图片", path: "/@media/image", desc: "相册图库" }, + { + icon: BiSolidBookContent, + label: "书籍", + path: "/@media/books", + desc: "图书文档", + }, +] + +// ─── 全局状态(供 RootLayout 读取宽度) ────────────────────── +export const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false) +export const [sidebarVisible, setSidebarVisible] = createSignal(false) + +// ─── 透明模式持久化 ─────────────────────────────────────────── +const TRANSPARENT_KEY = "sidebar_transparent" +const initTransparent = () => { + try { + return localStorage.getItem(TRANSPARENT_KEY) === "true" + } catch { + return false + } +} +export const [sidebarTransparent, setSidebarTransparent] = + createSignal(initTransparent()) + +// ─── 主组件 ────────────────────────────────────────────────── +export const GlobalSidebar = () => { + const location = useLocation() + const { colorMode, toggleColorMode } = useColorMode() + + // 是否暗色模式 + const isDark = createMemo(() => colorMode() === "dark") + + // 移动端检测 + const [isMobile, setIsMobile] = createSignal( + typeof window !== "undefined" ? window.innerWidth < 768 : false, + ) + onMount(() => { + const handler = () => setIsMobile(window.innerWidth < 768) + window.addEventListener("resize", handler) + onCleanup(() => window.removeEventListener("resize", handler)) + }) + + const isVisible = createMemo(() => !isMobile() || sidebarVisible()) + const sidebarWidth = createMemo(() => (sidebarCollapsed() ? "48px" : "128px")) + + // Logo:从设置读取,支持亮/暗两套(与 Header.tsx 保持完全一致) + const logos = getSetting("logo").split("\n") + const logo = useColorModeValue(logos[0], logos.pop()) + // 站点标题:从数据库设置读取 + const siteTitle = getSetting("site_title") + + // ─── 主题色 token(亮/暗自适应) ───────────────────────── + const bg = createMemo(() => { + if (sidebarTransparent()) { + return isDark() ? "rgba(15,20,35,0.55)" : "rgba(255,255,255,0.55)" + } + return isDark() ? "rgba(18,22,36,0.98)" : "rgba(250,251,253,0.98)" + }) + + const borderColor = createMemo(() => + isDark() ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.07)", + ) + + const textPrimary = createMemo(() => (isDark() ? "#e2e8f0" : "#1e293b")) + const textSecondary = createMemo(() => (isDark() ? "#64748b" : "#94a3b8")) + const textMuted = createMemo(() => + isDark() ? "rgba(100,116,139,0.5)" : "rgba(148,163,184,0.7)", + ) + + // 激活态:使用品牌蓝而非紫色 + const activeBg = createMemo(() => + isDark() ? "rgba(59,130,246,0.18)" : "rgba(59,130,246,0.10)", + ) + const activeBorder = createMemo(() => + isDark() ? "rgba(59,130,246,0.35)" : "rgba(59,130,246,0.25)", + ) + const activeText = createMemo(() => (isDark() ? "#93c5fd" : "#2563eb")) + const activeBar = createMemo(() => "#3b82f6") + + const hoverBg = createMemo(() => + isDark() ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.04)", + ) + const hoverText = createMemo(() => (isDark() ? "#cbd5e1" : "#475569")) + + const btnBg = createMemo(() => + isDark() ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)", + ) + const btnBorder = createMemo(() => + isDark() ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)", + ) + + const shadowStyle = createMemo(() => + sidebarTransparent() + ? `4px 0 24px rgba(0,0,0,${isDark() ? "0.4" : "0.08"}), inset -1px 0 0 ${borderColor()}` + : `2px 0 16px rgba(0,0,0,${isDark() ? "0.3" : "0.06"}), inset -1px 0 0 ${borderColor()}`, + ) + + // ─── 激活判断 ───────────────────────────────────────────── + const isActive = (path: string) => { + const cur = location.pathname + if (path === "/") return !cur.startsWith("/@media") + return cur.startsWith(path) + } + + // ─── 导航跳转 ───────────────────────────────────────────── + const handleNav = (path: string) => { + window.location.href = joinBase(path) + if (isMobile()) setSidebarVisible(false) + } + + // ─── 透明模式切换 ───────────────────────────────────────── + const toggleTransparent = () => { + const next = !sidebarTransparent() + setSidebarTransparent(next) + try { + localStorage.setItem(TRANSPARENT_KEY, String(next)) + } catch {} + } + + // ─── 渲染 ───────────────────────────────────────────────── + return ( + <> + {/* 移动端遮罩 */} + +
setSidebarVisible(false)} + style={{ + position: "fixed", + inset: "0", + background: "rgba(0,0,0,0.5)", + "z-index": "99", + "backdrop-filter": "blur(3px)", + }} + /> + + + {/* ══════════════ 侧边栏主体 ══════════════ */} +
+ {/* ── Logo / 标题区 ── */} +
+ +
+ {/* Logo 图片(从设置读取,与 Header 保持一致) */} + + } + /> + {/* 站点标题(从数据库设置读取) */} + + + {siteTitle} + + +
+
+
+ + {/* ── 导航菜单 ── */} + + + {/* ── 底部工具栏 ── */} +
+ {/* 亮/暗模式切换 */} + + + {/* 透明模式切换(折叠时隐藏) */} + + + +
+
+ + {/* ══════════════ 移动端汉堡按钮 ══════════════ */} + + + + + ) +} diff --git a/src/lang/en/manage.json b/src/lang/en/manage.json index 5829c6ee7..0f23a94c4 100644 --- a/src/lang/en/manage.json +++ b/src/lang/en/manage.json @@ -27,7 +27,13 @@ "ldap": "LDAP", "s3": "S3", "ftp": "FTP", - "traffic": "Traffic" + "traffic": "Traffic", + "media": "Media Library", + "media_video": "Video", + "media_music": "Music", + "media_image": "Images", + "media_book": "Books", + "media_settings": "Settings" }, "title": "OpenList Management", "not_admin": "You are not an admin user, please login with an admin account.", diff --git a/src/lang/en/settings.json b/src/lang/en/settings.json index c624a41d7..908e06299 100755 --- a/src/lang/en/settings.json +++ b/src/lang/en/settings.json @@ -142,5 +142,14 @@ "version": "Version", "video_autoplay": "Video autoplay", "video_types": "Video types", - "webauthn_login_enabled": "Webauthn login enabled" + "webauthn_login_enabled": "Webauthn login enabled", + "media_tmdb_key": "TMDB API Key", + "media_discogs_token": "Discogs Token", + "media_store_thumbnail": "Store thumbnail", + "media_thumbnail_mode": "Thumbnail storage mode", + "media_thumbnail_modes": { + "base64": "Base64 (Database)", + "local": "Local file" + }, + "media_thumbnail_path": "Thumbnail storage path" } diff --git a/src/pages/home/Body.tsx b/src/pages/home/Body.tsx index a8d032dcc..185af2f02 100644 --- a/src/pages/home/Body.tsx +++ b/src/pages/home/Body.tsx @@ -1,24 +1,13 @@ import { VStack } from "@hope-ui/solid" -import { Nav } from "./Nav" import { Obj } from "./Obj" import { Readme } from "./Readme" -import { Container } from "./Container" import { Sidebar } from "./Sidebar" export const Body = () => { return ( - - +
+ -
) } diff --git a/src/pages/home/Layout.tsx b/src/pages/home/Layout.tsx index d5a485f1b..69184fad5 100644 --- a/src/pages/home/Layout.tsx +++ b/src/pages/home/Layout.tsx @@ -4,7 +4,6 @@ import { getSetting } from "~/store" import { notify } from "~/utils" import { Body } from "./Body" import { Footer } from "./Footer" -import { Header } from "./header/Header" import { Toolbar } from "./toolbar/Toolbar" import { onMount } from "solid-js" @@ -21,12 +20,17 @@ const Index = () => { } }) return ( - <> -
+
- +
) } diff --git a/src/pages/manage/media/MediaManage.tsx b/src/pages/manage/media/MediaManage.tsx new file mode 100644 index 000000000..36ddc35cb --- /dev/null +++ b/src/pages/manage/media/MediaManage.tsx @@ -0,0 +1,858 @@ +import { createSignal, createResource, Show, For, createEffect } from "solid-js" +import { + adminGetMediaConfigs, + adminSaveMediaConfig, + adminGetMediaItems, + adminUpdateMediaItem, + adminDeleteMediaItem, + adminStartMediaScan, + adminStartMediaScrape, + adminClearMediaDB, + adminGetMediaScanProgress, +} from "~/utils/media_api" +import type { MediaType, MediaItem, MediaConfig } from "~/types" + +// 别名,方便内部使用 +const getMediaConfig = async (mt: MediaType) => { + const resp = await adminGetMediaConfigs() + if (resp.code === 200) { + const found = (resp.data as MediaConfig[]).find((c) => c.media_type === mt) + return { code: 200, data: found ?? null } + } + return { code: resp.code, data: null } +} +const saveMediaConfig = adminSaveMediaConfig +const listMediaItems = adminGetMediaItems +const updateMediaItem = adminUpdateMediaItem +const deleteMediaItem = adminDeleteMediaItem +const scanMedia = adminStartMediaScan +const scrapeMedia = adminStartMediaScrape +const clearMediaDB = adminClearMediaDB +const getMediaScanProgress = adminGetMediaScanProgress + +// ==================== 通用媒体管理页 ==================== +interface MediaManagePageProps { + mediaType: MediaType + title: string + icon: string +} + +export const MediaManagePage = (props: MediaManagePageProps) => { + // 配置状态 + const [config, setConfig] = createSignal({ + media_type: props.mediaType, + enabled: false, + scan_path: "/", + path_merge: false, + last_scan_at: null, + last_scrape_at: null, + }) + const [configSaving, setConfigSaving] = createSignal(false) + + // 扫描/刮削状态 + const [scanning, setScanning] = createSignal(false) + const [scraping, setScraping] = createSignal(false) + const [progress, setProgress] = createSignal<{ + status: string + current: number + total: number + } | null>(null) + + // 数据库管理状态 + const [page, setPage] = createSignal(1) + const [editingItem, setEditingItem] = createSignal(null) + const [showEditModal, setShowEditModal] = createSignal(false) + const pageSize = 20 + + // 加载配置 + const [configData] = createResource( + () => props.mediaType, + async (mt) => { + const resp = await getMediaConfig(mt) + if (resp.code === 200 && resp.data) { + setConfig(resp.data) + } + return resp.data + }, + ) + + // 加载媒体条目 + const [itemsData, { refetch: refetchItems }] = createResource( + () => ({ media_type: props.mediaType, page: page(), page_size: pageSize }), + async (params) => { + const resp = await listMediaItems(params) + if (resp.code === 200) return resp.data + return { content: [], total: 0 } + }, + ) + + const items = () => (itemsData()?.content as MediaItem[]) ?? [] + const total = () => itemsData()?.total ?? 0 + const totalPages = () => Math.ceil(total() / pageSize) + + // 保存配置 + const handleSaveConfig = async () => { + setConfigSaving(true) + await saveMediaConfig(config()) + setConfigSaving(false) + } + + // 立即扫描 + const handleScan = async () => { + setScanning(true) + setProgress({ status: "扫描中...", current: 0, total: 0 }) + await scanMedia(props.mediaType) + // 轮询进度 + const timer = setInterval(async () => { + const resp = await getMediaScanProgress(props.mediaType) + if (resp.code === 200 && resp.data) { + const d = resp.data + setProgress({ + status: d.message || (d.running ? "扫描中..." : "完成"), + current: d.done, + total: d.total, + }) + if (!d.running) { + clearInterval(timer) + setScanning(false) + refetchItems() + } + } + }, 1000) + } + + // 立即刮削 + const handleScrape = async () => { + setScraping(true) + await scrapeMedia(props.mediaType) + setScraping(false) + refetchItems() + } + + // 清空数据库 + const handleClear = async () => { + if (!confirm(`确定要清空 ${props.title} 的所有数据吗?此操作不可恢复!`)) + return + await clearMediaDB(props.mediaType) + refetchItems() + } + + // 保存编辑 + const handleSaveItem = async () => { + if (!editingItem()) return + await updateMediaItem(editingItem()!) + setShowEditModal(false) + setEditingItem(null) + refetchItems() + } + + // 删除条目 + const handleDeleteItem = async (id: number) => { + if (!confirm("确定删除此条目?")) return + await deleteMediaItem(id) + refetchItems() + } + + return ( +
+

+ {props.icon} {props.title}管理 +

+ + {/* 配置区域 */} +
+

+ 基础配置 +

+ +
+ {/* 启用开关 */} +