mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
fix: stabilize library paths and subscription matching
This commit is contained in:
@@ -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),
|
||||
}
|
||||
@@ -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() },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
Vendored
+36
-2
@@ -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\],
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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="路径名称"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user