diff --git a/web/src/components/MobileBottomNav.tsx b/web/src/components/MobileBottomNav.tsx
new file mode 100644
index 0000000..5099b42
--- /dev/null
+++ b/web/src/components/MobileBottomNav.tsx
@@ -0,0 +1,55 @@
+import { Link, useLocation } from 'react-router-dom'
+import clsx from 'clsx'
+import { Menu } from 'lucide-react'
+
+import { MOBILE_BOTTOM_NAV_ITEMS } from './layoutNavigation'
+
+type MobileBottomNavProps = {
+ onOpenMenu: () => void
+}
+
+export function MobileBottomNav({ onOpenMenu }: MobileBottomNavProps) {
+ const location = useLocation()
+
+ return (
+
+ )
+}
diff --git a/web/src/components/PageBackButton.tsx b/web/src/components/PageBackButton.tsx
new file mode 100644
index 0000000..15d77c9
--- /dev/null
+++ b/web/src/components/PageBackButton.tsx
@@ -0,0 +1,48 @@
+import { ArrowLeft } from 'lucide-react'
+import { useNavigate } from 'react-router-dom'
+import clsx from 'clsx'
+
+type PageBackButtonProps = {
+ to?: string
+ label?: string
+ onClick?: () => void
+ className?: string
+ compact?: boolean
+}
+
+export function PageBackButton({
+ to,
+ label = '返回',
+ onClick,
+ className,
+ compact = false,
+}: PageBackButtonProps) {
+ const navigate = useNavigate()
+
+ const handleClick = () => {
+ if (onClick) {
+ onClick()
+ return
+ }
+ if (to) {
+ navigate(to)
+ return
+ }
+ navigate(-1)
+ }
+
+ return (
+
+ )
+}
diff --git a/web/src/components/PageHeader.tsx b/web/src/components/PageHeader.tsx
new file mode 100644
index 0000000..77088dc
--- /dev/null
+++ b/web/src/components/PageHeader.tsx
@@ -0,0 +1,38 @@
+import type { ReactNode } from 'react'
+
+import { PageBackButton } from './PageBackButton'
+
+type PageHeaderProps = {
+ title: string
+ description?: string
+ backTo?: string
+ backLabel?: string
+ onBack?: () => void
+ actions?: ReactNode
+ className?: string
+}
+
+export function PageHeader({
+ title,
+ description,
+ backTo,
+ backLabel,
+ onBack,
+ actions,
+ className,
+}: PageHeaderProps) {
+ const showBack = Boolean(backTo || onBack)
+
+ return (
+
+ {showBack && }
+
+
+
{title}
+ {description ?
{description}
: null}
+
+ {actions ?
{actions}
: null}
+
+
+ )
+}
diff --git a/web/src/components/layoutNavigation.ts b/web/src/components/layoutNavigation.ts
index 561f850..1c21806 100644
--- a/web/src/components/layoutNavigation.ts
+++ b/web/src/components/layoutNavigation.ts
@@ -1,9 +1,13 @@
import type { LucideIcon } from 'lucide-react'
import {
+ Clock,
FileOutput,
FolderOpen,
+ Heart,
+ Home,
Library,
ListChecks,
+ ListMusic,
Settings,
Tv,
User,
@@ -19,6 +23,20 @@ export type LayoutNavItem = {
adminOnly?: boolean
}
+export type HeaderBackTarget = {
+ to: string
+ label: string
+}
+
+export type MobileBottomNavItem = {
+ to: string
+ label: string
+ icon: LucideIcon
+ end?: boolean
+ match?: (pathname: string) => boolean
+}
+
+/** Desktop admin sidebar and admin mobile drawer. */
export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
{ to: '/profile', label: '个人资料', icon: User },
{ to: '/libraries?from=admin', label: '媒体库', icon: Library },
@@ -26,8 +44,90 @@ export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
{ to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
- // Emby 挂载(设置区):远程 Emby 媒体库挂载
{ to: '/emby-mount', label: 'Emby 挂载', icon: Tv, adminOnly: true },
- // STRM 管理(设置区):网盘目录生成 strm 与元数据同步
{ to: '/strm', label: 'STRM 管理', icon: FileOutput, adminOnly: true },
-]
\ No newline at end of file
+]
+
+/** Media browsing links for the mobile drawer when browsing libraries. */
+export const MEDIA_NAV_ITEMS: LayoutNavItem[] = [
+ { to: '/', label: '首页', icon: Home, end: true },
+ { to: '/libraries', label: '媒体库', icon: Library },
+ { to: '/favourites', label: '我的收藏', icon: Heart },
+ { to: '/playlists', label: '播放列表', icon: ListMusic },
+ { to: '/history', label: '观看历史', icon: Clock },
+]
+
+export const MOBILE_BOTTOM_NAV_ITEMS: MobileBottomNavItem[] = [
+ { to: '/', label: '首页', icon: Home, end: true },
+ {
+ to: '/libraries',
+ label: '媒体库',
+ icon: Library,
+ match: (p) => p === '/libraries' || p.startsWith('/library'),
+ },
+ { to: '/favourites', label: '收藏', icon: Heart },
+ {
+ to: '/playlists',
+ label: '列表',
+ icon: ListMusic,
+ match: (p) => p === '/playlists' || p.startsWith('/playlist'),
+ },
+]
+
+const ROOT_MEDIA_PATHS = new Set(['/', '/libraries', '/favourites', '/playlists', '/history'])
+
+export function isRootMediaPath(pathname: string): boolean {
+ return ROOT_MEDIA_PATHS.has(pathname)
+}
+
+export function resolveHeaderBack(pathname: string): HeaderBackTarget | null {
+ if (pathname.startsWith('/library/')) {
+ return { to: '/libraries', label: '媒体库' }
+ }
+ if (pathname.startsWith('/playlist/')) {
+ return { to: '/playlists', label: '播放列表' }
+ }
+ if (pathname.startsWith('/media/')) {
+ return null
+ }
+ if (pathname === '/scraper/queue') {
+ return { to: '/queue', label: '任务队列' }
+ }
+ if (pathname === '/strm/downloads' || pathname === '/strm/uploads') {
+ return { to: '/strm', label: 'STRM 管理' }
+ }
+ if (
+ pathname === '/profile' ||
+ pathname === '/dlna' ||
+ pathname === '/play-profiles' ||
+ pathname === '/poster-wall'
+ ) {
+ return { to: '/', label: '首页' }
+ }
+ if (
+ pathname === '/settings' ||
+ pathname === '/admin' ||
+ pathname === '/files' ||
+ pathname === '/queue' ||
+ pathname === '/emby-mount' ||
+ pathname === '/strm'
+ ) {
+ return { to: '/', label: '首页' }
+ }
+ return null
+}
+
+export function shouldShowMobileBottomNav(pathname: string): boolean {
+ if (pathname.startsWith('/play')) return false
+ return (
+ pathname === '/' ||
+ pathname === '/libraries' ||
+ pathname.startsWith('/library') ||
+ pathname.startsWith('/media') ||
+ pathname === '/favourites' ||
+ pathname === '/playlists' ||
+ pathname.startsWith('/playlist') ||
+ pathname === '/history' ||
+ pathname === '/poster-wall'
+ )
+}
diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx
index 209639a..301a099 100644
--- a/web/src/pages/LibraryPage.tsx
+++ b/web/src/pages/LibraryPage.tsx
@@ -13,6 +13,7 @@ import {
type SortOrder,
} from '../utils/mediaSort'
import { LibraryPageDialogs } from './LibraryPageDialogs'
+import { PageBackButton } from '../components/PageBackButton'
import { LibraryPageHeader } from './LibraryPageHeader'
import { LibraryMediaSections } from './LibraryMediaSections'
import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection'
@@ -181,6 +182,12 @@ export function LibraryPage() {
return (
+ {!selectedSeries && (
+
+ )}
+
{!selectedSeries && (
- 媒体资源已被移除或不存在
+
)
}
diff --git a/web/src/pages/PlaylistDetailPage.tsx b/web/src/pages/PlaylistDetailPage.tsx
index e0cdb4a..6010831 100644
--- a/web/src/pages/PlaylistDetailPage.tsx
+++ b/web/src/pages/PlaylistDetailPage.tsx
@@ -5,6 +5,7 @@ import toast from 'react-hot-toast'
import { playbackAPI, type PlaylistDetail } from '../api/playback'
import { MediaCard } from '../components/MediaCard'
+import { PageHeader } from '../components/PageHeader'
export function PlaylistDetailPage() {
const { id = '' } = useParams()
@@ -24,10 +25,12 @@ export function PlaylistDetailPage() {
return (
-
+
{detail.items.length === 0 && (
暂无内容,前往媒体详情页通过「加入播放列表」添加。
diff --git a/web/src/pages/ScraperQueuePage.tsx b/web/src/pages/ScraperQueuePage.tsx
index 4dc4e36..b861b17 100644
--- a/web/src/pages/ScraperQueuePage.tsx
+++ b/web/src/pages/ScraperQueuePage.tsx
@@ -60,7 +60,7 @@ const TYPE_LABELS: Record
= {
const PAGE_SIZE = 50
-export function ScraperQueuePage() {
+export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
const [snapshot, setSnapshot] = useState(null)
const [filter, setFilter] = useState<'all' | ScrapeTaskStatus>('all')
const [search, setSearch] = useState('')
@@ -251,7 +251,7 @@ export function ScraperQueuePage() {
return (
- {/* 1. Header */}
+ {!embedded && (