初始化

初始化项目
This commit is contained in:
truewhile
2026-08-23 22:12:32 +08:00
parent 0bcb1fec87
commit 71bf60c69c
631 changed files with 2121 additions and 76050 deletions
@@ -1,69 +0,0 @@
import { useState } from 'react'
import type { ExternalMediaResult } from '../api/ai'
import type { DiscoverItem } from '../api/discover'
import { imageURL } from '../api/client'
import { DiscoverDetailModal } from './DiscoverDetailModal'
type AIAssistantExternalResultsProps = {
items: ExternalMediaResult[]
}
export function AIAssistantExternalResults({ items }: AIAssistantExternalResultsProps) {
const [activeItem, setActiveItem] = useState<ExternalMediaResult | null>(null)
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}
role="button"
tabIndex={0}
onClick={() => setActiveItem(item)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') setActiveItem(item)
}}
className="cursor-pointer rounded-2xl border border-gray-200 bg-gray-50 p-3 transition hover:-translate-y-0.5 hover:border-primary-300"
>
<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>
<p className="mt-2 text-xs font-semibold text-brand-500">详情 / 订阅设置</p>
</div>
</div>
</article>
)
})}
</div>
{activeItem && (
<DiscoverDetailModal
item={activeItem as unknown as DiscoverItem}
onClose={() => setActiveItem(null)}
/>
)}
</>
)
}
-40
View File
@@ -1,40 +0,0 @@
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>
)
}
-51
View File
@@ -1,51 +0,0 @@
import { Link } from 'react-router-dom'
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 +
// a list of matching local media items.
// - recommendations: returns a list of recommended titles based on the
// current user's recent watch history.
//
// The Vue version had a full chat surface; the Go backend has no chat or
// operation-execute endpoints, so we render the same two capabilities as
// a focused two-panel screen.
export function AIAssistantPage() {
const assistant = useAIAssistantPage()
return (
<div className="space-y-6">
<AIAssistantHeader status={assistant.status} />
<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}
/>
<AIAssistantRecommendationsSection
recs={assistant.recs}
recommending={assistant.recommending}
onRecommend={assistant.onRecommend}
/>
{/* Decorative footer (mirrors the Vue page hint that AI runs locally). */}
{!assistant.status?.enabled && (
<p className="text-xs text-sand-500">
提示: 当前未配置外部 AI Provider,系统将使用本地规则引擎解析查询。
管理员可在 <Link to="/admin?tab=api" className="text-brand-500">API 配置</Link>{' '}
中接入 OpenAI / DeepSeek 等服务以获得更好效果。
</p>
)}
</div>
)
}
@@ -1,47 +0,0 @@
import { Link } from 'react-router-dom'
import { Loader2, Search, Wand2 } from 'lucide-react'
type AIAssistantRecommendationsSectionProps = {
recs: string[] | null
recommending: boolean
onRecommend: () => void
}
export function AIAssistantRecommendationsSection({
recs,
recommending,
onRecommend,
}: AIAssistantRecommendationsSectionProps) {
return (
<section className="glass-panel space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-display text-lg font-semibold text-ink-600">为你推荐</h2>
<button onClick={onRecommend} disabled={recommending} className="neon-button">
{recommending ? <Loader2 size={16} className="animate-spin" /> : <Wand2 size={16} />}
生成推荐
</button>
</div>
<p className="text-xs text-sand-500">推荐基于你的最近观看历史。点击标题在媒体库中查找。</p>
{recs && recs.length > 0 && (
<ul className="grid gap-2 sm:grid-cols-2">
{recs.map((title, index) => (
<li key={index}>
<Link
to={`/search?q=${encodeURIComponent(title)}`}
className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-ink-200 hover:border-primary-400/40 hover:text-brand-500"
>
<span className="truncate">{title}</span>
<Search size={14} className="shrink-0 opacity-60" />
</Link>
</li>
))}
</ul>
)}
{recs && recs.length === 0 && (
<p className="text-sm text-ink-50">还没有推荐结果 — 先去看几部片子,我再给你挑。</p>
)}
</section>
)
}
-138
View File
@@ -1,138 +0,0 @@
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 -1
View File
@@ -1,12 +1,13 @@
import { AdminLibraryCreateForm } from './AdminLibraryPanelSections'
import { AdminLibraryTable } from './AdminLibraryTable'
import { LibraryStorageStats } from './LibraryStorageStats'
import { useAdminLibraryPanel } from './useAdminLibraryPanel'
export function AdminLibraryPanel() {
const { libs, createForm, editableRoots, rootActions, libraryActions } = useAdminLibraryPanel()
return (
<div className="space-y-6">
<div className="space-y-8">
<AdminLibraryCreateForm
name={createForm.name}
type={createForm.type}
@@ -33,6 +34,7 @@ export function AdminLibraryPanel() {
onAddLibraryRoot={libraryActions.addLibraryRoot}
onEditLibraryCover={libraryActions.editLibraryCover}
/>
<LibraryStorageStats />
</div>
)
}
+11 -61
View File
@@ -1,71 +1,21 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Users } from 'lucide-react'
import { APIConfigsPanel } from '../components/APIConfigsPanel'
import { ManagementShortcuts } from '../components/ManagementShortcuts'
import { AdminLibraryPanel } from './AdminLibraryPanel'
import { AdminUsersPanel } from './AdminUsersPanel'
type AdminTab = 'library' | 'users' | 'api'
function parseAdminTab(value: string | null): AdminTab {
if (value === 'users' || value === 'api') return value
return 'library'
}
export function AdminPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [tab, setTab] = useState<AdminTab>(() => parseAdminTab(searchParams.get('tab')))
const tabs = [
{ key: 'library' as const, label: '媒体库' },
{ key: 'users' as const, label: '用户' },
{ key: 'api' as const, label: '外部API' },
]
useEffect(() => {
setTab(parseAdminTab(searchParams.get('tab')))
}, [searchParams])
const selectTab = (next: AdminTab) => {
setTab(next)
setSearchParams(next === 'library' ? {} : { tab: next })
}
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-ink-600">管理后台</h1>
<ManagementShortcuts
title="统一管理入口"
description="侧栏保持精简,完整管理能力统一从这里进入。"
items={[
{ to: '/sites', title: '站点管理', description: '维护 PT 站点、认证方式和检索配置', group: '站点与下载' },
{ to: '/download-clients', title: '下载器管理', description: '配置 qBittorrent 等下载器连接', badge: '下载', group: '站点与下载' },
{ to: '/files', title: '手动整理', description: '从下载目录选择文件夹并整理入库', group: '文件与入库' },
{ to: '/storage', title: '存储与文件', description: '查看占用、清理重复项和管理文件', group: '文件与入库' },
{ to: '/notify-channels', title: '通知渠道', description: '配置 Bot、Webhook、邮件等通知出口', group: '系统运维' },
{ to: '/assistant', title: 'AI 会话', description: '查看和接管后台 AI 操作记录', group: '系统运维' },
]}
/>
<div className="flex flex-wrap gap-2 border-b border-gray-200">
{tabs.map((k) => (
<button
key={k.key}
onClick={() => selectTab(k.key)}
className={
'border-b-2 px-4 py-2 text-sm transition ' +
(tab === k.key
? 'border-primary-400 text-brand-500'
: 'border-transparent text-ink-50 hover:text-white')
}
>
{k.label}
</button>
))}
</div>
<header className="flex items-center gap-3">
<Users className="h-6 w-6 text-brand-500" />
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">用户管理</h1>
<p className="text-sm text-ink-50">
管理系统用户账号、角色权限以及密码重置与启禁用操作。
</p>
</div>
</header>
{tab === 'library' && <AdminLibraryPanel />}
{tab === 'users' && <AdminUsersPanel />}
{tab === 'api' && <APIConfigsPanel />}
<AdminUsersPanel />
</div>
)
}
+4 -14
View File
@@ -2,7 +2,6 @@ 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'
@@ -11,19 +10,13 @@ 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)
setUsers(await adminAPI.listUsers())
}
useEffect(() => {
refresh().catch(() => undefined)
@@ -31,12 +24,9 @@ export function AdminUsersPanel() {
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 maxUsers = 20
const userLimitReached = users.length >= maxUsers
const userLimitLabel = String(maxUsers)
const handleCreate = async (e: FormEvent) => {
e.preventDefault()
-134
View File
@@ -1,134 +0,0 @@
import { FormEvent, useEffect, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import {
assistantAPI,
type AssistantMessage,
type AssistantSession,
type SessionView,
} from '../api/assistant'
import { confirmAction } from '../components/confirmAction'
import { AssistantConversationPane, AssistantSessionsSidebar } from './AssistantChatSections'
// AssistantChatPage is the multi-turn chat surface backed by the Go
// AssistantService. It complements the older AIAssistantPage which is
// limited to single-turn smart search + recommendations.
export function AssistantChatPage() {
const [sessions, setSessions] = useState<AssistantSession[]>([])
const [active, setActive] = useState<SessionView | null>(null)
const [draft, setDraft] = useState('')
const [sending, setSending] = useState(false)
const [loading, setLoading] = useState(true)
const messagesEndRef = useRef<HTMLDivElement | null>(null)
const refreshSessions = async () => {
try {
const list = await assistantAPI.listSessions()
setSessions(list)
// Auto-select the most recent if nothing is open.
if (list.length > 0 && !active) {
const view = await assistantAPI.getSession(list[0].id)
setActive(view)
}
} finally {
setLoading(false)
}
}
useEffect(() => {
refreshSessions().catch(() => undefined)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [active?.messages.length])
const onNew = async () => {
try {
const sess = await assistantAPI.createSession('New chat')
const view = await assistantAPI.getSession(sess.id)
setActive(view)
await refreshSessions()
} catch {
toast.error('创建会话失败')
}
}
const onSelect = async (id: string) => {
try {
setActive(await assistantAPI.getSession(id))
} catch {
toast.error('加载会话失败')
}
}
const onDelete = async (id: string) => {
if (!(await confirmAction({ title: '删除会话', message: '删除此会话?', confirmText: '删除' }))) return
try {
await assistantAPI.deleteSession(id)
if (active?.session.id === id) setActive(null)
await refreshSessions()
} catch {
toast.error('删除失败')
}
}
const onSend = async (e: FormEvent) => {
e.preventDefault()
if (!draft.trim() || !active) return
setSending(true)
const text = draft.trim()
setDraft('')
// Optimistic append so the user's turn shows immediately.
setActive((s) =>
s
? {
...s,
messages: [
...s.messages,
{
id: 'pending-' + Date.now(),
session_id: s.session.id,
role: 'user',
content: text,
created_at: new Date().toISOString(),
} as AssistantMessage,
],
}
: s,
)
try {
const view = await assistantAPI.chat(active.session.id, text)
setActive(view)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'发送失败'
toast.error(msg)
} finally {
setSending(false)
}
}
return (
<div className="grid h-[calc(100vh-100px)] grid-cols-[260px_1fr] gap-4">
<AssistantSessionsSidebar
sessions={sessions}
activeSessionId={active?.session.id}
loading={loading}
onNew={onNew}
onSelect={onSelect}
onDelete={onDelete}
/>
<AssistantConversationPane
active={active}
draft={draft}
sending={sending}
messagesEndRef={messagesEndRef}
onDraftChange={setDraft}
onSend={onSend}
/>
</div>
)
}
-143
View File
@@ -1,143 +0,0 @@
import type { FormEvent, RefObject } from 'react'
import { Loader2, MessageSquare, Plus, Send, Trash2 } from 'lucide-react'
import type { AssistantMessage, AssistantSession, SessionView } from '../api/assistant'
export function AssistantSessionsSidebar({
sessions,
activeSessionId,
loading,
onNew,
onSelect,
onDelete,
}: {
sessions: AssistantSession[]
activeSessionId?: string
loading: boolean
onNew: () => void
onSelect: (id: string) => void
onDelete: (id: string) => void
}) {
return (
<aside className="glass-panel flex flex-col overflow-hidden">
<div className="mb-3 flex items-center justify-between">
<h2 className="font-display text-sm font-semibold text-ink-600">会话</h2>
<button onClick={onNew} className="neon-button !px-2 !py-1 text-xs">
<Plus size={12} /> 新建
</button>
</div>
{loading && (
<div className="flex justify-center py-6 text-ink-50">
<Loader2 className="animate-spin" />
</div>
)}
<ul className="flex-1 space-y-1 overflow-y-auto pr-1">
{sessions.map((session) => (
<li
key={session.id}
className={
'group flex items-center gap-2 rounded px-2 py-2 text-sm cursor-pointer ' +
(activeSessionId === session.id
? 'bg-primary-400/10 text-brand-500'
: 'text-ink-100 hover:bg-gray-50 hover:text-white')
}
onClick={() => onSelect(session.id)}
>
<MessageSquare size={14} className="shrink-0" />
<span className="flex-1 truncate">{session.title || '未命名'}</span>
<button
onClick={(event) => {
event.stopPropagation()
onDelete(session.id)
}}
className="opacity-0 group-hover:opacity-100"
>
<Trash2 size={12} className="text-red-400" />
</button>
</li>
))}
{!loading && sessions.length === 0 && (
<li className="px-2 py-2 text-xs text-sand-500">暂无会话</li>
)}
</ul>
</aside>
)
}
export function AssistantConversationPane({
active,
draft,
sending,
messagesEndRef,
onDraftChange,
onSend,
}: {
active: SessionView | null
draft: string
sending: boolean
messagesEndRef: RefObject<HTMLDivElement>
onDraftChange: (value: string) => void
onSend: (event: FormEvent<HTMLFormElement>) => void
}) {
return (
<section className="glass-panel flex flex-col overflow-hidden">
{!active && (
<div className="m-auto text-center text-ink-50">
<MessageSquare size={32} className="mx-auto mb-2 opacity-50" />
<p>选择或创建一个会话开始对话</p>
</div>
)}
{active && (
<>
<div className="mb-3 border-b border-gray-200 pb-2">
<h2 className="font-display text-base font-semibold text-ink-600">
{active.session.title || '未命名'}
</h2>
</div>
<div className="flex-1 space-y-3 overflow-y-auto pr-2">
{active.messages.length === 0 && (
<p className="text-sm text-sand-500">说点什么开始对话…</p>
)}
{active.messages.map((message) => (
<Bubble key={message.id} message={message} />
))}
<div ref={messagesEndRef} />
</div>
<form onSubmit={onSend} className="mt-3 flex gap-2 border-t border-gray-200 pt-3">
<input
className="input-base flex-1"
placeholder="发送消息…"
value={draft}
onChange={(event) => onDraftChange(event.target.value)}
disabled={sending}
/>
<button type="submit" disabled={sending || !draft.trim()} className="neon-button">
{sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
发送
</button>
</form>
</>
)}
</section>
)
}
function Bubble({ message }: { message: AssistantMessage }) {
const mine = message.role === 'user'
return (
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
<div
className={
'max-w-[80%] whitespace-pre-wrap rounded-2xl px-4 py-2 text-sm ' +
(mine
? 'bg-primary-400/20 text-primary-100'
: message.role === 'system'
? 'border border-amber-400/30 bg-amber-400/5 text-amber-200'
: 'bg-gray-50 text-ink-200')
}
>
{message.content}
</div>
</div>
)
}
-46
View File
@@ -1,46 +0,0 @@
import type { StorageType } from '../api/storage_config'
import { CloudBrowserToolbar } from './CloudBrowserToolbar'
import { CloudEntryList } from './CloudEntryList'
import { CloudMountList } from './CloudMountList'
import { CloudScanPanel } from './CloudScanPanel'
import { useCloudBrowser } from './useCloudBrowser'
// Lists cloud directories and imports a file as a 302-backed media.
export function CloudBrowser({ type, enabled = true }: { type: StorageType; enabled?: boolean }) {
const browser = useCloudBrowser(type, enabled)
return (
<div className="mt-2 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] p-3">
<CloudScanPanel
scanBusy={browser.scanBusy}
cancelBusy={browser.cancelBusy}
scanStatuses={browser.scanStatuses}
onScanAll={browser.scanAllCloudLibraries}
onCancelScans={browser.cancelCloudScans}
/>
<CloudMountList mounts={browser.mounts} onRemove={browser.removeMount} />
<CloudBrowserToolbar
stack={browser.stack}
mountMediaType={browser.mountMediaType}
mounting={browser.mounting}
batchMounting={browser.batchMounting}
loading={browser.loading}
hasDirectories={browser.hasDirectories}
onGoTo={browser.goTo}
onGoUp={browser.goUp}
onCreateFolder={browser.createFolder}
onMediaTypeChange={browser.setMountMediaType}
onMountCurrent={browser.mountCurrent}
onMountVisibleDirectories={browser.mountVisibleDirectories}
/>
<CloudEntryList
loading={browser.loading}
error={browser.error}
items={browser.items}
onEnter={browser.enter}
onImport={browser.doImport}
onRename={browser.renameFolder}
/>
</div>
)
}
-99
View File
@@ -1,99 +0,0 @@
import { ArrowUp, FolderPlus } from 'lucide-react'
interface CloudBrowserToolbarProps {
stack: { id: string; name: string }[]
mountMediaType: string
mounting: boolean
batchMounting: boolean
loading: boolean
hasDirectories: boolean
onGoTo: (index: number) => void
onGoUp: () => void
onCreateFolder: () => void
onMediaTypeChange: (value: string) => void
onMountCurrent: () => void
onMountVisibleDirectories: () => void
}
export function CloudBrowserToolbar({
stack,
mountMediaType,
mounting,
batchMounting,
loading,
hasDirectories,
onGoTo,
onGoUp,
onCreateFolder,
onMediaTypeChange,
onMountCurrent,
onMountVisibleDirectories,
}: CloudBrowserToolbarProps) {
return (
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-1 text-xs text-[var(--app-muted)]">
<span className="text-[var(--app-subtle)]">网盘资源:</span>
{stack.map((item, index) => (
<span key={index}>
<button type="button" className="hover:text-brand-500" onClick={() => onGoTo(index)}>
{item.name}
</button>
{index < stack.length - 1 && <span className="mx-1">/</span>}
</span>
))}
</div>
<p className="basis-full text-xs text-[var(--app-muted)]">
挂载后不会复制网盘文件;后台会递归读取该目录里的子文件夹和媒体文件,扫描到的影片会自动加入对应媒体库。小目录通常几十秒,大目录取决于网盘接口速度。
如果已有同名同类型媒体库,会在首页和 Emby/SenPlayer 中自动归并显示。
</p>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-[var(--app-border)] px-2 py-0.5 text-xs text-[var(--app-subtle)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-not-allowed disabled:opacity-50"
disabled={loading || stack.length <= 1}
onClick={onGoUp}
>
<ArrowUp size={14} />
返回上级
</button>
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-[var(--app-border)] px-2 py-0.5 text-xs text-[var(--app-subtle)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-not-allowed disabled:opacity-50"
disabled={loading}
onClick={onCreateFolder}
>
<FolderPlus size={14} />
新建文件夹
</button>
<select
className="rounded border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-0.5 text-xs text-[var(--app-subtle)]"
value={mountMediaType}
onChange={(event) => onMediaTypeChange(event.target.value)}
>
<option value="auto">自动识别</option>
<option value="movie">电影</option>
<option value="tv">剧集</option>
<option value="anime">动漫</option>
<option value="variety">综艺</option>
<option value="adult">成人</option>
</select>
<button
type="button"
className="rounded border border-brand-400/40 px-2 py-0.5 text-xs text-brand-500 hover:bg-brand-400/10 disabled:opacity-50"
disabled={mounting || batchMounting || loading}
onClick={onMountCurrent}
>
{mounting ? '挂载中…' : '挂载当前目录并归并到媒体库'}
</button>
<button
type="button"
className="rounded border border-blue-300/70 px-2 py-0.5 text-xs text-blue-500 hover:bg-blue-500/10 disabled:opacity-50"
disabled={mounting || batchMounting || loading || !hasDirectories}
onClick={onMountVisibleDirectories}
>
{batchMounting ? '批量挂载中…' : '一键挂载当前目录下所有文件夹'}
</button>
</div>
</div>
)
}
-61
View File
@@ -1,61 +0,0 @@
import { FileVideo, Folder, Loader2, Pencil } from 'lucide-react'
import type { CloudEntry } from '../api/storage_config'
interface CloudEntryListProps {
loading: boolean
error: string
items: CloudEntry[]
onEnter: (entry: CloudEntry) => void
onImport: (entry: CloudEntry) => void
onRename: (entry: CloudEntry) => void
}
export function CloudEntryList({ loading, error, items, onEnter, onImport, onRename }: CloudEntryListProps) {
if (loading) {
return (
<div className="flex justify-center py-4 text-[var(--app-muted)]">
<Loader2 className="animate-spin" size={16} />
</div>
)
}
if (error) return <p className="py-2 text-sm text-red-400">{error}</p>
if (items.length === 0) return <p className="py-2 text-sm text-[var(--app-muted)]">该目录为空</p>
return (
<ul className="divide-y divide-[var(--app-border)]">
{items.map((entry) => (
<li key={entry.id} className="flex items-center gap-2 py-1.5 text-sm">
{entry.is_dir ? <Folder size={15} className="text-amber-400" /> : <FileVideo size={15} className="text-blue-300" />}
{entry.is_dir ? (
<>
<button type="button" className="flex-1 text-left text-[var(--app-subtle)] hover:text-brand-500" onClick={() => onEnter(entry)}>
{entry.name}
</button>
<button
type="button"
title="重命名文件夹"
className="rounded border border-[var(--app-border)] p-1 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
onClick={() => onRename(entry)}
>
<Pencil size={14} />
</button>
</>
) : (
<>
<span className="flex-1 truncate text-[var(--app-subtle)]">{entry.name}</span>
<button
type="button"
className="rounded border border-[var(--app-border)] px-2 py-0.5 text-xs text-[var(--app-subtle)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
onClick={() => onImport(entry)}
>
导入
</button>
</>
)}
</li>
))}
</ul>
)
}
-36
View File
@@ -1,36 +0,0 @@
import { Trash2 } from 'lucide-react'
import type { Library } from '../types'
import { cloudLibraryLabel } from './storageConfigModel'
interface CloudMountListProps {
mounts: Library[]
onRemove: (library: Library) => void
}
export function CloudMountList({ mounts, onRemove }: CloudMountListProps) {
if (mounts.length === 0) return null
return (
<div className="mb-3 rounded border border-blue-300/30 bg-blue-500/10 p-2">
<div className="mb-1 text-xs font-semibold text-[var(--app-subtle)]">已挂载目录</div>
<div className="space-y-1">
{mounts.map((lib) => (
<div key={lib.id} className="flex items-center gap-2 rounded border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 text-xs">
<span className="min-w-0 flex-1 truncate text-[var(--app-subtle)]">
{lib.name} · {cloudLibraryLabel(lib.path)}
</span>
<button
type="button"
className="rounded border border-red-300/60 px-1.5 py-0.5 text-red-500 hover:bg-[var(--app-danger-soft)]"
onClick={() => onRemove(lib)}
title="移除挂载"
>
<Trash2 size={13} />
</button>
</div>
))}
</div>
</div>
)
}
-70
View File
@@ -1,70 +0,0 @@
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>
)
}
-202
View File
@@ -1,202 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { ChevronLeft, ChevronRight, Info } from 'lucide-react'
import type { DiscoverItem } from '../api/discover'
import { imageURL } from '../api/client'
import { discoverItemSource } from './discoverPageModel'
export function ContentRow({
title,
items,
page = 1,
canNext = false,
imageVersion,
refreshImageVersion,
onPageChange,
onSelect,
}: {
title: string
items: DiscoverItem[]
page?: number
canNext?: boolean
imageVersion?: string
refreshImageVersion?: string
onPageChange?: (delta: number) => void
onSelect: (item: DiscoverItem) => void
}) {
return (
<section className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h2 className="pl-1 font-display text-2xl font-semibold text-ink-600">{title}</h2>
{onPageChange && (
<div className="flex items-center gap-2">
<button
type="button"
aria-label={`${title} 上一页`}
disabled={page <= 1}
onClick={() => onPageChange(-1)}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-gray-200 bg-white text-ink-100 transition hover:border-primary-300 hover:text-brand-500 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronLeft size={16} />
</button>
<span className="min-w-10 text-center text-xs font-semibold text-sand-500">第 {page} 页</span>
<button
type="button"
aria-label={`${title} 下一页`}
disabled={!canNext}
onClick={() => onPageChange(1)}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-gray-200 bg-white text-ink-100 transition hover:border-primary-300 hover:text-brand-500 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronRight size={16} />
</button>
</div>
)}
</div>
<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}
imageVersion={imageVersion}
refreshImageVersion={refreshImageVersion}
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,
imageVersion,
refreshImageVersion,
onSelect,
}: {
item: DiscoverItem
imageVersion?: string
refreshImageVersion?: string
onSelect: (item: DiscoverItem) => void
}) {
const source = discoverItemSource(item)
const imageCandidates = useMemo(
() =>
[item.poster_url, item.backdrop_url]
.map((value) => value?.trim())
.filter((value, index, values): value is string => Boolean(value) && values.indexOf(value) === index),
[item.poster_url, item.backdrop_url],
)
const [imageIndex, setImageIndex] = useState(0)
const [posterRetry, setPosterRetry] = useState(0)
const [posterUnavailable, setPosterUnavailable] = useState(false)
const posterVersion = [imageVersion, posterRetry > 0 ? `r${posterRetry}` : ''].filter(Boolean).join('-')
const activeImage = imageCandidates[imageIndex] ?? ''
const shouldRefreshCache = Boolean(
(imageVersion && refreshImageVersion === imageVersion) || posterRetry > 0,
)
const posterSrc = useMemo(
() =>
imageURL(activeImage, posterVersion, {
refreshCache: shouldRefreshCache,
retryFailed: true,
}),
[activeImage, posterVersion, shouldRefreshCache],
)
useEffect(() => {
setImageIndex(0)
setPosterRetry(0)
setPosterUnavailable(false)
}, [item.poster_url, item.backdrop_url, imageVersion])
useEffect(() => {
if (!posterUnavailable) return
if (imageIndex + 1 < imageCandidates.length) {
const timer = window.setTimeout(() => {
setImageIndex((current) => Math.min(current + 1, imageCandidates.length - 1))
setPosterRetry(0)
setPosterUnavailable(false)
}, 150)
return () => window.clearTimeout(timer)
}
if (posterRetry >= 3) return
const timer = window.setTimeout(() => {
setPosterRetry((current) => current + 1)
setPosterUnavailable(false)
}, 1200 * (posterRetry + 1))
return () => window.clearTimeout(timer)
}, [imageCandidates.length, imageIndex, posterRetry, posterUnavailable])
const markPosterUnavailable = () => setPosterUnavailable(true)
if (!posterSrc || posterUnavailable) return null
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">
{posterSrc && (
<img
src={posterSrc}
alt={item.title}
loading="eager"
decoding="async"
referrerPolicy="no-referrer"
onError={markPosterUnavailable}
onLoad={(event) => {
const img = event.currentTarget
if (img.naturalWidth <= 1 && img.naturalHeight <= 1) {
markPosterUnavailable()
}
}}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
)}
<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}`
}
-61
View File
@@ -1,61 +0,0 @@
import { useState } from 'react'
import toast from 'react-hot-toast'
import type { DiscoverItem } from '../api/discover'
import { subscriptionsAPI } from '../api/subscriptions'
import { discoverItemSource } from './discoverPageModel'
import {
DiscoverArtworkPanel,
DiscoverModalHeader,
DiscoverOverviewPanel,
DiscoverSubscriptionRules,
} from './DiscoverDetailModalSections'
import {
apiErrorMessage,
buildDiscoverSubscriptionInput,
initialDiscoverSubscriptionForm,
} from './discoverDetailModalModel'
export function DiscoverDetailModal({ item, onClose }: { item: DiscoverItem; onClose: () => void }) {
const source = discoverItemSource(item)
const [form, setForm] = useState(() => initialDiscoverSubscriptionForm(item))
const [busy, setBusy] = useState(false)
const submit = async () => {
setBusy(true)
try {
const sub = await subscriptionsAPI.create(buildDiscoverSubscriptionInput(item, form, source))
if (form.run_now) {
const run = await subscriptionsAPI.runNow(sub.id)
toast.success(run.queued > 0 ? `已订阅并加入 ${run.queued} 个下载` : '已订阅,暂未命中可下载资源')
} else {
toast.success('已创建订阅')
}
onClose()
} catch (err) {
toast.error(apiErrorMessage(err, '订阅失败'))
} 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">
<DiscoverModalHeader item={item} source={source} onClose={onClose} />
<div className="grid gap-5 lg:grid-cols-[260px_1fr]">
<DiscoverArtworkPanel item={item} />
<div className="space-y-5">
<DiscoverOverviewPanel overview={item.overview} />
<DiscoverSubscriptionRules
form={form}
busy={busy}
onChange={(patch) => setForm((current) => ({ ...current, ...patch }))}
onSubmit={submit}
/>
</div>
</div>
</div>
</div>
)
}
@@ -1,172 +0,0 @@
import { Download, Rss, X } from 'lucide-react'
import type { DiscoverItem } from '../api/discover'
import { imageURL } from '../api/client'
import { discoverItemMetaText, type DiscoverSubscriptionForm } from './discoverDetailModalModel'
type FormPatch = Partial<DiscoverSubscriptionForm>
type FieldOption = {
label: string
value: string
}
const searchModeOptions: FieldOption[] = [
{ value: 'keyword', label: '标题关键词' },
{ value: 'imdb', label: 'IMDB ID' },
]
const mediaTypeOptions: FieldOption[] = [
{ value: '', label: '自动识别' },
{ value: 'movie', label: '电影' },
{ value: 'tv', label: '电视剧' },
{ value: 'anime', label: '动漫' },
{ value: 'variety', label: '综艺' },
]
const resolutionOptions: FieldOption[] = [
{ value: 'best', label: '自动择优' },
{ value: '2160p', label: '2160p / 4K' },
{ value: '1080p', label: '1080p' },
{ value: '720p', label: '720p' },
]
const qualityOptions: FieldOption[] = [
{ value: '', label: '不限' },
{ value: 'remux', label: 'REMUX' },
{ value: 'bluray', label: 'BluRay' },
{ value: 'web-dl', label: 'WEB-DL' },
{ value: 'hdtv', label: 'HDTV' },
]
const washPriorityOptions: FieldOption[] = [
{ value: 'balanced', label: '均衡' },
{ value: 'resolution', label: '分辨率优先' },
{ value: 'quality', label: '片源质量优先' },
{ value: 'effects', label: 'HDR/DV/Atmos 优先' },
{ value: 'seeders', label: '做种数优先' },
]
export function DiscoverModalHeader({ item, source, onClose }: { item: DiscoverItem; source: string; onClose: () => void }) {
return (
<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">{discoverItemMetaText(item)}</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>
)
}
export function DiscoverArtworkPanel({ item }: { item: DiscoverItem }) {
return (
<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>
)
}
export function DiscoverOverviewPanel({ overview }: { overview?: string }) {
return (
<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">{overview || '当前数据源没有返回简介。'}</p>
</section>
)
}
export function DiscoverSubscriptionRules({
form,
busy,
onChange,
onSubmit,
}: {
form: DiscoverSubscriptionForm
busy: boolean
onChange: (patch: FormPatch) => void
onSubmit: () => void
}) {
return (
<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">
<TextField label="搜索关键词" value={form.keyword} onChange={(keyword) => onChange({ keyword })} className="md:col-span-2" />
<SelectField label="搜索方式" value={form.search_mode} options={searchModeOptions} onChange={(search_mode) => onChange({ search_mode })} />
<TextField label="IMDB ID" value={form.imdb_id} placeholder="tt1160419" onChange={(imdb_id) => onChange({ imdb_id })} />
<SelectField label="类型" value={form.media_type} options={mediaTypeOptions} onChange={(media_type) => onChange({ media_type })} />
<SelectField label="分辨率" value={form.resolution} options={resolutionOptions} onChange={(resolution) => onChange({ resolution })} />
<SelectField label="质量" value={form.quality} options={qualityOptions} onChange={(quality) => onChange({ quality })} />
<TextField label="特效 / 音轨" value={form.effects} placeholder="hdr,dolby-vision,atmos" onChange={(effects) => onChange({ effects })} />
<SelectField label="洗版优先级" value={form.wash_priority} options={washPriorityOptions} disabled={!form.wash_enabled} onChange={(wash_priority) => onChange({ wash_priority })} />
<CheckboxField label="启用洗版择优" checked={form.wash_enabled} onChange={(wash_enabled) => onChange({ wash_enabled })} />
<TextField label="发布组" value={form.release_groups} placeholder="如 FRDS,OurTV" onChange={(release_groups) => onChange({ release_groups })} />
<TextField label="排除词" value={form.exclude_words} onChange={(exclude_words) => onChange({ exclude_words })} />
<TextField label="分类覆盖" value={form.media_category} placeholder="综艺 / 日番 / 欧美剧" onChange={(media_category) => onChange({ media_category })} />
<TextField label="保存路径覆盖" value={form.save_path} onChange={(save_path) => onChange({ save_path })} />
</div>
<DiscoverSubmitBar busy={busy} runNow={form.run_now} onRunNowChange={(run_now) => onChange({ run_now })} onSubmit={onSubmit} />
</section>
)
}
function TextField({ label, value, placeholder, className = '', onChange }: { label: string; value: string; placeholder?: string; className?: string; onChange: (value: string) => void }) {
return (
<label className={`text-xs text-sand-500 ${className}`}>
{label}
<input className="input-base mt-1" placeholder={placeholder} value={value} onChange={(event) => onChange(event.target.value)} />
</label>
)
}
function SelectField({ label, value, options, disabled, onChange }: { label: string; value: string; options: FieldOption[]; disabled?: boolean; onChange: (value: string) => void }) {
return (
<label className="text-xs text-sand-500">
{label}
<select className="input-base mt-1 disabled:opacity-50" disabled={disabled} value={value} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => (
<option key={option.value || option.label} value={option.value}>{option.label}</option>
))}
</select>
</label>
)
}
function CheckboxField({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) {
return (
<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={checked} onChange={(event) => onChange(event.target.checked)} />
{label}
</label>
)
}
function DiscoverSubmitBar({ busy, runNow, onRunNowChange, onSubmit }: { busy: boolean; runNow: boolean; onRunNowChange: (value: boolean) => void; onSubmit: () => void }) {
return (
<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={runNow} onChange={(event) => onRunNowChange(event.target.checked)} />
创建后立即搜索并下载
</label>
<button disabled={busy} onClick={onSubmit} className="neon-button disabled:opacity-60">
<Download size={16} />
{busy ? '处理中…' : '创建订阅'}
</button>
</div>
)
}
-239
View File
@@ -1,239 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { discoverAPI, type DiscoverItem, type DiscoverSection } from '../api/discover'
import { DiscoverSkeleton } from './DiscoverContentRow'
import { DiscoverDetailModal } from './DiscoverDetailModal'
import { DiscoverEmptySelection, DiscoverHeader, DiscoverResults } from './DiscoverPageSections'
import {
defaultSections,
discoverStorageKey,
readCachedDiscoverRows,
readSavedSections,
serializeSavedSections,
writeCachedDiscoverRow,
} from './discoverPageModel'
export function DiscoverPage() {
const [sections, setSections] = useState<DiscoverSection[]>([])
const [selected, setSelected] = useState<string[]>([])
const [rows, setRows] = useState<Record<string, DiscoverItem[]>>({})
const [rowPages, setRowPages] = useState<Record<string, number>>({})
const [rowCanNext, setRowCanNext] = useState<Record<string, boolean>>({})
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({})
const [rowErrors, setRowErrors] = useState<Record<string, string>>({})
const [sectionsReady, setSectionsReady] = useState(false)
const [loading, setLoading] = useState(false)
const [activeItem, setActiveItem] = useState<DiscoverItem | null>(null)
const [reloadSeq, setReloadSeq] = useState(0)
const [imageVersion, setImageVersion] = useState(() => String(Date.now()))
const [refreshImageVersion, setRefreshImageVersion] = useState<string>()
useEffect(() => {
let cancelled = false
setSectionsReady(false)
discoverAPI
.sections()
.then((items) => {
if (cancelled) return
setSections(items)
const saved = readSavedSections(items)
const available = new Set(items.map((item) => item.key))
const fallback = defaultSections.filter((key) => available.has(key))
const nextSelected = saved.length > 0 ? saved : fallback
const cached = readCachedDiscoverRows(nextSelected)
setSelected(nextSelected)
setRowPages(Object.fromEntries(nextSelected.map((key) => [key, 1])))
setRows(cached.rows)
setRowCanNext(cached.rowCanNext)
setSectionsReady(true)
})
.catch(() => {
if (cancelled) return
setSections([])
setSelected([])
setSectionsReady(true)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!sectionsReady) return
const available = new Set(sections.map((section) => section.key))
const activeSelected = selected.filter((key) => available.has(key))
if (activeSelected.length !== selected.length) {
setSelected(activeSelected)
return
}
if (selected.length === 0) {
setRows({})
setRowLoading({})
setRowCanNext({})
setRowErrors({})
setLoading(false)
return
}
let cancelled = false
setLoading(true)
setRowErrors({})
setRowLoading(Object.fromEntries(selected.map((key) => [key, true])))
setRows((current) => {
const next: Record<string, DiscoverItem[]> = {}
for (const key of selected) {
next[key] = current[key] ?? []
}
return next
})
window.localStorage.setItem(discoverStorageKey, serializeSavedSections(selected))
let pending = selected.length
const markDone = () => {
pending -= 1
if (!cancelled && pending <= 0) setLoading(false)
}
for (const key of selected) {
const page = rowPages[key] ?? 1
discoverAPI
.feed([key], page)
.then((feed) => {
if (cancelled) return
const error = feed.meta[key]?.error
const nextItems = feed.items[key] ?? []
const nextCanNext = Boolean(feed.meta[key]?.has_next)
setRows((current) => {
if (error && nextItems.length === 0 && (current[key]?.length ?? 0) > 0) {
return current
}
return { ...current, [key]: nextItems }
})
setRowCanNext((current) => {
if (error && nextItems.length === 0 && key in current) {
return current
}
return { ...current, [key]: nextCanNext }
})
if (!error) {
writeCachedDiscoverRow(key, page, nextItems, nextCanNext)
}
setRowErrors((current) => updateDiscoverRowError(current, key, error))
})
.catch((err) => {
if (cancelled) return
const message = discoverRequestErrorMessage(err)
setRows((current) => ((current[key]?.length ?? 0) > 0 ? current : { ...current, [key]: [] }))
setRowCanNext((current) => (key in current ? current : { ...current, [key]: false }))
setRowErrors((current) => ({ ...current, [key]: message }))
})
.finally(() => {
if (!cancelled) {
setRowLoading((current) => ({ ...current, [key]: false }))
}
markDone()
})
}
return () => {
cancelled = true
}
}, [sections, sectionsReady, selected, rowPages, reloadSeq])
const sectionMap = useMemo(
() => new Map(sections.map((section) => [section.key, section])),
[sections],
)
const hasContent = selected.some((key) => (rows[key] ?? []).length > 0)
const sectionLabel = (key: string) => sectionMap.get(key)?.label ?? key
const toggleSection = (key: string) => {
setSelected((current) => {
if (current.includes(key)) {
return current.filter((item) => item !== key)
}
return [...current, key]
})
setRowPages((current) => ({ ...current, [key]: current[key] ?? 1 }))
}
const changeDiscoverPage = (key: string, delta: number) => {
setRowPages((current) => {
const nextPage = Math.max(1, (current[key] ?? 1) + delta)
if (nextPage === (current[key] ?? 1)) return current
return { ...current, [key]: nextPage }
})
}
const refreshDiscover = () => {
const nextImageVersion = String(Date.now())
setImageVersion(nextImageVersion)
setRefreshImageVersion(nextImageVersion)
setReloadSeq((current) => current + 1)
}
return (
<div className="mx-auto max-w-7xl space-y-8 px-4 py-6">
<DiscoverHeader
sections={sections}
selected={selected}
sectionsReady={sectionsReady}
loading={loading}
onRefresh={refreshDiscover}
onToggleSection={toggleSection}
/>
{!sectionsReady && <DiscoverSkeleton />}
{sectionsReady && !loading && selected.length === 0 && (
<DiscoverEmptySelection />
)}
{sectionsReady && selected.length > 0 && (
<DiscoverResults
selected={selected}
rows={rows}
rowLoading={rowLoading}
rowErrors={rowErrors}
rowPages={rowPages}
rowCanNext={rowCanNext}
loading={loading}
hasContent={hasContent}
imageVersion={imageVersion}
refreshImageVersion={refreshImageVersion}
sectionLabel={sectionLabel}
onPageChange={changeDiscoverPage}
onSelect={setActiveItem}
/>
)}
{activeItem && (
<DiscoverDetailModal
item={activeItem}
onClose={() => setActiveItem(null)}
/>
)}
</div>
)
}
function updateDiscoverRowError(
current: Record<string, string>,
key: string,
error?: string,
): Record<string, string> {
if (error) return { ...current, [key]: error }
if (!(key in current)) return current
const next = { ...current }
delete next[key]
return next
}
function discoverRequestErrorMessage(err: unknown): string {
const raw = err instanceof Error ? err.message : String(err)
const lower = raw.toLowerCase()
if (lower.includes('timeout') || lower.includes('deadline')) {
return '推荐源请求超时,已跳过本次加载'
}
if (lower.includes('network')) {
return '推荐源网络不可用,已跳过本次加载'
}
return '推荐源暂时不可用,已跳过本次加载'
}
-188
View File
@@ -1,188 +0,0 @@
import { AlertTriangle, RefreshCw, Sparkles } from 'lucide-react'
import type { DiscoverItem, DiscoverSection } from '../api/discover'
import { ContentRow } from './DiscoverContentRow'
type SectionLabel = (key: string) => string
export function DiscoverHeader({
sections,
selected,
sectionsReady,
loading,
onRefresh,
onToggleSection,
}: {
sections: DiscoverSection[]
selected: string[]
sectionsReady: boolean
loading: boolean
onRefresh: () => void
onToggleSection: (key: string) => void
}) {
return (
<header className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="flex items-center gap-4">
<div className="rounded-2xl border border-primary-500/20 bg-gradient-to-br from-primary-500/20 to-primary-600/10 p-3">
<Sparkles className="h-8 w-8 text-brand-500" />
</div>
<div>
<h1 className="font-display text-4xl font-bold tracking-tight text-ink-600">
发现
</h1>
<p className="mt-1 text-base text-ink-50">
多源推荐:TMDb / 豆瓣 / Bangumi,可按需组合显示
</p>
</div>
</div>
<div className="flex flex-col gap-3 lg:items-end">
<button
type="button"
onClick={onRefresh}
disabled={!sectionsReady || selected.length === 0}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-ink-600 transition hover:border-primary-300 hover:text-brand-500 disabled:cursor-not-allowed disabled:opacity-50"
>
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
刷新
</button>
<div className="flex flex-wrap justify-start gap-2 lg:justify-end">
{sections.map((section) => {
const active = selected.includes(section.key)
return (
<button
key={section.key}
type="button"
onClick={() => onToggleSection(section.key)}
className={
'rounded-full border px-3 py-1.5 text-xs font-semibold transition ' +
(active
? 'border-primary-400 bg-primary-400/15 text-brand-500'
: 'border-gray-200 bg-white text-gray-500 hover:border-primary-300 hover:text-ink-600')
}
>
{section.label}
</button>
)
})}
</div>
</div>
</header>
)
}
export function DiscoverEmptySelection() {
return (
<div className="rounded-2xl border border-gray-200 bg-white p-10 text-center text-sand-500">
至少选择一个推荐源,小宇宙才会开始转动。
</div>
)
}
export function DiscoverResults({
selected,
rows,
rowLoading,
rowErrors,
rowPages,
rowCanNext,
loading,
hasContent,
imageVersion,
refreshImageVersion,
sectionLabel,
onPageChange,
onSelect,
}: {
selected: string[]
rows: Record<string, DiscoverItem[]>
rowLoading: Record<string, boolean>
rowErrors: Record<string, string>
rowPages: Record<string, number>
rowCanNext: Record<string, boolean>
loading: boolean
hasContent: boolean
imageVersion: string
refreshImageVersion?: string
sectionLabel: SectionLabel
onPageChange: (key: string, delta: number) => void
onSelect: (item: DiscoverItem) => void
}) {
const hasRowErrors = Object.keys(rowErrors).length > 0
return (
<div className="space-y-10">
{selected.map((key) => {
const items = rows[key] ?? []
if (items.length === 0) {
if (rowLoading[key]) {
return <DiscoverRowSkeleton key={key} title={sectionLabel(key)} />
}
return null
}
return (
<ContentRow
key={key}
title={sectionLabel(key)}
items={items}
page={rowPages[key] ?? 1}
canNext={Boolean(rowCanNext[key])}
imageVersion={imageVersion}
refreshImageVersion={refreshImageVersion}
onPageChange={(delta) => onPageChange(key, delta)}
onSelect={onSelect}
/>
)
})}
{hasRowErrors && (
<DiscoverRowErrors rowErrors={rowErrors} sectionLabel={sectionLabel} />
)}
{!loading && !hasContent && !hasRowErrors && <DiscoverNoContent />}
</div>
)
}
function DiscoverRowErrors({
rowErrors,
sectionLabel,
}: {
rowErrors: Record<string, string>
sectionLabel: SectionLabel
}) {
return (
<div className="flex items-start gap-3 rounded-lg border border-amber-300/70 bg-amber-50 px-3 py-2 text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-500" />
<div className="space-y-1 text-xs">
<p className="font-semibold">部分推荐源暂不可用,其他已加载内容不受影响。</p>
{Object.entries(rowErrors).map(([key, message]) => (
<p key={key}>{sectionLabel(key)}:{message}</p>
))}
</div>
</div>
)
}
function DiscoverNoContent() {
return (
<div className="rounded-2xl border border-gray-200 bg-white p-10 text-center">
<p className="text-sand-500">
当前选择的推荐源暂未返回内容,可切换豆瓣 / Bangumi 或检查网络代理。
</p>
</div>
)
}
function DiscoverRowSkeleton({ title }: { title: string }) {
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">
{[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>
)
}
-70
View File
@@ -1,70 +0,0 @@
import { Loader2, Pencil, Send, Trash2 } from 'lucide-react'
import type { DownloadClient } from '../api/download_clients'
export function DownloadClientCard({
client,
testing,
onDelete,
onEdit,
onTest,
}: {
client: DownloadClient
testing: boolean
onDelete: (client: DownloadClient) => void
onEdit: (client: DownloadClient) => void
onTest: (id: string) => void
}) {
return (
<div className="glass-panel flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-ink-600">{client.name}</span>
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs text-ink-50">
{client.type}
</span>
{client.is_default && (
<span className="rounded-lg bg-primary-400/20 px-2 py-0.5 text-xs text-brand-500">
默认
</span>
)}
{!client.enabled && (
<span className="rounded-lg bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">
已禁用
</span>
)}
</div>
<div className="mt-1 truncate text-xs text-ink-50">
{client.host}
{client.username && ` · ${client.username}`}
</div>
</div>
<div className="flex shrink-0 gap-2">
<button
onClick={() => onTest(client.id)}
disabled={testing}
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
{testing ? (
<Loader2 size={12} className="inline animate-spin" />
) : (
<Send size={12} className="inline" />
)}{' '}
测试
</button>
<button
onClick={() => onEdit(client)}
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
<Pencil size={12} className="inline" /> 编辑
</button>
<button
onClick={() => onDelete(client)}
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
>
<Trash2 size={12} className="inline" /> 删除
</button>
</div>
</div>
)
}
-170
View File
@@ -1,170 +0,0 @@
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>
)
}
-129
View File
@@ -1,129 +0,0 @@
import { useEffect, useState } from 'react'
import { Loader2, Plus, Server } from 'lucide-react'
import toast from 'react-hot-toast'
import {
downloadClientsAPI,
type DownloadClient,
} from '../api/download_clients'
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
// surface and a per-client Test button.
export function DownloadClientsPage() {
const [clients, setClients] = useState<DownloadClient[]>([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState<DownloadClient | null>(null)
const [showForm, setShowForm] = useState(false)
const [testing, setTesting] = useState<Record<string, boolean>>({})
const refresh = async () => {
setLoading(true)
try {
setClients(await downloadClientsAPI.list())
} finally {
setLoading(false)
}
}
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const onTest = async (id: string) => {
if (testing[id]) return
setTesting((current) => ({ ...current, [id]: true }))
try {
const r = await downloadClientsAPI.test(id)
if (r.ok) toast.success('连接成功')
else toast.error(r.error ?? '连接失败')
} catch (err: unknown) {
const msg = apiErrorMessage(err, '测试失败')
toast.error(msg)
} finally {
setTesting((current) => ({ ...current, [id]: false }))
}
}
const onDelete = async (c: DownloadClient) => {
if (!(await confirmAction({ title: '删除下载器', message: `确定删除「${c.name}」?`, confirmText: '删除' }))) return
try {
await downloadClientsAPI.remove(c.id)
toast.success('已删除')
await refresh()
} catch (err: unknown) {
const msg = apiErrorMessage(err, '删除失败')
toast.error(msg)
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-cyan-400/10 text-cyan-300">
<Server size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">下载器管理</h1>
<p className="text-sm text-ink-50">
qBittorrent / Aria2 / Transmission · 多客户端 + 连接测试
</p>
</div>
</div>
<button
onClick={() => {
setEditing(null)
setShowForm(true)
}}
className="neon-button"
>
<Plus size={16} /> 添加下载器
</button>
</div>
{loading && (
<div className="flex justify-center py-12 text-ink-50">
<Loader2 className="animate-spin" />
</div>
)}
{!loading && clients.length === 0 && (
<div className="glass-panel py-12 text-center text-ink-50">暂无下载器</div>
)}
{!loading && clients.length > 0 && (
<div className="space-y-3">
{clients.map((c) => (
<DownloadClientCard
key={c.id}
client={c}
testing={Boolean(testing[c.id])}
onDelete={onDelete}
onEdit={(client) => {
setEditing(client)
setShowForm(true)
}}
onTest={onTest}
/>
))}
</div>
)}
{showForm && (
<ClientFormModal
editing={editing}
onClose={() => setShowForm(false)}
onSaved={async () => {
setShowForm(false)
refresh().catch((err: unknown) => toast.error(apiErrorMessage(err, '刷新下载器列表失败')))
}}
/>
)}
</div>
)
}
-143
View File
@@ -1,143 +0,0 @@
import type { ReactNode } from 'react'
import { ArrowDown, ArrowUp, Film, HardDrive, Rss, Trash2 } from 'lucide-react'
import { imageURL } from '../api/client'
import type { DownloadCardItem } from './downloadTaskCardModel'
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'
}
-134
View File
@@ -1,134 +0,0 @@
import { FormEvent, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import toast from 'react-hot-toast'
import { Download, ShieldCheck } from 'lucide-react'
import { downloadsAPI } from '../api/downloads'
import { useAuthStore } from '../stores/auth'
import { confirmAction } from '../components/confirmAction'
import type { DownloadTask, QBitTorrent } from '../types'
import { DownloadTaskCard } from './DownloadTaskCard'
import { toLiveCard, toTaskCard } from './downloadTaskCardModel'
export function DownloadsPage() {
const role = useAuthStore((s) => s.user?.role)
const [tasks, setTasks] = useState<DownloadTask[]>([])
const [torrents, setTorrents] = useState<QBitTorrent[] | null>(null)
const [url, setURL] = useState('')
const [savePath, setSavePath] = useState('')
const refresh = () =>
downloadsAPI.list().then((d) => {
setTasks(d.tasks)
setTorrents(d.torrents)
})
useEffect(() => {
void refresh().catch(() => undefined)
const id = window.setInterval(() => void refresh().catch(() => undefined), 5_000)
return () => window.clearInterval(id)
}, [])
const onAdd = async (e: FormEvent) => {
e.preventDefault()
try {
await downloadsAPI.add(url, savePath)
toast.success('已加入下载队列')
setURL('')
setSavePath('')
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">
<header className="space-y-2">
<h1 className="font-display text-3xl font-bold text-ink-600">下载管理</h1>
<p className="flex items-center gap-2 text-sm text-ink-50">
<ShieldCheck size={16} className="text-brand-500" />
页面仅展示安全标题、海报和进度信息,不再暴露种子原始 URL 或私有 Token。
</p>
</header>
<form onSubmit={onAdd} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_auto]">
<input
required
className="input-base md:col-span-2"
placeholder="磁力链接 / .torrent URL(提交后不会在页面公开显示)"
value={url}
onChange={(e) => setURL(e.target.value)}
/>
<input
className="input-base"
placeholder="保存路径 (可选)"
value={savePath}
onChange={(e) => setSavePath(e.target.value)}
/>
<button type="submit" className="neon-button md:col-span-3">
<Download size={16} /> 添加
</button>
</form>
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-display text-xl font-semibold text-ink-600">当前下载</h2>
<span className="text-xs text-ink-50">每 5 秒自动刷新</span>
</div>
{torrents === null && (
<div className="glass-panel text-sand-500">
尚未连接到下载器 —{' '}
{role === 'admin' ? (
<>
请到{' '}
<Link to="/download-clients" className="text-brand-500 hover:underline">
下载器
</Link>{' '}
页面添加并测试连接。
</>
) : (
'请联系管理员添加并测试下载器连接。'
)}
</div>
)}
{torrents && torrents.length === 0 && (
<div className="glass-panel text-sand-500">暂无运行中任务。</div>
)}
{torrents && torrents.length > 0 && (
<div className="grid gap-5 lg:grid-cols-2 2xl:grid-cols-3">
{torrents.map((torrent) => (
<DownloadTaskCard
key={`${torrent.client_id}:${torrent.hash}`}
item={toLiveCard(torrent)}
removable={role === 'admin'}
onRemove={async () => {
if (!(await confirmAction({ title: '删除下载任务', message: `删除「${torrent.title || torrent.name}」?`, confirmText: '删除' }))) return
await downloadsAPI.remove(torrent.hash, torrent.client_id, false)
toast.success('已删除任务')
await refresh()
}}
/>
))}
</div>
)}
</section>
<section className="space-y-3">
<h2 className="font-display text-xl font-semibold text-ink-600">下载历史</h2>
{tasks.length === 0 ? (
<div className="glass-panel text-sand-500">暂无历史下载。</div>
) : (
<div className="grid gap-5 lg:grid-cols-2 2xl:grid-cols-3">
{tasks.map((task) => (
<DownloadTaskCard key={task.id} item={toTaskCard(task)} />
))}
</div>
)}
</section>
</div>
)
}
-130
View File
@@ -1,130 +0,0 @@
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Copy, Trash2 } from 'lucide-react'
import { duplicatesAPI, type DuplicateReport } from '../api/duplicates'
import { libraryAPI } from '../api/library'
import { confirmAction } from '../components/confirmAction'
import type { Library } from '../types'
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]}`
}
export function DuplicatesPage() {
const [libs, setLibs] = useState<Library[]>([])
const [libID, setLibID] = useState('')
const [report, setReport] = useState<DuplicateReport | null>(null)
const [scanning, setScanning] = useState(false)
useEffect(() => {
libraryAPI.list().then(setLibs)
}, [])
useEffect(() => {
duplicatesAPI.list(libID).then(setReport).catch(() => setReport(null))
}, [libID])
const scan = async () => {
setScanning(true)
try {
const r = await duplicatesAPI.scan(libID)
setReport(r)
const cleaned = r.missing_removed ? `, 清理 ${r.missing_removed} 条失效记录` : ''
toast.success(`扫描完成: ${r.groups_found} 组重复, ${r.items_marked} 项标记${cleaned}`)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'扫描失败'
toast.error(msg)
} finally {
setScanning(false)
}
}
const unmark = async () => {
if (!(await confirmAction({ title: '清除重复标记', message: '清除所有重复标记?(磁盘文件不会被删除)', confirmText: '清除' }))) return
const r = await duplicatesAPI.unmark(libID)
toast.success(`已清除 ${r.unmarked} 项`)
setReport(null)
}
return (
<div className="space-y-6">
<header className="flex items-center gap-3">
<Copy className="h-6 w-6 text-brand-500" />
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">重复文件</h1>
<p className="text-sm text-ink-50">
通过稀疏采样 MD5(头部 / 中部 / 尾部各 1 MiB + 文件大小)检测重复媒体,
同一组中保留刮削过的较大文件作为主条目,其余标记为重复。
</p>
</div>
</header>
<div className="glass-panel grid gap-3 md:grid-cols-[1fr_auto_auto]">
<select
className="input-base"
value={libID}
onChange={(e) => setLibID(e.target.value)}
>
<option value="">所有媒体库</option>
{libs.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
</select>
<button onClick={scan} disabled={scanning} className="neon-button">
{scanning ? '扫描中…' : '开始扫描'}
</button>
<button onClick={unmark} className="neon-button !border-red-400/40 !text-red-400">
<Trash2 size={14} /> 清除标记
</button>
</div>
{report && report.groups_found === 0 && (
<p className="text-ink-50">扫描了 {report.total_scanned} 项,未发现重复。</p>
)}
{report && report.missing_removed ? (
<p className="rounded-2xl border border-amber-300/50 bg-amber-50 px-4 py-3 text-sm text-amber-700">
已清理 {report.missing_removed} 条文件不存在的媒体记录,统计容量会在刷新后恢复正常。
</p>
) : null}
{report && (report.groups ?? []).map((g) => (
<section key={g.hash} className="glass-panel space-y-2">
<div className="flex items-center justify-between">
<p className="font-mono text-xs text-sand-500">{g.hash}</p>
<span className="rounded-lg border border-emerald-400/40 px-2 py-0.5 text-xs text-emerald-400">
主条目
</span>
</div>
<p className="font-medium text-ink-600">{g.primary.title}</p>
<p className="font-mono text-xs text-ink-50">
{g.primary.path} · {fmtBytes(g.primary.size_bytes)}
</p>
<div className="space-y-1 border-t border-gray-200 pt-2">
<p className="text-xs uppercase tracking-wider text-red-400">
重复 ({g.duplicates.length})
</p>
{g.duplicates.map((d) => (
<div key={d.id} className="text-xs text-ink-50">
<span className="font-medium text-ink-600">{d.title}</span> · {d.path} · {fmtBytes(d.size_bytes)}
</div>
))}
</div>
</section>
))}
</div>
)
}
+147 -32
View File
@@ -1,62 +1,157 @@
import { useEffect, useMemo, useState } from 'react'
import { libraryAPI, mediaAPI } from '../api/library'
import { libraryAPI } from '../api/library'
import { playbackAPI, type HistoryItem } from '../api/playback'
import type { Library, Media } from '../types'
import { groupSeries, type SeriesCard } from '../utils/groupSeries'
import {
ContinueWatchingSection,
HomeCarouselSection,
HomeEmptyState,
HomeFeaturedSection,
HomeLibrariesSection,
HomeLibraryRowSection,
HomeLoadingState,
RecentMediaSection,
} from './HomePageSections'
const CAROUSEL_STORAGE_KEY = 'mmtl.home.carousel_libraries'
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
const asArray = <T,>(value: unknown): T[] => (Array.isArray(value) ? value as T[] : [])
const asArray = <T,>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : [])
export function HomePage() {
const [libraries, setLibraries] = useState<Library[]>([])
const [recentCards, setRecentCards] = useState<SeriesCard[]>([])
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>>({})
const [selectedLibraryIds, setSelectedLibraryIds] = useState<string[]>(() => {
try {
const saved = localStorage.getItem(CAROUSEL_STORAGE_KEY)
return saved ? JSON.parse(saved) : []
} catch {
return []
}
})
const [history, setHistory] = useState<HistoryItem[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
try {
const [libs, recentItems, hist] = await Promise.all([
const [libs, hist] = await Promise.all([
libraryAPI.list().then((rows) => asArray<Library>(rows)).catch(() => [] as Library[]),
mediaAPI.recent(24).then((rows) => asArray<SeriesCard>(rows)).catch(async () => {
const fallback = await mediaAPI.search('', 120).then((d) => asArray<Media>(d?.items)).catch(() => [] as Media[])
return groupSeries(fallback).slice(0, 24)
}),
playbackAPI.recentHistory().then((rows) => asArray<HistoryItem>(rows)).catch(() => [] as HistoryItem[]),
])
if (cancelled) return
setLibraries(libs)
setRecentCards(recentItems)
setHistory(hist.filter((h) => h && !h.completed && !!h.media))
// Set default selected libraries if none saved yet
setSelectedLibraryIds((current) => {
if (current.length > 0) return current
const allIds = libs.map((l) => l.id)
try {
localStorage.setItem(CAROUSEL_STORAGE_KEY, JSON.stringify(allIds))
} catch {
// ignore
}
return allIds
})
// Fetch media items for all libraries in parallel
const results = await Promise.allSettled(
libs.map(async (lib) => {
const page = await libraryAPI.listMedia(lib.id, 1, 30)
const items = asArray<Media>(page?.items)
const cards = groupSeries(items)
return {
id: lib.id,
cards,
items,
total: page?.total ?? items.length,
}
}),
)
if (cancelled) return
const mapData: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }> = {}
for (const res of results) {
if (res.status === 'fulfilled' && res.value) {
mapData[res.value.id] = {
cards: res.value.cards,
items: res.value.items,
total: res.value.total,
}
}
}
setLibraryData(mapData)
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => { cancelled = true }
return () => {
cancelled = true
}
}, [])
const featuredItem = useMemo(() => {
const candidates = [
...(history.map((h) => h.media).filter(Boolean) as Media[]),
...recentCards.map((card) => card.rep),
]
return candidates.find(hasArtwork) ?? candidates[0] ?? null
}, [history, recentCards])
const featuredVisual = featuredItem?.backdrop_url || featuredItem?.poster_url || ''
const featuredPoster = featuredItem?.poster_url || featuredItem?.backdrop_url || ''
const featuredMark = (featuredItem?.title || 'MS').trim().slice(0, 4).toUpperCase()
const empty = !loading && libraries.length === 0 && recentCards.length === 0 && history.length === 0
// Quick lookup map for libraries
const libraryMap = useMemo(() => {
const map = new Map<string, Library>()
for (const lib of libraries) {
map.set(lib.id, lib)
}
return map
}, [libraries])
// Counts lookup
const libraryCounts = useMemo(() => {
const counts: Record<string, number> = {}
for (const lib of libraries) {
counts[lib.id] = libraryData[lib.id]?.total ?? 0
}
return counts
}, [libraries, libraryData])
// Compute items to show in the Hero Carousel
const carouselItems = useMemo(() => {
const candidateMedia: Media[] = []
const effectiveSelectedIds =
selectedLibraryIds.length > 0 ? selectedLibraryIds : libraries.map((l) => l.id)
for (const libId of effectiveSelectedIds) {
const data = libraryData[libId]
if (data && data.items.length > 0) {
// Pick representative items from series cards or raw items
for (const card of data.cards) {
if (hasArtwork(card.rep)) {
candidateMedia.push(card.rep)
}
}
}
}
// Sort by artwork score / rating or shuffle / interleave
if (candidateMedia.length === 0) {
// Fallback to all loaded items with artwork
for (const lib of libraries) {
const data = libraryData[lib.id]
if (data) {
for (const card of data.cards) {
candidateMedia.push(card.rep)
}
}
}
}
return candidateMedia.slice(0, 10)
}, [selectedLibraryIds, libraries, libraryData])
const empty =
!loading &&
libraries.length === 0 &&
history.length === 0
if (loading) {
return <HomeLoadingState />
@@ -67,18 +162,38 @@ export function HomePage() {
}
return (
<div className="space-y-12">
{featuredItem && (
<HomeFeaturedSection
featuredItem={featuredItem}
featuredVisual={featuredVisual}
featuredPoster={featuredPoster}
featuredMark={featuredMark}
<div className="space-y-12 pb-16">
{/* 1. 顶部海报轮播区 */}
{carouselItems.length > 0 && (
<HomeCarouselSection items={carouselItems} libraryMap={libraryMap} />
)}
{/* 2. 继续观看(若有历史) */}
{history.length > 0 && <ContinueWatchingSection history={history} />}
{/* 3. 媒体库卡片区 */}
{libraries.length > 0 && (
<HomeLibrariesSection
libraries={libraries}
libraryData={libraryData}
libraryCounts={libraryCounts}
/>
)}
{history.length > 0 && <ContinueWatchingSection history={history} />}
{recentCards.length > 0 && <RecentMediaSection recentCards={recentCards} />}
{/* 4. 各媒体库内容展示行 */}
<div className="space-y-10">
{libraries.map((lib) => {
const cards = libraryData[lib.id]?.cards || []
if (cards.length === 0) return null
return (
<HomeLibraryRowSection
key={lib.id}
library={lib}
cards={cards}
/>
)
})}
</div>
</div>
)
}
+523 -122
View File
@@ -1,23 +1,69 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ArrowRight, Clock, Film, Play, Sparkles } from 'lucide-react'
import { AnimatePresence, motion } from 'framer-motion'
import {
ArrowRight,
ChevronLeft,
ChevronRight,
Clock,
Film,
FolderOpen,
Library as LibraryIcon,
Music,
Play,
PlayCircle,
Sparkles,
Star,
Tv,
} from 'lucide-react'
import { imageURL } from '../api/client'
import { MediaCard } from '../components/MediaCard'
import type { HistoryItem } from '../api/playback'
import type { Media } from '../types'
import type { Library, Media } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
import { seriesCardLink } from '../utils/groupSeries'
import { getLibraryArtworks } from './librariesPageModel'
const TYPE_ICONS: Record<string, ReactNode> = {
movie: <Film size={18} />,
movies: <Film size={18} />,
tv: <Tv size={18} />,
series: <Tv size={18} />,
anime: <PlayCircle size={18} />,
shows: <Tv size={18} />,
variety: <Tv size={18} />,
music: <Music size={18} />,
adult: <Film size={18} />,
}
const TYPE_LABELS: Record<string, string> = {
movie: '电影',
movies: '电影',
tv: '剧集',
series: '剧集',
anime: '动漫',
shows: '综艺',
variety: '综艺',
music: '音乐',
adult: 'Adult',
}
export function HomeLoadingState() {
return (
<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">
<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 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 uppercase tracking-widest text-[var(--app-muted)]">首页内容准备中…</span>
<span className="text-sm font-semibold uppercase tracking-widest text-[var(--app-muted)]">
首页内容准备中…
</span>
</motion.div>
</div>
)
@@ -31,165 +77,512 @@ export function HomeEmptyState() {
</div>
<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">
前往管理后台
<Link to="/libraries" className="mt-8 btn-primary">
前往媒体库
</Link>
</div>
)
}
export function HomeFeaturedSection({
featuredItem,
featuredVisual,
featuredPoster,
featuredMark,
/* =========================================================================
1. 海报轮播区 (Hero Carousel Section)
========================================================================= */
export function HomeCarouselSection({
items,
libraryMap,
}: {
featuredItem: Media
featuredVisual: string
featuredPoster: string
featuredMark: string
items: Media[]
libraryMap: Map<string, Library>
}) {
const [currentIndex, setCurrentIndex] = useState(0)
const [isPaused, setIsPaused] = useState(false)
const currentItem = items[currentIndex] || items[0]
const count = items.length
useEffect(() => {
if (count <= 1 || isPaused) return
const timer = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % count)
}, 5000)
return () => clearInterval(timer)
}, [count, isPaused])
if (!currentItem) return null
const handlePrev = () => {
setCurrentIndex((prev) => (prev - 1 + count) % count)
}
const handleNext = () => {
setCurrentIndex((prev) => (prev + 1) % count)
}
const visual = currentItem.backdrop_url || currentItem.poster_url || ''
const poster = currentItem.poster_url || currentItem.backdrop_url || ''
const lib = libraryMap.get(currentItem.display_library_id || currentItem.library_id)
return (
<section className="relative overflow-hidden rounded-[2rem] border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_24px_80px_var(--app-shadow)]">
<section
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
className="group relative overflow-hidden rounded-[2rem] border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_24px_80px_var(--app-shadow)] min-h-[420px] md:min-h-[460px] flex flex-col justify-end"
>
{/* Background Backdrop Image with Crossfade */}
<div className="absolute inset-0 z-0">
<div className="theme-hero-bg h-full w-full" />
{featuredVisual && (
<img
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' }}
/>
)}
<AnimatePresence mode="wait">
{visual && (
<motion.img
key={visual + currentItem.id}
initial={{ opacity: 0, scale: 1.08 }}
animate={{ opacity: 0.38, scale: 1.02 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
src={imageURL(visual, currentItem.updated_at)}
alt=""
className="absolute inset-0 h-full w-full object-cover object-center blur-[1px]"
referrerPolicy="no-referrer"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
)}
</AnimatePresence>
<div className="theme-hero-overlay absolute inset-0" />
<div className="theme-hero-fade absolute inset-x-0 bottom-0 h-32" />
<div className="theme-hero-fade absolute inset-x-0 bottom-0 h-36" />
</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 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-[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>
{/* Main Content Layout */}
<div className="relative z-10 grid gap-8 px-6 py-8 sm:px-8 md:grid-cols-[minmax(0,1fr)_260px] md:px-12 md:py-12 lg:grid-cols-[minmax(0,1fr)_320px] lg:px-14 lg:py-14">
{/* Left Column: Metadata & Actions */}
<div className="flex min-w-0 flex-col justify-center space-y-4 sm:space-y-5">
{/* Tag / Library Label */}
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex w-fit items-center gap-1.5 rounded-full border border-[var(--app-brand-border)] bg-[var(--app-brand-soft)] px-3 py-1 text-xs font-bold uppercase tracking-widest text-[var(--app-brand-text)] shadow-sm backdrop-blur">
<Sparkles size={12} fill="currentColor" />
<span>焦点推荐</span>
</div>
<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>
{lib && (
<span className="inline-flex items-center gap-1 rounded-full border border-[var(--app-border)] bg-[var(--app-panel)]/80 px-2.5 py-0.5 text-xs font-bold text-[var(--app-text)] shadow-sm backdrop-blur">
{TYPE_ICONS[lib.type] || <FolderOpen size={12} />}
<span>{lib.name}</span>
</span>
)}
</div>
<p className="line-clamp-3 max-w-2xl text-sm font-semibold leading-relaxed text-[var(--app-subtle)] sm:text-base">
{featuredItem.overview || '家庭私人媒体中心收藏。支持多端播放、外部播放器、智能刮削与订阅下载。'}
{/* Title */}
<div className="space-y-2">
<AnimatePresence mode="wait">
<motion.h1
key={currentItem.title + currentItem.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.3 }}
className="font-display text-2xl font-extrabold leading-tight tracking-tight text-[var(--app-text)] sm:text-3xl md:text-4xl lg:text-5xl"
>
{currentItem.title}
</motion.h1>
</AnimatePresence>
{currentItem.original_name && currentItem.original_name !== currentItem.title && (
<p className="text-xs font-semibold text-[var(--app-muted)] tracking-wide">
{currentItem.original_name}
</p>
)}
</div>
{/* Metadata Chips */}
<div className="flex flex-wrap items-center gap-2.5 text-xs font-bold text-[var(--app-muted)]">
{currentItem.rating > 0 && (
<span className="inline-flex items-center gap-1 rounded-xl border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-xs font-bold text-amber-500 shadow-sm">
<Star size={12} fill="currentColor" />
<span>{currentItem.rating.toFixed(1)}</span>
</span>
)}
{currentItem.year > 0 && (
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-2.5 py-1 text-[var(--app-text)] shadow-sm">
{currentItem.year} 年
</span>
)}
{currentItem.video_codec && (
<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)]">
{currentItem.video_codec}
</span>
)}
{currentItem.container && (
<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)]">
{currentItem.container}
</span>
)}
</div>
{/* Overview */}
<p className="line-clamp-3 max-w-2xl text-xs font-semibold leading-relaxed text-[var(--app-subtle)] sm:text-sm">
{currentItem.overview || '家庭私人媒体中心收藏。支持多端播放、高码率串流与智能刮削。'}
</p>
<div className="flex flex-wrap items-center gap-3 text-xs font-bold text-[var(--app-muted)]">
{featuredItem.year > 0 && (
<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 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 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>
{/* Action Buttons */}
<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-[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">
<Link
to={`/play/${currentItem.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 hover:shadow-brand-500/25"
>
<Play size={16} fill="currentColor" />
<span>立即播放</span>
</Link>
<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>
<Link
to={`/media/${currentItem.id}`}
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>
</div>
</div>
<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" />
{/* Right Column: Floating 3D Poster Card */}
<div className="relative order-first mx-auto flex w-full max-w-[200px] items-center md:order-none md:max-w-[240px] lg:max-w-[280px]">
<div className="absolute -right-6 top-5 h-32 w-32 rounded-full bg-brand-500/20 blur-3xl" />
<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)' }}>
<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-[var(--app-text)]">{featuredItem.title}</span>
<span className="px-6 font-display text-2xl font-black tracking-tight text-[var(--app-text)]">
{currentItem.title}
</span>
</div>
{featuredPoster && (
{poster && (
<img
src={imageURL(featuredPoster, featuredItem.updated_at)}
alt={featuredItem.title}
src={imageURL(poster, currentItem.updated_at)}
alt={currentItem.title}
className="absolute inset-2 h-[calc(100%-1rem)] w-[calc(100%-1rem)] rounded-[1.25rem] object-cover"
referrerPolicy="no-referrer"
onError={(event) => { event.currentTarget.style.display = 'none' }}
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
)}
</div>
</div>
</div>
{/* Carousel Controls & Dots */}
{count > 1 && (
<div className="relative z-20 flex items-center justify-between border-t border-[var(--app-border)]/40 bg-[var(--app-panel)]/40 px-6 py-3 backdrop-blur-md">
{/* Navigation Indicators */}
<div className="flex items-center gap-1.5">
{items.map((_, idx) => (
<button
key={idx}
type="button"
onClick={() => setCurrentIndex(idx)}
className={`h-2 rounded-full transition-all duration-300 ${
idx === currentIndex
? 'w-7 bg-brand-500'
: 'w-2 bg-[var(--app-border)] hover:bg-[var(--app-muted)]'
}`}
title={`第 ${idx + 1} 张海报`}
/>
))}
</div>
{/* Slide Number & Arrow Buttons */}
<div className="flex items-center gap-3">
<span className="font-mono text-xs font-bold text-[var(--app-muted)]">
{String(currentIndex + 1).padStart(2, '0')} / {String(count).padStart(2, '0')}
</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={handlePrev}
className="rounded-xl border border-[var(--app-border)] p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors"
title="上一张"
>
<ChevronLeft size={16} />
</button>
<button
type="button"
onClick={handleNext}
className="rounded-xl border border-[var(--app-border)] p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors"
title="下一张"
>
<ChevronRight size={16} />
</button>
</div>
</div>
</div>
)}
</section>
)
}
export function ContinueWatchingSection({ history }: { history: HistoryItem[] }) {
/* =========================================================================
2. 媒体库卡片区 (Libraries Grid)
========================================================================= */
export function HomeLibrariesSection({
libraries,
libraryData,
libraryCounts,
}: {
libraries: Library[]
libraryData?: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>
libraryCounts: Record<string, number>
}) {
return (
<section className="space-y-5">
<div className="flex items-center gap-2.5">
<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-[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>
<section className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--app-border)] pb-3">
<div className="flex items-center gap-2.5">
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-1.5 text-[var(--app-text)]">
<LibraryIcon size={18} />
</span>
<div>
<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="/libraries"
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>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{history.slice(0, 8).map((h) => {
const media = h.media!
const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
return <ContinueCard key={h.id} media={media} progress={progress} />
{/* Libraries Grid */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-6">
{libraries.map((lib) => {
const count = libraryCounts[lib.id] ?? 0
const cards = libraryData?.[lib.id]?.cards || []
const artwork = getLibraryArtworks(lib, cards)
return (
<Link
key={lib.id}
to={`/library/${lib.id}`}
className="group relative flex flex-col justify-between overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3 transition-all duration-300 hover:-translate-y-1 hover:border-brand-500/50 hover:bg-[var(--app-hover)]/40 hover:shadow-lg hover:shadow-brand-500/10"
>
{/* 封面图片展示区:和媒体库页面一样,显示设置好的或生成的图片 */}
<div
className={`relative h-28 w-full overflow-hidden rounded-xl bg-[linear-gradient(135deg,var(--app-panel-soft),var(--app-panel))] shadow-inner ${
artwork.length > 1 ? 'grid grid-cols-2 gap-0.5' : ''
}`}
>
{artwork.length > 0 ? (
artwork.map(({ src, version }, index) => (
<img
key={`${src}-${index}`}
src={imageURL(src, version)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
onError={(event) => {
event.currentTarget.style.visibility = 'hidden'
}}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center text-brand-500">
{TYPE_ICONS[lib.type] || <FolderOpen size={28} />}
</div>
)}
{/* 浮动类型标签 */}
<div className="absolute top-2 right-2 rounded-lg border border-white/20 bg-black/60 px-2 py-0.5 text-[10px] font-bold text-white backdrop-blur-md shadow-sm">
{TYPE_LABELS[lib.type] || '自定义'}
</div>
</div>
{/* 媒体库信息 */}
<div className="mt-3 flex flex-col justify-between">
<h3 className="truncate font-display text-sm font-bold text-[var(--app-text)] group-hover:text-brand-500">
{lib.name}
</h3>
<p className="mt-0.5 text-xs text-[var(--app-muted)]">
{count > 0 ? `${count} 部媒体` : '暂无条目'}
</p>
</div>
</Link>
)
})}
</div>
</section>
)
}
export function RecentMediaSection({ recentCards }: { recentCards: SeriesCard[] }) {
/* =========================================================================
3. 单个媒体库内容行 (Horizontal Scroll Row)
========================================================================= */
export function HomeLibraryRowSection({
library,
cards,
}: {
library: Library
cards: SeriesCard[]
}) {
const scrollRef = useRef<HTMLDivElement>(null)
if (!cards || cards.length === 0) return null
const scroll = (direction: 'left' | 'right') => {
if (scrollRef.current) {
const scrollAmount = direction === 'left' ? -480 : 480
scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' })
}
}
return (
<section className="space-y-5">
<section className="space-y-4">
{/* Row Header */}
<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="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-1.5 text-brand-500">
{TYPE_ICONS[library.type] || <FolderOpen size={18} />}
</span>
<div>
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">
{library.name}
</h2>
<span className="text-xs text-[var(--app-muted)]">
共 {cards.length} 部精选内容
</span>
</div>
</div>
<div className="flex items-center gap-3">
<Link
to={`/library/${library.id}`}
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>
{/* Scroll Buttons */}
<div className="hidden sm:flex items-center gap-1">
<button
type="button"
onClick={() => scroll('left')}
className="rounded-xl border border-[var(--app-border)] p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors"
title="向左滚动"
>
<ChevronLeft size={16} />
</button>
<button
type="button"
onClick={() => scroll('right')}
className="rounded-xl border border-[var(--app-border)] p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors"
title="向右滚动"
>
<ChevronRight size={16} />
</button>
</div>
</div>
</div>
{/* Horizontal Carousel Row */}
<div
ref={scrollRef}
className="flex gap-4 overflow-x-auto pb-3 pt-1 no-scrollbar scroll-smooth snap-x"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{cards.map((card) => (
<div
key={card.key}
className="w-28 sm:w-32 md:w-36 lg:w-40 shrink-0 snap-start"
>
<MediaCard
media={card.rep}
count={card.count}
linkTo={seriesCardLink(card)}
compact
/>
</div>
))}
</div>
</section>
)
}
/* =========================================================================
4. 继续观看行 (Continue Watching)
========================================================================= */
export function ContinueWatchingSection({ history }: { history: HistoryItem[] }) {
const scrollRef = useRef<HTMLDivElement>(null)
const scroll = (direction: 'left' | 'right') => {
if (scrollRef.current) {
const scrollAmount = direction === 'left' ? -400 : 400
scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' })
}
}
return (
<section className="space-y-4">
<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="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-[var(--app-text)]">最近入库</h2>
<p className="text-xs text-[var(--app-muted)]">按整部电影、剧集、番剧和综艺合集展示新增内容。</p>
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">
继续观看
</h2>
<span className="text-xs text-[var(--app-muted)]">
{history.length} 条播放记录
</span>
</div>
</div>
<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>
<div className="hidden sm:flex items-center gap-1">
<button
type="button"
onClick={() => scroll('left')}
className="rounded-xl border border-[var(--app-border)] p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors"
title="向左滚动"
>
<ChevronLeft size={16} />
</button>
<button
type="button"
onClick={() => scroll('right')}
className="rounded-xl border border-[var(--app-border)] p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors"
title="向右滚动"
>
<ChevronRight size={16} />
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7">
{recentCards.map((card) => (
<MediaCard
key={card.key}
media={card.rep}
count={card.count}
linkTo={seriesCardLink(card)}
/>
))}
<div
ref={scrollRef}
className="flex gap-4 overflow-x-auto pb-3 pt-1 no-scrollbar scroll-smooth"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{history.slice(0, 12).map((h) => {
const media = h.media!
const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
return (
<div key={h.id} className="w-64 sm:w-72 shrink-0">
<ContinueCard media={media} progress={progress} />
</div>
)
})}
</div>
</section>
)
@@ -197,42 +590,50 @@ export function RecentMediaSection({ recentCards }: { recentCards: SeriesCard[]
function ContinueCard({ media, progress }: { media: Media; progress: number }) {
return (
<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)]">
<Link
to={`/media/${media.id}`}
className="group flex items-center gap-3.5 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3 shadow-sm transition-all duration-300 hover:border-brand-500/30 hover:bg-[var(--app-panel-soft)] hover:shadow-md"
>
<div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-xl bg-[var(--app-panel-soft)]">
{media.poster_url ? (
<img
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"
loading="lazy"
/>
) : (
<div className="flex h-full items-center justify-center bg-[var(--app-panel-soft)] text-[var(--app-muted)]">
<Film size={16} />
<div className="flex h-full w-full items-center justify-center text-[var(--app-muted)]">
<Film size={18} />
</div>
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/30 opacity-0 transition-opacity group-hover:opacity-100">
<Play size={14} fill="white" className="text-white" />
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
<Play size={16} fill="currentColor" className="text-white" />
</div>
</div>
<div className="min-w-0 flex-1 space-y-1.5">
<p className="truncate text-sm font-bold text-[var(--app-text)] transition-colors group-hover:text-brand-500">
<div className="min-w-0 flex-1 space-y-1">
<p className="truncate text-xs font-bold text-[var(--app-text)] 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-[var(--app-hover)]">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${Math.round(progress * 100)}%` }}
transition={{ duration: 0.5, delay: 0.1 }}
className="h-full rounded-full bg-gradient-to-r from-brand-400 to-brand-500"
<div className="flex items-center gap-2 text-[10px] text-[var(--app-muted)]">
{media.year > 0 && <span>{media.year}</span>}
{media.season_num !== undefined && media.episode_num !== undefined && (
<span>
S{media.season_num}E{media.episode_num}
</span>
)}
</div>
{/* Progress Bar */}
{progress > 0 && progress < 1 && (
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-[var(--app-hover)]">
<div
className="h-full bg-brand-500 rounded-full"
style={{ width: `${Math.round(progress * 100)}%` }}
/>
</div>
<p className="text-[10px] font-bold uppercase tracking-wide text-[var(--app-muted)]">
已观看到 {Math.round(progress * 100)}%
</p>
</div>
)}
</div>
</Link>
)
+5 -16
View File
@@ -6,9 +6,10 @@ import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, PlayCircle
import { imageURL } from '../api/client'
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
import { MediaCard } from '../components/MediaCard'
import { artworkScore, seriesCardLink, type SeriesCard } from '../utils/groupSeries'
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
import { seriesCardLink } from '../utils/groupSeries'
import { libraryDisplayPath } from './libraryDisplayModel'
import { mediaTime, type LibraryPreview } from './librariesPageModel'
import { libraryArtworkItems, type LibraryPreview } from './librariesPageModel'
const TYPE_ICONS: Record<string, ReactNode> = {
movie: <Film size={18} />,
@@ -71,10 +72,9 @@ export function LibrariesHeader({
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
{repairing ? '正在启动…' : '全库修复+重刮'}
</button>
<Link to="/admin" className="btn-outline">
<button type="button" onClick={() => openManageLibrariesDialog()} className="btn-outline">
管理媒体库
<ArrowRight size={14} />
</Link>
</button>
</div>
</div>
)
@@ -223,14 +223,3 @@ function LibraryShelf({ preview }: { preview: LibraryPreview }) {
</section>
)
}
function libraryArtworkItems(cards: SeriesCard[]): Array<{ src: string; version?: string }> {
return [...cards]
.sort((a, b) => artworkScore(b.rep) - artworkScore(a.rep) || mediaTime(b.rep) - mediaTime(a.rep))
.map((card) => ({
src: card.rep.poster_url || card.rep.backdrop_url || '',
version: card.rep.updated_at,
}))
.filter((item) => Boolean(item.src))
.slice(0, 4)
}
+305
View File
@@ -0,0 +1,305 @@
import { useEffect, useState } from 'react'
import {
Check,
CheckSquare,
Film,
FolderOpen,
HeartHandshake,
Layers,
Loader2,
Music,
Save,
SlidersHorizontal,
Square,
Tv,
} from 'lucide-react'
import toast from 'react-hot-toast'
import { adminAPI } from '../api/admin'
import { imageURL } from '../api/client'
import { libraryAPI } from '../api/library'
import type { Library, Setting } from '../types'
import { groupSeries, type SeriesCard } from '../utils/groupSeries'
import { getLibraryArtworks } from './librariesPageModel'
const CAROUSEL_STORAGE_KEY = 'mmtl.home.carousel_libraries'
const SETTING_KEY_CAROUSEL = 'home.carousel_libraries'
const TYPE_ICONS: Record<string, React.ReactNode> = {
movie: <Film size={20} />,
movies: <Film size={20} />,
tv: <Tv size={20} />,
series: <Tv size={20} />,
anime: <Layers size={20} />,
shows: <Tv size={20} />,
variety: <Tv size={20} />,
music: <Music size={20} />,
adult: <HeartHandshake size={20} />,
}
const TYPE_LABELS: Record<string, string> = {
movie: '电影',
movies: '电影',
tv: '剧集',
series: '剧集',
anime: '动漫',
shows: '综艺',
variety: '综艺',
music: '音乐',
adult: 'Adult',
}
export function LibrarySettingsPanel() {
const [libraries, setLibraries] = useState<Library[]>([])
const [libraryCards, setLibraryCards] = useState<Record<string, SeriesCard[]>>({})
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
useEffect(() => {
async function load() {
setLoading(true)
try {
const [libs, settings] = await Promise.all([
libraryAPI.list({ includeHidden: true }).catch(() => [] as Library[]),
adminAPI.listSettings().catch(() => [] as Setting[]),
])
const libList = Array.isArray(libs) ? libs : []
setLibraries(libList)
// 异步拉取各个媒体库的前几个条目用于封面展示
Promise.allSettled(
libList.map(async (lib) => {
const page = await libraryAPI.listMedia(lib.id, 1, 10)
const items = Array.isArray(page?.items) ? page.items : []
return { id: lib.id, cards: groupSeries(items) }
}),
).then((results) => {
const map: Record<string, SeriesCard[]> = {}
for (const r of results) {
if (r.status === 'fulfilled' && r.value) {
map[r.value.id] = r.value.cards
}
}
setLibraryCards(map)
})
// 优先读取系统配置,其次读取 localStorage,默认全选
const settingItem = Array.isArray(settings)
? settings.find((s) => s.key === SETTING_KEY_CAROUSEL)
: undefined
let initialIds: string[] | null = null
if (settingItem?.value) {
try {
initialIds = JSON.parse(settingItem.value)
} catch {
// ignore
}
}
if (!initialIds) {
try {
const saved = localStorage.getItem(CAROUSEL_STORAGE_KEY)
if (saved) initialIds = JSON.parse(saved)
} catch {
// ignore
}
}
if (Array.isArray(initialIds)) {
setSelectedIds(initialIds)
} else {
setSelectedIds(libList.map((l) => l.id))
}
} finally {
setLoading(false)
}
}
load()
}, [])
const toggleLibrary = (id: string) => {
setSelectedIds((prev) => {
const next = prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]
setDirty(true)
return next
})
}
const selectAll = () => {
setSelectedIds(libraries.map((l) => l.id))
setDirty(true)
}
const deselectAll = () => {
setSelectedIds([])
setDirty(true)
}
const handleSave = async () => {
setSaving(true)
try {
const jsonValue = JSON.stringify(selectedIds)
await adminAPI.updateSetting(SETTING_KEY_CAROUSEL, jsonValue)
try {
localStorage.setItem(CAROUSEL_STORAGE_KEY, jsonValue)
} catch {
// ignore
}
toast.success('海报轮播设置已保存')
setDirty(false)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'保存设置失败'
toast.error(msg)
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="flex justify-center py-12 text-ink-50">
<Loader2 className="animate-spin" />
</div>
)
}
return (
<div className="glass-panel space-y-6">
{/* 头部说明与快捷操作 */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between border-b border-gray-200/80 pb-4">
<div className="flex items-start gap-3">
<div className="rounded-xl border border-primary-400/40 bg-primary-400/10 p-2 text-brand-500 mt-0.5">
<SlidersHorizontal size={20} />
</div>
<div>
<h3 className="font-display text-lg font-bold text-ink-600">首页海报轮播设置</h3>
<p className="text-xs text-sand-500 mt-0.5">
选择参与首页顶部大图海报轮播推荐的媒体库。勾选的媒体库内容将轮流展示在系统首页顶部。
</p>
</div>
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
<button
type="button"
onClick={selectAll}
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-ink-100 transition hover:border-primary-400/50 hover:text-brand-500"
>
<CheckSquare size={14} />
<span>全选</span>
</button>
<button
type="button"
onClick={deselectAll}
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-ink-100 transition hover:border-primary-400/50 hover:text-brand-500"
>
<Square size={14} />
<span>清空</span>
</button>
</div>
</div>
{/* 媒体库列表卡片 */}
{libraries.length === 0 ? (
<div className="py-8 text-center text-xs text-sand-500">
暂无可用媒体库,请先添加媒体库后再配置海报轮播。
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{libraries.map((lib) => {
const isSelected = selectedIds.includes(lib.id)
const cards = libraryCards[lib.id] || []
const artwork = getLibraryArtworks(lib, cards)
return (
<div
key={lib.id}
onClick={() => toggleLibrary(lib.id)}
className={`flex cursor-pointer items-center justify-between rounded-2xl border p-4 transition-all duration-200 select-none ${
isSelected
? 'border-brand-500/60 bg-primary-400/10 shadow-sm'
: 'border-gray-200 bg-white/70 hover:border-gray-300'
}`}
>
<div className="flex items-center gap-3 min-w-0">
<div
className={`grid h-12 w-16 shrink-0 gap-0.5 overflow-hidden rounded-xl bg-gray-100 shadow-inner transition-colors ${
artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'
} ${
isSelected ? 'ring-2 ring-brand-500/30' : ''
}`}
>
{artwork.length > 0 ? (
artwork.map(({ src, version }, index) => (
<img
key={`${src}-${index}`}
src={imageURL(src, version)}
alt=""
className="h-full w-full object-cover"
referrerPolicy="no-referrer"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center text-ink-50">
{TYPE_ICONS[lib.type] || <FolderOpen size={20} />}
</div>
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate font-display text-sm font-bold text-ink-600">
{lib.name}
</span>
<span className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold border border-gray-200 bg-gray-50 text-ink-50">
{TYPE_LABELS[lib.type] || '自定义'}
</span>
</div>
<p className="text-xs text-sand-500 mt-0.5">
{isSelected ? '已启用轮播' : '未参与轮播'}
</p>
</div>
</div>
<div
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border transition-colors ${
isSelected
? 'border-brand-500 bg-brand-500 text-white'
: 'border-gray-300 bg-white text-transparent'
}`}
>
<Check size={14} strokeWidth={3} />
</div>
</div>
)
})}
</div>
)}
{/* 底部保存按钮 */}
<div className="flex items-center justify-between pt-2 border-t border-gray-200/80">
<span className="text-xs text-sand-500">
已选择 {selectedIds.length} / {libraries.length} 个媒体库参与轮播
</span>
<button
type="button"
onClick={handleSave}
disabled={saving || !dirty}
className="neon-button disabled:opacity-50"
>
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
保存设置
</button>
</div>
</div>
)
}
+180
View File
@@ -0,0 +1,180 @@
import { useEffect, useState } from 'react'
import { Database, HardDrive, PieChart, RefreshCw } from 'lucide-react'
import { storageAPI, type StorageBreakdown } from '../api/storage'
export function fmtBytes(n: number): string {
if (!n) return '0 B'
const u = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
let v = n
let i = 0
while (v >= 1024 && i < u.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(2)} ${u[i]}`
}
export function fmtHours(seconds: number): string {
if (!seconds) return '—'
const h = Math.floor(seconds / 3600)
return `${h.toLocaleString()} h`
}
export function LibraryStorageStats() {
const [data, setData] = useState<StorageBreakdown | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const loadData = () => {
setRefreshing(true)
storageAPI
.breakdown()
.then(setData)
.finally(() => {
setLoading(false)
setRefreshing(false)
})
}
useEffect(() => {
loadData()
}, [])
if (loading) {
return (
<div className="glass-panel py-8 text-center text-sm text-sand-500">
加载统计数据中…
</div>
)
}
if (!data) {
return (
<div className="glass-panel py-8 text-center text-sm text-sand-500">
无法获取存储数据
</div>
)
}
const totalBytes = data.total_bytes || 1
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="font-display text-xl font-semibold text-ink-600">存储与统计</h2>
<button
type="button"
onClick={loadData}
disabled={refreshing}
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white/80 px-2.5 py-1 text-xs text-ink-100 transition hover:border-primary-400/50 hover:text-brand-500 disabled:opacity-50"
title="刷新统计数据"
>
<RefreshCw size={13} className={refreshing ? 'animate-spin' : ''} />
<span>刷新</span>
</button>
</div>
<section className="grid gap-4 sm:grid-cols-3">
<StatTile icon={<Database size={20} />} label="总占用" value={fmtBytes(data.total_bytes)} />
<StatTile icon={<PieChart size={20} />} label="媒体库" value={`${data.by_library.length}`} />
<StatTile icon={<HardDrive size={20} />} label="累计时长" value={fmtHours(data.total_seconds)} />
</section>
<section className="space-y-3">
<h3 className="font-display text-lg font-semibold text-ink-600">按媒体库</h3>
<div className="glass-panel overflow-x-auto !p-3">
<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>
</tr>
</thead>
<tbody>
{data.by_library.length === 0 ? (
<tr>
<td colSpan={5} className="py-4 text-center text-xs text-sand-500">
暂无媒体库数据
</td>
</tr>
) : (
data.by_library.map((l) => {
const pct = (l.total_bytes / totalBytes) * 100
return (
<tr key={l.library_id} className="border-t border-gray-200">
<td className="py-2 text-ink-600 font-medium">{l.name}</td>
<td className="text-ink-100">{l.type}</td>
<td className="text-ink-100">{l.media_count}</td>
<td className="text-ink-100">{fmtBytes(l.total_bytes)}</td>
<td>
<div className="flex items-center gap-2">
<div className="h-1.5 w-24 overflow-hidden rounded bg-gray-200">
<div
className="h-full bg-primary-400"
style={{ width: `${Math.min(100, Math.max(0, pct))}%` }}
/>
</div>
<span className="text-xs text-ink-50">{pct.toFixed(1)}%</span>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</section>
<section className="space-y-3">
<h3 className="font-display text-lg font-semibold text-ink-600">按容器格式</h3>
{data.by_container.length === 0 ? (
<div className="glass-panel py-4 text-center text-xs text-sand-500">
暂无格式统计
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 md:grid-cols-3">
{data.by_container.map((c) => (
<div
key={c.container}
className="glass-panel flex items-center justify-between !p-4"
>
<div>
<p className="text-xs uppercase tracking-wider text-sand-500">{c.container || '未知'}</p>
<p className="font-display text-lg font-semibold text-ink-600">{c.count} 项</p>
</div>
<p className="text-sm text-ink-100">{fmtBytes(c.bytes)}</p>
</div>
))}
</div>
)}
</section>
</div>
)
}
function StatTile({
icon,
label,
value,
}: {
icon: React.ReactNode
label: string
value: string
}) {
return (
<div className="glass-panel flex items-center gap-3 !p-4">
<div className="rounded-xl border border-primary-400/40 bg-primary-400/10 p-2 text-brand-500">
{icon}
</div>
<div>
<p className="text-xs uppercase tracking-wider text-sand-500">{label}</p>
<p className="font-display text-lg font-semibold text-ink-600">{value}</p>
</div>
</div>
)
}
-99
View File
@@ -1,99 +0,0 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { licenseAPI, type LicenseStatus } from '../api/license'
import {
LicenseBindPanel,
LicenseHeader,
LicenseInactiveTip,
LicenseStatusPanel,
} from './LicensePageSections'
export function LicensePage() {
const [status, setStatus] = useState<LicenseStatus | null>(null)
const [loadingStatus, setLoadingStatus] = useState(true)
const [bindKey, setBindKey] = useState('')
const [binding, setBinding] = useState(false)
const refreshStatus = useCallback(async () => {
setLoadingStatus(true)
try {
const s = await licenseAPI.status()
setStatus(s)
} catch {
setStatus({ active: false })
} finally {
setLoadingStatus(false)
}
}, [])
useEffect(() => {
refreshStatus()
}, [refreshStatus])
const onBind = async (e: FormEvent) => {
e.preventDefault()
const key = bindKey.trim()
if (!key) {
toast.error('请输入许可证密钥')
return
}
setBinding(true)
try {
const activation = await licenseAPI.bind(key)
toast.success('许可证绑定成功!')
setBindKey('')
// Optimistically update status
setStatus({
active: true,
activation,
max_users: activation.max_users,
unlimited_users: activation.unlimited_users,
message: '已激活',
})
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'绑定失败,请检查密钥是否正确'
toast.error(msg)
} finally {
setBinding(false)
}
}
const onHeartbeat = async () => {
try {
await licenseAPI.heartbeat()
toast.success('心跳上报成功')
} catch {
toast.error('心跳上报失败')
}
}
// ── Derive display values ──
const active = status?.active === true
const activation = status?.activation
const isExpired =
activation?.expires_at != null && new Date(activation.expires_at).getTime() < Date.now()
return (
<div className="mx-auto max-w-2xl space-y-8">
<LicenseHeader />
<LicenseBindPanel
bindKey={bindKey}
binding={binding}
onBind={onBind}
onBindKeyChange={setBindKey}
/>
<LicenseStatusPanel
status={status}
loadingStatus={loadingStatus}
active={active}
isExpired={isExpired}
onRefresh={refreshStatus}
onHeartbeat={onHeartbeat}
/>
<LicenseInactiveTip active={active} loadingStatus={loadingStatus} />
</div>
)
}
-219
View File
@@ -1,219 +0,0 @@
import type { FormEvent } from 'react'
import {
CheckCircle2,
KeySquare,
Link2,
Loader2,
RefreshCw,
XCircle,
} from 'lucide-react'
import type { LicenseStatus } from '../api/license'
export function LicenseHeader() {
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-fuchsia-400/10 text-fuchsia-300">
<KeySquare size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">许可证</h1>
<p className="text-sm text-ink-50">绑定授权密钥以提升多用户容量</p>
</div>
</div>
)
}
export function LicenseBindPanel({
bindKey,
binding,
onBind,
onBindKeyChange,
}: {
bindKey: string
binding: boolean
onBind: (event: FormEvent<HTMLFormElement>) => void
onBindKeyChange: (value: string) => void
}) {
return (
<div className="glass-panel space-y-4">
<div className="flex items-center gap-2">
<Link2 size={18} className="text-brand-500" />
<h2 className="font-display text-lg font-semibold text-ink-600">绑定许可证</h2>
</div>
<form onSubmit={onBind} className="flex gap-3">
<input
className="input-base flex-1 font-mono text-sm tracking-wider"
placeholder="MS-XXXX-XXXX-XXXX"
value={bindKey}
onChange={(event) => onBindKeyChange(event.target.value)}
disabled={binding}
/>
<button type="submit" disabled={binding} className="neon-button shrink-0">
{binding ? <Loader2 size={16} className="animate-spin" /> : <Link2 size={16} />}
绑定
</button>
</form>
<p className="text-xs text-sand-500">
输入从授权服务器获取的许可证密钥,激活后按授权额度开放更多平台用户。
</p>
</div>
)
}
export function LicenseStatusPanel({
status,
loadingStatus,
active,
isExpired,
onRefresh,
onHeartbeat,
}: {
status: LicenseStatus | null
loadingStatus: boolean
active: boolean
isExpired: boolean
onRefresh: () => void
onHeartbeat: () => void
}) {
const activation = status?.activation
if (loadingStatus) {
return (
<div className="flex justify-center py-8 text-ink-50">
<Loader2 className="animate-spin" />
</div>
)
}
return (
<div className="glass-panel space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{active && !isExpired ? (
<CheckCircle2 size={18} className="text-emerald-400" />
) : (
<XCircle size={18} className="text-sand-500" />
)}
<h2 className="font-display text-lg font-semibold text-ink-600">当前状态</h2>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={onRefresh}
className="rounded-lg border border-gray-200 p-2 text-ink-50 hover:bg-gray-50 hover:text-white"
title="刷新状态"
>
<RefreshCw size={14} />
</button>
{active && (
<button
onClick={onHeartbeat}
className="rounded-lg border border-primary-400/30 px-3 py-1.5 text-xs text-brand-500 hover:bg-primary-400/10"
>
心跳上报
</button>
)}
</div>
</div>
{!active && (
<div className="space-y-3 rounded-xl border border-gray-200 bg-gray-50 p-4 text-center">
<p className="text-ink-50">
尚未绑定许可证。请在上方输入密钥完成激活。
</p>
</div>
)}
{active && activation && (
<div className="grid grid-cols-2 gap-3">
<StatusBadge label="密钥" value={activation.key || activation.key_id} mono />
<StatusBadge
label="套餐"
value={activation.plan ?? 'standard'}
className={
activation.plan === 'enterprise'
? 'text-amber-300'
: activation.plan === 'pro'
? 'text-brand-500'
: 'text-ink-100'
}
/>
<StatusBadge label="设备名称" value={activation.device_name || 'Web Client'} />
<StatusBadge label="设备数" value={`${activation.max_activations ?? 1} 台`} />
<StatusBadge label="绑定时间" value={fmtDate(activation.created_at)} />
<StatusBadge
label="到期时间"
value={fmtDate(activation.expires_at)}
className={isExpired ? 'text-red-400' : undefined}
/>
<StatusBadge label="最近心跳" value={fmtDateTime(activation.heartbeat_at)} />
<StatusBadge label="客户端 IP" value={activation.ip ?? '—'} />
<StatusBadge
label="用户额度"
value={fmtUserLimit(status?.max_users, status?.unlimited_users)}
/>
</div>
)}
{isExpired && (
<div className="rounded-xl border border-red-400/20 bg-red-400/5 px-4 py-3 text-sm text-red-400">
此许可证已过期,部分功能可能受限。请获取新的许可证密钥。
</div>
)}
</div>
)
}
export function LicenseInactiveTip({ active, loadingStatus }: { active: boolean; loadingStatus: boolean }) {
if (active || loadingStatus) return null
return (
<div className="rounded-xl border border-gray-200 bg-gray-50 p-5 text-center">
<p className="text-sm text-sand-500">
需要获取许可证?请联系管理员获取 MediaStationGo 授权密钥。
</p>
<p className="mt-1 text-xs text-gray-500">
授权服务器地址可在系统设置中配置
</p>
</div>
)
}
function StatusBadge({
label,
value,
mono,
className,
}: {
label: string
value: string
mono?: boolean
className?: string
}) {
return (
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3">
<p className="mb-0.5 text-xs text-sand-500">{label}</p>
<p className={`text-sm text-ink-600 ${mono ? 'font-mono' : ''} ${className ?? ''}`}>
{value}
</p>
</div>
)
}
function fmtDate(iso: string | null | undefined): string {
if (!iso) return '永久'
return new Date(iso).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
function fmtDateTime(iso: string | null | undefined): string {
if (!iso) return '—'
return new Date(iso).toLocaleString('zh-CN')
}
function fmtUserLimit(maxUsers: number | null | undefined, unlimited?: boolean): string {
if (unlimited || maxUsers == null) return '不限制'
return `${maxUsers} 人`
}
+3 -19
View File
@@ -2,8 +2,6 @@ import type { FormEvent, ReactNode } from 'react'
import { motion } from 'framer-motion'
import { ArrowRight, Eye, EyeOff, Lock, User } from 'lucide-react'
import { AppFooter } from '../components/AppFooter'
type LoginPageShellProps = {
children: ReactNode
}
@@ -37,7 +35,6 @@ export function LoginPageShell({ children }: LoginPageShellProps) {
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-gray-50/50 px-4">
<LoginBackground />
{children}
<LoginFooter />
</div>
)
}
@@ -75,8 +72,8 @@ function LoginBrandHeader() {
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.15, type: 'spring', stiffness: 200 }}
src="/brand/mediastationgo-logo.svg"
alt="MediaStationGo"
src="/brand/logo-192.png"
alt="MMTL"
className="mb-4 h-14 w-14 rounded-2xl object-contain shadow-sm"
/>
@@ -86,7 +83,7 @@ function LoginBrandHeader() {
transition={{ delay: 0.25 }}
className="font-display text-2xl font-extrabold tracking-tight text-gray-900"
>
MediaStationGo
MMTL
</motion.h1>
<motion.p
@@ -219,16 +216,3 @@ function LoginReadyLabel() {
</span>
)
}
function LoginFooter() {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
className="relative z-10 mt-10 text-gray-500 transition-colors hover:text-gray-600"
>
<AppFooter />
</motion.div>
)
}
-60
View File
@@ -1,60 +0,0 @@
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>
)
}
@@ -1,97 +0,0 @@
import { Field } from './NotifyChannelFormField'
type ConfigControlProps = {
config: Record<string, string>
updateConfig: (key: string, value: string) => void
}
type ConfigInputProps = ConfigControlProps & {
label: string
name: string
placeholder?: string
required?: boolean
type?: string
}
export function ConfigInput({
config,
updateConfig,
label,
name,
placeholder,
required,
type,
}: ConfigInputProps) {
return (
<Field label={label}>
<input
required={required}
type={type}
className="input-base"
placeholder={placeholder}
value={config[name] ?? ''}
onChange={(event) => updateConfig(name, event.target.value)}
/>
</Field>
)
}
type ConfigSelectProps = ConfigControlProps & {
label: string
name: string
options: Array<{ value: string; label: string }>
defaultValue?: string
}
export function ConfigSelect({
config,
updateConfig,
label,
name,
options,
defaultValue = '',
}: ConfigSelectProps) {
return (
<Field label={label}>
<select
className="input-base"
value={config[name] ?? defaultValue}
onChange={(event) => updateConfig(name, event.target.value)}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Field>
)
}
type ConfigTextareaProps = ConfigControlProps & {
label: string
name: string
placeholder?: string
rows: number
}
export function ConfigTextarea({
config,
updateConfig,
label,
name,
placeholder,
rows,
}: ConfigTextareaProps) {
return (
<Field label={label}>
<textarea
rows={rows}
className="input-base font-mono text-xs"
placeholder={placeholder}
value={config[name] ?? ''}
onChange={(event) => updateConfig(name, event.target.value)}
/>
</Field>
)
}
@@ -1,67 +0,0 @@
import { EVENT_OPTIONS, type EventMode } from './notifyChannelsModel'
import { Field } from './NotifyChannelFormField'
type NotifyChannelEventFieldsProps = {
events: string[]
eventMode: EventMode
setEventMode: (mode: EventMode) => void
toggleEvent: (event: string) => void
}
export function NotifyChannelEventFields({
events,
eventMode,
setEventMode,
toggleEvent,
}: NotifyChannelEventFieldsProps) {
return (
<Field label="推送事件">
<div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50/60 p-3">
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="radio"
name="notify-event-mode"
className="h-4 w-4 accent-primary-400"
checked={eventMode === 'all'}
onChange={() => setEventMode('all')}
/>
全部事件
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="radio"
name="notify-event-mode"
className="h-4 w-4 accent-primary-400"
checked={eventMode === 'none'}
onChange={() => setEventMode('none')}
/>
关闭全部推送事件
</label>
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="radio"
name="notify-event-mode"
className="h-4 w-4 accent-primary-400"
checked={eventMode === 'custom'}
onChange={() => setEventMode('custom')}
/>
仅推送勾选事件
</label>
<div className="grid gap-2 sm:grid-cols-2">
{EVENT_OPTIONS.map((event) => (
<label key={event.value} className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
disabled={eventMode !== 'custom'}
checked={events.includes(event.value)}
onChange={() => toggleEvent(event.value)}
/>
{event.label}
</label>
))}
</div>
</div>
</Field>
)
}
-10
View File
@@ -1,10 +0,0 @@
import type { ReactNode } from 'react'
export function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-sm text-ink-100">{label}</span>
{children}
</label>
)
}
-185
View File
@@ -1,185 +0,0 @@
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>
)
}
@@ -1,203 +0,0 @@
import {
ConfigInput,
ConfigSelect,
ConfigTextarea,
} from './NotifyChannelConfigControls'
type ProviderFieldsProps = {
config: Record<string, string>
updateConfig: (key: string, value: string) => void
}
const METHOD_OPTIONS = [
{ value: 'POST', label: 'POST' },
{ value: 'GET', label: 'GET' },
]
const TLS_OPTIONS = [
{ value: 'true', label: '启用' },
{ value: 'false', label: '关闭' },
]
export function TelegramFields({ config, updateConfig }: ProviderFieldsProps) {
const control = { config, updateConfig }
return (
<>
<ConfigInput
{...control}
required
label="Bot Token"
name="bot_token"
placeholder="123456:ABC-DEF…"
/>
<ConfigInput
{...control}
required
label="管理员 Telegram ID"
name="admin_user_ids"
placeholder="多个用逗号分隔,如 123456789,987654321"
/>
<ConfigInput
{...control}
label="绑定群组 ID"
name="group_chat_id"
placeholder="选填,如 -1001234567890;填写后群组成员可唤醒/绑定"
/>
<ConfigInput
{...control}
label="绑定频道 ID"
name="channel_chat_id"
placeholder="选填,如 -1009876543210;填写后频道成员可唤醒/绑定"
/>
<ConfigInput
{...control}
label="Telegram API 地址 (可选)"
name="api_base_url"
placeholder="默认 https://api.telegram.org;反代可填 https://tg.example.com"
/>
<ConfigInput
{...control}
label="Telegram 代理 (可选)"
name="proxy_url"
placeholder="如 http://172.17.0.1:7890 或 socks5://172.17.0.1:1080"
/>
<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) {
const control = { config, updateConfig }
return (
<ConfigInput
{...control}
required
label="SendKey"
name="sendkey"
placeholder="SCT…"
/>
)
}
export function BarkFields({ config, updateConfig }: ProviderFieldsProps) {
const control = { config, updateConfig }
return (
<>
<ConfigInput
{...control}
required
label="设备 Key"
name="device_key"
/>
<ConfigInput
{...control}
label="服务器地址 (可选)"
name="server"
placeholder="https://api.day.app"
/>
</>
)
}
export function WebhookFields({ config, updateConfig }: ProviderFieldsProps) {
const control = { config, updateConfig }
return (
<>
<ConfigInput
{...control}
required
label="URL"
name="url"
placeholder="https://example.com/notify"
/>
<ConfigSelect
{...control}
label="Method"
name="method"
options={METHOD_OPTIONS}
defaultValue="POST"
/>
<ConfigTextarea
{...control}
label="Headers (JSON)"
name="headers"
rows={2}
placeholder='{"Content-Type":"application/json"}'
/>
<ConfigTextarea
{...control}
label="Body 模板 (支持 {{title}} {{message}})"
name="body_template"
rows={3}
placeholder='{"title":"{{title}}","message":"{{message}}"}'
/>
</>
)
}
export function EmailFields({ config, updateConfig }: ProviderFieldsProps) {
const control = { config, updateConfig }
return (
<>
<ConfigInput
{...control}
required
label="SMTP 地址"
name="smtp_host"
placeholder="smtp.gmail.com"
/>
<div className="grid grid-cols-2 gap-3">
<ConfigInput
{...control}
required
label="SMTP 端口"
name="smtp_port"
placeholder="465"
/>
<ConfigSelect
{...control}
label="TLS"
name="tls"
options={TLS_OPTIONS}
defaultValue="true"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<ConfigInput
{...control}
required
label="用户名"
name="username"
/>
<ConfigInput
{...control}
required
label="密码"
name="password"
type="password"
/>
</div>
<ConfigInput
{...control}
required
label="发件人"
name="from"
placeholder="noreply@example.com"
/>
<ConfigInput
{...control}
required
label="收件人 (多个用逗号分隔)"
name="to"
placeholder="user@example.com"
/>
</>
)
}
-181
View File
@@ -1,181 +0,0 @@
import { useEffect, useState } from 'react'
import { Bell, Loader2, Play, Plus, Square } from 'lucide-react'
import toast from 'react-hot-toast'
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
// fire a test notification on demand.
export function NotifyChannelsPage() {
const [channels, setChannels] = useState<NotifyChannel[]>([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState<NotifyChannel | null>(null)
const [showForm, setShowForm] = useState(false)
const [testingID, setTestingID] = useState<string | null>(null)
const [pollingBusy, setPollingBusy] = useState<'start' | 'stop' | null>(null)
const refresh = async () => {
setLoading(true)
try {
setChannels(await notifyChannelsAPI.list())
} finally {
setLoading(false)
}
}
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const onTest = async (id: string) => {
setTestingID(id)
try {
await notifyChannelsAPI.test(id)
toast.success('测试消息已发送')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '发送失败'
toast.error(msg)
} finally {
setTestingID(null)
}
}
const onStartPolling = async () => {
setPollingBusy('start')
try {
const res = await notifyChannelsAPI.startTelegramPolling()
if ((res.started ?? 0) > 0) {
toast.success(`已启动 ${res.started} 个 Telegram Bot 轮询`)
} else if ((res.already_running ?? 0) > 0) {
toast.success('Telegram Bot 轮询已在运行')
} else {
toast.error(res.errors?.[0] ?? '没有可启动的 Telegram Bot 渠道')
}
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '启动轮询失败'
toast.error(msg)
} finally {
setPollingBusy(null)
}
}
const onStopPolling = async () => {
setPollingBusy('stop')
try {
const res = await notifyChannelsAPI.stopTelegramPolling()
toast.success(res.stopped > 0 ? `已停止 ${res.stopped} 个 Telegram Bot 轮询` : '当前没有运行中的轮询')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '停止轮询失败'
toast.error(msg)
} finally {
setPollingBusy(null)
}
}
const onDelete = async (ch: NotifyChannel) => {
if (!(await confirmAction({ title: '删除通知渠道', message: `确定删除「${ch.name}」?`, confirmText: '删除' }))) return
try {
await notifyChannelsAPI.remove(ch.id)
toast.success('已删除')
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">
<div className="flex items-center justify-between">
<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-300">
<Bell size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">通知渠道</h1>
<p className="text-sm text-ink-50">
配置 Telegram / Bark / 企业微信 / Webhook 多通道推送
</p>
</div>
</div>
<div className="flex flex-wrap justify-end gap-2">
<button
onClick={onStartPolling}
disabled={pollingBusy !== null}
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:border-primary-400/40 hover:text-brand-500 disabled:opacity-50"
title="启动 Telegram Bot 长轮询"
>
{pollingBusy === 'start' ? <Loader2 size={16} className="inline animate-spin" /> : <Play size={16} className="inline" />}
{' '}启动 Bot 轮询
</button>
<button
onClick={onStopPolling}
disabled={pollingBusy !== null}
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:border-primary-400/40 hover:text-brand-500 disabled:opacity-50"
title="停止本地长轮询。"
>
{pollingBusy === 'stop' ? <Loader2 size={16} className="inline animate-spin" /> : <Square size={16} className="inline" />}
{' '}停止轮询
</button>
<button
onClick={() => {
setEditing(null)
setShowForm(true)
}}
className="neon-button"
>
<Plus size={16} /> 添加渠道
</button>
</div>
</div>
{loading && (
<div className="flex justify-center py-12 text-ink-50">
<Loader2 className="animate-spin" />
</div>
)}
{!loading && channels.length === 0 && (
<div className="glass-panel py-12 text-center text-ink-50">暂无通知渠道</div>
)}
{!loading && channels.length > 0 && (
<div className="space-y-3">
{channels.map((ch) => (
<NotifyChannelCard
key={ch.id}
channel={ch}
onTest={() => onTest(ch.id)}
testing={testingID === ch.id}
onEdit={() => {
setEditing(ch)
setShowForm(true)
}}
onDelete={() => onDelete(ch)}
/>
))}
</div>
)}
{showForm && (
<NotifyChannelFormModal
editing={editing}
onClose={() => setShowForm(false)}
onSaved={async () => {
setShowForm(false)
await refresh()
}}
/>
)}
</div>
)
}
-78
View File
@@ -1,78 +0,0 @@
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>
)
}
-179
View File
@@ -1,179 +0,0 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import toast from 'react-hot-toast'
import { Home, RotateCcw, Square, CheckSquare, Trash2 } from 'lucide-react'
import { recycleAPI } from '../api/recycle'
import { confirmAction } from '../components/confirmAction'
import type { Media } from '../types'
export function RecycleBinPage() {
const [items, setItems] = useState<Media[]>([])
const [loading, setLoading] = useState(true)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [batchBusy, setBatchBusy] = useState('')
const refresh = () =>
recycleAPI
.list()
.then((rows) => {
setItems(rows)
setSelectedIds((current) => current.filter((id) => rows.some((item) => item.id === id)))
})
.finally(() => setLoading(false))
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const allSelected = items.length > 0 && selectedIds.length === items.length
const toggleAll = () => {
setSelectedIds(allSelected ? [] : items.map((item) => item.id))
}
const toggleOne = (id: string) => {
setSelectedIds((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id])
}
const runBatch = async (mode: 'restore' | 'purge') => {
if (selectedIds.length === 0) return
if (mode === 'purge' && !(await confirmAction({
title: '批量彻底删除记录',
message: `彻底删除选中的 ${selectedIds.length} 条记录? (磁盘文件保留)`,
confirmText: '彻底删除',
}))) return
setBatchBusy(mode)
try {
const result = await runRecycleBatchRequest(mode, selectedIds)
toast.success(`${mode === 'restore' ? '恢复' : '彻底删除'}完成:${result.applied} 条`)
setSelectedIds([])
await refresh()
} catch (err: unknown) {
const response = (err as { response?: { status?: number; data?: { error?: string } }; message?: string })?.response
const msg = response?.data?.error || (response?.status ? `批量操作失败 (${response.status})` : (err as { message?: string })?.message || '批量操作失败')
toast.error(msg)
} finally {
setBatchBusy('')
}
}
const runRecycleBatchRequest = async (mode: 'restore' | 'purge', ids: string[]) => {
try {
return mode === 'restore'
? await recycleAPI.restoreMany(ids)
: await recycleAPI.purgeMany(ids)
} catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status
if (status !== 404 && status !== 405) throw err
let applied = 0
for (const id of ids) {
if (mode === 'restore') {
await recycleAPI.restore(id)
} else {
await recycleAPI.purge(id)
}
applied += 1
}
return { applied, errors: [] as string[] }
}
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">回收站</h1>
<p className="mt-2 text-sm text-ink-50">
软删除的媒体保留在数据库中,可以恢复。彻底删除不会移除磁盘上的文件,只会从数据库清除条目。
</p>
<p className="mt-1 text-xs text-sand-500">系统最多保留最新 200 条回收站记录,超过后会自动清理旧记录。</p>
</div>
<Link to="/" className="btn-outline shrink-0 py-2.5 px-4 text-sm">
<Home size={15} />
返回系统首页
</Link>
</div>
{loading && <p className="text-sand-500">加载中…</p>}
{!loading && items.length === 0 && <p className="text-ink-50">回收站为空。</p>}
{items.length > 0 && (
<div className="glass-panel">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-sand-500">
已选择 {selectedIds.length} / {items.length} 条
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => runBatch('restore')}
disabled={selectedIds.length === 0 || !!batchBusy}
className="btn-outline py-2 px-3 text-xs"
>
<RotateCcw size={13} />
{batchBusy === 'restore' ? '恢复中…' : '批量恢复'}
</button>
<button
onClick={() => runBatch('purge')}
disabled={selectedIds.length === 0 || !!batchBusy}
className="btn-outline py-2 px-3 text-xs !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200"
>
<Trash2 size={13} />
{batchBusy === 'purge' ? '删除中…' : '批量彻底删除'}
</button>
</div>
</div>
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="w-10 py-2">
<button onClick={toggleAll} className="text-sand-500 hover:text-brand-500" aria-label="全选">
{allSelected ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
</th>
<th className="py-2">标题</th>
<th>路径</th>
<th className="text-right">操作</th>
</tr>
</thead>
<tbody>
{items.map((m) => (
<tr key={m.id} className="border-t border-gray-200">
<td className="py-2">
<button onClick={() => toggleOne(m.id)} className="text-sand-500 hover:text-brand-500" aria-label={`选择 ${m.title}`}>
{selectedIds.includes(m.id) ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
</td>
<td className="py-2 text-ink-600">{m.title}</td>
<td className="max-w-md truncate text-ink-50" title={m.path}>
{m.path}
</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 () => {
await recycleAPI.restore(m.id)
toast.success('已恢复')
await refresh()
}}
>
<RotateCcw 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"
onClick={async () => {
if (!(await confirmAction({ title: '彻底删除记录', message: `彻底删除「${m.title}」? (磁盘文件保留)`, confirmText: '彻底删除' }))) return
await recycleAPI.purge(m.id)
toast.success('已彻底删除')
await refresh()
}}
>
<Trash2 size={12} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
-84
View File
@@ -1,84 +0,0 @@
import { useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Clock, Play } from 'lucide-react'
import { schedulerAPI, type JobStatus } from '../api/scheduler'
export function SchedulerPage() {
const [jobs, setJobs] = useState<JobStatus[]>([])
const [running, setRunning] = useState<string>('')
const refresh = () => schedulerAPI.status().then(setJobs)
useEffect(() => {
refresh().catch(() => undefined)
const id = window.setInterval(refresh, 5_000)
return () => window.clearInterval(id)
}, [])
const runNow = async (name: string) => {
setRunning(name)
try {
await schedulerAPI.run(name)
toast.success(`${name} 已运行`)
await refresh()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'运行失败'
toast.error(msg)
} finally {
setRunning('')
}
}
return (
<div className="space-y-6">
<header className="flex items-center gap-3">
<Clock className="h-6 w-6 text-brand-500" />
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">定时任务</h1>
<p className="text-sm text-ink-50">
后端周期性任务(媒体库扫描、转码缓存清理、回收站自动清理),每 5 秒刷新状态。
</p>
</div>
</header>
<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>错误</th>
<th className="text-right">操作</th>
</tr>
</thead>
<tbody>
{jobs.map((j) => (
<tr key={j.name} className="border-t border-gray-200">
<td className="py-2 font-mono text-ink-600">{j.name}</td>
<td className="text-ink-100">{j.interval}</td>
<td className="text-ink-50">
{j.last_run && new Date(j.last_run).getFullYear() > 2000
? new Date(j.last_run).toLocaleString()
: '尚未运行'}
</td>
<td className="text-red-400">{j.last_err || '—'}</td>
<td className="py-2 text-right">
<button
onClick={() => runNow(j.name)}
disabled={running === j.name}
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
>
<Play size={12} className="inline" /> 立即运行
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
-209
View File
@@ -1,209 +0,0 @@
import { useState } from 'react'
import { CheckCircle2, Info, Rss } from 'lucide-react'
import toast from 'react-hot-toast'
import type { DiscoverItem } from '../api/discover'
import type { ExternalMediaResult } from '../api/ai'
import { imageURL } from '../api/client'
import { subscriptionsAPI } from '../api/subscriptions'
import { DiscoverSubscriptionRules } from './DiscoverDetailModalSections'
import {
apiErrorMessage,
buildDiscoverSubscriptionInput,
initialDiscoverSubscriptionForm,
} from './discoverDetailModalModel'
export function ExternalResults({
items,
}: {
items: ExternalMediaResult[]
}) {
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()
setDetail(item)
}}
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" />
订阅规则
</button>
</div>
</article>
)
})}
</div>
{detail && (
<ExternalDetailModal
item={detail}
onClose={() => setDetail(null)}
/>
)}
</section>
)
}
function ExternalDetailModal({
item,
onClose,
}: {
item: ExternalMediaResult
onClose: () => void
}) {
const missing = item.missing_episodes ?? []
const discoverItem = item as unknown as DiscoverItem
const [form, setForm] = useState(() => initialDiscoverSubscriptionForm(discoverItem))
const [formBusy, setFormBusy] = useState(false)
const submit = async () => {
setFormBusy(true)
try {
const sub = await subscriptionsAPI.create(
buildDiscoverSubscriptionInput(discoverItem, form, item.source || 'tmdb'),
)
if (form.run_now) {
const run = await subscriptionsAPI.runNow(sub.id)
toast.success(run.queued > 0 ? `已订阅并加入 ${run.queued} 个下载` : '已订阅,暂未命中可下载资源')
} else {
toast.success('已创建订阅')
}
onClose()
} catch (err) {
toast.error(apiErrorMessage(err, '订阅失败'))
} finally {
setFormBusy(false)
}
}
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-y-auto 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>
)}
<DiscoverSubscriptionRules
form={form}
busy={formBusy}
onChange={(patch) => setForm((current) => ({ ...current, ...patch }))}
onSubmit={submit}
/>
<div className="flex justify-end 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>
</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())
}
-25
View File
@@ -1,25 +0,0 @@
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>
)
}
-37
View File
@@ -1,37 +0,0 @@
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)}
/>
)
}
-38
View File
@@ -1,38 +0,0 @@
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>
</>
)
}
-54
View File
@@ -1,54 +0,0 @@
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 search = useSearchPage()
return (
<div className="space-y-6">
<SearchHeader
aiOn={search.aiOn}
aiAvailable={search.aiAvailable}
onToggleAI={() => search.setAiOn((on) => !on)}
/>
<SearchInputBar
aiOn={search.aiOn}
query={search.q}
onQueryChange={search.setQ}
onAISubmit={search.onAISubmit}
/>
{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(search.intent)}</span>
</div>
)}
<SearchStatusPanels
loading={search.loading}
error={search.error}
showIdle={search.showIdle}
showEmpty={search.showEmpty}
/>
<SearchLocalResults
localCards={search.localCards}
itemCount={search.itemCount}
searchTotal={search.searchTotal}
loading={search.loading}
/>
{search.externalItems.length > 0 && (
<ExternalResults
items={search.externalItems}
/>
)}
</div>
)
}
-42
View File
@@ -1,42 +0,0 @@
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>
)}
</>
)
}
+18 -4
View File
@@ -1,24 +1,37 @@
import { FormEvent, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Loader2, Save, SettingsIcon } from 'lucide-react'
import toast from 'react-hot-toast'
import { adminAPI } from '../api/admin'
import { libraryAPI } from '../api/library'
import type { Library, Setting } from '../types'
import { APIConfigsPanel } from '../components/APIConfigsPanel'
import { AdultSettingsPanel } from './AdultSettingsPanel'
import { LibrarySettingsPanel } from './LibrarySettingsPanel'
import { RecognitionWordsPanel } from './RecognitionWordsPanel'
import { SettingRow } from './SettingsRow'
import { SystemUpdatePanel } from './SystemUpdatePanel'
import { ALL_KEYS, GROUPS } from './settingsGroups'
export function SettingsPage() {
const [activeGroup, setActiveGroup] = useState(GROUPS[0].key)
const [searchParams] = useSearchParams()
const [activeGroup, setActiveGroup] = useState(() => {
const fromQuery = searchParams.get('group')
return GROUPS.some((g) => g.key === fromQuery) ? (fromQuery as string) : GROUPS[0].key
})
const [values, setValues] = useState<Record<string, string>>({})
const [dirty, setDirty] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [libraries, setLibraries] = useState<Library[]>([])
useEffect(() => {
const fromQuery = searchParams.get('group')
if (fromQuery && GROUPS.some((g) => g.key === fromQuery)) {
setActiveGroup(fromQuery)
}
}, [searchParams])
const refresh = async () => {
setLoading(true)
try {
@@ -108,10 +121,11 @@ export function SettingsPage() {
{!loading && (
<div className="space-y-4">
{group.key === 'system-update' && <SystemUpdatePanel />}
{group.key === 'library' && <LibrarySettingsPanel />}
{group.key === 'api-configs' && <APIConfigsPanel />}
{group.key === 'recognition-words' && <RecognitionWordsPanel />}
{group.key === 'adult' && <AdultSettingsPanel />}
{group.key !== 'adult' && group.items.length > 0 && (
{group.key !== 'adult' && group.key !== 'library' && group.items.length > 0 && (
<form onSubmit={onSave} className="glass-panel space-y-4">
{group.description && <p className="text-xs text-sand-500">{group.description}</p>}
{group.items.map((it) => (
-161
View File
@@ -1,161 +0,0 @@
import {
CheckCircle,
Edit3,
HelpCircle,
RefreshCw,
Trash2,
Wifi,
XCircle,
} from "lucide-react";
import type { Site } from "../types";
import {
AUTH_TYPE_LABELS,
SITE_TYPE_ABBR,
SITE_TYPE_COLORS,
SITE_TYPE_LABELS,
} from "./sitesPageModel";
type SiteCardProps = {
site: Site;
testing: boolean;
onTest: () => void;
onEdit: () => void;
onDelete: () => void;
};
export function SiteCard({
site,
testing,
onTest,
onEdit,
onDelete,
}: SiteCardProps) {
return (
<div className="glass-panel p-4 space-y-3 transition-all hover:border-primary-400/30">
{/* 头部 */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-2 min-w-0">
<div
className={`w-8 h-8 rounded-xl flex items-center justify-center text-xs font-bold shrink-0 ${SITE_TYPE_COLORS[site.type] || "bg-sand-500/15 text-ink-50"}`}
>
{SITE_TYPE_ABBR[site.type] || "?"}
</div>
<div className="min-w-0">
<div className="font-medium text-ink-600 truncate">{site.name}</div>
<div className="text-xs text-ink-50 truncate max-w-[160px]">
{site.url}
</div>
</div>
</div>
{/* 状态指示 */}
<div className="flex items-center gap-1 shrink-0 ml-2">
{site.last_check_at ? (
site.last_error ? (
<XCircle size={14} className="text-red-400" />
) : (
<CheckCircle size={14} className="text-green-400" />
)
) : (
<HelpCircle size={14} className="text-sand-500" />
)}
{!site.enabled && (
<span className="text-xs text-sand-500 ml-1">已停用</span>
)}
</div>
</div>
{/* 标签 */}
<div className="flex flex-wrap gap-1.5">
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-gray-50 text-ink-50">
{SITE_TYPE_LABELS[site.type] || site.type}
</span>
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-gray-50 text-ink-50">
{AUTH_TYPE_LABELS[site.auth_type] || site.auth_type}
</span>
{site.is_default && (
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-primary-400/15 text-brand-500">
默认
</span>
)}
{site.use_proxy && (
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-blue-500/15 text-blue-400">
代理
</span>
)}
{site.rate_limit && (
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-yellow-500/15 text-yellow-400">
限流
</span>
)}
{site.browser_emulation && (
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-purple-500/15 text-purple-400">
浏览器
</span>
)}
</div>
{/* 状态与统计(只读) */}
<div className="text-xs text-sand-500 space-y-0.5">
<div>
状态:
<span
className={
site.login_status === "ok"
? "text-green-400"
: site.login_status === "failed"
? "text-red-400"
: "text-ink-50"
}
>
{site.login_status || "unknown"}
</span>
</div>
{(site.upload_bytes || 0) > 0 && (
<div>
↑ {Math.round(((site.upload_bytes ?? 0) / 1073741824) * 100) / 100}{" "}
GB / ↓{" "}
{Math.round(((site.download_bytes ?? 0) / 1073741824) * 100) / 100}{" "}
GB
</div>
)}
{site.priority !== 50 && <div>优先级:{site.priority}</div>}
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => onTest()}
disabled={testing}
className="flex-1 rounded-lg border border-gray-200 px-2 py-1.5 text-xs text-ink-100 hover:bg-gray-50 disabled:opacity-50 flex items-center justify-center gap-1 transition"
>
{testing ? (
<>
<RefreshCw size={12} className="animate-spin" />
测试中...
</>
) : (
<>
<Wifi size={12} />
测试连接
</>
)}
</button>
<button
onClick={() => onEdit()}
className="rounded-lg border border-gray-200 p-1.5 text-ink-50 hover:text-white hover:bg-gray-50 transition"
title="编辑"
>
<Edit3 size={14} />
</button>
<button
onClick={() => onDelete()}
className="rounded-lg border border-gray-200 p-1.5 text-ink-50 hover:text-red-400 hover:bg-red-400/10 transition"
title="删除"
>
<Trash2 size={14} />
</button>
</div>
</div>
);
}
-41
View File
@@ -1,41 +0,0 @@
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>
);
}
-183
View File
@@ -1,183 +0,0 @@
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>
);
}
-133
View File
@@ -1,133 +0,0 @@
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>
);
}
-121
View File
@@ -1,121 +0,0 @@
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>
);
}
return null;
}
-76
View File
@@ -1,76 +0,0 @@
import type { Dispatch, FormEvent, SetStateAction } from "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;
form: SiteForm;
setForm: Dispatch<SetStateAction<SiteForm>>;
saving: boolean;
advancedOpen: boolean;
setAdvancedOpen: Dispatch<SetStateAction<boolean>>;
onClose: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
onTypeChange: (type: string) => void;
};
export function SiteFormModal({
editingId,
form,
setForm,
saving,
advancedOpen,
setAdvancedOpen,
onClose,
onSubmit,
onTypeChange,
}: SiteFormModalProps) {
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="glass-panel w-full max-w-xl max-h-[75vh] overflow-y-auto mx-4 space-y-5"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-ink-600">
{editingId ? "编辑站点" : "添加站点"}
</h2>
<button
onClick={onClose}
className="text-ink-50 hover:text-white transition"
>
<X size={20} />
</button>
</div>
<form onSubmit={onSubmit} className="space-y-4">
<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>
);
}
-169
View File
@@ -1,169 +0,0 @@
import { FormEvent, useState } from 'react'
import toast from 'react-hot-toast'
import { Download, Search } from 'lucide-react'
import { sitesAPI, type SiteSearchResult } from '../api/sites'
import { downloadsAPI } from '../api/downloads'
function fmtBytes(n: number): string {
if (!n || n <= 0) return '—'
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]}`
}
// SiteSearchPage performs cross-site torrent search across all enabled
// sites and displays a merged result table sorted by seeders.
export function SiteSearchPage() {
const [keyword, setKeyword] = useState('')
const [results, setResults] = useState<SiteSearchResult[]>([])
const [loading, setLoading] = useState(false)
const [searched, setSearched] = useState(false)
const onSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!keyword.trim()) return
setLoading(true)
setSearched(true)
try {
const data = await sitesAPI.search(keyword.trim())
setResults(data.items || [])
if ((data.items || []).length === 0) {
toast('未找到结果,请检查站点配置或更换关键词')
}
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'搜索失败'
toast.error(msg)
} finally {
setLoading(false)
}
}
const downloadTorrent = async (item: SiteSearchResult) => {
const url = item.download_url || item.torrent_url
if (!url) {
toast.error('无可用下载链接')
return
}
try {
await downloadsAPI.add(url, '', {
title: item.title,
source_category: item.category,
})
toast.success(`已加入下载: ${item.title.substring(0, 40)}...`)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'下载失败'
toast.error(msg)
}
}
return (
<div className="space-y-6">
<header className="flex items-center gap-3">
<Search className="h-6 w-6 text-brand-500" />
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">站点搜索</h1>
<p className="text-sm text-ink-50">
跨站搜索所有已启用的 PT/BT 站点,结果按做种数排序。
</p>
</div>
</header>
<form onSubmit={onSubmit} className="flex flex-wrap gap-2">
<input
autoFocus
className="input-base flex-1"
placeholder="输入关键词搜索(如: 流浪地球 / Dune 2024)"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<button type="submit" disabled={loading} className="neon-button">
{loading ? '搜索中…' : '搜索'}
</button>
</form>
{loading && <p className="text-sand-500">正在查询所有站点…</p>}
{!loading && searched && results.length === 0 && (
<div className="glass-panel">
<p className="text-ink-100">
未找到结果。请确认已在「站点管理」中添加并启用了至少一个站点,且 Cookie / API Key 有效。
</p>
</div>
)}
{results.length > 0 && (
<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>S</th>
<th>L</th>
<th>Free</th>
<th className="text-right">操作</th>
</tr>
</thead>
<tbody>
{results.map((item, idx) => (
<tr key={idx} className="border-t border-gray-200 align-top">
<td className="py-2 text-brand-500">{item.site_name}</td>
<td className="max-w-md py-2">
<a
href={item.torrent_url}
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-ink-600 transition hover:text-brand-500"
title={item.title}
>
{item.title.length > 80
? item.title.substring(0, 80) + '…'
: item.title}
</a>
{item.subtitle && (
<div className="mt-1 line-clamp-2 text-xs text-ink-50" title={item.subtitle}>
{item.subtitle}
</div>
)}
</td>
<td className="whitespace-nowrap text-ink-100">
{fmtBytes(item.size)}
</td>
<td className="text-emerald-400">{item.seeders || '—'}</td>
<td className="text-red-400">{item.leechers || '—'}</td>
<td>
{item.free && (
<span className="rounded-lg border border-emerald-400/40 px-1.5 py-0.5 text-xs text-emerald-400">
Free
</span>
)}
</td>
<td className="py-2 text-right">
<button
onClick={() => downloadTorrent(item)}
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
title="加入下载"
>
<Download size={12} className="inline" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
-191
View File
@@ -1,191 +0,0 @@
import { type FormEvent, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { sitesAPI } from "../api/sites";
import type { Site } from "../types";
import { confirmAction } from "../components/confirmAction";
import {
API_SITE_TIMEOUT_SECONDS,
API_SITE_TYPES,
DEFAULT_SITE_TIMEOUT_SECONDS,
defaultSiteForm,
siteFormToPayload,
siteToForm,
} from "./sitesPageModel";
import { SiteFormModal } from "./SiteFormModal";
import {
SitesGrid,
SitesManagementShortcuts,
SitesPageHeader,
} from "./SitesPageSections";
function apiErrorMessage(err: unknown, fallback: string): string {
const data = (err as { response?: { data?: { message?: string; error?: string } } })
?.response?.data;
return data?.message || data?.error || (err instanceof Error ? err.message : fallback);
}
export function SitesPage() {
const [sites, setSites] = useState<Site[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState(defaultSiteForm());
const [saving, setSaving] = useState(false);
const [testingId, setTestingId] = useState<string | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const loadSites = async () => {
setLoading(true);
try {
const response = await sitesAPI.list();
setSites(Array.isArray(response.data) ? response.data : []);
} catch {
toast.error("加载站点列表失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadSites();
}, []);
const openCreate = () => {
setEditingId(null);
setForm(defaultSiteForm());
setAdvancedOpen(false);
setShowModal(true);
};
const openEdit = async (id: string) => {
try {
const response = await sitesAPI.get(id);
setEditingId(id);
setForm(siteToForm(response.data as Site));
setAdvancedOpen(false);
setShowModal(true);
} catch {
toast.error("获取站点详情失败");
}
};
const closeModal = () => {
setShowModal(false);
setEditingId(null);
};
const silentSave = async (): Promise<{ ok: boolean; message?: string }> => {
if (!form.name.trim() || !form.url.trim()) {
return { ok: false, message: "站点名称和地址不能为空" };
}
const payload = siteFormToPayload(form, !editingId);
try {
if (editingId) {
await sitesAPI.update(editingId, payload);
} else {
const response = await sitesAPI.create(payload);
setEditingId((response.data as Site)?.id ?? null);
}
return { ok: true };
} catch (err: unknown) {
return { ok: false, message: apiErrorMessage(err, "保存失败") };
}
};
const handleSave = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSaving(true);
const result = await silentSave();
if (result.ok) {
toast.success(editingId ? "站点已更新" : "站点已添加");
closeModal();
await loadSites();
} else {
toast.error(result.message || "保存失败");
}
setSaving(false);
};
const handleTest = async (id: string) => {
if (editingId === id) {
setSaving(true);
const result = await silentSave();
setSaving(false);
if (!result.ok) {
toast.error(result.message || "保存失败,无法测试");
return;
}
}
setTestingId(id);
try {
const response = await sitesAPI.test(id);
toast.success(response?.message || "连接测试成功");
} catch (err: unknown) {
toast.error(apiErrorMessage(err, "连接测试失败"));
} finally {
setTestingId(null);
}
};
const handleDelete = async (site: Site) => {
const confirmed = await confirmAction({
title: "删除站点",
message: `确定要删除站点「${site.name}」吗?此操作不可撤销。`,
confirmText: "删除",
});
if (!confirmed) return;
try {
await sitesAPI.remove(site.id);
toast.success("站点已删除");
await loadSites();
} catch {
toast.error("删除站点失败");
}
};
const handleTypeChange = (type: string) => {
const isAPISite = API_SITE_TYPES.has(type);
setForm((current) => ({
...current,
type,
auth_type: isAPISite ? "api_key" : current.auth_type,
timeout:
isAPISite && current.timeout === DEFAULT_SITE_TIMEOUT_SECONDS
? API_SITE_TIMEOUT_SECONDS
: current.timeout,
}));
};
return (
<div className="space-y-6">
<SitesManagementShortcuts />
<SitesPageHeader onCreate={openCreate} />
<SitesGrid
sites={sites}
loading={loading}
testingId={testingId}
onTest={handleTest}
onEdit={openEdit}
onDelete={handleDelete}
/>
{showModal && (
<SiteFormModal
editingId={editingId}
form={form}
setForm={setForm}
saving={saving}
advancedOpen={advancedOpen}
setAdvancedOpen={setAdvancedOpen}
onClose={closeModal}
onSubmit={handleSave}
onTypeChange={handleTypeChange}
/>
)}
</div>
);
}
-113
View File
@@ -1,113 +0,0 @@
import { Globe, Plus, RefreshCw } from "lucide-react";
import { ManagementShortcuts } from "../components/ManagementShortcuts";
import type { Site } from "../types";
import { SiteCard } from "./SiteCard";
export function SitesManagementShortcuts() {
return (
<ManagementShortcuts
title="站点与下载链路"
description="把站点、搜索、订阅和下载器放在同一工作流里,避免功能入口被隐藏。"
items={[
{
to: "/download-clients",
title: "下载器管理",
description: "添加、测试和维护下载器连接",
badge: "必需",
group: "基础配置",
},
{
to: "/site-search",
title: "站点检索",
description: "跨 PT 站点搜索资源并创建下载任务",
group: "资源获取",
},
{
to: "/subscriptions",
title: "订阅管理",
description: "管理追剧追番和自动下载规则",
group: "资源获取",
},
{
to: "/downloads",
title: "下载中心",
description: "查看下载任务状态和历史记录",
group: "任务状态",
},
]}
/>
);
}
export function SitesPageHeader({ onCreate }: { onCreate: () => void }) {
return (
<div className="flex items-center justify-between">
<h1 className="font-display text-3xl font-bold text-ink-600">
站点管理
</h1>
<button
onClick={onCreate}
className="neon-button flex items-center gap-2"
>
<Plus size={16} />
添加站点
</button>
</div>
);
}
export function SitesGrid({
sites,
loading,
testingId,
onTest,
onEdit,
onDelete,
}: {
sites: Site[];
loading: boolean;
testingId: string | null;
onTest: (id: string) => void;
onEdit: (id: string) => void;
onDelete: (site: Site) => void;
}) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sites.map((site) => (
<SiteCard
key={site.id}
site={site}
testing={testingId === site.id}
onTest={() => onTest(site.id)}
onEdit={() => onEdit(site.id)}
onDelete={() => onDelete(site)}
/>
))}
{!loading && sites.length === 0 && <SitesEmptyState />}
{loading && <SitesLoadingState />}
</div>
);
}
function SitesEmptyState() {
return (
<div className="col-span-full py-12 text-center text-ink-50">
<Globe size={40} className="mx-auto mb-3 text-gray-500" />
<p>暂无站点</p>
<p className="text-sm mt-1 text-sand-500">
点击「添加站点」添加 PT/BT 站点
</p>
</div>
);
}
function SitesLoadingState() {
return (
<div className="col-span-full py-12 text-center text-ink-50">
<RefreshCw size={24} className="mx-auto mb-3 animate-spin" />
<p>加载中...</p>
</div>
);
}
-94
View File
@@ -1,94 +0,0 @@
import { useEffect, useState } from 'react'
import { statsAPI } from '../api/stats'
import type { Hardware, StatsSnapshot } from '../types'
import {
AggregateStatsSection,
StatsHeader,
StatsTiles,
SystemMonitorSection,
} from './StatsPageSections'
// StatsPage renders the operator dashboard. Aggregate stats refresh less often,
// while hardware metrics are polled every 2 s for a real-time monitoring feel.
export function StatsPage() {
const [snap, setSnap] = useState<StatsSnapshot | null>(null)
const [hardware, setHardware] = useState<Hardware | null>(null)
const [lastMonitorAt, setLastMonitorAt] = useState<string>('')
const [monitorError, setMonitorError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
const tick = () =>
statsAPI.snapshot().then((s) => {
if (!cancelled) {
setSnap(s)
setHardware((current) => current ?? s.hardware)
setLastMonitorAt((current) => current || s.generated_at)
}
})
tick().finally(() => setLoading(false))
const id = window.setInterval(tick, 30_000)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [])
useEffect(() => {
let cancelled = false
const tick = () =>
statsAPI
.monitor()
.then((m) => {
if (!cancelled) {
setHardware(m)
setLastMonitorAt(new Date().toISOString())
setMonitorError('')
}
})
.catch((err: unknown) => {
if (!cancelled) {
setMonitorError((err as { message?: string })?.message ?? '实时监控暂不可用')
}
})
tick()
const id = window.setInterval(tick, 2_000)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [])
if (loading) return <p className="text-sand-500">加载中…</p>
if (!snap) return <p className="text-sand-500">无法获取统计数据</p>
const live = hardware ?? snap.hardware
const memPct =
live.memory_total > 0
? (live.memory_used / live.memory_total) * 100
: 0
const diskPct =
live.disk_total > 0
? (live.disk_used / live.disk_total) * 100
: 0
return (
<div className="space-y-8">
<StatsHeader
generatedAt={snap.generated_at}
lastMonitorAt={lastMonitorAt}
monitorError={monitorError}
/>
<StatsTiles snap={snap} live={live} memPct={memPct} diskPct={diskPct} />
<SystemMonitorSection
live={live}
memPct={memPct}
diskPct={diskPct}
monitorError={monitorError}
/>
<AggregateStatsSection generatedAt={snap.generated_at} />
</div>
)
}
-197
View File
@@ -1,197 +0,0 @@
import type { ReactNode } from 'react'
import { Activity, Cpu, Database, Film, HardDrive, Radio, Users } from 'lucide-react'
import type { Hardware, StatsSnapshot } from '../types'
function fmtBytes(n: number): string {
if (!n || n <= 0) return '—'
const u = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
let v = n
let i = 0
while (v >= 1024 && i < u.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(2)} ${u[i]}`
}
function fmtHours(seconds: number): string {
if (!seconds || seconds <= 0) return '—'
const h = Math.floor(seconds / 3600)
return `${h.toLocaleString()} h`
}
export function StatsHeader({
generatedAt,
lastMonitorAt,
monitorError,
}: {
generatedAt: string
lastMonitorAt: string
monitorError: string
}) {
return (
<header className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">运行状态</h1>
<p className="text-sm text-ink-50">
聚合快照:{new Date(generatedAt).toLocaleString()} · 实时监控每 2 秒刷新
</p>
</div>
<div className="glass-panel inline-flex items-center gap-3 !px-4 !py-3">
<span className="relative flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-70" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-emerald-500" />
</span>
<div>
<p className="text-xs font-bold uppercase tracking-widest text-emerald-600">
{monitorError ? '监控重试中' : '实时监控中'}
</p>
<p className="text-xs text-ink-50">
最近刷新:{lastMonitorAt ? new Date(lastMonitorAt).toLocaleTimeString() : '—'}
</p>
</div>
</div>
</header>
)
}
export function StatsTiles({
snap,
live,
memPct,
diskPct,
}: {
snap: StatsSnapshot
live: Hardware
memPct: number
diskPct: number
}) {
return (
<section className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<Tile icon={<Database size={20} />} label="媒体库" value={snap.libraries.toLocaleString()} />
<Tile icon={<Film size={20} />} label="媒体总数" value={snap.media_count.toLocaleString()} />
<Tile icon={<Users size={20} />} label="用户" value={snap.users_count.toLocaleString()} />
<Tile icon={<HardDrive size={20} />} label="入库容量" value={fmtBytes(snap.total_size_bytes)} />
<Tile icon={<Activity size={20} />} label="累计时长" value={fmtHours(snap.total_seconds)} />
<Tile icon={<Cpu size={20} />} label="CPU 占用" value={`${live.cpu_percent.toFixed(1)}%`} meter={live.cpu_percent} />
<Tile icon={<Cpu size={20} />} label="内存占用" value={`${memPct.toFixed(1)}%`} />
<Tile icon={<HardDrive size={20} />} label="数据盘占用" value={`${diskPct.toFixed(1)}%`} />
</section>
)
}
export function SystemMonitorSection({
live,
memPct,
diskPct,
monitorError,
}: {
live: Hardware
memPct: number
diskPct: number
monitorError: string
}) {
return (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="font-display text-xl font-semibold text-ink-600">系统实时监控</h2>
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">
<Radio size={12} />
Live
</span>
</div>
<div className="glass-panel grid gap-4 text-sm lg:grid-cols-[1fr_1.4fr]">
<div className="grid gap-2">
<Row label="Go 运行时" value={live.go_version} />
<Row label="Goroutines" value={live.goroutines.toLocaleString()} />
<Row
label="内存"
value={`${fmtBytes(live.memory_used)} / ${fmtBytes(live.memory_total)}`}
/>
<Row
label="数据盘"
value={`${fmtBytes(live.disk_used)} / ${fmtBytes(live.disk_total)}`}
/>
</div>
<div className="grid gap-3">
<Meter label="CPU" value={live.cpu_percent} />
<Meter label="内存" value={memPct} />
<Meter label="数据盘" value={diskPct} />
{monitorError && <p className="text-xs text-red-500">实时监控错误:{monitorError}</p>}
</div>
</div>
</section>
)
}
export function AggregateStatsSection({ generatedAt }: { generatedAt: string }) {
return (
<section className="space-y-3">
<h2 className="font-display text-xl font-semibold text-ink-600">聚合统计</h2>
<div className="glass-panel grid gap-2 text-sm">
<Row
label="统计刷新"
value={new Date(generatedAt).toLocaleString()}
/>
<Row label="统计口径" value="媒体库、用户、容量每 30 秒刷新" />
</div>
</section>
)
}
function Tile({
icon,
label,
value,
meter,
}: {
icon: ReactNode
label: string
value: string
meter?: number
}) {
return (
<div className="glass-panel flex items-center gap-3 !p-4">
<div className="rounded-xl border border-primary-400/40 bg-primary-400/10 p-2 text-brand-500">
{icon}
</div>
<div className="min-w-0 flex-1">
<p className="text-xs uppercase tracking-wider text-sand-500">{label}</p>
<p className="font-display text-lg font-semibold text-ink-600">{value}</p>
{typeof meter === 'number' && <Bar value={meter} />}
</div>
</div>
)
}
function Meter({ label, value }: { label: string; value: number }) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="font-semibold text-ink-600">{label}</span>
<span className="font-mono text-ink-50">{value.toFixed(1)}%</span>
</div>
<Bar value={value} />
</div>
)
}
function Bar({ value }: { value: number }) {
const pct = Math.max(0, Math.min(100, value || 0))
const color = pct > 85 ? 'bg-red-500' : pct > 65 ? 'bg-amber-500' : 'bg-emerald-500'
return (
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
<div className={`h-full rounded-full transition-all duration-700 ${color}`} style={{ width: `${pct}%` }} />
</div>
)
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between border-b border-gray-200 pb-1 text-sm last:border-0">
<span className="text-ink-50">{label}</span>
<span className="font-mono text-ink-600">{value}</span>
</div>
)
}
-52
View File
@@ -1,52 +0,0 @@
import { useState } from 'react'
import { Cloud } from 'lucide-react'
import {
type StorageType,
} from '../api/storage_config'
import {
STORAGE_TABS,
TYPE_LABEL,
} from './storageConfigModel'
import { StorageForm } from './StorageForm'
// 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() {
const [active, setActive] = useState<StorageType>('openlist')
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-blue-400/10 text-blue-300">
<Cloud size={20} />
</div>
<div>
<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-[var(--app-border)]">
{STORAGE_TABS.map((t) => (
<button
key={t}
onClick={() => setActive(t)}
className={
'border-b-2 px-4 py-2 text-sm ' +
(active === t
? 'border-primary-400 text-brand-500'
: 'border-transparent text-[var(--app-muted)] hover:text-[var(--app-text)]')
}
>
{TYPE_LABEL[t] ?? t}
</button>
))}
</div>
<StorageForm key={active} type={active} />
</div>
)
}
-202
View File
@@ -1,202 +0,0 @@
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 [browserVersion, setBrowserVersion] = useState(0)
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()
setBrowserVersion((version) => version + 1)
} 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.error(r.error ?? '连接失败')
return
}
if (isCloud(type)) {
await storageAPI.save(type, config, true)
setEnabled(true)
toast.success('连接成功,已保存并启用')
await refresh()
setBrowserVersion((version) => version + 1)
return
}
toast.success('连接成功')
} 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()
setBrowserVersion((version) => version + 1)
} 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 key={`${type}-${browserVersion}-${enabled ? 'on' : 'off'}`} type={type} enabled={enabled} />}
</form>
)
}
-48
View File
@@ -1,48 +0,0 @@
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>
)
}
-179
View File
@@ -1,179 +0,0 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Database, HardDrive, PieChart } from 'lucide-react'
import { storageAPI, type StorageBreakdown } from '../api/storage'
import { ManagementShortcuts } from '../components/ManagementShortcuts'
function fmtBytes(n: number): string {
if (!n) return '0 B'
const u = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
let v = n
let i = 0
while (v >= 1024 && i < u.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(2)} ${u[i]}`
}
function fmtHours(seconds: number): string {
if (!seconds) return '—'
const h = Math.floor(seconds / 3600)
return `${h.toLocaleString()} h`
}
// StoragePage shows disk usage broken down by library and by container.
export function StoragePage() {
const [data, setData] = useState<StorageBreakdown | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
storageAPI
.breakdown()
.then(setData)
.finally(() => setLoading(false))
}, [])
if (loading) return <p className="text-sand-500">加载中…</p>
if (!data) return <p className="text-sand-500">无法获取存储数据</p>
const totalBytes = data.total_bytes || 1
return (
<div className="space-y-8">
<header className="flex items-center gap-3">
<HardDrive className="h-6 w-6 text-brand-500" />
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">存储与文件</h1>
<p className="text-sm text-ink-50">
文件浏览、整理入库、排重与存储统计统一在这里,避免入口重复。
</p>
</div>
</header>
<ManagementShortcuts
title="核心操作"
description="保留常用入口:先整理/入库,再按需进入文件或清理功能。"
items={[
{ to: '/files', title: '文件管理', description: '浏览服务器文件,做少量安全文件操作', group: '整理入库' },
{ to: '/storage-config', title: '存储配置', description: '维护媒体存储路径和容量策略', group: '整理入库' },
{ to: '/duplicates', title: '重复清理', description: '扫描重复媒体并进行安全清理', badge: '清理', group: '空间维护' },
{ to: '/recycle', title: '回收站', description: '查看已删除资源并执行恢复或释放空间', group: '空间维护' },
]}
/>
<details className="glass-panel group">
<summary className="cursor-pointer list-none font-display text-lg font-semibold text-ink-600">
低频维护入口 <span className="text-xs font-normal text-sand-500">(点击展开)</span>
</summary>
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-6">
<MaintenanceLink to="/strm" title="STRM 生成" />
<MaintenanceLink to="/scheduler" title="定时任务" />
<MaintenanceLink to="/tasks" title="任务队列" />
<MaintenanceLink to="/stats" title="运行状态" />
<MaintenanceLink to="/notify-channels" title="通知渠道" />
<MaintenanceLink to="/assistant" title="AI 对话台" />
</div>
</details>
<section className="grid gap-4 sm:grid-cols-3">
<Tile icon={<Database size={20} />} label="总占用" value={fmtBytes(data.total_bytes)} />
<Tile icon={<PieChart size={20} />} label="媒体库" value={`${data.by_library.length}`} />
<Tile icon={<HardDrive size={20} />} label="累计时长" value={fmtHours(data.total_seconds)} />
</section>
<section className="space-y-3">
<h2 className="font-display text-xl font-semibold text-ink-600">按媒体库</h2>
<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>占用</th>
<th>占比</th>
</tr>
</thead>
<tbody>
{data.by_library.map((l) => {
const pct = (l.total_bytes / totalBytes) * 100
return (
<tr key={l.library_id} className="border-t border-gray-200">
<td className="py-2 text-ink-600">{l.name}</td>
<td className="text-ink-100">{l.type}</td>
<td className="text-ink-100">{l.media_count}</td>
<td className="text-ink-100">{fmtBytes(l.total_bytes)}</td>
<td>
<div className="flex items-center gap-2">
<div className="h-1 w-24 overflow-hidden rounded bg-gray-200">
<div
className="h-full bg-primary-400"
style={{ width: `${pct.toFixed(1)}%` }}
/>
</div>
<span className="text-xs text-ink-50">{pct.toFixed(1)}%</span>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</section>
<section className="space-y-3">
<h2 className="font-display text-xl font-semibold text-ink-600">按容器格式</h2>
<div className="grid gap-3 sm:grid-cols-2 md:grid-cols-3">
{data.by_container.map((c) => (
<div
key={c.container}
className="glass-panel flex items-center justify-between !p-4"
>
<div>
<p className="text-xs uppercase tracking-wider text-sand-500">{c.container}</p>
<p className="font-display text-lg font-semibold text-ink-600">{c.count} 项</p>
</div>
<p className="text-sm text-ink-100">{fmtBytes(c.bytes)}</p>
</div>
))}
</div>
</section>
</div>
)
}
function Tile({
icon,
label,
value,
}: {
icon: React.ReactNode
label: string
value: string
}) {
return (
<div className="glass-panel flex items-center gap-3 !p-4">
<div className="rounded-xl border border-primary-400/40 bg-primary-400/10 p-2 text-brand-500">
{icon}
</div>
<div>
<p className="text-xs uppercase tracking-wider text-sand-500">{label}</p>
<p className="font-display text-lg font-semibold text-ink-600">{value}</p>
</div>
</div>
)
}
function MaintenanceLink({ to, title }: { to: string; title: string }) {
return (
<Link
to={to}
className="rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-ink-100 transition hover:border-primary-400/40 hover:text-brand-500"
>
{title}
</Link>
)
}
-49
View File
@@ -1,49 +0,0 @@
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>
)
}
-131
View File
@@ -1,131 +0,0 @@
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>
)
}
-103
View File
@@ -1,103 +0,0 @@
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>
)
}
-65
View File
@@ -1,65 +0,0 @@
import type { FormEvent } from 'react'
import type { GenerateSTRMResult, STRMOutputPreset } from '../api/strm'
import type { Library } from '../types'
import type { CloudPlaybackMode } from './strmPageModel'
import {
PlaybackPreferencePanel,
PlaybackTogglePanel,
StrmGenerateForm,
StrmGenerateHeader,
StrmGenerateHint,
StrmGenerateResultPanel,
} from './StrmGenerateSectionParts'
export type StrmGenerateSectionProps = {
libraries: Library[]
generateLibraryID: string
baseURL: string
outputDir: string
outputPresets: STRMOutputPreset[]
cloudPlaybackMode: CloudPlaybackMode
strmPlaybackEnabled: boolean
redirectProxyEnabled: boolean
autoGenerate: boolean
savingSettings: boolean
overwrite: boolean
includeLocal: boolean
preserveTree: boolean
refreshLibrary: boolean
scrapeAfter: 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
setIncludeLocal: (value: boolean) => void
setPreserveTree: (value: boolean) => void
setRefreshLibrary: (value: boolean) => void
setScrapeAfter: (value: boolean) => void
}
export function StrmGenerateSection(props: StrmGenerateSectionProps) {
return (
<section className="glass-panel space-y-4">
<StrmGenerateHeader
playbackStatus={props.playbackStatus}
strmPlaybackEnabled={props.strmPlaybackEnabled}
redirectProxyEnabled={props.redirectProxyEnabled}
/>
<PlaybackTogglePanel {...props} />
<PlaybackPreferencePanel {...props} />
<StrmGenerateForm {...props} />
<StrmGenerateHint />
<StrmGenerateResultPanel result={props.generateResult} />
</section>
)
}
-338
View File
@@ -1,338 +0,0 @@
import { Loader2, Save, Wand2 } from 'lucide-react'
import type { GenerateSTRMResult, STRMRefreshResult } from '../api/strm'
import type { Library } from '../types'
import { currentOrigin, type CloudPlaybackMode } from './strmPageModel'
import type { StrmGenerateSectionProps } from './StrmGenerateSection'
import { StrmOutputDirPicker } from './StrmOutputDirPicker'
type PlaybackStatusProps = Pick<
StrmGenerateSectionProps,
'playbackStatus' | 'strmPlaybackEnabled' | 'redirectProxyEnabled'
>
export function StrmGenerateHeader({ playbackStatus, strmPlaybackEnabled, redirectProxyEnabled }: PlaybackStatusProps) {
const enabled = strmPlaybackEnabled || redirectProxyEnabled
return (
<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 ${
enabled
? '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>
)
}
type PlaybackToggleProps = Pick<
StrmGenerateSectionProps,
'strmPlaybackEnabled' | 'redirectProxyEnabled' | 'setStrmPlaybackEnabled' | 'setRedirectProxyEnabled'
>
export function PlaybackTogglePanel({
strmPlaybackEnabled,
redirectProxyEnabled,
setStrmPlaybackEnabled,
setRedirectProxyEnabled,
}: PlaybackToggleProps) {
return (
<div className="grid gap-3 rounded-2xl border border-gray-200 bg-white/70 p-4 md:grid-cols-2">
<PlaybackToggle
checked={strmPlaybackEnabled}
title="启用 STRMURL 播放"
description="第三方客户端可拿到 /api/stream/媒体ID 入口,适合 STRM 管理和自动生成方案。"
onChange={setStrmPlaybackEnabled}
/>
<PlaybackToggle
checked={redirectProxyEnabled}
title="启用 302/反代播放"
description="第三方客户端可拿到 /Videos/媒体ID/stream 入口,由服务端解析后 302 或必要时反代。"
onChange={setRedirectProxyEnabled}
/>
</div>
)
}
type PlaybackToggleItemProps = {
checked: boolean
title: string
description: string
onChange: (value: boolean) => void
}
function PlaybackToggle({ checked, title, description, onChange }: PlaybackToggleItemProps) {
return (
<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={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<span>
<span className="block font-medium text-ink-600">{title}</span>
<span className="text-xs text-ink-50">{description}</span>
</span>
</label>
)
}
type PlaybackPreferenceProps = Pick<
StrmGenerateSectionProps,
| 'cloudPlaybackMode'
| 'strmPlaybackEnabled'
| 'redirectProxyEnabled'
| 'autoGenerate'
| 'savingSettings'
| 'saveSTRMSettings'
| 'setCloudPlaybackMode'
| 'setAutoGenerate'
>
export function PlaybackPreferencePanel({
cloudPlaybackMode,
strmPlaybackEnabled,
redirectProxyEnabled,
autoGenerate,
savingSettings,
saveSTRMSettings,
setCloudPlaybackMode,
setAutoGenerate,
}: PlaybackPreferenceProps) {
return (
<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>
<PlaybackToggle
checked={autoGenerate}
title="扫描后自动刷新 STRM 文件"
description="默认关闭,避免扫描大型网盘库时重复写文件。"
onChange={setAutoGenerate}
/>
<button type="button" className="neon-button self-center" disabled={savingSettings} onClick={saveSTRMSettings}>
{savingSettings ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
保存开关
</button>
</div>
)
}
type StrmGenerateFormProps = Pick<
StrmGenerateSectionProps,
| 'libraries'
| 'generateLibraryID'
| 'baseURL'
| 'outputDir'
| 'outputPresets'
| 'overwrite'
| 'includeLocal'
| 'preserveTree'
| 'refreshLibrary'
| 'scrapeAfter'
| 'generating'
| 'onGenerate'
| 'setGenerateLibraryID'
| 'setBaseURL'
| 'setOutputDir'
| 'setOverwrite'
| 'setIncludeLocal'
| 'setPreserveTree'
| 'setRefreshLibrary'
| 'setScrapeAfter'
>
export function StrmGenerateForm({
libraries,
generateLibraryID,
baseURL,
outputDir,
outputPresets,
overwrite,
includeLocal,
preserveTree,
refreshLibrary,
scrapeAfter,
generating,
onGenerate,
setGenerateLibraryID,
setBaseURL,
setOutputDir,
setOverwrite,
setIncludeLocal,
setPreserveTree,
setRefreshLibrary,
setScrapeAfter,
}: StrmGenerateFormProps) {
return (
<form onSubmit={onGenerate} className="grid gap-3 md:grid-cols-4">
<LibrarySelect libraries={libraries} value={generateLibraryID} onChange={setGenerateLibraryID} />
<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>
<div className="grid gap-2 md:col-span-4 md:grid-cols-5">
<CompactOption checked={overwrite} label="覆盖已存在" onChange={setOverwrite} />
<CompactOption checked={includeLocal} label="包含本地媒体" onChange={setIncludeLocal} />
<CompactOption checked={preserveTree} label="保留目录树" onChange={setPreserveTree} />
<CompactOption checked={refreshLibrary} label="生成后刷新媒体库" onChange={setRefreshLibrary} />
<CompactOption
checked={refreshLibrary && scrapeAfter}
disabled={!refreshLibrary}
label="刷新后自动刮削"
onChange={setScrapeAfter}
/>
</div>
<StrmOutputDirPicker
className="md:col-span-4"
presets={outputPresets}
placeholder="输出目录可留空,默认写入 data/strm/分类/子分类"
value={outputDir}
onChange={setOutputDir}
/>
<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>
)
}
type CompactOptionProps = {
checked: boolean
disabled?: boolean
label: string
onChange: (value: boolean) => void
}
function CompactOption({ checked, disabled, label, onChange }: CompactOptionProps) {
return (
<label className={`flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50 ${disabled ? 'cursor-not-allowed opacity-50' : ''}`}>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
{label}
</label>
)
}
type LibrarySelectProps = {
libraries: Library[]
value: string
onChange: (value: string) => void
}
function LibrarySelect({ libraries, value, onChange }: LibrarySelectProps) {
return (
<select
required
className="input-base"
value={value}
onChange={(e) => onChange(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>
)
}
export function StrmGenerateHint() {
return (
<p className="text-xs text-sand-500">
生成内容为 <code>域名 + /api/stream/媒体ID?token=...</code>;第三方客户端播放优先方式由上方「STRMURL / 302反代」模式决定。域名会同步保存到系统设置中的「公开访问域名 / STRM 域名」。
</p>
)
}
export function StrmGenerateResultPanel({ result }: { result: GenerateSTRMResult | null }) {
if (!result) {
return null
}
return (
<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">
输出目录:{result.output_dir}
</div>
<div className="mt-1">
{result.previewed ? `预检 ${result.previewed} · ` : ''}
{result.total ? `共 ${result.total} · ` : ''}
新增 {result.generated} · 更新 {result.updated} · 跳过 {result.skipped}
{result.ignored ? ` · 忽略 ${result.ignored}` : ''} · 清理 {result.cleaned || 0}
</div>
{result.batch_limited && (
<div className="mt-1 text-amber-600">本批已达到上限,剩余 {result.remaining || 0} 个可继续运行。</div>
)}
<StrmRefreshStatus refresh={result.refresh} />
{result.ignored_items && result.ignored_items.length > 0 && (
<div className="mt-1 text-amber-600">
已忽略 {result.ignored || result.ignored_items.length} 个非视频/sidecar:{result.ignored_items.slice(0, 3).join(';')}
</div>
)}
{result.errors && result.errors.length > 0 && (
<div className="mt-2 text-red-500">
失败 {result.errors.length} 条:{result.errors.slice(0, 3).join(';')}
</div>
)}
</div>
)
}
export function StrmRefreshStatus({ refresh }: { refresh?: STRMRefreshResult }) {
if (!refresh) {
return null
}
return (
<>
<div className={refresh.queued ? 'mt-1 text-emerald-600' : 'mt-1 text-amber-600'}>
{refresh.queued
? `媒体库刷新已排队:${refresh.targets?.map((target) => target.name).join('、') || '已匹配媒体库'}`
: `媒体库未刷新:${refresh.reason || '未匹配到可扫描媒体库'}`}
</div>
{refresh.scrape_requested && (
<div className={refresh.scrape_queued ? 'mt-1 text-emerald-600' : 'mt-1 text-amber-600'}>
{refresh.scrape_queued
? '扫描完成后将自动刮削'
: `未安排刮削:${refresh.scrape_reason || '媒体库未刷新'}`}
</div>
)}
</>
)
}
-71
View File
@@ -1,71 +0,0 @@
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>
)
}
-50
View File
@@ -1,50 +0,0 @@
import type { STRMOutputPreset } from '../api/strm'
type StrmOutputDirPickerProps = {
className?: string
inputClassName?: string
presets: STRMOutputPreset[]
placeholder: string
value: string
required?: boolean
onChange: (value: string) => void
}
export function StrmOutputDirPicker({
className = '',
inputClassName = '',
presets,
placeholder,
value,
required = false,
onChange,
}: StrmOutputDirPickerProps) {
const normalizedValue = value.trim()
const selectedPreset = presets.some((preset) => preset.path === normalizedValue) ? normalizedValue : ''
return (
<div className={`grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(10rem,16rem)] ${className}`}>
<input
required={required}
className={`input-base ${inputClassName}`}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<select
className="input-base"
value={selectedPreset}
onChange={(e) => {
if (e.target.value) onChange(e.target.value)
}}
>
<option value="">选择输出目录</option>
{presets.map((preset) => (
<option key={`${preset.kind}:${preset.path}`} value={preset.path}>
{preset.kind === 'library' ? `${preset.label} - ${preset.path}` : preset.label}
</option>
))}
</select>
</div>
)
}
-86
View File
@@ -1,86 +0,0 @@
import { StrmAttachSection } from './StrmAttachSection'
import { StrmGenerateSection } from './StrmGenerateSection'
import { StrmImportSection } from './StrmImportSection'
import { StrmPageHeader } from './StrmPageHeader'
import { StrmRepairSection } from './StrmRepairSection'
import { StrmTreeGenerateSection } from './StrmTreeGenerateSection'
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)
// tuple — useful for streaming-only entries with no on-disk file.
// - search existing media and attach / detach a STRM URL so the player
// issues a 302 redirect to the remote source instead of opening a
// local file.
export function StrmPage() {
const strm = useStrmPage()
return (
<div className="space-y-6">
<StrmPageHeader />
<StrmGenerateSection
libraries={strm.libraries}
generateLibraryID={strm.generate.generateLibraryID}
baseURL={strm.generate.baseURL}
outputDir={strm.generate.outputDir}
outputPresets={strm.outputPresets}
cloudPlaybackMode={strm.generate.cloudPlaybackMode}
strmPlaybackEnabled={strm.generate.strmPlaybackEnabled}
redirectProxyEnabled={strm.generate.redirectProxyEnabled}
autoGenerate={strm.generate.autoGenerate}
savingSettings={strm.generate.savingSettings}
overwrite={strm.generate.overwrite}
includeLocal={strm.generate.includeLocal}
preserveTree={strm.generate.preserveTree}
refreshLibrary={strm.generate.refreshLibrary}
scrapeAfter={strm.generate.scrapeAfter}
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}
setIncludeLocal={strm.generate.setIncludeLocal}
setPreserveTree={strm.generate.setPreserveTree}
setRefreshLibrary={strm.generate.setRefreshLibrary}
setScrapeAfter={strm.generate.setScrapeAfter}
/>
<StrmTreeGenerateSection {...strm.treeGenerate} outputPresets={strm.outputPresets} />
<StrmRepairSection {...strm.repair} outputPresets={strm.outputPresets} />
<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}
/>
<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>
)
}
-17
View File
@@ -1,17 +0,0 @@
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 直链以&quot;虚拟文件&quot;形式纳入媒体库
</p>
</div>
</div>
)
}
-101
View File
@@ -1,101 +0,0 @@
import { Loader2, RotateCcw, Wrench } from 'lucide-react'
import type { STRMOutputPreset } from '../api/strm'
import { currentOrigin } from './strmPageModel'
import { StrmRefreshStatus } from './StrmGenerateSectionParts'
import { StrmOutputDirPicker } from './StrmOutputDirPicker'
import type { useStrmRepairForm } from './useStrmRepairForm'
type StrmRepairSectionProps = ReturnType<typeof useStrmRepairForm> & {
outputPresets: STRMOutputPreset[]
}
export function StrmRepairSection({
baseURL,
outputDir,
outputPresets,
repairing,
refreshLibrary,
result,
runningMode,
scrapeAfter,
onPreview,
onRepair,
setBaseURL,
setOutputDir,
setRefreshLibrary,
setScrapeAfter,
}: StrmRepairSectionProps) {
return (
<section className="glass-panel space-y-4">
<div>
<h2 className="font-display text-lg font-semibold text-ink-600">STRM 修复</h2>
<p className="text-sm text-ink-50">批量修复已存在 .strm 文件中的本服务播放地址。</p>
</div>
<form onSubmit={onRepair} className="grid gap-3 md:grid-cols-4">
<StrmOutputDirPicker
className="md:col-span-2"
required
presets={outputPresets}
placeholder="STRM 输出目录"
value={outputDir}
onChange={setOutputDir}
/>
<input
className="input-base"
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 min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50 md:col-span-4">
<input type="checkbox" checked={refreshLibrary} onChange={(e) => setRefreshLibrary(e.target.checked)} />
修复后刷新媒体库
</label>
<label className={`flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50 md:col-span-4 ${!refreshLibrary ? 'cursor-not-allowed opacity-50' : ''}`}>
<input
type="checkbox"
checked={refreshLibrary && scrapeAfter}
disabled={!refreshLibrary}
onChange={(e) => setScrapeAfter(e.target.checked)}
/>
刷新后自动刮削
</label>
<button
type="button"
disabled={repairing || !outputDir.trim()}
className="inline-flex min-h-10 items-center justify-center gap-2 rounded-2xl border border-primary-400/40 px-3 py-2 text-sm font-medium text-brand-500 transition hover:bg-primary-400/10 disabled:cursor-not-allowed disabled:opacity-50 md:col-span-2"
onClick={onPreview}
>
{runningMode === 'preview' ? <Loader2 size={16} className="animate-spin" /> : <RotateCcw size={16} />}
{runningMode === 'preview' ? '预检中...' : '预检修复'}
</button>
<button type="submit" disabled={repairing || !outputDir.trim()} className="neon-button md:col-span-2">
{runningMode === 'repair' ? <Loader2 size={16} className="animate-spin" /> : <Wrench size={16} />}
{runningMode === 'repair' ? '修复中...' : '修复 STRM'}
</button>
</form>
{result && (
<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">输出目录:{result.output_dir}</div>
<div className="mt-1">
{result.previewed ? `预检 ${result.previewed} · ` : ''}
修复 {result.repaired} · 跳过 {result.skipped}
</div>
<StrmRefreshStatus refresh={result.refresh} />
{result.errors && result.errors.length > 0 && (
<div className="mt-2 text-red-500">
失败 {result.errors.length} 条:{result.errors.slice(0, 3).join(';')}
</div>
)}
</div>
)}
</section>
)
}
-253
View File
@@ -1,253 +0,0 @@
import { FolderTree, Loader2, Upload, X } from 'lucide-react'
import type { STRMOutputPreset } from '../api/strm'
import { currentOrigin } from './strmPageModel'
import { StrmGenerateResultPanel } from './StrmGenerateSectionParts'
import { StrmOutputDirPicker } from './StrmOutputDirPicker'
import type { useStrmTreeGenerateForm } from './useStrmTreeGenerateForm'
type StrmTreeGenerateSectionProps = ReturnType<typeof useStrmTreeGenerateForm> & {
outputPresets: STRMOutputPreset[]
}
const outputPrefixPresets = [
'电影/演唱会',
'电影/纪录片',
'电影/动画电影',
'电影/华语电影',
'电影/日韩电影',
'电影/欧美电影',
'电视剧/纪录片',
'电视剧/儿童',
'电视剧/综艺',
'电视剧/国产剧',
'电视剧/日韩剧',
'电视剧/欧美剧',
'动漫/日番',
'动漫/国漫',
'动漫/韩漫',
'动漫/美漫',
'动漫/其他',
'成人',
]
export function StrmTreeGenerateSection({
baseURL,
batchLimit,
cleanup,
generating,
missingOnly,
onGenerate,
onPreview,
onImportTreeFile,
outputDir,
outputPresets,
outputPrefix,
overwrite,
pathsText,
provider,
recognizeRename,
refreshLibrary,
result,
runningMode,
scrapeAfter,
setBaseURL,
setBatchLimit,
setCleanup,
setMissingOnly,
setOutputDir,
setOutputPrefix,
setOverwrite,
setPathsText,
setProvider,
setRecognizeRename,
setRefreshLibrary,
setScrapeAfter,
setSourceRoot,
setTreeText,
setTransferSubtitles,
sourceRoot,
transferSubtitles,
treeText,
}: StrmTreeGenerateSectionProps) {
const batchLimitEnabled = Number.parseInt(batchLimit, 10) > 0
return (
<section className="glass-panel space-y-4">
<div>
<h2 className="font-display text-lg font-semibold text-ink-600">目录树生成 STRM</h2>
<p className="text-sm text-ink-50">从网盘目录树或路径列表直接生成 .strm 文件。</p>
</div>
<form onSubmit={onGenerate} className="grid gap-3 md:grid-cols-4">
<select className="input-base" value={provider} onChange={(e) => setProvider(e.target.value)}>
<option value="openlist">OpenList</option>
<option value="alist">Alist</option>
<option value="cloud115">115</option>
<option value="webdav">WebDAV</option>
<option value="clouddrive2">CloudDrive2</option>
</select>
<input
className="input-base"
placeholder="/电视剧"
value={sourceRoot}
onChange={(e) => setSourceRoot(e.target.value)}
/>
<StrmOutputDirPicker
className="md:col-span-2"
required
presets={outputPresets}
placeholder="输出目录"
value={outputDir}
onChange={setOutputDir}
/>
<select
className="input-base"
value={outputPrefixPresets.includes(outputPrefix) ? outputPrefix : ''}
onChange={(e) => setOutputPrefix(e.target.value)}
>
<option value="">选择输出分类</option>
<optgroup label="电影">
<option value="电影/演唱会">演唱会</option>
<option value="电影/纪录片">纪录片</option>
<option value="电影/动画电影">动画电影</option>
<option value="电影/华语电影">华语电影</option>
<option value="电影/日韩电影">日韩电影</option>
<option value="电影/欧美电影">欧美电影</option>
</optgroup>
<optgroup label="电视剧">
<option value="电视剧/纪录片">纪录片</option>
<option value="电视剧/儿童">儿童</option>
<option value="电视剧/综艺">综艺</option>
<option value="电视剧/国产剧">国产剧</option>
<option value="电视剧/日韩剧">日韩剧</option>
<option value="电视剧/欧美剧">欧美剧</option>
</optgroup>
<optgroup label="动漫">
<option value="动漫/日番">日番</option>
<option value="动漫/国漫">国漫</option>
<option value="动漫/韩漫">韩漫</option>
<option value="动漫/美漫">美漫</option>
<option value="动漫/其他">其他</option>
</optgroup>
<option value="成人">成人</option>
</select>
<input
className="input-base"
placeholder="输出分类,如 电影/欧美电影"
value={outputPrefix}
onChange={(e) => setOutputPrefix(e.target.value)}
/>
<input
className="input-base"
inputMode="numeric"
min={0}
placeholder="每批数量"
step={1}
type="number"
value={batchLimit}
onChange={(e) => setBatchLimit(e.target.value)}
/>
<input
className="input-base md:col-span-3"
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>
<textarea
className="input-base min-h-44 md:col-span-4"
placeholder={'电视剧\n├── 国产剧\n│ └── 南部档案\n│ └── Archives.S01E01.mkv'}
value={treeText}
onChange={(e) => setTreeText(e.target.value)}
/>
<textarea
className="input-base min-h-32 md:col-span-4"
placeholder={'/电视剧/国产剧/南部档案/Season 01/Archives.S01E01.mkv\ncloud://openlist/电影/欧美电影/Dune.Part.Two.2024.mkv'}
value={pathsText}
onChange={(e) => setPathsText(e.target.value)}
/>
<div className="flex flex-wrap items-center gap-2 md:col-span-4">
<label className="inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm font-medium text-ink-500 transition hover:border-primary-300 hover:text-brand-500">
<Upload size={16} />
导入文本
<input className="sr-only" type="file" accept=".txt,.tree,text/plain" onChange={onImportTreeFile} />
</label>
<button
type="button"
className="inline-flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm font-medium text-ink-500 transition hover:border-red-200 hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!treeText.trim() && !pathsText.trim()}
onClick={() => {
setTreeText('')
setPathsText('')
}}
>
<X size={16} />
清空
</button>
</div>
<label className="flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50 md:col-span-4">
<input type="checkbox" checked={overwrite} onChange={(e) => setOverwrite(e.target.checked)} />
覆盖已存在
</label>
<label className="flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50 md:col-span-4">
<input type="checkbox" checked={recognizeRename} onChange={(e) => setRecognizeRename(e.target.checked)} />
识别重命名
</label>
<label className="flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50 md:col-span-4">
<input type="checkbox" checked={transferSubtitles} onChange={(e) => setTransferSubtitles(e.target.checked)} />
转移同名字幕链接
</label>
<label className="flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50 md:col-span-4">
<input type="checkbox" checked={missingOnly} onChange={(e) => setMissingOnly(e.target.checked)} />
仅生成媒体库缺失
</label>
<label className="flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50 md:col-span-4">
<input type="checkbox" checked={refreshLibrary} onChange={(e) => setRefreshLibrary(e.target.checked)} />
生成后刷新媒体库
</label>
<label className={`flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50 md:col-span-4 ${!refreshLibrary ? 'cursor-not-allowed opacity-50' : ''}`}>
<input
type="checkbox"
checked={refreshLibrary && scrapeAfter}
disabled={!refreshLibrary}
onChange={(e) => setScrapeAfter(e.target.checked)}
/>
刷新后自动刮削
</label>
<label className="flex min-h-10 items-center gap-2 rounded-2xl border border-gray-200 bg-white/70 px-3 py-2 text-sm text-ink-50 md:col-span-4">
<input
type="checkbox"
checked={!batchLimitEnabled && cleanup}
disabled={batchLimitEnabled}
onChange={(e) => setCleanup(e.target.checked)}
/>
清理不在当前目录树中的旧 STRM
</label>
<button
type="button"
disabled={generating || (!treeText.trim() && !pathsText.trim()) || !outputDir.trim()}
className="inline-flex min-h-10 items-center justify-center gap-2 rounded-2xl border border-primary-400/40 px-3 py-2 text-sm font-medium text-brand-500 transition hover:bg-primary-400/10 disabled:cursor-not-allowed disabled:opacity-50 md:col-span-2"
onClick={onPreview}
>
{runningMode === 'preview' ? <Loader2 size={16} className="animate-spin" /> : <FolderTree size={16} />}
{runningMode === 'preview' ? '预检中...' : '预检目录树'}
</button>
<button
type="submit"
disabled={generating || (!treeText.trim() && !pathsText.trim()) || !outputDir.trim()}
className="neon-button md:col-span-2"
>
{runningMode === 'generate' ? <Loader2 size={16} className="animate-spin" /> : <FolderTree size={16} />}
{runningMode === 'generate' ? '生成中...' : '按目录树生成'}
</button>
</form>
<StrmGenerateResultPanel result={result} />
</section>
)
}
-105
View File
@@ -1,105 +0,0 @@
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>
)
}
-170
View File
@@ -1,170 +0,0 @@
import { FormEvent } from 'react'
import { Loader2, Plus, Save } from 'lucide-react'
import type { SubscriptionFormValues } from './subscriptionFormModel'
interface SubscriptionFormProps {
values: SubscriptionFormValues
editing: boolean
busy: boolean
onSubmit: (event: FormEvent) => void
onCancelEdit: () => void
onChange: <K extends keyof SubscriptionFormValues>(key: K, value: SubscriptionFormValues[K]) => void
}
export function SubscriptionForm({ values, editing, busy, 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,dovi,h265,10bit,杜比"
value={values.excludeWords}
onChange={(e) => onChange('excludeWords', e.target.value)}
/>
<input
className="input-base"
inputMode="numeric"
placeholder="最少做种数"
value={values.minSeeders}
onChange={(e) => onChange('minSeeders', e.target.value)}
/>
<input
className="input-base"
inputMode="numeric"
placeholder="最多做种数"
value={values.maxSeeders}
onChange={(e) => onChange('maxSeeders', e.target.value)}
/>
<input
className="input-base"
inputMode="decimal"
placeholder="最小体积 GB"
value={values.minSizeGB}
onChange={(e) => onChange('minSizeGB', e.target.value)}
/>
<input
className="input-base"
inputMode="decimal"
placeholder="最大体积 GB"
value={values.maxSizeGB}
onChange={(e) => onChange('maxSizeGB', 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.freeOnly}
onChange={(e) => onChange('freeOnly', e.target.checked)}
/>
只下载免费资源
</label>
<button type="submit" className="neon-button md:col-span-1 disabled:cursor-not-allowed disabled:opacity-60" disabled={busy}>
{busy ? <Loader2 size={16} className="animate-spin" /> : editing ? <Save size={16} /> : <Plus size={16} />}
{busy ? '提交中…' : editing ? '保存' : '添加'}
</button>
{editing && (
<button
type="button"
onClick={onCancelEdit}
disabled={busy}
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:bg-gray-50"
>
取消编辑
</button>
)}
</form>
)
}
@@ -1,99 +0,0 @@
import { AlertTriangle, Archive, Film, Play, RefreshCw, RotateCcw } from 'lucide-react'
import { imageURL } from '../api/client'
import type { Subscription } from '../types'
import { subscriptionProgressLabel } from './subscriptionPageModel'
interface SubscriptionHistorySectionProps {
subscriptions: Subscription[]
loading?: boolean
error?: string
onRefresh?: () => Promise<void>
onRestore: (subscription: Subscription, runAfterRestore?: boolean) => void
}
export function SubscriptionHistorySection({
subscriptions,
loading = false,
error = '',
onRefresh,
onRestore,
}: SubscriptionHistorySectionProps) {
return (
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Archive size={18} className="text-brand-500" />
<h2 className="font-display text-xl font-semibold text-ink-600">订阅历史</h2>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-ink-50">{subscriptions.length} 条</span>
</div>
{onRefresh && (
<button
type="button"
className="inline-flex items-center gap-2 rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => onRefresh().catch(() => undefined)}
disabled={loading}
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
刷新历史
</button>
)}
</div>
{loading && <p className="text-sm text-sand-500">历史加载中…</p>}
{!loading && error && (
<div className="flex items-center gap-2 rounded-2xl border border-red-300/70 bg-red-50 px-4 py-3 text-sm text-red-700">
<AlertTriangle size={16} className="shrink-0" />
<span className="break-words">订阅历史加载失败:{error}</span>
</div>
)}
{!loading && !error && subscriptions.length === 0 && <p className="text-sm text-ink-50">暂无历史订阅。</p>}
{!error && subscriptions.length > 0 && (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{subscriptions.map((subscription) => (
<article key={subscription.id} className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex gap-3">
<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>
)
}
-273
View File
@@ -1,273 +0,0 @@
import { FormEvent, useEffect, useState } from 'react'
import { AlertTriangle, RefreshCw } from 'lucide-react'
import toast from 'react-hot-toast'
import { subscriptionsAPI } from '../api/subscriptions'
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 [formValues, setFormValues] = useState<SubscriptionFormValues>(defaultSubscriptionFormValues)
const [editingId, setEditingId] = useState('')
const [loading, setLoading] = useState(true)
const [historyLoading, setHistoryLoading] = useState(true)
const [listError, setListError] = useState('')
const [historyError, setHistoryError] = useState('')
const [saving, setSaving] = useState(false)
const refresh = async () => {
setLoading(true)
setHistoryLoading(true)
setListError('')
setHistoryError('')
const historyPromise = subscriptionsAPI
.history()
.then((history) => {
setHistoryItems(history)
})
.catch((err: unknown) => {
const msg = apiErrorMessage(err, '订阅历史加载失败')
setHistoryError(msg)
toast.error(msg)
})
.finally(() => setHistoryLoading(false))
try {
const active = await subscriptionsAPI.list()
setItems(active)
} catch (err: unknown) {
const msg = apiErrorMessage(err, '订阅列表加载失败')
setListError(msg)
setItems([])
toast.error(msg)
} finally {
setLoading(false)
}
await historyPromise
}
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const onCreate = async (e: FormEvent) => {
e.preventDefault()
if (saving) return
setSaving(true)
try {
const payload = {
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: formValues.quality,
effects: formValues.effects,
release_groups: formValues.releaseGroups,
exclude_words: formValues.excludeWords,
min_seeders: numericRuleValue(formValues.minSeeders),
max_seeders: numericRuleValue(formValues.maxSeeders),
min_size_gb: numericRuleValue(formValues.minSizeGB),
max_size_gb: numericRuleValue(formValues.maxSizeGB),
free_only: formValues.freeOnly,
wash_enabled: formValues.washEnabled,
wash_priority: formValues.washPriority,
priority: 50,
}
if (editingId) {
await subscriptionsAPI.update(editingId, payload)
toast.success('已更新订阅')
} else {
await subscriptionsAPI.create(payload)
toast.success('已创建订阅')
}
resetForm()
await refresh()
} catch (err: unknown) {
const msg = apiErrorMessage(err, '创建失败')
toast.error(msg)
} finally {
setSaving(false)
}
}
const resetForm = () => {
setEditingId('')
setFormValues(defaultSubscriptionFormValues)
}
const startEdit = (s: Subscription) => {
setEditingId(s.id)
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 || '',
minSeeders: stringRuleValue(s.min_seeders),
maxSeeders: stringRuleValue(s.max_seeders),
minSizeGB: stringRuleValue(s.min_size_gb),
maxSizeGB: stringRuleValue(s.max_size_gb),
freeOnly: Boolean(s.free_only),
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)
if (runAfterRestore) {
const result = await subscriptionsAPI.runNow(restored.id)
toast.success(`已恢复订阅并加入 ${result.queued} 项`)
} else {
toast.success('已恢复到正在订阅')
}
await refresh()
} catch (err: unknown) {
const msg = apiErrorMessage(err, '恢复订阅失败')
toast.error(msg)
}
}
const runSubscriptionNow = async (subscription: Subscription) => {
try {
const result = await subscriptionsAPI.runNow(subscription.id)
toast.success(`已加入 ${result.queued} 项`)
await refresh()
} catch (err: unknown) {
toast.error(apiErrorMessage(err, '运行订阅失败'))
}
}
const removeSubscription = async (subscription: Subscription) => {
if (!(await confirmAction({ title: '删除订阅', message: `删除订阅「${subscription.name}」?`, confirmText: '删除' }))) return
try {
await subscriptionsAPI.remove(subscription.id)
toast.success('已删除')
await refresh()
} catch (err: unknown) {
toast.error(apiErrorMessage(err, '删除失败'))
}
}
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-ink-600">RSS 订阅</h1>
<p className="text-sm text-ink-50">
后台统一每 3 小时轮询 RSS / 站点搜索订阅;手动立即执行不受影响。匹配过滤器的项目会自动加入下载队列,启用智能分类后按二级分类写入下载目录。
</p>
<SubscriptionForm
values={formValues}
editing={Boolean(editingId)}
busy={saving}
onSubmit={onCreate}
onCancelEdit={resetForm}
onChange={updateFormValue}
/>
<div className="flex items-center justify-between gap-3">
<h2 className="font-display text-xl font-semibold text-ink-600">正在订阅</h2>
<button
type="button"
className="inline-flex items-center gap-2 rounded-xl border border-primary-400/40 bg-white px-3 py-2 text-xs font-semibold text-brand-500 hover:bg-primary-400/10 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => refresh().catch(() => undefined)}
disabled={loading || historyLoading}
>
<RefreshCw size={14} className={loading || historyLoading ? 'animate-spin' : ''} />
刷新
</button>
</div>
{loading && <p className="text-sand-500">加载中…</p>}
{!loading && listError && <SubscriptionLoadError message={listError} onRetry={refresh} />}
{!loading && !listError && items.length === 0 && <p className="text-ink-50">暂无订阅。</p>}
{!listError && items.length > 0 && (
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{items.map((subscription) => (
<SubscriptionCard
key={subscription.id}
subscription={subscription}
onEdit={startEdit}
onRunNow={runSubscriptionNow}
onRemove={removeSubscription}
/>
))}
</div>
)}
<SubscriptionHistorySection
subscriptions={historyItems}
loading={historyLoading}
error={historyError}
onRefresh={refresh}
onRestore={restoreHistorySubscription}
/>
</div>
)
}
function SubscriptionLoadError({ message, onRetry }: { message: string; onRetry: () => Promise<void> }) {
return (
<div className="rounded-2xl border border-red-300/70 bg-red-50 px-4 py-3 text-sm text-red-700">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<AlertTriangle size={16} className="shrink-0" />
<span className="break-words">订阅列表加载失败:{message}</span>
</div>
<button
type="button"
className="rounded-xl border border-red-300 bg-white px-3 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-100"
onClick={() => onRetry().catch(() => undefined)}
>
重试
</button>
</div>
</div>
)
}
function apiErrorMessage(err: unknown, fallback: string): string {
const data = (err as { response?: { data?: { error?: string; message?: string }; status?: number } })?.response
if (data?.data?.error) return data.data.error
if (data?.data?.message) return data.data.message
if (data?.status) return `${fallback} (${data.status})`
if ((err as { code?: string })?.code === 'ECONNABORTED') return '请求超时,请检查服务或网络'
return fallback
}
function numericRuleValue(value: string): number {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
}
function stringRuleValue(value?: number): string {
return value && value > 0 ? String(value) : ''
}
-242
View File
@@ -1,242 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import toast from 'react-hot-toast'
import {
AlertTriangle,
CheckCircle2,
Clock3,
Loader2,
PackageCheck,
RefreshCw,
RotateCw,
Server,
} from 'lucide-react'
import { adminAPI, type SystemUpdateStatus } from '../api/admin'
type UpdateErrorPayload = {
response?: {
data?: {
error?: string
status?: SystemUpdateStatus
}
}
}
export function SystemUpdatePanel() {
const [status, setStatus] = useState<SystemUpdateStatus | null>(null)
const [loading, setLoading] = useState(true)
const [checking, setChecking] = useState(false)
const [applying, setApplying] = useState(false)
const refreshStatus = useCallback(async () => {
const next = await adminAPI.systemUpdateStatus()
setStatus(next)
return next
}, [])
useEffect(() => {
let cancelled = false
setLoading(true)
adminAPI
.systemUpdateStatus()
.then((next) => {
if (!cancelled) setStatus(next)
})
.catch(() => undefined)
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!status?.running) return undefined
const id = window.setInterval(() => {
refreshStatus().catch(() => undefined)
}, 3_000)
return () => window.clearInterval(id)
}, [refreshStatus, status?.running])
const checkUpdate = async () => {
setChecking(true)
try {
const next = await adminAPI.systemUpdateCheck()
setStatus(next)
toast.success(next.message || '检查完成')
} catch (err: unknown) {
toast.error(apiErrorMessage(err, '检查更新失败'))
} finally {
setChecking(false)
}
}
const applyUpdate = async () => {
setApplying(true)
try {
const next = await adminAPI.systemUpdateApply()
setStatus(next)
toast.success(next.message || '更新任务已启动')
} catch (err: unknown) {
const payload = err as UpdateErrorPayload
if (payload.response?.data?.status) setStatus(payload.response.data.status)
toast.error(apiErrorMessage(err, '启动更新失败'))
} finally {
setApplying(false)
}
}
const tone = updateTone(status)
const ToneIcon = tone.icon
return (
<section className="glass-panel space-y-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-gray-200 bg-white text-brand-500">
<PackageCheck size={20} />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-display text-xl font-semibold text-ink-600">Docker Compose 一键更新</h2>
<span className={`inline-flex items-center gap-1 rounded-lg border px-2 py-1 text-xs ${tone.className}`}>
<ToneIcon size={13} />
{tone.label}
</span>
</div>
<p className="mt-1 text-sm text-ink-50">
{loading ? '正在读取更新状态…' : status?.message || '尚未检查更新'}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="btn-outline"
disabled={loading}
onClick={() => refreshStatus().catch(() => toast.error('刷新状态失败'))}
title="刷新状态"
>
{loading ? <Loader2 size={16} className="animate-spin" /> : <RefreshCw size={16} />}
刷新
</button>
<button type="button" className="btn-outline" disabled={checking || status?.running} onClick={checkUpdate}>
{checking ? <Loader2 size={16} className="animate-spin" /> : <CheckCircle2 size={16} />}
检查更新
</button>
<button
type="button"
className="btn-primary"
disabled={applying || status?.running}
onClick={applyUpdate}
>
{applying || status?.running ? <Loader2 size={16} className="animate-spin" /> : <RotateCw size={16} />}
一键更新
</button>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<UpdateFact label="当前版本" value={status?.current_version || 'dev'} mono />
<UpdateFact icon={Server} label="更新方式" value={status?.update_mode === 'compose' ? 'Docker Compose' : '自定义命令'} />
<UpdateFact label="Compose 目录" value={status?.compose_dir || '自动识别'} mono />
<UpdateFact label="应用镜像" value={status?.image || '-'} mono />
<UpdateFact label="当前容器" value={status?.container_name || status?.container_id || '-'} mono />
</div>
<div className="grid gap-3 md:grid-cols-2">
<DigestRow label="Compose 文件" digest={status?.compose_file} />
<DigestRow label="Docker 连接" digest={status?.docker_available ? '可连接' : '未连接或未检查'} />
<DigestRow label="本地摘要" digest={status?.local_digest} />
<DigestRow label="远端摘要" digest={status?.remote_digest} />
</div>
{status?.task_id && (
<div className="flex items-center justify-between gap-3 rounded-xl border border-gray-200 bg-gray-50/70 px-3 py-2 text-sm">
<span className="min-w-0 truncate text-ink-100">
后台任务:<span className="font-mono">{status.task_id}</span>
</span>
<Link className="btn-ghost shrink-0 px-3 py-2" to="/tasks">
查看任务
</Link>
</div>
)}
{status?.details && (
<pre className="max-h-56 overflow-auto rounded-xl border border-gray-200 bg-gray-50 p-3 font-mono text-xs leading-relaxed text-ink-100">
{status.details}
</pre>
)}
</section>
)
}
function UpdateFact({
icon: Icon,
label,
value,
mono = false,
}: {
icon?: typeof Server
label: string
value: string
mono?: boolean
}) {
return (
<div className="rounded-xl border border-gray-200 bg-gray-50/60 px-3 py-2">
<div className="flex items-center gap-1.5 text-xs text-sand-500">
{Icon && <Icon size={13} />}
{label}
</div>
<div className={`mt-1 truncate text-sm text-ink-600 ${mono ? 'font-mono' : ''}`} title={value}>
{value}
</div>
</div>
)
}
function DigestRow({ label, digest }: { label: string; digest?: string }) {
return (
<div className="flex min-w-0 items-center justify-between gap-3 rounded-xl border border-gray-200 bg-gray-50/60 px-3 py-2">
<span className="shrink-0 text-xs text-sand-500">{label}</span>
<span className="min-w-0 truncate font-mono text-xs text-ink-100" title={digest || ''}>
{shortDigest(digest)}
</span>
</div>
)
}
function updateTone(status: SystemUpdateStatus | null): {
label: string
className: string
icon: typeof Clock3
} {
if (!status) {
return { label: '读取中', className: 'border-gray-200 text-sand-500', icon: Clock3 }
}
if (status.running) {
return { label: '更新中', className: 'border-yellow-400/50 text-yellow-600', icon: Loader2 }
}
if (!status.can_apply && status.message && status.message !== '尚未检查更新') {
return { label: '不可更新', className: 'border-red-400/50 text-red-500', icon: AlertTriangle }
}
if (status.update_available === true) {
return { label: '有新版', className: 'border-emerald-400/50 text-emerald-600', icon: CheckCircle2 }
}
if (status.update_available === false) {
return { label: '已最新', className: 'border-gray-300 text-ink-100', icon: CheckCircle2 }
}
return { label: '可执行', className: 'border-brand-300/60 text-brand-500', icon: CheckCircle2 }
}
function shortDigest(digest?: string): string {
if (!digest) return '-'
return digest.length > 24 ? `${digest.slice(0, 18)}…${digest.slice(-8)}` : digest
}
function apiErrorMessage(err: unknown, fallback: string): string {
return (err as UpdateErrorPayload).response?.data?.error || fallback
}
-171
View File
@@ -1,171 +0,0 @@
import { useState } from 'react'
import toast from 'react-hot-toast'
import { X } from 'lucide-react'
import { toolsAPI } from '../api/tools'
import { libraryAPI } from '../api/library'
import type { BackgroundTask, TaskItem } from '../api/tasks'
const kindLabels: Record<string, string> = {
organize: '整理 / 重命名',
scan: '入库扫描',
scrape: '刮削',
}
// ItemRetryDialog lets the operator manually re-handle a single failed item.
// It pre-fills the values captured from the failed task so retrying is
// reproducible, while still allowing overrides before re-running.
export function ItemRetryDialog({
task,
item,
onClose,
}: {
task: BackgroundTask
item: TaskItem
onClose: () => void
}) {
const [sourcePath, setSourcePath] = useState(item.source || task.source_path || '')
const [destPath, setDestPath] = useState(item.dest_path || task.dest_path || '')
const [transferMode, setTransferMode] = useState('hardlink')
const [scanAfter, setScanAfter] = useState(true)
const [scrapeAfter, setScrapeAfter] = useState(true)
const [busy, setBusy] = useState(false)
const run = async () => {
setBusy(true)
try {
if (item.kind === 'organize') {
if (!sourcePath.trim()) {
toast.error('整理需要来源路径')
return
}
await toolsAPI.organizeDirectory({
source_path: sourcePath.trim(),
dest_path: destPath.trim() || undefined,
transfer_mode: transferMode,
scan_after: scanAfter,
scrape_after: scanAfter && scrapeAfter,
})
toast.success(`已重新整理:${item.name}`)
} else if (item.kind === 'scan' && item.library_id) {
await libraryAPI.scan(item.library_id)
toast.success(`已重新入库:${item.name}`)
} else if (item.kind === 'scrape' && item.library_id) {
await libraryAPI.scrape(item.library_id, {
episode_artwork: true,
refresh_matched: true,
})
toast.success(`已重新刮削:${item.name}`)
} else {
toast.error(`无法处理该类型条目(${item.kind})`)
return
}
onClose()
} catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '处理失败')
} finally {
setBusy(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
<div
className="glass-card-elevated w-full max-w-lg space-y-4 rounded-2xl bg-white p-5 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start justify-between">
<div>
<h3 className="font-display text-lg font-semibold text-ink-600">手动处理失败条目</h3>
<p className="text-xs text-sand-500">
{item.name} · {kindLabels[item.kind] ?? item.kind}
</p>
</div>
<button
type="button"
className="rounded-lg p-1 text-sand-500 hover:bg-gray-100"
onClick={onClose}
aria-label="关闭"
>
<X size={18} />
</button>
</div>
{item.error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-2 text-xs text-red-600">{item.error}</div>
)}
<div className="space-y-3">
{item.kind === 'organize' && (
<>
<label className="block text-sm">
<span className="mb-1 block text-xs font-medium text-sand-500">来源路径</span>
<input
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-ink-600 outline-none focus:border-primary-400"
value={sourcePath}
onChange={(e) => setSourcePath(e.target.value)}
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-xs font-medium text-sand-500">目标路径(留空则用默认)</span>
<input
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-ink-600 outline-none focus:border-primary-400"
value={destPath}
onChange={(e) => setDestPath(e.target.value)}
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-xs font-medium text-sand-500">转移方式</span>
<select
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-ink-600 outline-none focus:border-primary-400"
value={transferMode}
onChange={(e) => setTransferMode(e.target.value)}
>
<option value="hardlink">硬链接</option>
<option value="move">移动</option>
<option value="copy">复制</option>
</select>
</label>
<div className="flex items-center gap-4">
<label className="flex items-center gap-1.5 text-sm text-ink-600">
<input type="checkbox" checked={scanAfter} onChange={(e) => setScanAfter(e.target.checked)} />
入库
</label>
<label className="flex items-center gap-1.5 text-sm text-ink-600">
<input
type="checkbox"
checked={scrapeAfter}
disabled={!scanAfter}
onChange={(e) => setScrapeAfter(e.target.checked)}
/>
刮削
</label>
</div>
</>
)}
{item.kind === 'scan' && (
<p className="text-sm text-sand-600">
将重新扫描媒体库「{item.name}」以入库。完成后会自动触发刮削(如已启用)。
</p>
)}
{item.kind === 'scrape' && (
<p className="text-sm text-sand-600">
将重新刮削媒体库「{item.name}」。会重试上次未匹配的条目并刷新元数据。
</p>
)}
</div>
<div className="flex justify-end gap-2 pt-1">
<button type="button" className="btn-ghost" onClick={onClose} disabled={busy}>
取消
</button>
<button type="button" className="btn-primary" onClick={() => void run()} disabled={busy}>
{busy ? '处理中…' : '开始处理'}
</button>
</div>
</div>
</div>
)
}
-85
View File
@@ -1,85 +0,0 @@
import type { ActiveTranscode } from '../api/tasks'
import type { QBitTorrent } from '../types'
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(2)} ${u[i]}`
}
export function TranscodeTaskTable({ transcodes }: { transcodes: ActiveTranscode[] }) {
if (transcodes.length === 0) return <p className="text-sand-500">暂无运行中转码。</p>
return (
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">媒体 ID</th>
<th>编码器</th>
<th>开始时间</th>
<th>就绪</th>
</tr>
</thead>
<tbody>
{transcodes.map((t) => (
<tr key={t.media_id} className="border-t border-gray-200">
<td className="py-2 font-mono text-xs text-ink-600">{t.media_id}</td>
<td className="text-ink-100">{t.encoder || 'libx264'}</td>
<td className="text-ink-100">{new Date(t.started_at).toLocaleTimeString()}</td>
<td>
{t.playlist_ok ? (
<span className="rounded-lg border border-emerald-400/40 px-1.5 py-0.5 text-xs text-emerald-400">
ready
</span>
) : (
<span className="rounded-lg border border-yellow-400/40 px-1.5 py-0.5 text-xs text-yellow-400">
starting
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)
}
export function TorrentTaskTable({ torrents }: { torrents: QBitTorrent[] }) {
if (torrents.length === 0) return <p className="text-sand-500">暂无运行中下载。</p>
return (
<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>
</tr>
</thead>
<tbody>
{torrents.map((t) => (
<tr key={t.hash} className="border-t border-gray-200 align-top">
<td className="max-w-md truncate py-2 text-ink-600" title={t.name}>
{t.name}
</td>
<td className="text-ink-100">{t.state}</td>
<td className="text-ink-100">
<div className="flex items-center gap-2">
<div className="h-1 w-24 overflow-hidden rounded-lg bg-gray-200">
<div className="h-full bg-primary-400" style={{ width: `${Math.round(t.progress * 100)}%` }} />
</div>
{(t.progress * 100).toFixed(1)}%
</div>
</td>
<td className="text-ink-100">{fmtBytes(t.size)}</td>
</tr>
))}
</tbody>
</table>
)
}
-436
View File
@@ -1,436 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Activity, Copy, RotateCw, SlidersHorizontal } from 'lucide-react'
import { toolsAPI } from '../api/tools'
import { libraryAPI } from '../api/library'
import { tasksAPI, type BackgroundTask, type TaskItem, type TasksSnapshot } from '../api/tasks'
import { useWebSocket } from '../hooks/useWebSocket'
import { confirmAction } from '../components/confirmAction'
import { TorrentTaskTable, TranscodeTaskTable } from './TaskRuntimeTables'
import { ItemRetryDialog } from './TaskItemRetryDialog'
const metricLabels: Record<string, string> = {
organized: '新增',
replaced: '替换',
reclassified: '纠偏',
skipped: '跳过',
errors: '错误',
scans: '扫描库',
scan_visited: '访问',
scan_added: '入库',
scan_updated: '更新',
scan_removed: '移除',
scan_errors: '扫描错误',
scrapes: '刮削库',
scrape_matched: '匹配',
scrape_processed: '刮削处理',
scrape_skipped: '刮削跳过',
scrape_errors: '刮削错误',
skip_already_organized: '已在目标',
skip_duplicate_in_library: '已入库去重',
skip_target_file_exists: '目标已存在',
skip_sample_trailer_clip: '样片过滤',
skip_duplicate_exists: '重复跳过',
skip_target_exists: '目标已存在',
visited: '访问',
added: '入库',
updated: '更新',
probed: '探测',
local_metadata: '本地元数据',
removed: '移除',
matched: '匹配',
processed: '处理',
queued: '排队',
}
function formatMetrics(metrics?: Record<string, number>): string {
if (!metrics) return ''
return Object.entries(metrics)
.filter(([, value]) => Number.isFinite(value) && value !== 0)
.map(([key, value]) => `${metricLabels[key] ?? key} ${value}`)
.join(' · ')
}
function hasTaskIssues(task: BackgroundTask): boolean {
return Boolean(task.metrics?.errors || task.metrics?.scan_errors || task.metrics?.scrape_errors)
}
const itemKindLabels: Record<string, string> = {
organize: '整理/重命名',
scan: '入库',
scrape: '刮削',
}
const itemStatusLabels: Record<string, string> = {
pending: '待进行',
running: '进行中',
succeeded: '成功',
failed: '失败',
}
function statusBadge(task: BackgroundTask) {
if (task.status === 'failed') {
return <span className="rounded-lg border border-red-400/40 px-1.5 py-0.5 text-xs text-red-500">failed</span>
}
if (hasTaskIssues(task)) {
return <span className="rounded-lg border border-orange-400/40 px-1.5 py-0.5 text-xs text-orange-500">issues</span>
}
if (task.status === 'completed') {
return <span className="rounded-lg border border-emerald-400/40 px-1.5 py-0.5 text-xs text-emerald-500">done</span>
}
return <span className="rounded-lg border border-yellow-400/40 px-1.5 py-0.5 text-xs text-yellow-500">running</span>
}
function itemStatusBadge(item: TaskItem) {
switch (item.status) {
case 'failed':
return <span className="rounded-lg border border-red-400/40 px-1.5 py-0.5 text-xs text-red-500">失败</span>
case 'running':
return <span className="rounded-lg border border-yellow-400/40 px-1.5 py-0.5 text-xs text-yellow-500">进行中</span>
case 'succeeded':
return <span className="rounded-lg border border-emerald-400/40 px-1.5 py-0.5 text-xs text-emerald-500">成功</span>
default:
return <span className="rounded-lg border border-gray-300 px-1.5 py-0.5 text-xs text-sand-500">待进行</span>
}
}
function taskCopyText(task: BackgroundTask): string {
const lines = [
`任务: ${task.name}`,
`状态: ${task.status}${hasTaskIssues(task) ? ' (issues)' : ''}`,
`阶段: ${task.stage || '-'}`,
`来源: ${task.source_path || '-'}`,
`目标: ${task.dest_path || '-'}`,
`消息: ${task.error || task.message || '-'}`,
]
const metrics = formatMetrics(task.metrics)
if (metrics) lines.push(`指标: ${metrics}`)
if (task.details?.length) {
lines.push('详情:')
lines.push(...task.details)
}
if (task.items?.length) {
lines.push('条目:')
lines.push(
...task.items.map(
(item) =>
`[${itemKindLabels[item.kind] ?? item.kind}] ${item.name} → ${itemStatusLabels[item.status] ?? item.status}${
item.error ? ` (${item.error})` : ''
}`,
),
)
}
return lines.join('\n')
}
async function copyTask(task: BackgroundTask) {
try {
await navigator.clipboard.writeText(taskCopyText(task))
toast.success('任务详情已复制')
} catch {
toast.error('复制失败,请手动选中详情文本')
}
}
// Renders one task's per-item rows, each with status + (for failed items)
// retry / manual-handle actions.
function TaskItemRows({
task,
onRetry,
onManual,
}: {
task: BackgroundTask
onRetry: (item: TaskItem) => void
onManual: (item: TaskItem) => void
}) {
const items = task.items ?? []
if (items.length === 0) {
// Fall back to the compact aggregate view for legacy tasks without items.
return (
<div className="flex items-center justify-between gap-3 py-1">
<div className="min-w-0">
<div className="truncate font-medium text-ink-600" title={task.source_path || task.dest_path}>
{task.name}
</div>
<div className="truncate font-mono text-xs text-sand-500">
{task.source_path || task.dest_path || task.message || '-'}
</div>
</div>
<div className="flex items-center gap-2">
{statusBadge(task)}
{hasTaskIssues(task) && (
<button
type="button"
className="rounded-lg border border-gray-200 bg-white p-1 text-sand-500 hover:border-primary-400/40 hover:text-brand-500"
title="复制任务详情"
onClick={() => void copyTask(task)}
>
<Copy size={14} />
</button>
)}
</div>
</div>
)
}
return (
<div className="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-1">名称</th>
<th>类型</th>
<th>状态</th>
<th>路径</th>
<th className="text-right">操作</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-t border-gray-100 align-top">
<td className="max-w-xs py-1.5">
<div className="truncate font-medium text-ink-600" title={item.name}>
{item.name}
</div>
{item.error && (
<div className="truncate font-mono text-[11px] text-red-500" title={item.error}>
{item.error}
</div>
)}
</td>
<td className="py-1.5">
<span className="rounded-md bg-gray-100 px-1.5 py-0.5 text-xs text-sand-600">
{itemKindLabels[item.kind] ?? item.kind}
</span>
</td>
<td className="py-1.5">{itemStatusBadge(item)}</td>
<td className="max-w-sm py-1.5">
<div className="truncate font-mono text-xs text-sand-500" title={item.source || item.dest_path}>
{item.source || item.dest_path || '-'}
</div>
</td>
<td className="py-1.5 text-right whitespace-nowrap">
{item.status === 'failed' && (
<div className="inline-flex items-center gap-1.5">
<button
type="button"
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-2 py-1 text-xs text-ink-600 hover:border-primary-400/40 hover:text-brand-500"
title="一键重试该条目"
onClick={() => onRetry(item)}
>
<RotateCw size={12} />
重试
</button>
<button
type="button"
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-2 py-1 text-xs text-ink-600 hover:border-primary-400/40 hover:text-brand-500"
title="打开手动处理表单"
onClick={() => onManual(item)}
>
<SlidersHorizontal size={12} />
手动处理
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function TaskSection({
tasks,
empty,
onRetry,
onManual,
}: {
tasks: BackgroundTask[]
empty: string
onRetry: (task: BackgroundTask, item: TaskItem) => void
onManual: (task: BackgroundTask, item: TaskItem) => void
}) {
const withItems = tasks.filter((task) => (task.items ?? []).length > 0)
const withoutItems = tasks.filter((task) => (task.items ?? []).length === 0)
if (tasks.length === 0) return <p className="text-sand-500">{empty}</p>
return (
<div className="space-y-4">
{withItems.length > 0 && (
<div className="space-y-3">
{withItems.map((task) => (
<div key={task.id} className="rounded-xl border border-gray-200 bg-gray-50/60 p-3">
<div className="mb-1 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium text-ink-600">{task.name}</span>
{statusBadge(task)}
</div>
<button
type="button"
className="rounded-lg border border-gray-200 bg-white p-1 text-sand-500 hover:border-primary-400/40 hover:text-brand-500"
title="复制任务详情"
onClick={() => void copyTask(task)}
>
<Copy size={13} />
</button>
</div>
<TaskItemRows task={task} onRetry={(item) => onRetry(task, item)} onManual={(item) => onManual(task, item)} />
{formatMetrics(task.metrics) && (
<div className="mt-2 select-text text-xs text-sand-500">{formatMetrics(task.metrics)}</div>
)}
</div>
))}
</div>
)}
{withoutItems.length > 0 && (
<div className="space-y-2">
{withoutItems.map((task) => (
<div key={task.id} className="rounded-xl border border-gray-200 bg-gray-50/60 p-3">
<TaskItemRows
task={task}
onRetry={(item) => onRetry(task, item)}
onManual={(item) => onManual(task, item)}
/>
</div>
))}
</div>
)}
</div>
)
}
// TasksPage shows everything the backend is doing right now: ffmpeg
// transcodes + qBittorrent downloads + item-level organize/ingest/scrape
// progress. Refreshes every 3 s and reconciles against live WS "task" events.
export function TasksPage() {
const [snap, setSnap] = useState<TasksSnapshot | null>(null)
const [manualTarget, setManualTarget] = useState<{ task: BackgroundTask; item: TaskItem } | null>(null)
const mergeEvent = useCallback((payload: unknown) => {
const eventTask = payload as BackgroundTask
if (!eventTask || typeof eventTask.id !== 'string') return
setSnap((prev) => {
if (!prev) return prev
const merge = (list: BackgroundTask[]) =>
list.map((t) => (t.id === eventTask.id ? { ...eventTask } : t))
return {
...prev,
background_tasks: {
active: merge(prev.background_tasks?.active ?? []),
recent: merge(prev.background_tasks?.recent ?? []),
},
}
})
}, [])
useWebSocket((topic, payload) => {
if (topic === 'task') mergeEvent(payload)
})
useEffect(() => {
let cancelled = false
const tick = () =>
tasksAPI.snapshot().then((s) => {
if (!cancelled) setSnap(s)
})
void tick()
const id = window.setInterval(tick, 3_000)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [])
if (!snap) return <p className="text-sand-500">加载中…</p>
const torrents = snap.torrents ?? []
const background = snap.background_tasks ?? { active: [], recent: [] }
const handleRetry = async (task: BackgroundTask, item: TaskItem) => {
const ok = await confirmAction({
title: '重试失败条目',
message: `确定要重新处理「${item.name}」吗?\n类型:${itemKindLabels[item.kind] ?? item.kind}`,
confirmText: '重试',
})
if (!ok) return
try {
if (item.kind === 'scan' && item.library_id) {
await libraryAPI.scan(item.library_id)
toast.success(`已重新入库:${item.name}`)
} else if (item.kind === 'scrape' && item.library_id) {
await libraryAPI.scrape(item.library_id)
toast.success(`已重新刮削:${item.name}`)
} else if (item.kind === 'organize' && item.source) {
const dest = task.dest_path || item.dest_path || undefined
await toolsAPI.organizeDirectory({
source_path: item.source,
dest_path: dest,
scan_after: true,
scrape_after: true,
})
toast.success(`已重新整理:${item.name}`)
} else {
toast.error(`无法重试:缺少必要信息(${item.kind})`)
return
}
} catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '重试失败')
}
}
const handleManual = (task: BackgroundTask, item: TaskItem) => {
setManualTarget({ task, item })
}
return (
<div className="space-y-8">
<header className="flex items-center gap-3">
<Activity className="h-6 w-6 text-brand-500" />
<h1 className="font-display text-3xl font-bold text-ink-600">实时任务</h1>
</header>
<section className="glass-panel">
<h2 className="mb-3 font-display text-lg font-semibold text-ink-600">整理 / 重命名 / 入库 / 刮削任务</h2>
<div className="space-y-5">
<div>
<h3 className="mb-2 text-sm font-semibold text-ink-500">运行中</h3>
<TaskSection
tasks={background.active}
empty="暂无运行中的整理、重命名、入库或刮削任务。"
onRetry={(task, item) => void handleRetry(task, item)}
onManual={handleManual}
/>
</div>
<div>
<h3 className="mb-2 text-sm font-semibold text-ink-500">最近完成</h3>
<TaskSection
tasks={background.recent.slice(0, 10)}
empty="暂无最近完成的后台任务。"
onRetry={(task, item) => void handleRetry(task, item)}
onManual={handleManual}
/>
</div>
</div>
</section>
<section className="glass-panel">
<h2 className="mb-3 font-display text-lg font-semibold text-ink-600">转码任务</h2>
<TranscodeTaskTable transcodes={snap.transcodes} />
</section>
<section className="glass-panel">
<h2 className="mb-3 font-display text-lg font-semibold text-ink-600">下载任务</h2>
<TorrentTaskTable torrents={torrents} />
</section>
{manualTarget && (
<ItemRetryDialog
task={manualTarget.task}
item={manualTarget.item}
onClose={() => setManualTarget(null)}
/>
)}
</div>
)
}
-94
View File
@@ -1,94 +0,0 @@
import type { DiscoverItem } from '../api/discover'
import { buildSiteSearchFeedURL, buildSubscriptionAliases } from '../api/subscriptions'
import { buildSubscribeKeyword } from './discoverPageModel'
import { defaultSubscriptionExcludeWords } from './subscriptionFormModel'
export type DiscoverSubscriptionForm = {
keyword: string
search_mode: string
imdb_id: string
media_type: string
resolution: string
quality: string
effects: string
release_groups: string
exclude_words: string
wash_enabled: boolean
wash_priority: string
save_path: string
media_category: string
priority: number
run_now: boolean
}
export function discoverSubscriptionKeyword(item: DiscoverItem): string {
return item.subscribe_keyword || buildSubscribeKeyword(item)
}
export function initialDiscoverSubscriptionForm(item: DiscoverItem): DiscoverSubscriptionForm {
return {
keyword: discoverSubscriptionKeyword(item),
search_mode: 'keyword',
imdb_id: '',
media_type: item.media_type || '',
resolution: 'best',
quality: '',
effects: '',
release_groups: '',
exclude_words: defaultSubscriptionExcludeWords,
wash_enabled: false,
wash_priority: 'balanced',
save_path: '',
media_category: '',
priority: 50,
run_now: true,
}
}
export function discoverItemMetaText(item: DiscoverItem): string {
return [
item.media_type,
item.year && item.year > 0 ? item.year : '',
item.rating ? `★ ${item.rating.toFixed(1)}` : '',
]
.filter(Boolean)
.join(' · ')
}
export function buildDiscoverSubscriptionInput(
item: DiscoverItem,
form: DiscoverSubscriptionForm,
source: string,
) {
const finalKeyword = form.keyword.trim() || discoverSubscriptionKeyword(item)
return {
name: `${item.title} 自动订阅`,
feed_url: buildSiteSearchFeedURL(finalKeyword, source, buildSubscriptionAliases(item)),
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,
total_episodes: item.total_episodes || 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,
}
}
export function apiErrorMessage(err: unknown, fallback: string): string {
return (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? fallback
}
-169
View File
@@ -1,169 +0,0 @@
import type { DiscoverItem, DiscoverSection } from '../api/discover'
export const defaultSections = [
'tmdb_trending_day',
'tmdb_latest_movie',
'tmdb_latest_tv',
'douban_hot_movie',
'douban_hot_tv',
'bangumi_calendar',
]
export const discoverStorageKey = 'mediastation.discover.sections'
export const discoverRowsStorageKey = 'mediastation.discover.rows'
const discoverStorageVersion = 3
const discoverRowsStorageVersion = 1
const discoverRowsCacheMaxAgeMs = 6 * 60 * 60 * 1000
const legacyDefaultAdditions = ['tmdb_latest_movie', 'tmdb_latest_tv']
interface CachedDiscoverRow {
page: number
has_next: boolean
items: DiscoverItem[]
}
interface CachedDiscoverRowsPayload {
version: number
saved_at: number
rows: Record<string, CachedDiscoverRow>
}
export const defaultSectionDefs: DiscoverSection[] = [
{ key: 'tmdb_trending_day', label: 'TMDb 今日趋势', provider: 'tmdb' },
{ key: 'tmdb_latest_movie', label: 'TMDb 最新电影', provider: 'tmdb' },
{ key: 'tmdb_latest_tv', 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)
const allowed = new Set(sections.map((section) => section.key))
if (Array.isArray(parsed)) {
return orderSectionKeys(addLegacyDefaults(parsed, allowed), sections)
}
if (!parsed || !Array.isArray(parsed.selected)) return []
const selected = sanitizeSectionKeys(parsed.selected, allowed)
if (parsed.version === discoverStorageVersion) {
return orderSectionKeys(selected, sections)
}
return orderSectionKeys(addLegacyDefaults(selected, allowed), sections)
} catch {
return []
}
}
export function serializeSavedSections(selected: string[]): string {
return JSON.stringify({ version: discoverStorageVersion, selected })
}
export function readCachedDiscoverRows(selected: string[]): {
rows: Record<string, DiscoverItem[]>
rowCanNext: Record<string, boolean>
} {
try {
const raw = window.localStorage.getItem(discoverRowsStorageKey)
if (!raw) return { rows: {}, rowCanNext: {} }
const parsed = JSON.parse(raw) as Partial<CachedDiscoverRowsPayload>
if (
parsed.version !== discoverRowsStorageVersion ||
typeof parsed.saved_at !== 'number' ||
Date.now() - parsed.saved_at > discoverRowsCacheMaxAgeMs ||
!parsed.rows
) {
return { rows: {}, rowCanNext: {} }
}
const allowed = new Set(selected)
const rows: Record<string, DiscoverItem[]> = {}
const rowCanNext: Record<string, boolean> = {}
for (const [key, row] of Object.entries(parsed.rows)) {
if (!allowed.has(key) || row.page !== 1 || !Array.isArray(row.items) || row.items.length === 0) {
continue
}
rows[key] = row.items
rowCanNext[key] = Boolean(row.has_next)
}
return { rows, rowCanNext }
} catch {
return { rows: {}, rowCanNext: {} }
}
}
export function writeCachedDiscoverRow(
key: string,
page: number,
items: DiscoverItem[],
hasNext: boolean,
) {
if (page !== 1 || items.length === 0) return
try {
const current = readRawDiscoverRowsCache()
current.rows[key] = {
page,
has_next: hasNext,
items,
}
current.saved_at = Date.now()
window.localStorage.setItem(discoverRowsStorageKey, JSON.stringify(current))
} catch {
// Best-effort UI cache only; failing to persist should never break Discover.
}
}
function readRawDiscoverRowsCache(): CachedDiscoverRowsPayload {
try {
const raw = window.localStorage.getItem(discoverRowsStorageKey)
if (!raw) return emptyDiscoverRowsCache()
const parsed = JSON.parse(raw) as Partial<CachedDiscoverRowsPayload>
if (parsed.version !== discoverRowsStorageVersion || !parsed.rows) {
return emptyDiscoverRowsCache()
}
return {
version: discoverRowsStorageVersion,
saved_at: typeof parsed.saved_at === 'number' ? parsed.saved_at : Date.now(),
rows: parsed.rows,
}
} catch {
return emptyDiscoverRowsCache()
}
}
function emptyDiscoverRowsCache(): CachedDiscoverRowsPayload {
return {
version: discoverRowsStorageVersion,
saved_at: Date.now(),
rows: {},
}
}
function sanitizeSectionKeys(keys: unknown[], allowed: Set<string>): string[] {
return keys.filter((key): key is string => typeof key === 'string' && allowed.has(key))
}
function addLegacyDefaults(keys: unknown[], allowed: Set<string>): string[] {
const out = sanitizeSectionKeys(keys, allowed)
for (const key of legacyDefaultAdditions) {
if (allowed.has(key) && !out.includes(key)) {
out.push(key)
}
}
return out
}
function orderSectionKeys(keys: string[], sections: DiscoverSection[]): string[] {
const selected = new Set(keys)
return sections.map((section) => section.key).filter((key) => selected.has(key))
}
export function buildSubscribeKeyword(item: DiscoverItem): string {
return [item.title, item.year && item.year > 0 ? item.year : ''].filter(Boolean).join(' ')
}
-7
View File
@@ -1,7 +0,0 @@
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
}
-63
View File
@@ -1,63 +0,0 @@
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,
}
}
+21
View File
@@ -24,3 +24,24 @@ export function mediaTime(media: Media): number {
if (media.year > 0) return Date.UTC(media.year, 11, 31)
return Date.parse(media.updated_at || media.created_at || '') || 0
}
export function libraryArtworkItems(cards: SeriesCard[] = []): Array<{ src: string; version?: string }> {
return [...cards]
.sort((a, b) => artworkScore(b.rep) - artworkScore(a.rep) || mediaTime(b.rep) - mediaTime(a.rep))
.map((card) => ({
src: card.rep.poster_url || card.rep.backdrop_url || '',
version: card.rep.updated_at,
}))
.filter((item) => Boolean(item.src))
.slice(0, 4)
}
export function getLibraryArtworks(
library: Library,
cards: SeriesCard[] = [],
): Array<{ src: string; version?: string }> {
if (library.cover_url) {
return [{ src: library.cover_url, version: library.updated_at }]
}
return libraryArtworkItems(cards)
}
+3 -7
View File
@@ -1,8 +1,4 @@
import { cloudLibraryLabel, cloudLibraryProvider, TYPE_LABEL } from './storageConfigModel'
export function libraryDisplayPath(path: string): string {
const provider = cloudLibraryProvider(path)
if (!provider) return path
const providerLabel = TYPE_LABEL[provider] || provider
return `${providerLabel} / ${cloudLibraryLabel(path)}`
}
// 网盘标识已随「存储配置」功能移除;直接展示媒体库路径。
return path
}
-15
View File
@@ -1,4 +1,3 @@
import type { CloudScanStatus } from '../api/storage_config'
import type { Media } from '../types'
export function formatDuration(seconds: number): string {
@@ -11,20 +10,6 @@ export function formatDuration(seconds: number): string {
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 ''
-87
View File
@@ -1,87 +0,0 @@
import type { NotifyChannel } from '../types'
export const TYPE_LABELS: Record<NotifyChannel['type'], string> = {
telegram: 'Telegram',
wechat: '企业微信',
bark: 'Bark',
webhook: 'Webhook',
email: 'Email',
}
export const EVENT_OPTIONS = [
{ value: 'subscription_hit', label: '订阅命中新资源' },
{ value: 'download_complete', label: '下载任务完成' },
{ value: 'library_ingest', label: '入库完成' },
{ value: 'scrape_failed', label: '刮削失败告警' },
{ value: 'system_alert', label: '系统异常通知' },
]
export const EVENT_ALL = '__all__'
export const EVENT_NONE = '__none__'
export type EventMode = 'all' | 'custom' | 'none'
export 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' },
}
export 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'
}
export 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('、')}`
}
export 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 ''
}
}
export 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
}
+8
View File
@@ -0,0 +1,8 @@
import type { SettingGroup } from './settingsGroupTypes'
export const apiConfigsSettingsGroup: SettingGroup = {
key: 'api-configs',
label: '外部 API',
description: 'TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban / Adult 密钥与源管理。',
items: [],
}
-99
View File
@@ -1,99 +0,0 @@
import type { SettingGroup } from './settingsGroupTypes'
export const cloudUploadSettingsGroup: SettingGroup = {
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 原生(待接分片上传)' },
],
},
{
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',
},
],
}
-41
View File
@@ -5,13 +5,6 @@ export const generalSettingsGroup: SettingGroup = {
label: '常规',
description: '语言 / 转码引擎参数(API 密钥请在管理后台 → 外部API 配置)',
items: [
{
key: 'ui.hide_community_links_for_users',
label: '对普通用户隐藏社区页脚链接',
type: 'toggle',
hint: '开启后,登录页及普通用户登录后的页脚不显示 TG 群组、开源仓库和作者主页;管理员仍可见。',
defaultValue: 'false',
},
{
key: 'tmdb.language',
label: 'TMDb 元数据语言',
@@ -23,13 +16,6 @@ export const generalSettingsGroup: SettingGroup = {
{ 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: '客户端直连解码(释放宿主机资源)',
@@ -113,30 +99,3 @@ export const generalSettingsGroup: SettingGroup = {
},
],
}
export const licenseSettingsGroup: SettingGroup = {
key: 'license',
label: '授权服务',
description: '连接私有 MediaStationGo 授权服务;开源版默认最多 20 个用户,激活后按授权策略提升额度。',
items: [
{
key: 'license.server_url',
label: 'License Server 地址',
type: 'text',
placeholder: 'https://mgosever.3jzs.com',
},
{
key: 'license.public_key',
label: 'Ed25519 验签公钥',
type: 'text',
hint: '优先使用。客户端只保存公钥,无法伪造授权服务响应;自建 MgoSever 时填写私钥对应的公钥。',
placeholder: 'MCowBQYDK2VwAyEA...',
},
{
key: 'license.hmac_secret',
label: 'HMAC 签名密钥(旧版兼容)',
type: 'text',
hint: '仅兼容旧版授权服务。新版本应使用 Ed25519 公钥,避免把共享密钥编译进客户端。',
},
],
}
+8
View File
@@ -0,0 +1,8 @@
import type { SettingGroup } from './settingsGroupTypes'
export const librarySettingsGroup: SettingGroup = {
key: 'library',
label: '媒体库',
description: '配置首页海报轮播等媒体库展示与偏好',
items: [],
}
@@ -1,16 +0,0 @@
import type { SettingGroup } from './settingsGroupTypes'
export const subscriptionSettingsGroup: SettingGroup = {
key: 'subscriptions',
label: '订阅任务',
description: '控制 RSS / 站点搜索订阅的唯一后台轮询频率。手动“立即执行”不受这里限制。',
items: [
{
key: 'subscription.interval_seconds',
label: '订阅自动同步间隔秒数',
type: 'number',
hint: '默认 10800 秒,最小 10800 秒。后台自动刷新固定保守节奏,避免馒头等站点 API 超限。',
defaultValue: '10800',
},
],
}
@@ -1,32 +0,0 @@
import type { SettingGroup } from './settingsGroupTypes'
export const systemUpdateSettingsGroup: SettingGroup = {
key: 'system-update',
label: '系统更新',
description: 'Docker Compose 部署可在这里检查并拉取最新版镜像。',
items: [
{
key: 'system.update.image',
label: '应用镜像',
type: 'text',
defaultValue: 'ghcr.io/shukebta/mediastation-go:latest',
placeholder: 'ghcr.io/shukebta/mediastation-go:latest',
hint: '用于检查远端摘要;保持 latest 即可跟随主分支镜像。',
},
{
key: 'system.update.compose_dir',
label: 'Docker Compose 安装目录',
type: 'text',
placeholder: '/vol1/1000/docker/mediastation-go',
hint: '留空时自动查找 docker-compose.yml / compose.yml 所在目录。',
},
{
key: 'system.update.command',
label: '自定义更新命令',
type: 'textarea',
placeholder:
'cd {{compose_dir}} && {{compose_command}} pull && {{compose_command}} up -d && docker image prune -f && docker restart {{container}}',
hint: '留空时使用 Docker Compose 默认命令。支持 {{image}}、{{compose_dir}}、{{compose_file}}、{{compose_command}}、{{container}}、{{container_id}}、{{container_name}}。',
},
],
}
+5 -8
View File
@@ -1,20 +1,17 @@
import { adultSettingsGroup } from './settingsGroupAccess'
import { cloudUploadSettingsGroup } from './settingsGroupCloud'
import { generalSettingsGroup, licenseSettingsGroup } from './settingsGroupGeneral'
import { apiConfigsSettingsGroup } from './settingsGroupAPIConfigs'
import { generalSettingsGroup } from './settingsGroupGeneral'
import { librarySettingsGroup } from './settingsGroupLibrary'
import { recognitionWordsSettingsGroup } from './settingsGroupRecognitionWords'
import { subscriptionSettingsGroup } from './settingsGroupSubscriptions'
import { systemUpdateSettingsGroup } from './settingsGroupSystemUpdate'
import type { SettingGroup } from './settingsGroupTypes'
export type { SettingGroup } from './settingsGroupTypes'
export const GROUPS: SettingGroup[] = [
generalSettingsGroup,
licenseSettingsGroup,
systemUpdateSettingsGroup,
librarySettingsGroup,
apiConfigsSettingsGroup,
recognitionWordsSettingsGroup,
subscriptionSettingsGroup,
cloudUploadSettingsGroup,
adultSettingsGroup,
]

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