mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 12:06:38 +08:00
refactor: split modules and harden scraping workflows
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { useState } from 'react'
|
||||
import { Rss } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import type { ExternalMediaResult } from '../api/ai'
|
||||
import { imageURL } from '../api/client'
|
||||
import { buildSiteSearchFeedURL, buildSubscriptionAliases, subscriptionsAPI } from '../api/subscriptions'
|
||||
|
||||
type AIAssistantExternalResultsProps = {
|
||||
items: ExternalMediaResult[]
|
||||
}
|
||||
|
||||
export function AIAssistantExternalResults({ items }: AIAssistantExternalResultsProps) {
|
||||
const [subscribing, setSubscribing] = useState('')
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => {
|
||||
const keyword = item.subscribe_keyword || item.title
|
||||
const key = `${item.source}:${keyword}`
|
||||
return (
|
||||
<article key={key} className="rounded-2xl border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="h-24 w-16 shrink-0 overflow-hidden rounded-xl bg-white">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={imageURL(item.poster_url)}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex flex-wrap gap-2 text-[10px] uppercase text-brand-500">
|
||||
<span>{item.source}</span>
|
||||
{item.media_type && <span>{item.media_type}</span>}
|
||||
{item.year ? <span>{item.year}</span> : null}
|
||||
</div>
|
||||
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-ink-50">
|
||||
{item.overview || `订阅关键词:${keyword}`}
|
||||
</p>
|
||||
<button
|
||||
disabled={subscribing === key}
|
||||
onClick={() => subscribeExternalItem(item, key, keyword, setSubscribing)}
|
||||
className="mt-2 rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10 disabled:opacity-50"
|
||||
>
|
||||
<Rss size={12} className="mr-1 inline" />
|
||||
{subscribing === key ? '订阅中…' : '订阅并搜索 PT'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function subscribeExternalItem(
|
||||
item: ExternalMediaResult,
|
||||
key: string,
|
||||
keyword: string,
|
||||
setSubscribing: (key: string) => void,
|
||||
) {
|
||||
setSubscribing(key)
|
||||
try {
|
||||
const feed = buildSiteSearchFeedURL(keyword, item.source, buildSubscriptionAliases(item))
|
||||
const sub = await subscriptionsAPI.create({
|
||||
name: `${item.title} 自动订阅`,
|
||||
feed_url: feed,
|
||||
filter: keyword,
|
||||
media_type: item.media_type,
|
||||
source: item.source,
|
||||
poster_url: item.poster_url,
|
||||
backdrop_url: item.backdrop_url,
|
||||
overview: item.overview,
|
||||
original_name: item.original_name,
|
||||
year: item.year,
|
||||
total_episodes: item.total_episodes,
|
||||
enabled: true,
|
||||
})
|
||||
const run = await subscriptionsAPI.runNow(sub.id)
|
||||
toast.success(
|
||||
run.queued > 0
|
||||
? `已订阅并加入 ${run.queued} 个下载`
|
||||
: '已订阅,暂未在 PT 站点找到可下载资源',
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'订阅失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSubscribing('')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
export type AIAssistantStatus = {
|
||||
enabled: boolean
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
type AIAssistantHeaderProps = {
|
||||
status: AIAssistantStatus | null
|
||||
}
|
||||
|
||||
export function AIAssistantHeader({ status }: AIAssistantHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary-400 to-purple-500">
|
||||
<Sparkles className="h-5 w-5 text-ink-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">AI 助手</h1>
|
||||
<p className="text-sm text-ink-50">自然语言搜索 · 基于观影历史的智能推荐</p>
|
||||
</div>
|
||||
</div>
|
||||
{status && (
|
||||
<div className="text-xs text-ink-50">
|
||||
<span
|
||||
className={
|
||||
'mr-2 inline-block h-2 w-2 rounded-full ' +
|
||||
(status.enabled ? 'bg-emerald-400' : 'bg-sand-500/30')
|
||||
}
|
||||
/>
|
||||
{status.enabled
|
||||
? `已连接 · ${status.provider}${status.model ? ' / ' + status.model : ''}`
|
||||
: '未配置 AI 服务,使用本地规则解析'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Loader2, Rss, Search, Sparkles, Wand2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { aiAPI, type ExternalMediaResult, type SearchIntent } from '../api/ai'
|
||||
import { imageURL } from '../api/client'
|
||||
import { buildSiteSearchFeedURL, subscriptionsAPI } from '../api/subscriptions'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { Media } from '../types'
|
||||
import { groupSeries, seriesCardLink } from '../utils/groupSeries'
|
||||
import { AIAssistantHeader } from './AIAssistantHeader'
|
||||
import { AIAssistantRecommendationsSection } from './AIAssistantRecommendationsSection'
|
||||
import { AIAssistantSearchSection } from './AIAssistantSearchSection'
|
||||
import { useAIAssistantPage } from './useAIAssistantPage'
|
||||
|
||||
// AIAssistantPage exposes the two AI helpers backed by the Go server:
|
||||
// - smart search: parses a natural-language query into a SearchIntent +
|
||||
@@ -20,308 +15,31 @@ import { groupSeries, seriesCardLink } from '../utils/groupSeries'
|
||||
// operation-execute endpoints, so we render the same two capabilities as
|
||||
// a focused two-panel screen.
|
||||
export function AIAssistantPage() {
|
||||
const [status, setStatus] = useState<{
|
||||
enabled: boolean
|
||||
provider: string
|
||||
model: string
|
||||
} | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [intent, setIntent] = useState<SearchIntent | null>(null)
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [externalItems, setExternalItems] = useState<ExternalMediaResult[]>([])
|
||||
const [subscribing, setSubscribing] = useState('')
|
||||
const localCards = useMemo(() => groupSeries(items), [items])
|
||||
|
||||
const [recs, setRecs] = useState<string[] | null>(null)
|
||||
const [recommending, setRecommending] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
aiAPI
|
||||
.status()
|
||||
.then(setStatus)
|
||||
.catch(() => setStatus({ enabled: false, provider: '', model: '' }))
|
||||
}, [])
|
||||
|
||||
const onSearch = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!query.trim()) return
|
||||
setSearching(true)
|
||||
setIntent(null)
|
||||
setItems([])
|
||||
setExternalItems([])
|
||||
try {
|
||||
const r = await aiAPI.smartSearch(query.trim())
|
||||
setIntent(r.intent)
|
||||
setItems(r.items)
|
||||
setExternalItems(r.external_items ?? [])
|
||||
if (r.items.length === 0 && (r.external_items ?? []).length === 0) toast('未找到匹配项')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'搜索失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onRecommend = async () => {
|
||||
setRecommending(true)
|
||||
try {
|
||||
const titles = await aiAPI.recommend()
|
||||
setRecs(titles)
|
||||
if (titles.length === 0) toast('暂无可推荐内容,请先观看一些媒体')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'获取推荐失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setRecommending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const quickHints = [
|
||||
'2023 年的科幻电影',
|
||||
'评分高的动漫',
|
||||
'最近添加的纪录片',
|
||||
'中文剧集',
|
||||
]
|
||||
const assistant = useAIAssistantPage()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary-400 to-purple-500">
|
||||
<Sparkles className="h-5 w-5 text-ink-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">AI 助手</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
自然语言搜索 · 基于观影历史的智能推荐
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{status && (
|
||||
<div className="text-xs text-ink-50">
|
||||
<span
|
||||
className={
|
||||
'mr-2 inline-block h-2 w-2 rounded-full ' +
|
||||
(status.enabled ? 'bg-emerald-400' : 'bg-sand-500/30')
|
||||
}
|
||||
/>
|
||||
{status.enabled
|
||||
? `已连接 · ${status.provider}${status.model ? ' / ' + status.model : ''}`
|
||||
: '未配置 AI 服务,使用本地规则解析'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AIAssistantHeader status={assistant.status} />
|
||||
|
||||
{/* Smart search */}
|
||||
<section className="glass-panel space-y-4">
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">智能搜索</h2>
|
||||
<form onSubmit={onSearch} className="flex flex-wrap gap-2">
|
||||
<input
|
||||
className="input-base flex-1"
|
||||
placeholder="试试: 2023 年的高分动作片"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={searching} className="neon-button">
|
||||
{searching ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Search size={16} />
|
||||
)}
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
<AIAssistantSearchSection
|
||||
query={assistant.query}
|
||||
searching={assistant.searching}
|
||||
intent={assistant.intent}
|
||||
items={assistant.items}
|
||||
localCards={assistant.localCards}
|
||||
externalItems={assistant.externalItems}
|
||||
onSearch={assistant.onSearch}
|
||||
setQuery={assistant.setQuery}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickHints.map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => {
|
||||
setQuery(h)
|
||||
}}
|
||||
className="rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{intent && (
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-xs text-ink-100">
|
||||
<div className="mb-1 font-semibold text-ink-200">解析结果</div>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1">
|
||||
<span>
|
||||
查询: <span className="text-brand-500">{intent.query || '—'}</span>
|
||||
</span>
|
||||
{intent.year !== undefined && intent.year > 0 && (
|
||||
<span>
|
||||
年份: <span className="text-brand-500">{intent.year}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.genre && (
|
||||
<span>
|
||||
类型: <span className="text-brand-500">{intent.genre}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.type && (
|
||||
<span>
|
||||
分类: <span className="text-brand-500">{intent.type}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.sort && (
|
||||
<span>
|
||||
排序: <span className="text-brand-500">{intent.sort}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.language && (
|
||||
<span>
|
||||
语言: <span className="text-brand-500">{intent.language}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localCards.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-semibold text-ink-100">
|
||||
本地媒体库 · {localCards.length} 个合集 / {items.length} 个条目
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{localCards.map((card) => (
|
||||
<MediaCard
|
||||
key={card.key}
|
||||
media={card.rep}
|
||||
count={card.count}
|
||||
linkTo={seriesCardLink(card)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{externalItems.length > 0 && (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{externalItems.map((item) => {
|
||||
const keyword = item.subscribe_keyword || item.title
|
||||
const key = `${item.source}:${keyword}`
|
||||
return (
|
||||
<article key={key} className="rounded-2xl border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="h-24 w-16 shrink-0 overflow-hidden rounded-xl bg-white">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={imageURL(item.poster_url)}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex flex-wrap gap-2 text-[10px] uppercase text-brand-500">
|
||||
<span>{item.source}</span>
|
||||
{item.media_type && <span>{item.media_type}</span>}
|
||||
{item.year ? <span>{item.year}</span> : null}
|
||||
</div>
|
||||
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-ink-50">
|
||||
{item.overview || `订阅关键词:${keyword}`}
|
||||
</p>
|
||||
<button
|
||||
disabled={subscribing === key}
|
||||
onClick={async () => {
|
||||
setSubscribing(key)
|
||||
try {
|
||||
const feed = buildSiteSearchFeedURL(keyword, item.source, [item.title, item.original_name || ''])
|
||||
const sub = await subscriptionsAPI.create({
|
||||
name: `${item.title} 自动订阅`,
|
||||
feed_url: feed,
|
||||
filter: keyword,
|
||||
media_type: item.media_type,
|
||||
source: item.source,
|
||||
poster_url: item.poster_url,
|
||||
backdrop_url: item.backdrop_url,
|
||||
overview: item.overview,
|
||||
total_episodes: item.total_episodes,
|
||||
enabled: true,
|
||||
})
|
||||
const run = await subscriptionsAPI.runNow(sub.id)
|
||||
toast.success(
|
||||
run.queued > 0
|
||||
? `已订阅并加入 ${run.queued} 个下载`
|
||||
: '已订阅,暂未在 PT 站点找到可下载资源',
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'订阅失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSubscribing('')
|
||||
}
|
||||
}}
|
||||
className="mt-2 rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10 disabled:opacity-50"
|
||||
>
|
||||
<Rss size={12} className="mr-1 inline" />
|
||||
{subscribing === key ? '订阅中…' : '订阅并搜索 PT'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Recommendations */}
|
||||
<section className="glass-panel space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">为你推荐</h2>
|
||||
<button onClick={onRecommend} disabled={recommending} className="neon-button">
|
||||
{recommending ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Wand2 size={16} />
|
||||
)}
|
||||
生成推荐
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-sand-500">
|
||||
推荐基于你的最近观看历史。点击标题在媒体库中查找。
|
||||
</p>
|
||||
|
||||
{recs && recs.length > 0 && (
|
||||
<ul className="grid gap-2 sm:grid-cols-2">
|
||||
{recs.map((t, i) => (
|
||||
<li key={i}>
|
||||
<Link
|
||||
to={`/search?q=${encodeURIComponent(t)}`}
|
||||
className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-ink-200 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<span className="truncate">{t}</span>
|
||||
<Search size={14} className="shrink-0 opacity-60" />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{recs && recs.length === 0 && (
|
||||
<p className="text-sm text-ink-50">
|
||||
还没有推荐结果 — 先去看几部片子,我再给你挑。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
<AIAssistantRecommendationsSection
|
||||
recs={assistant.recs}
|
||||
recommending={assistant.recommending}
|
||||
onRecommend={assistant.onRecommend}
|
||||
/>
|
||||
|
||||
{/* Decorative footer (mirrors the Vue page hint that AI runs locally). */}
|
||||
{!status?.enabled && (
|
||||
{!assistant.status?.enabled && (
|
||||
<p className="text-xs text-sand-500">
|
||||
提示: 当前未配置外部 AI Provider,系统将使用本地规则引擎解析查询。
|
||||
管理员可在 <Link to="/admin?tab=api" className="text-brand-500">API 配置</Link>{' '}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Loader2, Search, Wand2 } from 'lucide-react'
|
||||
|
||||
type AIAssistantRecommendationsSectionProps = {
|
||||
recs: string[] | null
|
||||
recommending: boolean
|
||||
onRecommend: () => void
|
||||
}
|
||||
|
||||
export function AIAssistantRecommendationsSection({
|
||||
recs,
|
||||
recommending,
|
||||
onRecommend,
|
||||
}: AIAssistantRecommendationsSectionProps) {
|
||||
return (
|
||||
<section className="glass-panel space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">为你推荐</h2>
|
||||
<button onClick={onRecommend} disabled={recommending} className="neon-button">
|
||||
{recommending ? <Loader2 size={16} className="animate-spin" /> : <Wand2 size={16} />}
|
||||
生成推荐
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-sand-500">推荐基于你的最近观看历史。点击标题在媒体库中查找。</p>
|
||||
|
||||
{recs && recs.length > 0 && (
|
||||
<ul className="grid gap-2 sm:grid-cols-2">
|
||||
{recs.map((title, index) => (
|
||||
<li key={index}>
|
||||
<Link
|
||||
to={`/search?q=${encodeURIComponent(title)}`}
|
||||
className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-ink-200 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
<Search size={14} className="shrink-0 opacity-60" />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{recs && recs.length === 0 && (
|
||||
<p className="text-sm text-ink-50">还没有推荐结果 — 先去看几部片子,我再给你挑。</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import { Loader2, Search } from 'lucide-react'
|
||||
|
||||
import type { ExternalMediaResult, SearchIntent } from '../api/ai'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { Media } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { seriesCardLink } from '../utils/groupSeries'
|
||||
import { AIAssistantExternalResults } from './AIAssistantExternalResults'
|
||||
|
||||
type AIAssistantSearchSectionProps = {
|
||||
query: string
|
||||
searching: boolean
|
||||
intent: SearchIntent | null
|
||||
items: Media[]
|
||||
localCards: SeriesCard[]
|
||||
externalItems: ExternalMediaResult[]
|
||||
onSearch: (event: FormEvent) => void
|
||||
setQuery: (query: string) => void
|
||||
}
|
||||
|
||||
const quickHints = [
|
||||
'2023 年的科幻电影',
|
||||
'评分高的动漫',
|
||||
'最近添加的纪录片',
|
||||
'中文剧集',
|
||||
]
|
||||
|
||||
export function AIAssistantSearchSection({
|
||||
query,
|
||||
searching,
|
||||
intent,
|
||||
items,
|
||||
localCards,
|
||||
externalItems,
|
||||
onSearch,
|
||||
setQuery,
|
||||
}: AIAssistantSearchSectionProps) {
|
||||
return (
|
||||
<section className="glass-panel space-y-4">
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">智能搜索</h2>
|
||||
<form onSubmit={onSearch} className="flex flex-wrap gap-2">
|
||||
<input
|
||||
className="input-base flex-1"
|
||||
placeholder="试试: 2023 年的高分动作片"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={searching} className="neon-button">
|
||||
{searching ? <Loader2 size={16} className="animate-spin" /> : <Search size={16} />}
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickHints.map((hint) => (
|
||||
<button
|
||||
key={hint}
|
||||
onClick={() => setQuery(hint)}
|
||||
className="rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
{hint}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SearchIntentSummary intent={intent} />
|
||||
<LocalMediaResults localCards={localCards} itemCount={items.length} />
|
||||
<AIAssistantExternalResults items={externalItems} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchIntentSummary({ intent }: { intent: SearchIntent | null }) {
|
||||
if (!intent) return null
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-xs text-ink-100">
|
||||
<div className="mb-1 font-semibold text-ink-200">解析结果</div>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1">
|
||||
<span>
|
||||
查询: <span className="text-brand-500">{intent.query || '—'}</span>
|
||||
</span>
|
||||
{intent.year !== undefined && intent.year > 0 && (
|
||||
<span>
|
||||
年份: <span className="text-brand-500">{intent.year}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.genre && (
|
||||
<span>
|
||||
类型: <span className="text-brand-500">{intent.genre}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.type && (
|
||||
<span>
|
||||
分类: <span className="text-brand-500">{intent.type}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.sort && (
|
||||
<span>
|
||||
排序: <span className="text-brand-500">{intent.sort}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.language && (
|
||||
<span>
|
||||
语言: <span className="text-brand-500">{intent.language}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LocalMediaResults({
|
||||
localCards,
|
||||
itemCount,
|
||||
}: {
|
||||
localCards: SeriesCard[]
|
||||
itemCount: number
|
||||
}) {
|
||||
if (localCards.length === 0) return null
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-semibold text-ink-100">
|
||||
本地媒体库 · {localCards.length} 个合集 / {itemCount} 个条目
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{localCards.map((card) => (
|
||||
<MediaCard
|
||||
key={card.key}
|
||||
media={card.rep}
|
||||
count={card.count}
|
||||
linkTo={seriesCardLink(card)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import toast from 'react-hot-toast'
|
||||
import { Eye, KeyRound, Save, Trash2 } from 'lucide-react'
|
||||
|
||||
import { apiConfigsAPI, type APIConfig } from '../api/api_configs'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
|
||||
// APIConfigsPage manages third-party API keys (TMDb / Bangumi / TheTVDB /
|
||||
// Fanart / OpenAI / Douban). Plaintext keys are never returned by the
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library } from '../types'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
|
||||
export function AdminLibraryPanel() {
|
||||
const [libs, setLibs] = useState<Library[]>([])
|
||||
const [name, setName] = useState('')
|
||||
const [path, setPath] = useState('')
|
||||
const [type, setType] = useState('movie')
|
||||
|
||||
const refresh = () => libraryAPI.list({ includeHidden: true }).then(setLibs)
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const handleCreate = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await libraryAPI.create(name, path, type)
|
||||
toast.success('媒体库已创建')
|
||||
setName('')
|
||||
setPath('')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'创建失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-4">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="名称"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
required
|
||||
className="input-base md:col-span-2"
|
||||
placeholder="容器路径,如 /media/电视剧/国产剧"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
/>
|
||||
<p className="md:col-span-4 -mt-2 text-xs text-sand-500">
|
||||
Docker 部署时请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧;如果误填 NAS
|
||||
宿主机路径,系统会尝试按 compose 挂载自动转换。
|
||||
</p>
|
||||
<select className="input-base" value={type} onChange={(e) => setType(e.target.value)}>
|
||||
<option value="movie">电影</option>
|
||||
<option value="tv">电视剧</option>
|
||||
<option value="variety">综艺</option>
|
||||
<option value="anime">动漫</option>
|
||||
<option value="music">音乐</option>
|
||||
</select>
|
||||
<button type="submit" className="neon-button md:col-span-4">
|
||||
新建媒体库
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="glass-panel">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
<th>路径</th>
|
||||
<th>类型</th>
|
||||
<th className="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{libs.map((l) => (
|
||||
<tr key={l.id} className="border-t border-gray-200">
|
||||
<td className="py-2 text-ink-600">{l.name}</td>
|
||||
<td className="text-ink-100">{l.path}</td>
|
||||
<td className="text-ink-100">{l.type}</td>
|
||||
<td className="space-x-2 py-2 text-right">
|
||||
<button
|
||||
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
onClick={async () => {
|
||||
const r = await libraryAPI.scan(l.id)
|
||||
if (r.queued) toast.success('云盘扫描已加入后台队列,会自动入库')
|
||||
else toast.success(`扫描完成,新增 ${r.added},更新 ${r.updated ?? 0}`)
|
||||
}}
|
||||
>
|
||||
扫描
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
onClick={async () => {
|
||||
if (!(await confirmAction({ title: '删除媒体库', message: `确定删除「${l.name}」?`, confirmText: '删除' }))) return
|
||||
await libraryAPI.remove(l.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+9
-394
@@ -1,16 +1,10 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import { KeyRound, Loader2, Pencil, Plus, ShieldCheck, Trash2, UserCheck, UserX, X } from 'lucide-react'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { licenseAPI, type LicenseStatus } from '../api/license'
|
||||
import type { Library, User } from '../types'
|
||||
import { APIConfigsPanel } from '../components/APIConfigsPanel'
|
||||
import { ManagementShortcuts } from '../components/ManagementShortcuts'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { requestPassword } from '../components/PasswordDialog'
|
||||
import { AdminLibraryPanel } from './AdminLibraryPanel'
|
||||
import { AdminUsersPanel } from './AdminUsersPanel'
|
||||
|
||||
type AdminTab = 'library' | 'users' | 'api'
|
||||
|
||||
@@ -44,10 +38,10 @@ export function AdminPage() {
|
||||
title="统一管理入口"
|
||||
description="侧栏保持精简,完整管理能力统一从这里进入。"
|
||||
items={[
|
||||
{ to: '/sites', title: '站点管理', description: '维护 PT 站点、认证方式和检索配置' },
|
||||
{ to: '/download-clients', title: '下载器管理', description: '配置 qBittorrent 等下载器连接', badge: '下载' },
|
||||
{ to: '/files', title: '手动整理', description: '从下载目录选择文件夹并整理入库' },
|
||||
{ to: '/storage', title: '存储与文件', description: '查看占用、清理重复项和管理文件' },
|
||||
{ to: '/sites', title: '站点管理', description: '维护 PT 站点、认证方式和检索配置', group: '站点与下载' },
|
||||
{ to: '/download-clients', title: '下载器管理', description: '配置 qBittorrent 等下载器连接', badge: '下载', group: '站点与下载' },
|
||||
{ to: '/files', title: '手动整理', description: '从下载目录选择文件夹并整理入库', group: '文件与入库' },
|
||||
{ to: '/storage', title: '存储与文件', description: '查看占用、清理重复项和管理文件', group: '文件与入库' },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2 border-b border-gray-200">
|
||||
@@ -67,388 +61,9 @@ export function AdminPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'library' && <LibraryPanel />}
|
||||
{tab === 'users' && <UsersPanel />}
|
||||
{tab === 'library' && <AdminLibraryPanel />}
|
||||
{tab === 'users' && <AdminUsersPanel />}
|
||||
{tab === 'api' && <APIConfigsPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LibraryPanel() {
|
||||
const [libs, setLibs] = useState<Library[]>([])
|
||||
const [name, setName] = useState('')
|
||||
const [path, setPath] = useState('')
|
||||
const [type, setType] = useState('movie')
|
||||
|
||||
const refresh = () => libraryAPI.list({ includeHidden: true }).then(setLibs)
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const handleCreate = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await libraryAPI.create(name, path, type)
|
||||
toast.success('媒体库已创建')
|
||||
setName('')
|
||||
setPath('')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'创建失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-4">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="名称"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
required
|
||||
className="input-base md:col-span-2"
|
||||
placeholder="容器路径,如 /media/电视剧/国产剧"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
/>
|
||||
<p className="md:col-span-4 -mt-2 text-xs text-sand-500">
|
||||
Docker 部署时请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧;如果误填 NAS
|
||||
宿主机路径,系统会尝试按 compose 挂载自动转换。
|
||||
</p>
|
||||
<select className="input-base" value={type} onChange={(e) => setType(e.target.value)}>
|
||||
<option value="movie">电影</option>
|
||||
<option value="tv">电视剧</option>
|
||||
<option value="variety">综艺</option>
|
||||
<option value="anime">动漫</option>
|
||||
<option value="music">音乐</option>
|
||||
</select>
|
||||
<button type="submit" className="neon-button md:col-span-4">
|
||||
新建媒体库
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="glass-panel">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
<th>路径</th>
|
||||
<th>类型</th>
|
||||
<th className="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{libs.map((l) => (
|
||||
<tr key={l.id} className="border-t border-gray-200">
|
||||
<td className="py-2 text-ink-600">{l.name}</td>
|
||||
<td className="text-ink-100">{l.path}</td>
|
||||
<td className="text-ink-100">{l.type}</td>
|
||||
<td className="space-x-2 py-2 text-right">
|
||||
<button
|
||||
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
onClick={async () => {
|
||||
const r = await libraryAPI.scan(l.id)
|
||||
if (r.queued) toast.success('云盘扫描已加入后台队列,会自动入库')
|
||||
else toast.success(`扫描完成,新增 ${r.added},更新 ${r.updated ?? 0}`)
|
||||
}}
|
||||
>
|
||||
扫描
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
onClick={async () => {
|
||||
if (!(await confirmAction({ title: '删除媒体库', message: `确定删除「${l.name}」?`, confirmText: '删除' }))) return
|
||||
await libraryAPI.remove(l.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UsersPanel() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [licenseStatus, setLicenseStatus] = useState<LicenseStatus | null>(null)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [editingID, setEditingID] = useState<string | null>(null)
|
||||
const [editingUsername, setEditingUsername] = useState('')
|
||||
const [resettingPasswordID, setResettingPasswordID] = useState<string | null>(null)
|
||||
const refresh = async () => {
|
||||
const [nextUsers, nextLicense] = await Promise.all([
|
||||
adminAPI.listUsers(),
|
||||
licenseAPI.status().catch(() => null),
|
||||
])
|
||||
setUsers(nextUsers)
|
||||
setLicenseStatus(nextLicense)
|
||||
}
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const unlimitedUsers =
|
||||
licenseStatus?.active === true &&
|
||||
(licenseStatus.unlimited_users === true || licenseStatus.max_users == null)
|
||||
const maxUsers = unlimitedUsers ? null : (licenseStatus?.max_users ?? 20)
|
||||
const userLimitReached = maxUsers != null && users.length >= maxUsers
|
||||
const userLimitLabel = unlimitedUsers ? '不限制' : String(maxUsers)
|
||||
|
||||
const handleCreate = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await adminAPI.createUser({ username, password })
|
||||
toast.success('用户已添加,默认仅允许浏览与播放媒体')
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
userCreateErrorMessage(err) ??
|
||||
'添加用户失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = (u: User) => {
|
||||
setEditingID(u.id)
|
||||
setEditingUsername(u.username)
|
||||
}
|
||||
|
||||
const saveEdit = async (id: string) => {
|
||||
try {
|
||||
await adminAPI.updateUser(id, { username: editingUsername })
|
||||
toast.success('用户名已更新')
|
||||
setEditingID(null)
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'更新失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const resetPassword = async (u: User) => {
|
||||
if (resettingPasswordID) return
|
||||
const nextPassword = await requestPassword({
|
||||
title: `重置 ${u.username} 的密码`,
|
||||
message: '请输入新的临时密码,至少 6 位。保存后该用户可立即使用新密码登录 Web、Bot 与第三方客户端。',
|
||||
confirmText: '重置密码',
|
||||
})
|
||||
if (!nextPassword) return
|
||||
if (nextPassword.length < 6) {
|
||||
toast.error('新密码至少 6 位')
|
||||
return
|
||||
}
|
||||
setResettingPasswordID(u.id)
|
||||
try {
|
||||
await adminAPI.resetUserPassword(u.id, nextPassword)
|
||||
toast.success('密码已重置')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'重置密码失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setResettingPasswordID(null)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStatus = async (u: User) => {
|
||||
const next = !u.is_active
|
||||
if (!next && u.is_protected) {
|
||||
toast.error('受保护管理员不可禁用')
|
||||
return
|
||||
}
|
||||
if (
|
||||
!next &&
|
||||
!(await confirmAction({
|
||||
title: '禁用用户',
|
||||
message: `禁用「${u.username}」后,Web 与第三方客户端已有登录也会失效。`,
|
||||
confirmText: '禁用',
|
||||
}))
|
||||
) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await adminAPI.setUserStatus(u.id, next)
|
||||
toast.success(next ? '用户已解禁' : '用户已禁用')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'操作失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
||||
<div className="md:col-span-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">用户管理</h2>
|
||||
<p className="text-xs text-sand-500">
|
||||
已创建 {users.length}/{userLimitLabel} 个用户;新增用户默认只有媒体库浏览、播放、外部播放器与第三方客户端观看权限。
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-primary-400/30 px-3 py-1 text-xs text-brand-500">
|
||||
默认管理员不可删除 · 最高权限
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={userLimitReached}
|
||||
/>
|
||||
<input
|
||||
required
|
||||
minLength={6}
|
||||
className="input-base"
|
||||
placeholder="初始密码(至少 6 位)"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={userLimitReached}
|
||||
/>
|
||||
<button type="submit" className="neon-button inline-flex items-center justify-center gap-2" disabled={userLimitReached}>
|
||||
<Plus size={16} />
|
||||
添加用户
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="glass-panel overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">用户名</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>权限说明</th>
|
||||
<th>最近登录</th>
|
||||
<th className="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-t border-gray-200">
|
||||
<td className="py-2 text-ink-600">
|
||||
{editingID === u.id ? (
|
||||
<input
|
||||
className="input-base h-9 max-w-48"
|
||||
value={editingUsername}
|
||||
onChange={(e) => setEditingUsername(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{u.username}
|
||||
{u.is_default_admin && <ShieldCheck size={15} className="text-brand-500" />}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-ink-100">{u.role === 'admin' ? '管理员' : '观看用户'}</td>
|
||||
<td className={u.is_active ? 'text-green-500' : 'text-red-400'}>
|
||||
{u.is_active ? '正常' : '已禁用'}
|
||||
</td>
|
||||
<td className="text-ink-50">
|
||||
{u.role === 'admin' ? '全部管理权限' : '仅浏览/播放/外部播放器,无下载与文件操作'}
|
||||
</td>
|
||||
<td className="text-ink-50">
|
||||
{u.last_login_at ? new Date(u.last_login_at).toLocaleString() : '从未登录'}
|
||||
</td>
|
||||
<td className="space-x-2 py-2 text-right">
|
||||
{editingID === u.id ? (
|
||||
<>
|
||||
<button
|
||||
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
onClick={() => saveEdit(u.id)}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-gray-300 px-2 py-1 text-xs text-ink-100 hover:bg-gray-100"
|
||||
onClick={() => setEditingID(null)}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
onClick={() => startEdit(u)}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="rounded-lg border border-amber-400/40 px-2 py-1 text-xs text-amber-500 hover:bg-amber-400/10"
|
||||
title="重置密码"
|
||||
disabled={resettingPasswordID === u.id}
|
||||
onClick={() => resetPassword(u)}
|
||||
>
|
||||
{resettingPasswordID === u.id ? <Loader2 size={12} className="animate-spin" /> : <KeyRound size={12} />}
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
'rounded-lg border px-2 py-1 text-xs disabled:cursor-not-allowed disabled:opacity-40 ' +
|
||||
(u.is_active
|
||||
? 'border-orange-400/40 text-orange-500 hover:bg-orange-400/10'
|
||||
: 'border-green-400/40 text-green-500 hover:bg-green-400/10')
|
||||
}
|
||||
disabled={u.is_protected && u.is_active}
|
||||
title={u.is_active ? '禁用用户' : '解禁用户'}
|
||||
onClick={() => toggleStatus(u)}
|
||||
>
|
||||
{u.is_active ? <UserX size={12} /> : <UserCheck size={12} />}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={u.is_protected}
|
||||
title={u.is_protected ? '默认管理员禁止删除' : '删除用户'}
|
||||
onClick={async () => {
|
||||
if (u.is_protected) return
|
||||
if (!(await confirmAction({ title: '删除用户', message: `确定删除「${u.username}」?`, confirmText: '删除' }))) return
|
||||
await adminAPI.deleteUser(u.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function userCreateErrorMessage(err: unknown): string | undefined {
|
||||
const data = (err as { response?: { data?: { error?: string; max_users?: number } } })?.response?.data
|
||||
if (!data?.error) return undefined
|
||||
if (data.error === 'user limit reached' && data.max_users != null) {
|
||||
return `用户数量已达到授权上限:${data.max_users} 人`
|
||||
}
|
||||
return data.error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { FormEvent } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
|
||||
type AdminUsersFormProps = {
|
||||
usersCount: number
|
||||
userLimitLabel: string
|
||||
username: string
|
||||
password: string
|
||||
userLimitReached: boolean
|
||||
onUsernameChange: (value: string) => void
|
||||
onPasswordChange: (value: string) => void
|
||||
onSubmit: (e: FormEvent) => void
|
||||
}
|
||||
|
||||
export function AdminUsersForm({
|
||||
usersCount,
|
||||
userLimitLabel,
|
||||
username,
|
||||
password,
|
||||
userLimitReached,
|
||||
onUsernameChange,
|
||||
onPasswordChange,
|
||||
onSubmit,
|
||||
}: AdminUsersFormProps) {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
||||
<div className="md:col-span-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">用户管理</h2>
|
||||
<p className="text-xs text-sand-500">
|
||||
已创建 {usersCount}/{userLimitLabel} 个用户;新增用户默认只有媒体库浏览、播放、外部播放器与第三方客户端观看权限。
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-primary-400/30 px-3 py-1 text-xs text-brand-500">
|
||||
默认管理员不可删除 · 最高权限
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={(e) => onUsernameChange(e.target.value)}
|
||||
disabled={userLimitReached}
|
||||
/>
|
||||
<input
|
||||
required
|
||||
minLength={6}
|
||||
className="input-base"
|
||||
placeholder="初始密码(至少 6 位)"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => onPasswordChange(e.target.value)}
|
||||
disabled={userLimitReached}
|
||||
/>
|
||||
<button type="submit" className="neon-button inline-flex items-center justify-center gap-2" disabled={userLimitReached}>
|
||||
<Plus size={16} />
|
||||
添加用户
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import { licenseAPI, type LicenseStatus } from '../api/license'
|
||||
import type { User } from '../types'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import { requestPassword } from '../components/requestPassword'
|
||||
import { AdminUsersForm } from './AdminUsersForm'
|
||||
import { AdminUsersTable } from './AdminUsersTable'
|
||||
|
||||
export function AdminUsersPanel() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [licenseStatus, setLicenseStatus] = useState<LicenseStatus | null>(null)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [editingID, setEditingID] = useState<string | null>(null)
|
||||
const [editingUsername, setEditingUsername] = useState('')
|
||||
const [resettingPasswordID, setResettingPasswordID] = useState<string | null>(null)
|
||||
const refresh = async () => {
|
||||
const [nextUsers, nextLicense] = await Promise.all([
|
||||
adminAPI.listUsers(),
|
||||
licenseAPI.status().catch(() => null),
|
||||
])
|
||||
setUsers(nextUsers)
|
||||
setLicenseStatus(nextLicense)
|
||||
}
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
const timer = window.setInterval(() => refresh().catch(() => undefined), 10000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const unlimitedUsers =
|
||||
licenseStatus?.active === true &&
|
||||
(licenseStatus.unlimited_users === true || licenseStatus.max_users == null)
|
||||
const maxUsers = unlimitedUsers ? null : (licenseStatus?.max_users ?? 20)
|
||||
const userLimitReached = maxUsers != null && users.length >= maxUsers
|
||||
const userLimitLabel = unlimitedUsers ? '不限制' : String(maxUsers)
|
||||
|
||||
const handleCreate = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await adminAPI.createUser({ username, password })
|
||||
toast.success('用户已添加,默认仅允许浏览与播放媒体')
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
userCreateErrorMessage(err) ??
|
||||
'添加用户失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = (u: User) => {
|
||||
setEditingID(u.id)
|
||||
setEditingUsername(u.username)
|
||||
}
|
||||
|
||||
const saveEdit = async (id: string) => {
|
||||
try {
|
||||
await adminAPI.updateUser(id, { username: editingUsername })
|
||||
toast.success('用户名已更新')
|
||||
setEditingID(null)
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'更新失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const resetPassword = async (u: User) => {
|
||||
if (resettingPasswordID) return
|
||||
const nextPassword = await requestPassword({
|
||||
title: `重置 ${u.username} 的密码`,
|
||||
message: '请输入新的临时密码,至少 6 位。保存后该用户可立即使用新密码登录 Web、Bot 与第三方客户端。',
|
||||
confirmText: '重置密码',
|
||||
})
|
||||
if (!nextPassword) return
|
||||
if (nextPassword.length < 6) {
|
||||
toast.error('新密码至少 6 位')
|
||||
return
|
||||
}
|
||||
setResettingPasswordID(u.id)
|
||||
try {
|
||||
await adminAPI.resetUserPassword(u.id, nextPassword)
|
||||
toast.success('密码已重置')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'重置密码失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setResettingPasswordID(null)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStatus = async (u: User) => {
|
||||
const next = !u.is_active
|
||||
if (!next && u.is_protected) {
|
||||
toast.error('受保护管理员不可禁用')
|
||||
return
|
||||
}
|
||||
if (
|
||||
!next &&
|
||||
!(await confirmAction({
|
||||
title: '禁用用户',
|
||||
message: `禁用「${u.username}」后,Web 与第三方客户端已有登录也会失效。`,
|
||||
confirmText: '禁用',
|
||||
}))
|
||||
) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await adminAPI.setUserStatus(u.id, next)
|
||||
toast.success(next ? '用户已解禁' : '用户已禁用')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'操作失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteUser = async (u: User) => {
|
||||
if (u.is_protected) return
|
||||
if (!(await confirmAction({ title: '删除用户', message: `确定删除「${u.username}」?`, confirmText: '删除' }))) return
|
||||
await adminAPI.deleteUser(u.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AdminUsersForm
|
||||
usersCount={users.length}
|
||||
userLimitLabel={userLimitLabel}
|
||||
username={username}
|
||||
password={password}
|
||||
userLimitReached={userLimitReached}
|
||||
onUsernameChange={setUsername}
|
||||
onPasswordChange={setPassword}
|
||||
onSubmit={handleCreate}
|
||||
/>
|
||||
|
||||
<AdminUsersTable
|
||||
users={users}
|
||||
editingID={editingID}
|
||||
editingUsername={editingUsername}
|
||||
resettingPasswordID={resettingPasswordID}
|
||||
onEditingUsernameChange={setEditingUsername}
|
||||
onSaveEdit={saveEdit}
|
||||
onCancelEdit={() => setEditingID(null)}
|
||||
onStartEdit={startEdit}
|
||||
onResetPassword={resetPassword}
|
||||
onToggleStatus={toggleStatus}
|
||||
onDeleteUser={deleteUser}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function userCreateErrorMessage(err: unknown): string | undefined {
|
||||
const data = (err as { response?: { data?: { error?: string; max_users?: number } } })?.response?.data
|
||||
if (!data?.error) return undefined
|
||||
if (data.error === 'user limit reached' && data.max_users != null) {
|
||||
return `用户数量已达到授权上限:${data.max_users} 人`
|
||||
}
|
||||
return data.error
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { KeyRound, Loader2, Pencil, ShieldCheck, Trash2, UserCheck, UserX, X } from 'lucide-react'
|
||||
|
||||
import type { User } from '../types'
|
||||
|
||||
type AdminUsersTableProps = {
|
||||
users: User[]
|
||||
editingID: string | null
|
||||
editingUsername: string
|
||||
resettingPasswordID: string | null
|
||||
onEditingUsernameChange: (value: string) => void
|
||||
onSaveEdit: (id: string) => void
|
||||
onCancelEdit: () => void
|
||||
onStartEdit: (user: User) => void
|
||||
onResetPassword: (user: User) => void
|
||||
onToggleStatus: (user: User) => void
|
||||
onDeleteUser: (user: User) => void
|
||||
}
|
||||
|
||||
export function AdminUsersTable({
|
||||
users,
|
||||
editingID,
|
||||
editingUsername,
|
||||
resettingPasswordID,
|
||||
onEditingUsernameChange,
|
||||
onSaveEdit,
|
||||
onCancelEdit,
|
||||
onStartEdit,
|
||||
onResetPassword,
|
||||
onToggleStatus,
|
||||
onDeleteUser,
|
||||
}: AdminUsersTableProps) {
|
||||
return (
|
||||
<div className="glass-panel overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">用户名</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>权限说明</th>
|
||||
<th>最近登录</th>
|
||||
<th className="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-t border-gray-200">
|
||||
<td className="py-2 text-ink-600">
|
||||
{editingID === u.id ? (
|
||||
<input
|
||||
className="input-base h-9 max-w-48"
|
||||
value={editingUsername}
|
||||
onChange={(e) => onEditingUsernameChange(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{u.username}
|
||||
{u.is_default_admin && <ShieldCheck size={15} className="text-brand-500" />}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-ink-100">{u.role === 'admin' ? '管理员' : '观看用户'}</td>
|
||||
<td className={u.is_active ? 'text-green-500' : 'text-red-400'}>
|
||||
{u.is_active ? '正常' : '已禁用'}
|
||||
</td>
|
||||
<td className="text-ink-50">
|
||||
{u.role === 'admin' ? '全部管理权限' : '仅浏览/播放/外部播放器,无下载与文件操作'}
|
||||
</td>
|
||||
<td className="text-ink-50">
|
||||
<span className="inline-flex flex-wrap items-center gap-2">
|
||||
<span>{u.last_login_at ? new Date(u.last_login_at).toLocaleString() : '从未登录'}</span>
|
||||
{u.realtime_online && <span className="rounded border border-green-400/40 px-1.5 py-0.5 text-[11px] text-green-500">在线</span>}
|
||||
{(u.realtime_device_count ?? 0) > 0 && <span className="text-xs text-ink-50">{u.realtime_device_count} 台</span>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="space-x-2 py-2 text-right">
|
||||
{editingID === u.id ? (
|
||||
<>
|
||||
<button
|
||||
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
onClick={() => onSaveEdit(u.id)}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-gray-300 px-2 py-1 text-xs text-ink-100 hover:bg-gray-100"
|
||||
onClick={onCancelEdit}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
onClick={() => onStartEdit(u)}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="rounded-lg border border-amber-400/40 px-2 py-1 text-xs text-amber-500 hover:bg-amber-400/10"
|
||||
title="重置密码"
|
||||
disabled={resettingPasswordID === u.id}
|
||||
onClick={() => onResetPassword(u)}
|
||||
>
|
||||
{resettingPasswordID === u.id ? <Loader2 size={12} className="animate-spin" /> : <KeyRound size={12} />}
|
||||
</button>
|
||||
<button
|
||||
className={
|
||||
'rounded-lg border px-2 py-1 text-xs disabled:cursor-not-allowed disabled:opacity-40 ' +
|
||||
(u.is_active
|
||||
? 'border-orange-400/40 text-orange-500 hover:bg-orange-400/10'
|
||||
: 'border-green-400/40 text-green-500 hover:bg-green-400/10')
|
||||
}
|
||||
disabled={u.is_protected && u.is_active}
|
||||
title={u.is_active ? '禁用用户' : '解禁用户'}
|
||||
onClick={() => onToggleStatus(u)}
|
||||
>
|
||||
{u.is_active ? <UserX size={12} /> : <UserCheck size={12} />}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={u.is_protected}
|
||||
title={u.is_protected ? '默认管理员禁止删除' : '删除用户'}
|
||||
onClick={() => onDeleteUser(u)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type AssistantSession,
|
||||
type SessionView,
|
||||
} from '../api/assistant'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
|
||||
// AssistantChatPage is the multi-turn chat surface backed by the Go
|
||||
// AssistantService. It complements the older AIAssistantPage which is
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
import {
|
||||
type AutoOrganizeConfig,
|
||||
type AutoOrganizeTab,
|
||||
} from './autoOrganizeModel'
|
||||
import {
|
||||
AutoOrganizeBasicTab,
|
||||
AutoOrganizeNamingTab,
|
||||
AutoOrganizeScrapeTab,
|
||||
} from './AutoOrganizeSettingsTabs'
|
||||
|
||||
type AutoOrganizeSettingsPanelProps = {
|
||||
config: AutoOrganizeConfig
|
||||
currentDir: string
|
||||
activeTab: AutoOrganizeTab
|
||||
dirty: boolean
|
||||
loading: boolean
|
||||
saving: boolean
|
||||
running: boolean
|
||||
moveKeepsSeeding: boolean
|
||||
onRefresh: () => void
|
||||
onSave: () => void
|
||||
onRunNow: () => void
|
||||
onTabChange: (tab: AutoOrganizeTab) => void
|
||||
onConfigChange: (key: keyof AutoOrganizeConfig, value: string) => void
|
||||
}
|
||||
|
||||
const AUTO_ORGANIZE_TABS: Array<[AutoOrganizeTab, string]> = [
|
||||
['basic', '基础设置'],
|
||||
['naming', '命名规则'],
|
||||
['scrape', '刮削联动'],
|
||||
]
|
||||
|
||||
export function AutoOrganizeSettingsPanel({
|
||||
config,
|
||||
currentDir,
|
||||
activeTab,
|
||||
dirty,
|
||||
loading,
|
||||
saving,
|
||||
running,
|
||||
moveKeepsSeeding,
|
||||
onRefresh,
|
||||
onSave,
|
||||
onRunNow,
|
||||
onTabChange,
|
||||
onConfigChange,
|
||||
}: AutoOrganizeSettingsPanelProps) {
|
||||
return (
|
||||
<section className="glass-panel space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">自动整理设置</h2>
|
||||
<p className="text-xs text-sand-500">
|
||||
设置后可自动递归扫描下载/待整理目录,整理到媒体库目录;也可以在这里立即执行一次。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="neon-button !border-primary-400/30 !bg-white !text-brand-500"
|
||||
disabled={loading || saving}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw size={14} /> 重新读取
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="neon-button !border-primary-400/30 !bg-white !text-brand-500"
|
||||
disabled={loading || saving || !dirty}
|
||||
onClick={onSave}
|
||||
>
|
||||
{saving ? '保存中…' : '保存设置'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="neon-button"
|
||||
disabled={loading || saving || running}
|
||||
onClick={onRunNow}
|
||||
>
|
||||
{running ? '执行中…' : '立即整理一次'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 rounded-2xl border border-gray-200 bg-gray-50 p-1">
|
||||
{AUTO_ORGANIZE_TABS.map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={
|
||||
activeTab === key
|
||||
? 'rounded-xl bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 shadow-sm'
|
||||
: 'rounded-xl px-3 py-1.5 text-xs text-ink-100 hover:bg-white/70'
|
||||
}
|
||||
onClick={() => onTabChange(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-auto self-center px-2 text-xs text-sand-500">
|
||||
{dirty ? '有未保存设置' : '设置已同步'} · 定时任务名:organize_source
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{activeTab === 'basic' && (
|
||||
<AutoOrganizeBasicTab
|
||||
config={config}
|
||||
currentDir={currentDir}
|
||||
moveKeepsSeeding={moveKeepsSeeding}
|
||||
onConfigChange={onConfigChange}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'naming' && (
|
||||
<AutoOrganizeNamingTab config={config} onConfigChange={onConfigChange} />
|
||||
)}
|
||||
{activeTab === 'scrape' && (
|
||||
<AutoOrganizeScrapeTab config={config} onConfigChange={onConfigChange} />
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
type AutoOrganizeConfig,
|
||||
settingOn,
|
||||
} from './autoOrganizeModel'
|
||||
|
||||
type ConfigChangeHandler = (key: keyof AutoOrganizeConfig, value: string) => void
|
||||
|
||||
type AutoOrganizeTabProps = {
|
||||
config: AutoOrganizeConfig
|
||||
onConfigChange: ConfigChangeHandler
|
||||
}
|
||||
|
||||
export function AutoOrganizeBasicTab({
|
||||
config,
|
||||
currentDir,
|
||||
moveKeepsSeeding,
|
||||
onConfigChange,
|
||||
}: AutoOrganizeTabProps & {
|
||||
currentDir: string
|
||||
moveKeepsSeeding: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_150px_140px]">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-ink-50">整理源目录(待整理 / 下载目录)</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input-base w-full"
|
||||
placeholder="例如 F:\\downloads 或 /downloads"
|
||||
value={config.sourceDir}
|
||||
onChange={(event) => onConfigChange('sourceDir', event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40"
|
||||
disabled={!currentDir}
|
||||
onClick={() => onConfigChange('sourceDir', currentDir)}
|
||||
>
|
||||
当前
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-ink-50">整理目的地目录(媒体库根目录)</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input-base w-full"
|
||||
placeholder="例如 F:\\media 或 /media"
|
||||
value={config.targetDir}
|
||||
onChange={(event) => onConfigChange('targetDir', event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40"
|
||||
disabled={!currentDir}
|
||||
onClick={() => onConfigChange('targetDir', currentDir)}
|
||||
>
|
||||
当前
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-ink-50">默认整理方式</span>
|
||||
<select
|
||||
className="input-base w-full"
|
||||
value={config.transferMode}
|
||||
onChange={(event) => onConfigChange('transferMode', event.target.value)}
|
||||
>
|
||||
<option value="hardlink">硬链接</option>
|
||||
<option value="move">移动(关闭保种才会移动)</option>
|
||||
<option value="copy">复制</option>
|
||||
<option value="symlink">软链接</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-ink-50">检查间隔(秒)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={60}
|
||||
className="input-base w-full"
|
||||
value={config.intervalSeconds}
|
||||
onChange={(event) => onConfigChange('intervalSeconds', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{moveKeepsSeeding && (
|
||||
<div className="rounded-xl border border-orange-300 bg-orange-50 px-3 py-2 text-xs text-orange-700">
|
||||
当前同时选择了“移动”和“保种”。为避免 qB 做种源文件被删除,后端会实际使用硬链接;Docker / NAS
|
||||
多挂载或不同子卷下可能报 invalid cross-device link。需要真正移动时请关闭“保种”,需要保种但硬链接失败时请选择“复制”。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<BooleanSetting config={config} settingKey="enabled" label="整理源目录定时自动整理" onConfigChange={onConfigChange} />
|
||||
<BooleanSetting config={config} settingKey="afterDownload" label="qB 下载完成后自动整理" onConfigChange={onConfigChange} />
|
||||
<BooleanSetting config={config} settingKey="downloadSmartClassify" label="下载器智能分类" onConfigChange={onConfigChange} />
|
||||
<BooleanSetting config={config} settingKey="smartClassify" label="智能分类到子库" onConfigChange={onConfigChange} />
|
||||
<BooleanSetting config={config} settingKey="keepSeeding" label="保种" onConfigChange={onConfigChange} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function AutoOrganizeNamingTab({ config, onConfigChange }: AutoOrganizeTabProps) {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<TextSetting config={config} settingKey="movieFormat" label="电影命名格式" className="input-base w-full font-mono text-xs" onConfigChange={onConfigChange} />
|
||||
<TextSetting config={config} settingKey="tvFormat" label="剧集命名格式" className="input-base w-full font-mono text-xs" onConfigChange={onConfigChange} />
|
||||
<TextSetting config={config} settingKey="animeFormat" label="动漫命名格式" className="input-base w-full font-mono text-xs" onConfigChange={onConfigChange} />
|
||||
<p className="text-xs text-sand-500">
|
||||
可用占位符:{'{title}'} {'{year}'} {'{season}'} {'{season:02}'} {'{episode}'} {'{episode:02}'} {'{category}'}。扩展名会自动补齐。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AutoOrganizeScrapeTab({ config, onConfigChange }: AutoOrganizeTabProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<BooleanSetting config={config} settingKey="scrapeAfter" label="整理后自动刮削" onConfigChange={onConfigChange} />
|
||||
<BooleanSetting config={config} settingKey="scrapeAutoOnScan" label="扫描后自动刮削" onConfigChange={onConfigChange} />
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_160px_160px_160px]">
|
||||
<TextSetting
|
||||
config={config}
|
||||
settingKey="scrapeProviders"
|
||||
label="刮削源优先级"
|
||||
placeholder="tmdb,douban,bangumi,thetvdb,fanart"
|
||||
onConfigChange={onConfigChange}
|
||||
/>
|
||||
<TextSetting config={config} settingKey="scrapeLanguage" label="首选语言" onConfigChange={onConfigChange} />
|
||||
<NumberSetting config={config} settingKey="scrapeDelayMinMs" label="最小间隔 ms" onConfigChange={onConfigChange} />
|
||||
<NumberSetting config={config} settingKey="scrapeDelayMaxMs" label="最大间隔 ms" onConfigChange={onConfigChange} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BooleanSetting({
|
||||
config,
|
||||
settingKey,
|
||||
label,
|
||||
onConfigChange,
|
||||
}: AutoOrganizeTabProps & {
|
||||
settingKey: keyof AutoOrganizeConfig
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settingOn(config[settingKey])}
|
||||
onChange={(event) => onConfigChange(settingKey, event.target.checked ? 'true' : 'false')}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function TextSetting({
|
||||
config,
|
||||
settingKey,
|
||||
label,
|
||||
className = 'input-base w-full',
|
||||
placeholder,
|
||||
onConfigChange,
|
||||
}: AutoOrganizeTabProps & {
|
||||
settingKey: keyof AutoOrganizeConfig
|
||||
label: string
|
||||
className?: string
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-ink-50">{label}</span>
|
||||
<input
|
||||
className={className}
|
||||
placeholder={placeholder}
|
||||
value={config[settingKey]}
|
||||
onChange={(event) => onConfigChange(settingKey, event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberSetting({
|
||||
config,
|
||||
settingKey,
|
||||
label,
|
||||
onConfigChange,
|
||||
}: AutoOrganizeTabProps & {
|
||||
settingKey: keyof AutoOrganizeConfig
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-ink-50">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="input-base w-full"
|
||||
value={config[settingKey]}
|
||||
onChange={(event) => onConfigChange(settingKey, event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import {
|
||||
cloudAPI,
|
||||
storageAPI,
|
||||
type CloudEntry,
|
||||
type CloudScanStatus,
|
||||
type StorageType,
|
||||
} from '../api/storage_config'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import type { Library } from '../types'
|
||||
import { CloudBrowserToolbar } from './CloudBrowserToolbar'
|
||||
import { CloudEntryList } from './CloudEntryList'
|
||||
import { CloudMountList } from './CloudMountList'
|
||||
import { CloudScanPanel } from './CloudScanPanel'
|
||||
import {
|
||||
TYPE_LABEL,
|
||||
cloudLibraryProvider,
|
||||
cloudMountDisplayPath,
|
||||
} from './storageConfigModel'
|
||||
|
||||
// Lists cloud directories and imports a file as a 302-backed media.
|
||||
export function CloudBrowser({ type }: { type: StorageType }) {
|
||||
const [stack, setStack] = useState<{ id: string; name: string }[]>([{ id: '', name: '根目录' }])
|
||||
const [items, setItems] = useState<CloudEntry[]>([])
|
||||
const [mounts, setMounts] = useState<Library[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [mounting, setMounting] = useState(false)
|
||||
const [batchMounting, setBatchMounting] = useState(false)
|
||||
const [scanBusy, setScanBusy] = useState(false)
|
||||
const [cancelBusy, setCancelBusy] = useState(false)
|
||||
const [scanStatuses, setScanStatuses] = useState<CloudScanStatus[]>([])
|
||||
const [mountMediaType, setMountMediaType] = useState('auto')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const cur = stack[stack.length - 1]
|
||||
const load = async (dir: string) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const r = await cloudAPI.list(type, dir)
|
||||
setItems(r.items ?? [])
|
||||
if (r.error) setError(r.error)
|
||||
} catch (err: unknown) {
|
||||
setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加载失败')
|
||||
setItems([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMounts = async () => {
|
||||
const libs = await libraryAPI.list({ includeHidden: true })
|
||||
setMounts(libs.filter((lib) => cloudLibraryProvider(lib.path) === type))
|
||||
}
|
||||
|
||||
const loadScanStatus = async () => {
|
||||
const r = await storageAPI.cloudScanStatus()
|
||||
setScanStatuses((r.items ?? []).filter((item) => !type || item.provider === type))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load(cur.id).catch(() => undefined)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stack.length, type])
|
||||
|
||||
useEffect(() => {
|
||||
loadMounts().catch(() => undefined)
|
||||
loadScanStatus().catch(() => undefined)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [type])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
loadScanStatus().catch(() => undefined)
|
||||
}, 3000)
|
||||
return () => window.clearInterval(timer)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [type])
|
||||
|
||||
const enter = (entry: CloudEntry) => setStack((current) => [...current, { id: entry.id, name: entry.name }])
|
||||
const goTo = (index: number) => setStack((current) => current.slice(0, index + 1))
|
||||
const goUp = () => setStack((current) => (current.length > 1 ? current.slice(0, -1) : current))
|
||||
const currentMountPath = () => cloudMountDisplayPath(type, stack)
|
||||
const childMountPath = (child: CloudEntry) => cloudMountDisplayPath(type, stack, child)
|
||||
const currentDir = () => stack[stack.length - 1]?.id ?? ''
|
||||
|
||||
const handleMountResult = (res: unknown, label: string) => {
|
||||
const out = res as { already_mounted?: boolean; skipped?: boolean; reason?: string; library?: Library; message?: string; estimate_message?: string }
|
||||
if (out.skipped) {
|
||||
toast(`已跳过「${label}」:和已挂载目录重叠`)
|
||||
return 'skipped'
|
||||
}
|
||||
if (out.already_mounted) {
|
||||
toast(`「${label}」已经挂载,后台会刷新扫描并自动入库`)
|
||||
return 'mounted'
|
||||
}
|
||||
toast.success(`已挂载「${label}」,${out.message ?? '后台会递归扫描并自动加入媒体库'}。${out.estimate_message ?? ''}`)
|
||||
return 'mounted'
|
||||
}
|
||||
|
||||
const doImport = async (entry: CloudEntry) => {
|
||||
const ref = type === 'cloud115' ? entry.pick_code || entry.id : entry.id
|
||||
try {
|
||||
await cloudAPI.import(type, ref, entry.name, entry.size)
|
||||
toast.success(`已导入「${entry.name}」,可在媒体库中 302 播放`)
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '导入失败')
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeFolderInput = (value: string | null) => {
|
||||
const name = (value ?? '').trim()
|
||||
if (!name) return ''
|
||||
if (name === '.' || name === '..' || /[\\/]/.test(name)) {
|
||||
toast.error('文件夹名称不能包含路径分隔符')
|
||||
return ''
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
const createFolder = async () => {
|
||||
const name = normalizeFolderInput(window.prompt('新建文件夹名称') ?? '')
|
||||
if (!name) return
|
||||
setLoading(true)
|
||||
try {
|
||||
await cloudAPI.mkdir(type, currentDir(), name)
|
||||
toast.success(`已新建文件夹「${name}」`)
|
||||
await load(currentDir())
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '新建文件夹失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renameFolder = async (entry: CloudEntry) => {
|
||||
if (!entry.is_dir) return
|
||||
const name = normalizeFolderInput(window.prompt('重命名文件夹', entry.name) ?? '')
|
||||
if (!name || name === entry.name) return
|
||||
setLoading(true)
|
||||
try {
|
||||
await cloudAPI.rename(type, entry.id, name)
|
||||
toast.success(`已重命名为「${name}」`)
|
||||
await load(currentDir())
|
||||
await loadMounts()
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '重命名失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const mountCurrent = async () => {
|
||||
setMounting(true)
|
||||
try {
|
||||
const label = TYPE_LABEL[type] ?? type
|
||||
const name = cur.id ? cur.name : label
|
||||
const res = await cloudAPI.mount(type, cur.id, name, mountMediaType, currentMountPath())
|
||||
handleMountResult(res, cur.name)
|
||||
await loadMounts()
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '挂载失败')
|
||||
} finally {
|
||||
setMounting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const mountVisibleDirectories = async () => {
|
||||
const dirs = items.filter((item) => item.is_dir)
|
||||
if (dirs.length === 0) {
|
||||
toast.error('当前目录下没有可挂载的子目录')
|
||||
return
|
||||
}
|
||||
setBatchMounting(true)
|
||||
let ok = 0
|
||||
let skipped = 0
|
||||
let failed = 0
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
const result = await cloudAPI.mount(type, dir.id, dir.name, 'auto', childMountPath(dir))
|
||||
const state = handleMountResult(result, dir.name)
|
||||
if (state === 'skipped') skipped += 1
|
||||
else ok += 1
|
||||
} catch {
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
if (failed > 0) {
|
||||
toast.error(`已挂载 ${ok} 个目录,跳过 ${skipped} 个重叠目录,失败 ${failed} 个`)
|
||||
} else {
|
||||
toast.success(`已挂载 ${ok} 个目录,跳过 ${skipped} 个重叠目录,后台会自动生成 302/STRM 播放入口`)
|
||||
}
|
||||
await loadMounts()
|
||||
setBatchMounting(false)
|
||||
}
|
||||
|
||||
const removeMount = async (lib: Library) => {
|
||||
const ok = await confirmAction({
|
||||
title: '移除网盘挂载',
|
||||
message: `仅移除「${lib.name}」在本项目中的媒体库和媒体记录,不会删除网盘文件。`,
|
||||
confirmText: '移除',
|
||||
})
|
||||
if (!ok) return
|
||||
await libraryAPI.remove(lib.id)
|
||||
toast.success('已移除挂载')
|
||||
await loadMounts()
|
||||
}
|
||||
|
||||
const scanAllCloudLibraries = async () => {
|
||||
setScanBusy(true)
|
||||
try {
|
||||
const r = await storageAPI.scanAllCloud()
|
||||
setScanStatuses(r.items ?? [])
|
||||
toast.success(r.message ?? '已开始扫描所有启用的网盘媒体库')
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '启动扫描失败')
|
||||
} finally {
|
||||
setScanBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelCloudScans = async () => {
|
||||
setCancelBusy(true)
|
||||
try {
|
||||
const r = await storageAPI.cancelCloudScan('', type)
|
||||
toast.success(r.message ?? `已中断 ${r.cancelled} 个扫描任务`)
|
||||
await loadScanStatus()
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '中断扫描失败')
|
||||
} finally {
|
||||
setCancelBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] p-3">
|
||||
<CloudScanPanel
|
||||
scanBusy={scanBusy}
|
||||
cancelBusy={cancelBusy}
|
||||
scanStatuses={scanStatuses}
|
||||
onScanAll={scanAllCloudLibraries}
|
||||
onCancelScans={cancelCloudScans}
|
||||
/>
|
||||
<CloudMountList mounts={mounts} onRemove={removeMount} />
|
||||
<CloudBrowserToolbar
|
||||
stack={stack}
|
||||
mountMediaType={mountMediaType}
|
||||
mounting={mounting}
|
||||
batchMounting={batchMounting}
|
||||
loading={loading}
|
||||
hasDirectories={items.some((item) => item.is_dir)}
|
||||
onGoTo={goTo}
|
||||
onGoUp={goUp}
|
||||
onCreateFolder={createFolder}
|
||||
onMediaTypeChange={setMountMediaType}
|
||||
onMountCurrent={mountCurrent}
|
||||
onMountVisibleDirectories={mountVisibleDirectories}
|
||||
/>
|
||||
<CloudEntryList
|
||||
loading={loading}
|
||||
error={error}
|
||||
items={items}
|
||||
onEnter={enter}
|
||||
onImport={doImport}
|
||||
onRename={renameFolder}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ArrowUp, FolderPlus } from 'lucide-react'
|
||||
|
||||
interface CloudBrowserToolbarProps {
|
||||
stack: { id: string; name: string }[]
|
||||
mountMediaType: string
|
||||
mounting: boolean
|
||||
batchMounting: boolean
|
||||
loading: boolean
|
||||
hasDirectories: boolean
|
||||
onGoTo: (index: number) => void
|
||||
onGoUp: () => void
|
||||
onCreateFolder: () => void
|
||||
onMediaTypeChange: (value: string) => void
|
||||
onMountCurrent: () => void
|
||||
onMountVisibleDirectories: () => void
|
||||
}
|
||||
|
||||
export function CloudBrowserToolbar({
|
||||
stack,
|
||||
mountMediaType,
|
||||
mounting,
|
||||
batchMounting,
|
||||
loading,
|
||||
hasDirectories,
|
||||
onGoTo,
|
||||
onGoUp,
|
||||
onCreateFolder,
|
||||
onMediaTypeChange,
|
||||
onMountCurrent,
|
||||
onMountVisibleDirectories,
|
||||
}: CloudBrowserToolbarProps) {
|
||||
return (
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1 text-xs text-[var(--app-muted)]">
|
||||
<span className="text-[var(--app-subtle)]">网盘资源:</span>
|
||||
{stack.map((item, index) => (
|
||||
<span key={index}>
|
||||
<button type="button" className="hover:text-brand-500" onClick={() => onGoTo(index)}>
|
||||
{item.name}
|
||||
</button>
|
||||
{index < stack.length - 1 && <span className="mx-1">/</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="basis-full text-xs text-[var(--app-muted)]">
|
||||
挂载后不会复制网盘文件;后台会递归读取该目录里的子文件夹和媒体文件,扫描到的影片会自动加入对应媒体库。小目录通常几十秒,大目录取决于网盘接口速度。
|
||||
如果已有同名同类型媒体库,会在首页和 Emby/SenPlayer 中自动归并显示。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border border-[var(--app-border)] px-2 py-0.5 text-xs text-[var(--app-subtle)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={loading || stack.length <= 1}
|
||||
onClick={onGoUp}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
返回上级
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border border-[var(--app-border)] px-2 py-0.5 text-xs text-[var(--app-subtle)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={loading}
|
||||
onClick={onCreateFolder}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
新建文件夹
|
||||
</button>
|
||||
<select
|
||||
className="rounded border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-0.5 text-xs text-[var(--app-subtle)]"
|
||||
value={mountMediaType}
|
||||
onChange={(event) => onMediaTypeChange(event.target.value)}
|
||||
>
|
||||
<option value="auto">自动识别</option>
|
||||
<option value="movie">电影</option>
|
||||
<option value="tv">剧集</option>
|
||||
<option value="anime">动漫</option>
|
||||
<option value="variety">综艺</option>
|
||||
<option value="adult">成人</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-brand-400/40 px-2 py-0.5 text-xs text-brand-500 hover:bg-brand-400/10 disabled:opacity-50"
|
||||
disabled={mounting || batchMounting || loading}
|
||||
onClick={onMountCurrent}
|
||||
>
|
||||
{mounting ? '挂载中…' : '挂载当前目录并归并到媒体库'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-blue-300/70 px-2 py-0.5 text-xs text-blue-500 hover:bg-blue-500/10 disabled:opacity-50"
|
||||
disabled={mounting || batchMounting || loading || !hasDirectories}
|
||||
onClick={onMountVisibleDirectories}
|
||||
>
|
||||
{batchMounting ? '批量挂载中…' : '一键挂载当前目录下所有文件夹'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { FileVideo, Folder, Loader2, Pencil } from 'lucide-react'
|
||||
|
||||
import type { CloudEntry } from '../api/storage_config'
|
||||
|
||||
interface CloudEntryListProps {
|
||||
loading: boolean
|
||||
error: string
|
||||
items: CloudEntry[]
|
||||
onEnter: (entry: CloudEntry) => void
|
||||
onImport: (entry: CloudEntry) => void
|
||||
onRename: (entry: CloudEntry) => void
|
||||
}
|
||||
|
||||
export function CloudEntryList({ loading, error, items, onEnter, onImport, onRename }: CloudEntryListProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-4 text-[var(--app-muted)]">
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) return <p className="py-2 text-sm text-red-400">{error}</p>
|
||||
if (items.length === 0) return <p className="py-2 text-sm text-[var(--app-muted)]">该目录为空</p>
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-[var(--app-border)]">
|
||||
{items.map((entry) => (
|
||||
<li key={entry.id} className="flex items-center gap-2 py-1.5 text-sm">
|
||||
{entry.is_dir ? <Folder size={15} className="text-amber-400" /> : <FileVideo size={15} className="text-blue-300" />}
|
||||
{entry.is_dir ? (
|
||||
<>
|
||||
<button type="button" className="flex-1 text-left text-[var(--app-subtle)] hover:text-brand-500" onClick={() => onEnter(entry)}>
|
||||
{entry.name}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="重命名文件夹"
|
||||
className="rounded border border-[var(--app-border)] p-1 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
onClick={() => onRename(entry)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 truncate text-[var(--app-subtle)]">{entry.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-[var(--app-border)] px-2 py-0.5 text-xs text-[var(--app-subtle)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
onClick={() => onImport(entry)}
|
||||
>
|
||||
导入
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Trash2 } from 'lucide-react'
|
||||
|
||||
import type { Library } from '../types'
|
||||
import { cloudLibraryLabel } from './storageConfigModel'
|
||||
|
||||
interface CloudMountListProps {
|
||||
mounts: Library[]
|
||||
onRemove: (library: Library) => void
|
||||
}
|
||||
|
||||
export function CloudMountList({ mounts, onRemove }: CloudMountListProps) {
|
||||
if (mounts.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mb-3 rounded border border-blue-300/30 bg-blue-500/10 p-2">
|
||||
<div className="mb-1 text-xs font-semibold text-[var(--app-subtle)]">已挂载目录</div>
|
||||
<div className="space-y-1">
|
||||
{mounts.map((lib) => (
|
||||
<div key={lib.id} className="flex items-center gap-2 rounded border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 text-xs">
|
||||
<span className="min-w-0 flex-1 truncate text-[var(--app-subtle)]">
|
||||
{lib.name} · {cloudLibraryLabel(lib.path)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-red-300/60 px-1.5 py-0.5 text-red-500 hover:bg-[var(--app-danger-soft)]"
|
||||
onClick={() => onRemove(lib)}
|
||||
title="移除挂载"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Loader2, PauseCircle, RefreshCw } from 'lucide-react'
|
||||
|
||||
import type { CloudScanStatus } from '../api/storage_config'
|
||||
|
||||
interface CloudScanPanelProps {
|
||||
scanBusy: boolean
|
||||
cancelBusy: boolean
|
||||
scanStatuses: CloudScanStatus[]
|
||||
onScanAll: () => void
|
||||
onCancelScans: () => void
|
||||
}
|
||||
|
||||
export function CloudScanPanel({
|
||||
scanBusy,
|
||||
cancelBusy,
|
||||
scanStatuses,
|
||||
onScanAll,
|
||||
onCancelScans,
|
||||
}: CloudScanPanelProps) {
|
||||
return (
|
||||
<div className="mb-3 rounded border border-emerald-300/30 bg-emerald-500/10 p-2">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-[var(--app-subtle)]">网盘媒体库扫描</div>
|
||||
<p className="text-xs text-[var(--app-muted)]">
|
||||
只需在系统设置填写公开域名,扫描会自动为网盘媒体生成 STRM/302 播放入口;中断后再次扫描会去重补齐。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-emerald-300/70 px-2 py-1 text-xs text-emerald-600 hover:bg-emerald-500/10"
|
||||
disabled={scanBusy}
|
||||
onClick={onScanAll}
|
||||
>
|
||||
{scanBusy ? <Loader2 size={13} className="inline animate-spin" /> : <RefreshCw size={13} className="inline" />}
|
||||
{' '}一键扫描全部网盘库
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-red-300/60 px-2 py-1 text-xs text-red-500 hover:bg-[var(--app-danger-soft)]"
|
||||
disabled={cancelBusy}
|
||||
onClick={onCancelScans}
|
||||
>
|
||||
{cancelBusy ? <Loader2 size={13} className="inline animate-spin" /> : <PauseCircle size={13} className="inline" />}
|
||||
{' '}中断当前网盘扫描
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{scanStatuses.length > 0 && (
|
||||
<div className="grid gap-1 text-xs text-[var(--app-muted)] md:grid-cols-2">
|
||||
{scanStatuses.slice(0, 6).map((item) => (
|
||||
<div key={item.library_id} className="rounded border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1">
|
||||
<span className="font-mono text-[var(--app-subtle)]">{item.state}</span>
|
||||
{' · '}
|
||||
{item.provider}
|
||||
{' · 目录 '}
|
||||
{item.dirs}
|
||||
{' · 发现 '}
|
||||
{item.discovered}
|
||||
{' · 入库 '}
|
||||
{item.added + item.updated}
|
||||
{item.error ? <span className="text-red-500"> · {item.error}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Info } from 'lucide-react'
|
||||
|
||||
import type { DiscoverItem } from '../api/discover'
|
||||
import { imageURL } from '../api/client'
|
||||
import { discoverItemSource } from './discoverPageModel'
|
||||
|
||||
export function ContentRow({
|
||||
title,
|
||||
items,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string
|
||||
items: DiscoverItem[]
|
||||
onSelect: (item: DiscoverItem) => void
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="pl-1 font-display text-2xl font-semibold text-ink-600">{title}</h2>
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{items.map((item, index) => (
|
||||
<DiscoverCard key={discoverKey(item, index)} item={item} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function DiscoverSkeleton() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{[1, 2, 3].map((section) => (
|
||||
<section key={section} className="space-y-4">
|
||||
<div className="h-8 w-48 animate-pulse rounded-xl bg-gray-100" />
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((item) => (
|
||||
<div key={item} className="aspect-[2/3] animate-pulse rounded-xl bg-gray-100" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DiscoverCard({ item, onSelect }: { item: DiscoverItem; onSelect: (item: DiscoverItem) => void }) {
|
||||
const source = discoverItemSource(item)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(item)}
|
||||
className="group relative overflow-hidden rounded-xl border border-gray-200 bg-gray-50 text-left transition-all duration-300 hover:-translate-y-1 hover:border-primary-500/30 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-primary-400/40"
|
||||
>
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-900">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={imageURL(item.poster_url)}
|
||||
alt={item.title}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-gray-500">
|
||||
无海报
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-1.5 top-1.5 rounded-xl border border-white/20 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-white backdrop-blur-sm">
|
||||
{source}
|
||||
</div>
|
||||
{(item.rating ?? 0) > 0 && (
|
||||
<div className="absolute right-1.5 top-1.5 rounded-xl border border-yellow-400/30 bg-black/70 px-1.5 py-0.5 text-[11px] font-semibold text-yellow-400 backdrop-blur-sm">
|
||||
★ {(item.rating ?? 0).toFixed(1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5 px-2.5 py-2">
|
||||
<p className="truncate text-xs font-medium text-ink-600 transition-colors group-hover:text-brand-500">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-[11px] text-sand-500">
|
||||
{[item.media_type, item.year && item.year > 0 ? item.year : ''].filter(Boolean).join(' · ') || '推荐'}
|
||||
</p>
|
||||
<p className="flex items-center gap-1 pt-1 text-[10px] font-semibold text-brand-500">
|
||||
<Info size={10} />
|
||||
详情 / 订阅
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function discoverKey(item: DiscoverItem, index: number): string {
|
||||
return `${item.source || 'source'}:${item.tmdb_id || item.douban_id || item.bangumi_id || item.title}:${index}`
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Download, Rss, X } from 'lucide-react'
|
||||
|
||||
import type { DiscoverItem } from '../api/discover'
|
||||
import { imageURL } from '../api/client'
|
||||
import { buildSiteSearchFeedURL, buildSubscriptionAliases, subscriptionsAPI } from '../api/subscriptions'
|
||||
import { buildSubscribeKeyword, discoverItemSource } from './discoverPageModel'
|
||||
|
||||
export function DiscoverDetailModal({ item, onClose }: { item: DiscoverItem; onClose: () => void }) {
|
||||
const source = discoverItemSource(item)
|
||||
const keyword = item.subscribe_keyword || buildSubscribeKeyword(item)
|
||||
const [form, setForm] = useState({
|
||||
keyword,
|
||||
search_mode: 'keyword',
|
||||
imdb_id: '',
|
||||
media_type: item.media_type || '',
|
||||
resolution: 'best',
|
||||
quality: '',
|
||||
effects: '',
|
||||
release_groups: '',
|
||||
exclude_words: 'cam,ts,tc,枪版',
|
||||
wash_enabled: false,
|
||||
wash_priority: 'balanced',
|
||||
save_path: '',
|
||||
media_category: '',
|
||||
priority: 50,
|
||||
run_now: true,
|
||||
})
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async () => {
|
||||
const finalKeyword = form.keyword.trim() || keyword
|
||||
const feed = buildSiteSearchFeedURL(finalKeyword, source, buildSubscriptionAliases(item))
|
||||
setBusy(true)
|
||||
try {
|
||||
const sub = await subscriptionsAPI.create({
|
||||
name: `${item.title} 自动订阅`,
|
||||
feed_url: feed,
|
||||
filter: finalKeyword,
|
||||
media_type: form.media_type || undefined,
|
||||
media_category: form.media_category || undefined,
|
||||
save_path: form.save_path || undefined,
|
||||
search_mode: form.search_mode,
|
||||
imdb_id: form.imdb_id || undefined,
|
||||
source,
|
||||
poster_url: item.poster_url || undefined,
|
||||
backdrop_url: item.backdrop_url || undefined,
|
||||
overview: item.overview || undefined,
|
||||
original_name: item.original_name || undefined,
|
||||
year: item.year || undefined,
|
||||
resolution: form.resolution === 'best' ? 'best' : form.resolution,
|
||||
quality: form.quality || undefined,
|
||||
effects: form.effects || undefined,
|
||||
release_groups: form.release_groups || undefined,
|
||||
exclude_words: form.exclude_words || undefined,
|
||||
wash_enabled: form.wash_enabled,
|
||||
wash_priority: form.wash_priority,
|
||||
priority: form.priority,
|
||||
enabled: true,
|
||||
})
|
||||
if (form.run_now) {
|
||||
const run = await subscriptionsAPI.runNow(sub.id)
|
||||
toast.success(run.queued > 0 ? `已订阅并加入 ${run.queued} 个下载` : '已订阅,暂未命中可下载资源')
|
||||
} else {
|
||||
toast.success('已创建订阅')
|
||||
}
|
||||
onClose()
|
||||
} catch (err) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'订阅失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm">
|
||||
<div className="max-h-[92vh] w-full max-w-5xl overflow-y-auto rounded-3xl border border-white/60 bg-white p-5 shadow-2xl">
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-brand-500">{source}</p>
|
||||
<h2 className="font-display text-2xl font-bold text-ink-600">{item.title}</h2>
|
||||
<p className="mt-1 text-sm text-sand-500">
|
||||
{[item.media_type, item.year && item.year > 0 ? item.year : '', item.rating ? `★ ${item.rating.toFixed(1)}` : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
<button className="rounded-full border border-gray-200 p-2 text-ink-50 hover:bg-gray-50" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[260px_1fr]">
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-hidden rounded-2xl bg-gray-100">
|
||||
{item.poster_url ? (
|
||||
<img src={imageURL(item.poster_url)} alt={item.title} className="aspect-[2/3] w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex aspect-[2/3] items-center justify-center text-sand-500">无海报</div>
|
||||
)}
|
||||
</div>
|
||||
{item.backdrop_url && (
|
||||
<img src={imageURL(item.backdrop_url)} alt="" className="h-24 w-full rounded-2xl object-cover" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<section className="rounded-2xl border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 className="mb-2 font-semibold text-ink-600">简介</h3>
|
||||
<p className="text-sm leading-6 text-ink-100">{item.overview || '当前数据源没有返回简介。'}</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-primary-400/20 bg-primary-400/5 p-4">
|
||||
<h3 className="mb-3 flex items-center gap-2 font-semibold text-ink-600">
|
||||
<Rss size={16} />
|
||||
订阅下载规则
|
||||
</h3>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="text-xs text-sand-500 md:col-span-2">
|
||||
搜索关键词
|
||||
<input className="input-base mt-1" value={form.keyword} onChange={(e) => setForm((f) => ({ ...f, keyword: e.target.value }))} />
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
搜索方式
|
||||
<select className="input-base mt-1" value={form.search_mode} onChange={(e) => setForm((f) => ({ ...f, search_mode: e.target.value }))}>
|
||||
<option value="keyword">标题关键词</option>
|
||||
<option value="imdb">IMDB ID</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
IMDB ID
|
||||
<input className="input-base mt-1" placeholder="tt1160419" value={form.imdb_id} onChange={(e) => setForm((f) => ({ ...f, imdb_id: e.target.value }))} />
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
类型
|
||||
<select className="input-base mt-1" value={form.media_type} onChange={(e) => setForm((f) => ({ ...f, media_type: e.target.value }))}>
|
||||
<option value="">自动识别</option>
|
||||
<option value="movie">电影</option>
|
||||
<option value="tv">电视剧</option>
|
||||
<option value="anime">动漫</option>
|
||||
<option value="variety">综艺</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
分辨率
|
||||
<select className="input-base mt-1" value={form.resolution} onChange={(e) => setForm((f) => ({ ...f, resolution: e.target.value }))}>
|
||||
<option value="best">自动择优</option>
|
||||
<option value="2160p">2160p / 4K</option>
|
||||
<option value="1080p">1080p</option>
|
||||
<option value="720p">720p</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
质量
|
||||
<select className="input-base mt-1" value={form.quality} onChange={(e) => setForm((f) => ({ ...f, quality: e.target.value }))}>
|
||||
<option value="">不限</option>
|
||||
<option value="remux">REMUX</option>
|
||||
<option value="bluray">BluRay</option>
|
||||
<option value="web-dl">WEB-DL</option>
|
||||
<option value="hdtv">HDTV</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
特效 / 音轨
|
||||
<input className="input-base mt-1" placeholder="hdr,dolby-vision,atmos" value={form.effects} onChange={(e) => setForm((f) => ({ ...f, effects: e.target.value }))} />
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
洗版优先级
|
||||
<select className="input-base mt-1 disabled:opacity-50" disabled={!form.wash_enabled} value={form.wash_priority} onChange={(e) => setForm((f) => ({ ...f, wash_priority: e.target.value }))}>
|
||||
<option value="balanced">均衡</option>
|
||||
<option value="resolution">分辨率优先</option>
|
||||
<option value="quality">片源质量优先</option>
|
||||
<option value="effects">HDR/DV/Atmos 优先</option>
|
||||
<option value="seeders">做种数优先</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs text-ink-100">
|
||||
<input type="checkbox" checked={form.wash_enabled} onChange={(e) => setForm((f) => ({ ...f, wash_enabled: e.target.checked }))} />
|
||||
启用洗版择优
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
发布组
|
||||
<input className="input-base mt-1" placeholder="如 FRDS,OurTV" value={form.release_groups} onChange={(e) => setForm((f) => ({ ...f, release_groups: e.target.value }))} />
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
排除词
|
||||
<input className="input-base mt-1" value={form.exclude_words} onChange={(e) => setForm((f) => ({ ...f, exclude_words: e.target.value }))} />
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
分类覆盖
|
||||
<input className="input-base mt-1" placeholder="综艺 / 日番 / 欧美剧" value={form.media_category} onChange={(e) => setForm((f) => ({ ...f, media_category: e.target.value }))} />
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
保存路径覆盖
|
||||
<input className="input-base mt-1" value={form.save_path} onChange={(e) => setForm((f) => ({ ...f, save_path: e.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<label className="flex items-center gap-2 text-sm text-ink-100">
|
||||
<input type="checkbox" checked={form.run_now} onChange={(e) => setForm((f) => ({ ...f, run_now: e.target.checked }))} />
|
||||
创建后立即搜索并下载
|
||||
</label>
|
||||
<button disabled={busy} onClick={submit} className="neon-button disabled:opacity-60">
|
||||
<Download size={16} />
|
||||
{busy ? '处理中…' : '创建订阅'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+63
-354
@@ -1,24 +1,21 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { AlertTriangle, Download, Info, Rss, Sparkles, X } from 'lucide-react'
|
||||
import { AlertTriangle, Sparkles } from 'lucide-react'
|
||||
|
||||
import { discoverAPI, type DiscoverItem, type DiscoverSection } from '../api/discover'
|
||||
import { imageURL } from '../api/client'
|
||||
import { buildSiteSearchFeedURL, subscriptionsAPI } from '../api/subscriptions'
|
||||
|
||||
const defaultSections = [
|
||||
'tmdb_trending_day',
|
||||
'douban_hot_movie',
|
||||
'douban_hot_tv',
|
||||
'bangumi_calendar',
|
||||
]
|
||||
|
||||
const storageKey = 'mediastation.discover.sections'
|
||||
import { ContentRow, DiscoverSkeleton } from './DiscoverContentRow'
|
||||
import { DiscoverDetailModal } from './DiscoverDetailModal'
|
||||
import {
|
||||
defaultSectionDefs,
|
||||
defaultSections,
|
||||
discoverStorageKey,
|
||||
readSavedSections,
|
||||
} from './discoverPageModel'
|
||||
|
||||
export function DiscoverPage() {
|
||||
const [sections, setSections] = useState<DiscoverSection[]>([])
|
||||
const [selected, setSelected] = useState<string[]>(defaultSections)
|
||||
const [rows, setRows] = useState<Record<string, DiscoverItem[]>>({})
|
||||
const [rowErrors, setRowErrors] = useState<Record<string, string>>({})
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeItem, setActiveItem] = useState<DiscoverItem | null>(null)
|
||||
@@ -29,7 +26,9 @@ export function DiscoverPage() {
|
||||
.then((items) => {
|
||||
setSections(items)
|
||||
const saved = readSavedSections(items)
|
||||
setSelected(saved.length > 0 ? saved : defaultSections)
|
||||
const available = new Set(items.map((item) => item.key))
|
||||
const fallback = defaultSections.filter((key) => available.has(key))
|
||||
setSelected(saved.length > 0 ? saved : fallback)
|
||||
})
|
||||
.catch(() => {
|
||||
setSections(defaultSectionDefs)
|
||||
@@ -40,26 +39,46 @@ export function DiscoverPage() {
|
||||
useEffect(() => {
|
||||
if (selected.length === 0) {
|
||||
setRows({})
|
||||
setRowErrors({})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError('')
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(selected))
|
||||
discoverAPI
|
||||
.feed(selected)
|
||||
.then((feed) => {
|
||||
const next: Record<string, DiscoverItem[]> = {}
|
||||
for (const key of selected) {
|
||||
next[key] = feed[key] ?? []
|
||||
}
|
||||
setRows(next)
|
||||
})
|
||||
.catch((err) => {
|
||||
setRows({})
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
setRowErrors({})
|
||||
setRows((current) => {
|
||||
const next: Record<string, DiscoverItem[]> = {}
|
||||
for (const key of selected) {
|
||||
next[key] = current[key] ?? []
|
||||
}
|
||||
return next
|
||||
})
|
||||
window.localStorage.setItem(discoverStorageKey, JSON.stringify(selected))
|
||||
|
||||
let pending = selected.length
|
||||
const markDone = () => {
|
||||
pending -= 1
|
||||
if (!cancelled && pending <= 0) setLoading(false)
|
||||
}
|
||||
for (const key of selected) {
|
||||
discoverAPI
|
||||
.feed([key])
|
||||
.then((feed) => {
|
||||
if (cancelled) return
|
||||
setRows((current) => ({ ...current, [key]: feed[key] ?? [] }))
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setRows((current) => ({ ...current, [key]: [] }))
|
||||
setRowErrors((current) => ({ ...current, [key]: message }))
|
||||
})
|
||||
.finally(markDone)
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selected])
|
||||
|
||||
const sectionMap = useMemo(
|
||||
@@ -67,6 +86,7 @@ export function DiscoverPage() {
|
||||
[sections],
|
||||
)
|
||||
const hasContent = selected.some((key) => (rows[key] ?? []).length > 0)
|
||||
const hasRowErrors = Object.keys(rowErrors).length > 0
|
||||
|
||||
const toggleSection = (key: string) => {
|
||||
setSelected((current) => {
|
||||
@@ -116,7 +136,7 @@ export function DiscoverPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading && <DiscoverSkeleton />}
|
||||
{loading && !hasContent && <DiscoverSkeleton />}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-red-500/20 bg-red-500/10 p-4">
|
||||
@@ -131,7 +151,7 @@ export function DiscoverPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && selected.length > 0 && (
|
||||
{!error && selected.length > 0 && (hasContent || !loading) && (
|
||||
<div className="space-y-10">
|
||||
{selected.map((key) => {
|
||||
const items = rows[key] ?? []
|
||||
@@ -146,7 +166,18 @@ export function DiscoverPage() {
|
||||
)
|
||||
})}
|
||||
|
||||
{!hasContent && (
|
||||
{hasRowErrors && (
|
||||
<div className="flex items-start gap-3 rounded-2xl border border-amber-500/20 bg-amber-500/10 p-4">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-400" />
|
||||
<div className="space-y-1 text-sm text-amber-200">
|
||||
{Object.entries(rowErrors).map(([key, message]) => (
|
||||
<p key={key}>{sectionMap.get(key)?.label ?? key}:{message}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !hasContent && (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-10 text-center">
|
||||
<p className="text-sand-500">
|
||||
当前选择的推荐源暂未返回内容,可切换豆瓣 / Bangumi 或检查网络代理。
|
||||
@@ -165,325 +196,3 @@ export function DiscoverPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContentRow({
|
||||
title,
|
||||
items,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string
|
||||
items: DiscoverItem[]
|
||||
onSelect: (item: DiscoverItem) => void
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="pl-1 font-display text-2xl font-semibold text-ink-600">{title}</h2>
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{items.map((item, index) => (
|
||||
<DiscoverCard key={discoverKey(item, index)} item={item} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function DiscoverCard({ item, onSelect }: { item: DiscoverItem; onSelect: (item: DiscoverItem) => void }) {
|
||||
const source = item.source || (item.bangumi_id ? 'bangumi' : item.douban_id ? 'douban' : 'tmdb')
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(item)}
|
||||
className="group relative overflow-hidden rounded-xl border border-gray-200 bg-gray-50 text-left transition-all duration-300 hover:-translate-y-1 hover:border-primary-500/30 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-primary-400/40"
|
||||
>
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-900">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={imageURL(item.poster_url)}
|
||||
alt={item.title}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-gray-500">
|
||||
无海报
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-1.5 top-1.5 rounded-xl border border-white/20 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-white backdrop-blur-sm">
|
||||
{source}
|
||||
</div>
|
||||
{(item.rating ?? 0) > 0 && (
|
||||
<div className="absolute right-1.5 top-1.5 rounded-xl border border-yellow-400/30 bg-black/70 px-1.5 py-0.5 text-[11px] font-semibold text-yellow-400 backdrop-blur-sm">
|
||||
★ {(item.rating ?? 0).toFixed(1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5 px-2.5 py-2">
|
||||
<p className="truncate text-xs font-medium text-ink-600 transition-colors group-hover:text-brand-500">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-[11px] text-sand-500">
|
||||
{[item.media_type, item.year && item.year > 0 ? item.year : ''].filter(Boolean).join(' · ') || '推荐'}
|
||||
</p>
|
||||
<p className="flex items-center gap-1 pt-1 text-[10px] font-semibold text-brand-500">
|
||||
<Info size={10} />
|
||||
详情 / 订阅
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function DiscoverDetailModal({ item, onClose }: { item: DiscoverItem; onClose: () => void }) {
|
||||
const source = item.source || (item.bangumi_id ? 'bangumi' : item.douban_id ? 'douban' : 'tmdb')
|
||||
const keyword = item.subscribe_keyword || buildSubscribeKeyword(item)
|
||||
const [form, setForm] = useState({
|
||||
keyword,
|
||||
search_mode: 'keyword',
|
||||
imdb_id: '',
|
||||
media_type: item.media_type || '',
|
||||
resolution: 'best',
|
||||
quality: '',
|
||||
effects: '',
|
||||
release_groups: '',
|
||||
exclude_words: 'cam,ts,tc,枪版',
|
||||
wash_enabled: false,
|
||||
wash_priority: 'balanced',
|
||||
save_path: '',
|
||||
media_category: '',
|
||||
priority: 50,
|
||||
run_now: true,
|
||||
})
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async () => {
|
||||
const finalKeyword = form.keyword.trim() || keyword
|
||||
const feed = buildSiteSearchFeedURL(finalKeyword, source, [item.title, item.original_name || ''])
|
||||
setBusy(true)
|
||||
try {
|
||||
const sub = await subscriptionsAPI.create({
|
||||
name: `${item.title} 自动订阅`,
|
||||
feed_url: feed,
|
||||
filter: finalKeyword,
|
||||
media_type: form.media_type || undefined,
|
||||
media_category: form.media_category || undefined,
|
||||
save_path: form.save_path || undefined,
|
||||
search_mode: form.search_mode,
|
||||
imdb_id: form.imdb_id || undefined,
|
||||
source,
|
||||
poster_url: item.poster_url || undefined,
|
||||
backdrop_url: item.backdrop_url || undefined,
|
||||
overview: item.overview || undefined,
|
||||
resolution: form.resolution === 'best' ? 'best' : form.resolution,
|
||||
quality: form.quality || undefined,
|
||||
effects: form.effects || undefined,
|
||||
release_groups: form.release_groups || undefined,
|
||||
exclude_words: form.exclude_words || undefined,
|
||||
wash_enabled: form.wash_enabled,
|
||||
wash_priority: form.wash_priority,
|
||||
priority: form.priority,
|
||||
enabled: true,
|
||||
})
|
||||
if (form.run_now) {
|
||||
const run = await subscriptionsAPI.runNow(sub.id)
|
||||
toast.success(run.queued > 0 ? `已订阅并加入 ${run.queued} 个下载` : '已订阅,暂未命中可下载资源')
|
||||
} else {
|
||||
toast.success('已创建订阅')
|
||||
}
|
||||
onClose()
|
||||
} catch (err) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'订阅失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm">
|
||||
<div className="max-h-[92vh] w-full max-w-5xl overflow-y-auto rounded-3xl border border-white/60 bg-white p-5 shadow-2xl">
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-brand-500">{source}</p>
|
||||
<h2 className="font-display text-2xl font-bold text-ink-600">{item.title}</h2>
|
||||
<p className="mt-1 text-sm text-sand-500">
|
||||
{[item.media_type, item.year && item.year > 0 ? item.year : '', item.rating ? `★ ${item.rating.toFixed(1)}` : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
<button className="rounded-full border border-gray-200 p-2 text-ink-50 hover:bg-gray-50" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[260px_1fr]">
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-hidden rounded-2xl bg-gray-100">
|
||||
{item.poster_url ? (
|
||||
<img src={imageURL(item.poster_url)} alt={item.title} className="aspect-[2/3] w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex aspect-[2/3] items-center justify-center text-sand-500">无海报</div>
|
||||
)}
|
||||
</div>
|
||||
{item.backdrop_url && (
|
||||
<img src={imageURL(item.backdrop_url)} alt="" className="h-24 w-full rounded-2xl object-cover" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<section className="rounded-2xl border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 className="mb-2 font-semibold text-ink-600">简介</h3>
|
||||
<p className="text-sm leading-6 text-ink-100">{item.overview || '当前数据源没有返回简介。'}</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-primary-400/20 bg-primary-400/5 p-4">
|
||||
<h3 className="mb-3 flex items-center gap-2 font-semibold text-ink-600">
|
||||
<Rss size={16} />
|
||||
订阅下载规则
|
||||
</h3>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="text-xs text-sand-500 md:col-span-2">
|
||||
搜索关键词
|
||||
<input className="input-base mt-1" value={form.keyword} onChange={(e) => setForm((f) => ({ ...f, keyword: e.target.value }))} />
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
搜索方式
|
||||
<select className="input-base mt-1" value={form.search_mode} onChange={(e) => setForm((f) => ({ ...f, search_mode: e.target.value }))}>
|
||||
<option value="keyword">标题关键词</option>
|
||||
<option value="imdb">IMDB ID</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
IMDB ID
|
||||
<input className="input-base mt-1" placeholder="tt1160419" value={form.imdb_id} onChange={(e) => setForm((f) => ({ ...f, imdb_id: e.target.value }))} />
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
类型
|
||||
<select className="input-base mt-1" value={form.media_type} onChange={(e) => setForm((f) => ({ ...f, media_type: e.target.value }))}>
|
||||
<option value="">自动识别</option>
|
||||
<option value="movie">电影</option>
|
||||
<option value="tv">电视剧</option>
|
||||
<option value="anime">动漫</option>
|
||||
<option value="variety">综艺</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
分辨率
|
||||
<select className="input-base mt-1" value={form.resolution} onChange={(e) => setForm((f) => ({ ...f, resolution: e.target.value }))}>
|
||||
<option value="best">自动择优</option>
|
||||
<option value="2160p">2160p / 4K</option>
|
||||
<option value="1080p">1080p</option>
|
||||
<option value="720p">720p</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
质量
|
||||
<select className="input-base mt-1" value={form.quality} onChange={(e) => setForm((f) => ({ ...f, quality: e.target.value }))}>
|
||||
<option value="">不限</option>
|
||||
<option value="remux">REMUX</option>
|
||||
<option value="bluray">BluRay</option>
|
||||
<option value="web-dl">WEB-DL</option>
|
||||
<option value="hdtv">HDTV</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
特效 / 音轨
|
||||
<input className="input-base mt-1" placeholder="hdr,dolby-vision,atmos" value={form.effects} onChange={(e) => setForm((f) => ({ ...f, effects: e.target.value }))} />
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
洗版优先级
|
||||
<select className="input-base mt-1 disabled:opacity-50" disabled={!form.wash_enabled} value={form.wash_priority} onChange={(e) => setForm((f) => ({ ...f, wash_priority: e.target.value }))}>
|
||||
<option value="balanced">均衡</option>
|
||||
<option value="resolution">分辨率优先</option>
|
||||
<option value="quality">片源质量优先</option>
|
||||
<option value="effects">HDR/DV/Atmos 优先</option>
|
||||
<option value="seeders">做种数优先</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs text-ink-100">
|
||||
<input type="checkbox" checked={form.wash_enabled} onChange={(e) => setForm((f) => ({ ...f, wash_enabled: e.target.checked }))} />
|
||||
启用洗版择优
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
发布组
|
||||
<input className="input-base mt-1" placeholder="如 FRDS,OurTV" value={form.release_groups} onChange={(e) => setForm((f) => ({ ...f, release_groups: e.target.value }))} />
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
排除词
|
||||
<input className="input-base mt-1" value={form.exclude_words} onChange={(e) => setForm((f) => ({ ...f, exclude_words: e.target.value }))} />
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
分类覆盖
|
||||
<input className="input-base mt-1" placeholder="综艺 / 日番 / 欧美剧" value={form.media_category} onChange={(e) => setForm((f) => ({ ...f, media_category: e.target.value }))} />
|
||||
</label>
|
||||
<label className="text-xs text-sand-500">
|
||||
保存路径覆盖
|
||||
<input className="input-base mt-1" value={form.save_path} onChange={(e) => setForm((f) => ({ ...f, save_path: e.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<label className="flex items-center gap-2 text-sm text-ink-100">
|
||||
<input type="checkbox" checked={form.run_now} onChange={(e) => setForm((f) => ({ ...f, run_now: e.target.checked }))} />
|
||||
创建后立即搜索并下载
|
||||
</label>
|
||||
<button disabled={busy} onClick={submit} className="neon-button disabled:opacity-60">
|
||||
<Download size={16} />
|
||||
{busy ? '处理中…' : '创建订阅'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function buildSubscribeKeyword(item: DiscoverItem): string {
|
||||
return [item.title, item.year && item.year > 0 ? item.year : ''].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function DiscoverSkeleton() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{[1, 2, 3].map((section) => (
|
||||
<section key={section} className="space-y-4">
|
||||
<div className="h-8 w-48 animate-pulse rounded-xl bg-gray-100" />
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((item) => (
|
||||
<div key={item} className="aspect-[2/3] animate-pulse rounded-xl bg-gray-100" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function discoverKey(item: DiscoverItem, index: number): string {
|
||||
return `${item.source || 'source'}:${item.tmdb_id || item.douban_id || item.bangumi_id || item.title}:${index}`
|
||||
}
|
||||
|
||||
function readSavedSections(sections: DiscoverSection[]): string[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
const allowed = new Set(sections.map((section) => section.key))
|
||||
return parsed.filter((key) => typeof key === 'string' && allowed.has(key))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSectionDefs: DiscoverSection[] = [
|
||||
{ key: 'tmdb_trending_day', label: 'TMDb 今日趋势', provider: 'tmdb' },
|
||||
{ key: 'tmdb_popular_movie', label: 'TMDb 热门电影', provider: 'tmdb' },
|
||||
{ key: 'douban_hot_movie', label: '豆瓣热门电影', provider: 'douban' },
|
||||
{ key: 'douban_hot_tv', label: '豆瓣热门剧集', provider: 'douban' },
|
||||
{ key: 'bangumi_calendar', label: 'Bangumi 每日放送', provider: 'bangumi' },
|
||||
]
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Loader2, Pencil, Send, Trash2 } from 'lucide-react'
|
||||
|
||||
import type { DownloadClient } from '../api/download_clients'
|
||||
|
||||
export function DownloadClientCard({
|
||||
client,
|
||||
testing,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onTest,
|
||||
}: {
|
||||
client: DownloadClient
|
||||
testing: boolean
|
||||
onDelete: (client: DownloadClient) => void
|
||||
onEdit: (client: DownloadClient) => void
|
||||
onTest: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-panel flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-ink-600">{client.name}</span>
|
||||
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs text-ink-50">
|
||||
{client.type}
|
||||
</span>
|
||||
{client.is_default && (
|
||||
<span className="rounded-lg bg-primary-400/20 px-2 py-0.5 text-xs text-brand-500">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
{!client.enabled && (
|
||||
<span className="rounded-lg bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">
|
||||
已禁用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-ink-50">
|
||||
{client.host}
|
||||
{client.username && ` · ${client.username}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={() => onTest(client.id)}
|
||||
disabled={testing}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 size={12} className="inline animate-spin" />
|
||||
) : (
|
||||
<Send size={12} className="inline" />
|
||||
)}{' '}
|
||||
测试
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onEdit(client)}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<Pencil size={12} className="inline" /> 编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(client)}
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
>
|
||||
<Trash2 size={12} className="inline" /> 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { type FormEvent, type ReactNode, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
downloadClientsAPI,
|
||||
type DownloadClient,
|
||||
type DownloadClientInput,
|
||||
type DownloadClientType,
|
||||
} from '../api/download_clients'
|
||||
import { apiErrorMessage } from './downloadClientPageModel'
|
||||
|
||||
export function ClientFormModal({
|
||||
editing,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
editing: DownloadClient | null
|
||||
onClose: () => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<DownloadClientInput>(() => ({
|
||||
name: editing?.name ?? '',
|
||||
type: editing?.type ?? 'qbittorrent',
|
||||
host: editing?.host ?? '',
|
||||
username: editing?.username ?? '',
|
||||
password: '',
|
||||
is_default: editing?.is_default ?? false,
|
||||
enabled: editing?.enabled ?? true,
|
||||
}))
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (saving) return
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) await downloadClientsAPI.update(editing.id, form)
|
||||
else await downloadClientsAPI.create(form)
|
||||
toast.success('已保存')
|
||||
setSaving(false)
|
||||
onSaved()
|
||||
} catch (err: unknown) {
|
||||
const msg = apiErrorMessage(err, '保存失败')
|
||||
toast.error(msg)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const update = <K extends keyof DownloadClientInput>(k: K, v: DownloadClientInput[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const placeholder = (
|
||||
{
|
||||
qbittorrent: 'http://127.0.0.1:8080',
|
||||
aria2: 'http://127.0.0.1:6800/jsonrpc',
|
||||
transmission: 'http://127.0.0.1:9091/transmission/rpc',
|
||||
} as Record<DownloadClientType, string>
|
||||
)[form.type]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
|
||||
{editing ? '编辑下载器' : '添加下载器'}
|
||||
</h2>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<Field label="名称">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
value={form.name}
|
||||
onChange={(e) => update('name', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="类型">
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.type}
|
||||
onChange={(e) => update('type', e.target.value as DownloadClientType)}
|
||||
>
|
||||
<option value="qbittorrent">qBittorrent</option>
|
||||
<option value="aria2">Aria2 (JSON-RPC)</option>
|
||||
<option value="transmission">Transmission</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="URL">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder={placeholder}
|
||||
value={form.host}
|
||||
onChange={(e) => update('host', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{form.type !== 'aria2' && (
|
||||
<>
|
||||
<Field label="用户名">
|
||||
<input
|
||||
className="input-base"
|
||||
value={form.username ?? ''}
|
||||
onChange={(e) => update('username', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={editing ? '密码 (留空保持不变)' : '密码'}>
|
||||
<input
|
||||
type="password"
|
||||
className="input-base"
|
||||
value={form.password ?? ''}
|
||||
onChange={(e) => update('password', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{form.type === 'aria2' && (
|
||||
<Field label="RPC Token (作为密码字段保存)">
|
||||
<input
|
||||
type="password"
|
||||
className="input-base"
|
||||
value={form.password ?? ''}
|
||||
onChange={(e) => update('password', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={form.is_default}
|
||||
onChange={(e) => update('is_default', e.target.checked)}
|
||||
/>
|
||||
设为默认
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={form.enabled}
|
||||
onChange={(e) => update('enabled', e.target.checked)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving && <Loader2 size={16} className="animate-spin" />} 保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-ink-100">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Loader2, Pencil, Plus, Send, Server, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loader2, Plus, Server } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
downloadClientsAPI,
|
||||
type DownloadClient,
|
||||
type DownloadClientInput,
|
||||
type DownloadClientType,
|
||||
} from '../api/download_clients'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import { DownloadClientCard } from './DownloadClientCard'
|
||||
import { ClientFormModal } from './DownloadClientFormModal'
|
||||
import { apiErrorMessage } from './downloadClientPageModel'
|
||||
|
||||
// DownloadClientsPage manages multiple downloader integrations.
|
||||
// Replaces the Vue UI's DownloadView "clients" tab with a typed CRUD
|
||||
@@ -98,62 +99,17 @@ export function DownloadClientsPage() {
|
||||
{!loading && clients.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{clients.map((c) => (
|
||||
<div
|
||||
<DownloadClientCard
|
||||
key={c.id}
|
||||
className="glass-panel flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-ink-600">{c.name}</span>
|
||||
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs text-ink-50">
|
||||
{c.type}
|
||||
</span>
|
||||
{c.is_default && (
|
||||
<span className="rounded-lg bg-primary-400/20 px-2 py-0.5 text-xs text-brand-500">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
{!c.enabled && (
|
||||
<span className="rounded-lg bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">
|
||||
已禁用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-ink-50">
|
||||
{c.host}
|
||||
{c.username && ` · ${c.username}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={() => onTest(c.id)}
|
||||
disabled={testing[c.id]}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
{testing[c.id] ? (
|
||||
<Loader2 size={12} className="inline animate-spin" />
|
||||
) : (
|
||||
<Send size={12} className="inline" />
|
||||
)}{' '}
|
||||
测试
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(c)
|
||||
setShowForm(true)
|
||||
}}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<Pencil size={12} className="inline" /> 编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(c)}
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
>
|
||||
<Trash2 size={12} className="inline" /> 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
client={c}
|
||||
testing={Boolean(testing[c.id])}
|
||||
onDelete={onDelete}
|
||||
onEdit={(client) => {
|
||||
setEditing(client)
|
||||
setShowForm(true)
|
||||
}}
|
||||
onTest={onTest}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -171,170 +127,3 @@ export function DownloadClientsPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ClientFormModal({
|
||||
editing,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
editing: DownloadClient | null
|
||||
onClose: () => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<DownloadClientInput>(() => ({
|
||||
name: editing?.name ?? '',
|
||||
type: editing?.type ?? 'qbittorrent',
|
||||
host: editing?.host ?? '',
|
||||
username: editing?.username ?? '',
|
||||
password: '',
|
||||
is_default: editing?.is_default ?? false,
|
||||
enabled: editing?.enabled ?? true,
|
||||
}))
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (saving) return
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) await downloadClientsAPI.update(editing.id, form)
|
||||
else await downloadClientsAPI.create(form)
|
||||
toast.success('已保存')
|
||||
setSaving(false)
|
||||
onSaved()
|
||||
} catch (err: unknown) {
|
||||
const msg = apiErrorMessage(err, '保存失败')
|
||||
toast.error(msg)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const update = <K extends keyof DownloadClientInput>(k: K, v: DownloadClientInput[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const placeholder = (
|
||||
{
|
||||
qbittorrent: 'http://127.0.0.1:8080',
|
||||
aria2: 'http://127.0.0.1:6800/jsonrpc',
|
||||
transmission: 'http://127.0.0.1:9091/transmission/rpc',
|
||||
} as Record<DownloadClientType, string>
|
||||
)[form.type]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
|
||||
{editing ? '编辑下载器' : '添加下载器'}
|
||||
</h2>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<Field label="名称">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
value={form.name}
|
||||
onChange={(e) => update('name', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="类型">
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.type}
|
||||
onChange={(e) => update('type', e.target.value as DownloadClientType)}
|
||||
>
|
||||
<option value="qbittorrent">qBittorrent</option>
|
||||
<option value="aria2">Aria2 (JSON-RPC)</option>
|
||||
<option value="transmission">Transmission</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="URL">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder={placeholder}
|
||||
value={form.host}
|
||||
onChange={(e) => update('host', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{form.type !== 'aria2' && (
|
||||
<>
|
||||
<Field label="用户名">
|
||||
<input
|
||||
className="input-base"
|
||||
value={form.username ?? ''}
|
||||
onChange={(e) => update('username', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={editing ? '密码 (留空保持不变)' : '密码'}>
|
||||
<input
|
||||
type="password"
|
||||
className="input-base"
|
||||
value={form.password ?? ''}
|
||||
onChange={(e) => update('password', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{form.type === 'aria2' && (
|
||||
<Field label="RPC Token (作为密码字段保存)">
|
||||
<input
|
||||
type="password"
|
||||
className="input-base"
|
||||
value={form.password ?? ''}
|
||||
onChange={(e) => update('password', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={form.is_default}
|
||||
onChange={(e) => update('is_default', e.target.checked)}
|
||||
/>
|
||||
设为默认
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={form.enabled}
|
||||
onChange={(e) => update('enabled', e.target.checked)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving && <Loader2 size={16} className="animate-spin" />} 保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function apiErrorMessage(err: unknown, fallback: string): string {
|
||||
const data = (err as { response?: { data?: { error?: string; message?: string } } })?.response?.data
|
||||
if (data?.error) return data.error
|
||||
if (data?.message) return data.message
|
||||
if ((err as { code?: string })?.code === 'ECONNABORTED') return '请求超时,请检查服务或网络'
|
||||
return fallback
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-ink-100">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ArrowDown, ArrowUp, Film, HardDrive, Rss, Trash2 } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import type { DownloadTask, QBitTorrent } from '../types'
|
||||
|
||||
export type DownloadCardItem = {
|
||||
id?: string
|
||||
hash?: string
|
||||
title: string
|
||||
poster_url?: string
|
||||
backdrop_url?: string
|
||||
overview?: string
|
||||
save_path?: string
|
||||
status?: string
|
||||
state?: string
|
||||
progress: number
|
||||
dlspeed?: number
|
||||
upspeed?: number
|
||||
num_seeds?: number
|
||||
num_leechs?: number
|
||||
size?: number
|
||||
downloaded?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export function toLiveCard(t: QBitTorrent): DownloadCardItem {
|
||||
return {
|
||||
hash: t.hash,
|
||||
title: t.title || t.name || '下载任务',
|
||||
poster_url: t.poster_url,
|
||||
backdrop_url: t.backdrop_url,
|
||||
overview: t.overview,
|
||||
save_path: t.save_path,
|
||||
state: t.state,
|
||||
progress: t.progress,
|
||||
dlspeed: t.dlspeed,
|
||||
upspeed: t.upspeed,
|
||||
num_seeds: t.num_seeds,
|
||||
num_leechs: t.num_leechs,
|
||||
size: t.size,
|
||||
downloaded: t.downloaded,
|
||||
}
|
||||
}
|
||||
|
||||
export function toTaskCard(t: DownloadTask): DownloadCardItem {
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.title || '下载任务',
|
||||
poster_url: t.poster_url,
|
||||
backdrop_url: t.backdrop_url,
|
||||
overview: t.overview,
|
||||
save_path: t.save_path,
|
||||
status: t.status,
|
||||
state: t.state,
|
||||
progress: t.progress,
|
||||
dlspeed: t.dlspeed,
|
||||
upspeed: t.upspeed,
|
||||
num_seeds: t.num_seeds,
|
||||
num_leechs: t.num_leechs,
|
||||
size: t.size,
|
||||
downloaded: t.downloaded,
|
||||
created_at: t.created_at,
|
||||
updated_at: t.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function DownloadTaskCard({
|
||||
item,
|
||||
removable,
|
||||
onRemove,
|
||||
}: {
|
||||
item: DownloadCardItem
|
||||
removable?: boolean
|
||||
onRemove?: () => Promise<void>
|
||||
}) {
|
||||
const progress = pct(item.progress)
|
||||
const visual = item.poster_url || item.backdrop_url
|
||||
const downloaded = item.downloaded || (item.size ? Math.round(item.size * (item.progress || 0)) : 0)
|
||||
|
||||
return (
|
||||
<article className="group overflow-hidden rounded-3xl border border-white/70 bg-white shadow-sm transition hover:-translate-y-1 hover:shadow-xl">
|
||||
<div className="relative flex gap-4 p-4">
|
||||
<div className="relative h-40 w-28 flex-shrink-0 overflow-hidden rounded-2xl bg-gradient-to-br from-primary-400/15 via-white to-surface-200 shadow-inner">
|
||||
{visual ? (
|
||||
<img
|
||||
src={imageURL(visual, item.updated_at)}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-3 text-center text-xs font-semibold text-brand-500">
|
||||
<Film size={24} />
|
||||
<span className="line-clamp-3">{item.title}</span>
|
||||
</div>
|
||||
)}
|
||||
<span className={`absolute left-2 top-2 rounded-full px-2 py-0.5 text-[10px] font-semibold ${statusTone(item)}`}>
|
||||
{stateLabel(item)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div>
|
||||
<h2 className="line-clamp-2 font-display text-lg font-semibold leading-snug text-ink-600" title={item.title}>
|
||||
{item.title}
|
||||
</h2>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-ink-50">
|
||||
{item.overview || item.save_path || '已隐藏原始种子 URL,避免泄露私有 Token。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-ink-100">
|
||||
<span>进度 {progress.toFixed(1)}%</span>
|
||||
<span>{fmtBytes(downloaded)} / {fmtBytes(item.size)}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-primary-400 to-brand-500 transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-ink-100">
|
||||
<DownloadMetric icon={<ArrowDown size={13} className="mr-1 inline text-brand-500" />} value={fmtSpeed(item.dlspeed)} />
|
||||
<DownloadMetric icon={<ArrowUp size={13} className="mr-1 inline text-brand-500" />} value={fmtSpeed(item.upspeed)} />
|
||||
<DownloadMetric icon={<Rss size={13} className="mr-1 inline text-brand-500" />} value={`${item.num_seeds ?? 0} / ${item.num_leechs ?? 0}`} />
|
||||
<DownloadMetric icon={<HardDrive size={13} className="mr-1 inline text-brand-500" />} value={fmtBytes(item.size)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-ink-50">
|
||||
<span className="truncate" title={item.save_path || ''}>
|
||||
{item.save_path || '默认下载目录'}
|
||||
</span>
|
||||
{item.created_at && <span>{new Date(item.created_at).toLocaleString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{removable && onRemove && (
|
||||
<div className="flex justify-end border-t border-gray-100 bg-gray-50/70 px-4 py-3">
|
||||
<button
|
||||
className="rounded-xl border border-red-400/40 bg-white px-3 py-1.5 text-xs font-semibold text-red-400 hover:bg-red-400/10"
|
||||
onClick={() => void onRemove()}
|
||||
>
|
||||
<Trash2 size={13} className="mr-1 inline" />
|
||||
删除任务
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function DownloadMetric({ icon, value }: { icon: ReactNode; value: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-gray-50 px-3 py-2">
|
||||
{icon}
|
||||
{value}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtBytes(n?: number): string {
|
||||
if (!n || n <= 0) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < u.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(v >= 100 ? 0 : 1)} ${u[i]}`
|
||||
}
|
||||
|
||||
function fmtSpeed(n?: number): string {
|
||||
return `${fmtBytes(n)}/s`
|
||||
}
|
||||
|
||||
function pct(progress?: number): number {
|
||||
if (!Number.isFinite(progress)) return 0
|
||||
return Math.min(100, Math.max(0, Math.round((progress ?? 0) * 1000) / 10))
|
||||
}
|
||||
|
||||
function stateLabel(item: DownloadCardItem): string {
|
||||
const state = (item.state || item.status || 'queued').toLowerCase()
|
||||
if (state.includes('down') || state.includes('meta')) return '下载中'
|
||||
if (state.includes('up') || state.includes('seed')) return '做种中'
|
||||
if (state.includes('pause')) return '已暂停'
|
||||
if (state.includes('error')) return '出错'
|
||||
if (state.includes('complete') || pct(item.progress) >= 100) return '已完成'
|
||||
if (state.includes('queue')) return '排队中'
|
||||
return item.state || item.status || '等待中'
|
||||
}
|
||||
|
||||
function statusTone(item: DownloadCardItem): string {
|
||||
const state = stateLabel(item)
|
||||
if (state === '已完成' || state === '做种中') return 'bg-emerald-50 text-emerald-600'
|
||||
if (state === '出错') return 'bg-red-50 text-red-500'
|
||||
if (state === '已暂停') return 'bg-amber-50 text-amber-600'
|
||||
return 'bg-primary-400/10 text-brand-500'
|
||||
}
|
||||
@@ -1,214 +1,13 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ArrowDown, ArrowUp, Download, Film, HardDrive, Rss, ShieldCheck, Trash2 } from 'lucide-react'
|
||||
import { Download, ShieldCheck } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { downloadsAPI } from '../api/downloads'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import type { DownloadTask, QBitTorrent } from '../types'
|
||||
|
||||
type DownloadCardItem = {
|
||||
id?: string
|
||||
hash?: string
|
||||
title: string
|
||||
poster_url?: string
|
||||
backdrop_url?: string
|
||||
overview?: string
|
||||
save_path?: string
|
||||
status?: string
|
||||
state?: string
|
||||
progress: number
|
||||
dlspeed?: number
|
||||
upspeed?: number
|
||||
num_seeds?: number
|
||||
num_leechs?: number
|
||||
size?: number
|
||||
downloaded?: number
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
function fmtBytes(n?: number): string {
|
||||
if (!n || n <= 0) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < u.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(v >= 100 ? 0 : 1)} ${u[i]}`
|
||||
}
|
||||
|
||||
function fmtSpeed(n?: number): string {
|
||||
return `${fmtBytes(n)}/s`
|
||||
}
|
||||
|
||||
function pct(progress?: number): number {
|
||||
if (!Number.isFinite(progress)) return 0
|
||||
return Math.min(100, Math.max(0, Math.round((progress ?? 0) * 1000) / 10))
|
||||
}
|
||||
|
||||
function stateLabel(item: DownloadCardItem): string {
|
||||
const state = (item.state || item.status || 'queued').toLowerCase()
|
||||
if (state.includes('down') || state.includes('meta')) return '下载中'
|
||||
if (state.includes('up') || state.includes('seed')) return '做种中'
|
||||
if (state.includes('pause')) return '已暂停'
|
||||
if (state.includes('error')) return '出错'
|
||||
if (state.includes('complete') || pct(item.progress) >= 100) return '已完成'
|
||||
if (state.includes('queue')) return '排队中'
|
||||
return item.state || item.status || '等待中'
|
||||
}
|
||||
|
||||
function statusTone(item: DownloadCardItem): string {
|
||||
const state = stateLabel(item)
|
||||
if (state === '已完成' || state === '做种中') return 'bg-emerald-50 text-emerald-600'
|
||||
if (state === '出错') return 'bg-red-50 text-red-500'
|
||||
if (state === '已暂停') return 'bg-amber-50 text-amber-600'
|
||||
return 'bg-primary-400/10 text-brand-500'
|
||||
}
|
||||
|
||||
function toLiveCard(t: QBitTorrent): DownloadCardItem {
|
||||
return {
|
||||
hash: t.hash,
|
||||
title: t.title || t.name || '下载任务',
|
||||
poster_url: t.poster_url,
|
||||
backdrop_url: t.backdrop_url,
|
||||
overview: t.overview,
|
||||
save_path: t.save_path,
|
||||
state: t.state,
|
||||
progress: t.progress,
|
||||
dlspeed: t.dlspeed,
|
||||
upspeed: t.upspeed,
|
||||
num_seeds: t.num_seeds,
|
||||
num_leechs: t.num_leechs,
|
||||
size: t.size,
|
||||
downloaded: t.downloaded,
|
||||
}
|
||||
}
|
||||
|
||||
function toTaskCard(t: DownloadTask): DownloadCardItem {
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.title || '下载任务',
|
||||
poster_url: t.poster_url,
|
||||
backdrop_url: t.backdrop_url,
|
||||
overview: t.overview,
|
||||
save_path: t.save_path,
|
||||
status: t.status,
|
||||
state: t.state,
|
||||
progress: t.progress,
|
||||
dlspeed: t.dlspeed,
|
||||
upspeed: t.upspeed,
|
||||
num_seeds: t.num_seeds,
|
||||
num_leechs: t.num_leechs,
|
||||
size: t.size,
|
||||
downloaded: t.downloaded,
|
||||
created_at: t.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
function DownloadCard({
|
||||
item,
|
||||
removable,
|
||||
onRemove,
|
||||
}: {
|
||||
item: DownloadCardItem
|
||||
removable?: boolean
|
||||
onRemove?: () => Promise<void>
|
||||
}) {
|
||||
const progress = pct(item.progress)
|
||||
const visual = item.poster_url || item.backdrop_url
|
||||
const downloaded = item.downloaded || (item.size ? Math.round(item.size * (item.progress || 0)) : 0)
|
||||
|
||||
return (
|
||||
<article className="group overflow-hidden rounded-3xl border border-white/70 bg-white shadow-sm transition hover:-translate-y-1 hover:shadow-xl">
|
||||
<div className="relative flex gap-4 p-4">
|
||||
<div className="relative h-40 w-28 flex-shrink-0 overflow-hidden rounded-2xl bg-gradient-to-br from-primary-400/15 via-white to-surface-200 shadow-inner">
|
||||
{visual ? (
|
||||
<img
|
||||
src={imageURL(visual)}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-3 text-center text-xs font-semibold text-brand-500">
|
||||
<Film size={24} />
|
||||
<span className="line-clamp-3">{item.title}</span>
|
||||
</div>
|
||||
)}
|
||||
<span className={`absolute left-2 top-2 rounded-full px-2 py-0.5 text-[10px] font-semibold ${statusTone(item)}`}>
|
||||
{stateLabel(item)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div>
|
||||
<h2 className="line-clamp-2 font-display text-lg font-semibold leading-snug text-ink-600" title={item.title}>
|
||||
{item.title}
|
||||
</h2>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-ink-50">
|
||||
{item.overview || item.save_path || '已隐藏原始种子 URL,避免泄露私有 Token。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-ink-100">
|
||||
<span>进度 {progress.toFixed(1)}%</span>
|
||||
<span>{fmtBytes(downloaded)} / {fmtBytes(item.size)}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-primary-400 to-brand-500 transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-ink-100">
|
||||
<div className="rounded-2xl bg-gray-50 px-3 py-2">
|
||||
<ArrowDown size={13} className="mr-1 inline text-brand-500" />
|
||||
{fmtSpeed(item.dlspeed)}
|
||||
</div>
|
||||
<div className="rounded-2xl bg-gray-50 px-3 py-2">
|
||||
<ArrowUp size={13} className="mr-1 inline text-brand-500" />
|
||||
{fmtSpeed(item.upspeed)}
|
||||
</div>
|
||||
<div className="rounded-2xl bg-gray-50 px-3 py-2">
|
||||
<Rss size={13} className="mr-1 inline text-brand-500" />
|
||||
{item.num_seeds ?? 0} / {item.num_leechs ?? 0}
|
||||
</div>
|
||||
<div className="rounded-2xl bg-gray-50 px-3 py-2">
|
||||
<HardDrive size={13} className="mr-1 inline text-brand-500" />
|
||||
{fmtBytes(item.size)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-ink-50">
|
||||
<span className="truncate" title={item.save_path || ''}>
|
||||
{item.save_path || '默认下载目录'}
|
||||
</span>
|
||||
{item.created_at && <span>{new Date(item.created_at).toLocaleString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{removable && onRemove && (
|
||||
<div className="flex justify-end border-t border-gray-100 bg-gray-50/70 px-4 py-3">
|
||||
<button
|
||||
className="rounded-xl border border-red-400/40 bg-white px-3 py-1.5 text-xs font-semibold text-red-400 hover:bg-red-400/10"
|
||||
onClick={() => void onRemove()}
|
||||
>
|
||||
<Trash2 size={13} className="mr-1 inline" />
|
||||
删除任务
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
import { DownloadTaskCard, toLiveCard, toTaskCard } from './DownloadTaskCard'
|
||||
|
||||
export function DownloadsPage() {
|
||||
const role = useAuthStore((s) => s.user?.role)
|
||||
@@ -301,7 +100,7 @@ export function DownloadsPage() {
|
||||
{torrents && torrents.length > 0 && (
|
||||
<div className="grid gap-5 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
{torrents.map((torrent) => (
|
||||
<DownloadCard
|
||||
<DownloadTaskCard
|
||||
key={torrent.hash}
|
||||
item={toLiveCard(torrent)}
|
||||
removable={role === 'admin'}
|
||||
@@ -324,7 +123,7 @@ export function DownloadsPage() {
|
||||
) : (
|
||||
<div className="grid gap-5 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
{tasks.map((task) => (
|
||||
<DownloadCard key={task.id} item={toTaskCard(task)} />
|
||||
<DownloadTaskCard key={task.id} item={toTaskCard(task)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Copy, Trash2 } from 'lucide-react'
|
||||
|
||||
import { duplicatesAPI, type DuplicateReport } from '../api/duplicates'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import type { Library } from '../types'
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { FolderOpen } from 'lucide-react'
|
||||
|
||||
type FileRoot = {
|
||||
label: string
|
||||
path: string
|
||||
}
|
||||
|
||||
type FileBrowserRootsProps = {
|
||||
roots: FileRoot[]
|
||||
onOpen: (path: string) => void
|
||||
}
|
||||
|
||||
export function FileBrowserRoots({ roots, onOpen }: FileBrowserRootsProps) {
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{roots.map((root) => (
|
||||
<button
|
||||
key={root.path}
|
||||
onClick={() => onOpen(root.path)}
|
||||
className="glass-panel flex items-center gap-3 text-left transition hover:border-primary-400/40"
|
||||
>
|
||||
<FolderOpen size={20} className="text-brand-500" />
|
||||
<div>
|
||||
<p className="font-mono text-sm text-ink-600">{root.label}</p>
|
||||
<p className="font-mono text-xs text-ink-50">{root.path}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { FileVideo, Folder, HardDrive } from 'lucide-react'
|
||||
|
||||
import type { FileEntry } from '../api/files'
|
||||
|
||||
type FileEntriesTableProps = {
|
||||
basePath: string
|
||||
entries: FileEntry[]
|
||||
recursive: boolean
|
||||
selectedPath?: string
|
||||
selectedPaths: string[]
|
||||
onSelectAll: (checked: boolean) => void
|
||||
onToggleSelectedPath: (entry: FileEntry, checked: boolean) => void
|
||||
onEnter: (entry: FileEntry) => void
|
||||
onChoose: (entry: FileEntry) => void
|
||||
}
|
||||
|
||||
export function FileEntriesTable({
|
||||
basePath,
|
||||
entries,
|
||||
recursive,
|
||||
selectedPath,
|
||||
selectedPaths,
|
||||
onSelectAll,
|
||||
onToggleSelectedPath,
|
||||
onEnter,
|
||||
onChoose,
|
||||
}: FileEntriesTableProps) {
|
||||
return (
|
||||
<div className="glass-panel overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="w-10 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="选择当前目录全部项目"
|
||||
checked={entries.length > 0 && entries.every((entry) => selectedPaths.includes(entry.path))}
|
||||
onChange={(event) => onSelectAll(event.target.checked)}
|
||||
/>
|
||||
</th>
|
||||
<th className="py-2">名称</th>
|
||||
<th>大小</th>
|
||||
<th>修改时间</th>
|
||||
<th className="text-right">选择</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr
|
||||
key={entry.path}
|
||||
className={'border-t border-gray-200 transition hover:bg-gray-50 ' + (selectedPath === entry.path || selectedPaths.includes(entry.path) ? 'bg-primary-400/5' : '')}
|
||||
>
|
||||
<td className="py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择 ${entry.name}`}
|
||||
checked={selectedPaths.includes(entry.path)}
|
||||
onChange={(event) => onToggleSelectedPath(entry, event.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 text-ink-600">
|
||||
<button
|
||||
className="flex max-w-xl items-center gap-2 text-left"
|
||||
onClick={() => (entry.is_dir ? onEnter(entry) : onChoose(entry))}
|
||||
title={entry.path}
|
||||
>
|
||||
{entry.is_dir ? <Folder size={16} className="text-brand-500" /> : <FileVideo size={16} className="text-ink-50" />}
|
||||
<span className="truncate">{entryLabel(entry, basePath, recursive)}</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="text-ink-100">{entry.is_dir ? '—' : fmtBytes(entry.size)}</td>
|
||||
<td className="text-sand-500">{new Date(entry.modified * 1000).toLocaleString()}</td>
|
||||
<td className="text-right">
|
||||
<button
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40"
|
||||
onClick={() => onChoose(entry)}
|
||||
>
|
||||
<HardDrive size={12} className="mr-1 inline" /> 操作
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function entryLabel(entry: FileEntry, basePath: string, recursive: boolean): string {
|
||||
if (!recursive) return entry.name
|
||||
return entry.path.replace((basePath || '') + '\\', '').replace((basePath || '') + '/', '')
|
||||
}
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (!n) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < u.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(1)} ${u[i]}`
|
||||
}
|
||||
+108
-878
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
import { ChevronUp, Home, RefreshCw } from 'lucide-react'
|
||||
|
||||
type FileManagerToolbarProps = {
|
||||
currentPath?: string
|
||||
parentPath?: string
|
||||
recursive: boolean
|
||||
onRoot: () => void
|
||||
onParent: (path: string) => void
|
||||
onRefresh: () => void
|
||||
onRecursiveChange: (value: boolean) => void
|
||||
}
|
||||
|
||||
export function FileManagerToolbar({
|
||||
currentPath,
|
||||
parentPath,
|
||||
recursive,
|
||||
onRoot,
|
||||
onParent,
|
||||
onRefresh,
|
||||
onRecursiveChange,
|
||||
}: FileManagerToolbarProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button className="neon-button !px-3 !py-1 !text-xs" onClick={onRoot} title="返回根列表">
|
||||
<Home size={14} /> 根
|
||||
</button>
|
||||
{parentPath && (
|
||||
<button className="neon-button !px-3 !py-1 !text-xs" onClick={() => onParent(parentPath)}>
|
||||
<ChevronUp size={14} /> 上一级
|
||||
</button>
|
||||
)}
|
||||
<button className="neon-button !px-3 !py-1 !text-xs" onClick={onRefresh}>
|
||||
<RefreshCw size={14} /> 刷新
|
||||
</button>
|
||||
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
|
||||
<input type="checkbox" checked={recursive} onChange={(e) => onRecursiveChange(e.target.checked)} />
|
||||
递归扫描
|
||||
</label>
|
||||
{currentPath && (
|
||||
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 font-mono text-xs text-ink-100">
|
||||
{currentPath}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Copy, GitBranch, Move, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
import type { FileEntry } from '../api/files'
|
||||
|
||||
type FileOperationsPanelProps = {
|
||||
selected: FileEntry | null
|
||||
folderName: string
|
||||
renameTo: string
|
||||
destPath: string
|
||||
transferMode: string
|
||||
busy: string
|
||||
onFolderNameChange: (value: string) => void
|
||||
onRenameToChange: (value: string) => void
|
||||
onDestPathChange: (value: string) => void
|
||||
onTransferModeChange: (value: string) => void
|
||||
onCreateFolder: () => void
|
||||
onRenameSelected: () => void
|
||||
onDeleteSelected: () => void
|
||||
onTransferSelected: () => void
|
||||
}
|
||||
|
||||
export function FileOperationsPanel({
|
||||
selected,
|
||||
folderName,
|
||||
renameTo,
|
||||
destPath,
|
||||
transferMode,
|
||||
busy,
|
||||
onFolderNameChange,
|
||||
onRenameToChange,
|
||||
onDestPathChange,
|
||||
onTransferModeChange,
|
||||
onCreateFolder,
|
||||
onRenameSelected,
|
||||
onDeleteSelected,
|
||||
onTransferSelected,
|
||||
}: FileOperationsPanelProps) {
|
||||
return (
|
||||
<details className="glass-panel" open={Boolean(selected)}>
|
||||
<summary className="cursor-pointer list-none font-display text-lg font-semibold text-ink-600">
|
||||
文件操作
|
||||
<span className="ml-2 text-xs font-normal text-sand-500">
|
||||
新建目录 / 重命名 / 删除 / 转移
|
||||
</span>
|
||||
</summary>
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[1fr_1.2fr]">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-semibold text-ink-600">新建目录</h2>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input-base flex-1"
|
||||
placeholder="新目录名称"
|
||||
value={folderName}
|
||||
onChange={(event) => onFolderNameChange(event.target.value)}
|
||||
/>
|
||||
<button className="neon-button" disabled={busy === 'mkdir' || !folderName.trim()} onClick={onCreateFolder}>
|
||||
<Plus size={16} /> 创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-semibold text-ink-600">选中项</h2>
|
||||
{selected ? (
|
||||
<div className="space-y-2">
|
||||
<p className="truncate font-mono text-xs text-ink-50" title={selected.path}>{selected.path}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
className="input-base min-w-[220px] flex-1"
|
||||
value={renameTo}
|
||||
onChange={(event) => onRenameToChange(event.target.value)}
|
||||
/>
|
||||
<button className="neon-button" disabled={busy === 'rename' || !renameTo.trim()} onClick={onRenameSelected}>
|
||||
<Pencil size={16} /> 重命名
|
||||
</button>
|
||||
<button
|
||||
className="rounded-xl border border-red-400/40 px-3 py-2 text-sm text-red-500 hover:bg-red-50"
|
||||
disabled={busy === 'delete'}
|
||||
onClick={onDeleteSelected}
|
||||
>
|
||||
<Trash2 size={16} className="inline" /> 删除
|
||||
</button>
|
||||
</div>
|
||||
{!selected.is_dir && (
|
||||
<div className="grid gap-2 md:grid-cols-[1fr_140px_auto]">
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="目标目录路径"
|
||||
value={destPath}
|
||||
onChange={(event) => onDestPathChange(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="input-base"
|
||||
value={transferMode}
|
||||
onChange={(event) => onTransferModeChange(event.target.value)}
|
||||
>
|
||||
<option value="copy">复制</option>
|
||||
<option value="move">移动</option>
|
||||
<option value="hardlink">硬链接</option>
|
||||
<option value="symlink">软链接</option>
|
||||
</select>
|
||||
<button className="neon-button" disabled={busy === 'transfer' || !destPath.trim()} onClick={onTransferSelected}>
|
||||
{transferIcon(transferMode)}
|
||||
转移
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-ink-50">先在下方列表点击“操作”选择文件或目录。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
function transferIcon(mode: string) {
|
||||
if (mode === 'move') return <Move size={16} />
|
||||
if (mode === 'copy') return <Copy size={16} />
|
||||
return <GitBranch size={16} />
|
||||
}
|
||||
+40
-40
@@ -60,10 +60,10 @@ export function HomePage() {
|
||||
<div className="flex items-center justify-center py-48">
|
||||
<motion.div animate={{ opacity: [0.4, 1, 0.4] }} transition={{ repeat: Infinity, duration: 1.5 }} className="flex flex-col items-center gap-4">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="h-10 w-10 rounded-full border-2 border-gray-100 border-t-gray-900 animate-spin" />
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-2 border-[var(--app-border)] border-t-[var(--app-active-bg)]" />
|
||||
<Film className="absolute h-4 w-4 text-brand-500" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold tracking-widest text-gray-500 uppercase">首页内容准备中…</span>
|
||||
<span className="text-sm font-semibold uppercase tracking-widest text-[var(--app-muted)]">首页内容准备中…</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
@@ -72,11 +72,11 @@ export function HomePage() {
|
||||
if (empty) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-32 text-center max-w-md mx-auto">
|
||||
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-3xl bg-gray-100 border border-gray-200/50 shadow-sm">
|
||||
<Film className="h-10 w-10 text-gray-500" />
|
||||
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] shadow-sm">
|
||||
<Film className="h-10 w-10 text-[var(--app-muted)]" />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-gray-900">您的家庭影视站暂无内容</p>
|
||||
<p className="mt-2 text-sm text-gray-500 leading-relaxed">
|
||||
<p className="text-xl font-bold text-[var(--app-text)]">您的家庭影视站暂无内容</p>
|
||||
<p className="mt-2 text-sm leading-relaxed text-[var(--app-muted)]">
|
||||
前往管理后台添加媒体目录,扫描后首页将展示本周力荐、继续观看和最近入库。
|
||||
</p>
|
||||
<Link to="/admin" className="mt-8 btn-primary">
|
||||
@@ -89,65 +89,65 @@ export function HomePage() {
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
{featuredItem && (
|
||||
<section className="relative overflow-hidden rounded-[2rem] bg-white border border-gray-200/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)]">
|
||||
<section className="relative overflow-hidden rounded-[2rem] border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_24px_80px_var(--app-shadow)]">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div className="h-full w-full bg-[radial-gradient(circle_at_80%_20%,rgba(212,175,55,0.22),transparent_34%),linear-gradient(135deg,#fff7ed,#f8fafc_52%,#eef2ff)]" />
|
||||
<div className="theme-hero-bg h-full w-full" />
|
||||
{featuredVisual && (
|
||||
<img
|
||||
src={imageURL(featuredVisual)}
|
||||
src={imageURL(featuredVisual, featuredItem.updated_at)}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full scale-105 object-cover object-center opacity-[0.34] blur-[1px]"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(event) => { event.currentTarget.style.display = 'none' }}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(90deg,#ffffff_0%,rgba(255,255,255,0.96)_37%,rgba(255,255,255,0.62)_68%,rgba(255,255,255,0.2)_100%)]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-white to-transparent" />
|
||||
<div className="theme-hero-overlay absolute inset-0" />
|
||||
<div className="theme-hero-fade absolute inset-x-0 bottom-0 h-32" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 grid gap-8 px-6 py-8 sm:px-8 md:grid-cols-[minmax(0,1fr)_280px] md:px-12 md:py-12 lg:grid-cols-[minmax(0,1fr)_340px] lg:px-14 lg:py-14">
|
||||
<div className="flex min-w-0 flex-col justify-center space-y-5">
|
||||
<div className="inline-flex w-fit items-center gap-2 rounded-full bg-white/82 px-3.5 py-1.5 text-xs font-bold uppercase tracking-widest text-[#a8732d] border border-[#ead6b6] shadow-sm backdrop-blur">
|
||||
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-[var(--app-brand-border)] bg-[var(--app-brand-soft)] px-3.5 py-1.5 text-xs font-bold uppercase tracking-widest text-[var(--app-brand-text)] shadow-sm backdrop-blur">
|
||||
<Sparkles size={12} fill="currentColor" />
|
||||
<span>本周力荐 / Featured</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="inline-flex max-w-full items-center gap-2 rounded-2xl bg-gray-950 px-3 py-2 text-white shadow-lg shadow-gray-950/10">
|
||||
<div className="inline-flex max-w-full items-center gap-2 rounded-2xl bg-[var(--app-active-bg)] px-3 py-2 text-[var(--app-active-text)] shadow-lg">
|
||||
<span className="h-2 w-2 rounded-full bg-[#d4af37]" />
|
||||
<span className="truncate text-xs font-black tracking-[0.26em]">{featuredMark}</span>
|
||||
</div>
|
||||
<h1 className="font-display text-3xl sm:text-4xl md:text-5xl font-extrabold tracking-tight leading-tight text-gray-950">
|
||||
<h1 className="font-display text-3xl font-extrabold leading-tight tracking-tight text-[var(--app-text)] sm:text-4xl md:text-5xl">
|
||||
{featuredItem.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<p className="max-w-2xl text-gray-600 text-sm sm:text-base leading-relaxed line-clamp-3 font-semibold">
|
||||
<p className="line-clamp-3 max-w-2xl text-sm font-semibold leading-relaxed text-[var(--app-subtle)] sm:text-base">
|
||||
{featuredItem.overview || '家庭私人媒体中心收藏。支持多端播放、外部播放器、智能刮削与订阅下载。'}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 font-bold">
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs font-bold text-[var(--app-muted)]">
|
||||
{featuredItem.year > 0 && (
|
||||
<span className="bg-white/85 px-2.5 py-1 rounded-xl text-gray-900 border border-gray-200 shadow-sm">{featuredItem.year} 年</span>
|
||||
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-2.5 py-1 text-[var(--app-text)] shadow-sm">{featuredItem.year} 年</span>
|
||||
)}
|
||||
{featuredItem.video_codec && (
|
||||
<span className="rounded-lg bg-[#fff8e7] px-2 py-1 text-[#9a6a1e] border border-[#ead6b6] uppercase font-bold text-[10px]">
|
||||
<span className="rounded-lg border border-[var(--app-brand-border)] bg-[var(--app-brand-soft)] px-2 py-1 text-[10px] font-bold uppercase text-[var(--app-brand-text)]">
|
||||
{featuredItem.video_codec}
|
||||
</span>
|
||||
)}
|
||||
{featuredItem.container && (
|
||||
<span className="rounded-lg bg-white/80 px-2 py-1 text-gray-700 border border-gray-200 uppercase font-mono text-[10px]">
|
||||
<span className="rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 font-mono text-[10px] uppercase text-[var(--app-subtle)]">
|
||||
{featuredItem.container}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 pt-2">
|
||||
<Link to={`/media/${featuredItem.id}`} className="inline-flex items-center justify-center gap-2 rounded-xl bg-[#111827] px-6 py-3.5 text-sm font-bold text-white shadow-lg shadow-gray-900/15 hover:bg-[#1f2937] hover:-translate-y-0.5 transition-all">
|
||||
<Link to={`/media/${featuredItem.id}`} className="inline-flex items-center justify-center gap-2 rounded-xl bg-[var(--app-command-bg)] px-6 py-3.5 text-sm font-bold text-[var(--app-command-text)] shadow-lg transition-all hover:-translate-y-0.5">
|
||||
<Play size={16} fill="currentColor" />
|
||||
<span>立即播放</span>
|
||||
</Link>
|
||||
<Link to="/discover" className="btn-outline bg-white/80 px-5 py-3.5 text-sm font-bold text-gray-700 border border-gray-200 hover:border-gray-300">
|
||||
<Link to="/discover" className="inline-flex items-center justify-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-5 py-3.5 text-sm font-bold text-[var(--app-subtle)] shadow-sm transition-all hover:-translate-y-0.5 hover:border-brand-500/40 hover:text-[var(--app-text)]">
|
||||
<span>发现更多精彩</span>
|
||||
<ArrowRight size={16} />
|
||||
</Link>
|
||||
@@ -156,14 +156,14 @@ export function HomePage() {
|
||||
|
||||
<div className="relative order-first mx-auto flex w-full max-w-[220px] items-center md:order-none md:max-w-[260px] lg:max-w-[310px]">
|
||||
<div className="absolute -right-6 top-5 h-32 w-32 rounded-full bg-[#d4af37]/20 blur-3xl" />
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden rounded-[1.7rem] border border-white/70 bg-white p-2 shadow-[0_32px_80px_rgba(15,23,42,0.20)]">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center rounded-[1.25rem] bg-[linear-gradient(135deg,#f9fafb,#fff7ed)] text-center">
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden rounded-[1.7rem] border border-[var(--app-border)] bg-[var(--app-poster-shell)] p-2 shadow-[0_32px_80px_var(--app-shadow)]">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center rounded-[1.25rem] text-center" style={{ background: 'var(--app-poster-empty)' }}>
|
||||
<Film className="mb-4 h-12 w-12 text-[#c9954a]" />
|
||||
<span className="px-6 font-display text-3xl font-black tracking-tight text-gray-950">{featuredItem.title}</span>
|
||||
<span className="px-6 font-display text-3xl font-black tracking-tight text-[var(--app-text)]">{featuredItem.title}</span>
|
||||
</div>
|
||||
{featuredPoster && (
|
||||
<img
|
||||
src={imageURL(featuredPoster)}
|
||||
src={imageURL(featuredPoster, featuredItem.updated_at)}
|
||||
alt={featuredItem.title}
|
||||
className="absolute inset-2 h-[calc(100%-1rem)] w-[calc(100%-1rem)] rounded-[1.25rem] object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
@@ -179,11 +179,11 @@ export function HomePage() {
|
||||
{history.length > 0 && (
|
||||
<section className="space-y-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="p-1.5 rounded-xl bg-gray-100 border border-gray-200/50 text-gray-900">
|
||||
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-1.5 text-[var(--app-text)]">
|
||||
<Clock size={16} />
|
||||
</span>
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">继续观看</h2>
|
||||
<span className="text-xs font-bold text-gray-500 bg-gray-100 px-2.5 py-0.5 rounded-full border border-gray-200/40">{history.length} 个记录</span>
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">继续观看</h2>
|
||||
<span className="rounded-full border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-0.5 text-xs font-bold text-[var(--app-muted)]">{history.length} 个记录</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
|
||||
@@ -198,17 +198,17 @@ export function HomePage() {
|
||||
|
||||
{recentCards.length > 0 && (
|
||||
<section className="space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-gray-200/80 pb-3">
|
||||
<div className="flex items-center justify-between border-b border-[var(--app-border)] pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="p-1.5 rounded-xl bg-gray-100 text-gray-900 border border-gray-200/50">
|
||||
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-1.5 text-[var(--app-text)]">
|
||||
<Clock size={18} />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">最近入库</h2>
|
||||
<p className="text-xs text-gray-500">按整部电影、剧集、番剧和综艺合集展示新增内容。</p>
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">最近入库</h2>
|
||||
<p className="text-xs text-[var(--app-muted)]">按整部电影、剧集、番剧和综艺合集展示新增内容。</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/poster-wall" className="group inline-flex items-center gap-1 text-xs font-bold text-gray-600 hover:text-brand-600 transition-colors">
|
||||
<Link to="/poster-wall" className="group inline-flex items-center gap-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:text-brand-500">
|
||||
<span>海报墙</span>
|
||||
<ArrowRight size={14} className="transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
@@ -232,18 +232,18 @@ export function HomePage() {
|
||||
|
||||
function ContinueCard({ media, progress }: { media: Media; progress: number }) {
|
||||
return (
|
||||
<Link to={`/media/${media.id}`} className="group flex items-center gap-4 rounded-2xl bg-white p-3.5 border border-gray-200/80 shadow-[0_1px_3px_rgba(0,0,0,0.01)] transition-all duration-300 hover:shadow-md hover:border-brand-500/30">
|
||||
<div className="relative h-18 w-12 shrink-0 overflow-hidden rounded-xl bg-gray-50 border border-gray-100">
|
||||
<Link to={`/media/${media.id}`} className="group flex items-center gap-4 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3.5 shadow-[0_1px_3px_rgba(0,0,0,0.01)] transition-all duration-300 hover:border-brand-500/30 hover:bg-[var(--app-panel-soft)] hover:shadow-md">
|
||||
<div className="relative h-18 w-12 shrink-0 overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url)}
|
||||
src={imageURL(media.poster_url, media.updated_at)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-gray-500 bg-gray-50">
|
||||
<div className="flex h-full items-center justify-center bg-[var(--app-panel-soft)] text-[var(--app-muted)]">
|
||||
<Film size={16} />
|
||||
</div>
|
||||
)}
|
||||
@@ -252,11 +252,11 @@ function ContinueCard({ media, progress }: { media: Media; progress: number }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<p className="truncate text-sm font-bold text-gray-900 group-hover:text-brand-500 transition-colors">
|
||||
<p className="truncate text-sm font-bold text-[var(--app-text)] transition-colors group-hover:text-brand-500">
|
||||
{media.title}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-[var(--app-hover)]">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${Math.round(progress * 100)}%` }}
|
||||
@@ -264,7 +264,7 @@ function ContinueCard({ media, progress }: { media: Media; progress: number }) {
|
||||
className="h-full rounded-full bg-gradient-to-r from-brand-400 to-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500 font-bold tracking-wide uppercase">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wide text-[var(--app-muted)]">
|
||||
已观看到 {Math.round(progress * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, PlayCircle
|
||||
import { imageURL } from '../api/client'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { toolsAPI } from '../api/tools'
|
||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { Library, Media } from '../types'
|
||||
import { artworkScore, groupSeries, seriesCardLink, type SeriesCard } from '../utils/groupSeries'
|
||||
@@ -37,6 +38,7 @@ export function LibrariesPage() {
|
||||
const [previews, setPreviews] = useState<LibraryPreview[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [repairing, setRepairing] = useState(false)
|
||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false)
|
||||
const [repairMsg, setRepairMsg] = useState('')
|
||||
|
||||
async function handleRepairRescrape() {
|
||||
@@ -44,7 +46,7 @@ export function LibrariesPage() {
|
||||
setRepairing(true)
|
||||
setRepairMsg('')
|
||||
try {
|
||||
await toolsAPI.repairAndRescrapeAll()
|
||||
await toolsAPI.repairAndRescrapeAll({ episode_images: repairEpisodeArtwork, refresh_matched: true })
|
||||
setRepairMsg('已开始全库修复+重刮,进度可在任务中查看。')
|
||||
} catch {
|
||||
setRepairMsg('启动失败,请稍后重试。')
|
||||
@@ -61,7 +63,7 @@ export function LibrariesPage() {
|
||||
const libs = await libraryAPI.list()
|
||||
const rows = await Promise.all(libs.map(async (library) => {
|
||||
try {
|
||||
const page = await libraryAPI.listMedia(library.id, 1, 160)
|
||||
const page = await libraryAPI.listMedia(library.id, 1, 160, { groupVersions: false })
|
||||
const cards = latestCards(page.items)
|
||||
return { library, items: page.items, total: page.total, cards } satisfies LibraryPreview
|
||||
} catch {
|
||||
@@ -94,6 +96,12 @@ export function LibrariesPage() {
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{repairMsg && <span className="text-xs text-ink-50">{repairMsg}</span>}
|
||||
<EpisodeArtworkToggle
|
||||
checked={repairEpisodeArtwork}
|
||||
onChange={setRepairEpisodeArtwork}
|
||||
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||
className="h-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRepairRescrape}
|
||||
@@ -159,9 +167,12 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
||||
const library = preview.library
|
||||
const artwork = preview.cards
|
||||
.sort((a, b) => artworkScore(b.rep) - artworkScore(a.rep) || mediaTime(b.rep) - mediaTime(a.rep))
|
||||
.map((card) => card.rep.poster_url || card.rep.backdrop_url)
|
||||
.filter(Boolean)
|
||||
.slice(0, 4) as string[]
|
||||
.map((card) => ({
|
||||
src: card.rep.poster_url || card.rep.backdrop_url,
|
||||
version: card.rep.updated_at,
|
||||
}))
|
||||
.filter((item) => Boolean(item.src))
|
||||
.slice(0, 4)
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -170,10 +181,10 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
||||
>
|
||||
<div className="grid h-24 w-36 shrink-0 grid-cols-2 gap-1 overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)]">
|
||||
{artwork.length > 0 ? (
|
||||
artwork.map((src, index) => (
|
||||
artwork.map(({ src, version }, index) => (
|
||||
<img
|
||||
key={`${src}-${index}`}
|
||||
src={imageURL(src)}
|
||||
src={imageURL(src, version)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Film } from 'lucide-react'
|
||||
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { Media } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
|
||||
type LibraryMediaSectionsProps = {
|
||||
isSeries: boolean
|
||||
items: Media[]
|
||||
seriesCards: SeriesCard[]
|
||||
selectedSeries: SeriesCard | null
|
||||
loading: boolean
|
||||
movieActions: (media: Media) => ReactNode
|
||||
onSeriesClick: (series: SeriesCard) => void
|
||||
}
|
||||
|
||||
export function LibraryMediaSections({
|
||||
isSeries,
|
||||
items,
|
||||
seriesCards,
|
||||
selectedSeries,
|
||||
loading,
|
||||
movieActions,
|
||||
onSeriesClick,
|
||||
}: LibraryMediaSectionsProps) {
|
||||
return (
|
||||
<>
|
||||
{!isSeries && items.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
|
||||
{items.map((media) => (
|
||||
<MediaCard key={media.id} media={media} actions={movieActions(media)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSeries && items.length === 0 && (
|
||||
<LibraryEmptyState message="该媒体库暂无内容,触发一次扫描后再来看看" />
|
||||
)}
|
||||
|
||||
{isSeries && seriesCards.length > 0 && !selectedSeries && (
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
|
||||
{seriesCards.map((series) => (
|
||||
<MediaCard
|
||||
key={series.key}
|
||||
media={series.rep}
|
||||
count={series.count}
|
||||
onClick={() => onSeriesClick(series)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSeries && seriesCards.length === 0 && !loading && (
|
||||
<LibraryEmptyState message="该库尚未发现任何剧集,触发一次扫描后再来看看" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LibraryEmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<Film className="mb-4 h-12 w-12 text-gray-500" />
|
||||
<p className="text-ink-50">{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Database, FileText, Search, Sparkles, Trash2 } from 'lucide-react'
|
||||
|
||||
import type { Media } from '../types'
|
||||
|
||||
type LibraryMovieActionsProps = {
|
||||
media: Media
|
||||
busy: boolean
|
||||
onSmartScrape: (media: Media) => void
|
||||
onManualScrape: (media: Media) => void
|
||||
onProbe: (media: Media) => void
|
||||
onNFO: (media: Media) => void
|
||||
onSoftDelete: (media: Media) => void
|
||||
}
|
||||
|
||||
export function LibraryMovieActions({
|
||||
media,
|
||||
busy,
|
||||
onSmartScrape,
|
||||
onManualScrape,
|
||||
onProbe,
|
||||
onNFO,
|
||||
onSoftDelete,
|
||||
}: LibraryMovieActionsProps) {
|
||||
const buttonClass = 'flex h-8 w-8 items-center justify-center rounded-lg border border-white/70 bg-white/90 text-gray-700 shadow-sm backdrop-blur transition hover:bg-brand-50 hover:text-brand-600 disabled:opacity-50'
|
||||
|
||||
return (
|
||||
<>
|
||||
<button title="智能刮削" disabled={busy} onClick={() => onSmartScrape(media)} className={buttonClass}>
|
||||
<Sparkles size={13} />
|
||||
</button>
|
||||
<button title="手动匹配刮削" disabled={busy} onClick={() => onManualScrape(media)} className={buttonClass}>
|
||||
<Search size={13} />
|
||||
</button>
|
||||
<button title="探测媒体轨" disabled={busy} onClick={() => onProbe(media)} className={buttonClass}>
|
||||
<Database size={13} />
|
||||
</button>
|
||||
<button title="写出本地 NFO" disabled={busy} onClick={() => onNFO(media)} className={buttonClass}>
|
||||
<FileText size={13} />
|
||||
</button>
|
||||
<button title="移入回收站" disabled={busy} onClick={() => onSoftDelete(media)} className={`${buttonClass} hover:!bg-red-50 hover:!text-red-500`}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+136
-670
@@ -1,24 +1,20 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import { useLocation, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ArrowLeft, Play, Film, Database, FileText, Search, Sparkles, Trash2, Pencil, FolderInput } from 'lucide-react'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { toolsAPI } from '../api/tools'
|
||||
import { storageAPI, type CloudScanStatus } from '../api/storage_config'
|
||||
import { api } from '../api/client'
|
||||
import { recycleAPI } from '../api/recycle'
|
||||
import type { Library, Media } from '../types'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { getSeriesKey, groupSeries, isEpisodeLike, seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||
import { useWebSocket } from '../hooks/useWebSocket'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
||||
import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
||||
import { LibrarySeriesEpisodes } from './LibrarySeriesEpisodes'
|
||||
import { LibrarySeriesDetailHeader } from './LibrarySeriesDetailHeader'
|
||||
import { LibraryPageHeader } from './LibraryPageHeader'
|
||||
import { LibraryMediaSections } from './LibraryMediaSections'
|
||||
import { useLibraryData } from './useLibraryData'
|
||||
import { useLibraryScanStatus } from './useLibraryScanStatus'
|
||||
import { useLibrarySeriesSelection } from './useLibrarySeriesSelection'
|
||||
import { useLibraryAdminActions } from './useLibraryAdminActions'
|
||||
|
||||
export function LibraryPage() {
|
||||
const { id = '' } = useParams()
|
||||
@@ -26,422 +22,85 @@ export function LibraryPage() {
|
||||
const location = useLocation()
|
||||
const role = useAuthStore((s) => s.user?.role)
|
||||
|
||||
const [library, setLibrary] = useState<Library | null>(null)
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingAll, setLoadingAll] = useState(false)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [scanProgress, setScanProgress] = useState('')
|
||||
const [scraping, setScraping] = useState(false)
|
||||
const [repairing, setRepairing] = useState(false)
|
||||
const [seriesToolBusy, setSeriesToolBusy] = useState('')
|
||||
const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false)
|
||||
const [seriesMetadataEditOpen, setSeriesMetadataEditOpen] = useState(false)
|
||||
const [manualMovie, setManualMovie] = useState<Media | null>(null)
|
||||
const [movieToolBusy, setMovieToolBusy] = useState('')
|
||||
|
||||
// 剧集模式:选中某个剧集后展开详情
|
||||
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
|
||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null)
|
||||
|
||||
const hasEpisodicItems = useMemo(() => items.some(isEpisodeLike), [items])
|
||||
const isSeries = library?.type === 'tv' || library?.type === 'anime' || library?.type === 'variety' || hasEpisodicItems
|
||||
const {
|
||||
library,
|
||||
items,
|
||||
seriesEpisodeItems,
|
||||
total,
|
||||
loading,
|
||||
loadingSeriesEpisodes,
|
||||
isSeriesLibrary,
|
||||
isSeries,
|
||||
seriesCards,
|
||||
loadingAllText,
|
||||
reloadCurrentLibrary,
|
||||
} = useLibraryData(id, selectedSeries)
|
||||
|
||||
// 折叠后的剧集卡片
|
||||
const seriesCards = useMemo(() => {
|
||||
if (!isSeries || items.length === 0) return []
|
||||
return groupSeries(items)
|
||||
}, [isSeries, items])
|
||||
const {
|
||||
scanning,
|
||||
scanProgress,
|
||||
handleScan,
|
||||
} = useLibraryScanStatus({
|
||||
libraryID: id,
|
||||
isAdmin: role === 'admin',
|
||||
onLibraryChanged: reloadCurrentLibrary,
|
||||
})
|
||||
|
||||
// 选中的剧集:所有集按季分组
|
||||
const selectedEpisodes = useMemo(() => {
|
||||
if (!selectedSeries || items.length === 0) return []
|
||||
const eps = items.filter((m) => getSeriesKey(m) === selectedSeries.key)
|
||||
// 按季分组,按集排序
|
||||
const seasons = new Map<number, Media[]>()
|
||||
for (const ep of eps) {
|
||||
const s = ep.episode_num > 0 ? (ep.season_num ?? 0) : (ep.season_num || 1)
|
||||
if (!seasons.has(s)) seasons.set(s, [])
|
||||
seasons.get(s)!.push(ep)
|
||||
}
|
||||
for (const [, list] of seasons) {
|
||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||
}
|
||||
return Array.from(seasons.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([season, episodes]) => ({ season, episodes }))
|
||||
}, [selectedSeries, items])
|
||||
const {
|
||||
selectedEpisodes,
|
||||
visibleEpisodes,
|
||||
selectedSeriesEpisodes,
|
||||
selectedSeriesMediaIDs,
|
||||
handleSeriesClick,
|
||||
clearSelectedSeries,
|
||||
} = useLibrarySeriesSelection({
|
||||
items,
|
||||
seriesEpisodeItems,
|
||||
isSeriesLibrary,
|
||||
isSeries,
|
||||
loading,
|
||||
seriesCards,
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
selectedSeries,
|
||||
setSelectedSeries,
|
||||
selectedSeason,
|
||||
setSelectedSeason,
|
||||
onClearSeriesState: () => setSeriesMetadataEditOpen(false),
|
||||
})
|
||||
|
||||
const visibleEpisodes = useMemo(() => {
|
||||
if (selectedSeason == null) return selectedEpisodes[0]?.episodes ?? []
|
||||
return selectedEpisodes.find((s) => s.season === selectedSeason)?.episodes ?? []
|
||||
}, [selectedEpisodes, selectedSeason])
|
||||
|
||||
const selectedSeriesEpisodes = useMemo(
|
||||
() => selectedEpisodes.flatMap((season) => season.episodes),
|
||||
[selectedEpisodes],
|
||||
)
|
||||
|
||||
const selectedSeriesMediaIDs = useMemo(
|
||||
() => selectedSeriesEpisodes.map((ep) => ep.id),
|
||||
[selectedSeriesEpisodes],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
libraryAPI.list().then((all) => {
|
||||
const lib = all.find((l) => l.id === id) ?? null
|
||||
setLibrary(lib)
|
||||
})
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !library) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setLoadingAll(true)
|
||||
setItems([])
|
||||
const loadAll = async () => {
|
||||
const pageSize = 2000
|
||||
let page = 1
|
||||
let collected: Media[] = []
|
||||
try {
|
||||
for (;;) {
|
||||
const d = await libraryAPI.listMedia(id, page, pageSize)
|
||||
if (cancelled) return
|
||||
collected = collected.concat(d.items)
|
||||
setItems(collected)
|
||||
setTotal(d.total)
|
||||
if (page === 1) setLoading(false)
|
||||
if (collected.length >= d.total || d.items.length < pageSize) break
|
||||
page += 1
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
setLoadingAll(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
loadAll().catch(() => {
|
||||
if (!cancelled) {
|
||||
toast.error('媒体库加载失败')
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [id, library])
|
||||
|
||||
const reloadCurrentLibrary = useCallback(() => {
|
||||
setLibrary((l) => (l ? { ...l } : l))
|
||||
}, [])
|
||||
|
||||
const onRealtimeEvent = useCallback((topic: string, payload: unknown) => {
|
||||
if (role !== 'admin') return
|
||||
if (topic !== 'scan' || !payload || typeof payload !== 'object') return
|
||||
const p = payload as Record<string, unknown>
|
||||
if (p.library_id !== id) return
|
||||
if (p.error) {
|
||||
setScanning(false)
|
||||
setScanProgress(`扫描失败:${String(p.error)}`)
|
||||
return
|
||||
}
|
||||
if (p.finished) {
|
||||
setScanning(false)
|
||||
const elapsed = Number(p.elapsed_seconds ?? p.elapsed ?? 0)
|
||||
const elapsedText = elapsed > 0 ? ` · 耗时 ${formatDuration(elapsed)}` : ''
|
||||
setScanProgress(`扫描完成:发现 ${p.discovered ?? p.visited ?? 0} · 新增 ${p.added ?? 0} · 更新 ${p.updated ?? 0} · 跳过 ${p.skipped ?? 0}${elapsedText}`)
|
||||
reloadCurrentLibrary()
|
||||
return
|
||||
}
|
||||
if (p.queued) {
|
||||
setScanning(true)
|
||||
setScanProgress(String(p.message ?? '扫描已排队,后台会自动入库'))
|
||||
return
|
||||
}
|
||||
if (p.cloud && p.stage) {
|
||||
const stage = p.stage === 'importing' ? '正在入库' : '正在遍历目录'
|
||||
const speed = Number(p.files_per_second ?? 0)
|
||||
const speedText = speed > 0 ? ` · ${speed.toFixed(speed >= 10 ? 0 : 1)} 个/秒` : ''
|
||||
setScanning(true)
|
||||
setScanProgress(`${stage}:目录 ${p.dirs ?? 0} · 已发现 ${p.discovered ?? 0} · 已入库 ${p.visited ?? 0}${speedText}`)
|
||||
}
|
||||
}, [id, reloadCurrentLibrary, role])
|
||||
|
||||
useWebSocket(onRealtimeEvent)
|
||||
|
||||
useEffect(() => {
|
||||
if (role !== 'admin' || !id) return
|
||||
let cancelled = false
|
||||
let terminal = false
|
||||
let timer: number | undefined
|
||||
const restoreCloudScanStatus = async () => {
|
||||
const r = await storageAPI.cloudScanStatus()
|
||||
if (cancelled) return
|
||||
const status = (r.items ?? []).find((item) => item.library_id === id)
|
||||
if (!status) return
|
||||
if (status.state === 'running' || status.state === 'queued' || status.state === 'canceling') {
|
||||
setScanning(true)
|
||||
setScanProgress(formatCloudScanStatus(status))
|
||||
return
|
||||
}
|
||||
if (status.state === 'finished') {
|
||||
setScanning(false)
|
||||
setScanProgress(formatCloudScanStatus(status))
|
||||
reloadCurrentLibrary()
|
||||
terminal = true
|
||||
if (timer) window.clearInterval(timer)
|
||||
return
|
||||
}
|
||||
if (status.state === 'error' && status.error) {
|
||||
setScanning(false)
|
||||
setScanProgress(`扫描失败:${status.error}`)
|
||||
terminal = true
|
||||
if (timer) window.clearInterval(timer)
|
||||
}
|
||||
}
|
||||
restoreCloudScanStatus()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (!cancelled && !terminal) {
|
||||
timer = window.setInterval(() => {
|
||||
restoreCloudScanStatus().catch(() => undefined)
|
||||
}, 5000)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (timer) window.clearInterval(timer)
|
||||
}
|
||||
}, [id, reloadCurrentLibrary, role])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return
|
||||
if (!isSeries) {
|
||||
setSelectedSeries(null)
|
||||
setSelectedSeason(null)
|
||||
return
|
||||
}
|
||||
|
||||
const key = searchParams.get('series')
|
||||
if (!key) {
|
||||
setSelectedSeries(null)
|
||||
return
|
||||
}
|
||||
|
||||
const next = seriesCards.find((card) => card.key === key)
|
||||
if (next) {
|
||||
setSelectedSeries(next)
|
||||
} else {
|
||||
setSelectedSeries(null)
|
||||
}
|
||||
}, [isSeries, loading, searchParams, seriesCards])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSeries || selectedEpisodes.length === 0) {
|
||||
setSelectedSeason(null)
|
||||
return
|
||||
}
|
||||
if (selectedSeason == null || !selectedEpisodes.some((s) => s.season === selectedSeason)) {
|
||||
setSelectedSeason(selectedEpisodes[0].season)
|
||||
}
|
||||
}, [selectedSeries, selectedEpisodes, selectedSeason])
|
||||
|
||||
const handleScan = async () => {
|
||||
setScanning(true)
|
||||
setScanProgress('正在提交扫描任务…')
|
||||
let keepScanning = false
|
||||
try {
|
||||
const r = await libraryAPI.scan(id)
|
||||
if (r.queued) {
|
||||
keepScanning = true
|
||||
setScanProgress(`${r.message ?? '云盘扫描已在后台运行,发现的媒体会自动加入当前媒体库'};${r.estimate_message ?? '大目录耗时取决于网盘接口速度'}`)
|
||||
toast.success('云盘扫描已加入后台队列')
|
||||
} else {
|
||||
toast.success(`扫描完成:新增 ${r.added} 项,更新 ${r.updated ?? 0} 项`)
|
||||
setScanProgress(`扫描完成:新增 ${r.added} · 更新 ${r.updated ?? 0}`)
|
||||
reloadCurrentLibrary()
|
||||
setScanning(false)
|
||||
}
|
||||
} catch {
|
||||
toast.error('扫描失败')
|
||||
setScanProgress('扫描失败,请查看日志或稍后重试')
|
||||
setScanning(false)
|
||||
} finally {
|
||||
if (!keepScanning) setScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleScrape = async () => {
|
||||
setScraping(true)
|
||||
try {
|
||||
await libraryAPI.scrape(id)
|
||||
toast.success('刮削已加入后台队列')
|
||||
} catch { toast.error('刮削失败') }
|
||||
finally { setScraping(false) }
|
||||
}
|
||||
|
||||
const handleRepairRescrape = async () => {
|
||||
if (repairing) return
|
||||
setRepairing(true)
|
||||
try {
|
||||
await toolsAPI.repairAndRescrapeLibrary(id)
|
||||
toast.success('本库修复+重刮已加入后台队列,进度可在任务中查看')
|
||||
} catch { toast.error('修复+重刮启动失败') }
|
||||
finally { setRepairing(false) }
|
||||
}
|
||||
|
||||
const runSeriesTool = async (key: string, label: string, action: (media: Media) => Promise<unknown>) => {
|
||||
if (selectedSeriesEpisodes.length === 0) return
|
||||
setSeriesToolBusy(key)
|
||||
try {
|
||||
for (const ep of selectedSeriesEpisodes) {
|
||||
await action(ep)
|
||||
}
|
||||
toast.success(`${label}完成:${selectedSeriesEpisodes.length} 个媒体`)
|
||||
reloadCurrentLibrary()
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || `${label}失败`
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSeriesToolBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSeriesSmartScrape = () => {
|
||||
runSeriesTool('scrape', '整剧智能刮削', (media) => api.post(`/media/${media.id}/scrape`))
|
||||
}
|
||||
|
||||
const handleSeriesProbe = () => {
|
||||
runSeriesTool('probe', '整剧媒体轨探测', (media) => api.post(`/media/${media.id}/probe`))
|
||||
}
|
||||
|
||||
const handleSeriesNFO = () => {
|
||||
runSeriesTool('nfo', '整剧 NFO 写出', (media) => recycleAPI.exportNFO(media.id))
|
||||
}
|
||||
|
||||
const handleSeriesOrganize = async () => {
|
||||
if (!selectedSeries || selectedSeriesEpisodes.length === 0 || !library) return
|
||||
const source = seriesSourceRoot(selectedSeriesEpisodes)
|
||||
if (!source || source.toLowerCase().startsWith('cloud://')) {
|
||||
toast.error('当前合集不是本地文件夹,无法使用本地整理入库')
|
||||
return
|
||||
}
|
||||
if (!(await confirmAction({
|
||||
title: '整理当前合集',
|
||||
message: `来源:${source}\n目标:${library.path}\n将按当前媒体库类型整理整个文件夹。`,
|
||||
confirmText: '开始整理',
|
||||
}))) return
|
||||
setSeriesToolBusy('organize')
|
||||
try {
|
||||
const result = await toolsAPI.organizeDirectory({
|
||||
source_path: source,
|
||||
dest_path: library.path,
|
||||
media_type: library.type || 'auto',
|
||||
library_id: library.id,
|
||||
scan_after: true,
|
||||
scrape_after: true,
|
||||
})
|
||||
const replaced = result.replaced ?? 0
|
||||
toast.success(`合集整理完成:新增 ${result.organized ?? 0} · 替换 ${replaced} · 跳过 ${result.skipped ?? 0}`)
|
||||
reloadCurrentLibrary()
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '合集整理失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSeriesToolBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSeriesSoftDelete = async () => {
|
||||
if (!selectedSeries || selectedSeriesEpisodes.length === 0) return
|
||||
if (!(await confirmAction({
|
||||
title: '移入回收站',
|
||||
message: `将「${seriesTitle(selectedSeries.rep)}」的 ${selectedSeriesEpisodes.length} 个媒体移至回收站? (磁盘文件保留)`,
|
||||
confirmText: '移入回收站',
|
||||
}))) return
|
||||
await runSeriesTool('delete', '整剧移入回收站', (media) => recycleAPI.softDelete(media.id))
|
||||
clearSelectedSeries()
|
||||
}
|
||||
|
||||
const runMovieTool = async (media: Media, key: string, label: string, action: (media: Media) => Promise<unknown>) => {
|
||||
const busyKey = `${key}:${media.id}`
|
||||
setMovieToolBusy(busyKey)
|
||||
try {
|
||||
await action(media)
|
||||
toast.success(`${label}完成:${media.title}`)
|
||||
reloadCurrentLibrary()
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || `${label}失败`
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setMovieToolBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMovieSmartScrape = (media: Media) => {
|
||||
runMovieTool(media, 'scrape', '智能刮削', (item) => api.post(`/media/${item.id}/scrape`))
|
||||
}
|
||||
|
||||
const handleMovieProbe = (media: Media) => {
|
||||
runMovieTool(media, 'probe', '媒体轨探测', (item) => api.post(`/media/${item.id}/probe`))
|
||||
}
|
||||
|
||||
const handleMovieNFO = (media: Media) => {
|
||||
runMovieTool(media, 'nfo', 'NFO 写出', (item) => recycleAPI.exportNFO(item.id))
|
||||
}
|
||||
|
||||
const handleMovieSoftDelete = async (media: Media) => {
|
||||
if (!(await confirmAction({
|
||||
title: '移入回收站',
|
||||
message: `将「${media.title}」移至回收站? (磁盘文件保留)`,
|
||||
confirmText: '移入回收站',
|
||||
}))) return
|
||||
await runMovieTool(media, 'delete', '移入回收站', (item) => recycleAPI.softDelete(item.id))
|
||||
}
|
||||
|
||||
const movieActions = (media: Media) => {
|
||||
if (role !== 'admin') return undefined
|
||||
const busy = movieToolBusy.endsWith(`:${media.id}`)
|
||||
const buttonClass = 'flex h-8 w-8 items-center justify-center rounded-lg border border-white/70 bg-white/90 text-gray-700 shadow-sm backdrop-blur transition hover:bg-brand-50 hover:text-brand-600 disabled:opacity-50'
|
||||
return (
|
||||
<>
|
||||
<button title="智能刮削" disabled={busy} onClick={() => handleMovieSmartScrape(media)} className={buttonClass}>
|
||||
<Sparkles size={13} />
|
||||
</button>
|
||||
<button title="手动匹配刮削" disabled={busy} onClick={() => setManualMovie(media)} className={buttonClass}>
|
||||
<Search size={13} />
|
||||
</button>
|
||||
<button title="探测媒体轨" disabled={busy} onClick={() => handleMovieProbe(media)} className={buttonClass}>
|
||||
<Database size={13} />
|
||||
</button>
|
||||
<button title="写出本地 NFO" disabled={busy} onClick={() => handleMovieNFO(media)} className={buttonClass}>
|
||||
<FileText size={13} />
|
||||
</button>
|
||||
<button title="移入回收站" disabled={busy} onClick={() => handleMovieSoftDelete(media)} className={`${buttonClass} hover:!bg-red-50 hover:!text-red-500`}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSeriesClick = (card: SeriesCard) => {
|
||||
setSelectedSeries(card)
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.set('series', card.key)
|
||||
setSearchParams(next)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const clearSelectedSeries = () => {
|
||||
setSelectedSeries(null)
|
||||
setSelectedSeason(null)
|
||||
setSeriesMetadataEditOpen(false)
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.delete('series')
|
||||
setSearchParams(next)
|
||||
}
|
||||
const {
|
||||
scraping,
|
||||
scrapeEpisodeArtwork,
|
||||
repairing,
|
||||
seriesToolBusy,
|
||||
setScrapeEpisodeArtwork,
|
||||
handleScrape,
|
||||
handleRepairRescrape,
|
||||
handleSeriesSmartScrape,
|
||||
handleSeriesProbe,
|
||||
handleSeriesNFO,
|
||||
handleSeriesOrganize,
|
||||
handleSeriesSoftDelete,
|
||||
movieActions,
|
||||
} = useLibraryAdminActions({
|
||||
libraryID: id,
|
||||
role,
|
||||
library,
|
||||
selectedSeries,
|
||||
selectedSeriesEpisodes,
|
||||
reloadCurrentLibrary,
|
||||
clearSelectedSeries,
|
||||
setManualMovie,
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -456,52 +115,31 @@ export function LibraryPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">
|
||||
{library?.name ?? '媒体库'}
|
||||
<span className="text-sand-500"> ({isSeries ? seriesCards.length : total})</span>
|
||||
</h1>
|
||||
{library && <p className="text-sm text-ink-50">{library.type} · {library.path}</p>}
|
||||
{loadingAll && !loading && total > items.length && (
|
||||
<p className="mt-1 text-xs text-sand-500">正在继续加载全部条目:{items.length} / {total}</p>
|
||||
)}
|
||||
{scanProgress && <p className="mt-1 text-xs text-brand-500">{scanProgress}</p>}
|
||||
</div>
|
||||
{role === 'admin' && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={handleScan} disabled={scanning} className="btn-outline">{scanning ? '扫描中…' : '立即扫描'}</button>
|
||||
<button onClick={handleScrape} disabled={scraping} className="btn-outline">{scraping ? '刮削中…' : '刮削元数据'}</button>
|
||||
<button onClick={handleRepairRescrape} disabled={repairing} className="btn-outline" title="回填本库占位符外部 ID 并重刮,修正空 ID / 拆集问题">{repairing ? '修复中…' : '修复+重刮本库'}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<LibraryPageHeader
|
||||
library={library}
|
||||
itemCount={isSeries ? seriesCards.length : total}
|
||||
loadingAllText={loadingAllText}
|
||||
scanProgress={scanProgress}
|
||||
isAdmin={role === 'admin'}
|
||||
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
||||
scanning={scanning}
|
||||
scraping={scraping}
|
||||
repairing={repairing}
|
||||
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
|
||||
onScan={handleScan}
|
||||
onScrape={handleScrape}
|
||||
onRepairRescrape={handleRepairRescrape}
|
||||
/>
|
||||
|
||||
{/* 非剧集:直接展示海报网格 */}
|
||||
{!isSeries && items.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} actions={movieActions(m)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSeries && items.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<Film className="h-12 w-12 text-gray-500 mb-4" />
|
||||
<p className="text-ink-50">该媒体库暂无内容,触发一次扫描后再来看看</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 剧集模式:折叠卡片网格 */}
|
||||
{isSeries && seriesCards.length > 0 && !selectedSeries && (
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
|
||||
{seriesCards.map((s) => (
|
||||
<MediaCard key={s.key} media={s.rep} count={s.count} onClick={() => handleSeriesClick(s)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<LibraryMediaSections
|
||||
isSeries={isSeries}
|
||||
items={items}
|
||||
seriesCards={seriesCards}
|
||||
selectedSeries={selectedSeries}
|
||||
loading={loading}
|
||||
movieActions={movieActions}
|
||||
onSeriesClick={handleSeriesClick}
|
||||
/>
|
||||
|
||||
{/* 剧集详情:季/集选择器 */}
|
||||
<AnimatePresence mode="wait">
|
||||
@@ -512,173 +150,46 @@ export function LibraryPage() {
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* 返回按钮 + 标题 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={clearSelectedSeries}
|
||||
className="btn-ghost gap-2"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
返回列表
|
||||
</button>
|
||||
<h2 className="font-display text-2xl font-bold text-ink-600 truncate">
|
||||
{seriesTitle(selectedSeries.rep)}
|
||||
</h2>
|
||||
<span className="text-sm text-sand-500">共 {selectedSeries.count} 集</span>
|
||||
</div>
|
||||
|
||||
{/* 海报 + 从第一集开始播放 */}
|
||||
<div className="flex flex-col sm:flex-row gap-6">
|
||||
<div className="w-40 shrink-0 overflow-hidden rounded-xl bg-sand-200 shadow-card">
|
||||
{selectedSeries.rep.poster_url ? (
|
||||
<img src={imageURL(selectedSeries.rep.poster_url)} alt={selectedSeries.rep.title} className="w-full aspect-[2/3] object-cover" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<div className="flex items-center justify-center aspect-[2/3] text-gray-500"><Film size={40} /></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<p className="text-sm text-ink-50 leading-relaxed">
|
||||
{selectedSeries.rep.overview || '暂无简介'}
|
||||
</p>
|
||||
|
||||
{/* 从第一集开始 */}
|
||||
{(() => {
|
||||
const firstEps = [...(visibleEpisodes.length > 0 ? visibleEpisodes : selectedEpisodes.flatMap((s) => s.episodes))]
|
||||
firstEps.sort((a, b) =>
|
||||
(a.season_num || 0) - (b.season_num || 0)
|
||||
|| (a.episode_num || 0) - (b.episode_num || 0),
|
||||
)
|
||||
const first = firstEps.length > 0 ? firstEps[0] : null
|
||||
return first ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link to={`/play/${first.id}`} state={{ from: `${location.pathname}${location.search}` }} className="btn-primary inline-flex">
|
||||
<Play size={16} fill="currentColor" />
|
||||
从第一集开始播放
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={first.id} label="外部播放器播放" />
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
|
||||
{role === 'admin' && selectedSeriesEpisodes.length > 0 && (
|
||||
<div className="rounded-2xl border border-sand-200 bg-white/80 p-4 shadow-sm">
|
||||
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={handleSeriesSmartScrape} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
|
||||
<Sparkles size={13} className="text-[#c9954a]" />
|
||||
<span>{seriesToolBusy === 'scrape' ? '刮削中…' : '整剧智能刮削'}</span>
|
||||
</button>
|
||||
<button onClick={() => setManualSeriesScrapeOpen(true)} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
|
||||
<Search size={13} className="text-[#c9954a]" />
|
||||
<span>手动匹配整剧</span>
|
||||
</button>
|
||||
<button onClick={() => setSeriesMetadataEditOpen(true)} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
|
||||
<Pencil size={13} />
|
||||
<span>编辑元数据</span>
|
||||
</button>
|
||||
<button onClick={handleSeriesProbe} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
|
||||
<Database size={13} />
|
||||
<span>{seriesToolBusy === 'probe' ? '探测中…' : '探测媒体轨'}</span>
|
||||
</button>
|
||||
<button onClick={handleSeriesNFO} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
|
||||
<FileText size={13} />
|
||||
<span>{seriesToolBusy === 'nfo' ? '写出中…' : '写出本地 NFO'}</span>
|
||||
</button>
|
||||
<button onClick={handleSeriesOrganize} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
|
||||
<FolderInput size={13} />
|
||||
<span>{seriesToolBusy === 'organize' ? '整理中…' : '整理当前合集'}</span>
|
||||
</button>
|
||||
<button onClick={handleSeriesSoftDelete} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200">
|
||||
<Trash2 size={13} />
|
||||
<span>{seriesToolBusy === 'delete' ? '处理中…' : '移入回收站'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<LibrarySeriesDetailHeader
|
||||
series={selectedSeries}
|
||||
visibleEpisodes={visibleEpisodes}
|
||||
allEpisodes={selectedSeriesEpisodes}
|
||||
playbackFrom={`${location.pathname}${location.search}`}
|
||||
isAdmin={role === 'admin'}
|
||||
seriesToolBusy={seriesToolBusy}
|
||||
onBack={clearSelectedSeries}
|
||||
onSmartScrape={handleSeriesSmartScrape}
|
||||
onManualScrape={() => setManualSeriesScrapeOpen(true)}
|
||||
onMetadataEdit={() => setSeriesMetadataEditOpen(true)}
|
||||
onProbe={handleSeriesProbe}
|
||||
onNFO={handleSeriesNFO}
|
||||
onOrganize={handleSeriesOrganize}
|
||||
onSoftDelete={handleSeriesSoftDelete}
|
||||
/>
|
||||
|
||||
{/* 季 / 集列表 */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{selectedEpisodes.map(({ season, episodes }) => (
|
||||
<button
|
||||
key={season}
|
||||
onClick={() => setSelectedSeason(season)}
|
||||
className={
|
||||
'rounded-xl border px-4 py-2 text-sm font-semibold transition ' +
|
||||
(selectedSeason === season
|
||||
? 'border-brand-300 bg-brand-50 text-brand-700'
|
||||
: 'border-sand-200 bg-white text-ink-100 hover:border-brand-200 hover:text-brand-600')
|
||||
}
|
||||
>
|
||||
{season === 0 ? '特别篇' : `第 ${season} 季`} · {episodes.length} 集
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
|
||||
{(selectedSeason ?? selectedEpisodes[0]?.season ?? 1) === 0
|
||||
? '特别篇'
|
||||
: `第 ${selectedSeason ?? selectedEpisodes[0]?.season ?? 1} 季`}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{visibleEpisodes.map((ep) => (
|
||||
<div
|
||||
key={ep.id}
|
||||
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
|
||||
>
|
||||
<Link to={`/play/${ep.id}`} state={{ from: `${location.pathname}${location.search}` }} className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
|
||||
{ep.backdrop_url || ep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(ep.backdrop_url || ep.poster_url || '')}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
ep.episode_num || '—'
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-ink-600">
|
||||
{ep.original_name || (ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : ep.title)}
|
||||
</p>
|
||||
<p className="text-xs text-sand-500">
|
||||
{ep.duration_sec > 0
|
||||
? `${Math.floor(ep.duration_sec / 60)} 分钟`
|
||||
: formatSize(ep.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<Play size={14} className="shrink-0 text-gray-500 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<LibrarySeriesEpisodes
|
||||
loading={loadingSeriesEpisodes}
|
||||
selectedEpisodes={selectedEpisodes}
|
||||
selectedSeason={selectedSeason}
|
||||
visibleEpisodes={visibleEpisodes}
|
||||
playbackFrom={`${location.pathname}${location.search}`}
|
||||
onSeasonChange={setSelectedSeason}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 剧集为空 */}
|
||||
{isSeries && seriesCards.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<Film className="h-12 w-12 text-gray-500 mb-4" />
|
||||
<p className="text-ink-50">该库尚未发现任何剧集,触发一次扫描后再来看看</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ManualScrapeDialog
|
||||
open={manualSeriesScrapeOpen}
|
||||
media={selectedSeries?.rep ?? null}
|
||||
mediaIds={selectedSeriesMediaIDs}
|
||||
defaultQuery={selectedSeries ? seriesTitle(selectedSeries.rep) : ''}
|
||||
mediaType={library?.type || 'tv'}
|
||||
mediaType={selectedSeries ? scrapeMediaType(library?.type, selectedSeries.rep) : 'tv'}
|
||||
scopeLabel={selectedSeries ? seriesTitle(selectedSeries.rep) : '当前剧集'}
|
||||
episodeArtwork={scrapeEpisodeArtwork}
|
||||
onClose={() => setManualSeriesScrapeOpen(false)}
|
||||
onApplied={reloadCurrentLibrary}
|
||||
/>
|
||||
@@ -695,8 +206,9 @@ export function LibraryPage() {
|
||||
open={!!manualMovie}
|
||||
media={manualMovie}
|
||||
defaultQuery={manualMovie?.title ?? ''}
|
||||
mediaType={library?.type || 'movie'}
|
||||
mediaType={manualMovie ? scrapeMediaType(library?.type, manualMovie) : library?.type || 'movie'}
|
||||
scopeLabel={manualMovie?.title ?? '当前电影'}
|
||||
episodeArtwork={scrapeEpisodeArtwork}
|
||||
onClose={() => setManualMovie(null)}
|
||||
onApplied={reloadCurrentLibrary}
|
||||
/>
|
||||
@@ -704,55 +216,9 @@ export function LibraryPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return ''
|
||||
if (seconds < 60) return `${Math.round(seconds)}秒`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const rest = Math.round(seconds % 60)
|
||||
if (minutes < 60) return `${minutes}分${rest}秒`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return `${hours}小时${minutes % 60}分`
|
||||
}
|
||||
|
||||
function formatCloudScanStatus(status: CloudScanStatus): string {
|
||||
const stage =
|
||||
status.state === 'queued' ? '扫描已排队'
|
||||
: status.state === 'canceling' ? '正在中断扫描'
|
||||
: status.state === 'finished' ? '扫描完成'
|
||||
: status.stage === 'importing' ? '正在入库'
|
||||
: '正在遍历目录'
|
||||
const speed = Number(status.files_per_second ?? 0)
|
||||
const speedText = speed > 0 && status.state !== 'finished'
|
||||
? ` · ${speed.toFixed(speed >= 10 ? 0 : 1)} 个/秒`
|
||||
: ''
|
||||
return `${stage}:目录 ${status.dirs ?? 0} · 已发现 ${status.discovered ?? 0} · 已入库 ${status.visited ?? 0} · 新增 ${status.added ?? 0} · 更新 ${status.updated ?? 0}${speedText}`
|
||||
}
|
||||
|
||||
function seriesSourceRoot(episodes: Media[]): string {
|
||||
const firstPath = episodes.find((item) => item.path)?.path ?? ''
|
||||
if (!firstPath) return ''
|
||||
const dir = dirname(firstPath)
|
||||
const base = basename(dir)
|
||||
if (/^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*\d{1,2}\s*季|specials?|sp|ova|oad|特别篇|特別篇)$/i.test(base)) {
|
||||
return dirname(dir)
|
||||
function scrapeMediaType(libraryType: string | undefined, media: Media): string {
|
||||
if ((media.season_num ?? 0) > 0 || (media.episode_num ?? 0) > 0) {
|
||||
return 'tv'
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
function dirname(value: string): string {
|
||||
const index = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'))
|
||||
return index > 0 ? value.slice(0, index) : ''
|
||||
}
|
||||
|
||||
function basename(value: string): string {
|
||||
const index = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'))
|
||||
return index >= 0 ? value.slice(index + 1) : value
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes || bytes <= 0) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let v = bytes, i = 0
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
||||
return `${v.toFixed(1)} ${units[i]}`
|
||||
return libraryType || 'movie'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Library } from '../types'
|
||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||
|
||||
type LibraryPageHeaderProps = {
|
||||
library: Library | null
|
||||
itemCount: number
|
||||
loadingAllText: string
|
||||
scanProgress: string
|
||||
isAdmin: boolean
|
||||
scrapeEpisodeArtwork: boolean
|
||||
scanning: boolean
|
||||
scraping: boolean
|
||||
repairing: boolean
|
||||
onScrapeEpisodeArtworkChange: (checked: boolean) => void
|
||||
onScan: () => void
|
||||
onScrape: () => void
|
||||
onRepairRescrape: () => void
|
||||
}
|
||||
|
||||
export function LibraryPageHeader({
|
||||
library,
|
||||
itemCount,
|
||||
loadingAllText,
|
||||
scanProgress,
|
||||
isAdmin,
|
||||
scrapeEpisodeArtwork,
|
||||
scanning,
|
||||
scraping,
|
||||
repairing,
|
||||
onScrapeEpisodeArtworkChange,
|
||||
onScan,
|
||||
onScrape,
|
||||
onRepairRescrape,
|
||||
}: LibraryPageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">
|
||||
{library?.name ?? '媒体库'}
|
||||
<span className="text-sand-500"> ({itemCount})</span>
|
||||
</h1>
|
||||
{library && <p className="text-sm text-ink-50">{library.type} · {library.path}</p>}
|
||||
{loadingAllText && <p className="mt-1 text-xs text-sand-500">{loadingAllText}</p>}
|
||||
{scanProgress && <p className="mt-1 text-xs text-brand-500">{scanProgress}</p>}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EpisodeArtworkToggle
|
||||
checked={scrapeEpisodeArtwork}
|
||||
onChange={onScrapeEpisodeArtworkChange}
|
||||
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||
className="h-10"
|
||||
/>
|
||||
<button onClick={onScan} disabled={scanning} className="btn-outline">
|
||||
{scanning ? '扫描中…' : '立即扫描'}
|
||||
</button>
|
||||
<button onClick={onScrape} disabled={scraping} className="btn-outline">
|
||||
{scraping ? '刮削中…' : '刮削元数据'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onRepairRescrape}
|
||||
disabled={repairing}
|
||||
className="btn-outline"
|
||||
title="回填本库占位符外部 ID 并重刮,修正空 ID / 拆集问题"
|
||||
>
|
||||
{repairing ? '修复中…' : '修复+重刮本库'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeft, Database, FileText, Film, FolderInput, Pencil, Play, Search, Sparkles, Trash2 } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import type { Media } from '../types'
|
||||
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||
|
||||
type LibrarySeriesDetailHeaderProps = {
|
||||
series: SeriesCard
|
||||
visibleEpisodes: Media[]
|
||||
allEpisodes: Media[]
|
||||
playbackFrom: string
|
||||
isAdmin: boolean
|
||||
seriesToolBusy: string
|
||||
onBack: () => void
|
||||
onSmartScrape: () => void
|
||||
onManualScrape: () => void
|
||||
onMetadataEdit: () => void
|
||||
onProbe: () => void
|
||||
onNFO: () => void
|
||||
onOrganize: () => void
|
||||
onSoftDelete: () => void
|
||||
}
|
||||
|
||||
export function LibrarySeriesDetailHeader({
|
||||
series,
|
||||
visibleEpisodes,
|
||||
allEpisodes,
|
||||
playbackFrom,
|
||||
isAdmin,
|
||||
seriesToolBusy,
|
||||
onBack,
|
||||
onSmartScrape,
|
||||
onManualScrape,
|
||||
onMetadataEdit,
|
||||
onProbe,
|
||||
onNFO,
|
||||
onOrganize,
|
||||
onSoftDelete,
|
||||
}: LibrarySeriesDetailHeaderProps) {
|
||||
const firstEpisode = firstPlayableEpisode(visibleEpisodes.length > 0 ? visibleEpisodes : allEpisodes)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={onBack} className="btn-ghost gap-2">
|
||||
<ArrowLeft size={16} />
|
||||
返回列表
|
||||
</button>
|
||||
<h2 className="truncate font-display text-2xl font-bold text-ink-600">
|
||||
{seriesTitle(series.rep)}
|
||||
</h2>
|
||||
<span className="text-sm text-sand-500">共 {series.count} 集</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 sm:flex-row">
|
||||
<div className="w-40 shrink-0 overflow-hidden rounded-xl bg-sand-200 shadow-card">
|
||||
{series.rep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(series.rep.poster_url, series.rep.updated_at)}
|
||||
alt={series.rep.title}
|
||||
className="aspect-[2/3] w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex aspect-[2/3] items-center justify-center text-gray-500">
|
||||
<Film size={40} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<p className="text-sm leading-relaxed text-ink-50">
|
||||
{series.rep.overview || '暂无简介'}
|
||||
</p>
|
||||
|
||||
{firstEpisode && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link to={`/play/${firstEpisode.id}`} state={{ from: playbackFrom }} className="btn-primary inline-flex">
|
||||
<Play size={16} fill="currentColor" />
|
||||
从第一集开始播放
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={firstEpisode.id} label="外部播放器播放" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && allEpisodes.length > 0 && (
|
||||
<div className="rounded-2xl border border-sand-200 bg-white/80 p-4 shadow-sm">
|
||||
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={onSmartScrape} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
|
||||
<Sparkles size={13} className="text-[#c9954a]" />
|
||||
<span>{seriesToolBusy === 'scrape' ? '刮削中…' : '整剧智能刮削'}</span>
|
||||
</button>
|
||||
<button onClick={onManualScrape} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
|
||||
<Search size={13} className="text-[#c9954a]" />
|
||||
<span>手动匹配整剧</span>
|
||||
</button>
|
||||
<button onClick={onMetadataEdit} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
|
||||
<Pencil size={13} />
|
||||
<span>编辑元数据</span>
|
||||
</button>
|
||||
<button onClick={onProbe} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
|
||||
<Database size={13} />
|
||||
<span>{seriesToolBusy === 'probe' ? '探测中…' : '探测媒体轨'}</span>
|
||||
</button>
|
||||
<button onClick={onNFO} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
|
||||
<FileText size={13} />
|
||||
<span>{seriesToolBusy === 'nfo' ? '写出中…' : '写出本地 NFO'}</span>
|
||||
</button>
|
||||
<button onClick={onOrganize} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
|
||||
<FolderInput size={13} />
|
||||
<span>{seriesToolBusy === 'organize' ? '整理中…' : '整理当前合集'}</span>
|
||||
</button>
|
||||
<button onClick={onSoftDelete} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!border-red-200 hover:!bg-red-50">
|
||||
<Trash2 size={13} />
|
||||
<span>{seriesToolBusy === 'delete' ? '处理中…' : '移入回收站'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function firstPlayableEpisode(episodes: Media[]): Media | null {
|
||||
const sorted = [...episodes]
|
||||
sorted.sort((a, b) =>
|
||||
(a.season_num || 0) - (b.season_num || 0)
|
||||
|| (a.episode_num || 0) - (b.episode_num || 0),
|
||||
)
|
||||
return sorted[0] ?? null
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Play } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import type { Media } from '../types'
|
||||
import { seriesTitleFromPath } from '../utils/groupSeries'
|
||||
import { formatSize } from './libraryPageModel'
|
||||
|
||||
type SeasonGroup = {
|
||||
season: number
|
||||
episodes: Media[]
|
||||
}
|
||||
|
||||
type LibrarySeriesEpisodesProps = {
|
||||
loading: boolean
|
||||
selectedEpisodes: SeasonGroup[]
|
||||
selectedSeason: number | null
|
||||
visibleEpisodes: Media[]
|
||||
playbackFrom: string
|
||||
onSeasonChange: (season: number) => void
|
||||
}
|
||||
|
||||
export function LibrarySeriesEpisodes({
|
||||
loading,
|
||||
selectedEpisodes,
|
||||
selectedSeason,
|
||||
visibleEpisodes,
|
||||
playbackFrom,
|
||||
onSeasonChange,
|
||||
}: LibrarySeriesEpisodesProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-white/75 p-6 text-center text-sm text-sand-500 shadow-soft">
|
||||
正在加载剧集…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const displaySeason = selectedSeason ?? selectedEpisodes[0]?.season ?? 1
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{selectedEpisodes.map(({ season, episodes }) => (
|
||||
<button
|
||||
key={season}
|
||||
onClick={() => onSeasonChange(season)}
|
||||
className={
|
||||
'rounded-xl border px-4 py-2 text-sm font-semibold transition ' +
|
||||
(selectedSeason === season
|
||||
? 'border-brand-300 bg-brand-50 text-brand-700'
|
||||
: 'border-sand-200 bg-white text-ink-100 hover:border-brand-200 hover:text-brand-600')
|
||||
}
|
||||
>
|
||||
{season === 0 ? '特别篇' : `第 ${season} 季`} · {episodes.length} 集
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
|
||||
{displaySeason === 0 ? '特别篇' : `第 ${displaySeason} 季`}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{visibleEpisodes.map((ep) => (
|
||||
<div
|
||||
key={ep.id}
|
||||
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
|
||||
>
|
||||
<Link to={`/play/${ep.id}`} state={{ from: playbackFrom }} className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
|
||||
{ep.backdrop_url || ep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(ep.backdrop_url || ep.poster_url || '', ep.updated_at)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
ep.episode_num || '—'
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-ink-600">
|
||||
{episodeDisplayTitle(ep, visibleEpisodes)}
|
||||
</p>
|
||||
<p className="text-xs text-sand-500">
|
||||
{ep.duration_sec > 0
|
||||
? `${Math.floor(ep.duration_sec / 60)} 分钟`
|
||||
: formatSize(ep.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<Play size={14} className="shrink-0 text-gray-500 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function episodeDisplayTitle(ep: Media, siblings: Media[]): string {
|
||||
const title = ep.episode_title?.trim()
|
||||
if (title && !looksLikeSeriesTitle(ep, title, siblings)) {
|
||||
return title
|
||||
}
|
||||
|
||||
const mediaTitle = ep.title?.trim()
|
||||
if (mediaTitle && !looksLikeSeriesTitle(ep, mediaTitle, siblings)) {
|
||||
return mediaTitle
|
||||
}
|
||||
|
||||
return ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : mediaTitle || title || '未命名'
|
||||
}
|
||||
|
||||
function looksLikeSeriesTitle(ep: Media, title: string, siblings: Media[]): boolean {
|
||||
const normalized = normalizeEpisodeTitle(title)
|
||||
if (!normalized) return true
|
||||
if (ep.original_name && normalizeEpisodeTitle(ep.original_name) === normalized) return true
|
||||
const pathTitle = seriesTitleFromPath(ep.path)
|
||||
if (pathTitle && normalizeEpisodeTitle(pathTitle) === normalized) return true
|
||||
|
||||
const siblingTitles = new Set(
|
||||
siblings
|
||||
.map((item) => normalizeEpisodeTitle(item.title))
|
||||
.filter(Boolean),
|
||||
)
|
||||
return siblingTitles.size === 1 && siblingTitles.has(normalized) && siblings.length > 1
|
||||
}
|
||||
|
||||
function normalizeEpisodeTitle(value?: string): string {
|
||||
return (value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/\s*\((?:19|20)\d{2}\)\s*/g, ' ')
|
||||
.replace(/\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*/g, ' ')
|
||||
.replace(/[\s._-]+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { Library } from '../types'
|
||||
|
||||
type PreviewItem = {
|
||||
source: string
|
||||
target?: string
|
||||
action: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
type ManualOrganizePanelProps = {
|
||||
organizeSource: string
|
||||
selectedCount: number
|
||||
localLibraries: Library[]
|
||||
organizeLibraryID: string
|
||||
organizeDestPath: string
|
||||
organizeMediaType: string
|
||||
organizeTransferMode: string
|
||||
manualMoveKeepsSeeding: boolean
|
||||
scanAfter: boolean
|
||||
scrapeAfter: boolean
|
||||
organizeReady: boolean
|
||||
organizeBusy: string
|
||||
previewItems: PreviewItem[]
|
||||
onClearSelected: () => void
|
||||
onLibraryChange: (value: string) => void
|
||||
onDestPathChange: (value: string) => void
|
||||
onMediaTypeChange: (value: string) => void
|
||||
onTransferModeChange: (value: string) => void
|
||||
onScanAfterChange: (value: boolean) => void
|
||||
onScrapeAfterChange: (value: boolean) => void
|
||||
onPreview: () => void
|
||||
onRun: () => void
|
||||
}
|
||||
|
||||
export function ManualOrganizePanel({
|
||||
organizeSource,
|
||||
selectedCount,
|
||||
localLibraries,
|
||||
organizeLibraryID,
|
||||
organizeDestPath,
|
||||
organizeMediaType,
|
||||
organizeTransferMode,
|
||||
manualMoveKeepsSeeding,
|
||||
scanAfter,
|
||||
scrapeAfter,
|
||||
organizeReady,
|
||||
organizeBusy,
|
||||
previewItems,
|
||||
onClearSelected,
|
||||
onLibraryChange,
|
||||
onDestPathChange,
|
||||
onMediaTypeChange,
|
||||
onTransferModeChange,
|
||||
onScanAfterChange,
|
||||
onScrapeAfterChange,
|
||||
onPreview,
|
||||
onRun,
|
||||
}: ManualOrganizePanelProps) {
|
||||
return (
|
||||
<section className="glass-panel space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">手动整理入库</h2>
|
||||
<p className="text-xs text-sand-500">来源优先使用选中项;未选中时使用当前目录。</p>
|
||||
</div>
|
||||
<div className="max-w-xl truncate rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 font-mono text-xs text-ink-100" title={organizeSource}>
|
||||
来源:{organizeSource || '未选择'}
|
||||
</div>
|
||||
</div>
|
||||
{selectedCount > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-primary-400/20 bg-primary-400/5 px-3 py-2 text-xs text-ink-100">
|
||||
<span>已选择 {selectedCount} 个项目用于整理。</span>
|
||||
<button type="button" className="font-semibold text-brand-500 hover:text-brand-700" onClick={onClearSelected}>
|
||||
清空选择
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[1.2fr_1fr_150px_150px]">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-ink-50">目标媒体库 / 存储</span>
|
||||
<select
|
||||
className="input-base w-full"
|
||||
value={organizeLibraryID}
|
||||
onChange={(event) => onLibraryChange(event.target.value)}
|
||||
>
|
||||
<option value="">手动填写目的路径</option>
|
||||
{localLibraries.map((library) => (
|
||||
<option key={library.id} value={library.id}>
|
||||
{library.name}({library.type})— {library.path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-[11px] text-sand-500">
|
||||
手动整理只写入本地可写媒体库;网盘请到“外部存储”中挂载、扫描或转存。
|
||||
</span>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-ink-50">目的路径</span>
|
||||
<input
|
||||
className="input-base w-full"
|
||||
placeholder="例如 F:\\media\\电影 或 /media/电影"
|
||||
value={organizeDestPath}
|
||||
onChange={(event) => onDestPathChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-ink-50">类型</span>
|
||||
<select className="input-base w-full" value={organizeMediaType} onChange={(event) => onMediaTypeChange(event.target.value)}>
|
||||
<option value="auto">自动识别</option>
|
||||
<option value="movie">电影</option>
|
||||
<option value="tv">剧集</option>
|
||||
<option value="anime">动漫</option>
|
||||
<option value="variety">综艺</option>
|
||||
<option value="adult">成人</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-ink-50">整理方式</span>
|
||||
<select className="input-base w-full" value={organizeTransferMode} onChange={(event) => onTransferModeChange(event.target.value)}>
|
||||
<option value="hardlink">硬链接</option>
|
||||
<option value="move">移动(关闭保种才会移动)</option>
|
||||
<option value="copy">复制</option>
|
||||
<option value="symlink">软链接</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{manualMoveKeepsSeeding && (
|
||||
<div className="rounded-xl border border-orange-300 bg-orange-50 px-3 py-2 text-xs text-orange-700">
|
||||
“保种”已开启,选择“移动”时后端会改用硬链接以保留下载源。要执行真正移动,请先在上方自动整理设置里关闭“保种”并保存。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
|
||||
<input type="checkbox" checked={scanAfter} onChange={(event) => onScanAfterChange(event.target.checked)} />
|
||||
整理后扫描入库
|
||||
</label>
|
||||
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scanAfter && scrapeAfter}
|
||||
disabled={!scanAfter}
|
||||
onChange={(event) => onScrapeAfterChange(event.target.checked)}
|
||||
/>
|
||||
整理后自动刮削
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="neon-button !border-primary-400/30 !bg-white !text-brand-500"
|
||||
disabled={!organizeReady || organizeBusy !== ''}
|
||||
onClick={onPreview}
|
||||
>
|
||||
{organizeBusy === 'preview' ? '预览中…' : '预览整理'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="neon-button"
|
||||
disabled={!organizeReady || organizeBusy !== ''}
|
||||
onClick={onRun}
|
||||
>
|
||||
{organizeBusy === 'run' ? '整理中…' : '开始整理入库'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{previewItems.length > 0 && <ManualOrganizePreviewTable items={previewItems} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ManualOrganizePreviewTable({ items }: { items: PreviewItem[] }) {
|
||||
return (
|
||||
<div className="max-h-72 overflow-auto rounded-xl border border-gray-200 bg-white/70">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="sticky top-0 bg-white text-sand-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2">动作</th>
|
||||
<th>来源</th>
|
||||
<th>目标</th>
|
||||
<th>原因</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => (
|
||||
<tr key={`${item.source}-${index}`} className="border-t border-gray-200 align-top">
|
||||
<td className="px-3 py-2 font-semibold text-brand-500">{item.action}</td>
|
||||
<td className="max-w-xs truncate py-2 font-mono text-ink-100" title={item.source}>{item.source}</td>
|
||||
<td className="max-w-xs truncate py-2 font-mono text-ink-100" title={item.target}>{item.target || '—'}</td>
|
||||
<td className="py-2 text-sand-500">{item.reason || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Database, FileText, FolderInput, Pencil, Search, Sparkles, Trash2 } from 'lucide-react'
|
||||
|
||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||
import type { Media } from '../types'
|
||||
|
||||
type MediaDetailAdminPanelProps = {
|
||||
media: Media
|
||||
scrapeEpisodeArtwork: boolean
|
||||
onScrapeEpisodeArtworkChange: (checked: boolean) => void
|
||||
onSmartScrape: () => void
|
||||
onManualScrape: () => void
|
||||
onMetadataEdit: () => void
|
||||
onOrganize: () => void
|
||||
onProbe: () => void
|
||||
onExportNFO: () => void
|
||||
onSoftDelete: () => void
|
||||
}
|
||||
|
||||
export function MediaDetailAdminPanel({
|
||||
media,
|
||||
scrapeEpisodeArtwork,
|
||||
onScrapeEpisodeArtworkChange,
|
||||
onSmartScrape,
|
||||
onManualScrape,
|
||||
onMetadataEdit,
|
||||
onOrganize,
|
||||
onProbe,
|
||||
onExportNFO,
|
||||
onSoftDelete,
|
||||
}: MediaDetailAdminPanelProps) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50/50 p-5 space-y-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
|
||||
{isEpisodeArtworkTarget(media) && (
|
||||
<EpisodeArtworkToggle
|
||||
checked={scrapeEpisodeArtwork}
|
||||
onChange={onScrapeEpisodeArtworkChange}
|
||||
title="关闭后仍会获取每集简介、评分和时长,只跳过单集图片"
|
||||
className="h-10"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={onSmartScrape} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Sparkles size={13} className="text-[#c9954a]" />
|
||||
<span>智能刮削 (TMDB)</span>
|
||||
</button>
|
||||
<button onClick={onManualScrape} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Search size={13} className="text-[#c9954a]" />
|
||||
<span>手动匹配刮削</span>
|
||||
</button>
|
||||
<button onClick={onMetadataEdit} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Pencil size={13} className="text-gray-600" />
|
||||
<span>编辑元数据</span>
|
||||
</button>
|
||||
<button onClick={onOrganize} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<FolderInput size={13} className="text-[#c9954a]" />
|
||||
<span>整理入库</span>
|
||||
</button>
|
||||
<button onClick={onProbe} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Database size={13} className="text-gray-600" />
|
||||
<span>探测媒体轨 (ffprobe)</span>
|
||||
</button>
|
||||
<button onClick={onExportNFO} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<FileText size={13} />
|
||||
<span>写出本地 NFO 属性</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onSoftDelete}
|
||||
className="btn-outline py-2 px-3.5 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
<span>移入回收站</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isEpisodeArtworkTarget(media: Media): boolean {
|
||||
return media.season_num > 0 || media.episode_num > 0
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { FileText, Play } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
type MediaDetailArtworkProps = {
|
||||
media: Media
|
||||
}
|
||||
|
||||
export function MediaDetailBackdrop({ media }: MediaDetailArtworkProps) {
|
||||
return (
|
||||
<div className="absolute inset-0 h-[480px] z-0 overflow-hidden">
|
||||
{media.backdrop_url || media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.backdrop_url || media.poster_url || '', media.updated_at)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover opacity-[0.04] scale-110 blur-2xl"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-b from-gray-50 to-transparent" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white via-white/95 to-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MediaDetailPoster({ media }: MediaDetailArtworkProps) {
|
||||
return (
|
||||
<div className="w-56 shrink-0 mx-auto md:mx-0">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02 }}
|
||||
className="aspect-[2/3] w-full rounded-2xl overflow-hidden bg-gray-50 border border-gray-200 shadow-md relative group"
|
||||
>
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url, media.updated_at)}
|
||||
alt={media.title}
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-gray-500 bg-gray-50">
|
||||
<FileText size={40} className="stroke-[1]" />
|
||||
<span className="text-xs uppercase tracking-wider font-bold">无海报</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to={`/play/${media.id}`}
|
||||
className="absolute inset-0 bg-[#111827]/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
>
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-brand-500 text-white shadow-xl transform scale-90 group-hover:scale-100 transition-transform">
|
||||
<Play size={24} fill="currentColor" />
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Calendar } from 'lucide-react'
|
||||
|
||||
import type { Media } from '../types'
|
||||
|
||||
type MediaDetailMetadataProps = {
|
||||
media: Media
|
||||
}
|
||||
|
||||
export function MediaDetailMetadata({ media }: MediaDetailMetadataProps) {
|
||||
const heading = media.episode_title?.trim() || media.title
|
||||
const showTitleContext = Boolean(media.episode_title?.trim() && media.title && media.title !== heading)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<h1 className="font-display text-3xl sm:text-4xl font-extrabold tracking-tight text-gray-900 leading-tight">
|
||||
{heading}
|
||||
</h1>
|
||||
{showTitleContext && (
|
||||
<p className="text-sm font-semibold text-gray-500">
|
||||
{media.title}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2.5 text-xs text-gray-500 font-bold tracking-wide uppercase">
|
||||
{media.year > 0 && (
|
||||
<span className="inline-flex items-center gap-1 bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
|
||||
<Calendar size={13} className="text-brand-500" />
|
||||
<span>{media.year} 年</span>
|
||||
</span>
|
||||
)}
|
||||
{media.width > 0 && (
|
||||
<span className="inline-flex items-center gap-1 bg-brand-50 text-brand-700 border border-brand-100/50 px-2.5 py-1 rounded-xl">
|
||||
<span>{media.width} × {media.height}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
|
||||
{fmtSize(media.size_bytes)}
|
||||
</span>
|
||||
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
|
||||
{fmtDuration(media.duration_sec)}
|
||||
</span>
|
||||
{media.container && (
|
||||
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700 font-mono">
|
||||
{media.container}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{media.overview && (
|
||||
<div className="rounded-2xl bg-gray-50/50 border border-gray-100 p-5 space-y-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-brand-500">剧情简介</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed font-semibold">
|
||||
{media.overview}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<MetadataTags label="类型流派" values={parseCSV(media.genres)} primary />
|
||||
<MetadataTags label="语言" values={parseCSV(media.languages)} />
|
||||
<MetadataTags label="国家/地区" values={parseCSV(media.countries)} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MetadataTags({ label, values, primary = false }: { label: string; values: string[]; primary?: boolean }) {
|
||||
if (values.length === 0) return null
|
||||
const tagClass = primary
|
||||
? 'rounded-full bg-brand-50 text-brand-700 border border-brand-100/30 px-3 py-1 text-2xs font-bold uppercase tracking-wider'
|
||||
: 'rounded-xl bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold'
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs font-bold text-gray-500 w-16 uppercase tracking-wider">{label}</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{values.map((value) => (
|
||||
<span key={value} className={tagClass}>
|
||||
{value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtDuration(sec: number): string {
|
||||
if (!sec || sec <= 0) return '—'
|
||||
const h = Math.floor(sec / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (!bytes || bytes <= 0) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let v = bytes
|
||||
let i = 0
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(2)} ${units[i]}`
|
||||
}
|
||||
|
||||
function parseCSV(s?: string): string[] {
|
||||
if (!s) return []
|
||||
return s.split(',').map((x) => x.trim()).filter(Boolean)
|
||||
}
|
||||
@@ -1,44 +1,32 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2, Calendar, Database, Search, Pencil, FolderInput } from 'lucide-react'
|
||||
import { ArrowLeft, Heart, Play, RefreshCw } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { recycleAPI } from '../api/recycle'
|
||||
import { imageURL } from '../api/client'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { api } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
||||
import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
||||
import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog'
|
||||
import { getSeriesKey, isEpisodeLike } from '../utils/groupSeries'
|
||||
import { MediaDetailAdminPanel } from './MediaDetailAdminPanel'
|
||||
import { MediaDetailBackdrop, MediaDetailPoster } from './MediaDetailArtwork'
|
||||
import { MediaDetailMetadata } from './MediaDetailMetadata'
|
||||
|
||||
function fmtDuration(sec: number): string {
|
||||
if (!sec || sec <= 0) return '—'
|
||||
const h = Math.floor(sec / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
function mediaLibraryBackTarget(media: Media): string {
|
||||
const libraryID = media.display_library_id || media.library_id
|
||||
if (!libraryID) return ''
|
||||
if (!isEpisodeLike(media)) return `/library/${encodeURIComponent(libraryID)}`
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (!bytes || bytes <= 0) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let v = bytes
|
||||
let i = 0
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(2)} ${units[i]}`
|
||||
}
|
||||
|
||||
function parseCSV(s?: string): string[] {
|
||||
if (!s) return []
|
||||
return s.split(',').map(x => x.trim()).filter(Boolean)
|
||||
const seriesKey = getSeriesKey(media)
|
||||
const target = `/library/${encodeURIComponent(libraryID)}`
|
||||
return seriesKey ? `${target}?series=${encodeURIComponent(seriesKey)}` : target
|
||||
}
|
||||
|
||||
export function MediaDetailPage() {
|
||||
@@ -51,8 +39,9 @@ export function MediaDetailPage() {
|
||||
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
|
||||
const [metadataEditOpen, setMetadataEditOpen] = useState(false)
|
||||
const [organizeOpen, setOrganizeOpen] = useState(false)
|
||||
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
const refresh = useCallback(async () => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -63,10 +52,11 @@ export function MediaDetailPage() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [id])
|
||||
}, [refresh])
|
||||
|
||||
const toggleFav = async () => {
|
||||
if (!media) return
|
||||
@@ -77,7 +67,11 @@ export function MediaDetailPage() {
|
||||
|
||||
const rescrape = async () => {
|
||||
if (!media) return
|
||||
await api.post(`/media/${media.id}/scrape`)
|
||||
await api.post(`/media/${media.id}/scrape`, {
|
||||
episode_images: scrapeEpisodeArtwork,
|
||||
refresh_matched: true,
|
||||
include_matched: true,
|
||||
})
|
||||
toast.success('已触发重新刮削')
|
||||
await refresh()
|
||||
}
|
||||
@@ -118,7 +112,9 @@ export function MediaDetailPage() {
|
||||
if (!(await confirmAction({ title: '移入回收站', message: `将「${media.title}」移至回收站? (磁盘文件保留)`, confirmText: '移入回收站' }))) return
|
||||
await recycleAPI.softDelete(media.id)
|
||||
toast.success('已移至回收站')
|
||||
navigate(-1)
|
||||
const backTarget = mediaLibraryBackTarget(media)
|
||||
if (backTarget) navigate(backTarget, { replace: true })
|
||||
else navigate(-1)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
@@ -138,155 +134,38 @@ export function MediaDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-3xl bg-white border border-gray-200/90 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)]">
|
||||
{/* ── Cinematic Blurred Backdrop Glow ── */}
|
||||
<div className="absolute inset-0 h-[480px] z-0 overflow-hidden">
|
||||
{(media.backdrop_url || media.poster_url) ? (
|
||||
<img
|
||||
src={imageURL(media.backdrop_url || media.poster_url || '')}
|
||||
alt=""
|
||||
className="w-full h-full object-cover opacity-[0.04] scale-110 blur-2xl"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-b from-gray-50 to-transparent" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white via-white/95 to-transparent" />
|
||||
<MediaDetailBackdrop media={media} />
|
||||
|
||||
<div className="relative z-20 px-6 pt-6 sm:px-10 sm:pt-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const backTarget = mediaLibraryBackTarget(media)
|
||||
if (backTarget) navigate(backTarget)
|
||||
else navigate(-1)
|
||||
}}
|
||||
className="btn-ghost gap-2 bg-white/80 shadow-sm backdrop-blur hover:bg-white"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>返回媒体库</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Main Details Layout Container ── */}
|
||||
<div className="relative z-10 p-6 sm:p-10 flex flex-col md:flex-row gap-8 lg:gap-12">
|
||||
{/* Poster Card */}
|
||||
<div className="w-56 shrink-0 mx-auto md:mx-0">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02 }}
|
||||
className="aspect-[2/3] w-full rounded-2xl overflow-hidden bg-gray-50 border border-gray-200 shadow-md relative group"
|
||||
>
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url)}
|
||||
alt={media.title}
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-gray-500 bg-gray-50">
|
||||
<FileText size={40} className="stroke-[1]" />
|
||||
<span className="text-xs uppercase tracking-wider font-bold">无海报</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Play overlay button */}
|
||||
<Link
|
||||
to={`/play/${media.id}`}
|
||||
className="absolute inset-0 bg-[#111827]/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
>
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-brand-500 text-white shadow-xl transform scale-90 group-hover:scale-100 transition-transform">
|
||||
<Play size={24} fill="currentColor" />
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
<MediaDetailPoster media={media} />
|
||||
|
||||
{/* Detailed Metadata Body */}
|
||||
<div className="flex-1 space-y-6">
|
||||
{/* Title and Year Header */}
|
||||
<div className="space-y-3">
|
||||
<h1 className="font-display text-3xl sm:text-4xl font-extrabold tracking-tight text-gray-900 leading-tight">
|
||||
{media.title}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-2.5 text-xs text-gray-500 font-bold tracking-wide uppercase">
|
||||
{media.year > 0 && (
|
||||
<span className="inline-flex items-center gap-1 bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
|
||||
<Calendar size={13} className="text-brand-500" />
|
||||
<span>{media.year} 年</span>
|
||||
</span>
|
||||
)}
|
||||
{media.width > 0 && (
|
||||
<span className="inline-flex items-center gap-1 bg-brand-50 text-brand-700 border border-brand-100/50 px-2.5 py-1 rounded-xl">
|
||||
<span>{media.width} × {media.height}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
|
||||
{fmtSize(media.size_bytes)}
|
||||
</span>
|
||||
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700">
|
||||
{fmtDuration(media.duration_sec)}
|
||||
</span>
|
||||
{media.container && (
|
||||
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-xl text-gray-700 font-mono">
|
||||
{media.container}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description Card */}
|
||||
{media.overview && (
|
||||
<div className="rounded-2xl bg-gray-50/50 border border-gray-100 p-5 space-y-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-brand-500">剧情简介</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed font-semibold">
|
||||
{media.overview}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tag Rows (Genres, Languages, Countries) */}
|
||||
<div className="space-y-4">
|
||||
{/* Genres */}
|
||||
{parseCSV(media.genres).length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs font-bold text-gray-500 w-16 uppercase tracking-wider">类型流派</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{parseCSV(media.genres).map((g) => (
|
||||
<span key={g} className="rounded-full bg-brand-50 text-brand-700 border border-brand-100/30 px-3 py-1 text-2xs font-bold uppercase tracking-wider">
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Languages */}
|
||||
{parseCSV(media.languages).length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs font-bold text-gray-500 w-16 uppercase tracking-wider">语言</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{parseCSV(media.languages).map((l) => (
|
||||
<span key={l} className="rounded-xl bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold">
|
||||
{l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Countries */}
|
||||
{parseCSV(media.countries).length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs font-bold text-gray-500 w-16 uppercase tracking-wider">国家/地区</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{parseCSV(media.countries).map((c) => (
|
||||
<span key={c} className="rounded-xl bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold">
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MediaDetailMetadata media={media} />
|
||||
|
||||
<div className="divider border-gray-200/60" />
|
||||
|
||||
{/* Action Buttons Panel */}
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Primary Direct Play */}
|
||||
<Link to={`/play/${media.id}`} className="btn-primary px-6 py-3.5 shadow-sm">
|
||||
<Play size={16} fill="currentColor" />
|
||||
<span>立即播放</span>
|
||||
</Link>
|
||||
|
||||
{/* Transcode Playback */}
|
||||
<Link
|
||||
to={`/play/${media.id}?mode=hls`}
|
||||
className="btn-outline border-brand-500/30 hover:border-brand-500 text-[#c9954a] hover:bg-brand-50 px-5"
|
||||
@@ -297,7 +176,6 @@ export function MediaDetailPage() {
|
||||
|
||||
<ExternalPlayerButton mediaId={media.id} />
|
||||
|
||||
{/* Toggle Favourites */}
|
||||
<button
|
||||
onClick={toggleFav}
|
||||
className={
|
||||
@@ -312,44 +190,19 @@ export function MediaDetailPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Admin Management Toolbar */}
|
||||
{role === 'admin' && (
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50/50 p-5 space-y-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={rescrape} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Sparkles size={13} className="text-[#c9954a]" />
|
||||
<span>智能刮削 (TMDB)</span>
|
||||
</button>
|
||||
<button onClick={() => setManualScrapeOpen(true)} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Search size={13} className="text-[#c9954a]" />
|
||||
<span>手动匹配刮削</span>
|
||||
</button>
|
||||
<button onClick={() => setMetadataEditOpen(true)} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Pencil size={13} className="text-gray-600" />
|
||||
<span>编辑元数据</span>
|
||||
</button>
|
||||
<button onClick={() => setOrganizeOpen(true)} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<FolderInput size={13} className="text-[#c9954a]" />
|
||||
<span>整理入库</span>
|
||||
</button>
|
||||
<button onClick={reprobe} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Database size={13} className="text-gray-600" />
|
||||
<span>探测媒体轨 (ffprobe)</span>
|
||||
</button>
|
||||
<button onClick={exportNFO} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<FileText size={13} />
|
||||
<span>写出本地 NFO 属性</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={softDelete}
|
||||
className="btn-outline py-2 px-3.5 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
<span>移入回收站</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<MediaDetailAdminPanel
|
||||
media={media}
|
||||
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
||||
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
|
||||
onSmartScrape={rescrape}
|
||||
onManualScrape={() => setManualScrapeOpen(true)}
|
||||
onMetadataEdit={() => setMetadataEditOpen(true)}
|
||||
onOrganize={() => setOrganizeOpen(true)}
|
||||
onProbe={reprobe}
|
||||
onExportNFO={exportNFO}
|
||||
onSoftDelete={softDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -360,6 +213,7 @@ export function MediaDetailPage() {
|
||||
defaultQuery={media.title}
|
||||
mediaType={media.season_num > 0 || media.episode_num > 0 ? 'tv' : undefined}
|
||||
scopeLabel={media.title}
|
||||
episodeArtwork={scrapeEpisodeArtwork}
|
||||
onClose={() => setManualScrapeOpen(false)}
|
||||
onApplied={refresh}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Loader2, Pencil, Send, Trash2 } from 'lucide-react'
|
||||
|
||||
import type { NotifyChannel } from '../types'
|
||||
import { channelSummary, eventSummary, TYPE_LABELS } from './notifyChannelsModel'
|
||||
|
||||
type NotifyChannelCardProps = {
|
||||
channel: NotifyChannel
|
||||
onTest: () => void
|
||||
testing?: boolean
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
export function NotifyChannelCard({
|
||||
channel,
|
||||
onTest,
|
||||
testing,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: NotifyChannelCardProps) {
|
||||
const summary = channelSummary(channel)
|
||||
return (
|
||||
<div className="glass-panel flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-ink-600">{channel.name}</span>
|
||||
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs text-ink-50">
|
||||
{TYPE_LABELS[channel.type] ?? channel.type}
|
||||
</span>
|
||||
{!channel.enabled && (
|
||||
<span className="rounded-lg bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">已禁用</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-ink-50">{summary}</div>
|
||||
<div className="mt-1 truncate text-xs text-sand-500">{eventSummary(channel.events)}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
{testing ? <Loader2 size={12} className="inline animate-spin" /> : <Send size={12} className="inline" />} 测试
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<Pencil size={12} className="inline" /> 编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
>
|
||||
<Trash2 size={12} className="inline" /> 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { EVENT_OPTIONS, type EventMode } from './notifyChannelsModel'
|
||||
import { Field } from './NotifyChannelFormField'
|
||||
|
||||
type NotifyChannelEventFieldsProps = {
|
||||
events: string[]
|
||||
eventMode: EventMode
|
||||
setEventMode: (mode: EventMode) => void
|
||||
toggleEvent: (event: string) => void
|
||||
}
|
||||
|
||||
export function NotifyChannelEventFields({
|
||||
events,
|
||||
eventMode,
|
||||
setEventMode,
|
||||
toggleEvent,
|
||||
}: NotifyChannelEventFieldsProps) {
|
||||
return (
|
||||
<Field label="推送事件">
|
||||
<div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50/60 p-3">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="radio"
|
||||
name="notify-event-mode"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={eventMode === 'all'}
|
||||
onChange={() => setEventMode('all')}
|
||||
/>
|
||||
全部事件
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="radio"
|
||||
name="notify-event-mode"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={eventMode === 'none'}
|
||||
onChange={() => setEventMode('none')}
|
||||
/>
|
||||
关闭全部推送事件
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="radio"
|
||||
name="notify-event-mode"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={eventMode === 'custom'}
|
||||
onChange={() => setEventMode('custom')}
|
||||
/>
|
||||
仅推送勾选事件
|
||||
</label>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{EVENT_OPTIONS.map((event) => (
|
||||
<label key={event.value} className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
disabled={eventMode !== 'custom'}
|
||||
checked={events.includes(event.value)}
|
||||
onChange={() => toggleEvent(event.value)}
|
||||
/>
|
||||
{event.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-ink-100">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
notifyChannelsAPI,
|
||||
type NotifyChannelInput,
|
||||
} from '../api/notify_channels'
|
||||
import type { NotifyChannel } from '../types'
|
||||
import {
|
||||
EMPTY_CONFIG,
|
||||
EVENT_ALL,
|
||||
EVENT_NONE,
|
||||
EVENT_OPTIONS,
|
||||
type EventMode,
|
||||
initialEventMode,
|
||||
normalizeInitialConfig,
|
||||
} from './notifyChannelsModel'
|
||||
import { Field } from './NotifyChannelFormField'
|
||||
import { NotifyChannelEventFields } from './NotifyChannelEventFields'
|
||||
import {
|
||||
BarkFields,
|
||||
EmailFields,
|
||||
TelegramFields,
|
||||
WebhookFields,
|
||||
WechatFields,
|
||||
} from './NotifyChannelProviderFields'
|
||||
|
||||
type NotifyChannelFormModalProps = {
|
||||
editing: NotifyChannel | null
|
||||
onClose: () => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function NotifyChannelFormModal({
|
||||
editing,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: NotifyChannelFormModalProps) {
|
||||
const [name, setName] = useState(editing?.name ?? '')
|
||||
const [type, setType] = useState<NotifyChannel['type']>(
|
||||
editing?.type ?? 'telegram',
|
||||
)
|
||||
const [config, setConfig] = useState<Record<string, string>>(
|
||||
normalizeInitialConfig(editing?.type ?? 'telegram', editing?.config ?? {}),
|
||||
)
|
||||
const [events, setEvents] = useState<string[]>(editing?.events ?? [])
|
||||
const [eventMode, setEventMode] = useState<EventMode>(initialEventMode(editing?.events))
|
||||
const [enabled, setEnabled] = useState(editing?.enabled ?? true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const onTypeChange = (t: NotifyChannel['type']) => {
|
||||
setType(t)
|
||||
setConfig({ ...EMPTY_CONFIG[t] })
|
||||
}
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (type === 'telegram') {
|
||||
if (!String(config.bot_token ?? '').trim()) {
|
||||
toast.error('请填写 Telegram Bot Token')
|
||||
return
|
||||
}
|
||||
if (!String(config.admin_user_ids ?? '').trim()) {
|
||||
toast.error('请填写管理员 Telegram ID')
|
||||
return
|
||||
}
|
||||
}
|
||||
const selectedEvents = events.filter((event) => EVENT_OPTIONS.some((item) => item.value === event))
|
||||
if (eventMode === 'custom' && selectedEvents.length === 0) {
|
||||
toast.error('请至少选择一个推送事件,或选择关闭全部推送事件')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const cleanedConfig = Object.fromEntries(
|
||||
Object.entries(config).map(([key, value]) => [key, String(value ?? '').trim()]),
|
||||
)
|
||||
delete cleanedConfig.chat_id
|
||||
const input: NotifyChannelInput = {
|
||||
name: name.trim(),
|
||||
type: type,
|
||||
config: cleanedConfig,
|
||||
events: eventMode === 'all' ? [EVENT_ALL] : eventMode === 'none' ? [EVENT_NONE] : selectedEvents,
|
||||
enabled,
|
||||
}
|
||||
if (editing) {
|
||||
await notifyChannelsAPI.update(editing.id, input)
|
||||
} else {
|
||||
await notifyChannelsAPI.create(input)
|
||||
}
|
||||
toast.success('已保存')
|
||||
await onSaved()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateConfig = (k: string, v: string) =>
|
||||
setConfig((c) => ({ ...c, [k]: v }))
|
||||
|
||||
const toggleEvent = (event: string) => {
|
||||
setEvents((current) =>
|
||||
current.includes(event)
|
||||
? current.filter((item) => item !== event)
|
||||
: [...current.filter((item) => item !== EVENT_ALL && item !== EVENT_NONE), event],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
|
||||
{editing ? '编辑通知渠道' : '添加通知渠道'}
|
||||
</h2>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<Field label="名称">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="如: Telegram 通知"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="渠道类型">
|
||||
<select
|
||||
className="input-base"
|
||||
value={type}
|
||||
onChange={(e) => onTypeChange(e.target.value as NotifyChannel['type'])}
|
||||
>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="wechat">企业微信 / Server酱</option>
|
||||
<option value="bark">Bark (iOS)</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
<option value="email">Email (SMTP)</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{type === 'telegram' && <TelegramFields config={config} updateConfig={updateConfig} />}
|
||||
{type === 'wechat' && <WechatFields config={config} updateConfig={updateConfig} />}
|
||||
{type === 'bark' && <BarkFields config={config} updateConfig={updateConfig} />}
|
||||
{type === 'webhook' && <WebhookFields config={config} updateConfig={updateConfig} />}
|
||||
{type === 'email' && <EmailFields config={config} updateConfig={updateConfig} />}
|
||||
|
||||
<NotifyChannelEventFields
|
||||
events={events}
|
||||
eventMode={eventMode}
|
||||
setEventMode={setEventMode}
|
||||
toggleEvent={toggleEvent}
|
||||
/>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving && <Loader2 size={16} className="animate-spin" />} 保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { Field } from './NotifyChannelFormField'
|
||||
|
||||
type ProviderFieldsProps = {
|
||||
config: Record<string, string>
|
||||
updateConfig: (key: string, value: string) => void
|
||||
}
|
||||
|
||||
export function TelegramFields({ config, updateConfig }: ProviderFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<Field label="Bot Token">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="123456:ABC-DEF…"
|
||||
value={config.bot_token ?? ''}
|
||||
onChange={(e) => updateConfig('bot_token', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="管理员 Telegram ID">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="多个用逗号分隔,如 123456789,987654321"
|
||||
value={config.admin_user_ids ?? ''}
|
||||
onChange={(e) => updateConfig('admin_user_ids', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="绑定群组 ID">
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="选填,如 -1001234567890;填写后群组成员可唤醒/绑定"
|
||||
value={config.group_chat_id ?? ''}
|
||||
onChange={(e) => updateConfig('group_chat_id', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="绑定频道 ID">
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="选填,如 -1009876543210;填写后频道成员可唤醒/绑定"
|
||||
value={config.channel_chat_id ?? ''}
|
||||
onChange={(e) => updateConfig('channel_chat_id', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Telegram API 地址 (可选)">
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="默认 https://api.telegram.org;反代可填 https://tg.example.com"
|
||||
value={config.api_base_url ?? ''}
|
||||
onChange={(e) => updateConfig('api_base_url', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Telegram 代理 (可选)">
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="如 http://172.17.0.1:7890 或 socks5://172.17.0.1:1080"
|
||||
value={config.proxy_url ?? ''}
|
||||
onChange={(e) => updateConfig('proxy_url', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-2xl border border-primary-400/15 bg-primary-400/5 px-4 py-3 text-xs leading-6 text-ink-50">
|
||||
群组 ID、频道 ID 均为选填,可填一个、两个都填,也可以不填。管理功能始终仅管理员 Telegram ID 或已绑定的本地管理员可用;普通用户需要在已配置的群组或频道中,才能使用 <code>/start 用户名 密码</code> 绑定账号、切换隐藏成人媒体库和目录。不配置群组/频道时,普通用户不会被放行。若测试通知超时,可填写反代 API 地址或代理地址。
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function WechatFields({ config, updateConfig }: ProviderFieldsProps) {
|
||||
return (
|
||||
<Field label="SendKey">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="SCT…"
|
||||
value={config.sendkey ?? ''}
|
||||
onChange={(e) => updateConfig('sendkey', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export function BarkFields({ config, updateConfig }: ProviderFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<Field label="设备 Key">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
value={config.device_key ?? ''}
|
||||
onChange={(e) => updateConfig('device_key', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="服务器地址 (可选)">
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="https://api.day.app"
|
||||
value={config.server ?? ''}
|
||||
onChange={(e) => updateConfig('server', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function WebhookFields({ config, updateConfig }: ProviderFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<Field label="URL">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="https://example.com/notify"
|
||||
value={config.url ?? ''}
|
||||
onChange={(e) => updateConfig('url', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Method">
|
||||
<select
|
||||
className="input-base"
|
||||
value={config.method ?? 'POST'}
|
||||
onChange={(e) => updateConfig('method', e.target.value)}
|
||||
>
|
||||
<option value="POST">POST</option>
|
||||
<option value="GET">GET</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Headers (JSON)">
|
||||
<textarea
|
||||
rows={2}
|
||||
className="input-base font-mono text-xs"
|
||||
placeholder='{"Content-Type":"application/json"}'
|
||||
value={config.headers ?? ''}
|
||||
onChange={(e) => updateConfig('headers', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Body 模板 (支持 {{title}} {{message}})">
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base font-mono text-xs"
|
||||
placeholder='{"title":"{{title}}","message":"{{message}}"}'
|
||||
value={config.body_template ?? ''}
|
||||
onChange={(e) => updateConfig('body_template', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmailFields({ config, updateConfig }: ProviderFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<Field label="SMTP 地址">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="smtp.gmail.com"
|
||||
value={config.smtp_host ?? ''}
|
||||
onChange={(e) => updateConfig('smtp_host', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="SMTP 端口">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="465"
|
||||
value={config.smtp_port ?? ''}
|
||||
onChange={(e) => updateConfig('smtp_port', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="TLS">
|
||||
<select
|
||||
className="input-base"
|
||||
value={config.tls ?? 'true'}
|
||||
onChange={(e) => updateConfig('tls', e.target.value)}
|
||||
>
|
||||
<option value="true">启用</option>
|
||||
<option value="false">关闭</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="用户名">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
value={config.username ?? ''}
|
||||
onChange={(e) => updateConfig('username', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="密码">
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
className="input-base"
|
||||
value={config.password ?? ''}
|
||||
onChange={(e) => updateConfig('password', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="发件人">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="noreply@example.com"
|
||||
value={config.from ?? ''}
|
||||
onChange={(e) => updateConfig('from', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="收件人 (多个用逗号分隔)">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="user@example.com"
|
||||
value={config.to ?? ''}
|
||||
onChange={(e) => updateConfig('to', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Bell, Loader2, Pencil, Play, Plus, Send, Square, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Bell, Loader2, Play, Plus, Square } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
notifyChannelsAPI,
|
||||
type NotifyChannelInput,
|
||||
} from '../api/notify_channels'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { notifyChannelsAPI } from '../api/notify_channels'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import type { NotifyChannel } from '../types'
|
||||
import { NotifyChannelCard } from './NotifyChannelCard'
|
||||
import { NotifyChannelFormModal } from './NotifyChannelFormModal'
|
||||
|
||||
// NotifyChannelsPage replaces the Vue NotifyTab. Operators can register
|
||||
// multiple Telegram bots / Bark devices / WeChat keys / Webhooks and
|
||||
@@ -152,7 +151,7 @@ export function NotifyChannelsPage() {
|
||||
{!loading && channels.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{channels.map((ch) => (
|
||||
<ChannelCard
|
||||
<NotifyChannelCard
|
||||
key={ch.id}
|
||||
channel={ch}
|
||||
onTest={() => onTest(ch.id)}
|
||||
@@ -168,7 +167,7 @@ export function NotifyChannelsPage() {
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<ChannelFormModal
|
||||
<NotifyChannelFormModal
|
||||
editing={editing}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={async () => {
|
||||
@@ -180,546 +179,3 @@ export function NotifyChannelsPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<NotifyChannel['type'], string> = {
|
||||
telegram: 'Telegram',
|
||||
wechat: '企业微信',
|
||||
bark: 'Bark',
|
||||
webhook: 'Webhook',
|
||||
email: 'Email',
|
||||
}
|
||||
|
||||
const EVENT_OPTIONS = [
|
||||
{ value: 'subscription_hit', label: '订阅命中新资源' },
|
||||
{ value: 'download_complete', label: '下载任务完成' },
|
||||
{ value: 'library_ingest', label: '入库完成' },
|
||||
{ value: 'scrape_failed', label: '刮削失败告警' },
|
||||
{ value: 'system_alert', label: '系统异常通知' },
|
||||
]
|
||||
|
||||
const EVENT_ALL = '__all__'
|
||||
const EVENT_NONE = '__none__'
|
||||
type EventMode = 'all' | 'custom' | 'none'
|
||||
|
||||
function initialEventMode(events: string[] | undefined): EventMode {
|
||||
if (events?.includes(EVENT_NONE)) return 'none'
|
||||
if (events?.includes(EVENT_ALL) || !events || events.length === 0) return 'all'
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
function ChannelCard({
|
||||
channel,
|
||||
onTest,
|
||||
testing,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
channel: NotifyChannel
|
||||
onTest: () => void
|
||||
testing?: boolean
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const summary = channelSummary(channel)
|
||||
return (
|
||||
<div className="glass-panel flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-ink-600">{channel.name}</span>
|
||||
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs text-ink-50">
|
||||
{TYPE_LABELS[channel.type] ?? channel.type}
|
||||
</span>
|
||||
{!channel.enabled && (
|
||||
<span className="rounded-lg bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">已禁用</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-ink-50">{summary}</div>
|
||||
<div className="mt-1 truncate text-xs text-sand-500">{eventSummary(channel.events)}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
{testing ? <Loader2 size={12} className="inline animate-spin" /> : <Send size={12} className="inline" />} 测试
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<Pencil size={12} className="inline" /> 编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
>
|
||||
<Trash2 size={12} className="inline" /> 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function eventSummary(events: string[] | undefined): string {
|
||||
if (events?.includes(EVENT_NONE)) return '事件:不推送'
|
||||
if (events?.includes(EVENT_ALL)) return '事件:全部'
|
||||
if (!events || events.length === 0) return '事件:全部'
|
||||
const labels = events.map((event) => EVENT_OPTIONS.find((item) => item.value === event)?.label ?? event)
|
||||
return `事件:${labels.join('、')}`
|
||||
}
|
||||
|
||||
function channelSummary(ch: NotifyChannel): string {
|
||||
const cfg = ch.config ?? {}
|
||||
switch (ch.type) {
|
||||
case 'telegram':
|
||||
return `Bot ${cfg.bot_token ? '已配置' : '未配置'} → 管理员 ${cfg.admin_user_ids ?? '-'} · 群组 ${cfg.group_chat_id ?? '-'} · 频道 ${cfg.channel_chat_id ?? '-'}`
|
||||
case 'wechat':
|
||||
return `SendKey ${String(cfg.sendkey ?? '').slice(0, 10)}…`
|
||||
case 'bark':
|
||||
return `Device ${String(cfg.device_key ?? '').slice(0, 10)}…`
|
||||
case 'webhook':
|
||||
return `${cfg.method ?? 'POST'} ${cfg.url ?? ''}`
|
||||
case 'email':
|
||||
return `SMTP ${cfg.smtp_host ?? '-'}:${cfg.smtp_port ?? '-'} → ${cfg.to ?? '-'}`
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Form Modal ─────────────────────────────────────────────────────────────
|
||||
|
||||
const EMPTY_CONFIG: Record<NotifyChannel['type'], Record<string, string>> = {
|
||||
telegram: {
|
||||
bot_token: '',
|
||||
admin_user_ids: '',
|
||||
group_chat_id: '',
|
||||
channel_chat_id: '',
|
||||
api_base_url: '',
|
||||
proxy_url: '',
|
||||
},
|
||||
wechat: { sendkey: '' },
|
||||
bark: { device_key: '', server: '' },
|
||||
webhook: { url: '', method: 'POST', headers: '', body_template: '' },
|
||||
email: { smtp_host: '', smtp_port: '465', username: '', password: '', from: '', to: '', tls: 'true' },
|
||||
}
|
||||
|
||||
function normalizeInitialConfig(type: NotifyChannel['type'], raw: Record<string, unknown>): Record<string, string> {
|
||||
const base = { ...EMPTY_CONFIG[type] }
|
||||
for (const [key, value] of Object.entries(raw ?? {})) {
|
||||
base[key] = String(value ?? '')
|
||||
}
|
||||
if (type === 'telegram' && !base.group_chat_id && !base.channel_chat_id && base.chat_id?.startsWith('-')) {
|
||||
base.group_chat_id = base.chat_id
|
||||
}
|
||||
if (type === 'telegram' && !base.admin_user_ids && base.chat_id && !base.chat_id.startsWith('-')) {
|
||||
base.admin_user_ids = base.chat_id
|
||||
}
|
||||
delete base.chat_id
|
||||
return base
|
||||
}
|
||||
|
||||
function ChannelFormModal({
|
||||
editing,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
editing: NotifyChannel | null
|
||||
onClose: () => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}) {
|
||||
const [name, setName] = useState(editing?.name ?? '')
|
||||
const [type, setType] = useState<NotifyChannel['type']>(
|
||||
editing?.type ?? 'telegram',
|
||||
)
|
||||
const [config, setConfig] = useState<Record<string, string>>(
|
||||
normalizeInitialConfig(editing?.type ?? 'telegram', editing?.config ?? {}),
|
||||
)
|
||||
const [events, setEvents] = useState<string[]>(editing?.events ?? [])
|
||||
const [eventMode, setEventMode] = useState<EventMode>(initialEventMode(editing?.events))
|
||||
const [enabled, setEnabled] = useState(editing?.enabled ?? true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const onTypeChange = (t: NotifyChannel['type']) => {
|
||||
setType(t)
|
||||
setConfig({ ...EMPTY_CONFIG[t] })
|
||||
}
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (type === 'telegram') {
|
||||
if (!String(config.bot_token ?? '').trim()) {
|
||||
toast.error('请填写 Telegram Bot Token')
|
||||
return
|
||||
}
|
||||
if (!String(config.admin_user_ids ?? '').trim()) {
|
||||
toast.error('请填写管理员 Telegram ID')
|
||||
return
|
||||
}
|
||||
}
|
||||
const selectedEvents = events.filter((event) => EVENT_OPTIONS.some((item) => item.value === event))
|
||||
if (eventMode === 'custom' && selectedEvents.length === 0) {
|
||||
toast.error('请至少选择一个推送事件,或选择关闭全部推送事件')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const cleanedConfig = Object.fromEntries(
|
||||
Object.entries(config).map(([key, value]) => [key, String(value ?? '').trim()]),
|
||||
)
|
||||
delete cleanedConfig.chat_id
|
||||
const input: NotifyChannelInput = {
|
||||
name: name.trim(),
|
||||
type: type,
|
||||
config: cleanedConfig,
|
||||
events: eventMode === 'all' ? [EVENT_ALL] : eventMode === 'none' ? [EVENT_NONE] : selectedEvents,
|
||||
enabled,
|
||||
}
|
||||
if (editing) {
|
||||
await notifyChannelsAPI.update(editing.id, input)
|
||||
} else {
|
||||
await notifyChannelsAPI.create(input)
|
||||
}
|
||||
toast.success('已保存')
|
||||
await onSaved()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateConfig = (k: string, v: string) =>
|
||||
setConfig((c) => ({ ...c, [k]: v }))
|
||||
|
||||
const toggleEvent = (event: string) => {
|
||||
setEvents((current) =>
|
||||
current.includes(event)
|
||||
? current.filter((item) => item !== event)
|
||||
: [...current.filter((item) => item !== EVENT_ALL && item !== EVENT_NONE), event],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
|
||||
{editing ? '编辑通知渠道' : '添加通知渠道'}
|
||||
</h2>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<Field label="名称">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="如: Telegram 通知"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="渠道类型">
|
||||
<select
|
||||
className="input-base"
|
||||
value={type}
|
||||
onChange={(e) => onTypeChange(e.target.value as NotifyChannel['type'])}
|
||||
>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="wechat">企业微信 / Server酱</option>
|
||||
<option value="bark">Bark (iOS)</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
<option value="email">Email (SMTP)</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{type === 'telegram' && (
|
||||
<>
|
||||
<Field label="Bot Token">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="123456:ABC-DEF…"
|
||||
value={config.bot_token ?? ''}
|
||||
onChange={(e) => updateConfig('bot_token', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="管理员 Telegram ID">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="多个用逗号分隔,如 123456789,987654321"
|
||||
value={config.admin_user_ids ?? ''}
|
||||
onChange={(e) => updateConfig('admin_user_ids', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="绑定群组 ID">
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="选填,如 -1001234567890;填写后群组成员可唤醒/绑定"
|
||||
value={config.group_chat_id ?? ''}
|
||||
onChange={(e) => updateConfig('group_chat_id', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="绑定频道 ID">
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="选填,如 -1009876543210;填写后频道成员可唤醒/绑定"
|
||||
value={config.channel_chat_id ?? ''}
|
||||
onChange={(e) => updateConfig('channel_chat_id', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Telegram API 地址 (可选)">
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="默认 https://api.telegram.org;反代可填 https://tg.example.com"
|
||||
value={config.api_base_url ?? ''}
|
||||
onChange={(e) => updateConfig('api_base_url', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Telegram 代理 (可选)">
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="如 http://172.17.0.1:7890 或 socks5://172.17.0.1:1080"
|
||||
value={config.proxy_url ?? ''}
|
||||
onChange={(e) => updateConfig('proxy_url', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-2xl border border-primary-400/15 bg-primary-400/5 px-4 py-3 text-xs leading-6 text-ink-50">
|
||||
群组 ID、频道 ID 均为选填,可填一个、两个都填,也可以不填。管理功能始终仅管理员 Telegram ID 或已绑定的本地管理员可用;普通用户需要在已配置的群组或频道中,才能使用 <code>/start 用户名 密码</code> 绑定账号、切换隐藏成人媒体库和目录。不配置群组/频道时,普通用户不会被放行。若测试通知超时,可填写反代 API 地址或代理地址。
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'wechat' && (
|
||||
<Field label="SendKey">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="SCT…"
|
||||
value={config.sendkey ?? ''}
|
||||
onChange={(e) => updateConfig('sendkey', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === 'bark' && (
|
||||
<>
|
||||
<Field label="设备 Key">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
value={config.device_key ?? ''}
|
||||
onChange={(e) => updateConfig('device_key', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="服务器地址 (可选)">
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="https://api.day.app"
|
||||
value={config.server ?? ''}
|
||||
onChange={(e) => updateConfig('server', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'webhook' && (
|
||||
<>
|
||||
<Field label="URL">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="https://example.com/notify"
|
||||
value={config.url ?? ''}
|
||||
onChange={(e) => updateConfig('url', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Method">
|
||||
<select
|
||||
className="input-base"
|
||||
value={config.method ?? 'POST'}
|
||||
onChange={(e) => updateConfig('method', e.target.value)}
|
||||
>
|
||||
<option value="POST">POST</option>
|
||||
<option value="GET">GET</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Headers (JSON)">
|
||||
<textarea
|
||||
rows={2}
|
||||
className="input-base font-mono text-xs"
|
||||
placeholder='{"Content-Type":"application/json"}'
|
||||
value={config.headers ?? ''}
|
||||
onChange={(e) => updateConfig('headers', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Body 模板 (支持 {{title}} {{message}})">
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base font-mono text-xs"
|
||||
placeholder='{"title":"{{title}}","message":"{{message}}"}'
|
||||
value={config.body_template ?? ''}
|
||||
onChange={(e) => updateConfig('body_template', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'email' && (
|
||||
<>
|
||||
<Field label="SMTP 地址">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="smtp.gmail.com"
|
||||
value={config.smtp_host ?? ''}
|
||||
onChange={(e) => updateConfig('smtp_host', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="SMTP 端口">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="465"
|
||||
value={config.smtp_port ?? ''}
|
||||
onChange={(e) => updateConfig('smtp_port', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="TLS">
|
||||
<select
|
||||
className="input-base"
|
||||
value={config.tls ?? 'true'}
|
||||
onChange={(e) => updateConfig('tls', e.target.value)}
|
||||
>
|
||||
<option value="true">启用</option>
|
||||
<option value="false">关闭</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="用户名">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
value={config.username ?? ''}
|
||||
onChange={(e) => updateConfig('username', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="密码">
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
className="input-base"
|
||||
value={config.password ?? ''}
|
||||
onChange={(e) => updateConfig('password', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="发件人">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="noreply@example.com"
|
||||
value={config.from ?? ''}
|
||||
onChange={(e) => updateConfig('from', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="收件人 (多个用逗号分隔)">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="user@example.com"
|
||||
value={config.to ?? ''}
|
||||
onChange={(e) => updateConfig('to', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Field label="推送事件">
|
||||
<div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50/60 p-3">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="radio"
|
||||
name="notify-event-mode"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={eventMode === 'all'}
|
||||
onChange={() => setEventMode('all')}
|
||||
/>
|
||||
全部事件
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="radio"
|
||||
name="notify-event-mode"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={eventMode === 'none'}
|
||||
onChange={() => setEventMode('none')}
|
||||
/>
|
||||
关闭全部推送事件
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="radio"
|
||||
name="notify-event-mode"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={eventMode === 'custom'}
|
||||
onChange={() => setEventMode('custom')}
|
||||
/>
|
||||
仅推送勾选事件
|
||||
</label>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{EVENT_OPTIONS.map((event) => (
|
||||
<label key={event.value} className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
disabled={eventMode !== 'custom'}
|
||||
checked={events.includes(event.value)}
|
||||
onChange={() => toggleEvent(event.value)}
|
||||
/>
|
||||
{event.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving && <Loader2 size={16} className="animate-spin" />} 保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-ink-100">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
+76
-140
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import type Hls from 'hls.js'
|
||||
import { ArrowLeft, RefreshCw, Sparkles } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
@@ -11,8 +10,9 @@ import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { systemAPI } from '../api/system'
|
||||
import type { Media } from '../types'
|
||||
import { getSeriesKey, isEpisodeLike } from '../utils/groupSeries'
|
||||
|
||||
type Mode = 'direct' | 'hls'
|
||||
import { pickPlayerMode, needsTranscodeForBrowser, type PlayerMode } from './playerPageModel'
|
||||
import { PlayerTopBar } from './PlayerTopBar'
|
||||
import { PlayerVideoStage } from './PlayerVideoStage'
|
||||
|
||||
// Fullscreen, dark-themed video page.
|
||||
//
|
||||
@@ -36,13 +36,37 @@ export function PlayerPage() {
|
||||
const lastSentRef = useRef(0)
|
||||
|
||||
const [media, setMedia] = useState<Media | null>(null)
|
||||
const [mode, setMode] = useState<Mode>('direct')
|
||||
const [mode, setMode] = useState<PlayerMode>('direct')
|
||||
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||
const [playerError, setPlayerError] = useState('')
|
||||
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
|
||||
const [directOnly, setDirectOnly] = useState(false)
|
||||
|
||||
const teardownHls = useCallback((mediaId?: string, stopServer = false) => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy()
|
||||
hlsRef.current = null
|
||||
}
|
||||
if (stopServer && mediaId) {
|
||||
api.delete(`/hls/${encodeURIComponent(mediaId)}`).catch(() => undefined)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const backTarget = useCallback(() => {
|
||||
const state = location.state as { from?: string } | null
|
||||
if (state?.from) return state.from
|
||||
if (media && isEpisodeLike(media) && media.library_id) {
|
||||
return `/library/${encodeURIComponent(media.display_library_id || media.library_id)}?series=${encodeURIComponent(getSeriesKey(media))}`
|
||||
}
|
||||
const target = media?.id || id
|
||||
return target ? `/media/${target}` : '/'
|
||||
}, [id, location.state, media])
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
navigate(backTarget(), { replace: true })
|
||||
}, [backTarget, navigate])
|
||||
|
||||
// 读取宿主机的「直连解码」开关。开启时全程 direct play,不走 HLS。
|
||||
useEffect(() => {
|
||||
systemAPI
|
||||
@@ -56,8 +80,8 @@ export function PlayerPage() {
|
||||
if (!id) return
|
||||
mediaAPI.get(id).then((m) => {
|
||||
setMedia(m)
|
||||
const forced = params.get('mode') as Mode | null
|
||||
const auto = pickMode(m)
|
||||
const forced = params.get('mode') as PlayerMode | null
|
||||
const auto = pickPlayerMode(m)
|
||||
// 直连解码模式下忽略 ?mode=hls 与自动判定,始终 direct play。
|
||||
setMode(directOnly ? 'direct' : (forced ?? auto))
|
||||
setPlayerError('')
|
||||
@@ -114,7 +138,7 @@ export function PlayerPage() {
|
||||
void video.play().catch(() => undefined)
|
||||
}
|
||||
return () => teardownHls(media.id, mode === 'hls')
|
||||
}, [hlsUnavailable, media, mode, params, setParams])
|
||||
}, [hlsUnavailable, media, mode, params, setParams, teardownHls])
|
||||
|
||||
// Persist resume position every 10 seconds while playing.
|
||||
useEffect(() => {
|
||||
@@ -138,30 +162,6 @@ export function PlayerPage() {
|
||||
}
|
||||
}, [media])
|
||||
|
||||
const teardownHls = (mediaId?: string, stopServer = false) => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy()
|
||||
hlsRef.current = null
|
||||
}
|
||||
if (stopServer && mediaId) {
|
||||
api.delete(`/hls/${encodeURIComponent(mediaId)}`).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const backTarget = () => {
|
||||
const state = location.state as { from?: string } | null
|
||||
if (state?.from) return state.from
|
||||
if (media && isEpisodeLike(media) && media.library_id) {
|
||||
return `/library/${encodeURIComponent(media.display_library_id || media.library_id)}?series=${encodeURIComponent(getSeriesKey(media))}`
|
||||
}
|
||||
const target = media?.id || id
|
||||
return target ? `/media/${target}` : '/'
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
navigate(backTarget(), { replace: true })
|
||||
}
|
||||
|
||||
// ESC = back.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -169,117 +169,53 @@ export function PlayerPage() {
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [location.state, media, id])
|
||||
}, [goBack])
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||
setMode(next)
|
||||
params.set('mode', next)
|
||||
setParams(params, { replace: true })
|
||||
}, [mode, params, setParams])
|
||||
|
||||
const handleVideoError = useCallback(() => {
|
||||
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
|
||||
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
|
||||
if (mode === 'direct') {
|
||||
if (directOnly) {
|
||||
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
|
||||
toast.error('直接播放失败(客户端直连解码模式)')
|
||||
} else if (hlsUnavailable) {
|
||||
setPlayerError('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。')
|
||||
toast.error('直接播放失败,HLS 转码不可用')
|
||||
} else {
|
||||
toast.error('直接播放失败,切换到 HLS 转码')
|
||||
setMode('hls')
|
||||
params.set('mode', 'hls')
|
||||
setParams(params, { replace: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
|
||||
toast.error('视频播放失败,请检查文件是否存在')
|
||||
}, [directOnly, hlsUnavailable, mode, params, setParams])
|
||||
|
||||
return (
|
||||
<div className="relative -m-6 flex min-h-screen flex-col overflow-hidden bg-black md:-m-8">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between p-4 sm:p-6">
|
||||
<button
|
||||
onClick={goBack}
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
|
||||
>
|
||||
<ArrowLeft size={16} /> 返回
|
||||
</button>
|
||||
|
||||
{directOnly ? (
|
||||
<span
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
|
||||
title="宿主机不转码,由客户端本地解码直连"
|
||||
>
|
||||
<Sparkles size={14} /> 客户端直连解码
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||
setMode(next)
|
||||
params.set('mode', next)
|
||||
setParams(params, { replace: true })
|
||||
}}
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
|
||||
title="切换播放模式"
|
||||
>
|
||||
{mode === 'hls' ? (
|
||||
<>
|
||||
<RefreshCw size={14} /> HLS 转码中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={14} /> 直接播放
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
{media ? (
|
||||
<video
|
||||
ref={ref}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
className="max-h-screen w-full max-w-[1600px] bg-black"
|
||||
onError={() => {
|
||||
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
|
||||
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
|
||||
if (mode === 'direct') {
|
||||
if (directOnly) {
|
||||
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
|
||||
toast.error('直接播放失败(客户端直连解码模式)')
|
||||
} else if (hlsUnavailable) {
|
||||
setPlayerError('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。')
|
||||
toast.error('直接播放失败,HLS 转码不可用')
|
||||
} else {
|
||||
toast.error('直接播放失败,切换到 HLS 转码')
|
||||
setMode('hls')
|
||||
params.set('mode', 'hls')
|
||||
setParams(params, { replace: true })
|
||||
}
|
||||
} else {
|
||||
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
|
||||
toast.error('视频播放失败,请检查文件是否存在')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{subs.map((t, i) => (
|
||||
<track
|
||||
key={t.path}
|
||||
kind="subtitles"
|
||||
src={subtitlesAPI.url(media.id, t.path)}
|
||||
srcLang={t.lang}
|
||||
label={t.label || t.lang}
|
||||
default={i === 0}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
) : (
|
||||
<p className="text-sand-500">加载中…</p>
|
||||
)}
|
||||
{playerError ? (
|
||||
<div className="absolute bottom-20 left-1/2 w-[min(92vw,720px)] -translate-x-1/2 rounded-2xl border border-white/15 bg-black/75 px-5 py-4 text-sm text-white shadow-2xl backdrop-blur">
|
||||
{playerError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<PlayerTopBar
|
||||
directOnly={directOnly}
|
||||
mode={mode}
|
||||
onBack={goBack}
|
||||
onToggleMode={toggleMode}
|
||||
/>
|
||||
<PlayerVideoStage
|
||||
media={media}
|
||||
playerError={playerError}
|
||||
subs={subs}
|
||||
videoRef={ref}
|
||||
onVideoError={handleVideoError}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const directContainers = ['mp4', 'webm', 'm4v']
|
||||
const directVideoCodecs = ['h264', 'avc', 'avc1']
|
||||
const directAudioCodecs = ['aac', 'mp3', 'opus']
|
||||
|
||||
function pickMode(m: Media): Mode {
|
||||
return needsTranscodeForBrowser(m) ? 'hls' : 'direct'
|
||||
}
|
||||
|
||||
function needsTranscodeForBrowser(m: Media): boolean {
|
||||
const c = (m.container ?? '').toLowerCase()
|
||||
const v = (m.video_codec ?? '').toLowerCase()
|
||||
const a = (m.audio_codec ?? '').toLowerCase()
|
||||
const containerOK = directContainers.some((x) => c.includes(x))
|
||||
const videoOK = !v || directVideoCodecs.some((x) => v.includes(x))
|
||||
const audioOK = !a || directAudioCodecs.some((x) => a.includes(x))
|
||||
return !(containerOK && videoOK && audioOK)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ArrowLeft, RefreshCw, Sparkles } from 'lucide-react'
|
||||
|
||||
import type { PlayerMode } from './playerPageModel'
|
||||
|
||||
type PlayerTopBarProps = {
|
||||
directOnly: boolean
|
||||
mode: PlayerMode
|
||||
onBack: () => void
|
||||
onToggleMode: () => void
|
||||
}
|
||||
|
||||
export function PlayerTopBar({ directOnly, mode, onBack, onToggleMode }: PlayerTopBarProps) {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between p-4 sm:p-6">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
|
||||
>
|
||||
<ArrowLeft size={16} /> 返回
|
||||
</button>
|
||||
|
||||
{directOnly ? (
|
||||
<span
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
|
||||
title="宿主机不转码,由客户端本地解码直连"
|
||||
>
|
||||
<Sparkles size={14} /> 客户端直连解码
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={onToggleMode}
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
|
||||
title="切换播放模式"
|
||||
>
|
||||
{mode === 'hls' ? (
|
||||
<>
|
||||
<RefreshCw size={14} /> HLS 转码中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={14} /> 直接播放
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { RefObject } from 'react'
|
||||
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import type { Media } from '../types'
|
||||
|
||||
type PlayerVideoStageProps = {
|
||||
media: Media | null
|
||||
playerError: string
|
||||
subs: SubtitleTrack[]
|
||||
videoRef: RefObject<HTMLVideoElement>
|
||||
onVideoError: () => void
|
||||
}
|
||||
|
||||
export function PlayerVideoStage({
|
||||
media,
|
||||
playerError,
|
||||
subs,
|
||||
videoRef,
|
||||
onVideoError,
|
||||
}: PlayerVideoStageProps) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
{media ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
className="max-h-screen w-full max-w-[1600px] bg-black"
|
||||
onError={onVideoError}
|
||||
>
|
||||
{subs.map((track, index) => (
|
||||
<track
|
||||
key={track.path}
|
||||
kind="subtitles"
|
||||
src={subtitlesAPI.url(media.id, track.path)}
|
||||
srcLang={track.lang}
|
||||
label={track.label || track.lang}
|
||||
default={index === 0}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
) : (
|
||||
<p className="text-sand-500">加载中…</p>
|
||||
)}
|
||||
{playerError ? (
|
||||
<div className="absolute bottom-20 left-1/2 w-[min(92vw,720px)] -translate-x-1/2 rounded-2xl border border-white/15 bg-black/75 px-5 py-4 text-sm text-white shadow-2xl backdrop-blur">
|
||||
{playerError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
@@ -10,12 +10,13 @@ export function PlaylistDetailPage() {
|
||||
const { id = '' } = useParams()
|
||||
const [detail, setDetail] = useState<PlaylistDetail | null>(null)
|
||||
|
||||
const refresh = () =>
|
||||
playbackAPI.getPlaylist(id).then(setDetail).catch(() => setDetail(null))
|
||||
const refresh = useCallback(() => {
|
||||
return playbackAPI.getPlaylist(id).then(setDetail).catch(() => setDetail(null))
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [id])
|
||||
}, [refresh])
|
||||
|
||||
if (!detail) {
|
||||
return <p className="text-sand-500">加载中…</p>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ListPlus, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import type { Playlist } from '../types'
|
||||
|
||||
// Landing page for playlists. Lists every playlist owned by the current
|
||||
|
||||
@@ -28,7 +28,7 @@ export function PosterWallPage() {
|
||||
const rows: Media[] = []
|
||||
let page = 1
|
||||
for (;;) {
|
||||
const data = await libraryAPI.listMedia(library.id, page, POSTER_WALL_PAGE_SIZE)
|
||||
const data = await libraryAPI.listMedia(library.id, page, POSTER_WALL_PAGE_SIZE, { groupVersions: false })
|
||||
rows.push(...(data.items || []))
|
||||
if (rows.length >= data.total || data.items.length === 0) break
|
||||
page += 1
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
|
||||
import type { Library, PlayProfile } from '../types'
|
||||
|
||||
type ProfileCardProps = {
|
||||
profile: PlayProfile
|
||||
libraries: Library[]
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
export function ProfileCard({
|
||||
profile,
|
||||
libraries,
|
||||
active,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: ProfileCardProps) {
|
||||
const libNames = useMemo(() => {
|
||||
if (!profile.allowed_library_ids?.length) return '全部'
|
||||
const idx = new Map(libraries.map((library) => [library.id, library.name]))
|
||||
return profile.allowed_library_ids.map((id) => idx.get(id) ?? id).join(', ')
|
||||
}, [profile, libraries])
|
||||
|
||||
return (
|
||||
<div className="glass-panel flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div
|
||||
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-lg font-bold text-ink-600"
|
||||
style={{
|
||||
background: `hsl(${(profile.name.charCodeAt(0) * 47) % 360}, 60%, 35%)`,
|
||||
}}
|
||||
>
|
||||
{profile.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-ink-600">{profile.name}</span>
|
||||
{profile.is_default && (
|
||||
<span className="rounded-lg bg-primary-400/20 px-2 py-0.5 text-xs text-brand-500">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
{active && (
|
||||
<span className="rounded-lg bg-gray-950 px-2 py-0.5 text-xs text-white">
|
||||
当前使用
|
||||
</span>
|
||||
)}
|
||||
{profile.allow_adult && (
|
||||
<span className="rounded-lg bg-red-400/20 px-2 py-0.5 text-xs text-red-400">
|
||||
成人内容
|
||||
</span>
|
||||
)}
|
||||
{profile.require_pin && (
|
||||
<span className="rounded-lg bg-amber-400/20 px-2 py-0.5 text-xs text-amber-400">
|
||||
🔒 PIN
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-50">
|
||||
{profile.content_rating_limit && <span>分级: {profile.content_rating_limit}</span>}
|
||||
<span>媒体库: {libNames}</span>
|
||||
</div>
|
||||
<div className="text-xs text-sand-500">
|
||||
观看时长 {Math.round(profile.total_watch_time / 3600)} 小时
|
||||
{profile.last_active_at && ` · 最近活跃 ${new Date(profile.last_active_at).toLocaleDateString()}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
>
|
||||
设为当前
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<Pencil size={12} className="inline" /> 编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
>
|
||||
<Trash2 size={12} className="inline" /> 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { FormEvent, useState, type ReactNode } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { playProfilesAPI, type PlayProfileInput } from '../api/play_profiles'
|
||||
import type { Library, PlayProfile } from '../types'
|
||||
|
||||
type ProfileFormModalProps = {
|
||||
editing: PlayProfile | null
|
||||
libraries: Library[]
|
||||
defaultUserID: string
|
||||
onClose: () => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function ProfileFormModal({
|
||||
editing,
|
||||
libraries,
|
||||
defaultUserID,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: ProfileFormModalProps) {
|
||||
const [form, setForm] = useState<PlayProfileInput>(() => ({
|
||||
user_id: editing?.user_id ?? defaultUserID,
|
||||
name: editing?.name ?? '',
|
||||
is_default: editing?.is_default ?? false,
|
||||
content_rating_limit: editing?.content_rating_limit ?? '',
|
||||
allow_adult: editing?.allow_adult ?? false,
|
||||
require_pin: editing?.require_pin ?? false,
|
||||
pin: '',
|
||||
preferred_subtitle_lang: editing?.preferred_subtitle_lang ?? '',
|
||||
preferred_audio_lang: editing?.preferred_audio_lang ?? '',
|
||||
autoplay_next: editing?.autoplay_next ?? true,
|
||||
skip_intro: editing?.skip_intro ?? false,
|
||||
allowed_library_ids: editing?.allowed_library_ids ?? [],
|
||||
}))
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const update = (patch: Partial<PlayProfileInput>) => setForm((current) => ({ ...current, ...patch }))
|
||||
|
||||
const toggleLib = (id: string) => {
|
||||
const next = form.allowed_library_ids.includes(id)
|
||||
? form.allowed_library_ids.filter((item) => item !== id)
|
||||
: [...form.allowed_library_ids, id]
|
||||
update({ allowed_library_ids: next })
|
||||
}
|
||||
|
||||
const onSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) {
|
||||
await playProfilesAPI.update(editing.id, form)
|
||||
} else {
|
||||
await playProfilesAPI.create(form)
|
||||
}
|
||||
toast.success('已保存')
|
||||
await onSaved()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
|
||||
{editing ? '编辑 Profile' : '创建 Profile'}
|
||||
</h2>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<Field label="名称">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
maxLength={50}
|
||||
placeholder="如:儿童模式、影院模式"
|
||||
value={form.name}
|
||||
onChange={(event) => update({ name: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Toggle
|
||||
label="设为默认 Profile"
|
||||
hint="登录后默认使用"
|
||||
checked={form.is_default}
|
||||
onChange={(value) => update({ is_default: value })}
|
||||
/>
|
||||
|
||||
<Field label="内容分级限制">
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.content_rating_limit ?? ''}
|
||||
onChange={(event) => update({ content_rating_limit: event.target.value })}
|
||||
>
|
||||
<option value="">不限制</option>
|
||||
<option value="G">G</option>
|
||||
<option value="PG">PG</option>
|
||||
<option value="PG-13">PG-13</option>
|
||||
<option value="R">R</option>
|
||||
<option value="NC-17">NC-17</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Toggle
|
||||
label="允许成人内容"
|
||||
hint="开启后可访问 NSFW 媒体"
|
||||
checked={form.allow_adult}
|
||||
onChange={(value) => update({ allow_adult: value })}
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
label="切换时需要 PIN"
|
||||
hint="切换到此 Profile 需输入 PIN 码"
|
||||
checked={form.require_pin}
|
||||
onChange={(value) => update({ require_pin: value })}
|
||||
/>
|
||||
{form.require_pin && (
|
||||
<Field label="PIN 码 (4-8 位)">
|
||||
<input
|
||||
type="password"
|
||||
className="input-base"
|
||||
maxLength={8}
|
||||
placeholder={editing ? '留空保持不变' : '设置 PIN'}
|
||||
value={form.pin ?? ''}
|
||||
onChange={(event) => update({ pin: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="首选字幕">
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.preferred_subtitle_lang ?? ''}
|
||||
onChange={(event) => update({ preferred_subtitle_lang: event.target.value })}
|
||||
>
|
||||
<option value="">跟随系统</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en">English</option>
|
||||
<option value="ja">日语</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="首选音轨">
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.preferred_audio_lang ?? ''}
|
||||
onChange={(event) => update({ preferred_audio_lang: event.target.value })}
|
||||
>
|
||||
<option value="">跟随系统</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="ja">日语</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
label="自动播放下一集"
|
||||
checked={form.autoplay_next}
|
||||
onChange={(value) => update({ autoplay_next: value })}
|
||||
/>
|
||||
<Toggle
|
||||
label="自动跳过片头"
|
||||
checked={form.skip_intro}
|
||||
onChange={(value) => update({ skip_intro: value })}
|
||||
/>
|
||||
|
||||
<Field label="允许访问的媒体库 (空 = 全部)">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{libraries.map((library) => (
|
||||
<button
|
||||
key={library.id}
|
||||
type="button"
|
||||
onClick={() => toggleLib(library.id)}
|
||||
className={
|
||||
'rounded-full border px-3 py-1 text-xs ' +
|
||||
(form.allowed_library_ids.includes(library.id)
|
||||
? 'border-primary-400/60 bg-primary-400/10 text-brand-500'
|
||||
: 'border-gray-200 text-ink-50 hover:text-white')
|
||||
}
|
||||
>
|
||||
{library.name}
|
||||
</button>
|
||||
))}
|
||||
{libraries.length === 0 && (
|
||||
<span className="text-xs text-sand-500">暂无媒体库</span>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving && <Loader2 size={16} className="animate-spin" />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-ink-100">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
hint?: string
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm text-ink-600">{label}</div>
|
||||
{hint && <div className="text-xs text-ink-50">{hint}</div>}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2, Pencil, Plus, Trash2, UserCog } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loader2, Plus, UserCog } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { playProfilesAPI, type PlayProfileInput } from '../api/play_profiles'
|
||||
import { playProfilesAPI } from '../api/play_profiles'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePlayProfileStore } from '../stores/playProfile'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { requestPassword } from '../components/PasswordDialog'
|
||||
import { requestPIN } from '../components/PinDialog'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import { requestPassword } from '../components/requestPassword'
|
||||
import { requestPIN } from '../components/requestPIN'
|
||||
import type { Library, PlayProfile } from '../types'
|
||||
import { ProfileCard } from './ProfileCard'
|
||||
import { ProfileFormModal } from './ProfileFormModal'
|
||||
|
||||
const MAX_PLAY_PROFILES = 3
|
||||
|
||||
@@ -187,334 +189,3 @@ export function ProfileManagementPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileCard({
|
||||
profile,
|
||||
libraries,
|
||||
active,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
profile: PlayProfile
|
||||
libraries: Library[]
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const libNames = useMemo(() => {
|
||||
if (!profile.allowed_library_ids?.length) return '全部'
|
||||
const idx = new Map(libraries.map((l) => [l.id, l.name]))
|
||||
return profile.allowed_library_ids.map((id) => idx.get(id) ?? id).join(', ')
|
||||
}, [profile, libraries])
|
||||
|
||||
return (
|
||||
<div className="glass-panel flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div
|
||||
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-lg font-bold text-ink-600"
|
||||
style={{
|
||||
background: `hsl(${(profile.name.charCodeAt(0) * 47) % 360}, 60%, 35%)`,
|
||||
}}
|
||||
>
|
||||
{profile.name[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-ink-600">{profile.name}</span>
|
||||
{profile.is_default && (
|
||||
<span className="rounded-lg bg-primary-400/20 px-2 py-0.5 text-xs text-brand-500">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
{active && (
|
||||
<span className="rounded-lg bg-gray-950 px-2 py-0.5 text-xs text-white">
|
||||
当前使用
|
||||
</span>
|
||||
)}
|
||||
{profile.allow_adult && (
|
||||
<span className="rounded-lg bg-red-400/20 px-2 py-0.5 text-xs text-red-400">
|
||||
成人内容
|
||||
</span>
|
||||
)}
|
||||
{profile.require_pin && (
|
||||
<span className="rounded-lg bg-amber-400/20 px-2 py-0.5 text-xs text-amber-400">
|
||||
🔒 PIN
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-50">
|
||||
{profile.content_rating_limit && <span>分级: {profile.content_rating_limit}</span>}
|
||||
<span>媒体库: {libNames}</span>
|
||||
</div>
|
||||
<div className="text-xs text-sand-500">
|
||||
观看时长 {Math.round(profile.total_watch_time / 3600)} 小时
|
||||
{profile.last_active_at && ` · 最近活跃 ${new Date(profile.last_active_at).toLocaleDateString()}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
>
|
||||
设为当前
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<Pencil size={12} className="inline" /> 编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
>
|
||||
<Trash2 size={12} className="inline" /> 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileFormModal({
|
||||
editing,
|
||||
libraries,
|
||||
defaultUserID,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
editing: PlayProfile | null
|
||||
libraries: Library[]
|
||||
defaultUserID: string
|
||||
onClose: () => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<PlayProfileInput>(() => ({
|
||||
user_id: editing?.user_id ?? defaultUserID,
|
||||
name: editing?.name ?? '',
|
||||
is_default: editing?.is_default ?? false,
|
||||
content_rating_limit: editing?.content_rating_limit ?? '',
|
||||
allow_adult: editing?.allow_adult ?? false,
|
||||
require_pin: editing?.require_pin ?? false,
|
||||
pin: '',
|
||||
preferred_subtitle_lang: editing?.preferred_subtitle_lang ?? '',
|
||||
preferred_audio_lang: editing?.preferred_audio_lang ?? '',
|
||||
autoplay_next: editing?.autoplay_next ?? true,
|
||||
skip_intro: editing?.skip_intro ?? false,
|
||||
allowed_library_ids: editing?.allowed_library_ids ?? [],
|
||||
}))
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const update = (patch: Partial<PlayProfileInput>) => setForm((f) => ({ ...f, ...patch }))
|
||||
|
||||
const toggleLib = (id: string) => {
|
||||
const next = form.allowed_library_ids.includes(id)
|
||||
? form.allowed_library_ids.filter((x) => x !== id)
|
||||
: [...form.allowed_library_ids, id]
|
||||
update({ allowed_library_ids: next })
|
||||
}
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) {
|
||||
await playProfilesAPI.update(editing.id, form)
|
||||
} else {
|
||||
await playProfilesAPI.create(form)
|
||||
}
|
||||
toast.success('已保存')
|
||||
await onSaved()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
|
||||
{editing ? '编辑 Profile' : '创建 Profile'}
|
||||
</h2>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<Field label="名称">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
maxLength={50}
|
||||
placeholder="如:儿童模式、影院模式"
|
||||
value={form.name}
|
||||
onChange={(e) => update({ name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Toggle
|
||||
label="设为默认 Profile"
|
||||
hint="登录后默认使用"
|
||||
checked={form.is_default}
|
||||
onChange={(v) => update({ is_default: v })}
|
||||
/>
|
||||
|
||||
<Field label="内容分级限制">
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.content_rating_limit ?? ''}
|
||||
onChange={(e) => update({ content_rating_limit: e.target.value })}
|
||||
>
|
||||
<option value="">不限制</option>
|
||||
<option value="G">G</option>
|
||||
<option value="PG">PG</option>
|
||||
<option value="PG-13">PG-13</option>
|
||||
<option value="R">R</option>
|
||||
<option value="NC-17">NC-17</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Toggle
|
||||
label="允许成人内容"
|
||||
hint="开启后可访问 NSFW 媒体"
|
||||
checked={form.allow_adult}
|
||||
onChange={(v) => update({ allow_adult: v })}
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
label="切换时需要 PIN"
|
||||
hint="切换到此 Profile 需输入 PIN 码"
|
||||
checked={form.require_pin}
|
||||
onChange={(v) => update({ require_pin: v })}
|
||||
/>
|
||||
{form.require_pin && (
|
||||
<Field label="PIN 码 (4-8 位)">
|
||||
<input
|
||||
type="password"
|
||||
className="input-base"
|
||||
maxLength={8}
|
||||
placeholder={editing ? '留空保持不变' : '设置 PIN'}
|
||||
value={form.pin ?? ''}
|
||||
onChange={(e) => update({ pin: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="首选字幕">
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.preferred_subtitle_lang ?? ''}
|
||||
onChange={(e) => update({ preferred_subtitle_lang: e.target.value })}
|
||||
>
|
||||
<option value="">跟随系统</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en">English</option>
|
||||
<option value="ja">日语</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="首选音轨">
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.preferred_audio_lang ?? ''}
|
||||
onChange={(e) => update({ preferred_audio_lang: e.target.value })}
|
||||
>
|
||||
<option value="">跟随系统</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="ja">日语</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
label="自动播放下一集"
|
||||
checked={form.autoplay_next}
|
||||
onChange={(v) => update({ autoplay_next: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label="自动跳过片头"
|
||||
checked={form.skip_intro}
|
||||
onChange={(v) => update({ skip_intro: v })}
|
||||
/>
|
||||
|
||||
<Field label="允许访问的媒体库 (空 = 全部)">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{libraries.map((l) => (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
onClick={() => toggleLib(l.id)}
|
||||
className={
|
||||
'rounded-full border px-3 py-1 text-xs ' +
|
||||
(form.allowed_library_ids.includes(l.id)
|
||||
? 'border-primary-400/60 bg-primary-400/10 text-brand-500'
|
||||
: 'border-gray-200 text-ink-50 hover:text-white')
|
||||
}
|
||||
>
|
||||
{l.name}
|
||||
</button>
|
||||
))}
|
||||
{libraries.length === 0 && (
|
||||
<span className="text-xs text-sand-500">暂无媒体库</span>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving && <Loader2 size={16} className="animate-spin" />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-ink-100">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
hint?: string
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm text-ink-600">{label}</div>
|
||||
{hint && <div className="text-xs text-ink-50">{hint}</div>}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { EyeOff, KeyRound, Loader2, Save } from 'lucide-react'
|
||||
|
||||
import { authAPI } from '../api/auth'
|
||||
import { profileAPI } from '../api/profile'
|
||||
import { requestPassword } from '../components/PasswordDialog'
|
||||
import { requestPassword } from '../components/requestPassword'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
export function ProfilePage() {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loader2, QrCode } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { cloudAPI, type QRSession, type StorageType } from '../api/storage_config'
|
||||
|
||||
interface QRLoginPanelProps {
|
||||
type: StorageType
|
||||
onCookie: (cookie: string) => void
|
||||
}
|
||||
|
||||
export function QRLoginPanel({ type, onCookie }: QRLoginPanelProps) {
|
||||
const [sess, setSess] = useState<QRSession | null>(null)
|
||||
const [state, setState] = useState<string>('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sess) return
|
||||
let alive = true
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const st = await cloudAPI.qrPoll(type, sess)
|
||||
if (!alive) return
|
||||
setState(st.state)
|
||||
if (st.state === 'confirmed' && st.cookie) {
|
||||
onCookie(st.cookie)
|
||||
toast.success('扫码登录成功,Cookie 已填入,请点击保存')
|
||||
setSess(null)
|
||||
} else if (st.state === 'expired') {
|
||||
toast.error('二维码已过期,请重新获取')
|
||||
setSess(null)
|
||||
}
|
||||
} catch {
|
||||
/* keep polling */
|
||||
}
|
||||
}, 2000)
|
||||
return () => {
|
||||
alive = false
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [onCookie, sess, type])
|
||||
|
||||
const start = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const nextSession = await cloudAPI.qrStart(type)
|
||||
setSess(nextSession)
|
||||
setState('waiting')
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '获取二维码失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={start}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : <QrCode size={14} />}
|
||||
使用 115 App 扫码登录
|
||||
</button>
|
||||
{sess && (
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<img src={sess.qr_image_url} alt="115 QR" className="h-40 w-40 rounded bg-white p-1" />
|
||||
<span className="text-sm text-ink-50">
|
||||
{state === 'scanned' ? '已扫描,请在手机上确认登录…' : '请使用 115 手机 App 扫描二维码…'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import toast from 'react-hot-toast'
|
||||
import { Home, RotateCcw, Square, CheckSquare, Trash2 } from 'lucide-react'
|
||||
|
||||
import { recycleAPI } from '../api/recycle'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import type { Media } from '../types'
|
||||
|
||||
export function RecycleBinPage() {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle2, Info, Rss } from 'lucide-react'
|
||||
|
||||
import type { ExternalMediaResult } from '../api/ai'
|
||||
import { imageURL } from '../api/client'
|
||||
|
||||
export function ExternalResults({
|
||||
items,
|
||||
busyKey,
|
||||
onSubscribe,
|
||||
}: {
|
||||
items: ExternalMediaResult[]
|
||||
busyKey: string
|
||||
onSubscribe: (item: ExternalMediaResult) => Promise<void>
|
||||
}) {
|
||||
const [detail, setDetail] = useState<ExternalMediaResult | null>(null)
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-semibold text-ink-600">外部数据源</h2>
|
||||
<p className="text-xs text-ink-50">
|
||||
来自 TMDb / 豆瓣 / Bangumi。电影入队最佳资源;剧集/动漫优先整季或全集包,否则按集批量入队。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => {
|
||||
const keyword = item.subscribe_keyword || item.title
|
||||
const key = `${item.source}:${keyword}`
|
||||
return (
|
||||
<article
|
||||
key={key}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setDetail(item)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') setDetail(item)
|
||||
}}
|
||||
className="glass-panel flex cursor-pointer gap-3 !p-3 transition hover:-translate-y-0.5 hover:shadow-lg"
|
||||
>
|
||||
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-xl bg-gray-100">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={imageURL(item.poster_url)}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full bg-primary-400/10 px-2 py-0.5 text-[10px] uppercase text-brand-500">
|
||||
{item.source}
|
||||
</span>
|
||||
{item.media_type && <span className="text-xs text-sand-500">{item.media_type}</span>}
|
||||
{item.year ? <span className="text-xs text-sand-500">{item.year}</span> : null}
|
||||
{item.rating ? <span className="text-xs text-amber-500">★ {item.rating.toFixed(1)}</span> : null}
|
||||
</div>
|
||||
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-ink-50">
|
||||
{item.overview || `订阅关键词:${keyword}`}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-[10px]">
|
||||
<span className={'rounded-full px-2 py-0.5 font-semibold ' + (item.in_library ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-600')}>
|
||||
{item.in_library ? '本地已入库' : '本地未入库'}
|
||||
</span>
|
||||
{isSeriesItem(item) ? (
|
||||
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-ink-100">
|
||||
已有 {item.downloaded_episodes || 0}/{item.total_episodes || '未知'} 集
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onSubscribe(item)
|
||||
}}
|
||||
disabled={busyKey === key}
|
||||
className="mt-3 rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10 disabled:opacity-50"
|
||||
>
|
||||
<Rss size={12} className="mr-1 inline" />
|
||||
{busyKey === key ? '订阅中…' : '订阅并搜索 PT'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{detail && (
|
||||
<ExternalDetailModal
|
||||
item={detail}
|
||||
busy={busyKey === `${detail.source}:${detail.subscribe_keyword || detail.title}`}
|
||||
onClose={() => setDetail(null)}
|
||||
onSubscribe={onSubscribe}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ExternalDetailModal({
|
||||
item,
|
||||
busy,
|
||||
onClose,
|
||||
onSubscribe,
|
||||
}: {
|
||||
item: ExternalMediaResult
|
||||
busy: boolean
|
||||
onClose: () => void
|
||||
onSubscribe: (item: ExternalMediaResult) => Promise<void>
|
||||
}) {
|
||||
const missing = item.missing_episodes ?? []
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="max-h-[88vh] w-full max-w-3xl overflow-hidden rounded-3xl bg-white shadow-2xl" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="grid gap-0 md:grid-cols-[220px,1fr]">
|
||||
<div className="min-h-72 bg-gray-100">
|
||||
{item.poster_url ? (
|
||||
<img src={imageURL(item.poster_url)} alt={item.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full min-h-72 items-center justify-center text-brand-500">
|
||||
<Info size={42} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4 p-5">
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap gap-2 text-xs">
|
||||
<span className="rounded-full bg-primary-400/10 px-2 py-0.5 font-semibold uppercase text-brand-500">{item.source}</span>
|
||||
{item.media_type ? <span className="rounded-full bg-gray-100 px-2 py-0.5 text-ink-100">{item.media_type}</span> : null}
|
||||
{item.year ? <span className="rounded-full bg-gray-100 px-2 py-0.5 text-ink-100">{item.year}</span> : null}
|
||||
{item.rating ? <span className="rounded-full bg-amber-50 px-2 py-0.5 text-amber-600">★ {item.rating.toFixed(1)}</span> : null}
|
||||
</div>
|
||||
<h3 className="font-display text-2xl font-bold text-ink-600">{item.title}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-ink-50">{item.overview || '暂无简介。'}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<StatusBox label="入库状态" value={item.in_library ? '已入库' : '未入库'} ok={item.in_library} />
|
||||
<StatusBox label="本地条目" value={`${item.local_media_count || 0} 个`} />
|
||||
<StatusBox label="剧集进度" value={isSeriesItem(item) ? `${item.downloaded_episodes || 0}/${item.total_episodes || '未知'} 集` : '单部影片'} />
|
||||
</div>
|
||||
|
||||
{isSeriesItem(item) && (
|
||||
<div className="rounded-2xl border border-gray-100 bg-gray-50 p-3 text-sm">
|
||||
<div className="mb-2 font-semibold text-ink-600">缺失情况</div>
|
||||
{missing.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{missing.slice(0, 80).map((episode) => (
|
||||
<span key={episode} className="rounded-full bg-white px-2 py-0.5 text-xs text-ink-100 shadow-sm">第 {episode} 集</span>
|
||||
))}
|
||||
{missing.length > 80 ? <span className="text-xs text-sand-500">还有 {missing.length - 80} 集…</span> : null}
|
||||
</div>
|
||||
) : item.in_library && item.total_episodes ? (
|
||||
<p className="flex items-center gap-1.5 text-emerald-600"><CheckCircle2 size={15} /> 已完整入库</p>
|
||||
) : (
|
||||
<p className="text-sand-500">总集数未知,订阅时会跳过本地已有单集,优先补新集。</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={onClose} className="rounded-xl border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50">关闭</button>
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
await onSubscribe(item)
|
||||
onClose()
|
||||
}}
|
||||
className="neon-button"
|
||||
>
|
||||
<Rss size={14} /> {busy ? '订阅中…' : item.in_library ? '补全缺失集' : '订阅全集'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBox({ label, value, ok }: { label: string; value: string; ok?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-100 bg-gray-50 p-3">
|
||||
<div className="text-xs text-sand-500">{label}</div>
|
||||
<div className={'mt-1 font-semibold ' + (ok ? 'text-emerald-600' : 'text-ink-600')}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isSeriesItem(item: ExternalMediaResult) {
|
||||
return ['tv', 'anime', 'variety'].includes((item.media_type || '').toLowerCase())
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
type SearchHeaderProps = {
|
||||
aiOn: boolean
|
||||
aiAvailable: boolean
|
||||
onToggleAI: () => void
|
||||
}
|
||||
|
||||
export function SearchHeader({ aiOn, aiAvailable, onToggleAI }: SearchHeaderProps) {
|
||||
return (
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">搜索</h1>
|
||||
<button
|
||||
className={
|
||||
'neon-button !px-3 !py-1 !text-xs ' +
|
||||
(aiOn ? '!border-accent-400 !bg-accent-400/20 !text-accent-400' : '')
|
||||
}
|
||||
onClick={onToggleAI}
|
||||
title={aiAvailable ? '启用 AI 智能搜索' : '使用本地规则 + 外部数据源搜索'}
|
||||
>
|
||||
<Sparkles size={12} /> {aiOn ? '智能搜索已开启' : '智能搜索'}
|
||||
</button>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ChangeEvent, FormEvent } from 'react'
|
||||
|
||||
type SearchInputBarProps = {
|
||||
aiOn: boolean
|
||||
query: string
|
||||
onQueryChange: (query: string) => void
|
||||
onAISubmit: (event: FormEvent) => void
|
||||
}
|
||||
|
||||
export function SearchInputBar({ aiOn, query, onQueryChange, onAISubmit }: SearchInputBarProps) {
|
||||
if (aiOn) {
|
||||
return (
|
||||
<form onSubmit={onAISubmit} className="flex flex-wrap gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
className="input-base"
|
||||
placeholder='例如:"2010 年后的科幻电影" / "最近的动漫"'
|
||||
value={query}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onQueryChange(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="neon-button">
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
className="input-base"
|
||||
placeholder="按标题搜索…"
|
||||
value={query}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onQueryChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { seriesCardLink } from '../utils/groupSeries'
|
||||
|
||||
type SearchLocalResultsProps = {
|
||||
localCards: SeriesCard[]
|
||||
itemCount: number
|
||||
searchTotal: number
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export function SearchLocalResults({
|
||||
localCards,
|
||||
itemCount,
|
||||
searchTotal,
|
||||
loading,
|
||||
}: SearchLocalResultsProps) {
|
||||
if (localCards.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm font-semibold text-ink-100">
|
||||
本地媒体库 · {localCards.length} 个合集 / {itemCount} 个条目
|
||||
{loading && searchTotal > itemCount ? ` · 正在加载全部结果 ${itemCount}/${searchTotal}` : ''}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{localCards.map((card) => (
|
||||
<MediaCard
|
||||
key={card.key}
|
||||
media={card.rep}
|
||||
count={card.count}
|
||||
linkTo={seriesCardLink(card)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+36
-421
@@ -1,441 +1,56 @@
|
||||
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'
|
||||
|
||||
import { aiAPI, type ExternalMediaResult, type SearchIntent } from '../api/ai'
|
||||
import { imageURL } from '../api/client'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { buildSiteSearchFeedURL, subscriptionsAPI } from '../api/subscriptions'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { Media } from '../types'
|
||||
import { groupSeries, seriesCardLink } from '../utils/groupSeries'
|
||||
|
||||
const LOCAL_SEARCH_PAGE_SIZE = 2000
|
||||
import { ExternalResults } from './SearchExternalResults'
|
||||
import { SearchHeader } from './SearchHeader'
|
||||
import { SearchInputBar } from './SearchInputBar'
|
||||
import { SearchLocalResults } from './SearchLocalResults'
|
||||
import { SearchStatusPanels } from './SearchStatusPanels'
|
||||
import { useSearchPage } from './useSearchPage'
|
||||
|
||||
export function SearchPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const urlQuery = searchParams.get('q') ?? ''
|
||||
const [q, setQ] = useState('')
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [aiOn, setAiOn] = useState(false)
|
||||
const [aiAvailable, setAiAvailable] = useState(false)
|
||||
const [intent, setIntent] = useState<SearchIntent | null>(null)
|
||||
const [hasSearched, setHasSearched] = useState(false)
|
||||
const [externalItems, setExternalItems] = useState<ExternalMediaResult[]>([])
|
||||
const [subscribing, setSubscribing] = useState('')
|
||||
const [searchTotal, setSearchTotal] = useState(0)
|
||||
const searchSeq = useRef(0)
|
||||
const localCards = useMemo(() => groupSeries(items), [items])
|
||||
|
||||
useEffect(() => {
|
||||
aiAPI
|
||||
.status()
|
||||
.then((s) => setAiAvailable(s.enabled))
|
||||
.catch(() => setAiAvailable(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setQ(urlQuery)
|
||||
}, [urlQuery])
|
||||
|
||||
const doQuickSearch = useCallback((query: string) => {
|
||||
const seq = ++searchSeq.current
|
||||
if (!query.trim()) {
|
||||
setItems([])
|
||||
setSearchTotal(0)
|
||||
setHasSearched(false)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setHasSearched(true)
|
||||
setError('')
|
||||
const loadAll = async () => {
|
||||
let page = 1
|
||||
let collected: Media[] = []
|
||||
for (;;) {
|
||||
const d = await mediaAPI.searchPage(query, page, LOCAL_SEARCH_PAGE_SIZE)
|
||||
if (seq !== searchSeq.current) return
|
||||
const pageItems = d.items ?? []
|
||||
collected = collected.concat(pageItems)
|
||||
const total = d.total ?? collected.length
|
||||
setItems(collected)
|
||||
setSearchTotal(total)
|
||||
if (collected.length >= total || pageItems.length < LOCAL_SEARCH_PAGE_SIZE) break
|
||||
page += 1
|
||||
}
|
||||
setExternalItems([])
|
||||
setIntent(null)
|
||||
}
|
||||
loadAll()
|
||||
.catch((err) => {
|
||||
if (seq !== searchSeq.current) return
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'搜索失败'
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
})
|
||||
.finally(() => {
|
||||
if (seq === searchSeq.current) setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Fast LIKE search-as-you-type when AI mode is OFF.
|
||||
useEffect(() => {
|
||||
if (aiOn) return
|
||||
setLoading(true)
|
||||
const t = setTimeout(() => doQuickSearch(q), 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [q, aiOn, doQuickSearch])
|
||||
|
||||
const onAISubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!q.trim()) return
|
||||
++searchSeq.current
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setHasSearched(true)
|
||||
try {
|
||||
const data = await aiAPI.smartSearch(q)
|
||||
setItems(data.items ?? [])
|
||||
setSearchTotal((data.items ?? []).length)
|
||||
setExternalItems(data.external_items ?? [])
|
||||
setIntent(data.intent)
|
||||
} catch (err) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'AI 搜索失败'
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showEmpty = !loading && !error && hasSearched && localCards.length === 0
|
||||
const showIdle = !loading && !error && !hasSearched
|
||||
const search = useSearchPage()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">搜索</h1>
|
||||
<button
|
||||
className={
|
||||
'neon-button !px-3 !py-1 !text-xs ' +
|
||||
(aiOn ? '!border-accent-400 !bg-accent-400/20 !text-accent-400' : '')
|
||||
}
|
||||
onClick={() => setAiOn((on) => !on)}
|
||||
title={aiAvailable ? '启用 AI 智能搜索' : '使用本地规则 + 外部数据源搜索'}
|
||||
>
|
||||
<Sparkles size={12} /> {aiOn ? '智能搜索已开启' : '智能搜索'}
|
||||
</button>
|
||||
</header>
|
||||
<SearchHeader
|
||||
aiOn={search.aiOn}
|
||||
aiAvailable={search.aiAvailable}
|
||||
onToggleAI={() => search.setAiOn((on) => !on)}
|
||||
/>
|
||||
|
||||
{aiOn ? (
|
||||
<form onSubmit={onAISubmit} className="flex flex-wrap gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
className="input-base"
|
||||
placeholder='例如:"2010 年后的科幻电影" / "最近的动漫"'
|
||||
value={q}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setQ(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="neon-button">
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<input
|
||||
autoFocus
|
||||
className="input-base"
|
||||
placeholder="按标题搜索…"
|
||||
value={q}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setQ(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<SearchInputBar
|
||||
aiOn={search.aiOn}
|
||||
query={search.q}
|
||||
onQueryChange={search.setQ}
|
||||
onAISubmit={search.onAISubmit}
|
||||
/>
|
||||
|
||||
{intent && (
|
||||
{search.intent && (
|
||||
<div className="glass-panel !p-3 text-xs text-ink-100">
|
||||
AI 解析:
|
||||
<span className="ml-2 font-mono text-brand-500">{JSON.stringify(intent)}</span>
|
||||
<span className="ml-2 font-mono text-brand-500">{JSON.stringify(search.intent)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 py-8 text-ink-50">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-primary-400 border-t-transparent" />
|
||||
搜索中…
|
||||
</div>
|
||||
)}
|
||||
<SearchStatusPanels
|
||||
loading={search.loading}
|
||||
error={search.error}
|
||||
showIdle={search.showIdle}
|
||||
showEmpty={search.showEmpty}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="glass-panel !border-red-400/30 p-4 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
<SearchLocalResults
|
||||
localCards={search.localCards}
|
||||
itemCount={search.itemCount}
|
||||
searchTotal={search.searchTotal}
|
||||
loading={search.loading}
|
||||
/>
|
||||
|
||||
{showIdle && (
|
||||
<div className="glass-panel flex flex-col items-center gap-2 p-10 text-center">
|
||||
<p className="text-lg text-ink-100">输入关键词开始搜索</p>
|
||||
<p className="text-sm text-sand-500">
|
||||
支持电影、电视剧、动漫等媒体内容的快速搜索
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEmpty && (
|
||||
<div className="glass-panel flex flex-col items-center gap-2 p-10 text-center">
|
||||
<p className="text-lg text-ink-100">未找到匹配的媒体</p>
|
||||
<p className="text-sm text-sand-500">尝试其他关键词,或者添加媒体库后执行扫描</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localCards.length > 0 && (
|
||||
<>
|
||||
<div className="text-sm font-semibold text-ink-100">
|
||||
本地媒体库 · {localCards.length} 个合集 / {items.length} 个条目
|
||||
{loading && searchTotal > items.length ? ` · 正在加载全部结果 ${items.length}/${searchTotal}` : ''}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{localCards.map((card) => (
|
||||
<MediaCard
|
||||
key={card.key}
|
||||
media={card.rep}
|
||||
count={card.count}
|
||||
linkTo={seriesCardLink(card)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{externalItems.length > 0 && (
|
||||
{search.externalItems.length > 0 && (
|
||||
<ExternalResults
|
||||
items={externalItems}
|
||||
busyKey={subscribing}
|
||||
onSubscribe={async (item) => {
|
||||
const keyword = item.subscribe_keyword || item.title
|
||||
const key = `${item.source}:${keyword}`
|
||||
setSubscribing(key)
|
||||
try {
|
||||
const feed = buildSiteSearchFeedURL(keyword, item.source, [item.title, item.original_name || ''])
|
||||
const sub = await subscriptionsAPI.create({
|
||||
name: `${item.title} 自动订阅`,
|
||||
feed_url: feed,
|
||||
filter: keyword,
|
||||
media_type: item.media_type,
|
||||
source: item.source,
|
||||
poster_url: item.poster_url,
|
||||
backdrop_url: item.backdrop_url,
|
||||
overview: item.overview,
|
||||
total_episodes: item.total_episodes,
|
||||
enabled: true,
|
||||
})
|
||||
const run = await subscriptionsAPI.runNow(sub.id)
|
||||
toast.success(
|
||||
run.queued > 0
|
||||
? `已订阅并加入 ${run.queued} 个下载`
|
||||
: '已订阅,暂未在 PT 站点找到可下载资源',
|
||||
)
|
||||
} catch (err) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'订阅失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSubscribing('')
|
||||
}
|
||||
}}
|
||||
items={search.externalItems}
|
||||
busyKey={search.subscribing}
|
||||
onSubscribe={search.onSubscribe}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExternalResults({
|
||||
items,
|
||||
busyKey,
|
||||
onSubscribe,
|
||||
}: {
|
||||
items: ExternalMediaResult[]
|
||||
busyKey: string
|
||||
onSubscribe: (item: ExternalMediaResult) => Promise<void>
|
||||
}) {
|
||||
const [detail, setDetail] = useState<ExternalMediaResult | null>(null)
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-semibold text-ink-600">外部数据源</h2>
|
||||
<p className="text-xs text-ink-50">
|
||||
来自 TMDb / 豆瓣 / Bangumi。电影入队最佳资源;剧集/动漫优先整季或全集包,否则按集批量入队。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => {
|
||||
const keyword = item.subscribe_keyword || item.title
|
||||
const key = `${item.source}:${keyword}`
|
||||
return (
|
||||
<article
|
||||
key={key}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setDetail(item)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') setDetail(item)
|
||||
}}
|
||||
className="glass-panel flex cursor-pointer gap-3 !p-3 transition hover:-translate-y-0.5 hover:shadow-lg"
|
||||
>
|
||||
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-xl bg-gray-100">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={imageURL(item.poster_url)}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full bg-primary-400/10 px-2 py-0.5 text-[10px] uppercase text-brand-500">
|
||||
{item.source}
|
||||
</span>
|
||||
{item.media_type && <span className="text-xs text-sand-500">{item.media_type}</span>}
|
||||
{item.year ? <span className="text-xs text-sand-500">{item.year}</span> : null}
|
||||
{item.rating ? <span className="text-xs text-amber-500">★ {item.rating.toFixed(1)}</span> : null}
|
||||
</div>
|
||||
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-ink-50">
|
||||
{item.overview || `订阅关键词:${keyword}`}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-[10px]">
|
||||
<span className={'rounded-full px-2 py-0.5 font-semibold ' + (item.in_library ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-600')}>
|
||||
{item.in_library ? '本地已入库' : '本地未入库'}
|
||||
</span>
|
||||
{isSeriesItem(item) ? (
|
||||
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-ink-100">
|
||||
已有 {item.downloaded_episodes || 0}/{item.total_episodes || '未知'} 集
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onSubscribe(item)
|
||||
}}
|
||||
disabled={busyKey === key}
|
||||
className="mt-3 rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10 disabled:opacity-50"
|
||||
>
|
||||
<Rss size={12} className="mr-1 inline" />
|
||||
{busyKey === key ? '订阅中…' : '订阅并搜索 PT'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{detail && (
|
||||
<ExternalDetailModal
|
||||
item={detail}
|
||||
busy={busyKey === `${detail.source}:${detail.subscribe_keyword || detail.title}`}
|
||||
onClose={() => setDetail(null)}
|
||||
onSubscribe={onSubscribe}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ExternalDetailModal({
|
||||
item,
|
||||
busy,
|
||||
onClose,
|
||||
onSubscribe,
|
||||
}: {
|
||||
item: ExternalMediaResult
|
||||
busy: boolean
|
||||
onClose: () => void
|
||||
onSubscribe: (item: ExternalMediaResult) => Promise<void>
|
||||
}) {
|
||||
const missing = item.missing_episodes ?? []
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="max-h-[88vh] w-full max-w-3xl overflow-hidden rounded-3xl bg-white shadow-2xl" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="grid gap-0 md:grid-cols-[220px,1fr]">
|
||||
<div className="min-h-72 bg-gray-100">
|
||||
{item.poster_url ? (
|
||||
<img src={imageURL(item.poster_url)} alt={item.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full min-h-72 items-center justify-center text-brand-500">
|
||||
<Info size={42} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4 p-5">
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap gap-2 text-xs">
|
||||
<span className="rounded-full bg-primary-400/10 px-2 py-0.5 font-semibold uppercase text-brand-500">{item.source}</span>
|
||||
{item.media_type ? <span className="rounded-full bg-gray-100 px-2 py-0.5 text-ink-100">{item.media_type}</span> : null}
|
||||
{item.year ? <span className="rounded-full bg-gray-100 px-2 py-0.5 text-ink-100">{item.year}</span> : null}
|
||||
{item.rating ? <span className="rounded-full bg-amber-50 px-2 py-0.5 text-amber-600">★ {item.rating.toFixed(1)}</span> : null}
|
||||
</div>
|
||||
<h3 className="font-display text-2xl font-bold text-ink-600">{item.title}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-ink-50">{item.overview || '暂无简介。'}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<StatusBox label="入库状态" value={item.in_library ? '已入库' : '未入库'} ok={item.in_library} />
|
||||
<StatusBox label="本地条目" value={`${item.local_media_count || 0} 个`} />
|
||||
<StatusBox label="剧集进度" value={isSeriesItem(item) ? `${item.downloaded_episodes || 0}/${item.total_episodes || '未知'} 集` : '单部影片'} />
|
||||
</div>
|
||||
|
||||
{isSeriesItem(item) && (
|
||||
<div className="rounded-2xl border border-gray-100 bg-gray-50 p-3 text-sm">
|
||||
<div className="mb-2 font-semibold text-ink-600">缺失情况</div>
|
||||
{missing.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{missing.slice(0, 80).map((episode) => (
|
||||
<span key={episode} className="rounded-full bg-white px-2 py-0.5 text-xs text-ink-100 shadow-sm">第 {episode} 集</span>
|
||||
))}
|
||||
{missing.length > 80 ? <span className="text-xs text-sand-500">还有 {missing.length - 80} 集…</span> : null}
|
||||
</div>
|
||||
) : item.in_library && item.total_episodes ? (
|
||||
<p className="flex items-center gap-1.5 text-emerald-600"><CheckCircle2 size={15} /> 已完整入库</p>
|
||||
) : (
|
||||
<p className="text-sand-500">总集数未知,订阅时会跳过本地已有单集,优先补新集。</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={onClose} className="rounded-xl border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50">关闭</button>
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
await onSubscribe(item)
|
||||
onClose()
|
||||
}}
|
||||
className="neon-button"
|
||||
>
|
||||
<Rss size={14} /> {busy ? '订阅中…' : item.in_library ? '补全缺失集' : '订阅全集'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBox({ label, value, ok }: { label: string; value: string; ok?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-100 bg-gray-50 p-3">
|
||||
<div className="text-xs text-sand-500">{label}</div>
|
||||
<div className={'mt-1 font-semibold ' + (ok ? 'text-emerald-600' : 'text-ink-600')}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isSeriesItem(item: ExternalMediaResult) {
|
||||
return ['tv', 'anime', 'variety'].includes((item.media_type || '').toLowerCase())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
type SearchStatusPanelsProps = {
|
||||
loading: boolean
|
||||
error: string
|
||||
showIdle: boolean
|
||||
showEmpty: boolean
|
||||
}
|
||||
|
||||
export function SearchStatusPanels({
|
||||
loading,
|
||||
error,
|
||||
showIdle,
|
||||
showEmpty,
|
||||
}: SearchStatusPanelsProps) {
|
||||
return (
|
||||
<>
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 py-8 text-ink-50">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-primary-400 border-t-transparent" />
|
||||
搜索中…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="glass-panel !border-red-400/30 p-4 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
{showIdle && (
|
||||
<div className="glass-panel flex flex-col items-center gap-2 p-10 text-center">
|
||||
<p className="text-lg text-ink-100">输入关键词开始搜索</p>
|
||||
<p className="text-sm text-sand-500">支持电影、电视剧、动漫等媒体内容的快速搜索</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEmpty && (
|
||||
<div className="glass-panel flex flex-col items-center gap-2 p-10 text-center">
|
||||
<p className="text-lg text-ink-100">未找到匹配的媒体</p>
|
||||
<p className="text-sm text-sand-500">尝试其他关键词,或者添加媒体库后执行扫描</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,293 +5,8 @@ import toast from 'react-hot-toast'
|
||||
import { adminAPI } from '../api/admin'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library, Setting } from '../types'
|
||||
|
||||
// SettingsPage replaces the Vue SettingsView's curated runtime settings.
|
||||
// The Go backend stores settings as a single
|
||||
// key/value table; we group the most useful keys client-side and let
|
||||
// the operator edit them with typed widgets (select / toggle / input).
|
||||
//
|
||||
// Any key not in the curated list still works via the AdminPage generic
|
||||
// key/value editor — this page is the curated UX over the same store.
|
||||
interface SettingDef {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'select' | 'toggle' | 'number' | 'textarea' | 'library-multiselect'
|
||||
hint?: string
|
||||
defaultValue?: string
|
||||
options?: { value: string; label: string }[]
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
interface SettingGroup {
|
||||
key: string
|
||||
label: string
|
||||
description?: string
|
||||
items: SettingDef[]
|
||||
}
|
||||
|
||||
const GROUPS: SettingGroup[] = [
|
||||
{
|
||||
key: 'general',
|
||||
label: '常规',
|
||||
description: '语言 / 转码引擎参数(API 密钥请在管理后台 → 外部API 配置)',
|
||||
items: [
|
||||
{
|
||||
key: 'tmdb.language',
|
||||
label: 'TMDb 元数据语言',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'zh-CN', label: '简体中文' },
|
||||
{ value: 'zh-TW', label: '繁体中文' },
|
||||
{ value: 'en-US', label: 'English' },
|
||||
{ value: 'ja-JP', label: '日本語' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'app.server_url',
|
||||
label: '公开访问域名 / STRM 域名',
|
||||
type: 'text',
|
||||
hint: '例如 http://NAS-IP:18080 或 https://media.example.com。填写后网盘媒体扫描会自动生成完整 STRM/302 播放入口;不填则使用同源相对路径。',
|
||||
placeholder: 'http://192.168.1.125:18080',
|
||||
},
|
||||
{
|
||||
key: 'playback.direct_only',
|
||||
label: '客户端直连解码(释放宿主机资源)',
|
||||
type: 'toggle',
|
||||
hint: '默认关闭。开启后宿主机不再进行任何 FFmpeg 转码,所有播放交给第三方客户端(Infuse / VLC / Emby 客户端等)或浏览器本地解码直连(direct play / 302 直链),大幅降低宿主机 CPU 占用。若客户端不支持源编码可能无法播放。',
|
||||
defaultValue: 'false',
|
||||
},
|
||||
{
|
||||
key: 'transcode.enabled',
|
||||
label: '启用转码',
|
||||
type: 'toggle',
|
||||
hint: '关闭后所有视频直连播放(「客户端直连解码」开启时本项自动失效)',
|
||||
defaultValue: 'true',
|
||||
},
|
||||
{
|
||||
key: 'transcode.hw_accel',
|
||||
label: '硬件编码器',
|
||||
type: 'select',
|
||||
hint: '只有开启下方「启用硬件加速」后才会使用;未开启时强制软件转码',
|
||||
defaultValue: 'none',
|
||||
options: [
|
||||
{ value: 'none', label: '软件转码' },
|
||||
{ value: 'nvenc', label: 'NVIDIA NVENC' },
|
||||
{ value: 'qsv', label: 'Intel QSV' },
|
||||
{ value: 'vaapi', label: 'VAAPI (Linux)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'transcode.hw_enabled',
|
||||
label: '启用硬件加速',
|
||||
type: 'toggle',
|
||||
hint: '关闭时即使选择了 NVENC/QSV/VAAPI,也不会调用硬件编码参数',
|
||||
defaultValue: 'false',
|
||||
},
|
||||
{
|
||||
key: 'transcode.max_jobs',
|
||||
label: '最大并发转码任务',
|
||||
type: 'number',
|
||||
hint: 'NAS 建议 1',
|
||||
defaultValue: '1',
|
||||
},
|
||||
{
|
||||
key: 'transcode.realtime',
|
||||
label: '按播放速度转码',
|
||||
type: 'toggle',
|
||||
hint: '开启后 ffmpeg 不会抢跑压完整片,可显著降低 CPU 峰值',
|
||||
defaultValue: 'true',
|
||||
},
|
||||
{
|
||||
key: 'transcode.threads',
|
||||
label: '软件转码线程数',
|
||||
type: 'number',
|
||||
hint: 'NAS 建议 1-2;仅软件转码生效',
|
||||
defaultValue: '2',
|
||||
},
|
||||
{
|
||||
key: 'transcode.idle_timeout_seconds',
|
||||
label: '转码空闲停止秒数',
|
||||
type: 'number',
|
||||
hint: '播放器关闭或停止请求分片后自动结束 ffmpeg',
|
||||
defaultValue: '120',
|
||||
},
|
||||
{
|
||||
key: 'ffmpeg.path',
|
||||
label: 'FFmpeg 路径',
|
||||
type: 'text',
|
||||
placeholder: 'ffmpeg',
|
||||
},
|
||||
{
|
||||
key: 'ffprobe.path',
|
||||
label: 'FFprobe 路径',
|
||||
type: 'text',
|
||||
placeholder: 'ffprobe',
|
||||
},
|
||||
{
|
||||
key: 'ffprobe.max_concurrent',
|
||||
label: 'FFprobe 最大并发',
|
||||
type: 'number',
|
||||
hint: 'NAS 建议 1;用于扫描、整理洗版和手动探测,避免同时启动多个 ffprobe 进程',
|
||||
defaultValue: '1',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'license',
|
||||
label: '授权服务',
|
||||
description: '连接私有 MediaStationGo 授权服务;开源版默认最多 20 个用户,激活后按授权策略提升额度。',
|
||||
items: [
|
||||
{
|
||||
key: 'license.server_url',
|
||||
label: 'License Server 地址',
|
||||
type: 'text',
|
||||
placeholder: 'http://127.0.0.1:8001',
|
||||
},
|
||||
{
|
||||
key: 'license.hmac_secret',
|
||||
label: 'HMAC 签名密钥',
|
||||
type: 'text',
|
||||
hint: '必须与 License Server 的 LICENSE_HMAC_SECRET 保持一致;留空则跳过响应签名校验。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'cloud-upload',
|
||||
label: '网盘转存',
|
||||
description: '把本地媒体复制上传到外部存储。推荐:将 115、123、夸克等挂载到 OpenList、CloudDrive2 或 Alist 后,使用桥接目标转存。',
|
||||
items: [
|
||||
{
|
||||
key: 'cloud.auto_sync_enabled',
|
||||
label: '夜间自动同步网盘媒体库',
|
||||
type: 'toggle',
|
||||
hint: '默认关闭。开启后仅在每天 23:00-05:00 按检查间隔触发;每次完整扫描所有启用网盘库一次后自动停止。手动扫描仍可随时执行。',
|
||||
defaultValue: 'false',
|
||||
},
|
||||
{
|
||||
key: 'cloud.sync_interval_seconds',
|
||||
label: '夜间窗口检查间隔秒数',
|
||||
type: 'number',
|
||||
hint: '最小 300 秒,建议 1800 秒;同一个夜间窗口成功同步后不会重复全量扫,避免大型网盘反复递归。',
|
||||
defaultValue: '1800',
|
||||
},
|
||||
{
|
||||
key: 'cloud.boot_scan_enabled',
|
||||
label: '启动后立即扫描网盘',
|
||||
type: 'toggle',
|
||||
hint: '默认关闭。仅排障或小型网盘建议开启;大型库请使用手动扫描或夜间自动同步。',
|
||||
defaultValue: 'false',
|
||||
},
|
||||
{
|
||||
key: 'cloud.upload_auto_enabled',
|
||||
label: '启用自动转存',
|
||||
type: 'toggle',
|
||||
hint: '开启后后台会按间隔扫描本地源目录,把视频、NFO、海报、字幕转存到目标存储;还需要在外部存储页开启该目标的“允许转存写入”。',
|
||||
defaultValue: 'false',
|
||||
},
|
||||
{
|
||||
key: 'cloud.upload_provider',
|
||||
label: '转存目标',
|
||||
type: 'select',
|
||||
defaultValue: 'alist',
|
||||
options: [
|
||||
{ value: 'openlist', label: 'OpenList(推荐,可桥接 115/123/阿里/夸克)' },
|
||||
{ value: 'clouddrive2', label: 'CloudDrive2(推荐,可桥接 115/123/阿里/夸克)' },
|
||||
{ value: 'alist', label: 'Alist(可桥接多网盘)' },
|
||||
{ value: 'webdav', label: 'WebDAV' },
|
||||
{ value: 'cloud115', label: '115 原生(待接分片上传)' },
|
||||
{ value: 'quark', label: '夸克原生(待接分片上传)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'cloud.upload_source_dir',
|
||||
label: '本地源目录',
|
||||
type: 'text',
|
||||
placeholder: '/media/电影 或 F:\\media\\Movies',
|
||||
},
|
||||
{
|
||||
key: 'cloud.upload_dest_path',
|
||||
label: '网盘目标目录',
|
||||
type: 'text',
|
||||
defaultValue: '/MediaStationGo',
|
||||
placeholder: '/MediaStationGo',
|
||||
},
|
||||
{
|
||||
key: 'cloud.upload_recursive',
|
||||
label: '递归扫描源目录',
|
||||
type: 'toggle',
|
||||
defaultValue: 'true',
|
||||
},
|
||||
{
|
||||
key: 'cloud.upload_sidecars',
|
||||
label: '同步 NFO / 海报 / 字幕',
|
||||
type: 'toggle',
|
||||
defaultValue: 'true',
|
||||
},
|
||||
{
|
||||
key: 'cloud.upload_overwrite',
|
||||
label: '覆盖远端同名文件',
|
||||
type: 'toggle',
|
||||
defaultValue: 'false',
|
||||
},
|
||||
{
|
||||
key: 'cloud.upload_transfer_mode',
|
||||
label: '自动转存方式',
|
||||
type: 'select',
|
||||
defaultValue: 'copy',
|
||||
hint: '复制会保留本地源文件;移动只在上传成功后删除本地文件。',
|
||||
options: [
|
||||
{ value: 'copy', label: '复制' },
|
||||
{ value: 'move', label: '移动' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'cloud.upload_interval_seconds',
|
||||
label: '自动转存间隔秒数',
|
||||
type: 'number',
|
||||
hint: '最小 300 秒,建议 3600 秒或更高,避免频繁读盘和触发网盘风控。',
|
||||
defaultValue: '3600',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'adult',
|
||||
label: 'Adult / NSFW',
|
||||
description: '成人内容隔离开关 (默认隐藏)',
|
||||
items: [
|
||||
{
|
||||
key: 'adult.enabled',
|
||||
label: '启用成人内容',
|
||||
type: 'toggle',
|
||||
hint: '全局开关。关闭后所有人都无法显示成人库;开启后用户仍默认隐藏,可在个人资料或 Bot 中自行显示。',
|
||||
defaultValue: 'true',
|
||||
},
|
||||
{
|
||||
key: 'adult.library_ids',
|
||||
label: '指定成人媒体库',
|
||||
type: 'library-multiselect',
|
||||
hint: '管理员指定哪些媒体库目录属于成人影视库。指定后网页、搜索和第三方客户端都会统一隐藏。',
|
||||
defaultValue: '[]',
|
||||
},
|
||||
{
|
||||
key: 'adult.require_pin',
|
||||
label: '访问需要 PIN',
|
||||
type: 'toggle',
|
||||
},
|
||||
{
|
||||
key: 'adult.pin',
|
||||
label: 'PIN 码',
|
||||
type: 'text',
|
||||
hint: '4-8 位数字',
|
||||
},
|
||||
],
|
||||
},
|
||||
// qBittorrent 配置已迁移到独立的「下载器」页面(侧边栏 → 下载器),
|
||||
// 该页面支持多客户端 + 连接测试。这里不再重复暴露入口,避免与
|
||||
// /api/admin/download/clients 写入的数据来源冲突。
|
||||
]
|
||||
|
||||
const ALL_KEYS = new Set(GROUPS.flatMap((g) => g.items.map((i) => i.key)))
|
||||
import { SettingRow } from './SettingsRow'
|
||||
import { ALL_KEYS, GROUPS } from './settingsGroups'
|
||||
|
||||
export function SettingsPage() {
|
||||
const [activeGroup, setActiveGroup] = useState(GROUPS[0].key)
|
||||
@@ -418,121 +133,3 @@ export function SettingsPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingRow({
|
||||
def,
|
||||
value,
|
||||
onChange,
|
||||
libraries = [],
|
||||
}: {
|
||||
def: SettingDef
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
libraries?: Library[]
|
||||
}) {
|
||||
const toggleOn = value === 'true' || value === '1' || value === 'on'
|
||||
const selectedLibraryIDs = parseLibraryIDs(value)
|
||||
const toggleLibrary = (id: string, checked: boolean) => {
|
||||
const next = checked
|
||||
? Array.from(new Set([...selectedLibraryIDs, id]))
|
||||
: selectedLibraryIDs.filter((item) => item !== id)
|
||||
onChange(JSON.stringify(next))
|
||||
}
|
||||
return (
|
||||
<div className="grid items-start gap-2 md:grid-cols-[280px_1fr]">
|
||||
<label className="text-sm text-ink-100">
|
||||
<div className="font-medium">{def.label}</div>
|
||||
{def.hint && <div className="mt-0.5 text-xs text-sand-500">{def.hint}</div>}
|
||||
<div className="mt-0.5 font-mono text-[10px] text-gray-500">{def.key}</div>
|
||||
</label>
|
||||
<div>
|
||||
{def.type === 'text' && (
|
||||
<input
|
||||
className="input-base"
|
||||
value={value}
|
||||
placeholder={def.placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{def.type === 'number' && (
|
||||
<input
|
||||
type="number"
|
||||
className="input-base"
|
||||
value={value}
|
||||
placeholder={def.placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{def.type === 'textarea' && (
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base font-mono text-xs"
|
||||
value={value}
|
||||
placeholder={def.placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{def.type === 'select' && (
|
||||
<select className="input-base" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">(未设置)</option>
|
||||
{def.options?.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{def.type === 'toggle' && (
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={toggleOn}
|
||||
onChange={(e) => onChange(e.target.checked ? 'true' : 'false')}
|
||||
/>
|
||||
<span className="text-sm text-ink-100">{toggleOn ? '已启用' : '已关闭'}</span>
|
||||
</label>
|
||||
)}
|
||||
{def.type === 'library-multiselect' && (
|
||||
<div className="space-y-2 rounded-2xl border border-gray-200 bg-white/70 p-3">
|
||||
{libraries.length === 0 && (
|
||||
<div className="text-sm text-ink-50">暂无媒体库,请先到「媒体与用户 → 媒体库」添加。</div>
|
||||
)}
|
||||
{libraries.map((lib) => (
|
||||
<label key={lib.id} className="flex cursor-pointer items-start gap-3 rounded-xl px-2 py-2 hover:bg-sand-100/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 accent-primary-400"
|
||||
checked={selectedLibraryIDs.includes(lib.id)}
|
||||
onChange={(e) => toggleLibrary(lib.id, e.target.checked)}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-ink-600">{lib.name}</span>
|
||||
<span className="block truncate font-mono text-xs text-ink-50">{lib.path}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
<div className="text-xs text-sand-500">
|
||||
已选择 {selectedLibraryIDs.length} 个成人媒体库;新用户默认隐藏,用户可在个人资料或 Bot 中显示/隐藏。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function parseLibraryIDs(raw: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]')
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item) => String(item).trim()).filter(Boolean)
|
||||
}
|
||||
} catch {
|
||||
return raw
|
||||
.split(/[,\n;,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { Library } from '../types'
|
||||
|
||||
export interface SettingDef {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'select' | 'toggle' | 'number' | 'textarea' | 'library-multiselect'
|
||||
hint?: string
|
||||
defaultValue?: string
|
||||
options?: { value: string; label: string }[]
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
type SettingsRowProps = {
|
||||
def: SettingDef
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
libraries?: Library[]
|
||||
}
|
||||
|
||||
export function SettingRow({ def, value, onChange, libraries = [] }: SettingsRowProps) {
|
||||
const toggleOn = value === 'true' || value === '1' || value === 'on'
|
||||
const selectedLibraryIDs = parseLibraryIDs(value)
|
||||
const toggleLibrary = (id: string, checked: boolean) => {
|
||||
const next = checked
|
||||
? Array.from(new Set([...selectedLibraryIDs, id]))
|
||||
: selectedLibraryIDs.filter((item) => item !== id)
|
||||
onChange(JSON.stringify(next))
|
||||
}
|
||||
return (
|
||||
<div className="grid items-start gap-2 md:grid-cols-[280px_1fr]">
|
||||
<label className="text-sm text-ink-100">
|
||||
<div className="font-medium">{def.label}</div>
|
||||
{def.hint && <div className="mt-0.5 text-xs text-sand-500">{def.hint}</div>}
|
||||
<div className="mt-0.5 font-mono text-[10px] text-gray-500">{def.key}</div>
|
||||
</label>
|
||||
<div>
|
||||
{def.type === 'text' && (
|
||||
<input
|
||||
className="input-base"
|
||||
value={value}
|
||||
placeholder={def.placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
{def.type === 'number' && (
|
||||
<input
|
||||
type="number"
|
||||
className="input-base"
|
||||
value={value}
|
||||
placeholder={def.placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
{def.type === 'textarea' && (
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base font-mono text-xs"
|
||||
value={value}
|
||||
placeholder={def.placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
{def.type === 'select' && (
|
||||
<select className="input-base" value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
<option value="">(未设置)</option>
|
||||
{def.options?.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{def.type === 'toggle' && (
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={toggleOn}
|
||||
onChange={(event) => onChange(event.target.checked ? 'true' : 'false')}
|
||||
/>
|
||||
<span className="text-sm text-ink-100">{toggleOn ? '已启用' : '已关闭'}</span>
|
||||
</label>
|
||||
)}
|
||||
{def.type === 'library-multiselect' && (
|
||||
<LibraryMultiSelect
|
||||
libraries={libraries}
|
||||
selectedLibraryIDs={selectedLibraryIDs}
|
||||
onToggle={toggleLibrary}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LibraryMultiSelect({
|
||||
libraries,
|
||||
selectedLibraryIDs,
|
||||
onToggle,
|
||||
}: {
|
||||
libraries: Library[]
|
||||
selectedLibraryIDs: string[]
|
||||
onToggle: (id: string, checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2 rounded-2xl border border-gray-200 bg-white/70 p-3">
|
||||
{libraries.length === 0 && (
|
||||
<div className="text-sm text-ink-50">暂无媒体库,请先到「媒体与用户 → 媒体库」添加。</div>
|
||||
)}
|
||||
{libraries.map((lib) => (
|
||||
<label key={lib.id} className="flex cursor-pointer items-start gap-3 rounded-xl px-2 py-2 hover:bg-sand-100/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 accent-primary-400"
|
||||
checked={selectedLibraryIDs.includes(lib.id)}
|
||||
onChange={(event) => onToggle(lib.id, event.target.checked)}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-ink-600">{lib.name}</span>
|
||||
<span className="block truncate font-mono text-xs text-ink-50">{lib.path}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
<div className="text-xs text-sand-500">
|
||||
已选择 {selectedLibraryIDs.length} 个成人媒体库;新用户默认隐藏,用户可在个人资料或 Bot 中显示/隐藏。
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function parseLibraryIDs(raw: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]')
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item) => String(item).trim()).filter(Boolean)
|
||||
}
|
||||
} catch {
|
||||
return raw
|
||||
.split(/[,\n;,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
import type { SiteForm } from "./sitesPageModel";
|
||||
|
||||
type SiteFormActionsProps = {
|
||||
form: SiteForm;
|
||||
saving: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function SiteFormActions({
|
||||
form,
|
||||
saving,
|
||||
onClose,
|
||||
}: SiteFormActionsProps) {
|
||||
return (
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50 transition"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !form.name.trim() || !form.url.trim()}
|
||||
className="neon-button text-sm disabled:opacity-50 flex items-center gap-1.5"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { SiteFormSectionProps } from "./siteFormModalTypes";
|
||||
|
||||
type SiteFormAdvancedOptionsProps = SiteFormSectionProps & {
|
||||
advancedOpen: boolean;
|
||||
setAdvancedOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function SiteFormAdvancedOptions({
|
||||
advancedOpen,
|
||||
setAdvancedOpen,
|
||||
form,
|
||||
setForm,
|
||||
}: SiteFormAdvancedOptionsProps) {
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen(!advancedOpen)}
|
||||
className="flex items-center gap-1 text-xs text-ink-50 hover:text-white transition"
|
||||
>
|
||||
{advancedOpen ? "▾" : "▸"} 高级选项
|
||||
</button>
|
||||
{advancedOpen && (
|
||||
<div className="mt-3 pl-4 space-y-3 border-l border-gray-200">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
User-Agent
|
||||
</label>
|
||||
<input
|
||||
className="input-base w-full text-xs"
|
||||
placeholder="自定义 UA,留空使用默认"
|
||||
value={form.user_agent}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
user_agent: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
请求超时 (秒)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-base w-full"
|
||||
min={1}
|
||||
max={300}
|
||||
value={form.timeout}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
timeout: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
优先级 (数字越大越优先)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-base w-full"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.priority}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
priority: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
关联下载器
|
||||
</label>
|
||||
<input
|
||||
className="input-base w-full text-xs"
|
||||
placeholder="下载器 ID 或名称"
|
||||
value={form.downloader}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
downloader: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<AdvancedCheckbox
|
||||
checked={form.use_proxy}
|
||||
label="使用代理"
|
||||
onChange={(checked) =>
|
||||
setForm((current) => ({ ...current, use_proxy: checked }))
|
||||
}
|
||||
/>
|
||||
<AdvancedCheckbox
|
||||
checked={form.rate_limit}
|
||||
label="启用限流"
|
||||
onChange={(checked) =>
|
||||
setForm((current) => ({ ...current, rate_limit: checked }))
|
||||
}
|
||||
/>
|
||||
<AdvancedCheckbox
|
||||
checked={form.browser_emulation}
|
||||
label="浏览器模拟"
|
||||
onChange={(checked) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
browser_emulation: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
Extra 扩展配置 (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base w-full resize-none text-xs font-mono"
|
||||
placeholder='{"key":"value"}'
|
||||
value={form.extra}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
extra: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
is_default: !current.is_default,
|
||||
}))
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors cursor-pointer ${form.is_default ? "bg-primary-500" : "bg-gray-200"}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${form.is_default ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-ink-50">设为默认站点</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdvancedCheckbox({
|
||||
checked,
|
||||
label,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
label: string;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
<span className="text-xs text-ink-100">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { SiteFormSectionProps } from "./siteFormModalTypes";
|
||||
|
||||
type SiteFormAuthFieldsProps = SiteFormSectionProps & {
|
||||
editingId: string | null;
|
||||
};
|
||||
|
||||
const authOptions = [
|
||||
{ value: "cookie", label: "Cookie" },
|
||||
{ value: "api_key", label: "API Key" },
|
||||
{ value: "auth_header", label: "Auth Header" },
|
||||
];
|
||||
|
||||
export function SiteFormAuthFields({
|
||||
editingId,
|
||||
form,
|
||||
setForm,
|
||||
}: SiteFormAuthFieldsProps) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-2">认证方式</label>
|
||||
<div className="flex gap-2 mb-3">
|
||||
{authOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
auth_type: option.value,
|
||||
}))
|
||||
}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-medium border transition ${
|
||||
form.auth_type === option.value
|
||||
? "bg-primary-500 text-ink-600 border-primary-500"
|
||||
: "border-gray-200 text-ink-50 hover:border-primary-500/50"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{form.auth_type === "cookie" && (
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">Cookie</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base w-full resize-none text-xs font-mono"
|
||||
placeholder="uid=xxx; pass=xxx; ..."
|
||||
value={form.cookie}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
cookie: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">
|
||||
从浏览器开发者工具的请求头中获取 Cookie 值
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.auth_type === "api_key" && (
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
令牌(API Key / Passkey)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input-base w-full font-mono text-sm"
|
||||
placeholder={
|
||||
editingId
|
||||
? "留空则保留原令牌,输入新值则替换"
|
||||
: "输入 API Access Token"
|
||||
}
|
||||
value={form.api_key}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
api_key: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">
|
||||
{form.type === "mteam"
|
||||
? "馒头:控制台 → 实验室 → 存取令牌;第三方工具通过 x-api-key 请求头访问"
|
||||
: form.type === "yemapt"
|
||||
? "YemaPT:个人详情页 → 第三方对接专用 auth;通过 Authorization 请求头原样访问"
|
||||
: "站点的访问 API Key"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.auth_type === "auth_header" && (
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
请求头(Authorization)
|
||||
</label>
|
||||
<input
|
||||
className="input-base w-full font-mono text-xs"
|
||||
placeholder="Bearer eyJhbGciOiJIUzI1NiIs..."
|
||||
value={form.auth_header}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
auth_header: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="block text-xs text-ink-50 mb-1">RSS 地址</label>
|
||||
<input
|
||||
className="input-base w-full text-xs font-mono"
|
||||
placeholder="https://.../torrents/rss?..."
|
||||
value={form.rss_url}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
rss_url: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">
|
||||
站点 RSS 订阅地址,用于获取最新资源
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { SiteFormSectionProps } from "./siteFormModalTypes";
|
||||
|
||||
type SiteFormBasicFieldsProps = SiteFormSectionProps & {
|
||||
onTypeChange: (type: string) => void;
|
||||
};
|
||||
|
||||
export function SiteFormBasicFields({
|
||||
form,
|
||||
setForm,
|
||||
onTypeChange,
|
||||
}: SiteFormBasicFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">
|
||||
站点名称 *
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
className="input-base w-full"
|
||||
placeholder="例如: 馒头、观众、家园"
|
||||
value={form.name}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, name: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">
|
||||
站点地址 *
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
className="input-base w-full"
|
||||
placeholder="https://www.example.com/"
|
||||
value={form.url}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, url: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">
|
||||
格式: https://www.example.com/
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">
|
||||
站点类型
|
||||
</label>
|
||||
<select
|
||||
className="input-base w-full"
|
||||
value={form.type}
|
||||
onChange={(event) => onTypeChange(event.target.value)}
|
||||
>
|
||||
<option value="nexusphp">NexusPHP(国内主流PT)</option>
|
||||
<option value="gazelle">Gazelle(HDBits等)</option>
|
||||
<option value="unit3d">UNIT3D(BeyondHD等)</option>
|
||||
<option value="mteam">馒头 M-Team(专用API)</option>
|
||||
<option value="yemapt">YemaPT(API Auth)</option>
|
||||
<option value="discuz">Discuz 论坛型</option>
|
||||
<option value="custom_rss">自定义 RSS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">状态</label>
|
||||
<div className="flex items-center gap-3 h-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
enabled: !current.enabled,
|
||||
}))
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors cursor-pointer ${form.enabled ? "bg-primary-500" : "bg-gray-200"}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${form.enabled ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
className={`text-sm ${form.enabled ? "text-white" : "text-sand-500"}`}
|
||||
>
|
||||
{form.enabled ? "启用" : "停用"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SiteTypeGuide type={form.type} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteTypeGuide({ type }: { type: string }) {
|
||||
if (type === "mteam") {
|
||||
return (
|
||||
<div className="p-3 rounded-xl border border-green-500/30 bg-green-500/5">
|
||||
<div className="text-sm font-medium text-green-400 mb-1">
|
||||
馒头站点配置指南
|
||||
</div>
|
||||
<div className="text-xs text-ink-50 space-y-1">
|
||||
<div>
|
||||
<b>认证方式:</b>必须使用「API Access Token」,不要使用 Cookie
|
||||
</div>
|
||||
<div className="pl-3 text-sand-500">
|
||||
1. 登录馒头站 → 控制台 → 实验室 → 存取令牌
|
||||
<br />
|
||||
2. 点击「创建令牌」,复制生成的 Token
|
||||
<br />
|
||||
3. 将 Token 填入下方「令牌」输入框
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "yemapt") {
|
||||
return (
|
||||
<div className="p-3 rounded-xl border border-cyan-500/30 bg-cyan-500/5">
|
||||
<div className="text-sm font-medium text-cyan-400 mb-1">
|
||||
YemaPT 配置指南
|
||||
</div>
|
||||
<div className="text-xs text-ink-50 space-y-1">
|
||||
<div>
|
||||
<b>认证方式:</b>使用个人详情页创建的第三方对接专用 auth
|
||||
</div>
|
||||
<div className="pl-3 text-sand-500">
|
||||
填入下方 API Key;后端会按 Wiki 要求通过 Authorization
|
||||
请求头原样发送。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
+22
-436
@@ -1,7 +1,11 @@
|
||||
import type { Dispatch, FormEvent, SetStateAction } from "react";
|
||||
import { RefreshCw, X } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import type { SiteForm } from "./sitesPageModel";
|
||||
import { SiteFormActions } from "./SiteFormActions";
|
||||
import { SiteFormAdvancedOptions } from "./SiteFormAdvancedOptions";
|
||||
import { SiteFormAuthFields } from "./SiteFormAuthFields";
|
||||
import { SiteFormBasicFields } from "./SiteFormBasicFields";
|
||||
|
||||
type SiteFormModalProps = {
|
||||
editingId: string | null;
|
||||
@@ -48,441 +52,23 @@ export function SiteFormModal({
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">
|
||||
站点名称 *
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
className="input-base w-full"
|
||||
placeholder="例如: 馒头、观众、家园"
|
||||
value={form.name}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, name: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">
|
||||
站点地址 *
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
className="input-base w-full"
|
||||
placeholder="https://www.example.com/"
|
||||
value={form.url}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, url: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">
|
||||
格式: https://www.example.com/
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">
|
||||
站点类型
|
||||
</label>
|
||||
<select
|
||||
className="input-base w-full"
|
||||
value={form.type}
|
||||
onChange={(event) => onTypeChange(event.target.value)}
|
||||
>
|
||||
<option value="nexusphp">NexusPHP(国内主流PT)</option>
|
||||
<option value="gazelle">Gazelle(HDBits等)</option>
|
||||
<option value="unit3d">UNIT3D(BeyondHD等)</option>
|
||||
<option value="mteam">馒头 M-Team(专用API)</option>
|
||||
<option value="yemapt">YemaPT(API Auth)</option>
|
||||
<option value="discuz">Discuz 论坛型</option>
|
||||
<option value="custom_rss">自定义 RSS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">状态</label>
|
||||
<div className="flex items-center gap-3 h-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
enabled: !current.enabled,
|
||||
}))
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors cursor-pointer ${form.enabled ? "bg-primary-500" : "bg-gray-200"}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${form.enabled ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
className={`text-sm ${form.enabled ? "text-white" : "text-sand-500"}`}
|
||||
>
|
||||
{form.enabled ? "启用" : "停用"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.type === "mteam" && (
|
||||
<div className="p-3 rounded-xl border border-green-500/30 bg-green-500/5">
|
||||
<div className="text-sm font-medium text-green-400 mb-1">
|
||||
馒头站点配置指南
|
||||
</div>
|
||||
<div className="text-xs text-ink-50 space-y-1">
|
||||
<div>
|
||||
<b>正式站地址:</b>
|
||||
<code className="text-green-300">https://api.m-team.cc</code>
|
||||
</div>
|
||||
<div>
|
||||
<b>测试站地址:</b>
|
||||
<code className="text-green-300">
|
||||
https://test2.m-team.cc
|
||||
</code>
|
||||
(开发测试环境)
|
||||
</div>
|
||||
<div>
|
||||
<b>认证方式:</b>必须使用「API Access Token」,不要使用 Cookie
|
||||
</div>
|
||||
<div className="pl-3 text-sand-500">
|
||||
1. 登录馒头站 → 控制台 → 实验室 → 存取令牌
|
||||
<br />
|
||||
2. 点击「创建令牌」,复制生成的 Token
|
||||
<br />
|
||||
3. 将 Token 填入下方「令牌」输入框
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.type === "yemapt" && (
|
||||
<div className="p-3 rounded-xl border border-cyan-500/30 bg-cyan-500/5">
|
||||
<div className="text-sm font-medium text-cyan-400 mb-1">
|
||||
YemaPT 配置指南
|
||||
</div>
|
||||
<div className="text-xs text-ink-50 space-y-1">
|
||||
<div>
|
||||
<b>站点地址:</b>
|
||||
<code className="text-cyan-300">https://www.yemapt.org</code>
|
||||
</div>
|
||||
<div>
|
||||
<b>认证方式:</b>使用个人详情页创建的第三方对接专用 auth
|
||||
</div>
|
||||
<div className="pl-3 text-sand-500">
|
||||
填入下方 API Key;后端会按 Wiki 要求通过 Authorization
|
||||
请求头原样发送。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-2">认证方式</label>
|
||||
<div className="flex gap-2 mb-3">
|
||||
{[
|
||||
{ value: "cookie", label: "Cookie" },
|
||||
{ value: "api_key", label: "API Key" },
|
||||
{ value: "auth_header", label: "Auth Header" },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
auth_type: option.value,
|
||||
}))
|
||||
}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-medium border transition ${
|
||||
form.auth_type === option.value
|
||||
? "bg-primary-500 text-ink-600 border-primary-500"
|
||||
: "border-gray-200 text-ink-50 hover:border-primary-500/50"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{form.auth_type === "cookie" && (
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">Cookie</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base w-full resize-none text-xs font-mono"
|
||||
placeholder="uid=xxx; pass=xxx; ..."
|
||||
value={form.cookie}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
cookie: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">
|
||||
从浏览器开发者工具的请求头中获取 Cookie 值
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.auth_type === "api_key" && (
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
令牌(API Key / Passkey)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input-base w-full font-mono text-sm"
|
||||
placeholder={
|
||||
editingId
|
||||
? "留空则保留原令牌,输入新值则替换"
|
||||
: "输入 API Access Token"
|
||||
}
|
||||
value={form.api_key}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
api_key: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">
|
||||
{form.type === "mteam"
|
||||
? "馒头:控制台 → 实验室 → 存取令牌;第三方工具通过 x-api-key 请求头访问"
|
||||
: form.type === "yemapt"
|
||||
? "YemaPT:个人详情页 → 第三方对接专用 auth;通过 Authorization 请求头原样访问"
|
||||
: "站点的访问 API Key"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.auth_type === "auth_header" && (
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
请求头(Authorization)
|
||||
</label>
|
||||
<input
|
||||
className="input-base w-full font-mono text-xs"
|
||||
placeholder="Bearer eyJhbGciOiJIUzI1NiIs..."
|
||||
value={form.auth_header}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
auth_header: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="block text-xs text-ink-50 mb-1">RSS 地址</label>
|
||||
<input
|
||||
className="input-base w-full text-xs font-mono"
|
||||
placeholder="https://.../torrents/rss?..."
|
||||
value={form.rss_url}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
rss_url: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">
|
||||
站点 RSS 订阅地址,用于获取最新资源
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen(!advancedOpen)}
|
||||
className="flex items-center gap-1 text-xs text-ink-50 hover:text-white transition"
|
||||
>
|
||||
{advancedOpen ? "▾" : "▸"} 高级选项
|
||||
</button>
|
||||
{advancedOpen && (
|
||||
<div className="mt-3 pl-4 space-y-3 border-l border-gray-200">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
User-Agent
|
||||
</label>
|
||||
<input
|
||||
className="input-base w-full text-xs"
|
||||
placeholder="自定义 UA,留空使用默认"
|
||||
value={form.user_agent}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
user_agent: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
请求超时 (秒)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-base w-full"
|
||||
min={1}
|
||||
max={300}
|
||||
value={form.timeout}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
timeout: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
优先级 (数字越大越优先)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-base w-full"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.priority}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
priority: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
关联下载器
|
||||
</label>
|
||||
<input
|
||||
className="input-base w-full text-xs"
|
||||
placeholder="下载器 ID 或名称"
|
||||
value={form.downloader}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
downloader: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={form.use_proxy}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
use_proxy: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-ink-100">使用代理</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={form.rate_limit}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
rate_limit: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-ink-100">启用限流</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={form.browser_emulation}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
browser_emulation: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-ink-100">浏览器模拟</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
Extra 扩展配置 (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base w-full resize-none text-xs font-mono"
|
||||
placeholder='{"key":"value"}'
|
||||
value={form.extra}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
extra: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
is_default: !current.is_default,
|
||||
}))
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors cursor-pointer ${form.is_default ? "bg-primary-500" : "bg-gray-200"}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${form.is_default ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-ink-50">设为默认站点</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50 transition"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !form.name.trim() || !form.url.trim()}
|
||||
className="neon-button text-sm disabled:opacity-50 flex items-center gap-1.5"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<SiteFormBasicFields
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
onTypeChange={onTypeChange}
|
||||
/>
|
||||
<SiteFormAuthFields
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
/>
|
||||
<SiteFormAdvancedOptions
|
||||
advancedOpen={advancedOpen}
|
||||
setAdvancedOpen={setAdvancedOpen}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
/>
|
||||
<SiteFormActions form={form} saving={saving} onClose={onClose} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Globe, Plus, RefreshCw } from "lucide-react";
|
||||
import { sitesAPI } from "../api/sites";
|
||||
import type { Site } from "../types";
|
||||
import { ManagementShortcuts } from "../components/ManagementShortcuts";
|
||||
import { confirmAction } from "../components/ConfirmDialog";
|
||||
import { confirmAction } from "../components/confirmAction";
|
||||
|
||||
import {
|
||||
defaultSiteForm,
|
||||
@@ -161,21 +161,25 @@ export function SitesPage() {
|
||||
title: "下载器管理",
|
||||
description: "添加、测试和维护下载器连接",
|
||||
badge: "必需",
|
||||
group: "基础配置",
|
||||
},
|
||||
{
|
||||
to: "/site-search",
|
||||
title: "站点检索",
|
||||
description: "跨 PT 站点搜索资源并创建下载任务",
|
||||
group: "资源获取",
|
||||
},
|
||||
{
|
||||
to: "/subscriptions",
|
||||
title: "订阅管理",
|
||||
description: "管理追剧追番和自动下载规则",
|
||||
group: "资源获取",
|
||||
},
|
||||
{
|
||||
to: "/downloads",
|
||||
title: "下载中心",
|
||||
description: "查看下载任务状态和历史记录",
|
||||
group: "任务状态",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,73 +1,16 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Cloud, Copy, FileVideo, Folder, Loader2, LogOut, Move, PauseCircle, QrCode, RefreshCw, Save, Send, Trash2, Upload } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useState } from 'react'
|
||||
import { Cloud } from 'lucide-react'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import {
|
||||
cloudAPI,
|
||||
storageAPI,
|
||||
type CloudScanStatus,
|
||||
type CloudEntry,
|
||||
type QRSession,
|
||||
type StorageType,
|
||||
} from '../api/storage_config'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import type { Library } from '../types'
|
||||
import {
|
||||
STORAGE_TABS,
|
||||
TYPE_LABEL,
|
||||
} from './storageConfigModel'
|
||||
import { StorageForm } from './StorageForm'
|
||||
|
||||
const CLOUD_TYPES: StorageType[] = ['cloud115', 'quark', 'clouddrive2', 'openlist']
|
||||
const isCloud = (t: StorageType) => CLOUD_TYPES.includes(t)
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
alist: 'ALIST',
|
||||
openlist: 'OpenList',
|
||||
webdav: 'WEBDAV',
|
||||
s3: 'S3',
|
||||
cloud115: '115网盘',
|
||||
quark: '夸克网盘',
|
||||
clouddrive2: 'CloudDrive2',
|
||||
}
|
||||
const PATH_BASED_CLOUD = new Set<StorageType>(['openlist', 'clouddrive2'])
|
||||
const TRANSFER_SUPPORTED_TYPES = new Set<StorageType>(['alist', 'openlist', 'webdav', 'clouddrive2'])
|
||||
|
||||
function normalizeCloudDisplayPath(value: string) {
|
||||
let text = value.trim()
|
||||
try {
|
||||
text = decodeURIComponent(text)
|
||||
} catch {
|
||||
// Keep the original value if it is not URI-encoded.
|
||||
}
|
||||
return text.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
|
||||
}
|
||||
|
||||
function cloudLibraryProvider(path: string) {
|
||||
if (!path.toLowerCase().startsWith('cloud://')) return ''
|
||||
try {
|
||||
return new URL(path).host
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function cloudLibraryLabel(path: string) {
|
||||
try {
|
||||
const parsed = new URL(path)
|
||||
const display = normalizeCloudDisplayPath(parsed.pathname)
|
||||
const scanDir = normalizeCloudDisplayPath(parsed.searchParams.get('dir') ?? '')
|
||||
return display || scanDir || '根目录'
|
||||
} catch {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
function cloudMountDisplayPath(type: StorageType, stack: { id: string; name: string }[], child?: CloudEntry) {
|
||||
if (PATH_BASED_CLOUD.has(type)) {
|
||||
return normalizeCloudDisplayPath(child?.id ?? stack[stack.length - 1]?.id ?? '')
|
||||
}
|
||||
const parts = stack.slice(1).map((item) => item.name).filter(Boolean)
|
||||
if (child?.name) parts.push(child.name)
|
||||
return parts.map(normalizeCloudDisplayPath).filter(Boolean).join('/')
|
||||
}
|
||||
|
||||
// StorageConfigPage manages the Alist / S3 / WebDAV adapters used by
|
||||
// StorageConfigPage manages the OpenList / Alist / WebDAV / CloudDrive2 / 115 adapters used by
|
||||
// the import / playback / STRM subsystems. Mirrors the Vue UI's
|
||||
// `admin/storage/*` tabs in a tabbed React surface.
|
||||
export function StorageConfigPage() {
|
||||
@@ -79,15 +22,15 @@ export function StorageConfigPage() {
|
||||
<Cloud size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">外部存储</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
配置 OpenList / Alist / S3 / WebDAV / CloudDrive2 / 网盘(115 / 夸克)后端,支持本地转存、网盘挂载和 302/反代播放
|
||||
<h1 className="font-display text-3xl font-bold text-[var(--app-text)]">外部存储</h1>
|
||||
<p className="text-sm text-[var(--app-muted)]">
|
||||
配置 OpenList / Alist / WebDAV / CloudDrive2 / 115 后端,支持本地转存、网盘挂载和 302/反代播放
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-b border-gray-200">
|
||||
{(['openlist', 'alist', 'webdav', 'clouddrive2', 's3', 'cloud115', 'quark'] as StorageType[]).map((t) => (
|
||||
<div className="flex gap-2 border-b border-[var(--app-border)]">
|
||||
{STORAGE_TABS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setActive(t)}
|
||||
@@ -95,7 +38,7 @@ export function StorageConfigPage() {
|
||||
'border-b-2 px-4 py-2 text-sm ' +
|
||||
(active === t
|
||||
? 'border-primary-400 text-brand-500'
|
||||
: 'border-transparent text-ink-50 hover:text-white')
|
||||
: 'border-transparent text-[var(--app-muted)] hover:text-[var(--app-text)]')
|
||||
}
|
||||
>
|
||||
{TYPE_LABEL[t] ?? t}
|
||||
@@ -107,777 +50,3 @@ export function StorageConfigPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FIELD_DEFS: Record<StorageType, { key: string; label: string; secret?: boolean; placeholder?: string }[]> = {
|
||||
alist: [
|
||||
{ key: 'server', label: 'Server URL', placeholder: 'https://alist.example.com' },
|
||||
{ key: 'token', label: 'Token', secret: true },
|
||||
],
|
||||
openlist: [
|
||||
{ key: 'server', label: 'OpenList 服务地址(API / 浏览 / 挂载 / 转存)', placeholder: 'http://NAS-IP:5244' },
|
||||
{ key: 'username', label: 'OpenList 用户名' },
|
||||
{ key: 'password', label: 'OpenList 密码', secret: true },
|
||||
{ key: 'token', label: 'OpenList Token(可选,优先于用户名密码)', secret: true },
|
||||
{ key: 'url', label: 'WebDAV URL(可选兼容备用)', placeholder: '通常不用填;需要备用时填 http://NAS-IP:5244/dav/' },
|
||||
{ key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' },
|
||||
],
|
||||
webdav: [
|
||||
{ key: 'url', label: 'URL', placeholder: 'https://example.com/dav/' },
|
||||
{ key: 'username', label: '用户名' },
|
||||
{ key: 'password', label: '密码', secret: true },
|
||||
{ key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' },
|
||||
],
|
||||
s3: [
|
||||
{ key: 'endpoint', label: 'Endpoint', placeholder: 'https://s3.amazonaws.com' },
|
||||
{ key: 'region', label: 'Region', placeholder: 'us-east-1' },
|
||||
{ key: 'bucket', label: 'Bucket' },
|
||||
{ key: 'access_key', label: 'Access Key', secret: true },
|
||||
{ key: 'secret_key', label: 'Secret Key', secret: true },
|
||||
{ key: 'force_path_style', label: 'force_path_style (true/false)' },
|
||||
],
|
||||
cloud115: [
|
||||
{ key: 'cookie', label: 'Cookie(UID / CID / SEID,或扫码登录自动填充)', secret: true, placeholder: 'UID=...; CID=...; SEID=...' },
|
||||
],
|
||||
quark: [
|
||||
{ key: 'cookie', label: 'Cookie(从 pan.quark.cn 复制整段)', secret: true, placeholder: '__pus=...; __kp=...; kps=...' },
|
||||
],
|
||||
clouddrive2: [
|
||||
{ key: 'url', label: 'CloudDrive2 WebDAV URL', placeholder: 'http://host.docker.internal:19798/dav 或 http://NAS-IP:19798/dav' },
|
||||
{ key: 'username', label: '用户名' },
|
||||
{ key: 'password', label: '密码 / Token', secret: true },
|
||||
{ key: 'token', label: 'Authorization Token(可选)', secret: true, placeholder: 'Bearer ... 或 Basic ...' },
|
||||
{ key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' },
|
||||
],
|
||||
}
|
||||
|
||||
function StorageForm({ type }: { type: StorageType }) {
|
||||
const fields = useMemo(() => FIELD_DEFS[type], [type])
|
||||
const transferSupported = TRANSFER_SUPPORTED_TYPES.has(type)
|
||||
const [config, setConfig] = useState<Record<string, string>>({})
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [loggingOut, setLoggingOut] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await storageAPI.get(type)
|
||||
const next: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(r.config ?? {})) {
|
||||
next[key] = value === '********' ? '' : String(value ?? '')
|
||||
}
|
||||
for (const f of fields) {
|
||||
const v = r.config?.[f.key]
|
||||
// List() redacts secrets to "********"; show empty so the user
|
||||
// doesn't accidentally save the placeholder.
|
||||
next[f.key] = v === '********' ? '' : v ?? ''
|
||||
}
|
||||
setConfig(next)
|
||||
setEnabled(r.enabled ?? true)
|
||||
} catch {
|
||||
const next: Record<string, string> = {}
|
||||
for (const f of fields) next[f.key] = ''
|
||||
setConfig(next)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [type])
|
||||
|
||||
const onSave = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
await storageAPI.save(type, config, enabled)
|
||||
toast.success('已保存')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onTest = async () => {
|
||||
setTesting(true)
|
||||
try {
|
||||
const r = await storageAPI.test(type, config)
|
||||
if (r.ok) toast.success('连接成功')
|
||||
else toast.error(r.error ?? '连接失败')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'测试失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onLogout = async () => {
|
||||
const ok = await confirmAction({
|
||||
title: '退出云盘登录',
|
||||
message: `将清空「${TYPE_LABEL[type] ?? type}」保存的 Cookie / Token / 密码并停用该外部存储,同时移除本项目中的对应网盘挂载和媒体记录;不会删除网盘里的真实文件。`,
|
||||
confirmText: '退出登录',
|
||||
})
|
||||
if (!ok) return
|
||||
setLoggingOut(true)
|
||||
try {
|
||||
await storageAPI.logout(type)
|
||||
toast.success('已退出云盘登录、停用该存储并清理本项目挂载')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'退出登录失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setLoggingOut(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12 text-ink-50">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSave} className="glass-panel space-y-4">
|
||||
{fields.map((f) => (
|
||||
<label key={f.key} className="block">
|
||||
<span className="mb-1 block text-sm text-ink-100">
|
||||
{f.label}
|
||||
<span className="ml-2 font-mono text-[10px] text-gray-500">{f.key}</span>
|
||||
</span>
|
||||
<input
|
||||
type={f.secret ? 'password' : 'text'}
|
||||
className="input-base"
|
||||
placeholder={f.placeholder}
|
||||
value={config[f.key] ?? ''}
|
||||
onChange={(e) => setConfig((c) => ({ ...c, [f.key]: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
{type === 'cloud115' && (
|
||||
<QRLoginPanel
|
||||
type={type}
|
||||
onCookie={(c) => setConfig((cfg) => ({ ...cfg, cookie: c }))}
|
||||
/>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
{transferSupported && (
|
||||
<div className="rounded-xl border border-blue-100 bg-blue-50/60 p-3">
|
||||
<div className="mb-2 text-sm font-semibold text-ink-600">转存写入权限</div>
|
||||
<p className="mb-3 text-xs text-ink-50">
|
||||
挂载/扫描只需要读取;只有手动开启这里,才允许把本地文件转存写入到该外部存储。可随时关闭。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-ink-100">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={(config.transfer_enabled ?? '').toLowerCase() === 'true'}
|
||||
onChange={(event) => setConfig((current) => ({ ...current, transfer_enabled: event.target.checked ? 'true' : 'false' }))}
|
||||
/>
|
||||
允许转存写入
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<span>转存方式</span>
|
||||
<select
|
||||
className="input-base w-32"
|
||||
value={config.transfer_mode === 'move' ? 'move' : 'copy'}
|
||||
onChange={(event) => setConfig((current) => ({ ...current, transfer_mode: event.target.value }))}
|
||||
>
|
||||
<option value="copy">复制</option>
|
||||
<option value="move">移动</option>
|
||||
</select>
|
||||
</label>
|
||||
<span className="text-xs text-ink-50">
|
||||
复制保留本地源文件;移动只在上传成功后删除本地文件。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
{isCloud(type) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
disabled={loggingOut}
|
||||
className="rounded-lg border border-red-200 px-4 py-2 text-sm text-red-500 hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
{loggingOut ? <Loader2 size={14} className="inline animate-spin" /> : <LogOut size={14} className="inline" />}
|
||||
{' '}退出登录
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
{testing ? <Loader2 size={14} className="inline animate-spin" /> : <Send size={14} className="inline" />}
|
||||
{' '}测试
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
<StorageUploadPanel
|
||||
type={type}
|
||||
transferEnabled={(config.transfer_enabled ?? '').toLowerCase() === 'true'}
|
||||
transferMode={config.transfer_mode === 'move' ? 'move' : 'copy'}
|
||||
/>
|
||||
{isCloud(type) && <CloudBrowser type={type} />}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function StorageUploadPanel({ type, transferEnabled, transferMode }: { type: StorageType; transferEnabled: boolean; transferMode: 'copy' | 'move' }) {
|
||||
const [sourcePath, setSourcePath] = useState('')
|
||||
const [destPath, setDestPath] = useState('/MediaStationGo')
|
||||
const [recursive, setRecursive] = useState(true)
|
||||
const [includeSidecars, setIncludeSidecars] = useState(true)
|
||||
const [overwrite, setOverwrite] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const supported = TRANSFER_SUPPORTED_TYPES.has(type)
|
||||
|
||||
const submit = async () => {
|
||||
if (!supported) {
|
||||
toast.error('本地直传目前支持 OpenList / Alist / WebDAV / CloudDrive2。115/123/夸克建议通过 OpenList、CloudDrive2 或 Alist 桥接后转存。')
|
||||
return
|
||||
}
|
||||
if (!sourcePath.trim()) {
|
||||
toast.error('请填写本地源目录或文件路径')
|
||||
return
|
||||
}
|
||||
if (!transferEnabled) {
|
||||
toast.error('请先开启“允许转存写入”并保存该外部存储配置')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
const { result, error } = await storageAPI.uploadLocal(type, {
|
||||
source_path: sourcePath.trim(),
|
||||
dest_path: destPath.trim() || '/',
|
||||
recursive,
|
||||
include_sidecars: includeSidecars,
|
||||
overwrite,
|
||||
transfer_mode: transferMode,
|
||||
})
|
||||
const errText = error || (result.errors && result.errors.length > 0 ? ` · 错误 ${result.errors.length}` : '')
|
||||
const movedText = result.moved ? ` · 移动 ${result.moved}` : ''
|
||||
toast.success(`转存完成:上传 ${result.uploaded}${movedText} · 跳过 ${result.skipped} · ${fmtBytes(result.bytes)}${errText}`)
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '转存失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/70 p-4">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 font-display text-base font-semibold text-ink-600">
|
||||
{transferMode === 'move' ? <Move size={16} /> : <Copy size={16} />} 本地媒体转存到此存储
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-ink-50">
|
||||
{transferMode === 'move'
|
||||
? '移动模式会先上传到外部存储,上传成功后才删除本地源文件;远端已存在且未覆盖时不会删除本地。'
|
||||
: '复制本地媒体文件到外部存储,保留本地源文件;自动跳过远端已存在文件。'}
|
||||
</p>
|
||||
</div>
|
||||
{!supported && (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-1 text-xs text-amber-700">
|
||||
直传待接
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!supported && (
|
||||
<p className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
115 / 夸克原生上传需要私有分片上传协议。推荐把 115、123、夸克等挂载到 OpenList、CloudDrive2 或 Alist 后,在这里选择 OpenList / CloudDrive2 / Alist 转存。
|
||||
</p>
|
||||
)}
|
||||
{type === 'openlist' && (
|
||||
<p className="mb-3 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
|
||||
OpenList 优先使用服务地址 + 用户名密码/Token 调用 API 进行浏览、挂载、转存和获取播放直链;WebDAV URL 只是兼容备用。默认端口常见为 5244,未配置 HTTPS 反代时请填写 http://。
|
||||
</p>
|
||||
)}
|
||||
{type === 'clouddrive2' && (
|
||||
<p className="mb-3 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
|
||||
CloudDrive2 已经对接 115、123、阿里、夸克等网盘;这里通过它的 WebDAV 入口浏览、挂载和上传,播放默认走服务端反代以携带认证头。
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-ink-100">本地源目录 / 文件</span>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="例如 /media/电影 或 F:\\media\\Movies"
|
||||
value={sourcePath}
|
||||
onChange={(event) => setSourcePath(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-ink-100">网盘目标目录</span>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="/MediaStationGo"
|
||||
value={destPath}
|
||||
onChange={(event) => setDestPath(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4 text-sm text-ink-100">
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={recursive} onChange={(event) => setRecursive(event.target.checked)} />
|
||||
递归目录
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={includeSidecars} onChange={(event) => setIncludeSidecars(event.target.checked)} />
|
||||
同步 NFO / 海报 / 字幕
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={overwrite} onChange={(event) => setOverwrite(event.target.checked)} />
|
||||
覆盖远端同名文件
|
||||
</label>
|
||||
<button type="button" className="neon-button ml-auto" disabled={busy || !supported || !transferEnabled} onClick={submit}>
|
||||
{busy ? <Loader2 size={16} className="animate-spin" /> : transferMode === 'move' ? <Move size={16} /> : <Upload size={16} />}
|
||||
{busy ? '转存中…' : transferMode === 'move' ? '开始移动转存' : '开始复制转存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtBytes(value: number): string {
|
||||
if (!value) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let size = value
|
||||
let idx = 0
|
||||
while (size >= 1024 && idx < units.length - 1) {
|
||||
size /= 1024
|
||||
idx++
|
||||
}
|
||||
return `${size.toFixed(size >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`
|
||||
}
|
||||
|
||||
// QRLoginPanel drives the 115 QR-code login: start → render image → poll →
|
||||
// fill the cookie field on confirmation.
|
||||
function QRLoginPanel({ type, onCookie }: { type: StorageType; onCookie: (c: string) => void }) {
|
||||
const [sess, setSess] = useState<QRSession | null>(null)
|
||||
const [state, setState] = useState<string>('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sess) return
|
||||
let alive = true
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const st = await cloudAPI.qrPoll(type, sess)
|
||||
if (!alive) return
|
||||
setState(st.state)
|
||||
if (st.state === 'confirmed' && st.cookie) {
|
||||
onCookie(st.cookie)
|
||||
toast.success('扫码登录成功,Cookie 已填入,请点击保存')
|
||||
setSess(null)
|
||||
} else if (st.state === 'expired') {
|
||||
toast.error('二维码已过期,请重新获取')
|
||||
setSess(null)
|
||||
}
|
||||
} catch {
|
||||
/* keep polling */
|
||||
}
|
||||
}, 2000)
|
||||
return () => {
|
||||
alive = false
|
||||
clearInterval(timer)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sess])
|
||||
|
||||
const start = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const s = await cloudAPI.qrStart(type)
|
||||
setSess(s)
|
||||
setState('waiting')
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '获取二维码失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={start}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : <QrCode size={14} />}
|
||||
使用 115 App 扫码登录
|
||||
</button>
|
||||
{sess && (
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<img src={sess.qr_image_url} alt="115 QR" className="h-40 w-40 rounded bg-white p-1" />
|
||||
<span className="text-sm text-ink-50">
|
||||
{state === 'scanned' ? '已扫描,请在手机上确认登录…' : '请使用 115 手机 App 扫描二维码…'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// CloudBrowser lists 网盘 directories and imports a file as a 302-backed media.
|
||||
function CloudBrowser({ type }: { type: StorageType }) {
|
||||
const [stack, setStack] = useState<{ id: string; name: string }[]>([{ id: '', name: '根目录' }])
|
||||
const [items, setItems] = useState<CloudEntry[]>([])
|
||||
const [mounts, setMounts] = useState<Library[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [mounting, setMounting] = useState(false)
|
||||
const [batchMounting, setBatchMounting] = useState(false)
|
||||
const [scanBusy, setScanBusy] = useState(false)
|
||||
const [cancelBusy, setCancelBusy] = useState(false)
|
||||
const [scanStatuses, setScanStatuses] = useState<CloudScanStatus[]>([])
|
||||
const [mountMediaType, setMountMediaType] = useState('auto')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const cur = stack[stack.length - 1]
|
||||
const load = async (dir: string) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const r = await cloudAPI.list(type, dir)
|
||||
setItems(r.items ?? [])
|
||||
if (r.error) setError(r.error)
|
||||
} catch (err: unknown) {
|
||||
setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加载失败')
|
||||
setItems([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMounts = async () => {
|
||||
const libs = await libraryAPI.list({ includeHidden: true })
|
||||
setMounts(libs.filter((lib) => cloudLibraryProvider(lib.path) === type))
|
||||
}
|
||||
|
||||
const loadScanStatus = async () => {
|
||||
const r = await storageAPI.cloudScanStatus()
|
||||
setScanStatuses((r.items ?? []).filter((item) => !type || item.provider === type))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load(cur.id).catch(() => undefined)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stack.length, type])
|
||||
|
||||
useEffect(() => {
|
||||
loadMounts().catch(() => undefined)
|
||||
loadScanStatus().catch(() => undefined)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [type])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
loadScanStatus().catch(() => undefined)
|
||||
}, 3000)
|
||||
return () => window.clearInterval(timer)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [type])
|
||||
|
||||
const enter = (e: CloudEntry) => setStack((s) => [...s, { id: e.id, name: e.name }])
|
||||
const goTo = (i: number) => setStack((s) => s.slice(0, i + 1))
|
||||
const currentMountPath = () => cloudMountDisplayPath(type, stack)
|
||||
const childMountPath = (child: CloudEntry) => cloudMountDisplayPath(type, stack, child)
|
||||
|
||||
const handleMountResult = (res: unknown, label: string) => {
|
||||
const out = res as { already_mounted?: boolean; skipped?: boolean; reason?: string; library?: Library; message?: string; estimate_message?: string }
|
||||
if (out.skipped) {
|
||||
toast(`已跳过「${label}」:和已挂载目录重叠`)
|
||||
return 'skipped'
|
||||
}
|
||||
if (out.already_mounted) {
|
||||
toast(`「${label}」已经挂载,后台会刷新扫描并自动入库`)
|
||||
return 'mounted'
|
||||
}
|
||||
toast.success(`已挂载「${label}」,${out.message ?? '后台会递归扫描并自动加入媒体库'}。${out.estimate_message ?? ''}`)
|
||||
return 'mounted'
|
||||
}
|
||||
|
||||
const doImport = async (e: CloudEntry) => {
|
||||
const ref = type === 'cloud115' ? e.pick_code || e.id : e.id
|
||||
try {
|
||||
await cloudAPI.import(type, ref, e.name, e.size)
|
||||
toast.success(`已导入「${e.name}」,可在媒体库中 302 播放`)
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '导入失败')
|
||||
}
|
||||
}
|
||||
|
||||
const mountCurrent = async () => {
|
||||
setMounting(true)
|
||||
try {
|
||||
const label = TYPE_LABEL[type] ?? type
|
||||
const name = cur.id ? cur.name : label
|
||||
const res = await cloudAPI.mount(type, cur.id, name, mountMediaType, currentMountPath())
|
||||
handleMountResult(res, cur.name)
|
||||
await loadMounts()
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '挂载失败')
|
||||
} finally {
|
||||
setMounting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const mountVisibleDirectories = async () => {
|
||||
const dirs = items.filter((item) => item.is_dir)
|
||||
if (dirs.length === 0) {
|
||||
toast.error('当前目录下没有可挂载的子目录')
|
||||
return
|
||||
}
|
||||
setBatchMounting(true)
|
||||
let ok = 0
|
||||
let skipped = 0
|
||||
let failed = 0
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
const result = await cloudAPI.mount(type, dir.id, dir.name, 'auto', childMountPath(dir))
|
||||
const state = handleMountResult(result, dir.name)
|
||||
if (state === 'skipped') skipped += 1
|
||||
else ok += 1
|
||||
} catch {
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
if (failed > 0) {
|
||||
toast.error(`已挂载 ${ok} 个目录,跳过 ${skipped} 个重叠目录,失败 ${failed} 个`)
|
||||
} else {
|
||||
toast.success(`已挂载 ${ok} 个目录,跳过 ${skipped} 个重叠目录,后台会自动生成 302/STRM 播放入口`)
|
||||
}
|
||||
await loadMounts()
|
||||
setBatchMounting(false)
|
||||
}
|
||||
|
||||
const removeMount = async (lib: Library) => {
|
||||
const ok = await confirmAction({
|
||||
title: '移除网盘挂载',
|
||||
message: `仅移除「${lib.name}」在本项目中的媒体库和媒体记录,不会删除网盘文件。`,
|
||||
confirmText: '移除',
|
||||
})
|
||||
if (!ok) return
|
||||
await libraryAPI.remove(lib.id)
|
||||
toast.success('已移除挂载')
|
||||
await loadMounts()
|
||||
}
|
||||
|
||||
const scanAllCloudLibraries = async () => {
|
||||
setScanBusy(true)
|
||||
try {
|
||||
const r = await storageAPI.scanAllCloud()
|
||||
setScanStatuses(r.items ?? [])
|
||||
toast.success(r.message ?? '已开始扫描所有启用的网盘媒体库')
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '启动扫描失败')
|
||||
} finally {
|
||||
setScanBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelCloudScans = async () => {
|
||||
setCancelBusy(true)
|
||||
try {
|
||||
const r = await storageAPI.cancelCloudScan('', type)
|
||||
toast.success(r.message ?? `已中断 ${r.cancelled} 个扫描任务`)
|
||||
await loadScanStatus()
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '中断扫描失败')
|
||||
} finally {
|
||||
setCancelBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-lg border border-gray-200 p-3">
|
||||
<div className="mb-3 rounded border border-emerald-100 bg-emerald-50/60 p-2">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-ink-100">网盘媒体库扫描</div>
|
||||
<p className="text-xs text-ink-50">
|
||||
只需在系统设置填写公开域名,扫描会自动为网盘媒体生成 STRM/302 播放入口;中断后再次扫描会去重补齐。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-emerald-300 px-2 py-1 text-xs text-emerald-700 hover:bg-emerald-50"
|
||||
disabled={scanBusy}
|
||||
onClick={scanAllCloudLibraries}
|
||||
>
|
||||
{scanBusy ? <Loader2 size={13} className="inline animate-spin" /> : <RefreshCw size={13} className="inline" />}
|
||||
{' '}一键扫描全部网盘库
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-red-200 px-2 py-1 text-xs text-red-500 hover:bg-red-50"
|
||||
disabled={cancelBusy}
|
||||
onClick={cancelCloudScans}
|
||||
>
|
||||
{cancelBusy ? <Loader2 size={13} className="inline animate-spin" /> : <PauseCircle size={13} className="inline" />}
|
||||
{' '}中断当前网盘扫描
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{scanStatuses.length > 0 && (
|
||||
<div className="grid gap-1 text-xs text-ink-50 md:grid-cols-2">
|
||||
{scanStatuses.slice(0, 6).map((item) => (
|
||||
<div key={item.library_id} className="rounded bg-white/80 px-2 py-1">
|
||||
<span className="font-mono text-ink-100">{item.state}</span>
|
||||
{' · '}
|
||||
{item.provider}
|
||||
{' · 目录 '}
|
||||
{item.dirs}
|
||||
{' · 发现 '}
|
||||
{item.discovered}
|
||||
{' · 入库 '}
|
||||
{item.added + item.updated}
|
||||
{item.error ? <span className="text-red-500"> · {item.error}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{mounts.length > 0 && (
|
||||
<div className="mb-3 rounded border border-blue-100 bg-blue-50/60 p-2">
|
||||
<div className="mb-1 text-xs font-semibold text-ink-100">已挂载目录</div>
|
||||
<div className="space-y-1">
|
||||
{mounts.map((lib) => (
|
||||
<div key={lib.id} className="flex items-center gap-2 rounded bg-white/80 px-2 py-1 text-xs">
|
||||
<span className="min-w-0 flex-1 truncate text-ink-100">
|
||||
{lib.name} · {cloudLibraryLabel(lib.path)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-red-200 px-1.5 py-0.5 text-red-500 hover:bg-red-50"
|
||||
onClick={() => removeMount(lib)}
|
||||
title="移除挂载"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1 text-xs text-ink-50">
|
||||
<span className="text-ink-100">网盘资源:</span>
|
||||
{stack.map((s, i) => (
|
||||
<span key={i}>
|
||||
<button type="button" className="hover:text-brand-500" onClick={() => goTo(i)}>
|
||||
{s.name}
|
||||
</button>
|
||||
{i < stack.length - 1 && <span className="mx-1">/</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="basis-full text-xs text-ink-50">
|
||||
挂载后不会复制网盘文件;后台会递归读取该目录里的子文件夹和媒体文件,扫描到的影片会自动加入对应媒体库。小目录通常几十秒,大目录取决于网盘接口速度。
|
||||
如果已有同名同类型媒体库,会在首页和 Emby/SenPlayer 中自动归并显示。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="rounded border border-gray-200 bg-white px-2 py-0.5 text-xs text-ink-100"
|
||||
value={mountMediaType}
|
||||
onChange={(event) => setMountMediaType(event.target.value)}
|
||||
>
|
||||
<option value="auto">自动识别</option>
|
||||
<option value="movie">电影</option>
|
||||
<option value="tv">剧集</option>
|
||||
<option value="anime">动漫</option>
|
||||
<option value="variety">综艺</option>
|
||||
<option value="adult">成人</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-brand-400/40 px-2 py-0.5 text-xs text-brand-500 hover:bg-brand-400/10"
|
||||
disabled={mounting || batchMounting || loading}
|
||||
onClick={mountCurrent}
|
||||
>
|
||||
{mounting ? '挂载中…' : '挂载当前目录并归并到媒体库'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-blue-300 px-2 py-0.5 text-xs text-blue-600 hover:bg-blue-50"
|
||||
disabled={mounting || batchMounting || loading || items.every((item) => !item.is_dir)}
|
||||
onClick={mountVisibleDirectories}
|
||||
>
|
||||
{batchMounting ? '批量挂载中…' : '一键挂载当前目录下所有文件夹'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-4 text-ink-50">
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="py-2 text-sm text-red-400">{error}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-2 text-sm text-ink-50">该目录为空</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{items.map((e) => (
|
||||
<li key={e.id} className="flex items-center gap-2 py-1.5 text-sm">
|
||||
{e.is_dir ? <Folder size={15} className="text-amber-400" /> : <FileVideo size={15} className="text-blue-300" />}
|
||||
{e.is_dir ? (
|
||||
<button type="button" className="flex-1 text-left text-ink-100 hover:text-brand-500" onClick={() => enter(e)}>
|
||||
{e.name}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 truncate text-ink-100">{e.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-gray-200 px-2 py-0.5 text-xs text-ink-100 hover:bg-gray-50"
|
||||
onClick={() => doImport(e)}
|
||||
>
|
||||
导入
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
storageAPI,
|
||||
type StorageType,
|
||||
} from '../api/storage_config'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import { CloudBrowser } from './CloudBrowser'
|
||||
import { QRLoginPanel } from './QRLoginPanel'
|
||||
import { StorageFormActions } from './StorageFormActions'
|
||||
import { StorageTransferSettings } from './StorageTransferSettings'
|
||||
import { StorageUploadPanel } from './StorageUploadPanel'
|
||||
import {
|
||||
FIELD_DEFS,
|
||||
TRANSFER_SUPPORTED_TYPES,
|
||||
TYPE_LABEL,
|
||||
isCloud,
|
||||
} from './storageConfigModel'
|
||||
|
||||
export function StorageForm({ type }: { type: StorageType }) {
|
||||
const fields = useMemo(() => FIELD_DEFS[type], [type])
|
||||
const transferSupported = TRANSFER_SUPPORTED_TYPES.has(type)
|
||||
const [config, setConfig] = useState<Record<string, string>>({})
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [loggingOut, setLoggingOut] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await storageAPI.get(type)
|
||||
const next: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(r.config ?? {})) {
|
||||
next[key] = value === '********' ? '' : String(value ?? '')
|
||||
}
|
||||
for (const f of fields) {
|
||||
const v = r.config?.[f.key]
|
||||
// List() redacts secrets to "********"; show empty so the user
|
||||
// doesn't accidentally save the placeholder.
|
||||
next[f.key] = v === '********' ? '' : v ?? ''
|
||||
}
|
||||
setConfig(next)
|
||||
setEnabled(r.enabled ?? true)
|
||||
} catch {
|
||||
const next: Record<string, string> = {}
|
||||
for (const f of fields) next[f.key] = ''
|
||||
setConfig(next)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [type])
|
||||
|
||||
const onSave = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
await storageAPI.save(type, config, enabled)
|
||||
toast.success('已保存')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onTest = async () => {
|
||||
setTesting(true)
|
||||
try {
|
||||
const r = await storageAPI.test(type, config)
|
||||
if (r.ok) toast.success('连接成功')
|
||||
else toast.error(r.error ?? '连接失败')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'测试失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onLogout = async () => {
|
||||
const ok = await confirmAction({
|
||||
title: '退出云盘登录',
|
||||
message: `将清空「${TYPE_LABEL[type] ?? type}」保存的 Cookie / Token / 密码并停用该外部存储,同时移除本项目中的对应网盘挂载和媒体记录;不会删除网盘里的真实文件。`,
|
||||
confirmText: '退出登录',
|
||||
})
|
||||
if (!ok) return
|
||||
setLoggingOut(true)
|
||||
try {
|
||||
await storageAPI.logout(type)
|
||||
toast.success('已退出云盘登录、停用该存储并清理本项目挂载')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'退出登录失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setLoggingOut(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12 text-ink-50">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const transferEnabled = (config.transfer_enabled ?? '').toLowerCase() === 'true'
|
||||
const transferMode = config.transfer_mode === 'move' ? 'move' : 'copy'
|
||||
|
||||
return (
|
||||
<form onSubmit={onSave} className="glass-panel space-y-4">
|
||||
{fields.map((f) => (
|
||||
<label key={f.key} className="block">
|
||||
<span className="mb-1 block text-sm text-ink-100">
|
||||
{f.label}
|
||||
<span className="ml-2 font-mono text-[10px] text-gray-500">{f.key}</span>
|
||||
</span>
|
||||
<input
|
||||
type={f.secret ? 'password' : 'text'}
|
||||
className="input-base"
|
||||
placeholder={f.placeholder}
|
||||
value={config[f.key] ?? ''}
|
||||
onChange={(e) => setConfig((c) => ({ ...c, [f.key]: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
{type === 'cloud115' && (
|
||||
<QRLoginPanel
|
||||
type={type}
|
||||
onCookie={(c) => setConfig((cfg) => ({ ...cfg, cookie: c }))}
|
||||
/>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
{transferSupported && (
|
||||
<StorageTransferSettings
|
||||
transferEnabled={transferEnabled}
|
||||
transferMode={transferMode}
|
||||
onTransferEnabledChange={(checked) => {
|
||||
setConfig((current) => ({ ...current, transfer_enabled: checked ? 'true' : 'false' }))
|
||||
}}
|
||||
onTransferModeChange={(mode) => {
|
||||
setConfig((current) => ({ ...current, transfer_mode: mode }))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<StorageFormActions
|
||||
showLogout={isCloud(type)}
|
||||
loggingOut={loggingOut}
|
||||
testing={testing}
|
||||
saving={saving}
|
||||
onLogout={onLogout}
|
||||
onTest={onTest}
|
||||
/>
|
||||
<StorageUploadPanel
|
||||
type={type}
|
||||
transferEnabled={transferEnabled}
|
||||
transferMode={transferMode}
|
||||
/>
|
||||
{isCloud(type) && <CloudBrowser type={type} />}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Loader2, LogOut, Save, Send } from 'lucide-react'
|
||||
|
||||
type StorageFormActionsProps = {
|
||||
showLogout: boolean
|
||||
loggingOut: boolean
|
||||
testing: boolean
|
||||
saving: boolean
|
||||
onLogout: () => void
|
||||
onTest: () => void
|
||||
}
|
||||
|
||||
export function StorageFormActions({
|
||||
showLogout,
|
||||
loggingOut,
|
||||
testing,
|
||||
saving,
|
||||
onLogout,
|
||||
onTest,
|
||||
}: StorageFormActionsProps) {
|
||||
return (
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
{showLogout && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
disabled={loggingOut}
|
||||
className="rounded-lg border border-red-300/60 px-4 py-2 text-sm text-red-500 hover:bg-[var(--app-danger-soft)] disabled:opacity-50"
|
||||
>
|
||||
{loggingOut ? <Loader2 size={14} className="inline animate-spin" /> : <LogOut size={14} className="inline" />}
|
||||
{' '}退出登录
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
className="rounded-lg border border-[var(--app-border)] px-4 py-2 text-sm text-[var(--app-subtle)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
>
|
||||
{testing ? <Loader2 size={14} className="inline animate-spin" /> : <Send size={14} className="inline" />}
|
||||
{' '}测试
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -56,10 +56,10 @@ export function StoragePage() {
|
||||
title="核心操作"
|
||||
description="保留常用入口:先整理/入库,再按需进入文件或清理功能。"
|
||||
items={[
|
||||
{ to: '/files', title: '文件管理', description: '浏览服务器文件,做少量安全文件操作' },
|
||||
{ to: '/duplicates', title: '重复清理', description: '扫描重复媒体并进行安全清理', badge: '清理' },
|
||||
{ to: '/recycle', title: '回收站', description: '查看已删除资源并执行恢复或释放空间' },
|
||||
{ to: '/storage-config', title: '存储配置', description: '维护媒体存储路径和容量策略' },
|
||||
{ to: '/files', title: '文件管理', description: '浏览服务器文件,做少量安全文件操作', group: '整理入库' },
|
||||
{ to: '/storage-config', title: '存储配置', description: '维护媒体存储路径和容量策略', group: '整理入库' },
|
||||
{ to: '/duplicates', title: '重复清理', description: '扫描重复媒体并进行安全清理', badge: '清理', group: '空间维护' },
|
||||
{ to: '/recycle', title: '回收站', description: '查看已删除资源并执行恢复或释放空间', group: '空间维护' },
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
type TransferMode = 'copy' | 'move'
|
||||
|
||||
type StorageTransferSettingsProps = {
|
||||
transferEnabled: boolean
|
||||
transferMode: TransferMode
|
||||
onTransferEnabledChange: (enabled: boolean) => void
|
||||
onTransferModeChange: (mode: TransferMode) => void
|
||||
}
|
||||
|
||||
export function StorageTransferSettings({
|
||||
transferEnabled,
|
||||
transferMode,
|
||||
onTransferEnabledChange,
|
||||
onTransferModeChange,
|
||||
}: StorageTransferSettingsProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-blue-300/30 bg-blue-500/10 p-3">
|
||||
<div className="mb-2 text-sm font-semibold text-[var(--app-text)]">转存写入权限</div>
|
||||
<p className="mb-3 text-xs text-[var(--app-muted)]">
|
||||
挂载/扫描只需要读取;只有手动开启这里,才允许把本地文件转存写入到该外部存储。可随时关闭。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-[var(--app-subtle)]">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={transferEnabled}
|
||||
onChange={(event) => onTransferEnabledChange(event.target.checked)}
|
||||
/>
|
||||
允许转存写入
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<span>转存方式</span>
|
||||
<select
|
||||
className="input-base w-32"
|
||||
value={transferMode}
|
||||
onChange={(event) => onTransferModeChange(event.target.value === 'move' ? 'move' : 'copy')}
|
||||
>
|
||||
<option value="copy">复制</option>
|
||||
<option value="move">移动</option>
|
||||
</select>
|
||||
</label>
|
||||
<span className="text-xs text-[var(--app-muted)]">
|
||||
复制保留本地源文件;移动只在上传成功后删除本地文件。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react'
|
||||
import { Copy, Loader2, Move, Upload } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { storageAPI, type StorageType } from '../api/storage_config'
|
||||
import { TRANSFER_SUPPORTED_TYPES, fmtBytes } from './storageConfigModel'
|
||||
|
||||
interface StorageUploadPanelProps {
|
||||
type: StorageType
|
||||
transferEnabled: boolean
|
||||
transferMode: 'copy' | 'move'
|
||||
}
|
||||
|
||||
export function StorageUploadPanel({ type, transferEnabled, transferMode }: StorageUploadPanelProps) {
|
||||
const [sourcePath, setSourcePath] = useState('')
|
||||
const [destPath, setDestPath] = useState('/MediaStationGo')
|
||||
const [recursive, setRecursive] = useState(true)
|
||||
const [includeSidecars, setIncludeSidecars] = useState(true)
|
||||
const [overwrite, setOverwrite] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const supported = TRANSFER_SUPPORTED_TYPES.has(type)
|
||||
|
||||
const submit = async () => {
|
||||
if (!supported) {
|
||||
toast.error('本地直传目前支持 OpenList / Alist / WebDAV / CloudDrive2。115 建议通过 OpenList、CloudDrive2 或 Alist 桥接后转存。')
|
||||
return
|
||||
}
|
||||
if (!sourcePath.trim()) {
|
||||
toast.error('请填写本地源目录或文件路径')
|
||||
return
|
||||
}
|
||||
if (!transferEnabled) {
|
||||
toast.error('请先开启“允许转存写入”并保存该外部存储配置')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
const { result, error } = await storageAPI.uploadLocal(type, {
|
||||
source_path: sourcePath.trim(),
|
||||
dest_path: destPath.trim() || '/',
|
||||
recursive,
|
||||
include_sidecars: includeSidecars,
|
||||
overwrite,
|
||||
transfer_mode: transferMode,
|
||||
})
|
||||
const errText = error || (result.errors && result.errors.length > 0 ? ` · 错误 ${result.errors.length}` : '')
|
||||
const movedText = result.moved ? ` · 移动 ${result.moved}` : ''
|
||||
toast.success(`转存完成:上传 ${result.uploaded}${movedText} · 跳过 ${result.skipped} · ${fmtBytes(result.bytes)}${errText}`)
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '转存失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-4">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 font-display text-base font-semibold text-[var(--app-text)]">
|
||||
{transferMode === 'move' ? <Move size={16} /> : <Copy size={16} />} 本地媒体转存到此存储
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-[var(--app-muted)]">
|
||||
{transferMode === 'move'
|
||||
? '移动模式会先上传到外部存储,上传成功后才删除本地源文件;远端已存在且未覆盖时不会删除本地。'
|
||||
: '复制本地媒体文件到外部存储,保留本地源文件;自动跳过远端已存在文件。'}
|
||||
</p>
|
||||
</div>
|
||||
{!supported && (
|
||||
<span className="rounded-full border border-amber-300/50 bg-amber-500/10 px-2 py-1 text-xs text-amber-600">
|
||||
直传待接
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!supported && (
|
||||
<p className="mb-3 rounded-lg border border-amber-300/50 bg-amber-500/10 px-3 py-2 text-xs text-amber-600">
|
||||
115 原生上传需要私有分片上传协议。推荐把 115、123、阿里等网盘挂载到 OpenList、CloudDrive2 或 Alist 后,在这里选择 OpenList / CloudDrive2 / Alist 转存。
|
||||
</p>
|
||||
)}
|
||||
{type === 'openlist' && (
|
||||
<p className="mb-3 rounded-lg border border-blue-300/50 bg-blue-500/10 px-3 py-2 text-xs text-blue-500">
|
||||
OpenList 优先使用服务地址 + 用户名密码/Token 调用 API 进行浏览、挂载、转存和获取播放直链;WebDAV URL 只是兼容备用。默认端口常见为 5244,未配置 HTTPS 反代时请填写 http://。
|
||||
</p>
|
||||
)}
|
||||
{type === 'clouddrive2' && (
|
||||
<p className="mb-3 rounded-lg border border-blue-300/50 bg-blue-500/10 px-3 py-2 text-xs text-blue-500">
|
||||
CloudDrive2 已经对接 115、123、阿里等网盘;这里通过它的 WebDAV 入口浏览、挂载和上传,播放默认走服务端反代以携带认证头。
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-[var(--app-subtle)]">本地源目录 / 文件</span>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="例如 /media/电影 或 F:\\media\\Movies"
|
||||
value={sourcePath}
|
||||
onChange={(event) => setSourcePath(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-[var(--app-subtle)]">网盘目标目录</span>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="/MediaStationGo"
|
||||
value={destPath}
|
||||
onChange={(event) => setDestPath(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4 text-sm text-[var(--app-subtle)]">
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={recursive} onChange={(event) => setRecursive(event.target.checked)} />
|
||||
递归目录
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={includeSidecars} onChange={(event) => setIncludeSidecars(event.target.checked)} />
|
||||
同步 NFO / 海报 / 字幕
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={overwrite} onChange={(event) => setOverwrite(event.target.checked)} />
|
||||
覆盖远端同名文件
|
||||
</label>
|
||||
<button type="button" className="neon-button ml-auto" disabled={busy || !supported || !transferEnabled} onClick={submit}>
|
||||
{busy ? <Loader2 size={16} className="animate-spin" /> : transferMode === 'move' ? <Move size={16} /> : <Upload size={16} />}
|
||||
{busy ? '转存中…' : transferMode === 'move' ? '开始移动转存' : '开始复制转存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import { Search, Trash2 } from 'lucide-react'
|
||||
|
||||
import type { Media } from '../types'
|
||||
|
||||
type StrmAttachSectionProps = {
|
||||
query: string
|
||||
searching: boolean
|
||||
results: Media[]
|
||||
drafts: Record<string, string>
|
||||
doSearch: (event?: FormEvent) => void
|
||||
setQuery: (value: string) => void
|
||||
setDrafts: (updater: (drafts: Record<string, string>) => Record<string, string>) => void
|
||||
onAttach: (media: Media) => void
|
||||
onDetach: (media: Media) => void
|
||||
}
|
||||
|
||||
export function StrmAttachSection({
|
||||
query,
|
||||
searching,
|
||||
results,
|
||||
drafts,
|
||||
doSearch,
|
||||
setQuery,
|
||||
setDrafts,
|
||||
onAttach,
|
||||
onDetach,
|
||||
}: StrmAttachSectionProps) {
|
||||
return (
|
||||
<section className="glass-panel space-y-4">
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">附加 STRM URL 到已有媒体</h2>
|
||||
<form onSubmit={doSearch} className="flex flex-wrap gap-2">
|
||||
<input
|
||||
className="input-base flex-1"
|
||||
placeholder="搜索媒体标题…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={searching} className="neon-button">
|
||||
<Search size={16} /> {searching ? '搜索中…' : '搜索'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{results.map((media) => {
|
||||
const isStrm = media.container === 'strm'
|
||||
return (
|
||||
<div
|
||||
key={media.id}
|
||||
className="space-y-3 rounded-xl border border-gray-200 bg-gray-50 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-ink-600">{media.title}</div>
|
||||
<div className="truncate text-xs text-ink-50">
|
||||
{media.year > 0 && `${media.year} · `}
|
||||
{media.container || '本地文件'}
|
||||
</div>
|
||||
{isStrm && (
|
||||
<div className="mt-1 break-all rounded-lg bg-emerald-400/10 px-2 py-0.5 text-xs text-emerald-300">
|
||||
已设置 STRM
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isStrm && (
|
||||
<button
|
||||
onClick={() => onDetach(media)}
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
>
|
||||
<Trash2 size={12} className="inline" /> 清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
className="input-base flex-1"
|
||||
placeholder="https://example.com/stream.m3u8"
|
||||
value={drafts[media.id] ?? ''}
|
||||
onChange={(e) =>
|
||||
setDrafts((current) => ({ ...current, [media.id]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<button
|
||||
onClick={() => onAttach(media)}
|
||||
disabled={!(drafts[media.id] ?? '').trim()}
|
||||
className="neon-button"
|
||||
>
|
||||
{isStrm ? '替换 URL' : '设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!searching && query && results.length === 0 && (
|
||||
<p className="text-sm text-ink-50">未找到匹配的媒体。</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import { Loader2, Save, Wand2 } from 'lucide-react'
|
||||
|
||||
import type { GenerateSTRMResult } from '../api/strm'
|
||||
import type { Library } from '../types'
|
||||
import { currentOrigin, type CloudPlaybackMode } from './strmPageModel'
|
||||
|
||||
type StrmGenerateSectionProps = {
|
||||
libraries: Library[]
|
||||
generateLibraryID: string
|
||||
baseURL: string
|
||||
outputDir: string
|
||||
cloudPlaybackMode: CloudPlaybackMode
|
||||
strmPlaybackEnabled: boolean
|
||||
redirectProxyEnabled: boolean
|
||||
autoGenerate: boolean
|
||||
savingSettings: boolean
|
||||
overwrite: boolean
|
||||
generating: boolean
|
||||
generateResult: GenerateSTRMResult | null
|
||||
playbackStatus: string
|
||||
onGenerate: (event: FormEvent) => void
|
||||
saveSTRMSettings: () => void
|
||||
setGenerateLibraryID: (value: string) => void
|
||||
setBaseURL: (value: string) => void
|
||||
setOutputDir: (value: string) => void
|
||||
setCloudPlaybackMode: (value: CloudPlaybackMode) => void
|
||||
setStrmPlaybackEnabled: (value: boolean) => void
|
||||
setRedirectProxyEnabled: (value: boolean) => void
|
||||
setAutoGenerate: (value: boolean) => void
|
||||
setOverwrite: (value: boolean) => void
|
||||
}
|
||||
|
||||
export function StrmGenerateSection({
|
||||
libraries,
|
||||
generateLibraryID,
|
||||
baseURL,
|
||||
outputDir,
|
||||
cloudPlaybackMode,
|
||||
strmPlaybackEnabled,
|
||||
redirectProxyEnabled,
|
||||
autoGenerate,
|
||||
savingSettings,
|
||||
overwrite,
|
||||
generating,
|
||||
generateResult,
|
||||
playbackStatus,
|
||||
onGenerate,
|
||||
saveSTRMSettings,
|
||||
setGenerateLibraryID,
|
||||
setBaseURL,
|
||||
setOutputDir,
|
||||
setCloudPlaybackMode,
|
||||
setStrmPlaybackEnabled,
|
||||
setRedirectProxyEnabled,
|
||||
setAutoGenerate,
|
||||
setOverwrite,
|
||||
}: StrmGenerateSectionProps) {
|
||||
return (
|
||||
<section className="glass-panel space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">自动生成 STRM 文件</h2>
|
||||
<p className="text-sm text-ink-50">
|
||||
只需要填写自己的访问域名,系统会按媒体库内每个媒体批量生成可播放的 .strm 文件。
|
||||
</p>
|
||||
</div>
|
||||
<span className={`rounded-full border px-3 py-1 text-xs font-semibold ${
|
||||
strmPlaybackEnabled || redirectProxyEnabled
|
||||
? 'border-emerald-300/40 bg-emerald-400/10 text-emerald-500'
|
||||
: 'border-red-300/40 bg-red-400/10 text-red-500'
|
||||
}`}>
|
||||
{playbackStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 rounded-2xl border border-gray-200 bg-white/70 p-4 md:grid-cols-2">
|
||||
<label className="flex items-start gap-3 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 accent-primary-400"
|
||||
checked={strmPlaybackEnabled}
|
||||
onChange={(e) => setStrmPlaybackEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium text-ink-600">启用 STRMURL 播放</span>
|
||||
<span className="text-xs text-ink-50">第三方客户端可拿到 /api/stream/媒体ID 入口,适合 STRM 管理和自动生成方案。</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 accent-primary-400"
|
||||
checked={redirectProxyEnabled}
|
||||
onChange={(e) => setRedirectProxyEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium text-ink-600">启用 302/反代播放</span>
|
||||
<span className="text-xs text-ink-50">第三方客户端可拿到 /Videos/媒体ID/stream 入口,由服务端解析后 302 或必要时反代。</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 rounded-2xl border border-gray-200 bg-white/70 p-4 md:grid-cols-[1fr_1fr_auto]">
|
||||
<label className="text-sm text-ink-100">
|
||||
<span className="mb-1 block font-medium text-ink-600">两者都开启时优先</span>
|
||||
<select
|
||||
className="input-base"
|
||||
value={cloudPlaybackMode}
|
||||
onChange={(e) => setCloudPlaybackMode(e.target.value as CloudPlaybackMode)}
|
||||
disabled={!strmPlaybackEnabled || !redirectProxyEnabled}
|
||||
>
|
||||
<option value="strm">优先 STRMURL</option>
|
||||
<option value="redirect_proxy">优先 302/反代</option>
|
||||
</select>
|
||||
<span className="mt-1 block text-xs text-ink-50">只开启一个时自动使用已开启的播放方式;两个都关闭时云盘媒体不向第三方提供播放。</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 accent-primary-400"
|
||||
checked={autoGenerate}
|
||||
onChange={(e) => setAutoGenerate(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium text-ink-600">扫描后自动刷新 STRM 文件</span>
|
||||
<span className="text-xs text-ink-50">默认关闭,避免扫描大型网盘库时重复写文件。</span>
|
||||
</span>
|
||||
</label>
|
||||
<button type="button" className="neon-button self-center" disabled={savingSettings} onClick={saveSTRMSettings}>
|
||||
{savingSettings ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
保存开关
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={onGenerate} className="grid gap-3 md:grid-cols-4">
|
||||
<select
|
||||
required
|
||||
className="input-base"
|
||||
value={generateLibraryID}
|
||||
onChange={(e) => setGenerateLibraryID(e.target.value)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
选择媒体库
|
||||
</option>
|
||||
<option value="*">全部媒体库</option>
|
||||
{libraries.map((library) => (
|
||||
<option key={library.id} value={library.id}>
|
||||
{library.name} ({library.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
required
|
||||
className="input-base md:col-span-2"
|
||||
placeholder="http://NAS-IP:18080 或 https://media.example.com"
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-2xl border border-primary-400/40 px-3 py-2 text-sm text-brand-500 transition hover:bg-primary-400/10"
|
||||
onClick={() => setBaseURL(currentOrigin())}
|
||||
>
|
||||
使用当前访问地址
|
||||
</button>
|
||||
<label className="flex items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={overwrite}
|
||||
onChange={(e) => setOverwrite(e.target.checked)}
|
||||
/>
|
||||
覆盖已存在
|
||||
</label>
|
||||
<input
|
||||
className="input-base md:col-span-3"
|
||||
placeholder="输出目录可留空,默认写入 data/strm/分类/子分类"
|
||||
value={outputDir}
|
||||
onChange={(e) => setOutputDir(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={generating || !generateLibraryID || !baseURL.trim()} className="neon-button md:col-span-4">
|
||||
{generating ? <Loader2 size={16} className="animate-spin" /> : <Wand2 size={16} />}
|
||||
{generating ? '生成中…' : '批量生成 STRM'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-xs text-sand-500">
|
||||
生成内容为 <code>域名 + /api/stream/媒体ID?token=...</code>;第三方客户端播放优先方式由上方「STRMURL / 302反代」模式决定。域名会同步保存到系统设置中的「公开访问域名 / STRM 域名」。
|
||||
</p>
|
||||
{generateResult && (
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 text-sm text-ink-50">
|
||||
<div className="font-semibold text-ink-600">
|
||||
输出目录:{generateResult.output_dir}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
新增 {generateResult.generated} · 更新 {generateResult.updated} · 跳过 {generateResult.skipped} · 清理 {generateResult.cleaned || 0}
|
||||
</div>
|
||||
{generateResult.errors && generateResult.errors.length > 0 && (
|
||||
<div className="mt-2 text-red-500">
|
||||
失败 {generateResult.errors.length} 条:{generateResult.errors.slice(0, 3).join(';')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
|
||||
import type { Library } from '../types'
|
||||
|
||||
type StrmImportSectionProps = {
|
||||
libraries: Library[]
|
||||
libraryID: string
|
||||
title: string
|
||||
url: string
|
||||
importing: boolean
|
||||
onImport: (event: FormEvent) => void
|
||||
setLibraryID: (value: string) => void
|
||||
setTitle: (value: string) => void
|
||||
setURL: (value: string) => void
|
||||
}
|
||||
|
||||
export function StrmImportSection({
|
||||
libraries,
|
||||
libraryID,
|
||||
title,
|
||||
url,
|
||||
importing,
|
||||
onImport,
|
||||
setLibraryID,
|
||||
setTitle,
|
||||
setURL,
|
||||
}: StrmImportSectionProps) {
|
||||
return (
|
||||
<section className="glass-panel space-y-4">
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">导入 STRM 条目</h2>
|
||||
<form onSubmit={onImport} className="grid gap-3 md:grid-cols-4">
|
||||
<select
|
||||
required
|
||||
className="input-base"
|
||||
value={libraryID}
|
||||
onChange={(e) => setLibraryID(e.target.value)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
选择媒体库
|
||||
</option>
|
||||
{libraries.map((library) => (
|
||||
<option key={library.id} value={library.id}>
|
||||
{library.name} ({library.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="标题"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
required
|
||||
className="input-base md:col-span-2"
|
||||
placeholder="https://example.com/movie.mp4"
|
||||
value={url}
|
||||
onChange={(e) => setURL(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={importing} className="neon-button md:col-span-4">
|
||||
<Plus size={16} /> {importing ? '导入中…' : '导入'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-xs text-sand-500">
|
||||
导入后会创建一条 container=strm 的媒体记录,播放时会 302 重定向到该 URL。
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+54
-508
@@ -1,37 +1,8 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Link as LinkIcon, Loader2, Plus, Save, Search, Trash2, Wand2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import { libraryAPI, mediaAPI } from '../api/library'
|
||||
import { strmAPI, type GenerateSTRMResult } from '../api/strm'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import type { Library, Media } from '../types'
|
||||
|
||||
type CloudPlaybackMode = 'strm' | 'redirect_proxy'
|
||||
|
||||
function currentOrigin() {
|
||||
if (typeof window === 'undefined') return ''
|
||||
return window.location.origin.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function isLocalPlaybackBase(raw: string) {
|
||||
try {
|
||||
const u = new URL(raw)
|
||||
const host = u.hostname.toLowerCase()
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function preferredSTRMBaseURL(saved: string) {
|
||||
const current = currentOrigin()
|
||||
const trimmed = saved.trim().replace(/\/+$/, '')
|
||||
if (!trimmed) return current
|
||||
if (current && isLocalPlaybackBase(trimmed) && trimmed !== current) return current
|
||||
return trimmed
|
||||
}
|
||||
import { StrmAttachSection } from './StrmAttachSection'
|
||||
import { StrmGenerateSection } from './StrmGenerateSection'
|
||||
import { StrmImportSection } from './StrmImportSection'
|
||||
import { StrmPageHeader } from './StrmPageHeader'
|
||||
import { useStrmPage } from './useStrmPage'
|
||||
|
||||
// StrmPage exposes the URL-as-file admin tooling backed by the Go server:
|
||||
// - import a brand-new media row directly from a (library, title, url)
|
||||
@@ -40,486 +11,61 @@ function preferredSTRMBaseURL(saved: string) {
|
||||
// issues a 302 redirect to the remote source instead of opening a
|
||||
// local file.
|
||||
export function StrmPage() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
|
||||
// Auto-generate state
|
||||
const [generateLibraryID, setGenerateLibraryID] = useState('')
|
||||
const [baseURL, setBaseURL] = useState('')
|
||||
const [outputDir, setOutputDir] = useState('')
|
||||
const [cloudPlaybackMode, setCloudPlaybackMode] = useState<CloudPlaybackMode>('redirect_proxy')
|
||||
const [strmPlaybackEnabled, setStrmPlaybackEnabled] = useState(false)
|
||||
const [redirectProxyEnabled, setRedirectProxyEnabled] = useState(true)
|
||||
const [autoGenerate, setAutoGenerate] = useState(false)
|
||||
const [savingSettings, setSavingSettings] = useState(false)
|
||||
const [overwrite, setOverwrite] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [generateResult, setGenerateResult] = useState<GenerateSTRMResult | null>(null)
|
||||
|
||||
// Import form state
|
||||
const [libraryID, setLibraryID] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [url, setURL] = useState('')
|
||||
const [importing, setImporting] = useState(false)
|
||||
|
||||
// Search + attach state
|
||||
const [query, setQuery] = useState('')
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [results, setResults] = useState<Media[]>([])
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
libraryAPI.list().then(setLibraries).catch(() => undefined)
|
||||
adminAPI
|
||||
.listSettings()
|
||||
.then((rows) => {
|
||||
const settings = Object.fromEntries(rows.map((row) => [row.key, row.value]))
|
||||
setBaseURL(preferredSTRMBaseURL(settings['strm.base_url'] || settings['app.server_url'] || ''))
|
||||
setOutputDir(settings['strm.output_dir'] || '')
|
||||
const mode = settings['cloud.playback_mode']
|
||||
const nextMode =
|
||||
mode === 'strm' || mode === 'redirect_proxy'
|
||||
? mode
|
||||
: settings['strm.enabled'] === 'true'
|
||||
? 'strm'
|
||||
: 'redirect_proxy'
|
||||
setCloudPlaybackMode(nextMode)
|
||||
setStrmPlaybackEnabled(
|
||||
settings['cloud.playback_strm_enabled'] != null
|
||||
? settings['cloud.playback_strm_enabled'] === 'true'
|
||||
: settings['strm.enabled'] === 'true' || nextMode === 'strm',
|
||||
)
|
||||
setRedirectProxyEnabled(settings['cloud.playback_redirect_proxy_enabled'] !== 'false')
|
||||
setAutoGenerate(settings['strm.auto_generate_enabled'] === 'true')
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
// Default the import library to the first available one once loaded.
|
||||
useEffect(() => {
|
||||
if (!libraryID && libraries[0]) setLibraryID(libraries[0].id)
|
||||
if (!generateLibraryID && libraries[0]) setGenerateLibraryID(libraries[0].id)
|
||||
}, [libraries, libraryID, generateLibraryID])
|
||||
|
||||
const onGenerate = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!generateLibraryID || !baseURL.trim()) return
|
||||
if (!/^https?:\/\//i.test(baseURL.trim())) {
|
||||
toast.error('域名必须以 http:// 或 https:// 开头')
|
||||
return
|
||||
}
|
||||
setGenerating(true)
|
||||
try {
|
||||
const result = await strmAPI.generate({
|
||||
library_id: generateLibraryID,
|
||||
base_url: baseURL.trim().replace(/\/+$/, ''),
|
||||
output_dir: outputDir.trim(),
|
||||
overwrite,
|
||||
enabled: autoGenerate,
|
||||
include_local: true,
|
||||
})
|
||||
setGenerateResult(result)
|
||||
setOutputDir(result.output_dir || outputDir)
|
||||
toast.success(`生成完成:新增 ${result.generated} · 更新 ${result.updated} · 跳过 ${result.skipped}`)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'生成失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveSTRMSettings = async () => {
|
||||
setSavingSettings(true)
|
||||
try {
|
||||
const effectiveMode: CloudPlaybackMode =
|
||||
strmPlaybackEnabled && !redirectProxyEnabled
|
||||
? 'strm'
|
||||
: !strmPlaybackEnabled && redirectProxyEnabled
|
||||
? 'redirect_proxy'
|
||||
: cloudPlaybackMode
|
||||
await Promise.all([
|
||||
adminAPI.updateSetting('cloud.playback_mode', effectiveMode),
|
||||
adminAPI.updateSetting('cloud.playback_strm_enabled', String(strmPlaybackEnabled)),
|
||||
adminAPI.updateSetting('cloud.playback_redirect_proxy_enabled', String(redirectProxyEnabled)),
|
||||
adminAPI.updateSetting('strm.enabled', String(strmPlaybackEnabled)),
|
||||
adminAPI.updateSetting('strm.auto_generate_enabled', String(autoGenerate)),
|
||||
])
|
||||
setCloudPlaybackMode(effectiveMode)
|
||||
toast.success(
|
||||
strmPlaybackEnabled || redirectProxyEnabled
|
||||
? `播放设置已保存:优先 ${effectiveMode === 'strm' ? 'STRMURL' : '302/反代'}`
|
||||
: '播放设置已保存:云盘第三方播放已关闭',
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'保存 STRM 开关失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSavingSettings(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onImport = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!libraryID || !title.trim() || !url.trim()) return
|
||||
if (!/^https?:\/\//i.test(url.trim())) {
|
||||
toast.error('URL 必须以 http:// 或 https:// 开头')
|
||||
return
|
||||
}
|
||||
setImporting(true)
|
||||
try {
|
||||
await strmAPI.importURL(libraryID, title.trim(), url.trim())
|
||||
toast.success(`已导入「${title.trim()}」`)
|
||||
setTitle('')
|
||||
setURL('')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'导入失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const doSearch = async (e?: FormEvent) => {
|
||||
e?.preventDefault()
|
||||
if (!query.trim()) return
|
||||
setSearching(true)
|
||||
try {
|
||||
const r = await mediaAPI.search(query.trim(), 30)
|
||||
setResults(r.items ?? [])
|
||||
} catch {
|
||||
toast.error('搜索失败')
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onAttach = async (m: Media) => {
|
||||
const next = (drafts[m.id] ?? '').trim()
|
||||
if (!next) return
|
||||
if (!/^https?:\/\//i.test(next)) {
|
||||
toast.error('URL 必须以 http:// 或 https:// 开头')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await strmAPI.set(m.id, next)
|
||||
toast.success('已设置 STRM URL')
|
||||
// Optimistic update so the user sees the new state without a re-search.
|
||||
setResults((rs) =>
|
||||
rs.map((x) => (x.id === m.id ? ({ ...x, container: 'strm' } as Media) : x)),
|
||||
)
|
||||
setDrafts((d) => ({ ...d, [m.id]: '' }))
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'设置失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const onDetach = async (m: Media) => {
|
||||
if (!(await confirmAction({ title: '清除 STRM URL', message: `清除「${m.title}」的 STRM URL?`, confirmText: '清除' }))) return
|
||||
try {
|
||||
await strmAPI.clear(m.id)
|
||||
toast.success('已清除')
|
||||
setResults((rs) =>
|
||||
rs.map((x) =>
|
||||
x.id === m.id ? ({ ...x, container: x.container === 'strm' ? '' : x.container } as Media) : x,
|
||||
),
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'清除失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const playbackStatus =
|
||||
strmPlaybackEnabled && redirectProxyEnabled
|
||||
? `两者开启 · 优先${cloudPlaybackMode === 'strm' ? 'STRMURL' : '302/反代'}`
|
||||
: strmPlaybackEnabled
|
||||
? '仅 STRMURL'
|
||||
: redirectProxyEnabled
|
||||
? '仅 302/反代'
|
||||
: '云盘第三方播放关闭'
|
||||
const strm = useStrmPage()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-400/10 text-amber-400">
|
||||
<LinkIcon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">STRM 管理</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
将外部 HTTP / WebDAV / Alist 直链以"虚拟文件"形式纳入媒体库
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<StrmPageHeader />
|
||||
|
||||
<section className="glass-panel space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">自动生成 STRM 文件</h2>
|
||||
<p className="text-sm text-ink-50">
|
||||
只需要填写自己的访问域名,系统会按媒体库内每个媒体批量生成可播放的 .strm 文件。
|
||||
</p>
|
||||
</div>
|
||||
<span className={`rounded-full border px-3 py-1 text-xs font-semibold ${
|
||||
strmPlaybackEnabled || redirectProxyEnabled
|
||||
? 'border-emerald-300/40 bg-emerald-400/10 text-emerald-500'
|
||||
: 'border-red-300/40 bg-red-400/10 text-red-500'
|
||||
}`}>
|
||||
{playbackStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 rounded-2xl border border-gray-200 bg-white/70 p-4 md:grid-cols-2">
|
||||
<label className="flex items-start gap-3 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 accent-primary-400"
|
||||
checked={strmPlaybackEnabled}
|
||||
onChange={(e) => setStrmPlaybackEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium text-ink-600">启用 STRMURL 播放</span>
|
||||
<span className="text-xs text-ink-50">第三方客户端可拿到 /api/stream/媒体ID 入口,适合 STRM 管理和自动生成方案。</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 accent-primary-400"
|
||||
checked={redirectProxyEnabled}
|
||||
onChange={(e) => setRedirectProxyEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium text-ink-600">启用 302/反代播放</span>
|
||||
<span className="text-xs text-ink-50">第三方客户端可拿到 /Videos/媒体ID/stream 入口,由服务端解析后 302 或必要时反代。</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 rounded-2xl border border-gray-200 bg-white/70 p-4 md:grid-cols-[1fr_1fr_auto]">
|
||||
<label className="text-sm text-ink-100">
|
||||
<span className="mb-1 block font-medium text-ink-600">两者都开启时优先</span>
|
||||
<select
|
||||
className="input-base"
|
||||
value={cloudPlaybackMode}
|
||||
onChange={(e) => setCloudPlaybackMode(e.target.value as CloudPlaybackMode)}
|
||||
disabled={!strmPlaybackEnabled || !redirectProxyEnabled}
|
||||
>
|
||||
<option value="strm">优先 STRMURL</option>
|
||||
<option value="redirect_proxy">优先 302/反代</option>
|
||||
</select>
|
||||
<span className="mt-1 block text-xs text-ink-50">只开启一个时自动使用已开启的播放方式;两个都关闭时云盘媒体不向第三方提供播放。</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 accent-primary-400"
|
||||
checked={autoGenerate}
|
||||
onChange={(e) => setAutoGenerate(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium text-ink-600">扫描后自动刷新 STRM 文件</span>
|
||||
<span className="text-xs text-ink-50">默认关闭,避免扫描大型网盘库时重复写文件。</span>
|
||||
</span>
|
||||
</label>
|
||||
<button type="button" className="neon-button self-center" disabled={savingSettings} onClick={saveSTRMSettings}>
|
||||
{savingSettings ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
保存开关
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={onGenerate} className="grid gap-3 md:grid-cols-4">
|
||||
<select
|
||||
required
|
||||
className="input-base"
|
||||
value={generateLibraryID}
|
||||
onChange={(e) => setGenerateLibraryID(e.target.value)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
选择媒体库
|
||||
</option>
|
||||
{libraries.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.name} ({l.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
required
|
||||
className="input-base md:col-span-2"
|
||||
placeholder="http://NAS-IP:18080 或 https://media.example.com"
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-2xl border border-primary-400/40 px-3 py-2 text-sm text-brand-500 transition hover:bg-primary-400/10"
|
||||
onClick={() => setBaseURL(currentOrigin())}
|
||||
>
|
||||
使用当前访问地址
|
||||
</button>
|
||||
<label className="flex items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={overwrite}
|
||||
onChange={(e) => setOverwrite(e.target.checked)}
|
||||
/>
|
||||
覆盖已存在
|
||||
</label>
|
||||
<input
|
||||
className="input-base md:col-span-3"
|
||||
placeholder="输出目录可留空,默认写入 data/strm/媒体库名"
|
||||
value={outputDir}
|
||||
onChange={(e) => setOutputDir(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={generating || !generateLibraryID || !baseURL.trim()} className="neon-button md:col-span-4">
|
||||
{generating ? <Loader2 size={16} className="animate-spin" /> : <Wand2 size={16} />}
|
||||
{generating ? '生成中…' : '批量生成 STRM'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-xs text-sand-500">
|
||||
生成内容为 <code>域名 + /api/stream/媒体ID?token=...</code>;第三方客户端播放优先方式由上方「STRMURL / 302反代」模式决定。域名会同步保存到系统设置中的「公开访问域名 / STRM 域名」。
|
||||
</p>
|
||||
{generateResult && (
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 text-sm text-ink-50">
|
||||
<div className="font-semibold text-ink-600">
|
||||
输出目录:{generateResult.output_dir}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
新增 {generateResult.generated} · 更新 {generateResult.updated} · 跳过 {generateResult.skipped}
|
||||
</div>
|
||||
{generateResult.errors && generateResult.errors.length > 0 && (
|
||||
<div className="mt-2 text-red-500">
|
||||
失败 {generateResult.errors.length} 条:{generateResult.errors.slice(0, 3).join(';')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<StrmGenerateSection
|
||||
libraries={strm.libraries}
|
||||
generateLibraryID={strm.generate.generateLibraryID}
|
||||
baseURL={strm.generate.baseURL}
|
||||
outputDir={strm.generate.outputDir}
|
||||
cloudPlaybackMode={strm.generate.cloudPlaybackMode}
|
||||
strmPlaybackEnabled={strm.generate.strmPlaybackEnabled}
|
||||
redirectProxyEnabled={strm.generate.redirectProxyEnabled}
|
||||
autoGenerate={strm.generate.autoGenerate}
|
||||
savingSettings={strm.generate.savingSettings}
|
||||
overwrite={strm.generate.overwrite}
|
||||
generating={strm.generate.generating}
|
||||
generateResult={strm.generate.generateResult}
|
||||
playbackStatus={strm.generate.playbackStatus}
|
||||
onGenerate={strm.generate.onGenerate}
|
||||
saveSTRMSettings={strm.generate.saveSTRMSettings}
|
||||
setGenerateLibraryID={strm.generate.setGenerateLibraryID}
|
||||
setBaseURL={strm.generate.setBaseURL}
|
||||
setOutputDir={strm.generate.setOutputDir}
|
||||
setCloudPlaybackMode={strm.generate.setCloudPlaybackMode}
|
||||
setStrmPlaybackEnabled={strm.generate.setStrmPlaybackEnabled}
|
||||
setRedirectProxyEnabled={strm.generate.setRedirectProxyEnabled}
|
||||
setAutoGenerate={strm.generate.setAutoGenerate}
|
||||
setOverwrite={strm.generate.setOverwrite}
|
||||
/>
|
||||
|
||||
{/* Import a new STRM-only entry. */}
|
||||
<section className="glass-panel space-y-4">
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">导入 STRM 条目</h2>
|
||||
<form onSubmit={onImport} className="grid gap-3 md:grid-cols-4">
|
||||
<select
|
||||
required
|
||||
className="input-base"
|
||||
value={libraryID}
|
||||
onChange={(e) => setLibraryID(e.target.value)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
选择媒体库
|
||||
</option>
|
||||
{libraries.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.name} ({l.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="标题"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
required
|
||||
className="input-base md:col-span-2"
|
||||
placeholder="https://example.com/movie.mp4"
|
||||
value={url}
|
||||
onChange={(e) => setURL(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={importing} className="neon-button md:col-span-4">
|
||||
<Plus size={16} /> {importing ? '导入中…' : '导入'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-xs text-sand-500">
|
||||
导入后会创建一条 container=strm 的媒体记录,播放时会 302 重定向到该 URL。
|
||||
</p>
|
||||
</section>
|
||||
<StrmImportSection
|
||||
libraries={strm.libraries}
|
||||
libraryID={strm.importForm.libraryID}
|
||||
title={strm.importForm.title}
|
||||
url={strm.importForm.url}
|
||||
importing={strm.importForm.importing}
|
||||
onImport={strm.importForm.onImport}
|
||||
setLibraryID={strm.importForm.setLibraryID}
|
||||
setTitle={strm.importForm.setTitle}
|
||||
setURL={strm.importForm.setURL}
|
||||
/>
|
||||
|
||||
{/* Attach / detach STRM URL on existing media. */}
|
||||
<section className="glass-panel space-y-4">
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">附加 STRM URL 到已有媒体</h2>
|
||||
<form onSubmit={doSearch} className="flex flex-wrap gap-2">
|
||||
<input
|
||||
className="input-base flex-1"
|
||||
placeholder="搜索媒体标题…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<button type="submit" disabled={searching} className="neon-button">
|
||||
<Search size={16} /> {searching ? '搜索中…' : '搜索'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{results.map((m) => {
|
||||
const isStrm = m.container === 'strm'
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className="rounded-xl border border-gray-200 bg-gray-50 p-4 space-y-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-ink-600">{m.title}</div>
|
||||
<div className="truncate text-xs text-ink-50">
|
||||
{m.year > 0 && `${m.year} · `}
|
||||
{m.container || '本地文件'}
|
||||
</div>
|
||||
{isStrm && (
|
||||
<div className="mt-1 break-all rounded-lg bg-emerald-400/10 px-2 py-0.5 text-xs text-emerald-300">
|
||||
已设置 STRM
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isStrm && (
|
||||
<button
|
||||
onClick={() => onDetach(m)}
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
>
|
||||
<Trash2 size={12} className="inline" /> 清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
className="input-base flex-1"
|
||||
placeholder="https://example.com/stream.m3u8"
|
||||
value={drafts[m.id] ?? ''}
|
||||
onChange={(e) =>
|
||||
setDrafts((d) => ({ ...d, [m.id]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<button
|
||||
onClick={() => onAttach(m)}
|
||||
disabled={!(drafts[m.id] ?? '').trim()}
|
||||
className="neon-button"
|
||||
>
|
||||
{isStrm ? '替换 URL' : '设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!searching && query && results.length === 0 && (
|
||||
<p className="text-sm text-ink-50">未找到匹配的媒体。</p>
|
||||
)}
|
||||
</section>
|
||||
<StrmAttachSection
|
||||
query={strm.attach.query}
|
||||
searching={strm.attach.searching}
|
||||
results={strm.attach.results}
|
||||
drafts={strm.attach.drafts}
|
||||
doSearch={strm.attach.doSearch}
|
||||
setQuery={strm.attach.setQuery}
|
||||
setDrafts={strm.attach.setDrafts}
|
||||
onAttach={strm.attach.onAttach}
|
||||
onDetach={strm.attach.onDetach}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Link as LinkIcon } from 'lucide-react'
|
||||
|
||||
export function StrmPageHeader() {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-400/10 text-amber-400">
|
||||
<LinkIcon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">STRM 管理</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
将外部 HTTP / WebDAV / Alist 直链以"虚拟文件"形式纳入媒体库
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { CalendarClock, CheckCircle2, Film, Pencil, Play, ShieldCheck, Trash2 } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import type { Subscription } from '../types'
|
||||
import { subscriptionProgressLabel, subscriptionRuleBadges } from './subscriptionPageModel'
|
||||
|
||||
interface SubscriptionCardProps {
|
||||
subscription: Subscription
|
||||
onEdit: (subscription: Subscription) => void
|
||||
onRunNow: (subscription: Subscription) => void
|
||||
onRemove: (subscription: Subscription) => void
|
||||
}
|
||||
|
||||
export function SubscriptionCard({ subscription, onEdit, onRunNow, onRemove }: SubscriptionCardProps) {
|
||||
return (
|
||||
<article className="group overflow-hidden rounded-3xl border border-white/70 bg-white shadow-sm transition hover:-translate-y-1 hover:shadow-xl">
|
||||
<div className="relative flex gap-4 p-4">
|
||||
<div className="relative h-36 w-24 flex-shrink-0 overflow-hidden rounded-2xl bg-gradient-to-br from-primary-400/15 to-surface-200 shadow-inner">
|
||||
{subscription.poster_url ? (
|
||||
<img src={imageURL(subscription.poster_url, subscription.updated_at)} alt={subscription.name} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-2 text-center text-xs font-semibold text-brand-500">
|
||||
<Film size={22} />
|
||||
<span className="line-clamp-3">{subscription.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div>
|
||||
<div className="mb-1 flex flex-wrap gap-1.5">
|
||||
<span className="rounded-full bg-primary-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase text-brand-500">
|
||||
{subscription.source || 'RSS'}
|
||||
</span>
|
||||
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-semibold text-sand-500">
|
||||
{[subscription.media_type, subscription.media_category].filter(Boolean).join(' / ') || '自动分类'}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
'rounded-full px-2 py-0.5 text-[10px] font-semibold ' +
|
||||
(subscription.enabled ? 'bg-emerald-50 text-emerald-600' : 'bg-gray-100 text-gray-500')
|
||||
}
|
||||
>
|
||||
{subscription.enabled ? '启用中' : '已停用'}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="truncate font-display text-lg font-semibold text-ink-600" title={subscription.name}>
|
||||
{subscription.name}
|
||||
</h2>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-ink-50">
|
||||
{subscription.overview || subscription.filter || '已隐藏订阅源地址,避免多用户场景泄露私有 RSS Token。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-xs text-ink-100">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShieldCheck size={13} className="text-brand-500" />
|
||||
<span>订阅源已脱敏</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CalendarClock size={13} className="text-brand-500" />
|
||||
<span>{subscription.last_run_at ? new Date(subscription.last_run_at).toLocaleString() : '尚未运行'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle2 size={13} className="text-brand-500" />
|
||||
<span>{subscriptionProgressLabel(subscription)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{subscriptionRuleBadges(subscription).map((label) => (
|
||||
<span key={label} className="rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] text-ink-100">
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-gray-100 bg-gray-50/70 px-4 py-3">
|
||||
<button
|
||||
className="rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 hover:bg-gray-50"
|
||||
onClick={() => onEdit(subscription)}
|
||||
>
|
||||
<Pencil size={13} className="mr-1 inline" />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
className="rounded-xl border border-primary-400/40 bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 hover:bg-primary-400/10"
|
||||
onClick={() => onRunNow(subscription)}
|
||||
>
|
||||
<Play size={13} className="mr-1 inline" />
|
||||
运行
|
||||
</button>
|
||||
<button
|
||||
className="rounded-xl border border-red-400/40 bg-white px-3 py-1.5 text-xs font-semibold text-red-400 hover:bg-red-400/10"
|
||||
onClick={() => onRemove(subscription)}
|
||||
>
|
||||
<Trash2 size={13} className="mr-1 inline" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { FormEvent } from 'react'
|
||||
import { Plus, Save } from 'lucide-react'
|
||||
|
||||
import type { SubscriptionFormValues } from './subscriptionFormModel'
|
||||
|
||||
interface SubscriptionFormProps {
|
||||
values: SubscriptionFormValues
|
||||
editing: boolean
|
||||
onSubmit: (event: FormEvent) => void
|
||||
onCancelEdit: () => void
|
||||
onChange: <K extends keyof SubscriptionFormValues>(key: K, value: SubscriptionFormValues[K]) => void
|
||||
}
|
||||
|
||||
export function SubscriptionForm({ values, editing, onSubmit, onCancelEdit, onChange }: SubscriptionFormProps) {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="glass-panel grid gap-3 md:grid-cols-4">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="名称(显示用)"
|
||||
value={values.name}
|
||||
onChange={(e) => onChange('name', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="RSS 地址"
|
||||
value={values.feed}
|
||||
onChange={(e) => onChange('feed', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="过滤器(正则,可选)"
|
||||
value={values.filter}
|
||||
onChange={(e) => onChange('filter', e.target.value)}
|
||||
/>
|
||||
<select className="input-base" value={values.mediaType} onChange={(e) => onChange('mediaType', e.target.value)}>
|
||||
<option value="">自动识别类型</option>
|
||||
<option value="movie">电影</option>
|
||||
<option value="tv">电视剧</option>
|
||||
<option value="anime">动漫</option>
|
||||
<option value="variety">综艺</option>
|
||||
</select>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="二级分类覆盖(如 综艺/日番,可选)"
|
||||
value={values.mediaCategory}
|
||||
onChange={(e) => onChange('mediaCategory', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input-base md:col-span-2"
|
||||
placeholder="下载根目录覆盖(可选,默认使用下载器保存路径)"
|
||||
value={values.savePath}
|
||||
onChange={(e) => onChange('savePath', e.target.value)}
|
||||
/>
|
||||
<select className="input-base" value={values.searchMode} onChange={(e) => onChange('searchMode', e.target.value)}>
|
||||
<option value="keyword">标题关键词搜索</option>
|
||||
<option value="imdb">IMDB ID 搜索</option>
|
||||
</select>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="IMDB ID,如 tt1160419"
|
||||
value={values.imdbID}
|
||||
onChange={(e) => onChange('imdbID', e.target.value)}
|
||||
/>
|
||||
<select className="input-base" value={values.resolution} onChange={(e) => onChange('resolution', e.target.value)}>
|
||||
<option value="best">分辨率自动择优</option>
|
||||
<option value="2160p">2160p / 4K</option>
|
||||
<option value="1080p">1080p</option>
|
||||
<option value="720p">720p</option>
|
||||
</select>
|
||||
<select className="input-base" value={values.quality} onChange={(e) => onChange('quality', e.target.value)}>
|
||||
<option value="">质量不限</option>
|
||||
<option value="remux">REMUX</option>
|
||||
<option value="bluray">BluRay</option>
|
||||
<option value="web-dl">WEB-DL</option>
|
||||
<option value="hdtv">HDTV</option>
|
||||
</select>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="特效/音轨 hdr,dolby-vision,atmos"
|
||||
value={values.effects}
|
||||
onChange={(e) => onChange('effects', e.target.value)}
|
||||
/>
|
||||
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={values.washEnabled}
|
||||
onChange={(e) => onChange('washEnabled', e.target.checked)}
|
||||
/>
|
||||
启用洗版择优
|
||||
</label>
|
||||
<select
|
||||
className="input-base disabled:opacity-50"
|
||||
disabled={!values.washEnabled}
|
||||
value={values.washPriority}
|
||||
onChange={(e) => onChange('washPriority', e.target.value)}
|
||||
>
|
||||
<option value="balanced">洗版:均衡</option>
|
||||
<option value="resolution">洗版:分辨率优先</option>
|
||||
<option value="quality">洗版:片源质量优先</option>
|
||||
<option value="effects">洗版:HDR/DV/Atmos 优先</option>
|
||||
<option value="seeders">洗版:做种数优先</option>
|
||||
</select>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="发布组白名单,如 FRDS,OurTV"
|
||||
value={values.releaseGroups}
|
||||
onChange={(e) => onChange('releaseGroups', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="排除词,如 cam,ts,tc"
|
||||
value={values.excludeWords}
|
||||
onChange={(e) => onChange('excludeWords', e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="neon-button md:col-span-1">
|
||||
{editing ? <Save size={16} /> : <Plus size={16} />}
|
||||
{editing ? '保存' : '添加'}
|
||||
</button>
|
||||
{editing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelEdit}
|
||||
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
取消编辑
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Archive, Film, Play, RotateCcw } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import type { Subscription } from '../types'
|
||||
import { subscriptionProgressLabel } from './subscriptionPageModel'
|
||||
|
||||
interface SubscriptionHistorySectionProps {
|
||||
subscriptions: Subscription[]
|
||||
onRestore: (subscription: Subscription, runAfterRestore?: boolean) => void
|
||||
}
|
||||
|
||||
export function SubscriptionHistorySection({ subscriptions, onRestore }: SubscriptionHistorySectionProps) {
|
||||
if (subscriptions.length === 0) return null
|
||||
|
||||
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>
|
||||
<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">
|
||||
<div className="h-20 w-14 flex-shrink-0 overflow-hidden rounded-xl bg-primary-400/10">
|
||||
{subscription.poster_url ? (
|
||||
<img src={imageURL(subscription.poster_url, subscription.updated_at)} alt={subscription.name} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-brand-500">
|
||||
<Film size={18} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate font-semibold text-ink-600" title={subscription.name}>
|
||||
{subscription.name}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-ink-50">{subscription.archive_reason || '订阅已完成'}</p>
|
||||
<p className="mt-2 text-xs text-ink-100">
|
||||
{subscription.archived_at ? new Date(subscription.archived_at).toLocaleString() : '完成时间未知'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-ink-50">{subscriptionProgressLabel(subscription)}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
className="rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 hover:bg-gray-50"
|
||||
onClick={() => onRestore(subscription)}
|
||||
>
|
||||
<RotateCcw size={13} className="mr-1 inline" />
|
||||
恢复订阅
|
||||
</button>
|
||||
<button
|
||||
className="rounded-xl border border-primary-400/40 bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 hover:bg-primary-400/10"
|
||||
onClick={() => onRestore(subscription, true)}
|
||||
>
|
||||
<Play size={13} className="mr-1 inline" />
|
||||
恢复并运行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +1,18 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Archive, CalendarClock, CheckCircle2, Film, Pencil, Play, Plus, RotateCcw, Save, ShieldCheck, Trash2 } from 'lucide-react'
|
||||
|
||||
import { subscriptionsAPI } from '../api/subscriptions'
|
||||
import { imageURL } from '../api/client'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import type { Subscription } from '../types'
|
||||
import { SubscriptionCard } from './SubscriptionCard'
|
||||
import { SubscriptionForm } from './SubscriptionForm'
|
||||
import { SubscriptionHistorySection } from './SubscriptionHistorySection'
|
||||
import { defaultSubscriptionFormValues, type SubscriptionFormValues } from './subscriptionFormModel'
|
||||
|
||||
export function SubscriptionsPage() {
|
||||
const [items, setItems] = useState<Subscription[]>([])
|
||||
const [historyItems, setHistoryItems] = useState<Subscription[]>([])
|
||||
const [name, setName] = useState('')
|
||||
const [feed, setFeed] = useState('')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [mediaType, setMediaType] = useState('')
|
||||
const [mediaCategory, setMediaCategory] = useState('')
|
||||
const [savePath, setSavePath] = useState('')
|
||||
const [searchMode, setSearchMode] = useState('keyword')
|
||||
const [imdbID, setImdbID] = useState('')
|
||||
const [resolution, setResolution] = useState('best')
|
||||
const [quality, setQuality] = useState('')
|
||||
const [effects, setEffects] = useState('')
|
||||
const [releaseGroups, setReleaseGroups] = useState('')
|
||||
const [excludeWords, setExcludeWords] = useState('cam,ts,tc,枪版')
|
||||
const [washEnabled, setWashEnabled] = useState(false)
|
||||
const [washPriority, setWashPriority] = useState('balanced')
|
||||
const [formValues, setFormValues] = useState<SubscriptionFormValues>(defaultSubscriptionFormValues)
|
||||
const [editingId, setEditingId] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@@ -44,22 +32,22 @@ export function SubscriptionsPage() {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const payload = {
|
||||
name,
|
||||
feed_url: feed,
|
||||
filter,
|
||||
media_type: mediaType || undefined,
|
||||
media_category: mediaCategory || undefined,
|
||||
save_path: savePath || undefined,
|
||||
search_mode: searchMode,
|
||||
imdb_id: imdbID || undefined,
|
||||
resolution,
|
||||
name: formValues.name,
|
||||
feed_url: formValues.feed,
|
||||
filter: formValues.filter,
|
||||
media_type: formValues.mediaType || undefined,
|
||||
media_category: formValues.mediaCategory || undefined,
|
||||
save_path: formValues.savePath || undefined,
|
||||
search_mode: formValues.searchMode,
|
||||
imdb_id: formValues.imdbID || undefined,
|
||||
resolution: formValues.resolution,
|
||||
// 规则类字段发送原始字符串(含空串),以便编辑时可清空/修改;create 存空串无害,update 才能真正生效。
|
||||
quality,
|
||||
effects,
|
||||
release_groups: releaseGroups,
|
||||
exclude_words: excludeWords,
|
||||
wash_enabled: washEnabled,
|
||||
wash_priority: washPriority,
|
||||
quality: formValues.quality,
|
||||
effects: formValues.effects,
|
||||
release_groups: formValues.releaseGroups,
|
||||
exclude_words: formValues.excludeWords,
|
||||
wash_enabled: formValues.washEnabled,
|
||||
wash_priority: formValues.washPriority,
|
||||
priority: 50,
|
||||
}
|
||||
if (editingId) {
|
||||
@@ -80,43 +68,35 @@ export function SubscriptionsPage() {
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingId('')
|
||||
setName('')
|
||||
setFeed('')
|
||||
setFilter('')
|
||||
setMediaType('')
|
||||
setMediaCategory('')
|
||||
setSavePath('')
|
||||
setSearchMode('keyword')
|
||||
setImdbID('')
|
||||
setResolution('best')
|
||||
setQuality('')
|
||||
setEffects('')
|
||||
setReleaseGroups('')
|
||||
setExcludeWords('cam,ts,tc,枪版')
|
||||
setWashEnabled(false)
|
||||
setWashPriority('balanced')
|
||||
setFormValues(defaultSubscriptionFormValues)
|
||||
}
|
||||
|
||||
const startEdit = (s: Subscription) => {
|
||||
setEditingId(s.id)
|
||||
setName(s.name)
|
||||
setFeed(s.feed_url)
|
||||
setFilter(s.filter || '')
|
||||
setMediaType(s.media_type || '')
|
||||
setMediaCategory(s.media_category || '')
|
||||
setSavePath(s.save_path || '')
|
||||
setSearchMode(s.search_mode || 'keyword')
|
||||
setImdbID(s.imdb_id || '')
|
||||
setResolution(s.resolution || 'best')
|
||||
setQuality(s.quality || '')
|
||||
setEffects(s.effects || '')
|
||||
setReleaseGroups(s.release_groups || '')
|
||||
setExcludeWords(s.exclude_words || '')
|
||||
setWashEnabled(Boolean(s.wash_enabled))
|
||||
setWashPriority(s.wash_priority || 'balanced')
|
||||
setFormValues({
|
||||
name: s.name,
|
||||
feed: s.feed_url,
|
||||
filter: s.filter || '',
|
||||
mediaType: s.media_type || '',
|
||||
mediaCategory: s.media_category || '',
|
||||
savePath: s.save_path || '',
|
||||
searchMode: s.search_mode || 'keyword',
|
||||
imdbID: s.imdb_id || '',
|
||||
resolution: s.resolution || 'best',
|
||||
quality: s.quality || '',
|
||||
effects: s.effects || '',
|
||||
releaseGroups: s.release_groups || '',
|
||||
excludeWords: s.exclude_words || '',
|
||||
washEnabled: Boolean(s.wash_enabled),
|
||||
washPriority: s.wash_priority || 'balanced',
|
||||
})
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const updateFormValue = <K extends keyof SubscriptionFormValues>(key: K, value: SubscriptionFormValues[K]) => {
|
||||
setFormValues((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
const restoreHistorySubscription = async (subscription: Subscription, runAfterRestore = false) => {
|
||||
try {
|
||||
const restored = await subscriptionsAPI.restore(subscription.id)
|
||||
@@ -133,6 +113,18 @@ export function SubscriptionsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const runSubscriptionNow = async (subscription: Subscription) => {
|
||||
const result = await subscriptionsAPI.runNow(subscription.id)
|
||||
toast.success(`已加入 ${result.queued} 项`)
|
||||
}
|
||||
|
||||
const removeSubscription = async (subscription: Subscription) => {
|
||||
if (!(await confirmAction({ title: '删除订阅', message: `删除订阅「${subscription.name}」?`, confirmText: '删除' }))) return
|
||||
await subscriptionsAPI.remove(subscription.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">RSS 订阅</h1>
|
||||
@@ -140,88 +132,13 @@ export function SubscriptionsPage() {
|
||||
定期轮询 RSS 源(每 10 分钟一次),将匹配过滤器的项目自动加入下载队列;启用智能分类后会按二级分类写入下载目录。
|
||||
</p>
|
||||
|
||||
<form onSubmit={onCreate} className="glass-panel grid gap-3 md:grid-cols-4">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="名称(显示用)"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="RSS 地址"
|
||||
value={feed}
|
||||
onChange={(e) => setFeed(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="过滤器(正则,可选)"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
<select className="input-base" value={mediaType} onChange={(e) => setMediaType(e.target.value)}>
|
||||
<option value="">自动识别类型</option>
|
||||
<option value="movie">电影</option>
|
||||
<option value="tv">电视剧</option>
|
||||
<option value="anime">动漫</option>
|
||||
<option value="variety">综艺</option>
|
||||
</select>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="二级分类覆盖(如 综艺/日番,可选)"
|
||||
value={mediaCategory}
|
||||
onChange={(e) => setMediaCategory(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input-base md:col-span-2"
|
||||
placeholder="下载根目录覆盖(可选,默认使用下载器保存路径)"
|
||||
value={savePath}
|
||||
onChange={(e) => setSavePath(e.target.value)}
|
||||
/>
|
||||
<select className="input-base" value={searchMode} onChange={(e) => setSearchMode(e.target.value)}>
|
||||
<option value="keyword">标题关键词搜索</option>
|
||||
<option value="imdb">IMDB ID 搜索</option>
|
||||
</select>
|
||||
<input className="input-base" placeholder="IMDB ID,如 tt1160419" value={imdbID} onChange={(e) => setImdbID(e.target.value)} />
|
||||
<select className="input-base" value={resolution} onChange={(e) => setResolution(e.target.value)}>
|
||||
<option value="best">分辨率自动择优</option>
|
||||
<option value="2160p">2160p / 4K</option>
|
||||
<option value="1080p">1080p</option>
|
||||
<option value="720p">720p</option>
|
||||
</select>
|
||||
<select className="input-base" value={quality} onChange={(e) => setQuality(e.target.value)}>
|
||||
<option value="">质量不限</option>
|
||||
<option value="remux">REMUX</option>
|
||||
<option value="bluray">BluRay</option>
|
||||
<option value="web-dl">WEB-DL</option>
|
||||
<option value="hdtv">HDTV</option>
|
||||
</select>
|
||||
<input className="input-base" placeholder="特效/音轨 hdr,dolby-vision,atmos" value={effects} onChange={(e) => setEffects(e.target.value)} />
|
||||
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm text-ink-100">
|
||||
<input type="checkbox" checked={washEnabled} onChange={(e) => setWashEnabled(e.target.checked)} />
|
||||
启用洗版择优
|
||||
</label>
|
||||
<select className="input-base disabled:opacity-50" disabled={!washEnabled} value={washPriority} onChange={(e) => setWashPriority(e.target.value)}>
|
||||
<option value="balanced">洗版:均衡</option>
|
||||
<option value="resolution">洗版:分辨率优先</option>
|
||||
<option value="quality">洗版:片源质量优先</option>
|
||||
<option value="effects">洗版:HDR/DV/Atmos 优先</option>
|
||||
<option value="seeders">洗版:做种数优先</option>
|
||||
</select>
|
||||
<input className="input-base" placeholder="发布组白名单,如 FRDS,OurTV" value={releaseGroups} onChange={(e) => setReleaseGroups(e.target.value)} />
|
||||
<input className="input-base" placeholder="排除词,如 cam,ts,tc" value={excludeWords} onChange={(e) => setExcludeWords(e.target.value)} />
|
||||
<button type="submit" className="neon-button md:col-span-1">
|
||||
{editingId ? <Save size={16} /> : <Plus size={16} />}
|
||||
{editingId ? '保存' : '添加'}
|
||||
</button>
|
||||
{editingId && (
|
||||
<button type="button" onClick={resetForm} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:bg-gray-50">
|
||||
取消编辑
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
<SubscriptionForm
|
||||
values={formValues}
|
||||
editing={Boolean(editingId)}
|
||||
onSubmit={onCreate}
|
||||
onCancelEdit={resetForm}
|
||||
onChange={updateFormValue}
|
||||
/>
|
||||
|
||||
{loading && <p className="text-sand-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && <p className="text-ink-50">暂无订阅。</p>}
|
||||
@@ -229,202 +146,18 @@ export function SubscriptionsPage() {
|
||||
{items.length > 0 && (
|
||||
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((subscription) => (
|
||||
<article
|
||||
<SubscriptionCard
|
||||
key={subscription.id}
|
||||
className="group overflow-hidden rounded-3xl border border-white/70 bg-white shadow-sm transition hover:-translate-y-1 hover:shadow-xl"
|
||||
>
|
||||
<div className="relative flex gap-4 p-4">
|
||||
<div className="relative h-36 w-24 flex-shrink-0 overflow-hidden rounded-2xl bg-gradient-to-br from-primary-400/15 to-surface-200 shadow-inner">
|
||||
{subscription.poster_url ? (
|
||||
<img
|
||||
src={imageURL(subscription.poster_url)}
|
||||
alt={subscription.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-2 text-center text-xs font-semibold text-brand-500">
|
||||
<Film size={22} />
|
||||
<span className="line-clamp-3">{subscription.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div>
|
||||
<div className="mb-1 flex flex-wrap gap-1.5">
|
||||
<span className="rounded-full bg-primary-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase text-brand-500">
|
||||
{subscription.source || 'RSS'}
|
||||
</span>
|
||||
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-semibold text-sand-500">
|
||||
{[subscription.media_type, subscription.media_category].filter(Boolean).join(' / ') || '自动分类'}
|
||||
</span>
|
||||
<span className={'rounded-full px-2 py-0.5 text-[10px] font-semibold ' + (subscription.enabled ? 'bg-emerald-50 text-emerald-600' : 'bg-gray-100 text-gray-500')}>
|
||||
{subscription.enabled ? '启用中' : '已停用'}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="truncate font-display text-lg font-semibold text-ink-600" title={subscription.name}>
|
||||
{subscription.name}
|
||||
</h2>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-ink-50">
|
||||
{subscription.overview || subscription.filter || '已隐藏订阅源地址,避免多用户场景泄露私有 RSS Token。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-xs text-ink-100">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShieldCheck size={13} className="text-brand-500" />
|
||||
<span>订阅源已脱敏</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CalendarClock size={13} className="text-brand-500" />
|
||||
<span>{subscription.last_run_at ? new Date(subscription.last_run_at).toLocaleString() : '尚未运行'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle2 size={13} className="text-brand-500" />
|
||||
<span>{subscriptionProgressLabel(subscription)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{subscriptionRuleBadges(subscription).map((label) => (
|
||||
<span key={label} className="rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] text-ink-100">
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-gray-100 bg-gray-50/70 px-4 py-3">
|
||||
<button
|
||||
className="rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 hover:bg-gray-50"
|
||||
onClick={() => startEdit(subscription)}
|
||||
>
|
||||
<Pencil size={13} className="mr-1 inline" />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
className="rounded-xl border border-primary-400/40 bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 hover:bg-primary-400/10"
|
||||
onClick={async () => {
|
||||
const result = await subscriptionsAPI.runNow(subscription.id)
|
||||
toast.success(`已加入 ${result.queued} 项`)
|
||||
}}
|
||||
>
|
||||
<Play size={13} className="mr-1 inline" />
|
||||
运行
|
||||
</button>
|
||||
<button
|
||||
className="rounded-xl border border-red-400/40 bg-white px-3 py-1.5 text-xs font-semibold text-red-400 hover:bg-red-400/10"
|
||||
onClick={async () => {
|
||||
if (!(await confirmAction({ title: '删除订阅', message: `删除订阅「${subscription.name}」?`, confirmText: '删除' }))) return
|
||||
await subscriptionsAPI.remove(subscription.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}}
|
||||
>
|
||||
<Trash2 size={13} className="mr-1 inline" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
subscription={subscription}
|
||||
onEdit={startEdit}
|
||||
onRunNow={runSubscriptionNow}
|
||||
onRemove={removeSubscription}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{historyItems.length > 0 && (
|
||||
<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">{historyItems.length} 条</span>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{historyItems.map((subscription) => (
|
||||
<article key={subscription.id} className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex gap-3">
|
||||
<div className="h-20 w-14 flex-shrink-0 overflow-hidden rounded-xl bg-primary-400/10">
|
||||
{subscription.poster_url ? (
|
||||
<img src={imageURL(subscription.poster_url)} alt={subscription.name} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-brand-500">
|
||||
<Film size={18} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate font-semibold text-ink-600" title={subscription.name}>{subscription.name}</h3>
|
||||
<p className="mt-1 text-xs text-ink-50">
|
||||
{subscription.archive_reason || '订阅已完成'}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-ink-100">
|
||||
{subscription.archived_at ? new Date(subscription.archived_at).toLocaleString() : '完成时间未知'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-ink-50">{subscriptionProgressLabel(subscription)}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
className="rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 hover:bg-gray-50"
|
||||
onClick={() => restoreHistorySubscription(subscription)}
|
||||
>
|
||||
<RotateCcw size={13} className="mr-1 inline" />
|
||||
恢复订阅
|
||||
</button>
|
||||
<button
|
||||
className="rounded-xl border border-primary-400/40 bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 hover:bg-primary-400/10"
|
||||
onClick={() => restoreHistorySubscription(subscription, true)}
|
||||
>
|
||||
<Play size={13} className="mr-1 inline" />
|
||||
恢复并运行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<SubscriptionHistorySection subscriptions={historyItems} onRestore={restoreHistorySubscription} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function subscriptionRuleBadges(subscription: Subscription): string[] {
|
||||
const labels = [
|
||||
subscription.search_mode === 'imdb' ? `IMDB ${subscription.imdb_id || '未填'}` : '关键词搜索',
|
||||
subscription.resolution && subscription.resolution !== 'best' ? subscription.resolution : '分辨率不限',
|
||||
subscription.quality || '质量不限',
|
||||
subscription.effects || '',
|
||||
subscription.release_groups ? `发布组 ${subscription.release_groups}` : '',
|
||||
subscription.wash_enabled ? `洗版 ${washPriorityLabel(subscription.wash_priority)}` : '未启用洗版',
|
||||
]
|
||||
return labels.filter(Boolean)
|
||||
}
|
||||
|
||||
function subscriptionProgressLabel(subscription: Subscription): string {
|
||||
const isSeries = ['tv', 'anime', 'variety'].includes((subscription.media_type || '').toLowerCase())
|
||||
if (!isSeries) {
|
||||
if (subscription.in_library) return '本地已入库'
|
||||
return (subscription.downloaded_episodes || subscription.local_media_count || 0) > 0 ? '已下载未入库' : '本地未入库'
|
||||
}
|
||||
const downloaded = subscription.downloaded_episodes || 0
|
||||
const total = subscription.total_episodes || 0
|
||||
if (total > 0) {
|
||||
const missing = subscription.missing_episodes?.length || 0
|
||||
return missing > 0 ? `已下载 ${downloaded}/${total} 集,缺 ${missing} 集` : `已下载 ${downloaded}/${total} 集`
|
||||
}
|
||||
return `已下载 ${downloaded}/未知 集`
|
||||
}
|
||||
|
||||
function washPriorityLabel(priority?: string): string {
|
||||
switch (priority) {
|
||||
case 'resolution':
|
||||
return '分辨率优先'
|
||||
case 'quality':
|
||||
return '片源质量优先'
|
||||
case 'effects':
|
||||
return '特效优先'
|
||||
case 'seeders':
|
||||
return '做种数优先'
|
||||
default:
|
||||
return '均衡'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ function fmtBytes(n: number): string {
|
||||
const metricLabels: Record<string, string> = {
|
||||
organized: '新增',
|
||||
replaced: '替换',
|
||||
reclassified: '纠偏',
|
||||
skipped: '跳过',
|
||||
errors: '错误',
|
||||
scans: '扫描库',
|
||||
@@ -29,6 +30,7 @@ const metricLabels: Record<string, string> = {
|
||||
scan_errors: '扫描错误',
|
||||
scrapes: '刮削库',
|
||||
scrape_matched: '匹配',
|
||||
scrape_processed: '刮削处理',
|
||||
scrape_skipped: '刮削跳过',
|
||||
scrape_errors: '刮削错误',
|
||||
skip_already_organized: '已在目标',
|
||||
|
||||
@@ -5,7 +5,7 @@ import toast from 'react-hot-toast'
|
||||
|
||||
import { historyAPI } from '../api/history'
|
||||
import { imageURL } from '../api/client'
|
||||
import { confirmAction } from '../components/ConfirmDialog'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import type { HistoryItem } from '../types'
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
@@ -102,7 +102,7 @@ export function WatchHistoryPage() {
|
||||
<div className="h-16 w-12 shrink-0 overflow-hidden rounded-lg bg-surface-900">
|
||||
{m.poster_url ? (
|
||||
<img
|
||||
src={imageURL(m.poster_url)}
|
||||
src={imageURL(m.poster_url, m.updated_at)}
|
||||
alt={m.title}
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Setting } from '../types'
|
||||
|
||||
export type AutoOrganizeConfig = {
|
||||
enabled: string
|
||||
afterDownload: string
|
||||
scrapeAfter: string
|
||||
downloadSmartClassify: string
|
||||
smartClassify: string
|
||||
sourceDir: string
|
||||
targetDir: string
|
||||
transferMode: string
|
||||
intervalSeconds: string
|
||||
keepSeeding: string
|
||||
movieFormat: string
|
||||
tvFormat: string
|
||||
animeFormat: string
|
||||
scrapeAutoOnScan: string
|
||||
scrapeProviders: string
|
||||
scrapeLanguage: string
|
||||
scrapeDelayMinMs: string
|
||||
scrapeDelayMaxMs: string
|
||||
}
|
||||
|
||||
export const AUTO_ORGANIZE_DEFAULTS: AutoOrganizeConfig = {
|
||||
enabled: 'false',
|
||||
afterDownload: 'false',
|
||||
scrapeAfter: 'true',
|
||||
downloadSmartClassify: 'true',
|
||||
smartClassify: 'true',
|
||||
sourceDir: '',
|
||||
targetDir: '',
|
||||
transferMode: 'hardlink',
|
||||
intervalSeconds: '300',
|
||||
keepSeeding: 'true',
|
||||
movieFormat: '{title} ({year})/{title} ({year})',
|
||||
tvFormat: '{title} ({year})/Season {season:02}/{title} S{season:02}E{episode:02}',
|
||||
animeFormat: '{title}/Season {season:02}/{title} S{season:02}E{episode:02}',
|
||||
scrapeAutoOnScan: 'false',
|
||||
scrapeProviders: 'tmdb,douban,bangumi,thetvdb,fanart',
|
||||
scrapeLanguage: 'zh-CN',
|
||||
scrapeDelayMinMs: '250',
|
||||
scrapeDelayMaxMs: '500',
|
||||
}
|
||||
|
||||
export const AUTO_ORGANIZE_KEYS: Record<keyof AutoOrganizeConfig, string> = {
|
||||
enabled: 'organize.auto',
|
||||
afterDownload: 'organizer.auto_after_download',
|
||||
scrapeAfter: 'organize.scrape_after',
|
||||
downloadSmartClassify: 'downloads.smart_classify',
|
||||
smartClassify: 'organizer.smart_classify',
|
||||
sourceDir: 'organize.source_dir',
|
||||
targetDir: 'organize.target_dir',
|
||||
transferMode: 'organize.transfer_mode',
|
||||
intervalSeconds: 'organize.interval_seconds',
|
||||
keepSeeding: 'organize.keep_seeding',
|
||||
movieFormat: 'organize.movie_format',
|
||||
tvFormat: 'organize.tv_format',
|
||||
animeFormat: 'organize.anime_format',
|
||||
scrapeAutoOnScan: 'scrape.auto_on_scan',
|
||||
scrapeProviders: 'scrape.providers',
|
||||
scrapeLanguage: 'scrape.language',
|
||||
scrapeDelayMinMs: 'scrape.delay_min_ms',
|
||||
scrapeDelayMaxMs: 'scrape.delay_max_ms',
|
||||
}
|
||||
|
||||
export type AutoOrganizeTab = 'basic' | 'naming' | 'scrape'
|
||||
|
||||
export function mergeAutoOrganizeSettings(rows: Setting[]): AutoOrganizeConfig {
|
||||
const idx = settingIndex(rows)
|
||||
return {
|
||||
enabled: idx[AUTO_ORGANIZE_KEYS.enabled] ?? AUTO_ORGANIZE_DEFAULTS.enabled,
|
||||
afterDownload: idx[AUTO_ORGANIZE_KEYS.afterDownload] ?? AUTO_ORGANIZE_DEFAULTS.afterDownload,
|
||||
scrapeAfter: idx[AUTO_ORGANIZE_KEYS.scrapeAfter] ?? AUTO_ORGANIZE_DEFAULTS.scrapeAfter,
|
||||
downloadSmartClassify: idx[AUTO_ORGANIZE_KEYS.downloadSmartClassify] ?? AUTO_ORGANIZE_DEFAULTS.downloadSmartClassify,
|
||||
smartClassify: idx[AUTO_ORGANIZE_KEYS.smartClassify] ?? AUTO_ORGANIZE_DEFAULTS.smartClassify,
|
||||
sourceDir: idx[AUTO_ORGANIZE_KEYS.sourceDir] ?? AUTO_ORGANIZE_DEFAULTS.sourceDir,
|
||||
targetDir: idx[AUTO_ORGANIZE_KEYS.targetDir] ?? AUTO_ORGANIZE_DEFAULTS.targetDir,
|
||||
transferMode: idx[AUTO_ORGANIZE_KEYS.transferMode] ?? AUTO_ORGANIZE_DEFAULTS.transferMode,
|
||||
intervalSeconds: idx[AUTO_ORGANIZE_KEYS.intervalSeconds] ?? AUTO_ORGANIZE_DEFAULTS.intervalSeconds,
|
||||
keepSeeding: idx[AUTO_ORGANIZE_KEYS.keepSeeding] ?? AUTO_ORGANIZE_DEFAULTS.keepSeeding,
|
||||
movieFormat: idx[AUTO_ORGANIZE_KEYS.movieFormat] ?? AUTO_ORGANIZE_DEFAULTS.movieFormat,
|
||||
tvFormat: idx[AUTO_ORGANIZE_KEYS.tvFormat] ?? AUTO_ORGANIZE_DEFAULTS.tvFormat,
|
||||
animeFormat: idx[AUTO_ORGANIZE_KEYS.animeFormat] ?? AUTO_ORGANIZE_DEFAULTS.animeFormat,
|
||||
scrapeAutoOnScan: idx[AUTO_ORGANIZE_KEYS.scrapeAutoOnScan] ?? AUTO_ORGANIZE_DEFAULTS.scrapeAutoOnScan,
|
||||
scrapeProviders: idx[AUTO_ORGANIZE_KEYS.scrapeProviders] ?? AUTO_ORGANIZE_DEFAULTS.scrapeProviders,
|
||||
scrapeLanguage: idx[AUTO_ORGANIZE_KEYS.scrapeLanguage] ?? AUTO_ORGANIZE_DEFAULTS.scrapeLanguage,
|
||||
scrapeDelayMinMs: idx[AUTO_ORGANIZE_KEYS.scrapeDelayMinMs] ?? AUTO_ORGANIZE_DEFAULTS.scrapeDelayMinMs,
|
||||
scrapeDelayMaxMs: idx[AUTO_ORGANIZE_KEYS.scrapeDelayMaxMs] ?? AUTO_ORGANIZE_DEFAULTS.scrapeDelayMaxMs,
|
||||
}
|
||||
}
|
||||
|
||||
export function settingOn(value: string): boolean {
|
||||
return ['1', 'true', 'yes', 'on', 'enabled', '启用', '开启'].includes(value.trim().toLowerCase())
|
||||
}
|
||||
|
||||
function settingIndex(rows: Setting[]): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const row of rows) out[row.key] = row.value
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { DiscoverItem, DiscoverSection } from '../api/discover'
|
||||
|
||||
export const defaultSections = [
|
||||
'tmdb_trending_day',
|
||||
'douban_hot_movie',
|
||||
'douban_hot_tv',
|
||||
'bangumi_calendar',
|
||||
]
|
||||
|
||||
export const discoverStorageKey = 'mediastation.discover.sections'
|
||||
|
||||
export const defaultSectionDefs: DiscoverSection[] = [
|
||||
{ key: 'tmdb_trending_day', label: 'TMDb 今日趋势', provider: 'tmdb' },
|
||||
{ key: 'tmdb_popular_movie', label: 'TMDb 热门电影', provider: 'tmdb' },
|
||||
{ key: 'douban_hot_movie', label: '豆瓣热门电影', provider: 'douban' },
|
||||
{ key: 'douban_hot_tv', label: '豆瓣热门剧集', provider: 'douban' },
|
||||
{ key: 'bangumi_calendar', label: 'Bangumi 每日放送', provider: 'bangumi' },
|
||||
]
|
||||
|
||||
export function discoverItemSource(item: DiscoverItem): string {
|
||||
return item.source || (item.bangumi_id ? 'bangumi' : item.douban_id ? 'douban' : 'tmdb')
|
||||
}
|
||||
|
||||
export function readSavedSections(sections: DiscoverSection[]): string[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(discoverStorageKey)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
const allowed = new Set(sections.map((section) => section.key))
|
||||
return parsed.filter((key) => typeof key === 'string' && allowed.has(key))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSubscribeKeyword(item: DiscoverItem): string {
|
||||
return [item.title, item.year && item.year > 0 ? item.year : ''].filter(Boolean).join(' ')
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function apiErrorMessage(err: unknown, fallback: string): string {
|
||||
const data = (err as { response?: { data?: { error?: string; message?: string } } })?.response?.data
|
||||
if (data?.error) return data.error
|
||||
if (data?.message) return data.message
|
||||
if ((err as { code?: string })?.code === 'ECONNABORTED') return '请求超时,请检查服务或网络'
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export type OrganizePreviewItem = {
|
||||
source: string
|
||||
target?: string
|
||||
action: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
type OrganizeScanSummary = {
|
||||
name: string
|
||||
added: number
|
||||
updated: number
|
||||
visited: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
type OrganizeScrapeSummary = {
|
||||
name: string
|
||||
matched: number
|
||||
processed?: number
|
||||
skipped?: boolean
|
||||
reason?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
type OrganizeDirectoryResult = {
|
||||
organized?: number
|
||||
replaced?: number
|
||||
reclassified?: number
|
||||
skipped?: number
|
||||
errors?: string[]
|
||||
items?: OrganizePreviewItem[]
|
||||
scans?: OrganizeScanSummary[]
|
||||
scrapes?: OrganizeScrapeSummary[]
|
||||
}
|
||||
|
||||
export function isCloudLibraryPath(value: string): boolean {
|
||||
return value.trim().toLowerCase().startsWith('cloud://')
|
||||
}
|
||||
|
||||
export function summarizeOrganizeResults(results: OrganizeDirectoryResult[]) {
|
||||
const preview = results.flatMap((result) => result.items ?? [])
|
||||
const organized = results.reduce((sum, result) => sum + (result.organized ?? 0), 0)
|
||||
const replaced = results.reduce((sum, result) => sum + (result.replaced ?? 0), 0)
|
||||
const reclassified = results.reduce((sum, result) => sum + (result.reclassified ?? 0), 0)
|
||||
const skipped = results.reduce((sum, result) => sum + (result.skipped ?? 0), 0)
|
||||
const errors = results.flatMap((result) => result.errors ?? [])
|
||||
const scans = results.flatMap((result) => result.scans ?? [])
|
||||
const scrapes = results.flatMap((result) => result.scrapes ?? [])
|
||||
const total = organized + replaced + reclassified + skipped + errors.length
|
||||
|
||||
return {
|
||||
preview,
|
||||
organized,
|
||||
replaced,
|
||||
reclassified,
|
||||
skipped,
|
||||
errors,
|
||||
scans,
|
||||
scrapes,
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
export function formatScanSummary(scans: OrganizeScanSummary[]): string {
|
||||
if (scans.length === 0) return ' · 未扫描:没有匹配的媒体库'
|
||||
const ok = scans.filter((scan) => !scan.error)
|
||||
const added = ok.reduce((sum, scan) => sum + (scan.added ?? 0), 0)
|
||||
const updated = ok.reduce((sum, scan) => sum + (scan.updated ?? 0), 0)
|
||||
const visited = ok.reduce((sum, scan) => sum + (scan.visited ?? 0), 0)
|
||||
return ` · 扫描 ${ok.length}/${scans.length} 个库 · 新入库 ${added} · 更新 ${updated} · 访问 ${visited}`
|
||||
}
|
||||
|
||||
export function formatScrapeSummary(scrapes: OrganizeScrapeSummary[]): string {
|
||||
if (scrapes.length === 0) return ''
|
||||
const ok = scrapes.filter((scrape) => !scrape.error && !scrape.skipped)
|
||||
const skipped = scrapes.filter((scrape) => scrape.skipped).length
|
||||
const matched = ok.reduce((sum, scrape) => sum + (scrape.matched ?? 0), 0)
|
||||
const processed = ok.reduce((sum, scrape) => sum + (scrape.processed ?? 0), 0)
|
||||
if (ok.length === 0 && skipped > 0) return ` · 刮削跳过 ${skipped} 个库`
|
||||
return ` · 刮削 ${ok.length}/${scrapes.length} 个库 · 处理 ${processed} · 匹配 ${matched}${skipped ? ` · 跳过 ${skipped}` : ''}`
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { CloudScanStatus } from '../api/storage_config'
|
||||
import type { Media } from '../types'
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return ''
|
||||
if (seconds < 60) return `${Math.round(seconds)}秒`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const rest = Math.round(seconds % 60)
|
||||
if (minutes < 60) return `${minutes}分${rest}秒`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return `${hours}小时${minutes % 60}分`
|
||||
}
|
||||
|
||||
export function formatCloudScanStatus(status: CloudScanStatus): string {
|
||||
const stage =
|
||||
status.state === 'queued' ? '扫描已排队'
|
||||
: status.state === 'canceling' ? '正在中断扫描'
|
||||
: status.state === 'finished' ? '扫描完成'
|
||||
: status.stage === 'importing' ? '正在入库'
|
||||
: '正在遍历目录'
|
||||
const speed = Number(status.files_per_second ?? 0)
|
||||
const speedText = speed > 0 && status.state !== 'finished'
|
||||
? ` · ${speed.toFixed(speed >= 10 ? 0 : 1)} 个/秒`
|
||||
: ''
|
||||
return `${stage}:目录 ${status.dirs ?? 0} · 已发现 ${status.discovered ?? 0} · 已入库 ${status.visited ?? 0} · 新增 ${status.added ?? 0} · 更新 ${status.updated ?? 0}${speedText}`
|
||||
}
|
||||
|
||||
export function seriesSourceRoot(episodes: Media[]): string {
|
||||
const firstPath = episodes.find((item) => item.path)?.path ?? ''
|
||||
if (!firstPath) return ''
|
||||
const dir = dirname(firstPath)
|
||||
const base = basename(dir)
|
||||
if (/^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*\d{1,2}\s*季|specials?|sp|ova|oad|extra|extras|特别篇|特別篇|番外|特典)$/i.test(base)) {
|
||||
return dirname(dir)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
if (!bytes || bytes <= 0) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let value = bytes
|
||||
let index = 0
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024
|
||||
index += 1
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[index]}`
|
||||
}
|
||||
|
||||
function dirname(value: string): string {
|
||||
const index = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'))
|
||||
return index > 0 ? value.slice(0, index) : ''
|
||||
}
|
||||
|
||||
function basename(value: string): string {
|
||||
const index = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'))
|
||||
return index >= 0 ? value.slice(index + 1) : value
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user