refactor: split modules and harden scraping workflows

This commit is contained in:
ShukeBta
2026-06-24 11:59:18 +08:00
parent efca3cbe69
commit 192f35d9fa
470 changed files with 47957 additions and 33839 deletions
+23 -262
View File
@@ -1,108 +1,11 @@
import { Component, Suspense, lazy, type ErrorInfo, type ReactNode } from 'react'
import { Component, Suspense, type ErrorInfo, type ReactNode } from 'react'
import { Navigate, Route, Routes } from 'react-router-dom'
import { appRoutes, type AppRoute } from './appRoutes'
import { Layout } from './components/Layout'
import { RequireAdmin, RequireAuth } from './components/RequireAuth'
import { LoginPage } from './pages/LoginPage'
// Lazy-loaded routes — the login screen and the layout shell ship in the
// initial bundle; everything else is fetched on first navigation.
const HomePage = lazy(() => import('./pages/HomePage').then((m) => ({ default: m.HomePage })))
const LibraryPage = lazy(() =>
import('./pages/LibraryPage').then((m) => ({ default: m.LibraryPage })),
)
const LibrariesPage = lazy(() =>
import('./pages/LibrariesPage').then((m) => ({ default: m.LibrariesPage })),
)
const SearchPage = lazy(() =>
import('./pages/SearchPage').then((m) => ({ default: m.SearchPage })),
)
const FavouritesPage = lazy(() =>
import('./pages/FavouritesPage').then((m) => ({ default: m.FavouritesPage })),
)
const PlaylistsPage = lazy(() =>
import('./pages/PlaylistsPage').then((m) => ({ default: m.PlaylistsPage })),
)
const PlaylistDetailPage = lazy(() =>
import('./pages/PlaylistDetailPage').then((m) => ({ default: m.PlaylistDetailPage })),
)
const MediaDetailPage = lazy(() =>
import('./pages/MediaDetailPage').then((m) => ({ default: m.MediaDetailPage })),
)
const PlayerPage = lazy(() =>
import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage })),
)
const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })))
const DownloadsPage = lazy(() =>
import('./pages/DownloadsPage').then((m) => ({ default: m.DownloadsPage })),
)
const SubscriptionsPage = lazy(() =>
import('./pages/SubscriptionsPage').then((m) => ({ default: m.SubscriptionsPage })),
)
const ProfilePage = lazy(() =>
import('./pages/ProfilePage').then((m) => ({ default: m.ProfilePage })),
)
const StatsPage = lazy(() => import('./pages/StatsPage').then((m) => ({ default: m.StatsPage })))
const DiscoverPage = lazy(() =>
import('./pages/DiscoverPage').then((m) => ({ default: m.DiscoverPage })),
)
const TasksPage = lazy(() => import('./pages/TasksPage').then((m) => ({ default: m.TasksPage })))
const RecycleBinPage = lazy(() =>
import('./pages/RecycleBinPage').then((m) => ({ default: m.RecycleBinPage })),
)
const DlnaPage = lazy(() => import('./pages/DlnaPage').then((m) => ({ default: m.DlnaPage })))
const FileManagerPage = lazy(() =>
import('./pages/FileManagerPage').then((m) => ({ default: m.FileManagerPage })),
)
const StoragePage = lazy(() =>
import('./pages/StoragePage').then((m) => ({ default: m.StoragePage })),
)
const DuplicatesPage = lazy(() =>
import('./pages/DuplicatesPage').then((m) => ({ default: m.DuplicatesPage })),
)
const SchedulerPage = lazy(() =>
import('./pages/SchedulerPage').then((m) => ({ default: m.SchedulerPage })),
)
const WatchHistoryPage = lazy(() =>
import('./pages/WatchHistoryPage').then((m) => ({ default: m.WatchHistoryPage })),
)
const PosterWallPage = lazy(() =>
import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })),
)
const SitesPage = lazy(() =>
import('./pages/SitesPage').then((m) => ({ default: m.SitesPage })),
)
const SiteSearchPage = lazy(() =>
import('./pages/SiteSearchPage').then((m) => ({ default: m.SiteSearchPage })),
)
const AIAssistantPage = lazy(() =>
import('./pages/AIAssistantPage').then((m) => ({ default: m.AIAssistantPage })),
)
const StrmPage = lazy(() =>
import('./pages/StrmPage').then((m) => ({ default: m.StrmPage })),
)
const ProfileManagementPage = lazy(() =>
import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })),
)
const NotifyChannelsPage = lazy(() =>
import('./pages/NotifyChannelsPage').then((m) => ({ default: m.NotifyChannelsPage })),
)
const SettingsPage = lazy(() =>
import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })),
)
const AssistantChatPage = lazy(() =>
import('./pages/AssistantChatPage').then((m) => ({ default: m.AssistantChatPage })),
)
const DownloadClientsPage = lazy(() =>
import('./pages/DownloadClientsPage').then((m) => ({ default: m.DownloadClientsPage })),
)
const StorageConfigPage = lazy(() =>
import('./pages/StorageConfigPage').then((m) => ({ default: m.StorageConfigPage })),
)
const LicensePage = lazy(() =>
import('./pages/LicensePage').then((m) => ({ default: m.LicensePage })),
)
const Loading = () => <p className="px-6 py-8 text-sand-500">加载中…</p>
class AppErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
@@ -140,177 +43,35 @@ class AppErrorBoundary extends Component<{ children: ReactNode }, { hasError: bo
}
}
function routeElement(route: AppRoute) {
if (!route.adminOnly) return route.element
return <RequireAdmin>{route.element}</RequireAdmin>
}
export default function App() {
return (
<AppErrorBoundary>
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route index element={<HomePage />} />
<Route path="libraries" element={<LibrariesPage />} />
<Route path="library/:id" element={<LibraryPage />} />
<Route path="discover" element={<DiscoverPage />} />
<Route path="search" element={<SearchPage />} />
<Route path="favourites" element={<FavouritesPage />} />
<Route path="playlists" element={<PlaylistsPage />} />
<Route path="playlist/:id" element={<PlaylistDetailPage />} />
<Route path="media/:id" element={<MediaDetailPage />} />
<Route path="play/:id" element={<PlayerPage />} />
<Route path="downloads" element={<DownloadsPage />} />
<Route path="subscriptions" element={<SubscriptionsPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="dlna" element={<DlnaPage />} />
<Route path="history" element={<WatchHistoryPage />} />
<Route path="poster-wall" element={<PosterWallPage />} />
<Route path="site-search" element={<SiteSearchPage />} />
<Route path="ai" element={<AIAssistantPage />} />
<Route path="play-profiles" element={<ProfileManagementPage />} />
<Route path="/login" element={<LoginPage />} />
<Route
path="sites"
path="/"
element={
<RequireAdmin>
<SitesPage />
</RequireAdmin>
<RequireAuth>
<Layout />
</RequireAuth>
}
/>
<Route
path="files"
element={
<RequireAdmin>
<FileManagerPage />
</RequireAdmin>
}
/>
<Route
path="storage"
element={
<RequireAdmin>
<StoragePage />
</RequireAdmin>
}
/>
<Route
path="duplicates"
element={
<RequireAdmin>
<DuplicatesPage />
</RequireAdmin>
}
/>
<Route
path="scheduler"
element={
<RequireAdmin>
<SchedulerPage />
</RequireAdmin>
}
/>
<Route
path="api-configs"
element={<Navigate to="/admin?tab=api" replace />}
/>
<Route
path="tasks"
element={
<RequireAdmin>
<TasksPage />
</RequireAdmin>
}
/>
<Route
path="recycle"
element={
<RequireAdmin>
<RecycleBinPage />
</RequireAdmin>
}
/>
<Route
path="strm"
element={
<RequireAdmin>
<StrmPage />
</RequireAdmin>
}
/>
<Route
path="tools"
element={<Navigate to="/storage" replace />}
/>
<Route
path="notify-channels"
element={
<RequireAdmin>
<NotifyChannelsPage />
</RequireAdmin>
}
/>
<Route
path="settings"
element={
<RequireAdmin>
<SettingsPage />
</RequireAdmin>
}
/>
<Route
path="assistant"
element={
<RequireAdmin>
<AssistantChatPage />
</RequireAdmin>
}
/>
<Route
path="download-clients"
element={
<RequireAdmin>
<DownloadClientsPage />
</RequireAdmin>
}
/>
<Route
path="license"
element={
<RequireAdmin>
<LicensePage />
</RequireAdmin>
}
/>
<Route
path="storage-config"
element={
<RequireAdmin>
<StorageConfigPage />
</RequireAdmin>
}
/>
<Route
path="stats"
element={
<RequireAdmin>
<StatsPage />
</RequireAdmin>
}
/>
<Route
path="admin"
element={
<RequireAdmin>
<AdminPage />
</RequireAdmin>
}
/>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
>
{appRoutes.map((route) => (
<Route
key={route.index ? 'index' : route.path}
index={route.index}
path={route.path}
element={routeElement(route)}
/>
))}
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</AppErrorBoundary>
+1
View File
@@ -24,6 +24,7 @@ export interface ExternalMediaResult {
bangumi_id?: number
douban_id?: string
subscribe_keyword: string
subscribe_aliases?: string[]
total_episodes?: number
downloaded_episodes?: number
local_media_count?: number
-58
View File
@@ -1,58 +0,0 @@
// API 配置 API 模块
import { api } from './client'
import type { ApiConfig, ApiProvider } from '../types'
// 获取所有 API 配置
export async function listApiConfigs(): Promise<ApiConfig[]> {
const resp = await api.get<ApiConfig[]>('/api-config')
return resp.data as unknown as ApiConfig[]
}
// 获取提供者列表
export async function getProviders(): Promise<ApiProvider[]> {
const resp = await api.get<ApiProvider[]>('/api-config/providers/list')
return resp.data as unknown as ApiProvider[]
}
// 获取指定提供者的配置
export async function getApiConfig(provider: string): Promise<ApiConfig> {
const resp = await api.get<ApiConfig>(`/api-config/${provider}`)
return resp.data as unknown as ApiConfig
}
// 获取生效的配置
export async function getEffectiveConfig(provider: string): Promise<ApiConfig> {
const resp = await api.get<ApiConfig>(`/api-config/${provider}/effective`)
return resp.data as unknown as ApiConfig
}
// 更新 API 配置
export interface UpdateApiConfigRequest {
api_key?: string
base_url?: string
extra?: string
enabled?: boolean
}
export async function updateApiConfig(
provider: string,
data: UpdateApiConfigRequest
): Promise<ApiConfig> {
const resp = await api.post<ApiConfig>(`/api-config/${provider}`, data)
return resp.data as unknown as ApiConfig
}
// 删除 API 配置
export async function deleteApiConfig(provider: string): Promise<void> {
await api.delete(`/api-config/${provider}`)
}
// 测试 API 连接
export interface TestApiConfigResponse {
result: 'success' | 'error' | 'invalid' | 'unknown'
}
export async function testApiConfig(provider: string): Promise<TestApiConfigResponse> {
const resp = await api.post<TestApiConfigResponse>(`/api-config/${provider}/test`)
return resp.data as unknown as TestApiConfigResponse
}
+20 -4
View File
@@ -151,11 +151,13 @@ export function hlsURL(mediaId: string): string {
// imageURL converts a remote poster URL into a same-origin proxy URL so it
// can never be blocked by CORS / GFW. Empty strings pass through unchanged.
export function imageURL(remote?: string): string {
export function imageURL(remote?: string, version?: string): string {
if (!remote) return ''
if (remote.startsWith('/api/img')) return remote
if (remote.startsWith('/api/')) return withQuery(remote, tokenQuery())
return `/api/img?url=${encodeURIComponent(remote)}&${tokenQuery()}`
const versionQuery = version ? `v=${encodeURIComponent(version)}` : ''
if (remote.startsWith('/api/img')) return withQuery(withoutAuthQuery(remote), versionQuery)
if (remote.startsWith('/api/cloud/play/')) return withQuery(withoutAuthQuery(remote), versionQuery)
if (remote.startsWith('/api/')) return withQuery(withQuery(remote, tokenQuery()), versionQuery)
return withQuery(`/api/img?url=${encodeURIComponent(remote)}`, versionQuery)
}
function withQuery(url: string, query: string): string {
@@ -163,6 +165,20 @@ function withQuery(url: string, query: string): string {
return `${url}${url.includes('?') ? '&' : '?'}${query}`
}
function withoutAuthQuery(url: string): string {
const hashIndex = url.indexOf('#')
const beforeHash = hashIndex >= 0 ? url.slice(0, hashIndex) : url
const hash = hashIndex >= 0 ? url.slice(hashIndex) : ''
const queryIndex = beforeHash.indexOf('?')
if (queryIndex < 0) return url
const path = beforeHash.slice(0, queryIndex)
const params = new URLSearchParams(beforeHash.slice(queryIndex + 1))
;['token', 'api_key', 'apiKey', 'ApiKey'].forEach((key) => params.delete(key))
const query = params.toString()
return `${path}${query ? `?${query}` : ''}${hash}`
}
// getToken returns the current auth token
export function getToken(): string | null {
return useAuthStore.getState().token
+1
View File
@@ -16,6 +16,7 @@ export interface DiscoverItem extends Partial<Media> {
year?: number
rating?: number
subscribe_keyword?: string
subscribe_aliases?: string[]
}
export interface DiscoverSection {
-17
View File
@@ -1,17 +0,0 @@
import { api } from './client'
import type { DiscoverItem, DiscoverSection } from '../types'
// discoverExtraAPI wraps the Vue-style multi-section feed used by the
// React DiscoverPage rails. Use the existing /discover/trending and
// /discover/popular helpers for the simple cases.
export const discoverExtraAPI = {
sections: () =>
api.get<{ sections: DiscoverSection[] }>('/discover/sections').then((r) => r.data.sections),
feed: (sectionKeys: string[]) =>
api
.get<Record<string, DiscoverItem[] | null>>('/discover/feed', {
params: { sections: sectionKeys.join(',') },
})
.then((r) => r.data),
}
+66 -10
View File
@@ -1,5 +1,6 @@
import { api, BATCH_REQUEST_TIMEOUT, LONG_REQUEST_TIMEOUT } from './client'
import type { Library, Media, ScanResult } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
export interface MediaPage {
items: Media[]
@@ -15,6 +16,13 @@ export interface MediaSearchPage {
page_size?: number
}
export interface SeriesPage {
items: SeriesCard[]
total: number
page: number
page_size: number
}
export interface ManualScrapeCandidate {
source: string
media_type?: string
@@ -35,6 +43,15 @@ export interface ManualScrapeCandidate {
nsfw?: boolean
}
export interface ScrapeOptions {
episode_artwork?: boolean
episode_images?: boolean
refresh_matched?: boolean
include_matched?: boolean
}
export type ManualScrapeApplyOptions = ScrapeOptions
export interface MediaMetadataUpdate {
title?: string
original_name?: string
@@ -71,26 +88,51 @@ export const libraryAPI = {
scan: (id: string) =>
api.post<ScanResult>(`/libraries/${id}/scan`, null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data),
scrape: (id: string) =>
api.post(`/libraries/${id}/scrape`, null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data),
scrape: (id: string, options?: ScrapeOptions) =>
api.post(`/libraries/${id}/scrape`, options ?? null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data),
listMedia: (id: string, page = 1, pageSize = 50) =>
listMedia: (id: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean }) =>
api
.get<MediaPage>(`/libraries/${id}/media`, {
params: {
page,
page_size: pageSize,
group_versions: options?.groupVersions === false ? 0 : undefined,
},
timeout: LONG_REQUEST_TIMEOUT,
})
.then((r) => r.data),
listSeries: (id: string, page = 1, pageSize = 500) =>
api
.get<SeriesPage>(`/libraries/${id}/series`, {
params: { page, page_size: pageSize },
timeout: LONG_REQUEST_TIMEOUT,
})
.then((r) => r.data),
listSeriesEpisodes: (id: string, key: string) =>
api
.get<{ items: Media[]; total: number }>(`/libraries/${id}/series/episodes`, {
params: { key },
timeout: LONG_REQUEST_TIMEOUT,
})
.then((r) => r.data),
}
export const mediaAPI = {
search: (q: string, limit = 50) =>
api.get<MediaSearchPage>('/media', { params: { q, limit } }).then((r) => r.data),
searchPage: (q: string, page = 1, pageSize = 50) =>
searchPage: (q: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean }) =>
api
.get<MediaSearchPage>('/media', {
params: { q, page, page_size: pageSize },
params: {
q,
page,
page_size: pageSize,
group_versions: options?.groupVersions === false ? 0 : undefined,
},
timeout: LONG_REQUEST_TIMEOUT,
})
.then((r) => r.data),
@@ -105,11 +147,25 @@ export const mediaAPI = {
.get<{ items: ManualScrapeCandidate[] }>(`/media/${id}/scrape/search`, { params })
.then((r) => r.data.items),
applyManualScrape: (id: string, match: ManualScrapeCandidate) =>
api.post<Media>(`/media/${id}/scrape/apply`, match, { timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
applyManualScrapeBatch: (mediaIDs: string[], match: ManualScrapeCandidate) =>
applyManualScrape: (id: string, match: ManualScrapeCandidate, options?: ManualScrapeApplyOptions) =>
api
.post<{ applied: number; errors?: string[] }>('/media/scrape/apply', { media_ids: mediaIDs, match }, { timeout: BATCH_REQUEST_TIMEOUT })
.post<Media>(
`/media/${id}/scrape/apply`,
episodeImageOption(options) === undefined ? match : { ...match, episode_images: episodeImageOption(options) },
{ timeout: LONG_REQUEST_TIMEOUT },
)
.then((r) => r.data),
applyManualScrapeBatch: (mediaIDs: string[], match: ManualScrapeCandidate, options?: ManualScrapeApplyOptions) =>
api
.post<{ applied: number; errors?: string[] }>(
'/media/scrape/apply',
{ media_ids: mediaIDs, match, episode_images: episodeImageOption(options) },
{ timeout: BATCH_REQUEST_TIMEOUT },
)
.then((r) => r.data),
}
function episodeImageOption(options?: ScrapeOptions): boolean | undefined {
return options?.episode_images ?? options?.episode_artwork
}
-19
View File
@@ -1,19 +0,0 @@
import { api } from './client'
import type { Media } from '../types'
// Auxiliary media surfaces used by the home page rails and the admin
// dashboard "library composition" card.
export const mediaExtraAPI = {
recent: (limit = 12) =>
api.get<Media[]>('/media/recent', { params: { limit } }).then((r) => r.data),
stats: () =>
api
.get<{
by_type: { movies: number; tv: number; anime: number; music: number; unscraped: number }
total: number
total_size: number
total_seconds: number
}>('/media/stats')
.then((r) => r.data),
}
-53
View File
@@ -1,53 +0,0 @@
import { api } from './client'
export interface UserPermission {
user_id: string
can_play_media: boolean
can_favorite: boolean
can_view_history: boolean
can_view_dashboard: boolean
can_view_discover: boolean
can_manage_downloads: boolean
can_manage_subscriptions: boolean
can_manage_sites: boolean
can_manage_files: boolean
can_manage_strm: boolean
can_cast: boolean
can_use_ai_assistant: boolean
can_access_settings: boolean
updated_at: string
}
type ApiEnvelope<T> = {
code?: number
message?: string
data?: T
}
function unwrap<T>(raw: T | ApiEnvelope<T>): T {
if (raw && typeof raw === 'object' && 'data' in raw && (raw as ApiEnvelope<T>).data !== undefined) {
return (raw as ApiEnvelope<T>).data as T
}
return raw as T
}
export const permissionsAPI = {
// Caller's effective permissions; admins always get the all-true set.
mine: () => api.get<UserPermission | ApiEnvelope<UserPermission>>('/auth/permissions').then((r) => unwrap(r.data)),
// Admin endpoints
get: (userID: string) =>
api
.get<UserPermission | ApiEnvelope<UserPermission>>(`/admin/users/${userID}/permissions`)
.then((r) => unwrap(r.data)),
save: (userID: string, p: UserPermission) =>
api
.put<UserPermission | ApiEnvelope<UserPermission>>(`/admin/users/${userID}/permissions`, p)
.then((r) => unwrap(r.data)),
reset: (userID: string) =>
api
.post<UserPermission | ApiEnvelope<UserPermission>>(`/admin/users/${userID}/permissions/reset`)
.then((r) => unwrap(r.data)),
}
+13 -2
View File
@@ -27,6 +27,11 @@ export interface ExternalPlayer {
url: string
}
function publicOriginHeader() {
if (typeof window === 'undefined' || !window.location?.origin) return undefined
return { 'X-MediaStation-Public-Origin': window.location.origin }
}
export const playbackAPI = {
recordProgress: (mediaId: string, positionMs: number, durationMs: number) =>
api
@@ -70,9 +75,15 @@ export const playbackAPI = {
externalPlayers: (mediaId: string) =>
api
.get<{ players: ExternalPlayer[]; url?: string }>(`/playback/${mediaId}/external-players`)
.get<{ players: ExternalPlayer[]; url?: string }>(`/playback/${mediaId}/external-players`, {
headers: publicOriginHeader(),
})
.then((r) => r.data),
externalURL: (mediaId: string) =>
api.get<{ url: string }>(`/playback/${mediaId}/external-url`).then((r) => r.data),
api
.get<{ url: string }>(`/playback/${mediaId}/external-url`, {
headers: publicOriginHeader(),
})
.then((r) => r.data),
}
-14
View File
@@ -1,14 +0,0 @@
import { api } from './client'
import type { Media } from '../types'
export interface SeasonGroup {
season: number
episodes: Media[]
}
export const seriesAPI = {
seasons: (libraryID: string) =>
api
.get<{ seasons: SeasonGroup[] }>(`/libraries/${libraryID}/seasons`)
.then((r) => r.data.seasons),
}
-40
View File
@@ -1,40 +0,0 @@
import { api } from './client'
import type { Hardware, Library, Media } from '../types'
// statsExtraAPI exposes the admin dashboard surfaces beyond /stats.
export const statsExtraAPI = {
overview: () =>
api
.get<{
libraries: number
media_count: number
users_count: number
total_size: number
total_seconds: number
generated_at: string
}>('/stats/overview')
.then((r) => r.data),
trend: (days = 14) =>
api
.get<{ trend: { day: string; count: number }[]; days: number }>('/stats/trend', {
params: { days },
})
.then((r) => r.data),
topContent: (limit = 10) =>
api
.get<{
items: { media: Media; play_count: number; last_played: string }[]
}>('/stats/top-content', { params: { limit } })
.then((r) => r.data),
libraries: () =>
api
.get<{
libraries: { library: Library; item_count: number; total_size: number }[]
}>('/stats/libraries')
.then((r) => r.data),
monitor: () => api.get<Hardware>('/stats/monitor').then((r) => r.data),
}
+15 -1
View File
@@ -1,6 +1,6 @@
import { api, BATCH_REQUEST_TIMEOUT, LONG_REQUEST_TIMEOUT } from './client'
export type StorageType = 'alist' | 'openlist' | 's3' | 'webdav' | 'cloud115' | 'quark' | 'clouddrive2'
export type StorageType = 'alist' | 'openlist' | 'webdav' | 'cloud115' | 'clouddrive2'
export interface CloudEntry {
id: string
@@ -141,6 +141,20 @@ export const cloudAPI = {
})
.then((r) => r.data),
mkdir: (type: StorageType, dir: string, name: string) =>
api
.post<{ entry: CloudEntry }>(`/admin/cloud/${type}/mkdir`, { dir, name }, {
timeout: LONG_REQUEST_TIMEOUT,
})
.then((r) => r.data),
rename: (type: StorageType, ref: string, name: string) =>
api
.put<{ entry: CloudEntry }>(`/admin/cloud/${type}/rename`, { ref, name }, {
timeout: LONG_REQUEST_TIMEOUT,
})
.then((r) => r.data),
import: (type: StorageType, ref: string, name: string, size: number) =>
api
.post(`/admin/cloud/${type}/import`, { ref, name, size })
+1
View File
@@ -15,6 +15,7 @@ export type GenerateSTRMResult = {
generated: number
updated: number
skipped: number
cleaned: number
errors?: string[]
items?: Array<{
media_id: string
+24
View File
@@ -18,6 +18,28 @@ export function buildSiteSearchFeedURL(keyword: string, source?: string, aliases
return `site-search://search?${params.toString()}`
}
export function buildSubscriptionAliases(item: {
title?: string
original_name?: string
subscribe_keyword?: string
subscribe_aliases?: string[]
year?: number
}) {
const withYear = (value?: string) => {
const title = (value || '').trim()
if (!title) return ''
return item.year && item.year > 0 ? `${title} ${item.year}` : title
}
return [
...(item.subscribe_aliases || []),
item.title || '',
item.original_name || '',
withYear(item.title),
withYear(item.original_name),
item.subscribe_keyword || '',
]
}
export const subscriptionsAPI = {
list: () =>
api.get<{ items: Subscription[] }>('/subscriptions').then((r) => r.data.items),
@@ -38,6 +60,8 @@ export const subscriptionsAPI = {
poster_url?: string
backdrop_url?: string
overview?: string
original_name?: string
year?: number
resolution?: string
quality?: string
effects?: string
+6 -4
View File
@@ -1,4 +1,5 @@
import { api } from './client'
import type { ScrapeOptions } from './library'
// toolsAPI groups admin-only endpoints that don't fit the other domain
// modules: organizing media files into the canonical naming layout, and
@@ -55,6 +56,7 @@ export const toolsAPI = {
organized: number
skipped: number
replaced?: number
reclassified?: number
source_path?: string
dest_path?: string
errors?: string[]
@@ -101,15 +103,15 @@ export const toolsAPI = {
// repairAndRescrapeAll 触发「全库修复+重刮」:先从媒体路径中的
// {tmdb-N}/{bangumi-N} 占位符回填缺失/错误的外部 ID,再批量重刮整库。
// 后端异步执行,立即返回;进度通过 WS "scrape" topic 推送。
repairAndRescrapeAll: () =>
repairAndRescrapeAll: (options?: ScrapeOptions) =>
api
.post<{ status: string }>('/admin/media/repair-rescrape', {})
.post<{ status: string }>('/admin/media/repair-rescrape', options ?? {})
.then((r) => r.data),
// repairAndRescrapeLibrary 触发「单库修复+重刮」:只对指定媒体库回填占位符
// 外部 ID 并重刮,不影响其它库。后端异步执行,进度通过 WS "scrape" topic 推送。
repairAndRescrapeLibrary: (libraryID: string) =>
repairAndRescrapeLibrary: (libraryID: string, options?: ScrapeOptions) =>
api
.post<{ status: string }>(`/admin/libraries/${libraryID}/repair-rescrape`, {})
.post<{ status: string }>(`/admin/libraries/${libraryID}/repair-rescrape`, options ?? {})
.then((r) => r.data),
}
+106
View File
@@ -0,0 +1,106 @@
/* eslint-disable react-refresh/only-export-components */
import { lazy, type ReactElement } from 'react'
import { Navigate } from 'react-router-dom'
const HomePage = lazy(() => import('./pages/HomePage').then((m) => ({ default: m.HomePage })))
const LibraryPage = lazy(() => import('./pages/LibraryPage').then((m) => ({ default: m.LibraryPage })))
const LibrariesPage = lazy(() => import('./pages/LibrariesPage').then((m) => ({ default: m.LibrariesPage })))
const SearchPage = lazy(() => import('./pages/SearchPage').then((m) => ({ default: m.SearchPage })))
const FavouritesPage = lazy(() => import('./pages/FavouritesPage').then((m) => ({ default: m.FavouritesPage })))
const PlaylistsPage = lazy(() => import('./pages/PlaylistsPage').then((m) => ({ default: m.PlaylistsPage })))
const PlaylistDetailPage = lazy(() =>
import('./pages/PlaylistDetailPage').then((m) => ({ default: m.PlaylistDetailPage })),
)
const MediaDetailPage = lazy(() => import('./pages/MediaDetailPage').then((m) => ({ default: m.MediaDetailPage })))
const PlayerPage = lazy(() => import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage })))
const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })))
const DownloadsPage = lazy(() => import('./pages/DownloadsPage').then((m) => ({ default: m.DownloadsPage })))
const SubscriptionsPage = lazy(() =>
import('./pages/SubscriptionsPage').then((m) => ({ default: m.SubscriptionsPage })),
)
const ProfilePage = lazy(() => import('./pages/ProfilePage').then((m) => ({ default: m.ProfilePage })))
const StatsPage = lazy(() => import('./pages/StatsPage').then((m) => ({ default: m.StatsPage })))
const DiscoverPage = lazy(() => import('./pages/DiscoverPage').then((m) => ({ default: m.DiscoverPage })))
const TasksPage = lazy(() => import('./pages/TasksPage').then((m) => ({ default: m.TasksPage })))
const RecycleBinPage = lazy(() => import('./pages/RecycleBinPage').then((m) => ({ default: m.RecycleBinPage })))
const DlnaPage = lazy(() => import('./pages/DlnaPage').then((m) => ({ default: m.DlnaPage })))
const FileManagerPage = lazy(() =>
import('./pages/FileManagerPage').then((m) => ({ default: m.FileManagerPage })),
)
const StoragePage = lazy(() => import('./pages/StoragePage').then((m) => ({ default: m.StoragePage })))
const DuplicatesPage = lazy(() => import('./pages/DuplicatesPage').then((m) => ({ default: m.DuplicatesPage })))
const SchedulerPage = lazy(() => import('./pages/SchedulerPage').then((m) => ({ default: m.SchedulerPage })))
const WatchHistoryPage = lazy(() =>
import('./pages/WatchHistoryPage').then((m) => ({ default: m.WatchHistoryPage })),
)
const PosterWallPage = lazy(() => import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })))
const SitesPage = lazy(() => import('./pages/SitesPage').then((m) => ({ default: m.SitesPage })))
const SiteSearchPage = lazy(() => import('./pages/SiteSearchPage').then((m) => ({ default: m.SiteSearchPage })))
const AIAssistantPage = lazy(() =>
import('./pages/AIAssistantPage').then((m) => ({ default: m.AIAssistantPage })),
)
const StrmPage = lazy(() => import('./pages/StrmPage').then((m) => ({ default: m.StrmPage })))
const ProfileManagementPage = lazy(() =>
import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })),
)
const NotifyChannelsPage = lazy(() =>
import('./pages/NotifyChannelsPage').then((m) => ({ default: m.NotifyChannelsPage })),
)
const SettingsPage = lazy(() => import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })))
const AssistantChatPage = lazy(() =>
import('./pages/AssistantChatPage').then((m) => ({ default: m.AssistantChatPage })),
)
const DownloadClientsPage = lazy(() =>
import('./pages/DownloadClientsPage').then((m) => ({ default: m.DownloadClientsPage })),
)
const StorageConfigPage = lazy(() =>
import('./pages/StorageConfigPage').then((m) => ({ default: m.StorageConfigPage })),
)
const LicensePage = lazy(() => import('./pages/LicensePage').then((m) => ({ default: m.LicensePage })))
export type AppRoute = {
path?: string
index?: boolean
element: ReactElement
adminOnly?: boolean
}
export const appRoutes: AppRoute[] = [
{ index: true, element: <HomePage /> },
{ path: 'libraries', element: <LibrariesPage /> },
{ path: 'library/:id', element: <LibraryPage /> },
{ path: 'discover', element: <DiscoverPage /> },
{ path: 'search', element: <SearchPage /> },
{ path: 'favourites', element: <FavouritesPage /> },
{ path: 'playlists', element: <PlaylistsPage /> },
{ path: 'playlist/:id', element: <PlaylistDetailPage /> },
{ path: 'media/:id', element: <MediaDetailPage /> },
{ path: 'play/:id', element: <PlayerPage /> },
{ path: 'downloads', element: <DownloadsPage /> },
{ path: 'subscriptions', element: <SubscriptionsPage /> },
{ path: 'profile', element: <ProfilePage /> },
{ path: 'dlna', element: <DlnaPage /> },
{ path: 'history', element: <WatchHistoryPage /> },
{ path: 'poster-wall', element: <PosterWallPage /> },
{ path: 'site-search', element: <SiteSearchPage /> },
{ path: 'ai', element: <AIAssistantPage /> },
{ path: 'play-profiles', element: <ProfileManagementPage /> },
{ path: 'api-configs', element: <Navigate to="/admin?tab=api" replace /> },
{ path: 'tools', element: <Navigate to="/storage" replace /> },
{ path: 'sites', element: <SitesPage />, adminOnly: true },
{ path: 'files', element: <FileManagerPage />, adminOnly: true },
{ path: 'storage', element: <StoragePage />, adminOnly: true },
{ path: 'duplicates', element: <DuplicatesPage />, adminOnly: true },
{ path: 'scheduler', element: <SchedulerPage />, adminOnly: true },
{ path: 'tasks', element: <TasksPage />, adminOnly: true },
{ path: 'recycle', element: <RecycleBinPage />, adminOnly: true },
{ path: 'strm', element: <StrmPage />, adminOnly: true },
{ path: 'notify-channels', element: <NotifyChannelsPage />, adminOnly: true },
{ path: 'settings', element: <SettingsPage />, adminOnly: true },
{ path: 'assistant', element: <AssistantChatPage />, adminOnly: true },
{ path: 'download-clients', element: <DownloadClientsPage />, adminOnly: true },
{ path: 'license', element: <LicensePage />, adminOnly: true },
{ path: 'storage-config', element: <StorageConfigPage />, adminOnly: true },
{ path: 'stats', element: <StatsPage />, adminOnly: true },
{ path: 'admin', element: <AdminPage />, adminOnly: true },
]
+1 -1
View File
@@ -3,7 +3,7 @@ import toast from 'react-hot-toast'
import { Eye, KeyRound, Pencil, Save, Trash2, X } from 'lucide-react'
import { apiConfigsAPI, type APIConfig } from '../api/api_configs'
import { confirmAction } from './ConfirmDialog'
import { confirmAction } from './confirmAction'
// Compact inline-editable provider table for use inside AdminPage's "外部API" tab.
export function APIConfigsPanel() {
+2 -17
View File
@@ -1,7 +1,6 @@
import { createRoot } from 'react-dom/client'
import { AlertTriangle } from 'lucide-react'
type ConfirmOptions = {
export type ConfirmOptions = {
title?: string
message: string
confirmText?: string
@@ -9,21 +8,7 @@ type ConfirmOptions = {
danger?: boolean
}
export function confirmAction(options: ConfirmOptions): Promise<boolean> {
return new Promise((resolve) => {
const host = document.createElement('div')
document.body.appendChild(host)
const root = createRoot(host)
const close = (value: boolean) => {
root.unmount()
host.remove()
resolve(value)
}
root.render(<ConfirmDialog options={options} onClose={close} />)
})
}
function ConfirmDialog({
export function ConfirmDialog({
options,
onClose,
}: {
@@ -0,0 +1,57 @@
import { ImageOff, ImagePlus } from 'lucide-react'
type EpisodeArtworkToggleProps = {
checked: boolean
onChange: (checked: boolean) => void
title?: string
className?: string
}
export function EpisodeArtworkToggle({
checked,
onChange,
title = '关闭后仍会写入每集文字元数据,只跳过每集图片',
className = '',
}: EpisodeArtworkToggleProps) {
const Icon = checked ? ImagePlus : ImageOff
const controlStyle = checked
? { backgroundColor: 'var(--app-brand-soft)', borderColor: 'var(--app-brand-border)', color: 'var(--app-brand-text)' }
: undefined
const emphasisStyle = checked
? { backgroundColor: 'var(--app-brand-emphasis)', color: 'var(--app-brand-text)' }
: undefined
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label="获取每集图片"
title={title}
onClick={() => onChange(!checked)}
data-state={checked ? 'on' : 'off'}
style={controlStyle}
className={[
'episode-artwork-toggle group inline-flex h-11 shrink-0 items-center gap-2 rounded-xl border px-3 text-sm font-semibold shadow-sm transition-all duration-200',
checked ? 'episode-artwork-toggle--on' : '',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500',
'hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:scale-[0.98]',
className,
].join(' ')}
>
<span
className="episode-artwork-toggle__icon grid h-7 w-7 place-items-center rounded-lg transition-colors"
style={emphasisStyle}
>
<Icon size={16} />
</span>
<span className="whitespace-nowrap">每集图片</span>
<span
className="episode-artwork-toggle__state rounded-full px-2 py-0.5 text-[11px] font-bold"
style={emphasisStyle}
>
{checked ? '开启' : '关闭'}
</span>
</button>
)
}
+12 -1
View File
@@ -37,7 +37,18 @@ export function GlobalEvents() {
}
}
if (topic === 'scrape' && p.finished) {
toast.success(`刮削完成:成功匹配 ${p.matched ?? 0} 项`)
const processed = Number(p.processed ?? 0)
const matched = Number(p.matched ?? 0)
const failed = Number(p.failed ?? 0)
if (failed > 0) {
toast.error(`刮削完成但有错误:处理 ${processed} 项 · 成功匹配 ${matched} 项 · 失败 ${failed} 项`)
return
}
if (processed > 0) {
toast.success(`刮削完成:处理 ${processed} 项 · 成功匹配 ${matched} 项`)
} else {
toast.success(`刮削完成:没有待刮削媒体 · 成功匹配 ${matched} 项`)
}
}
if (topic === 'subscription') {
const queued = (p.queued as number | undefined) ?? 0
+89 -645
View File
@@ -1,343 +1,75 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import toast from 'react-hot-toast'
import {
Activity, Clock, CloudDownload, Compass,
Cast, Globe, HardDrive, Heart, Home, Image, KeySquare,
ListMusic, LogOut, MessageSquareText, Rss, Search, Trash2,
Settings, Sliders, Sparkles, UserCog,
Library as LibraryIcon, User as UserIcon, ChevronDown, Menu, X
} from 'lucide-react'
import { Menu, MessageSquareText, Search, Sparkles } from 'lucide-react'
import clsx from 'clsx'
import { AppFooter } from './AppFooter'
import { useAuthStore } from '../stores/auth'
import { usePermissionStore } from '../stores/permissions'
import { usePlayProfileStore } from '../stores/playProfile'
import { imageURL } from '../api/client'
import { mediaAPI } from '../api/library'
import { playProfilesAPI } from '../api/play_profiles'
import { requestPIN } from './PinDialog'
import type { Media, PlayProfile } from '../types'
import { groupSeries, seriesCardLink } from '../utils/groupSeries'
import { LayoutSearchBox } from './LayoutSearchBox'
import { LayoutSidebarContent } from './LayoutSidebarContent'
import { LayoutThemeToggle } from './LayoutThemeToggle'
import { LayoutUserMenu } from './LayoutUserMenu'
import { useLayoutSearch } from './useLayoutSearch'
import { useLayoutPermissions } from './useLayoutPermissions'
import { useLayoutProfiles } from './useLayoutProfiles'
import { useLayoutSidebar } from './useLayoutSidebar'
import { useThemeMode } from './useThemeMode'
export function Layout() {
const navigate = useNavigate()
const location = useLocation()
const user = useAuthStore((s) => s.user)
const logout = useAuthStore((s) => s.logout)
const permissions = usePermissionStore((s) => s.permissions)
const isSuper = usePermissionStore((s) => s.isSuper)
const isPermissionLoading = usePermissionStore((s) => s.isLoading)
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
const activeProfileId = usePlayProfileStore((s) => s.activeProfileId)
const setActiveProfile = usePlayProfileStore((s) => s.setActiveProfile)
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
const [isProfileOpen, setIsProfileOpen] = useState(false)
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({ media: true })
const [profiles, setProfiles] = useState<PlayProfile[]>([])
const [searchFocused, setSearchFocused] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [searchItems, setSearchItems] = useState<Media[]>([])
const [searchLoading, setSearchLoading] = useState(false)
const [searchTotal, setSearchTotal] = useState(0)
const [searchError, setSearchError] = useState('')
const searchSeq = useRef(0)
const searchCards = useMemo(() => groupSeries(searchItems).slice(0, 8), [searchItems])
const theme = useThemeMode()
const search = useLayoutSearch({
pathname: location.pathname,
locationSearch: location.search,
navigate,
})
const { can, isAdmin } = useLayoutPermissions(user)
const {
isMobileDrawerOpen,
isRouteIn,
isSidebarOpen,
openGroups,
setIsMobileDrawerOpen,
setIsSidebarOpen,
toggleGroup,
} = useLayoutSidebar(location.pathname)
const {
activeProfile,
isProfileOpen,
profiles,
setIsProfileOpen,
switchProfile,
useDefaultProfile,
} = useLayoutProfiles({ activeProfileId, setActiveProfile, user })
// Auto-collapse sidebar on smaller tablet screens, and auto-hide drawer on path change
useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 1024) {
setIsSidebarOpen(false)
} else {
setIsSidebarOpen(true)
}
}
handleResize()
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
useEffect(() => {
setIsMobileDrawerOpen(false)
}, [location.pathname])
useEffect(() => {
if (user && !isPermissionLoading && Object.keys(permissions ?? {}).length === 0) {
fetchPermissions().catch(() => undefined)
}
}, [fetchPermissions, isPermissionLoading, permissions, user])
useEffect(() => {
if (location.pathname === '/search') {
const query = new URLSearchParams(location.search).get('q') ?? ''
setSearchQuery(query)
}
}, [location.pathname, location.search])
useEffect(() => {
const query = searchQuery.trim()
const seq = ++searchSeq.current
if (!searchFocused || !query) {
setSearchItems([])
setSearchTotal(0)
setSearchError('')
setSearchLoading(false)
return
}
setSearchLoading(true)
setSearchError('')
const timer = window.setTimeout(() => {
mediaAPI
.search(query, 24)
.then((data) => {
if (seq !== searchSeq.current) return
setSearchItems(data.items ?? [])
setSearchTotal(data.total ?? (data.items ?? []).length)
})
.catch(() => {
if (seq !== searchSeq.current) return
setSearchItems([])
setSearchTotal(0)
setSearchError('搜索失败,请稍后再试')
})
.finally(() => {
if (seq === searchSeq.current) setSearchLoading(false)
})
}, 220)
return () => window.clearTimeout(timer)
}, [searchFocused, searchQuery])
useEffect(() => {
if (!user) {
setProfiles([])
setActiveProfile(null)
return
}
playProfilesAPI
.list()
.then((rows) => {
setProfiles(rows)
const active = rows.find((p) => p.id === activeProfileId)
if (!active) {
const defaultProfile = rows.find((p) => p.is_default && !p.require_pin)
setActiveProfile(defaultProfile?.id ?? null)
}
})
.catch(() => undefined)
}, [activeProfileId, setActiveProfile, user])
const isAdmin = user?.role === 'admin'
const can = (key: string) => isAdmin || isSuper || (permissions ?? {})[key] === true
const activeProfile = profiles.find((p) => p.id === activeProfileId) ?? null
const sidebarExpanded = isSidebarOpen || isMobileDrawerOpen
const isRouteIn = (paths: string[]) =>
paths.some((path) => (path === '/' ? location.pathname === '/' : location.pathname.startsWith(path)))
const toggleGroup = (key: string) =>
setOpenGroups((current) => ({ ...current, [key]: !current[key] }))
useEffect(() => {
const groupPaths: Record<string, string[]> = {
media: ['/', '/libraries', '/library', '/poster-wall', '/discover', '/search', '/dlna', '/ai'],
personal: ['/favourites', '/playlists', '/playlist', '/history', '/profile', '/play-profiles'],
downloads: ['/downloads', '/download-clients', '/subscriptions', '/site-search'],
tools: ['/storage', '/storage-config', '/files', '/strm', '/duplicates', '/tasks', '/scheduler', '/recycle', '/stats'],
system: ['/admin', '/sites', '/notify-channels', '/license', '/settings', '/assistant'],
}
const active = Object.entries(groupPaths).find(([, paths]) => isRouteIn(paths))?.[0]
if (active) {
setOpenGroups((current) => (current[active] ? current : { ...current, [active]: true }))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname])
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (searchQuery.trim()) {
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
setSearchFocused(false)
}
}
const handleProfileSwitch = async (profile: PlayProfile) => {
if (activeProfileId === profile.id) {
setIsProfileOpen(false)
return
}
try {
let pinToken: string | null = null
if (profile.require_pin) {
const pin = await requestPIN({ profileName: profile.name })
if (!pin) return
const verified = await playProfilesAPI.verifyPin(profile.id, pin)
pinToken = verified.token
}
setActiveProfile(profile.id, pinToken)
setIsProfileOpen(false)
toast.success(`已切换到「${profile.name}」`)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'PIN 验证失败'
toast.error(msg)
}
const handleLogout = () => {
logout()
navigate('/login')
}
const sidebarContent = (
<div className="flex h-full flex-col bg-white border-r border-gray-200/80">
{/* Brand Logo & Brand Title */}
<div className="flex h-20 items-center justify-between px-6 border-b border-gray-100">
<Link to="/" className="flex items-center gap-3">
<img
src="/brand/mediastationgo-logo.svg"
alt="MediaStationGo"
className="h-10 w-10 shrink-0 rounded-xl object-contain shadow-sm"
/>
{(isSidebarOpen || isMobileDrawerOpen) && (
<motion.span
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="font-display text-lg font-extrabold tracking-tight text-[#111827]"
>
MediaStationGo
</motion.span>
)}
</Link>
{/* Toggle Collapse Button for Large Screen */}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="rounded-xl p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-colors hidden lg:block"
>
<Menu size={18} />
</button>
{/* Mobile Drawer Close Button */}
<button
onClick={() => setIsMobileDrawerOpen(false)}
className="rounded-xl p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-colors block lg:hidden"
>
<X size={18} />
</button>
</div>
{/* Navigation List */}
<nav className="flex-1 overflow-y-auto px-4 py-5 space-y-2 scrollbar-hide">
<SidebarGroup
id="media"
icon={<Home size={18} />}
label="影音中心"
collapsed={!sidebarExpanded}
open={openGroups.media ?? true}
active={isRouteIn(['/', '/libraries', '/library', '/poster-wall', '/discover', '/search', '/dlna', '/ai'])}
onToggle={toggleGroup}
>
<SidebarLink to="/" icon={<Home size={16} />} label="系统首页" end child />
<SidebarLink to="/libraries" icon={<LibraryIcon size={16} />} label="媒体库" child />
<SidebarLink to="/poster-wall" icon={<Image size={16} />} label="海报墙" child />
{can('can_view_discover') && <SidebarLink to="/discover" icon={<Compass size={16} />} label="精彩发现" child />}
{can('can_use_ai') && <SidebarLink to="/search" icon={<Search size={16} />} label="智能搜索" child />}
{can('can_cast') && <SidebarLink to="/dlna" icon={<Cast size={16} />} label="DLNA 投屏" child />}
{can('can_use_ai_assistant') && <SidebarLink to="/ai" icon={<Sparkles size={16} />} label="AI 助理" child />}
</SidebarGroup>
<SidebarGroup
id="personal"
icon={<UserIcon size={18} />}
label="个人空间"
collapsed={!sidebarExpanded}
open={openGroups.personal ?? false}
active={isRouteIn(['/favourites', '/playlists', '/playlist', '/history', '/profile', '/play-profiles'])}
onToggle={toggleGroup}
>
<SidebarLink to="/favourites" icon={<Heart size={16} />} label="我的收藏" child />
<SidebarLink to="/playlists" icon={<ListMusic size={16} />} label="播放列表" child />
<SidebarLink to="/history" icon={<Clock size={16} />} label="观看历史" child />
<SidebarLink to="/profile" icon={<UserIcon size={16} />} label="账号信息" child />
<SidebarLink to="/play-profiles" icon={<UserCog size={16} />} label="观影 Profile" child />
</SidebarGroup>
{(can('can_manage_downloads') || can('can_manage_subscriptions') || can('can_manage_sites')) && (
<SidebarGroup
id="downloads"
icon={<CloudDownload size={18} />}
label="下载订阅"
collapsed={!sidebarExpanded}
open={openGroups.downloads ?? false}
active={isRouteIn(['/downloads', '/download-clients', '/subscriptions', '/site-search'])}
onToggle={toggleGroup}
>
{can('can_manage_downloads') && <SidebarLink to="/downloads" icon={<CloudDownload size={16} />} label="下载中心" child />}
{can('can_manage_subscriptions') && <SidebarLink to="/subscriptions" icon={<Rss size={16} />} label="订阅管理" child />}
{can('can_manage_sites') && <SidebarLink to="/site-search" icon={<Search size={16} />} label="站点检索" child />}
{isAdmin && <SidebarLink to="/download-clients" icon={<Sliders size={16} />} label="下载器管理" child />}
</SidebarGroup>
)}
{isAdmin && (
<>
<SidebarGroup
id="tools"
icon={<HardDrive size={18} />}
label="存储工具"
collapsed={!sidebarExpanded}
open={openGroups.tools ?? false}
active={isRouteIn(['/storage', '/storage-config', '/files', '/strm', '/duplicates', '/tasks', '/scheduler', '/recycle', '/stats'])}
onToggle={toggleGroup}
>
<SidebarLink to="/storage" icon={<HardDrive size={16} />} label="存储与文件" child />
<SidebarLink to="/storage-config" icon={<CloudDownload size={16} />} label="外部存储" child />
<SidebarLink to="/files" icon={<LibraryIcon size={16} />} label="文件管理" child />
<SidebarLink to="/strm" icon={<Cast size={16} />} label="STRM 管理" child />
<SidebarLink to="/duplicates" icon={<Image size={16} />} label="重复文件" child />
<SidebarLink to="/tasks" icon={<Activity size={16} />} label="任务队列" child />
<SidebarLink to="/scheduler" icon={<Clock size={16} />} label="计划任务" child />
<SidebarLink to="/recycle" icon={<Trash2 size={16} />} label="回收站" child />
<SidebarLink to="/stats" icon={<Activity size={16} />} label="运行状态" child />
</SidebarGroup>
<SidebarGroup
id="system"
icon={<Settings size={18} />}
label="系统管理"
collapsed={!sidebarExpanded}
open={openGroups.system ?? false}
active={isRouteIn(['/admin', '/sites', '/notify-channels', '/license', '/settings', '/assistant'])}
onToggle={toggleGroup}
>
<SidebarLink to="/admin" icon={<Settings size={16} />} label="媒体与用户" child />
<SidebarLink to="/sites" icon={<Globe size={16} />} label="站点管理" child />
<SidebarLink to="/notify-channels" icon={<MessageSquareText size={16} />} label="通知渠道" child />
<SidebarLink to="/assistant" icon={<Sparkles size={16} />} label="AI 会话" child />
<SidebarLink to="/license" icon={<KeySquare size={16} />} label="授权许可" child />
<SidebarLink to="/settings" icon={<Sliders size={16} />} label="系统设置" child />
</SidebarGroup>
</>
)}
</nav>
{/* Sidebar Logout Action */}
<div className="border-t border-gray-100 p-4 bg-gray-50/50">
<button
onClick={() => { logout(); navigate('/login') }}
className={clsx(
"flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 w-full group/logout",
(isSidebarOpen || isMobileDrawerOpen) ? "justify-start text-gray-500 hover:bg-red-50 hover:text-red-600" : "justify-center text-gray-500 hover:text-red-600"
)}
title={`安全登出 (${user?.username})`}
>
<LogOut size={18} className="transition-transform group-hover/logout:-translate-x-0.5" />
{(isSidebarOpen || isMobileDrawerOpen) && <span>安全退出</span>}
</button>
</div>
</div>
<LayoutSidebarContent
isSidebarOpen={isSidebarOpen}
isMobileDrawerOpen={isMobileDrawerOpen}
openGroups={openGroups}
isAdmin={isAdmin}
username={user?.username}
can={can}
isRouteIn={isRouteIn}
onToggleGroup={toggleGroup}
onToggleSidebar={() => setIsSidebarOpen((current) => !current)}
onCloseMobileDrawer={() => setIsMobileDrawerOpen(false)}
onLogout={handleLogout}
/>
)
return (
<div className="flex h-screen w-screen overflow-hidden bg-[#f9fafb] text-gray-900 font-body select-none">
<div className="flex h-screen w-screen overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
{/* 1. Desktop Persistent Sidebar */}
<aside className={clsx(
@@ -377,124 +109,37 @@ export function Layout() {
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
{/* Top Header Bar */}
<header className="flex h-20 shrink-0 items-center justify-between px-6 md:px-8 bg-white/80 border-b border-gray-200/60 backdrop-blur-md z-30">
<header className="flex h-20 shrink-0 items-center justify-between border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-4 backdrop-blur-md z-30 md:px-8">
{/* Header Left Area: Hamburger and Search */}
<div className="flex items-center gap-4 flex-1 max-w-lg">
<div className="flex items-center gap-3 flex-1 max-w-lg md:gap-4">
{/* Hamburger button shown on Mobile & Tablet */}
<button
onClick={() => setIsMobileDrawerOpen(true)}
className="rounded-xl border border-gray-200 p-2.5 text-gray-500 hover:bg-gray-100 lg:hidden"
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors lg:hidden"
>
<Menu size={18} />
</button>
{/* Premium Integrated Search Form */}
<form onSubmit={handleSearchSubmit} className="relative w-full hidden sm:block">
<span className={clsx(
"absolute left-4 top-1/2 -translate-y-1/2 transition-colors duration-200",
searchFocused ? "text-brand-600" : "text-gray-500"
)}>
<Search size={16} />
</span>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onMouseDown={() => setSearchFocused(true)}
onClick={() => setSearchFocused(true)}
onFocus={() => setSearchFocused(true)}
onBlur={() => window.setTimeout(() => setSearchFocused(false), 120)}
placeholder="搜索电影、电视剧、演员、种子站点..."
className="w-full rounded-full border border-gray-200 bg-gray-50/50 py-2.5 pl-11 pr-12 text-sm text-gray-900 placeholder-gray-500 outline-none transition-all duration-300 focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/40"
/>
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none">
<span className="rounded-xl border border-gray-200 bg-white px-1.5 py-0.5 text-[9px] font-bold text-gray-500 uppercase tracking-wider">
Enter
</span>
</div>
<AnimatePresence>
{searchFocused && searchQuery.trim() && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 6, scale: 0.98 }}
transition={{ duration: 0.14 }}
onMouseDown={(event) => event.preventDefault()}
className="absolute left-0 right-0 top-full z-50 mt-3 overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl"
>
<div className="max-h-[420px] overflow-y-auto p-2">
{searchLoading && (
<div className="flex items-center gap-2 px-3 py-4 text-sm text-gray-500">
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
搜索中...
</div>
)}
{!searchLoading && searchError && (
<div className="px-3 py-4 text-sm text-red-500">{searchError}</div>
)}
{!searchLoading && !searchError && searchCards.length === 0 && (
<div className="px-3 py-4 text-sm text-gray-500">没有找到匹配的本地媒体</div>
)}
{!searchLoading && !searchError && searchCards.length > 0 && (
<div className="space-y-1">
{searchCards.map((card) => (
<Link
key={card.key}
to={seriesCardLink(card)}
onClick={() => setSearchFocused(false)}
className="flex items-center gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-gray-50"
>
<div className="h-14 w-10 shrink-0 overflow-hidden rounded-lg bg-gray-100">
{card.rep.poster_url ? (
<img
src={imageURL(card.rep.poster_url)}
alt={card.rep.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-gray-400">
<LibraryIcon size={16} />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-gray-900">
{card.rep.title || card.rep.original_name || '未命名媒体'}
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-gray-500">
{card.rep.year ? <span>{card.rep.year}</span> : null}
<span>{card.count > 1 ? `${card.count} 集/条目` : '单条媒体'}</span>
{card.rep.width ? <span>{card.rep.width}x{card.rep.height}</span> : null}
</div>
</div>
</Link>
))}
</div>
)}
</div>
<Link
to={`/search?q=${encodeURIComponent(searchQuery.trim())}`}
onClick={() => setSearchFocused(false)}
className="flex items-center justify-between border-t border-gray-100 px-4 py-3 text-sm font-semibold text-brand-600 hover:bg-brand-50/60"
>
<span>查看全部搜索结果</span>
<span className="text-xs text-gray-500">
{searchTotal > 0 ? `${searchTotal} 个条目` : 'Enter'}
</span>
</Link>
</motion.div>
)}
</AnimatePresence>
</form>
<LayoutSearchBox
query={search.query}
focused={search.focused}
loading={search.loading}
error={search.error}
cards={search.cards}
total={search.total}
onQueryChange={search.setQuery}
onFocusedChange={search.setFocused}
onSubmit={search.submit}
/>
</div>
{/* Header Right Area: Actions, Notifications, Profile Dropdown */}
<div className="flex items-center gap-4 shrink-0">
<div className="flex shrink-0 items-center gap-2 sm:gap-3 md:gap-4">
{/* Quick search button shown ONLY on Mobile screens */}
<Link
to="/search"
className="rounded-xl border border-gray-200 p-2.5 text-gray-500 hover:bg-gray-100 hover:text-gray-900 sm:hidden"
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors sm:hidden"
>
<Search size={18} />
</Link>
@@ -503,7 +148,7 @@ export function Layout() {
{can('can_view_discover') && (
<Link
to="/discover"
className="hidden md:flex items-center gap-2 rounded-xl border border-gray-200 px-4 py-2.5 text-xs font-bold text-gray-500 hover:bg-gray-50 hover:text-gray-900 transition-all"
className="hidden md:flex items-center gap-2 rounded-xl border border-[var(--app-border)] px-4 py-2.5 text-xs font-bold text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-all"
>
<Sparkles size={14} className="text-brand-500" />
<span>发现新片</span>
@@ -516,122 +161,33 @@ export function Layout() {
to="/notify-channels"
title="通知配置"
aria-label="打开通知配置"
className="relative rounded-xl border border-gray-200 p-2.5 text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-all"
className="relative rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-all"
>
<MessageSquareText size={18} />
</Link>
)}
{/* Horizontal divider lines */}
<span className="h-6 w-px bg-gray-200" />
<LayoutThemeToggle mode={theme.mode} onChange={theme.setMode} />
{/* Elegant user dropdown list */}
<div className="relative">
<button
onClick={() => setIsProfileOpen(!isProfileOpen)}
className="flex items-center gap-2.5 rounded-full border border-gray-200 p-1 pr-3 hover:bg-gray-50 transition-all"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-[#111827] to-[#1f2937] text-white font-display text-xs font-bold shadow-sm">
{user?.username?.slice(0, 2).toUpperCase() || "US"}
</div>
<div className="text-left hidden md:block">
<p className="text-xs font-bold text-gray-900 leading-none">{user?.username}</p>
<p className="text-[9px] text-gray-500 font-bold uppercase tracking-wider mt-0.5 leading-none">
{activeProfile ? `Profile: ${activeProfile.name}` : user?.role}
</p>
</div>
<ChevronDown size={14} className="text-gray-500" />
</button>
<span className="hidden h-6 w-px bg-[var(--app-border)] sm:block" />
<AnimatePresence>
{isProfileOpen && (
<>
{/* Backdrop cover for closing drawer click */}
<div className="fixed inset-0 z-10" onClick={() => setIsProfileOpen(false)} />
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="absolute right-0 mt-3 w-56 origin-top-right rounded-2xl border border-gray-200 bg-white p-2 shadow-xl z-20"
>
<Link
to="/profile"
onClick={() => setIsProfileOpen(false)}
className="flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 hover:text-gray-950 transition-colors"
>
<UserIcon size={16} />
<span>个人基本信息</span>
</Link>
{user?.role === 'admin' && (
<Link
to="/admin"
onClick={() => setIsProfileOpen(false)}
className="flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 hover:text-gray-950 transition-colors"
>
<Settings size={16} />
<span>管理主控制台</span>
</Link>
)}
<div className="my-1.5 border-t border-gray-100" />
<div className="px-3 py-2">
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-gray-500">
当前观影 Profile
</p>
<div className="space-y-1">
<button
onClick={() => {
setActiveProfile(null)
setIsProfileOpen(false)
}}
className={clsx(
'flex w-full items-center justify-between rounded-xl px-2.5 py-2 text-left text-xs transition-colors',
!activeProfileId ? 'bg-gray-950 text-white' : 'text-gray-600 hover:bg-gray-50',
)}
>
<span>账号默认</span>
<span>{!activeProfileId ? '使用中' : ''}</span>
</button>
{profiles.map((profile) => (
<button
key={profile.id}
onClick={() => handleProfileSwitch(profile)}
className={clsx(
'flex w-full items-center justify-between rounded-xl px-2.5 py-2 text-left text-xs transition-colors',
activeProfileId === profile.id ? 'bg-gray-950 text-white' : 'text-gray-600 hover:bg-gray-50',
)}
>
<span className="truncate">{profile.name}</span>
<span className="ml-2 shrink-0">{profile.allow_adult ? '成人' : '安全'}</span>
</button>
))}
</div>
</div>
<Link
to="/play-profiles"
onClick={() => setIsProfileOpen(false)}
className="flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 hover:text-gray-950 transition-colors"
>
<UserCog size={16} />
<span>管理观影 Profile</span>
</Link>
<div className="my-1.5 border-t border-gray-100" />
<button
onClick={() => {
setIsProfileOpen(false);
logout();
navigate('/login');
}}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
<LogOut size={16} />
<span>安全登出系统</span>
</button>
</motion.div>
</>
)}
</AnimatePresence>
</div>
<LayoutUserMenu
user={user}
isOpen={isProfileOpen}
profiles={profiles}
activeProfileId={activeProfileId}
activeProfile={activeProfile}
onToggle={() => setIsProfileOpen((open) => !open)}
onClose={() => setIsProfileOpen(false)}
onUseDefaultProfile={useDefaultProfile}
onSwitchProfile={switchProfile}
onLogout={() => {
setIsProfileOpen(false)
logout()
navigate('/login')
}}
/>
</div>
</header>
@@ -653,120 +209,8 @@ export function Layout() {
</main>
{/* Absolute Footer Frame */}
<AppFooter className="border-t border-gray-200/50 bg-white py-5 text-center text-xs text-gray-500" />
<AppFooter className="border-t border-[var(--app-border)] bg-[var(--app-panel)] py-5 text-center text-xs text-[var(--app-muted)]" />
</div>
</div>
)
}
interface SidebarGroupProps {
id: string;
icon: React.ReactNode;
label: string;
children: React.ReactNode;
collapsed?: boolean;
open?: boolean;
active?: boolean;
onToggle: (id: string) => void;
}
function SidebarGroup({ id, icon, label, children, collapsed, open, active, onToggle }: SidebarGroupProps) {
return (
<div className="space-y-1">
<button
type="button"
onClick={() => onToggle(id)}
className={clsx(
'group relative flex w-full items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-bold transition-all duration-300',
active ? 'bg-gray-950 text-white shadow-sm' : 'text-gray-500 hover:bg-gray-50 hover:text-gray-900',
collapsed && 'justify-center px-0',
)}
>
<span className={clsx('flex h-5 w-5 shrink-0 items-center justify-center', active ? 'text-[#c9954a]' : 'text-gray-500 group-hover:text-gray-700')}>
{icon}
</span>
{!collapsed && (
<>
<span className="flex-1 truncate text-left">{label}</span>
<ChevronDown
size={14}
className={clsx('transition-transform duration-200', open && 'rotate-180')}
/>
</>
)}
{collapsed && (
<div className="absolute left-full ml-3 rounded-xl bg-gray-900 px-2.5 py-1.5 text-xs font-semibold text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
{label}
</div>
)}
</button>
<AnimatePresence initial={false}>
{!collapsed && open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="space-y-1 pb-1 pl-3">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
interface SidebarLinkProps {
to: string;
icon: React.ReactNode;
label: string;
end?: boolean;
collapsed?: boolean;
child?: boolean;
}
function SidebarLink({ to, icon, label, end, collapsed, child }: SidebarLinkProps) {
return (
<NavLink
to={to}
end={end}
className={({ isActive }) =>
clsx(
"flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 relative group",
child && "py-2.5 text-[13px]",
isActive
? "bg-[#111827] text-white shadow-sm"
: "text-gray-500 hover:bg-gray-50 hover:text-gray-900"
)
}
>
{({ isActive }) => (
<>
<span className={clsx(
"flex shrink-0 items-center justify-center w-5 h-5 transition-transform duration-300 group-hover:scale-110",
isActive ? "text-[#c9954a]" : "text-gray-500 group-hover:text-gray-700"
)}>
{icon}
</span>
{!collapsed && (
<motion.span
initial={{ opacity: 0, x: -5 }}
animate={{ opacity: 1, x: 0 }}
className="truncate whitespace-nowrap"
>
{label}
</motion.span>
)}
{collapsed && (
<div className="absolute left-full ml-3 px-2.5 py-1.5 rounded-xl bg-gray-900 text-white text-xs font-semibold opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity shadow-lg z-50 whitespace-nowrap">
{label}
</div>
)}
</>
)}
</NavLink>
)
}
+143
View File
@@ -0,0 +1,143 @@
import { FormEvent } from 'react'
import { Link } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { Library as LibraryIcon, Search } from 'lucide-react'
import clsx from 'clsx'
import { imageURL } from '../api/client'
import { seriesCardLink, type SeriesCard } from '../utils/groupSeries'
type LayoutSearchBoxProps = {
query: string
focused: boolean
loading: boolean
error: string
cards: SeriesCard[]
total: number
onQueryChange: (value: string) => void
onFocusedChange: (focused: boolean) => void
onSubmit: (event: FormEvent) => void
}
export function LayoutSearchBox({
query,
focused,
loading,
error,
cards,
total,
onQueryChange,
onFocusedChange,
onSubmit,
}: LayoutSearchBoxProps) {
const trimmedQuery = query.trim()
return (
<form onSubmit={onSubmit} className="relative hidden w-full sm:block">
<span className={clsx(
'absolute left-4 top-1/2 -translate-y-1/2 transition-colors duration-200',
focused ? 'text-brand-500' : 'text-[var(--app-muted)]',
)}>
<Search size={16} />
</span>
<input
type="text"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onMouseDown={() => onFocusedChange(true)}
onClick={() => onFocusedChange(true)}
onFocus={() => onFocusedChange(true)}
onBlur={() => window.setTimeout(() => onFocusedChange(false), 120)}
placeholder="搜索电影、电视剧、演员、种子站点..."
className="w-full rounded-full border border-[var(--app-border)] bg-[var(--app-control-bg)] py-2.5 pl-11 pr-12 text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] outline-none transition-all duration-300 focus:border-brand-500 focus:bg-[var(--app-panel)] focus:ring-4 focus:ring-brand-100/40"
/>
<div className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2">
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
Enter
</span>
</div>
<AnimatePresence>
{focused && trimmedQuery && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 6, scale: 0.98 }}
transition={{ duration: 0.14 }}
onMouseDown={(event) => event.preventDefault()}
className="absolute left-0 right-0 top-full z-50 mt-3 overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
>
<div className="max-h-[420px] overflow-y-auto p-2">
{loading && (
<div className="flex items-center gap-2 px-3 py-4 text-sm text-[var(--app-muted)]">
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
搜索中...
</div>
)}
{!loading && error && (
<div className="px-3 py-4 text-sm text-red-500">{error}</div>
)}
{!loading && !error && cards.length === 0 && (
<div className="px-3 py-4 text-sm text-[var(--app-muted)]">没有找到匹配的本地媒体</div>
)}
{!loading && !error && cards.length > 0 && (
<div className="space-y-1">
{cards.map((card) => (
<SearchResultItem
key={card.key}
card={card}
onClick={() => onFocusedChange(false)}
/>
))}
</div>
)}
</div>
<Link
to={`/search?q=${encodeURIComponent(trimmedQuery)}`}
onClick={() => onFocusedChange(false)}
className="flex items-center justify-between border-t border-[var(--app-border)] px-4 py-3 text-sm font-semibold text-brand-500 hover:bg-[var(--app-hover)]"
>
<span>查看全部搜索结果</span>
<span className="text-xs text-[var(--app-muted)]">
{total > 0 ? `${total} 个条目` : 'Enter'}
</span>
</Link>
</motion.div>
)}
</AnimatePresence>
</form>
)
}
function SearchResultItem({ card, onClick }: { card: SeriesCard; onClick: () => void }) {
return (
<Link
to={seriesCardLink(card)}
onClick={onClick}
className="flex items-center gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-[var(--app-hover)]"
>
<div className="h-14 w-10 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
{card.rep.poster_url ? (
<img
src={imageURL(card.rep.poster_url, card.rep.updated_at)}
alt={card.rep.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-[var(--app-muted)]">
<LibraryIcon size={16} />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--app-text)]">
{card.rep.title || card.rep.original_name || '未命名媒体'}
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-[var(--app-muted)]">
{card.rep.year ? <span>{card.rep.year}</span> : null}
<span>{card.count > 1 ? `${card.count} 集/条目` : '单条媒体'}</span>
{card.rep.width ? <span>{card.rep.width}x{card.rep.height}</span> : null}
</div>
</div>
</Link>
)
}
+130
View File
@@ -0,0 +1,130 @@
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { LogOut, Menu, X } from 'lucide-react'
import clsx from 'clsx'
import { LAYOUT_NAV_GROUPS, NAV_GROUP_PATHS, type LayoutNavItem } from './layoutNavigation'
import { SidebarGroup, SidebarLink } from './LayoutSidebarNav'
type LayoutSidebarContentProps = {
isSidebarOpen: boolean
isMobileDrawerOpen: boolean
openGroups: Record<string, boolean>
isAdmin: boolean
username?: string
can: (key: string) => boolean
isRouteIn: (paths: string[]) => boolean
onToggleGroup: (id: string) => void
onToggleSidebar: () => void
onCloseMobileDrawer: () => void
onLogout: () => void
}
export function LayoutSidebarContent({
isSidebarOpen,
isMobileDrawerOpen,
openGroups,
isAdmin,
username,
can,
isRouteIn,
onToggleGroup,
onToggleSidebar,
onCloseMobileDrawer,
onLogout,
}: LayoutSidebarContentProps) {
const sidebarExpanded = isSidebarOpen || isMobileDrawerOpen
const isItemVisible = (item: LayoutNavItem) =>
(!item.adminOnly || isAdmin) && (!item.permission || can(item.permission))
const visibleGroups = LAYOUT_NAV_GROUPS
.filter((group) => !group.adminOnly || isAdmin)
.map((group) => ({
group,
items: group.items.filter(isItemVisible),
}))
.filter(({ items }) => items.length > 0)
return (
<div className="flex h-full flex-col border-r border-[var(--app-border)] bg-[var(--app-panel)]">
<div className="flex h-20 items-center justify-between border-b border-[var(--app-border)] px-6">
<Link to="/" className="flex items-center gap-3">
<img
src="/brand/mediastationgo-logo.svg"
alt="MediaStationGo"
className="h-10 w-10 shrink-0 rounded-xl object-contain shadow-sm"
/>
{sidebarExpanded && (
<motion.span
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="font-display text-lg font-extrabold tracking-tight text-[var(--app-text)]"
>
MediaStationGo
</motion.span>
)}
</Link>
<button
onClick={onToggleSidebar}
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors hidden lg:block"
>
<Menu size={18} />
</button>
<button
onClick={onCloseMobileDrawer}
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors block lg:hidden"
>
<X size={18} />
</button>
</div>
<nav className="flex-1 overflow-y-auto px-4 py-5 space-y-2 scrollbar-hide">
{visibleGroups.map(({ group, items }) => {
const GroupIcon = group.icon
return (
<SidebarGroup
key={group.id}
id={group.id}
icon={<GroupIcon size={18} />}
label={group.label}
collapsed={!sidebarExpanded}
open={openGroups[group.id] ?? group.id === 'media'}
active={isRouteIn(NAV_GROUP_PATHS[group.id])}
onToggle={onToggleGroup}
>
{items.map((item) => {
const ItemIcon = item.icon
return (
<SidebarLink
key={item.to}
to={item.to}
icon={<ItemIcon size={16} />}
label={item.label}
end={item.end}
child
/>
)
})}
</SidebarGroup>
)
})}
</nav>
<div className="border-t border-[var(--app-border)] bg-[var(--app-panel-soft)] p-4">
<button
onClick={onLogout}
className={clsx(
'flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 w-full group/logout',
sidebarExpanded
? 'justify-start text-[var(--app-muted)] hover:bg-[var(--app-danger-soft)] hover:text-red-500'
: 'justify-center text-[var(--app-muted)] hover:text-red-500',
)}
title={`安全登出 (${username ?? ''})`}
>
<LogOut size={18} className="transition-transform group-hover/logout:-translate-x-0.5" />
{sidebarExpanded && <span>安全退出</span>}
</button>
</div>
</div>
)
}
+122
View File
@@ -0,0 +1,122 @@
import type { ReactNode } from 'react'
import { NavLink } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { ChevronDown } from 'lucide-react'
import clsx from 'clsx'
type SidebarGroupProps = {
id: string
icon: ReactNode
label: string
children: ReactNode
collapsed?: boolean
open?: boolean
active?: boolean
onToggle: (id: string) => void
}
export function SidebarGroup({ id, icon, label, children, collapsed, open, active, onToggle }: SidebarGroupProps) {
return (
<div className="space-y-1">
<button
type="button"
onClick={() => onToggle(id)}
className={clsx(
'group relative flex w-full items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-bold transition-all duration-300',
active
? 'bg-[var(--app-active-bg)] text-[var(--app-active-text)] shadow-sm'
: 'text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]',
collapsed && 'justify-center px-0',
)}
>
<span className={clsx(
'flex h-5 w-5 shrink-0 items-center justify-center',
active ? 'text-[var(--app-active-icon)]' : 'text-[var(--app-muted)] group-hover:text-[var(--app-subtle)]',
)}>
{icon}
</span>
{!collapsed && (
<>
<span className="flex-1 truncate text-left">{label}</span>
<ChevronDown
size={14}
className={clsx('transition-transform duration-200', open && 'rotate-180')}
/>
</>
)}
{collapsed && (
<div className="absolute left-full z-50 ml-3 rounded-xl bg-[var(--app-tooltip-bg)] px-2.5 py-1.5 text-xs font-semibold text-[var(--app-tooltip-text)] opacity-0 shadow-lg transition-opacity group-hover:opacity-100">
{label}
</div>
)}
</button>
<AnimatePresence initial={false}>
{!collapsed && open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="space-y-1 pb-1 pl-3">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
type SidebarLinkProps = {
to: string
icon: ReactNode
label: string
end?: boolean
collapsed?: boolean
child?: boolean
}
export function SidebarLink({ to, icon, label, end, collapsed, child }: SidebarLinkProps) {
return (
<NavLink
to={to}
end={end}
className={({ isActive }) =>
clsx(
'relative flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 group',
child && 'py-2.5 text-[13px]',
isActive
? 'bg-[var(--app-active-bg)] text-[var(--app-active-text)] shadow-sm'
: 'text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]',
)
}
>
{({ isActive }) => (
<>
<span className={clsx(
'flex h-5 w-5 shrink-0 items-center justify-center transition-transform duration-300 group-hover:scale-110',
isActive ? 'text-[var(--app-active-icon)]' : 'text-[var(--app-muted)] group-hover:text-[var(--app-subtle)]',
)}>
{icon}
</span>
{!collapsed && (
<motion.span
initial={{ opacity: 0, x: -5 }}
animate={{ opacity: 1, x: 0 }}
className="truncate whitespace-nowrap"
>
{label}
</motion.span>
)}
{collapsed && (
<div className="pointer-events-none absolute left-full z-50 ml-3 whitespace-nowrap rounded-xl bg-[var(--app-tooltip-bg)] px-2.5 py-1.5 text-xs font-semibold text-[var(--app-tooltip-text)] opacity-0 shadow-lg transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
{label}
</div>
)}
</>
)}
</NavLink>
)
}
+48
View File
@@ -0,0 +1,48 @@
import { Monitor, Moon, Sun } from 'lucide-react'
import clsx from 'clsx'
import type { ThemeMode } from './useThemeMode'
type LayoutThemeToggleProps = {
mode: ThemeMode
onChange: (mode: ThemeMode) => void
}
const options: Array<{
mode: ThemeMode
label: string
icon: typeof Sun
}> = [
{ mode: 'light', label: '白天模式', icon: Sun },
{ mode: 'dark', label: '夜晚模式', icon: Moon },
{ mode: 'system', label: '跟随系统', icon: Monitor },
]
export function LayoutThemeToggle({ mode, onChange }: LayoutThemeToggleProps) {
return (
<div className="flex items-center rounded-full border border-[var(--app-border)] bg-[var(--app-control-bg)] p-1 shadow-sm">
{options.map((option) => {
const Icon = option.icon
const active = mode === option.mode
return (
<button
key={option.mode}
type="button"
title={option.label}
aria-label={option.label}
aria-pressed={active}
className={clsx(
'inline-flex h-8 w-8 items-center justify-center rounded-full transition-all duration-200',
active
? 'bg-[var(--app-command-bg)] text-[var(--app-command-text)] shadow-sm'
: 'text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]',
)}
onClick={() => onChange(option.mode)}
>
<Icon size={15} />
</button>
)
})}
</div>
)
}
+143
View File
@@ -0,0 +1,143 @@
import { Link } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { ChevronDown, LogOut, Settings, User as UserIcon, UserCog } from 'lucide-react'
import clsx from 'clsx'
import type { PlayProfile } from '../types'
type LayoutUser = {
username?: string
role?: string
}
type LayoutUserMenuProps = {
user: LayoutUser | null | undefined
isOpen: boolean
profiles: PlayProfile[]
activeProfileId: string | null
activeProfile: PlayProfile | null
onToggle: () => void
onClose: () => void
onUseDefaultProfile: () => void
onSwitchProfile: (profile: PlayProfile) => void
onLogout: () => void
}
export function LayoutUserMenu({
user,
isOpen,
profiles,
activeProfileId,
activeProfile,
onToggle,
onClose,
onUseDefaultProfile,
onSwitchProfile,
onLogout,
}: LayoutUserMenuProps) {
return (
<div className="relative">
<button
onClick={onToggle}
className="flex items-center gap-2.5 rounded-full border border-[var(--app-border)] p-1 pr-3 transition-all hover:bg-[var(--app-hover)]"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-[#111827] to-[#1f2937] font-display text-xs font-bold text-white shadow-sm">
{user?.username?.slice(0, 2).toUpperCase() || 'US'}
</div>
<div className="hidden text-left md:block">
<p className="text-xs font-bold leading-none text-[var(--app-text)]">{user?.username}</p>
<p className="mt-0.5 text-[9px] font-bold uppercase leading-none tracking-wider text-[var(--app-muted)]">
{activeProfile ? `Profile: ${activeProfile.name}` : user?.role}
</p>
</div>
<ChevronDown size={14} className="text-[var(--app-muted)]" />
</button>
<AnimatePresence>
{isOpen && (
<>
<div className="fixed inset-0 z-10" onClick={onClose} />
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="absolute right-0 z-20 mt-3 w-56 origin-top-right rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-xl"
>
<UserMenuLink to="/profile" icon={<UserIcon size={16} />} label="个人基本信息" onClick={onClose} />
{user?.role === 'admin' && (
<UserMenuLink to="/admin" icon={<Settings size={16} />} label="管理主控制台" onClick={onClose} />
)}
<div className="my-1.5 border-t border-[var(--app-border)]" />
<div className="px-3 py-2">
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
当前观影 Profile
</p>
<div className="space-y-1">
<button
onClick={onUseDefaultProfile}
className={profileButtonClass(!activeProfileId)}
>
<span>账号默认</span>
<span>{!activeProfileId ? '使用中' : ''}</span>
</button>
{profiles.map((profile) => (
<button
key={profile.id}
onClick={() => onSwitchProfile(profile)}
className={profileButtonClass(activeProfileId === profile.id)}
>
<span className="truncate">{profile.name}</span>
<span className="ml-2 shrink-0">{profile.allow_adult ? '成人' : '安全'}</span>
</button>
))}
</div>
</div>
<UserMenuLink to="/play-profiles" icon={<UserCog size={16} />} label="管理观影 Profile" onClick={onClose} />
<div className="my-1.5 border-t border-[var(--app-border)]" />
<button
onClick={onLogout}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-sm text-red-500 transition-colors hover:bg-[var(--app-danger-soft)]"
>
<LogOut size={16} />
<span>安全登出系统</span>
</button>
</motion.div>
</>
)}
</AnimatePresence>
</div>
)
}
function UserMenuLink({
to,
icon,
label,
onClick,
}: {
to: string
icon: React.ReactNode
label: string
onClick: () => void
}) {
return (
<Link
to={to}
onClick={onClick}
className="flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
>
{icon}
<span>{label}</span>
</Link>
)
}
function profileButtonClass(active: boolean): string {
return clsx(
'flex w-full items-center justify-between rounded-xl px-2.5 py-2 text-left text-xs transition-colors',
active
? 'bg-[var(--app-active-bg)] text-[var(--app-active-text)]'
: 'text-[var(--app-subtle)] hover:bg-[var(--app-hover)]',
)
}
+70 -32
View File
@@ -6,6 +6,7 @@ type ShortcutItem = {
title: string
description: string
badge?: string
group?: string
}
type ManagementShortcutsProps = {
@@ -15,43 +16,80 @@ type ManagementShortcutsProps = {
}
export function ManagementShortcuts({ title, description, items }: ManagementShortcutsProps) {
const groups = groupShortcutItems(items)
const hasNamedGroups = groups.some((group) => group.label)
return (
<section className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="mb-4 flex flex-wrap items-end justify-between gap-3">
<section className="space-y-4">
<div className="flex flex-wrap items-end justify-between gap-3 border-b border-[var(--app-border)] pb-3">
<div>
<h2 className="font-display text-lg font-bold text-ink-600">{title}</h2>
{description && <p className="mt-1 text-sm text-ink-50">{description}</p>}
<h2 className="font-display text-lg font-bold text-[var(--app-text)]">{title}</h2>
{description && <p className="mt-1 text-sm text-[var(--app-muted)]">{description}</p>}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{items.map((item) => (
<Link
key={item.to}
to={item.to}
className="group rounded-2xl border border-gray-200 bg-gray-50/70 p-4 transition hover:-translate-y-0.5 hover:border-primary-400/50 hover:bg-white hover:shadow-md"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-bold text-ink-600">{item.title}</h3>
{item.badge && (
<span className="shrink-0 rounded-full bg-primary-400/10 px-2 py-0.5 text-[10px] font-bold text-brand-500">
{item.badge}
</span>
)}
</div>
<p className="mt-2 line-clamp-2 text-xs leading-5 text-ink-50">
{item.description}
</p>
</div>
<ArrowRight
size={16}
className="mt-0.5 shrink-0 text-sand-500 transition group-hover:translate-x-0.5 group-hover:text-brand-500"
/>
{hasNamedGroups ? (
<div className="grid gap-5 lg:grid-cols-2">
{groups.map((group) => (
<div key={group.label || 'default'} className="space-y-3">
{group.label && <h3 className="text-xs font-bold uppercase tracking-wider text-[var(--app-brand-text)]">{group.label}</h3>}
<ShortcutGrid items={group.items} />
</div>
</Link>
))}
</div>
))}
</div>
) : (
<ShortcutGrid items={items} />
)}
</section>
)
}
function groupShortcutItems(items: ShortcutItem[]) {
const groups: { label: string; items: ShortcutItem[] }[] = []
items.forEach((item) => {
const label = item.group ?? ''
const group = groups.find((candidate) => candidate.label === label)
if (group) {
group.items.push(item)
return
}
groups.push({ label, items: [item] })
})
return groups
}
function ShortcutGrid({ items }: { items: ShortcutItem[] }) {
return (
<div className="grid gap-3 sm:grid-cols-2">
{items.map((item) => (
<ShortcutCard key={item.to} item={item} />
))}
</div>
)
}
function ShortcutCard({ item }: { item: ShortcutItem }) {
return (
<Link
to={item.to}
className="group rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] p-4 shadow-sm transition hover:-translate-y-0.5 hover:border-brand-500/50 hover:bg-[var(--app-panel-soft)] hover:shadow-md"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-bold text-[var(--app-text)]">{item.title}</h3>
{item.badge && (
<span className="shrink-0 rounded-full border border-[var(--app-brand-border)] bg-[var(--app-brand-soft)] px-2 py-0.5 text-[10px] font-bold text-[var(--app-brand-text)]">
{item.badge}
</span>
)}
</div>
<p className="mt-2 line-clamp-2 text-xs leading-5 text-[var(--app-muted)]">{item.description}</p>
</div>
<ArrowRight
size={16}
className="mt-0.5 shrink-0 text-[var(--app-brand-text)] transition group-hover:translate-x-0.5 group-hover:text-brand-500"
/>
</div>
</Link>
)
}
+67 -17
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'
import { Check, LoaderCircle, Search, Sparkles, X } from 'lucide-react'
import toast from 'react-hot-toast'
import { imageURL } from '../api/client'
import { mediaAPI, type ManualScrapeCandidate } from '../api/library'
import type { Media } from '../types'
import { EpisodeArtworkToggle } from './EpisodeArtworkToggle'
interface ManualScrapeDialogProps {
open: boolean
@@ -13,12 +14,12 @@ interface ManualScrapeDialogProps {
defaultQuery?: string
mediaType?: string
scopeLabel?: string
episodeArtwork?: boolean
onClose: () => void
onApplied?: () => void
}
const providers = [
{ value: 'all', label: '全部源' },
{ value: 'tmdb', label: 'TMDb' },
{ value: 'douban', label: '豆瓣' },
{ value: 'bangumi', label: 'Bangumi' },
@@ -33,11 +34,13 @@ export function ManualScrapeDialog({
defaultQuery,
mediaType,
scopeLabel,
episodeArtwork,
onClose,
onApplied,
}: ManualScrapeDialogProps) {
const [query, setQuery] = useState('')
const [provider, setProvider] = useState('all')
const [selectedProviders, setSelectedProviders] = useState<string[]>([])
const [includeEpisodeArtwork, setIncludeEpisodeArtwork] = useState(false)
const [searching, setSearching] = useState(false)
const [applyingKey, setApplyingKey] = useState('')
const [items, setItems] = useState<ManualScrapeCandidate[]>([])
@@ -50,10 +53,11 @@ export function ManualScrapeDialog({
useEffect(() => {
if (!open) return
setQuery(defaultQuery || media?.title || '')
setProvider('all')
setSelectedProviders([])
setIncludeEpisodeArtwork(episodeArtwork ?? false)
setItems([])
setApplyingKey('')
}, [defaultQuery, media?.title, open])
}, [defaultQuery, episodeArtwork, media?.title, open])
if (!open || !media) return null
@@ -67,7 +71,7 @@ export function ManualScrapeDialog({
try {
const results = await mediaAPI.manualScrapeSearch(media.id, {
query: text,
provider,
provider: selectedProviders.length > 0 ? selectedProviders.join(',') : 'all',
media_type: mediaType,
})
setItems(results)
@@ -84,11 +88,12 @@ export function ManualScrapeDialog({
const key = candidateKey(item)
setApplyingKey(key)
try {
const options = { episode_images: includeEpisodeArtwork }
if (targetIds.length > 1) {
const result = await mediaAPI.applyManualScrapeBatch(targetIds, item)
const result = await mediaAPI.applyManualScrapeBatch(targetIds, item, options)
toast.success(`已应用到 ${result.applied} 个媒体`)
} else {
await mediaAPI.applyManualScrape(media.id, item)
await mediaAPI.applyManualScrape(media.id, item, options)
toast.success('已应用手动匹配')
}
onApplied?.()
@@ -117,15 +122,30 @@ export function ManualScrapeDialog({
</div>
<div className="flex flex-col gap-3 border-b border-sand-200 p-5 sm:flex-row">
<select
value={provider}
onChange={(event) => setProvider(event.target.value)}
className="h-11 rounded-xl border border-sand-200 bg-white px-3 text-sm font-semibold text-ink-600 outline-none focus:border-brand-300"
>
{providers.map((item) => (
<option key={item.value} value={item.value}>{item.label}</option>
))}
</select>
<div className="flex min-w-0 flex-wrap gap-2">
<button
type="button"
onClick={() => setSelectedProviders([])}
className={providerButtonClass(selectedProviders.length === 0)}
>
{selectedProviders.length === 0 && <Check size={13} />}
全部源
</button>
{providers.map((item) => {
const active = selectedProviders.includes(item.value)
return (
<button
key={item.value}
type="button"
onClick={() => toggleProvider(item.value, setSelectedProviders)}
className={providerButtonClass(active)}
>
{active && <Check size={13} />}
{item.label}
</button>
)
})}
</div>
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-sand-500" />
<input
@@ -140,6 +160,13 @@ export function ManualScrapeDialog({
{searching ? <LoaderCircle size={16} className="animate-spin" /> : <Sparkles size={16} />}
搜索
</button>
{isEpisodeArtworkTarget(media, mediaType, targetIds.length) && (
<EpisodeArtworkToggle
checked={includeEpisodeArtwork}
onChange={setIncludeEpisodeArtwork}
title="关闭后仍写入每集简介、评分和时长,只跳过每集图片"
/>
)}
</div>
<div className="flex-1 overflow-y-auto p-5">
@@ -190,6 +217,29 @@ function candidateKey(item: ManualScrapeCandidate): string {
return `${item.source}:${item.tmdb_id || item.bangumi_id || item.douban_id || item.thetvdb_id || item.title}:${item.media_type || ''}`
}
function toggleProvider(value: string, setSelectedProviders: Dispatch<SetStateAction<string[]>>) {
setSelectedProviders((current) => {
if (current.includes(value)) {
return current.filter((item) => item !== value)
}
return [...current, value]
})
}
function providerButtonClass(active: boolean): string {
return (
'inline-flex h-11 items-center gap-1.5 rounded-xl border px-3 text-xs font-bold transition ' +
(active
? 'border-brand-300 bg-brand-50 text-brand-700'
: 'border-sand-200 bg-white text-sand-600 hover:border-brand-200 hover:text-brand-600')
)
}
function isEpisodeArtworkTarget(media: Media, mediaType?: string, targetCount = 1): boolean {
const type = (mediaType || '').toLowerCase()
return type === 'tv' || type === 'anime' || type === 'variety' || media.season_num > 0 || media.episode_num > 0 || targetCount > 1
}
function candidateIDText(item: ManualScrapeCandidate): string {
const parts = [
item.tmdb_id ? `TMDb ${item.tmdb_id}` : '',
+15 -14
View File
@@ -19,25 +19,26 @@ export const MediaCard = ({
const ref = useRef<HTMLDivElement>(null)
const href = linkTo ?? `/media/${media.id}`
const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover')
const posterSrc = imageURL(media.poster_url, media.updated_at)
useEffect(() => {
setPosterFit('cover')
}, [media.poster_url])
}, [media.poster_url, media.updated_at])
const card = (
<motion.div
ref={ref}
whileHover={{ scale: 1.04, y: -6 }}
transition={{ type: 'spring', stiffness: 280, damping: 22 }}
className="relative overflow-hidden rounded-2xl bg-white border border-gray-200/90 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:border-brand-500/40 hover:shadow-[0_12px_32px_rgba(17,24,39,0.04),0_4px_12px_rgba(17,24,39,0.01)]"
className="relative overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
>
{/* Poster Wrapper */}
<div className="relative aspect-[2/3] w-full overflow-hidden bg-gray-50">
<div className="relative aspect-[2/3] w-full overflow-hidden bg-[var(--app-panel-soft)]">
{media.poster_url ? (
<>
{posterFit === 'contain' && (
<img
src={imageURL(media.poster_url)}
src={posterSrc}
alt=""
aria-hidden="true"
loading="lazy"
@@ -46,7 +47,7 @@ export const MediaCard = ({
/>
)}
<img
src={imageURL(media.poster_url)}
src={posterSrc}
alt={media.title}
loading="lazy"
decoding="async"
@@ -62,7 +63,7 @@ export const MediaCard = ({
/>
</>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-gray-500 bg-gray-50">
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-[var(--app-panel-soft)] text-[var(--app-muted)]">
<Film size={28} className="stroke-[1.5]" />
<span className="text-[10px] uppercase tracking-wider font-bold">No Poster</span>
</div>
@@ -70,7 +71,7 @@ export const MediaCard = ({
{/* Episode count badge */}
{count !== undefined && count > 1 && (
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-xl bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-white shadow-sm border border-gray-200">
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-white shadow-sm">
<Layers size={10} className="text-[#c9954a]" />
<span>{count} 集</span>
</span>
@@ -78,7 +79,7 @@ export const MediaCard = ({
{/* Rating Badge */}
{(rating || (media as any).rating) && (
<span className="absolute left-3 top-3 inline-flex items-center gap-0.5 rounded-xl bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-[#c9954a] shadow-sm border border-gray-200">
<span className="absolute left-3 top-3 inline-flex items-center gap-0.5 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-[#c9954a] shadow-sm">
<Star size={10} fill="currentColor" />
<span>{(rating || (media as any).rating).toFixed(1)}</span>
</span>
@@ -96,7 +97,7 @@ export const MediaCard = ({
<Play size={10} fill="currentColor" className="text-white" />
<span>立即观影</span>
</span>
<p className="text-[10px] text-gray-500 font-semibold line-clamp-2 leading-relaxed">
<p className="text-[10px] text-gray-200 font-semibold line-clamp-2 leading-relaxed">
{media.overview || "暂无简介内容"}
</p>
</motion.div>
@@ -104,7 +105,7 @@ export const MediaCard = ({
{/* Progress Bar overlay */}
{progress !== undefined && progress > 0 && progress < 1 && (
<div className="absolute inset-x-0 bottom-0 h-1.5 bg-gray-200">
<div className="absolute inset-x-0 bottom-0 h-1.5 bg-[var(--app-hover)]">
<div
className="h-full bg-gradient-to-r from-brand-400 to-brand-500 rounded-r-full transition-all duration-300"
style={{ width: `${Math.round(progress * 100)}%` }}
@@ -114,14 +115,14 @@ export const MediaCard = ({
</div>
{/* Media Metadata Info */}
<div className="p-4 space-y-1 bg-white border-t border-gray-50">
<p className="truncate text-sm font-bold text-gray-900 group-hover:text-brand-500 transition-colors duration-200">
<div className="space-y-1 border-t border-[var(--app-border)] bg-[var(--app-panel)] p-4">
<p className="truncate text-sm font-bold text-[var(--app-text)] transition-colors duration-200 group-hover:text-brand-500">
{media.title}
</p>
<div className="flex items-center justify-between text-[11px] text-gray-500 font-bold uppercase tracking-wider">
<div className="flex items-center justify-between text-[11px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
<span>{media.year > 0 ? media.year : "未知年份"}</span>
{media.video_codec && (
<span className="px-1.5 py-0.5 rounded-xl bg-gray-50 text-gray-600 border border-gray-100">
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)]">
{media.video_codec}
</span>
)}
+2 -17
View File
@@ -1,28 +1,13 @@
import { FormEvent, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { KeyRound } from 'lucide-react'
type PasswordOptions = {
export type PasswordOptions = {
title?: string
message?: string
confirmText?: string
}
export function requestPassword(options: PasswordOptions): Promise<string | null> {
return new Promise((resolve) => {
const host = document.createElement('div')
document.body.appendChild(host)
const root = createRoot(host)
const close = (value: string | null) => {
root.unmount()
host.remove()
resolve(value)
}
root.render(<PasswordDialog options={options} onClose={close} />)
})
}
function PasswordDialog({
export function PasswordDialog({
options,
onClose,
}: {
-15
View File
@@ -50,18 +50,3 @@ export function PermissionGuard({
return <>{fallback}</>
}
// 权限检查工具函数
export function checkPermission(
permission: string,
isSuper: boolean,
tier: string,
role: string,
permissions: Record<string, boolean>
): boolean {
// 超级用户有所有权限
if (isSuper || tier === 'plus' || role === 'admin') {
return true
}
return permissions[permission] === true
}
+2 -17
View File
@@ -1,28 +1,13 @@
import { FormEvent, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { LockKeyhole } from 'lucide-react'
type PinOptions = {
export type PinOptions = {
title?: string
message?: string
profileName: string
}
export function requestPIN(options: PinOptions): Promise<string | null> {
return new Promise((resolve) => {
const host = document.createElement('div')
document.body.appendChild(host)
const root = createRoot(host)
const close = (value: string | null) => {
root.unmount()
host.remove()
resolve(value)
}
root.render(<PinDialog options={options} onClose={close} />)
})
}
function PinDialog({
export function PinDialog({
options,
onClose,
}: {
+17
View File
@@ -0,0 +1,17 @@
import { createRoot } from 'react-dom/client'
import { ConfirmDialog, type ConfirmOptions } from './ConfirmDialog'
export function confirmAction(options: ConfirmOptions): Promise<boolean> {
return new Promise((resolve) => {
const host = document.createElement('div')
document.body.appendChild(host)
const root = createRoot(host)
const close = (value: boolean) => {
root.unmount()
host.remove()
resolve(value)
}
root.render(<ConfirmDialog options={options} onClose={close} />)
})
}
+126
View File
@@ -0,0 +1,126 @@
import type { LucideIcon } from 'lucide-react'
import {
Activity,
Cast,
Clock,
CloudDownload,
Compass,
Globe,
HardDrive,
Heart,
Home,
Image,
KeySquare,
Library,
ListMusic,
MessageSquareText,
Rss,
Search,
Settings,
Sliders,
Sparkles,
Trash2,
User,
UserCog,
} from 'lucide-react'
export type LayoutNavGroupID = 'media' | 'personal' | 'downloads' | 'tools' | 'system'
export type LayoutNavItem = {
to: string
label: string
icon: LucideIcon
end?: boolean
permission?: string
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 },
{ to: '/profile', label: '账号信息', icon: User },
{ to: '/play-profiles', label: '观影 Profile', icon: UserCog },
],
},
{
id: 'downloads',
label: '下载与订阅',
icon: CloudDownload,
activePaths: ['/downloads', '/download-clients', '/subscriptions', '/site-search'],
items: [
{ to: '/downloads', label: '下载中心', icon: CloudDownload, permission: 'can_manage_downloads' },
{ to: '/subscriptions', label: '订阅管理', icon: Rss, permission: 'can_manage_subscriptions' },
{ to: '/site-search', label: '站点检索', icon: Search, permission: 'can_manage_sites' },
{ to: '/download-clients', label: '下载器管理', icon: Sliders, adminOnly: true },
],
},
{
id: 'tools',
label: '文件与自动化',
icon: HardDrive,
activePaths: ['/storage', '/storage-config', '/files', '/strm', '/duplicates', '/tasks', '/scheduler', '/recycle', '/stats'],
adminOnly: true,
items: [
{ to: '/storage', label: '存储与文件', icon: HardDrive },
{ to: '/storage-config', label: '外部存储', icon: CloudDownload },
{ to: '/files', label: '文件管理', icon: Library },
{ to: '/strm', label: 'STRM 管理', icon: Cast },
{ to: '/duplicates', label: '重复文件', icon: Image },
{ to: '/tasks', label: '任务队列', icon: Activity },
{ to: '/scheduler', label: '计划任务', icon: Clock },
{ to: '/recycle', label: '回收站', icon: Trash2 },
{ to: '/stats', label: '运行状态', icon: Activity },
],
},
{
id: 'system',
label: '系统配置',
icon: Settings,
activePaths: ['/admin', '/sites', '/notify-channels', '/license', '/settings', '/assistant'],
adminOnly: true,
items: [
{ to: '/admin', label: '媒体与用户', icon: Settings },
{ to: '/sites', label: '站点管理', icon: Globe },
{ to: '/notify-channels', label: '通知渠道', icon: MessageSquareText },
{ to: '/assistant', label: 'AI 会话', icon: Sparkles },
{ to: '/license', label: '授权许可', icon: KeySquare },
{ to: '/settings', label: '系统设置', icon: Sliders },
],
},
]
export const NAV_GROUP_PATHS: Record<LayoutNavGroupID, string[]> = LAYOUT_NAV_GROUPS.reduce(
(paths, group) => ({ ...paths, [group.id]: group.activePaths }),
{} as Record<LayoutNavGroupID, string[]>,
)
+17
View File
@@ -0,0 +1,17 @@
import { createRoot } from 'react-dom/client'
import { PinDialog, type PinOptions } from './PinDialog'
export function requestPIN(options: PinOptions): Promise<string | null> {
return new Promise((resolve) => {
const host = document.createElement('div')
document.body.appendChild(host)
const root = createRoot(host)
const close = (value: string | null) => {
root.unmount()
host.remove()
resolve(value)
}
root.render(<PinDialog options={options} onClose={close} />)
})
}
+17
View File
@@ -0,0 +1,17 @@
import { createRoot } from 'react-dom/client'
import { PasswordDialog, type PasswordOptions } from './PasswordDialog'
export function requestPassword(options: PasswordOptions): Promise<string | null> {
return new Promise((resolve) => {
const host = document.createElement('div')
document.body.appendChild(host)
const root = createRoot(host)
const close = (value: string | null) => {
root.unmount()
host.remove()
resolve(value)
}
root.render(<PasswordDialog options={options} onClose={close} />)
})
}
@@ -0,0 +1,25 @@
import { useCallback, useEffect } from 'react'
import { usePermissionStore } from '../stores/permissions'
import type { User } from '../types'
export function useLayoutPermissions(user: User | null | undefined) {
const permissions = usePermissionStore((state) => state.permissions)
const isSuper = usePermissionStore((state) => state.isSuper)
const isPermissionLoading = usePermissionStore((state) => state.isLoading)
const fetchPermissions = usePermissionStore((state) => state.fetchPermissions)
useEffect(() => {
if (user && !isPermissionLoading && Object.keys(permissions ?? {}).length === 0) {
fetchPermissions().catch(() => undefined)
}
}, [fetchPermissions, isPermissionLoading, permissions, user])
const isAdmin = user?.role === 'admin'
const can = useCallback(
(key: string) => isAdmin || isSuper || (permissions ?? {})[key] === true,
[isAdmin, isSuper, permissions],
)
return { can, isAdmin }
}
+86
View File
@@ -0,0 +1,86 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import toast from 'react-hot-toast'
import { playProfilesAPI } from '../api/play_profiles'
import type { PlayProfile, User } from '../types'
import { requestPIN } from './requestPIN'
type UseLayoutProfilesOptions = {
activeProfileId: string | null
setActiveProfile: (id: string | null, pinToken?: string | null) => void
user: User | null | undefined
}
export function useLayoutProfiles({
activeProfileId,
setActiveProfile,
user,
}: UseLayoutProfilesOptions) {
const [isProfileOpen, setIsProfileOpen] = useState(false)
const [profiles, setProfiles] = useState<PlayProfile[]>([])
useEffect(() => {
if (!user) {
setProfiles([])
setActiveProfile(null)
return
}
playProfilesAPI
.list()
.then((rows) => {
setProfiles(rows)
const active = rows.find((profile) => profile.id === activeProfileId)
if (!active) {
const defaultProfile = rows.find((profile) => profile.is_default && !profile.require_pin)
setActiveProfile(defaultProfile?.id ?? null)
}
})
.catch(() => undefined)
}, [activeProfileId, setActiveProfile, user])
const activeProfile = useMemo(
() => profiles.find((profile) => profile.id === activeProfileId) ?? null,
[activeProfileId, profiles],
)
const switchProfile = useCallback(
async (profile: PlayProfile) => {
if (activeProfileId === profile.id) {
setIsProfileOpen(false)
return
}
try {
let pinToken: string | null = null
if (profile.require_pin) {
const pin = await requestPIN({ profileName: profile.name })
if (!pin) return
const verified = await playProfilesAPI.verifyPin(profile.id, pin)
pinToken = verified.token
}
setActiveProfile(profile.id, pinToken)
setIsProfileOpen(false)
toast.success(`已切换到「${profile.name}」`)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'PIN 验证失败'
toast.error(msg)
}
},
[activeProfileId, setActiveProfile],
)
const useDefaultProfile = useCallback(() => {
setActiveProfile(null)
setIsProfileOpen(false)
}, [setActiveProfile])
return {
activeProfile,
isProfileOpen,
profiles,
setIsProfileOpen,
switchProfile,
useDefaultProfile,
}
}
+85
View File
@@ -0,0 +1,85 @@
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<Media[]>([])
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,
}
}
+50
View File
@@ -0,0 +1,50 @@
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<Record<string, boolean>>({ media: true })
useEffect(() => {
const handleResize = () => {
setIsSidebarOpen(window.innerWidth >= 1024)
}
handleResize()
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
useEffect(() => {
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])
return {
isMobileDrawerOpen,
isRouteIn,
isSidebarOpen,
openGroups,
setIsMobileDrawerOpen,
setIsSidebarOpen,
toggleGroup,
}
}
+64
View File
@@ -0,0 +1,64 @@
import { useCallback, useEffect, useState } from 'react'
export type ThemeMode = 'light' | 'dark' | 'system'
export type ResolvedTheme = 'light' | 'dark'
const THEME_STORAGE_KEY = 'mediastationgo.theme'
const DARK_QUERY = '(prefers-color-scheme: dark)'
function readStoredTheme(): ThemeMode {
if (typeof window === 'undefined') return 'system'
const value = window.localStorage.getItem(THEME_STORAGE_KEY)
return value === 'light' || value === 'dark' || value === 'system' ? value : 'system'
}
function systemTheme(): ResolvedTheme {
if (typeof window === 'undefined') return 'light'
return window.matchMedia(DARK_QUERY).matches ? 'dark' : 'light'
}
function resolveTheme(mode: ThemeMode): ResolvedTheme {
return mode === 'system' ? systemTheme() : mode
}
function applyTheme(mode: ThemeMode) {
if (typeof document === 'undefined') return
const resolved = resolveTheme(mode)
const root = document.documentElement
root.dataset.themeMode = mode
root.dataset.theme = resolved
root.style.colorScheme = resolved
}
export function initializeThemeMode() {
applyTheme(readStoredTheme())
}
export function useThemeMode() {
const [mode, setModeState] = useState<ThemeMode>(() => readStoredTheme())
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() => resolveTheme(readStoredTheme()))
useEffect(() => {
applyTheme(mode)
setResolvedTheme(resolveTheme(mode))
window.localStorage.setItem(THEME_STORAGE_KEY, mode)
}, [mode])
useEffect(() => {
const media = window.matchMedia(DARK_QUERY)
const update = () => {
if (mode === 'system') {
applyTheme(mode)
setResolvedTheme(resolveTheme(mode))
}
}
media.addEventListener('change', update)
return () => media.removeEventListener('change', update)
}, [mode])
const setMode = useCallback((nextMode: ThemeMode) => {
setModeState(nextMode)
}, [])
return { mode, resolvedTheme, setMode }
}
+165 -4
View File
@@ -4,9 +4,71 @@
html, body, #root { height: 100%; }
:root {
--app-bg: #f9fafb;
--app-panel: #ffffff;
--app-panel-soft: #f9fafb;
--app-header-bg: rgba(255, 255, 255, 0.82);
--app-control-bg: rgba(249, 250, 251, 0.72);
--app-hover: #f3f4f6;
--app-active-bg: #111827;
--app-active-text: #ffffff;
--app-active-icon: #c9954a;
--app-command-bg: #111827;
--app-command-text: #ffffff;
--app-tooltip-bg: #111827;
--app-tooltip-text: #ffffff;
--app-brand-soft: rgba(255, 248, 231, 0.9);
--app-brand-text: #9a6a1e;
--app-brand-border: rgba(234, 214, 182, 0.9);
--app-brand-emphasis: rgba(201, 149, 74, 0.18);
--app-danger-soft: rgba(254, 242, 242, 0.9);
--app-text: #111827;
--app-subtle: #374151;
--app-muted: #6b7280;
--app-border: rgba(229, 231, 235, 0.82);
--app-shadow: rgba(17, 24, 39, 0.08);
--app-hero-bg: radial-gradient(circle at 80% 20%, rgba(212, 175, 55, 0.22), transparent 34%), linear-gradient(135deg, #fff7ed, #f8fafc 52%, #eef2ff);
--app-hero-overlay: linear-gradient(90deg, #ffffff 0%, rgba(255,255,255,0.96) 37%, rgba(255,255,255,0.62) 68%, rgba(255,255,255,0.2) 100%);
--app-hero-fade: linear-gradient(to top, #ffffff, transparent);
--app-poster-shell: #ffffff;
--app-poster-empty: linear-gradient(135deg, #f9fafb, #fff7ed);
}
:root[data-theme='dark'] {
--app-bg: #070a0f;
--app-panel: #0f141d;
--app-panel-soft: #111827;
--app-header-bg: rgba(15, 20, 29, 0.86);
--app-control-bg: rgba(17, 24, 39, 0.72);
--app-hover: #1f2937;
--app-active-bg: #1f2937;
--app-active-text: #f9fafb;
--app-active-icon: #e3b56d;
--app-command-bg: #f9fafb;
--app-command-text: #0b1018;
--app-tooltip-bg: #f9fafb;
--app-tooltip-text: #0b1018;
--app-brand-soft: rgba(201, 149, 74, 0.14);
--app-brand-text: #e3b56d;
--app-brand-border: rgba(201, 149, 74, 0.34);
--app-brand-emphasis: rgba(201, 149, 74, 0.18);
--app-danger-soft: rgba(127, 29, 29, 0.22);
--app-text: #f9fafb;
--app-subtle: #d1d5db;
--app-muted: #9ca3af;
--app-border: rgba(55, 65, 81, 0.82);
--app-shadow: rgba(0, 0, 0, 0.45);
--app-hero-bg: radial-gradient(circle at 78% 18%, rgba(201, 149, 74, 0.18), transparent 34%), linear-gradient(135deg, #0b1018, #101826 54%, #171f2d);
--app-hero-overlay: linear-gradient(90deg, rgba(7,10,15,0.98) 0%, rgba(15,20,29,0.92) 42%, rgba(15,20,29,0.62) 70%, rgba(15,20,29,0.24) 100%);
--app-hero-fade: linear-gradient(to top, #0f141d, transparent);
--app-poster-shell: #141b26;
--app-poster-empty: linear-gradient(135deg, #111827, #1f2937);
}
body {
background-color: #f9fafb; /* Premium Swiss Editorial Light/Slate Background */
color: #111827; /* Deep Graphite Charcoal Body Text */
background-color: var(--app-bg);
color: var(--app-text);
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -15,7 +77,7 @@ body {
/* ─── Selection ─── */
::selection {
background: rgba(201, 149, 74, 0.2);
color: #111827;
color: var(--app-text);
}
/* ─── Focus Ring ─── */
@@ -31,6 +93,77 @@ body {
::-webkit-scrollbar-thumb { background: #e5e7eb; border-radius: 999px; }
::-webkit-scrollbar-thumb:hover { background: #c9954a; }
:root[data-theme='dark'] ::-webkit-scrollbar-thumb { background: #374151; }
:root[data-theme='dark'] .bg-white,
:root[data-theme='dark'] .bg-gray-50,
:root[data-theme='dark'] .bg-gray-50\/50,
:root[data-theme='dark'] .bg-gray-50\/70,
:root[data-theme='dark'] .bg-white\/80,
:root[data-theme='dark'] .bg-white\/82,
:root[data-theme='dark'] .bg-white\/85 {
background-color: var(--app-panel) !important;
}
:root[data-theme='dark'] .bg-gray-100,
:root[data-theme='dark'] .bg-gray-100\/50 {
background-color: var(--app-hover) !important;
}
:root[data-theme='dark'] .bg-\[\#f9fafb\] {
background-color: var(--app-bg) !important;
}
:root[data-theme='dark'] .text-gray-900,
:root[data-theme='dark'] .text-gray-950,
:root[data-theme='dark'] .text-\[\#111827\],
:root[data-theme='dark'] .text-ink-600,
:root[data-theme='dark'] .text-ink-500,
:root[data-theme='dark'] .text-ink-400,
:root[data-theme='dark'] .text-ink-300,
:root[data-theme='dark'] .text-ink-200,
:root[data-theme='dark'] .text-ink-100 {
color: var(--app-text) !important;
}
:root[data-theme='dark'] .text-gray-700,
:root[data-theme='dark'] .text-gray-600,
:root[data-theme='dark'] .text-gray-500,
:root[data-theme='dark'] .text-ink-50,
:root[data-theme='dark'] .text-sand-500 {
color: var(--app-muted) !important;
}
:root[data-theme='dark'] .border-gray-100,
:root[data-theme='dark'] .border-gray-200,
:root[data-theme='dark'] .border-gray-200\/50,
:root[data-theme='dark'] .border-gray-200\/60,
:root[data-theme='dark'] .border-gray-200\/80,
:root[data-theme='dark'] .border-gray-200\/90,
:root[data-theme='dark'] .border-gray-300 {
border-color: var(--app-border) !important;
}
:root[data-theme='dark'] .shadow-xl,
:root[data-theme='dark'] .shadow-2xl,
:root[data-theme='dark'] .shadow-md,
:root[data-theme='dark'] .shadow-sm {
box-shadow: 0 18px 50px var(--app-shadow) !important;
}
:root[data-theme='dark'] input,
:root[data-theme='dark'] select,
:root[data-theme='dark'] textarea {
color: var(--app-text) !important;
background-color: var(--app-panel-soft) !important;
border-color: var(--app-border) !important;
}
:root[data-theme='dark'] input::placeholder,
:root[data-theme='dark'] textarea::placeholder {
color: #6b7280;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
@@ -47,6 +180,10 @@ body {
}
@layer components {
.theme-hero-bg { background: var(--app-hero-bg); }
.theme-hero-overlay { background: var(--app-hero-overlay); }
.theme-hero-fade { background: var(--app-hero-fade); }
/* ── Unified Swiss-Editorial Cards ── */
.card {
@apply rounded-2xl bg-white border border-gray-200/80 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] p-4 sm:p-6;
@@ -130,4 +267,28 @@ body {
.neon-button { @apply btn-outline; }
.input-base { @apply input-field; }
.warm-divider { @apply divider; }
}
}
.episode-artwork-toggle {
background-color: var(--app-panel);
border-color: var(--app-border);
color: var(--app-subtle);
}
.episode-artwork-toggle--on,
.episode-artwork-toggle[data-state='on'] {
background-color: var(--app-brand-soft) !important;
border-color: var(--app-brand-border) !important;
color: var(--app-brand-text) !important;
}
.episode-artwork-toggle__icon,
.episode-artwork-toggle__state {
background-color: var(--app-panel-soft);
color: var(--app-muted);
}
.episode-artwork-toggle--on .episode-artwork-toggle__icon,
.episode-artwork-toggle--on .episode-artwork-toggle__state,
.episode-artwork-toggle[data-state='on'] .episode-artwork-toggle__icon,
.episode-artwork-toggle[data-state='on'] .episode-artwork-toggle__state {
background-color: var(--app-brand-emphasis) !important;
color: var(--app-brand-text) !important;
}
+3
View File
@@ -5,8 +5,11 @@ import { Toaster } from 'react-hot-toast'
import App from './App'
import { GlobalEvents } from './components/GlobalEvents'
import { initializeThemeMode } from './components/useThemeMode'
import './index.css'
initializeThemeMode()
if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/artwork-cache-sw.js').catch(() => undefined)
@@ -0,0 +1,99 @@
import { useState } from 'react'
import { Rss } from 'lucide-react'
import toast from 'react-hot-toast'
import type { ExternalMediaResult } from '../api/ai'
import { imageURL } from '../api/client'
import { buildSiteSearchFeedURL, buildSubscriptionAliases, subscriptionsAPI } from '../api/subscriptions'
type AIAssistantExternalResultsProps = {
items: ExternalMediaResult[]
}
export function AIAssistantExternalResults({ items }: AIAssistantExternalResultsProps) {
const [subscribing, setSubscribing] = useState('')
if (items.length === 0) return null
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => {
const keyword = item.subscribe_keyword || item.title
const key = `${item.source}:${keyword}`
return (
<article key={key} className="rounded-2xl border border-gray-200 bg-gray-50 p-3">
<div className="flex gap-3">
<div className="h-24 w-16 shrink-0 overflow-hidden rounded-xl bg-white">
{item.poster_url ? (
<img
src={imageURL(item.poster_url)}
alt={item.title}
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap gap-2 text-[10px] uppercase text-brand-500">
<span>{item.source}</span>
{item.media_type && <span>{item.media_type}</span>}
{item.year ? <span>{item.year}</span> : null}
</div>
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
<p className="mt-1 line-clamp-2 text-xs text-ink-50">
{item.overview || `订阅关键词:${keyword}`}
</p>
<button
disabled={subscribing === key}
onClick={() => subscribeExternalItem(item, key, keyword, setSubscribing)}
className="mt-2 rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10 disabled:opacity-50"
>
<Rss size={12} className="mr-1 inline" />
{subscribing === key ? '订阅中…' : '订阅并搜索 PT'}
</button>
</div>
</div>
</article>
)
})}
</div>
)
}
async function subscribeExternalItem(
item: ExternalMediaResult,
key: string,
keyword: string,
setSubscribing: (key: string) => void,
) {
setSubscribing(key)
try {
const feed = buildSiteSearchFeedURL(keyword, item.source, buildSubscriptionAliases(item))
const sub = await subscriptionsAPI.create({
name: `${item.title} 自动订阅`,
feed_url: feed,
filter: keyword,
media_type: item.media_type,
source: item.source,
poster_url: item.poster_url,
backdrop_url: item.backdrop_url,
overview: item.overview,
original_name: item.original_name,
year: item.year,
total_episodes: item.total_episodes,
enabled: true,
})
const run = await subscriptionsAPI.runNow(sub.id)
toast.success(
run.queued > 0
? `已订阅并加入 ${run.queued} 个下载`
: '已订阅,暂未在 PT 站点找到可下载资源',
)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'订阅失败'
toast.error(msg)
} finally {
setSubscribing('')
}
}
+40
View File
@@ -0,0 +1,40 @@
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 (
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary-400 to-purple-500">
<Sparkles className="h-5 w-5 text-ink-600" />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">AI 助手</h1>
<p className="text-sm text-ink-50">自然语言搜索 · 基于观影历史的智能推荐</p>
</div>
</div>
{status && (
<div className="text-xs text-ink-50">
<span
className={
'mr-2 inline-block h-2 w-2 rounded-full ' +
(status.enabled ? 'bg-emerald-400' : 'bg-sand-500/30')
}
/>
{status.enabled
? `已连接 · ${status.provider}${status.model ? ' / ' + status.model : ''}`
: '未配置 AI 服务,使用本地规则解析'}
</div>
)}
</div>
)
}
+22 -304
View File
@@ -1,14 +1,9 @@
import { FormEvent, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loader2, Rss, Search, Sparkles, Wand2 } from 'lucide-react'
import toast from 'react-hot-toast'
import { aiAPI, type ExternalMediaResult, type SearchIntent } from '../api/ai'
import { imageURL } from '../api/client'
import { buildSiteSearchFeedURL, subscriptionsAPI } from '../api/subscriptions'
import { MediaCard } from '../components/MediaCard'
import type { Media } from '../types'
import { groupSeries, seriesCardLink } from '../utils/groupSeries'
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 +
@@ -20,308 +15,31 @@ import { groupSeries, seriesCardLink } from '../utils/groupSeries'
// operation-execute endpoints, so we render the same two capabilities as
// a focused two-panel screen.
export function AIAssistantPage() {
const [status, setStatus] = useState<{
enabled: boolean
provider: string
model: string
} | null>(null)
const [query, setQuery] = useState('')
const [searching, setSearching] = useState(false)
const [intent, setIntent] = useState<SearchIntent | null>(null)
const [items, setItems] = useState<Media[]>([])
const [externalItems, setExternalItems] = useState<ExternalMediaResult[]>([])
const [subscribing, setSubscribing] = useState('')
const localCards = useMemo(() => groupSeries(items), [items])
const [recs, setRecs] = useState<string[] | null>(null)
const [recommending, setRecommending] = useState(false)
useEffect(() => {
aiAPI
.status()
.then(setStatus)
.catch(() => setStatus({ enabled: false, provider: '', model: '' }))
}, [])
const onSearch = async (e: FormEvent) => {
e.preventDefault()
if (!query.trim()) return
setSearching(true)
setIntent(null)
setItems([])
setExternalItems([])
try {
const r = await aiAPI.smartSearch(query.trim())
setIntent(r.intent)
setItems(r.items)
setExternalItems(r.external_items ?? [])
if (r.items.length === 0 && (r.external_items ?? []).length === 0) toast('未找到匹配项')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'搜索失败'
toast.error(msg)
} finally {
setSearching(false)
}
}
const onRecommend = async () => {
setRecommending(true)
try {
const titles = await aiAPI.recommend()
setRecs(titles)
if (titles.length === 0) toast('暂无可推荐内容,请先观看一些媒体')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'获取推荐失败'
toast.error(msg)
} finally {
setRecommending(false)
}
}
const quickHints = [
'2023 年的科幻电影',
'评分高的动漫',
'最近添加的纪录片',
'中文剧集',
]
const assistant = useAIAssistantPage()
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary-400 to-purple-500">
<Sparkles className="h-5 w-5 text-ink-600" />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">AI 助手</h1>
<p className="text-sm text-ink-50">
自然语言搜索 · 基于观影历史的智能推荐
</p>
</div>
</div>
{status && (
<div className="text-xs text-ink-50">
<span
className={
'mr-2 inline-block h-2 w-2 rounded-full ' +
(status.enabled ? 'bg-emerald-400' : 'bg-sand-500/30')
}
/>
{status.enabled
? `已连接 · ${status.provider}${status.model ? ' / ' + status.model : ''}`
: '未配置 AI 服务,使用本地规则解析'}
</div>
)}
</div>
<AIAssistantHeader status={assistant.status} />
{/* Smart search */}
<section className="glass-panel space-y-4">
<h2 className="font-display text-lg font-semibold text-ink-600">智能搜索</h2>
<form onSubmit={onSearch} className="flex flex-wrap gap-2">
<input
className="input-base flex-1"
placeholder="试试: 2023 年的高分动作片"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit" disabled={searching} className="neon-button">
{searching ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Search size={16} />
)}
搜索
</button>
</form>
<AIAssistantSearchSection
query={assistant.query}
searching={assistant.searching}
intent={assistant.intent}
items={assistant.items}
localCards={assistant.localCards}
externalItems={assistant.externalItems}
onSearch={assistant.onSearch}
setQuery={assistant.setQuery}
/>
<div className="flex flex-wrap gap-2">
{quickHints.map((h) => (
<button
key={h}
onClick={() => {
setQuery(h)
}}
className="rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
{h}
</button>
))}
</div>
{intent && (
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-xs text-ink-100">
<div className="mb-1 font-semibold text-ink-200">解析结果</div>
<div className="flex flex-wrap gap-x-6 gap-y-1">
<span>
查询: <span className="text-brand-500">{intent.query || '—'}</span>
</span>
{intent.year !== undefined && intent.year > 0 && (
<span>
年份: <span className="text-brand-500">{intent.year}</span>
</span>
)}
{intent.genre && (
<span>
类型: <span className="text-brand-500">{intent.genre}</span>
</span>
)}
{intent.type && (
<span>
分类: <span className="text-brand-500">{intent.type}</span>
</span>
)}
{intent.sort && (
<span>
排序: <span className="text-brand-500">{intent.sort}</span>
</span>
)}
{intent.language && (
<span>
语言: <span className="text-brand-500">{intent.language}</span>
</span>
)}
</div>
</div>
)}
{localCards.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-semibold text-ink-100">
本地媒体库 · {localCards.length} 个合集 / {items.length} 个条目
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{localCards.map((card) => (
<MediaCard
key={card.key}
media={card.rep}
count={card.count}
linkTo={seriesCardLink(card)}
/>
))}
</div>
</div>
)}
{externalItems.length > 0 && (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{externalItems.map((item) => {
const keyword = item.subscribe_keyword || item.title
const key = `${item.source}:${keyword}`
return (
<article key={key} className="rounded-2xl border border-gray-200 bg-gray-50 p-3">
<div className="flex gap-3">
<div className="h-24 w-16 shrink-0 overflow-hidden rounded-xl bg-white">
{item.poster_url ? (
<img
src={imageURL(item.poster_url)}
alt={item.title}
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap gap-2 text-[10px] uppercase text-brand-500">
<span>{item.source}</span>
{item.media_type && <span>{item.media_type}</span>}
{item.year ? <span>{item.year}</span> : null}
</div>
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
<p className="mt-1 line-clamp-2 text-xs text-ink-50">
{item.overview || `订阅关键词:${keyword}`}
</p>
<button
disabled={subscribing === key}
onClick={async () => {
setSubscribing(key)
try {
const feed = buildSiteSearchFeedURL(keyword, item.source, [item.title, item.original_name || ''])
const sub = await subscriptionsAPI.create({
name: `${item.title} 自动订阅`,
feed_url: feed,
filter: keyword,
media_type: item.media_type,
source: item.source,
poster_url: item.poster_url,
backdrop_url: item.backdrop_url,
overview: item.overview,
total_episodes: item.total_episodes,
enabled: true,
})
const run = await subscriptionsAPI.runNow(sub.id)
toast.success(
run.queued > 0
? `已订阅并加入 ${run.queued} 个下载`
: '已订阅,暂未在 PT 站点找到可下载资源',
)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'订阅失败'
toast.error(msg)
} finally {
setSubscribing('')
}
}}
className="mt-2 rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10 disabled:opacity-50"
>
<Rss size={12} className="mr-1 inline" />
{subscribing === key ? '订阅中…' : '订阅并搜索 PT'}
</button>
</div>
</div>
</article>
)
})}
</div>
)}
</section>
{/* Recommendations */}
<section className="glass-panel space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-display text-lg font-semibold text-ink-600">为你推荐</h2>
<button onClick={onRecommend} disabled={recommending} className="neon-button">
{recommending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Wand2 size={16} />
)}
生成推荐
</button>
</div>
<p className="text-xs text-sand-500">
推荐基于你的最近观看历史。点击标题在媒体库中查找。
</p>
{recs && recs.length > 0 && (
<ul className="grid gap-2 sm:grid-cols-2">
{recs.map((t, i) => (
<li key={i}>
<Link
to={`/search?q=${encodeURIComponent(t)}`}
className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-ink-200 hover:border-primary-400/40 hover:text-brand-500"
>
<span className="truncate">{t}</span>
<Search size={14} className="shrink-0 opacity-60" />
</Link>
</li>
))}
</ul>
)}
{recs && recs.length === 0 && (
<p className="text-sm text-ink-50">
还没有推荐结果 — 先去看几部片子,我再给你挑。
</p>
)}
</section>
<AIAssistantRecommendationsSection
recs={assistant.recs}
recommending={assistant.recommending}
onRecommend={assistant.onRecommend}
/>
{/* Decorative footer (mirrors the Vue page hint that AI runs locally). */}
{!status?.enabled && (
{!assistant.status?.enabled && (
<p className="text-xs text-sand-500">
提示: 当前未配置外部 AI Provider,系统将使用本地规则引擎解析查询。
管理员可在 <Link to="/admin?tab=api" className="text-brand-500">API 配置</Link>{' '}
@@ -0,0 +1,47 @@
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 (
<section className="glass-panel space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-display text-lg font-semibold text-ink-600">为你推荐</h2>
<button onClick={onRecommend} disabled={recommending} className="neon-button">
{recommending ? <Loader2 size={16} className="animate-spin" /> : <Wand2 size={16} />}
生成推荐
</button>
</div>
<p className="text-xs text-sand-500">推荐基于你的最近观看历史。点击标题在媒体库中查找。</p>
{recs && recs.length > 0 && (
<ul className="grid gap-2 sm:grid-cols-2">
{recs.map((title, index) => (
<li key={index}>
<Link
to={`/search?q=${encodeURIComponent(title)}`}
className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-ink-200 hover:border-primary-400/40 hover:text-brand-500"
>
<span className="truncate">{title}</span>
<Search size={14} className="shrink-0 opacity-60" />
</Link>
</li>
))}
</ul>
)}
{recs && recs.length === 0 && (
<p className="text-sm text-ink-50">还没有推荐结果 — 先去看几部片子,我再给你挑。</p>
)}
</section>
)
}
+138
View File
@@ -0,0 +1,138 @@
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 (
<section className="glass-panel space-y-4">
<h2 className="font-display text-lg font-semibold text-ink-600">智能搜索</h2>
<form onSubmit={onSearch} className="flex flex-wrap gap-2">
<input
className="input-base flex-1"
placeholder="试试: 2023 年的高分动作片"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit" disabled={searching} className="neon-button">
{searching ? <Loader2 size={16} className="animate-spin" /> : <Search size={16} />}
搜索
</button>
</form>
<div className="flex flex-wrap gap-2">
{quickHints.map((hint) => (
<button
key={hint}
onClick={() => setQuery(hint)}
className="rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
{hint}
</button>
))}
</div>
<SearchIntentSummary intent={intent} />
<LocalMediaResults localCards={localCards} itemCount={items.length} />
<AIAssistantExternalResults items={externalItems} />
</section>
)
}
function SearchIntentSummary({ intent }: { intent: SearchIntent | null }) {
if (!intent) return null
return (
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-xs text-ink-100">
<div className="mb-1 font-semibold text-ink-200">解析结果</div>
<div className="flex flex-wrap gap-x-6 gap-y-1">
<span>
查询: <span className="text-brand-500">{intent.query || '—'}</span>
</span>
{intent.year !== undefined && intent.year > 0 && (
<span>
年份: <span className="text-brand-500">{intent.year}</span>
</span>
)}
{intent.genre && (
<span>
类型: <span className="text-brand-500">{intent.genre}</span>
</span>
)}
{intent.type && (
<span>
分类: <span className="text-brand-500">{intent.type}</span>
</span>
)}
{intent.sort && (
<span>
排序: <span className="text-brand-500">{intent.sort}</span>
</span>
)}
{intent.language && (
<span>
语言: <span className="text-brand-500">{intent.language}</span>
</span>
)}
</div>
</div>
)
}
function LocalMediaResults({
localCards,
itemCount,
}: {
localCards: SeriesCard[]
itemCount: number
}) {
if (localCards.length === 0) return null
return (
<div className="space-y-2">
<div className="text-sm font-semibold text-ink-100">
本地媒体库 · {localCards.length} 个合集 / {itemCount} 个条目
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{localCards.map((card) => (
<MediaCard
key={card.key}
media={card.rep}
count={card.count}
linkTo={seriesCardLink(card)}
/>
))}
</div>
</div>
)
}
+1 -1
View File
@@ -3,7 +3,7 @@ import toast from 'react-hot-toast'
import { Eye, KeyRound, Save, Trash2 } from 'lucide-react'
import { apiConfigsAPI, type APIConfig } from '../api/api_configs'
import { confirmAction } from '../components/ConfirmDialog'
import { confirmAction } from '../components/confirmAction'
// APIConfigsPage manages third-party API keys (TMDb / Bangumi / TheTVDB /
// Fanart / OpenAI / Douban). Plaintext keys are never returned by the
+115
View File
@@ -0,0 +1,115 @@
import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Trash2 } from 'lucide-react'
import { libraryAPI } from '../api/library'
import type { Library } from '../types'
import { confirmAction } from '../components/confirmAction'
export function AdminLibraryPanel() {
const [libs, setLibs] = useState<Library[]>([])
const [name, setName] = useState('')
const [path, setPath] = useState('')
const [type, setType] = useState('movie')
const refresh = () => libraryAPI.list({ includeHidden: true }).then(setLibs)
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const handleCreate = async (e: FormEvent) => {
e.preventDefault()
try {
await libraryAPI.create(name, path, type)
toast.success('媒体库已创建')
setName('')
setPath('')
await refresh()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'创建失败'
toast.error(msg)
}
}
return (
<div className="space-y-6">
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-4">
<input
required
className="input-base"
placeholder="名称"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
required
className="input-base md:col-span-2"
placeholder="容器路径,如 /media/电视剧/国产剧"
value={path}
onChange={(e) => setPath(e.target.value)}
/>
<p className="md:col-span-4 -mt-2 text-xs text-sand-500">
Docker 部署时请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧;如果误填 NAS
宿主机路径,系统会尝试按 compose 挂载自动转换。
</p>
<select className="input-base" value={type} onChange={(e) => setType(e.target.value)}>
<option value="movie">电影</option>
<option value="tv">电视剧</option>
<option value="variety">综艺</option>
<option value="anime">动漫</option>
<option value="music">音乐</option>
</select>
<button type="submit" className="neon-button md:col-span-4">
新建媒体库
</button>
</form>
<div className="glass-panel">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">名称</th>
<th>路径</th>
<th>类型</th>
<th className="text-right">操作</th>
</tr>
</thead>
<tbody>
{libs.map((l) => (
<tr key={l.id} className="border-t border-gray-200">
<td className="py-2 text-ink-600">{l.name}</td>
<td className="text-ink-100">{l.path}</td>
<td className="text-ink-100">{l.type}</td>
<td className="space-x-2 py-2 text-right">
<button
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
onClick={async () => {
const r = await libraryAPI.scan(l.id)
if (r.queued) toast.success('云盘扫描已加入后台队列,会自动入库')
else toast.success(`扫描完成,新增 ${r.added},更新 ${r.updated ?? 0}`)
}}
>
扫描
</button>
<button
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
onClick={async () => {
if (!(await confirmAction({ title: '删除媒体库', message: `确定删除「${l.name}」?`, confirmText: '删除' }))) return
await libraryAPI.remove(l.id)
toast.success('已删除')
await refresh()
}}
>
<Trash2 size={12} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
+9 -394
View File
@@ -1,16 +1,10 @@
import { FormEvent, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import toast from 'react-hot-toast'
import { KeyRound, Loader2, Pencil, Plus, ShieldCheck, Trash2, UserCheck, UserX, X } from 'lucide-react'
import { adminAPI } from '../api/admin'
import { libraryAPI } from '../api/library'
import { licenseAPI, type LicenseStatus } from '../api/license'
import type { Library, User } from '../types'
import { APIConfigsPanel } from '../components/APIConfigsPanel'
import { ManagementShortcuts } from '../components/ManagementShortcuts'
import { confirmAction } from '../components/ConfirmDialog'
import { requestPassword } from '../components/PasswordDialog'
import { AdminLibraryPanel } from './AdminLibraryPanel'
import { AdminUsersPanel } from './AdminUsersPanel'
type AdminTab = 'library' | 'users' | 'api'
@@ -44,10 +38,10 @@ export function AdminPage() {
title="统一管理入口"
description="侧栏保持精简,完整管理能力统一从这里进入。"
items={[
{ to: '/sites', title: '站点管理', description: '维护 PT 站点、认证方式和检索配置' },
{ to: '/download-clients', title: '下载器管理', description: '配置 qBittorrent 等下载器连接', badge: '下载' },
{ to: '/files', title: '手动整理', description: '从下载目录选择文件夹并整理入库' },
{ to: '/storage', title: '存储与文件', description: '查看占用、清理重复项和管理文件' },
{ to: '/sites', title: '站点管理', description: '维护 PT 站点、认证方式和检索配置', group: '站点与下载' },
{ to: '/download-clients', title: '下载器管理', description: '配置 qBittorrent 等下载器连接', badge: '下载', group: '站点与下载' },
{ to: '/files', title: '手动整理', description: '从下载目录选择文件夹并整理入库', group: '文件与入库' },
{ to: '/storage', title: '存储与文件', description: '查看占用、清理重复项和管理文件', group: '文件与入库' },
]}
/>
<div className="flex flex-wrap gap-2 border-b border-gray-200">
@@ -67,388 +61,9 @@ export function AdminPage() {
))}
</div>
{tab === 'library' && <LibraryPanel />}
{tab === 'users' && <UsersPanel />}
{tab === 'library' && <AdminLibraryPanel />}
{tab === 'users' && <AdminUsersPanel />}
{tab === 'api' && <APIConfigsPanel />}
</div>
)
}
function LibraryPanel() {
const [libs, setLibs] = useState<Library[]>([])
const [name, setName] = useState('')
const [path, setPath] = useState('')
const [type, setType] = useState('movie')
const refresh = () => libraryAPI.list({ includeHidden: true }).then(setLibs)
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const handleCreate = async (e: FormEvent) => {
e.preventDefault()
try {
await libraryAPI.create(name, path, type)
toast.success('媒体库已创建')
setName('')
setPath('')
await refresh()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'创建失败'
toast.error(msg)
}
}
return (
<div className="space-y-6">
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-4">
<input
required
className="input-base"
placeholder="名称"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
required
className="input-base md:col-span-2"
placeholder="容器路径,如 /media/电视剧/国产剧"
value={path}
onChange={(e) => setPath(e.target.value)}
/>
<p className="md:col-span-4 -mt-2 text-xs text-sand-500">
Docker 部署时请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧;如果误填 NAS
宿主机路径,系统会尝试按 compose 挂载自动转换。
</p>
<select className="input-base" value={type} onChange={(e) => setType(e.target.value)}>
<option value="movie">电影</option>
<option value="tv">电视剧</option>
<option value="variety">综艺</option>
<option value="anime">动漫</option>
<option value="music">音乐</option>
</select>
<button type="submit" className="neon-button md:col-span-4">
新建媒体库
</button>
</form>
<div className="glass-panel">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">名称</th>
<th>路径</th>
<th>类型</th>
<th className="text-right">操作</th>
</tr>
</thead>
<tbody>
{libs.map((l) => (
<tr key={l.id} className="border-t border-gray-200">
<td className="py-2 text-ink-600">{l.name}</td>
<td className="text-ink-100">{l.path}</td>
<td className="text-ink-100">{l.type}</td>
<td className="space-x-2 py-2 text-right">
<button
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
onClick={async () => {
const r = await libraryAPI.scan(l.id)
if (r.queued) toast.success('云盘扫描已加入后台队列,会自动入库')
else toast.success(`扫描完成,新增 ${r.added},更新 ${r.updated ?? 0}`)
}}
>
扫描
</button>
<button
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
onClick={async () => {
if (!(await confirmAction({ title: '删除媒体库', message: `确定删除「${l.name}」?`, confirmText: '删除' }))) return
await libraryAPI.remove(l.id)
toast.success('已删除')
await refresh()
}}
>
<Trash2 size={12} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function UsersPanel() {
const [users, setUsers] = useState<User[]>([])
const [licenseStatus, setLicenseStatus] = useState<LicenseStatus | null>(null)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [editingID, setEditingID] = useState<string | null>(null)
const [editingUsername, setEditingUsername] = useState('')
const [resettingPasswordID, setResettingPasswordID] = useState<string | null>(null)
const refresh = async () => {
const [nextUsers, nextLicense] = await Promise.all([
adminAPI.listUsers(),
licenseAPI.status().catch(() => null),
])
setUsers(nextUsers)
setLicenseStatus(nextLicense)
}
useEffect(() => {
refresh().catch(() => undefined)
}, [])
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 handleCreate = async (e: FormEvent) => {
e.preventDefault()
try {
await adminAPI.createUser({ username, password })
toast.success('用户已添加,默认仅允许浏览与播放媒体')
setUsername('')
setPassword('')
await refresh()
} catch (err: unknown) {
const msg =
userCreateErrorMessage(err) ??
'添加用户失败'
toast.error(msg)
}
}
const startEdit = (u: User) => {
setEditingID(u.id)
setEditingUsername(u.username)
}
const saveEdit = async (id: string) => {
try {
await adminAPI.updateUser(id, { username: editingUsername })
toast.success('用户名已更新')
setEditingID(null)
await refresh()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'更新失败'
toast.error(msg)
}
}
const resetPassword = async (u: User) => {
if (resettingPasswordID) return
const nextPassword = await requestPassword({
title: `重置 ${u.username} 的密码`,
message: '请输入新的临时密码,至少 6 位。保存后该用户可立即使用新密码登录 Web、Bot 与第三方客户端。',
confirmText: '重置密码',
})
if (!nextPassword) return
if (nextPassword.length < 6) {
toast.error('新密码至少 6 位')
return
}
setResettingPasswordID(u.id)
try {
await adminAPI.resetUserPassword(u.id, nextPassword)
toast.success('密码已重置')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'重置密码失败'
toast.error(msg)
} finally {
setResettingPasswordID(null)
}
}
const toggleStatus = async (u: User) => {
const next = !u.is_active
if (!next && u.is_protected) {
toast.error('受保护管理员不可禁用')
return
}
if (
!next &&
!(await confirmAction({
title: '禁用用户',
message: `禁用「${u.username}」后,Web 与第三方客户端已有登录也会失效。`,
confirmText: '禁用',
}))
) {
return
}
try {
await adminAPI.setUserStatus(u.id, next)
toast.success(next ? '用户已解禁' : '用户已禁用')
await refresh()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'操作失败'
toast.error(msg)
}
}
return (
<div className="space-y-6">
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_auto]">
<div className="md:col-span-3 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-display text-lg font-semibold text-ink-600">用户管理</h2>
<p className="text-xs text-sand-500">
已创建 {users.length}/{userLimitLabel} 个用户;新增用户默认只有媒体库浏览、播放、外部播放器与第三方客户端观看权限。
</p>
</div>
<span className="rounded-full border border-primary-400/30 px-3 py-1 text-xs text-brand-500">
默认管理员不可删除 · 最高权限
</span>
</div>
<input
required
className="input-base"
placeholder="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={userLimitReached}
/>
<input
required
minLength={6}
className="input-base"
placeholder="初始密码(至少 6 位)"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={userLimitReached}
/>
<button type="submit" className="neon-button inline-flex items-center justify-center gap-2" disabled={userLimitReached}>
<Plus size={16} />
添加用户
</button>
</form>
<div className="glass-panel overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">用户名</th>
<th>角色</th>
<th>状态</th>
<th>权限说明</th>
<th>最近登录</th>
<th className="text-right">操作</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-t border-gray-200">
<td className="py-2 text-ink-600">
{editingID === u.id ? (
<input
className="input-base h-9 max-w-48"
value={editingUsername}
onChange={(e) => setEditingUsername(e.target.value)}
/>
) : (
<span className="inline-flex items-center gap-2">
{u.username}
{u.is_default_admin && <ShieldCheck size={15} className="text-brand-500" />}
</span>
)}
</td>
<td className="text-ink-100">{u.role === 'admin' ? '管理员' : '观看用户'}</td>
<td className={u.is_active ? 'text-green-500' : 'text-red-400'}>
{u.is_active ? '正常' : '已禁用'}
</td>
<td className="text-ink-50">
{u.role === 'admin' ? '全部管理权限' : '仅浏览/播放/外部播放器,无下载与文件操作'}
</td>
<td className="text-ink-50">
{u.last_login_at ? new Date(u.last_login_at).toLocaleString() : '从未登录'}
</td>
<td className="space-x-2 py-2 text-right">
{editingID === u.id ? (
<>
<button
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
onClick={() => saveEdit(u.id)}
>
保存
</button>
<button
className="rounded-lg border border-gray-300 px-2 py-1 text-xs text-ink-100 hover:bg-gray-100"
onClick={() => setEditingID(null)}
>
<X size={12} />
</button>
</>
) : (
<button
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
onClick={() => startEdit(u)}
>
<Pencil size={12} />
</button>
)}
<button
className="rounded-lg border border-amber-400/40 px-2 py-1 text-xs text-amber-500 hover:bg-amber-400/10"
title="重置密码"
disabled={resettingPasswordID === u.id}
onClick={() => resetPassword(u)}
>
{resettingPasswordID === u.id ? <Loader2 size={12} className="animate-spin" /> : <KeyRound size={12} />}
</button>
<button
className={
'rounded-lg border px-2 py-1 text-xs disabled:cursor-not-allowed disabled:opacity-40 ' +
(u.is_active
? 'border-orange-400/40 text-orange-500 hover:bg-orange-400/10'
: 'border-green-400/40 text-green-500 hover:bg-green-400/10')
}
disabled={u.is_protected && u.is_active}
title={u.is_active ? '禁用用户' : '解禁用户'}
onClick={() => toggleStatus(u)}
>
{u.is_active ? <UserX size={12} /> : <UserCheck size={12} />}
</button>
<button
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10 disabled:cursor-not-allowed disabled:opacity-40"
disabled={u.is_protected}
title={u.is_protected ? '默认管理员禁止删除' : '删除用户'}
onClick={async () => {
if (u.is_protected) return
if (!(await confirmAction({ title: '删除用户', message: `确定删除「${u.username}」?`, confirmText: '删除' }))) return
await adminAPI.deleteUser(u.id)
toast.success('已删除')
await refresh()
}}
>
<Trash2 size={12} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function userCreateErrorMessage(err: unknown): string | undefined {
const data = (err as { response?: { data?: { error?: string; max_users?: number } } })?.response?.data
if (!data?.error) return undefined
if (data.error === 'user limit reached' && data.max_users != null) {
return `用户数量已达到授权上限:${data.max_users} 人`
}
return data.error
}
+62
View File
@@ -0,0 +1,62 @@
import { FormEvent } from 'react'
import { Plus } from 'lucide-react'
type AdminUsersFormProps = {
usersCount: number
userLimitLabel: string
username: string
password: string
userLimitReached: boolean
onUsernameChange: (value: string) => void
onPasswordChange: (value: string) => void
onSubmit: (e: FormEvent) => void
}
export function AdminUsersForm({
usersCount,
userLimitLabel,
username,
password,
userLimitReached,
onUsernameChange,
onPasswordChange,
onSubmit,
}: AdminUsersFormProps) {
return (
<form onSubmit={onSubmit} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_auto]">
<div className="md:col-span-3 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-display text-lg font-semibold text-ink-600">用户管理</h2>
<p className="text-xs text-sand-500">
已创建 {usersCount}/{userLimitLabel} 个用户;新增用户默认只有媒体库浏览、播放、外部播放器与第三方客户端观看权限。
</p>
</div>
<span className="rounded-full border border-primary-400/30 px-3 py-1 text-xs text-brand-500">
默认管理员不可删除 · 最高权限
</span>
</div>
<input
required
className="input-base"
placeholder="用户名"
value={username}
onChange={(e) => onUsernameChange(e.target.value)}
disabled={userLimitReached}
/>
<input
required
minLength={6}
className="input-base"
placeholder="初始密码(至少 6 位)"
type="password"
value={password}
onChange={(e) => onPasswordChange(e.target.value)}
disabled={userLimitReached}
/>
<button type="submit" className="neon-button inline-flex items-center justify-center gap-2" disabled={userLimitReached}>
<Plus size={16} />
添加用户
</button>
</form>
)
}
+175
View File
@@ -0,0 +1,175 @@
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'
import { AdminUsersForm } from './AdminUsersForm'
import { AdminUsersTable } from './AdminUsersTable'
export function AdminUsersPanel() {
const [users, setUsers] = useState<User[]>([])
const [licenseStatus, setLicenseStatus] = useState<LicenseStatus | null>(null)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [editingID, setEditingID] = useState<string | null>(null)
const [editingUsername, setEditingUsername] = useState('')
const [resettingPasswordID, setResettingPasswordID] = useState<string | null>(null)
const refresh = async () => {
const [nextUsers, nextLicense] = await Promise.all([
adminAPI.listUsers(),
licenseAPI.status().catch(() => null),
])
setUsers(nextUsers)
setLicenseStatus(nextLicense)
}
useEffect(() => {
refresh().catch(() => undefined)
const timer = window.setInterval(() => refresh().catch(() => undefined), 10000)
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 handleCreate = async (e: FormEvent) => {
e.preventDefault()
try {
await adminAPI.createUser({ username, password })
toast.success('用户已添加,默认仅允许浏览与播放媒体')
setUsername('')
setPassword('')
await refresh()
} catch (err: unknown) {
const msg =
userCreateErrorMessage(err) ??
'添加用户失败'
toast.error(msg)
}
}
const startEdit = (u: User) => {
setEditingID(u.id)
setEditingUsername(u.username)
}
const saveEdit = async (id: string) => {
try {
await adminAPI.updateUser(id, { username: editingUsername })
toast.success('用户名已更新')
setEditingID(null)
await refresh()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'更新失败'
toast.error(msg)
}
}
const resetPassword = async (u: User) => {
if (resettingPasswordID) return
const nextPassword = await requestPassword({
title: `重置 ${u.username} 的密码`,
message: '请输入新的临时密码,至少 6 位。保存后该用户可立即使用新密码登录 Web、Bot 与第三方客户端。',
confirmText: '重置密码',
})
if (!nextPassword) return
if (nextPassword.length < 6) {
toast.error('新密码至少 6 位')
return
}
setResettingPasswordID(u.id)
try {
await adminAPI.resetUserPassword(u.id, nextPassword)
toast.success('密码已重置')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'重置密码失败'
toast.error(msg)
} finally {
setResettingPasswordID(null)
}
}
const toggleStatus = async (u: User) => {
const next = !u.is_active
if (!next && u.is_protected) {
toast.error('受保护管理员不可禁用')
return
}
if (
!next &&
!(await confirmAction({
title: '禁用用户',
message: `禁用「${u.username}」后,Web 与第三方客户端已有登录也会失效。`,
confirmText: '禁用',
}))
) {
return
}
try {
await adminAPI.setUserStatus(u.id, next)
toast.success(next ? '用户已解禁' : '用户已禁用')
await refresh()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'操作失败'
toast.error(msg)
}
}
const deleteUser = async (u: User) => {
if (u.is_protected) return
if (!(await confirmAction({ title: '删除用户', message: `确定删除「${u.username}」?`, confirmText: '删除' }))) return
await adminAPI.deleteUser(u.id)
toast.success('已删除')
await refresh()
}
return (
<div className="space-y-6">
<AdminUsersForm
usersCount={users.length}
userLimitLabel={userLimitLabel}
username={username}
password={password}
userLimitReached={userLimitReached}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
onSubmit={handleCreate}
/>
<AdminUsersTable
users={users}
editingID={editingID}
editingUsername={editingUsername}
resettingPasswordID={resettingPasswordID}
onEditingUsernameChange={setEditingUsername}
onSaveEdit={saveEdit}
onCancelEdit={() => setEditingID(null)}
onStartEdit={startEdit}
onResetPassword={resetPassword}
onToggleStatus={toggleStatus}
onDeleteUser={deleteUser}
/>
</div>
)
}
function userCreateErrorMessage(err: unknown): string | undefined {
const data = (err as { response?: { data?: { error?: string; max_users?: number } } })?.response?.data
if (!data?.error) return undefined
if (data.error === 'user limit reached' && data.max_users != null) {
return `用户数量已达到授权上限:${data.max_users} 人`
}
return data.error
}
+136
View File
@@ -0,0 +1,136 @@
import { KeyRound, Loader2, Pencil, ShieldCheck, Trash2, UserCheck, UserX, X } from 'lucide-react'
import type { User } from '../types'
type AdminUsersTableProps = {
users: User[]
editingID: string | null
editingUsername: string
resettingPasswordID: string | null
onEditingUsernameChange: (value: string) => void
onSaveEdit: (id: string) => void
onCancelEdit: () => void
onStartEdit: (user: User) => void
onResetPassword: (user: User) => void
onToggleStatus: (user: User) => void
onDeleteUser: (user: User) => void
}
export function AdminUsersTable({
users,
editingID,
editingUsername,
resettingPasswordID,
onEditingUsernameChange,
onSaveEdit,
onCancelEdit,
onStartEdit,
onResetPassword,
onToggleStatus,
onDeleteUser,
}: AdminUsersTableProps) {
return (
<div className="glass-panel overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">用户名</th>
<th>角色</th>
<th>状态</th>
<th>权限说明</th>
<th>最近登录</th>
<th className="text-right">操作</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-t border-gray-200">
<td className="py-2 text-ink-600">
{editingID === u.id ? (
<input
className="input-base h-9 max-w-48"
value={editingUsername}
onChange={(e) => onEditingUsernameChange(e.target.value)}
/>
) : (
<span className="inline-flex items-center gap-2">
{u.username}
{u.is_default_admin && <ShieldCheck size={15} className="text-brand-500" />}
</span>
)}
</td>
<td className="text-ink-100">{u.role === 'admin' ? '管理员' : '观看用户'}</td>
<td className={u.is_active ? 'text-green-500' : 'text-red-400'}>
{u.is_active ? '正常' : '已禁用'}
</td>
<td className="text-ink-50">
{u.role === 'admin' ? '全部管理权限' : '仅浏览/播放/外部播放器,无下载与文件操作'}
</td>
<td className="text-ink-50">
<span className="inline-flex flex-wrap items-center gap-2">
<span>{u.last_login_at ? new Date(u.last_login_at).toLocaleString() : '从未登录'}</span>
{u.realtime_online && <span className="rounded border border-green-400/40 px-1.5 py-0.5 text-[11px] text-green-500">在线</span>}
{(u.realtime_device_count ?? 0) > 0 && <span className="text-xs text-ink-50">{u.realtime_device_count} 台</span>}
</span>
</td>
<td className="space-x-2 py-2 text-right">
{editingID === u.id ? (
<>
<button
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
onClick={() => onSaveEdit(u.id)}
>
保存
</button>
<button
className="rounded-lg border border-gray-300 px-2 py-1 text-xs text-ink-100 hover:bg-gray-100"
onClick={onCancelEdit}
>
<X size={12} />
</button>
</>
) : (
<button
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
onClick={() => onStartEdit(u)}
>
<Pencil size={12} />
</button>
)}
<button
className="rounded-lg border border-amber-400/40 px-2 py-1 text-xs text-amber-500 hover:bg-amber-400/10"
title="重置密码"
disabled={resettingPasswordID === u.id}
onClick={() => onResetPassword(u)}
>
{resettingPasswordID === u.id ? <Loader2 size={12} className="animate-spin" /> : <KeyRound size={12} />}
</button>
<button
className={
'rounded-lg border px-2 py-1 text-xs disabled:cursor-not-allowed disabled:opacity-40 ' +
(u.is_active
? 'border-orange-400/40 text-orange-500 hover:bg-orange-400/10'
: 'border-green-400/40 text-green-500 hover:bg-green-400/10')
}
disabled={u.is_protected && u.is_active}
title={u.is_active ? '禁用用户' : '解禁用户'}
onClick={() => onToggleStatus(u)}
>
{u.is_active ? <UserX size={12} /> : <UserCheck size={12} />}
</button>
<button
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10 disabled:cursor-not-allowed disabled:opacity-40"
disabled={u.is_protected}
title={u.is_protected ? '默认管理员禁止删除' : '删除用户'}
onClick={() => onDeleteUser(u)}
>
<Trash2 size={12} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
+1 -1
View File
@@ -8,7 +8,7 @@ import {
type AssistantSession,
type SessionView,
} from '../api/assistant'
import { confirmAction } from '../components/ConfirmDialog'
import { confirmAction } from '../components/confirmAction'
// AssistantChatPage is the multi-turn chat surface backed by the Go
// AssistantService. It complements the older AIAssistantPage which is
+123
View File
@@ -0,0 +1,123 @@
import { RefreshCw } from 'lucide-react'
import {
type AutoOrganizeConfig,
type AutoOrganizeTab,
} from './autoOrganizeModel'
import {
AutoOrganizeBasicTab,
AutoOrganizeNamingTab,
AutoOrganizeScrapeTab,
} from './AutoOrganizeSettingsTabs'
type AutoOrganizeSettingsPanelProps = {
config: AutoOrganizeConfig
currentDir: string
activeTab: AutoOrganizeTab
dirty: boolean
loading: boolean
saving: boolean
running: boolean
moveKeepsSeeding: boolean
onRefresh: () => void
onSave: () => void
onRunNow: () => void
onTabChange: (tab: AutoOrganizeTab) => void
onConfigChange: (key: keyof AutoOrganizeConfig, value: string) => void
}
const AUTO_ORGANIZE_TABS: Array<[AutoOrganizeTab, string]> = [
['basic', '基础设置'],
['naming', '命名规则'],
['scrape', '刮削联动'],
]
export function AutoOrganizeSettingsPanel({
config,
currentDir,
activeTab,
dirty,
loading,
saving,
running,
moveKeepsSeeding,
onRefresh,
onSave,
onRunNow,
onTabChange,
onConfigChange,
}: AutoOrganizeSettingsPanelProps) {
return (
<section className="glass-panel space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="font-display text-lg font-semibold text-ink-600">自动整理设置</h2>
<p className="text-xs text-sand-500">
设置后可自动递归扫描下载/待整理目录,整理到媒体库目录;也可以在这里立即执行一次。
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="neon-button !border-primary-400/30 !bg-white !text-brand-500"
disabled={loading || saving}
onClick={onRefresh}
>
<RefreshCw size={14} /> 重新读取
</button>
<button
type="button"
className="neon-button !border-primary-400/30 !bg-white !text-brand-500"
disabled={loading || saving || !dirty}
onClick={onSave}
>
{saving ? '保存中…' : '保存设置'}
</button>
<button
type="button"
className="neon-button"
disabled={loading || saving || running}
onClick={onRunNow}
>
{running ? '执行中…' : '立即整理一次'}
</button>
</div>
</div>
<div className="flex flex-wrap gap-2 rounded-2xl border border-gray-200 bg-gray-50 p-1">
{AUTO_ORGANIZE_TABS.map(([key, label]) => (
<button
key={key}
type="button"
className={
activeTab === key
? 'rounded-xl bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 shadow-sm'
: 'rounded-xl px-3 py-1.5 text-xs text-ink-100 hover:bg-white/70'
}
onClick={() => onTabChange(key)}
>
{label}
</button>
))}
<span className="ml-auto self-center px-2 text-xs text-sand-500">
{dirty ? '有未保存设置' : '设置已同步'} · 定时任务名:organize_source
</span>
</div>
{activeTab === 'basic' && (
<AutoOrganizeBasicTab
config={config}
currentDir={currentDir}
moveKeepsSeeding={moveKeepsSeeding}
onConfigChange={onConfigChange}
/>
)}
{activeTab === 'naming' && (
<AutoOrganizeNamingTab config={config} onConfigChange={onConfigChange} />
)}
{activeTab === 'scrape' && (
<AutoOrganizeScrapeTab config={config} onConfigChange={onConfigChange} />
)}
</section>
)
}
+210
View File
@@ -0,0 +1,210 @@
import {
type AutoOrganizeConfig,
settingOn,
} from './autoOrganizeModel'
type ConfigChangeHandler = (key: keyof AutoOrganizeConfig, value: string) => void
type AutoOrganizeTabProps = {
config: AutoOrganizeConfig
onConfigChange: ConfigChangeHandler
}
export function AutoOrganizeBasicTab({
config,
currentDir,
moveKeepsSeeding,
onConfigChange,
}: AutoOrganizeTabProps & {
currentDir: string
moveKeepsSeeding: boolean
}) {
return (
<>
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_150px_140px]">
<label className="space-y-1">
<span className="text-xs text-ink-50">整理源目录(待整理 / 下载目录)</span>
<div className="flex gap-2">
<input
className="input-base w-full"
placeholder="例如 F:\\downloads 或 /downloads"
value={config.sourceDir}
onChange={(event) => onConfigChange('sourceDir', event.target.value)}
/>
<button
type="button"
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40"
disabled={!currentDir}
onClick={() => onConfigChange('sourceDir', currentDir)}
>
当前
</button>
</div>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">整理目的地目录(媒体库根目录)</span>
<div className="flex gap-2">
<input
className="input-base w-full"
placeholder="例如 F:\\media 或 /media"
value={config.targetDir}
onChange={(event) => onConfigChange('targetDir', event.target.value)}
/>
<button
type="button"
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40"
disabled={!currentDir}
onClick={() => onConfigChange('targetDir', currentDir)}
>
当前
</button>
</div>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">默认整理方式</span>
<select
className="input-base w-full"
value={config.transferMode}
onChange={(event) => onConfigChange('transferMode', event.target.value)}
>
<option value="hardlink">硬链接</option>
<option value="move">移动(关闭保种才会移动)</option>
<option value="copy">复制</option>
<option value="symlink">软链接</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">检查间隔(秒)</span>
<input
type="number"
min={60}
className="input-base w-full"
value={config.intervalSeconds}
onChange={(event) => onConfigChange('intervalSeconds', event.target.value)}
/>
</label>
</div>
{moveKeepsSeeding && (
<div className="rounded-xl border border-orange-300 bg-orange-50 px-3 py-2 text-xs text-orange-700">
当前同时选择了“移动”和“保种”。为避免 qB 做种源文件被删除,后端会实际使用硬链接;Docker / NAS
多挂载或不同子卷下可能报 invalid cross-device link。需要真正移动时请关闭“保种”,需要保种但硬链接失败时请选择“复制”。
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<BooleanSetting config={config} settingKey="enabled" label="整理源目录定时自动整理" onConfigChange={onConfigChange} />
<BooleanSetting config={config} settingKey="afterDownload" label="qB 下载完成后自动整理" onConfigChange={onConfigChange} />
<BooleanSetting config={config} settingKey="downloadSmartClassify" label="下载器智能分类" onConfigChange={onConfigChange} />
<BooleanSetting config={config} settingKey="smartClassify" label="智能分类到子库" onConfigChange={onConfigChange} />
<BooleanSetting config={config} settingKey="keepSeeding" label="保种" onConfigChange={onConfigChange} />
</div>
</>
)
}
export function AutoOrganizeNamingTab({ config, onConfigChange }: AutoOrganizeTabProps) {
return (
<div className="grid gap-3">
<TextSetting config={config} settingKey="movieFormat" label="电影命名格式" className="input-base w-full font-mono text-xs" onConfigChange={onConfigChange} />
<TextSetting config={config} settingKey="tvFormat" label="剧集命名格式" className="input-base w-full font-mono text-xs" onConfigChange={onConfigChange} />
<TextSetting config={config} settingKey="animeFormat" label="动漫命名格式" className="input-base w-full font-mono text-xs" onConfigChange={onConfigChange} />
<p className="text-xs text-sand-500">
可用占位符:{'{title}'} {'{year}'} {'{season}'} {'{season:02}'} {'{episode}'} {'{episode:02}'} {'{category}'}。扩展名会自动补齐。
</p>
</div>
)
}
export function AutoOrganizeScrapeTab({ config, onConfigChange }: AutoOrganizeTabProps) {
return (
<>
<div className="flex flex-wrap items-center gap-3">
<BooleanSetting config={config} settingKey="scrapeAfter" label="整理后自动刮削" onConfigChange={onConfigChange} />
<BooleanSetting config={config} settingKey="scrapeAutoOnScan" label="扫描后自动刮削" onConfigChange={onConfigChange} />
</div>
<div className="grid gap-3 lg:grid-cols-[1fr_160px_160px_160px]">
<TextSetting
config={config}
settingKey="scrapeProviders"
label="刮削源优先级"
placeholder="tmdb,douban,bangumi,thetvdb,fanart"
onConfigChange={onConfigChange}
/>
<TextSetting config={config} settingKey="scrapeLanguage" label="首选语言" onConfigChange={onConfigChange} />
<NumberSetting config={config} settingKey="scrapeDelayMinMs" label="最小间隔 ms" onConfigChange={onConfigChange} />
<NumberSetting config={config} settingKey="scrapeDelayMaxMs" label="最大间隔 ms" onConfigChange={onConfigChange} />
</div>
</>
)
}
function BooleanSetting({
config,
settingKey,
label,
onConfigChange,
}: AutoOrganizeTabProps & {
settingKey: keyof AutoOrganizeConfig
label: string
}) {
return (
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={settingOn(config[settingKey])}
onChange={(event) => onConfigChange(settingKey, event.target.checked ? 'true' : 'false')}
/>
{label}
</label>
)
}
function TextSetting({
config,
settingKey,
label,
className = 'input-base w-full',
placeholder,
onConfigChange,
}: AutoOrganizeTabProps & {
settingKey: keyof AutoOrganizeConfig
label: string
className?: string
placeholder?: string
}) {
return (
<label className="space-y-1">
<span className="text-xs text-ink-50">{label}</span>
<input
className={className}
placeholder={placeholder}
value={config[settingKey]}
onChange={(event) => onConfigChange(settingKey, event.target.value)}
/>
</label>
)
}
function NumberSetting({
config,
settingKey,
label,
onConfigChange,
}: AutoOrganizeTabProps & {
settingKey: keyof AutoOrganizeConfig
label: string
}) {
return (
<label className="space-y-1">
<span className="text-xs text-ink-50">{label}</span>
<input
type="number"
min={0}
className="input-base w-full"
value={config[settingKey]}
onChange={(event) => onConfigChange(settingKey, event.target.value)}
/>
</label>
)
}
+272
View File
@@ -0,0 +1,272 @@
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { libraryAPI } from '../api/library'
import {
cloudAPI,
storageAPI,
type CloudEntry,
type CloudScanStatus,
type StorageType,
} from '../api/storage_config'
import { confirmAction } from '../components/confirmAction'
import type { Library } from '../types'
import { CloudBrowserToolbar } from './CloudBrowserToolbar'
import { CloudEntryList } from './CloudEntryList'
import { CloudMountList } from './CloudMountList'
import { CloudScanPanel } from './CloudScanPanel'
import {
TYPE_LABEL,
cloudLibraryProvider,
cloudMountDisplayPath,
} from './storageConfigModel'
// Lists cloud directories and imports a file as a 302-backed media.
export function CloudBrowser({ type }: { type: StorageType }) {
const [stack, setStack] = useState<{ id: string; name: string }[]>([{ id: '', name: '根目录' }])
const [items, setItems] = useState<CloudEntry[]>([])
const [mounts, setMounts] = useState<Library[]>([])
const [loading, setLoading] = useState(false)
const [mounting, setMounting] = useState(false)
const [batchMounting, setBatchMounting] = useState(false)
const [scanBusy, setScanBusy] = useState(false)
const [cancelBusy, setCancelBusy] = useState(false)
const [scanStatuses, setScanStatuses] = useState<CloudScanStatus[]>([])
const [mountMediaType, setMountMediaType] = useState('auto')
const [error, setError] = useState('')
const cur = stack[stack.length - 1]
const load = async (dir: string) => {
setLoading(true)
setError('')
try {
const r = await cloudAPI.list(type, dir)
setItems(r.items ?? [])
if (r.error) setError(r.error)
} catch (err: unknown) {
setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加载失败')
setItems([])
} finally {
setLoading(false)
}
}
const loadMounts = async () => {
const libs = await libraryAPI.list({ includeHidden: true })
setMounts(libs.filter((lib) => cloudLibraryProvider(lib.path) === type))
}
const loadScanStatus = async () => {
const r = await storageAPI.cloudScanStatus()
setScanStatuses((r.items ?? []).filter((item) => !type || item.provider === type))
}
useEffect(() => {
load(cur.id).catch(() => undefined)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stack.length, type])
useEffect(() => {
loadMounts().catch(() => undefined)
loadScanStatus().catch(() => undefined)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [type])
useEffect(() => {
const timer = window.setInterval(() => {
loadScanStatus().catch(() => undefined)
}, 3000)
return () => window.clearInterval(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [type])
const enter = (entry: CloudEntry) => setStack((current) => [...current, { id: entry.id, name: entry.name }])
const goTo = (index: number) => setStack((current) => current.slice(0, index + 1))
const goUp = () => setStack((current) => (current.length > 1 ? current.slice(0, -1) : current))
const currentMountPath = () => cloudMountDisplayPath(type, stack)
const childMountPath = (child: CloudEntry) => cloudMountDisplayPath(type, stack, child)
const currentDir = () => stack[stack.length - 1]?.id ?? ''
const handleMountResult = (res: unknown, label: string) => {
const out = res as { already_mounted?: boolean; skipped?: boolean; reason?: string; library?: Library; message?: string; estimate_message?: string }
if (out.skipped) {
toast(`已跳过「${label}」:和已挂载目录重叠`)
return 'skipped'
}
if (out.already_mounted) {
toast(`「${label}」已经挂载,后台会刷新扫描并自动入库`)
return 'mounted'
}
toast.success(`已挂载「${label}」,${out.message ?? '后台会递归扫描并自动加入媒体库'}。${out.estimate_message ?? ''}`)
return 'mounted'
}
const doImport = async (entry: CloudEntry) => {
const ref = type === 'cloud115' ? entry.pick_code || entry.id : entry.id
try {
await cloudAPI.import(type, ref, entry.name, entry.size)
toast.success(`已导入「${entry.name}」,可在媒体库中 302 播放`)
} catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '导入失败')
}
}
const normalizeFolderInput = (value: string | null) => {
const name = (value ?? '').trim()
if (!name) return ''
if (name === '.' || name === '..' || /[\\/]/.test(name)) {
toast.error('文件夹名称不能包含路径分隔符')
return ''
}
return name
}
const createFolder = async () => {
const name = normalizeFolderInput(window.prompt('新建文件夹名称') ?? '')
if (!name) return
setLoading(true)
try {
await cloudAPI.mkdir(type, currentDir(), name)
toast.success(`已新建文件夹「${name}」`)
await load(currentDir())
} catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '新建文件夹失败')
} finally {
setLoading(false)
}
}
const renameFolder = async (entry: CloudEntry) => {
if (!entry.is_dir) return
const name = normalizeFolderInput(window.prompt('重命名文件夹', entry.name) ?? '')
if (!name || name === entry.name) return
setLoading(true)
try {
await cloudAPI.rename(type, entry.id, name)
toast.success(`已重命名为「${name}」`)
await load(currentDir())
await loadMounts()
} catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '重命名失败')
} finally {
setLoading(false)
}
}
const mountCurrent = async () => {
setMounting(true)
try {
const label = TYPE_LABEL[type] ?? type
const name = cur.id ? cur.name : label
const res = await cloudAPI.mount(type, cur.id, name, mountMediaType, currentMountPath())
handleMountResult(res, cur.name)
await loadMounts()
} catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '挂载失败')
} finally {
setMounting(false)
}
}
const mountVisibleDirectories = async () => {
const dirs = items.filter((item) => item.is_dir)
if (dirs.length === 0) {
toast.error('当前目录下没有可挂载的子目录')
return
}
setBatchMounting(true)
let ok = 0
let skipped = 0
let failed = 0
for (const dir of dirs) {
try {
const result = await cloudAPI.mount(type, dir.id, dir.name, 'auto', childMountPath(dir))
const state = handleMountResult(result, dir.name)
if (state === 'skipped') skipped += 1
else ok += 1
} catch {
failed += 1
}
}
if (failed > 0) {
toast.error(`已挂载 ${ok} 个目录,跳过 ${skipped} 个重叠目录,失败 ${failed} 个`)
} else {
toast.success(`已挂载 ${ok} 个目录,跳过 ${skipped} 个重叠目录,后台会自动生成 302/STRM 播放入口`)
}
await loadMounts()
setBatchMounting(false)
}
const removeMount = async (lib: Library) => {
const ok = await confirmAction({
title: '移除网盘挂载',
message: `仅移除「${lib.name}」在本项目中的媒体库和媒体记录,不会删除网盘文件。`,
confirmText: '移除',
})
if (!ok) return
await libraryAPI.remove(lib.id)
toast.success('已移除挂载')
await loadMounts()
}
const scanAllCloudLibraries = async () => {
setScanBusy(true)
try {
const r = await storageAPI.scanAllCloud()
setScanStatuses(r.items ?? [])
toast.success(r.message ?? '已开始扫描所有启用的网盘媒体库')
} catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '启动扫描失败')
} finally {
setScanBusy(false)
}
}
const cancelCloudScans = async () => {
setCancelBusy(true)
try {
const r = await storageAPI.cancelCloudScan('', type)
toast.success(r.message ?? `已中断 ${r.cancelled} 个扫描任务`)
await loadScanStatus()
} catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '中断扫描失败')
} finally {
setCancelBusy(false)
}
}
return (
<div className="mt-2 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] p-3">
<CloudScanPanel
scanBusy={scanBusy}
cancelBusy={cancelBusy}
scanStatuses={scanStatuses}
onScanAll={scanAllCloudLibraries}
onCancelScans={cancelCloudScans}
/>
<CloudMountList mounts={mounts} onRemove={removeMount} />
<CloudBrowserToolbar
stack={stack}
mountMediaType={mountMediaType}
mounting={mounting}
batchMounting={batchMounting}
loading={loading}
hasDirectories={items.some((item) => item.is_dir)}
onGoTo={goTo}
onGoUp={goUp}
onCreateFolder={createFolder}
onMediaTypeChange={setMountMediaType}
onMountCurrent={mountCurrent}
onMountVisibleDirectories={mountVisibleDirectories}
/>
<CloudEntryList
loading={loading}
error={error}
items={items}
onEnter={enter}
onImport={doImport}
onRename={renameFolder}
/>
</div>
)
}
+99
View File
@@ -0,0 +1,99 @@
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 (
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-1 text-xs text-[var(--app-muted)]">
<span className="text-[var(--app-subtle)]">网盘资源:</span>
{stack.map((item, index) => (
<span key={index}>
<button type="button" className="hover:text-brand-500" onClick={() => onGoTo(index)}>
{item.name}
</button>
{index < stack.length - 1 && <span className="mx-1">/</span>}
</span>
))}
</div>
<p className="basis-full text-xs text-[var(--app-muted)]">
挂载后不会复制网盘文件;后台会递归读取该目录里的子文件夹和媒体文件,扫描到的影片会自动加入对应媒体库。小目录通常几十秒,大目录取决于网盘接口速度。
如果已有同名同类型媒体库,会在首页和 Emby/SenPlayer 中自动归并显示。
</p>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-[var(--app-border)] px-2 py-0.5 text-xs text-[var(--app-subtle)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-not-allowed disabled:opacity-50"
disabled={loading || stack.length <= 1}
onClick={onGoUp}
>
<ArrowUp size={14} />
返回上级
</button>
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-[var(--app-border)] px-2 py-0.5 text-xs text-[var(--app-subtle)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-not-allowed disabled:opacity-50"
disabled={loading}
onClick={onCreateFolder}
>
<FolderPlus size={14} />
新建文件夹
</button>
<select
className="rounded border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-0.5 text-xs text-[var(--app-subtle)]"
value={mountMediaType}
onChange={(event) => onMediaTypeChange(event.target.value)}
>
<option value="auto">自动识别</option>
<option value="movie">电影</option>
<option value="tv">剧集</option>
<option value="anime">动漫</option>
<option value="variety">综艺</option>
<option value="adult">成人</option>
</select>
<button
type="button"
className="rounded border border-brand-400/40 px-2 py-0.5 text-xs text-brand-500 hover:bg-brand-400/10 disabled:opacity-50"
disabled={mounting || batchMounting || loading}
onClick={onMountCurrent}
>
{mounting ? '挂载中…' : '挂载当前目录并归并到媒体库'}
</button>
<button
type="button"
className="rounded border border-blue-300/70 px-2 py-0.5 text-xs text-blue-500 hover:bg-blue-500/10 disabled:opacity-50"
disabled={mounting || batchMounting || loading || !hasDirectories}
onClick={onMountVisibleDirectories}
>
{batchMounting ? '批量挂载中…' : '一键挂载当前目录下所有文件夹'}
</button>
</div>
</div>
)
}
+61
View File
@@ -0,0 +1,61 @@
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 (
<div className="flex justify-center py-4 text-[var(--app-muted)]">
<Loader2 className="animate-spin" size={16} />
</div>
)
}
if (error) return <p className="py-2 text-sm text-red-400">{error}</p>
if (items.length === 0) return <p className="py-2 text-sm text-[var(--app-muted)]">该目录为空</p>
return (
<ul className="divide-y divide-[var(--app-border)]">
{items.map((entry) => (
<li key={entry.id} className="flex items-center gap-2 py-1.5 text-sm">
{entry.is_dir ? <Folder size={15} className="text-amber-400" /> : <FileVideo size={15} className="text-blue-300" />}
{entry.is_dir ? (
<>
<button type="button" className="flex-1 text-left text-[var(--app-subtle)] hover:text-brand-500" onClick={() => onEnter(entry)}>
{entry.name}
</button>
<button
type="button"
title="重命名文件夹"
className="rounded border border-[var(--app-border)] p-1 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
onClick={() => onRename(entry)}
>
<Pencil size={14} />
</button>
</>
) : (
<>
<span className="flex-1 truncate text-[var(--app-subtle)]">{entry.name}</span>
<button
type="button"
className="rounded border border-[var(--app-border)] px-2 py-0.5 text-xs text-[var(--app-subtle)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
onClick={() => onImport(entry)}
>
导入
</button>
</>
)}
</li>
))}
</ul>
)
}
+36
View File
@@ -0,0 +1,36 @@
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 (
<div className="mb-3 rounded border border-blue-300/30 bg-blue-500/10 p-2">
<div className="mb-1 text-xs font-semibold text-[var(--app-subtle)]">已挂载目录</div>
<div className="space-y-1">
{mounts.map((lib) => (
<div key={lib.id} className="flex items-center gap-2 rounded border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 text-xs">
<span className="min-w-0 flex-1 truncate text-[var(--app-subtle)]">
{lib.name} · {cloudLibraryLabel(lib.path)}
</span>
<button
type="button"
className="rounded border border-red-300/60 px-1.5 py-0.5 text-red-500 hover:bg-[var(--app-danger-soft)]"
onClick={() => onRemove(lib)}
title="移除挂载"
>
<Trash2 size={13} />
</button>
</div>
))}
</div>
</div>
)
}
+70
View File
@@ -0,0 +1,70 @@
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 (
<div className="mb-3 rounded border border-emerald-300/30 bg-emerald-500/10 p-2">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-xs font-semibold text-[var(--app-subtle)]">网盘媒体库扫描</div>
<p className="text-xs text-[var(--app-muted)]">
只需在系统设置填写公开域名,扫描会自动为网盘媒体生成 STRM/302 播放入口;中断后再次扫描会去重补齐。
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded border border-emerald-300/70 px-2 py-1 text-xs text-emerald-600 hover:bg-emerald-500/10"
disabled={scanBusy}
onClick={onScanAll}
>
{scanBusy ? <Loader2 size={13} className="inline animate-spin" /> : <RefreshCw size={13} className="inline" />}
{' '}一键扫描全部网盘库
</button>
<button
type="button"
className="rounded border border-red-300/60 px-2 py-1 text-xs text-red-500 hover:bg-[var(--app-danger-soft)]"
disabled={cancelBusy}
onClick={onCancelScans}
>
{cancelBusy ? <Loader2 size={13} className="inline animate-spin" /> : <PauseCircle size={13} className="inline" />}
{' '}中断当前网盘扫描
</button>
</div>
</div>
{scanStatuses.length > 0 && (
<div className="grid gap-1 text-xs text-[var(--app-muted)] md:grid-cols-2">
{scanStatuses.slice(0, 6).map((item) => (
<div key={item.library_id} className="rounded border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1">
<span className="font-mono text-[var(--app-subtle)]">{item.state}</span>
{' · '}
{item.provider}
{' · 目录 '}
{item.dirs}
{' · 发现 '}
{item.discovered}
{' · 入库 '}
{item.added + item.updated}
{item.error ? <span className="text-red-500"> · {item.error}</span> : null}
</div>
))}
</div>
)}
</div>
)
}
+94
View File
@@ -0,0 +1,94 @@
import { Info } from 'lucide-react'
import type { DiscoverItem } from '../api/discover'
import { imageURL } from '../api/client'
import { discoverItemSource } from './discoverPageModel'
export function ContentRow({
title,
items,
onSelect,
}: {
title: string
items: DiscoverItem[]
onSelect: (item: DiscoverItem) => void
}) {
return (
<section className="space-y-4">
<h2 className="pl-1 font-display text-2xl font-semibold text-ink-600">{title}</h2>
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{items.map((item, index) => (
<DiscoverCard key={discoverKey(item, index)} item={item} onSelect={onSelect} />
))}
</div>
</section>
)
}
export function DiscoverSkeleton() {
return (
<div className="space-y-8">
{[1, 2, 3].map((section) => (
<section key={section} className="space-y-4">
<div className="h-8 w-48 animate-pulse rounded-xl bg-gray-100" />
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{[1, 2, 3, 4, 5, 6, 7, 8].map((item) => (
<div key={item} className="aspect-[2/3] animate-pulse rounded-xl bg-gray-100" />
))}
</div>
</section>
))}
</div>
)
}
function DiscoverCard({ item, onSelect }: { item: DiscoverItem; onSelect: (item: DiscoverItem) => void }) {
const source = discoverItemSource(item)
return (
<button
type="button"
onClick={() => onSelect(item)}
className="group relative overflow-hidden rounded-xl border border-gray-200 bg-gray-50 text-left transition-all duration-300 hover:-translate-y-1 hover:border-primary-500/30 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-primary-400/40"
>
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-900">
{item.poster_url ? (
<img
src={imageURL(item.poster_url)}
alt={item.title}
loading="lazy"
referrerPolicy="no-referrer"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-gray-500">
无海报
</div>
)}
<div className="absolute left-1.5 top-1.5 rounded-xl border border-white/20 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-white backdrop-blur-sm">
{source}
</div>
{(item.rating ?? 0) > 0 && (
<div className="absolute right-1.5 top-1.5 rounded-xl border border-yellow-400/30 bg-black/70 px-1.5 py-0.5 text-[11px] font-semibold text-yellow-400 backdrop-blur-sm">
★ {(item.rating ?? 0).toFixed(1)}
</div>
)}
</div>
<div className="space-y-0.5 px-2.5 py-2">
<p className="truncate text-xs font-medium text-ink-600 transition-colors group-hover:text-brand-500">
{item.title}
</p>
<p className="text-[11px] text-sand-500">
{[item.media_type, item.year && item.year > 0 ? item.year : ''].filter(Boolean).join(' · ') || '推荐'}
</p>
<p className="flex items-center gap-1 pt-1 text-[10px] font-semibold text-brand-500">
<Info size={10} />
详情 / 订阅
</p>
</div>
</button>
)
}
function discoverKey(item: DiscoverItem, index: number): string {
return `${item.source || 'source'}:${item.tmdb_id || item.douban_id || item.bangumi_id || item.title}:${index}`
}
+218
View File
@@ -0,0 +1,218 @@
import { useState } from 'react'
import toast from 'react-hot-toast'
import { Download, Rss, X } from 'lucide-react'
import type { DiscoverItem } from '../api/discover'
import { imageURL } from '../api/client'
import { buildSiteSearchFeedURL, buildSubscriptionAliases, subscriptionsAPI } from '../api/subscriptions'
import { buildSubscribeKeyword, discoverItemSource } from './discoverPageModel'
export function DiscoverDetailModal({ item, onClose }: { item: DiscoverItem; onClose: () => void }) {
const source = discoverItemSource(item)
const keyword = item.subscribe_keyword || buildSubscribeKeyword(item)
const [form, setForm] = useState({
keyword,
search_mode: 'keyword',
imdb_id: '',
media_type: item.media_type || '',
resolution: 'best',
quality: '',
effects: '',
release_groups: '',
exclude_words: 'cam,ts,tc,枪版',
wash_enabled: false,
wash_priority: 'balanced',
save_path: '',
media_category: '',
priority: 50,
run_now: true,
})
const [busy, setBusy] = useState(false)
const submit = async () => {
const finalKeyword = form.keyword.trim() || keyword
const feed = buildSiteSearchFeedURL(finalKeyword, source, buildSubscriptionAliases(item))
setBusy(true)
try {
const sub = await subscriptionsAPI.create({
name: `${item.title} 自动订阅`,
feed_url: feed,
filter: finalKeyword,
media_type: form.media_type || undefined,
media_category: form.media_category || undefined,
save_path: form.save_path || undefined,
search_mode: form.search_mode,
imdb_id: form.imdb_id || undefined,
source,
poster_url: item.poster_url || undefined,
backdrop_url: item.backdrop_url || undefined,
overview: item.overview || undefined,
original_name: item.original_name || undefined,
year: item.year || undefined,
resolution: form.resolution === 'best' ? 'best' : form.resolution,
quality: form.quality || undefined,
effects: form.effects || undefined,
release_groups: form.release_groups || undefined,
exclude_words: form.exclude_words || undefined,
wash_enabled: form.wash_enabled,
wash_priority: form.wash_priority,
priority: form.priority,
enabled: true,
})
if (form.run_now) {
const run = await subscriptionsAPI.runNow(sub.id)
toast.success(run.queued > 0 ? `已订阅并加入 ${run.queued} 个下载` : '已订阅,暂未命中可下载资源')
} else {
toast.success('已创建订阅')
}
onClose()
} catch (err) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'订阅失败'
toast.error(msg)
} finally {
setBusy(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm">
<div className="max-h-[92vh] w-full max-w-5xl overflow-y-auto rounded-3xl border border-white/60 bg-white p-5 shadow-2xl">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-brand-500">{source}</p>
<h2 className="font-display text-2xl font-bold text-ink-600">{item.title}</h2>
<p className="mt-1 text-sm text-sand-500">
{[item.media_type, item.year && item.year > 0 ? item.year : '', item.rating ? `★ ${item.rating.toFixed(1)}` : '']
.filter(Boolean)
.join(' · ')}
</p>
</div>
<button className="rounded-full border border-gray-200 p-2 text-ink-50 hover:bg-gray-50" onClick={onClose}>
<X size={18} />
</button>
</div>
<div className="grid gap-5 lg:grid-cols-[260px_1fr]">
<div className="space-y-3">
<div className="overflow-hidden rounded-2xl bg-gray-100">
{item.poster_url ? (
<img src={imageURL(item.poster_url)} alt={item.title} className="aspect-[2/3] w-full object-cover" />
) : (
<div className="flex aspect-[2/3] items-center justify-center text-sand-500">无海报</div>
)}
</div>
{item.backdrop_url && (
<img src={imageURL(item.backdrop_url)} alt="" className="h-24 w-full rounded-2xl object-cover" />
)}
</div>
<div className="space-y-5">
<section className="rounded-2xl border border-gray-200 bg-gray-50 p-4">
<h3 className="mb-2 font-semibold text-ink-600">简介</h3>
<p className="text-sm leading-6 text-ink-100">{item.overview || '当前数据源没有返回简介。'}</p>
</section>
<section className="rounded-2xl border border-primary-400/20 bg-primary-400/5 p-4">
<h3 className="mb-3 flex items-center gap-2 font-semibold text-ink-600">
<Rss size={16} />
订阅下载规则
</h3>
<div className="grid gap-3 md:grid-cols-3">
<label className="text-xs text-sand-500 md:col-span-2">
搜索关键词
<input className="input-base mt-1" value={form.keyword} onChange={(e) => setForm((f) => ({ ...f, keyword: e.target.value }))} />
</label>
<label className="text-xs text-sand-500">
搜索方式
<select className="input-base mt-1" value={form.search_mode} onChange={(e) => setForm((f) => ({ ...f, search_mode: e.target.value }))}>
<option value="keyword">标题关键词</option>
<option value="imdb">IMDB ID</option>
</select>
</label>
<label className="text-xs text-sand-500">
IMDB ID
<input className="input-base mt-1" placeholder="tt1160419" value={form.imdb_id} onChange={(e) => setForm((f) => ({ ...f, imdb_id: e.target.value }))} />
</label>
<label className="text-xs text-sand-500">
类型
<select className="input-base mt-1" value={form.media_type} onChange={(e) => setForm((f) => ({ ...f, media_type: e.target.value }))}>
<option value="">自动识别</option>
<option value="movie">电影</option>
<option value="tv">电视剧</option>
<option value="anime">动漫</option>
<option value="variety">综艺</option>
</select>
</label>
<label className="text-xs text-sand-500">
分辨率
<select className="input-base mt-1" value={form.resolution} onChange={(e) => setForm((f) => ({ ...f, resolution: e.target.value }))}>
<option value="best">自动择优</option>
<option value="2160p">2160p / 4K</option>
<option value="1080p">1080p</option>
<option value="720p">720p</option>
</select>
</label>
<label className="text-xs text-sand-500">
质量
<select className="input-base mt-1" value={form.quality} onChange={(e) => setForm((f) => ({ ...f, quality: e.target.value }))}>
<option value="">不限</option>
<option value="remux">REMUX</option>
<option value="bluray">BluRay</option>
<option value="web-dl">WEB-DL</option>
<option value="hdtv">HDTV</option>
</select>
</label>
<label className="text-xs text-sand-500">
特效 / 音轨
<input className="input-base mt-1" placeholder="hdr,dolby-vision,atmos" value={form.effects} onChange={(e) => setForm((f) => ({ ...f, effects: e.target.value }))} />
</label>
<label className="text-xs text-sand-500">
洗版优先级
<select className="input-base mt-1 disabled:opacity-50" disabled={!form.wash_enabled} value={form.wash_priority} onChange={(e) => setForm((f) => ({ ...f, wash_priority: e.target.value }))}>
<option value="balanced">均衡</option>
<option value="resolution">分辨率优先</option>
<option value="quality">片源质量优先</option>
<option value="effects">HDR/DV/Atmos 优先</option>
<option value="seeders">做种数优先</option>
</select>
</label>
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs text-ink-100">
<input type="checkbox" checked={form.wash_enabled} onChange={(e) => setForm((f) => ({ ...f, wash_enabled: e.target.checked }))} />
启用洗版择优
</label>
<label className="text-xs text-sand-500">
发布组
<input className="input-base mt-1" placeholder="如 FRDS,OurTV" value={form.release_groups} onChange={(e) => setForm((f) => ({ ...f, release_groups: e.target.value }))} />
</label>
<label className="text-xs text-sand-500">
排除词
<input className="input-base mt-1" value={form.exclude_words} onChange={(e) => setForm((f) => ({ ...f, exclude_words: e.target.value }))} />
</label>
<label className="text-xs text-sand-500">
分类覆盖
<input className="input-base mt-1" placeholder="综艺 / 日番 / 欧美剧" value={form.media_category} onChange={(e) => setForm((f) => ({ ...f, media_category: e.target.value }))} />
</label>
<label className="text-xs text-sand-500">
保存路径覆盖
<input className="input-base mt-1" value={form.save_path} onChange={(e) => setForm((f) => ({ ...f, save_path: e.target.value }))} />
</label>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<label className="flex items-center gap-2 text-sm text-ink-100">
<input type="checkbox" checked={form.run_now} onChange={(e) => setForm((f) => ({ ...f, run_now: e.target.checked }))} />
创建后立即搜索并下载
</label>
<button disabled={busy} onClick={submit} className="neon-button disabled:opacity-60">
<Download size={16} />
{busy ? '处理中…' : '创建订阅'}
</button>
</div>
</section>
</div>
</div>
</div>
</div>
)
}
+63 -354
View File
@@ -1,24 +1,21 @@
import { useEffect, useMemo, useState } from 'react'
import toast from 'react-hot-toast'
import { AlertTriangle, Download, Info, Rss, Sparkles, X } from 'lucide-react'
import { AlertTriangle, Sparkles } from 'lucide-react'
import { discoverAPI, type DiscoverItem, type DiscoverSection } from '../api/discover'
import { imageURL } from '../api/client'
import { buildSiteSearchFeedURL, subscriptionsAPI } from '../api/subscriptions'
const defaultSections = [
'tmdb_trending_day',
'douban_hot_movie',
'douban_hot_tv',
'bangumi_calendar',
]
const storageKey = 'mediastation.discover.sections'
import { ContentRow, DiscoverSkeleton } from './DiscoverContentRow'
import { DiscoverDetailModal } from './DiscoverDetailModal'
import {
defaultSectionDefs,
defaultSections,
discoverStorageKey,
readSavedSections,
} from './discoverPageModel'
export function DiscoverPage() {
const [sections, setSections] = useState<DiscoverSection[]>([])
const [selected, setSelected] = useState<string[]>(defaultSections)
const [rows, setRows] = useState<Record<string, DiscoverItem[]>>({})
const [rowErrors, setRowErrors] = useState<Record<string, string>>({})
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [activeItem, setActiveItem] = useState<DiscoverItem | null>(null)
@@ -29,7 +26,9 @@ export function DiscoverPage() {
.then((items) => {
setSections(items)
const saved = readSavedSections(items)
setSelected(saved.length > 0 ? saved : defaultSections)
const available = new Set(items.map((item) => item.key))
const fallback = defaultSections.filter((key) => available.has(key))
setSelected(saved.length > 0 ? saved : fallback)
})
.catch(() => {
setSections(defaultSectionDefs)
@@ -40,26 +39,46 @@ export function DiscoverPage() {
useEffect(() => {
if (selected.length === 0) {
setRows({})
setRowErrors({})
setLoading(false)
return
}
let cancelled = false
setLoading(true)
setError('')
window.localStorage.setItem(storageKey, JSON.stringify(selected))
discoverAPI
.feed(selected)
.then((feed) => {
const next: Record<string, DiscoverItem[]> = {}
for (const key of selected) {
next[key] = feed[key] ?? []
}
setRows(next)
})
.catch((err) => {
setRows({})
setError(err instanceof Error ? err.message : String(err))
})
.finally(() => setLoading(false))
setRowErrors({})
setRows((current) => {
const next: Record<string, DiscoverItem[]> = {}
for (const key of selected) {
next[key] = current[key] ?? []
}
return next
})
window.localStorage.setItem(discoverStorageKey, JSON.stringify(selected))
let pending = selected.length
const markDone = () => {
pending -= 1
if (!cancelled && pending <= 0) setLoading(false)
}
for (const key of selected) {
discoverAPI
.feed([key])
.then((feed) => {
if (cancelled) return
setRows((current) => ({ ...current, [key]: feed[key] ?? [] }))
})
.catch((err) => {
if (cancelled) return
const message = err instanceof Error ? err.message : String(err)
setRows((current) => ({ ...current, [key]: [] }))
setRowErrors((current) => ({ ...current, [key]: message }))
})
.finally(markDone)
}
return () => {
cancelled = true
}
}, [selected])
const sectionMap = useMemo(
@@ -67,6 +86,7 @@ export function DiscoverPage() {
[sections],
)
const hasContent = selected.some((key) => (rows[key] ?? []).length > 0)
const hasRowErrors = Object.keys(rowErrors).length > 0
const toggleSection = (key: string) => {
setSelected((current) => {
@@ -116,7 +136,7 @@ export function DiscoverPage() {
</div>
</header>
{loading && <DiscoverSkeleton />}
{loading && !hasContent && <DiscoverSkeleton />}
{!loading && error && (
<div className="flex items-center gap-3 rounded-2xl border border-red-500/20 bg-red-500/10 p-4">
@@ -131,7 +151,7 @@ export function DiscoverPage() {
</div>
)}
{!loading && !error && selected.length > 0 && (
{!error && selected.length > 0 && (hasContent || !loading) && (
<div className="space-y-10">
{selected.map((key) => {
const items = rows[key] ?? []
@@ -146,7 +166,18 @@ export function DiscoverPage() {
)
})}
{!hasContent && (
{hasRowErrors && (
<div className="flex items-start gap-3 rounded-2xl border border-amber-500/20 bg-amber-500/10 p-4">
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-400" />
<div className="space-y-1 text-sm text-amber-200">
{Object.entries(rowErrors).map(([key, message]) => (
<p key={key}>{sectionMap.get(key)?.label ?? key}:{message}</p>
))}
</div>
</div>
)}
{!loading && !hasContent && (
<div className="rounded-2xl border border-gray-200 bg-white p-10 text-center">
<p className="text-sand-500">
当前选择的推荐源暂未返回内容,可切换豆瓣 / Bangumi 或检查网络代理。
@@ -165,325 +196,3 @@ export function DiscoverPage() {
</div>
)
}
function ContentRow({
title,
items,
onSelect,
}: {
title: string
items: DiscoverItem[]
onSelect: (item: DiscoverItem) => void
}) {
return (
<section className="space-y-4">
<h2 className="pl-1 font-display text-2xl font-semibold text-ink-600">{title}</h2>
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{items.map((item, index) => (
<DiscoverCard key={discoverKey(item, index)} item={item} onSelect={onSelect} />
))}
</div>
</section>
)
}
function DiscoverCard({ item, onSelect }: { item: DiscoverItem; onSelect: (item: DiscoverItem) => void }) {
const source = item.source || (item.bangumi_id ? 'bangumi' : item.douban_id ? 'douban' : 'tmdb')
return (
<button
type="button"
onClick={() => onSelect(item)}
className="group relative overflow-hidden rounded-xl border border-gray-200 bg-gray-50 text-left transition-all duration-300 hover:-translate-y-1 hover:border-primary-500/30 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-primary-400/40"
>
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-900">
{item.poster_url ? (
<img
src={imageURL(item.poster_url)}
alt={item.title}
loading="lazy"
referrerPolicy="no-referrer"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-gray-500">
无海报
</div>
)}
<div className="absolute left-1.5 top-1.5 rounded-xl border border-white/20 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-white backdrop-blur-sm">
{source}
</div>
{(item.rating ?? 0) > 0 && (
<div className="absolute right-1.5 top-1.5 rounded-xl border border-yellow-400/30 bg-black/70 px-1.5 py-0.5 text-[11px] font-semibold text-yellow-400 backdrop-blur-sm">
★ {(item.rating ?? 0).toFixed(1)}
</div>
)}
</div>
<div className="space-y-0.5 px-2.5 py-2">
<p className="truncate text-xs font-medium text-ink-600 transition-colors group-hover:text-brand-500">
{item.title}
</p>
<p className="text-[11px] text-sand-500">
{[item.media_type, item.year && item.year > 0 ? item.year : ''].filter(Boolean).join(' · ') || '推荐'}
</p>
<p className="flex items-center gap-1 pt-1 text-[10px] font-semibold text-brand-500">
<Info size={10} />
详情 / 订阅
</p>
</div>
</button>
)
}
function DiscoverDetailModal({ item, onClose }: { item: DiscoverItem; onClose: () => void }) {
const source = item.source || (item.bangumi_id ? 'bangumi' : item.douban_id ? 'douban' : 'tmdb')
const keyword = item.subscribe_keyword || buildSubscribeKeyword(item)
const [form, setForm] = useState({
keyword,
search_mode: 'keyword',
imdb_id: '',
media_type: item.media_type || '',
resolution: 'best',
quality: '',
effects: '',
release_groups: '',
exclude_words: 'cam,ts,tc,枪版',
wash_enabled: false,
wash_priority: 'balanced',
save_path: '',
media_category: '',
priority: 50,
run_now: true,
})
const [busy, setBusy] = useState(false)
const submit = async () => {
const finalKeyword = form.keyword.trim() || keyword
const feed = buildSiteSearchFeedURL(finalKeyword, source, [item.title, item.original_name || ''])
setBusy(true)
try {
const sub = await subscriptionsAPI.create({
name: `${item.title} 自动订阅`,
feed_url: feed,
filter: finalKeyword,
media_type: form.media_type || undefined,
media_category: form.media_category || undefined,
save_path: form.save_path || undefined,
search_mode: form.search_mode,
imdb_id: form.imdb_id || undefined,
source,
poster_url: item.poster_url || undefined,
backdrop_url: item.backdrop_url || undefined,
overview: item.overview || undefined,
resolution: form.resolution === 'best' ? 'best' : form.resolution,
quality: form.quality || undefined,
effects: form.effects || undefined,
release_groups: form.release_groups || undefined,
exclude_words: form.exclude_words || undefined,
wash_enabled: form.wash_enabled,
wash_priority: form.wash_priority,
priority: form.priority,
enabled: true,
})
if (form.run_now) {
const run = await subscriptionsAPI.runNow(sub.id)
toast.success(run.queued > 0 ? `已订阅并加入 ${run.queued} 个下载` : '已订阅,暂未命中可下载资源')
} else {
toast.success('已创建订阅')
}
onClose()
} catch (err) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'订阅失败'
toast.error(msg)
} finally {
setBusy(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm">
<div className="max-h-[92vh] w-full max-w-5xl overflow-y-auto rounded-3xl border border-white/60 bg-white p-5 shadow-2xl">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-brand-500">{source}</p>
<h2 className="font-display text-2xl font-bold text-ink-600">{item.title}</h2>
<p className="mt-1 text-sm text-sand-500">
{[item.media_type, item.year && item.year > 0 ? item.year : '', item.rating ? `★ ${item.rating.toFixed(1)}` : '']
.filter(Boolean)
.join(' · ')}
</p>
</div>
<button className="rounded-full border border-gray-200 p-2 text-ink-50 hover:bg-gray-50" onClick={onClose}>
<X size={18} />
</button>
</div>
<div className="grid gap-5 lg:grid-cols-[260px_1fr]">
<div className="space-y-3">
<div className="overflow-hidden rounded-2xl bg-gray-100">
{item.poster_url ? (
<img src={imageURL(item.poster_url)} alt={item.title} className="aspect-[2/3] w-full object-cover" />
) : (
<div className="flex aspect-[2/3] items-center justify-center text-sand-500">无海报</div>
)}
</div>
{item.backdrop_url && (
<img src={imageURL(item.backdrop_url)} alt="" className="h-24 w-full rounded-2xl object-cover" />
)}
</div>
<div className="space-y-5">
<section className="rounded-2xl border border-gray-200 bg-gray-50 p-4">
<h3 className="mb-2 font-semibold text-ink-600">简介</h3>
<p className="text-sm leading-6 text-ink-100">{item.overview || '当前数据源没有返回简介。'}</p>
</section>
<section className="rounded-2xl border border-primary-400/20 bg-primary-400/5 p-4">
<h3 className="mb-3 flex items-center gap-2 font-semibold text-ink-600">
<Rss size={16} />
订阅下载规则
</h3>
<div className="grid gap-3 md:grid-cols-3">
<label className="text-xs text-sand-500 md:col-span-2">
搜索关键词
<input className="input-base mt-1" value={form.keyword} onChange={(e) => setForm((f) => ({ ...f, keyword: e.target.value }))} />
</label>
<label className="text-xs text-sand-500">
搜索方式
<select className="input-base mt-1" value={form.search_mode} onChange={(e) => setForm((f) => ({ ...f, search_mode: e.target.value }))}>
<option value="keyword">标题关键词</option>
<option value="imdb">IMDB ID</option>
</select>
</label>
<label className="text-xs text-sand-500">
IMDB ID
<input className="input-base mt-1" placeholder="tt1160419" value={form.imdb_id} onChange={(e) => setForm((f) => ({ ...f, imdb_id: e.target.value }))} />
</label>
<label className="text-xs text-sand-500">
类型
<select className="input-base mt-1" value={form.media_type} onChange={(e) => setForm((f) => ({ ...f, media_type: e.target.value }))}>
<option value="">自动识别</option>
<option value="movie">电影</option>
<option value="tv">电视剧</option>
<option value="anime">动漫</option>
<option value="variety">综艺</option>
</select>
</label>
<label className="text-xs text-sand-500">
分辨率
<select className="input-base mt-1" value={form.resolution} onChange={(e) => setForm((f) => ({ ...f, resolution: e.target.value }))}>
<option value="best">自动择优</option>
<option value="2160p">2160p / 4K</option>
<option value="1080p">1080p</option>
<option value="720p">720p</option>
</select>
</label>
<label className="text-xs text-sand-500">
质量
<select className="input-base mt-1" value={form.quality} onChange={(e) => setForm((f) => ({ ...f, quality: e.target.value }))}>
<option value="">不限</option>
<option value="remux">REMUX</option>
<option value="bluray">BluRay</option>
<option value="web-dl">WEB-DL</option>
<option value="hdtv">HDTV</option>
</select>
</label>
<label className="text-xs text-sand-500">
特效 / 音轨
<input className="input-base mt-1" placeholder="hdr,dolby-vision,atmos" value={form.effects} onChange={(e) => setForm((f) => ({ ...f, effects: e.target.value }))} />
</label>
<label className="text-xs text-sand-500">
洗版优先级
<select className="input-base mt-1 disabled:opacity-50" disabled={!form.wash_enabled} value={form.wash_priority} onChange={(e) => setForm((f) => ({ ...f, wash_priority: e.target.value }))}>
<option value="balanced">均衡</option>
<option value="resolution">分辨率优先</option>
<option value="quality">片源质量优先</option>
<option value="effects">HDR/DV/Atmos 优先</option>
<option value="seeders">做种数优先</option>
</select>
</label>
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs text-ink-100">
<input type="checkbox" checked={form.wash_enabled} onChange={(e) => setForm((f) => ({ ...f, wash_enabled: e.target.checked }))} />
启用洗版择优
</label>
<label className="text-xs text-sand-500">
发布组
<input className="input-base mt-1" placeholder="如 FRDS,OurTV" value={form.release_groups} onChange={(e) => setForm((f) => ({ ...f, release_groups: e.target.value }))} />
</label>
<label className="text-xs text-sand-500">
排除词
<input className="input-base mt-1" value={form.exclude_words} onChange={(e) => setForm((f) => ({ ...f, exclude_words: e.target.value }))} />
</label>
<label className="text-xs text-sand-500">
分类覆盖
<input className="input-base mt-1" placeholder="综艺 / 日番 / 欧美剧" value={form.media_category} onChange={(e) => setForm((f) => ({ ...f, media_category: e.target.value }))} />
</label>
<label className="text-xs text-sand-500">
保存路径覆盖
<input className="input-base mt-1" value={form.save_path} onChange={(e) => setForm((f) => ({ ...f, save_path: e.target.value }))} />
</label>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<label className="flex items-center gap-2 text-sm text-ink-100">
<input type="checkbox" checked={form.run_now} onChange={(e) => setForm((f) => ({ ...f, run_now: e.target.checked }))} />
创建后立即搜索并下载
</label>
<button disabled={busy} onClick={submit} className="neon-button disabled:opacity-60">
<Download size={16} />
{busy ? '处理中…' : '创建订阅'}
</button>
</div>
</section>
</div>
</div>
</div>
</div>
)
}
function buildSubscribeKeyword(item: DiscoverItem): string {
return [item.title, item.year && item.year > 0 ? item.year : ''].filter(Boolean).join(' ')
}
function DiscoverSkeleton() {
return (
<div className="space-y-8">
{[1, 2, 3].map((section) => (
<section key={section} className="space-y-4">
<div className="h-8 w-48 animate-pulse rounded-xl bg-gray-100" />
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
{[1, 2, 3, 4, 5, 6, 7, 8].map((item) => (
<div key={item} className="aspect-[2/3] animate-pulse rounded-xl bg-gray-100" />
))}
</div>
</section>
))}
</div>
)
}
function discoverKey(item: DiscoverItem, index: number): string {
return `${item.source || 'source'}:${item.tmdb_id || item.douban_id || item.bangumi_id || item.title}:${index}`
}
function readSavedSections(sections: DiscoverSection[]): string[] {
try {
const raw = window.localStorage.getItem(storageKey)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
const allowed = new Set(sections.map((section) => section.key))
return parsed.filter((key) => typeof key === 'string' && allowed.has(key))
} catch {
return []
}
}
const defaultSectionDefs: DiscoverSection[] = [
{ key: 'tmdb_trending_day', label: 'TMDb 今日趋势', provider: 'tmdb' },
{ key: 'tmdb_popular_movie', label: 'TMDb 热门电影', provider: 'tmdb' },
{ key: 'douban_hot_movie', label: '豆瓣热门电影', provider: 'douban' },
{ key: 'douban_hot_tv', label: '豆瓣热门剧集', provider: 'douban' },
{ key: 'bangumi_calendar', label: 'Bangumi 每日放送', provider: 'bangumi' },
]
+70
View File
@@ -0,0 +1,70 @@
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 (
<div className="glass-panel flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-ink-600">{client.name}</span>
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs text-ink-50">
{client.type}
</span>
{client.is_default && (
<span className="rounded-lg bg-primary-400/20 px-2 py-0.5 text-xs text-brand-500">
默认
</span>
)}
{!client.enabled && (
<span className="rounded-lg bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">
已禁用
</span>
)}
</div>
<div className="mt-1 truncate text-xs text-ink-50">
{client.host}
{client.username && ` · ${client.username}`}
</div>
</div>
<div className="flex shrink-0 gap-2">
<button
onClick={() => onTest(client.id)}
disabled={testing}
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
{testing ? (
<Loader2 size={12} className="inline animate-spin" />
) : (
<Send size={12} className="inline" />
)}{' '}
测试
</button>
<button
onClick={() => onEdit(client)}
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
<Pencil size={12} className="inline" /> 编辑
</button>
<button
onClick={() => onDelete(client)}
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
>
<Trash2 size={12} className="inline" /> 删除
</button>
</div>
</div>
)
}
+170
View File
@@ -0,0 +1,170 @@
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<void>
}) {
const [form, setForm] = useState<DownloadClientInput>(() => ({
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 extends keyof DownloadClientInput>(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<DownloadClientType, string>
)[form.type]
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
{editing ? '编辑下载器' : '添加下载器'}
</h2>
<form onSubmit={onSubmit} className="space-y-4">
<Field label="名称">
<input
required
className="input-base"
value={form.name}
onChange={(e) => update('name', e.target.value)}
/>
</Field>
<Field label="类型">
<select
className="input-base"
value={form.type}
onChange={(e) => update('type', e.target.value as DownloadClientType)}
>
<option value="qbittorrent">qBittorrent</option>
<option value="aria2">Aria2 (JSON-RPC)</option>
<option value="transmission">Transmission</option>
</select>
</Field>
<Field label="URL">
<input
required
className="input-base"
placeholder={placeholder}
value={form.host}
onChange={(e) => update('host', e.target.value)}
/>
</Field>
{form.type !== 'aria2' && (
<>
<Field label="用户名">
<input
className="input-base"
value={form.username ?? ''}
onChange={(e) => update('username', e.target.value)}
/>
</Field>
<Field label={editing ? '密码 (留空保持不变)' : '密码'}>
<input
type="password"
className="input-base"
value={form.password ?? ''}
onChange={(e) => update('password', e.target.value)}
/>
</Field>
</>
)}
{form.type === 'aria2' && (
<Field label="RPC Token (作为密码字段保存)">
<input
type="password"
className="input-base"
value={form.password ?? ''}
onChange={(e) => update('password', e.target.value)}
/>
</Field>
)}
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={form.is_default}
onChange={(e) => update('is_default', e.target.checked)}
/>
设为默认
</label>
<label className="flex items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={form.enabled}
onChange={(e) => update('enabled', e.target.checked)}
/>
启用
</label>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50"
>
取消
</button>
<button type="submit" disabled={saving} className="neon-button">
{saving && <Loader2 size={16} className="animate-spin" />} 保存
</button>
</div>
</form>
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-sm text-ink-100">{label}</span>
{children}
</label>
)
}
+16 -227
View File
@@ -1,14 +1,15 @@
import { FormEvent, useEffect, useState } from 'react'
import { Loader2, Pencil, Plus, Send, Server, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Loader2, Plus, Server } from 'lucide-react'
import toast from 'react-hot-toast'
import {
downloadClientsAPI,
type DownloadClient,
type DownloadClientInput,
type DownloadClientType,
} from '../api/download_clients'
import { confirmAction } from '../components/ConfirmDialog'
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
@@ -98,62 +99,17 @@ export function DownloadClientsPage() {
{!loading && clients.length > 0 && (
<div className="space-y-3">
{clients.map((c) => (
<div
<DownloadClientCard
key={c.id}
className="glass-panel flex items-center justify-between gap-3"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-ink-600">{c.name}</span>
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs text-ink-50">
{c.type}
</span>
{c.is_default && (
<span className="rounded-lg bg-primary-400/20 px-2 py-0.5 text-xs text-brand-500">
默认
</span>
)}
{!c.enabled && (
<span className="rounded-lg bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">
已禁用
</span>
)}
</div>
<div className="mt-1 truncate text-xs text-ink-50">
{c.host}
{c.username && ` · ${c.username}`}
</div>
</div>
<div className="flex shrink-0 gap-2">
<button
onClick={() => onTest(c.id)}
disabled={testing[c.id]}
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
{testing[c.id] ? (
<Loader2 size={12} className="inline animate-spin" />
) : (
<Send size={12} className="inline" />
)}{' '}
测试
</button>
<button
onClick={() => {
setEditing(c)
setShowForm(true)
}}
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
<Pencil size={12} className="inline" /> 编辑
</button>
<button
onClick={() => onDelete(c)}
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
>
<Trash2 size={12} className="inline" /> 删除
</button>
</div>
</div>
client={c}
testing={Boolean(testing[c.id])}
onDelete={onDelete}
onEdit={(client) => {
setEditing(client)
setShowForm(true)
}}
onTest={onTest}
/>
))}
</div>
)}
@@ -171,170 +127,3 @@ export function DownloadClientsPage() {
</div>
)
}
function ClientFormModal({
editing,
onClose,
onSaved,
}: {
editing: DownloadClient | null
onClose: () => void
onSaved: () => void | Promise<void>
}) {
const [form, setForm] = useState<DownloadClientInput>(() => ({
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 extends keyof DownloadClientInput>(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<DownloadClientType, string>
)[form.type]
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
{editing ? '编辑下载器' : '添加下载器'}
</h2>
<form onSubmit={onSubmit} className="space-y-4">
<Field label="名称">
<input
required
className="input-base"
value={form.name}
onChange={(e) => update('name', e.target.value)}
/>
</Field>
<Field label="类型">
<select
className="input-base"
value={form.type}
onChange={(e) => update('type', e.target.value as DownloadClientType)}
>
<option value="qbittorrent">qBittorrent</option>
<option value="aria2">Aria2 (JSON-RPC)</option>
<option value="transmission">Transmission</option>
</select>
</Field>
<Field label="URL">
<input
required
className="input-base"
placeholder={placeholder}
value={form.host}
onChange={(e) => update('host', e.target.value)}
/>
</Field>
{form.type !== 'aria2' && (
<>
<Field label="用户名">
<input
className="input-base"
value={form.username ?? ''}
onChange={(e) => update('username', e.target.value)}
/>
</Field>
<Field label={editing ? '密码 (留空保持不变)' : '密码'}>
<input
type="password"
className="input-base"
value={form.password ?? ''}
onChange={(e) => update('password', e.target.value)}
/>
</Field>
</>
)}
{form.type === 'aria2' && (
<Field label="RPC Token (作为密码字段保存)">
<input
type="password"
className="input-base"
value={form.password ?? ''}
onChange={(e) => update('password', e.target.value)}
/>
</Field>
)}
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={form.is_default}
onChange={(e) => update('is_default', e.target.checked)}
/>
设为默认
</label>
<label className="flex items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={form.enabled}
onChange={(e) => update('enabled', e.target.checked)}
/>
启用
</label>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50"
>
取消
</button>
<button type="submit" disabled={saving} className="neon-button">
{saving && <Loader2 size={16} className="animate-spin" />} 保存
</button>
</div>
</form>
</div>
</div>
)
}
function apiErrorMessage(err: unknown, fallback: string): string {
const data = (err as { response?: { data?: { error?: string; message?: string } } })?.response?.data
if (data?.error) return data.error
if (data?.message) return data.message
if ((err as { code?: string })?.code === 'ECONNABORTED') return '请求超时,请检查服务或网络'
return fallback
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-sm text-ink-100">{label}</span>
{children}
</label>
)
}
+205
View File
@@ -0,0 +1,205 @@
import type { ReactNode } from 'react'
import { ArrowDown, ArrowUp, Film, HardDrive, Rss, Trash2 } from 'lucide-react'
import { imageURL } from '../api/client'
import type { DownloadTask, QBitTorrent } from '../types'
export type DownloadCardItem = {
id?: string
hash?: string
title: string
poster_url?: string
backdrop_url?: string
overview?: string
save_path?: string
status?: string
state?: string
progress: number
dlspeed?: number
upspeed?: number
num_seeds?: number
num_leechs?: number
size?: number
downloaded?: number
created_at?: string
updated_at?: string
}
export function toLiveCard(t: QBitTorrent): DownloadCardItem {
return {
hash: t.hash,
title: t.title || t.name || '下载任务',
poster_url: t.poster_url,
backdrop_url: t.backdrop_url,
overview: t.overview,
save_path: t.save_path,
state: t.state,
progress: t.progress,
dlspeed: t.dlspeed,
upspeed: t.upspeed,
num_seeds: t.num_seeds,
num_leechs: t.num_leechs,
size: t.size,
downloaded: t.downloaded,
}
}
export function toTaskCard(t: DownloadTask): DownloadCardItem {
return {
id: t.id,
title: t.title || '下载任务',
poster_url: t.poster_url,
backdrop_url: t.backdrop_url,
overview: t.overview,
save_path: t.save_path,
status: t.status,
state: t.state,
progress: t.progress,
dlspeed: t.dlspeed,
upspeed: t.upspeed,
num_seeds: t.num_seeds,
num_leechs: t.num_leechs,
size: t.size,
downloaded: t.downloaded,
created_at: t.created_at,
updated_at: t.updated_at,
}
}
export function DownloadTaskCard({
item,
removable,
onRemove,
}: {
item: DownloadCardItem
removable?: boolean
onRemove?: () => Promise<void>
}) {
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 (
<article className="group overflow-hidden rounded-3xl border border-white/70 bg-white shadow-sm transition hover:-translate-y-1 hover:shadow-xl">
<div className="relative flex gap-4 p-4">
<div className="relative h-40 w-28 flex-shrink-0 overflow-hidden rounded-2xl bg-gradient-to-br from-primary-400/15 via-white to-surface-200 shadow-inner">
{visual ? (
<img
src={imageURL(visual, item.updated_at)}
alt={item.title}
className="h-full w-full object-cover"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-3 text-center text-xs font-semibold text-brand-500">
<Film size={24} />
<span className="line-clamp-3">{item.title}</span>
</div>
)}
<span className={`absolute left-2 top-2 rounded-full px-2 py-0.5 text-[10px] font-semibold ${statusTone(item)}`}>
{stateLabel(item)}
</span>
</div>
<div className="min-w-0 flex-1 space-y-3">
<div>
<h2 className="line-clamp-2 font-display text-lg font-semibold leading-snug text-ink-600" title={item.title}>
{item.title}
</h2>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-ink-50">
{item.overview || item.save_path || '已隐藏原始种子 URL,避免泄露私有 Token。'}
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs font-semibold text-ink-100">
<span>进度 {progress.toFixed(1)}%</span>
<span>{fmtBytes(downloaded)} / {fmtBytes(item.size)}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
<div
className="h-full rounded-full bg-gradient-to-r from-primary-400 to-brand-500 transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-ink-100">
<DownloadMetric icon={<ArrowDown size={13} className="mr-1 inline text-brand-500" />} value={fmtSpeed(item.dlspeed)} />
<DownloadMetric icon={<ArrowUp size={13} className="mr-1 inline text-brand-500" />} value={fmtSpeed(item.upspeed)} />
<DownloadMetric icon={<Rss size={13} className="mr-1 inline text-brand-500" />} value={`${item.num_seeds ?? 0} / ${item.num_leechs ?? 0}`} />
<DownloadMetric icon={<HardDrive size={13} className="mr-1 inline text-brand-500" />} value={fmtBytes(item.size)} />
</div>
<div className="flex items-center justify-between gap-2 text-xs text-ink-50">
<span className="truncate" title={item.save_path || ''}>
{item.save_path || '默认下载目录'}
</span>
{item.created_at && <span>{new Date(item.created_at).toLocaleString()}</span>}
</div>
</div>
</div>
{removable && onRemove && (
<div className="flex justify-end border-t border-gray-100 bg-gray-50/70 px-4 py-3">
<button
className="rounded-xl border border-red-400/40 bg-white px-3 py-1.5 text-xs font-semibold text-red-400 hover:bg-red-400/10"
onClick={() => void onRemove()}
>
<Trash2 size={13} className="mr-1 inline" />
删除任务
</button>
</div>
)}
</article>
)
}
function DownloadMetric({ icon, value }: { icon: ReactNode; value: string }) {
return (
<div className="rounded-2xl bg-gray-50 px-3 py-2">
{icon}
{value}
</div>
)
}
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'
}
+5 -206
View File
@@ -1,214 +1,13 @@
import { FormEvent, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import toast from 'react-hot-toast'
import { ArrowDown, ArrowUp, Download, Film, HardDrive, Rss, ShieldCheck, Trash2 } from 'lucide-react'
import { Download, ShieldCheck } from 'lucide-react'
import { imageURL } from '../api/client'
import { downloadsAPI } from '../api/downloads'
import { useAuthStore } from '../stores/auth'
import { confirmAction } from '../components/ConfirmDialog'
import { confirmAction } from '../components/confirmAction'
import type { DownloadTask, QBitTorrent } from '../types'
type DownloadCardItem = {
id?: string
hash?: string
title: string
poster_url?: string
backdrop_url?: string
overview?: string
save_path?: string
status?: string
state?: string
progress: number
dlspeed?: number
upspeed?: number
num_seeds?: number
num_leechs?: number
size?: number
downloaded?: number
created_at?: string
}
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'
}
function toLiveCard(t: QBitTorrent): DownloadCardItem {
return {
hash: t.hash,
title: t.title || t.name || '下载任务',
poster_url: t.poster_url,
backdrop_url: t.backdrop_url,
overview: t.overview,
save_path: t.save_path,
state: t.state,
progress: t.progress,
dlspeed: t.dlspeed,
upspeed: t.upspeed,
num_seeds: t.num_seeds,
num_leechs: t.num_leechs,
size: t.size,
downloaded: t.downloaded,
}
}
function toTaskCard(t: DownloadTask): DownloadCardItem {
return {
id: t.id,
title: t.title || '下载任务',
poster_url: t.poster_url,
backdrop_url: t.backdrop_url,
overview: t.overview,
save_path: t.save_path,
status: t.status,
state: t.state,
progress: t.progress,
dlspeed: t.dlspeed,
upspeed: t.upspeed,
num_seeds: t.num_seeds,
num_leechs: t.num_leechs,
size: t.size,
downloaded: t.downloaded,
created_at: t.created_at,
}
}
function DownloadCard({
item,
removable,
onRemove,
}: {
item: DownloadCardItem
removable?: boolean
onRemove?: () => Promise<void>
}) {
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 (
<article className="group overflow-hidden rounded-3xl border border-white/70 bg-white shadow-sm transition hover:-translate-y-1 hover:shadow-xl">
<div className="relative flex gap-4 p-4">
<div className="relative h-40 w-28 flex-shrink-0 overflow-hidden rounded-2xl bg-gradient-to-br from-primary-400/15 via-white to-surface-200 shadow-inner">
{visual ? (
<img
src={imageURL(visual)}
alt={item.title}
className="h-full w-full object-cover"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-3 text-center text-xs font-semibold text-brand-500">
<Film size={24} />
<span className="line-clamp-3">{item.title}</span>
</div>
)}
<span className={`absolute left-2 top-2 rounded-full px-2 py-0.5 text-[10px] font-semibold ${statusTone(item)}`}>
{stateLabel(item)}
</span>
</div>
<div className="min-w-0 flex-1 space-y-3">
<div>
<h2 className="line-clamp-2 font-display text-lg font-semibold leading-snug text-ink-600" title={item.title}>
{item.title}
</h2>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-ink-50">
{item.overview || item.save_path || '已隐藏原始种子 URL,避免泄露私有 Token。'}
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs font-semibold text-ink-100">
<span>进度 {progress.toFixed(1)}%</span>
<span>{fmtBytes(downloaded)} / {fmtBytes(item.size)}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
<div
className="h-full rounded-full bg-gradient-to-r from-primary-400 to-brand-500 transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-ink-100">
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<ArrowDown size={13} className="mr-1 inline text-brand-500" />
{fmtSpeed(item.dlspeed)}
</div>
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<ArrowUp size={13} className="mr-1 inline text-brand-500" />
{fmtSpeed(item.upspeed)}
</div>
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<Rss size={13} className="mr-1 inline text-brand-500" />
{item.num_seeds ?? 0} / {item.num_leechs ?? 0}
</div>
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<HardDrive size={13} className="mr-1 inline text-brand-500" />
{fmtBytes(item.size)}
</div>
</div>
<div className="flex items-center justify-between gap-2 text-xs text-ink-50">
<span className="truncate" title={item.save_path || ''}>
{item.save_path || '默认下载目录'}
</span>
{item.created_at && <span>{new Date(item.created_at).toLocaleString()}</span>}
</div>
</div>
</div>
{removable && onRemove && (
<div className="flex justify-end border-t border-gray-100 bg-gray-50/70 px-4 py-3">
<button
className="rounded-xl border border-red-400/40 bg-white px-3 py-1.5 text-xs font-semibold text-red-400 hover:bg-red-400/10"
onClick={() => void onRemove()}
>
<Trash2 size={13} className="mr-1 inline" />
删除任务
</button>
</div>
)}
</article>
)
}
import { DownloadTaskCard, toLiveCard, toTaskCard } from './DownloadTaskCard'
export function DownloadsPage() {
const role = useAuthStore((s) => s.user?.role)
@@ -301,7 +100,7 @@ export function DownloadsPage() {
{torrents && torrents.length > 0 && (
<div className="grid gap-5 lg:grid-cols-2 2xl:grid-cols-3">
{torrents.map((torrent) => (
<DownloadCard
<DownloadTaskCard
key={torrent.hash}
item={toLiveCard(torrent)}
removable={role === 'admin'}
@@ -324,7 +123,7 @@ export function DownloadsPage() {
) : (
<div className="grid gap-5 lg:grid-cols-2 2xl:grid-cols-3">
{tasks.map((task) => (
<DownloadCard key={task.id} item={toTaskCard(task)} />
<DownloadTaskCard key={task.id} item={toTaskCard(task)} />
))}
</div>
)}
+1 -1
View File
@@ -4,7 +4,7 @@ import { Copy, Trash2 } from 'lucide-react'
import { duplicatesAPI, type DuplicateReport } from '../api/duplicates'
import { libraryAPI } from '../api/library'
import { confirmAction } from '../components/ConfirmDialog'
import { confirmAction } from '../components/confirmAction'
import type { Library } from '../types'
function fmtBytes(n: number): string {
+31
View File
@@ -0,0 +1,31 @@
import { FolderOpen } from 'lucide-react'
type FileRoot = {
label: string
path: string
}
type FileBrowserRootsProps = {
roots: FileRoot[]
onOpen: (path: string) => void
}
export function FileBrowserRoots({ roots, onOpen }: FileBrowserRootsProps) {
return (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{roots.map((root) => (
<button
key={root.path}
onClick={() => onOpen(root.path)}
className="glass-panel flex items-center gap-3 text-left transition hover:border-primary-400/40"
>
<FolderOpen size={20} className="text-brand-500" />
<div>
<p className="font-mono text-sm text-ink-600">{root.label}</p>
<p className="font-mono text-xs text-ink-50">{root.path}</p>
</div>
</button>
))}
</div>
)
}
+104
View File
@@ -0,0 +1,104 @@
import { FileVideo, Folder, HardDrive } from 'lucide-react'
import type { FileEntry } from '../api/files'
type FileEntriesTableProps = {
basePath: string
entries: FileEntry[]
recursive: boolean
selectedPath?: string
selectedPaths: string[]
onSelectAll: (checked: boolean) => void
onToggleSelectedPath: (entry: FileEntry, checked: boolean) => void
onEnter: (entry: FileEntry) => void
onChoose: (entry: FileEntry) => void
}
export function FileEntriesTable({
basePath,
entries,
recursive,
selectedPath,
selectedPaths,
onSelectAll,
onToggleSelectedPath,
onEnter,
onChoose,
}: FileEntriesTableProps) {
return (
<div className="glass-panel overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="w-10 py-2">
<input
type="checkbox"
aria-label="选择当前目录全部项目"
checked={entries.length > 0 && entries.every((entry) => selectedPaths.includes(entry.path))}
onChange={(event) => onSelectAll(event.target.checked)}
/>
</th>
<th className="py-2">名称</th>
<th>大小</th>
<th>修改时间</th>
<th className="text-right">选择</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr
key={entry.path}
className={'border-t border-gray-200 transition hover:bg-gray-50 ' + (selectedPath === entry.path || selectedPaths.includes(entry.path) ? 'bg-primary-400/5' : '')}
>
<td className="py-2">
<input
type="checkbox"
aria-label={`选择 ${entry.name}`}
checked={selectedPaths.includes(entry.path)}
onChange={(event) => onToggleSelectedPath(entry, event.target.checked)}
/>
</td>
<td className="py-2 text-ink-600">
<button
className="flex max-w-xl items-center gap-2 text-left"
onClick={() => (entry.is_dir ? onEnter(entry) : onChoose(entry))}
title={entry.path}
>
{entry.is_dir ? <Folder size={16} className="text-brand-500" /> : <FileVideo size={16} className="text-ink-50" />}
<span className="truncate">{entryLabel(entry, basePath, recursive)}</span>
</button>
</td>
<td className="text-ink-100">{entry.is_dir ? '—' : fmtBytes(entry.size)}</td>
<td className="text-sand-500">{new Date(entry.modified * 1000).toLocaleString()}</td>
<td className="text-right">
<button
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40"
onClick={() => onChoose(entry)}
>
<HardDrive size={12} className="mr-1 inline" /> 操作
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function entryLabel(entry: FileEntry, basePath: string, recursive: boolean): string {
if (!recursive) return entry.name
return entry.path.replace((basePath || '') + '\\', '').replace((basePath || '') + '/', '')
}
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]}`
}
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
import { ChevronUp, Home, RefreshCw } from 'lucide-react'
type FileManagerToolbarProps = {
currentPath?: string
parentPath?: string
recursive: boolean
onRoot: () => void
onParent: (path: string) => void
onRefresh: () => void
onRecursiveChange: (value: boolean) => void
}
export function FileManagerToolbar({
currentPath,
parentPath,
recursive,
onRoot,
onParent,
onRefresh,
onRecursiveChange,
}: FileManagerToolbarProps) {
return (
<div className="flex flex-wrap items-center gap-2">
<button className="neon-button !px-3 !py-1 !text-xs" onClick={onRoot} title="返回根列表">
<Home size={14} /> 根
</button>
{parentPath && (
<button className="neon-button !px-3 !py-1 !text-xs" onClick={() => onParent(parentPath)}>
<ChevronUp size={14} /> 上一级
</button>
)}
<button className="neon-button !px-3 !py-1 !text-xs" onClick={onRefresh}>
<RefreshCw size={14} /> 刷新
</button>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input type="checkbox" checked={recursive} onChange={(e) => onRecursiveChange(e.target.checked)} />
递归扫描
</label>
{currentPath && (
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 font-mono text-xs text-ink-100">
{currentPath}
</span>
)}
</div>
)
}
+122
View File
@@ -0,0 +1,122 @@
import { Copy, GitBranch, Move, Pencil, Plus, Trash2 } from 'lucide-react'
import type { FileEntry } from '../api/files'
type FileOperationsPanelProps = {
selected: FileEntry | null
folderName: string
renameTo: string
destPath: string
transferMode: string
busy: string
onFolderNameChange: (value: string) => void
onRenameToChange: (value: string) => void
onDestPathChange: (value: string) => void
onTransferModeChange: (value: string) => void
onCreateFolder: () => void
onRenameSelected: () => void
onDeleteSelected: () => void
onTransferSelected: () => void
}
export function FileOperationsPanel({
selected,
folderName,
renameTo,
destPath,
transferMode,
busy,
onFolderNameChange,
onRenameToChange,
onDestPathChange,
onTransferModeChange,
onCreateFolder,
onRenameSelected,
onDeleteSelected,
onTransferSelected,
}: FileOperationsPanelProps) {
return (
<details className="glass-panel" open={Boolean(selected)}>
<summary className="cursor-pointer list-none font-display text-lg font-semibold text-ink-600">
文件操作
<span className="ml-2 text-xs font-normal text-sand-500">
新建目录 / 重命名 / 删除 / 转移
</span>
</summary>
<div className="mt-4 grid gap-4 lg:grid-cols-[1fr_1.2fr]">
<div className="space-y-2">
<h2 className="text-sm font-semibold text-ink-600">新建目录</h2>
<div className="flex gap-2">
<input
className="input-base flex-1"
placeholder="新目录名称"
value={folderName}
onChange={(event) => onFolderNameChange(event.target.value)}
/>
<button className="neon-button" disabled={busy === 'mkdir' || !folderName.trim()} onClick={onCreateFolder}>
<Plus size={16} /> 创建
</button>
</div>
</div>
<div className="space-y-2">
<h2 className="text-sm font-semibold text-ink-600">选中项</h2>
{selected ? (
<div className="space-y-2">
<p className="truncate font-mono text-xs text-ink-50" title={selected.path}>{selected.path}</p>
<div className="flex flex-wrap gap-2">
<input
className="input-base min-w-[220px] flex-1"
value={renameTo}
onChange={(event) => onRenameToChange(event.target.value)}
/>
<button className="neon-button" disabled={busy === 'rename' || !renameTo.trim()} onClick={onRenameSelected}>
<Pencil size={16} /> 重命名
</button>
<button
className="rounded-xl border border-red-400/40 px-3 py-2 text-sm text-red-500 hover:bg-red-50"
disabled={busy === 'delete'}
onClick={onDeleteSelected}
>
<Trash2 size={16} className="inline" /> 删除
</button>
</div>
{!selected.is_dir && (
<div className="grid gap-2 md:grid-cols-[1fr_140px_auto]">
<input
className="input-base"
placeholder="目标目录路径"
value={destPath}
onChange={(event) => onDestPathChange(event.target.value)}
/>
<select
className="input-base"
value={transferMode}
onChange={(event) => onTransferModeChange(event.target.value)}
>
<option value="copy">复制</option>
<option value="move">移动</option>
<option value="hardlink">硬链接</option>
<option value="symlink">软链接</option>
</select>
<button className="neon-button" disabled={busy === 'transfer' || !destPath.trim()} onClick={onTransferSelected}>
{transferIcon(transferMode)}
转移
</button>
</div>
)}
</div>
) : (
<p className="text-sm text-ink-50">先在下方列表点击“操作”选择文件或目录。</p>
)}
</div>
</div>
</details>
)
}
function transferIcon(mode: string) {
if (mode === 'move') return <Move size={16} />
if (mode === 'copy') return <Copy size={16} />
return <GitBranch size={16} />
}
+40 -40
View File
@@ -60,10 +60,10 @@ export function HomePage() {
<div className="flex items-center justify-center py-48">
<motion.div animate={{ opacity: [0.4, 1, 0.4] }} transition={{ repeat: Infinity, duration: 1.5 }} className="flex flex-col items-center gap-4">
<div className="relative flex items-center justify-center">
<div className="h-10 w-10 rounded-full border-2 border-gray-100 border-t-gray-900 animate-spin" />
<div className="h-10 w-10 animate-spin rounded-full border-2 border-[var(--app-border)] border-t-[var(--app-active-bg)]" />
<Film className="absolute h-4 w-4 text-brand-500" />
</div>
<span className="text-sm font-semibold tracking-widest text-gray-500 uppercase">首页内容准备中…</span>
<span className="text-sm font-semibold uppercase tracking-widest text-[var(--app-muted)]">首页内容准备中…</span>
</motion.div>
</div>
)
@@ -72,11 +72,11 @@ export function HomePage() {
if (empty) {
return (
<div className="flex flex-col items-center justify-center py-32 text-center max-w-md mx-auto">
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-3xl bg-gray-100 border border-gray-200/50 shadow-sm">
<Film className="h-10 w-10 text-gray-500" />
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] shadow-sm">
<Film className="h-10 w-10 text-[var(--app-muted)]" />
</div>
<p className="text-xl font-bold text-gray-900">您的家庭影视站暂无内容</p>
<p className="mt-2 text-sm text-gray-500 leading-relaxed">
<p className="text-xl font-bold text-[var(--app-text)]">您的家庭影视站暂无内容</p>
<p className="mt-2 text-sm leading-relaxed text-[var(--app-muted)]">
前往管理后台添加媒体目录,扫描后首页将展示本周力荐、继续观看和最近入库。
</p>
<Link to="/admin" className="mt-8 btn-primary">
@@ -89,65 +89,65 @@ export function HomePage() {
return (
<div className="space-y-12">
{featuredItem && (
<section className="relative overflow-hidden rounded-[2rem] bg-white border border-gray-200/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)]">
<section className="relative overflow-hidden rounded-[2rem] border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_24px_80px_var(--app-shadow)]">
<div className="absolute inset-0 z-0">
<div className="h-full w-full bg-[radial-gradient(circle_at_80%_20%,rgba(212,175,55,0.22),transparent_34%),linear-gradient(135deg,#fff7ed,#f8fafc_52%,#eef2ff)]" />
<div className="theme-hero-bg h-full w-full" />
{featuredVisual && (
<img
src={imageURL(featuredVisual)}
src={imageURL(featuredVisual, featuredItem.updated_at)}
alt=""
className="absolute inset-0 h-full w-full scale-105 object-cover object-center opacity-[0.34] blur-[1px]"
referrerPolicy="no-referrer"
onError={(event) => { event.currentTarget.style.display = 'none' }}
/>
)}
<div className="absolute inset-0 bg-[linear-gradient(90deg,#ffffff_0%,rgba(255,255,255,0.96)_37%,rgba(255,255,255,0.62)_68%,rgba(255,255,255,0.2)_100%)]" />
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-white to-transparent" />
<div className="theme-hero-overlay absolute inset-0" />
<div className="theme-hero-fade absolute inset-x-0 bottom-0 h-32" />
</div>
<div className="relative z-10 grid gap-8 px-6 py-8 sm:px-8 md:grid-cols-[minmax(0,1fr)_280px] md:px-12 md:py-12 lg:grid-cols-[minmax(0,1fr)_340px] lg:px-14 lg:py-14">
<div className="flex min-w-0 flex-col justify-center space-y-5">
<div className="inline-flex w-fit items-center gap-2 rounded-full bg-white/82 px-3.5 py-1.5 text-xs font-bold uppercase tracking-widest text-[#a8732d] border border-[#ead6b6] shadow-sm backdrop-blur">
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-[var(--app-brand-border)] bg-[var(--app-brand-soft)] px-3.5 py-1.5 text-xs font-bold uppercase tracking-widest text-[var(--app-brand-text)] shadow-sm backdrop-blur">
<Sparkles size={12} fill="currentColor" />
<span>本周力荐 / Featured</span>
</div>
<div className="space-y-3">
<div className="inline-flex max-w-full items-center gap-2 rounded-2xl bg-gray-950 px-3 py-2 text-white shadow-lg shadow-gray-950/10">
<div className="inline-flex max-w-full items-center gap-2 rounded-2xl bg-[var(--app-active-bg)] px-3 py-2 text-[var(--app-active-text)] shadow-lg">
<span className="h-2 w-2 rounded-full bg-[#d4af37]" />
<span className="truncate text-xs font-black tracking-[0.26em]">{featuredMark}</span>
</div>
<h1 className="font-display text-3xl sm:text-4xl md:text-5xl font-extrabold tracking-tight leading-tight text-gray-950">
<h1 className="font-display text-3xl font-extrabold leading-tight tracking-tight text-[var(--app-text)] sm:text-4xl md:text-5xl">
{featuredItem.title}
</h1>
</div>
<p className="max-w-2xl text-gray-600 text-sm sm:text-base leading-relaxed line-clamp-3 font-semibold">
<p className="line-clamp-3 max-w-2xl text-sm font-semibold leading-relaxed text-[var(--app-subtle)] sm:text-base">
{featuredItem.overview || '家庭私人媒体中心收藏。支持多端播放、外部播放器、智能刮削与订阅下载。'}
</p>
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 font-bold">
<div className="flex flex-wrap items-center gap-3 text-xs font-bold text-[var(--app-muted)]">
{featuredItem.year > 0 && (
<span className="bg-white/85 px-2.5 py-1 rounded-xl text-gray-900 border border-gray-200 shadow-sm">{featuredItem.year} 年</span>
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-2.5 py-1 text-[var(--app-text)] shadow-sm">{featuredItem.year} 年</span>
)}
{featuredItem.video_codec && (
<span className="rounded-lg bg-[#fff8e7] px-2 py-1 text-[#9a6a1e] border border-[#ead6b6] uppercase font-bold text-[10px]">
<span className="rounded-lg border border-[var(--app-brand-border)] bg-[var(--app-brand-soft)] px-2 py-1 text-[10px] font-bold uppercase text-[var(--app-brand-text)]">
{featuredItem.video_codec}
</span>
)}
{featuredItem.container && (
<span className="rounded-lg bg-white/80 px-2 py-1 text-gray-700 border border-gray-200 uppercase font-mono text-[10px]">
<span className="rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 font-mono text-[10px] uppercase text-[var(--app-subtle)]">
{featuredItem.container}
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-4 pt-2">
<Link to={`/media/${featuredItem.id}`} className="inline-flex items-center justify-center gap-2 rounded-xl bg-[#111827] px-6 py-3.5 text-sm font-bold text-white shadow-lg shadow-gray-900/15 hover:bg-[#1f2937] hover:-translate-y-0.5 transition-all">
<Link to={`/media/${featuredItem.id}`} className="inline-flex items-center justify-center gap-2 rounded-xl bg-[var(--app-command-bg)] px-6 py-3.5 text-sm font-bold text-[var(--app-command-text)] shadow-lg transition-all hover:-translate-y-0.5">
<Play size={16} fill="currentColor" />
<span>立即播放</span>
</Link>
<Link to="/discover" className="btn-outline bg-white/80 px-5 py-3.5 text-sm font-bold text-gray-700 border border-gray-200 hover:border-gray-300">
<Link to="/discover" className="inline-flex items-center justify-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-5 py-3.5 text-sm font-bold text-[var(--app-subtle)] shadow-sm transition-all hover:-translate-y-0.5 hover:border-brand-500/40 hover:text-[var(--app-text)]">
<span>发现更多精彩</span>
<ArrowRight size={16} />
</Link>
@@ -156,14 +156,14 @@ export function HomePage() {
<div className="relative order-first mx-auto flex w-full max-w-[220px] items-center md:order-none md:max-w-[260px] lg:max-w-[310px]">
<div className="absolute -right-6 top-5 h-32 w-32 rounded-full bg-[#d4af37]/20 blur-3xl" />
<div className="relative aspect-[2/3] w-full overflow-hidden rounded-[1.7rem] border border-white/70 bg-white p-2 shadow-[0_32px_80px_rgba(15,23,42,0.20)]">
<div className="flex h-full w-full flex-col items-center justify-center rounded-[1.25rem] bg-[linear-gradient(135deg,#f9fafb,#fff7ed)] text-center">
<div className="relative aspect-[2/3] w-full overflow-hidden rounded-[1.7rem] border border-[var(--app-border)] bg-[var(--app-poster-shell)] p-2 shadow-[0_32px_80px_var(--app-shadow)]">
<div className="flex h-full w-full flex-col items-center justify-center rounded-[1.25rem] text-center" style={{ background: 'var(--app-poster-empty)' }}>
<Film className="mb-4 h-12 w-12 text-[#c9954a]" />
<span className="px-6 font-display text-3xl font-black tracking-tight text-gray-950">{featuredItem.title}</span>
<span className="px-6 font-display text-3xl font-black tracking-tight text-[var(--app-text)]">{featuredItem.title}</span>
</div>
{featuredPoster && (
<img
src={imageURL(featuredPoster)}
src={imageURL(featuredPoster, featuredItem.updated_at)}
alt={featuredItem.title}
className="absolute inset-2 h-[calc(100%-1rem)] w-[calc(100%-1rem)] rounded-[1.25rem] object-cover"
referrerPolicy="no-referrer"
@@ -179,11 +179,11 @@ export function HomePage() {
{history.length > 0 && (
<section className="space-y-5">
<div className="flex items-center gap-2.5">
<span className="p-1.5 rounded-xl bg-gray-100 border border-gray-200/50 text-gray-900">
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-1.5 text-[var(--app-text)]">
<Clock size={16} />
</span>
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">继续观看</h2>
<span className="text-xs font-bold text-gray-500 bg-gray-100 px-2.5 py-0.5 rounded-full border border-gray-200/40">{history.length} 个记录</span>
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">继续观看</h2>
<span className="rounded-full border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-0.5 text-xs font-bold text-[var(--app-muted)]">{history.length} 个记录</span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
@@ -198,17 +198,17 @@ export function HomePage() {
{recentCards.length > 0 && (
<section className="space-y-5">
<div className="flex items-center justify-between border-b border-gray-200/80 pb-3">
<div className="flex items-center justify-between border-b border-[var(--app-border)] pb-3">
<div className="flex items-center gap-2.5">
<span className="p-1.5 rounded-xl bg-gray-100 text-gray-900 border border-gray-200/50">
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-1.5 text-[var(--app-text)]">
<Clock size={18} />
</span>
<div>
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">最近入库</h2>
<p className="text-xs text-gray-500">按整部电影、剧集、番剧和综艺合集展示新增内容。</p>
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">最近入库</h2>
<p className="text-xs text-[var(--app-muted)]">按整部电影、剧集、番剧和综艺合集展示新增内容。</p>
</div>
</div>
<Link to="/poster-wall" className="group inline-flex items-center gap-1 text-xs font-bold text-gray-600 hover:text-brand-600 transition-colors">
<Link to="/poster-wall" className="group inline-flex items-center gap-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:text-brand-500">
<span>海报墙</span>
<ArrowRight size={14} className="transition-transform group-hover:translate-x-0.5" />
</Link>
@@ -232,18 +232,18 @@ export function HomePage() {
function ContinueCard({ media, progress }: { media: Media; progress: number }) {
return (
<Link to={`/media/${media.id}`} className="group flex items-center gap-4 rounded-2xl bg-white p-3.5 border border-gray-200/80 shadow-[0_1px_3px_rgba(0,0,0,0.01)] transition-all duration-300 hover:shadow-md hover:border-brand-500/30">
<div className="relative h-18 w-12 shrink-0 overflow-hidden rounded-xl bg-gray-50 border border-gray-100">
<Link to={`/media/${media.id}`} className="group flex items-center gap-4 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3.5 shadow-[0_1px_3px_rgba(0,0,0,0.01)] transition-all duration-300 hover:border-brand-500/30 hover:bg-[var(--app-panel-soft)] hover:shadow-md">
<div className="relative h-18 w-12 shrink-0 overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
{media.poster_url ? (
<img
src={imageURL(media.poster_url)}
src={imageURL(media.poster_url, media.updated_at)}
alt=""
loading="lazy"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-full items-center justify-center text-gray-500 bg-gray-50">
<div className="flex h-full items-center justify-center bg-[var(--app-panel-soft)] text-[var(--app-muted)]">
<Film size={16} />
</div>
)}
@@ -252,11 +252,11 @@ function ContinueCard({ media, progress }: { media: Media; progress: number }) {
</div>
</div>
<div className="min-w-0 flex-1 space-y-1.5">
<p className="truncate text-sm font-bold text-gray-900 group-hover:text-brand-500 transition-colors">
<p className="truncate text-sm font-bold text-[var(--app-text)] transition-colors group-hover:text-brand-500">
{media.title}
</p>
<div className="space-y-1">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-[var(--app-hover)]">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${Math.round(progress * 100)}%` }}
@@ -264,7 +264,7 @@ function ContinueCard({ media, progress }: { media: Media; progress: number }) {
className="h-full rounded-full bg-gradient-to-r from-brand-400 to-brand-500"
/>
</div>
<p className="text-[10px] text-gray-500 font-bold tracking-wide uppercase">
<p className="text-[10px] font-bold uppercase tracking-wide text-[var(--app-muted)]">
已观看到 {Math.round(progress * 100)}%
</p>
</div>
+18 -7
View File
@@ -6,6 +6,7 @@ import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, PlayCircle
import { imageURL } from '../api/client'
import { libraryAPI } from '../api/library'
import { toolsAPI } from '../api/tools'
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
import { MediaCard } from '../components/MediaCard'
import type { Library, Media } from '../types'
import { artworkScore, groupSeries, seriesCardLink, type SeriesCard } from '../utils/groupSeries'
@@ -37,6 +38,7 @@ export function LibrariesPage() {
const [previews, setPreviews] = useState<LibraryPreview[]>([])
const [loading, setLoading] = useState(true)
const [repairing, setRepairing] = useState(false)
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false)
const [repairMsg, setRepairMsg] = useState('')
async function handleRepairRescrape() {
@@ -44,7 +46,7 @@ export function LibrariesPage() {
setRepairing(true)
setRepairMsg('')
try {
await toolsAPI.repairAndRescrapeAll()
await toolsAPI.repairAndRescrapeAll({ episode_images: repairEpisodeArtwork, refresh_matched: true })
setRepairMsg('已开始全库修复+重刮,进度可在任务中查看。')
} catch {
setRepairMsg('启动失败,请稍后重试。')
@@ -61,7 +63,7 @@ export function LibrariesPage() {
const libs = await libraryAPI.list()
const rows = await Promise.all(libs.map(async (library) => {
try {
const page = await libraryAPI.listMedia(library.id, 1, 160)
const page = await libraryAPI.listMedia(library.id, 1, 160, { groupVersions: false })
const cards = latestCards(page.items)
return { library, items: page.items, total: page.total, cards } satisfies LibraryPreview
} catch {
@@ -94,6 +96,12 @@ export function LibrariesPage() {
</div>
<div className="flex flex-wrap items-center gap-3">
{repairMsg && <span className="text-xs text-ink-50">{repairMsg}</span>}
<EpisodeArtworkToggle
checked={repairEpisodeArtwork}
onChange={setRepairEpisodeArtwork}
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
className="h-10"
/>
<button
type="button"
onClick={handleRepairRescrape}
@@ -159,9 +167,12 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
const library = preview.library
const artwork = preview.cards
.sort((a, b) => artworkScore(b.rep) - artworkScore(a.rep) || mediaTime(b.rep) - mediaTime(a.rep))
.map((card) => card.rep.poster_url || card.rep.backdrop_url)
.filter(Boolean)
.slice(0, 4) as string[]
.map((card) => ({
src: card.rep.poster_url || card.rep.backdrop_url,
version: card.rep.updated_at,
}))
.filter((item) => Boolean(item.src))
.slice(0, 4)
return (
<Link
@@ -170,10 +181,10 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
>
<div className="grid h-24 w-36 shrink-0 grid-cols-2 gap-1 overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)]">
{artwork.length > 0 ? (
artwork.map((src, index) => (
artwork.map(({ src, version }, index) => (
<img
key={`${src}-${index}`}
src={imageURL(src)}
src={imageURL(src, version)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
+68
View File
@@ -0,0 +1,68 @@
import type { ReactNode } from 'react'
import { Film } from 'lucide-react'
import { MediaCard } from '../components/MediaCard'
import type { Media } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
type LibraryMediaSectionsProps = {
isSeries: boolean
items: Media[]
seriesCards: SeriesCard[]
selectedSeries: SeriesCard | null
loading: boolean
movieActions: (media: Media) => ReactNode
onSeriesClick: (series: SeriesCard) => void
}
export function LibraryMediaSections({
isSeries,
items,
seriesCards,
selectedSeries,
loading,
movieActions,
onSeriesClick,
}: LibraryMediaSectionsProps) {
return (
<>
{!isSeries && items.length > 0 && (
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
{items.map((media) => (
<MediaCard key={media.id} media={media} actions={movieActions(media)} />
))}
</div>
)}
{!isSeries && items.length === 0 && (
<LibraryEmptyState message="该媒体库暂无内容,触发一次扫描后再来看看" />
)}
{isSeries && seriesCards.length > 0 && !selectedSeries && (
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
{seriesCards.map((series) => (
<MediaCard
key={series.key}
media={series.rep}
count={series.count}
onClick={() => onSeriesClick(series)}
/>
))}
</div>
)}
{isSeries && seriesCards.length === 0 && !loading && (
<LibraryEmptyState message="该库尚未发现任何剧集,触发一次扫描后再来看看" />
)}
</>
)
}
function LibraryEmptyState({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center py-24 text-center">
<Film className="mb-4 h-12 w-12 text-gray-500" />
<p className="text-ink-50">{message}</p>
</div>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { Database, FileText, Search, Sparkles, Trash2 } from 'lucide-react'
import type { Media } from '../types'
type LibraryMovieActionsProps = {
media: Media
busy: boolean
onSmartScrape: (media: Media) => void
onManualScrape: (media: Media) => void
onProbe: (media: Media) => void
onNFO: (media: Media) => void
onSoftDelete: (media: Media) => void
}
export function LibraryMovieActions({
media,
busy,
onSmartScrape,
onManualScrape,
onProbe,
onNFO,
onSoftDelete,
}: LibraryMovieActionsProps) {
const buttonClass = 'flex h-8 w-8 items-center justify-center rounded-lg border border-white/70 bg-white/90 text-gray-700 shadow-sm backdrop-blur transition hover:bg-brand-50 hover:text-brand-600 disabled:opacity-50'
return (
<>
<button title="智能刮削" disabled={busy} onClick={() => onSmartScrape(media)} className={buttonClass}>
<Sparkles size={13} />
</button>
<button title="手动匹配刮削" disabled={busy} onClick={() => onManualScrape(media)} className={buttonClass}>
<Search size={13} />
</button>
<button title="探测媒体轨" disabled={busy} onClick={() => onProbe(media)} className={buttonClass}>
<Database size={13} />
</button>
<button title="写出本地 NFO" disabled={busy} onClick={() => onNFO(media)} className={buttonClass}>
<FileText size={13} />
</button>
<button title="移入回收站" disabled={busy} onClick={() => onSoftDelete(media)} className={`${buttonClass} hover:!bg-red-50 hover:!text-red-500`}>
<Trash2 size={13} />
</button>
</>
)
}
+136 -670
View File
@@ -1,24 +1,20 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom'
import { useState } from 'react'
import { useLocation, useParams, useSearchParams } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import toast from 'react-hot-toast'
import { ArrowLeft, Play, Film, Database, FileText, Search, Sparkles, Trash2, Pencil, FolderInput } from 'lucide-react'
import { libraryAPI } from '../api/library'
import { toolsAPI } from '../api/tools'
import { storageAPI, type CloudScanStatus } from '../api/storage_config'
import { api } from '../api/client'
import { recycleAPI } from '../api/recycle'
import type { Library, Media } from '../types'
import { MediaCard } from '../components/MediaCard'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import { imageURL } from '../api/client'
import type { Media } from '../types'
import { useAuthStore } from '../stores/auth'
import { getSeriesKey, groupSeries, isEpisodeLike, seriesTitle, type SeriesCard } from '../utils/groupSeries'
import { useWebSocket } from '../hooks/useWebSocket'
import { confirmAction } from '../components/ConfirmDialog'
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
import { MetadataEditDialog } from '../components/MetadataEditDialog'
import { LibrarySeriesEpisodes } from './LibrarySeriesEpisodes'
import { LibrarySeriesDetailHeader } from './LibrarySeriesDetailHeader'
import { LibraryPageHeader } from './LibraryPageHeader'
import { LibraryMediaSections } from './LibraryMediaSections'
import { useLibraryData } from './useLibraryData'
import { useLibraryScanStatus } from './useLibraryScanStatus'
import { useLibrarySeriesSelection } from './useLibrarySeriesSelection'
import { useLibraryAdminActions } from './useLibraryAdminActions'
export function LibraryPage() {
const { id = '' } = useParams()
@@ -26,422 +22,85 @@ export function LibraryPage() {
const location = useLocation()
const role = useAuthStore((s) => s.user?.role)
const [library, setLibrary] = useState<Library | null>(null)
const [items, setItems] = useState<Media[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [loadingAll, setLoadingAll] = useState(false)
const [scanning, setScanning] = useState(false)
const [scanProgress, setScanProgress] = useState('')
const [scraping, setScraping] = useState(false)
const [repairing, setRepairing] = useState(false)
const [seriesToolBusy, setSeriesToolBusy] = useState('')
const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false)
const [seriesMetadataEditOpen, setSeriesMetadataEditOpen] = useState(false)
const [manualMovie, setManualMovie] = useState<Media | null>(null)
const [movieToolBusy, setMovieToolBusy] = useState('')
// 剧集模式:选中某个剧集后展开详情
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
const [selectedSeason, setSelectedSeason] = useState<number | null>(null)
const hasEpisodicItems = useMemo(() => items.some(isEpisodeLike), [items])
const isSeries = library?.type === 'tv' || library?.type === 'anime' || library?.type === 'variety' || hasEpisodicItems
const {
library,
items,
seriesEpisodeItems,
total,
loading,
loadingSeriesEpisodes,
isSeriesLibrary,
isSeries,
seriesCards,
loadingAllText,
reloadCurrentLibrary,
} = useLibraryData(id, selectedSeries)
// 折叠后的剧集卡片
const seriesCards = useMemo(() => {
if (!isSeries || items.length === 0) return []
return groupSeries(items)
}, [isSeries, items])
const {
scanning,
scanProgress,
handleScan,
} = useLibraryScanStatus({
libraryID: id,
isAdmin: role === 'admin',
onLibraryChanged: reloadCurrentLibrary,
})
// 选中的剧集:所有集按季分组
const selectedEpisodes = useMemo(() => {
if (!selectedSeries || items.length === 0) return []
const eps = items.filter((m) => getSeriesKey(m) === selectedSeries.key)
// 按季分组,按集排序
const seasons = new Map<number, Media[]>()
for (const ep of eps) {
const s = ep.episode_num > 0 ? (ep.season_num ?? 0) : (ep.season_num || 1)
if (!seasons.has(s)) seasons.set(s, [])
seasons.get(s)!.push(ep)
}
for (const [, list] of seasons) {
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
}
return Array.from(seasons.entries())
.sort(([a], [b]) => a - b)
.map(([season, episodes]) => ({ season, episodes }))
}, [selectedSeries, items])
const {
selectedEpisodes,
visibleEpisodes,
selectedSeriesEpisodes,
selectedSeriesMediaIDs,
handleSeriesClick,
clearSelectedSeries,
} = useLibrarySeriesSelection({
items,
seriesEpisodeItems,
isSeriesLibrary,
isSeries,
loading,
seriesCards,
searchParams,
setSearchParams,
selectedSeries,
setSelectedSeries,
selectedSeason,
setSelectedSeason,
onClearSeriesState: () => setSeriesMetadataEditOpen(false),
})
const visibleEpisodes = useMemo(() => {
if (selectedSeason == null) return selectedEpisodes[0]?.episodes ?? []
return selectedEpisodes.find((s) => s.season === selectedSeason)?.episodes ?? []
}, [selectedEpisodes, selectedSeason])
const selectedSeriesEpisodes = useMemo(
() => selectedEpisodes.flatMap((season) => season.episodes),
[selectedEpisodes],
)
const selectedSeriesMediaIDs = useMemo(
() => selectedSeriesEpisodes.map((ep) => ep.id),
[selectedSeriesEpisodes],
)
useEffect(() => {
if (!id) return
libraryAPI.list().then((all) => {
const lib = all.find((l) => l.id === id) ?? null
setLibrary(lib)
})
}, [id])
useEffect(() => {
if (!id || !library) return
let cancelled = false
setLoading(true)
setLoadingAll(true)
setItems([])
const loadAll = async () => {
const pageSize = 2000
let page = 1
let collected: Media[] = []
try {
for (;;) {
const d = await libraryAPI.listMedia(id, page, pageSize)
if (cancelled) return
collected = collected.concat(d.items)
setItems(collected)
setTotal(d.total)
if (page === 1) setLoading(false)
if (collected.length >= d.total || d.items.length < pageSize) break
page += 1
}
} finally {
if (!cancelled) {
setLoading(false)
setLoadingAll(false)
}
}
}
loadAll().catch(() => {
if (!cancelled) {
toast.error('媒体库加载失败')
setLoading(false)
}
})
return () => { cancelled = true }
}, [id, library])
const reloadCurrentLibrary = useCallback(() => {
setLibrary((l) => (l ? { ...l } : l))
}, [])
const onRealtimeEvent = useCallback((topic: string, payload: unknown) => {
if (role !== 'admin') return
if (topic !== 'scan' || !payload || typeof payload !== 'object') return
const p = payload as Record<string, unknown>
if (p.library_id !== id) return
if (p.error) {
setScanning(false)
setScanProgress(`扫描失败:${String(p.error)}`)
return
}
if (p.finished) {
setScanning(false)
const elapsed = Number(p.elapsed_seconds ?? p.elapsed ?? 0)
const elapsedText = elapsed > 0 ? ` · 耗时 ${formatDuration(elapsed)}` : ''
setScanProgress(`扫描完成:发现 ${p.discovered ?? p.visited ?? 0} · 新增 ${p.added ?? 0} · 更新 ${p.updated ?? 0} · 跳过 ${p.skipped ?? 0}${elapsedText}`)
reloadCurrentLibrary()
return
}
if (p.queued) {
setScanning(true)
setScanProgress(String(p.message ?? '扫描已排队,后台会自动入库'))
return
}
if (p.cloud && p.stage) {
const stage = p.stage === 'importing' ? '正在入库' : '正在遍历目录'
const speed = Number(p.files_per_second ?? 0)
const speedText = speed > 0 ? ` · ${speed.toFixed(speed >= 10 ? 0 : 1)} 个/秒` : ''
setScanning(true)
setScanProgress(`${stage}:目录 ${p.dirs ?? 0} · 已发现 ${p.discovered ?? 0} · 已入库 ${p.visited ?? 0}${speedText}`)
}
}, [id, reloadCurrentLibrary, role])
useWebSocket(onRealtimeEvent)
useEffect(() => {
if (role !== 'admin' || !id) return
let cancelled = false
let terminal = false
let timer: number | undefined
const restoreCloudScanStatus = async () => {
const r = await storageAPI.cloudScanStatus()
if (cancelled) return
const status = (r.items ?? []).find((item) => item.library_id === id)
if (!status) return
if (status.state === 'running' || status.state === 'queued' || status.state === 'canceling') {
setScanning(true)
setScanProgress(formatCloudScanStatus(status))
return
}
if (status.state === 'finished') {
setScanning(false)
setScanProgress(formatCloudScanStatus(status))
reloadCurrentLibrary()
terminal = true
if (timer) window.clearInterval(timer)
return
}
if (status.state === 'error' && status.error) {
setScanning(false)
setScanProgress(`扫描失败:${status.error}`)
terminal = true
if (timer) window.clearInterval(timer)
}
}
restoreCloudScanStatus()
.catch(() => undefined)
.finally(() => {
if (!cancelled && !terminal) {
timer = window.setInterval(() => {
restoreCloudScanStatus().catch(() => undefined)
}, 5000)
}
})
return () => {
cancelled = true
if (timer) window.clearInterval(timer)
}
}, [id, reloadCurrentLibrary, role])
useEffect(() => {
if (loading) return
if (!isSeries) {
setSelectedSeries(null)
setSelectedSeason(null)
return
}
const key = searchParams.get('series')
if (!key) {
setSelectedSeries(null)
return
}
const next = seriesCards.find((card) => card.key === key)
if (next) {
setSelectedSeries(next)
} else {
setSelectedSeries(null)
}
}, [isSeries, loading, searchParams, seriesCards])
useEffect(() => {
if (!selectedSeries || selectedEpisodes.length === 0) {
setSelectedSeason(null)
return
}
if (selectedSeason == null || !selectedEpisodes.some((s) => s.season === selectedSeason)) {
setSelectedSeason(selectedEpisodes[0].season)
}
}, [selectedSeries, selectedEpisodes, selectedSeason])
const handleScan = async () => {
setScanning(true)
setScanProgress('正在提交扫描任务…')
let keepScanning = false
try {
const r = await libraryAPI.scan(id)
if (r.queued) {
keepScanning = true
setScanProgress(`${r.message ?? '云盘扫描已在后台运行,发现的媒体会自动加入当前媒体库'};${r.estimate_message ?? '大目录耗时取决于网盘接口速度'}`)
toast.success('云盘扫描已加入后台队列')
} else {
toast.success(`扫描完成:新增 ${r.added} 项,更新 ${r.updated ?? 0} 项`)
setScanProgress(`扫描完成:新增 ${r.added} · 更新 ${r.updated ?? 0}`)
reloadCurrentLibrary()
setScanning(false)
}
} catch {
toast.error('扫描失败')
setScanProgress('扫描失败,请查看日志或稍后重试')
setScanning(false)
} finally {
if (!keepScanning) setScanning(false)
}
}
const handleScrape = async () => {
setScraping(true)
try {
await libraryAPI.scrape(id)
toast.success('刮削已加入后台队列')
} catch { toast.error('刮削失败') }
finally { setScraping(false) }
}
const handleRepairRescrape = async () => {
if (repairing) return
setRepairing(true)
try {
await toolsAPI.repairAndRescrapeLibrary(id)
toast.success('本库修复+重刮已加入后台队列,进度可在任务中查看')
} catch { toast.error('修复+重刮启动失败') }
finally { setRepairing(false) }
}
const runSeriesTool = async (key: string, label: string, action: (media: Media) => Promise<unknown>) => {
if (selectedSeriesEpisodes.length === 0) return
setSeriesToolBusy(key)
try {
for (const ep of selectedSeriesEpisodes) {
await action(ep)
}
toast.success(`${label}完成:${selectedSeriesEpisodes.length} 个媒体`)
reloadCurrentLibrary()
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || `${label}失败`
toast.error(msg)
} finally {
setSeriesToolBusy('')
}
}
const handleSeriesSmartScrape = () => {
runSeriesTool('scrape', '整剧智能刮削', (media) => api.post(`/media/${media.id}/scrape`))
}
const handleSeriesProbe = () => {
runSeriesTool('probe', '整剧媒体轨探测', (media) => api.post(`/media/${media.id}/probe`))
}
const handleSeriesNFO = () => {
runSeriesTool('nfo', '整剧 NFO 写出', (media) => recycleAPI.exportNFO(media.id))
}
const handleSeriesOrganize = async () => {
if (!selectedSeries || selectedSeriesEpisodes.length === 0 || !library) return
const source = seriesSourceRoot(selectedSeriesEpisodes)
if (!source || source.toLowerCase().startsWith('cloud://')) {
toast.error('当前合集不是本地文件夹,无法使用本地整理入库')
return
}
if (!(await confirmAction({
title: '整理当前合集',
message: `来源:${source}\n目标:${library.path}\n将按当前媒体库类型整理整个文件夹。`,
confirmText: '开始整理',
}))) return
setSeriesToolBusy('organize')
try {
const result = await toolsAPI.organizeDirectory({
source_path: source,
dest_path: library.path,
media_type: library.type || 'auto',
library_id: library.id,
scan_after: true,
scrape_after: true,
})
const replaced = result.replaced ?? 0
toast.success(`合集整理完成:新增 ${result.organized ?? 0} · 替换 ${replaced} · 跳过 ${result.skipped ?? 0}`)
reloadCurrentLibrary()
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '合集整理失败'
toast.error(msg)
} finally {
setSeriesToolBusy('')
}
}
const handleSeriesSoftDelete = async () => {
if (!selectedSeries || selectedSeriesEpisodes.length === 0) return
if (!(await confirmAction({
title: '移入回收站',
message: `将「${seriesTitle(selectedSeries.rep)}」的 ${selectedSeriesEpisodes.length} 个媒体移至回收站? (磁盘文件保留)`,
confirmText: '移入回收站',
}))) return
await runSeriesTool('delete', '整剧移入回收站', (media) => recycleAPI.softDelete(media.id))
clearSelectedSeries()
}
const runMovieTool = async (media: Media, key: string, label: string, action: (media: Media) => Promise<unknown>) => {
const busyKey = `${key}:${media.id}`
setMovieToolBusy(busyKey)
try {
await action(media)
toast.success(`${label}完成:${media.title}`)
reloadCurrentLibrary()
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || `${label}失败`
toast.error(msg)
} finally {
setMovieToolBusy('')
}
}
const handleMovieSmartScrape = (media: Media) => {
runMovieTool(media, 'scrape', '智能刮削', (item) => api.post(`/media/${item.id}/scrape`))
}
const handleMovieProbe = (media: Media) => {
runMovieTool(media, 'probe', '媒体轨探测', (item) => api.post(`/media/${item.id}/probe`))
}
const handleMovieNFO = (media: Media) => {
runMovieTool(media, 'nfo', 'NFO 写出', (item) => recycleAPI.exportNFO(item.id))
}
const handleMovieSoftDelete = async (media: Media) => {
if (!(await confirmAction({
title: '移入回收站',
message: `将「${media.title}」移至回收站? (磁盘文件保留)`,
confirmText: '移入回收站',
}))) return
await runMovieTool(media, 'delete', '移入回收站', (item) => recycleAPI.softDelete(item.id))
}
const movieActions = (media: Media) => {
if (role !== 'admin') return undefined
const busy = movieToolBusy.endsWith(`:${media.id}`)
const buttonClass = 'flex h-8 w-8 items-center justify-center rounded-lg border border-white/70 bg-white/90 text-gray-700 shadow-sm backdrop-blur transition hover:bg-brand-50 hover:text-brand-600 disabled:opacity-50'
return (
<>
<button title="智能刮削" disabled={busy} onClick={() => handleMovieSmartScrape(media)} className={buttonClass}>
<Sparkles size={13} />
</button>
<button title="手动匹配刮削" disabled={busy} onClick={() => setManualMovie(media)} className={buttonClass}>
<Search size={13} />
</button>
<button title="探测媒体轨" disabled={busy} onClick={() => handleMovieProbe(media)} className={buttonClass}>
<Database size={13} />
</button>
<button title="写出本地 NFO" disabled={busy} onClick={() => handleMovieNFO(media)} className={buttonClass}>
<FileText size={13} />
</button>
<button title="移入回收站" disabled={busy} onClick={() => handleMovieSoftDelete(media)} className={`${buttonClass} hover:!bg-red-50 hover:!text-red-500`}>
<Trash2 size={13} />
</button>
</>
)
}
const handleSeriesClick = (card: SeriesCard) => {
setSelectedSeries(card)
const next = new URLSearchParams(searchParams)
next.set('series', card.key)
setSearchParams(next)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const clearSelectedSeries = () => {
setSelectedSeries(null)
setSelectedSeason(null)
setSeriesMetadataEditOpen(false)
const next = new URLSearchParams(searchParams)
next.delete('series')
setSearchParams(next)
}
const {
scraping,
scrapeEpisodeArtwork,
repairing,
seriesToolBusy,
setScrapeEpisodeArtwork,
handleScrape,
handleRepairRescrape,
handleSeriesSmartScrape,
handleSeriesProbe,
handleSeriesNFO,
handleSeriesOrganize,
handleSeriesSoftDelete,
movieActions,
} = useLibraryAdminActions({
libraryID: id,
role,
library,
selectedSeries,
selectedSeriesEpisodes,
reloadCurrentLibrary,
clearSelectedSeries,
setManualMovie,
})
if (loading) {
return (
@@ -456,52 +115,31 @@ export function LibraryPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">
{library?.name ?? '媒体库'}
<span className="text-sand-500"> ({isSeries ? seriesCards.length : total})</span>
</h1>
{library && <p className="text-sm text-ink-50">{library.type} · {library.path}</p>}
{loadingAll && !loading && total > items.length && (
<p className="mt-1 text-xs text-sand-500">正在继续加载全部条目:{items.length} / {total}</p>
)}
{scanProgress && <p className="mt-1 text-xs text-brand-500">{scanProgress}</p>}
</div>
{role === 'admin' && (
<div className="flex flex-wrap gap-2">
<button onClick={handleScan} disabled={scanning} className="btn-outline">{scanning ? '扫描中…' : '立即扫描'}</button>
<button onClick={handleScrape} disabled={scraping} className="btn-outline">{scraping ? '刮削中…' : '刮削元数据'}</button>
<button onClick={handleRepairRescrape} disabled={repairing} className="btn-outline" title="回填本库占位符外部 ID 并重刮,修正空 ID / 拆集问题">{repairing ? '修复中…' : '修复+重刮本库'}</button>
</div>
)}
</div>
<LibraryPageHeader
library={library}
itemCount={isSeries ? seriesCards.length : total}
loadingAllText={loadingAllText}
scanProgress={scanProgress}
isAdmin={role === 'admin'}
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
scanning={scanning}
scraping={scraping}
repairing={repairing}
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
onScan={handleScan}
onScrape={handleScrape}
onRepairRescrape={handleRepairRescrape}
/>
{/* 非剧集:直接展示海报网格 */}
{!isSeries && items.length > 0 && (
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
{items.map((m) => (
<MediaCard key={m.id} media={m} actions={movieActions(m)} />
))}
</div>
)}
{!isSeries && items.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 text-center">
<Film className="h-12 w-12 text-gray-500 mb-4" />
<p className="text-ink-50">该媒体库暂无内容,触发一次扫描后再来看看</p>
</div>
)}
{/* 剧集模式:折叠卡片网格 */}
{isSeries && seriesCards.length > 0 && !selectedSeries && (
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
{seriesCards.map((s) => (
<MediaCard key={s.key} media={s.rep} count={s.count} onClick={() => handleSeriesClick(s)} />
))}
</div>
)}
<LibraryMediaSections
isSeries={isSeries}
items={items}
seriesCards={seriesCards}
selectedSeries={selectedSeries}
loading={loading}
movieActions={movieActions}
onSeriesClick={handleSeriesClick}
/>
{/* 剧集详情:季/集选择器 */}
<AnimatePresence mode="wait">
@@ -512,173 +150,46 @@ export function LibraryPage() {
exit={{ opacity: 0 }}
className="space-y-6"
>
{/* 返回按钮 + 标题 */}
<div className="flex items-center gap-4">
<button
onClick={clearSelectedSeries}
className="btn-ghost gap-2"
>
<ArrowLeft size={16} />
返回列表
</button>
<h2 className="font-display text-2xl font-bold text-ink-600 truncate">
{seriesTitle(selectedSeries.rep)}
</h2>
<span className="text-sm text-sand-500">共 {selectedSeries.count} 集</span>
</div>
{/* 海报 + 从第一集开始播放 */}
<div className="flex flex-col sm:flex-row gap-6">
<div className="w-40 shrink-0 overflow-hidden rounded-xl bg-sand-200 shadow-card">
{selectedSeries.rep.poster_url ? (
<img src={imageURL(selectedSeries.rep.poster_url)} alt={selectedSeries.rep.title} className="w-full aspect-[2/3] object-cover" referrerPolicy="no-referrer" />
) : (
<div className="flex items-center justify-center aspect-[2/3] text-gray-500"><Film size={40} /></div>
)}
</div>
<div className="flex-1 space-y-3">
<p className="text-sm text-ink-50 leading-relaxed">
{selectedSeries.rep.overview || '暂无简介'}
</p>
{/* 从第一集开始 */}
{(() => {
const firstEps = [...(visibleEpisodes.length > 0 ? visibleEpisodes : selectedEpisodes.flatMap((s) => s.episodes))]
firstEps.sort((a, b) =>
(a.season_num || 0) - (b.season_num || 0)
|| (a.episode_num || 0) - (b.episode_num || 0),
)
const first = firstEps.length > 0 ? firstEps[0] : null
return first ? (
<div className="flex flex-wrap gap-2">
<Link to={`/play/${first.id}`} state={{ from: `${location.pathname}${location.search}` }} className="btn-primary inline-flex">
<Play size={16} fill="currentColor" />
从第一集开始播放
</Link>
<ExternalPlayerButton mediaId={first.id} label="外部播放器播放" />
</div>
) : null
})()}
{role === 'admin' && selectedSeriesEpisodes.length > 0 && (
<div className="rounded-2xl border border-sand-200 bg-white/80 p-4 shadow-sm">
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
<div className="flex flex-wrap gap-2">
<button onClick={handleSeriesSmartScrape} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
<Sparkles size={13} className="text-[#c9954a]" />
<span>{seriesToolBusy === 'scrape' ? '刮削中…' : '整剧智能刮削'}</span>
</button>
<button onClick={() => setManualSeriesScrapeOpen(true)} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
<Search size={13} className="text-[#c9954a]" />
<span>手动匹配整剧</span>
</button>
<button onClick={() => setSeriesMetadataEditOpen(true)} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
<Pencil size={13} />
<span>编辑元数据</span>
</button>
<button onClick={handleSeriesProbe} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
<Database size={13} />
<span>{seriesToolBusy === 'probe' ? '探测中…' : '探测媒体轨'}</span>
</button>
<button onClick={handleSeriesNFO} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
<FileText size={13} />
<span>{seriesToolBusy === 'nfo' ? '写出中…' : '写出本地 NFO'}</span>
</button>
<button onClick={handleSeriesOrganize} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
<FolderInput size={13} />
<span>{seriesToolBusy === 'organize' ? '整理中…' : '整理当前合集'}</span>
</button>
<button onClick={handleSeriesSoftDelete} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200">
<Trash2 size={13} />
<span>{seriesToolBusy === 'delete' ? '处理中…' : '移入回收站'}</span>
</button>
</div>
</div>
)}
</div>
</div>
<LibrarySeriesDetailHeader
series={selectedSeries}
visibleEpisodes={visibleEpisodes}
allEpisodes={selectedSeriesEpisodes}
playbackFrom={`${location.pathname}${location.search}`}
isAdmin={role === 'admin'}
seriesToolBusy={seriesToolBusy}
onBack={clearSelectedSeries}
onSmartScrape={handleSeriesSmartScrape}
onManualScrape={() => setManualSeriesScrapeOpen(true)}
onMetadataEdit={() => setSeriesMetadataEditOpen(true)}
onProbe={handleSeriesProbe}
onNFO={handleSeriesNFO}
onOrganize={handleSeriesOrganize}
onSoftDelete={handleSeriesSoftDelete}
/>
{/* 季 / 集列表 */}
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-2">
{selectedEpisodes.map(({ season, episodes }) => (
<button
key={season}
onClick={() => setSelectedSeason(season)}
className={
'rounded-xl border px-4 py-2 text-sm font-semibold transition ' +
(selectedSeason === season
? 'border-brand-300 bg-brand-50 text-brand-700'
: 'border-sand-200 bg-white text-ink-100 hover:border-brand-200 hover:text-brand-600')
}
>
{season === 0 ? '特别篇' : `第 ${season} 季`} · {episodes.length} 集
</button>
))}
</div>
<div>
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
{(selectedSeason ?? selectedEpisodes[0]?.season ?? 1) === 0
? '特别篇'
: `第 ${selectedSeason ?? selectedEpisodes[0]?.season ?? 1} 季`}
</h3>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{visibleEpisodes.map((ep) => (
<div
key={ep.id}
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
>
<Link to={`/play/${ep.id}`} state={{ from: `${location.pathname}${location.search}` }} className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
{ep.backdrop_url || ep.poster_url ? (
<img
src={imageURL(ep.backdrop_url || ep.poster_url || '')}
alt=""
className="h-full w-full object-cover"
referrerPolicy="no-referrer"
/>
) : (
ep.episode_num || '—'
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-ink-600">
{ep.original_name || (ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : ep.title)}
</p>
<p className="text-xs text-sand-500">
{ep.duration_sec > 0
? `${Math.floor(ep.duration_sec / 60)} 分钟`
: formatSize(ep.size_bytes)}
</p>
</div>
<Play size={14} className="shrink-0 text-gray-500 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
</Link>
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
</div>
))}
</div>
</div>
<LibrarySeriesEpisodes
loading={loadingSeriesEpisodes}
selectedEpisodes={selectedEpisodes}
selectedSeason={selectedSeason}
visibleEpisodes={visibleEpisodes}
playbackFrom={`${location.pathname}${location.search}`}
onSeasonChange={setSelectedSeason}
/>
</div>
</motion.div>
)}
</AnimatePresence>
{/* 剧集为空 */}
{isSeries && seriesCards.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center py-24 text-center">
<Film className="h-12 w-12 text-gray-500 mb-4" />
<p className="text-ink-50">该库尚未发现任何剧集,触发一次扫描后再来看看</p>
</div>
)}
<ManualScrapeDialog
open={manualSeriesScrapeOpen}
media={selectedSeries?.rep ?? null}
mediaIds={selectedSeriesMediaIDs}
defaultQuery={selectedSeries ? seriesTitle(selectedSeries.rep) : ''}
mediaType={library?.type || 'tv'}
mediaType={selectedSeries ? scrapeMediaType(library?.type, selectedSeries.rep) : 'tv'}
scopeLabel={selectedSeries ? seriesTitle(selectedSeries.rep) : '当前剧集'}
episodeArtwork={scrapeEpisodeArtwork}
onClose={() => setManualSeriesScrapeOpen(false)}
onApplied={reloadCurrentLibrary}
/>
@@ -695,8 +206,9 @@ export function LibraryPage() {
open={!!manualMovie}
media={manualMovie}
defaultQuery={manualMovie?.title ?? ''}
mediaType={library?.type || 'movie'}
mediaType={manualMovie ? scrapeMediaType(library?.type, manualMovie) : library?.type || 'movie'}
scopeLabel={manualMovie?.title ?? '当前电影'}
episodeArtwork={scrapeEpisodeArtwork}
onClose={() => setManualMovie(null)}
onApplied={reloadCurrentLibrary}
/>
@@ -704,55 +216,9 @@ export function LibraryPage() {
)
}
function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return ''
if (seconds < 60) return `${Math.round(seconds)}秒`
const minutes = Math.floor(seconds / 60)
const rest = Math.round(seconds % 60)
if (minutes < 60) return `${minutes}分${rest}秒`
const hours = Math.floor(minutes / 60)
return `${hours}小时${minutes % 60}分`
}
function formatCloudScanStatus(status: CloudScanStatus): string {
const stage =
status.state === 'queued' ? '扫描已排队'
: status.state === 'canceling' ? '正在中断扫描'
: status.state === 'finished' ? '扫描完成'
: status.stage === 'importing' ? '正在入库'
: '正在遍历目录'
const speed = Number(status.files_per_second ?? 0)
const speedText = speed > 0 && status.state !== 'finished'
? ` · ${speed.toFixed(speed >= 10 ? 0 : 1)} 个/秒`
: ''
return `${stage}:目录 ${status.dirs ?? 0} · 已发现 ${status.discovered ?? 0} · 已入库 ${status.visited ?? 0} · 新增 ${status.added ?? 0} · 更新 ${status.updated ?? 0}${speedText}`
}
function seriesSourceRoot(episodes: Media[]): string {
const firstPath = episodes.find((item) => item.path)?.path ?? ''
if (!firstPath) return ''
const dir = dirname(firstPath)
const base = basename(dir)
if (/^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*\d{1,2}\s*季|specials?|sp|ova|oad|特别篇|特別篇)$/i.test(base)) {
return dirname(dir)
function scrapeMediaType(libraryType: string | undefined, media: Media): string {
if ((media.season_num ?? 0) > 0 || (media.episode_num ?? 0) > 0) {
return 'tv'
}
return dir
}
function dirname(value: string): string {
const index = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'))
return index > 0 ? value.slice(0, index) : ''
}
function basename(value: string): string {
const index = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'))
return index >= 0 ? value.slice(index + 1) : value
}
function formatSize(bytes: number): string {
if (!bytes || bytes <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = bytes, i = 0
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
return `${v.toFixed(1)} ${units[i]}`
return libraryType || 'movie'
}
+72
View File
@@ -0,0 +1,72 @@
import type { Library } from '../types'
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
type LibraryPageHeaderProps = {
library: Library | null
itemCount: number
loadingAllText: string
scanProgress: string
isAdmin: boolean
scrapeEpisodeArtwork: boolean
scanning: boolean
scraping: boolean
repairing: boolean
onScrapeEpisodeArtworkChange: (checked: boolean) => void
onScan: () => void
onScrape: () => void
onRepairRescrape: () => void
}
export function LibraryPageHeader({
library,
itemCount,
loadingAllText,
scanProgress,
isAdmin,
scrapeEpisodeArtwork,
scanning,
scraping,
repairing,
onScrapeEpisodeArtworkChange,
onScan,
onScrape,
onRepairRescrape,
}: LibraryPageHeaderProps) {
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">
{library?.name ?? '媒体库'}
<span className="text-sand-500"> ({itemCount})</span>
</h1>
{library && <p className="text-sm text-ink-50">{library.type} · {library.path}</p>}
{loadingAllText && <p className="mt-1 text-xs text-sand-500">{loadingAllText}</p>}
{scanProgress && <p className="mt-1 text-xs text-brand-500">{scanProgress}</p>}
</div>
{isAdmin && (
<div className="flex flex-wrap items-center gap-2">
<EpisodeArtworkToggle
checked={scrapeEpisodeArtwork}
onChange={onScrapeEpisodeArtworkChange}
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
className="h-10"
/>
<button onClick={onScan} disabled={scanning} className="btn-outline">
{scanning ? '扫描中…' : '立即扫描'}
</button>
<button onClick={onScrape} disabled={scraping} className="btn-outline">
{scraping ? '刮削中…' : '刮削元数据'}
</button>
<button
onClick={onRepairRescrape}
disabled={repairing}
className="btn-outline"
title="回填本库占位符外部 ID 并重刮,修正空 ID / 拆集问题"
>
{repairing ? '修复中…' : '修复+重刮本库'}
</button>
</div>
)}
</div>
)
}
+135
View File
@@ -0,0 +1,135 @@
import { Link } from 'react-router-dom'
import { ArrowLeft, Database, FileText, Film, FolderInput, Pencil, Play, Search, Sparkles, Trash2 } from 'lucide-react'
import { imageURL } from '../api/client'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import type { Media } from '../types'
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
type LibrarySeriesDetailHeaderProps = {
series: SeriesCard
visibleEpisodes: Media[]
allEpisodes: Media[]
playbackFrom: string
isAdmin: boolean
seriesToolBusy: string
onBack: () => void
onSmartScrape: () => void
onManualScrape: () => void
onMetadataEdit: () => void
onProbe: () => void
onNFO: () => void
onOrganize: () => void
onSoftDelete: () => void
}
export function LibrarySeriesDetailHeader({
series,
visibleEpisodes,
allEpisodes,
playbackFrom,
isAdmin,
seriesToolBusy,
onBack,
onSmartScrape,
onManualScrape,
onMetadataEdit,
onProbe,
onNFO,
onOrganize,
onSoftDelete,
}: LibrarySeriesDetailHeaderProps) {
const firstEpisode = firstPlayableEpisode(visibleEpisodes.length > 0 ? visibleEpisodes : allEpisodes)
return (
<>
<div className="flex items-center gap-4">
<button onClick={onBack} className="btn-ghost gap-2">
<ArrowLeft size={16} />
返回列表
</button>
<h2 className="truncate font-display text-2xl font-bold text-ink-600">
{seriesTitle(series.rep)}
</h2>
<span className="text-sm text-sand-500">共 {series.count} 集</span>
</div>
<div className="flex flex-col gap-6 sm:flex-row">
<div className="w-40 shrink-0 overflow-hidden rounded-xl bg-sand-200 shadow-card">
{series.rep.poster_url ? (
<img
src={imageURL(series.rep.poster_url, series.rep.updated_at)}
alt={series.rep.title}
className="aspect-[2/3] w-full object-cover"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex aspect-[2/3] items-center justify-center text-gray-500">
<Film size={40} />
</div>
)}
</div>
<div className="flex-1 space-y-3">
<p className="text-sm leading-relaxed text-ink-50">
{series.rep.overview || '暂无简介'}
</p>
{firstEpisode && (
<div className="flex flex-wrap gap-2">
<Link to={`/play/${firstEpisode.id}`} state={{ from: playbackFrom }} className="btn-primary inline-flex">
<Play size={16} fill="currentColor" />
从第一集开始播放
</Link>
<ExternalPlayerButton mediaId={firstEpisode.id} label="外部播放器播放" />
</div>
)}
{isAdmin && allEpisodes.length > 0 && (
<div className="rounded-2xl border border-sand-200 bg-white/80 p-4 shadow-sm">
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
<div className="flex flex-wrap gap-2">
<button onClick={onSmartScrape} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
<Sparkles size={13} className="text-[#c9954a]" />
<span>{seriesToolBusy === 'scrape' ? '刮削中…' : '整剧智能刮削'}</span>
</button>
<button onClick={onManualScrape} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
<Search size={13} className="text-[#c9954a]" />
<span>手动匹配整剧</span>
</button>
<button onClick={onMetadataEdit} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
<Pencil size={13} />
<span>编辑元数据</span>
</button>
<button onClick={onProbe} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
<Database size={13} />
<span>{seriesToolBusy === 'probe' ? '探测中…' : '探测媒体轨'}</span>
</button>
<button onClick={onNFO} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
<FileText size={13} />
<span>{seriesToolBusy === 'nfo' ? '写出中…' : '写出本地 NFO'}</span>
</button>
<button onClick={onOrganize} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
<FolderInput size={13} />
<span>{seriesToolBusy === 'organize' ? '整理中…' : '整理当前合集'}</span>
</button>
<button onClick={onSoftDelete} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!border-red-200 hover:!bg-red-50">
<Trash2 size={13} />
<span>{seriesToolBusy === 'delete' ? '处理中…' : '移入回收站'}</span>
</button>
</div>
</div>
)}
</div>
</div>
</>
)
}
function firstPlayableEpisode(episodes: Media[]): Media | null {
const sorted = [...episodes]
sorted.sort((a, b) =>
(a.season_num || 0) - (b.season_num || 0)
|| (a.episode_num || 0) - (b.episode_num || 0),
)
return sorted[0] ?? null
}
+141
View File
@@ -0,0 +1,141 @@
import { Link } from 'react-router-dom'
import { Play } from 'lucide-react'
import { imageURL } from '../api/client'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import type { Media } from '../types'
import { seriesTitleFromPath } from '../utils/groupSeries'
import { formatSize } from './libraryPageModel'
type SeasonGroup = {
season: number
episodes: Media[]
}
type LibrarySeriesEpisodesProps = {
loading: boolean
selectedEpisodes: SeasonGroup[]
selectedSeason: number | null
visibleEpisodes: Media[]
playbackFrom: string
onSeasonChange: (season: number) => void
}
export function LibrarySeriesEpisodes({
loading,
selectedEpisodes,
selectedSeason,
visibleEpisodes,
playbackFrom,
onSeasonChange,
}: LibrarySeriesEpisodesProps) {
if (loading) {
return (
<div className="rounded-2xl bg-white/75 p-6 text-center text-sm text-sand-500 shadow-soft">
正在加载剧集…
</div>
)
}
const displaySeason = selectedSeason ?? selectedEpisodes[0]?.season ?? 1
return (
<>
<div className="flex flex-wrap items-center gap-2">
{selectedEpisodes.map(({ season, episodes }) => (
<button
key={season}
onClick={() => onSeasonChange(season)}
className={
'rounded-xl border px-4 py-2 text-sm font-semibold transition ' +
(selectedSeason === season
? 'border-brand-300 bg-brand-50 text-brand-700'
: 'border-sand-200 bg-white text-ink-100 hover:border-brand-200 hover:text-brand-600')
}
>
{season === 0 ? '特别篇' : `第 ${season} 季`} · {episodes.length} 集
</button>
))}
</div>
<div>
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
{displaySeason === 0 ? '特别篇' : `第 ${displaySeason} 季`}
</h3>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{visibleEpisodes.map((ep) => (
<div
key={ep.id}
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
>
<Link to={`/play/${ep.id}`} state={{ from: playbackFrom }} className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
{ep.backdrop_url || ep.poster_url ? (
<img
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
alt=""
className="h-full w-full object-cover"
referrerPolicy="no-referrer"
/>
) : (
ep.episode_num || '—'
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-ink-600">
{episodeDisplayTitle(ep, visibleEpisodes)}
</p>
<p className="text-xs text-sand-500">
{ep.duration_sec > 0
? `${Math.floor(ep.duration_sec / 60)} 分钟`
: formatSize(ep.size_bytes)}
</p>
</div>
<Play size={14} className="shrink-0 text-gray-500 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
</Link>
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
</div>
))}
</div>
</div>
</>
)
}
function episodeDisplayTitle(ep: Media, siblings: Media[]): string {
const title = ep.episode_title?.trim()
if (title && !looksLikeSeriesTitle(ep, title, siblings)) {
return title
}
const mediaTitle = ep.title?.trim()
if (mediaTitle && !looksLikeSeriesTitle(ep, mediaTitle, siblings)) {
return mediaTitle
}
return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名'
}
function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean {
const normalized = normalizeEpisodeTitle(title)
if (!normalized) return true
if (ep.original_name && normalizeEpisodeTitle(ep.original_name) === normalized) return true
const pathTitle = seriesTitleFromPath(ep.path)
if (pathTitle && normalizeEpisodeTitle(pathTitle) === normalized) return true
const siblingTitles = new Set(
siblings
.map((item) => normalizeEpisodeTitle(item.title))
.filter(Boolean),
)
return siblingTitles.size === 1 && siblingTitles.has(normalized) && siblings.length > 1
}
function normalizeEpisodeTitle(value?: string): string {
return (value ?? '')
.toLowerCase()
.replace(/\s*\((?:19|20)\d{2}\)\s*/g, ' ')
.replace(/\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*/g, ' ')
.replace(/[\s._-]+/g, ' ')
.trim()
}
+197
View File
@@ -0,0 +1,197 @@
import type { Library } from '../types'
type PreviewItem = {
source: string
target?: string
action: string
reason?: string
}
type ManualOrganizePanelProps = {
organizeSource: string
selectedCount: number
localLibraries: Library[]
organizeLibraryID: string
organizeDestPath: string
organizeMediaType: string
organizeTransferMode: string
manualMoveKeepsSeeding: boolean
scanAfter: boolean
scrapeAfter: boolean
organizeReady: boolean
organizeBusy: string
previewItems: PreviewItem[]
onClearSelected: () => void
onLibraryChange: (value: string) => void
onDestPathChange: (value: string) => void
onMediaTypeChange: (value: string) => void
onTransferModeChange: (value: string) => void
onScanAfterChange: (value: boolean) => void
onScrapeAfterChange: (value: boolean) => void
onPreview: () => void
onRun: () => void
}
export function ManualOrganizePanel({
organizeSource,
selectedCount,
localLibraries,
organizeLibraryID,
organizeDestPath,
organizeMediaType,
organizeTransferMode,
manualMoveKeepsSeeding,
scanAfter,
scrapeAfter,
organizeReady,
organizeBusy,
previewItems,
onClearSelected,
onLibraryChange,
onDestPathChange,
onMediaTypeChange,
onTransferModeChange,
onScanAfterChange,
onScrapeAfterChange,
onPreview,
onRun,
}: ManualOrganizePanelProps) {
return (
<section className="glass-panel space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="font-display text-lg font-semibold text-ink-600">手动整理入库</h2>
<p className="text-xs text-sand-500">来源优先使用选中项;未选中时使用当前目录。</p>
</div>
<div className="max-w-xl truncate rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 font-mono text-xs text-ink-100" title={organizeSource}>
来源:{organizeSource || '未选择'}
</div>
</div>
{selectedCount > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-primary-400/20 bg-primary-400/5 px-3 py-2 text-xs text-ink-100">
<span>已选择 {selectedCount} 个项目用于整理。</span>
<button type="button" className="font-semibold text-brand-500 hover:text-brand-700" onClick={onClearSelected}>
清空选择
</button>
</div>
)}
<div className="grid gap-3 lg:grid-cols-[1.2fr_1fr_150px_150px]">
<label className="space-y-1">
<span className="text-xs text-ink-50">目标媒体库 / 存储</span>
<select
className="input-base w-full"
value={organizeLibraryID}
onChange={(event) => onLibraryChange(event.target.value)}
>
<option value="">手动填写目的路径</option>
{localLibraries.map((library) => (
<option key={library.id} value={library.id}>
{library.name}({library.type})— {library.path}
</option>
))}
</select>
<span className="text-[11px] text-sand-500">
手动整理只写入本地可写媒体库;网盘请到“外部存储”中挂载、扫描或转存。
</span>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">目的路径</span>
<input
className="input-base w-full"
placeholder="例如 F:\\media\\电影 或 /media/电影"
value={organizeDestPath}
onChange={(event) => onDestPathChange(event.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">类型</span>
<select className="input-base w-full" value={organizeMediaType} onChange={(event) => onMediaTypeChange(event.target.value)}>
<option value="auto">自动识别</option>
<option value="movie">电影</option>
<option value="tv">剧集</option>
<option value="anime">动漫</option>
<option value="variety">综艺</option>
<option value="adult">成人</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">整理方式</span>
<select className="input-base w-full" value={organizeTransferMode} onChange={(event) => onTransferModeChange(event.target.value)}>
<option value="hardlink">硬链接</option>
<option value="move">移动(关闭保种才会移动)</option>
<option value="copy">复制</option>
<option value="symlink">软链接</option>
</select>
</label>
</div>
{manualMoveKeepsSeeding && (
<div className="rounded-xl border border-orange-300 bg-orange-50 px-3 py-2 text-xs text-orange-700">
“保种”已开启,选择“移动”时后端会改用硬链接以保留下载源。要执行真正移动,请先在上方自动整理设置里关闭“保种”并保存。
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input type="checkbox" checked={scanAfter} onChange={(event) => onScanAfterChange(event.target.checked)} />
整理后扫描入库
</label>
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
<input
type="checkbox"
checked={scanAfter && scrapeAfter}
disabled={!scanAfter}
onChange={(event) => onScrapeAfterChange(event.target.checked)}
/>
整理后自动刮削
</label>
<button
type="button"
className="neon-button !border-primary-400/30 !bg-white !text-brand-500"
disabled={!organizeReady || organizeBusy !== ''}
onClick={onPreview}
>
{organizeBusy === 'preview' ? '预览中…' : '预览整理'}
</button>
<button
type="button"
className="neon-button"
disabled={!organizeReady || organizeBusy !== ''}
onClick={onRun}
>
{organizeBusy === 'run' ? '整理中…' : '开始整理入库'}
</button>
</div>
{previewItems.length > 0 && <ManualOrganizePreviewTable items={previewItems} />}
</section>
)
}
function ManualOrganizePreviewTable({ items }: { items: PreviewItem[] }) {
return (
<div className="max-h-72 overflow-auto rounded-xl border border-gray-200 bg-white/70">
<table className="w-full text-left text-xs">
<thead className="sticky top-0 bg-white text-sand-500">
<tr>
<th className="px-3 py-2">动作</th>
<th>来源</th>
<th>目标</th>
<th>原因</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={`${item.source}-${index}`} className="border-t border-gray-200 align-top">
<td className="px-3 py-2 font-semibold text-brand-500">{item.action}</td>
<td className="max-w-xs truncate py-2 font-mono text-ink-100" title={item.source}>{item.source}</td>
<td className="max-w-xs truncate py-2 font-mono text-ink-100" title={item.target}>{item.target || '—'}</td>
<td className="py-2 text-sand-500">{item.reason || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { Database, FileText, FolderInput, Pencil, Search, Sparkles, Trash2 } from 'lucide-react'
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
import type { Media } from '../types'
type MediaDetailAdminPanelProps = {
media: Media
scrapeEpisodeArtwork: boolean
onScrapeEpisodeArtworkChange: (checked: boolean) => void
onSmartScrape: () => void
onManualScrape: () => void
onMetadataEdit: () => void
onOrganize: () => void
onProbe: () => void
onExportNFO: () => void
onSoftDelete: () => void
}
export function MediaDetailAdminPanel({
media,
scrapeEpisodeArtwork,
onScrapeEpisodeArtworkChange,
onSmartScrape,
onManualScrape,
onMetadataEdit,
onOrganize,
onProbe,
onExportNFO,
onSoftDelete,
}: MediaDetailAdminPanelProps) {
return (
<div className="rounded-2xl border border-gray-200 bg-gray-50/50 p-5 space-y-3">
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
{isEpisodeArtworkTarget(media) && (
<EpisodeArtworkToggle
checked={scrapeEpisodeArtwork}
onChange={onScrapeEpisodeArtworkChange}
title="关闭后仍会获取每集简介、评分和时长,只跳过单集图片"
className="h-10"
/>
)}
<div className="flex flex-wrap gap-2">
<button onClick={onSmartScrape} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Sparkles size={13} className="text-[#c9954a]" />
<span>智能刮削 (TMDB)</span>
</button>
<button onClick={onManualScrape} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Search size={13} className="text-[#c9954a]" />
<span>手动匹配刮削</span>
</button>
<button onClick={onMetadataEdit} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Pencil size={13} className="text-gray-600" />
<span>编辑元数据</span>
</button>
<button onClick={onOrganize} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<FolderInput size={13} className="text-[#c9954a]" />
<span>整理入库</span>
</button>
<button onClick={onProbe} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Database size={13} className="text-gray-600" />
<span>探测媒体轨 (ffprobe)</span>
</button>
<button onClick={onExportNFO} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<FileText size={13} />
<span>写出本地 NFO 属性</span>
</button>
<button
onClick={onSoftDelete}
className="btn-outline py-2 px-3.5 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200"
>
<Trash2 size={13} />
<span>移入回收站</span>
</button>
</div>
</div>
)
}
function isEpisodeArtworkTarget(media: Media): boolean {
return media.season_num > 0 || media.episode_num > 0
}
+62
View File
@@ -0,0 +1,62 @@
import { motion } from 'framer-motion'
import { FileText, Play } from 'lucide-react'
import { Link } from 'react-router-dom'
import { imageURL } from '../api/client'
import type { Media } from '../types'
type MediaDetailArtworkProps = {
media: Media
}
export function MediaDetailBackdrop({ media }: MediaDetailArtworkProps) {
return (
<div className="absolute inset-0 h-[480px] z-0 overflow-hidden">
{media.backdrop_url || media.poster_url ? (
<img
src={imageURL(media.backdrop_url || media.poster_url || '', media.updated_at)}
alt=""
className="w-full h-full object-cover opacity-[0.04] scale-110 blur-2xl"
referrerPolicy="no-referrer"
/>
) : (
<div className="w-full h-full bg-gradient-to-b from-gray-50 to-transparent" />
)}
<div className="absolute inset-0 bg-gradient-to-t from-white via-white/95 to-transparent" />
</div>
)
}
export function MediaDetailPoster({ media }: MediaDetailArtworkProps) {
return (
<div className="w-56 shrink-0 mx-auto md:mx-0">
<motion.div
whileHover={{ scale: 1.02 }}
className="aspect-[2/3] w-full rounded-2xl overflow-hidden bg-gray-50 border border-gray-200 shadow-md relative group"
>
{media.poster_url ? (
<img
src={imageURL(media.poster_url, media.updated_at)}
alt={media.title}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-gray-500 bg-gray-50">
<FileText size={40} className="stroke-[1]" />
<span className="text-xs uppercase tracking-wider font-bold">无海报</span>
</div>
)}
<Link
to={`/play/${media.id}`}
className="absolute inset-0 bg-[#111827]/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-brand-500 text-white shadow-xl transform scale-90 group-hover:scale-100 transition-transform">
<Play size={24} fill="currentColor" />
</div>
</Link>
</motion.div>
</div>
)
}
+109
View File
@@ -0,0 +1,109 @@
import { Calendar } from 'lucide-react'
import type { Media } from '../types'
type MediaDetailMetadataProps = {
media: Media
}
export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) {
const heading = media.episode_title?.trim() || media.title
const showTitleContext = Boolean(media.episode_title?.trim() && media.title && media.title !== heading)
return (
<>
<div className="space-y-3">
<h1 className="font-display text-3xl sm:text-4xl font-extrabold tracking-tight text-gray-900 leading-tight">
{heading}
</h1>
{showTitleContext && (
<p className="text-sm font-semibold text-gray-500">
{media.title}
</p>
)}
<div className="flex flex-wrap items-center gap-2.5 text-xs text-gray-500 font-bold tracking-wide uppercase">
{media.year > 0 && (
<span className="inline-flex items-center gap-1 bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
<Calendar size={13} className="text-brand-500" />
<span>{media.year} 年</span>
</span>
)}
{media.width > 0 && (
<span className="inline-flex items-center gap-1 bg-brand-50 text-brand-700 border border-brand-100/50 px-2.5 py-1 rounded-xl">
<span>{media.width} × {media.height}</span>
</span>
)}
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
{fmtSize(media.size_bytes)}
</span>
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
{fmtDuration(media.duration_sec)}
</span>
{media.container && (
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700 font-mono">
{media.container}
</span>
)}
</div>
</div>
{media.overview && (
<div className="rounded-2xl bg-gray-50/50 border border-gray-100 p-5 space-y-2">
<h3 className="text-xs font-bold uppercase tracking-widest text-brand-500">剧情简介</h3>
<p className="text-sm text-gray-600 leading-relaxed font-semibold">
{media.overview}
</p>
</div>
)}
<div className="space-y-4">
<MetadataTags label="类型流派" values={parseCSV(media.genres)} primary />
<MetadataTags label="语言" values={parseCSV(media.languages)} />
<MetadataTags label="国家/地区" values={parseCSV(media.countries)} />
</div>
</>
)
}
function MetadataTags({ label, values, primary = false }: { label: string; values: string[]; primary?: boolean }) {
if (values.length === 0) return null
const tagClass = primary
? 'rounded-full bg-brand-50 text-brand-700 border border-brand-100/30 px-3 py-1 text-2xs font-bold uppercase tracking-wider'
: 'rounded-xl bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold'
return (
<div className="flex flex-wrap items-center gap-3">
<span className="text-xs font-bold text-gray-500 w-16 uppercase tracking-wider">{label}</span>
<div className="flex flex-wrap gap-2">
{values.map((value) => (
<span key={value} className={tagClass}>
{value}
</span>
))}
</div>
</div>
)
}
function fmtDuration(sec: number): string {
if (!sec || sec <= 0) return '—'
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
function fmtSize(bytes: number): string {
if (!bytes || bytes <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(2)} ${units[i]}`
}
function parseCSV(s?: string): string[] {
if (!s) return []
return s.split(',').map((x) => x.trim()).filter(Boolean)
}
+57 -203
View File
@@ -1,44 +1,32 @@
import { motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2, Calendar, Database, Search, Pencil, FolderInput } from 'lucide-react'
import { ArrowLeft, Heart, Play, RefreshCw } from 'lucide-react'
import toast from 'react-hot-toast'
import { mediaAPI } from '../api/library'
import { playbackAPI } from '../api/playback'
import { recycleAPI } from '../api/recycle'
import { imageURL } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { api } from '../api/client'
import type { Media } from '../types'
import { confirmAction } from '../components/ConfirmDialog'
import { confirmAction } from '../components/confirmAction'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
import { MetadataEditDialog } from '../components/MetadataEditDialog'
import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog'
import { getSeriesKey, isEpisodeLike } from '../utils/groupSeries'
import { MediaDetailAdminPanel } from './MediaDetailAdminPanel'
import { MediaDetailBackdrop, MediaDetailPoster } from './MediaDetailArtwork'
import { MediaDetailMetadata } from './MediaDetailMetadata'
function fmtDuration(sec: number): string {
if (!sec || sec <= 0) return '—'
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
function mediaLibraryBackTarget(media: Media): string {
const libraryID = media.display_library_id || media.library_id
if (!libraryID) return ''
if (!isEpisodeLike(media)) return `/library/${encodeURIComponent(libraryID)}`
function fmtSize(bytes: number): string {
if (!bytes || bytes <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(2)} ${units[i]}`
}
function parseCSV(s?: string): string[] {
if (!s) return []
return s.split(',').map(x => x.trim()).filter(Boolean)
const seriesKey = getSeriesKey(media)
const target = `/library/${encodeURIComponent(libraryID)}`
return seriesKey ? `${target}?series=${encodeURIComponent(seriesKey)}` : target
}
export function MediaDetailPage() {
@@ -51,8 +39,9 @@ export function MediaDetailPage() {
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
const [metadataEditOpen, setMetadataEditOpen] = useState(false)
const [organizeOpen, setOrganizeOpen] = useState(false)
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useState(false)
const refresh = async () => {
const refresh = useCallback(async () => {
if (!id) return
setLoading(true)
try {
@@ -63,10 +52,11 @@ export function MediaDetailPage() {
} finally {
setLoading(false)
}
}
}, [id])
useEffect(() => {
refresh().catch(() => undefined)
}, [id])
}, [refresh])
const toggleFav = async () => {
if (!media) return
@@ -77,7 +67,11 @@ export function MediaDetailPage() {
const rescrape = async () => {
if (!media) return
await api.post(`/media/${media.id}/scrape`)
await api.post(`/media/${media.id}/scrape`, {
episode_images: scrapeEpisodeArtwork,
refresh_matched: true,
include_matched: true,
})
toast.success('已触发重新刮削')
await refresh()
}
@@ -118,7 +112,9 @@ export function MediaDetailPage() {
if (!(await confirmAction({ title: '移入回收站', message: `将「${media.title}」移至回收站? (磁盘文件保留)`, confirmText: '移入回收站' }))) return
await recycleAPI.softDelete(media.id)
toast.success('已移至回收站')
navigate(-1)
const backTarget = mediaLibraryBackTarget(media)
if (backTarget) navigate(backTarget, { replace: true })
else navigate(-1)
}
if (loading) {
@@ -138,155 +134,38 @@ export function MediaDetailPage() {
return (
<div className="relative overflow-hidden rounded-3xl bg-white border border-gray-200/90 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)]">
{/* ── Cinematic Blurred Backdrop Glow ── */}
<div className="absolute inset-0 h-[480px] z-0 overflow-hidden">
{(media.backdrop_url || media.poster_url) ? (
<img
src={imageURL(media.backdrop_url || media.poster_url || '')}
alt=""
className="w-full h-full object-cover opacity-[0.04] scale-110 blur-2xl"
referrerPolicy="no-referrer"
/>
) : (
<div className="w-full h-full bg-gradient-to-b from-gray-50 to-transparent" />
)}
<div className="absolute inset-0 bg-gradient-to-t from-white via-white/95 to-transparent" />
<MediaDetailBackdrop media={media} />
<div className="relative z-20 px-6 pt-6 sm:px-10 sm:pt-8">
<button
type="button"
onClick={() => {
const backTarget = mediaLibraryBackTarget(media)
if (backTarget) navigate(backTarget)
else navigate(-1)
}}
className="btn-ghost gap-2 bg-white/80 shadow-sm backdrop-blur hover:bg-white"
>
<ArrowLeft size={16} />
<span>返回媒体库</span>
</button>
</div>
{/* ── Main Details Layout Container ── */}
<div className="relative z-10 p-6 sm:p-10 flex flex-col md:flex-row gap-8 lg:gap-12">
{/* Poster Card */}
<div className="w-56 shrink-0 mx-auto md:mx-0">
<motion.div
whileHover={{ scale: 1.02 }}
className="aspect-[2/3] w-full rounded-2xl overflow-hidden bg-gray-50 border border-gray-200 shadow-md relative group"
>
{media.poster_url ? (
<img
src={imageURL(media.poster_url)}
alt={media.title}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-gray-500 bg-gray-50">
<FileText size={40} className="stroke-[1]" />
<span className="text-xs uppercase tracking-wider font-bold">无海报</span>
</div>
)}
{/* Quick Play overlay button */}
<Link
to={`/play/${media.id}`}
className="absolute inset-0 bg-[#111827]/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-brand-500 text-white shadow-xl transform scale-90 group-hover:scale-100 transition-transform">
<Play size={24} fill="currentColor" />
</div>
</Link>
</motion.div>
</div>
<MediaDetailPoster media={media} />
{/* Detailed Metadata Body */}
<div className="flex-1 space-y-6">
{/* Title and Year Header */}
<div className="space-y-3">
<h1 className="font-display text-3xl sm:text-4xl font-extrabold tracking-tight text-gray-900 leading-tight">
{media.title}
</h1>
<div className="flex flex-wrap items-center gap-2.5 text-xs text-gray-500 font-bold tracking-wide uppercase">
{media.year > 0 && (
<span className="inline-flex items-center gap-1 bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
<Calendar size={13} className="text-brand-500" />
<span>{media.year} 年</span>
</span>
)}
{media.width > 0 && (
<span className="inline-flex items-center gap-1 bg-brand-50 text-brand-700 border border-brand-100/50 px-2.5 py-1 rounded-xl">
<span>{media.width} × {media.height}</span>
</span>
)}
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
{fmtSize(media.size_bytes)}
</span>
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
{fmtDuration(media.duration_sec)}
</span>
{media.container && (
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700 font-mono">
{media.container}
</span>
)}
</div>
</div>
{/* Description Card */}
{media.overview && (
<div className="rounded-2xl bg-gray-50/50 border border-gray-100 p-5 space-y-2">
<h3 className="text-xs font-bold uppercase tracking-widest text-brand-500">剧情简介</h3>
<p className="text-sm text-gray-600 leading-relaxed font-semibold">
{media.overview}
</p>
</div>
)}
{/* Tag Rows (Genres, Languages, Countries) */}
<div className="space-y-4">
{/* Genres */}
{parseCSV(media.genres).length > 0 && (
<div className="flex flex-wrap items-center gap-3">
<span className="text-xs font-bold text-gray-500 w-16 uppercase tracking-wider">类型流派</span>
<div className="flex flex-wrap gap-2">
{parseCSV(media.genres).map((g) => (
<span key={g} className="rounded-full bg-brand-50 text-brand-700 border border-brand-100/30 px-3 py-1 text-2xs font-bold uppercase tracking-wider">
{g}
</span>
))}
</div>
</div>
)}
{/* Languages */}
{parseCSV(media.languages).length > 0 && (
<div className="flex flex-wrap items-center gap-3">
<span className="text-xs font-bold text-gray-500 w-16 uppercase tracking-wider">语言</span>
<div className="flex flex-wrap gap-2">
{parseCSV(media.languages).map((l) => (
<span key={l} className="rounded-xl bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold">
{l}
</span>
))}
</div>
</div>
)}
{/* Countries */}
{parseCSV(media.countries).length > 0 && (
<div className="flex flex-wrap items-center gap-3">
<span className="text-xs font-bold text-gray-500 w-16 uppercase tracking-wider">国家/地区</span>
<div className="flex flex-wrap gap-2">
{parseCSV(media.countries).map((c) => (
<span key={c} className="rounded-xl bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold">
{c}
</span>
))}
</div>
</div>
)}
</div>
<MediaDetailMetadata media={media} />
<div className="divider border-gray-200/60" />
{/* Action Buttons Panel */}
<div className="flex flex-col gap-5">
<div className="flex flex-wrap gap-3">
{/* Primary Direct Play */}
<Link to={`/play/${media.id}`} className="btn-primary px-6 py-3.5 shadow-sm">
<Play size={16} fill="currentColor" />
<span>立即播放</span>
</Link>
{/* Transcode Playback */}
<Link
to={`/play/${media.id}?mode=hls`}
className="btn-outline border-brand-500/30 hover:border-brand-500 text-[#c9954a] hover:bg-brand-50 px-5"
@@ -297,7 +176,6 @@ export function MediaDetailPage() {
<ExternalPlayerButton mediaId={media.id} />
{/* Toggle Favourites */}
<button
onClick={toggleFav}
className={
@@ -312,44 +190,19 @@ export function MediaDetailPage() {
</button>
</div>
{/* Admin Management Toolbar */}
{role === 'admin' && (
<div className="rounded-2xl border border-gray-200 bg-gray-50/50 p-5 space-y-3">
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
<div className="flex flex-wrap gap-2">
<button onClick={rescrape} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Sparkles size={13} className="text-[#c9954a]" />
<span>智能刮削 (TMDB)</span>
</button>
<button onClick={() => setManualScrapeOpen(true)} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Search size={13} className="text-[#c9954a]" />
<span>手动匹配刮削</span>
</button>
<button onClick={() => setMetadataEditOpen(true)} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Pencil size={13} className="text-gray-600" />
<span>编辑元数据</span>
</button>
<button onClick={() => setOrganizeOpen(true)} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<FolderInput size={13} className="text-[#c9954a]" />
<span>整理入库</span>
</button>
<button onClick={reprobe} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Database size={13} className="text-gray-600" />
<span>探测媒体轨 (ffprobe)</span>
</button>
<button onClick={exportNFO} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<FileText size={13} />
<span>写出本地 NFO 属性</span>
</button>
<button
onClick={softDelete}
className="btn-outline py-2 px-3.5 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200"
>
<Trash2 size={13} />
<span>移入回收站</span>
</button>
</div>
</div>
<MediaDetailAdminPanel
media={media}
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
onSmartScrape={rescrape}
onManualScrape={() => setManualScrapeOpen(true)}
onMetadataEdit={() => setMetadataEditOpen(true)}
onOrganize={() => setOrganizeOpen(true)}
onProbe={reprobe}
onExportNFO={exportNFO}
onSoftDelete={softDelete}
/>
)}
</div>
</div>
@@ -360,6 +213,7 @@ export function MediaDetailPage() {
defaultQuery={media.title}
mediaType={media.season_num > 0 || media.episode_num > 0 ? 'tv' : undefined}
scopeLabel={media.title}
episodeArtwork={scrapeEpisodeArtwork}
onClose={() => setManualScrapeOpen(false)}
onApplied={refresh}
/>
+60
View File
@@ -0,0 +1,60 @@
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 (
<div className="glass-panel flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-ink-600">{channel.name}</span>
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs text-ink-50">
{TYPE_LABELS[channel.type] ?? channel.type}
</span>
{!channel.enabled && (
<span className="rounded-lg bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">已禁用</span>
)}
</div>
<div className="mt-1 truncate text-xs text-ink-50">{summary}</div>
<div className="mt-1 truncate text-xs text-sand-500">{eventSummary(channel.events)}</div>
</div>
<div className="flex shrink-0 gap-2">
<button
onClick={onTest}
disabled={testing}
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
{testing ? <Loader2 size={12} className="inline animate-spin" /> : <Send size={12} className="inline" />} 测试
</button>
<button
onClick={onEdit}
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
<Pencil size={12} className="inline" /> 编辑
</button>
<button
onClick={onDelete}
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
>
<Trash2 size={12} className="inline" /> 删除
</button>
</div>
</div>
)
}
@@ -0,0 +1,67 @@
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 (
<Field label="推送事件">
<div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50/60 p-3">
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="radio"
name="notify-event-mode"
className="h-4 w-4 accent-primary-400"
checked={eventMode === 'all'}
onChange={() => setEventMode('all')}
/>
全部事件
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="radio"
name="notify-event-mode"
className="h-4 w-4 accent-primary-400"
checked={eventMode === 'none'}
onChange={() => setEventMode('none')}
/>
关闭全部推送事件
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="radio"
name="notify-event-mode"
className="h-4 w-4 accent-primary-400"
checked={eventMode === 'custom'}
onChange={() => setEventMode('custom')}
/>
仅推送勾选事件
</label>
<div className="grid gap-2 sm:grid-cols-2">
{EVENT_OPTIONS.map((event) => (
<label key={event.value} className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
disabled={eventMode !== 'custom'}
checked={events.includes(event.value)}
onChange={() => toggleEvent(event.value)}
/>
{event.label}
</label>
))}
</div>
</div>
</Field>
)
}
+10
View File
@@ -0,0 +1,10 @@
import type { ReactNode } from 'react'
export function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-sm text-ink-100">{label}</span>
{children}
</label>
)
}
+185
View File
@@ -0,0 +1,185 @@
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<void>
}
export function NotifyChannelFormModal({
editing,
onClose,
onSaved,
}: NotifyChannelFormModalProps) {
const [name, setName] = useState(editing?.name ?? '')
const [type, setType] = useState<NotifyChannel['type']>(
editing?.type ?? 'telegram',
)
const [config, setConfig] = useState<Record<string, string>>(
normalizeInitialConfig(editing?.type ?? 'telegram', editing?.config ?? {}),
)
const [events, setEvents] = useState<string[]>(editing?.events ?? [])
const [eventMode, setEventMode] = useState<EventMode>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
{editing ? '编辑通知渠道' : '添加通知渠道'}
</h2>
<form onSubmit={onSubmit} className="space-y-4">
<Field label="名称">
<input
required
className="input-base"
placeholder="如: Telegram 通知"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<Field label="渠道类型">
<select
className="input-base"
value={type}
onChange={(e) => onTypeChange(e.target.value as NotifyChannel['type'])}
>
<option value="telegram">Telegram</option>
<option value="wechat">企业微信 / Server酱</option>
<option value="bark">Bark (iOS)</option>
<option value="webhook">Webhook</option>
<option value="email">Email (SMTP)</option>
</select>
</Field>
{type === 'telegram' && <TelegramFields config={config} updateConfig={updateConfig} />}
{type === 'wechat' && <WechatFields config={config} updateConfig={updateConfig} />}
{type === 'bark' && <BarkFields config={config} updateConfig={updateConfig} />}
{type === 'webhook' && <WebhookFields config={config} updateConfig={updateConfig} />}
{type === 'email' && <EmailFields config={config} updateConfig={updateConfig} />}
<NotifyChannelEventFields
events={events}
eventMode={eventMode}
setEventMode={setEventMode}
toggleEvent={toggleEvent}
/>
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
启用
</label>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50"
>
取消
</button>
<button type="submit" disabled={saving} className="neon-button">
{saving && <Loader2 size={16} className="animate-spin" />} 保存
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,221 @@
import { Field } from './NotifyChannelFormField'
type ProviderFieldsProps = {
config: Record<string, string>
updateConfig: (key: string, value: string) => void
}
export function TelegramFields({ config, updateConfig }: ProviderFieldsProps) {
return (
<>
<Field label="Bot Token">
<input
required
className="input-base"
placeholder="123456:ABC-DEF…"
value={config.bot_token ?? ''}
onChange={(e) => updateConfig('bot_token', e.target.value)}
/>
</Field>
<Field label="管理员 Telegram ID">
<input
required
className="input-base"
placeholder="多个用逗号分隔,如 123456789,987654321"
value={config.admin_user_ids ?? ''}
onChange={(e) => updateConfig('admin_user_ids', e.target.value)}
/>
</Field>
<Field label="绑定群组 ID">
<input
className="input-base"
placeholder="选填,如 -1001234567890;填写后群组成员可唤醒/绑定"
value={config.group_chat_id ?? ''}
onChange={(e) => updateConfig('group_chat_id', e.target.value)}
/>
</Field>
<Field label="绑定频道 ID">
<input
className="input-base"
placeholder="选填,如 -1009876543210;填写后频道成员可唤醒/绑定"
value={config.channel_chat_id ?? ''}
onChange={(e) => updateConfig('channel_chat_id', e.target.value)}
/>
</Field>
<Field label="Telegram API 地址 (可选)">
<input
className="input-base"
placeholder="默认 https://api.telegram.org;反代可填 https://tg.example.com"
value={config.api_base_url ?? ''}
onChange={(e) => updateConfig('api_base_url', e.target.value)}
/>
</Field>
<Field label="Telegram 代理 (可选)">
<input
className="input-base"
placeholder="如 http://172.17.0.1:7890 或 socks5://172.17.0.1:1080"
value={config.proxy_url ?? ''}
onChange={(e) => updateConfig('proxy_url', e.target.value)}
/>
</Field>
<div className="rounded-2xl border border-primary-400/15 bg-primary-400/5 px-4 py-3 text-xs leading-6 text-ink-50">
群组 ID、频道 ID 均为选填,可填一个、两个都填,也可以不填。管理功能始终仅管理员 Telegram ID 或已绑定的本地管理员可用;普通用户需要在已配置的群组或频道中,才能使用 <code>/start 用户名 密码</code> 绑定账号、切换隐藏成人媒体库和目录。不配置群组/频道时,普通用户不会被放行。若测试通知超时,可填写反代 API 地址或代理地址。
</div>
</>
)
}
export function WechatFields({ config, updateConfig }: ProviderFieldsProps) {
return (
<Field label="SendKey">
<input
required
className="input-base"
placeholder="SCT…"
value={config.sendkey ?? ''}
onChange={(e) => updateConfig('sendkey', e.target.value)}
/>
</Field>
)
}
export function BarkFields({ config, updateConfig }: ProviderFieldsProps) {
return (
<>
<Field label="设备 Key">
<input
required
className="input-base"
value={config.device_key ?? ''}
onChange={(e) => updateConfig('device_key', e.target.value)}
/>
</Field>
<Field label="服务器地址 (可选)">
<input
className="input-base"
placeholder="https://api.day.app"
value={config.server ?? ''}
onChange={(e) => updateConfig('server', e.target.value)}
/>
</Field>
</>
)
}
export function WebhookFields({ config, updateConfig }: ProviderFieldsProps) {
return (
<>
<Field label="URL">
<input
required
className="input-base"
placeholder="https://example.com/notify"
value={config.url ?? ''}
onChange={(e) => updateConfig('url', e.target.value)}
/>
</Field>
<Field label="Method">
<select
className="input-base"
value={config.method ?? 'POST'}
onChange={(e) => updateConfig('method', e.target.value)}
>
<option value="POST">POST</option>
<option value="GET">GET</option>
</select>
</Field>
<Field label="Headers (JSON)">
<textarea
rows={2}
className="input-base font-mono text-xs"
placeholder='{"Content-Type":"application/json"}'
value={config.headers ?? ''}
onChange={(e) => updateConfig('headers', e.target.value)}
/>
</Field>
<Field label="Body 模板 (支持 {{title}} {{message}})">
<textarea
rows={3}
className="input-base font-mono text-xs"
placeholder='{"title":"{{title}}","message":"{{message}}"}'
value={config.body_template ?? ''}
onChange={(e) => updateConfig('body_template', e.target.value)}
/>
</Field>
</>
)
}
export function EmailFields({ config, updateConfig }: ProviderFieldsProps) {
return (
<>
<Field label="SMTP 地址">
<input
required
className="input-base"
placeholder="smtp.gmail.com"
value={config.smtp_host ?? ''}
onChange={(e) => updateConfig('smtp_host', e.target.value)}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="SMTP 端口">
<input
required
className="input-base"
placeholder="465"
value={config.smtp_port ?? ''}
onChange={(e) => updateConfig('smtp_port', e.target.value)}
/>
</Field>
<Field label="TLS">
<select
className="input-base"
value={config.tls ?? 'true'}
onChange={(e) => updateConfig('tls', e.target.value)}
>
<option value="true">启用</option>
<option value="false">关闭</option>
</select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="用户名">
<input
required
className="input-base"
value={config.username ?? ''}
onChange={(e) => updateConfig('username', e.target.value)}
/>
</Field>
<Field label="密码">
<input
type="password"
required
className="input-base"
value={config.password ?? ''}
onChange={(e) => updateConfig('password', e.target.value)}
/>
</Field>
</div>
<Field label="发件人">
<input
required
className="input-base"
placeholder="noreply@example.com"
value={config.from ?? ''}
onChange={(e) => updateConfig('from', e.target.value)}
/>
</Field>
<Field label="收件人 (多个用逗号分隔)">
<input
required
className="input-base"
placeholder="user@example.com"
value={config.to ?? ''}
onChange={(e) => updateConfig('to', e.target.value)}
/>
</Field>
</>
)
}
+8 -552
View File
@@ -1,13 +1,12 @@
import { FormEvent, useEffect, useState } from 'react'
import { Bell, Loader2, Pencil, Play, Plus, Send, Square, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Bell, Loader2, Play, Plus, Square } from 'lucide-react'
import toast from 'react-hot-toast'
import {
notifyChannelsAPI,
type NotifyChannelInput,
} from '../api/notify_channels'
import { confirmAction } from '../components/ConfirmDialog'
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
@@ -152,7 +151,7 @@ export function NotifyChannelsPage() {
{!loading && channels.length > 0 && (
<div className="space-y-3">
{channels.map((ch) => (
<ChannelCard
<NotifyChannelCard
key={ch.id}
channel={ch}
onTest={() => onTest(ch.id)}
@@ -168,7 +167,7 @@ export function NotifyChannelsPage() {
)}
{showForm && (
<ChannelFormModal
<NotifyChannelFormModal
editing={editing}
onClose={() => setShowForm(false)}
onSaved={async () => {
@@ -180,546 +179,3 @@ export function NotifyChannelsPage() {
</div>
)
}
const TYPE_LABELS: Record<NotifyChannel['type'], string> = {
telegram: 'Telegram',
wechat: '企业微信',
bark: 'Bark',
webhook: 'Webhook',
email: 'Email',
}
const EVENT_OPTIONS = [
{ value: 'subscription_hit', label: '订阅命中新资源' },
{ value: 'download_complete', label: '下载任务完成' },
{ value: 'library_ingest', label: '入库完成' },
{ value: 'scrape_failed', label: '刮削失败告警' },
{ value: 'system_alert', label: '系统异常通知' },
]
const EVENT_ALL = '__all__'
const EVENT_NONE = '__none__'
type EventMode = 'all' | 'custom' | 'none'
function initialEventMode(events: string[] | undefined): EventMode {
if (events?.includes(EVENT_NONE)) return 'none'
if (events?.includes(EVENT_ALL) || !events || events.length === 0) return 'all'
return 'custom'
}
function ChannelCard({
channel,
onTest,
testing,
onEdit,
onDelete,
}: {
channel: NotifyChannel
onTest: () => void
testing?: boolean
onEdit: () => void
onDelete: () => void
}) {
const summary = channelSummary(channel)
return (
<div className="glass-panel flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-ink-600">{channel.name}</span>
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs text-ink-50">
{TYPE_LABELS[channel.type] ?? channel.type}
</span>
{!channel.enabled && (
<span className="rounded-lg bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">已禁用</span>
)}
</div>
<div className="mt-1 truncate text-xs text-ink-50">{summary}</div>
<div className="mt-1 truncate text-xs text-sand-500">{eventSummary(channel.events)}</div>
</div>
<div className="flex shrink-0 gap-2">
<button
onClick={onTest}
disabled={testing}
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
{testing ? <Loader2 size={12} className="inline animate-spin" /> : <Send size={12} className="inline" />} 测试
</button>
<button
onClick={onEdit}
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
<Pencil size={12} className="inline" /> 编辑
</button>
<button
onClick={onDelete}
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
>
<Trash2 size={12} className="inline" /> 删除
</button>
</div>
</div>
)
}
function eventSummary(events: string[] | undefined): string {
if (events?.includes(EVENT_NONE)) return '事件:不推送'
if (events?.includes(EVENT_ALL)) return '事件:全部'
if (!events || events.length === 0) return '事件:全部'
const labels = events.map((event) => EVENT_OPTIONS.find((item) => item.value === event)?.label ?? event)
return `事件:${labels.join('、')}`
}
function channelSummary(ch: NotifyChannel): string {
const cfg = ch.config ?? {}
switch (ch.type) {
case 'telegram':
return `Bot ${cfg.bot_token ? '已配置' : '未配置'} → 管理员 ${cfg.admin_user_ids ?? '-'} · 群组 ${cfg.group_chat_id ?? '-'} · 频道 ${cfg.channel_chat_id ?? '-'}`
case 'wechat':
return `SendKey ${String(cfg.sendkey ?? '').slice(0, 10)}…`
case 'bark':
return `Device ${String(cfg.device_key ?? '').slice(0, 10)}…`
case 'webhook':
return `${cfg.method ?? 'POST'} ${cfg.url ?? ''}`
case 'email':
return `SMTP ${cfg.smtp_host ?? '-'}:${cfg.smtp_port ?? '-'} → ${cfg.to ?? '-'}`
default:
return ''
}
}
// ─── Form Modal ─────────────────────────────────────────────────────────────
const EMPTY_CONFIG: Record<NotifyChannel['type'], Record<string, string>> = {
telegram: {
bot_token: '',
admin_user_ids: '',
group_chat_id: '',
channel_chat_id: '',
api_base_url: '',
proxy_url: '',
},
wechat: { sendkey: '' },
bark: { device_key: '', server: '' },
webhook: { url: '', method: 'POST', headers: '', body_template: '' },
email: { smtp_host: '', smtp_port: '465', username: '', password: '', from: '', to: '', tls: 'true' },
}
function normalizeInitialConfig(type: NotifyChannel['type'], raw: Record<string, unknown>): Record<string, string> {
const base = { ...EMPTY_CONFIG[type] }
for (const [key, value] of Object.entries(raw ?? {})) {
base[key] = String(value ?? '')
}
if (type === 'telegram' && !base.group_chat_id && !base.channel_chat_id && base.chat_id?.startsWith('-')) {
base.group_chat_id = base.chat_id
}
if (type === 'telegram' && !base.admin_user_ids && base.chat_id && !base.chat_id.startsWith('-')) {
base.admin_user_ids = base.chat_id
}
delete base.chat_id
return base
}
function ChannelFormModal({
editing,
onClose,
onSaved,
}: {
editing: NotifyChannel | null
onClose: () => void
onSaved: () => void | Promise<void>
}) {
const [name, setName] = useState(editing?.name ?? '')
const [type, setType] = useState<NotifyChannel['type']>(
editing?.type ?? 'telegram',
)
const [config, setConfig] = useState<Record<string, string>>(
normalizeInitialConfig(editing?.type ?? 'telegram', editing?.config ?? {}),
)
const [events, setEvents] = useState<string[]>(editing?.events ?? [])
const [eventMode, setEventMode] = useState<EventMode>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
{editing ? '编辑通知渠道' : '添加通知渠道'}
</h2>
<form onSubmit={onSubmit} className="space-y-4">
<Field label="名称">
<input
required
className="input-base"
placeholder="如: Telegram 通知"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<Field label="渠道类型">
<select
className="input-base"
value={type}
onChange={(e) => onTypeChange(e.target.value as NotifyChannel['type'])}
>
<option value="telegram">Telegram</option>
<option value="wechat">企业微信 / Server酱</option>
<option value="bark">Bark (iOS)</option>
<option value="webhook">Webhook</option>
<option value="email">Email (SMTP)</option>
</select>
</Field>
{type === 'telegram' && (
<>
<Field label="Bot Token">
<input
required
className="input-base"
placeholder="123456:ABC-DEF…"
value={config.bot_token ?? ''}
onChange={(e) => updateConfig('bot_token', e.target.value)}
/>
</Field>
<Field label="管理员 Telegram ID">
<input
required
className="input-base"
placeholder="多个用逗号分隔,如 123456789,987654321"
value={config.admin_user_ids ?? ''}
onChange={(e) => updateConfig('admin_user_ids', e.target.value)}
/>
</Field>
<Field label="绑定群组 ID">
<input
className="input-base"
placeholder="选填,如 -1001234567890;填写后群组成员可唤醒/绑定"
value={config.group_chat_id ?? ''}
onChange={(e) => updateConfig('group_chat_id', e.target.value)}
/>
</Field>
<Field label="绑定频道 ID">
<input
className="input-base"
placeholder="选填,如 -1009876543210;填写后频道成员可唤醒/绑定"
value={config.channel_chat_id ?? ''}
onChange={(e) => updateConfig('channel_chat_id', e.target.value)}
/>
</Field>
<Field label="Telegram API 地址 (可选)">
<input
className="input-base"
placeholder="默认 https://api.telegram.org;反代可填 https://tg.example.com"
value={config.api_base_url ?? ''}
onChange={(e) => updateConfig('api_base_url', e.target.value)}
/>
</Field>
<Field label="Telegram 代理 (可选)">
<input
className="input-base"
placeholder="如 http://172.17.0.1:7890 或 socks5://172.17.0.1:1080"
value={config.proxy_url ?? ''}
onChange={(e) => updateConfig('proxy_url', e.target.value)}
/>
</Field>
<div className="rounded-2xl border border-primary-400/15 bg-primary-400/5 px-4 py-3 text-xs leading-6 text-ink-50">
群组 ID、频道 ID 均为选填,可填一个、两个都填,也可以不填。管理功能始终仅管理员 Telegram ID 或已绑定的本地管理员可用;普通用户需要在已配置的群组或频道中,才能使用 <code>/start 用户名 密码</code> 绑定账号、切换隐藏成人媒体库和目录。不配置群组/频道时,普通用户不会被放行。若测试通知超时,可填写反代 API 地址或代理地址。
</div>
</>
)}
{type === 'wechat' && (
<Field label="SendKey">
<input
required
className="input-base"
placeholder="SCT…"
value={config.sendkey ?? ''}
onChange={(e) => updateConfig('sendkey', e.target.value)}
/>
</Field>
)}
{type === 'bark' && (
<>
<Field label="设备 Key">
<input
required
className="input-base"
value={config.device_key ?? ''}
onChange={(e) => updateConfig('device_key', e.target.value)}
/>
</Field>
<Field label="服务器地址 (可选)">
<input
className="input-base"
placeholder="https://api.day.app"
value={config.server ?? ''}
onChange={(e) => updateConfig('server', e.target.value)}
/>
</Field>
</>
)}
{type === 'webhook' && (
<>
<Field label="URL">
<input
required
className="input-base"
placeholder="https://example.com/notify"
value={config.url ?? ''}
onChange={(e) => updateConfig('url', e.target.value)}
/>
</Field>
<Field label="Method">
<select
className="input-base"
value={config.method ?? 'POST'}
onChange={(e) => updateConfig('method', e.target.value)}
>
<option value="POST">POST</option>
<option value="GET">GET</option>
</select>
</Field>
<Field label="Headers (JSON)">
<textarea
rows={2}
className="input-base font-mono text-xs"
placeholder='{"Content-Type":"application/json"}'
value={config.headers ?? ''}
onChange={(e) => updateConfig('headers', e.target.value)}
/>
</Field>
<Field label="Body 模板 (支持 {{title}} {{message}})">
<textarea
rows={3}
className="input-base font-mono text-xs"
placeholder='{"title":"{{title}}","message":"{{message}}"}'
value={config.body_template ?? ''}
onChange={(e) => updateConfig('body_template', e.target.value)}
/>
</Field>
</>
)}
{type === 'email' && (
<>
<Field label="SMTP 地址">
<input
required
className="input-base"
placeholder="smtp.gmail.com"
value={config.smtp_host ?? ''}
onChange={(e) => updateConfig('smtp_host', e.target.value)}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="SMTP 端口">
<input
required
className="input-base"
placeholder="465"
value={config.smtp_port ?? ''}
onChange={(e) => updateConfig('smtp_port', e.target.value)}
/>
</Field>
<Field label="TLS">
<select
className="input-base"
value={config.tls ?? 'true'}
onChange={(e) => updateConfig('tls', e.target.value)}
>
<option value="true">启用</option>
<option value="false">关闭</option>
</select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="用户名">
<input
required
className="input-base"
value={config.username ?? ''}
onChange={(e) => updateConfig('username', e.target.value)}
/>
</Field>
<Field label="密码">
<input
type="password"
required
className="input-base"
value={config.password ?? ''}
onChange={(e) => updateConfig('password', e.target.value)}
/>
</Field>
</div>
<Field label="发件人">
<input
required
className="input-base"
placeholder="noreply@example.com"
value={config.from ?? ''}
onChange={(e) => updateConfig('from', e.target.value)}
/>
</Field>
<Field label="收件人 (多个用逗号分隔)">
<input
required
className="input-base"
placeholder="user@example.com"
value={config.to ?? ''}
onChange={(e) => updateConfig('to', e.target.value)}
/>
</Field>
</>
)}
<Field label="推送事件">
<div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50/60 p-3">
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="radio"
name="notify-event-mode"
className="h-4 w-4 accent-primary-400"
checked={eventMode === 'all'}
onChange={() => setEventMode('all')}
/>
全部事件
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="radio"
name="notify-event-mode"
className="h-4 w-4 accent-primary-400"
checked={eventMode === 'none'}
onChange={() => setEventMode('none')}
/>
关闭全部推送事件
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="radio"
name="notify-event-mode"
className="h-4 w-4 accent-primary-400"
checked={eventMode === 'custom'}
onChange={() => setEventMode('custom')}
/>
仅推送勾选事件
</label>
<div className="grid gap-2 sm:grid-cols-2">
{EVENT_OPTIONS.map((event) => (
<label key={event.value} className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
disabled={eventMode !== 'custom'}
checked={events.includes(event.value)}
onChange={() => toggleEvent(event.value)}
/>
{event.label}
</label>
))}
</div>
</div>
</Field>
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
启用
</label>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50"
>
取消
</button>
<button type="submit" disabled={saving} className="neon-button">
{saving && <Loader2 size={16} className="animate-spin" />} 保存
</button>
</div>
</form>
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-sm text-ink-100">{label}</span>
{children}
</label>
)
}
+76 -140
View File
@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import type Hls from 'hls.js'
import { ArrowLeft, RefreshCw, Sparkles } from 'lucide-react'
import toast from 'react-hot-toast'
import { mediaAPI } from '../api/library'
@@ -11,8 +10,9 @@ import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { systemAPI } from '../api/system'
import type { Media } from '../types'
import { getSeriesKey, isEpisodeLike } from '../utils/groupSeries'
type Mode = 'direct' | 'hls'
import { pickPlayerMode, needsTranscodeForBrowser, type PlayerMode } from './playerPageModel'
import { PlayerTopBar } from './PlayerTopBar'
import { PlayerVideoStage } from './PlayerVideoStage'
// Fullscreen, dark-themed video page.
//
@@ -36,13 +36,37 @@ export function PlayerPage() {
const lastSentRef = useRef(0)
const [media, setMedia] = useState<Media | null>(null)
const [mode, setMode] = useState<Mode>('direct')
const [mode, setMode] = useState<PlayerMode>('direct')
const [subs, setSubs] = useState<SubtitleTrack[]>([])
const [hlsUnavailable, setHlsUnavailable] = useState(false)
const [playerError, setPlayerError] = useState('')
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
const [directOnly, setDirectOnly] = useState(false)
const teardownHls = useCallback((mediaId?: string, stopServer = false) => {
if (hlsRef.current) {
hlsRef.current.destroy()
hlsRef.current = null
}
if (stopServer && mediaId) {
api.delete(`/hls/${encodeURIComponent(mediaId)}`).catch(() => undefined)
}
}, [])
const backTarget = useCallback(() => {
const state = location.state as { from?: string } | null
if (state?.from) return state.from
if (media && isEpisodeLike(media) && media.library_id) {
return `/library/${encodeURIComponent(media.display_library_id || media.library_id)}?series=${encodeURIComponent(getSeriesKey(media))}`
}
const target = media?.id || id
return target ? `/media/${target}` : '/'
}, [id, location.state, media])
const goBack = useCallback(() => {
navigate(backTarget(), { replace: true })
}, [backTarget, navigate])
// 读取宿主机的「直连解码」开关。开启时全程 direct play,不走 HLS。
useEffect(() => {
systemAPI
@@ -56,8 +80,8 @@ export function PlayerPage() {
if (!id) return
mediaAPI.get(id).then((m) => {
setMedia(m)
const forced = params.get('mode') as Mode | null
const auto = pickMode(m)
const forced = params.get('mode') as PlayerMode | null
const auto = pickPlayerMode(m)
// 直连解码模式下忽略 ?mode=hls 与自动判定,始终 direct play。
setMode(directOnly ? 'direct' : (forced ?? auto))
setPlayerError('')
@@ -114,7 +138,7 @@ export function PlayerPage() {
void video.play().catch(() => undefined)
}
return () => teardownHls(media.id, mode === 'hls')
}, [hlsUnavailable, media, mode, params, setParams])
}, [hlsUnavailable, media, mode, params, setParams, teardownHls])
// Persist resume position every 10 seconds while playing.
useEffect(() => {
@@ -138,30 +162,6 @@ export function PlayerPage() {
}
}, [media])
const teardownHls = (mediaId?: string, stopServer = false) => {
if (hlsRef.current) {
hlsRef.current.destroy()
hlsRef.current = null
}
if (stopServer && mediaId) {
api.delete(`/hls/${encodeURIComponent(mediaId)}`).catch(() => undefined)
}
}
const backTarget = () => {
const state = location.state as { from?: string } | null
if (state?.from) return state.from
if (media && isEpisodeLike(media) && media.library_id) {
return `/library/${encodeURIComponent(media.display_library_id || media.library_id)}?series=${encodeURIComponent(getSeriesKey(media))}`
}
const target = media?.id || id
return target ? `/media/${target}` : '/'
}
const goBack = () => {
navigate(backTarget(), { replace: true })
}
// ESC = back.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -169,117 +169,53 @@ export function PlayerPage() {
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [location.state, media, id])
}, [goBack])
const toggleMode = useCallback(() => {
const next = mode === 'hls' ? 'direct' : 'hls'
setMode(next)
params.set('mode', next)
setParams(params, { replace: true })
}, [mode, params, setParams])
const handleVideoError = useCallback(() => {
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
if (mode === 'direct') {
if (directOnly) {
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
toast.error('直接播放失败(客户端直连解码模式)')
} else if (hlsUnavailable) {
setPlayerError('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。')
toast.error('直接播放失败,HLS 转码不可用')
} else {
toast.error('直接播放失败,切换到 HLS 转码')
setMode('hls')
params.set('mode', 'hls')
setParams(params, { replace: true })
}
return
}
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
toast.error('视频播放失败,请检查文件是否存在')
}, [directOnly, hlsUnavailable, mode, params, setParams])
return (
<div className="relative -m-6 flex min-h-screen flex-col overflow-hidden bg-black md:-m-8">
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between p-4 sm:p-6">
<button
onClick={goBack}
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
>
<ArrowLeft size={16} /> 返回
</button>
{directOnly ? (
<span
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
title="宿主机不转码,由客户端本地解码直连"
>
<Sparkles size={14} /> 客户端直连解码
</span>
) : (
<button
onClick={() => {
const next = mode === 'hls' ? 'direct' : 'hls'
setMode(next)
params.set('mode', next)
setParams(params, { replace: true })
}}
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
title="切换播放模式"
>
{mode === 'hls' ? (
<>
<RefreshCw size={14} /> HLS 转码中
</>
) : (
<>
<Sparkles size={14} /> 直接播放
</>
)}
</button>
)}
</div>
<div className="flex flex-1 items-center justify-center">
{media ? (
<video
ref={ref}
controls
autoPlay
playsInline
className="max-h-screen w-full max-w-[1600px] bg-black"
onError={() => {
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
if (mode === 'direct') {
if (directOnly) {
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
toast.error('直接播放失败(客户端直连解码模式)')
} else if (hlsUnavailable) {
setPlayerError('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。')
toast.error('直接播放失败,HLS 转码不可用')
} else {
toast.error('直接播放失败,切换到 HLS 转码')
setMode('hls')
params.set('mode', 'hls')
setParams(params, { replace: true })
}
} else {
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
toast.error('视频播放失败,请检查文件是否存在')
}
}}
>
{subs.map((t, i) => (
<track
key={t.path}
kind="subtitles"
src={subtitlesAPI.url(media.id, t.path)}
srcLang={t.lang}
label={t.label || t.lang}
default={i === 0}
/>
))}
</video>
) : (
<p className="text-sand-500">加载中…</p>
)}
{playerError ? (
<div className="absolute bottom-20 left-1/2 w-[min(92vw,720px)] -translate-x-1/2 rounded-2xl border border-white/15 bg-black/75 px-5 py-4 text-sm text-white shadow-2xl backdrop-blur">
{playerError}
</div>
) : null}
</div>
<PlayerTopBar
directOnly={directOnly}
mode={mode}
onBack={goBack}
onToggleMode={toggleMode}
/>
<PlayerVideoStage
media={media}
playerError={playerError}
subs={subs}
videoRef={ref}
onVideoError={handleVideoError}
/>
</div>
)
}
const directContainers = ['mp4', 'webm', 'm4v']
const directVideoCodecs = ['h264', 'avc', 'avc1']
const directAudioCodecs = ['aac', 'mp3', 'opus']
function pickMode(m: Media): Mode {
return needsTranscodeForBrowser(m) ? 'hls' : 'direct'
}
function needsTranscodeForBrowser(m: Media): boolean {
const c = (m.container ?? '').toLowerCase()
const v = (m.video_codec ?? '').toLowerCase()
const a = (m.audio_codec ?? '').toLowerCase()
const containerOK = directContainers.some((x) => c.includes(x))
const videoOK = !v || directVideoCodecs.some((x) => v.includes(x))
const audioOK = !a || directAudioCodecs.some((x) => a.includes(x))
return !(containerOK && videoOK && audioOK)
}
+48
View File
@@ -0,0 +1,48 @@
import { ArrowLeft, RefreshCw, Sparkles } from 'lucide-react'
import type { PlayerMode } from './playerPageModel'
type PlayerTopBarProps = {
directOnly: boolean
mode: PlayerMode
onBack: () => void
onToggleMode: () => void
}
export function PlayerTopBar({ directOnly, mode, onBack, onToggleMode }: PlayerTopBarProps) {
return (
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between p-4 sm:p-6">
<button
onClick={onBack}
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
>
<ArrowLeft size={16} /> 返回
</button>
{directOnly ? (
<span
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
title="宿主机不转码,由客户端本地解码直连"
>
<Sparkles size={14} /> 客户端直连解码
</span>
) : (
<button
onClick={onToggleMode}
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
title="切换播放模式"
>
{mode === 'hls' ? (
<>
<RefreshCw size={14} /> HLS 转码中
</>
) : (
<>
<Sparkles size={14} /> 直接播放
</>
)}
</button>
)}
</div>
)
}
+53
View File
@@ -0,0 +1,53 @@
import type { RefObject } from 'react'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import type { Media } from '../types'
type PlayerVideoStageProps = {
media: Media | null
playerError: string
subs: SubtitleTrack[]
videoRef: RefObject<HTMLVideoElement>
onVideoError: () => void
}
export function PlayerVideoStage({
media,
playerError,
subs,
videoRef,
onVideoError,
}: PlayerVideoStageProps) {
return (
<div className="flex flex-1 items-center justify-center">
{media ? (
<video
ref={videoRef}
controls
autoPlay
playsInline
className="max-h-screen w-full max-w-[1600px] bg-black"
onError={onVideoError}
>
{subs.map((track, index) => (
<track
key={track.path}
kind="subtitles"
src={subtitlesAPI.url(media.id, track.path)}
srcLang={track.lang}
label={track.label || track.lang}
default={index === 0}
/>
))}
</video>
) : (
<p className="text-sand-500">加载中…</p>
)}
{playerError ? (
<div className="absolute bottom-20 left-1/2 w-[min(92vw,720px)] -translate-x-1/2 rounded-2xl border border-white/15 bg-black/75 px-5 py-4 text-sm text-white shadow-2xl backdrop-blur">
{playerError}
</div>
) : null}
</div>
)
}
+5 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { X } from 'lucide-react'
import toast from 'react-hot-toast'
@@ -10,12 +10,13 @@ export function PlaylistDetailPage() {
const { id = '' } = useParams()
const [detail, setDetail] = useState<PlaylistDetail | null>(null)
const refresh = () =>
playbackAPI.getPlaylist(id).then(setDetail).catch(() => setDetail(null))
const refresh = useCallback(() => {
return playbackAPI.getPlaylist(id).then(setDetail).catch(() => setDetail(null))
}, [id])
useEffect(() => {
refresh()
}, [id])
}, [refresh])
if (!detail) {
return <p className="text-sand-500">加载中…</p>

Some files were not shown because too many files have changed in this diff Show More