mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 03:56:38 +08:00
Fix scraping workflows subscriptions bot and recycle bin
This commit is contained in:
@@ -15,6 +15,24 @@ export interface MediaSearchPage {
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export interface ManualScrapeCandidate {
|
||||
source: string
|
||||
media_type?: string
|
||||
title: string
|
||||
overview?: string
|
||||
poster_url?: string
|
||||
backdrop_url?: string
|
||||
year?: number
|
||||
rating?: number
|
||||
tmdb_id?: number
|
||||
bangumi_id?: number
|
||||
douban_id?: string
|
||||
thetvdb_id?: string
|
||||
languages?: string[]
|
||||
countries?: string[]
|
||||
genres?: string[]
|
||||
}
|
||||
|
||||
export const libraryAPI = {
|
||||
list: (options?: { includeHidden?: boolean }) =>
|
||||
api
|
||||
@@ -56,4 +74,15 @@ export const mediaAPI = {
|
||||
.then((r) => r.data),
|
||||
|
||||
get: (id: string) => api.get<Media>(`/media/${id}`).then((r) => r.data),
|
||||
|
||||
manualScrapeSearch: (id: string, params: { query: string; provider?: string; media_type?: string }) =>
|
||||
api
|
||||
.get<{ items: ManualScrapeCandidate[] }>(`/media/${id}/scrape/search`, { params })
|
||||
.then((r) => r.data.items),
|
||||
|
||||
applyManualScrape: (id: string, match: ManualScrapeCandidate) =>
|
||||
api.post<Media>(`/media/${id}/scrape/apply`, match).then((r) => r.data),
|
||||
|
||||
applyManualScrapeBatch: (mediaIDs: string[], match: ManualScrapeCandidate) =>
|
||||
api.post<{ applied: number; errors?: string[] }>('/media/scrape/apply', { media_ids: mediaIDs, match }).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -26,4 +26,16 @@ export const notifyChannelsAPI = {
|
||||
|
||||
test: (id: string) =>
|
||||
api.post<{ message: string }>(`/admin/notify/channels/${id}/test`).then((r) => r.data),
|
||||
|
||||
startTelegramPolling: () =>
|
||||
api.post<{
|
||||
message: string
|
||||
started: number
|
||||
already_running: number
|
||||
skipped: number
|
||||
errors?: string[]
|
||||
}>('/admin/telegram/polling/start').then((r) => r.data),
|
||||
|
||||
stopTelegramPolling: () =>
|
||||
api.post<{ message: string; stopped: number }>('/admin/telegram/polling/stop').then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -8,8 +8,14 @@ export const recycleAPI = {
|
||||
|
||||
restore: (id: string) => api.post(`/media/${id}/restore`).then((r) => r.data),
|
||||
|
||||
restoreMany: (ids: string[]) =>
|
||||
api.post<{ applied: number; errors?: string[] }>('/recycle/restore', { media_ids: ids }).then((r) => r.data),
|
||||
|
||||
purge: (id: string) => api.delete(`/media/${id}/purge`).then((r) => r.data),
|
||||
|
||||
purgeMany: (ids: string[]) =>
|
||||
api.post<{ applied: number; errors?: string[] }>('/recycle/purge', { media_ids: ids }).then((r) => r.data),
|
||||
|
||||
exportNFO: (id: string) =>
|
||||
api.post<{ path: string }>(`/media/${id}/nfo`).then((r) => r.data),
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import toast from 'react-hot-toast'
|
||||
@@ -14,9 +14,12 @@ import { AppFooter } from './AppFooter'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePermissionStore } from '../stores/permissions'
|
||||
import { usePlayProfileStore } from '../stores/playProfile'
|
||||
import { imageURL } from '../api/client'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { playProfilesAPI } from '../api/play_profiles'
|
||||
import { requestPIN } from './PinDialog'
|
||||
import type { PlayProfile } from '../types'
|
||||
import type { Media, PlayProfile } from '../types'
|
||||
import { groupSeries, seriesCardLink } from '../utils/groupSeries'
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate()
|
||||
@@ -36,6 +39,12 @@ export function Layout() {
|
||||
const [profiles, setProfiles] = useState<PlayProfile[]>([])
|
||||
const [searchFocused, setSearchFocused] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchItems, setSearchItems] = useState<Media[]>([])
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
const [searchTotal, setSearchTotal] = useState(0)
|
||||
const [searchError, setSearchError] = useState('')
|
||||
const searchSeq = useRef(0)
|
||||
const searchCards = useMemo(() => groupSeries(searchItems).slice(0, 8), [searchItems])
|
||||
|
||||
// Auto-collapse sidebar on smaller tablet screens, and auto-hide drawer on path change
|
||||
useEffect(() => {
|
||||
@@ -61,6 +70,48 @@ export function Layout() {
|
||||
}
|
||||
}, [fetchPermissions, isPermissionLoading, permissions, user])
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname === '/search') {
|
||||
const query = new URLSearchParams(location.search).get('q') ?? ''
|
||||
setSearchQuery(query)
|
||||
}
|
||||
}, [location.pathname, location.search])
|
||||
|
||||
useEffect(() => {
|
||||
const query = searchQuery.trim()
|
||||
const seq = ++searchSeq.current
|
||||
if (!searchFocused || !query) {
|
||||
setSearchItems([])
|
||||
setSearchTotal(0)
|
||||
setSearchError('')
|
||||
setSearchLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSearchLoading(true)
|
||||
setSearchError('')
|
||||
const timer = window.setTimeout(() => {
|
||||
mediaAPI
|
||||
.search(query, 24)
|
||||
.then((data) => {
|
||||
if (seq !== searchSeq.current) return
|
||||
setSearchItems(data.items ?? [])
|
||||
setSearchTotal(data.total ?? (data.items ?? []).length)
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq !== searchSeq.current) return
|
||||
setSearchItems([])
|
||||
setSearchTotal(0)
|
||||
setSearchError('搜索失败,请稍后再试')
|
||||
})
|
||||
.finally(() => {
|
||||
if (seq === searchSeq.current) setSearchLoading(false)
|
||||
})
|
||||
}, 220)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [searchFocused, searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setProfiles([])
|
||||
@@ -108,6 +159,7 @@ export function Layout() {
|
||||
e.preventDefault()
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
|
||||
setSearchFocused(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,8 +401,10 @@ export function Layout() {
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onMouseDown={() => setSearchFocused(true)}
|
||||
onClick={() => setSearchFocused(true)}
|
||||
onFocus={() => setSearchFocused(true)}
|
||||
onBlur={() => setSearchFocused(false)}
|
||||
onBlur={() => window.setTimeout(() => setSearchFocused(false), 120)}
|
||||
placeholder="搜索电影、电视剧、演员、种子站点..."
|
||||
className="w-full rounded-full border border-gray-200 bg-gray-50/50 py-2.5 pl-11 pr-12 text-sm text-gray-900 placeholder-gray-500 outline-none transition-all duration-300 focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/40"
|
||||
/>
|
||||
@@ -359,6 +413,79 @@ export function Layout() {
|
||||
Enter
|
||||
</span>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{searchFocused && searchQuery.trim() && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 6, scale: 0.98 }}
|
||||
transition={{ duration: 0.14 }}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
className="absolute left-0 right-0 top-full z-50 mt-3 overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl"
|
||||
>
|
||||
<div className="max-h-[420px] overflow-y-auto p-2">
|
||||
{searchLoading && (
|
||||
<div className="flex items-center gap-2 px-3 py-4 text-sm text-gray-500">
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
||||
搜索中...
|
||||
</div>
|
||||
)}
|
||||
{!searchLoading && searchError && (
|
||||
<div className="px-3 py-4 text-sm text-red-500">{searchError}</div>
|
||||
)}
|
||||
{!searchLoading && !searchError && searchCards.length === 0 && (
|
||||
<div className="px-3 py-4 text-sm text-gray-500">没有找到匹配的本地媒体</div>
|
||||
)}
|
||||
{!searchLoading && !searchError && searchCards.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{searchCards.map((card) => (
|
||||
<Link
|
||||
key={card.key}
|
||||
to={seriesCardLink(card)}
|
||||
onClick={() => setSearchFocused(false)}
|
||||
className="flex items-center gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<div className="h-14 w-10 shrink-0 overflow-hidden rounded-lg bg-gray-100">
|
||||
{card.rep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(card.rep.poster_url)}
|
||||
alt={card.rep.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-gray-400">
|
||||
<LibraryIcon size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-gray-900">
|
||||
{card.rep.title || card.rep.original_name || '未命名媒体'}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-gray-500">
|
||||
{card.rep.year ? <span>{card.rep.year}</span> : null}
|
||||
<span>{card.count > 1 ? `${card.count} 集/条目` : '单条媒体'}</span>
|
||||
{card.rep.width ? <span>{card.rep.width}x{card.rep.height}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to={`/search?q=${encodeURIComponent(searchQuery.trim())}`}
|
||||
onClick={() => setSearchFocused(false)}
|
||||
className="flex items-center justify-between border-t border-gray-100 px-4 py-3 text-sm font-semibold text-brand-600 hover:bg-brand-50/60"
|
||||
>
|
||||
<span>查看全部搜索结果</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{searchTotal > 0 ? `${searchTotal} 个条目` : 'Enter'}
|
||||
</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Check, LoaderCircle, Search, Sparkles, X } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { mediaAPI, type ManualScrapeCandidate } from '../api/library'
|
||||
import type { Media } from '../types'
|
||||
|
||||
interface ManualScrapeDialogProps {
|
||||
open: boolean
|
||||
media: Media | null
|
||||
mediaIds?: string[]
|
||||
defaultQuery?: string
|
||||
mediaType?: string
|
||||
scopeLabel?: string
|
||||
onClose: () => void
|
||||
onApplied?: () => void
|
||||
}
|
||||
|
||||
const providers = [
|
||||
{ value: 'all', label: '全部源' },
|
||||
{ value: 'tmdb', label: 'TMDb' },
|
||||
{ value: 'douban', label: '豆瓣' },
|
||||
{ value: 'bangumi', label: 'Bangumi' },
|
||||
{ value: 'thetvdb', label: 'TheTVDB' },
|
||||
]
|
||||
|
||||
export function ManualScrapeDialog({
|
||||
open,
|
||||
media,
|
||||
mediaIds,
|
||||
defaultQuery,
|
||||
mediaType,
|
||||
scopeLabel,
|
||||
onClose,
|
||||
onApplied,
|
||||
}: ManualScrapeDialogProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [provider, setProvider] = useState('all')
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [applyingKey, setApplyingKey] = useState('')
|
||||
const [items, setItems] = useState<ManualScrapeCandidate[]>([])
|
||||
|
||||
const targetIds = useMemo(() => {
|
||||
const ids = (mediaIds && mediaIds.length > 0 ? mediaIds : media ? [media.id] : []).filter(Boolean)
|
||||
return Array.from(new Set(ids))
|
||||
}, [media, mediaIds])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setQuery(defaultQuery || media?.title || '')
|
||||
setProvider('all')
|
||||
setItems([])
|
||||
setApplyingKey('')
|
||||
}, [defaultQuery, media?.title, open])
|
||||
|
||||
if (!open || !media) return null
|
||||
|
||||
const runSearch = async () => {
|
||||
const text = query.trim()
|
||||
if (!text) {
|
||||
toast.error('请输入标题或 TMDb/豆瓣/Bangumi/TheTVDB ID')
|
||||
return
|
||||
}
|
||||
setSearching(true)
|
||||
try {
|
||||
const results = await mediaAPI.manualScrapeSearch(media.id, {
|
||||
query: text,
|
||||
provider,
|
||||
media_type: mediaType,
|
||||
})
|
||||
setItems(results)
|
||||
if (results.length === 0) toast.error('没有找到可用候选')
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '搜索失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const apply = async (item: ManualScrapeCandidate) => {
|
||||
const key = candidateKey(item)
|
||||
setApplyingKey(key)
|
||||
try {
|
||||
if (targetIds.length > 1) {
|
||||
const result = await mediaAPI.applyManualScrapeBatch(targetIds, item)
|
||||
toast.success(`已应用到 ${result.applied} 个媒体`)
|
||||
} else {
|
||||
await mediaAPI.applyManualScrape(media.id, item)
|
||||
toast.success('已应用手动匹配')
|
||||
}
|
||||
onApplied?.()
|
||||
onClose()
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '应用失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setApplyingKey('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-900/40 px-4 py-8 backdrop-blur-sm">
|
||||
<div className="flex max-h-[86vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-sand-200 bg-white shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-sand-200 px-5 py-4">
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-bold text-ink-600">手动搜索刮削</h2>
|
||||
<p className="mt-1 text-xs text-sand-500">
|
||||
{scopeLabel || media.title} · {targetIds.length > 1 ? `将应用到 ${targetIds.length} 个媒体` : '单个媒体'}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="btn-ghost h-9 w-9 p-0" aria-label="关闭">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-b border-sand-200 p-5 sm:flex-row">
|
||||
<select
|
||||
value={provider}
|
||||
onChange={(event) => setProvider(event.target.value)}
|
||||
className="h-11 rounded-xl border border-sand-200 bg-white px-3 text-sm font-semibold text-ink-600 outline-none focus:border-brand-300"
|
||||
>
|
||||
{providers.map((item) => (
|
||||
<option key={item.value} value={item.value}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-sand-500" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter') runSearch() }}
|
||||
className="h-11 w-full rounded-xl border border-sand-200 bg-white pl-9 pr-3 text-sm font-semibold text-ink-600 outline-none focus:border-brand-300"
|
||||
placeholder="输入标题或 TMDb / 豆瓣 / Bangumi / TheTVDB ID"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={runSearch} disabled={searching} className="btn-primary h-11 px-5">
|
||||
{searching ? <LoaderCircle size={16} className="animate-spin" /> : <Sparkles size={16} />}
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex min-h-56 items-center justify-center rounded-xl border border-dashed border-sand-200 text-sm text-sand-500">
|
||||
搜索后在这里选择正确的元数据
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{items.map((item) => {
|
||||
const key = candidateKey(item)
|
||||
const applying = applyingKey === key
|
||||
return (
|
||||
<div key={key} className="flex gap-4 rounded-xl border border-sand-200 bg-white p-3 shadow-sm">
|
||||
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100">
|
||||
{item.poster_url ? (
|
||||
<img src={imageURL(item.poster_url)} alt={item.title} className="h-full w-full object-cover" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-xs text-sand-500">无海报</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
|
||||
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold uppercase text-brand-700">{item.source}</span>
|
||||
{item.year ? <span className="text-xs text-sand-500">{item.year}</span> : null}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-ink-50">{item.overview || '暂无简介'}</p>
|
||||
<p className="mt-2 text-[11px] font-semibold text-sand-500">{candidateIDText(item)}</p>
|
||||
</div>
|
||||
<button onClick={() => apply(item)} disabled={!!applyingKey} className="btn-outline h-10 shrink-0 self-center px-3 text-xs">
|
||||
{applying ? <LoaderCircle size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
应用匹配
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function candidateKey(item: ManualScrapeCandidate): string {
|
||||
return `${item.source}:${item.tmdb_id || item.bangumi_id || item.douban_id || item.thetvdb_id || item.title}:${item.media_type || ''}`
|
||||
}
|
||||
|
||||
function candidateIDText(item: ManualScrapeCandidate): string {
|
||||
const parts = [
|
||||
item.tmdb_id ? `TMDb ${item.tmdb_id}` : '',
|
||||
item.douban_id ? `豆瓣 ${item.douban_id}` : '',
|
||||
item.bangumi_id ? `Bangumi ${item.bangumi_id}` : '',
|
||||
item.thetvdb_id ? `TheTVDB ${item.thetvdb_id}` : '',
|
||||
item.media_type ? item.media_type : '',
|
||||
].filter(Boolean)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Film, Play, Layers, Star } from 'lucide-react'
|
||||
@@ -6,7 +6,7 @@ import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
export const MediaCard = ({
|
||||
media, progress, count, rating, linkTo, onClick,
|
||||
media, progress, count, rating, linkTo, onClick, actions,
|
||||
}: {
|
||||
media: Media
|
||||
progress?: number
|
||||
@@ -14,6 +14,7 @@ export const MediaCard = ({
|
||||
rating?: number
|
||||
linkTo?: string
|
||||
onClick?: () => void
|
||||
actions?: ReactNode
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const href = linkTo ?? `/media/${media.id}`
|
||||
@@ -131,15 +132,35 @@ export const MediaCard = ({
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="group block w-full text-left">
|
||||
{card}
|
||||
</button>
|
||||
<div className="group relative block w-full">
|
||||
<button type="button" onClick={onClick} className="block w-full text-left">
|
||||
{card}
|
||||
</button>
|
||||
{actions && (
|
||||
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (actions) {
|
||||
return (
|
||||
<div className="group relative block">
|
||||
<Link to={href} className="block">
|
||||
{card}
|
||||
</Link>
|
||||
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={href} className="group block">
|
||||
{card}
|
||||
{card}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ArrowLeft, Play, Film } from 'lucide-react'
|
||||
import { ArrowLeft, Play, Film, Database, FileText, Search, Sparkles, Trash2 } from 'lucide-react'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { storageAPI, type CloudScanStatus } from '../api/storage_config'
|
||||
import { api } from '../api/client'
|
||||
import { recycleAPI } from '../api/recycle'
|
||||
import type { Library, Media } from '../types'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
@@ -13,6 +15,8 @@ import { imageURL } from '../api/client'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { getSeriesKey, groupSeries, isEpisodeLike, seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||
import { useWebSocket } from '../hooks/useWebSocket'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
||||
|
||||
export function LibraryPage() {
|
||||
const { id = '' } = useParams()
|
||||
@@ -27,6 +31,10 @@ export function LibraryPage() {
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [scanProgress, setScanProgress] = useState('')
|
||||
const [scraping, setScraping] = useState(false)
|
||||
const [seriesToolBusy, setSeriesToolBusy] = useState('')
|
||||
const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false)
|
||||
const [manualMovie, setManualMovie] = useState<Media | null>(null)
|
||||
const [movieToolBusy, setMovieToolBusy] = useState('')
|
||||
|
||||
// 剧集模式:选中某个剧集后展开详情
|
||||
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
|
||||
@@ -65,6 +73,16 @@ export function LibraryPage() {
|
||||
return selectedEpisodes.find((s) => s.season === selectedSeason)?.episodes ?? []
|
||||
}, [selectedEpisodes, selectedSeason])
|
||||
|
||||
const selectedSeriesEpisodes = useMemo(
|
||||
() => selectedEpisodes.flatMap((season) => season.episodes),
|
||||
[selectedEpisodes],
|
||||
)
|
||||
|
||||
const selectedSeriesMediaIDs = useMemo(
|
||||
() => selectedSeriesEpisodes.map((ep) => ep.id),
|
||||
[selectedSeriesEpisodes],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
libraryAPI.list().then((all) => {
|
||||
@@ -255,6 +273,107 @@ export function LibraryPage() {
|
||||
finally { setScraping(false) }
|
||||
}
|
||||
|
||||
const runSeriesTool = async (key: string, label: string, action: (media: Media) => Promise<unknown>) => {
|
||||
if (selectedSeriesEpisodes.length === 0) return
|
||||
setSeriesToolBusy(key)
|
||||
try {
|
||||
for (const ep of selectedSeriesEpisodes) {
|
||||
await action(ep)
|
||||
}
|
||||
toast.success(`${label}完成:${selectedSeriesEpisodes.length} 个媒体`)
|
||||
reloadCurrentLibrary()
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || `${label}失败`
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSeriesToolBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSeriesSmartScrape = () => {
|
||||
runSeriesTool('scrape', '整剧智能刮削', (media) => api.post(`/media/${media.id}/scrape`))
|
||||
}
|
||||
|
||||
const handleSeriesProbe = () => {
|
||||
runSeriesTool('probe', '整剧媒体轨探测', (media) => api.post(`/media/${media.id}/probe`))
|
||||
}
|
||||
|
||||
const handleSeriesNFO = () => {
|
||||
runSeriesTool('nfo', '整剧 NFO 写出', (media) => recycleAPI.exportNFO(media.id))
|
||||
}
|
||||
|
||||
const handleSeriesSoftDelete = async () => {
|
||||
if (!selectedSeries || selectedSeriesEpisodes.length === 0) return
|
||||
if (!(await confirmAction({
|
||||
title: '移入回收站',
|
||||
message: `将「${seriesTitle(selectedSeries.rep)}」的 ${selectedSeriesEpisodes.length} 个媒体移至回收站? (磁盘文件保留)`,
|
||||
confirmText: '移入回收站',
|
||||
}))) return
|
||||
await runSeriesTool('delete', '整剧移入回收站', (media) => recycleAPI.softDelete(media.id))
|
||||
clearSelectedSeries()
|
||||
}
|
||||
|
||||
const runMovieTool = async (media: Media, key: string, label: string, action: (media: Media) => Promise<unknown>) => {
|
||||
const busyKey = `${key}:${media.id}`
|
||||
setMovieToolBusy(busyKey)
|
||||
try {
|
||||
await action(media)
|
||||
toast.success(`${label}完成:${media.title}`)
|
||||
reloadCurrentLibrary()
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || `${label}失败`
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setMovieToolBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMovieSmartScrape = (media: Media) => {
|
||||
runMovieTool(media, 'scrape', '智能刮削', (item) => api.post(`/media/${item.id}/scrape`))
|
||||
}
|
||||
|
||||
const handleMovieProbe = (media: Media) => {
|
||||
runMovieTool(media, 'probe', '媒体轨探测', (item) => api.post(`/media/${item.id}/probe`))
|
||||
}
|
||||
|
||||
const handleMovieNFO = (media: Media) => {
|
||||
runMovieTool(media, 'nfo', 'NFO 写出', (item) => recycleAPI.exportNFO(item.id))
|
||||
}
|
||||
|
||||
const handleMovieSoftDelete = async (media: Media) => {
|
||||
if (!(await confirmAction({
|
||||
title: '移入回收站',
|
||||
message: `将「${media.title}」移至回收站? (磁盘文件保留)`,
|
||||
confirmText: '移入回收站',
|
||||
}))) return
|
||||
await runMovieTool(media, 'delete', '移入回收站', (item) => recycleAPI.softDelete(item.id))
|
||||
}
|
||||
|
||||
const movieActions = (media: Media) => {
|
||||
if (role !== 'admin') return undefined
|
||||
const busy = movieToolBusy.endsWith(`:${media.id}`)
|
||||
const buttonClass = 'flex h-8 w-8 items-center justify-center rounded-lg border border-white/70 bg-white/90 text-gray-700 shadow-sm backdrop-blur transition hover:bg-brand-50 hover:text-brand-600 disabled:opacity-50'
|
||||
return (
|
||||
<>
|
||||
<button title="智能刮削" disabled={busy} onClick={() => handleMovieSmartScrape(media)} className={buttonClass}>
|
||||
<Sparkles size={13} />
|
||||
</button>
|
||||
<button title="手动匹配刮削" disabled={busy} onClick={() => setManualMovie(media)} className={buttonClass}>
|
||||
<Search size={13} />
|
||||
</button>
|
||||
<button title="探测媒体轨" disabled={busy} onClick={() => handleMovieProbe(media)} className={buttonClass}>
|
||||
<Database size={13} />
|
||||
</button>
|
||||
<button title="写出本地 NFO" disabled={busy} onClick={() => handleMovieNFO(media)} className={buttonClass}>
|
||||
<FileText size={13} />
|
||||
</button>
|
||||
<button title="移入回收站" disabled={busy} onClick={() => handleMovieSoftDelete(media)} className={`${buttonClass} hover:!bg-red-50 hover:!text-red-500`}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSeriesClick = (card: SeriesCard) => {
|
||||
setSelectedSeries(card)
|
||||
const next = new URLSearchParams(searchParams)
|
||||
@@ -309,7 +428,7 @@ export function LibraryPage() {
|
||||
{!isSeries && items.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
<MediaCard key={m.id} media={m} actions={movieActions(m)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -386,6 +505,34 @@ export function LibraryPage() {
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
|
||||
{role === 'admin' && selectedSeriesEpisodes.length > 0 && (
|
||||
<div className="rounded-2xl border border-sand-200 bg-white/80 p-4 shadow-sm">
|
||||
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={handleSeriesSmartScrape} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
|
||||
<Sparkles size={13} className="text-[#c9954a]" />
|
||||
<span>{seriesToolBusy === 'scrape' ? '刮削中…' : '整剧智能刮削'}</span>
|
||||
</button>
|
||||
<button onClick={() => setManualSeriesScrapeOpen(true)} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
|
||||
<Search size={13} className="text-[#c9954a]" />
|
||||
<span>手动匹配整剧</span>
|
||||
</button>
|
||||
<button onClick={handleSeriesProbe} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
|
||||
<Database size={13} />
|
||||
<span>{seriesToolBusy === 'probe' ? '探测中…' : '探测媒体轨'}</span>
|
||||
</button>
|
||||
<button onClick={handleSeriesNFO} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
|
||||
<FileText size={13} />
|
||||
<span>{seriesToolBusy === 'nfo' ? '写出中…' : '写出本地 NFO'}</span>
|
||||
</button>
|
||||
<button onClick={handleSeriesSoftDelete} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200">
|
||||
<Trash2 size={13} />
|
||||
<span>{seriesToolBusy === 'delete' ? '处理中…' : '移入回收站'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -451,6 +598,26 @@ export function LibraryPage() {
|
||||
<p className="text-ink-50">该库尚未发现任何剧集,触发一次扫描后再来看看</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ManualScrapeDialog
|
||||
open={manualSeriesScrapeOpen}
|
||||
media={selectedSeries?.rep ?? null}
|
||||
mediaIds={selectedSeriesMediaIDs}
|
||||
defaultQuery={selectedSeries ? seriesTitle(selectedSeries.rep) : ''}
|
||||
mediaType={library?.type || 'tv'}
|
||||
scopeLabel={selectedSeries ? seriesTitle(selectedSeries.rep) : '当前剧集'}
|
||||
onClose={() => setManualSeriesScrapeOpen(false)}
|
||||
onApplied={reloadCurrentLibrary}
|
||||
/>
|
||||
<ManualScrapeDialog
|
||||
open={!!manualMovie}
|
||||
media={manualMovie}
|
||||
defaultQuery={manualMovie?.title ?? ''}
|
||||
mediaType={library?.type || 'movie'}
|
||||
scopeLabel={manualMovie?.title ?? '当前电影'}
|
||||
onClose={() => setManualMovie(null)}
|
||||
onApplied={reloadCurrentLibrary}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2, Calendar, Database } from 'lucide-react'
|
||||
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2, Calendar, Database, Search } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
@@ -13,6 +13,7 @@ import { api } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
||||
|
||||
function fmtDuration(sec: number): string {
|
||||
if (!sec || sec <= 0) return '—'
|
||||
@@ -45,6 +46,7 @@ export function MediaDetailPage() {
|
||||
const [media, setMedia] = useState<Media | null>(null)
|
||||
const [favourite, setFavourite] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
if (!id) return
|
||||
@@ -315,6 +317,10 @@ export function MediaDetailPage() {
|
||||
<Sparkles size={13} className="text-[#c9954a]" />
|
||||
<span>智能刮削 (TMDB)</span>
|
||||
</button>
|
||||
<button onClick={() => setManualScrapeOpen(true)} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Search size={13} className="text-[#c9954a]" />
|
||||
<span>手动匹配刮削</span>
|
||||
</button>
|
||||
<button onClick={reprobe} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Database size={13} className="text-gray-600" />
|
||||
<span>探测媒体轨 (ffprobe)</span>
|
||||
@@ -336,6 +342,15 @@ export function MediaDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ManualScrapeDialog
|
||||
open={manualScrapeOpen}
|
||||
media={media}
|
||||
defaultQuery={media.title}
|
||||
mediaType={media.season_num > 0 || media.episode_num > 0 ? 'tv' : undefined}
|
||||
scopeLabel={media.title}
|
||||
onClose={() => setManualScrapeOpen(false)}
|
||||
onApplied={refresh}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Bell, Loader2, Pencil, Plus, Send, Trash2 } from 'lucide-react'
|
||||
import { Bell, Loader2, Pencil, Play, Plus, Send, Square, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
@@ -17,6 +17,8 @@ export function NotifyChannelsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<NotifyChannel | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [testingID, setTestingID] = useState<string | null>(null)
|
||||
const [pollingBusy, setPollingBusy] = useState<'start' | 'stop' | null>(null)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
@@ -32,6 +34,7 @@ export function NotifyChannelsPage() {
|
||||
}, [])
|
||||
|
||||
const onTest = async (id: string) => {
|
||||
setTestingID(id)
|
||||
try {
|
||||
await notifyChannelsAPI.test(id)
|
||||
toast.success('测试消息已发送')
|
||||
@@ -39,6 +42,42 @@ export function NotifyChannelsPage() {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '发送失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setTestingID(null)
|
||||
}
|
||||
}
|
||||
|
||||
const onStartPolling = async () => {
|
||||
setPollingBusy('start')
|
||||
try {
|
||||
const res = await notifyChannelsAPI.startTelegramPolling()
|
||||
if ((res.started ?? 0) > 0) {
|
||||
toast.success(`已启动 ${res.started} 个 Telegram Bot 轮询`)
|
||||
} else if ((res.already_running ?? 0) > 0) {
|
||||
toast.success('Telegram Bot 轮询已在运行')
|
||||
} else {
|
||||
toast.error(res.errors?.[0] ?? '没有可启动的 Telegram Bot 渠道')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '启动轮询失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setPollingBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const onStopPolling = async () => {
|
||||
setPollingBusy('stop')
|
||||
try {
|
||||
const res = await notifyChannelsAPI.stopTelegramPolling()
|
||||
toast.success(res.stopped > 0 ? `已停止 ${res.stopped} 个 Telegram Bot 轮询` : '当前没有运行中的轮询')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '停止轮询失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setPollingBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,15 +108,35 @@ export function NotifyChannelsPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(null)
|
||||
setShowForm(true)
|
||||
}}
|
||||
className="neon-button"
|
||||
>
|
||||
<Plus size={16} /> 添加渠道
|
||||
</button>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
onClick={onStartPolling}
|
||||
disabled={pollingBusy !== null}
|
||||
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:border-primary-400/40 hover:text-brand-500 disabled:opacity-50"
|
||||
title="启动 Telegram Bot 长轮询"
|
||||
>
|
||||
{pollingBusy === 'start' ? <Loader2 size={16} className="inline animate-spin" /> : <Play size={16} className="inline" />}
|
||||
{' '}启动 Bot 轮询
|
||||
</button>
|
||||
<button
|
||||
onClick={onStopPolling}
|
||||
disabled={pollingBusy !== null}
|
||||
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:border-primary-400/40 hover:text-brand-500 disabled:opacity-50"
|
||||
title="停止本地长轮询。"
|
||||
>
|
||||
{pollingBusy === 'stop' ? <Loader2 size={16} className="inline animate-spin" /> : <Square size={16} className="inline" />}
|
||||
{' '}停止轮询
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(null)
|
||||
setShowForm(true)
|
||||
}}
|
||||
className="neon-button"
|
||||
>
|
||||
<Plus size={16} /> 添加渠道
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
@@ -97,6 +156,7 @@ export function NotifyChannelsPage() {
|
||||
key={ch.id}
|
||||
channel={ch}
|
||||
onTest={() => onTest(ch.id)}
|
||||
testing={testingID === ch.id}
|
||||
onEdit={() => {
|
||||
setEditing(ch)
|
||||
setShowForm(true)
|
||||
@@ -140,11 +200,13 @@ const EVENT_OPTIONS = [
|
||||
function ChannelCard({
|
||||
channel,
|
||||
onTest,
|
||||
testing,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
channel: NotifyChannel
|
||||
onTest: () => void
|
||||
testing?: boolean
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
@@ -167,9 +229,10 @@ function ChannelCard({
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<Send size={12} className="inline" /> 测试
|
||||
{testing ? <Loader2 size={12} className="inline animate-spin" /> : <Send size={12} className="inline" />} 测试
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import { RotateCcw, Trash2 } from 'lucide-react'
|
||||
import { Home, RotateCcw, Square, CheckSquare, Trash2 } from 'lucide-react'
|
||||
|
||||
import { recycleAPI } from '../api/recycle'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
@@ -9,32 +10,124 @@ import type { Media } from '../types'
|
||||
export function RecycleBinPage() {
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [batchBusy, setBatchBusy] = useState('')
|
||||
|
||||
const refresh = () =>
|
||||
recycleAPI
|
||||
.list()
|
||||
.then(setItems)
|
||||
.then((rows) => {
|
||||
setItems(rows)
|
||||
setSelectedIds((current) => current.filter((id) => rows.some((item) => item.id === id)))
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const allSelected = items.length > 0 && selectedIds.length === items.length
|
||||
const toggleAll = () => {
|
||||
setSelectedIds(allSelected ? [] : items.map((item) => item.id))
|
||||
}
|
||||
const toggleOne = (id: string) => {
|
||||
setSelectedIds((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id])
|
||||
}
|
||||
const runBatch = async (mode: 'restore' | 'purge') => {
|
||||
if (selectedIds.length === 0) return
|
||||
if (mode === 'purge' && !(await confirmAction({
|
||||
title: '批量彻底删除记录',
|
||||
message: `彻底删除选中的 ${selectedIds.length} 条记录? (磁盘文件保留)`,
|
||||
confirmText: '彻底删除',
|
||||
}))) return
|
||||
setBatchBusy(mode)
|
||||
try {
|
||||
const result = await runRecycleBatchRequest(mode, selectedIds)
|
||||
toast.success(`${mode === 'restore' ? '恢复' : '彻底删除'}完成:${result.applied} 条`)
|
||||
setSelectedIds([])
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const response = (err as { response?: { status?: number; data?: { error?: string } }; message?: string })?.response
|
||||
const msg = response?.data?.error || (response?.status ? `批量操作失败 (${response.status})` : (err as { message?: string })?.message || '批量操作失败')
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setBatchBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const runRecycleBatchRequest = async (mode: 'restore' | 'purge', ids: string[]) => {
|
||||
try {
|
||||
return mode === 'restore'
|
||||
? await recycleAPI.restoreMany(ids)
|
||||
: await recycleAPI.purgeMany(ids)
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
if (status !== 404 && status !== 405) throw err
|
||||
let applied = 0
|
||||
for (const id of ids) {
|
||||
if (mode === 'restore') {
|
||||
await recycleAPI.restore(id)
|
||||
} else {
|
||||
await recycleAPI.purge(id)
|
||||
}
|
||||
applied += 1
|
||||
}
|
||||
return { applied, errors: [] as string[] }
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">回收站</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
软删除的媒体保留在数据库中,可以恢复。彻底删除不会移除磁盘上的文件,只会从数据库清除条目。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">回收站</h1>
|
||||
<p className="mt-2 text-sm text-ink-50">
|
||||
软删除的媒体保留在数据库中,可以恢复。彻底删除不会移除磁盘上的文件,只会从数据库清除条目。
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-sand-500">系统最多保留最新 200 条回收站记录,超过后会自动清理旧记录。</p>
|
||||
</div>
|
||||
<Link to="/" className="btn-outline shrink-0 py-2.5 px-4 text-sm">
|
||||
<Home size={15} />
|
||||
返回系统首页
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-sand-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && <p className="text-ink-50">回收站为空。</p>}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="glass-panel">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-xs font-semibold text-sand-500">
|
||||
已选择 {selectedIds.length} / {items.length} 条
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => runBatch('restore')}
|
||||
disabled={selectedIds.length === 0 || !!batchBusy}
|
||||
className="btn-outline py-2 px-3 text-xs"
|
||||
>
|
||||
<RotateCcw size={13} />
|
||||
{batchBusy === 'restore' ? '恢复中…' : '批量恢复'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => runBatch('purge')}
|
||||
disabled={selectedIds.length === 0 || !!batchBusy}
|
||||
className="btn-outline py-2 px-3 text-xs !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
{batchBusy === 'purge' ? '删除中…' : '批量彻底删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="w-10 py-2">
|
||||
<button onClick={toggleAll} className="text-sand-500 hover:text-brand-500" aria-label="全选">
|
||||
{allSelected ? <CheckSquare size={16} /> : <Square size={16} />}
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-2">标题</th>
|
||||
<th>路径</th>
|
||||
<th className="text-right">操作</th>
|
||||
@@ -43,6 +136,11 @@ export function RecycleBinPage() {
|
||||
<tbody>
|
||||
{items.map((m) => (
|
||||
<tr key={m.id} className="border-t border-gray-200">
|
||||
<td className="py-2">
|
||||
<button onClick={() => toggleOne(m.id)} className="text-sand-500 hover:text-brand-500" aria-label={`选择 ${m.title}`}>
|
||||
{selectedIds.includes(m.id) ? <CheckSquare size={16} /> : <Square size={16} />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-2 text-ink-600">{m.title}</td>
|
||||
<td className="max-w-md truncate text-ink-50" title={m.path}>
|
||||
{m.path}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import { CheckCircle2, Info, Rss, Sparkles } from 'lucide-react'
|
||||
|
||||
@@ -13,6 +14,8 @@ import { groupSeries, seriesCardLink } from '../utils/groupSeries'
|
||||
const LOCAL_SEARCH_PAGE_SIZE = 2000
|
||||
|
||||
export function SearchPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const urlQuery = searchParams.get('q') ?? ''
|
||||
const [q, setQ] = useState('')
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -34,6 +37,10 @@ export function SearchPage() {
|
||||
.catch(() => setAiAvailable(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setQ(urlQuery)
|
||||
}, [urlQuery])
|
||||
|
||||
const doQuickSearch = useCallback((query: string) => {
|
||||
const seq = ++searchSeq.current
|
||||
if (!query.trim()) {
|
||||
|
||||
@@ -127,6 +127,8 @@ export interface Media {
|
||||
scrape_status: string
|
||||
tmdb_id: number
|
||||
bangumi_id: number
|
||||
douban_id?: string
|
||||
thetvdb_id?: string
|
||||
languages?: string
|
||||
countries?: string
|
||||
genres?: string
|
||||
|
||||
Reference in New Issue
Block a user