mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
UI 全面重设计: 明亮画廊风格 + 剧集合并显示
## 色彩体系重构 - tailwind.config.js: sand/ink 全新色阶 + shadow 阴影预设 - index.css: 全组件系统重写 (card/input/btn/badge/table) ## 核心页面重写 - LoginPage: 纯白卡片 + 品牌光晕 + Framer Motion 入场动画 - HomePage: Billboard 推荐位 + 剧集卡片联动媒体库 - Layout: 白色侧边栏 + 路由过渡动画 - MediaCard: 新增 linkTo prop, 悬停浮层优化 ## 剧集合并显示 - LibraryPage: 使用 groupSeries 合并剧集, 分季选集详情 - 首页剧集卡片自动链接到媒体库详情 ## 全量类名迁移 (43 文件) - text-slate-* -> text-ink-*/text-sand-* - text-primary-* -> text-brand-* - text-white -> text-ink-600 - bg/border-slate-* -> bg/border-sand-* - 500+ 深色硬编码类名清零
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#111110" />
|
||||
<meta name="theme-color" content="#faf8f5" />
|
||||
<meta name="description" content="MediaStationGo — 个人家庭媒体中心" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ const StorageConfigPage = lazy(() =>
|
||||
import('./pages/StorageConfigPage').then((m) => ({ default: m.StorageConfigPage })),
|
||||
)
|
||||
|
||||
const Loading = () => <p className="px-6 py-8 text-slate-500">加载中…</p>
|
||||
const Loading = () => <p className="px-6 py-8 text-sand-500">加载中…</p>
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
|
||||
@@ -23,10 +23,10 @@ export function APIConfigsPanel() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<KeyRound className="h-5 w-5 text-primary-400" />
|
||||
<KeyRound className="h-5 w-5 text-brand-500" />
|
||||
<div>
|
||||
<p className="font-display text-lg font-semibold text-white">外部 API 配置</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
<p className="font-display text-lg font-semibold text-ink-600">外部 API 配置</p>
|
||||
<p className="text-xs text-ink-50">
|
||||
TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban 密钥管理
|
||||
· AES-GCM 加密存储
|
||||
</p>
|
||||
@@ -34,13 +34,13 @@ export function APIConfigsPanel() {
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<p className="py-6 text-center text-sm text-slate-500">加载中…</p>
|
||||
<p className="py-6 text-center text-sm text-sand-500">加载中…</p>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<div className="glass-panel overflow-hidden">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-white/5 text-xs uppercase tracking-wider text-slate-500">
|
||||
<thead className="border-b border-white/5 text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">服务</th>
|
||||
<th className="px-4 py-3">密钥</th>
|
||||
@@ -66,16 +66,16 @@ export function APIConfigsPanel() {
|
||||
className="border-t border-white/5 transition hover:bg-white/[0.02]"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-white">{item.provider}</p>
|
||||
<p className="font-medium text-ink-600">{item.provider}</p>
|
||||
{item.description && (
|
||||
<p className="text-xs text-slate-500">{item.description}</p>
|
||||
<p className="text-xs text-sand-500">{item.description}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">
|
||||
{item.has_key ? (
|
||||
<span className="text-primary-400">{item.masked_key}</span>
|
||||
<span className="text-brand-500">{item.masked_key}</span>
|
||||
) : (
|
||||
<span className="text-slate-600">未配置</span>
|
||||
<span className="text-sand-400">未配置</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -84,7 +84,7 @@ export function APIConfigsPanel() {
|
||||
已配置
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-400/10 px-2 py-0.5 text-xs text-slate-500">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-sand-300/40 px-2 py-0.5 text-xs text-sand-500">
|
||||
未配置
|
||||
</span>
|
||||
)}
|
||||
@@ -93,7 +93,7 @@ export function APIConfigsPanel() {
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => setEditing(item.provider)}
|
||||
className="rounded p-1.5 text-slate-400 transition hover:bg-white/5 hover:text-white"
|
||||
className="rounded p-1.5 text-ink-50 transition hover:bg-white/5 hover:text-white"
|
||||
title="编辑"
|
||||
>
|
||||
<Save size={14} />
|
||||
@@ -104,7 +104,7 @@ export function APIConfigsPanel() {
|
||||
`已配置 ${item.has_key ? '✓' : '✗'} 密钥 (在线测试请用对应功能页面)`,
|
||||
)
|
||||
}}
|
||||
className="rounded p-1.5 text-slate-400 transition hover:bg-white/5 hover:text-slate-200"
|
||||
className="rounded p-1.5 text-ink-50 transition hover:bg-white/5 hover:text-ink-200"
|
||||
title="查看状态"
|
||||
>
|
||||
<Eye size={14} />
|
||||
@@ -117,7 +117,7 @@ export function APIConfigsPanel() {
|
||||
toast.success('已清除')
|
||||
refresh()
|
||||
}}
|
||||
className="rounded p-1.5 text-slate-400 transition hover:bg-red-400/10 hover:text-red-400"
|
||||
className="rounded p-1.5 text-ink-50 transition hover:bg-red-400/10 hover:text-red-400"
|
||||
title="清除密钥"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@@ -173,8 +173,8 @@ function EditingRow({
|
||||
<tr className="border-t border-white/5 bg-primary-400/5">
|
||||
<td colSpan={4} className="px-4 py-3">
|
||||
<form onSubmit={submit} className="flex flex-wrap items-end gap-3">
|
||||
<span className="text-sm font-medium text-white">{item.provider}</span>
|
||||
<label className="flex-1 text-xs text-slate-400">
|
||||
<span className="text-sm font-medium text-ink-600">{item.provider}</span>
|
||||
<label className="flex-1 text-xs text-ink-50">
|
||||
API Key
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
@@ -184,7 +184,7 @@ function EditingRow({
|
||||
onChange={(e) => setAPIKey(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex-1 text-xs text-slate-400">
|
||||
<label className="flex-1 text-xs text-ink-50">
|
||||
Base URL
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
@@ -193,7 +193,7 @@ function EditingRow({
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<label className="flex items-center gap-2 text-xs text-ink-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
@@ -207,7 +207,7 @@ function EditingRow({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded border border-slate-400/30 px-2 py-1.5 text-xs text-slate-400 hover:text-white"
|
||||
className="rounded border border-sand-400/30 px-2 py-1.5 text-xs text-ink-50 hover:text-white"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
import { Github, Globe, Send } from 'lucide-react'
|
||||
|
||||
const LINKS = [
|
||||
{
|
||||
href: 'https://github.com/ShukeBta/MediaStationGo',
|
||||
icon: Github,
|
||||
label: '开源仓库',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/ShukeBta',
|
||||
icon: Globe,
|
||||
label: '作者主页',
|
||||
},
|
||||
{
|
||||
href: 'https://t.me/MediaStationGo',
|
||||
icon: Send,
|
||||
label: 'TG 群组',
|
||||
},
|
||||
{ href: 'https://github.com/ShukeBta/MediaStationGo', icon: Github, label: '开源仓库' },
|
||||
{ href: 'https://github.com/ShukeBta', icon: Globe, label: '作者主页' },
|
||||
{ href: 'https://t.me/MediaStationGo', icon: Send, label: 'TG 群组' },
|
||||
]
|
||||
|
||||
export function AppFooter({ className = '' }: { className?: string }) {
|
||||
@@ -23,12 +11,12 @@ export function AppFooter({ className = '' }: { className?: string }) {
|
||||
<footer className={`flex items-center justify-center gap-1 ${className}`}>
|
||||
{LINKS.map((link, i) => (
|
||||
<span key={link.href} className="flex items-center">
|
||||
{i > 0 && <span className="mx-2 h-3 w-px bg-cream-900/20" />}
|
||||
{i > 0 && <span className="mx-2 h-3 w-px bg-sand-300" />}
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-cream-500 transition-colors hover:bg-cream-900/10 hover:text-cream-300"
|
||||
className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-sand-500 transition-colors hover:bg-sand-200 hover:text-ink-50"
|
||||
title={link.label}
|
||||
>
|
||||
<link.icon size={14} />
|
||||
|
||||
+408
-172
@@ -1,40 +1,14 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Cast,
|
||||
Clock,
|
||||
CloudDownload,
|
||||
Compass,
|
||||
Copy,
|
||||
Film,
|
||||
FolderTree,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Heart,
|
||||
Home,
|
||||
GalleryHorizontalEnd,
|
||||
Link2,
|
||||
ListChecks,
|
||||
ListMusic,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
Rss,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
Sliders,
|
||||
Sparkles,
|
||||
Cloud,
|
||||
Trash2,
|
||||
UserCog,
|
||||
Wrench,
|
||||
Library as LibraryIcon,
|
||||
User as UserIcon,
|
||||
Activity, Bell, Cast, Clock, CloudDownload, Compass, Copy, Film,
|
||||
FolderTree, Globe, HardDrive, Heart, Home, GalleryHorizontalEnd,
|
||||
Link2, ListChecks, ListMusic, LogOut, MessageSquare, Rss, Search,
|
||||
Server, Settings, Sliders, Sparkles, Cloud, Trash2, UserCog, Wrench,
|
||||
Library as LibraryIcon, User as UserIcon, ChevronDown, Menu, X
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { AppFooter } from './AppFooter'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
@@ -42,180 +16,442 @@ import type { Library } from '../types'
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
|
||||
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false)
|
||||
const [searchFocused, setSearchFocused] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Automatically fetch libraries
|
||||
useEffect(() => {
|
||||
libraryAPI.list().then(setLibraries).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
// Auto-collapse sidebar on smaller tablet screens, and auto-hide drawer on path change
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth < 1024) {
|
||||
setIsSidebarOpen(false)
|
||||
} else {
|
||||
setIsSidebarOpen(true)
|
||||
}
|
||||
}
|
||||
handleResize()
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setIsMobileDrawerOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full">
|
||||
{/* ── Sidebar group — collapsed 64px, expands to 240px on hover ── */}
|
||||
<div className="group relative z-30 shrink-0">
|
||||
<aside
|
||||
className={clsx(
|
||||
'flex h-full flex-col border-r border-cream-900/15 bg-surface-600',
|
||||
'w-16 transition-all duration-300 ease-out',
|
||||
'group-hover:w-60',
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-3 px-4 py-5">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-brand-500/15">
|
||||
<Film className="h-4 w-4 text-brand-400" />
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
'font-display text-lg font-bold tracking-tight text-cream-100',
|
||||
'overflow-hidden whitespace-nowrap opacity-0 transition-opacity duration-200',
|
||||
'group-hover:opacity-100',
|
||||
)}
|
||||
const sidebarContent = (
|
||||
<div className="flex h-full flex-col bg-white border-r border-gray-200/80">
|
||||
{/* Brand Logo & Brand Title */}
|
||||
<div className="flex h-20 items-center justify-between px-6 border-b border-gray-100">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-[#111827] to-[#1f2937] shadow-sm">
|
||||
<Film className="h-5 w-5 text-[#c9954a]" />
|
||||
</div>
|
||||
{(isSidebarOpen || isMobileDrawerOpen) && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="font-display text-lg font-extrabold tracking-tight text-[#111827]"
|
||||
>
|
||||
MediaStation
|
||||
</span>
|
||||
</Link>
|
||||
</motion.span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Toggle Collapse Button for Large Screen */}
|
||||
<button
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-900 transition-colors hidden lg:block"
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 pb-4">
|
||||
<SidebarLink to="/" icon={<Home size={18} />} label="首页" end />
|
||||
|
||||
<SectionHeader label="浏览" />
|
||||
<SidebarLink to="/discover" icon={<Compass size={18} />} label="发现" />
|
||||
<SidebarLink to="/search" icon={<Search size={18} />} label="搜索" />
|
||||
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="收藏" />
|
||||
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" />
|
||||
<SidebarLink to="/history" icon={<Clock size={18} />} label="观看历史" />
|
||||
<SidebarLink to="/poster-wall" icon={<GalleryHorizontalEnd size={18} />} label="海报墙" />
|
||||
<SidebarLink to="/ai" icon={<Sparkles size={18} />} label="AI 助手" />
|
||||
|
||||
<SectionHeader label="媒体库" />
|
||||
{libraries.length === 0 && (
|
||||
<div className="hidden px-3 py-1.5 text-xs text-cream-500/50 group-hover:block">
|
||||
暂无媒体库
|
||||
</div>
|
||||
)}
|
||||
{libraries.map((lib) => (
|
||||
<SidebarLink
|
||||
key={lib.id}
|
||||
to={`/library/${lib.id}`}
|
||||
icon={<LibraryIcon size={18} />}
|
||||
label={lib.name}
|
||||
/>
|
||||
))}
|
||||
|
||||
<SectionHeader label="自动化" />
|
||||
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载" />
|
||||
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="RSS 订阅" />
|
||||
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 投屏" />
|
||||
<SidebarLink to="/site-search" icon={<Search size={18} />} label="站点搜索" />
|
||||
|
||||
<SectionHeader label="账号" />
|
||||
<SidebarLink to="/profile" icon={<UserIcon size={18} />} label="个人资料" />
|
||||
<SidebarLink to="/play-profiles" icon={<UserCog size={18} />} label="观影 Profile" />
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<>
|
||||
<SectionHeader label="管理" />
|
||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
|
||||
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" />
|
||||
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" />
|
||||
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点管理" />
|
||||
<SidebarLink to="/notify-channels" icon={<Bell size={18} />} label="通知渠道" />
|
||||
<SidebarLink to="/download-clients" icon={<Server size={18} />} label="下载器" />
|
||||
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时任务" />
|
||||
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储" />
|
||||
<SidebarLink to="/storage-config" icon={<Cloud size={18} />} label="外部存储" />
|
||||
<SidebarLink to="/files" icon={<FolderTree size={18} />} label="文件浏览" />
|
||||
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="重复文件" />
|
||||
<SidebarLink to="/strm" icon={<Link2 size={18} />} label="STRM 管理" />
|
||||
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="运维工具" />
|
||||
<SidebarLink to="/assistant" icon={<MessageSquare size={18} />} label="AI 对话" />
|
||||
<SidebarLink to="/settings" icon={<Sliders size={18} />} label="系统设置" />
|
||||
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Bottom: logout */}
|
||||
<div className="border-t border-cream-900/15 px-3 py-3">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-sm text-cream-400 transition hover:bg-cream-900/10 hover:text-cream-200"
|
||||
title={`退出登录 (${user?.username})`}
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span className="overflow-hidden whitespace-nowrap opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
退出
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
{/* Mobile Drawer Close Button */}
|
||||
<button
|
||||
onClick={() => setIsMobileDrawerOpen(false)}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-900 transition-colors block lg:hidden"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<main className="flex flex-1 flex-col overflow-y-auto">
|
||||
<div className="flex-1 px-6 py-8">
|
||||
<Outlet />
|
||||
{/* Navigation List */}
|
||||
<nav className="flex-1 overflow-y-auto px-4 py-6 space-y-6 scrollbar-hide">
|
||||
{/* Navigation Group: Main */}
|
||||
<div>
|
||||
<SectionHeader label="影音中心" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||
<div className="space-y-1">
|
||||
<SidebarLink to="/" icon={<Home size={18} />} label="系统首页" end collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/discover" icon={<Compass size={18} />} label="精彩发现" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/search" icon={<Search size={18} />} label="智能搜索" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="我的收藏" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/history" icon={<Clock size={18} />} label="观看历史" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/poster-wall" icon={<GalleryHorizontalEnd size={18} />} label="影音海报墙" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/ai" icon={<Sparkles size={18} />} label="AI 影视助理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
</div>
|
||||
</div>
|
||||
<AppFooter className="py-4" />
|
||||
</main>
|
||||
|
||||
{/* Navigation Group: Libraries */}
|
||||
<div>
|
||||
<SectionHeader label="媒体片库" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||
<div className="space-y-1">
|
||||
{libraries.length === 0 && (isSidebarOpen || isMobileDrawerOpen) && (
|
||||
<div className="px-4 py-2 text-xs text-gray-400 italic">暂无媒体片库</div>
|
||||
)}
|
||||
{libraries.map((lib) => (
|
||||
<SidebarLink
|
||||
key={lib.id}
|
||||
to={`/library/${lib.id}`}
|
||||
icon={<LibraryIcon size={18} />}
|
||||
label={lib.name}
|
||||
collapsed={!isSidebarOpen && !isMobileDrawerOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Group: Automation */}
|
||||
<div>
|
||||
<SectionHeader label="自动化运维" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||
<div className="space-y-1">
|
||||
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载中心" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="RSS 订阅" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 串流" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/site-search" icon={<Search size={18} />} label="站点检索" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Group: Account Profile */}
|
||||
<div>
|
||||
<SectionHeader label="个人资料" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||
<div className="space-y-1">
|
||||
<SidebarLink to="/profile" icon={<UserIcon size={18} />} label="账号信息" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/play-profiles" icon={<UserCog size={18} />} label="观影 Profile" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Group: Admin Dashboard */}
|
||||
{user?.role === 'admin' && (
|
||||
<div>
|
||||
<SectionHeader label="系统后台" visible={isSidebarOpen || isMobileDrawerOpen} />
|
||||
<div className="space-y-1">
|
||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="后台主页" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行监控" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/notify-channels" icon={<Bell size={18} />} label="通知配置" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/download-clients" icon={<Server size={18} />} label="下载客户端" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时机制" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储分析" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/storage-config" icon={<Cloud size={18} />} label="外部挂载" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/files" icon={<FolderTree size={18} />} label="文件管家" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="排重清理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/strm" icon={<Link2 size={18} />} label="STRM 关联" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="运维工具" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/assistant" icon={<MessageSquare size={18} />} label="AI 智能助教" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/settings" icon={<Sliders size={18} />} label="系统参数" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Sidebar Logout Action */}
|
||||
<div className="border-t border-gray-100 p-4 bg-gray-50/50">
|
||||
<button
|
||||
onClick={() => { logout(); navigate('/login') }}
|
||||
className={clsx(
|
||||
"flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 w-full group/logout",
|
||||
(isSidebarOpen || isMobileDrawerOpen) ? "justify-start text-gray-500 hover:bg-red-50 hover:text-red-600" : "justify-center text-gray-400 hover:text-red-600"
|
||||
)}
|
||||
title={`安全登出 (${user?.username})`}
|
||||
>
|
||||
<LogOut size={18} className="transition-transform group-hover/logout:-translate-x-0.5" />
|
||||
{(isSidebarOpen || isMobileDrawerOpen) && <span>安全退出</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-[#f9fafb] text-gray-900 font-body select-none">
|
||||
|
||||
{/* 1. Desktop Persistent Sidebar */}
|
||||
<aside className={clsx(
|
||||
"hidden lg:flex flex-col h-full shrink-0 transition-all duration-300 ease-out",
|
||||
isSidebarOpen ? "w-64" : "w-20"
|
||||
)}>
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
|
||||
{/* 2. Mobile & Tablet Sidebar Drawer (Overlay) */}
|
||||
<AnimatePresence>
|
||||
{isMobileDrawerOpen && (
|
||||
<div className="fixed inset-0 z-50 flex lg:hidden">
|
||||
{/* Backdrop sheet */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setIsMobileDrawerOpen(false)}
|
||||
className="fixed inset-0 bg-black/15 backdrop-blur-sm"
|
||||
/>
|
||||
{/* Drawer body container */}
|
||||
<motion.div
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '-100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 220 }}
|
||||
className="relative flex w-64 max-w-xs flex-col h-full z-10 shadow-xl"
|
||||
>
|
||||
{sidebarContent}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 3. Main Workspace Container */}
|
||||
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
|
||||
|
||||
{/* Top Header Bar */}
|
||||
<header className="flex h-20 shrink-0 items-center justify-between px-6 md:px-8 bg-white/80 border-b border-gray-200/60 backdrop-blur-md z-30">
|
||||
|
||||
{/* Header Left Area: Hamburger and Search */}
|
||||
<div className="flex items-center gap-4 flex-1 max-w-lg">
|
||||
{/* Hamburger button shown on Mobile & Tablet */}
|
||||
<button
|
||||
onClick={() => setIsMobileDrawerOpen(true)}
|
||||
className="rounded-xl border border-gray-200 p-2.5 text-gray-500 hover:bg-gray-100 lg:hidden"
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
|
||||
{/* Premium Integrated Search Form */}
|
||||
<form onSubmit={handleSearchSubmit} className="relative w-full hidden sm:block">
|
||||
<span className={clsx(
|
||||
"absolute left-4 top-1/2 -translate-y-1/2 transition-colors duration-200",
|
||||
searchFocused ? "text-brand-600" : "text-gray-400"
|
||||
)}>
|
||||
<Search size={16} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onFocus={() => setSearchFocused(true)}
|
||||
onBlur={() => setSearchFocused(false)}
|
||||
placeholder="搜索电影、电视剧、演员、种子站点..."
|
||||
className="w-full rounded-full border border-gray-200 bg-gray-50/50 py-2.5 pl-11 pr-12 text-sm text-gray-900 placeholder-gray-400 outline-none transition-all duration-300 focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/40"
|
||||
/>
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<span className="rounded-md border border-gray-200 bg-white px-1.5 py-0.5 text-[9px] font-bold text-gray-400 uppercase tracking-wider">
|
||||
Enter
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Header Right Area: Actions, Notifications, Profile Dropdown */}
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
{/* Quick search button shown ONLY on Mobile screens */}
|
||||
<Link
|
||||
to="/search"
|
||||
className="rounded-xl border border-gray-200 p-2.5 text-gray-400 hover:bg-gray-100 hover:text-gray-900 sm:hidden"
|
||||
>
|
||||
<Search size={18} />
|
||||
</Link>
|
||||
|
||||
{/* Quick Discover shortcut button */}
|
||||
<Link
|
||||
to="/discover"
|
||||
className="hidden md:flex items-center gap-2 rounded-xl border border-gray-200 px-4 py-2.5 text-xs font-bold text-gray-500 hover:bg-gray-50 hover:text-gray-900 transition-all"
|
||||
>
|
||||
<Sparkles size={14} className="text-brand-500" />
|
||||
<span>发现新片</span>
|
||||
</Link>
|
||||
|
||||
{/* Notification alert bubble */}
|
||||
<button className="relative rounded-xl border border-gray-200 p-2.5 text-gray-400 hover:bg-gray-100 hover:text-gray-900 transition-all">
|
||||
<Bell size={18} />
|
||||
<span className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-brand-500 ring-2 ring-white animate-pulse" />
|
||||
</button>
|
||||
|
||||
{/* Horizontal divider lines */}
|
||||
<span className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* Elegant user dropdown list */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsProfileOpen(!isProfileOpen)}
|
||||
className="flex items-center gap-2.5 rounded-full border border-gray-200 p-1 pr-3 hover:bg-gray-50 transition-all"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-[#111827] to-[#1f2937] text-white font-display text-xs font-bold shadow-sm">
|
||||
{user?.username?.slice(0, 2).toUpperCase() || "US"}
|
||||
</div>
|
||||
<div className="text-left hidden md:block">
|
||||
<p className="text-xs font-bold text-gray-900 leading-none">{user?.username}</p>
|
||||
<p className="text-[9px] text-gray-500 font-bold uppercase tracking-wider mt-0.5 leading-none">{user?.role}</p>
|
||||
</div>
|
||||
<ChevronDown size={14} className="text-gray-400" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isProfileOpen && (
|
||||
<>
|
||||
{/* Backdrop cover for closing drawer click */}
|
||||
<div className="fixed inset-0 z-10" onClick={() => setIsProfileOpen(false)} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute right-0 mt-3 w-56 origin-top-right rounded-2xl border border-gray-200 bg-white p-2 shadow-xl z-20"
|
||||
>
|
||||
<Link
|
||||
to="/profile"
|
||||
onClick={() => setIsProfileOpen(false)}
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 hover:text-gray-950 transition-colors"
|
||||
>
|
||||
<UserIcon size={16} />
|
||||
<span>个人基本信息</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/play-profiles"
|
||||
onClick={() => setIsProfileOpen(false)}
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 hover:text-gray-950 transition-colors"
|
||||
>
|
||||
<UserCog size={16} />
|
||||
<span>观影 Profile 切换</span>
|
||||
</Link>
|
||||
{user?.role === 'admin' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
onClick={() => setIsProfileOpen(false)}
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 hover:text-gray-950 transition-colors"
|
||||
>
|
||||
<Settings size={16} />
|
||||
<span>管理主控制台</span>
|
||||
</Link>
|
||||
)}
|
||||
<div className="my-1.5 border-t border-gray-100" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsProfileOpen(false);
|
||||
logout();
|
||||
navigate('/login');
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>安全登出系统</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Dynamic Content Workspace Scroll Area ── */}
|
||||
<main className="flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||
>
|
||||
<Outlet />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Absolute Footer Frame */}
|
||||
<AppFooter className="border-t border-gray-200/50 bg-white py-5 text-center text-xs text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Section divider — visible only when sidebar is expanded */
|
||||
function SectionHeader({ label }: { label: string }) {
|
||||
function SectionHeader({ label, visible }: { label: string; visible: boolean }) {
|
||||
if (!visible) return <div className="h-5" />;
|
||||
return (
|
||||
<div className="mt-4 mb-1 overflow-hidden whitespace-nowrap px-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<span className="text-[10px] font-medium uppercase tracking-[0.15em] text-cream-500/50">
|
||||
<div className="px-4 mb-2 mt-4">
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.25em] text-gray-400">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Single nav link — icon always visible, label fades in on expand */
|
||||
function SidebarLink({
|
||||
to,
|
||||
icon,
|
||||
label,
|
||||
end,
|
||||
}: {
|
||||
to: string
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
end?: boolean
|
||||
}) {
|
||||
interface SidebarLinkProps {
|
||||
to: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
end?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
function SidebarLink({ to, icon, label, end, collapsed }: SidebarLinkProps) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
"flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 relative group",
|
||||
isActive
|
||||
? 'bg-brand-500/10 text-brand-400'
|
||||
: 'text-cream-400 hover:bg-cream-900/10 hover:text-cream-200',
|
||||
? "bg-[#111827] text-white shadow-sm"
|
||||
: "text-gray-500 hover:bg-gray-50 hover:text-gray-900"
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="flex shrink-0 items-center justify-center w-5 h-5">{icon}</span>
|
||||
<span
|
||||
className={clsx(
|
||||
'overflow-hidden whitespace-nowrap truncate',
|
||||
'opacity-0 transition-opacity duration-200',
|
||||
'group-hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className={clsx(
|
||||
"flex shrink-0 items-center justify-center w-5 h-5 transition-transform duration-300 group-hover:scale-110",
|
||||
isActive ? "text-[#c9954a]" : "text-gray-400 group-hover:text-gray-700"
|
||||
)}>
|
||||
{icon}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -5 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="truncate whitespace-nowrap"
|
||||
>
|
||||
{label}
|
||||
</motion.span>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="absolute left-full ml-3 px-2.5 py-1.5 rounded-lg bg-gray-900 text-white text-xs font-semibold opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity shadow-lg z-50 whitespace-nowrap">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +1,103 @@
|
||||
import { useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Film, Play } from 'lucide-react'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { Film, Play, Layers, Star } from 'lucide-react'
|
||||
import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
/**
|
||||
* Compact poster tile — used by library, search, favourites, poster wall, etc.
|
||||
*
|
||||
* 视觉密度调小:之前字体偏大、卡片整体偏宽,导致每页只能塞下 5 个左右;
|
||||
* 现在文字行 truncate + 字号缩到 12px,让 8 列网格不再"挤"。
|
||||
*/
|
||||
export function MediaCard({
|
||||
media,
|
||||
progress,
|
||||
export const MediaCard = ({
|
||||
media, progress, count, rating, linkTo,
|
||||
}: {
|
||||
media: Media
|
||||
progress?: number
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={`/media/${media.id}`}
|
||||
className="group block overflow-hidden rounded-lg border border-cream-900/15 bg-surface-400 transition-all hover:border-brand-500/30 hover:bg-surface-300"
|
||||
>
|
||||
{/* Poster */}
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-600">
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url)}
|
||||
alt={media.title}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-cream-900/30">
|
||||
<Film size={36} />
|
||||
</div>
|
||||
)}
|
||||
media: Media; progress?: number; count?: number; rating?: number; linkTo?: string
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const href = linkTo ?? `/media/${media.id}`
|
||||
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<span className="flex items-center gap-1 text-[11px] text-white/90">
|
||||
<Play size={11} />
|
||||
播放
|
||||
</span>
|
||||
return (
|
||||
<Link to={href} className="group block">
|
||||
<motion.div
|
||||
ref={ref}
|
||||
whileHover={{ scale: 1.04, y: -6 }}
|
||||
transition={{ type: 'spring', stiffness: 280, damping: 22 }}
|
||||
className="relative overflow-hidden rounded-2xl bg-white border border-gray-200/90 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:border-brand-500/40 hover:shadow-[0_12px_32px_rgba(17,24,39,0.04),0_4px_12px_rgba(17,24,39,0.01)]"
|
||||
>
|
||||
{/* Poster Wrapper */}
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-gray-50">
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url)}
|
||||
alt={media.title}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-gray-400 bg-gray-50">
|
||||
<Film size={28} className="stroke-[1.5]" />
|
||||
<span className="text-[10px] uppercase tracking-wider font-bold">No Poster</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Episode count badge */}
|
||||
{count !== undefined && count > 1 && (
|
||||
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-lg bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-white shadow-sm border border-white/10">
|
||||
<Layers size={10} className="text-[#c9954a]" />
|
||||
<span>{count} 集</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Rating Badge */}
|
||||
{(rating || (media as any).rating) && (
|
||||
<span className="absolute left-3 top-3 inline-flex items-center gap-0.5 rounded-lg bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-[#c9954a] shadow-sm border border-white/10">
|
||||
<Star size={10} fill="currentColor" />
|
||||
<span>{(rating || (media as any).rating).toFixed(1)}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Premium Hover Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#111827]/90 via-[#111827]/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end p-4">
|
||||
<motion.div
|
||||
initial={{ y: 15, opacity: 0 }}
|
||||
whileHover={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-xl bg-brand-500 px-4 py-2 text-xs font-bold text-white shadow-md shadow-brand-500/20">
|
||||
<Play size={10} fill="currentColor" className="text-white" />
|
||||
<span>立即观影</span>
|
||||
</span>
|
||||
<p className="text-[10px] text-gray-300 font-semibold line-clamp-2 leading-relaxed">
|
||||
{media.overview || "暂无简介内容"}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar overlay */}
|
||||
{progress !== undefined && progress > 0 && progress < 1 && (
|
||||
<div className="absolute inset-x-0 bottom-0 h-1.5 bg-gray-200">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-brand-400 to-brand-500 rounded-r-full transition-all duration-300"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{progress !== undefined && progress > 0 && progress < 1 && (
|
||||
<div className="absolute inset-x-0 bottom-0 h-1 bg-black/40">
|
||||
<div
|
||||
className="h-full bg-brand-500/80 transition-colors group-hover:bg-brand-400"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||
/>
|
||||
{/* Media Metadata Info */}
|
||||
<div className="p-4 space-y-1 bg-white border-t border-gray-50">
|
||||
<p className="truncate text-sm font-bold text-gray-900 group-hover:text-brand-500 transition-colors duration-200">
|
||||
{media.title}
|
||||
</p>
|
||||
<div className="flex items-center justify-between text-[11px] text-gray-500 font-bold uppercase tracking-wider">
|
||||
<span>{media.year > 0 ? media.year : "未知年份"}</span>
|
||||
{media.video_codec && (
|
||||
<span className="px-1.5 py-0.5 rounded-md bg-gray-50 text-gray-600 border border-gray-100">
|
||||
{media.video_codec}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="px-2 py-2">
|
||||
<p className="truncate text-xs font-medium text-cream-200 group-hover:text-cream-100">
|
||||
{media.title}
|
||||
</p>
|
||||
{media.year > 0 && (
|
||||
<p className="mt-0.5 text-[11px] text-cream-500">{media.year}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
}
|
||||
+108
-137
@@ -1,162 +1,133 @@
|
||||
@tailwind base;
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
body {
|
||||
background-color: #111110;
|
||||
color: #e8e4d9;
|
||||
background-color: #f9fafb; /* Premium Swiss Editorial Light/Slate Background */
|
||||
color: #111827; /* Deep Graphite Charcoal Body Text */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ─── SVG 噪点纹理 ─── */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 0.035;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
background-repeat: repeat;
|
||||
background-size: 256px 256px;
|
||||
}
|
||||
|
||||
/* ─── Scrollbar ─── */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #2e2e2a;
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3a3a35;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ─── Selection ─── */
|
||||
::selection {
|
||||
background: rgba(201, 149, 74, 0.3);
|
||||
color: #e8e4d9;
|
||||
background: rgba(201, 149, 74, 0.2);
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* ─── Focus ring ─── */
|
||||
/* ─── Focus Ring ─── */
|
||||
*:focus-visible {
|
||||
outline: 2px solid #c9954a;
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ─── Scrollbar ─── */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #e5e7eb; border-radius: 999px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #c9954a; }
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* ─── Reduced Motion ─── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ─── Surface Card — 替代 glass-panel ─── */
|
||||
.surface-card {
|
||||
@apply rounded-lg border border-cream-900/20 bg-surface-400 px-4 py-3;
|
||||
/* No shadow, no blur. Pure material feel. */
|
||||
/* ── Unified Swiss-Editorial Cards ── */
|
||||
.card {
|
||||
@apply rounded-2xl bg-white border border-gray-200/80 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)];
|
||||
}
|
||||
.surface-card-elevated {
|
||||
@apply surface-card;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
|
||||
.card-hover {
|
||||
@apply card transition-all duration-300 ease-out hover:border-gray-300 hover:shadow-[0_12px_32px_rgba(17,24,39,0.04),0_4px_12px_rgba(17,24,39,0.01)] hover:-translate-y-1;
|
||||
}
|
||||
.card-elevated {
|
||||
@apply card shadow-[0_20px_40px_rgba(17,24,39,0.05),0_1px_3px_rgba(17,24,39,0.01)];
|
||||
}
|
||||
|
||||
/* ─── 暖色分割线 ─── */
|
||||
.warm-divider {
|
||||
@apply border-t border-cream-900/15;
|
||||
}
|
||||
|
||||
/* ─── 琥珀色按钮 — 替代 neon-button ─── */
|
||||
.amber-btn {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg border border-brand-500/30
|
||||
bg-brand-500/10 px-4 py-2 text-sm font-medium text-brand-400 transition-all
|
||||
hover:border-brand-500/50 hover:bg-brand-500/20 hover:text-brand-300
|
||||
active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40;
|
||||
}
|
||||
.amber-btn--solid {
|
||||
@apply amber-btn border-transparent bg-brand-600 text-cream-100
|
||||
hover:bg-brand-500 hover:text-white;
|
||||
}
|
||||
.amber-btn--ghost {
|
||||
@apply amber-btn border-transparent bg-transparent hover:bg-cream-900/10;
|
||||
}
|
||||
|
||||
/* ─── 鼠尾草按钮 ─── */
|
||||
.sage-btn {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg border border-sage-400/20
|
||||
bg-sage-400/10 px-4 py-2 text-sm font-medium text-sage-300 transition-all
|
||||
hover:border-sage-400/40 hover:bg-sage-400/20 hover:text-sage-200
|
||||
active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40;
|
||||
}
|
||||
|
||||
/* ─── 表单输入 ─── */
|
||||
.field-input {
|
||||
@apply w-full rounded-lg border border-cream-900/20 bg-surface-600 px-4 py-2.5
|
||||
text-sm text-cream-200 placeholder:text-cream-500/40
|
||||
transition-colors
|
||||
focus:border-brand-500/50 focus:bg-surface-500 focus:outline-none
|
||||
disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
.field-label {
|
||||
@apply block text-xs font-medium uppercase tracking-wider text-cream-400 mb-1.5;
|
||||
}
|
||||
|
||||
/* ─── 标签/徽章 ─── */
|
||||
.badge {
|
||||
@apply inline-flex items-center rounded-full px-2 py-0.5 text-2xs font-medium;
|
||||
}
|
||||
.badge--brand {
|
||||
@apply badge bg-brand-500/15 text-brand-400;
|
||||
}
|
||||
.badge--sage {
|
||||
@apply badge bg-sage-400/15 text-sage-300;
|
||||
}
|
||||
.badge--neutral {
|
||||
@apply badge bg-cream-900/20 text-cream-400;
|
||||
}
|
||||
|
||||
/* ─── 页面标题 ─── */
|
||||
.page-heading {
|
||||
@apply font-display text-2xl font-bold tracking-tight text-cream-100;
|
||||
}
|
||||
.page-subtitle {
|
||||
@apply mt-1 text-sm text-cream-400;
|
||||
}
|
||||
|
||||
/* ─── 表格(管理面板用) ─── */
|
||||
.data-table {
|
||||
@apply w-full text-left text-sm;
|
||||
}
|
||||
.data-table thead {
|
||||
@apply border-b border-cream-900/20;
|
||||
}
|
||||
.data-table th {
|
||||
@apply px-4 py-3 text-xs font-medium uppercase tracking-wider text-cream-400;
|
||||
}
|
||||
.data-table td {
|
||||
@apply px-4 py-3 text-cream-300;
|
||||
}
|
||||
.data-table tbody tr {
|
||||
@apply border-b border-cream-900/10 transition-colors hover:bg-cream-900/5;
|
||||
}
|
||||
|
||||
/* ─── 旧版兼容别名(逐步废弃) ─── */
|
||||
/* ── Glassmorphic Bright Panels ── */
|
||||
.glass-panel {
|
||||
@apply surface-card;
|
||||
@apply backdrop-blur-xl bg-white/80 border border-white/50 shadow-[0_8px_32px_rgba(17,24,39,0.015)];
|
||||
}
|
||||
.neon-button {
|
||||
@apply amber-btn;
|
||||
.glass-card {
|
||||
@apply rounded-2xl backdrop-blur-lg bg-white/90 border border-white/60 shadow-[0_4px_24px_rgba(17,24,39,0.01)];
|
||||
}
|
||||
.input-base {
|
||||
@apply field-input;
|
||||
.glass-card-elevated {
|
||||
@apply rounded-2xl backdrop-blur-xl bg-white/95 border border-white/70 shadow-[0_16px_48px_rgba(17,24,39,0.03)];
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Input Fields Redesign ── */
|
||||
.input-field {
|
||||
@apply w-full rounded-xl border border-gray-200 bg-gray-50/50 px-4 py-3 text-sm text-gray-900
|
||||
placeholder:text-gray-400 outline-none transition-all duration-300 ease-out
|
||||
focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/50;
|
||||
}
|
||||
.input-label {
|
||||
@apply block mb-1.5 text-xs font-bold uppercase tracking-widest text-gray-500;
|
||||
}
|
||||
|
||||
/* ── Premium Editorial Buttons Redesign ── */
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl bg-[#111827] px-5 py-3
|
||||
text-sm font-bold text-white transition-all duration-300 ease-out shadow-sm
|
||||
hover:bg-[#1f2937] hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98]
|
||||
disabled:cursor-not-allowed disabled:opacity-40 disabled:translate-y-0;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5
|
||||
text-sm font-semibold text-gray-600 transition-all duration-200 ease-out
|
||||
hover:bg-gray-100 hover:text-gray-900 active:scale-[0.98];
|
||||
}
|
||||
.btn-outline {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl border border-gray-200/90
|
||||
bg-white px-4 py-2.5 text-sm font-bold text-gray-700 transition-all duration-300 ease-out shadow-sm
|
||||
hover:border-gray-300 hover:text-gray-900 hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98];
|
||||
}
|
||||
|
||||
/* ── Badges Redesign ── */
|
||||
.badge {
|
||||
@apply inline-flex items-center rounded-full px-3 py-1 text-2xs font-bold uppercase tracking-wider;
|
||||
}
|
||||
.badge-brand { @apply badge bg-brand-50 text-brand-700 border border-brand-100/30; }
|
||||
.badge-sage { @apply badge bg-sage-50 text-sage-700 border border-sage-100/30; }
|
||||
.badge-neutral { @apply badge bg-gray-100 text-gray-600 border border-gray-200/50; }
|
||||
|
||||
/* ── Page Headings ── */
|
||||
.page-heading { @apply font-display text-3xl font-extrabold tracking-tight text-gray-900; }
|
||||
.page-subtitle { @apply mt-1 text-sm text-gray-500; }
|
||||
|
||||
/* ── Data Tables Redesign ── */
|
||||
.data-table { @apply w-full text-left text-sm border-collapse; }
|
||||
.data-table thead { @apply border-b border-gray-200 bg-gray-50/70; }
|
||||
.data-table th { @apply px-5 py-4 text-xs font-bold uppercase tracking-wider text-gray-500; }
|
||||
.data-table td { @apply px-5 py-4 text-gray-700 border-b border-gray-100; }
|
||||
.data-table tbody tr { @apply transition-colors hover:bg-gray-50/50; }
|
||||
|
||||
/* ── Dividers ── */
|
||||
.divider { @apply border-t border-gray-200/80; }
|
||||
|
||||
/* ── Backward Compatibility ── */
|
||||
.surface-card { @apply card; }
|
||||
.surface-card-elevated { @apply card-elevated; }
|
||||
.field-input { @apply input-field; }
|
||||
.field-label { @apply input-label; }
|
||||
.amber-btn { @apply btn-outline; }
|
||||
.amber-btn--solid { @apply btn-primary; }
|
||||
.amber-btn--ghost { @apply btn-ghost; }
|
||||
.neon-button { @apply btn-outline; }
|
||||
.input-base { @apply input-field; }
|
||||
.warm-divider { @apply divider; }
|
||||
}
|
||||
@@ -86,21 +86,21 @@ export function AIAssistantPage() {
|
||||
<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-white" />
|
||||
<Sparkles className="h-5 w-5 text-ink-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">AI 助手</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
<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-slate-400">
|
||||
<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-slate-500')
|
||||
(status.enabled ? 'bg-emerald-400' : 'bg-sand-500/30')
|
||||
}
|
||||
/>
|
||||
{status.enabled
|
||||
@@ -112,7 +112,7 @@ export function AIAssistantPage() {
|
||||
|
||||
{/* Smart search */}
|
||||
<section className="glass-panel space-y-4">
|
||||
<h2 className="font-display text-lg font-semibold text-white">智能搜索</h2>
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">智能搜索</h2>
|
||||
<form onSubmit={onSearch} className="flex gap-2">
|
||||
<input
|
||||
className="input-base flex-1"
|
||||
@@ -137,7 +137,7 @@ export function AIAssistantPage() {
|
||||
onClick={() => {
|
||||
setQuery(h)
|
||||
}}
|
||||
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
||||
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
@@ -145,35 +145,35 @@ export function AIAssistantPage() {
|
||||
</div>
|
||||
|
||||
{intent && (
|
||||
<div className="rounded-lg border border-white/5 bg-white/5 p-3 text-xs text-slate-300">
|
||||
<div className="mb-1 font-semibold text-slate-200">解析结果</div>
|
||||
<div className="rounded-lg border border-white/5 bg-white/5 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-primary-400">{intent.query || '—'}</span>
|
||||
查询: <span className="text-brand-500">{intent.query || '—'}</span>
|
||||
</span>
|
||||
{intent.year !== undefined && intent.year > 0 && (
|
||||
<span>
|
||||
年份: <span className="text-primary-400">{intent.year}</span>
|
||||
年份: <span className="text-brand-500">{intent.year}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.genre && (
|
||||
<span>
|
||||
类型: <span className="text-primary-400">{intent.genre}</span>
|
||||
类型: <span className="text-brand-500">{intent.genre}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.type && (
|
||||
<span>
|
||||
分类: <span className="text-primary-400">{intent.type}</span>
|
||||
分类: <span className="text-brand-500">{intent.type}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.sort && (
|
||||
<span>
|
||||
排序: <span className="text-primary-400">{intent.sort}</span>
|
||||
排序: <span className="text-brand-500">{intent.sort}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.language && (
|
||||
<span>
|
||||
语言: <span className="text-primary-400">{intent.language}</span>
|
||||
语言: <span className="text-brand-500">{intent.language}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -192,7 +192,7 @@ export function AIAssistantPage() {
|
||||
{/* Recommendations */}
|
||||
<section className="glass-panel space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-display text-lg font-semibold text-white">为你推荐</h2>
|
||||
<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" />
|
||||
@@ -202,7 +202,7 @@ export function AIAssistantPage() {
|
||||
生成推荐
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-sand-500">
|
||||
推荐基于你的最近观看历史。点击标题在媒体库中查找。
|
||||
</p>
|
||||
|
||||
@@ -212,7 +212,7 @@ export function AIAssistantPage() {
|
||||
<li key={i}>
|
||||
<Link
|
||||
to={`/search?q=${encodeURIComponent(t)}`}
|
||||
className="flex items-center justify-between rounded-lg border border-white/5 bg-white/5 px-3 py-2 text-sm text-slate-200 hover:border-primary-400/40 hover:text-primary-400"
|
||||
className="flex items-center justify-between rounded-lg border border-white/5 bg-white/5 px-3 py-2 text-sm text-ink-200 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<span className="truncate">{t}</span>
|
||||
<Search size={14} className="shrink-0 opacity-60" />
|
||||
@@ -223,7 +223,7 @@ export function AIAssistantPage() {
|
||||
)}
|
||||
|
||||
{recs && recs.length === 0 && (
|
||||
<p className="text-sm text-slate-400">
|
||||
<p className="text-sm text-ink-50">
|
||||
还没有推荐结果 — 先去看几部片子,我再给你挑。
|
||||
</p>
|
||||
)}
|
||||
@@ -231,9 +231,9 @@ export function AIAssistantPage() {
|
||||
|
||||
{/* Decorative footer (mirrors the Vue page hint that AI runs locally). */}
|
||||
{!status?.enabled && (
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-sand-500">
|
||||
提示: 当前未配置外部 AI Provider,系统将使用本地规则引擎解析查询。
|
||||
管理员可在 <Link to="/api-configs" className="text-primary-400">API 配置</Link>{' '}
|
||||
管理员可在 <Link to="/api-configs" className="text-brand-500">API 配置</Link>{' '}
|
||||
中接入 OpenAI / DeepSeek 等服务以获得更好效果。
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -25,17 +25,17 @@ export function APIConfigsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<KeyRound className="h-6 w-6 text-primary-400" />
|
||||
<KeyRound className="h-6 w-6 text-brand-500" />
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">外部 API 配置</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">外部 API 配置</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
管理 TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban 的密钥。
|
||||
后端使用 AES-GCM 加密存储,数据库泄漏时密钥仍然安全。
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
{loading && <p className="text-sand-500">加载中…</p>}
|
||||
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
@@ -87,19 +87,19 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
|
||||
return (
|
||||
<form onSubmit={submit} className="glass-panel grid gap-3 md:grid-cols-[1fr_2fr]">
|
||||
<div>
|
||||
<p className="font-display text-lg font-semibold text-white">{item.provider}</p>
|
||||
<p className="font-display text-lg font-semibold text-ink-600">{item.provider}</p>
|
||||
{item.description && (
|
||||
<p className="text-xs text-slate-400">{item.description}</p>
|
||||
<p className="text-xs text-ink-50">{item.description}</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
状态: {item.has_key ? <span className="text-emerald-400">已配置</span> : <span className="text-slate-500">未配置</span>}
|
||||
<p className="mt-2 text-xs text-sand-500">
|
||||
状态: {item.has_key ? <span className="text-emerald-400">已配置</span> : <span className="text-sand-500">未配置</span>}
|
||||
{item.has_key && (
|
||||
<span className="ml-2 font-mono text-primary-400">{item.masked_key}</span>
|
||||
<span className="ml-2 font-mono text-brand-500">{item.masked_key}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs text-slate-400">
|
||||
<label className="block text-xs text-ink-50">
|
||||
API Key (留空保留原值)
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
@@ -109,7 +109,7 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
|
||||
onChange={(e) => setAPIKey(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-slate-400">
|
||||
<label className="block text-xs text-ink-50">
|
||||
Base URL (可选)
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
@@ -118,7 +118,7 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-xs text-slate-400">
|
||||
<label className="inline-flex items-center gap-2 text-xs text-ink-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
@@ -133,7 +133,7 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
|
||||
<button
|
||||
type="button"
|
||||
onClick={testKey}
|
||||
className="neon-button !text-xs !border-slate-400/40 !text-slate-300"
|
||||
className="neon-button !text-xs !border-sand-400/40 !text-ink-100"
|
||||
>
|
||||
<Eye size={12} /> 状态
|
||||
</button>
|
||||
|
||||
+12
-12
@@ -16,7 +16,7 @@ export function AdminPage() {
|
||||
]
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-white">管理后台</h1>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">管理后台</h1>
|
||||
<div className="flex flex-wrap gap-2 border-b border-white/10">
|
||||
{tabs.map((k) => (
|
||||
<button
|
||||
@@ -25,8 +25,8 @@ export function AdminPage() {
|
||||
className={
|
||||
'border-b-2 px-4 py-2 text-sm transition ' +
|
||||
(tab === k.key
|
||||
? 'border-primary-400 text-primary-400'
|
||||
: 'border-transparent text-slate-400 hover:text-white')
|
||||
? 'border-primary-400 text-brand-500'
|
||||
: 'border-transparent text-ink-50 hover:text-white')
|
||||
}
|
||||
>
|
||||
{k.label}
|
||||
@@ -98,7 +98,7 @@ function LibraryPanel() {
|
||||
|
||||
<div className="glass-panel">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
<th>路径</th>
|
||||
@@ -109,12 +109,12 @@ function LibraryPanel() {
|
||||
<tbody>
|
||||
{libs.map((l) => (
|
||||
<tr key={l.id} className="border-t border-white/5">
|
||||
<td className="py-2 text-white">{l.name}</td>
|
||||
<td className="text-slate-300">{l.path}</td>
|
||||
<td className="text-slate-300">{l.type}</td>
|
||||
<td className="py-2 text-ink-600">{l.name}</td>
|
||||
<td className="text-ink-100">{l.path}</td>
|
||||
<td className="text-ink-100">{l.type}</td>
|
||||
<td className="space-x-2 py-2 text-right">
|
||||
<button
|
||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
onClick={async () => {
|
||||
const r = await libraryAPI.scan(l.id)
|
||||
toast.success(`扫描完成,新增 ${r.added}`)
|
||||
@@ -153,7 +153,7 @@ function UsersPanel() {
|
||||
return (
|
||||
<div className="glass-panel">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">用户名</th>
|
||||
<th>角色</th>
|
||||
@@ -164,9 +164,9 @@ function UsersPanel() {
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-t border-white/5">
|
||||
<td className="py-2 text-white">{u.username}</td>
|
||||
<td className="text-slate-300">{u.role}</td>
|
||||
<td className="text-slate-400">
|
||||
<td className="py-2 text-ink-600">{u.username}</td>
|
||||
<td className="text-ink-100">{u.role}</td>
|
||||
<td className="text-ink-50">
|
||||
{u.last_login_at ? new Date(u.last_login_at).toLocaleString() : '从未登录'}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
|
||||
@@ -115,13 +115,13 @@ export function AssistantChatPage() {
|
||||
{/* Sessions sidebar */}
|
||||
<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-white">会话</h2>
|
||||
<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-slate-400">
|
||||
<div className="flex justify-center py-6 text-ink-50">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
@@ -132,8 +132,8 @@ export function AssistantChatPage() {
|
||||
className={
|
||||
'group flex items-center gap-2 rounded px-2 py-2 text-sm cursor-pointer ' +
|
||||
(active?.session.id === s.id
|
||||
? 'bg-primary-400/10 text-primary-400'
|
||||
: 'text-slate-300 hover:bg-white/5 hover:text-white')
|
||||
? 'bg-primary-400/10 text-brand-500'
|
||||
: 'text-ink-100 hover:bg-white/5 hover:text-white')
|
||||
}
|
||||
onClick={() => onSelect(s.id)}
|
||||
>
|
||||
@@ -151,7 +151,7 @@ export function AssistantChatPage() {
|
||||
</li>
|
||||
))}
|
||||
{!loading && sessions.length === 0 && (
|
||||
<li className="px-2 py-2 text-xs text-slate-500">暂无会话</li>
|
||||
<li className="px-2 py-2 text-xs text-sand-500">暂无会话</li>
|
||||
)}
|
||||
</ul>
|
||||
</aside>
|
||||
@@ -159,7 +159,7 @@ export function AssistantChatPage() {
|
||||
{/* Conversation pane */}
|
||||
<section className="glass-panel flex flex-col overflow-hidden">
|
||||
{!active && (
|
||||
<div className="m-auto text-center text-slate-400">
|
||||
<div className="m-auto text-center text-ink-50">
|
||||
<MessageSquare size={32} className="mx-auto mb-2 opacity-50" />
|
||||
<p>选择或创建一个会话开始对话</p>
|
||||
</div>
|
||||
@@ -167,13 +167,13 @@ export function AssistantChatPage() {
|
||||
{active && (
|
||||
<>
|
||||
<div className="mb-3 border-b border-white/5 pb-2">
|
||||
<h2 className="font-display text-base font-semibold text-white">
|
||||
<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-slate-500">说点什么开始对话…</p>
|
||||
<p className="text-sm text-sand-500">说点什么开始对话…</p>
|
||||
)}
|
||||
{active.messages.map((m) => (
|
||||
<Bubble key={m.id} message={m} />
|
||||
@@ -211,7 +211,7 @@ function Bubble({ message }: { message: AssistantMessage }) {
|
||||
? 'bg-primary-400/20 text-primary-100'
|
||||
: message.role === 'system'
|
||||
? 'border border-amber-400/30 bg-amber-400/5 text-amber-200'
|
||||
: 'bg-white/5 text-slate-200')
|
||||
: 'bg-white/5 text-ink-200')
|
||||
}
|
||||
>
|
||||
{message.content}
|
||||
|
||||
@@ -65,11 +65,11 @@ export function DiscoverPage() {
|
||||
{/* Header */}
|
||||
<header className="flex items-center gap-4 mb-8">
|
||||
<div className="p-3 rounded-2xl bg-gradient-to-br from-primary-500/20 to-primary-600/10 border border-primary-500/20">
|
||||
<Sparkles className="h-8 w-8 text-primary-400" />
|
||||
<Sparkles className="h-8 w-8 text-brand-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-4xl font-bold text-white tracking-tight">发现</h1>
|
||||
<p className="mt-1 text-base text-slate-400">
|
||||
<h1 className="font-display text-4xl font-bold text-ink-600 tracking-tight">发现</h1>
|
||||
<p className="mt-1 text-base text-ink-50">
|
||||
来自 TMDb 的当日热门与流行榜单
|
||||
</p>
|
||||
</div>
|
||||
@@ -83,13 +83,13 @@ export function DiscoverPage() {
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-amber-500/10 flex items-center justify-center">
|
||||
<AlertTriangle className="h-8 w-8 text-amber-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white">TMDb API Key 未配置</h3>
|
||||
<p className="text-sm text-slate-400 max-w-md mx-auto">
|
||||
<h3 className="text-lg font-semibold text-ink-600">TMDb API Key 未配置</h3>
|
||||
<p className="text-sm text-ink-50 max-w-md mx-auto">
|
||||
您需要在管理后台填入 TMDb API Key 才能查看发现内容。
|
||||
</p>
|
||||
<Link
|
||||
to="/admin"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-primary-500/20 text-primary-400 hover:bg-primary-500/30 transition-colors font-medium"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-primary-500/20 text-brand-500 hover:bg-primary-500/30 transition-colors font-medium"
|
||||
>
|
||||
前往管理后台
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
@@ -103,15 +103,15 @@ export function DiscoverPage() {
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<Wifi className="h-8 w-8 text-orange-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white">无法连接到 TMDb</h3>
|
||||
<p className="text-sm text-slate-400 max-w-lg mx-auto">
|
||||
<h3 className="text-lg font-semibold text-ink-600">无法连接到 TMDb</h3>
|
||||
<p className="text-sm text-ink-50 max-w-lg mx-auto">
|
||||
服务器到 <code className="font-mono text-orange-300">api.themoviedb.org</code> 的连接超时。
|
||||
通常是因为部署机器没有走代理。可以在系统环境变量里设置
|
||||
<code className="font-mono text-orange-300 mx-1">HTTPS_PROXY</code>,
|
||||
或在「外部 API」配置里填写自建反代地址(tmdb_api_proxy / tmdb_image_proxy)。
|
||||
</p>
|
||||
<details className="text-xs text-slate-500 max-w-lg mx-auto text-left">
|
||||
<summary className="cursor-pointer hover:text-slate-400">查看原始错误</summary>
|
||||
<details className="text-xs text-sand-500 max-w-lg mx-auto text-left">
|
||||
<summary className="cursor-pointer hover:text-ink-50">查看原始错误</summary>
|
||||
<pre className="mt-2 p-2 rounded bg-black/40 overflow-x-auto whitespace-pre-wrap">{anyErr}</pre>
|
||||
</details>
|
||||
</div>
|
||||
@@ -134,7 +134,7 @@ export function DiscoverPage() {
|
||||
{/* TMDB 配置 OK 但本次没拿到任何条目(极少见) */}
|
||||
{!networkErr && trending.length === 0 && popular.length === 0 && !otherErr && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-500">暂无发现内容</p>
|
||||
<p className="text-sand-500">暂无发现内容</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -146,7 +146,7 @@ export function DiscoverPage() {
|
||||
function ContentRow({ title, items }: { title: string; items: DiscoverItem[] }) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="font-display text-2xl font-semibold text-white pl-1">{title}</h2>
|
||||
<h2 className="font-display text-2xl font-semibold text-ink-600 pl-1">{title}</h2>
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{items.map((item) => (
|
||||
<DiscoverCard key={item.tmdb_id} item={item} />
|
||||
@@ -169,7 +169,7 @@ function DiscoverCard({ item }: { item: DiscoverItem }) {
|
||||
className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-slate-600 text-xs">
|
||||
<div className="flex h-full w-full items-center justify-center text-sand-400 text-xs">
|
||||
无海报
|
||||
</div>
|
||||
)}
|
||||
@@ -180,11 +180,11 @@ function DiscoverCard({ item }: { item: DiscoverItem }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2.5 py-2 space-y-0.5">
|
||||
<p className="text-xs font-medium text-white truncate group-hover:text-primary-400 transition-colors">
|
||||
<p className="text-xs font-medium text-ink-600 truncate group-hover:text-brand-500 transition-colors">
|
||||
{item.title}
|
||||
</p>
|
||||
{item.year > 0 && (
|
||||
<p className="text-[11px] text-slate-500">{item.year}</p>
|
||||
<p className="text-[11px] text-sand-500">{item.year}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,17 +53,17 @@ export function DlnaPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<Cast className="h-6 w-6 text-primary-400" />
|
||||
<Cast className="h-6 w-6 text-brand-500" />
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">DLNA 投屏</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">DLNA 投屏</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
扫描局域网中的 UPnP MediaRenderer 设备(电视、机顶盒等),选择媒体后一键播放。
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="glass-panel space-y-3">
|
||||
<label className="block text-sm text-slate-300">选择媒体:</label>
|
||||
<label className="block text-sm text-ink-100">选择媒体:</label>
|
||||
<select
|
||||
className="input-base"
|
||||
value={selectedMedia}
|
||||
@@ -79,7 +79,7 @@ export function DlnaPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-display text-xl font-semibold text-white">
|
||||
<h2 className="font-display text-xl font-semibold text-ink-600">
|
||||
设备 ({devices.length})
|
||||
</h2>
|
||||
<button
|
||||
@@ -93,7 +93,7 @@ export function DlnaPage() {
|
||||
|
||||
{devices.length === 0 && !scanning && (
|
||||
<div className="glass-panel">
|
||||
<p className="text-slate-300">
|
||||
<p className="text-ink-100">
|
||||
未发现任何 DLNA 设备。请确保:服务器与设备在同一局域网,容器使用 host 网络模式,
|
||||
目标设备已开启 DLNA / 屏幕镜像。
|
||||
</p>
|
||||
@@ -104,10 +104,10 @@ export function DlnaPage() {
|
||||
{devices.map((dev) => (
|
||||
<div key={dev.udn} className="glass-panel space-y-2 !p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tv size={18} className="text-primary-400" />
|
||||
<p className="font-medium text-white">{dev.friendly_name || dev.model_name}</p>
|
||||
<Tv size={18} className="text-brand-500" />
|
||||
<p className="font-medium text-ink-600">{dev.friendly_name || dev.model_name}</p>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
<p className="text-xs text-ink-50">
|
||||
{dev.manufacturer} · {dev.ip_address}
|
||||
</p>
|
||||
<button
|
||||
|
||||
@@ -66,8 +66,8 @@ export function DownloadClientsPage() {
|
||||
<Server size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">下载器管理</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">下载器管理</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
qBittorrent / Aria2 / Transmission · 多客户端 + 连接测试
|
||||
</p>
|
||||
</div>
|
||||
@@ -84,13 +84,13 @@ export function DownloadClientsPage() {
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12 text-slate-400">
|
||||
<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-slate-400">暂无下载器</div>
|
||||
<div className="glass-panel py-12 text-center text-ink-50">暂无下载器</div>
|
||||
)}
|
||||
|
||||
{!loading && clients.length > 0 && (
|
||||
@@ -102,22 +102,22 @@ export function DownloadClientsPage() {
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white">{c.name}</span>
|
||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">
|
||||
<span className="font-medium text-ink-600">{c.name}</span>
|
||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-ink-50">
|
||||
{c.type}
|
||||
</span>
|
||||
{c.is_default && (
|
||||
<span className="rounded bg-primary-400/20 px-2 py-0.5 text-xs text-primary-400">
|
||||
<span className="rounded bg-primary-400/20 px-2 py-0.5 text-xs text-brand-500">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
{!c.enabled && (
|
||||
<span className="rounded bg-slate-500/30 px-2 py-0.5 text-xs text-slate-300">
|
||||
<span className="rounded bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">
|
||||
已禁用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-slate-400">
|
||||
<div className="mt-1 truncate text-xs text-ink-50">
|
||||
{c.host}
|
||||
{c.username && ` · ${c.username}`}
|
||||
</div>
|
||||
@@ -125,7 +125,7 @@ export function DownloadClientsPage() {
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={() => onTest(c.id)}
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<Send size={12} className="inline" /> 测试
|
||||
</button>
|
||||
@@ -134,7 +134,7 @@ export function DownloadClientsPage() {
|
||||
setEditing(c)
|
||||
setShowForm(true)
|
||||
}}
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<Pencil size={12} className="inline" /> 编辑
|
||||
</button>
|
||||
@@ -216,7 +216,7 @@ function ClientFormModal({
|
||||
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-white">
|
||||
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
|
||||
{editing ? '编辑下载器' : '添加下载器'}
|
||||
</h2>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
@@ -278,7 +278,7 @@ function ClientFormModal({
|
||||
</Field>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<label className="flex items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
@@ -287,7 +287,7 @@ function ClientFormModal({
|
||||
/>
|
||||
设为默认
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<label className="flex items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
@@ -301,7 +301,7 @@ function ClientFormModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-ink-100 hover:bg-white/5"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
@@ -318,7 +318,7 @@ function ClientFormModal({
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">{label}</span>
|
||||
<span className="mb-1 block text-sm text-ink-100">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
|
||||
@@ -60,7 +60,7 @@ export function DownloadsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-white">下载</h1>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">下载</h1>
|
||||
|
||||
<form onSubmit={onAdd} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
||||
<input
|
||||
@@ -82,20 +82,20 @@ export function DownloadsPage() {
|
||||
</form>
|
||||
|
||||
<section className="glass-panel">
|
||||
<h2 className="mb-3 font-display text-lg font-semibold text-white">实时状态</h2>
|
||||
<h2 className="mb-3 font-display text-lg font-semibold text-ink-600">实时状态</h2>
|
||||
{torrents === null && (
|
||||
<p className="text-slate-500">
|
||||
<p className="text-sand-500">
|
||||
尚未连接到下载器 — 请到{' '}
|
||||
<a href="/download-clients" className="text-primary-400 hover:underline">
|
||||
<a href="/download-clients" className="text-brand-500 hover:underline">
|
||||
下载器
|
||||
</a>{' '}
|
||||
页面添加并测试连接(qBittorrent / Aria2 / Transmission)。
|
||||
</p>
|
||||
)}
|
||||
{torrents && torrents.length === 0 && <p className="text-slate-500">暂无运行中任务。</p>}
|
||||
{torrents && torrents.length === 0 && <p className="text-sand-500">暂无运行中任务。</p>}
|
||||
{torrents && torrents.length > 0 && (
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
<th>状态</th>
|
||||
@@ -108,11 +108,11 @@ export function DownloadsPage() {
|
||||
<tbody>
|
||||
{torrents.map((t) => (
|
||||
<tr key={t.hash} className="border-t border-white/5 align-top">
|
||||
<td className="max-w-md truncate py-2 text-white" title={t.name}>
|
||||
<td className="max-w-md truncate py-2 text-ink-600" title={t.name}>
|
||||
{t.name}
|
||||
</td>
|
||||
<td className="text-slate-300">{t.state}</td>
|
||||
<td className="text-slate-300">
|
||||
<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 bg-white/10">
|
||||
<div
|
||||
@@ -123,10 +123,10 @@ export function DownloadsPage() {
|
||||
{(t.progress * 100).toFixed(1)}%
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-slate-300">
|
||||
<td className="text-ink-100">
|
||||
↓ {fmtSpeed(t.dlspeed)} / ↑ {fmtSpeed(t.upspeed)}
|
||||
</td>
|
||||
<td className="text-slate-300">{fmtBytes(t.size)}</td>
|
||||
<td className="text-ink-100">{fmtBytes(t.size)}</td>
|
||||
{role === 'admin' && (
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
@@ -151,9 +151,9 @@ export function DownloadsPage() {
|
||||
|
||||
{tasks.length > 0 && (
|
||||
<section className="glass-panel">
|
||||
<h2 className="mb-3 font-display text-lg font-semibold text-white">历史记录</h2>
|
||||
<h2 className="mb-3 font-display text-lg font-semibold text-ink-600">历史记录</h2>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">来源</th>
|
||||
<th>URL</th>
|
||||
@@ -164,12 +164,12 @@ export function DownloadsPage() {
|
||||
<tbody>
|
||||
{tasks.map((t) => (
|
||||
<tr key={t.id} className="border-t border-white/5">
|
||||
<td className="py-2 text-slate-300">{t.source}</td>
|
||||
<td className="max-w-md truncate text-slate-300" title={t.url}>
|
||||
<td className="py-2 text-ink-100">{t.source}</td>
|
||||
<td className="max-w-md truncate text-ink-100" title={t.url}>
|
||||
{t.url}
|
||||
</td>
|
||||
<td className="text-slate-300">{t.save_path || '—'}</td>
|
||||
<td className="text-slate-500">
|
||||
<td className="text-ink-100">{t.save_path || '—'}</td>
|
||||
<td className="text-sand-500">
|
||||
{new Date(t.created_at).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -54,10 +54,10 @@ export function DuplicatesPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<Copy className="h-6 w-6 text-primary-400" />
|
||||
<Copy className="h-6 w-6 text-brand-500" />
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">重复文件</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">重复文件</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
通过稀疏采样 MD5(头部 / 中部 / 尾部各 1 MiB + 文件大小)检测重复媒体,
|
||||
同一组中保留刮削过的较大文件作为主条目,其余标记为重复。
|
||||
</p>
|
||||
@@ -86,19 +86,19 @@ export function DuplicatesPage() {
|
||||
</div>
|
||||
|
||||
{report && report.groups_found === 0 && (
|
||||
<p className="text-slate-400">扫描了 {report.total_scanned} 项,未发现重复。</p>
|
||||
<p className="text-ink-50">扫描了 {report.total_scanned} 项,未发现重复。</p>
|
||||
)}
|
||||
|
||||
{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-slate-500">{g.hash}</p>
|
||||
<p className="font-mono text-xs text-sand-500">{g.hash}</p>
|
||||
<span className="rounded border border-emerald-400/40 px-2 py-0.5 text-xs text-emerald-400">
|
||||
主条目
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-medium text-white">{g.primary.title}</p>
|
||||
<p className="font-mono text-xs text-slate-400">
|
||||
<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-white/5 pt-2">
|
||||
@@ -106,8 +106,8 @@ export function DuplicatesPage() {
|
||||
重复 ({g.duplicates.length})
|
||||
</p>
|
||||
{g.duplicates.map((d) => (
|
||||
<div key={d.id} className="text-xs text-slate-400">
|
||||
<span className="text-white">{d.title}</span> · {d.path} · {fmtBytes(d.size_bytes)}
|
||||
<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>
|
||||
|
||||
@@ -28,10 +28,10 @@ export function FavouritesPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-white">我的收藏</h1>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">我的收藏</h1>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 py-8 text-slate-400">
|
||||
<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>
|
||||
@@ -64,8 +64,8 @@ export function FavouritesPage() {
|
||||
|
||||
{isEmpty && (
|
||||
<div className="glass-panel flex flex-col items-center gap-3 p-10 text-center">
|
||||
<p className="text-lg text-slate-300">还没有任何收藏</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
<p className="text-lg text-ink-100">还没有任何收藏</p>
|
||||
<p className="text-sm text-sand-500">
|
||||
点击媒体详情页的「收藏」按钮添加喜欢的内容
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -49,8 +49,8 @@ export function FileManagerPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="font-display text-3xl font-bold text-white">文件浏览器</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">文件浏览器</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
只允许访问已配置的根目录(媒体库 + data + cache)。
|
||||
</p>
|
||||
</header>
|
||||
@@ -72,13 +72,13 @@ export function FileManagerPage() {
|
||||
</button>
|
||||
)}
|
||||
{data?.path && (
|
||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-1 font-mono text-xs text-slate-300">
|
||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-1 font-mono text-xs text-ink-100">
|
||||
{data.path}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
{loading && <p className="text-sand-500">加载中…</p>}
|
||||
{error && <div className="glass-panel !border-red-400/40 text-red-400">{error}</div>}
|
||||
|
||||
{!loading && data && !data.entries && data.roots && (
|
||||
@@ -89,10 +89,10 @@ export function FileManagerPage() {
|
||||
onClick={() => setPath(r.path)}
|
||||
className="glass-panel flex items-center gap-3 text-left transition hover:border-primary-400/40"
|
||||
>
|
||||
<FolderOpen size={20} className="text-primary-400" />
|
||||
<FolderOpen size={20} className="text-brand-500" />
|
||||
<div>
|
||||
<p className="font-mono text-sm text-white">{r.label}</p>
|
||||
<p className="font-mono text-xs text-slate-400">{r.path}</p>
|
||||
<p className="font-mono text-sm text-ink-600">{r.label}</p>
|
||||
<p className="font-mono text-xs text-ink-50">{r.path}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -102,7 +102,7 @@ export function FileManagerPage() {
|
||||
{!loading && data?.entries && data.entries.length > 0 && (
|
||||
<div className="glass-panel">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
<th>大小</th>
|
||||
@@ -117,16 +117,16 @@ export function FileManagerPage() {
|
||||
onClick={() => enter(e)}
|
||||
title={e.path}
|
||||
>
|
||||
<td className="flex items-center gap-2 py-2 text-white">
|
||||
<td className="flex items-center gap-2 py-2 text-ink-600">
|
||||
{e.is_dir ? (
|
||||
<Folder size={16} className="text-primary-400" />
|
||||
<Folder size={16} className="text-brand-500" />
|
||||
) : (
|
||||
<FileVideo size={16} className="text-slate-400" />
|
||||
<FileVideo size={16} className="text-ink-50" />
|
||||
)}
|
||||
{e.name}
|
||||
</td>
|
||||
<td className="text-slate-300">{e.is_dir ? '—' : fmtBytes(e.size)}</td>
|
||||
<td className="text-slate-500">
|
||||
<td className="text-ink-100">{e.is_dir ? '—' : fmtBytes(e.size)}</td>
|
||||
<td className="text-sand-500">
|
||||
{new Date(e.modified * 1000).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -137,7 +137,7 @@ export function FileManagerPage() {
|
||||
)}
|
||||
|
||||
{!loading && data?.entries && data.entries.length === 0 && (
|
||||
<p className="text-slate-400">空目录。</p>
|
||||
<p className="text-ink-50">空目录。</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
+227
-278
@@ -1,44 +1,23 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
ArrowRight,
|
||||
Play,
|
||||
Clock,
|
||||
Film,
|
||||
Compass,
|
||||
Sparkles,
|
||||
Layers,
|
||||
Library as LibraryIcon,
|
||||
Tv,
|
||||
Music,
|
||||
PlayCircle,
|
||||
ArrowRight, Play, Clock, Film, Sparkles,
|
||||
Library as LibraryIcon, Tv, Music, PlayCircle, ChevronRight
|
||||
} from 'lucide-react'
|
||||
|
||||
import { libraryAPI, mediaAPI } from '../api/library'
|
||||
import { playbackAPI, type HistoryItem } from '../api/playback'
|
||||
import { imageURL } from '../api/client'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { Library, Media } from '../types'
|
||||
import { groupSeries } from '../utils/groupSeries'
|
||||
|
||||
// LibraryRow 把单个媒体库的最近添加聚合成代表卡片列表。
|
||||
type LibraryRow = {
|
||||
library: Library
|
||||
cards: ReturnType<typeof groupSeries>
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
movie: '电影',
|
||||
tv: '电视剧',
|
||||
anime: '动漫',
|
||||
music: '音乐',
|
||||
}
|
||||
type LibraryRow = { library: Library; cards: ReturnType<typeof groupSeries> }
|
||||
|
||||
const TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
movie: <Film size={16} />,
|
||||
tv: <Tv size={16} />,
|
||||
anime: <PlayCircle size={16} />,
|
||||
music: <Music size={16} />,
|
||||
movie: <Film size={18} />, tv: <Tv size={18} />, anime: <PlayCircle size={18} />, music: <Music size={18} />,
|
||||
}
|
||||
const TYPE_LABELS: Record<string, string> = { movie: '电影', tv: '电视剧', anime: '动漫', music: '音乐' }
|
||||
|
||||
export function HomePage() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
@@ -48,11 +27,9 @@ export function HomePage() {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
// 1) 拉所有库 + 历史
|
||||
const [libs, hist] = await Promise.all([
|
||||
libraryAPI.list().catch(() => [] as Library[]),
|
||||
playbackAPI.recentHistory().catch(() => [] as HistoryItem[]),
|
||||
@@ -60,295 +37,267 @@ export function HomePage() {
|
||||
if (cancelled) return
|
||||
setLibraries(libs)
|
||||
setHistory(hist.filter((h) => !h.completed && !!h.media))
|
||||
|
||||
// 2) 并行为每个库拉最近 60 条 media,前端折叠成剧集卡片
|
||||
const perLib = await Promise.all(
|
||||
libs.map(async (lib) => {
|
||||
try {
|
||||
const page = await libraryAPI.listMedia(lib.id, 1, 60)
|
||||
const cards = groupSeries(page.items).slice(0, 12)
|
||||
return { library: lib, cards } as LibraryRow
|
||||
} catch {
|
||||
return { library: lib, cards: [] } as LibraryRow
|
||||
}
|
||||
}),
|
||||
)
|
||||
const perLib = await Promise.all(libs.map(async (lib) => {
|
||||
try {
|
||||
const page = await libraryAPI.listMedia(lib.id, 1, 60)
|
||||
return { library: lib, cards: groupSeries(page.items).slice(0, 14) } as LibraryRow
|
||||
} catch { return { library: lib, cards: [] } as LibraryRow }
|
||||
}))
|
||||
if (cancelled) return
|
||||
setRows(perLib)
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
} finally { if (!cancelled) setLoading(false) }
|
||||
}
|
||||
|
||||
load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
// 兜底:如果一个库都没有,但 /api/media 有内容(旧数据),回退展示一个总卡片墙。
|
||||
const [fallback, setFallback] = useState<Media[]>([])
|
||||
useEffect(() => {
|
||||
if (loading) return
|
||||
if (libraries.length > 0) return
|
||||
if (loading || libraries.length > 0) return
|
||||
mediaAPI.search('', 60).then((d) => setFallback(d.items)).catch(() => undefined)
|
||||
}, [loading, libraries.length])
|
||||
|
||||
const fallbackCards = useMemo(() => groupSeries(fallback).slice(0, 16), [fallback])
|
||||
const empty =
|
||||
!loading &&
|
||||
history.length === 0 &&
|
||||
rows.every((r) => r.cards.length === 0) &&
|
||||
fallbackCards.length === 0
|
||||
|
||||
// Pick a featured movie/show for the premium editorial billboard
|
||||
const featuredItem = useMemo(() => {
|
||||
if (history.length > 0 && history[0].media) {
|
||||
return history[0].media;
|
||||
}
|
||||
if (rows.length > 0 && rows[0].cards.length > 0) {
|
||||
return rows[0].cards[0].rep;
|
||||
}
|
||||
if (fallback.length > 0) {
|
||||
return fallback[0];
|
||||
}
|
||||
return null;
|
||||
}, [history, rows, fallback]);
|
||||
|
||||
const empty = !loading && history.length === 0 && rows.every((r) => r.cards.length === 0) && fallbackCards.length === 0
|
||||
|
||||
if (loading) {
|
||||
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">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="h-10 w-10 rounded-full border-2 border-gray-100 border-t-gray-900 animate-spin" />
|
||||
<Film className="absolute h-4 w-4 text-brand-500" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold tracking-widest text-gray-400 uppercase">站点舱准备中…</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-32 text-center max-w-md mx-auto">
|
||||
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-3xl bg-gray-100 border border-gray-200/50 shadow-sm">
|
||||
<Film className="h-10 w-10 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-gray-900">您的家庭影视站暂无内容</p>
|
||||
<p className="mt-2 text-sm text-gray-500 leading-relaxed">
|
||||
立即前往管理后台配置您的第一个媒体库,添加影视目录并触发后台刮削扫描任务。
|
||||
</p>
|
||||
<Link to="/admin" className="mt-8 btn-primary">
|
||||
前往管理后台
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-12">
|
||||
{/* ── Header ── */}
|
||||
<header className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold tracking-tight text-cream-100">
|
||||
媒体中心
|
||||
</h1>
|
||||
<p className="mt-1.5 text-sm text-cream-400">
|
||||
从你上次离开的地方继续
|
||||
</p>
|
||||
</div>
|
||||
{!loading && (
|
||||
<Link
|
||||
to="/discover"
|
||||
className="flex items-center gap-1.5 text-sm text-cream-400 transition-colors hover:text-brand-400"
|
||||
>
|
||||
<Compass size={14} />
|
||||
发现更多
|
||||
<ArrowRight size={14} />
|
||||
</Link>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-3 py-16">
|
||||
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-brand-500/60" />
|
||||
<p className="text-sm text-cream-500">加载媒体库…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty */}
|
||||
{empty && (
|
||||
<div className="surface-card flex flex-col items-center gap-4 py-16 text-center">
|
||||
<Film className="h-12 w-12 text-cream-900/40" />
|
||||
<div>
|
||||
<p className="font-medium text-cream-300">还没有任何媒体</p>
|
||||
<p className="mt-1 text-sm text-cream-500">
|
||||
前往{' '}
|
||||
<Link to="/admin" className="text-brand-400 underline underline-offset-2">
|
||||
管理后台
|
||||
</Link>{' '}
|
||||
创建媒体库,然后触发一次扫描
|
||||
</p>
|
||||
<div className="space-y-12">
|
||||
{/* ─── Premium Swiss-Editorial Billboard Hero ─── */}
|
||||
{featuredItem && (
|
||||
<section className="relative overflow-hidden rounded-3xl bg-white border border-gray-200/90 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)]">
|
||||
{/* Background Poster Cover & Soft Faded Backdrop */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
{featuredItem.poster_url ? (
|
||||
<img
|
||||
src={imageURL(featuredItem.poster_url)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover object-top opacity-5 scale-105 blur-sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-50" />
|
||||
)}
|
||||
{/* Soft Bright Masking */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-white via-white/95 to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white via-transparent to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Billboard Content */}
|
||||
<div className="relative z-10 px-8 py-14 sm:px-12 md:py-16 lg:py-20 max-w-3xl space-y-5">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-brand-50 px-3.5 py-1.5 text-xs font-bold uppercase tracking-widest text-[#c9954a] border border-brand-100/40">
|
||||
<Sparkles size={12} fill="currentColor" />
|
||||
<span>本周力荐 / Featured</span>
|
||||
</div>
|
||||
|
||||
<h1 className="font-display text-3xl sm:text-4xl md:text-5xl font-extrabold tracking-tight leading-tight text-gray-900">
|
||||
{featuredItem.title}
|
||||
</h1>
|
||||
|
||||
{featuredItem.overview ? (
|
||||
<p className="text-gray-500 text-sm sm:text-base leading-relaxed line-clamp-3 font-semibold">
|
||||
{featuredItem.overview}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-gray-400 text-sm italic">
|
||||
家庭私人媒体中心收藏。极高视听品质,支持多端原生无损解码及HLS转码播放。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Metadata Badges */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 font-bold">
|
||||
{featuredItem.year > 0 && (
|
||||
<span className="bg-gray-100 px-2.5 py-1 rounded-md text-gray-900 border border-gray-200">{featuredItem.year} 年</span>
|
||||
)}
|
||||
{featuredItem.video_codec && (
|
||||
<span className="rounded bg-brand-50 px-2 py-1 text-brand-700 border border-brand-100 uppercase font-bold text-[10px]">
|
||||
{featuredItem.video_codec}
|
||||
</span>
|
||||
)}
|
||||
{featuredItem.container && (
|
||||
<span className="rounded bg-gray-100 px-2 py-1 text-gray-700 border border-gray-200 uppercase font-mono text-[10px]">
|
||||
{featuredItem.container}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex flex-wrap items-center gap-4 pt-4">
|
||||
<Link to={`/media/${featuredItem.id}`} className="inline-flex items-center justify-center gap-2 rounded-xl bg-[#111827] px-6 py-3.5 text-sm font-bold text-white shadow-sm hover:bg-[#1f2937] hover:-translate-y-0.5 transition-all">
|
||||
<Play size={16} fill="currentColor" />
|
||||
<span>立即播放</span>
|
||||
</Link>
|
||||
<Link to="/discover" className="btn-outline px-5 py-3.5 text-sm font-bold text-gray-700 border border-gray-200 hover:border-gray-300">
|
||||
<span>发现更多精彩</span>
|
||||
<ArrowRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Continue Watching */}
|
||||
{/* ─── Continue Watching Section ─── */}
|
||||
{history.length > 0 && (
|
||||
<section>
|
||||
<SectionHeading icon={<Play size={16} />} label="继续观看" />
|
||||
<div className="flex gap-4 overflow-x-auto pb-2">
|
||||
{history.slice(0, 6).map((h) => {
|
||||
<section className="space-y-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="p-1.5 rounded-lg bg-gray-100 border border-gray-200/50 text-gray-900">
|
||||
<Clock size={16} />
|
||||
</span>
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">继续观看</h2>
|
||||
<span className="text-xs font-bold text-gray-400 bg-gray-100 px-2.5 py-0.5 rounded-full border border-gray-200/40">{history.length} 个记录</span>
|
||||
</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 m = h.media!
|
||||
const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
|
||||
return <WideContinueCard key={h.id} media={m} progress={progress} />
|
||||
const pct = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
|
||||
return <ContinueCard key={h.id} media={m} progress={pct} />
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 每个媒体库一行 */}
|
||||
{!loading &&
|
||||
rows
|
||||
.filter((r) => r.cards.length > 0)
|
||||
.map((row) => (
|
||||
<LibrarySection key={row.library.id} row={row} />
|
||||
))}
|
||||
{/* ─── Media Libraries Grid Sections ─── */}
|
||||
<div className="space-y-12">
|
||||
{rows.filter((r) => r.cards.length > 0).map((row) => (
|
||||
<section key={row.library.id} className="space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-gray-200/80 pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="p-1.5 rounded-lg bg-gray-100 text-gray-900 border border-gray-200/50">
|
||||
{TYPE_ICONS[row.library.type] ?? <LibraryIcon size={18} />}
|
||||
</span>
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">
|
||||
{row.library.name}
|
||||
</h2>
|
||||
<span className="rounded-full bg-gray-100 border border-gray-200/80 px-2 py-0.5 text-[10px] font-bold text-gray-500 uppercase tracking-wider">
|
||||
{TYPE_LABELS[row.library.type] ?? row.library.type}
|
||||
</span>
|
||||
</div>
|
||||
<Link to={`/library/${row.library.id}`} className="group inline-flex items-center gap-1 text-xs font-bold text-gray-600 hover:text-brand-600 transition-colors">
|
||||
<span>浏览全部</span>
|
||||
<ChevronRight size={14} className="transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 旧数据兜底 */}
|
||||
{!loading && libraries.length === 0 && fallbackCards.length > 0 && (
|
||||
<section>
|
||||
<SectionHeading icon={<Clock size={16} />} label="最近添加" />
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{fallbackCards.map((s) => (
|
||||
<PosterCard key={s.rep.id} media={s.rep} count={s.count} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Quick Links */}
|
||||
{!empty && (
|
||||
<section>
|
||||
<SectionHeading icon={<Sparkles size={16} />} label="快捷入口" />
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<QuickLink to="/discover" label="发现" desc="探索新内容" />
|
||||
<QuickLink to="/favourites" label="收藏" desc="我的珍藏" />
|
||||
<QuickLink to="/history" label="历史" desc="观看记录" />
|
||||
<QuickLink to="/ai" label="AI" desc="智能推荐" />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Library 行 ─── */
|
||||
function LibrarySection({ row }: { row: LibraryRow }) {
|
||||
const lib = row.library
|
||||
const typeIcon = TYPE_ICONS[lib.type] ?? <LibraryIcon size={16} />
|
||||
const typeLabel = TYPE_LABELS[lib.type] ?? lib.type
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-cream-400">{typeIcon}</span>
|
||||
<h2 className="font-display text-lg font-semibold tracking-tight text-cream-200">
|
||||
{lib.name}
|
||||
</h2>
|
||||
<span className="text-xs text-cream-500">·</span>
|
||||
<span className="text-xs text-cream-500">{typeLabel}</span>
|
||||
</div>
|
||||
<Link
|
||||
to={`/library/${lib.id}`}
|
||||
className="flex items-center gap-1.5 text-xs text-cream-400 transition-colors hover:text-brand-400"
|
||||
>
|
||||
查看全部
|
||||
<ArrowRight size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{row.cards.map((s) => (
|
||||
<PosterCard key={s.rep.id} media={s.rep} count={s.count} />
|
||||
<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">
|
||||
{row.cards.map((s) => (
|
||||
<MediaCard
|
||||
key={s.rep.id}
|
||||
media={s.rep}
|
||||
count={s.count}
|
||||
linkTo={s.count > 1 ? `/library/${row.library.id}` : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Section Heading ─── */
|
||||
function SectionHeading({ icon, label }: { icon: React.ReactNode; label: string }) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<span className="text-cream-400">{icon}</span>
|
||||
<h2 className="font-display text-lg font-semibold tracking-tight text-cream-200">
|
||||
{label}
|
||||
</h2>
|
||||
{/* ─── Fallback Recents Section ─── */}
|
||||
{libraries.length === 0 && fallbackCards.length > 0 && (
|
||||
<section className="space-y-5">
|
||||
<div className="flex items-center justify-between border-b border-gray-200/80 pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="p-1.5 rounded-lg bg-gray-100 text-gray-900 border border-gray-200/50">
|
||||
<Clock size={18} />
|
||||
</span>
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">最近添加</h2>
|
||||
</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">
|
||||
{fallbackCards.map((s) => (
|
||||
<MediaCard key={s.rep.id} media={s.rep} count={s.count} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Wide Continue-Watching Card ─── */
|
||||
function WideContinueCard({ media, progress }: { media: Media; progress: number }) {
|
||||
function ContinueCard({ media, progress }: { media: Media; progress: number }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/media/${media.id}`}
|
||||
className="group flex shrink-0 w-72 items-center gap-4 rounded-lg border border-cream-900/15 bg-surface-400 p-3 transition-all hover:border-brand-500/30 hover:bg-surface-300"
|
||||
>
|
||||
<div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-md bg-surface-600">
|
||||
<Link to={`/media/${media.id}`} className="group flex items-center gap-4 rounded-2xl bg-white p-3.5 border border-gray-200/80 shadow-[0_1px_3px_rgba(0,0,0,0.01)] transition-all duration-300 hover:shadow-md hover:border-brand-500/30">
|
||||
<div className="relative h-18 w-12 shrink-0 overflow-hidden rounded-xl bg-gray-50 border border-gray-100">
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url)}
|
||||
alt={media.title}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
<img
|
||||
src={imageURL(media.poster_url)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-cream-900/30">
|
||||
<Film size={20} />
|
||||
<div className="flex h-full items-center justify-center text-gray-400 bg-gray-50">
|
||||
<Film size={16} />
|
||||
</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>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-cream-200 group-hover:text-cream-100">
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<p className="truncate text-sm font-bold text-gray-900 group-hover:text-brand-500 transition-colors">
|
||||
{media.title}
|
||||
</p>
|
||||
{media.year > 0 && (
|
||||
<p className="mt-0.5 text-xs text-cream-500">{media.year}</p>
|
||||
)}
|
||||
{progress > 0 && (
|
||||
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-cream-900/20">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand-500/70 transition-all group-hover:bg-brand-400"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||
<div className="space-y-1">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<Play
|
||||
size={14}
|
||||
className="shrink-0 text-cream-600 opacity-0 transition-all group-hover:opacity-100 group-hover:text-brand-400"
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Poster Card (grid item) ─── */
|
||||
function PosterCard({ media, count = 1 }: { media: Media; count?: number }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/media/${media.id}`}
|
||||
className="group block overflow-hidden rounded-lg border border-cream-900/15 bg-surface-400 transition-all hover:border-brand-500/30 hover:bg-surface-300"
|
||||
>
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-600">
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url)}
|
||||
alt={media.title}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-cream-900/30">
|
||||
<Film size={36} />
|
||||
</div>
|
||||
)}
|
||||
{count > 1 && (
|
||||
<span className="absolute right-1.5 top-1.5 inline-flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
|
||||
<Layers size={10} />
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-2.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<span className="flex items-center gap-1 text-[11px] text-white/90">
|
||||
<Play size={11} />
|
||||
播放
|
||||
</span>
|
||||
<p className="text-[10px] text-gray-400 font-bold tracking-wide uppercase">
|
||||
已观看到 {Math.round(progress * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2.5 py-2">
|
||||
<p className="truncate text-xs font-medium text-cream-200 group-hover:text-cream-100">
|
||||
{media.title}
|
||||
</p>
|
||||
{media.year > 0 && (
|
||||
<p className="mt-0.5 text-[11px] text-cream-500">{media.year}</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Quick Link Tile ─── */
|
||||
function QuickLink({ to, label, desc }: { to: string; label: string; desc: string }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="group flex items-center gap-3 rounded-lg border border-cream-900/15 bg-surface-400 px-4 py-3.5 transition-all hover:border-brand-500/20 hover:bg-surface-300"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-cream-300 group-hover:text-cream-100">{label}</p>
|
||||
<p className="mt-0.5 text-xs text-cream-500">{desc}</p>
|
||||
</div>
|
||||
<ArrowRight size={14} className="text-cream-600 transition-colors group-hover:text-brand-400" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
}
|
||||
+197
-79
@@ -1,72 +1,92 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ArrowLeft, Play, Film, Layers } from 'lucide-react'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { seriesAPI, type SeasonGroup } from '../api/series'
|
||||
import type { Library, Media } from '../types'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import { imageURL } from '../api/client'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { groupSeries, type SeriesCard } from '../utils/groupSeries'
|
||||
|
||||
// LibraryPage renders one of two layouts depending on the library type:
|
||||
//
|
||||
// - movie / music / default → flat poster grid with pagination.
|
||||
// - tv / anime → grouped by season with an episode list.
|
||||
//
|
||||
// Admins also get scan + scrape buttons in the header.
|
||||
export function LibraryPage() {
|
||||
const { id = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const role = useAuthStore((s) => s.user?.role)
|
||||
|
||||
const [library, setLibrary] = useState<Library | null>(null)
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [seasons, setSeasons] = useState<SeasonGroup[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [scraping, setScraping] = useState(false)
|
||||
|
||||
// 剧集模式:选中某个剧集后展开详情
|
||||
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
|
||||
|
||||
const isSeries = library?.type === 'tv' || library?.type === 'anime'
|
||||
|
||||
// 折叠后的剧集卡片
|
||||
const seriesCards = useMemo(() => {
|
||||
if (!isSeries || items.length === 0) return []
|
||||
return groupSeries(items)
|
||||
}, [isSeries, items])
|
||||
|
||||
// 选中的剧集:所有集按季分组
|
||||
const selectedEpisodes = useMemo(() => {
|
||||
if (!selectedSeries || items.length === 0) return []
|
||||
const title = selectedSeries.rep.title.toLowerCase()
|
||||
const eps = items.filter(
|
||||
(m) => (m.title ?? '').toLowerCase() === title || m.tmdb_id === selectedSeries.rep.tmdb_id,
|
||||
)
|
||||
// 按季分组,按集排序
|
||||
const seasons = new Map<number, Media[]>()
|
||||
for (const ep of eps) {
|
||||
const s = ep.season_num || 1
|
||||
if (!seasons.has(s)) seasons.set(s, [])
|
||||
seasons.get(s)!.push(ep)
|
||||
}
|
||||
for (const [, list] of seasons) {
|
||||
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||
}
|
||||
return Array.from(seasons.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([season, episodes]) => ({ season, episodes }))
|
||||
}, [selectedSeries, items])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
libraryAPI.list().then((all) => all.find((l) => l.id === id) ?? null),
|
||||
]).then(([lib]) => setLibrary(lib))
|
||||
libraryAPI.list().then((all) => {
|
||||
const lib = all.find((l) => l.id === id) ?? null
|
||||
setLibrary(lib)
|
||||
})
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !library) return
|
||||
setLoading(true)
|
||||
if (library.type === 'tv' || library.type === 'anime') {
|
||||
seriesAPI
|
||||
.seasons(id)
|
||||
.then((s) => setSeasons(s))
|
||||
.finally(() => setLoading(false))
|
||||
} else {
|
||||
libraryAPI
|
||||
.listMedia(id)
|
||||
.then((d) => {
|
||||
setItems(d.items)
|
||||
setTotal(d.total)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
// tv/anime 拉取较多集数以支持前端分组
|
||||
const limit = library.type === 'tv' || library.type === 'anime' ? 1000 : 200
|
||||
libraryAPI
|
||||
.listMedia(id, 1, limit)
|
||||
.then((d) => {
|
||||
setItems(d.items)
|
||||
setTotal(d.total)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [id, library])
|
||||
|
||||
const handleScan = async () => {
|
||||
setScanning(true)
|
||||
try {
|
||||
const r = await libraryAPI.scan(id)
|
||||
toast.success(`扫描完成:新增 ${r.added} 项,探测 ${r.probed} 项`)
|
||||
// Force a reload by toggling library state.
|
||||
toast.success(`扫描完成:新增 ${r.added} 项`)
|
||||
setLibrary((l) => (l ? { ...l } : l))
|
||||
} catch {
|
||||
toast.error('扫描失败')
|
||||
} finally {
|
||||
setScanning(false)
|
||||
}
|
||||
} finally { setScanning(false) }
|
||||
}
|
||||
|
||||
const handleScrape = async () => {
|
||||
@@ -74,79 +94,177 @@ export function LibraryPage() {
|
||||
try {
|
||||
await libraryAPI.scrape(id)
|
||||
toast.success('刮削已加入后台队列')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'刮削失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setScraping(false)
|
||||
}
|
||||
} catch { toast.error('刮削失败') }
|
||||
finally { setScraping(false) }
|
||||
}
|
||||
|
||||
const heading = useMemo(
|
||||
() => (library ? `${library.name}` : '媒体库'),
|
||||
[library],
|
||||
)
|
||||
const handleSeriesClick = (card: SeriesCard) => {
|
||||
setSelectedSeries(card)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<motion.div animate={{ opacity: [0.4, 1, 0.4] }} transition={{ repeat: Infinity, duration: 2 }} className="flex items-center gap-3">
|
||||
<div className="h-2 w-2 rounded-full bg-brand-500" />
|
||||
<span className="text-sm text-sand-500">加载中…</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">
|
||||
{heading}
|
||||
{!isSeries && <span className="text-slate-500"> ({total})</span>}
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">
|
||||
{library?.name ?? '媒体库'}
|
||||
{!isSeries && <span className="text-sand-500"> ({total})</span>}
|
||||
</h1>
|
||||
{library && (
|
||||
<p className="text-sm text-slate-400">
|
||||
{library.type} · {library.path}
|
||||
</p>
|
||||
)}
|
||||
{library && <p className="text-sm text-ink-50">{library.type} · {library.path}</p>}
|
||||
</div>
|
||||
{role === 'admin' && (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleScan} disabled={scanning} className="neon-button">
|
||||
{scanning ? '扫描中…' : '立即扫描'}
|
||||
</button>
|
||||
<button onClick={handleScrape} disabled={scraping} className="neon-button">
|
||||
{scraping ? '刮削中…' : '刮削元数据'}
|
||||
</button>
|
||||
<button onClick={handleScan} disabled={scanning} className="btn-outline">{scanning ? '扫描中…' : '立即扫描'}</button>
|
||||
<button onClick={handleScrape} disabled={scraping} className="btn-outline">{scraping ? '刮削中…' : '刮削元数据'}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
|
||||
{!isSeries && (
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{/* 非剧集:直接展示海报网格 */}
|
||||
{!isSeries && items.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSeries &&
|
||||
seasons.map((s) => (
|
||||
<section key={s.season} className="space-y-3">
|
||||
<h2 className="font-display text-xl font-semibold text-white">
|
||||
{s.season > 0 ? `第 ${s.season} 季` : '未分季'} ({s.episodes.length})
|
||||
</h2>
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{s.episodes.map((e) => (
|
||||
<div key={e.id} className="space-y-1">
|
||||
<MediaCard media={e} />
|
||||
{e.episode_num > 0 && (
|
||||
<p className="px-1 text-xs text-slate-400">第 {e.episode_num} 集</p>
|
||||
)}
|
||||
{!isSeries && items.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<Film className="h-12 w-12 text-sand-400 mb-4" />
|
||||
<p className="text-ink-50">该媒体库暂无内容,触发一次扫描后再来看看</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 剧集模式:折叠卡片网格 */}
|
||||
{isSeries && seriesCards.length > 0 && !selectedSeries && (
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
|
||||
{seriesCards.map((s) => (
|
||||
<button key={s.rep.id} onClick={() => handleSeriesClick(s)} className="text-left">
|
||||
<MediaCard media={s.rep} count={s.count} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 剧集详情:季/集选择器 */}
|
||||
<AnimatePresence mode="wait">
|
||||
{selectedSeries && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* 返回按钮 + 标题 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setSelectedSeries(null)}
|
||||
className="btn-ghost gap-2"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
返回列表
|
||||
</button>
|
||||
<h2 className="font-display text-2xl font-bold text-ink-600 truncate">
|
||||
{selectedSeries.rep.title}
|
||||
</h2>
|
||||
<span className="text-sm text-sand-500">共 {selectedSeries.count} 集</span>
|
||||
</div>
|
||||
|
||||
{/* 海报 + 从第一集开始播放 */}
|
||||
<div className="flex flex-col sm:flex-row gap-6">
|
||||
<div className="w-40 shrink-0 overflow-hidden rounded-xl bg-sand-200 shadow-card">
|
||||
{selectedSeries.rep.poster_url ? (
|
||||
<img src={imageURL(selectedSeries.rep.poster_url)} alt={selectedSeries.rep.title} className="w-full aspect-[2/3] object-cover" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<div className="flex items-center justify-center aspect-[2/3] text-sand-400"><Film size={40} /></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<p className="text-sm text-ink-50 leading-relaxed">
|
||||
{selectedSeries.rep.overview || '暂无简介'}
|
||||
</p>
|
||||
|
||||
{/* 从第一集开始 */}
|
||||
{(() => {
|
||||
const firstEps = selectedEpisodes.flatMap((s) => s.episodes).filter((e) => e.episode_num > 0).sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
||||
const first = firstEps.length > 0 ? firstEps[0] : null
|
||||
return first ? (
|
||||
<Link to={`/play/${first.id}`} className="btn-primary inline-flex">
|
||||
<Play size={16} fill="currentColor" />
|
||||
从第一集开始播放
|
||||
</Link>
|
||||
) : null
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 季 / 集列表 */}
|
||||
<div className="space-y-6">
|
||||
{selectedEpisodes.map(({ season, episodes }) => (
|
||||
<div key={season}>
|
||||
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
|
||||
第 {season} 季 · {episodes.length} 集
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{episodes.map((ep) => (
|
||||
<Link
|
||||
key={ep.id}
|
||||
to={`/play/${ep.id}`}
|
||||
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-brand-50 text-brand-600 font-semibold text-sm">
|
||||
{ep.episode_num || '—'}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-ink-600">
|
||||
{ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : ep.title}
|
||||
</p>
|
||||
<p className="text-xs text-sand-500">
|
||||
{ep.duration_sec > 0
|
||||
? `${Math.floor(ep.duration_sec / 60)} 分钟`
|
||||
: formatSize(ep.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<Play size={14} className="shrink-0 text-sand-400 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{isSeries && !loading && seasons.length === 0 && (
|
||||
<p className="text-slate-400">该库尚未发现任何剧集,触发一次扫描后再来看看。</p>
|
||||
{/* 剧集为空 */}
|
||||
{isSeries && seriesCards.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<Film className="h-12 w-12 text-sand-400 mb-4" />
|
||||
<p className="text-ink-50">该库尚未发现任何剧集,触发一次扫描后再来看看</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes || bytes <= 0) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let v = bytes, i = 0
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
||||
return `${v.toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
@@ -102,16 +102,16 @@ export function LicensePage() {
|
||||
<KeySquare size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">许可证</h1>
|
||||
<p className="text-sm text-slate-400">绑定授权密钥以解锁全部功能</p>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">许可证</h1>
|
||||
<p className="text-sm text-ink-50">绑定授权密钥以解锁全部功能</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Bind Form ── */}
|
||||
<div className="glass-panel space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 size={18} className="text-primary-400" />
|
||||
<h2 className="font-display text-lg font-semibold text-white">绑定许可证</h2>
|
||||
<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
|
||||
@@ -126,14 +126,14 @@ export function LicensePage() {
|
||||
绑定
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-sand-500">
|
||||
输入从授权服务器获取的许可证密钥,激活后即可使用所有高级功能。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── License Status ── */}
|
||||
{loadingStatus ? (
|
||||
<div className="flex justify-center py-8 text-slate-400">
|
||||
<div className="flex justify-center py-8 text-ink-50">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
@@ -143,14 +143,14 @@ export function LicensePage() {
|
||||
{active && !isExpired ? (
|
||||
<CheckCircle2 size={18} className="text-emerald-400" />
|
||||
) : (
|
||||
<XCircle size={18} className="text-slate-500" />
|
||||
<XCircle size={18} className="text-sand-500" />
|
||||
)}
|
||||
<h2 className="font-display text-lg font-semibold text-white">当前状态</h2>
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">当前状态</h2>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={refreshStatus}
|
||||
className="rounded border border-white/10 p-2 text-slate-400 hover:bg-white/5 hover:text-white"
|
||||
className="rounded border border-white/10 p-2 text-ink-50 hover:bg-white/5 hover:text-white"
|
||||
title="刷新状态"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
@@ -158,7 +158,7 @@ export function LicensePage() {
|
||||
{active && (
|
||||
<button
|
||||
onClick={onHeartbeat}
|
||||
className="rounded border border-primary-400/30 px-3 py-1.5 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||
className="rounded border border-primary-400/30 px-3 py-1.5 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
>
|
||||
心跳上报
|
||||
</button>
|
||||
@@ -168,7 +168,7 @@ export function LicensePage() {
|
||||
|
||||
{!active && (
|
||||
<div className="space-y-3 rounded-lg border border-white/5 bg-white/[0.02] p-4 text-center">
|
||||
<p className="text-slate-400">
|
||||
<p className="text-ink-50">
|
||||
尚未绑定许可证。请在上方输入密钥完成激活。
|
||||
</p>
|
||||
</div>
|
||||
@@ -184,8 +184,8 @@ export function LicensePage() {
|
||||
activation.plan === 'enterprise'
|
||||
? 'text-amber-300'
|
||||
: activation.plan === 'pro'
|
||||
? 'text-primary-400'
|
||||
: 'text-slate-300'
|
||||
? 'text-brand-500'
|
||||
: 'text-ink-100'
|
||||
}
|
||||
/>
|
||||
<StatusBadge label="设备名称" value={activation.device_name || 'Web Client'} />
|
||||
@@ -212,10 +212,10 @@ export function LicensePage() {
|
||||
{/* ── Pro tip ── */}
|
||||
{!active && !loadingStatus && (
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-5 text-center">
|
||||
<p className="text-sm text-slate-500">
|
||||
<p className="text-sm text-sand-500">
|
||||
需要获取许可证?请联系管理员获取 MediaStationGo 授权密钥。
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-600">
|
||||
<p className="mt-1 text-xs text-sand-400">
|
||||
授权服务器地址可在系统设置中配置
|
||||
</p>
|
||||
</div>
|
||||
@@ -239,8 +239,8 @@ function StatusBadge({
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-white/5 bg-white/[0.02] p-3">
|
||||
<p className="mb-0.5 text-xs text-slate-500">{label}</p>
|
||||
<p className={`text-sm text-white ${mono ? 'font-mono' : ''} ${className ?? ''}`}>
|
||||
<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>
|
||||
|
||||
+168
-61
@@ -1,19 +1,18 @@
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Film } from 'lucide-react'
|
||||
|
||||
import { Eye, EyeOff, Film, ArrowRight, ShieldCheck, Lock, User } from 'lucide-react'
|
||||
import { AppFooter } from '../components/AppFooter'
|
||||
import { authAPI } from '../api/auth'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
// Single-form login screen. The first install seeds admin/admin123 — we
|
||||
// surface that hint when no JWT exists yet.
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const setSession = useAuthStore((s) => s.setSession)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPw, setShowPw] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
@@ -25,67 +24,175 @@ export function LoginPage() {
|
||||
toast.success(`欢迎回来, ${data.user.username}`)
|
||||
navigate('/')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'登录失败,请检查用户名与密码'
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '登录失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center px-4">
|
||||
{/* 表单居中区域:flex-grow + flex center 使表单在剩余空间内垂直居中 */}
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<form onSubmit={handleSubmit} className="glass-panel w-full max-w-md">
|
||||
<div className="mb-8 flex flex-col items-center gap-2">
|
||||
<Film className="h-10 w-10 text-primary-400" />
|
||||
<h1 className="font-display text-2xl font-bold tracking-wide text-white">
|
||||
MediaStationGo
|
||||
</h1>
|
||||
<p className="text-sm text-slate-400">登录到你的家庭媒体中心</p>
|
||||
</div>
|
||||
|
||||
<label className="mb-4 block">
|
||||
<span className="mb-1 block text-sm text-slate-300">用户名</span>
|
||||
<input
|
||||
className="input-base"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mb-6 block">
|
||||
<span className="mb-1 block text-sm text-slate-300">密码</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input-base"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="neon-button w-full justify-center"
|
||||
>
|
||||
{loading ? '登录中…' : '登录'}
|
||||
</button>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-slate-500">
|
||||
首次部署默认账号:<code className="text-primary-400">admin / admin123</code>
|
||||
</p>
|
||||
</form>
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center bg-gray-50/50 overflow-hidden px-4">
|
||||
{/* ── Editorial Soft Geometric Background Decor ── */}
|
||||
<div className="pointer-events-none absolute inset-0 z-0">
|
||||
<div className="absolute -right-20 -top-20 h-[600px] w-[600px] rounded-full bg-brand-100/30 blur-[130px]" />
|
||||
<div className="absolute -bottom-40 -left-20 h-[500px] w-[500px] rounded-full bg-sage-100/30 blur-[110px]" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] [background-size:20px_20px] opacity-40" />
|
||||
</div>
|
||||
|
||||
{/* 页脚始终在底部 */}
|
||||
<AppFooter className="mb-4 mt-auto" />
|
||||
{/* ── Main Container ── */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="relative z-10 w-full max-w-[420px] px-2"
|
||||
>
|
||||
{/* High-Contrast Swiss White Panel */}
|
||||
<div className="rounded-3xl border border-gray-200/90 bg-white p-8 sm:p-10 shadow-[0_25px_60px_rgba(17,24,39,0.04),0_1px_3px_rgba(17,24,39,0.01)]">
|
||||
|
||||
{/* Logo & Headline */}
|
||||
<div className="flex flex-col items-center text-center pb-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.15, type: 'spring', stiffness: 200 }}
|
||||
className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-[#111827] shadow-sm"
|
||||
>
|
||||
<Film className="h-7 w-7 text-[#c9954a]" />
|
||||
</motion.div>
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
className="font-display text-2xl font-extrabold tracking-tight text-gray-900"
|
||||
>
|
||||
MediaStation
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.35 }}
|
||||
className="mt-2 text-xs font-bold uppercase tracking-widest text-[#c9954a]"
|
||||
>
|
||||
家庭媒体私有站舱
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Username Input with Integrated Icon */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<label className="block mb-2 text-xs font-bold uppercase tracking-widest text-gray-500">
|
||||
用户名
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<User size={16} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-xl border border-gray-200 bg-gray-50/50 px-11 py-3.5 text-sm text-gray-900 placeholder-gray-400 outline-none transition-all duration-300 focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/50"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
placeholder="请输入您的账号"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Password Input with Integrated Icon */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.45 }}
|
||||
>
|
||||
<label className="block mb-2 text-xs font-bold uppercase tracking-widest text-gray-500">
|
||||
安全密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<Lock size={16} />
|
||||
</span>
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'}
|
||||
className="w-full rounded-xl border border-gray-200 bg-gray-50/50 px-11 py-3.5 text-sm text-gray-900 placeholder-gray-400 outline-none transition-all duration-300 focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/50 pr-12"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPw(!showPw)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-900"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="pt-2"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl bg-[#111827] py-3.5 text-sm font-bold text-white transition-all duration-300 hover:bg-[#1f2937] hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98] disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="h-4 w-4 animate-spin text-white" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
正在进入系统舱...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
立即开启观影之旅 <ArrowRight size={16} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
{/* Quick Helper Credentials */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.55 }}
|
||||
className="flex items-center justify-center gap-1.5 rounded-xl bg-gray-50 border border-gray-100 px-4 py-2.5 text-xs text-gray-500"
|
||||
>
|
||||
<ShieldCheck size={14} className="text-brand-500" />
|
||||
<span>默认访问凭证: </span>
|
||||
<code className="rounded bg-gray-200/50 px-1.5 py-0.5 font-mono text-[#111827] font-semibold">
|
||||
admin / admin123
|
||||
</code>
|
||||
</motion.div>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Footer */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="relative z-10 mt-10 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<AppFooter />
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
+215
-103
@@ -1,6 +1,7 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2 } from 'lucide-react'
|
||||
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2, Calendar, Database } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
@@ -35,14 +36,6 @@ function parseCSV(s?: string): string[] {
|
||||
return s.split(',').map(x => x.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
// Detail screen for a single media item.
|
||||
//
|
||||
// Buttons:
|
||||
// - Play (direct play)
|
||||
// - HLS (force transcoded play)
|
||||
// - Favourite (toggle, all users)
|
||||
// - Re-scrape (admin only, triggers TMDb lookup)
|
||||
// - Re-probe (admin only, refreshes ffprobe metadata)
|
||||
export function MediaDetailPage() {
|
||||
const { id = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
@@ -71,13 +64,13 @@ export function MediaDetailPage() {
|
||||
if (!media) return
|
||||
const state = await playbackAPI.toggleFavourite(media.id)
|
||||
setFavourite(state)
|
||||
toast.success(state ? '已加入收藏' : '已取消收藏')
|
||||
toast.success(state ? '已加入我的收藏' : '已取消收藏')
|
||||
}
|
||||
|
||||
const rescrape = async () => {
|
||||
if (!media) return
|
||||
await api.post(`/media/${media.id}/scrape`)
|
||||
toast.success('已刮削')
|
||||
toast.success('已触发重新刮削')
|
||||
await refresh()
|
||||
}
|
||||
|
||||
@@ -86,7 +79,7 @@ export function MediaDetailPage() {
|
||||
try {
|
||||
const r = await api.post(`/media/${media.id}/probe`)
|
||||
if (r.data?.code === 0) {
|
||||
toast.success('已重新探测')
|
||||
toast.success('重新探测成功')
|
||||
} else {
|
||||
toast.error(r.data?.error || '探测失败')
|
||||
}
|
||||
@@ -103,7 +96,7 @@ export function MediaDetailPage() {
|
||||
if (!media) return
|
||||
try {
|
||||
const r = await recycleAPI.exportNFO(media.id)
|
||||
toast.success(`NFO 已写入 ${r.path}`)
|
||||
toast.success(`NFO 已成功写入 ${r.path}`)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
@@ -120,106 +113,225 @@ export function MediaDetailPage() {
|
||||
navigate(-1)
|
||||
}
|
||||
|
||||
if (loading) return <p className="text-slate-500">加载中…</p>
|
||||
if (!media) return <p className="text-slate-500">媒体不存在</p>
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-48">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-100 border-t-gray-900" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!media) {
|
||||
return (
|
||||
<div className="text-center py-24 bg-white rounded-2xl border border-gray-200">
|
||||
<p className="text-gray-500">媒体资源已被移除或不存在</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-6 md:flex-row">
|
||||
<div className="aspect-[2/3] w-48 shrink-0 overflow-hidden rounded-xl bg-surface-800">
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url)}
|
||||
alt={media.title}
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-slate-600">
|
||||
无海报
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-4">
|
||||
<h1 className="font-display text-3xl font-bold text-white">{media.title}</h1>
|
||||
{media.year > 0 && <p className="text-slate-400">{media.year}</p>}
|
||||
{media.overview && <p className="text-slate-300">{media.overview}</p>}
|
||||
<div className="relative overflow-hidden rounded-3xl bg-white border border-gray-200/90 shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)]">
|
||||
{/* ── Cinematic Blurred Backdrop Glow ── */}
|
||||
<div className="absolute inset-0 h-[480px] z-0 overflow-hidden">
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover opacity-[0.04] scale-110 blur-2xl"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-b from-gray-50 to-transparent" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-white via-white/95 to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs text-slate-400">
|
||||
{media.video_codec && <Badge>{media.video_codec.toUpperCase()}</Badge>}
|
||||
{media.width > 0 && (
|
||||
<Badge>
|
||||
{media.width}×{media.height}
|
||||
</Badge>
|
||||
{/* ── Main Details Layout Container ── */}
|
||||
<div className="relative z-10 p-6 sm:p-10 flex flex-col md:flex-row gap-8 lg:gap-12">
|
||||
{/* Poster Card */}
|
||||
<div className="w-56 shrink-0 mx-auto md:mx-0">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02 }}
|
||||
className="aspect-[2/3] w-full rounded-2xl overflow-hidden bg-gray-50 border border-gray-200 shadow-md relative group"
|
||||
>
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url)}
|
||||
alt={media.title}
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-gray-400 bg-gray-50">
|
||||
<FileText size={40} className="stroke-[1]" />
|
||||
<span className="text-xs uppercase tracking-wider font-bold">无海报</span>
|
||||
</div>
|
||||
)}
|
||||
<Badge>{fmtDuration(media.duration_sec)}</Badge>
|
||||
<Badge>{fmtSize(media.size_bytes)}</Badge>
|
||||
{media.container && <Badge>{media.container.toUpperCase()}</Badge>}
|
||||
<Badge>{media.scrape_status}</Badge>
|
||||
{parseCSV(media.languages).map(l => (
|
||||
<Badge key={l} className="border-blue-400/30 text-blue-400 bg-blue-400/10">{l}</Badge>
|
||||
))}
|
||||
{parseCSV(media.countries).map(c => (
|
||||
<Badge key={c} className="border-green-400/30 text-green-400 bg-green-400/10">{c}</Badge>
|
||||
))}
|
||||
{parseCSV(media.genres).map(g => (
|
||||
<Badge key={g} className="border-purple-400/30 text-purple-400 bg-purple-400/10">{g}</Badge>
|
||||
))}
|
||||
|
||||
{/* Quick Play overlay button */}
|
||||
<Link
|
||||
to={`/play/${media.id}`}
|
||||
className="absolute inset-0 bg-[#111827]/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
>
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-brand-500 text-white shadow-xl transform scale-90 group-hover:scale-100 transition-transform">
|
||||
<Play size={24} fill="currentColor" />
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Metadata Body */}
|
||||
<div className="flex-1 space-y-6">
|
||||
{/* Title and Year Header */}
|
||||
<div className="space-y-3">
|
||||
<h1 className="font-display text-3xl sm:text-4xl font-extrabold tracking-tight text-gray-900 leading-tight">
|
||||
{media.title}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-2.5 text-xs text-gray-500 font-bold tracking-wide uppercase">
|
||||
{media.year > 0 && (
|
||||
<span className="inline-flex items-center gap-1 bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-lg text-gray-700">
|
||||
<Calendar size={13} className="text-brand-500" />
|
||||
<span>{media.year} 年</span>
|
||||
</span>
|
||||
)}
|
||||
{media.width > 0 && (
|
||||
<span className="inline-flex items-center gap-1 bg-brand-50 text-brand-700 border border-brand-100/50 px-2.5 py-1 rounded-lg">
|
||||
<span>{media.width} × {media.height}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-lg text-gray-700">
|
||||
{fmtSize(media.size_bytes)}
|
||||
</span>
|
||||
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-lg text-gray-700">
|
||||
{fmtDuration(media.duration_sec)}
|
||||
</span>
|
||||
{media.container && (
|
||||
<span className="bg-gray-100 border border-gray-200/50 px-2.5 py-1 rounded-lg text-gray-700 font-mono">
|
||||
{media.container}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link to={`/play/${media.id}`} className="neon-button">
|
||||
<Play size={18} /> 播放
|
||||
</Link>
|
||||
<Link
|
||||
to={`/play/${media.id}?mode=hls`}
|
||||
className="neon-button !border-accent-400/40 !bg-accent-400/10 !text-accent-400 hover:!border-accent-400"
|
||||
>
|
||||
<RefreshCw size={16} /> HLS 转码播放
|
||||
</Link>
|
||||
<button
|
||||
onClick={toggleFav}
|
||||
className={
|
||||
'neon-button ' +
|
||||
(favourite
|
||||
? '!border-pink-400/60 !bg-pink-400/10 !text-pink-400'
|
||||
: '')
|
||||
}
|
||||
>
|
||||
<Heart size={16} fill={favourite ? 'currentColor' : 'none'} />
|
||||
{favourite ? '已收藏' : '收藏'}
|
||||
</button>
|
||||
{/* Description Card */}
|
||||
{media.overview && (
|
||||
<div className="rounded-2xl bg-gray-50/50 border border-gray-100 p-5 space-y-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-brand-500">剧情简介</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed font-semibold">
|
||||
{media.overview}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tag Rows (Genres, Languages, Countries) */}
|
||||
<div className="space-y-4">
|
||||
{/* Genres */}
|
||||
{parseCSV(media.genres).length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs font-bold text-gray-400 w-16 uppercase tracking-wider">类型流派</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{parseCSV(media.genres).map((g) => (
|
||||
<span key={g} className="rounded-full bg-brand-50 text-brand-700 border border-brand-100/30 px-3 py-1 text-2xs font-bold uppercase tracking-wider">
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Languages */}
|
||||
{parseCSV(media.languages).length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs font-bold text-gray-400 w-16 uppercase tracking-wider">语言</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{parseCSV(media.languages).map((l) => (
|
||||
<span key={l} className="rounded-lg bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold">
|
||||
{l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Countries */}
|
||||
{parseCSV(media.countries).length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs font-bold text-gray-400 w-16 uppercase tracking-wider">国家/地区</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{parseCSV(media.countries).map((c) => (
|
||||
<span key={c} className="rounded-lg bg-gray-100 text-gray-600 border border-gray-200/40 px-2.5 py-1 text-2xs font-semibold">
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="divider border-gray-200/60" />
|
||||
|
||||
{/* Action Buttons Panel */}
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Primary Direct Play */}
|
||||
<Link to={`/play/${media.id}`} className="btn-primary px-6 py-3.5 shadow-sm">
|
||||
<Play size={16} fill="currentColor" />
|
||||
<span>立即播放</span>
|
||||
</Link>
|
||||
|
||||
{/* Transcode Playback */}
|
||||
<Link
|
||||
to={`/play/${media.id}?mode=hls`}
|
||||
className="btn-outline border-brand-500/30 hover:border-brand-500 text-[#c9954a] hover:bg-brand-50 px-5"
|
||||
>
|
||||
<RefreshCw size={14} className="animate-spin-slow" />
|
||||
<span>HLS 兼容转码播放</span>
|
||||
</Link>
|
||||
|
||||
{/* Toggle Favourites */}
|
||||
<button
|
||||
onClick={toggleFav}
|
||||
className={
|
||||
'btn-outline gap-2 ' +
|
||||
(favourite
|
||||
? '!border-red-200 !bg-red-50 !text-red-600 hover:!bg-red-100/50'
|
||||
: 'hover:border-red-200 hover:text-red-600 hover:bg-red-50/50')
|
||||
}
|
||||
>
|
||||
<Heart size={14} fill={favourite ? 'currentColor' : 'none'} />
|
||||
<span>{favourite ? '取消收藏' : '加入收藏'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Admin Management Toolbar */}
|
||||
{role === 'admin' && (
|
||||
<>
|
||||
<button onClick={rescrape} className="neon-button">
|
||||
<Sparkles size={16} /> 重新刮削
|
||||
</button>
|
||||
<button onClick={reprobe} className="neon-button">
|
||||
<RefreshCw size={16} /> 重新探测
|
||||
</button>
|
||||
<button onClick={exportNFO} className="neon-button">
|
||||
<FileText size={16} /> 导出 NFO
|
||||
</button>
|
||||
<button
|
||||
onClick={softDelete}
|
||||
className="neon-button !border-red-400/40 !bg-red-400/10 !text-red-400"
|
||||
>
|
||||
<Trash2 size={16} /> 移至回收站
|
||||
</button>
|
||||
</>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50/50 p-5 space-y-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={rescrape} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Sparkles size={13} className="text-[#c9954a]" />
|
||||
<span>智能刮削 (TMDB)</span>
|
||||
</button>
|
||||
<button onClick={reprobe} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<Database size={13} className="text-gray-600" />
|
||||
<span>探测媒体轨 (ffprobe)</span>
|
||||
</button>
|
||||
<button onClick={exportNFO} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
|
||||
<FileText size={13} />
|
||||
<span>写出本地 NFO 属性</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={softDelete}
|
||||
className="btn-outline py-2 px-3.5 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
<span>移入回收站</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Badge({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||
const base = "rounded border border-white/10 bg-white/5 px-2 py-0.5"
|
||||
return (
|
||||
<span className={`${base} ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -62,8 +62,8 @@ export function NotifyChannelsPage() {
|
||||
<Bell size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">通知渠道</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">通知渠道</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
配置 Telegram / Bark / 企业微信 / Webhook 多通道推送
|
||||
</p>
|
||||
</div>
|
||||
@@ -80,13 +80,13 @@ export function NotifyChannelsPage() {
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12 text-slate-400">
|
||||
<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-slate-400">暂无通知渠道</div>
|
||||
<div className="glass-panel py-12 text-center text-ink-50">暂无通知渠道</div>
|
||||
)}
|
||||
|
||||
{!loading && channels.length > 0 && (
|
||||
@@ -144,26 +144,26 @@ function ChannelCard({
|
||||
<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-white">{channel.name}</span>
|
||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">
|
||||
<span className="font-medium text-ink-600">{channel.name}</span>
|
||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-ink-50">
|
||||
{TYPE_LABELS[channel.channel_type] ?? channel.channel_type}
|
||||
</span>
|
||||
{!channel.enabled && (
|
||||
<span className="rounded bg-slate-500/30 px-2 py-0.5 text-xs text-slate-300">已禁用</span>
|
||||
<span className="rounded bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">已禁用</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-slate-400">{summary}</div>
|
||||
<div className="mt-1 truncate text-xs text-ink-50">{summary}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={onTest}
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<Send size={12} className="inline" /> 测试
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<Pencil size={12} className="inline" /> 编辑
|
||||
</button>
|
||||
@@ -262,7 +262,7 @@ function ChannelFormModal({
|
||||
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-white">
|
||||
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
|
||||
{editing ? '编辑通知渠道' : '添加通知渠道'}
|
||||
</h2>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
@@ -460,7 +460,7 @@ function ChannelFormModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-300">
|
||||
<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"
|
||||
@@ -474,7 +474,7 @@ function ChannelFormModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-ink-100 hover:bg-white/5"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
@@ -491,7 +491,7 @@ function ChannelFormModal({
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">{label}</span>
|
||||
<span className="mb-1 block text-sm text-ink-100">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
|
||||
@@ -130,7 +130,7 @@ export function PlayerPage() {
|
||||
<div className="-m-6 flex min-h-screen flex-col bg-black md:-m-8">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="absolute left-4 top-4 z-10 flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-white backdrop-blur transition hover:bg-black/80"
|
||||
className="absolute left-4 top-4 z-10 flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-ink-600 backdrop-blur transition hover:bg-black/80"
|
||||
>
|
||||
<ArrowLeft size={16} /> 返回
|
||||
</button>
|
||||
@@ -142,7 +142,7 @@ export function PlayerPage() {
|
||||
params.set('mode', next)
|
||||
setParams(params, { replace: true })
|
||||
}}
|
||||
className="absolute right-4 top-4 z-10 flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-white backdrop-blur transition hover:bg-black/80"
|
||||
className="absolute right-4 top-4 z-10 flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-ink-600 backdrop-blur transition hover:bg-black/80"
|
||||
title="切换播放模式"
|
||||
>
|
||||
{mode === 'hls' ? (
|
||||
@@ -189,7 +189,7 @@ export function PlayerPage() {
|
||||
))}
|
||||
</video>
|
||||
) : (
|
||||
<p className="text-slate-500">加载中…</p>
|
||||
<p className="text-sand-500">加载中…</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,18 +18,18 @@ export function PlaylistDetailPage() {
|
||||
}, [id])
|
||||
|
||||
if (!detail) {
|
||||
return <p className="text-slate-500">加载中…</p>
|
||||
return <p className="text-sand-500">加载中…</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="font-display text-3xl font-bold text-white">{detail.playlist.name}</h1>
|
||||
<p className="text-sm text-slate-400">{detail.items.length} 部影片</p>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">{detail.playlist.name}</h1>
|
||||
<p className="text-sm text-ink-50">{detail.items.length} 部影片</p>
|
||||
</header>
|
||||
|
||||
{detail.items.length === 0 && (
|
||||
<p className="text-slate-400">暂无内容,前往媒体详情页通过「加入播放列表」添加。</p>
|
||||
<p className="text-ink-50">暂无内容,前往媒体详情页通过「加入播放列表」添加。</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
@@ -37,7 +37,7 @@ export function PlaylistDetailPage() {
|
||||
<div key={m.id} className="relative">
|
||||
<MediaCard media={m} />
|
||||
<button
|
||||
className="absolute right-2 top-2 rounded-full bg-black/70 p-1 text-white transition hover:bg-red-600"
|
||||
className="absolute right-2 top-2 rounded-full bg-black/70 p-1 text-ink-600 transition hover:bg-red-600"
|
||||
onClick={async () => {
|
||||
await playbackAPI.removeFromPlaylist(detail.playlist.id, m.id)
|
||||
toast.success('已移除')
|
||||
|
||||
@@ -37,7 +37,7 @@ export function PlaylistsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-white">播放列表</h1>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">播放列表</h1>
|
||||
|
||||
<form onSubmit={onCreate} className="glass-panel grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<input
|
||||
@@ -52,8 +52,8 @@ export function PlaylistsPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && <p className="text-slate-400">还没有任何播放列表。</p>}
|
||||
{loading && <p className="text-sand-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && <p className="text-ink-50">还没有任何播放列表。</p>}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((p) => (
|
||||
@@ -63,11 +63,11 @@ export function PlaylistsPage() {
|
||||
>
|
||||
<Link
|
||||
to={`/playlist/${p.id}`}
|
||||
className="font-medium text-white transition hover:text-primary-400"
|
||||
className="font-medium text-ink-600 transition hover:text-brand-500"
|
||||
>
|
||||
{p.name}
|
||||
{p.is_public && (
|
||||
<span className="ml-2 rounded border border-primary-400/40 px-1.5 py-0.5 text-xs text-primary-400">
|
||||
<span className="ml-2 rounded border border-primary-400/40 px-1.5 py-0.5 text-xs text-brand-500">
|
||||
public
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -26,25 +26,25 @@ export function PosterWallPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<GalleryHorizontalEnd className="h-6 w-6 text-primary-400" />
|
||||
<GalleryHorizontalEnd className="h-6 w-6 text-brand-500" />
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">海报墙</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">海报墙</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
按剧集聚合 · 共 {cards.length} 个条目
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
{loading && <p className="text-sand-500">加载中…</p>}
|
||||
{!loading && cards.length === 0 && (
|
||||
<p className="text-slate-400">暂无媒体。请先添加媒体库并扫描。</p>
|
||||
<p className="text-ink-50">暂无媒体。请先添加媒体库并扫描。</p>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10">
|
||||
{cards.map((s) => (
|
||||
<div key={s.rep.id} className="relative">
|
||||
<MediaCard media={s.rep} />
|
||||
{s.count > 1 && (
|
||||
<span className="pointer-events-none absolute right-1.5 top-1.5 inline-flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm">
|
||||
<span className="pointer-events-none absolute right-1.5 top-1.5 inline-flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-ink-600 backdrop-blur-sm">
|
||||
<Layers size={10} />
|
||||
{s.count}
|
||||
</span>
|
||||
|
||||
@@ -72,8 +72,8 @@ export function ProfileManagementPage() {
|
||||
<UserCog size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">观影 Profile</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">观影 Profile</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
为不同场景(儿童 / 影院 / 成人)定义独立的内容分级和媒体库访问规则
|
||||
</p>
|
||||
</div>
|
||||
@@ -84,7 +84,7 @@ export function ProfileManagementPage() {
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12 text-slate-400">
|
||||
<div className="flex justify-center py-12 text-ink-50">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
@@ -92,8 +92,8 @@ export function ProfileManagementPage() {
|
||||
{!loading && profiles.length === 0 && (
|
||||
<div className="glass-panel py-12 text-center">
|
||||
<div className="mb-2 text-4xl">👤</div>
|
||||
<p className="font-medium text-white">暂无 Profile</p>
|
||||
<p className="text-sm text-slate-400">点击右上角"创建 Profile"开始</p>
|
||||
<p className="font-medium text-ink-600">暂无 Profile</p>
|
||||
<p className="text-sm text-ink-50">点击右上角"创建 Profile"开始</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -149,7 +149,7 @@ function ProfileCard({
|
||||
<div className="glass-panel flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div
|
||||
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-lg font-bold text-white"
|
||||
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-lg font-bold text-ink-600"
|
||||
style={{
|
||||
background: `hsl(${(profile.name.charCodeAt(0) * 47) % 360}, 60%, 35%)`,
|
||||
}}
|
||||
@@ -158,9 +158,9 @@ function ProfileCard({
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-white">{profile.name}</span>
|
||||
<span className="font-semibold text-ink-600">{profile.name}</span>
|
||||
{profile.is_default && (
|
||||
<span className="rounded bg-primary-400/20 px-2 py-0.5 text-xs text-primary-400">
|
||||
<span className="rounded bg-primary-400/20 px-2 py-0.5 text-xs text-brand-500">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
@@ -175,12 +175,12 @@ function ProfileCard({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-400">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-50">
|
||||
{profile.content_rating_limit && <span>分级: {profile.content_rating_limit}</span>}
|
||||
<span>媒体库: {libNames}</span>
|
||||
<span>用户: {profile.user_id.slice(0, 8)}…</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
<div className="text-xs text-sand-500">
|
||||
观看时长 {Math.round(profile.total_watch_time / 3600)} 小时
|
||||
{profile.last_active_at && ` · 最近活跃 ${new Date(profile.last_active_at).toLocaleDateString()}`}
|
||||
</div>
|
||||
@@ -189,7 +189,7 @@ function ProfileCard({
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<Pencil size={12} className="inline" /> 编辑
|
||||
</button>
|
||||
@@ -267,7 +267,7 @@ function ProfileFormModal({
|
||||
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-white">
|
||||
<h2 className="mb-4 font-display text-xl font-semibold text-ink-600">
|
||||
{editing ? '编辑 Profile' : '创建 Profile'}
|
||||
</h2>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
@@ -388,15 +388,15 @@ function ProfileFormModal({
|
||||
className={
|
||||
'rounded-full border px-3 py-1 text-xs ' +
|
||||
(form.allowed_library_ids.includes(l.id)
|
||||
? 'border-primary-400/60 bg-primary-400/10 text-primary-400'
|
||||
: 'border-white/10 text-slate-400 hover:text-white')
|
||||
? 'border-primary-400/60 bg-primary-400/10 text-brand-500'
|
||||
: 'border-white/10 text-ink-50 hover:text-white')
|
||||
}
|
||||
>
|
||||
{l.name}
|
||||
</button>
|
||||
))}
|
||||
{libraries.length === 0 && (
|
||||
<span className="text-xs text-slate-500">暂无媒体库</span>
|
||||
<span className="text-xs text-sand-500">暂无媒体库</span>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
@@ -405,7 +405,7 @@ function ProfileFormModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-ink-100 hover:bg-white/5"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
@@ -423,7 +423,7 @@ function ProfileFormModal({
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">{label}</span>
|
||||
<span className="mb-1 block text-sm text-ink-100">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
@@ -443,8 +443,8 @@ function Toggle({
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border border-white/5 bg-white/5 px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm text-white">{label}</div>
|
||||
{hint && <div className="text-xs text-slate-400">{hint}</div>}
|
||||
<div className="text-sm text-ink-600">{label}</div>
|
||||
{hint && <div className="text-xs text-ink-50">{hint}</div>}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -43,10 +43,10 @@ export function ProfilePage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-white">个人资料</h1>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">个人资料</h1>
|
||||
|
||||
<form onSubmit={onProfile} className="glass-panel space-y-4">
|
||||
<h2 className="font-display text-lg font-semibold text-white">基本信息</h2>
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">基本信息</h2>
|
||||
<Field label="用户名">
|
||||
<input className="input-base" value={user?.username ?? ''} disabled />
|
||||
</Field>
|
||||
@@ -74,7 +74,7 @@ export function ProfilePage() {
|
||||
</form>
|
||||
|
||||
<form onSubmit={onPwd} className="glass-panel space-y-4">
|
||||
<h2 className="font-display text-lg font-semibold text-white">修改密码</h2>
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">修改密码</h2>
|
||||
<Field label="当前密码">
|
||||
<input
|
||||
required
|
||||
@@ -107,7 +107,7 @@ export function ProfilePage() {
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">{label}</span>
|
||||
<span className="mb-1 block text-sm text-ink-100">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
|
||||
@@ -21,18 +21,18 @@ export function RecycleBinPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-white">回收站</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">回收站</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
软删除的媒体保留在数据库中,可以恢复。彻底删除不会移除磁盘上的文件,只会从数据库清除条目。
|
||||
</p>
|
||||
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && <p className="text-slate-400">回收站为空。</p>}
|
||||
{loading && <p className="text-sand-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && <p className="text-ink-50">回收站为空。</p>}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="glass-panel">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">标题</th>
|
||||
<th>路径</th>
|
||||
@@ -42,13 +42,13 @@ export function RecycleBinPage() {
|
||||
<tbody>
|
||||
{items.map((m) => (
|
||||
<tr key={m.id} className="border-t border-white/5">
|
||||
<td className="py-2 text-white">{m.title}</td>
|
||||
<td className="max-w-md truncate text-slate-400" title={m.path}>
|
||||
<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 border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||
className="rounded 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('已恢复')
|
||||
|
||||
@@ -34,10 +34,10 @@ export function SchedulerPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<Clock className="h-6 w-6 text-primary-400" />
|
||||
<Clock className="h-6 w-6 text-brand-500" />
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">定时任务</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">定时任务</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
后端周期性任务(媒体库扫描、转码缓存清理、回收站自动清理),每 5 秒刷新状态。
|
||||
</p>
|
||||
</div>
|
||||
@@ -45,7 +45,7 @@ export function SchedulerPage() {
|
||||
|
||||
<div className="glass-panel">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">任务</th>
|
||||
<th>间隔</th>
|
||||
@@ -57,9 +57,9 @@ export function SchedulerPage() {
|
||||
<tbody>
|
||||
{jobs.map((j) => (
|
||||
<tr key={j.name} className="border-t border-white/5">
|
||||
<td className="py-2 font-mono text-white">{j.name}</td>
|
||||
<td className="text-slate-300">{j.interval}</td>
|
||||
<td className="text-slate-400">
|
||||
<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()
|
||||
: '尚未运行'}
|
||||
@@ -69,7 +69,7 @@ export function SchedulerPage() {
|
||||
<button
|
||||
onClick={() => runNow(j.name)}
|
||||
disabled={running === j.name}
|
||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||
className="rounded 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>
|
||||
|
||||
@@ -84,7 +84,7 @@ export function SearchPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="font-display text-3xl font-bold text-white">搜索</h1>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">搜索</h1>
|
||||
<button
|
||||
disabled={!aiAvailable}
|
||||
className={
|
||||
@@ -122,14 +122,14 @@ export function SearchPage() {
|
||||
)}
|
||||
|
||||
{intent && (
|
||||
<div className="glass-panel !p-3 text-xs text-slate-300">
|
||||
<div className="glass-panel !p-3 text-xs text-ink-100">
|
||||
AI 解析:
|
||||
<span className="ml-2 font-mono text-primary-400">{JSON.stringify(intent)}</span>
|
||||
<span className="ml-2 font-mono text-brand-500">{JSON.stringify(intent)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 py-8 text-slate-400">
|
||||
<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>
|
||||
@@ -141,8 +141,8 @@ export function SearchPage() {
|
||||
|
||||
{showIdle && (
|
||||
<div className="glass-panel flex flex-col items-center gap-2 p-10 text-center">
|
||||
<p className="text-lg text-slate-300">输入关键词开始搜索</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
<p className="text-lg text-ink-100">输入关键词开始搜索</p>
|
||||
<p className="text-sm text-sand-500">
|
||||
支持电影、电视剧、动漫等媒体内容的快速搜索
|
||||
</p>
|
||||
</div>
|
||||
@@ -150,8 +150,8 @@ export function SearchPage() {
|
||||
|
||||
{showEmpty && (
|
||||
<div className="glass-panel flex flex-col items-center gap-2 p-10 text-center">
|
||||
<p className="text-lg text-slate-300">未找到匹配的媒体</p>
|
||||
<p className="text-sm text-slate-500">尝试其他关键词,或者添加媒体库后执行扫描</p>
|
||||
<p className="text-lg text-ink-100">未找到匹配的媒体</p>
|
||||
<p className="text-sm text-sand-500">尝试其他关键词,或者添加媒体库后执行扫描</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -239,12 +239,12 @@ export function SettingsPage() {
|
||||
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-slate-400/10 text-slate-300">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-sand-300/40 text-ink-100">
|
||||
<SettingsIcon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">系统设置</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">系统设置</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
按分组编辑转码 / 整理 / 刮削 / 下载器等关键配置
|
||||
</p>
|
||||
</div>
|
||||
@@ -258,8 +258,8 @@ export function SettingsPage() {
|
||||
className={
|
||||
'border-b-2 px-4 py-2 text-sm whitespace-nowrap transition ' +
|
||||
(activeGroup === g.key
|
||||
? 'border-primary-400 text-primary-400'
|
||||
: 'border-transparent text-slate-400 hover:text-white')
|
||||
? 'border-primary-400 text-brand-500'
|
||||
: 'border-transparent text-ink-50 hover:text-white')
|
||||
}
|
||||
>
|
||||
{g.label}
|
||||
@@ -268,14 +268,14 @@ export function SettingsPage() {
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12 text-slate-400">
|
||||
<div className="flex justify-center py-12 text-ink-50">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<form onSubmit={onSave} className="glass-panel space-y-4">
|
||||
{group.description && <p className="text-xs text-slate-500">{group.description}</p>}
|
||||
{group.description && <p className="text-xs text-sand-500">{group.description}</p>}
|
||||
{group.items.map((it) => (
|
||||
<SettingRow
|
||||
key={it.key}
|
||||
@@ -285,7 +285,7 @@ export function SettingsPage() {
|
||||
/>
|
||||
))}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-xs text-slate-500">
|
||||
<span className="text-xs text-sand-500">
|
||||
{dirty.size > 0 ? `有 ${dirty.size} 项未保存` : '所有更改已保存'}
|
||||
</span>
|
||||
<button
|
||||
@@ -303,12 +303,12 @@ export function SettingsPage() {
|
||||
{/* 整理 tab 时显示各媒体库默认路径 */}
|
||||
{!loading && activeGroup === 'organize' && libraries.length > 0 && (
|
||||
<div className="glass-panel">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm text-slate-300">
|
||||
<FolderOpen size={16} className="text-primary-400" />
|
||||
<div className="mb-3 flex items-center gap-2 text-sm text-ink-100">
|
||||
<FolderOpen size={16} className="text-brand-500" />
|
||||
<span>默认整理路径参考(未设目标目录时按媒体库归类)</span>
|
||||
</div>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">媒体库</th>
|
||||
<th>类型</th>
|
||||
@@ -319,12 +319,12 @@ export function SettingsPage() {
|
||||
<tbody>
|
||||
{libraries.map((lib) => (
|
||||
<tr key={lib.id} className="border-t border-white/5">
|
||||
<td className="py-2 font-medium text-white">{lib.name}</td>
|
||||
<td className="text-slate-400">
|
||||
<td className="py-2 font-medium text-ink-600">{lib.name}</td>
|
||||
<td className="text-ink-50">
|
||||
{lib.type === 'movie' ? '电影' : lib.type === 'tv' ? '电视剧' : lib.type === 'anime' ? '动漫' : '音乐'}
|
||||
</td>
|
||||
<td className="font-mono text-xs text-slate-400">{lib.path}</td>
|
||||
<td className="font-mono text-[11px] text-slate-500">
|
||||
<td className="font-mono text-xs text-ink-50">{lib.path}</td>
|
||||
<td className="font-mono text-[11px] text-sand-500">
|
||||
{lib.type === 'movie'
|
||||
? `${lib.path}/片名 (2024)/片名 (2024).mkv`
|
||||
: lib.type === 'tv' || lib.type === 'anime'
|
||||
@@ -352,10 +352,10 @@ function SettingRow({
|
||||
}) {
|
||||
return (
|
||||
<div className="grid items-start gap-2 md:grid-cols-[280px_1fr]">
|
||||
<label className="text-sm text-slate-300">
|
||||
<label className="text-sm text-ink-100">
|
||||
<div className="font-medium">{def.label}</div>
|
||||
{def.hint && <div className="mt-0.5 text-xs text-slate-500">{def.hint}</div>}
|
||||
<div className="mt-0.5 font-mono text-[10px] text-slate-600">{def.key}</div>
|
||||
{def.hint && <div className="mt-0.5 text-xs text-sand-500">{def.hint}</div>}
|
||||
<div className="mt-0.5 font-mono text-[10px] text-sand-400">{def.key}</div>
|
||||
</label>
|
||||
<div>
|
||||
{def.type === 'text' && (
|
||||
@@ -402,7 +402,7 @@ function SettingRow({
|
||||
checked={value === 'true' || value === '1' || value === 'on'}
|
||||
onChange={(e) => onChange(e.target.checked ? 'true' : 'false')}
|
||||
/>
|
||||
<span className="text-sm text-slate-300">{value === 'true' ? '已启用' : '已关闭'}</span>
|
||||
<span className="text-sm text-ink-100">{value === 'true' ? '已启用' : '已关闭'}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -66,10 +66,10 @@ export function SiteSearchPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<Search className="h-6 w-6 text-primary-400" />
|
||||
<Search className="h-6 w-6 text-brand-500" />
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">站点搜索</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">站点搜索</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
跨站搜索所有已启用的 PT/BT 站点,结果按做种数排序。
|
||||
</p>
|
||||
</div>
|
||||
@@ -88,11 +88,11 @@ export function SiteSearchPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{loading && <p className="text-slate-500">正在查询所有站点…</p>}
|
||||
{loading && <p className="text-sand-500">正在查询所有站点…</p>}
|
||||
|
||||
{!loading && searched && results.length === 0 && (
|
||||
<div className="glass-panel">
|
||||
<p className="text-slate-300">
|
||||
<p className="text-ink-100">
|
||||
未找到结果。请确认已在「站点管理」中添加并启用了至少一个站点,且 Cookie / API Key 有效。
|
||||
</p>
|
||||
</div>
|
||||
@@ -101,7 +101,7 @@ export function SiteSearchPage() {
|
||||
{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-slate-500">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">站点</th>
|
||||
<th>标题</th>
|
||||
@@ -115,13 +115,13 @@ export function SiteSearchPage() {
|
||||
<tbody>
|
||||
{results.map((item, idx) => (
|
||||
<tr key={idx} className="border-t border-white/5 align-top">
|
||||
<td className="py-2 text-primary-400">{item.site_name}</td>
|
||||
<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="text-white transition hover:text-primary-400"
|
||||
className="text-white transition hover:text-brand-500"
|
||||
title={item.title}
|
||||
>
|
||||
{item.title.length > 80
|
||||
@@ -129,7 +129,7 @@ export function SiteSearchPage() {
|
||||
: item.title}
|
||||
</a>
|
||||
</td>
|
||||
<td className="whitespace-nowrap text-slate-300">
|
||||
<td className="whitespace-nowrap text-ink-100">
|
||||
{fmtBytes(item.size)}
|
||||
</td>
|
||||
<td className="text-emerald-400">{item.seeders || '—'}</td>
|
||||
@@ -144,7 +144,7 @@ export function SiteSearchPage() {
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
onClick={() => downloadTorrent(item)}
|
||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||
className="rounded 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" />
|
||||
|
||||
+50
-50
@@ -30,7 +30,7 @@ const SITE_TYPE_COLORS: Record<string, string> = {
|
||||
unit3d: 'bg-orange-500/15 text-orange-400',
|
||||
mteam: 'bg-green-500/15 text-green-400',
|
||||
discuz: 'bg-yellow-500/15 text-yellow-400',
|
||||
custom_rss: 'bg-slate-500/15 text-slate-400',
|
||||
custom_rss: 'bg-sand-500/15 text-ink-50',
|
||||
}
|
||||
|
||||
const AUTH_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -237,7 +237,7 @@ export function SitesPage() {
|
||||
<div className="space-y-6">
|
||||
{/* 页头 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-display text-3xl font-bold text-white">站点管理</h1>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">站点管理</h1>
|
||||
<button onClick={openCreate} className="neon-button flex items-center gap-2">
|
||||
<Plus size={16} />
|
||||
添加站点
|
||||
@@ -251,12 +251,12 @@ export function SitesPage() {
|
||||
{/* 头部 */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold shrink-0 ${SITE_TYPE_COLORS[site.type] || 'bg-slate-500/15 text-slate-400'}`}>
|
||||
<div className={`w-8 h-8 rounded-lg 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-white truncate">{site.name}</div>
|
||||
<div className="text-xs text-slate-400 truncate max-w-[160px]">{site.url}</div>
|
||||
<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>
|
||||
{/* 状态指示 */}
|
||||
@@ -268,22 +268,22 @@ export function SitesPage() {
|
||||
<CheckCircle size={14} className="text-green-400" />
|
||||
)
|
||||
) : (
|
||||
<HelpCircle size={14} className="text-slate-500" />
|
||||
<HelpCircle size={14} className="text-sand-500" />
|
||||
)}
|
||||
{!site.enabled && <span className="text-xs text-slate-500 ml-1">已停用</span>}
|
||||
{!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 bg-white/5 text-slate-400">
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-white/5 text-ink-50">
|
||||
{SITE_TYPE_LABELS[site.type] || site.type}
|
||||
</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-white/5 text-slate-400">
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-white/5 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 bg-primary-400/15 text-primary-400">默认</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-primary-400/15 text-brand-500">默认</span>
|
||||
)}
|
||||
{site.use_proxy && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-400">代理</span>
|
||||
@@ -297,11 +297,11 @@ export function SitesPage() {
|
||||
</div>
|
||||
|
||||
{/* 状态与统计(只读) */}
|
||||
<div className="text-xs text-slate-500 space-y-0.5">
|
||||
<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-slate-400'
|
||||
site.login_status === 'failed' ? 'text-red-400' : 'text-ink-50'
|
||||
}>
|
||||
{site.login_status || 'unknown'}
|
||||
</span>
|
||||
@@ -319,7 +319,7 @@ export function SitesPage() {
|
||||
<button
|
||||
onClick={() => handleTest(site.id)}
|
||||
disabled={testingId === site.id}
|
||||
className="flex-1 rounded border border-white/10 px-2 py-1.5 text-xs text-slate-300 hover:bg-white/5 disabled:opacity-50 flex items-center justify-center gap-1 transition"
|
||||
className="flex-1 rounded border border-white/10 px-2 py-1.5 text-xs text-ink-100 hover:bg-white/5 disabled:opacity-50 flex items-center justify-center gap-1 transition"
|
||||
>
|
||||
{testingId === site.id ? (
|
||||
<>
|
||||
@@ -335,14 +335,14 @@ export function SitesPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEdit(site.id)}
|
||||
className="rounded border border-white/10 p-1.5 text-slate-400 hover:text-white hover:bg-white/5 transition"
|
||||
className="rounded border border-white/10 p-1.5 text-ink-50 hover:text-white hover:bg-white/5 transition"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(site)}
|
||||
className="rounded border border-white/10 p-1.5 text-slate-400 hover:text-red-400 hover:bg-red-400/10 transition"
|
||||
className="rounded border border-white/10 p-1.5 text-ink-50 hover:text-red-400 hover:bg-red-400/10 transition"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@@ -353,16 +353,16 @@ export function SitesPage() {
|
||||
|
||||
{/* 空状态 */}
|
||||
{!loading && sites.length === 0 && (
|
||||
<div className="col-span-full py-12 text-center text-slate-400">
|
||||
<Globe size={40} className="mx-auto mb-3 text-slate-600" />
|
||||
<div className="col-span-full py-12 text-center text-ink-50">
|
||||
<Globe size={40} className="mx-auto mb-3 text-sand-400" />
|
||||
<p>暂无站点</p>
|
||||
<p className="text-sm mt-1 text-slate-500">点击「添加站点」添加 PT/BT 站点</p>
|
||||
<p className="text-sm mt-1 text-sand-500">点击「添加站点」添加 PT/BT 站点</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载中 */}
|
||||
{loading && (
|
||||
<div className="col-span-full py-12 text-center text-slate-400">
|
||||
<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>
|
||||
@@ -378,10 +378,10 @@ export function SitesPage() {
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-white">
|
||||
<h2 className="text-lg font-bold text-ink-600">
|
||||
{editingId ? '编辑站点' : '添加站点'}
|
||||
</h2>
|
||||
<button onClick={closeModal} className="text-slate-400 hover:text-white transition">
|
||||
<button onClick={closeModal} className="text-ink-50 hover:text-white transition">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -389,7 +389,7 @@ export function SitesPage() {
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
{/* 名称 */}
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1.5">站点名称 *</label>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">站点名称 *</label>
|
||||
<input
|
||||
required
|
||||
className="input-base w-full"
|
||||
@@ -401,7 +401,7 @@ export function SitesPage() {
|
||||
|
||||
{/* 地址 */}
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1.5">站点地址 *</label>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">站点地址 *</label>
|
||||
<input
|
||||
required
|
||||
className="input-base w-full"
|
||||
@@ -409,13 +409,13 @@ export function SitesPage() {
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">格式: https://www.example.com/</p>
|
||||
<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-slate-400 mb-1.5">站点类型</label>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">站点类型</label>
|
||||
<select
|
||||
className="input-base w-full"
|
||||
value={form.type}
|
||||
@@ -430,7 +430,7 @@ export function SitesPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1.5">状态</label>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">状态</label>
|
||||
<div className="flex items-center gap-3 h-10">
|
||||
<button
|
||||
type="button"
|
||||
@@ -441,7 +441,7 @@ export function SitesPage() {
|
||||
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-slate-500'}`}>
|
||||
<span className={`text-sm ${form.enabled ? 'text-white' : 'text-sand-500'}`}>
|
||||
{form.enabled ? '启用' : '停用'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -452,10 +452,10 @@ export function SitesPage() {
|
||||
{form.type === 'mteam' && (
|
||||
<div className="p-3 rounded-lg 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-slate-400 space-y-1">
|
||||
<div className="text-xs text-ink-50 space-y-1">
|
||||
<div><b>站点地址:</b><code className="text-green-300">https://api2.m-team.cc</code></div>
|
||||
<div><b>认证方式:</b>推荐使用「API Key / Passkey」</div>
|
||||
<div className="pl-3 text-slate-500">
|
||||
<div className="pl-3 text-sand-500">
|
||||
1. 登录馒头站 → 控制台 → 实验室 → 存取令牌<br />
|
||||
2. 点击「创建令牌」,复制生成的 Token<br />
|
||||
3. 将 Token 填入下方「令牌」输入框
|
||||
@@ -466,7 +466,7 @@ export function SitesPage() {
|
||||
|
||||
{/* 认证方式 */}
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-2">认证方式</label>
|
||||
<label className="block text-sm text-ink-50 mb-2">认证方式</label>
|
||||
<div className="flex gap-2 mb-3">
|
||||
{[
|
||||
{ value: 'cookie', label: 'Cookie' },
|
||||
@@ -479,8 +479,8 @@ export function SitesPage() {
|
||||
onClick={() => setForm((f) => ({ ...f, auth_type: opt.value }))}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition ${
|
||||
form.auth_type === opt.value
|
||||
? 'bg-primary-500 text-white border-primary-500'
|
||||
: 'border-white/10 text-slate-400 hover:border-primary-500/50'
|
||||
? 'bg-primary-500 text-ink-600 border-primary-500'
|
||||
: 'border-white/10 text-ink-50 hover:border-primary-500/50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
@@ -490,7 +490,7 @@ export function SitesPage() {
|
||||
|
||||
{form.auth_type === 'cookie' && (
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">Cookie</label>
|
||||
<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"
|
||||
@@ -498,13 +498,13 @@ export function SitesPage() {
|
||||
value={form.cookie}
|
||||
onChange={(e) => setForm((f) => ({ ...f, cookie: e.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">从浏览器开发者工具的请求头中获取 Cookie 值</p>
|
||||
<p className="text-xs text-sand-500 mt-1">从浏览器开发者工具的请求头中获取 Cookie 值</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.auth_type === 'api_key' && (
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">令牌(API Key / Passkey)</label>
|
||||
<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"
|
||||
@@ -512,7 +512,7 @@ export function SitesPage() {
|
||||
value={form.api_key}
|
||||
onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
<p className="text-xs text-sand-500 mt-1">
|
||||
{form.type === 'mteam'
|
||||
? '馒头:控制台 → 实验室 → 存取令牌'
|
||||
: '站点的访问 API Key'}
|
||||
@@ -522,7 +522,7 @@ export function SitesPage() {
|
||||
|
||||
{form.auth_type === 'auth_header' && (
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">请求头(Authorization)</label>
|
||||
<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..."
|
||||
@@ -534,14 +534,14 @@ export function SitesPage() {
|
||||
|
||||
{/* RSS 地址(主表单) */}
|
||||
<div className="mt-4">
|
||||
<label className="block text-xs text-slate-400 mb-1">RSS 地址</label>
|
||||
<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={(e) => setForm((f) => ({ ...f, rss_url: e.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">站点 RSS 订阅地址,用于获取最新资源</p>
|
||||
<p className="text-xs text-sand-500 mt-1">站点 RSS 订阅地址,用于获取最新资源</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -550,7 +550,7 @@ export function SitesPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen(!advancedOpen)}
|
||||
className="flex items-center gap-1 text-xs text-slate-400 hover:text-white transition"
|
||||
className="flex items-center gap-1 text-xs text-ink-50 hover:text-white transition"
|
||||
>
|
||||
{advancedOpen ? '▾' : '▸'} 高级选项
|
||||
</button>
|
||||
@@ -559,7 +559,7 @@ export function SitesPage() {
|
||||
{/* 新增高级字段 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">User-Agent</label>
|
||||
<label className="block text-xs text-ink-50 mb-1">User-Agent</label>
|
||||
<input
|
||||
className="input-base w-full text-xs"
|
||||
placeholder="自定义 UA,留空使用默认"
|
||||
@@ -568,7 +568,7 @@ export function SitesPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">请求超时 (秒)</label>
|
||||
<label className="block text-xs text-ink-50 mb-1">请求超时 (秒)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-base w-full"
|
||||
@@ -579,7 +579,7 @@ export function SitesPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">优先级 (数字越大越优先)</label>
|
||||
<label className="block text-xs text-ink-50 mb-1">优先级 (数字越大越优先)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-base w-full"
|
||||
@@ -590,7 +590,7 @@ export function SitesPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">关联下载器</label>
|
||||
<label className="block text-xs text-ink-50 mb-1">关联下载器</label>
|
||||
<input
|
||||
className="input-base w-full text-xs"
|
||||
placeholder="下载器 ID 或名称"
|
||||
@@ -609,7 +609,7 @@ export function SitesPage() {
|
||||
checked={form.use_proxy}
|
||||
onChange={(e) => setForm((f) => ({ ...f, use_proxy: e.target.checked }))}
|
||||
/>
|
||||
<span className="text-xs text-slate-300">使用代理</span>
|
||||
<span className="text-xs text-ink-100">使用代理</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
@@ -618,7 +618,7 @@ export function SitesPage() {
|
||||
checked={form.rate_limit}
|
||||
onChange={(e) => setForm((f) => ({ ...f, rate_limit: e.target.checked }))}
|
||||
/>
|
||||
<span className="text-xs text-slate-300">启用限流</span>
|
||||
<span className="text-xs text-ink-100">启用限流</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
@@ -627,13 +627,13 @@ export function SitesPage() {
|
||||
checked={form.browser_emulation}
|
||||
onChange={(e) => setForm((f) => ({ ...f, browser_emulation: e.target.checked }))}
|
||||
/>
|
||||
<span className="text-xs text-slate-300">浏览器模拟</span>
|
||||
<span className="text-xs text-ink-100">浏览器模拟</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Extra JSON */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">Extra 扩展配置 (JSON)</label>
|
||||
<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"
|
||||
@@ -652,7 +652,7 @@ export function SitesPage() {
|
||||
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-slate-400">设为默认站点</span>
|
||||
<span className="text-sm text-ink-50">设为默认站点</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -660,7 +660,7 @@ export function SitesPage() {
|
||||
|
||||
{/* 按钮 */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button type="button" onClick={closeModal} className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5 transition">
|
||||
<button type="button" onClick={closeModal} className="rounded border border-white/10 px-4 py-2 text-sm text-ink-100 hover:bg-white/5 transition">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
|
||||
+11
-11
@@ -43,8 +43,8 @@ export function StatsPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) return <p className="text-slate-500">加载中…</p>
|
||||
if (!snap) return <p className="text-slate-500">无法获取统计数据</p>
|
||||
if (loading) return <p className="text-sand-500">加载中…</p>
|
||||
if (!snap) return <p className="text-sand-500">无法获取统计数据</p>
|
||||
|
||||
const memPct =
|
||||
snap.hardware.memory_total > 0
|
||||
@@ -58,8 +58,8 @@ export function StatsPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header>
|
||||
<h1 className="font-display text-3xl font-bold text-white">运行状态</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">运行状态</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
快照时间:{new Date(snap.generated_at).toLocaleString()}
|
||||
</p>
|
||||
</header>
|
||||
@@ -76,7 +76,7 @@ export function StatsPage() {
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-xl font-semibold text-white">系统</h2>
|
||||
<h2 className="font-display text-xl font-semibold text-ink-600">系统</h2>
|
||||
<div className="glass-panel grid gap-2 text-sm">
|
||||
<Row label="Go 运行时" value={snap.hardware.go_version} />
|
||||
<Row label="Goroutines" value={snap.hardware.goroutines.toLocaleString()} />
|
||||
@@ -93,7 +93,7 @@ export function StatsPage() {
|
||||
|
||||
{snap.recently_added.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-xl font-semibold text-white">最近入库</h2>
|
||||
<h2 className="font-display text-xl font-semibold text-ink-600">最近入库</h2>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{snap.recently_added.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
@@ -116,12 +116,12 @@ function Tile({
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-panel flex items-center gap-3 !p-4">
|
||||
<div className="rounded-lg border border-primary-400/40 bg-primary-400/10 p-2 text-primary-400">
|
||||
<div className="rounded-lg 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-slate-500">{label}</p>
|
||||
<p className="font-display text-lg font-semibold text-white">{value}</p>
|
||||
<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>
|
||||
)
|
||||
@@ -130,8 +130,8 @@ function Tile({
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-white/5 pb-1 text-sm last:border-0">
|
||||
<span className="text-slate-400">{label}</span>
|
||||
<span className="font-mono text-white">{value}</span>
|
||||
<span className="text-ink-50">{label}</span>
|
||||
<span className="font-mono text-ink-600">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ export function StorageConfigPage() {
|
||||
<Cloud size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">外部存储</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">外部存储</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
配置 Alist / S3 / WebDAV 后端,支持密码加密存储 + 在线测试
|
||||
</p>
|
||||
</div>
|
||||
@@ -31,8 +31,8 @@ export function StorageConfigPage() {
|
||||
className={
|
||||
'border-b-2 px-4 py-2 text-sm uppercase ' +
|
||||
(active === t
|
||||
? 'border-primary-400 text-primary-400'
|
||||
: 'border-transparent text-slate-400 hover:text-white')
|
||||
? 'border-primary-400 text-brand-500'
|
||||
: 'border-transparent text-ink-50 hover:text-white')
|
||||
}
|
||||
>
|
||||
{t}
|
||||
@@ -135,7 +135,7 @@ function StorageForm({ type }: { type: StorageType }) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12 text-slate-400">
|
||||
<div className="flex justify-center py-12 text-ink-50">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)
|
||||
@@ -145,9 +145,9 @@ function StorageForm({ type }: { type: StorageType }) {
|
||||
<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-slate-300">
|
||||
<span className="mb-1 block text-sm text-ink-100">
|
||||
{f.label}
|
||||
<span className="ml-2 font-mono text-[10px] text-slate-600">{f.key}</span>
|
||||
<span className="ml-2 font-mono text-[10px] text-sand-400">{f.key}</span>
|
||||
</span>
|
||||
<input
|
||||
type={f.secret ? 'password' : 'text'}
|
||||
@@ -158,7 +158,7 @@ function StorageForm({ type }: { type: StorageType }) {
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<label className="flex items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
@@ -172,7 +172,7 @@ function StorageForm({ type }: { type: StorageType }) {
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-ink-100 hover:bg-white/5"
|
||||
>
|
||||
{testing ? <Loader2 size={14} className="inline animate-spin" /> : <Send size={14} className="inline" />}
|
||||
{' '}测试
|
||||
|
||||
@@ -33,18 +33,18 @@ export function StoragePage() {
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) return <p className="text-slate-500">加载中…</p>
|
||||
if (!data) return <p className="text-slate-500">无法获取存储数据</p>
|
||||
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-primary-400" />
|
||||
<HardDrive className="h-6 w-6 text-brand-500" />
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">存储</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">存储</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
按媒体库和容器格式统计的磁盘占用,数据来自数据库快照(无须实时扫描磁盘)。
|
||||
</p>
|
||||
</div>
|
||||
@@ -57,10 +57,10 @@ export function StoragePage() {
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-xl font-semibold text-white">按媒体库</h2>
|
||||
<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-slate-500">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
<th>类型</th>
|
||||
@@ -74,10 +74,10 @@ export function StoragePage() {
|
||||
const pct = (l.total_bytes / totalBytes) * 100
|
||||
return (
|
||||
<tr key={l.library_id} className="border-t border-white/5">
|
||||
<td className="py-2 text-white">{l.name}</td>
|
||||
<td className="text-slate-300">{l.type}</td>
|
||||
<td className="text-slate-300">{l.media_count}</td>
|
||||
<td className="text-slate-300">{fmtBytes(l.total_bytes)}</td>
|
||||
<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-white/10">
|
||||
@@ -86,7 +86,7 @@ export function StoragePage() {
|
||||
style={{ width: `${pct.toFixed(1)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">{pct.toFixed(1)}%</span>
|
||||
<span className="text-xs text-ink-50">{pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -98,7 +98,7 @@ export function StoragePage() {
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-xl font-semibold text-white">按容器格式</h2>
|
||||
<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
|
||||
@@ -106,10 +106,10 @@ export function StoragePage() {
|
||||
className="glass-panel flex items-center justify-between !p-4"
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-slate-500">{c.container}</p>
|
||||
<p className="font-display text-lg font-semibold text-white">{c.count} 项</p>
|
||||
<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-slate-300">{fmtBytes(c.bytes)}</p>
|
||||
<p className="text-sm text-ink-100">{fmtBytes(c.bytes)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -129,12 +129,12 @@ function Tile({
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-panel flex items-center gap-3 !p-4">
|
||||
<div className="rounded-lg border border-primary-400/40 bg-primary-400/10 p-2 text-primary-400">
|
||||
<div className="rounded-lg 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-slate-500">{label}</p>
|
||||
<p className="font-display text-lg font-semibold text-white">{value}</p>
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -121,8 +121,8 @@ export function StrmPage() {
|
||||
<LinkIcon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">STRM 管理</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">STRM 管理</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
将外部 HTTP / WebDAV / Alist 直链以"虚拟文件"形式纳入媒体库
|
||||
</p>
|
||||
</div>
|
||||
@@ -130,7 +130,7 @@ export function StrmPage() {
|
||||
|
||||
{/* Import a new STRM-only entry. */}
|
||||
<section className="glass-panel space-y-4">
|
||||
<h2 className="font-display text-lg font-semibold text-white">导入 STRM 条目</h2>
|
||||
<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
|
||||
@@ -165,14 +165,14 @@ export function StrmPage() {
|
||||
<Plus size={16} /> {importing ? '导入中…' : '导入'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-sand-500">
|
||||
导入后会创建一条 container=strm 的媒体记录,播放时会 302 重定向到该 URL。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Attach / detach STRM URL on existing media. */}
|
||||
<section className="glass-panel space-y-4">
|
||||
<h2 className="font-display text-lg font-semibold text-white">附加 STRM URL 到已有媒体</h2>
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">附加 STRM URL 到已有媒体</h2>
|
||||
<form onSubmit={doSearch} className="flex gap-2">
|
||||
<input
|
||||
className="input-base flex-1"
|
||||
@@ -196,8 +196,8 @@ export function StrmPage() {
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-white">{m.title}</div>
|
||||
<div className="truncate text-xs text-slate-400">
|
||||
<div className="truncate font-medium text-ink-600">{m.title}</div>
|
||||
<div className="truncate text-xs text-ink-50">
|
||||
{m.year > 0 && `${m.year} · `}
|
||||
{m.container || '本地文件'}
|
||||
</div>
|
||||
@@ -240,7 +240,7 @@ export function StrmPage() {
|
||||
)}
|
||||
|
||||
{!searching && query && results.length === 0 && (
|
||||
<p className="text-sm text-slate-400">未找到匹配的媒体。</p>
|
||||
<p className="text-sm text-ink-50">未找到匹配的媒体。</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -40,8 +40,8 @@ export function SubscriptionsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-white">RSS 订阅</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">RSS 订阅</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
定期轮询 RSS 源(每 10 分钟一次),将匹配过滤器的项目自动加入下载队列。
|
||||
</p>
|
||||
|
||||
@@ -71,13 +71,13 @@ export function SubscriptionsPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && <p className="text-slate-400">暂无订阅。</p>}
|
||||
{loading && <p className="text-sand-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && <p className="text-ink-50">暂无订阅。</p>}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="glass-panel">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
<th>RSS</th>
|
||||
@@ -89,17 +89,17 @@ export function SubscriptionsPage() {
|
||||
<tbody>
|
||||
{items.map((s) => (
|
||||
<tr key={s.id} className="border-t border-white/5">
|
||||
<td className="py-2 text-white">{s.name}</td>
|
||||
<td className="max-w-md truncate text-slate-300" title={s.feed_url}>
|
||||
<td className="py-2 text-ink-600">{s.name}</td>
|
||||
<td className="max-w-md truncate text-ink-100" title={s.feed_url}>
|
||||
{s.feed_url}
|
||||
</td>
|
||||
<td className="text-slate-300">{s.filter || '—'}</td>
|
||||
<td className="text-slate-500">
|
||||
<td className="text-ink-100">{s.filter || '—'}</td>
|
||||
<td className="text-sand-500">
|
||||
{s.last_run_at ? new Date(s.last_run_at).toLocaleString() : '—'}
|
||||
</td>
|
||||
<td className="space-x-2 py-2 text-right">
|
||||
<button
|
||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
onClick={async () => {
|
||||
const r = await subscriptionsAPI.runNow(s.id)
|
||||
toast.success(`已加入 ${r.queued} 项`)
|
||||
|
||||
+16
-16
@@ -34,23 +34,23 @@ export function TasksPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!snap) return <p className="text-slate-500">加载中…</p>
|
||||
if (!snap) return <p className="text-sand-500">加载中…</p>
|
||||
|
||||
const torrents = snap.torrents ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex items-center gap-3">
|
||||
<Activity className="h-6 w-6 text-primary-400" />
|
||||
<h1 className="font-display text-3xl font-bold text-white">实时任务</h1>
|
||||
<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-white">转码任务</h2>
|
||||
{snap.transcodes.length === 0 && <p className="text-slate-500">暂无运行中转码。</p>}
|
||||
<h2 className="mb-3 font-display text-lg font-semibold text-ink-600">转码任务</h2>
|
||||
{snap.transcodes.length === 0 && <p className="text-sand-500">暂无运行中转码。</p>}
|
||||
{snap.transcodes.length > 0 && (
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">媒体 ID</th>
|
||||
<th>编码器</th>
|
||||
@@ -61,9 +61,9 @@ export function TasksPage() {
|
||||
<tbody>
|
||||
{snap.transcodes.map((t) => (
|
||||
<tr key={t.media_id} className="border-t border-white/5">
|
||||
<td className="py-2 font-mono text-xs text-white">{t.media_id}</td>
|
||||
<td className="text-slate-300">{t.encoder || 'libx264'}</td>
|
||||
<td className="text-slate-300">{new Date(t.started_at).toLocaleTimeString()}</td>
|
||||
<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 border border-emerald-400/40 px-1.5 py-0.5 text-xs text-emerald-400">
|
||||
@@ -83,11 +83,11 @@ export function TasksPage() {
|
||||
</section>
|
||||
|
||||
<section className="glass-panel">
|
||||
<h2 className="mb-3 font-display text-lg font-semibold text-white">下载任务</h2>
|
||||
{torrents.length === 0 && <p className="text-slate-500">暂无运行中下载。</p>}
|
||||
<h2 className="mb-3 font-display text-lg font-semibold text-ink-600">下载任务</h2>
|
||||
{torrents.length === 0 && <p className="text-sand-500">暂无运行中下载。</p>}
|
||||
{torrents.length > 0 && (
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
<th>状态</th>
|
||||
@@ -98,11 +98,11 @@ export function TasksPage() {
|
||||
<tbody>
|
||||
{torrents.map((t) => (
|
||||
<tr key={t.hash} className="border-t border-white/5 align-top">
|
||||
<td className="max-w-md truncate py-2 text-white" title={t.name}>
|
||||
<td className="max-w-md truncate py-2 text-ink-600" title={t.name}>
|
||||
{t.name}
|
||||
</td>
|
||||
<td className="text-slate-300">{t.state}</td>
|
||||
<td className="text-slate-300">
|
||||
<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 bg-white/10">
|
||||
<div
|
||||
@@ -113,7 +113,7 @@ export function TasksPage() {
|
||||
{(t.progress * 100).toFixed(1)}%
|
||||
</div>
|
||||
</td>
|
||||
<td className="text-slate-300">{fmtBytes(t.size)}</td>
|
||||
<td className="text-ink-100">{fmtBytes(t.size)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
+14
-14
@@ -24,8 +24,8 @@ export function ToolsPage() {
|
||||
<Wrench size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">运维工具</h1>
|
||||
<p className="text-sm text-slate-400">整理媒体文件 · 测试通知渠道</p>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">运维工具</h1>
|
||||
<p className="text-sm text-ink-50">整理媒体文件 · 测试通知渠道</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -118,22 +118,22 @@ function OrganizePanel() {
|
||||
return (
|
||||
<section className="glass-panel space-y-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderCog size={18} className="text-primary-400" />
|
||||
<h2 className="font-display text-lg font-semibold text-white">整理 & 重命名</h2>
|
||||
<FolderCog size={18} className="text-brand-500" />
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">整理 & 重命名</h2>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-sand-500">
|
||||
将媒体文件按 <code className="rounded bg-white/5 px-1">媒体库/年份/标题 (年份)/标题.ext</code>{' '}
|
||||
的规范布局移动并重命名,确保已先完成刮削。
|
||||
</p>
|
||||
|
||||
{!loadingSettings && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Info size={14} className={smartClassify ? 'text-green-400' : 'text-slate-500'} />
|
||||
<span className={smartClassify ? 'text-green-400' : 'text-slate-500'}>
|
||||
<Info size={14} className={smartClassify ? 'text-green-400' : 'text-sand-500'} />
|
||||
<span className={smartClassify ? 'text-green-400' : 'text-sand-500'}>
|
||||
智能分类:{smartClassify ? '已启用' : '未启用'}
|
||||
</span>
|
||||
{smartClassify && (
|
||||
<span className="text-slate-500">(将根据元数据自动分类到子目录)</span>
|
||||
<span className="text-sand-500">(将根据元数据自动分类到子目录)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -181,8 +181,8 @@ function OrganizePanel() {
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-white/5 bg-white/5 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm text-white">{m.title}</div>
|
||||
<div className="truncate text-xs text-slate-400">{m.path}</div>
|
||||
<div className="truncate text-sm text-ink-600">{m.title}</div>
|
||||
<div className="truncate text-xs text-ink-50">{m.path}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onOrganizeOne(m)}
|
||||
@@ -202,7 +202,7 @@ function OrganizePanel() {
|
||||
)}
|
||||
|
||||
{!searching && query && results.length === 0 && (
|
||||
<p className="mt-3 text-sm text-slate-400">未找到匹配的媒体。</p>
|
||||
<p className="mt-3 text-sm text-ink-50">未找到匹配的媒体。</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -235,9 +235,9 @@ function NotifyPanel() {
|
||||
<section className="glass-panel space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell size={18} className="text-amber-300" />
|
||||
<h2 className="font-display text-lg font-semibold text-white">通知渠道测试</h2>
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">通知渠道测试</h2>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-sand-500">
|
||||
会向所有已配置的通知渠道(Telegram / Bark / Webhook 等)发送一条测试消息。
|
||||
</p>
|
||||
<form onSubmit={onSend} className="space-y-3">
|
||||
@@ -270,7 +270,7 @@ function NotifyPanel() {
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">{label}</span>
|
||||
<span className="mb-1 block text-sm text-ink-100">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
|
||||
@@ -29,13 +29,13 @@ export function WatchHistoryPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<Clock className="h-6 w-6 text-primary-400" />
|
||||
<h1 className="font-display text-3xl font-bold text-white">观看历史</h1>
|
||||
<Clock className="h-6 w-6 text-brand-500" />
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">观看历史</h1>
|
||||
</header>
|
||||
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
{loading && <p className="text-sand-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && (
|
||||
<p className="text-slate-400">暂无观看记录。</p>
|
||||
<p className="text-ink-50">暂无观看记录。</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
@@ -62,11 +62,11 @@ export function WatchHistoryPage() {
|
||||
<div className="flex-1 space-y-1">
|
||||
<Link
|
||||
to={`/media/${m.id}`}
|
||||
className="font-medium text-white transition hover:text-primary-400"
|
||||
className="font-medium text-ink-600 transition hover:text-brand-500"
|
||||
>
|
||||
{m.title}
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-400">
|
||||
<div className="flex items-center gap-3 text-xs text-ink-50">
|
||||
<span>{fmtDuration(h.position_ms)} / {fmtDuration(h.duration_ms)}</span>
|
||||
<span>{new Date(h.watched_at).toLocaleString()}</span>
|
||||
{h.completed && (
|
||||
|
||||
+90
-76
@@ -1,4 +1,4 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
@@ -9,73 +9,100 @@ export default {
|
||||
mono: ['"JetBrains Mono"', '"Fira Code"', 'monospace'],
|
||||
},
|
||||
colors: {
|
||||
// ── New Warm Industrial palette ──
|
||||
// ── Brand: Luxurious Editorial Gold ──
|
||||
brand: {
|
||||
DEFAULT: '#c9954a',
|
||||
50: '#fdf8f0',
|
||||
100: '#f9edda',
|
||||
200: '#f2d8b0',
|
||||
300: '#e8bc7d',
|
||||
400: '#dca45c',
|
||||
50: '#fdf9f4',
|
||||
100: '#fbeedf',
|
||||
200: '#f6dbb9',
|
||||
300: '#efc187',
|
||||
400: '#e5a153',
|
||||
500: '#c9954a',
|
||||
600: '#b07a38',
|
||||
700: '#936031',
|
||||
800: '#794e2d',
|
||||
900: '#654128',
|
||||
950: '#3a2214',
|
||||
600: '#b07f3c',
|
||||
700: '#926630',
|
||||
800: '#755027',
|
||||
900: '#5e4121',
|
||||
950: '#332110',
|
||||
},
|
||||
// ── Sage: Muted Teal Accent ──
|
||||
sage: {
|
||||
DEFAULT: '#7a9a8a',
|
||||
50: '#f3f7f4',
|
||||
100: '#e4ece6',
|
||||
200: '#c9d9ce',
|
||||
300: '#a6bfad',
|
||||
400: '#7a9a8a',
|
||||
500: '#5e7e6e',
|
||||
600: '#496558',
|
||||
700: '#3c5247',
|
||||
800: '#334339',
|
||||
900: '#2c3831',
|
||||
950: '#151f19',
|
||||
DEFAULT: '#6b8275',
|
||||
50: '#f4f6f5',
|
||||
100: '#e5eae7',
|
||||
200: '#ccd7d1',
|
||||
300: '#a3b8ad',
|
||||
400: '#6b8275',
|
||||
500: '#54685d',
|
||||
600: '#425149',
|
||||
700: '#35413a',
|
||||
800: '#2d3531',
|
||||
950: '#141a17',
|
||||
},
|
||||
// ── Ink: High-Contrast Graphite / Charcoal (Elegant Light-Theme Text) ──
|
||||
ink: {
|
||||
DEFAULT: '#111827',
|
||||
50: '#6b7280', // Elegant secondary text
|
||||
100: '#4b5563', // Main body text
|
||||
200: '#374151', // Dark text
|
||||
300: '#1f2937',
|
||||
400: '#111827',
|
||||
500: '#111827',
|
||||
600: '#111827', // Headers
|
||||
700: '#030712',
|
||||
800: '#000000',
|
||||
900: '#000000',
|
||||
},
|
||||
// ── Sand: High-End Swiss Slate Grays ──
|
||||
sand: {
|
||||
DEFAULT: '#f9fafb',
|
||||
50: '#ffffff', // Card base
|
||||
100: '#f9fafb', // System body background
|
||||
200: '#f3f4f6', // Panel base / hover background
|
||||
300: '#e5e7eb', // Thin premium borders
|
||||
400: '#d1d5db', // Mid-lines
|
||||
500: '#9ca3af', // Muted placeholders
|
||||
600: '#6b7280',
|
||||
700: '#4b5563',
|
||||
800: '#374151',
|
||||
900: '#1f2937',
|
||||
},
|
||||
// ── Surface: Compatibility ──
|
||||
surface: {
|
||||
DEFAULT: '#1a1a18',
|
||||
50: '#2e2e2a',
|
||||
100: '#2a2a26',
|
||||
200: '#242420',
|
||||
300: '#1e1e1b',
|
||||
400: '#1a1a18',
|
||||
500: '#161614',
|
||||
600: '#111110',
|
||||
// Backward-compat (old 700-950 → warm charcoal)
|
||||
700: '#242420',
|
||||
800: '#1a1a18',
|
||||
900: '#111110',
|
||||
950: '#0d0d0c',
|
||||
DEFAULT: '#ffffff',
|
||||
50: '#ffffff',
|
||||
100: '#f9fafb',
|
||||
200: '#f3f4f6',
|
||||
300: '#e5e7eb',
|
||||
400: '#ffffff',
|
||||
500: '#ffffff',
|
||||
600: '#1f2937',
|
||||
700: '#111827',
|
||||
800: '#030712',
|
||||
900: '#000000',
|
||||
950: '#000000',
|
||||
},
|
||||
cream: {
|
||||
DEFAULT: '#e8e4d9',
|
||||
50: '#faf8f4',
|
||||
100: '#f3f0e8',
|
||||
200: '#e8e4d9',
|
||||
300: '#c4bfb5',
|
||||
400: '#a09b92',
|
||||
500: '#7d7871',
|
||||
600: '#605c56',
|
||||
700: '#4a4742',
|
||||
800: '#363330',
|
||||
900: '#242220',
|
||||
},
|
||||
|
||||
// ── Backward-compat aliases (old class names → new warm colors) ──
|
||||
// Backward compatibility colors mapped to slate-light
|
||||
primary: {
|
||||
400: '#dca45c', // was cyan → now brand-400
|
||||
500: '#c9954a', // was cyan → now brand-500
|
||||
600: '#b07a38', // was cyan → now brand-600
|
||||
400: '#e5a153',
|
||||
500: '#c9954a',
|
||||
600: '#b07f3c',
|
||||
},
|
||||
accent: {
|
||||
400: '#7a9a8a', // was purple → now sage-400
|
||||
500: '#5e7e6e', // was purple → now sage-500
|
||||
400: '#6b8275',
|
||||
500: '#54685d',
|
||||
},
|
||||
cream: {
|
||||
DEFAULT: '#faf9f6',
|
||||
50: '#ffffff',
|
||||
100: '#faf9f6',
|
||||
200: '#f4f3ee',
|
||||
300: '#e8e6dc',
|
||||
400: '#9ca3af',
|
||||
500: '#6b7280',
|
||||
600: '#4b5563',
|
||||
700: '#1f2937',
|
||||
800: '#111827',
|
||||
900: '#000000',
|
||||
},
|
||||
},
|
||||
fontSize: {
|
||||
@@ -88,26 +115,13 @@ export default {
|
||||
transitionDuration: {
|
||||
'400': '400ms',
|
||||
},
|
||||
animation: {
|
||||
'slide-in-left': 'slideInLeft 0.3s ease-out',
|
||||
'fade-in': 'fadeIn 0.4s ease-out',
|
||||
'scale-in': 'scaleIn 0.3s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
slideInLeft: {
|
||||
'0%': { opacity: '0', transform: 'translateX(-8px)' },
|
||||
'100%': { opacity: '1', transform: 'translateX(0)' },
|
||||
},
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
scaleIn: {
|
||||
'0%': { opacity: '0', transform: 'scale(0.96)' },
|
||||
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||
},
|
||||
boxShadow: {
|
||||
'card': '0 1px 3px rgba(0,0,0,0.02), 0 1px 2px rgba(0,0,0,0.01)',
|
||||
'card-hover': '0 10px 30px rgba(0,0,0,0.04), 0 2px 8px rgba(0,0,0,0.02)',
|
||||
'elevated': '0 20px 50px rgba(0,0,0,0.06), 0 4px 12px rgba(0,0,0,0.03)',
|
||||
'sidebar': '1px 0 0 rgba(0,0,0,0.03)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user