fix: stabilize library paths and subscription matching

This commit is contained in:
ShukeBta
2026-06-28 12:15:10 +08:00
parent e4abb03ca5
commit a97dc35790
102 changed files with 4582 additions and 1519 deletions
+30
View File
@@ -0,0 +1,30 @@
import { api } from './client'
export interface RecognitionWordsConfig {
enabled: boolean
local_text: string
shared_urls: string[]
shared_text?: string
synced_at?: string
rule_count: number
}
export interface RecognitionWordsTestResult {
input: string
output: string
title: string
year: number
changed: boolean
}
export const recognitionWordsAPI = {
get: () => api.get<RecognitionWordsConfig>('/admin/recognition-words').then((r) => r.data),
save: (payload: RecognitionWordsConfig) =>
api.put<RecognitionWordsConfig>('/admin/recognition-words', payload).then((r) => r.data),
sync: () => api.post<RecognitionWordsConfig>('/admin/recognition-words/sync').then((r) => r.data),
test: (input: string) =>
api.post<RecognitionWordsTestResult>('/admin/recognition-words/test', { input }).then((r) => r.data),
}
+13 -2
View File
@@ -42,10 +42,14 @@ export function buildSubscriptionAliases(item: {
export const subscriptionsAPI = {
list: () =>
api.get<{ items: Subscription[] }>('/subscriptions').then((r) => r.data.items),
api
.get<{ items: Subscription[] }>('/subscriptions', subscriptionListRequestConfig())
.then((r) => r.data.items),
history: () =>
api.get<{ items: Subscription[] }>('/subscriptions/history').then((r) => r.data.items),
api
.get<{ items: Subscription[] }>('/subscriptions/history', subscriptionListRequestConfig())
.then((r) => r.data.items),
create: (input: {
name: string
@@ -86,3 +90,10 @@ export const subscriptionsAPI = {
runNow: (id: string) =>
api.post<{ queued: number }>(`/subscriptions/${id}/run`).then((r) => r.data),
}
function subscriptionListRequestConfig() {
return {
headers: { 'Cache-Control': 'no-cache' },
params: { _ts: Date.now() },
}
}
+204
View File
@@ -0,0 +1,204 @@
import { Link } from 'react-router-dom'
import { Menu, MessageSquareText, Search, Sparkles } from 'lucide-react'
import type { PlayProfile, User } from '../types'
import { LayoutSearchBox } from './LayoutSearchBox'
import { LayoutThemeToggle } from './LayoutThemeToggle'
import { LayoutUserMenu } from './LayoutUserMenu'
import type { useLayoutProfiles } from './useLayoutProfiles'
import type { useLayoutSearch } from './useLayoutSearch'
import type { ThemeMode, useThemeMode } from './useThemeMode'
type LayoutSearchState = ReturnType<typeof useLayoutSearch>
type LayoutProfileState = ReturnType<typeof useLayoutProfiles>
type LayoutThemeState = ReturnType<typeof useThemeMode>
type LayoutPermissionState = {
can: (key: string) => boolean
isAdmin: boolean
}
type LayoutHeaderProps = {
search: LayoutSearchState
permissions: LayoutPermissionState
theme: LayoutThemeState
onOpenMobileDrawer: () => void
user: User | null | undefined
activeProfileId: string | null
profile: LayoutProfileState
onLogout: () => void
}
export function LayoutHeader({
search,
permissions,
theme,
onOpenMobileDrawer,
user,
activeProfileId,
profile,
onLogout,
}: LayoutHeaderProps) {
return (
<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">
<LayoutHeaderSearch search={search} onOpenMobileDrawer={onOpenMobileDrawer} />
<LayoutHeaderActions
permissions={permissions}
themeMode={theme.mode}
onThemeChange={theme.setMode}
user={user}
isProfileOpen={profile.isProfileOpen}
profiles={profile.profiles}
activeProfileId={activeProfileId}
activeProfile={profile.activeProfile}
onToggleProfile={() => profile.setIsProfileOpen((open) => !open)}
onCloseProfile={() => profile.setIsProfileOpen(false)}
onUseDefaultProfile={profile.useDefaultProfile}
onSwitchProfile={profile.switchProfile}
onLogout={onLogout}
/>
</header>
)
}
function LayoutHeaderSearch({
search,
onOpenMobileDrawer,
}: {
search: LayoutSearchState
onOpenMobileDrawer: () => void
}) {
return (
<div className="flex items-center gap-3 flex-1 max-w-lg md:gap-4">
<button
onClick={onOpenMobileDrawer}
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>
<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>
)
}
type LayoutHeaderActionsProps = {
permissions: LayoutPermissionState
themeMode: ThemeMode
onThemeChange: (mode: ThemeMode) => void
user: User | null | undefined
isProfileOpen: boolean
profiles: PlayProfile[]
activeProfileId: string | null
activeProfile: PlayProfile | null
onToggleProfile: () => void
onCloseProfile: () => void
onUseDefaultProfile: () => void
onSwitchProfile: (profile: PlayProfile) => void
onLogout: () => void
}
function LayoutHeaderActions({
permissions,
themeMode,
onThemeChange,
user,
isProfileOpen,
profiles,
activeProfileId,
activeProfile,
onToggleProfile,
onCloseProfile,
onUseDefaultProfile,
onSwitchProfile,
onLogout,
}: LayoutHeaderActionsProps) {
return (
<div className="flex shrink-0 items-center gap-2 sm:gap-3 md:gap-4">
<LayoutQuickActions permissions={permissions} />
<LayoutThemeToggle mode={themeMode} onChange={onThemeChange} />
<span className="hidden h-6 w-px bg-[var(--app-border)] sm:block" />
<LayoutProfileMenu
user={user}
isProfileOpen={isProfileOpen}
profiles={profiles}
activeProfileId={activeProfileId}
activeProfile={activeProfile}
onToggleProfile={onToggleProfile}
onCloseProfile={onCloseProfile}
onUseDefaultProfile={onUseDefaultProfile}
onSwitchProfile={onSwitchProfile}
onLogout={onLogout}
/>
</div>
)
}
function LayoutQuickActions({ permissions }: { permissions: LayoutPermissionState }) {
return (
<>
<Link
to="/search"
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>
{permissions.can('can_view_discover') && (
<Link
to="/discover"
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>
</Link>
)}
{permissions.isAdmin && (
<Link
to="/notify-channels"
title="通知配置"
aria-label="打开通知配置"
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>
)}
</>
)
}
function LayoutProfileMenu({
user,
isProfileOpen,
profiles,
activeProfileId,
activeProfile,
onToggleProfile,
onCloseProfile,
onUseDefaultProfile,
onSwitchProfile,
onLogout,
}: Omit<LayoutHeaderActionsProps, 'permissions' | 'themeMode' | 'onThemeChange'>) {
return (
<LayoutUserMenu
user={user}
isOpen={isProfileOpen}
profiles={profiles}
activeProfileId={activeProfileId}
activeProfile={activeProfile}
onToggle={onToggleProfile}
onClose={onCloseProfile}
onUseDefaultProfile={onUseDefaultProfile}
onSwitchProfile={onSwitchProfile}
onLogout={onLogout}
/>
)
}
+3 -203
View File
@@ -1,29 +1,12 @@
import { Link, Outlet } from 'react-router-dom'
import { Outlet } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { Menu, MessageSquareText, Search, Sparkles } from 'lucide-react'
import clsx from 'clsx'
import type { PlayProfile, User } from '../types'
import { AppFooter } from './AppFooter'
import { LayoutSearchBox } from './LayoutSearchBox'
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
import { LayoutThemeToggle } from './LayoutThemeToggle'
import { LayoutUserMenu } from './LayoutUserMenu'
import type { ThemeMode } from './useThemeMode'
import type { useLayoutSearch } from './useLayoutSearch'
import type { useLayoutProfiles } from './useLayoutProfiles'
import type { useLayoutSidebar } from './useLayoutSidebar'
import type { useThemeMode } from './useThemeMode'
type LayoutSearchState = ReturnType<typeof useLayoutSearch>
type LayoutProfileState = ReturnType<typeof useLayoutProfiles>
type LayoutSidebarState = ReturnType<typeof useLayoutSidebar>
type LayoutThemeState = ReturnType<typeof useThemeMode>
type LayoutPermissionState = {
can: (key: string) => boolean
isAdmin: boolean
}
type LayoutSidebarProps = {
children: React.ReactNode
@@ -36,17 +19,6 @@ type LayoutMobileSidebarProps = {
onClose: () => void
}
type LayoutHeaderProps = {
search: LayoutSearchState
permissions: LayoutPermissionState
theme: LayoutThemeState
onOpenMobileDrawer: () => void
user: User | null | undefined
activeProfileId: string | null
profile: LayoutProfileState
onLogout: () => void
}
type LayoutSidebarsProps = Omit<
LayoutSidebarContentProps,
'isSidebarOpen' | 'isMobileDrawerOpen' | 'openGroups' | 'isRouteIn' | 'onToggleGroup' | 'onToggleSidebar' | 'onCloseMobileDrawer'
@@ -58,6 +30,8 @@ type LayoutWorkspaceProps = {
routeKey: string
}
export { LayoutHeader } from './LayoutHeaderSections'
export function LayoutDesktopSidebar({ children, isSidebarOpen }: LayoutSidebarProps) {
return (
<aside
@@ -134,38 +108,6 @@ export function LayoutSidebars({
)
}
export function LayoutHeader({
search,
permissions,
theme,
onOpenMobileDrawer,
user,
activeProfileId,
profile,
onLogout,
}: LayoutHeaderProps) {
return (
<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">
<LayoutHeaderSearch search={search} onOpenMobileDrawer={onOpenMobileDrawer} />
<LayoutHeaderActions
permissions={permissions}
themeMode={theme.mode}
onThemeChange={theme.setMode}
user={user}
isProfileOpen={profile.isProfileOpen}
profiles={profile.profiles}
activeProfileId={activeProfileId}
activeProfile={profile.activeProfile}
onToggleProfile={() => profile.setIsProfileOpen((open) => !open)}
onCloseProfile={() => profile.setIsProfileOpen(false)}
onUseDefaultProfile={profile.useDefaultProfile}
onSwitchProfile={profile.switchProfile}
onLogout={onLogout}
/>
</header>
)
}
export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) {
return (
<main className="flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10">
@@ -193,145 +135,3 @@ export function LayoutFrameFooter() {
}
export { LayoutSidebarContent }
function LayoutHeaderSearch({
search,
onOpenMobileDrawer,
}: {
search: LayoutSearchState
onOpenMobileDrawer: () => void
}) {
return (
<div className="flex items-center gap-3 flex-1 max-w-lg md:gap-4">
<button
onClick={onOpenMobileDrawer}
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>
<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>
)
}
type LayoutHeaderActionsProps = {
permissions: LayoutPermissionState
themeMode: ThemeMode
onThemeChange: (mode: ThemeMode) => void
user: User | null | undefined
isProfileOpen: boolean
profiles: PlayProfile[]
activeProfileId: string | null
activeProfile: PlayProfile | null
onToggleProfile: () => void
onCloseProfile: () => void
onUseDefaultProfile: () => void
onSwitchProfile: (profile: PlayProfile) => void
onLogout: () => void
}
function LayoutHeaderActions({
permissions,
themeMode,
onThemeChange,
user,
isProfileOpen,
profiles,
activeProfileId,
activeProfile,
onToggleProfile,
onCloseProfile,
onUseDefaultProfile,
onSwitchProfile,
onLogout,
}: LayoutHeaderActionsProps) {
return (
<div className="flex shrink-0 items-center gap-2 sm:gap-3 md:gap-4">
<LayoutQuickActions permissions={permissions} />
<LayoutThemeToggle mode={themeMode} onChange={onThemeChange} />
<span className="hidden h-6 w-px bg-[var(--app-border)] sm:block" />
<LayoutProfileMenu
user={user}
isProfileOpen={isProfileOpen}
profiles={profiles}
activeProfileId={activeProfileId}
activeProfile={activeProfile}
onToggleProfile={onToggleProfile}
onCloseProfile={onCloseProfile}
onUseDefaultProfile={onUseDefaultProfile}
onSwitchProfile={onSwitchProfile}
onLogout={onLogout}
/>
</div>
)
}
function LayoutQuickActions({ permissions }: { permissions: LayoutPermissionState }) {
return (
<>
<Link
to="/search"
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>
{permissions.can('can_view_discover') && (
<Link
to="/discover"
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>
</Link>
)}
{permissions.isAdmin && (
<Link
to="/notify-channels"
title="通知配置"
aria-label="打开通知配置"
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>
)}
</>
)
}
function LayoutProfileMenu({
user,
isProfileOpen,
profiles,
activeProfileId,
activeProfile,
onToggleProfile,
onCloseProfile,
onUseDefaultProfile,
onSwitchProfile,
onLogout,
}: Omit<LayoutHeaderActionsProps, 'permissions' | 'themeMode' | 'onThemeChange'>) {
return (
<LayoutUserMenu
user={user}
isOpen={isProfileOpen}
profiles={profiles}
activeProfileId={activeProfileId}
activeProfile={activeProfile}
onToggle={onToggleProfile}
onClose={onCloseProfile}
onUseDefaultProfile={onUseDefaultProfile}
onSwitchProfile={onSwitchProfile}
onLogout={onLogout}
/>
)
}
+3 -21
View File
@@ -1,6 +1,5 @@
import type { LucideIcon } from 'lucide-react'
import {
Activity,
Cast,
Clock,
CloudDownload,
@@ -13,15 +12,12 @@ import {
KeySquare,
Library,
ListMusic,
MessageSquareText,
Rss,
Search,
Settings,
Sliders,
Sparkles,
Trash2,
User,
UserCog,
} from 'lucide-react'
export type LayoutNavGroupID = 'media' | 'personal' | 'downloads' | 'tools' | 'system'
@@ -69,20 +65,17 @@ export const LAYOUT_NAV_GROUPS: LayoutNavGroup[] = [
{ 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'],
activePaths: ['/downloads', '/download-clients', '/subscriptions', '/site-search', '/sites'],
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 },
{ to: '/sites', label: '站点管理', icon: Globe, permission: 'can_manage_sites' },
],
},
{
@@ -93,14 +86,6 @@ export const LAYOUT_NAV_GROUPS: LayoutNavGroup[] = [
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 },
],
},
{
@@ -111,11 +96,8 @@ export const LAYOUT_NAV_GROUPS: LayoutNavGroup[] = [
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 },
{ to: '/license', label: '授权许可', icon: KeySquare },
],
},
]
+36 -2
View File
@@ -98,22 +98,56 @@ body {
: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\/60,
:root[data-theme='dark'] .bg-gray-50\/70,
:root[data-theme='dark'] .bg-gray-50\/80,
:root[data-theme='dark'] .bg-gray-50\/90,
:root[data-theme='dark'] .bg-white\/50,
:root[data-theme='dark'] .bg-white\/60,
:root[data-theme='dark'] .bg-white\/70,
:root[data-theme='dark'] .bg-white\/75,
:root[data-theme='dark'] .bg-white\/80,
:root[data-theme='dark'] .bg-white\/82,
:root[data-theme='dark'] .bg-white\/85 {
:root[data-theme='dark'] .bg-white\/85,
:root[data-theme='dark'] .bg-white\/90,
:root[data-theme='dark'] .bg-white\/95 {
background-color: var(--app-panel) !important;
}
:root[data-theme='dark'] .bg-gray-100,
:root[data-theme='dark'] .bg-gray-100\/50 {
:root[data-theme='dark'] .bg-gray-100\/50,
:root[data-theme='dark'] .bg-gray-100\/60,
:root[data-theme='dark'] .bg-gray-100\/70,
:root[data-theme='dark'] .bg-gray-100\/80 {
background-color: var(--app-hover) !important;
}
:root[data-theme='dark'] .bg-brand-50,
:root[data-theme='dark'] .bg-primary-400\/10,
:root[data-theme='dark'] .bg-primary-400\/15,
:root[data-theme='dark'] .bg-primary-400\/20,
:root[data-theme='dark'] .bg-sand-50,
:root[data-theme='dark'] .bg-amber-50,
:root[data-theme='dark'] .bg-yellow-50,
:root[data-theme='dark'] .bg-emerald-50,
:root[data-theme='dark'] .bg-green-50,
:root[data-theme='dark'] .bg-red-50 {
background-color: var(--app-control-bg) !important;
}
:root[data-theme='dark'] .bg-\[\#f9fafb\] {
background-color: var(--app-bg) !important;
}
:root[data-theme='dark'] .hover\:bg-gray-50:hover,
:root[data-theme='dark'] .hover\:bg-gray-50\/50:hover,
:root[data-theme='dark'] .hover\:bg-gray-100:hover,
:root[data-theme='dark'] .hover\:bg-brand-50:hover,
:root[data-theme='dark'] .hover\:bg-primary-400\/10:hover,
:root[data-theme='dark'] .hover\:bg-red-100:hover {
background-color: var(--app-hover) !important;
}
:root[data-theme='dark'] .text-gray-900,
:root[data-theme='dark'] .text-gray-950,
:root[data-theme='dark'] .text-\[\#111827\],
+1 -4
View File
@@ -3,7 +3,7 @@ import { AdminLibraryTable } from './AdminLibraryTable'
import { useAdminLibraryPanel } from './useAdminLibraryPanel'
export function AdminLibraryPanel() {
const { libs, createForm, newRoots, editableRoots, rootActions, libraryActions } = useAdminLibraryPanel()
const { libs, createForm, editableRoots, rootActions, libraryActions } = useAdminLibraryPanel()
return (
<div className="space-y-6">
@@ -20,11 +20,8 @@ export function AdminLibraryPanel() {
/>
<AdminLibraryTable
libs={libs}
newRootDraft={newRoots.newRootDraft}
editableRootDraft={editableRoots.editableRootDraft}
onNewRootChange={newRoots.setNewRootDraft}
onEditableRootChange={editableRoots.setEditableRootDraft}
onAddRoot={newRoots.addLibraryRoot}
onSaveRoot={rootActions.saveLibraryRoot}
onScanRoot={rootActions.scanLibraryRoot}
onToggleRoot={rootActions.toggleLibraryRoot}
+4 -4
View File
@@ -58,11 +58,11 @@ export function AdminLibraryCreateForm({
</button>
</div>
<p className="md:col-span-4 -mt-2 text-xs text-sand-500">
Docker 部署时请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧;如果误填 NAS
宿主机路径,系统会尝试按 compose 挂载自动转换。
名称和类型与现有媒体库一致时,会自动把这里填写的路径追加到该媒体库;Docker
部署请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧。
</p>
<button type="submit" className="neon-button md:col-span-4">
新建媒体库
新建 / 追加路径
</button>
</form>
)
@@ -78,7 +78,7 @@ type CreateRootRowProps = {
function CreateRootRow({ root, index, canRemove, onChange, onRemove }: CreateRootRowProps) {
return (
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]">
<div className="grid gap-2 rounded-xl border border-gray-200/80 bg-white/60 p-2 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]">
<input
className="input-base"
placeholder="路径名称"
+131 -92
View File
@@ -1,16 +1,14 @@
import { Plus, RefreshCw, Save, Trash2 } from 'lucide-react'
import type { MouseEvent, ReactNode } from 'react'
import { MoreVertical, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
import type { Library, LibraryRoot } from '../types'
import type { RootDraft } from './adminLibraryPanelModel'
import { fallbackLibraryRoot } from './adminLibraryPanelModel'
import { displayLibraryRootName, displayLibraryRootPath, fallbackLibraryRoot } from './adminLibraryPanelModel'
type LibraryTableProps = {
libs: Library[]
newRootDraft: (libraryID: string) => RootDraft
editableRootDraft: (libraryID: string, root: LibraryRoot) => RootDraft
onNewRootChange: (libraryID: string, patch: Partial<RootDraft>) => void
onEditableRootChange: (libraryID: string, root: LibraryRoot, patch: Partial<RootDraft>) => void
onAddRoot: (libraryID: string) => void
onSaveRoot: (libraryID: string, root: LibraryRoot) => void
onScanRoot: (libraryID: string, root: LibraryRoot) => void
onToggleRoot: (libraryID: string, root: LibraryRoot) => void
@@ -21,14 +19,14 @@ type LibraryTableProps = {
export function AdminLibraryTable({ libs, ...actions }: LibraryTableProps) {
return (
<div className="glass-panel">
<table className="w-full text-left text-sm">
<div className="glass-panel overflow-x-auto !p-3">
<table className="w-full min-w-[900px] text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">名称</th>
<th className="w-28 py-2">名称</th>
<th>路径</th>
<th>类型</th>
<th className="text-right">操作</th>
<th className="w-20">类型</th>
<th className="w-12 text-right">操作</th>
</tr>
</thead>
<tbody>
@@ -48,12 +46,12 @@ type LibraryTableRowProps = Omit<LibraryTableProps, 'libs'> & {
function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
return (
<tr className="border-t border-gray-200">
<td className="py-2 text-ink-600">{library.name}</td>
<td className="text-ink-100">
<td className="py-2 pr-3 font-medium text-ink-600">{library.name}</td>
<td className="py-1.5 text-ink-100">
<LibraryRootsCell library={library} {...actions} />
</td>
<td className="text-ink-100">{library.type}</td>
<td className="space-x-2 py-2 text-right">
<td className="px-3 text-ink-100">{library.type}</td>
<td className="py-2 text-right">
<LibraryActionsCell library={library} {...actions} />
</td>
</tr>
@@ -63,11 +61,10 @@ function LibraryTableRow({ library, ...actions }: LibraryTableRowProps) {
function LibraryRootsCell({ library, ...actions }: LibraryTableRowProps) {
const roots = library.roots?.length ? library.roots : [fallbackLibraryRoot(library)]
return (
<div className="space-y-2">
<div className="min-w-[520px] space-y-1">
{roots.map((root) => (
<ExistingRootEditor key={root.id || root.path} library={library} root={root} {...actions} />
))}
<AddRootRow library={library} {...actions} />
</div>
)
}
@@ -80,30 +77,36 @@ type RootEditorProps = Omit<LibraryTableRowProps, 'library'> & {
function ExistingRootEditor({ library, root, ...actions }: RootEditorProps) {
const draft = actions.editableRootDraft(library.id, root)
return (
<div className="rounded border border-gray-200/70 p-2">
<div className="grid gap-2 xl:grid-cols-[minmax(120px,0.8fr)_minmax(220px,2fr)_auto]">
{root.id ? (
<EditableRootFields library={library} root={root} draft={draft} {...actions} />
) : (
<span className="min-w-0 break-all xl:col-span-2">{root.name ? `${root.name}:${root.path}` : root.path}</span>
)}
<RootActionButtons library={library} root={root} draft={draft} {...actions} />
</div>
<div className="grid items-center gap-1.5 rounded-lg border border-gray-200/80 bg-gray-50/60 p-1.5 xl:grid-cols-[minmax(92px,0.65fr)_minmax(240px,2fr)_auto_auto]">
{root.id ? <EditableRootFields library={library} root={root} draft={draft} {...actions} /> : <ReadonlyRootFields root={root} />}
<RootStatus enabled={draft.enabled ?? root.enabled} />
<RootActionButtons library={library} root={root} draft={draft} {...actions} />
</div>
)
}
function ReadonlyRootFields({ root }: { root: LibraryRoot }) {
return (
<>
<span className="truncate rounded-md bg-white/80 px-2.5 py-1.5 text-xs text-ink-600">{displayLibraryRootName(root.name, root.path)}</span>
<span className="min-w-0 truncate rounded-md bg-white/80 px-2.5 py-1.5 text-xs text-ink-100" title={displayLibraryRootPath(root.path)}>
{displayLibraryRootPath(root.path)}
</span>
</>
)
}
function EditableRootFields({ library, root, draft, onEditableRootChange }: RootEditorProps & { draft: RootDraft }) {
return (
<>
<input
className="input-base"
className="h-9 w-full rounded-lg border border-gray-200 bg-white/80 px-3 text-xs text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100/60"
placeholder="路径名称"
value={draft.name ?? ''}
onChange={(e) => onEditableRootChange(library.id, root, { name: e.target.value })}
/>
<input
className="input-base"
className="h-9 w-full rounded-lg border border-gray-200 bg-white/80 px-3 text-xs text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100/60"
placeholder="真实路径"
value={draft.path}
onChange={(e) => onEditableRootChange(library.id, root, { path: e.target.value })}
@@ -112,81 +115,117 @@ function EditableRootFields({ library, root, draft, onEditableRootChange }: Root
)
}
function RootActionButtons({ library, root, draft, ...actions }: RootEditorProps & { draft: RootDraft }) {
function RootStatus({ enabled }: { enabled: boolean }) {
return (
<div className="flex flex-wrap items-center gap-2">
{root.id && (
<button
className="rounded border border-primary-400/40 p-1 text-brand-500 hover:bg-primary-400/10"
title="保存路径"
onClick={() => actions.onSaveRoot(library.id, root)}
>
<Save size={14} />
</button>
)}
<button
className="rounded border border-primary-400/40 p-1 text-brand-500 hover:bg-primary-400/10"
title="扫描路径"
onClick={() => actions.onScanRoot(library.id, root)}
>
<RefreshCw size={14} />
</button>
{root.id && (
<button className="rounded border border-gray-300 px-2 py-1 text-xs" onClick={() => actions.onToggleRoot(library.id, root)}>
{draft.enabled ? '启用' : '禁用'}
</button>
)}
{root.id && (
<button
className="rounded border border-red-400/40 p-1 text-red-400 hover:bg-red-400/10"
title="删除路径"
onClick={() => actions.onRemoveRoot(library, root)}
>
<Trash2 size={14} />
</button>
)}
</div>
<span
className={`whitespace-nowrap rounded-md border px-2 py-1 text-xs ${
enabled ? 'border-emerald-300/60 text-emerald-600' : 'border-gray-300 text-ink-50'
}`}
>
{enabled ? '启用' : '禁用'}
</span>
)
}
function AddRootRow({ library, ...actions }: LibraryTableRowProps) {
const draft = actions.newRootDraft(library.id)
function RootActionButtons({ library, root, draft, ...actions }: RootEditorProps & { draft: RootDraft }) {
const enabled = draft.enabled ?? root.enabled
return (
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]">
<input
className="input-base"
placeholder="路径名称"
value={draft.name ?? ''}
onChange={(e) => actions.onNewRootChange(library.id, { name: e.target.value })}
/>
<input
className="input-base"
placeholder="新增路径"
value={draft.path}
onChange={(e) => actions.onNewRootChange(library.id, { path: e.target.value })}
/>
<button className="rounded-lg border px-3 py-2 text-sm" onClick={() => actions.onAddRoot(library.id)}>
<Plus size={14} />
</button>
</div>
<ActionMenu label="路径操作">
{root.id && (
<MenuButton
icon={<Save size={14} />}
label="保存"
onClick={() => actions.onSaveRoot(library.id, root)}
>
保存
</MenuButton>
)}
<MenuButton
icon={<RefreshCw size={14} />}
label="扫描"
onClick={() => actions.onScanRoot(library.id, root)}
>
扫描
</MenuButton>
{root.id && (
<MenuButton
icon={enabled ? <PowerOff size={14} /> : <Power size={14} />}
label={enabled ? '禁用' : '启用'}
onClick={() => actions.onToggleRoot(library.id, root)}
>
{enabled ? '禁用' : '启用'}
</MenuButton>
)}
{root.id && (
<MenuButton
danger
icon={<Trash2 size={14} />}
label="删除"
onClick={() => actions.onRemoveRoot(library, root)}
>
删除
</MenuButton>
)}
</ActionMenu>
)
}
function LibraryActionsCell({ library, onScanLibrary, onRemoveLibrary }: LibraryTableRowProps) {
return (
<>
<button
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
onClick={() => onScanLibrary(library)}
>
<ActionMenu label="媒体库操作">
<MenuButton icon={<RefreshCw size={14} />} label="扫描" onClick={() => onScanLibrary(library)}>
扫描
</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={() => onRemoveLibrary(library)}
>
<Trash2 size={12} />
</button>
</>
</MenuButton>
<MenuButton danger icon={<Trash2 size={14} />} label="删除" onClick={() => onRemoveLibrary(library)}>
删除
</MenuButton>
</ActionMenu>
)
}
function ActionMenu({ label, children }: { label: string; children: ReactNode }) {
return (
<details className="group relative inline-flex justify-end">
<summary
className="flex h-8 w-8 cursor-pointer list-none items-center justify-center rounded-lg border border-gray-200 bg-white text-ink-50 transition hover:border-primary-400/50 hover:text-brand-500 [&::-webkit-details-marker]:hidden"
title={label}
>
<MoreVertical size={16} />
</summary>
<div className="absolute right-0 top-9 z-30 min-w-28 rounded-lg border border-gray-200 bg-white p-1 shadow-lg">
{children}
</div>
</details>
)
}
function MenuButton({
icon,
label,
danger,
onClick,
children,
}: {
icon: ReactNode
label: string
danger?: boolean
onClick: () => void
children: ReactNode
}) {
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
event.currentTarget.closest('details')?.removeAttribute('open')
onClick()
}
return (
<button
className={`flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition ${
danger ? 'text-red-500 hover:bg-red-50' : 'text-ink-100 hover:bg-gray-50 hover:text-brand-500'
}`}
title={label}
onClick={handleClick}
>
{icon}
<span>{children}</span>
</button>
)
}
+2
View File
@@ -42,6 +42,8 @@ export function AdminPage() {
{ to: '/download-clients', title: '下载器管理', description: '配置 qBittorrent 等下载器连接', badge: '下载', group: '站点与下载' },
{ to: '/files', title: '手动整理', description: '从下载目录选择文件夹并整理入库', group: '文件与入库' },
{ to: '/storage', title: '存储与文件', description: '查看占用、清理重复项和管理文件', group: '文件与入库' },
{ to: '/notify-channels', title: '通知渠道', description: '配置 Bot、Webhook、邮件等通知出口', group: '系统运维' },
{ to: '/assistant', title: 'AI 会话', description: '查看和接管后台 AI 操作记录', group: '系统运维' },
]}
/>
<div className="flex flex-wrap gap-2 border-b border-gray-200">
+2
View File
@@ -16,6 +16,7 @@ const TYPE_ICONS: Record<string, ReactNode> = {
anime: <PlayCircle size={18} />,
variety: <Tv size={18} />,
music: <Music size={18} />,
adult: <Film size={18} />,
}
const TYPE_LABELS: Record<string, string> = {
@@ -24,6 +25,7 @@ const TYPE_LABELS: Record<string, string> = {
anime: '动漫',
variety: '综艺',
music: '音乐',
adult: '成人',
}
export function LibrariesHeader({
+45
View File
@@ -0,0 +1,45 @@
import { Loader2 } from 'lucide-react'
import {
RecognitionWordsActions,
RecognitionWordsEditors,
RecognitionWordsHeader,
RecognitionWordsTester,
} from './RecognitionWordsPanelSections'
import { useRecognitionWordsPanel } from './useRecognitionWordsPanel'
export function RecognitionWordsPanel() {
const state = useRecognitionWordsPanel()
if (state.loading) {
return (
<div className="flex justify-center py-12 text-ink-50">
<Loader2 className="animate-spin" />
</div>
)
}
return (
<div className="glass-panel space-y-4">
<RecognitionWordsHeader config={state.config} onEnabledChange={state.updateEnabled} />
<RecognitionWordsEditors
config={state.config}
onLocalTextChange={state.updateLocalText}
onSharedURLsChange={state.updateSharedURLs}
/>
<RecognitionWordsTester
input={state.testInput}
result={state.testResult}
testing={state.testing}
onInputChange={state.setTestInput}
onTest={state.test}
/>
<RecognitionWordsActions
saving={state.saving}
syncing={state.syncing}
onSave={state.save}
onSync={state.sync}
/>
</div>
)
}
@@ -0,0 +1,149 @@
import { Loader2, Play, RefreshCw, Save } from 'lucide-react'
import type { RecognitionWordsConfig, RecognitionWordsTestResult } from '../api/recognitionWords'
export function RecognitionWordsHeader({
config,
onEnabledChange,
}: {
config: RecognitionWordsConfig
onEnabledChange: (enabled: boolean) => void
}) {
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-ink-600">自定义识别词</div>
<div className="text-xs text-sand-500">
当前已加载 {config.rule_count} 条规则{config.synced_at ? ` · 上次同步 ${new Date(config.synced_at).toLocaleString()}` : ''}
</div>
</div>
<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={config.enabled}
onChange={(event) => onEnabledChange(event.target.checked)}
/>
启用
</label>
</div>
)
}
export function RecognitionWordsEditors({
config,
onLocalTextChange,
onSharedURLsChange,
}: {
config: RecognitionWordsConfig
onLocalTextChange: (value: string) => void
onSharedURLsChange: (value: string) => void
}) {
return (
<div className="grid gap-4 lg:grid-cols-2">
<label className="space-y-2">
<div className="text-sm font-medium text-ink-100">共享识别词源</div>
<textarea
rows={7}
className="input-base font-mono text-xs"
value={config.shared_urls.join('\n')}
onChange={(event) => onSharedURLsChange(event.target.value)}
/>
</label>
<label className="space-y-2">
<div className="text-sm font-medium text-ink-100">本地识别词</div>
<textarea
rows={7}
className="input-base font-mono text-xs"
placeholder={'屏蔽词\n错误标题 => 正确标题\nS01E <> . >> EP-10'}
value={config.local_text}
onChange={(event) => onLocalTextChange(event.target.value)}
/>
</label>
</div>
)
}
export function RecognitionWordsTester({
input,
result,
testing,
onInputChange,
onTest,
}: {
input: string
result: RecognitionWordsTestResult | null
testing: boolean
onInputChange: (value: string) => void
onTest: () => void
}) {
return (
<div className="rounded-lg border border-white/10 bg-black/10 p-3">
<div className="grid gap-3 md:grid-cols-[1fr_auto]">
<input
className="input-base"
placeholder="输入文件名或种子标题测试识别效果"
value={input}
onChange={(event) => onInputChange(event.target.value)}
/>
<button type="button" className="neon-button" onClick={onTest} disabled={testing || !input.trim()}>
{testing ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
测试
</button>
</div>
{result && <RecognitionWordsTestResultGrid result={result} />}
</div>
)
}
function RecognitionWordsTestResultGrid({ result }: { result: RecognitionWordsTestResult }) {
return (
<div className="mt-3 grid gap-2 text-xs text-ink-100 md:grid-cols-3">
<RecognitionWordsResultCard label="识别后" value={result.output || '-'} mono />
<RecognitionWordsResultCard label="标题" value={result.title || '-'} />
<RecognitionWordsResultCard label="年份" value={result.year || '-'} />
</div>
)
}
function RecognitionWordsResultCard({
label,
mono,
value,
}: {
label: string
mono?: boolean
value: string | number
}) {
return (
<div className="rounded-md bg-white/5 p-2">
<div className="text-sand-500">{label}</div>
<div className={mono ? 'break-all font-mono' : ''}>{value}</div>
</div>
)
}
export function RecognitionWordsActions({
saving,
syncing,
onSave,
onSync,
}: {
saving: boolean
syncing: boolean
onSave: () => void
onSync: () => void
}) {
return (
<div className="flex flex-wrap justify-end gap-2">
<button type="button" className="btn-secondary" onClick={onSync} disabled={syncing}>
{syncing ? <Loader2 size={16} className="animate-spin" /> : <RefreshCw size={16} />}
同步共享词
</button>
<button type="button" className="neon-button" onClick={onSave} disabled={saving}>
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
保存
</button>
</div>
)
}
+29 -25
View File
@@ -8,6 +8,7 @@ import type { Library, Setting } from '../types'
import { SettingRow } from './SettingsRow'
import { ALL_KEYS, GROUPS } from './settingsGroups'
import { SystemUpdatePanel } from './SystemUpdatePanel'
import { RecognitionWordsPanel } from './RecognitionWordsPanel'
export function SettingsPage() {
const [activeGroup, setActiveGroup] = useState(GROUPS[0].key)
@@ -107,31 +108,34 @@ export function SettingsPage() {
{!loading && (
<div className="space-y-4">
{group.key === 'system-update' && <SystemUpdatePanel />}
<form onSubmit={onSave} className="glass-panel space-y-4">
{group.description && <p className="text-xs text-sand-500">{group.description}</p>}
{group.items.map((it) => (
<SettingRow
key={it.key}
def={it}
value={values[it.key] ?? it.defaultValue ?? ''}
onChange={(v) => onChange(it.key, v)}
libraries={libraries}
/>
))}
<div className="flex items-center justify-between pt-2">
<span className="text-xs text-sand-500">
{dirty.size > 0 ? `有 ${dirty.size} 项未保存` : '所有更改已保存'}
</span>
<button
type="submit"
disabled={saving || dirty.size === 0}
className="neon-button disabled:opacity-50"
>
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
保存
</button>
</div>
</form>
{group.key === 'recognition-words' && <RecognitionWordsPanel />}
{group.items.length > 0 && (
<form onSubmit={onSave} className="glass-panel space-y-4">
{group.description && <p className="text-xs text-sand-500">{group.description}</p>}
{group.items.map((it) => (
<SettingRow
key={it.key}
def={it}
value={values[it.key] ?? it.defaultValue ?? ''}
onChange={(v) => onChange(it.key, v)}
libraries={libraries}
/>
))}
<div className="flex items-center justify-between pt-2">
<span className="text-xs text-sand-500">
{dirty.size > 0 ? `有 ${dirty.size} 项未保存` : '所有更改已保存'}
</span>
<button
type="submit"
disabled={saving || dirty.size === 0}
className="neon-button disabled:opacity-50"
>
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
保存
</button>
</div>
</form>
)}
</div>
)}
</div>
+6 -1
View File
@@ -124,13 +124,18 @@ export function SiteSearchPage() {
href={item.torrent_url}
target="_blank"
rel="noopener noreferrer"
className="text-white transition hover:text-brand-500"
className="font-semibold text-ink-600 transition hover:text-brand-500"
title={item.title}
>
{item.title.length > 80
? item.title.substring(0, 80) + '…'
: item.title}
</a>
{item.subtitle && (
<div className="mt-1 line-clamp-2 text-xs text-ink-50" title={item.subtitle}>
{item.subtitle}
</div>
)}
</td>
<td className="whitespace-nowrap text-ink-100">
{fmtBytes(item.size)}
+2 -1
View File
@@ -67,10 +67,11 @@ export function StoragePage() {
<summary className="cursor-pointer list-none font-display text-lg font-semibold text-ink-600">
低频维护入口 <span className="text-xs font-normal text-sand-500">(点击展开)</span>
</summary>
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-5">
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-6">
<MaintenanceLink to="/strm" title="STRM 生成" />
<MaintenanceLink to="/scheduler" title="定时任务" />
<MaintenanceLink to="/tasks" title="任务队列" />
<MaintenanceLink to="/stats" title="运行状态" />
<MaintenanceLink to="/notify-channels" title="通知渠道" />
<MaintenanceLink to="/assistant" title="AI 对话台" />
</div>
+42 -10
View File
@@ -1,4 +1,4 @@
import { Archive, Film, Play, RotateCcw } from 'lucide-react'
import { AlertTriangle, Archive, Film, Play, RefreshCw, RotateCcw } from 'lucide-react'
import { imageURL } from '../api/client'
import type { Subscription } from '../types'
@@ -6,20 +6,51 @@ import { subscriptionProgressLabel } from './subscriptionPageModel'
interface SubscriptionHistorySectionProps {
subscriptions: Subscription[]
loading?: boolean
error?: string
onRefresh?: () => Promise<void>
onRestore: (subscription: Subscription, runAfterRestore?: boolean) => void
}
export function SubscriptionHistorySection({ subscriptions, onRestore }: SubscriptionHistorySectionProps) {
if (subscriptions.length === 0) return null
export function SubscriptionHistorySection({
subscriptions,
loading = false,
error = '',
onRefresh,
onRestore,
}: SubscriptionHistorySectionProps) {
return (
<section className="space-y-3">
<div className="flex items-center gap-2">
<Archive size={18} className="text-brand-500" />
<h2 className="font-display text-xl font-semibold text-ink-600">订阅历史</h2>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-ink-50">{subscriptions.length} 条</span>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Archive size={18} className="text-brand-500" />
<h2 className="font-display text-xl font-semibold text-ink-600">订阅历史</h2>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-ink-50">{subscriptions.length} 条</span>
</div>
{onRefresh && (
<button
type="button"
className="inline-flex items-center gap-2 rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => onRefresh().catch(() => undefined)}
disabled={loading}
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
刷新历史
</button>
)}
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{loading && <p className="text-sm text-sand-500">历史加载中…</p>}
{!loading && error && (
<div className="flex items-center gap-2 rounded-2xl border border-red-300/70 bg-red-50 px-4 py-3 text-sm text-red-700">
<AlertTriangle size={16} className="shrink-0" />
<span className="break-words">订阅历史加载失败:{error}</span>
</div>
)}
{!loading && !error && subscriptions.length === 0 && <p className="text-sm text-ink-50">暂无历史订阅。</p>}
{!error && subscriptions.length > 0 && (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{subscriptions.map((subscription) => (
<article key={subscription.id} className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex gap-3">
@@ -61,7 +92,8 @@ export function SubscriptionHistorySection({ subscriptions, onRestore }: Subscri
</div>
</article>
))}
</div>
</div>
)}
</section>
)
}
+93 -17
View File
@@ -1,4 +1,5 @@
import { FormEvent, useEffect, useState } from 'react'
import { AlertTriangle, RefreshCw } from 'lucide-react'
import toast from 'react-hot-toast'
import { subscriptionsAPI } from '../api/subscriptions'
@@ -15,22 +16,40 @@ export function SubscriptionsPage() {
const [formValues, setFormValues] = useState<SubscriptionFormValues>(defaultSubscriptionFormValues)
const [editingId, setEditingId] = useState('')
const [loading, setLoading] = useState(true)
const [historyLoading, setHistoryLoading] = useState(true)
const [listError, setListError] = useState('')
const [historyError, setHistoryError] = useState('')
const refresh = async () => {
setLoading(true)
void subscriptionsAPI
setHistoryLoading(true)
setListError('')
setHistoryError('')
const historyPromise = subscriptionsAPI
.history()
.then(setHistoryItems)
.catch(() => toast.error('订阅历史加载失败'))
.then((history) => {
setHistoryItems(history)
})
.catch((err: unknown) => {
const msg = apiErrorMessage(err, '订阅历史加载失败')
setHistoryError(msg)
toast.error(msg)
})
.finally(() => setHistoryLoading(false))
try {
const active = await subscriptionsAPI.list()
setItems(active)
} catch {
toast.error('订阅列表加载失败')
} catch (err: unknown) {
const msg = apiErrorMessage(err, '订阅列表加载失败')
setListError(msg)
setItems([])
toast.error(msg)
} finally {
setLoading(false)
}
await historyPromise
}
useEffect(() => {
@@ -69,8 +88,7 @@ export function SubscriptionsPage() {
resetForm()
await refresh()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '创建失败'
const msg = apiErrorMessage(err, '创建失败')
toast.error(msg)
}
}
@@ -117,21 +135,30 @@ export function SubscriptionsPage() {
}
await refresh()
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '恢复订阅失败'
const msg = apiErrorMessage(err, '恢复订阅失败')
toast.error(msg)
}
}
const runSubscriptionNow = async (subscription: Subscription) => {
const result = await subscriptionsAPI.runNow(subscription.id)
toast.success(`已加入 ${result.queued} 项`)
try {
const result = await subscriptionsAPI.runNow(subscription.id)
toast.success(`已加入 ${result.queued} 项`)
await refresh()
} catch (err: unknown) {
toast.error(apiErrorMessage(err, '运行订阅失败'))
}
}
const removeSubscription = async (subscription: Subscription) => {
if (!(await confirmAction({ title: '删除订阅', message: `删除订阅「${subscription.name}」?`, confirmText: '删除' }))) return
await subscriptionsAPI.remove(subscription.id)
toast.success('已删除')
await refresh()
try {
await subscriptionsAPI.remove(subscription.id)
toast.success('已删除')
await refresh()
} catch (err: unknown) {
toast.error(apiErrorMessage(err, '删除失败'))
}
}
return (
@@ -149,10 +176,24 @@ export function SubscriptionsPage() {
onChange={updateFormValue}
/>
{loading && <p className="text-sand-500">加载中…</p>}
{!loading && items.length === 0 && <p className="text-ink-50">暂无订阅。</p>}
<div className="flex items-center justify-between gap-3">
<h2 className="font-display text-xl font-semibold text-ink-600">正在订阅</h2>
<button
type="button"
className="inline-flex items-center gap-2 rounded-xl border border-primary-400/40 bg-white px-3 py-2 text-xs font-semibold text-brand-500 hover:bg-primary-400/10 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => refresh().catch(() => undefined)}
disabled={loading || historyLoading}
>
<RefreshCw size={14} className={loading || historyLoading ? 'animate-spin' : ''} />
刷新
</button>
</div>
{items.length > 0 && (
{loading && <p className="text-sand-500">加载中…</p>}
{!loading && listError && <SubscriptionLoadError message={listError} onRetry={refresh} />}
{!loading && !listError && items.length === 0 && <p className="text-ink-50">暂无订阅。</p>}
{!listError && items.length > 0 && (
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{items.map((subscription) => (
<SubscriptionCard
@@ -166,7 +207,42 @@ export function SubscriptionsPage() {
</div>
)}
<SubscriptionHistorySection subscriptions={historyItems} onRestore={restoreHistorySubscription} />
<SubscriptionHistorySection
subscriptions={historyItems}
loading={historyLoading}
error={historyError}
onRefresh={refresh}
onRestore={restoreHistorySubscription}
/>
</div>
)
}
function SubscriptionLoadError({ message, onRetry }: { message: string; onRetry: () => Promise<void> }) {
return (
<div className="rounded-2xl border border-red-300/70 bg-red-50 px-4 py-3 text-sm text-red-700">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<AlertTriangle size={16} className="shrink-0" />
<span className="break-words">订阅列表加载失败:{message}</span>
</div>
<button
type="button"
className="rounded-xl border border-red-300 bg-white px-3 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-100"
onClick={() => onRetry().catch(() => undefined)}
>
重试
</button>
</div>
</div>
)
}
function apiErrorMessage(err: unknown, fallback: string): string {
const data = (err as { response?: { data?: { error?: string; message?: string }; status?: number } })?.response
if (data?.data?.error) return data.data.error
if (data?.data?.message) return data.data.message
if (data?.status) return `${fallback} (${data.status})`
if ((err as { code?: string })?.code === 'ECONNABORTED') return '请求超时,请检查服务或网络'
return fallback
}
+21
View File
@@ -7,6 +7,27 @@ export const emptyRootDraft = (): RootDraft => ({ name: '', path: '', enabled: t
export const rootDraftKey = (libraryID: string, rootID: string) => `${libraryID}:${rootID}`
export function displayLibraryRootPath(path: string) {
if (!path.toLowerCase().startsWith('cloud://')) return path
return decodePercentEscapes(path)
}
export function displayLibraryRootName(name: string | undefined, path: string) {
const value = name?.trim() || '默认路径'
if (!path.toLowerCase().startsWith('cloud://') && !/%[0-9a-f]{2}/i.test(value)) return value
return decodePercentEscapes(value)
}
function decodePercentEscapes(value: string) {
return value.replace(/%[0-9a-f]{2}/gi, (token) => {
try {
return decodeURIComponent(token)
} catch {
return token
}
})
}
export function createRootPayload(roots: RootDraft[]) {
return roots
.map((root, index) => ({
@@ -0,0 +1,7 @@
import type { SettingGroup } from './settingsGroupTypes'
export const recognitionWordsSettingsGroup: SettingGroup = {
key: 'recognition-words',
label: '识别词',
items: [],
}
+2
View File
@@ -1,6 +1,7 @@
import { adultSettingsGroup } from './settingsGroupAccess'
import { cloudUploadSettingsGroup } from './settingsGroupCloud'
import { generalSettingsGroup, licenseSettingsGroup } from './settingsGroupGeneral'
import { recognitionWordsSettingsGroup } from './settingsGroupRecognitionWords'
import { subscriptionSettingsGroup } from './settingsGroupSubscriptions'
import { systemUpdateSettingsGroup } from './settingsGroupSystemUpdate'
import type { SettingGroup } from './settingsGroupTypes'
@@ -11,6 +12,7 @@ export const GROUPS: SettingGroup[] = [
generalSettingsGroup,
licenseSettingsGroup,
systemUpdateSettingsGroup,
recognitionWordsSettingsGroup,
subscriptionSettingsGroup,
cloudUploadSettingsGroup,
adultSettingsGroup,
+45 -4
View File
@@ -42,6 +42,9 @@ export function suggestedSTRMOutputDir(root: string, library: Library) {
const base = trimPath(root)
const subdir = strmLibraryOutputSubdir(library)
if (!subdir) return base
if (pathEndsWith(base, subdir) || pathEndsWith(base, pathBase(subdir))) return base
const tail = missingCategoryTail(base, subdir)
if (tail) return joinPath(base, tail)
return joinPath(base || 'data/strm', subdir)
}
@@ -77,7 +80,7 @@ function categoryPartsFromPath(parts: string[]) {
const root = canonicalRoot(part)
if (root) return [root, ...parts.slice(index + 1)]
const categoryRoot = categoryRootFor(part)
if (categoryRoot) return [categoryRoot, part]
if (categoryRoot) return [categoryRoot, canonicalCategory(part)]
}
return null
}
@@ -124,13 +127,34 @@ function canonicalRoot(part: string) {
function categoryRootFor(part: string) {
const key = part.trim().toLowerCase()
if (['动画电影', '动漫电影', '华语电影', '国产电影', '外语电影', '欧美电影', '日韩电影'].includes(key)) return '电影'
if (['国产剧', '欧美剧', '日韩剧', '日剧', '韩剧', '综艺', '真人秀', '纪录片', '纪录', '未分类'].includes(key)) return '电视剧'
if (['国漫', '国产动漫', '日番', '番剧', '日漫', '日本动漫', '日本动画', '欧美动漫', '欧美动画', '西方动画', '儿童', '少儿'].includes(key)) return '动漫'
if (['演唱会', '音乐会', '纪录片', '纪录', '动画电影', '动漫电影', '华语电影', '国产电影', '外语电影', '外国电影', '欧美电影', '日韩电影', '日本电影', '韩国电影'].includes(key)) return '电影'
if (['国产剧', '欧美剧', '日韩剧', '日剧', '韩剧', '综艺', '真人秀', '儿童', '少儿', '未分类'].includes(key)) return '电视剧'
if (['国漫', '国产动漫', '日番', '番剧', '日漫', '日本动漫', '日本动画', '韩漫', '韩国动漫', '韩国动画', '美漫', '欧美动漫', '欧美动画', '西方动画', '其他', '其他动漫', '其它动漫'].includes(key)) return '动漫'
if (key === '番号') return '成人'
return ''
}
function canonicalCategory(part: string) {
const key = part.trim().toLowerCase()
if (key === '音乐会') return '演唱会'
if (key === '纪录') return '纪录片'
if (key === '动漫电影') return '动画电影'
if (key === '国产电影') return '华语电影'
if (key === '外语电影' || key === '外国电影') return '欧美电影'
if (key === '日本电影' || key === '韩国电影') return '日韩电影'
if (key === '日剧' || key === '韩剧') return '日韩剧'
if (key === '真人秀') return '综艺'
if (key === '少儿') return '儿童'
if (key === '未分类') return '欧美剧'
if (key === '国产动漫') return '国漫'
if (['番剧', '日漫', '日本动漫', '日本动画'].includes(key)) return '日番'
if (key === '韩国动漫' || key === '韩国动画') return '韩漫'
if (['欧美动漫', '欧美动画', '西方动画'].includes(key)) return '美漫'
if (key === '其他动漫' || key === '其它动漫') return '其他'
if (key === '番号') return '成人'
return part.trim()
}
function splitPath(raw: string) {
return raw.split('/').map((part) => part.trim()).filter((part) => part && part !== '.')
}
@@ -153,6 +177,23 @@ function joinPath(root: string, ...parts: string[]) {
return [trimPath(root), ...parts.map(trimPath)].filter(Boolean).join('/')
}
function missingCategoryTail(base: string, subdir: string) {
const parts = splitPath(subdir)
if (parts.length < 2 || !pathEndsWith(base, parts[0])) return ''
return joinPath('', ...parts.slice(1))
}
function pathEndsWith(pathValue: string, suffix: string) {
const path = trimPath(pathValue).toLowerCase()
const tail = trimPath(suffix).toLowerCase()
return !!path && !!tail && (path === tail || path.endsWith(`/${tail}`))
}
function pathBase(pathValue: string) {
const parts = splitPath(pathValue)
return parts[parts.length - 1] || ''
}
function trimPath(raw: string) {
return toSlash(raw).replace(/\/+$/g, '').trim()
}
+6 -30
View File
@@ -4,17 +4,16 @@ import toast from 'react-hot-toast'
import { libraryAPI } from '../api/library'
import type { Library, LibraryRoot } from '../types'
import { confirmAction } from '../components/confirmAction'
import { apiErrorMessage, createRootPayload, emptyRootDraft, rootDraftKey, type RootDraft } from './adminLibraryPanelModel'
import { apiErrorMessage, createRootPayload, displayLibraryRootName, displayLibraryRootPath, emptyRootDraft, rootDraftKey, type RootDraft } from './adminLibraryPanelModel'
export function useAdminLibraryPanel() {
const { libs, refresh } = useAdminLibraryList()
const createForm = useCreateLibraryForm(refresh)
const newRoots = useNewLibraryRootDrafts(refresh)
const editableRoots = useEditableRootDrafts()
const rootActions = useEditableLibraryRootActions(refresh, editableRoots)
const libraryActions = useLibraryActions(refresh)
return { libs, createForm, newRoots, editableRoots, rootActions, libraryActions }
return { libs, createForm, editableRoots, rootActions, libraryActions }
}
function useAdminLibraryList() {
@@ -42,7 +41,7 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
return
}
await libraryAPI.createWithRoots(name, type, payload)
toast.success('媒体库已创建')
toast.success('媒体库已保存')
setName('')
setRoots([emptyRootDraft()])
await refresh()
@@ -68,37 +67,14 @@ function useCreateLibraryForm(refresh: () => Promise<void>) {
}
}
function useNewLibraryRootDrafts(refresh: () => Promise<void>) {
const [newRootByLibrary, setNewRootByLibrary] = useState<Record<string, RootDraft>>({})
const newRootDraft = (libraryID: string) => newRootByLibrary[libraryID] ?? emptyRootDraft()
const setNewRootDraft = (libraryID: string, patch: Partial<RootDraft>) => {
setNewRootByLibrary((prev) => ({ ...prev, [libraryID]: { ...newRootDraft(libraryID), ...patch } }))
}
const addLibraryRoot = async (libraryID: string) => {
const draft = newRootDraft(libraryID)
if (!draft.path?.trim()) {
toast.error('请填写路径')
return
}
await libraryAPI.addRoot(libraryID, { ...draft, path: draft.path.trim(), name: draft.name?.trim() })
setNewRootByLibrary((prev) => ({ ...prev, [libraryID]: emptyRootDraft() }))
toast.success('路径已添加')
await refresh()
}
return { newRootDraft, setNewRootDraft, addLibraryRoot }
}
function useEditableRootDrafts() {
const [rootDrafts, setRootDrafts] = useState<Record<string, RootDraft>>({})
const editableRootDraft = (libraryID: string, root: LibraryRoot): RootDraft => {
const key = rootDraftKey(libraryID, root.id)
return rootDrafts[key] ?? {
name: root.name ?? '',
path: root.path,
name: displayLibraryRootName(root.name, root.path),
path: displayLibraryRootPath(root.path),
enabled: root.enabled,
sort_order: root.sort_order,
}
@@ -154,7 +130,7 @@ function useEditableLibraryRootActions(refresh: () => Promise<void>, drafts: Edi
}
const removeLibraryRoot = async (library: Library, root: LibraryRoot) => {
if (!(await confirmAction({ title: '删除媒体库路径', message: `确定删除「${root.path}」?`, confirmText: '删除' }))) return
if (!(await confirmAction({ title: '删除媒体库路径', message: `确定删除「${displayLibraryRootPath(root.path)}」?`, confirmText: '删除' }))) return
await libraryAPI.removeRoot(library.id, root.id)
toast.success('路径已删除')
await refresh()
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { recognitionWordsAPI, type RecognitionWordsConfig, type RecognitionWordsTestResult } from '../api/recognitionWords'
const DEFAULT_CONFIG: RecognitionWordsConfig = {
enabled: true,
local_text: '',
shared_urls: [
'https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/general.txt',
'https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/TV.txt',
'https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/anime.txt',
],
rule_count: 0,
}
export function useRecognitionWordsPanel() {
const [config, setConfig] = useState<RecognitionWordsConfig>(DEFAULT_CONFIG)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [syncing, setSyncing] = useState(false)
const [testing, setTesting] = useState(false)
const [testInput, setTestInput] = useState('')
const [testResult, setTestResult] = useState<RecognitionWordsTestResult | null>(null)
const refresh = async () => {
setLoading(true)
try {
setConfig(await recognitionWordsAPI.get())
} finally {
setLoading(false)
}
}
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const updateEnabled = (enabled: boolean) => {
setConfig((prev) => ({ ...prev, enabled }))
}
const updateSharedURLs = (value: string) => {
setConfig((prev) => ({
...prev,
shared_urls: value.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
}))
}
const updateLocalText = (localText: string) => {
setConfig((prev) => ({ ...prev, local_text: localText }))
}
const save = async () => {
setSaving(true)
try {
const saved = await recognitionWordsAPI.save(config)
setConfig(saved)
toast.success('识别词配置已保存')
} catch (error: unknown) {
toast.error(apiErrorMessage(error, '保存失败'))
} finally {
setSaving(false)
}
}
const sync = async () => {
setSyncing(true)
try {
const synced = await recognitionWordsAPI.sync()
setConfig(synced)
toast.success(`已同步 ${synced.rule_count} 条识别词`)
} catch (error: unknown) {
toast.error(apiErrorMessage(error, '同步失败'))
} finally {
setSyncing(false)
}
}
const test = async () => {
if (!testInput.trim()) return
setTesting(true)
try {
setTestResult(await recognitionWordsAPI.test(testInput))
} catch (error: unknown) {
toast.error(apiErrorMessage(error, '测试失败'))
} finally {
setTesting(false)
}
}
return {
config,
loading,
saving,
syncing,
testing,
testInput,
testResult,
save,
setTestInput,
sync,
test,
updateEnabled,
updateLocalText,
updateSharedURLs,
}
}
function apiErrorMessage(error: unknown, fallback: string): string {
return (error as { response?: { data?: { error?: string } } })?.response?.data?.error ?? fallback
}
+15 -4
View File
@@ -14,11 +14,14 @@ import {
} from './strmPageModel'
import { apiErrorMessage, isHTTPURL } from './strmPageUtils'
type OutputRootSource = 'explicit' | 'generated'
export function useStrmGenerateForm(libraries: Library[]) {
const [generateLibraryID, setGenerateLibraryID] = useState('')
const [baseURL, setBaseURL] = useState('')
const [outputDir, setOutputDir] = useState('')
const [outputRoot, setOutputRoot] = useState('')
const [outputRootSource, setOutputRootSource] = useState<OutputRootSource>('explicit')
const [outputScope, setOutputScope] = useState('')
const [outputDirTouched, setOutputDirTouched] = useState(false)
const [settingsLoaded, setSettingsLoaded] = useState(false)
@@ -48,6 +51,7 @@ export function useStrmGenerateForm(libraries: Library[]) {
setBaseURL(preferredSTRMBaseURL(settings['strm.base_url'] || settings['app.server_url'] || ''))
setOutputDir(savedOutputDir)
setOutputRoot(savedOutputDir)
setOutputRootSource(settings['strm.output_scope'] === 'library' ? 'generated' : 'explicit')
setOutputScope(settings['strm.output_scope'] || '')
setOutputDirTouched(false)
setCloudPlaybackMode(nextMode)
@@ -69,18 +73,22 @@ export function useStrmGenerateForm(libraries: Library[]) {
useEffect(() => {
if (!settingsLoaded || outputDirTouched || !generateLibraryID) return
const root = outputScope === 'library' ? inferSTRMOutputRoot(outputRoot, libraries) : outputRoot
const root =
outputScope === 'library' && outputRootSource === 'generated'
? inferSTRMOutputRoot(outputRoot, libraries)
: outputRoot
if (generateLibraryID === '*') {
setOutputDir(root)
return
}
const library = libraries.find((item) => item.id === generateLibraryID)
if (library) setOutputDir(suggestedSTRMOutputDir(root, library))
}, [settingsLoaded, outputDirTouched, outputRoot, outputScope, generateLibraryID, libraries])
}, [settingsLoaded, outputDirTouched, outputRoot, outputRootSource, outputScope, generateLibraryID, libraries])
const onOutputDirChange = (value: string) => {
setOutputDir(value)
setOutputRoot(value)
setOutputRootSource('explicit')
setOutputScope('')
setOutputDirTouched(true)
}
@@ -95,11 +103,13 @@ export function useStrmGenerateForm(libraries: Library[]) {
}
setGenerating(true)
const submittedOutputDir = outputDir.trim()
const submittedOutputWasExplicit = outputDirTouched
try {
const result = await strmAPI.generate({
library_id: generateLibraryID,
base_url: trimmedBaseURL.replace(/\/+$/, ''),
output_dir: outputDir.trim(),
output_dir: submittedOutputDir,
overwrite,
enabled: autoGenerate,
include_local: true,
@@ -107,7 +117,8 @@ export function useStrmGenerateForm(libraries: Library[]) {
const nextOutputDir = result.output_dir || outputDir
setGenerateResult(result)
setOutputDir(nextOutputDir)
setOutputRoot(inferSTRMOutputRoot(nextOutputDir, libraries))
setOutputRoot(submittedOutputWasExplicit ? submittedOutputDir : inferSTRMOutputRoot(nextOutputDir, libraries))
setOutputRootSource(submittedOutputWasExplicit ? 'explicit' : 'generated')
setOutputScope(generateLibraryID === '*' ? 'all' : 'library')
setOutputDirTouched(false)
toast.success(`生成完成:新增 ${result.generated} · 更新 ${result.updated} · 跳过 ${result.skipped}`)
+1 -1
View File
@@ -78,7 +78,7 @@ export function isEpisodeLike(media: Media): boolean {
// 剧集类目录名(电视剧/动漫及其二级分类)。媒体路径落在这些目录下时, 即便
// 季集号未识别出来, 也应按剧集对待, 跳转到 /library 分类视图而非 /media 单页。
const EPISODIC_PATH_RE =
/[\\/](?:电视剧|剧集|国产剧|欧美剧|日韩剧|日剧|韩剧|综艺|纪录片|动漫|番剧|国漫|日番|欧美动漫|欧美动画|儿童|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]/i
/[\\/](?:电视剧|剧集|国产剧|欧美剧|日韩剧|日剧|韩剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]/i
const SEASON_FOLDER_RE =
/^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)$/i