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:
ShukeBta
2026-05-26 22:02:00 +08:00
parent 3a2db6bdd9
commit c094818594
45 changed files with 1938 additions and 1411 deletions
+21 -21
View File
@@ -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>
)}
+13 -13
View File
@@ -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
View File
@@ -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">
+9 -9
View File
@@ -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}
+15 -15
View File
@@ -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>
+9 -9
View File
@@ -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
+16 -16
View File
@@ -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>
)
+17 -17
View File
@@ -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>
+9 -9
View File
@@ -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>
+4 -4
View File
@@ -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>
+14 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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]}`
}
+17 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
}
}
+14 -14
View File
@@ -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>
)
+3 -3
View File
@@ -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>
+5 -5
View File
@@ -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('已移除')
+5 -5
View File
@@ -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>
)}
+6 -6
View File
@@ -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>
+19 -19
View File
@@ -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"
+4 -4
View File
@@ -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>
)
+8 -8
View File
@@ -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('已恢复')
+8 -8
View File
@@ -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>
+8 -8
View File
@@ -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>
)}
+19 -19
View File
@@ -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>
+10 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
}
+9 -9
View File
@@ -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" />}
{' '}测试
+19 -19
View File
@@ -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>
)
+8 -8
View File
@@ -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>
+10 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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">整理 &amp; 重命名</h2>
<FolderCog size={18} className="text-brand-500" />
<h2 className="font-display text-lg font-semibold text-ink-600">整理 &amp; 重命名</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>
)
+6 -6
View File
@@ -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 && (