diff --git a/web/src/components/MediaCard.tsx b/web/src/components/MediaCard.tsx
index 1c368a7..f7a95f0 100644
--- a/web/src/components/MediaCard.tsx
+++ b/web/src/components/MediaCard.tsx
@@ -6,7 +6,7 @@ import { imageURL } from '../api/client'
import type { Media } from '../types'
export const MediaCard = ({
- media, progress, count, rating, linkTo, onClick, actions,
+ media, progress, count, rating, linkTo, onClick, actions, compact,
}: {
media: Media
progress?: number
@@ -15,6 +15,7 @@ export const MediaCard = ({
linkTo?: string
onClick?: () => void
actions?: ReactNode
+ compact?: boolean
}) => {
const ref = useRef(null)
const href = linkTo ?? `/media/${media.id}`
@@ -95,20 +96,26 @@ export const MediaCard = ({
)}
{/* Premium Hover Overlay */}
-
+
-
-
+
+
立即观影
-
- {media.overview || "暂无简介内容"}
-
+ {!compact && (
+
+ {media.overview || "暂无简介内容"}
+
+ )}
@@ -124,8 +131,12 @@ export const MediaCard = ({
{/* Media Metadata Info */}
-
-
+
+
{media.title}
diff --git a/web/src/components/RouteErrorBoundary.tsx b/web/src/components/RouteErrorBoundary.tsx
index 27b0f78..3730322 100644
--- a/web/src/components/RouteErrorBoundary.tsx
+++ b/web/src/components/RouteErrorBoundary.tsx
@@ -43,7 +43,7 @@ export class RouteErrorBoundary extends Component<{ children: ReactNode }, { has
}
componentDidCatch(error: Error, info: ErrorInfo) {
- console.error('MediaStationGo route crashed', error, info)
+ console.error('MMTL route crashed', error, info)
}
render() {
diff --git a/web/src/components/layoutNavigation.ts b/web/src/components/layoutNavigation.ts
index fc7ed36..5887c2d 100644
--- a/web/src/components/layoutNavigation.ts
+++ b/web/src/components/layoutNavigation.ts
@@ -1,29 +1,12 @@
import type { LucideIcon } from 'lucide-react'
import {
- Activity,
- Cast,
- Clock,
- CloudDownload,
- Compass,
- FileText,
- Globe,
- HardDrive,
- Heart,
- Home,
- Image,
- KeySquare,
+ FolderOpen,
Library,
- ListMusic,
- Rss,
- Search,
Settings,
- Sliders,
- Sparkles,
User,
+ Users,
} from 'lucide-react'
-export type LayoutNavGroupID = 'media' | 'personal' | 'downloads' | 'tools' | 'system'
-
export type LayoutNavItem = {
to: string
label: string
@@ -33,80 +16,10 @@ export type LayoutNavItem = {
adminOnly?: boolean
}
-export type LayoutNavGroup = {
- id: LayoutNavGroupID
- label: string
- icon: LucideIcon
- activePaths: string[]
- adminOnly?: boolean
- items: LayoutNavItem[]
-}
-
-export const LAYOUT_NAV_GROUPS: LayoutNavGroup[] = [
- {
- id: 'media',
- label: '媒体浏览',
- icon: Home,
- activePaths: ['/', '/libraries', '/library', '/poster-wall', '/discover', '/search', '/dlna', '/ai'],
- items: [
- { to: '/', label: '系统首页', icon: Home, end: true },
- { to: '/libraries', label: '媒体库', icon: Library },
- { to: '/poster-wall', label: '海报墙', icon: Image },
- { to: '/discover', label: '精彩发现', icon: Compass, permission: 'can_view_discover' },
- { to: '/search', label: '智能搜索', icon: Search, permission: 'can_use_ai' },
- { to: '/dlna', label: 'DLNA 投屏', icon: Cast, permission: 'can_cast' },
- { to: '/ai', label: 'AI 助理', icon: Sparkles, permission: 'can_use_ai_assistant' },
- ],
- },
- {
- id: 'personal',
- label: '个人观影',
- icon: User,
- activePaths: ['/favourites', '/playlists', '/playlist', '/history', '/profile', '/play-profiles'],
- items: [
- { to: '/favourites', label: '我的收藏', icon: Heart },
- { to: '/playlists', label: '播放列表', icon: ListMusic },
- { to: '/history', label: '观看历史', icon: Clock },
- ],
- },
- {
- id: 'downloads',
- label: '下载与订阅',
- icon: CloudDownload,
- activePaths: ['/downloads', '/download-clients', '/subscriptions', '/site-search', '/sites', '/tasks'],
- items: [
- { to: '/downloads', label: '下载中心', icon: CloudDownload, permission: 'can_manage_downloads' },
- { to: '/subscriptions', label: '订阅管理', icon: Rss, permission: 'can_manage_subscriptions' },
- { to: '/sites', label: '站点管理', icon: Globe, permission: 'can_manage_sites' },
- { to: '/tasks', label: '实时任务', icon: Activity },
- ],
- },
- {
- id: 'tools',
- label: '文件与自动化',
- icon: HardDrive,
- activePaths: ['/storage', '/storage-config', '/files', '/strm', '/duplicates', '/scheduler', '/recycle', '/stats'],
- adminOnly: true,
- items: [
- { to: '/storage', label: '存储与文件', icon: HardDrive },
- { to: '/strm', label: 'STRM 管理', icon: FileText },
- ],
- },
- {
- id: 'system',
- label: '系统配置',
- icon: Settings,
- activePaths: ['/admin', '/sites', '/notify-channels', '/license', '/settings', '/assistant'],
- adminOnly: true,
- items: [
- { to: '/admin', label: '媒体与用户', icon: Settings },
- { to: '/settings', label: '系统设置', icon: Sliders },
- { to: '/license', label: '授权许可', icon: KeySquare },
- ],
- },
-]
-
-export const NAV_GROUP_PATHS: Record
= LAYOUT_NAV_GROUPS.reduce(
- (paths, group) => ({ ...paths, [group.id]: group.activePaths }),
- {} as Record,
-)
+export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
+ { to: '/profile', label: '个人资料', icon: User },
+ { to: '/libraries?from=admin', label: '媒体库', icon: Library },
+ { to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
+ { to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
+ { to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
+]
\ No newline at end of file
diff --git a/web/src/components/manageLibrariesDialog.tsx b/web/src/components/manageLibrariesDialog.tsx
new file mode 100644
index 0000000..3cc8de5
--- /dev/null
+++ b/web/src/components/manageLibrariesDialog.tsx
@@ -0,0 +1,51 @@
+import { createRoot } from 'react-dom/client'
+import { X } from 'lucide-react'
+
+import { AdminLibraryPanel } from '../pages/AdminLibraryPanel'
+
+export function openManageLibrariesDialog(): Promise {
+ return new Promise((resolve) => {
+ const host = document.createElement('div')
+ document.body.appendChild(host)
+ const root = createRoot(host)
+
+ const close = () => {
+ root.unmount()
+ host.remove()
+ resolve()
+ }
+
+ root.render()
+ })
+}
+
+function ManageLibrariesDialog({ onClose }: { onClose: () => void }) {
+ return (
+
+
event.stopPropagation()}
+ >
+
+
管理媒体库
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/web/src/components/useLayoutSearch.ts b/web/src/components/useLayoutSearch.ts
deleted file mode 100644
index 5cf0485..0000000
--- a/web/src/components/useLayoutSearch.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react'
-import type { NavigateFunction } from 'react-router-dom'
-
-import { mediaAPI } from '../api/library'
-import type { Media } from '../types'
-import { groupSeries } from '../utils/groupSeries'
-
-type UseLayoutSearchOptions = {
- pathname: string
- locationSearch: string
- navigate: NavigateFunction
-}
-
-export function useLayoutSearch({ pathname, locationSearch, navigate }: UseLayoutSearchOptions) {
- const [focused, setFocused] = useState(false)
- const [query, setQuery] = useState('')
- const [items, setItems] = useState([])
- const [loading, setLoading] = useState(false)
- const [total, setTotal] = useState(0)
- const [error, setError] = useState('')
- const searchSeq = useRef(0)
- const cards = useMemo(() => groupSeries(items).slice(0, 8), [items])
-
- useEffect(() => {
- if (pathname === '/search') {
- setQuery(new URLSearchParams(locationSearch).get('q') ?? '')
- }
- }, [pathname, locationSearch])
-
- useEffect(() => {
- const trimmedQuery = query.trim()
- const seq = ++searchSeq.current
- if (!focused || !trimmedQuery) {
- setItems([])
- setTotal(0)
- setError('')
- setLoading(false)
- return
- }
-
- setLoading(true)
- setError('')
- const timer = window.setTimeout(() => {
- mediaAPI
- .search(trimmedQuery, 24)
- .then((data) => {
- if (seq !== searchSeq.current) return
- setItems(data.items ?? [])
- setTotal(data.total ?? (data.items ?? []).length)
- })
- .catch(() => {
- if (seq !== searchSeq.current) return
- setItems([])
- setTotal(0)
- setError('搜索失败,请稍后再试')
- })
- .finally(() => {
- if (seq === searchSeq.current) setLoading(false)
- })
- }, 220)
-
- return () => window.clearTimeout(timer)
- }, [focused, query])
-
- const submit = (event: FormEvent) => {
- event.preventDefault()
- const trimmedQuery = query.trim()
- if (trimmedQuery) {
- navigate(`/search?q=${encodeURIComponent(trimmedQuery)}`)
- setFocused(false)
- }
- }
-
- return {
- cards,
- error,
- focused,
- loading,
- query,
- total,
- setFocused,
- setQuery,
- submit,
- }
-}
diff --git a/web/src/components/useLayoutSidebar.ts b/web/src/components/useLayoutSidebar.ts
index b7e6587..d4043b2 100644
--- a/web/src/components/useLayoutSidebar.ts
+++ b/web/src/components/useLayoutSidebar.ts
@@ -1,11 +1,8 @@
import { useCallback, useEffect, useState } from 'react'
-import { NAV_GROUP_PATHS } from './layoutNavigation'
-
export function useLayoutSidebar(pathname: string) {
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
- const [openGroups, setOpenGroups] = useState>({ media: true })
useEffect(() => {
const handleResize = () => {
@@ -20,31 +17,15 @@ export function useLayoutSidebar(pathname: string) {
setIsMobileDrawerOpen(false)
}, [pathname])
- const isRouteIn = useCallback(
- (paths: string[]) =>
- paths.some((path) => (path === '/' ? pathname === '/' : pathname.startsWith(path))),
- [pathname],
- )
-
- const toggleGroup = useCallback(
- (key: string) => setOpenGroups((current) => ({ ...current, [key]: !current[key] })),
- [],
- )
-
- useEffect(() => {
- const active = Object.entries(NAV_GROUP_PATHS).find(([, paths]) => isRouteIn(paths))?.[0]
- if (active) {
- setOpenGroups((current) => (current[active] ? current : { ...current, [active]: true }))
- }
- }, [isRouteIn])
+ const toggleSidebar = useCallback(() => {
+ setIsSidebarOpen((current) => !current)
+ }, [])
return {
isMobileDrawerOpen,
- isRouteIn,
isSidebarOpen,
- openGroups,
setIsMobileDrawerOpen,
setIsSidebarOpen,
- toggleGroup,
+ toggleSidebar,
}
-}
+}
\ No newline at end of file
diff --git a/web/src/pages/AIAssistantExternalResults.tsx b/web/src/pages/AIAssistantExternalResults.tsx
deleted file mode 100644
index 1781615..0000000
--- a/web/src/pages/AIAssistantExternalResults.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { useState } from 'react'
-
-import type { ExternalMediaResult } from '../api/ai'
-import type { DiscoverItem } from '../api/discover'
-import { imageURL } from '../api/client'
-import { DiscoverDetailModal } from './DiscoverDetailModal'
-
-type AIAssistantExternalResultsProps = {
- items: ExternalMediaResult[]
-}
-
-export function AIAssistantExternalResults({ items }: AIAssistantExternalResultsProps) {
- const [activeItem, setActiveItem] = useState(null)
-
- if (items.length === 0) return null
-
- return (
- <>
-
- {items.map((item) => {
- const keyword = item.subscribe_keyword || item.title
- const key = `${item.source}:${keyword}`
- return (
-
setActiveItem(item)}
- onKeyDown={(event) => {
- if (event.key === 'Enter' || event.key === ' ') setActiveItem(item)
- }}
- className="cursor-pointer rounded-2xl border border-gray-200 bg-gray-50 p-3 transition hover:-translate-y-0.5 hover:border-primary-300"
- >
-
-
- {item.poster_url ? (
-
})
- ) : null}
-
-
-
- {item.source}
- {item.media_type && {item.media_type}}
- {item.year ? {item.year} : null}
-
-
{item.title}
-
- {item.overview || `订阅关键词:${keyword}`}
-
-
详情 / 订阅设置
-
-
-
- )
- })}
-
- {activeItem && (
- setActiveItem(null)}
- />
- )}
- >
- )
-}
diff --git a/web/src/pages/AIAssistantHeader.tsx b/web/src/pages/AIAssistantHeader.tsx
deleted file mode 100644
index 0ee83f8..0000000
--- a/web/src/pages/AIAssistantHeader.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { Sparkles } from 'lucide-react'
-
-export type AIAssistantStatus = {
- enabled: boolean
- provider: string
- model: string
-}
-
-type AIAssistantHeaderProps = {
- status: AIAssistantStatus | null
-}
-
-export function AIAssistantHeader({ status }: AIAssistantHeaderProps) {
- return (
-
-
-
-
-
-
-
AI 助手
-
自然语言搜索 · 基于观影历史的智能推荐
-
-
- {status && (
-
-
- {status.enabled
- ? `已连接 · ${status.provider}${status.model ? ' / ' + status.model : ''}`
- : '未配置 AI 服务,使用本地规则解析'}
-
- )}
-
- )
-}
diff --git a/web/src/pages/AIAssistantPage.tsx b/web/src/pages/AIAssistantPage.tsx
deleted file mode 100644
index 391a675..0000000
--- a/web/src/pages/AIAssistantPage.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { Link } from 'react-router-dom'
-
-import { AIAssistantHeader } from './AIAssistantHeader'
-import { AIAssistantRecommendationsSection } from './AIAssistantRecommendationsSection'
-import { AIAssistantSearchSection } from './AIAssistantSearchSection'
-import { useAIAssistantPage } from './useAIAssistantPage'
-
-// AIAssistantPage exposes the two AI helpers backed by the Go server:
-// - smart search: parses a natural-language query into a SearchIntent +
-// a list of matching local media items.
-// - recommendations: returns a list of recommended titles based on the
-// current user's recent watch history.
-//
-// The Vue version had a full chat surface; the Go backend has no chat or
-// operation-execute endpoints, so we render the same two capabilities as
-// a focused two-panel screen.
-export function AIAssistantPage() {
- const assistant = useAIAssistantPage()
-
- return (
-
-
-
-
-
-
-
- {/* Decorative footer (mirrors the Vue page hint that AI runs locally). */}
- {!assistant.status?.enabled && (
-
- 提示: 当前未配置外部 AI Provider,系统将使用本地规则引擎解析查询。
- 管理员可在 API 配置{' '}
- 中接入 OpenAI / DeepSeek 等服务以获得更好效果。
-
- )}
-
- )
-}
diff --git a/web/src/pages/AIAssistantRecommendationsSection.tsx b/web/src/pages/AIAssistantRecommendationsSection.tsx
deleted file mode 100644
index 39536bd..0000000
--- a/web/src/pages/AIAssistantRecommendationsSection.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import { Link } from 'react-router-dom'
-import { Loader2, Search, Wand2 } from 'lucide-react'
-
-type AIAssistantRecommendationsSectionProps = {
- recs: string[] | null
- recommending: boolean
- onRecommend: () => void
-}
-
-export function AIAssistantRecommendationsSection({
- recs,
- recommending,
- onRecommend,
-}: AIAssistantRecommendationsSectionProps) {
- return (
-
-
-
为你推荐
-
-
- 推荐基于你的最近观看历史。点击标题在媒体库中查找。
-
- {recs && recs.length > 0 && (
-
- {recs.map((title, index) => (
- -
-
- {title}
-
-
-
- ))}
-
- )}
-
- {recs && recs.length === 0 && (
- 还没有推荐结果 — 先去看几部片子,我再给你挑。
- )}
-
- )
-}
diff --git a/web/src/pages/AIAssistantSearchSection.tsx b/web/src/pages/AIAssistantSearchSection.tsx
deleted file mode 100644
index badfc9e..0000000
--- a/web/src/pages/AIAssistantSearchSection.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-import type { FormEvent } from 'react'
-import { Loader2, Search } from 'lucide-react'
-
-import type { ExternalMediaResult, SearchIntent } from '../api/ai'
-import { MediaCard } from '../components/MediaCard'
-import type { Media } from '../types'
-import type { SeriesCard } from '../utils/groupSeries'
-import { seriesCardLink } from '../utils/groupSeries'
-import { AIAssistantExternalResults } from './AIAssistantExternalResults'
-
-type AIAssistantSearchSectionProps = {
- query: string
- searching: boolean
- intent: SearchIntent | null
- items: Media[]
- localCards: SeriesCard[]
- externalItems: ExternalMediaResult[]
- onSearch: (event: FormEvent) => void
- setQuery: (query: string) => void
-}
-
-const quickHints = [
- '2023 年的科幻电影',
- '评分高的动漫',
- '最近添加的纪录片',
- '中文剧集',
-]
-
-export function AIAssistantSearchSection({
- query,
- searching,
- intent,
- items,
- localCards,
- externalItems,
- onSearch,
- setQuery,
-}: AIAssistantSearchSectionProps) {
- return (
-
- 智能搜索
-
-
-
- {quickHints.map((hint) => (
-
- ))}
-
-
-
-
-
-
- )
-}
-
-function SearchIntentSummary({ intent }: { intent: SearchIntent | null }) {
- if (!intent) return null
- return (
-
-
解析结果
-
-
- 查询: {intent.query || '—'}
-
- {intent.year !== undefined && intent.year > 0 && (
-
- 年份: {intent.year}
-
- )}
- {intent.genre && (
-
- 类型: {intent.genre}
-
- )}
- {intent.type && (
-
- 分类: {intent.type}
-
- )}
- {intent.sort && (
-
- 排序: {intent.sort}
-
- )}
- {intent.language && (
-
- 语言: {intent.language}
-
- )}
-
-
- )
-}
-
-function LocalMediaResults({
- localCards,
- itemCount,
-}: {
- localCards: SeriesCard[]
- itemCount: number
-}) {
- if (localCards.length === 0) return null
- return (
-
-
- 本地媒体库 · {localCards.length} 个合集 / {itemCount} 个条目
-
-
- {localCards.map((card) => (
-
- ))}
-
-
- )
-}
diff --git a/web/src/pages/AdminLibraryPanel.tsx b/web/src/pages/AdminLibraryPanel.tsx
index b59107e..195b68b 100644
--- a/web/src/pages/AdminLibraryPanel.tsx
+++ b/web/src/pages/AdminLibraryPanel.tsx
@@ -1,12 +1,13 @@
import { AdminLibraryCreateForm } from './AdminLibraryPanelSections'
import { AdminLibraryTable } from './AdminLibraryTable'
+import { LibraryStorageStats } from './LibraryStorageStats'
import { useAdminLibraryPanel } from './useAdminLibraryPanel'
export function AdminLibraryPanel() {
const { libs, createForm, editableRoots, rootActions, libraryActions } = useAdminLibraryPanel()
return (
-
+
)
}
diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx
index d4dc0c2..90fa3e1 100644
--- a/web/src/pages/AdminPage.tsx
+++ b/web/src/pages/AdminPage.tsx
@@ -1,71 +1,21 @@
-import { useEffect, useState } from 'react'
-import { useSearchParams } from 'react-router-dom'
+import { Users } from 'lucide-react'
-import { APIConfigsPanel } from '../components/APIConfigsPanel'
-import { ManagementShortcuts } from '../components/ManagementShortcuts'
-import { AdminLibraryPanel } from './AdminLibraryPanel'
import { AdminUsersPanel } from './AdminUsersPanel'
-type AdminTab = 'library' | 'users' | 'api'
-
-function parseAdminTab(value: string | null): AdminTab {
- if (value === 'users' || value === 'api') return value
- return 'library'
-}
-
export function AdminPage() {
- const [searchParams, setSearchParams] = useSearchParams()
- const [tab, setTab] = useState
(() => parseAdminTab(searchParams.get('tab')))
- const tabs = [
- { key: 'library' as const, label: '媒体库' },
- { key: 'users' as const, label: '用户' },
- { key: 'api' as const, label: '外部API' },
- ]
-
- useEffect(() => {
- setTab(parseAdminTab(searchParams.get('tab')))
- }, [searchParams])
-
- const selectTab = (next: AdminTab) => {
- setTab(next)
- setSearchParams(next === 'library' ? {} : { tab: next })
- }
-
return (
-
管理后台
-
-
- {tabs.map((k) => (
-
- ))}
-
+
- {tab === 'library' &&
}
- {tab === 'users' &&
}
- {tab === 'api' &&
}
+
)
}
diff --git a/web/src/pages/AdminUsersPanel.tsx b/web/src/pages/AdminUsersPanel.tsx
index 6c426f8..ec1342c 100644
--- a/web/src/pages/AdminUsersPanel.tsx
+++ b/web/src/pages/AdminUsersPanel.tsx
@@ -2,7 +2,6 @@ import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { adminAPI } from '../api/admin'
-import { licenseAPI, type LicenseStatus } from '../api/license'
import type { User } from '../types'
import { confirmAction } from '../components/confirmAction'
import { requestPassword } from '../components/requestPassword'
@@ -11,19 +10,13 @@ import { AdminUsersTable } from './AdminUsersTable'
export function AdminUsersPanel() {
const [users, setUsers] = useState([])
- const [licenseStatus, setLicenseStatus] = useState(null)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [editingID, setEditingID] = useState(null)
const [editingUsername, setEditingUsername] = useState('')
const [resettingPasswordID, setResettingPasswordID] = useState(null)
const refresh = async () => {
- const [nextUsers, nextLicense] = await Promise.all([
- adminAPI.listUsers(),
- licenseAPI.status().catch(() => null),
- ])
- setUsers(nextUsers)
- setLicenseStatus(nextLicense)
+ setUsers(await adminAPI.listUsers())
}
useEffect(() => {
refresh().catch(() => undefined)
@@ -31,12 +24,9 @@ export function AdminUsersPanel() {
return () => window.clearInterval(timer)
}, [])
- const unlimitedUsers =
- licenseStatus?.active === true &&
- (licenseStatus.unlimited_users === true || licenseStatus.max_users == null)
- const maxUsers = unlimitedUsers ? null : (licenseStatus?.max_users ?? 20)
- const userLimitReached = maxUsers != null && users.length >= maxUsers
- const userLimitLabel = unlimitedUsers ? '不限制' : String(maxUsers)
+ const maxUsers = 20
+ const userLimitReached = users.length >= maxUsers
+ const userLimitLabel = String(maxUsers)
const handleCreate = async (e: FormEvent) => {
e.preventDefault()
diff --git a/web/src/pages/AssistantChatPage.tsx b/web/src/pages/AssistantChatPage.tsx
deleted file mode 100644
index 8dbfdc1..0000000
--- a/web/src/pages/AssistantChatPage.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-import { FormEvent, useEffect, useRef, useState } from 'react'
-import toast from 'react-hot-toast'
-
-import {
- assistantAPI,
- type AssistantMessage,
- type AssistantSession,
- type SessionView,
-} from '../api/assistant'
-import { confirmAction } from '../components/confirmAction'
-import { AssistantConversationPane, AssistantSessionsSidebar } from './AssistantChatSections'
-
-// AssistantChatPage is the multi-turn chat surface backed by the Go
-// AssistantService. It complements the older AIAssistantPage which is
-// limited to single-turn smart search + recommendations.
-export function AssistantChatPage() {
- const [sessions, setSessions] = useState([])
- const [active, setActive] = useState(null)
- const [draft, setDraft] = useState('')
- const [sending, setSending] = useState(false)
- const [loading, setLoading] = useState(true)
- const messagesEndRef = useRef(null)
-
- const refreshSessions = async () => {
- try {
- const list = await assistantAPI.listSessions()
- setSessions(list)
- // Auto-select the most recent if nothing is open.
- if (list.length > 0 && !active) {
- const view = await assistantAPI.getSession(list[0].id)
- setActive(view)
- }
- } finally {
- setLoading(false)
- }
- }
-
- useEffect(() => {
- refreshSessions().catch(() => undefined)
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [])
-
- useEffect(() => {
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
- }, [active?.messages.length])
-
- const onNew = async () => {
- try {
- const sess = await assistantAPI.createSession('New chat')
- const view = await assistantAPI.getSession(sess.id)
- setActive(view)
- await refreshSessions()
- } catch {
- toast.error('创建会话失败')
- }
- }
-
- const onSelect = async (id: string) => {
- try {
- setActive(await assistantAPI.getSession(id))
- } catch {
- toast.error('加载会话失败')
- }
- }
-
- const onDelete = async (id: string) => {
- if (!(await confirmAction({ title: '删除会话', message: '删除此会话?', confirmText: '删除' }))) return
- try {
- await assistantAPI.deleteSession(id)
- if (active?.session.id === id) setActive(null)
- await refreshSessions()
- } catch {
- toast.error('删除失败')
- }
- }
-
- const onSend = async (e: FormEvent) => {
- e.preventDefault()
- if (!draft.trim() || !active) return
- setSending(true)
- const text = draft.trim()
- setDraft('')
- // Optimistic append so the user's turn shows immediately.
- setActive((s) =>
- s
- ? {
- ...s,
- messages: [
- ...s.messages,
- {
- id: 'pending-' + Date.now(),
- session_id: s.session.id,
- role: 'user',
- content: text,
- created_at: new Date().toISOString(),
- } as AssistantMessage,
- ],
- }
- : s,
- )
- try {
- const view = await assistantAPI.chat(active.session.id, text)
- setActive(view)
- } catch (err: unknown) {
- const msg =
- (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
- '发送失败'
- toast.error(msg)
- } finally {
- setSending(false)
- }
- }
-
- return (
-
- )
-}
diff --git a/web/src/pages/AssistantChatSections.tsx b/web/src/pages/AssistantChatSections.tsx
deleted file mode 100644
index bb88e60..0000000
--- a/web/src/pages/AssistantChatSections.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import type { FormEvent, RefObject } from 'react'
-import { Loader2, MessageSquare, Plus, Send, Trash2 } from 'lucide-react'
-
-import type { AssistantMessage, AssistantSession, SessionView } from '../api/assistant'
-
-export function AssistantSessionsSidebar({
- sessions,
- activeSessionId,
- loading,
- onNew,
- onSelect,
- onDelete,
-}: {
- sessions: AssistantSession[]
- activeSessionId?: string
- loading: boolean
- onNew: () => void
- onSelect: (id: string) => void
- onDelete: (id: string) => void
-}) {
- return (
-
- )
-}
-
-export function AssistantConversationPane({
- active,
- draft,
- sending,
- messagesEndRef,
- onDraftChange,
- onSend,
-}: {
- active: SessionView | null
- draft: string
- sending: boolean
- messagesEndRef: RefObject
- onDraftChange: (value: string) => void
- onSend: (event: FormEvent) => void
-}) {
- return (
-
- {!active && (
-
- )}
- {active && (
- <>
-
-
- {active.session.title || '未命名'}
-
-
-
- {active.messages.length === 0 && (
-
说点什么开始对话…
- )}
- {active.messages.map((message) => (
-
- ))}
-
-
-
- >
- )}
-
- )
-}
-
-function Bubble({ message }: { message: AssistantMessage }) {
- const mine = message.role === 'user'
- return (
-
-
- {message.content}
-
-
- )
-}
diff --git a/web/src/pages/CloudBrowser.tsx b/web/src/pages/CloudBrowser.tsx
deleted file mode 100644
index fdad1e8..0000000
--- a/web/src/pages/CloudBrowser.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import type { StorageType } from '../api/storage_config'
-import { CloudBrowserToolbar } from './CloudBrowserToolbar'
-import { CloudEntryList } from './CloudEntryList'
-import { CloudMountList } from './CloudMountList'
-import { CloudScanPanel } from './CloudScanPanel'
-import { useCloudBrowser } from './useCloudBrowser'
-
-// Lists cloud directories and imports a file as a 302-backed media.
-export function CloudBrowser({ type, enabled = true }: { type: StorageType; enabled?: boolean }) {
- const browser = useCloudBrowser(type, enabled)
-
- return (
-
-
-
-
-
-
- )
-}
diff --git a/web/src/pages/CloudBrowserToolbar.tsx b/web/src/pages/CloudBrowserToolbar.tsx
deleted file mode 100644
index 3a3c77f..0000000
--- a/web/src/pages/CloudBrowserToolbar.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { ArrowUp, FolderPlus } from 'lucide-react'
-
-interface CloudBrowserToolbarProps {
- stack: { id: string; name: string }[]
- mountMediaType: string
- mounting: boolean
- batchMounting: boolean
- loading: boolean
- hasDirectories: boolean
- onGoTo: (index: number) => void
- onGoUp: () => void
- onCreateFolder: () => void
- onMediaTypeChange: (value: string) => void
- onMountCurrent: () => void
- onMountVisibleDirectories: () => void
-}
-
-export function CloudBrowserToolbar({
- stack,
- mountMediaType,
- mounting,
- batchMounting,
- loading,
- hasDirectories,
- onGoTo,
- onGoUp,
- onCreateFolder,
- onMediaTypeChange,
- onMountCurrent,
- onMountVisibleDirectories,
-}: CloudBrowserToolbarProps) {
- return (
-
-
- 网盘资源:
- {stack.map((item, index) => (
-
-
- {index < stack.length - 1 && /}
-
- ))}
-
-
- 挂载后不会复制网盘文件;后台会递归读取该目录里的子文件夹和媒体文件,扫描到的影片会自动加入对应媒体库。小目录通常几十秒,大目录取决于网盘接口速度。
- 如果已有同名同类型媒体库,会在首页和 Emby/SenPlayer 中自动归并显示。
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/web/src/pages/CloudEntryList.tsx b/web/src/pages/CloudEntryList.tsx
deleted file mode 100644
index 8dd35dc..0000000
--- a/web/src/pages/CloudEntryList.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import { FileVideo, Folder, Loader2, Pencil } from 'lucide-react'
-
-import type { CloudEntry } from '../api/storage_config'
-
-interface CloudEntryListProps {
- loading: boolean
- error: string
- items: CloudEntry[]
- onEnter: (entry: CloudEntry) => void
- onImport: (entry: CloudEntry) => void
- onRename: (entry: CloudEntry) => void
-}
-
-export function CloudEntryList({ loading, error, items, onEnter, onImport, onRename }: CloudEntryListProps) {
- if (loading) {
- return (
-
-
-
- )
- }
-
- if (error) return {error}
- if (items.length === 0) return 该目录为空
-
- return (
-
- {items.map((entry) => (
- -
- {entry.is_dir ? : }
- {entry.is_dir ? (
- <>
-
-
- >
- ) : (
- <>
- {entry.name}
-
- >
- )}
-
- ))}
-
- )
-}
diff --git a/web/src/pages/CloudMountList.tsx b/web/src/pages/CloudMountList.tsx
deleted file mode 100644
index c99ee2d..0000000
--- a/web/src/pages/CloudMountList.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { Trash2 } from 'lucide-react'
-
-import type { Library } from '../types'
-import { cloudLibraryLabel } from './storageConfigModel'
-
-interface CloudMountListProps {
- mounts: Library[]
- onRemove: (library: Library) => void
-}
-
-export function CloudMountList({ mounts, onRemove }: CloudMountListProps) {
- if (mounts.length === 0) return null
-
- return (
-
-
已挂载目录
-
- {mounts.map((lib) => (
-
-
- {lib.name} · {cloudLibraryLabel(lib.path)}
-
-
-
- ))}
-
-
- )
-}
diff --git a/web/src/pages/CloudScanPanel.tsx b/web/src/pages/CloudScanPanel.tsx
deleted file mode 100644
index 1258386..0000000
--- a/web/src/pages/CloudScanPanel.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { Loader2, PauseCircle, RefreshCw } from 'lucide-react'
-
-import type { CloudScanStatus } from '../api/storage_config'
-
-interface CloudScanPanelProps {
- scanBusy: boolean
- cancelBusy: boolean
- scanStatuses: CloudScanStatus[]
- onScanAll: () => void
- onCancelScans: () => void
-}
-
-export function CloudScanPanel({
- scanBusy,
- cancelBusy,
- scanStatuses,
- onScanAll,
- onCancelScans,
-}: CloudScanPanelProps) {
- return (
-
-
-
-
网盘媒体库扫描
-
- 只需在系统设置填写公开域名,扫描会自动为网盘媒体生成 STRM/302 播放入口;中断后再次扫描会去重补齐。
-
-
-
-
-
-
-
- {scanStatuses.length > 0 && (
-
- {scanStatuses.slice(0, 6).map((item) => (
-
- {item.state}
- {' · '}
- {item.provider}
- {' · 目录 '}
- {item.dirs}
- {' · 发现 '}
- {item.discovered}
- {' · 入库 '}
- {item.added + item.updated}
- {item.error ? · {item.error} : null}
-
- ))}
-
- )}
-
- )
-}
diff --git a/web/src/pages/DiscoverContentRow.tsx b/web/src/pages/DiscoverContentRow.tsx
deleted file mode 100644
index b15f2d3..0000000
--- a/web/src/pages/DiscoverContentRow.tsx
+++ /dev/null
@@ -1,202 +0,0 @@
-import { useEffect, useMemo, useState } from 'react'
-import { ChevronLeft, ChevronRight, Info } from 'lucide-react'
-
-import type { DiscoverItem } from '../api/discover'
-import { imageURL } from '../api/client'
-import { discoverItemSource } from './discoverPageModel'
-
-export function ContentRow({
- title,
- items,
- page = 1,
- canNext = false,
- imageVersion,
- refreshImageVersion,
- onPageChange,
- onSelect,
-}: {
- title: string
- items: DiscoverItem[]
- page?: number
- canNext?: boolean
- imageVersion?: string
- refreshImageVersion?: string
- onPageChange?: (delta: number) => void
- onSelect: (item: DiscoverItem) => void
-}) {
- return (
-
-
-
{title}
- {onPageChange && (
-
-
- 第 {page} 页
-
-
- )}
-
-
- {items.map((item, index) => (
-
- ))}
-
-
- )
-}
-
-export function DiscoverSkeleton() {
- return (
-
- {[1, 2, 3].map((section) => (
-
-
-
- {[1, 2, 3, 4, 5, 6, 7, 8].map((item) => (
-
- ))}
-
-
- ))}
-
- )
-}
-
-function DiscoverCard({
- item,
- imageVersion,
- refreshImageVersion,
- onSelect,
-}: {
- item: DiscoverItem
- imageVersion?: string
- refreshImageVersion?: string
- onSelect: (item: DiscoverItem) => void
-}) {
- const source = discoverItemSource(item)
- const imageCandidates = useMemo(
- () =>
- [item.poster_url, item.backdrop_url]
- .map((value) => value?.trim())
- .filter((value, index, values): value is string => Boolean(value) && values.indexOf(value) === index),
- [item.poster_url, item.backdrop_url],
- )
- const [imageIndex, setImageIndex] = useState(0)
- const [posterRetry, setPosterRetry] = useState(0)
- const [posterUnavailable, setPosterUnavailable] = useState(false)
- const posterVersion = [imageVersion, posterRetry > 0 ? `r${posterRetry}` : ''].filter(Boolean).join('-')
- const activeImage = imageCandidates[imageIndex] ?? ''
- const shouldRefreshCache = Boolean(
- (imageVersion && refreshImageVersion === imageVersion) || posterRetry > 0,
- )
- const posterSrc = useMemo(
- () =>
- imageURL(activeImage, posterVersion, {
- refreshCache: shouldRefreshCache,
- retryFailed: true,
- }),
- [activeImage, posterVersion, shouldRefreshCache],
- )
-
- useEffect(() => {
- setImageIndex(0)
- setPosterRetry(0)
- setPosterUnavailable(false)
- }, [item.poster_url, item.backdrop_url, imageVersion])
-
- useEffect(() => {
- if (!posterUnavailable) return
- if (imageIndex + 1 < imageCandidates.length) {
- const timer = window.setTimeout(() => {
- setImageIndex((current) => Math.min(current + 1, imageCandidates.length - 1))
- setPosterRetry(0)
- setPosterUnavailable(false)
- }, 150)
- return () => window.clearTimeout(timer)
- }
- if (posterRetry >= 3) return
- const timer = window.setTimeout(() => {
- setPosterRetry((current) => current + 1)
- setPosterUnavailable(false)
- }, 1200 * (posterRetry + 1))
- return () => window.clearTimeout(timer)
- }, [imageCandidates.length, imageIndex, posterRetry, posterUnavailable])
-
- const markPosterUnavailable = () => setPosterUnavailable(true)
-
- if (!posterSrc || posterUnavailable) return null
-
- return (
-
- )
-}
-
-function discoverKey(item: DiscoverItem, index: number): string {
- return `${item.source || 'source'}:${item.tmdb_id || item.douban_id || item.bangumi_id || item.title}:${index}`
-}
diff --git a/web/src/pages/DiscoverDetailModal.tsx b/web/src/pages/DiscoverDetailModal.tsx
deleted file mode 100644
index fc29f62..0000000
--- a/web/src/pages/DiscoverDetailModal.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import { useState } from 'react'
-import toast from 'react-hot-toast'
-
-import type { DiscoverItem } from '../api/discover'
-import { subscriptionsAPI } from '../api/subscriptions'
-import { discoverItemSource } from './discoverPageModel'
-import {
- DiscoverArtworkPanel,
- DiscoverModalHeader,
- DiscoverOverviewPanel,
- DiscoverSubscriptionRules,
-} from './DiscoverDetailModalSections'
-import {
- apiErrorMessage,
- buildDiscoverSubscriptionInput,
- initialDiscoverSubscriptionForm,
-} from './discoverDetailModalModel'
-
-export function DiscoverDetailModal({ item, onClose }: { item: DiscoverItem; onClose: () => void }) {
- const source = discoverItemSource(item)
- const [form, setForm] = useState(() => initialDiscoverSubscriptionForm(item))
- const [busy, setBusy] = useState(false)
-
- const submit = async () => {
- setBusy(true)
- try {
- const sub = await subscriptionsAPI.create(buildDiscoverSubscriptionInput(item, form, source))
- if (form.run_now) {
- const run = await subscriptionsAPI.runNow(sub.id)
- toast.success(run.queued > 0 ? `已订阅并加入 ${run.queued} 个下载` : '已订阅,暂未命中可下载资源')
- } else {
- toast.success('已创建订阅')
- }
- onClose()
- } catch (err) {
- toast.error(apiErrorMessage(err, '订阅失败'))
- } finally {
- setBusy(false)
- }
- }
-
- return (
-
-
-
-
-
-
-
- setForm((current) => ({ ...current, ...patch }))}
- onSubmit={submit}
- />
-
-
-
-
- )
-}
diff --git a/web/src/pages/DiscoverDetailModalSections.tsx b/web/src/pages/DiscoverDetailModalSections.tsx
deleted file mode 100644
index 6221828..0000000
--- a/web/src/pages/DiscoverDetailModalSections.tsx
+++ /dev/null
@@ -1,172 +0,0 @@
-import { Download, Rss, X } from 'lucide-react'
-
-import type { DiscoverItem } from '../api/discover'
-import { imageURL } from '../api/client'
-import { discoverItemMetaText, type DiscoverSubscriptionForm } from './discoverDetailModalModel'
-
-type FormPatch = Partial
-
-type FieldOption = {
- label: string
- value: string
-}
-
-const searchModeOptions: FieldOption[] = [
- { value: 'keyword', label: '标题关键词' },
- { value: 'imdb', label: 'IMDB ID' },
-]
-
-const mediaTypeOptions: FieldOption[] = [
- { value: '', label: '自动识别' },
- { value: 'movie', label: '电影' },
- { value: 'tv', label: '电视剧' },
- { value: 'anime', label: '动漫' },
- { value: 'variety', label: '综艺' },
-]
-
-const resolutionOptions: FieldOption[] = [
- { value: 'best', label: '自动择优' },
- { value: '2160p', label: '2160p / 4K' },
- { value: '1080p', label: '1080p' },
- { value: '720p', label: '720p' },
-]
-
-const qualityOptions: FieldOption[] = [
- { value: '', label: '不限' },
- { value: 'remux', label: 'REMUX' },
- { value: 'bluray', label: 'BluRay' },
- { value: 'web-dl', label: 'WEB-DL' },
- { value: 'hdtv', label: 'HDTV' },
-]
-
-const washPriorityOptions: FieldOption[] = [
- { value: 'balanced', label: '均衡' },
- { value: 'resolution', label: '分辨率优先' },
- { value: 'quality', label: '片源质量优先' },
- { value: 'effects', label: 'HDR/DV/Atmos 优先' },
- { value: 'seeders', label: '做种数优先' },
-]
-
-export function DiscoverModalHeader({ item, source, onClose }: { item: DiscoverItem; source: string; onClose: () => void }) {
- return (
-
-
-
{source}
-
{item.title}
-
{discoverItemMetaText(item)}
-
-
-
- )
-}
-
-export function DiscoverArtworkPanel({ item }: { item: DiscoverItem }) {
- return (
-
-
- {item.poster_url ? (
-
})
- ) : (
-
无海报
- )}
-
- {item.backdrop_url && (
-
})
- )}
-
- )
-}
-
-export function DiscoverOverviewPanel({ overview }: { overview?: string }) {
- return (
-
- 简介
- {overview || '当前数据源没有返回简介。'}
-
- )
-}
-
-export function DiscoverSubscriptionRules({
- form,
- busy,
- onChange,
- onSubmit,
-}: {
- form: DiscoverSubscriptionForm
- busy: boolean
- onChange: (patch: FormPatch) => void
- onSubmit: () => void
-}) {
- return (
-
-
-
- 订阅下载规则
-
-
- onChange({ keyword })} className="md:col-span-2" />
- onChange({ search_mode })} />
- onChange({ imdb_id })} />
- onChange({ media_type })} />
- onChange({ resolution })} />
- onChange({ quality })} />
- onChange({ effects })} />
- onChange({ wash_priority })} />
- onChange({ wash_enabled })} />
- onChange({ release_groups })} />
- onChange({ exclude_words })} />
- onChange({ media_category })} />
- onChange({ save_path })} />
-
- onChange({ run_now })} onSubmit={onSubmit} />
-
- )
-}
-
-function TextField({ label, value, placeholder, className = '', onChange }: { label: string; value: string; placeholder?: string; className?: string; onChange: (value: string) => void }) {
- return (
-
- )
-}
-
-function SelectField({ label, value, options, disabled, onChange }: { label: string; value: string; options: FieldOption[]; disabled?: boolean; onChange: (value: string) => void }) {
- return (
-
- )
-}
-
-function CheckboxField({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) {
- return (
-
- )
-}
-
-function DiscoverSubmitBar({ busy, runNow, onRunNowChange, onSubmit }: { busy: boolean; runNow: boolean; onRunNowChange: (value: boolean) => void; onSubmit: () => void }) {
- return (
-
-
-
-
- )
-}
diff --git a/web/src/pages/DiscoverPage.tsx b/web/src/pages/DiscoverPage.tsx
deleted file mode 100644
index 60d9821..0000000
--- a/web/src/pages/DiscoverPage.tsx
+++ /dev/null
@@ -1,239 +0,0 @@
-import { useEffect, useMemo, useState } from 'react'
-
-import { discoverAPI, type DiscoverItem, type DiscoverSection } from '../api/discover'
-import { DiscoverSkeleton } from './DiscoverContentRow'
-import { DiscoverDetailModal } from './DiscoverDetailModal'
-import { DiscoverEmptySelection, DiscoverHeader, DiscoverResults } from './DiscoverPageSections'
-import {
- defaultSections,
- discoverStorageKey,
- readCachedDiscoverRows,
- readSavedSections,
- serializeSavedSections,
- writeCachedDiscoverRow,
-} from './discoverPageModel'
-
-export function DiscoverPage() {
- const [sections, setSections] = useState([])
- const [selected, setSelected] = useState([])
- const [rows, setRows] = useState>({})
- const [rowPages, setRowPages] = useState>({})
- const [rowCanNext, setRowCanNext] = useState>({})
- const [rowLoading, setRowLoading] = useState>({})
- const [rowErrors, setRowErrors] = useState>({})
- const [sectionsReady, setSectionsReady] = useState(false)
- const [loading, setLoading] = useState(false)
- const [activeItem, setActiveItem] = useState(null)
- const [reloadSeq, setReloadSeq] = useState(0)
- const [imageVersion, setImageVersion] = useState(() => String(Date.now()))
- const [refreshImageVersion, setRefreshImageVersion] = useState()
-
- useEffect(() => {
- let cancelled = false
- setSectionsReady(false)
- discoverAPI
- .sections()
- .then((items) => {
- if (cancelled) return
- setSections(items)
- const saved = readSavedSections(items)
- const available = new Set(items.map((item) => item.key))
- const fallback = defaultSections.filter((key) => available.has(key))
- const nextSelected = saved.length > 0 ? saved : fallback
- const cached = readCachedDiscoverRows(nextSelected)
- setSelected(nextSelected)
- setRowPages(Object.fromEntries(nextSelected.map((key) => [key, 1])))
- setRows(cached.rows)
- setRowCanNext(cached.rowCanNext)
- setSectionsReady(true)
- })
- .catch(() => {
- if (cancelled) return
- setSections([])
- setSelected([])
- setSectionsReady(true)
- })
- return () => {
- cancelled = true
- }
- }, [])
-
- useEffect(() => {
- if (!sectionsReady) return
- const available = new Set(sections.map((section) => section.key))
- const activeSelected = selected.filter((key) => available.has(key))
- if (activeSelected.length !== selected.length) {
- setSelected(activeSelected)
- return
- }
- if (selected.length === 0) {
- setRows({})
- setRowLoading({})
- setRowCanNext({})
- setRowErrors({})
- setLoading(false)
- return
- }
- let cancelled = false
- setLoading(true)
- setRowErrors({})
- setRowLoading(Object.fromEntries(selected.map((key) => [key, true])))
- setRows((current) => {
- const next: Record = {}
- for (const key of selected) {
- next[key] = current[key] ?? []
- }
- return next
- })
- window.localStorage.setItem(discoverStorageKey, serializeSavedSections(selected))
-
- let pending = selected.length
- const markDone = () => {
- pending -= 1
- if (!cancelled && pending <= 0) setLoading(false)
- }
- for (const key of selected) {
- const page = rowPages[key] ?? 1
- discoverAPI
- .feed([key], page)
- .then((feed) => {
- if (cancelled) return
- const error = feed.meta[key]?.error
- const nextItems = feed.items[key] ?? []
- const nextCanNext = Boolean(feed.meta[key]?.has_next)
- setRows((current) => {
- if (error && nextItems.length === 0 && (current[key]?.length ?? 0) > 0) {
- return current
- }
- return { ...current, [key]: nextItems }
- })
- setRowCanNext((current) => {
- if (error && nextItems.length === 0 && key in current) {
- return current
- }
- return { ...current, [key]: nextCanNext }
- })
- if (!error) {
- writeCachedDiscoverRow(key, page, nextItems, nextCanNext)
- }
- setRowErrors((current) => updateDiscoverRowError(current, key, error))
- })
- .catch((err) => {
- if (cancelled) return
- const message = discoverRequestErrorMessage(err)
- setRows((current) => ((current[key]?.length ?? 0) > 0 ? current : { ...current, [key]: [] }))
- setRowCanNext((current) => (key in current ? current : { ...current, [key]: false }))
- setRowErrors((current) => ({ ...current, [key]: message }))
- })
- .finally(() => {
- if (!cancelled) {
- setRowLoading((current) => ({ ...current, [key]: false }))
- }
- markDone()
- })
- }
- return () => {
- cancelled = true
- }
- }, [sections, sectionsReady, selected, rowPages, reloadSeq])
-
- const sectionMap = useMemo(
- () => new Map(sections.map((section) => [section.key, section])),
- [sections],
- )
- const hasContent = selected.some((key) => (rows[key] ?? []).length > 0)
- const sectionLabel = (key: string) => sectionMap.get(key)?.label ?? key
-
- const toggleSection = (key: string) => {
- setSelected((current) => {
- if (current.includes(key)) {
- return current.filter((item) => item !== key)
- }
- return [...current, key]
- })
- setRowPages((current) => ({ ...current, [key]: current[key] ?? 1 }))
- }
-
- const changeDiscoverPage = (key: string, delta: number) => {
- setRowPages((current) => {
- const nextPage = Math.max(1, (current[key] ?? 1) + delta)
- if (nextPage === (current[key] ?? 1)) return current
- return { ...current, [key]: nextPage }
- })
- }
-
- const refreshDiscover = () => {
- const nextImageVersion = String(Date.now())
- setImageVersion(nextImageVersion)
- setRefreshImageVersion(nextImageVersion)
- setReloadSeq((current) => current + 1)
- }
-
- return (
-
-
-
- {!sectionsReady && }
-
- {sectionsReady && !loading && selected.length === 0 && (
-
- )}
-
- {sectionsReady && selected.length > 0 && (
-
- )}
-
- {activeItem && (
- setActiveItem(null)}
- />
- )}
-
- )
-}
-
-function updateDiscoverRowError(
- current: Record,
- key: string,
- error?: string,
-): Record {
- if (error) return { ...current, [key]: error }
- if (!(key in current)) return current
- const next = { ...current }
- delete next[key]
- return next
-}
-
-function discoverRequestErrorMessage(err: unknown): string {
- const raw = err instanceof Error ? err.message : String(err)
- const lower = raw.toLowerCase()
- if (lower.includes('timeout') || lower.includes('deadline')) {
- return '推荐源请求超时,已跳过本次加载'
- }
- if (lower.includes('network')) {
- return '推荐源网络不可用,已跳过本次加载'
- }
- return '推荐源暂时不可用,已跳过本次加载'
-}
diff --git a/web/src/pages/DiscoverPageSections.tsx b/web/src/pages/DiscoverPageSections.tsx
deleted file mode 100644
index 6ffafc4..0000000
--- a/web/src/pages/DiscoverPageSections.tsx
+++ /dev/null
@@ -1,188 +0,0 @@
-import { AlertTriangle, RefreshCw, Sparkles } from 'lucide-react'
-
-import type { DiscoverItem, DiscoverSection } from '../api/discover'
-import { ContentRow } from './DiscoverContentRow'
-
-type SectionLabel = (key: string) => string
-
-export function DiscoverHeader({
- sections,
- selected,
- sectionsReady,
- loading,
- onRefresh,
- onToggleSection,
-}: {
- sections: DiscoverSection[]
- selected: string[]
- sectionsReady: boolean
- loading: boolean
- onRefresh: () => void
- onToggleSection: (key: string) => void
-}) {
- return (
-
- )
-}
-
-export function DiscoverEmptySelection() {
- return (
-
- 至少选择一个推荐源,小宇宙才会开始转动。
-
- )
-}
-
-export function DiscoverResults({
- selected,
- rows,
- rowLoading,
- rowErrors,
- rowPages,
- rowCanNext,
- loading,
- hasContent,
- imageVersion,
- refreshImageVersion,
- sectionLabel,
- onPageChange,
- onSelect,
-}: {
- selected: string[]
- rows: Record
- rowLoading: Record
- rowErrors: Record
- rowPages: Record
- rowCanNext: Record
- loading: boolean
- hasContent: boolean
- imageVersion: string
- refreshImageVersion?: string
- sectionLabel: SectionLabel
- onPageChange: (key: string, delta: number) => void
- onSelect: (item: DiscoverItem) => void
-}) {
- const hasRowErrors = Object.keys(rowErrors).length > 0
-
- return (
-
- {selected.map((key) => {
- const items = rows[key] ?? []
- if (items.length === 0) {
- if (rowLoading[key]) {
- return
- }
- return null
- }
- return (
- onPageChange(key, delta)}
- onSelect={onSelect}
- />
- )
- })}
-
- {hasRowErrors && (
-
- )}
-
- {!loading && !hasContent && !hasRowErrors && }
-
- )
-}
-
-function DiscoverRowErrors({
- rowErrors,
- sectionLabel,
-}: {
- rowErrors: Record
- sectionLabel: SectionLabel
-}) {
- return (
-
-
-
-
部分推荐源暂不可用,其他已加载内容不受影响。
- {Object.entries(rowErrors).map(([key, message]) => (
-
{sectionLabel(key)}:{message}
- ))}
-
-
- )
-}
-
-function DiscoverNoContent() {
- return (
-
-
- 当前选择的推荐源暂未返回内容,可切换豆瓣 / Bangumi 或检查网络代理。
-
-
- )
-}
-
-function DiscoverRowSkeleton({ title }: { title: string }) {
- return (
-
- {title}
-
- {[1, 2, 3, 4, 5, 6, 7, 8].map((item) => (
-
- ))}
-
-
- )
-}
diff --git a/web/src/pages/DownloadClientCard.tsx b/web/src/pages/DownloadClientCard.tsx
deleted file mode 100644
index 86afd13..0000000
--- a/web/src/pages/DownloadClientCard.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { Loader2, Pencil, Send, Trash2 } from 'lucide-react'
-
-import type { DownloadClient } from '../api/download_clients'
-
-export function DownloadClientCard({
- client,
- testing,
- onDelete,
- onEdit,
- onTest,
-}: {
- client: DownloadClient
- testing: boolean
- onDelete: (client: DownloadClient) => void
- onEdit: (client: DownloadClient) => void
- onTest: (id: string) => void
-}) {
- return (
-
-
-
- {client.name}
-
- {client.type}
-
- {client.is_default && (
-
- 默认
-
- )}
- {!client.enabled && (
-
- 已禁用
-
- )}
-
-
- {client.host}
- {client.username && ` · ${client.username}`}
-
-
-
-
-
-
-
-
- )
-}
diff --git a/web/src/pages/DownloadClientFormModal.tsx b/web/src/pages/DownloadClientFormModal.tsx
deleted file mode 100644
index 55b6cd9..0000000
--- a/web/src/pages/DownloadClientFormModal.tsx
+++ /dev/null
@@ -1,170 +0,0 @@
-import { type FormEvent, type ReactNode, useState } from 'react'
-import { Loader2 } from 'lucide-react'
-import toast from 'react-hot-toast'
-
-import {
- downloadClientsAPI,
- type DownloadClient,
- type DownloadClientInput,
- type DownloadClientType,
-} from '../api/download_clients'
-import { apiErrorMessage } from './downloadClientPageModel'
-
-export function ClientFormModal({
- editing,
- onClose,
- onSaved,
-}: {
- editing: DownloadClient | null
- onClose: () => void
- onSaved: () => void | Promise
-}) {
- const [form, setForm] = useState(() => ({
- name: editing?.name ?? '',
- type: editing?.type ?? 'qbittorrent',
- host: editing?.host ?? '',
- username: editing?.username ?? '',
- password: '',
- is_default: editing?.is_default ?? false,
- enabled: editing?.enabled ?? true,
- }))
- const [saving, setSaving] = useState(false)
-
- const onSubmit = async (e: FormEvent) => {
- e.preventDefault()
- if (saving) return
- setSaving(true)
- try {
- if (editing) await downloadClientsAPI.update(editing.id, form)
- else await downloadClientsAPI.create(form)
- toast.success('已保存')
- setSaving(false)
- onSaved()
- } catch (err: unknown) {
- const msg = apiErrorMessage(err, '保存失败')
- toast.error(msg)
- setSaving(false)
- }
- }
-
- const update = (k: K, v: DownloadClientInput[K]) =>
- setForm((f) => ({ ...f, [k]: v }))
-
- const placeholder = (
- {
- qbittorrent: 'http://127.0.0.1:8080',
- aria2: 'http://127.0.0.1:6800/jsonrpc',
- transmission: 'http://127.0.0.1:9091/transmission/rpc',
- } as Record
- )[form.type]
-
- return (
-
-
-
- {editing ? '编辑下载器' : '添加下载器'}
-
-
-
-
- )
-}
-
-function Field({ label, children }: { label: string; children: ReactNode }) {
- return (
-
- )
-}
diff --git a/web/src/pages/DownloadClientsPage.tsx b/web/src/pages/DownloadClientsPage.tsx
deleted file mode 100644
index 7f2dde6..0000000
--- a/web/src/pages/DownloadClientsPage.tsx
+++ /dev/null
@@ -1,129 +0,0 @@
-import { useEffect, useState } from 'react'
-import { Loader2, Plus, Server } from 'lucide-react'
-import toast from 'react-hot-toast'
-
-import {
- downloadClientsAPI,
- type DownloadClient,
-} from '../api/download_clients'
-import { confirmAction } from '../components/confirmAction'
-import { DownloadClientCard } from './DownloadClientCard'
-import { ClientFormModal } from './DownloadClientFormModal'
-import { apiErrorMessage } from './downloadClientPageModel'
-
-// DownloadClientsPage manages multiple downloader integrations.
-// Replaces the Vue UI's DownloadView "clients" tab with a typed CRUD
-// surface and a per-client Test button.
-export function DownloadClientsPage() {
- const [clients, setClients] = useState([])
- const [loading, setLoading] = useState(true)
- const [editing, setEditing] = useState(null)
- const [showForm, setShowForm] = useState(false)
- const [testing, setTesting] = useState>({})
-
- const refresh = async () => {
- setLoading(true)
- try {
- setClients(await downloadClientsAPI.list())
- } finally {
- setLoading(false)
- }
- }
-
- useEffect(() => {
- refresh().catch(() => undefined)
- }, [])
-
- const onTest = async (id: string) => {
- if (testing[id]) return
- setTesting((current) => ({ ...current, [id]: true }))
- try {
- const r = await downloadClientsAPI.test(id)
- if (r.ok) toast.success('连接成功')
- else toast.error(r.error ?? '连接失败')
- } catch (err: unknown) {
- const msg = apiErrorMessage(err, '测试失败')
- toast.error(msg)
- } finally {
- setTesting((current) => ({ ...current, [id]: false }))
- }
- }
-
- const onDelete = async (c: DownloadClient) => {
- if (!(await confirmAction({ title: '删除下载器', message: `确定删除「${c.name}」?`, confirmText: '删除' }))) return
- try {
- await downloadClientsAPI.remove(c.id)
- toast.success('已删除')
- await refresh()
- } catch (err: unknown) {
- const msg = apiErrorMessage(err, '删除失败')
- toast.error(msg)
- }
- }
-
- return (
-
-
-
-
-
-
-
-
下载器管理
-
- qBittorrent / Aria2 / Transmission · 多客户端 + 连接测试
-
-
-
-
-
-
- {loading && (
-
-
-
- )}
-
- {!loading && clients.length === 0 && (
-
暂无下载器
- )}
-
- {!loading && clients.length > 0 && (
-
- {clients.map((c) => (
- {
- setEditing(client)
- setShowForm(true)
- }}
- onTest={onTest}
- />
- ))}
-
- )}
-
- {showForm && (
-
setShowForm(false)}
- onSaved={async () => {
- setShowForm(false)
- refresh().catch((err: unknown) => toast.error(apiErrorMessage(err, '刷新下载器列表失败')))
- }}
- />
- )}
-
- )
-}
diff --git a/web/src/pages/DownloadTaskCard.tsx b/web/src/pages/DownloadTaskCard.tsx
deleted file mode 100644
index faaaa16..0000000
--- a/web/src/pages/DownloadTaskCard.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import type { ReactNode } from 'react'
-import { ArrowDown, ArrowUp, Film, HardDrive, Rss, Trash2 } from 'lucide-react'
-
-import { imageURL } from '../api/client'
-import type { DownloadCardItem } from './downloadTaskCardModel'
-
-export function DownloadTaskCard({
- item,
- removable,
- onRemove,
-}: {
- item: DownloadCardItem
- removable?: boolean
- onRemove?: () => Promise
-}) {
- const progress = pct(item.progress)
- const visual = item.poster_url || item.backdrop_url
- const downloaded = item.downloaded || (item.size ? Math.round(item.size * (item.progress || 0)) : 0)
-
- return (
-
-
-
- {visual ? (
-

- ) : (
-
-
- {item.title}
-
- )}
-
- {stateLabel(item)}
-
-
-
-
-
-
- {item.title}
-
-
- {item.overview || item.save_path || '已隐藏原始种子 URL,避免泄露私有 Token。'}
-
-
-
-
-
- 进度 {progress.toFixed(1)}%
- {fmtBytes(downloaded)} / {fmtBytes(item.size)}
-
-
-
-
-
- } value={fmtSpeed(item.dlspeed)} />
- } value={fmtSpeed(item.upspeed)} />
- } value={`${item.num_seeds ?? 0} / ${item.num_leechs ?? 0}`} />
- } value={fmtBytes(item.size)} />
-
-
-
-
- {item.save_path || '默认下载目录'}
-
- {item.created_at && {new Date(item.created_at).toLocaleString()}}
-
-
-
-
- {removable && onRemove && (
-
-
-
- )}
-
- )
-}
-
-function DownloadMetric({ icon, value }: { icon: ReactNode; value: string }) {
- return (
-
- {icon}
- {value}
-
- )
-}
-
-function fmtBytes(n?: number): string {
- if (!n || n <= 0) return '0 B'
- const u = ['B', 'KB', 'MB', 'GB', 'TB']
- let v = n
- let i = 0
- while (v >= 1024 && i < u.length - 1) {
- v /= 1024
- i++
- }
- return `${v.toFixed(v >= 100 ? 0 : 1)} ${u[i]}`
-}
-
-function fmtSpeed(n?: number): string {
- return `${fmtBytes(n)}/s`
-}
-
-function pct(progress?: number): number {
- if (!Number.isFinite(progress)) return 0
- return Math.min(100, Math.max(0, Math.round((progress ?? 0) * 1000) / 10))
-}
-
-function stateLabel(item: DownloadCardItem): string {
- const state = (item.state || item.status || 'queued').toLowerCase()
- if (state.includes('down') || state.includes('meta')) return '下载中'
- if (state.includes('up') || state.includes('seed')) return '做种中'
- if (state.includes('pause')) return '已暂停'
- if (state.includes('error')) return '出错'
- if (state.includes('complete') || pct(item.progress) >= 100) return '已完成'
- if (state.includes('queue')) return '排队中'
- return item.state || item.status || '等待中'
-}
-
-function statusTone(item: DownloadCardItem): string {
- const state = stateLabel(item)
- if (state === '已完成' || state === '做种中') return 'bg-emerald-50 text-emerald-600'
- if (state === '出错') return 'bg-red-50 text-red-500'
- if (state === '已暂停') return 'bg-amber-50 text-amber-600'
- return 'bg-primary-400/10 text-brand-500'
-}
diff --git a/web/src/pages/DownloadsPage.tsx b/web/src/pages/DownloadsPage.tsx
deleted file mode 100644
index 961b3aa..0000000
--- a/web/src/pages/DownloadsPage.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-import { FormEvent, useEffect, useState } from 'react'
-import { Link } from 'react-router-dom'
-import toast from 'react-hot-toast'
-import { Download, ShieldCheck } from 'lucide-react'
-
-import { downloadsAPI } from '../api/downloads'
-import { useAuthStore } from '../stores/auth'
-import { confirmAction } from '../components/confirmAction'
-import type { DownloadTask, QBitTorrent } from '../types'
-import { DownloadTaskCard } from './DownloadTaskCard'
-import { toLiveCard, toTaskCard } from './downloadTaskCardModel'
-
-export function DownloadsPage() {
- const role = useAuthStore((s) => s.user?.role)
- const [tasks, setTasks] = useState([])
- const [torrents, setTorrents] = useState(null)
- const [url, setURL] = useState('')
- const [savePath, setSavePath] = useState('')
-
- const refresh = () =>
- downloadsAPI.list().then((d) => {
- setTasks(d.tasks)
- setTorrents(d.torrents)
- })
-
- useEffect(() => {
- void refresh().catch(() => undefined)
- const id = window.setInterval(() => void refresh().catch(() => undefined), 5_000)
- return () => window.clearInterval(id)
- }, [])
-
- const onAdd = async (e: FormEvent) => {
- e.preventDefault()
- try {
- await downloadsAPI.add(url, savePath)
- toast.success('已加入下载队列')
- setURL('')
- setSavePath('')
- await refresh()
- } catch (err: unknown) {
- const msg =
- (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
- '提交失败'
- toast.error(msg)
- }
- }
-
- return (
-
-
-
-
-
-
-
-
当前下载
- 每 5 秒自动刷新
-
- {torrents === null && (
-
- 尚未连接到下载器 —{' '}
- {role === 'admin' ? (
- <>
- 请到{' '}
-
- 下载器
- {' '}
- 页面添加并测试连接。
- >
- ) : (
- '请联系管理员添加并测试下载器连接。'
- )}
-
- )}
- {torrents && torrents.length === 0 && (
- 暂无运行中任务。
- )}
- {torrents && torrents.length > 0 && (
-
- {torrents.map((torrent) => (
- {
- if (!(await confirmAction({ title: '删除下载任务', message: `删除「${torrent.title || torrent.name}」?`, confirmText: '删除' }))) return
- await downloadsAPI.remove(torrent.hash, torrent.client_id, false)
- toast.success('已删除任务')
- await refresh()
- }}
- />
- ))}
-
- )}
-
-
-
- 下载历史
- {tasks.length === 0 ? (
- 暂无历史下载。
- ) : (
-
- {tasks.map((task) => (
-
- ))}
-
- )}
-
-
- )
-}
diff --git a/web/src/pages/DuplicatesPage.tsx b/web/src/pages/DuplicatesPage.tsx
deleted file mode 100644
index f39e385..0000000
--- a/web/src/pages/DuplicatesPage.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-import { useEffect, useState } from 'react'
-import toast from 'react-hot-toast'
-import { Copy, Trash2 } from 'lucide-react'
-
-import { duplicatesAPI, type DuplicateReport } from '../api/duplicates'
-import { libraryAPI } from '../api/library'
-import { confirmAction } from '../components/confirmAction'
-import type { Library } from '../types'
-
-function fmtBytes(n: number): string {
- if (!n) return '0 B'
- const u = ['B', 'KB', 'MB', 'GB', 'TB']
- let v = n
- let i = 0
- while (v >= 1024 && i < u.length - 1) {
- v /= 1024
- i++
- }
- return `${v.toFixed(1)} ${u[i]}`
-}
-
-export function DuplicatesPage() {
- const [libs, setLibs] = useState([])
- const [libID, setLibID] = useState('')
- const [report, setReport] = useState(null)
- const [scanning, setScanning] = useState(false)
-
- useEffect(() => {
- libraryAPI.list().then(setLibs)
- }, [])
-
- useEffect(() => {
- duplicatesAPI.list(libID).then(setReport).catch(() => setReport(null))
- }, [libID])
-
- const scan = async () => {
- setScanning(true)
- try {
- const r = await duplicatesAPI.scan(libID)
- setReport(r)
- const cleaned = r.missing_removed ? `, 清理 ${r.missing_removed} 条失效记录` : ''
- toast.success(`扫描完成: ${r.groups_found} 组重复, ${r.items_marked} 项标记${cleaned}`)
- } catch (err: unknown) {
- const msg =
- (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
- '扫描失败'
- toast.error(msg)
- } finally {
- setScanning(false)
- }
- }
-
- const unmark = async () => {
- if (!(await confirmAction({ title: '清除重复标记', message: '清除所有重复标记?(磁盘文件不会被删除)', confirmText: '清除' }))) return
- const r = await duplicatesAPI.unmark(libID)
- toast.success(`已清除 ${r.unmarked} 项`)
- setReport(null)
- }
-
- return (
-
-
-
-
-
-
-
-
-
- {report && report.groups_found === 0 && (
-
扫描了 {report.total_scanned} 项,未发现重复。
- )}
-
- {report && report.missing_removed ? (
-
- 已清理 {report.missing_removed} 条文件不存在的媒体记录,统计容量会在刷新后恢复正常。
-
- ) : null}
-
- {report && (report.groups ?? []).map((g) => (
-
-
- {g.primary.title}
-
- {g.primary.path} · {fmtBytes(g.primary.size_bytes)}
-
-
-
- 重复 ({g.duplicates.length})
-
- {g.duplicates.map((d) => (
-
- {d.title} · {d.path} · {fmtBytes(d.size_bytes)}
-
- ))}
-
-
- ))}
-
- )
-}
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index 41c8842..f5f0d92 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -1,62 +1,157 @@
import { useEffect, useMemo, useState } from 'react'
-import { libraryAPI, mediaAPI } from '../api/library'
+import { libraryAPI } from '../api/library'
import { playbackAPI, type HistoryItem } from '../api/playback'
import type { Library, Media } from '../types'
import { groupSeries, type SeriesCard } from '../utils/groupSeries'
import {
ContinueWatchingSection,
+ HomeCarouselSection,
HomeEmptyState,
- HomeFeaturedSection,
+ HomeLibrariesSection,
+ HomeLibraryRowSection,
HomeLoadingState,
- RecentMediaSection,
} from './HomePageSections'
+const CAROUSEL_STORAGE_KEY = 'mmtl.home.carousel_libraries'
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
-const asArray = (value: unknown): T[] => (Array.isArray(value) ? value as T[] : [])
+const asArray = (value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : [])
export function HomePage() {
const [libraries, setLibraries] = useState([])
- const [recentCards, setRecentCards] = useState([])
+ const [libraryData, setLibraryData] = useState>({})
+ const [selectedLibraryIds, setSelectedLibraryIds] = useState(() => {
+ try {
+ const saved = localStorage.getItem(CAROUSEL_STORAGE_KEY)
+ return saved ? JSON.parse(saved) : []
+ } catch {
+ return []
+ }
+ })
const [history, setHistory] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
+
async function load() {
setLoading(true)
try {
- const [libs, recentItems, hist] = await Promise.all([
+ const [libs, hist] = await Promise.all([
libraryAPI.list().then((rows) => asArray(rows)).catch(() => [] as Library[]),
- mediaAPI.recent(24).then((rows) => asArray(rows)).catch(async () => {
- const fallback = await mediaAPI.search('', 120).then((d) => asArray(d?.items)).catch(() => [] as Media[])
- return groupSeries(fallback).slice(0, 24)
- }),
playbackAPI.recentHistory().then((rows) => asArray(rows)).catch(() => [] as HistoryItem[]),
])
+
if (cancelled) return
setLibraries(libs)
- setRecentCards(recentItems)
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 results = await Promise.allSettled(
+ libs.map(async (lib) => {
+ const page = await libraryAPI.listMedia(lib.id, 1, 30)
+ const items = asArray(page?.items)
+ const cards = groupSeries(items)
+ return {
+ id: lib.id,
+ cards,
+ items,
+ total: page?.total ?? items.length,
+ }
+ }),
+ )
+
+ if (cancelled) return
+ const mapData: Record = {}
+ for (const res of results) {
+ if (res.status === 'fulfilled' && res.value) {
+ mapData[res.value.id] = {
+ cards: res.value.cards,
+ items: res.value.items,
+ total: res.value.total,
+ }
+ }
+ }
+ setLibraryData(mapData)
} finally {
if (!cancelled) setLoading(false)
}
}
+
load()
- return () => { cancelled = true }
+ return () => {
+ cancelled = true
+ }
}, [])
- const featuredItem = useMemo(() => {
- const candidates = [
- ...(history.map((h) => h.media).filter(Boolean) as Media[]),
- ...recentCards.map((card) => card.rep),
- ]
- return candidates.find(hasArtwork) ?? candidates[0] ?? null
- }, [history, recentCards])
- const featuredVisual = featuredItem?.backdrop_url || featuredItem?.poster_url || ''
- const featuredPoster = featuredItem?.poster_url || featuredItem?.backdrop_url || ''
- const featuredMark = (featuredItem?.title || 'MS').trim().slice(0, 4).toUpperCase()
- const empty = !loading && libraries.length === 0 && recentCards.length === 0 && history.length === 0
+ // Quick lookup map for libraries
+ const libraryMap = useMemo(() => {
+ const map = new Map()
+ for (const lib of libraries) {
+ map.set(lib.id, lib)
+ }
+ return map
+ }, [libraries])
+
+ // Counts lookup
+ const libraryCounts = useMemo(() => {
+ const counts: Record = {}
+ for (const lib of libraries) {
+ counts[lib.id] = libraryData[lib.id]?.total ?? 0
+ }
+ return counts
+ }, [libraries, libraryData])
+
+ // Compute items to show in the Hero Carousel
+ const carouselItems = useMemo(() => {
+ const candidateMedia: Media[] = []
+ const effectiveSelectedIds =
+ selectedLibraryIds.length > 0 ? selectedLibraryIds : libraries.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
+ for (const card of data.cards) {
+ if (hasArtwork(card.rep)) {
+ candidateMedia.push(card.rep)
+ }
+ }
+ }
+ }
+
+ // Sort by artwork score / rating or shuffle / interleave
+ if (candidateMedia.length === 0) {
+ // Fallback to all loaded items with artwork
+ for (const lib of libraries) {
+ const data = libraryData[lib.id]
+ if (data) {
+ for (const card of data.cards) {
+ candidateMedia.push(card.rep)
+ }
+ }
+ }
+ }
+
+ return candidateMedia.slice(0, 10)
+ }, [selectedLibraryIds, libraries, libraryData])
+
+ const empty =
+ !loading &&
+ libraries.length === 0 &&
+ history.length === 0
if (loading) {
return
@@ -67,18 +162,38 @@ export function HomePage() {
}
return (
-
- {featuredItem && (
-
+ {/* 1. 顶部海报轮播区 */}
+ {carouselItems.length > 0 && (
+
+ )}
+
+ {/* 2. 继续观看(若有历史) */}
+ {history.length > 0 && }
+
+ {/* 3. 媒体库卡片区 */}
+ {libraries.length > 0 && (
+
)}
- {history.length > 0 && }
- {recentCards.length > 0 && }
+ {/* 4. 各媒体库内容展示行 */}
+
+ {libraries.map((lib) => {
+ const cards = libraryData[lib.id]?.cards || []
+ if (cards.length === 0) return null
+ return (
+
+ )
+ })}
+
)
}
diff --git a/web/src/pages/HomePageSections.tsx b/web/src/pages/HomePageSections.tsx
index 9b81c81..c4aa643 100644
--- a/web/src/pages/HomePageSections.tsx
+++ b/web/src/pages/HomePageSections.tsx
@@ -1,23 +1,69 @@
+import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
-import { motion } from 'framer-motion'
-import { ArrowRight, Clock, Film, Play, Sparkles } from 'lucide-react'
+import { AnimatePresence, motion } from 'framer-motion'
+import {
+ ArrowRight,
+ ChevronLeft,
+ ChevronRight,
+ Clock,
+ Film,
+ FolderOpen,
+ Library as LibraryIcon,
+ Music,
+ Play,
+ PlayCircle,
+ Sparkles,
+ Star,
+ Tv,
+} from 'lucide-react'
import { imageURL } from '../api/client'
import { MediaCard } from '../components/MediaCard'
import type { HistoryItem } from '../api/playback'
-import type { Media } from '../types'
+import type { Library, Media } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
import { seriesCardLink } from '../utils/groupSeries'
+import { getLibraryArtworks } from './librariesPageModel'
+
+const TYPE_ICONS: Record = {
+ movie: ,
+ movies: ,
+ tv: ,
+ series: ,
+ anime: ,
+ shows: ,
+ variety: ,
+ music: ,
+ adult: ,
+}
+
+const TYPE_LABELS: Record = {
+ movie: '电影',
+ movies: '电影',
+ tv: '剧集',
+ series: '剧集',
+ anime: '动漫',
+ shows: '综艺',
+ variety: '综艺',
+ music: '音乐',
+ adult: 'Adult',
+}
export function HomeLoadingState() {
return (
-
+
- 首页内容准备中…
+
+ 首页内容准备中…
+
)
@@ -31,165 +77,512 @@ export function HomeEmptyState() {
您的家庭影视站暂无内容
- 前往管理后台添加媒体目录,扫描后首页将展示本周力荐、继续观看和最近入库。
+ 前往媒体库添加媒体目录,扫描后首页将展示海报轮播、媒体库和最新入库。
-
- 前往管理后台
+
+ 前往媒体库
)
}
-export function HomeFeaturedSection({
- featuredItem,
- featuredVisual,
- featuredPoster,
- featuredMark,
+/* =========================================================================
+ 1. 海报轮播区 (Hero Carousel Section)
+ ========================================================================= */
+
+export function HomeCarouselSection({
+ items,
+ libraryMap,
}: {
- featuredItem: Media
- featuredVisual: string
- featuredPoster: string
- featuredMark: string
+ items: Media[]
+ libraryMap: Map
}) {
+ const [currentIndex, setCurrentIndex] = useState(0)
+ const [isPaused, setIsPaused] = useState(false)
+
+ const currentItem = items[currentIndex] || items[0]
+ const count = items.length
+
+ useEffect(() => {
+ if (count <= 1 || isPaused) return
+ const timer = setInterval(() => {
+ setCurrentIndex((prev) => (prev + 1) % count)
+ }, 5000)
+ return () => clearInterval(timer)
+ }, [count, isPaused])
+
+ if (!currentItem) return null
+
+ const handlePrev = () => {
+ setCurrentIndex((prev) => (prev - 1 + count) % count)
+ }
+
+ const handleNext = () => {
+ setCurrentIndex((prev) => (prev + 1) % count)
+ }
+
+ const visual = currentItem.backdrop_url || currentItem.poster_url || ''
+ const poster = currentItem.poster_url || currentItem.backdrop_url || ''
+ const lib = libraryMap.get(currentItem.display_library_id || currentItem.library_id)
+
return (
-
+ setIsPaused(true)}
+ onMouseLeave={() => setIsPaused(false)}
+ className="group relative overflow-hidden rounded-[2rem] border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_24px_80px_var(--app-shadow)] min-h-[420px] md:min-h-[460px] flex flex-col justify-end"
+ >
+ {/* Background Backdrop Image with Crossfade */}
- {featuredVisual && (
-

{ event.currentTarget.style.display = 'none' }}
- />
- )}
+
+ {visual && (
+ {
+ e.currentTarget.style.display = 'none'
+ }}
+ />
+ )}
+
-
+
-
-
-
-
- 本周力荐 / Featured
-
-
-
-
-
-
{featuredMark}
+ {/* Main Content Layout */}
+
+ {/* Left Column: Metadata & Actions */}
+
+ {/* Tag / Library Label */}
+
+
+
+ 焦点推荐
-
- {featuredItem.title}
-
+ {lib && (
+
+ {TYPE_ICONS[lib.type] || }
+ {lib.name}
+
+ )}
-
- {featuredItem.overview || '家庭私人媒体中心收藏。支持多端播放、外部播放器、智能刮削与订阅下载。'}
+ {/* Title */}
+
+
+
+ {currentItem.title}
+
+
+ {currentItem.original_name && currentItem.original_name !== currentItem.title && (
+
+ {currentItem.original_name}
+
+ )}
+
+
+ {/* Metadata Chips */}
+
+ {currentItem.rating > 0 && (
+
+
+ {currentItem.rating.toFixed(1)}
+
+ )}
+ {currentItem.year > 0 && (
+
+ {currentItem.year} 年
+
+ )}
+ {currentItem.video_codec && (
+
+ {currentItem.video_codec}
+
+ )}
+ {currentItem.container && (
+
+ {currentItem.container}
+
+ )}
+
+
+ {/* Overview */}
+
+ {currentItem.overview || '家庭私人媒体中心收藏。支持多端播放、高码率串流与智能刮削。'}
-
- {featuredItem.year > 0 && (
- {featuredItem.year} 年
- )}
- {featuredItem.video_codec && (
-
- {featuredItem.video_codec}
-
- )}
- {featuredItem.container && (
-
- {featuredItem.container}
-
- )}
-
-
+ {/* Action Buttons */}
-
+
立即播放
-
-
发现更多精彩
+
+
查看详情
-
-
+ {/* Right Column: Floating 3D Poster Card */}
+
+
-
+
- {featuredItem.title}
+
+ {currentItem.title}
+
- {featuredPoster && (
+ {poster && (

{ event.currentTarget.style.display = 'none' }}
+ onError={(e) => {
+ e.currentTarget.style.display = 'none'
+ }}
/>
)}
+
+ {/* Carousel Controls & Dots */}
+ {count > 1 && (
+
+ {/* Navigation Indicators */}
+
+ {items.map((_, idx) => (
+
+
+ {/* Slide Number & Arrow Buttons */}
+
+
+ {String(currentIndex + 1).padStart(2, '0')} / {String(count).padStart(2, '0')}
+
+
+
+
+
+
+
+ )}
)
}
-export function ContinueWatchingSection({ history }: { history: HistoryItem[] }) {
+/* =========================================================================
+ 2. 媒体库卡片区 (Libraries Grid)
+ ========================================================================= */
+
+export function HomeLibrariesSection({
+ libraries,
+ libraryData,
+ libraryCounts,
+}: {
+ libraries: Library[]
+ libraryData?: Record
+ libraryCounts: Record
+}) {
return (
-
-
-
-
-
-
继续观看
-
{history.length} 个记录
+
+
+
+
+
+
+
+
+ 媒体库
+
+
+ 点击卡片浏览对应媒体库精选内容
+
+
+
+
+
+
全部媒体库
+
+
-
- {history.slice(0, 8).map((h) => {
- const media = h.media!
- const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
- return
+ {/* Libraries Grid */}
+
+ {libraries.map((lib) => {
+ const count = libraryCounts[lib.id] ?? 0
+ const cards = libraryData?.[lib.id]?.cards || []
+ const artwork = getLibraryArtworks(lib, cards)
+
+ return (
+
+ {/* 封面图片展示区:和媒体库页面一样,显示设置好的或生成的图片 */}
+
1 ? 'grid grid-cols-2 gap-0.5' : ''
+ }`}
+ >
+ {artwork.length > 0 ? (
+ artwork.map(({ src, version }, index) => (
+

{
+ event.currentTarget.style.visibility = 'hidden'
+ }}
+ />
+ ))
+ ) : (
+
+ {TYPE_ICONS[lib.type] || }
+
+ )}
+
+ {/* 浮动类型标签 */}
+
+ {TYPE_LABELS[lib.type] || '自定义'}
+
+
+
+ {/* 媒体库信息 */}
+
+
+ {lib.name}
+
+
+ {count > 0 ? `${count} 部媒体` : '暂无条目'}
+
+
+
+ )
})}
)
}
-export function RecentMediaSection({ recentCards }: { recentCards: SeriesCard[] }) {
+/* =========================================================================
+ 3. 单个媒体库内容行 (Horizontal Scroll Row)
+ ========================================================================= */
+
+export function HomeLibraryRowSection({
+ library,
+ cards,
+}: {
+ library: Library
+ cards: SeriesCard[]
+}) {
+ const scrollRef = useRef
(null)
+
+ if (!cards || cards.length === 0) return null
+
+ const scroll = (direction: 'left' | 'right') => {
+ if (scrollRef.current) {
+ const scrollAmount = direction === 'left' ? -480 : 480
+ scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' })
+ }
+ }
+
return (
-
+
+ {/* Row Header */}
+
+
+
+ {TYPE_ICONS[library.type] || }
+
+
+
+ {library.name}
+
+
+ 共 {cards.length} 部精选内容
+
+
+
+
+
+
+
查看全部
+
+
+
+ {/* Scroll Buttons */}
+
+
+
+
+
+
+
+ {/* Horizontal Carousel Row */}
+
+ {cards.map((card) => (
+
+
+
+ ))}
+
+
+ )
+}
+
+/* =========================================================================
+ 4. 继续观看行 (Continue Watching)
+ ========================================================================= */
+
+export function ContinueWatchingSection({ history }: { history: HistoryItem[] }) {
+ const scrollRef = useRef(null)
+
+ const scroll = (direction: 'left' | 'right') => {
+ if (scrollRef.current) {
+ const scrollAmount = direction === 'left' ? -400 : 400
+ scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' })
+ }
+ }
+
+ return (
+
-
最近入库
-
按整部电影、剧集、番剧和综艺合集展示新增内容。
+
+ 继续观看
+
+
+ {history.length} 条播放记录
+
-
-
海报墙
-
-
+
+
+
+
+
-
- {recentCards.map((card) => (
-
- ))}
+
+ {history.slice(0, 12).map((h) => {
+ const media = h.media!
+ const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
+ return (
+
+
+
+ )
+ })}
)
@@ -197,42 +590,50 @@ export function RecentMediaSection({ recentCards }: { recentCards: SeriesCard[]
function ContinueCard({ media, progress }: { media: Media; progress: number }) {
return (
-
-
+
+
{media.poster_url ? (

) : (
-
-
+
+
)}
-
-
-
+
+
+
{media.title}
-
-
-
+ {media.year > 0 && {media.year}}
+ {media.season_num !== undefined && media.episode_num !== undefined && (
+
+ S{media.season_num}E{media.episode_num}
+
+ )}
+
+
+ {/* Progress Bar */}
+ {progress > 0 && progress < 1 && (
+
-
- 已观看到 {Math.round(progress * 100)}%
-
-
+ )}
)
diff --git a/web/src/pages/LibrariesPageSections.tsx b/web/src/pages/LibrariesPageSections.tsx
index 4345be7..1547eec 100644
--- a/web/src/pages/LibrariesPageSections.tsx
+++ b/web/src/pages/LibrariesPageSections.tsx
@@ -6,9 +6,10 @@ import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, PlayCircle
import { imageURL } from '../api/client'
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
import { MediaCard } from '../components/MediaCard'
-import { artworkScore, seriesCardLink, type SeriesCard } from '../utils/groupSeries'
+import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
+import { seriesCardLink } from '../utils/groupSeries'
import { libraryDisplayPath } from './libraryDisplayModel'
-import { mediaTime, type LibraryPreview } from './librariesPageModel'
+import { libraryArtworkItems, type LibraryPreview } from './librariesPageModel'
const TYPE_ICONS: Record
= {
movie: ,
@@ -71,10 +72,9 @@ export function LibrariesHeader({
{repairing ? '正在启动…' : '全库修复+重刮'}
-
+
)
@@ -223,14 +223,3 @@ function LibraryShelf({ preview }: { preview: LibraryPreview }) {
)
}
-
-function libraryArtworkItems(cards: SeriesCard[]): Array<{ src: string; version?: string }> {
- return [...cards]
- .sort((a, b) => artworkScore(b.rep) - artworkScore(a.rep) || mediaTime(b.rep) - mediaTime(a.rep))
- .map((card) => ({
- src: card.rep.poster_url || card.rep.backdrop_url || '',
- version: card.rep.updated_at,
- }))
- .filter((item) => Boolean(item.src))
- .slice(0, 4)
-}
diff --git a/web/src/pages/LibrarySettingsPanel.tsx b/web/src/pages/LibrarySettingsPanel.tsx
new file mode 100644
index 0000000..7b673a7
--- /dev/null
+++ b/web/src/pages/LibrarySettingsPanel.tsx
@@ -0,0 +1,305 @@
+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
= {
+ movie: ,
+ movies: ,
+ tv: ,
+ series: ,
+ anime: ,
+ shows: ,
+ variety: ,
+ music: ,
+ adult: ,
+}
+
+const TYPE_LABELS: Record = {
+ movie: '电影',
+ movies: '电影',
+ tv: '剧集',
+ series: '剧集',
+ anime: '动漫',
+ shows: '综艺',
+ variety: '综艺',
+ music: '音乐',
+ adult: 'Adult',
+}
+
+export function LibrarySettingsPanel() {
+ const [libraries, setLibraries] = useState([])
+ const [libraryCards, setLibraryCards] = useState>({})
+ const [selectedIds, setSelectedIds] = useState([])
+ 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 = {}
+ 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 (
+
+
+
+ )
+ }
+
+ return (
+
+ {/* 头部说明与快捷操作 */}
+
+
+
+
+
+
+
首页海报轮播设置
+
+ 选择参与首页顶部大图海报轮播推荐的媒体库。勾选的媒体库内容将轮流展示在系统首页顶部。
+
+
+
+
+
+
+
+
+
+
+ {/* 媒体库列表卡片 */}
+ {libraries.length === 0 ? (
+
+ 暂无可用媒体库,请先添加媒体库后再配置海报轮播。
+
+ ) : (
+
+ {libraries.map((lib) => {
+ const isSelected = selectedIds.includes(lib.id)
+ const cards = libraryCards[lib.id] || []
+ const artwork = getLibraryArtworks(lib, cards)
+
+ return (
+
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'
+ }`}
+ >
+
+
1 ? 'grid-cols-2' : 'grid-cols-1'
+ } ${
+ isSelected ? 'ring-2 ring-brand-500/30' : ''
+ }`}
+ >
+ {artwork.length > 0 ? (
+ artwork.map(({ src, version }, index) => (
+

{
+ e.currentTarget.style.display = 'none'
+ }}
+ />
+ ))
+ ) : (
+
+ {TYPE_ICONS[lib.type] || }
+
+ )}
+
+
+
+
+ {lib.name}
+
+
+ {TYPE_LABELS[lib.type] || '自定义'}
+
+
+
+ {isSelected ? '已启用轮播' : '未参与轮播'}
+
+
+
+
+
+
+
+
+ )
+ })}
+
+ )}
+
+ {/* 底部保存按钮 */}
+
+
+ 已选择 {selectedIds.length} / {libraries.length} 个媒体库参与轮播
+
+
+
+
+ )
+}
diff --git a/web/src/pages/LibraryStorageStats.tsx b/web/src/pages/LibraryStorageStats.tsx
new file mode 100644
index 0000000..a57aeff
--- /dev/null
+++ b/web/src/pages/LibraryStorageStats.tsx
@@ -0,0 +1,180 @@
+import { useEffect, useState } from 'react'
+import { Database, HardDrive, PieChart, RefreshCw } from 'lucide-react'
+
+import { storageAPI, type StorageBreakdown } from '../api/storage'
+
+export function fmtBytes(n: number): string {
+ if (!n) return '0 B'
+ const u = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
+ let v = n
+ let i = 0
+ while (v >= 1024 && i < u.length - 1) {
+ v /= 1024
+ i++
+ }
+ return `${v.toFixed(2)} ${u[i]}`
+}
+
+export function fmtHours(seconds: number): string {
+ if (!seconds) return '—'
+ const h = Math.floor(seconds / 3600)
+ return `${h.toLocaleString()} h`
+}
+
+export function LibraryStorageStats() {
+ const [data, setData] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [refreshing, setRefreshing] = useState(false)
+
+ const loadData = () => {
+ setRefreshing(true)
+ storageAPI
+ .breakdown()
+ .then(setData)
+ .finally(() => {
+ setLoading(false)
+ setRefreshing(false)
+ })
+ }
+
+ useEffect(() => {
+ loadData()
+ }, [])
+
+ if (loading) {
+ return (
+
+ 加载统计数据中…
+
+ )
+ }
+
+ if (!data) {
+ return (
+
+ 无法获取存储数据
+
+ )
+ }
+
+ const totalBytes = data.total_bytes || 1
+
+ return (
+
+
+
存储与统计
+
+
+
+
+ } label="总占用" value={fmtBytes(data.total_bytes)} />
+ } label="媒体库" value={`${data.by_library.length}`} />
+ } label="累计时长" value={fmtHours(data.total_seconds)} />
+
+
+
+ 按媒体库
+
+
+
+
+ | 名称 |
+ 类型 |
+ 媒体数 |
+ 占用 |
+ 占比 |
+
+
+
+ {data.by_library.length === 0 ? (
+
+ |
+ 暂无媒体库数据
+ |
+
+ ) : (
+ data.by_library.map((l) => {
+ const pct = (l.total_bytes / totalBytes) * 100
+ return (
+
+ | {l.name} |
+ {l.type} |
+ {l.media_count} |
+ {fmtBytes(l.total_bytes)} |
+
+
+ |
+
+ )
+ })
+ )}
+
+
+
+
+
+
+ 按容器格式
+ {data.by_container.length === 0 ? (
+
+ 暂无格式统计
+
+ ) : (
+
+ {data.by_container.map((c) => (
+
+
+
{c.container || '未知'}
+
{c.count} 项
+
+
{fmtBytes(c.bytes)}
+
+ ))}
+
+ )}
+
+
+ )
+}
+
+function StatTile({
+ icon,
+ label,
+ value,
+}: {
+ icon: React.ReactNode
+ label: string
+ value: string
+}) {
+ return (
+
+ )
+}
diff --git a/web/src/pages/LicensePage.tsx b/web/src/pages/LicensePage.tsx
deleted file mode 100644
index 5fbf7d6..0000000
--- a/web/src/pages/LicensePage.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { FormEvent, useCallback, useEffect, useState } from 'react'
-import toast from 'react-hot-toast'
-
-import { licenseAPI, type LicenseStatus } from '../api/license'
-import {
- LicenseBindPanel,
- LicenseHeader,
- LicenseInactiveTip,
- LicenseStatusPanel,
-} from './LicensePageSections'
-
-export function LicensePage() {
- const [status, setStatus] = useState(null)
- const [loadingStatus, setLoadingStatus] = useState(true)
- const [bindKey, setBindKey] = useState('')
- const [binding, setBinding] = useState(false)
-
- const refreshStatus = useCallback(async () => {
- setLoadingStatus(true)
- try {
- const s = await licenseAPI.status()
- setStatus(s)
- } catch {
- setStatus({ active: false })
- } finally {
- setLoadingStatus(false)
- }
- }, [])
-
- useEffect(() => {
- refreshStatus()
- }, [refreshStatus])
-
- const onBind = async (e: FormEvent) => {
- e.preventDefault()
- const key = bindKey.trim()
- if (!key) {
- toast.error('请输入许可证密钥')
- return
- }
- setBinding(true)
- try {
- const activation = await licenseAPI.bind(key)
- toast.success('许可证绑定成功!')
- setBindKey('')
- // Optimistically update status
- setStatus({
- active: true,
- activation,
- max_users: activation.max_users,
- unlimited_users: activation.unlimited_users,
- message: '已激活',
- })
- } catch (err: unknown) {
- const msg =
- (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
- '绑定失败,请检查密钥是否正确'
- toast.error(msg)
- } finally {
- setBinding(false)
- }
- }
-
- const onHeartbeat = async () => {
- try {
- await licenseAPI.heartbeat()
- toast.success('心跳上报成功')
- } catch {
- toast.error('心跳上报失败')
- }
- }
-
- // ── Derive display values ──
- const active = status?.active === true
- const activation = status?.activation
- const isExpired =
- activation?.expires_at != null && new Date(activation.expires_at).getTime() < Date.now()
-
- return (
-
-
-
-
-
-
- )
-}
diff --git a/web/src/pages/LicensePageSections.tsx b/web/src/pages/LicensePageSections.tsx
deleted file mode 100644
index 4ddc0fb..0000000
--- a/web/src/pages/LicensePageSections.tsx
+++ /dev/null
@@ -1,219 +0,0 @@
-import type { FormEvent } from 'react'
-import {
- CheckCircle2,
- KeySquare,
- Link2,
- Loader2,
- RefreshCw,
- XCircle,
-} from 'lucide-react'
-
-import type { LicenseStatus } from '../api/license'
-
-export function LicenseHeader() {
- return (
-
- )
-}
-
-export function LicenseBindPanel({
- bindKey,
- binding,
- onBind,
- onBindKeyChange,
-}: {
- bindKey: string
- binding: boolean
- onBind: (event: FormEvent) => void
- onBindKeyChange: (value: string) => void
-}) {
- return (
-
-
-
-
绑定许可证
-
-
-
- 输入从授权服务器获取的许可证密钥,激活后按授权额度开放更多平台用户。
-
-
- )
-}
-
-export function LicenseStatusPanel({
- status,
- loadingStatus,
- active,
- isExpired,
- onRefresh,
- onHeartbeat,
-}: {
- status: LicenseStatus | null
- loadingStatus: boolean
- active: boolean
- isExpired: boolean
- onRefresh: () => void
- onHeartbeat: () => void
-}) {
- const activation = status?.activation
-
- if (loadingStatus) {
- return (
-
-
-
- )
- }
-
- return (
-
-
-
- {active && !isExpired ? (
-
- ) : (
-
- )}
-
当前状态
-
-
-
- {active && (
-
- )}
-
-
-
- {!active && (
-
-
- 尚未绑定许可证。请在上方输入密钥完成激活。
-
-
- )}
-
- {active && activation && (
-
-
-
-
-
-
-
-
-
-
-
- )}
-
- {isExpired && (
-
- 此许可证已过期,部分功能可能受限。请获取新的许可证密钥。
-
- )}
-
- )
-}
-
-export function LicenseInactiveTip({ active, loadingStatus }: { active: boolean; loadingStatus: boolean }) {
- if (active || loadingStatus) return null
- return (
-
-
- 需要获取许可证?请联系管理员获取 MediaStationGo 授权密钥。
-
-
- 授权服务器地址可在系统设置中配置
-
-
- )
-}
-
-function StatusBadge({
- label,
- value,
- mono,
- className,
-}: {
- label: string
- value: string
- mono?: boolean
- className?: string
-}) {
- return (
-
-
{label}
-
- {value}
-
-
- )
-}
-
-function fmtDate(iso: string | null | undefined): string {
- if (!iso) return '永久'
- return new Date(iso).toLocaleDateString('zh-CN', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- })
-}
-
-function fmtDateTime(iso: string | null | undefined): string {
- if (!iso) return '—'
- return new Date(iso).toLocaleString('zh-CN')
-}
-
-function fmtUserLimit(maxUsers: number | null | undefined, unlimited?: boolean): string {
- if (unlimited || maxUsers == null) return '不限制'
- return `${maxUsers} 人`
-}
diff --git a/web/src/pages/LoginPageSections.tsx b/web/src/pages/LoginPageSections.tsx
index e223068..99edb70 100644
--- a/web/src/pages/LoginPageSections.tsx
+++ b/web/src/pages/LoginPageSections.tsx
@@ -2,8 +2,6 @@ import type { FormEvent, ReactNode } from 'react'
import { motion } from 'framer-motion'
import { ArrowRight, Eye, EyeOff, Lock, User } from 'lucide-react'
-import { AppFooter } from '../components/AppFooter'
-
type LoginPageShellProps = {
children: ReactNode
}
@@ -37,7 +35,6 @@ export function LoginPageShell({ children }: LoginPageShellProps) {
{children}
-
)
}
@@ -75,8 +72,8 @@ function LoginBrandHeader() {
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.15, type: 'spring', stiffness: 200 }}
- src="/brand/mediastationgo-logo.svg"
- alt="MediaStationGo"
+ src="/brand/logo-192.png"
+ alt="MMTL"
className="mb-4 h-14 w-14 rounded-2xl object-contain shadow-sm"
/>
@@ -86,7 +83,7 @@ function LoginBrandHeader() {
transition={{ delay: 0.25 }}
className="font-display text-2xl font-extrabold tracking-tight text-gray-900"
>
- MediaStationGo
+ MMTL
)
}
-
-function LoginFooter() {
- return (
-
-
-
- )
-}
diff --git a/web/src/pages/NotifyChannelCard.tsx b/web/src/pages/NotifyChannelCard.tsx
deleted file mode 100644
index dff8c2e..0000000
--- a/web/src/pages/NotifyChannelCard.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { Loader2, Pencil, Send, Trash2 } from 'lucide-react'
-
-import type { NotifyChannel } from '../types'
-import { channelSummary, eventSummary, TYPE_LABELS } from './notifyChannelsModel'
-
-type NotifyChannelCardProps = {
- channel: NotifyChannel
- onTest: () => void
- testing?: boolean
- onEdit: () => void
- onDelete: () => void
-}
-
-export function NotifyChannelCard({
- channel,
- onTest,
- testing,
- onEdit,
- onDelete,
-}: NotifyChannelCardProps) {
- const summary = channelSummary(channel)
- return (
-
-
-
- {channel.name}
-
- {TYPE_LABELS[channel.type] ?? channel.type}
-
- {!channel.enabled && (
- 已禁用
- )}
-
-
{summary}
-
{eventSummary(channel.events)}
-
-
-
-
-
-
-
- )
-}
diff --git a/web/src/pages/NotifyChannelConfigControls.tsx b/web/src/pages/NotifyChannelConfigControls.tsx
deleted file mode 100644
index 35d8350..0000000
--- a/web/src/pages/NotifyChannelConfigControls.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-import { Field } from './NotifyChannelFormField'
-
-type ConfigControlProps = {
- config: Record
- updateConfig: (key: string, value: string) => void
-}
-
-type ConfigInputProps = ConfigControlProps & {
- label: string
- name: string
- placeholder?: string
- required?: boolean
- type?: string
-}
-
-export function ConfigInput({
- config,
- updateConfig,
- label,
- name,
- placeholder,
- required,
- type,
-}: ConfigInputProps) {
- return (
-
- updateConfig(name, event.target.value)}
- />
-
- )
-}
-
-type ConfigSelectProps = ConfigControlProps & {
- label: string
- name: string
- options: Array<{ value: string; label: string }>
- defaultValue?: string
-}
-
-export function ConfigSelect({
- config,
- updateConfig,
- label,
- name,
- options,
- defaultValue = '',
-}: ConfigSelectProps) {
- return (
-
-
-
- )
-}
-
-type ConfigTextareaProps = ConfigControlProps & {
- label: string
- name: string
- placeholder?: string
- rows: number
-}
-
-export function ConfigTextarea({
- config,
- updateConfig,
- label,
- name,
- placeholder,
- rows,
-}: ConfigTextareaProps) {
- return (
-
-
- )
-}
diff --git a/web/src/pages/NotifyChannelEventFields.tsx b/web/src/pages/NotifyChannelEventFields.tsx
deleted file mode 100644
index 435f8b4..0000000
--- a/web/src/pages/NotifyChannelEventFields.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import { EVENT_OPTIONS, type EventMode } from './notifyChannelsModel'
-import { Field } from './NotifyChannelFormField'
-
-type NotifyChannelEventFieldsProps = {
- events: string[]
- eventMode: EventMode
- setEventMode: (mode: EventMode) => void
- toggleEvent: (event: string) => void
-}
-
-export function NotifyChannelEventFields({
- events,
- eventMode,
- setEventMode,
- toggleEvent,
-}: NotifyChannelEventFieldsProps) {
- return (
-
-
-
- )
-}
diff --git a/web/src/pages/NotifyChannelFormField.tsx b/web/src/pages/NotifyChannelFormField.tsx
deleted file mode 100644
index 59fc264..0000000
--- a/web/src/pages/NotifyChannelFormField.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import type { ReactNode } from 'react'
-
-export function Field({ label, children }: { label: string; children: ReactNode }) {
- return (
-
- )
-}
diff --git a/web/src/pages/NotifyChannelFormModal.tsx b/web/src/pages/NotifyChannelFormModal.tsx
deleted file mode 100644
index ad8514b..0000000
--- a/web/src/pages/NotifyChannelFormModal.tsx
+++ /dev/null
@@ -1,185 +0,0 @@
-import type { FormEvent } from 'react'
-import { useState } from 'react'
-import { Loader2 } from 'lucide-react'
-import toast from 'react-hot-toast'
-
-import {
- notifyChannelsAPI,
- type NotifyChannelInput,
-} from '../api/notify_channels'
-import type { NotifyChannel } from '../types'
-import {
- EMPTY_CONFIG,
- EVENT_ALL,
- EVENT_NONE,
- EVENT_OPTIONS,
- type EventMode,
- initialEventMode,
- normalizeInitialConfig,
-} from './notifyChannelsModel'
-import { Field } from './NotifyChannelFormField'
-import { NotifyChannelEventFields } from './NotifyChannelEventFields'
-import {
- BarkFields,
- EmailFields,
- TelegramFields,
- WebhookFields,
- WechatFields,
-} from './NotifyChannelProviderFields'
-
-type NotifyChannelFormModalProps = {
- editing: NotifyChannel | null
- onClose: () => void
- onSaved: () => void | Promise
-}
-
-export function NotifyChannelFormModal({
- editing,
- onClose,
- onSaved,
-}: NotifyChannelFormModalProps) {
- const [name, setName] = useState(editing?.name ?? '')
- const [type, setType] = useState(
- editing?.type ?? 'telegram',
- )
- const [config, setConfig] = useState>(
- normalizeInitialConfig(editing?.type ?? 'telegram', editing?.config ?? {}),
- )
- const [events, setEvents] = useState(editing?.events ?? [])
- const [eventMode, setEventMode] = useState(initialEventMode(editing?.events))
- const [enabled, setEnabled] = useState(editing?.enabled ?? true)
- const [saving, setSaving] = useState(false)
-
- const onTypeChange = (t: NotifyChannel['type']) => {
- setType(t)
- setConfig({ ...EMPTY_CONFIG[t] })
- }
-
- const onSubmit = async (e: FormEvent) => {
- e.preventDefault()
- if (type === 'telegram') {
- if (!String(config.bot_token ?? '').trim()) {
- toast.error('请填写 Telegram Bot Token')
- return
- }
- if (!String(config.admin_user_ids ?? '').trim()) {
- toast.error('请填写管理员 Telegram ID')
- return
- }
- }
- const selectedEvents = events.filter((event) => EVENT_OPTIONS.some((item) => item.value === event))
- if (eventMode === 'custom' && selectedEvents.length === 0) {
- toast.error('请至少选择一个推送事件,或选择关闭全部推送事件')
- return
- }
- setSaving(true)
- try {
- const cleanedConfig = Object.fromEntries(
- Object.entries(config).map(([key, value]) => [key, String(value ?? '').trim()]),
- )
- delete cleanedConfig.chat_id
- const input: NotifyChannelInput = {
- name: name.trim(),
- type: type,
- config: cleanedConfig,
- events: eventMode === 'all' ? [EVENT_ALL] : eventMode === 'none' ? [EVENT_NONE] : selectedEvents,
- enabled,
- }
- if (editing) {
- await notifyChannelsAPI.update(editing.id, input)
- } else {
- await notifyChannelsAPI.create(input)
- }
- toast.success('已保存')
- await onSaved()
- } catch (err: unknown) {
- const msg =
- (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
- toast.error(msg)
- } finally {
- setSaving(false)
- }
- }
-
- const updateConfig = (k: string, v: string) =>
- setConfig((c) => ({ ...c, [k]: v }))
-
- const toggleEvent = (event: string) => {
- setEvents((current) =>
- current.includes(event)
- ? current.filter((item) => item !== event)
- : [...current.filter((item) => item !== EVENT_ALL && item !== EVENT_NONE), event],
- )
- }
-
- return (
-
-
-
- {editing ? '编辑通知渠道' : '添加通知渠道'}
-
-
-
-
- )
-}
diff --git a/web/src/pages/NotifyChannelProviderFields.tsx b/web/src/pages/NotifyChannelProviderFields.tsx
deleted file mode 100644
index 68154b8..0000000
--- a/web/src/pages/NotifyChannelProviderFields.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-import {
- ConfigInput,
- ConfigSelect,
- ConfigTextarea,
-} from './NotifyChannelConfigControls'
-
-type ProviderFieldsProps = {
- config: Record
- updateConfig: (key: string, value: string) => void
-}
-
-const METHOD_OPTIONS = [
- { value: 'POST', label: 'POST' },
- { value: 'GET', label: 'GET' },
-]
-
-const TLS_OPTIONS = [
- { value: 'true', label: '启用' },
- { value: 'false', label: '关闭' },
-]
-
-export function TelegramFields({ config, updateConfig }: ProviderFieldsProps) {
- const control = { config, updateConfig }
-
- return (
- <>
-
-
-
-
-
-
-
- 群组 ID、频道 ID 均为选填,可填一个、两个都填,也可以不填。管理功能始终仅管理员 Telegram ID 或已绑定的本地管理员可用;普通用户需要在已配置的群组或频道中,才能使用 /start 用户名 密码 绑定账号、切换隐藏成人媒体库和目录。不配置群组/频道时,普通用户不会被放行。若测试通知超时,可填写反代 API 地址或代理地址。
-
- >
- )
-}
-
-export function WechatFields({ config, updateConfig }: ProviderFieldsProps) {
- const control = { config, updateConfig }
-
- return (
-
- )
-}
-
-export function BarkFields({ config, updateConfig }: ProviderFieldsProps) {
- const control = { config, updateConfig }
-
- return (
- <>
-
-
- >
- )
-}
-
-export function WebhookFields({ config, updateConfig }: ProviderFieldsProps) {
- const control = { config, updateConfig }
-
- return (
- <>
-
-
-
-
- >
- )
-}
-
-export function EmailFields({ config, updateConfig }: ProviderFieldsProps) {
- const control = { config, updateConfig }
-
- return (
- <>
-
-
-
-
-
-
-
-
-
-
-
- >
- )
-}
diff --git a/web/src/pages/NotifyChannelsPage.tsx b/web/src/pages/NotifyChannelsPage.tsx
deleted file mode 100644
index 7009fd9..0000000
--- a/web/src/pages/NotifyChannelsPage.tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-import { useEffect, useState } from 'react'
-import { Bell, Loader2, Play, Plus, Square } from 'lucide-react'
-import toast from 'react-hot-toast'
-
-import { notifyChannelsAPI } from '../api/notify_channels'
-import { confirmAction } from '../components/confirmAction'
-import type { NotifyChannel } from '../types'
-import { NotifyChannelCard } from './NotifyChannelCard'
-import { NotifyChannelFormModal } from './NotifyChannelFormModal'
-
-// NotifyChannelsPage replaces the Vue NotifyTab. Operators can register
-// multiple Telegram bots / Bark devices / WeChat keys / Webhooks and
-// fire a test notification on demand.
-export function NotifyChannelsPage() {
- const [channels, setChannels] = useState([])
- const [loading, setLoading] = useState(true)
- const [editing, setEditing] = useState(null)
- const [showForm, setShowForm] = useState(false)
- const [testingID, setTestingID] = useState(null)
- const [pollingBusy, setPollingBusy] = useState<'start' | 'stop' | null>(null)
-
- const refresh = async () => {
- setLoading(true)
- try {
- setChannels(await notifyChannelsAPI.list())
- } finally {
- setLoading(false)
- }
- }
-
- useEffect(() => {
- refresh().catch(() => undefined)
- }, [])
-
- const onTest = async (id: string) => {
- setTestingID(id)
- try {
- await notifyChannelsAPI.test(id)
- toast.success('测试消息已发送')
- } catch (err: unknown) {
- const msg =
- (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '发送失败'
- toast.error(msg)
- } finally {
- setTestingID(null)
- }
- }
-
- const onStartPolling = async () => {
- setPollingBusy('start')
- try {
- const res = await notifyChannelsAPI.startTelegramPolling()
- if ((res.started ?? 0) > 0) {
- toast.success(`已启动 ${res.started} 个 Telegram Bot 轮询`)
- } else if ((res.already_running ?? 0) > 0) {
- toast.success('Telegram Bot 轮询已在运行')
- } else {
- toast.error(res.errors?.[0] ?? '没有可启动的 Telegram Bot 渠道')
- }
- } catch (err: unknown) {
- const msg =
- (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '启动轮询失败'
- toast.error(msg)
- } finally {
- setPollingBusy(null)
- }
- }
-
- const onStopPolling = async () => {
- setPollingBusy('stop')
- try {
- const res = await notifyChannelsAPI.stopTelegramPolling()
- toast.success(res.stopped > 0 ? `已停止 ${res.stopped} 个 Telegram Bot 轮询` : '当前没有运行中的轮询')
- } catch (err: unknown) {
- const msg =
- (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '停止轮询失败'
- toast.error(msg)
- } finally {
- setPollingBusy(null)
- }
- }
-
- const onDelete = async (ch: NotifyChannel) => {
- if (!(await confirmAction({ title: '删除通知渠道', message: `确定删除「${ch.name}」?`, confirmText: '删除' }))) return
- try {
- await notifyChannelsAPI.remove(ch.id)
- toast.success('已删除')
- await refresh()
- } catch (err: unknown) {
- const msg =
- (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '删除失败'
- toast.error(msg)
- }
- }
-
- return (
-
-
-
-
-
-
-
-
通知渠道
-
- 配置 Telegram / Bark / 企业微信 / Webhook 多通道推送
-
-
-
-
-
-
-
-
-
-
- {loading && (
-
-
-
- )}
-
- {!loading && channels.length === 0 && (
-
暂无通知渠道
- )}
-
- {!loading && channels.length > 0 && (
-
- {channels.map((ch) => (
- onTest(ch.id)}
- testing={testingID === ch.id}
- onEdit={() => {
- setEditing(ch)
- setShowForm(true)
- }}
- onDelete={() => onDelete(ch)}
- />
- ))}
-
- )}
-
- {showForm && (
-
setShowForm(false)}
- onSaved={async () => {
- setShowForm(false)
- await refresh()
- }}
- />
- )}
-
- )
-}
diff --git a/web/src/pages/QRLoginPanel.tsx b/web/src/pages/QRLoginPanel.tsx
deleted file mode 100644
index 2d1570c..0000000
--- a/web/src/pages/QRLoginPanel.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import { useEffect, useState } from 'react'
-import { Loader2, QrCode } from 'lucide-react'
-import toast from 'react-hot-toast'
-
-import { cloudAPI, type QRSession, type StorageType } from '../api/storage_config'
-
-interface QRLoginPanelProps {
- type: StorageType
- onCookie: (cookie: string) => void
-}
-
-export function QRLoginPanel({ type, onCookie }: QRLoginPanelProps) {
- const [sess, setSess] = useState(null)
- const [state, setState] = useState('')
- const [busy, setBusy] = useState(false)
-
- useEffect(() => {
- if (!sess) return
- let alive = true
- const timer = setInterval(async () => {
- try {
- const st = await cloudAPI.qrPoll(type, sess)
- if (!alive) return
- setState(st.state)
- if (st.state === 'confirmed' && st.cookie) {
- onCookie(st.cookie)
- toast.success('扫码登录成功,Cookie 已填入,请点击保存')
- setSess(null)
- } else if (st.state === 'expired') {
- toast.error('二维码已过期,请重新获取')
- setSess(null)
- }
- } catch {
- /* keep polling */
- }
- }, 2000)
- return () => {
- alive = false
- clearInterval(timer)
- }
- }, [onCookie, sess, type])
-
- const start = async () => {
- setBusy(true)
- try {
- const nextSession = await cloudAPI.qrStart(type)
- setSess(nextSession)
- setState('waiting')
- } catch (err: unknown) {
- const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '获取二维码失败'
- toast.error(msg)
- } finally {
- setBusy(false)
- }
- }
-
- return (
-
-
- {sess && (
-
-

-
- {state === 'scanned' ? '已扫描,请在手机上确认登录…' : '请使用 115 手机 App 扫描二维码…'}
-
-
- )}
-
- )
-}
diff --git a/web/src/pages/RecycleBinPage.tsx b/web/src/pages/RecycleBinPage.tsx
deleted file mode 100644
index 232050f..0000000
--- a/web/src/pages/RecycleBinPage.tsx
+++ /dev/null
@@ -1,179 +0,0 @@
-import { useEffect, useState } from 'react'
-import { Link } from 'react-router-dom'
-import toast from 'react-hot-toast'
-import { Home, RotateCcw, Square, CheckSquare, Trash2 } from 'lucide-react'
-
-import { recycleAPI } from '../api/recycle'
-import { confirmAction } from '../components/confirmAction'
-import type { Media } from '../types'
-
-export function RecycleBinPage() {
- const [items, setItems] = useState([])
- const [loading, setLoading] = useState(true)
- const [selectedIds, setSelectedIds] = useState([])
- const [batchBusy, setBatchBusy] = useState('')
-
- const refresh = () =>
- recycleAPI
- .list()
- .then((rows) => {
- setItems(rows)
- setSelectedIds((current) => current.filter((id) => rows.some((item) => item.id === id)))
- })
- .finally(() => setLoading(false))
-
- useEffect(() => {
- refresh().catch(() => undefined)
- }, [])
-
- const allSelected = items.length > 0 && selectedIds.length === items.length
- const toggleAll = () => {
- setSelectedIds(allSelected ? [] : items.map((item) => item.id))
- }
- const toggleOne = (id: string) => {
- setSelectedIds((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id])
- }
- const runBatch = async (mode: 'restore' | 'purge') => {
- if (selectedIds.length === 0) return
- if (mode === 'purge' && !(await confirmAction({
- title: '批量彻底删除记录',
- message: `彻底删除选中的 ${selectedIds.length} 条记录? (磁盘文件保留)`,
- confirmText: '彻底删除',
- }))) return
- setBatchBusy(mode)
- try {
- const result = await runRecycleBatchRequest(mode, selectedIds)
- toast.success(`${mode === 'restore' ? '恢复' : '彻底删除'}完成:${result.applied} 条`)
- setSelectedIds([])
- await refresh()
- } catch (err: unknown) {
- const response = (err as { response?: { status?: number; data?: { error?: string } }; message?: string })?.response
- const msg = response?.data?.error || (response?.status ? `批量操作失败 (${response.status})` : (err as { message?: string })?.message || '批量操作失败')
- toast.error(msg)
- } finally {
- setBatchBusy('')
- }
- }
-
- const runRecycleBatchRequest = async (mode: 'restore' | 'purge', ids: string[]) => {
- try {
- return mode === 'restore'
- ? await recycleAPI.restoreMany(ids)
- : await recycleAPI.purgeMany(ids)
- } catch (err: unknown) {
- const status = (err as { response?: { status?: number } })?.response?.status
- if (status !== 404 && status !== 405) throw err
- let applied = 0
- for (const id of ids) {
- if (mode === 'restore') {
- await recycleAPI.restore(id)
- } else {
- await recycleAPI.purge(id)
- }
- applied += 1
- }
- return { applied, errors: [] as string[] }
- }
- }
-
- return (
-
-
-
-
回收站
-
- 软删除的媒体保留在数据库中,可以恢复。彻底删除不会移除磁盘上的文件,只会从数据库清除条目。
-
-
系统最多保留最新 200 条回收站记录,超过后会自动清理旧记录。
-
-
-
- 返回系统首页
-
-
-
- {loading &&
加载中…
}
- {!loading && items.length === 0 &&
回收站为空。
}
-
- {items.length > 0 && (
-
-
-
- 已选择 {selectedIds.length} / {items.length} 条
-
-
-
-
-
-
-
-
-
- |
-
- |
- 标题 |
- 路径 |
- 操作 |
-
-
-
- {items.map((m) => (
-
- |
-
- |
- {m.title} |
-
- {m.path}
- |
-
-
-
- |
-
- ))}
-
-
-
- )}
-
- )
-}
diff --git a/web/src/pages/SchedulerPage.tsx b/web/src/pages/SchedulerPage.tsx
deleted file mode 100644
index d447656..0000000
--- a/web/src/pages/SchedulerPage.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { useEffect, useState } from 'react'
-import toast from 'react-hot-toast'
-import { Clock, Play } from 'lucide-react'
-
-import { schedulerAPI, type JobStatus } from '../api/scheduler'
-
-export function SchedulerPage() {
- const [jobs, setJobs] = useState([])
- const [running, setRunning] = useState('')
-
- const refresh = () => schedulerAPI.status().then(setJobs)
- useEffect(() => {
- refresh().catch(() => undefined)
- const id = window.setInterval(refresh, 5_000)
- return () => window.clearInterval(id)
- }, [])
-
- const runNow = async (name: string) => {
- setRunning(name)
- try {
- await schedulerAPI.run(name)
- toast.success(`${name} 已运行`)
- await refresh()
- } catch (err: unknown) {
- const msg =
- (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
- '运行失败'
- toast.error(msg)
- } finally {
- setRunning('')
- }
- }
-
- return (
-
-
-
-
-
-
-
- | 任务 |
- 间隔 |
- 上次运行 |
- 错误 |
- 操作 |
-
-
-
- {jobs.map((j) => (
-
- | {j.name} |
- {j.interval} |
-
- {j.last_run && new Date(j.last_run).getFullYear() > 2000
- ? new Date(j.last_run).toLocaleString()
- : '尚未运行'}
- |
- {j.last_err || '—'} |
-
-
- |
-
- ))}
-
-
-
-
- )
-}
diff --git a/web/src/pages/SearchExternalResults.tsx b/web/src/pages/SearchExternalResults.tsx
deleted file mode 100644
index 532cf2e..0000000
--- a/web/src/pages/SearchExternalResults.tsx
+++ /dev/null
@@ -1,209 +0,0 @@
-import { useState } from 'react'
-import { CheckCircle2, Info, Rss } from 'lucide-react'
-import toast from 'react-hot-toast'
-
-import type { DiscoverItem } from '../api/discover'
-import type { ExternalMediaResult } from '../api/ai'
-import { imageURL } from '../api/client'
-import { subscriptionsAPI } from '../api/subscriptions'
-import { DiscoverSubscriptionRules } from './DiscoverDetailModalSections'
-import {
- apiErrorMessage,
- buildDiscoverSubscriptionInput,
- initialDiscoverSubscriptionForm,
-} from './discoverDetailModalModel'
-
-export function ExternalResults({
- items,
-}: {
- items: ExternalMediaResult[]
-}) {
- const [detail, setDetail] = useState(null)
- return (
-
-
-
外部数据源
-
- 来自 TMDb / 豆瓣 / Bangumi。电影入队最佳资源;剧集/动漫优先整季或全集包,否则按集批量入队。
-
-
-
- {items.map((item) => {
- const keyword = item.subscribe_keyword || item.title
- const key = `${item.source}:${keyword}`
- return (
-
setDetail(item)}
- onKeyDown={(event) => {
- if (event.key === 'Enter' || event.key === ' ') setDetail(item)
- }}
- className="glass-panel flex cursor-pointer gap-3 !p-3 transition hover:-translate-y-0.5 hover:shadow-lg"
- >
-
- {item.poster_url ? (
-
})
- ) : null}
-
-
-
-
- {item.source}
-
- {item.media_type && {item.media_type}}
- {item.year ? {item.year} : null}
- {item.rating ? ★ {item.rating.toFixed(1)} : null}
-
-
{item.title}
-
- {item.overview || `订阅关键词:${keyword}`}
-
-
-
- {item.in_library ? '本地已入库' : '本地未入库'}
-
- {isSeriesItem(item) ? (
-
- 已有 {item.downloaded_episodes || 0}/{item.total_episodes || '未知'} 集
-
- ) : null}
-
-
-
-
- )
- })}
-
- {detail && (
- setDetail(null)}
- />
- )}
-
- )
-}
-
-function ExternalDetailModal({
- item,
- onClose,
-}: {
- item: ExternalMediaResult
- onClose: () => void
-}) {
- const missing = item.missing_episodes ?? []
- const discoverItem = item as unknown as DiscoverItem
- const [form, setForm] = useState(() => initialDiscoverSubscriptionForm(discoverItem))
- const [formBusy, setFormBusy] = useState(false)
- const submit = async () => {
- setFormBusy(true)
- try {
- const sub = await subscriptionsAPI.create(
- buildDiscoverSubscriptionInput(discoverItem, form, item.source || 'tmdb'),
- )
- if (form.run_now) {
- const run = await subscriptionsAPI.runNow(sub.id)
- toast.success(run.queued > 0 ? `已订阅并加入 ${run.queued} 个下载` : '已订阅,暂未命中可下载资源')
- } else {
- toast.success('已创建订阅')
- }
- onClose()
- } catch (err) {
- toast.error(apiErrorMessage(err, '订阅失败'))
- } finally {
- setFormBusy(false)
- }
- }
- return (
-
-
event.stopPropagation()}>
-
-
- {item.poster_url ? (
-
})
- ) : (
-
-
-
- )}
-
-
-
-
- {item.source}
- {item.media_type ? {item.media_type} : null}
- {item.year ? {item.year} : null}
- {item.rating ? ★ {item.rating.toFixed(1)} : null}
-
-
{item.title}
-
{item.overview || '暂无简介。'}
-
-
-
-
-
-
-
-
- {isSeriesItem(item) && (
-
-
缺失情况
- {missing.length > 0 ? (
-
- {missing.slice(0, 80).map((episode) => (
- 第 {episode} 集
- ))}
- {missing.length > 80 ? 还有 {missing.length - 80} 集… : null}
-
- ) : item.in_library && item.total_episodes ? (
-
已完整入库
- ) : (
-
总集数未知,订阅时会跳过本地已有单集,优先补新集。
- )}
-
- )}
-
-
setForm((current) => ({ ...current, ...patch }))}
- onSubmit={submit}
- />
-
-
-
-
-
-
-
-
- )
-}
-
-function StatusBox({ label, value, ok }: { label: string; value: string; ok?: boolean }) {
- return (
-
- )
-}
-
-function isSeriesItem(item: ExternalMediaResult) {
- return ['tv', 'anime', 'variety'].includes((item.media_type || '').toLowerCase())
-}
diff --git a/web/src/pages/SearchHeader.tsx b/web/src/pages/SearchHeader.tsx
deleted file mode 100644
index 7610852..0000000
--- a/web/src/pages/SearchHeader.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { Sparkles } from 'lucide-react'
-
-type SearchHeaderProps = {
- aiOn: boolean
- aiAvailable: boolean
- onToggleAI: () => void
-}
-
-export function SearchHeader({ aiOn, aiAvailable, onToggleAI }: SearchHeaderProps) {
- return (
-
- 搜索
-
-
- )
-}
diff --git a/web/src/pages/SearchInputBar.tsx b/web/src/pages/SearchInputBar.tsx
deleted file mode 100644
index bff69b9..0000000
--- a/web/src/pages/SearchInputBar.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import type { ChangeEvent, FormEvent } from 'react'
-
-type SearchInputBarProps = {
- aiOn: boolean
- query: string
- onQueryChange: (query: string) => void
- onAISubmit: (event: FormEvent) => void
-}
-
-export function SearchInputBar({ aiOn, query, onQueryChange, onAISubmit }: SearchInputBarProps) {
- if (aiOn) {
- return (
-
- )
- }
-
- return (
- ) => onQueryChange(e.target.value)}
- />
- )
-}
diff --git a/web/src/pages/SearchLocalResults.tsx b/web/src/pages/SearchLocalResults.tsx
deleted file mode 100644
index 0a4a848..0000000
--- a/web/src/pages/SearchLocalResults.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { MediaCard } from '../components/MediaCard'
-import type { SeriesCard } from '../utils/groupSeries'
-import { seriesCardLink } from '../utils/groupSeries'
-
-type SearchLocalResultsProps = {
- localCards: SeriesCard[]
- itemCount: number
- searchTotal: number
- loading: boolean
-}
-
-export function SearchLocalResults({
- localCards,
- itemCount,
- searchTotal,
- loading,
-}: SearchLocalResultsProps) {
- if (localCards.length === 0) return null
-
- return (
- <>
-
- 本地媒体库 · {localCards.length} 个合集 / {itemCount} 个条目
- {loading && searchTotal > itemCount ? ` · 正在加载全部结果 ${itemCount}/${searchTotal}` : ''}
-
-
- {localCards.map((card) => (
-
- ))}
-
- >
- )
-}
diff --git a/web/src/pages/SearchPage.tsx b/web/src/pages/SearchPage.tsx
deleted file mode 100644
index 27ee7bf..0000000
--- a/web/src/pages/SearchPage.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import { ExternalResults } from './SearchExternalResults'
-import { SearchHeader } from './SearchHeader'
-import { SearchInputBar } from './SearchInputBar'
-import { SearchLocalResults } from './SearchLocalResults'
-import { SearchStatusPanels } from './SearchStatusPanels'
-import { useSearchPage } from './useSearchPage'
-
-export function SearchPage() {
- const search = useSearchPage()
-
- return (
-
-
search.setAiOn((on) => !on)}
- />
-
-
-
- {search.intent && (
-
- AI 解析:
- {JSON.stringify(search.intent)}
-
- )}
-
-
-
-
-
- {search.externalItems.length > 0 && (
-
- )}
-
- )
-}
diff --git a/web/src/pages/SearchStatusPanels.tsx b/web/src/pages/SearchStatusPanels.tsx
deleted file mode 100644
index 00efd5a..0000000
--- a/web/src/pages/SearchStatusPanels.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-type SearchStatusPanelsProps = {
- loading: boolean
- error: string
- showIdle: boolean
- showEmpty: boolean
-}
-
-export function SearchStatusPanels({
- loading,
- error,
- showIdle,
- showEmpty,
-}: SearchStatusPanelsProps) {
- return (
- <>
- {loading && (
-
-
- 搜索中…
-
- )}
-
- {error && (
- {error}
- )}
-
- {showIdle && (
-
-
输入关键词开始搜索
-
支持电影、电视剧、动漫等媒体内容的快速搜索
-
- )}
-
- {showEmpty && (
-
-
未找到匹配的媒体
-
尝试其他关键词,或者添加媒体库后执行扫描
-
- )}
- >
- )
-}
diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx
index 310d564..285a57b 100644
--- a/web/src/pages/SettingsPage.tsx
+++ b/web/src/pages/SettingsPage.tsx
@@ -1,24 +1,37 @@
import { FormEvent, useEffect, useState } from 'react'
+import { useSearchParams } from 'react-router-dom'
import { Loader2, Save, SettingsIcon } from 'lucide-react'
import toast from 'react-hot-toast'
import { adminAPI } from '../api/admin'
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 { SystemUpdatePanel } from './SystemUpdatePanel'
import { ALL_KEYS, GROUPS } from './settingsGroups'
export function SettingsPage() {
- const [activeGroup, setActiveGroup] = useState(GROUPS[0].key)
+ const [searchParams] = useSearchParams()
+ const [activeGroup, setActiveGroup] = useState(() => {
+ const fromQuery = searchParams.get('group')
+ return GROUPS.some((g) => g.key === fromQuery) ? (fromQuery as string) : GROUPS[0].key
+ })
const [values, setValues] = useState>({})
const [dirty, setDirty] = useState>(new Set())
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [libraries, setLibraries] = useState([])
+ useEffect(() => {
+ const fromQuery = searchParams.get('group')
+ if (fromQuery && GROUPS.some((g) => g.key === fromQuery)) {
+ setActiveGroup(fromQuery)
+ }
+ }, [searchParams])
+
const refresh = async () => {
setLoading(true)
try {
@@ -108,10 +121,11 @@ export function SettingsPage() {
{!loading && (
- {group.key === 'system-update' &&
}
+ {group.key === 'library' &&
}
+ {group.key === 'api-configs' &&
}
{group.key === 'recognition-words' &&
}
{group.key === 'adult' &&
}
- {group.key !== 'adult' && group.items.length > 0 && (
+ {group.key !== 'adult' && group.key !== 'library' && group.items.length > 0 && (