mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 21:06:38 +08:00
@
feat: Warm Industrial UI redesign + social footer + code deduplication Design System (Phase A): - New palette: warm charcoal #111110 / amber gold #c9954a / sage green #7a9a8a - Fonts: Cabinet Grotesk (heading) + Geist (body) + JetBrains Mono (data) - SVG grain texture overlay, collapsed sidebar with hover expand - Bento grid dashboard with WideContinueCard + PosterCard + QuickLink - Backward-compatible CSS aliases for glass-panel/neon-button/input-base Social Footer (Phase B): - AppFooter component with GitHub repo / author homepage / TG group links - Embedded in LoginPage, Layout (global), and SettingsPage - TG group badge added to README.md Code Cleanup: - Removed deprecated DownloadClientCard / NotifyChannelCard components - Merged downloadClient/notify API clients into unified modules - Route deduplication: removed authed-group duplicates for admin endpoints - Removed redundant AdminPage tabs (sites/downloads/notify/scheduler) - UI fixes: blank pages (discover/download-clients/notify-channels) nil slice defense - Email channel support in NotifyChannelsPage @
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="README_EN.md"><img src="https://img.shields.io/badge/English-README-blue?style=flat-square" alt="English"></a>
|
<a href="README_EN.md"><img src="https://img.shields.io/badge/English-README-blue?style=flat-square" alt="English"></a>
|
||||||
|
<a href="https://t.me/MediaStationGo"><img src="https://img.shields.io/badge/Telegram-群组-26A5E4?style=flat-square&logo=telegram&logoColor=white" alt="Telegram 群组"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -150,23 +150,6 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
|||||||
// SSE event stream.
|
// SSE event stream.
|
||||||
authed.GET("/events", sseHandler(svc))
|
authed.GET("/events", sseHandler(svc))
|
||||||
|
|
||||||
// Download clients.
|
|
||||||
authed.GET("/download-clients", listDownloadClientsHandler(svc))
|
|
||||||
authed.POST("/download-clients", middleware.AdminRequired(), createDownloadClientHandler(svc))
|
|
||||||
authed.GET("/download-clients/:id", getDownloadClientHandler(svc))
|
|
||||||
authed.PUT("/download-clients/:id", middleware.AdminRequired(), updateDownloadClientHandler(svc))
|
|
||||||
authed.DELETE("/download-clients/:id", middleware.AdminRequired(), deleteDownloadClientHandler(svc))
|
|
||||||
authed.POST("/download-clients/:id/test", middleware.AdminRequired(), testDownloadClientHandler(svc))
|
|
||||||
|
|
||||||
// Notify channels.
|
|
||||||
authed.GET("/notify-channels", listNotifyChannelsHandler(svc))
|
|
||||||
authed.GET("/notify-channels/types", getNotifyChannelTypesHandler(svc))
|
|
||||||
authed.POST("/notify-channels", middleware.AdminRequired(), createNotifyChannelHandler(svc))
|
|
||||||
authed.GET("/notify-channels/:id", getNotifyChannelHandler(svc))
|
|
||||||
authed.PUT("/notify-channels/:id", middleware.AdminRequired(), updateNotifyChannelHandler(svc))
|
|
||||||
authed.DELETE("/notify-channels/:id", middleware.AdminRequired(), deleteNotifyChannelHandler(svc))
|
|
||||||
authed.POST("/notify-channels/:id/test", middleware.AdminRequired(), testNotifyChannelHandler(svc))
|
|
||||||
|
|
||||||
// Scheduler.
|
// Scheduler.
|
||||||
authed.GET("/scheduler/tasks", schedulerListTasksHandler(svc))
|
authed.GET("/scheduler/tasks", schedulerListTasksHandler(svc))
|
||||||
authed.POST("/scheduler/tasks/:id/run", middleware.AdminRequired(), schedulerRunTaskHandler(svc))
|
authed.POST("/scheduler/tasks/:id/run", middleware.AdminRequired(), schedulerRunTaskHandler(svc))
|
||||||
@@ -263,7 +246,6 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
|||||||
authed.POST("/download/sync", middleware.AdminRequired(), downloadSyncHandler(svc))
|
authed.POST("/download/sync", middleware.AdminRequired(), downloadSyncHandler(svc))
|
||||||
authed.POST("/download/start-auto-sync", middleware.AdminRequired(), downloadAutoSyncHandler(svc))
|
authed.POST("/download/start-auto-sync", middleware.AdminRequired(), downloadAutoSyncHandler(svc))
|
||||||
authed.GET("/download/tasks", downloadTasksAliasHandler(svc))
|
authed.GET("/download/tasks", downloadTasksAliasHandler(svc))
|
||||||
authed.POST("/download/add", addDownloadHandler(svc))
|
|
||||||
|
|
||||||
// ── Assistant (multi-turn AI chat) ──
|
// ── Assistant (multi-turn AI chat) ──
|
||||||
authed.GET("/admin/assistant/sessions", listAssistantSessionsHandler(svc))
|
authed.GET("/admin/assistant/sessions", listAssistantSessionsHandler(svc))
|
||||||
|
|||||||
+2
-2
@@ -3,12 +3,12 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#00F0FF" />
|
<meta name="theme-color" content="#111110" />
|
||||||
<meta name="description" content="MediaStationGo — 个人家庭媒体中心" />
|
<meta name="description" content="MediaStationGo — 个人家庭媒体中心" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Orbitron:wght@500;700;900&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Cabinet+Grotesk:wght@400;500;600;700;800&family=Geist:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<title>MediaStationGo</title>
|
<title>MediaStationGo</title>
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { api } from './client'
|
|
||||||
import type { DownloadClient } from '../types'
|
|
||||||
|
|
||||||
export interface DownloadClientCreateParams {
|
|
||||||
name: string
|
|
||||||
type: 'qbittorrent' | 'transmission' | 'aria2'
|
|
||||||
host: string
|
|
||||||
username?: string
|
|
||||||
password?: string
|
|
||||||
is_default?: boolean
|
|
||||||
extra?: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DownloadClientUpdateParams {
|
|
||||||
name?: string
|
|
||||||
type?: 'qbittorrent' | 'transmission' | 'aria2'
|
|
||||||
host?: string
|
|
||||||
username?: string
|
|
||||||
password?: string
|
|
||||||
is_default?: boolean
|
|
||||||
enabled?: boolean
|
|
||||||
extra?: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const downloadClientAPI = {
|
|
||||||
list: () =>
|
|
||||||
api
|
|
||||||
.get<{ code: number; data: DownloadClient[] }>('/download-clients')
|
|
||||||
.then((r) => r.data.data),
|
|
||||||
|
|
||||||
get: (id: string) =>
|
|
||||||
api
|
|
||||||
.get<{ code: number; data: DownloadClient }>(`/download-clients/${id}`)
|
|
||||||
.then((r) => r.data.data),
|
|
||||||
|
|
||||||
create: (params: DownloadClientCreateParams) =>
|
|
||||||
api
|
|
||||||
.post<{ code: number; data: DownloadClient }>('/download-clients', params)
|
|
||||||
.then((r) => r.data.data),
|
|
||||||
|
|
||||||
update: (id: string, params: DownloadClientUpdateParams) =>
|
|
||||||
api
|
|
||||||
.put<{ code: number; data: DownloadClient }>(`/download-clients/${id}`, params)
|
|
||||||
.then((r) => r.data.data),
|
|
||||||
|
|
||||||
delete: (id: string) =>
|
|
||||||
api.delete(`/download-clients/${id}`).then((r) => r.data),
|
|
||||||
|
|
||||||
test: (id: string) =>
|
|
||||||
api.post(`/download-clients/${id}/test`).then((r) => r.data),
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { api } from './client'
|
|
||||||
import type { NotifyChannel, NotifyProviderInfo } from '../types'
|
|
||||||
|
|
||||||
export interface NotifyChannelCreateParams {
|
|
||||||
name: string
|
|
||||||
type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
|
|
||||||
enabled?: boolean
|
|
||||||
config: Record<string, string>
|
|
||||||
events?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NotifyChannelUpdateParams {
|
|
||||||
name?: string
|
|
||||||
enabled?: boolean
|
|
||||||
config?: Record<string, string>
|
|
||||||
events?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const notifyAPI = {
|
|
||||||
list: () =>
|
|
||||||
api
|
|
||||||
.get<{ code: number; data: NotifyChannel[] }>('/notify-channels')
|
|
||||||
.then((r) => r.data.data),
|
|
||||||
|
|
||||||
get: (id: string) =>
|
|
||||||
api
|
|
||||||
.get<{ code: number; data: NotifyChannel }>(`/notify-channels/${id}`)
|
|
||||||
.then((r) => r.data.data),
|
|
||||||
|
|
||||||
getTypes: () =>
|
|
||||||
api
|
|
||||||
.get<{ code: number; data: NotifyProviderInfo[] }>('/notify-channels/types')
|
|
||||||
.then((r) => r.data.data),
|
|
||||||
|
|
||||||
create: (params: NotifyChannelCreateParams) =>
|
|
||||||
api
|
|
||||||
.post<{ code: number; data: NotifyChannel }>('/notify-channels', params)
|
|
||||||
.then((r) => r.data.data),
|
|
||||||
|
|
||||||
update: (id: string, params: NotifyChannelUpdateParams) =>
|
|
||||||
api
|
|
||||||
.put<{ code: number; data: NotifyChannel }>(`/notify-channels/${id}`, params)
|
|
||||||
.then((r) => r.data.data),
|
|
||||||
|
|
||||||
delete: (id: string) =>
|
|
||||||
api.delete(`/notify-channels/${id}`).then((r) => r.data),
|
|
||||||
|
|
||||||
test: (id: string) =>
|
|
||||||
api.post(`/notify-channels/${id}/test`).then((r) => r.data),
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Github, Globe, Send } from 'lucide-react'
|
||||||
|
|
||||||
|
const LINKS = [
|
||||||
|
{
|
||||||
|
href: 'https://github.com/ShukeBta/MediaStationGo',
|
||||||
|
icon: Github,
|
||||||
|
label: '开源仓库',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'https://github.com/ShukeBta',
|
||||||
|
icon: Globe,
|
||||||
|
label: '作者主页',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'https://t.me/MediaStationGo',
|
||||||
|
icon: Send,
|
||||||
|
label: 'TG 群组',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AppFooter({ className = '' }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<footer className={`flex items-center justify-center gap-1 ${className}`}>
|
||||||
|
{LINKS.map((link, i) => (
|
||||||
|
<span key={link.href} className="flex items-center">
|
||||||
|
{i > 0 && <span className="mx-2 h-3 w-px bg-cream-900/20" />}
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-cream-500 transition-colors hover:bg-cream-900/10 hover:text-cream-300"
|
||||||
|
title={link.label}
|
||||||
|
>
|
||||||
|
<link.icon size={14} />
|
||||||
|
<span>{link.label}</span>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
import { FormEvent, useEffect, useState } from 'react'
|
|
||||||
import toast from 'react-hot-toast'
|
|
||||||
import { Plug, Trash2, Zap, Settings, Loader2 } from 'lucide-react'
|
|
||||||
|
|
||||||
import { downloadClientAPI, type DownloadClientCreateParams } from '../api/downloadClient'
|
|
||||||
import type { DownloadClient } from '../types'
|
|
||||||
|
|
||||||
const CLIENT_TYPES = [
|
|
||||||
{ value: 'qbittorrent', label: 'qBittorrent' },
|
|
||||||
{ value: 'transmission', label: 'Transmission' },
|
|
||||||
{ value: 'aria2', label: 'Aria2' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const TYPE_LABELS: Record<string, string> = {
|
|
||||||
qbittorrent: 'qBittorrent',
|
|
||||||
transmission: 'Transmission',
|
|
||||||
aria2: 'Aria2',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DownloadClientCard() {
|
|
||||||
const [clients, setClients] = useState<DownloadClient[]>([])
|
|
||||||
const [showForm, setShowForm] = useState(false)
|
|
||||||
const [testing, setTesting] = useState<string | null>(null)
|
|
||||||
const [form, setForm] = useState<DownloadClientCreateParams>({
|
|
||||||
name: '',
|
|
||||||
type: 'qbittorrent',
|
|
||||||
host: '',
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
is_default: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const refresh = () => downloadClientAPI.list().then(setClients)
|
|
||||||
useEffect(() => {
|
|
||||||
refresh().catch(() => undefined)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
try {
|
|
||||||
await downloadClientAPI.create(form)
|
|
||||||
toast.success('下载客户端已创建')
|
|
||||||
setForm({ name: '', type: 'qbittorrent', host: '', username: '', password: '', is_default: false })
|
|
||||||
setShowForm(false)
|
|
||||||
await refresh()
|
|
||||||
} catch {
|
|
||||||
toast.error('创建失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTest = async (id: string) => {
|
|
||||||
setTesting(id)
|
|
||||||
try {
|
|
||||||
await downloadClientAPI.test(id)
|
|
||||||
toast.success('连接测试成功')
|
|
||||||
} catch {
|
|
||||||
toast.error('连接测试失败')
|
|
||||||
} finally {
|
|
||||||
setTesting(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (id: string, name: string) => {
|
|
||||||
if (!confirm(`确定删除「${name}」?`)) return
|
|
||||||
await downloadClientAPI.delete(id)
|
|
||||||
toast.success('已删除')
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleDefault = async (client: DownloadClient) => {
|
|
||||||
await downloadClientAPI.update(client.id, { is_default: true })
|
|
||||||
toast.success(`已设「${client.name}」为默认客户端`)
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleEnabled = async (client: DownloadClient) => {
|
|
||||||
await downloadClientAPI.update(client.id, { enabled: !client.enabled })
|
|
||||||
toast.success(client.enabled ? '已禁用' : '已启用')
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-lg font-semibold text-white">下载客户端</h2>
|
|
||||||
<button
|
|
||||||
className="neon-button flex items-center gap-1 text-sm"
|
|
||||||
onClick={() => setShowForm(!showForm)}
|
|
||||||
>
|
|
||||||
<Plug size={14} />
|
|
||||||
添加客户端
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showForm && (
|
|
||||||
<form onSubmit={handleSubmit} className="glass-panel space-y-3">
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
className="input-base"
|
|
||||||
placeholder="名称"
|
|
||||||
value={form.name}
|
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
className="input-base"
|
|
||||||
value={form.type}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm({ ...form, type: e.target.value as DownloadClientCreateParams['type'] })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{CLIENT_TYPES.map((t) => (
|
|
||||||
<option key={t.value} value={t.value}>
|
|
||||||
{t.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
className="input-base"
|
|
||||||
placeholder="地址 (如 http://127.0.0.1:8080)"
|
|
||||||
value={form.host}
|
|
||||||
onChange={(e) => setForm({ ...form, host: e.target.value })}
|
|
||||||
/>
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<input
|
|
||||||
className="input-base"
|
|
||||||
placeholder="用户名"
|
|
||||||
value={form.username}
|
|
||||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="input-base"
|
|
||||||
type="password"
|
|
||||||
placeholder="密码"
|
|
||||||
value={form.password}
|
|
||||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.is_default}
|
|
||||||
onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
|
|
||||||
className="rounded border-slate-600"
|
|
||||||
/>
|
|
||||||
设为默认客户端
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button type="submit" className="neon-button">
|
|
||||||
创建
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded border border-white/20 px-3 py-1.5 text-sm text-slate-300 hover:bg-white/5"
|
|
||||||
onClick={() => setShowForm(false)}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{clients.map((client) => (
|
|
||||||
<div
|
|
||||||
key={client.id}
|
|
||||||
className={`glass-panel flex items-center justify-between ${
|
|
||||||
!client.enabled ? 'opacity-50' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className={`rounded-lg p-2 ${
|
|
||||||
client.type === 'qbittorrent'
|
|
||||||
? 'bg-blue-500/20 text-blue-400'
|
|
||||||
: client.type === 'transmission'
|
|
||||||
? 'bg-purple-500/20 text-purple-400'
|
|
||||||
: 'bg-orange-500/20 text-orange-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Settings size={18} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-white">{client.name}</span>
|
|
||||||
<span className="rounded bg-white/10 px-1.5 py-0.5 text-xs text-slate-400">
|
|
||||||
{TYPE_LABELS[client.type]}
|
|
||||||
</span>
|
|
||||||
{client.is_default && (
|
|
||||||
<span className="rounded bg-primary-400/20 px-1.5 py-0.5 text-xs text-primary-400">
|
|
||||||
默认
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!client.enabled && (
|
|
||||||
<span className="rounded bg-red-400/20 px-1.5 py-0.5 text-xs text-red-400">
|
|
||||||
已禁用
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-slate-500">{client.host}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{!client.is_default && (
|
|
||||||
<button
|
|
||||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
|
||||||
onClick={() => handleToggleDefault(client)}
|
|
||||||
title="设为默认"
|
|
||||||
>
|
|
||||||
<Zap size={12} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="rounded border border-yellow-400/40 px-2 py-1 text-xs text-yellow-400 hover:bg-yellow-400/10"
|
|
||||||
onClick={() => handleToggleEnabled(client)}
|
|
||||||
title={client.enabled ? '禁用' : '启用'}
|
|
||||||
>
|
|
||||||
{client.enabled ? '暂停' : '启用'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
|
||||||
onClick={() => handleTest(client.id)}
|
|
||||||
disabled={testing === client.id}
|
|
||||||
>
|
|
||||||
{testing === client.id ? (
|
|
||||||
<Loader2 size={12} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
'测试'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
|
||||||
onClick={() => handleDelete(client.id, client.name)}
|
|
||||||
>
|
|
||||||
<Trash2 size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{clients.length === 0 && (
|
|
||||||
<div className="py-8 text-center text-sm text-slate-500">
|
|
||||||
暂无下载客户端,点击上方按钮添加
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+132
-88
@@ -36,11 +36,11 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
import { AppFooter } from './AppFooter'
|
||||||
import { libraryAPI } from '../api/library'
|
import { libraryAPI } from '../api/library'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import type { Library } from '../types'
|
import type { Library } from '../types'
|
||||||
|
|
||||||
// Top-level chrome: a fixed left rail + scrollable content area.
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
@@ -58,96 +58,132 @@ export function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full">
|
<div className="flex min-h-full">
|
||||||
<aside className="hidden w-64 shrink-0 flex-col border-r border-white/5 bg-surface-900/80 px-4 py-6 backdrop-blur md:flex">
|
{/* ── Sidebar group — collapsed 64px, expands to 240px on hover ── */}
|
||||||
<Link to="/" className="mb-8 flex items-center gap-2 px-2">
|
<div className="group relative z-30 shrink-0">
|
||||||
<Film className="h-6 w-6 text-primary-400" />
|
<aside
|
||||||
<span className="font-display text-xl font-bold tracking-wide text-white">
|
className={clsx(
|
||||||
MediaStationGo
|
'flex h-full flex-col border-r border-cream-900/15 bg-surface-600',
|
||||||
</span>
|
'w-16 transition-all duration-300 ease-out',
|
||||||
</Link>
|
'group-hover:w-60',
|
||||||
|
|
||||||
<nav className="flex-1 space-y-1 overflow-y-auto pr-1">
|
|
||||||
<SidebarLink to="/" icon={<Home size={18} />} label="首页" end />
|
|
||||||
<SidebarLink to="/discover" icon={<Compass size={18} />} label="发现" />
|
|
||||||
<SidebarLink to="/search" icon={<Search size={18} />} label="搜索" />
|
|
||||||
<SidebarLink to="/ai" icon={<Sparkles size={18} />} label="AI 助手" />
|
|
||||||
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="收藏" />
|
|
||||||
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" />
|
|
||||||
<SidebarLink to="/history" icon={<Clock size={18} />} label="观看历史" />
|
|
||||||
<SidebarLink to="/poster-wall" icon={<GalleryHorizontalEnd size={18} />} label="海报墙" />
|
|
||||||
|
|
||||||
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
|
|
||||||
媒体库
|
|
||||||
</div>
|
|
||||||
{libraries.length === 0 && (
|
|
||||||
<div className="px-2 py-1 text-sm text-slate-500">暂无媒体库</div>
|
|
||||||
)}
|
)}
|
||||||
{libraries.map((lib) => (
|
|
||||||
<SidebarLink
|
|
||||||
key={lib.id}
|
|
||||||
to={`/library/${lib.id}`}
|
|
||||||
icon={<LibraryIcon size={18} />}
|
|
||||||
label={lib.name}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
|
|
||||||
自动化
|
|
||||||
</div>
|
|
||||||
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载" />
|
|
||||||
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="RSS 订阅" />
|
|
||||||
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 投屏" />
|
|
||||||
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点管理" />
|
|
||||||
<SidebarLink to="/site-search" icon={<Search size={18} />} label="站点搜索" />
|
|
||||||
|
|
||||||
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
|
|
||||||
账号
|
|
||||||
</div>
|
|
||||||
<SidebarLink to="/profile" icon={<UserIcon size={18} />} label="个人资料" />
|
|
||||||
<SidebarLink to="/play-profiles" icon={<UserCog size={18} />} label="观影 Profile" />
|
|
||||||
|
|
||||||
{user?.role === 'admin' && (
|
|
||||||
<>
|
|
||||||
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
|
|
||||||
管理
|
|
||||||
</div>
|
|
||||||
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" />
|
|
||||||
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" />
|
|
||||||
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储" />
|
|
||||||
<SidebarLink to="/files" icon={<FolderTree size={18} />} label="文件浏览" />
|
|
||||||
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="重复文件" />
|
|
||||||
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时任务" />
|
|
||||||
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点管理" />
|
|
||||||
<SidebarLink to="/strm" icon={<Link2 size={18} />} label="STRM 管理" />
|
|
||||||
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="运维工具" />
|
|
||||||
<SidebarLink to="/notify-channels" icon={<Bell size={18} />} label="通知渠道" />
|
|
||||||
<SidebarLink to="/download-clients" icon={<Server size={18} />} label="下载器" />
|
|
||||||
<SidebarLink to="/storage-config" icon={<Cloud size={18} />} label="外部存储" />
|
|
||||||
<SidebarLink to="/license" icon={<KeySquare size={18} />} label="许可证" />
|
|
||||||
<SidebarLink to="/assistant" icon={<MessageSquare size={18} />} label="AI 对话" />
|
|
||||||
<SidebarLink to="/settings" icon={<Sliders size={18} />} label="系统设置" />
|
|
||||||
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
|
|
||||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="mt-4 flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-400 transition hover:bg-white/5 hover:text-white"
|
|
||||||
>
|
>
|
||||||
<LogOut size={16} />
|
{/* Logo */}
|
||||||
退出登录 ({user?.username})
|
<Link to="/" className="flex items-center gap-3 px-4 py-5">
|
||||||
</button>
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-brand-500/15">
|
||||||
</aside>
|
<Film className="h-4 w-4 text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'font-display text-lg font-bold tracking-tight text-cream-100',
|
||||||
|
'overflow-hidden whitespace-nowrap opacity-0 transition-opacity duration-200',
|
||||||
|
'group-hover:opacity-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
MediaStation
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<main className="flex-1 overflow-y-auto px-6 py-8">
|
{/* Navigation */}
|
||||||
<Outlet />
|
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 pb-4">
|
||||||
|
<SidebarLink to="/" icon={<Home size={18} />} label="首页" end />
|
||||||
|
|
||||||
|
<SectionHeader label="浏览" />
|
||||||
|
<SidebarLink to="/discover" icon={<Compass size={18} />} label="发现" />
|
||||||
|
<SidebarLink to="/search" icon={<Search size={18} />} label="搜索" />
|
||||||
|
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="收藏" />
|
||||||
|
<SidebarLink to="/playlists" icon={<ListMusic size={18} />} label="播放列表" />
|
||||||
|
<SidebarLink to="/history" icon={<Clock size={18} />} label="观看历史" />
|
||||||
|
<SidebarLink to="/poster-wall" icon={<GalleryHorizontalEnd size={18} />} label="海报墙" />
|
||||||
|
<SidebarLink to="/ai" icon={<Sparkles size={18} />} label="AI 助手" />
|
||||||
|
|
||||||
|
<SectionHeader label="媒体库" />
|
||||||
|
{libraries.length === 0 && (
|
||||||
|
<div className="hidden px-3 py-1.5 text-xs text-cream-500/50 group-hover:block">
|
||||||
|
暂无媒体库
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{libraries.map((lib) => (
|
||||||
|
<SidebarLink
|
||||||
|
key={lib.id}
|
||||||
|
to={`/library/${lib.id}`}
|
||||||
|
icon={<LibraryIcon size={18} />}
|
||||||
|
label={lib.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<SectionHeader label="自动化" />
|
||||||
|
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载" />
|
||||||
|
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="RSS 订阅" />
|
||||||
|
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 投屏" />
|
||||||
|
<SidebarLink to="/site-search" icon={<Search size={18} />} label="站点搜索" />
|
||||||
|
|
||||||
|
<SectionHeader label="账号" />
|
||||||
|
<SidebarLink to="/profile" icon={<UserIcon size={18} />} label="个人资料" />
|
||||||
|
<SidebarLink to="/play-profiles" icon={<UserCog size={18} />} label="观影 Profile" />
|
||||||
|
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<>
|
||||||
|
<SectionHeader label="管理" />
|
||||||
|
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
|
||||||
|
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" />
|
||||||
|
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" />
|
||||||
|
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点管理" />
|
||||||
|
<SidebarLink to="/notify-channels" icon={<Bell size={18} />} label="通知渠道" />
|
||||||
|
<SidebarLink to="/download-clients" icon={<Server size={18} />} label="下载器" />
|
||||||
|
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时任务" />
|
||||||
|
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储" />
|
||||||
|
<SidebarLink to="/storage-config" icon={<Cloud size={18} />} label="外部存储" />
|
||||||
|
<SidebarLink to="/files" icon={<FolderTree size={18} />} label="文件浏览" />
|
||||||
|
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="重复文件" />
|
||||||
|
<SidebarLink to="/strm" icon={<Link2 size={18} />} label="STRM 管理" />
|
||||||
|
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="运维工具" />
|
||||||
|
<SidebarLink to="/license" icon={<KeySquare size={18} />} label="许可证" />
|
||||||
|
<SidebarLink to="/assistant" icon={<MessageSquare size={18} />} label="AI 对话" />
|
||||||
|
<SidebarLink to="/settings" icon={<Sliders size={18} />} label="系统设置" />
|
||||||
|
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Bottom: logout */}
|
||||||
|
<div className="border-t border-cream-900/15 px-3 py-3">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-sm text-cream-400 transition hover:bg-cream-900/10 hover:text-cream-200"
|
||||||
|
title={`退出登录 (${user?.username})`}
|
||||||
|
>
|
||||||
|
<LogOut size={16} />
|
||||||
|
<span className="overflow-hidden whitespace-nowrap opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||||
|
退出
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Main content ── */}
|
||||||
|
<main className="flex flex-1 flex-col overflow-y-auto">
|
||||||
|
<div className="flex-1 px-6 py-8">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
<AppFooter className="py-4" />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Section divider — visible only when sidebar is expanded */
|
||||||
|
function SectionHeader({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 mb-1 overflow-hidden whitespace-nowrap px-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-[0.15em] text-cream-500/50">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single nav link — icon always visible, label fades in on expand */
|
||||||
function SidebarLink({
|
function SidebarLink({
|
||||||
to,
|
to,
|
||||||
icon,
|
icon,
|
||||||
@@ -165,15 +201,23 @@ function SidebarLink({
|
|||||||
end={end}
|
end={end}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
clsx(
|
clsx(
|
||||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition',
|
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-primary-400/10 text-primary-400'
|
? 'bg-brand-500/10 text-brand-400'
|
||||||
: 'text-slate-300 hover:bg-white/5 hover:text-white',
|
: 'text-cream-400 hover:bg-cream-900/10 hover:text-cream-200',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{icon}
|
<span className="flex shrink-0 items-center justify-center w-5 h-5">{icon}</span>
|
||||||
<span className="truncate">{label}</span>
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'overflow-hidden whitespace-nowrap truncate',
|
||||||
|
'opacity-0 transition-opacity duration-200',
|
||||||
|
'group-hover:opacity-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Film } from 'lucide-react'
|
import { Film, Play } from 'lucide-react'
|
||||||
|
|
||||||
import { imageURL } from '../api/client'
|
import { imageURL } from '../api/client'
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
|
|
||||||
// Compact poster tile used by every listing surface (home / library / search).
|
/** Compact poster tile — used by library, search, favourites, poster wall, etc. */
|
||||||
//
|
|
||||||
// Props:
|
|
||||||
// - media: the Media row to render
|
|
||||||
// - progress: 0..1 fractional resume position (optional)
|
|
||||||
export function MediaCard({
|
export function MediaCard({
|
||||||
media,
|
media,
|
||||||
progress,
|
progress,
|
||||||
@@ -19,34 +15,51 @@ export function MediaCard({
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/media/${media.id}`}
|
to={`/media/${media.id}`}
|
||||||
className="group block overflow-hidden rounded-xl border border-white/5 bg-surface-800/60 transition hover:border-primary-400/50 hover:shadow-lg hover:shadow-primary-400/20"
|
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 bg-surface-900">
|
{/* Poster */}
|
||||||
|
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-600">
|
||||||
{media.poster_url ? (
|
{media.poster_url ? (
|
||||||
<img
|
<img
|
||||||
src={imageURL(media.poster_url)}
|
src={imageURL(media.poster_url)}
|
||||||
alt={media.title}
|
alt={media.title}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="h-full w-full object-cover transition group-hover:scale-105"
|
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-slate-600">
|
<div className="flex h-full w-full items-center justify-center text-cream-900/30">
|
||||||
<Film size={48} />
|
<Film size={48} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Hover overlay */}
|
||||||
|
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-3 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-white/90">
|
||||||
|
<Play size={12} />
|
||||||
|
播放
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
{progress !== undefined && progress > 0 && progress < 1 && (
|
{progress !== undefined && progress > 0 && progress < 1 && (
|
||||||
<div className="absolute inset-x-0 bottom-0 h-1 bg-black/60">
|
<div className="absolute inset-x-0 bottom-0 h-1 bg-black/40">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-primary-400"
|
className="h-full bg-brand-500/80 transition-colors group-hover:bg-brand-400"
|
||||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-2">
|
|
||||||
<p className="truncate text-sm font-medium text-white">{media.title}</p>
|
{/* Info */}
|
||||||
{media.year > 0 && <p className="text-xs text-slate-400">{media.year}</p>}
|
<div className="px-3 py-2.5">
|
||||||
|
<p className="truncate text-sm font-medium text-cream-200 group-hover:text-cream-100">
|
||||||
|
{media.title}
|
||||||
|
</p>
|
||||||
|
{media.year > 0 && (
|
||||||
|
<p className="mt-0.5 text-xs text-cream-500">{media.year}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,282 +0,0 @@
|
|||||||
import { FormEvent, useEffect, useState } from 'react'
|
|
||||||
import toast from 'react-hot-toast'
|
|
||||||
import { Bell, Trash2, Loader2 } from 'lucide-react'
|
|
||||||
|
|
||||||
import { notifyAPI, type NotifyChannelCreateParams } from '../api/notify'
|
|
||||||
import type { NotifyChannel } from '../types'
|
|
||||||
|
|
||||||
const CHANNEL_TYPES = [
|
|
||||||
{ value: 'telegram', label: 'Telegram' },
|
|
||||||
{ value: 'wechat', label: 'Server酱' },
|
|
||||||
{ value: 'bark', label: 'Bark' },
|
|
||||||
{ value: 'webhook', label: 'Webhook' },
|
|
||||||
{ value: 'email', label: 'Email' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const TYPE_LABELS: Record<string, string> = {
|
|
||||||
telegram: 'Telegram',
|
|
||||||
wechat: 'Server酱',
|
|
||||||
bark: 'Bark',
|
|
||||||
webhook: 'Webhook',
|
|
||||||
email: 'Email',
|
|
||||||
}
|
|
||||||
|
|
||||||
const EVENT_OPTIONS = [
|
|
||||||
{ value: 'subscription_hit', label: '订阅命中' },
|
|
||||||
{ value: 'download_complete', label: '下载完成' },
|
|
||||||
{ value: 'scrape_failed', label: '刮削失败' },
|
|
||||||
{ value: 'system_alert', label: '系统告警' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const CONFIG_FIELDS: Record<string, { key: string; label: string; type?: string; placeholder: string }[]> = {
|
|
||||||
telegram: [
|
|
||||||
{ key: 'bot_token', label: 'Bot Token', placeholder: '123456:ABC-DEF...' },
|
|
||||||
{ key: 'chat_id', label: 'Chat ID', placeholder: '你的 Chat ID' },
|
|
||||||
{ key: 'parse_mode', label: 'Parse Mode', placeholder: 'HTML (可选)' },
|
|
||||||
],
|
|
||||||
wechat: [{ key: 'sendkey', label: 'SendKey', placeholder: 'SCT...' }],
|
|
||||||
bark: [
|
|
||||||
{ key: 'server_url', label: '服务器地址', placeholder: 'https://api.day.app (可选)' },
|
|
||||||
{ key: 'device_key', label: 'Device Key', placeholder: '你的 Bark Key' },
|
|
||||||
],
|
|
||||||
webhook: [
|
|
||||||
{ key: 'url', label: 'Webhook URL', placeholder: 'https://...' },
|
|
||||||
{ key: 'method', label: 'HTTP 方法', placeholder: 'POST (可选)' },
|
|
||||||
{ key: 'headers_json', label: 'Headers JSON', placeholder: '{"Authorization":"Bearer ..."} (可选)' },
|
|
||||||
{ key: 'body_template', label: 'Body 模板', placeholder: '{{title}}: {{message}} (可选)' },
|
|
||||||
],
|
|
||||||
email: [
|
|
||||||
{ key: 'smtp_host', label: 'SMTP 地址', placeholder: 'smtp.gmail.com' },
|
|
||||||
{ key: 'smtp_port', label: 'SMTP 端口', placeholder: '465' },
|
|
||||||
{ key: 'username', label: '用户名', placeholder: '' },
|
|
||||||
{ key: 'password', label: '密码', placeholder: '', type: 'password' },
|
|
||||||
{ key: 'from', label: '发件人', placeholder: 'noreply@example.com' },
|
|
||||||
{ key: 'to', label: '收件人', placeholder: 'user@example.com (多个用逗号分隔)' },
|
|
||||||
{ key: 'tls', label: 'TLS', placeholder: 'true/false' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotifyChannelCard() {
|
|
||||||
const [channels, setChannels] = useState<NotifyChannel[]>([])
|
|
||||||
const [showForm, setShowForm] = useState(false)
|
|
||||||
const [testing, setTesting] = useState<string | null>(null)
|
|
||||||
const [form, setForm] = useState<NotifyChannelCreateParams>({
|
|
||||||
name: '',
|
|
||||||
type: 'telegram',
|
|
||||||
enabled: true,
|
|
||||||
config: {},
|
|
||||||
events: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const refresh = () => notifyAPI.list().then(setChannels)
|
|
||||||
useEffect(() => {
|
|
||||||
refresh().catch(() => undefined)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleTypeChange = (type: string) => {
|
|
||||||
setForm({
|
|
||||||
...form,
|
|
||||||
type: type as NotifyChannelCreateParams['type'],
|
|
||||||
config: {},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleConfigChange = (key: string, value: string) => {
|
|
||||||
setForm({ ...form, config: { ...form.config, [key]: value } })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEventToggle = (event: string) => {
|
|
||||||
const events = form.events || []
|
|
||||||
const idx = events.indexOf(event)
|
|
||||||
if (idx >= 0) {
|
|
||||||
events.splice(idx, 1)
|
|
||||||
} else {
|
|
||||||
events.push(event)
|
|
||||||
}
|
|
||||||
setForm({ ...form, events: [...events] })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
try {
|
|
||||||
await notifyAPI.create(form)
|
|
||||||
toast.success('通知渠道已创建')
|
|
||||||
setForm({ name: '', type: 'telegram', enabled: true, config: {}, events: [] })
|
|
||||||
setShowForm(false)
|
|
||||||
await refresh()
|
|
||||||
} catch {
|
|
||||||
toast.error('创建失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTest = async (id: string) => {
|
|
||||||
setTesting(id)
|
|
||||||
try {
|
|
||||||
await notifyAPI.test(id)
|
|
||||||
toast.success('测试通知已发送')
|
|
||||||
} catch {
|
|
||||||
toast.error('测试通知发送失败')
|
|
||||||
} finally {
|
|
||||||
setTesting(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (id: string, name: string) => {
|
|
||||||
if (!confirm(`确定删除「${name}」?`)) return
|
|
||||||
await notifyAPI.delete(id)
|
|
||||||
toast.success('已删除')
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleEnabled = async (channel: NotifyChannel) => {
|
|
||||||
await notifyAPI.update(channel.id, { enabled: !channel.enabled })
|
|
||||||
toast.success(channel.enabled ? '已禁用' : '已启用')
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
const configFields = CONFIG_FIELDS[form.type] || []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-lg font-semibold text-white">通知渠道</h2>
|
|
||||||
<button
|
|
||||||
className="neon-button flex items-center gap-1 text-sm"
|
|
||||||
onClick={() => setShowForm(!showForm)}
|
|
||||||
>
|
|
||||||
<Bell size={14} />
|
|
||||||
添加渠道
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showForm && (
|
|
||||||
<form onSubmit={handleSubmit} className="glass-panel space-y-3">
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
className="input-base"
|
|
||||||
placeholder="名称"
|
|
||||||
value={form.name}
|
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
||||||
/>
|
|
||||||
<select className="input-base" value={form.type} onChange={(e) => handleTypeChange(e.target.value)}>
|
|
||||||
{CHANNEL_TYPES.map((t) => (
|
|
||||||
<option key={t.value} value={t.value}>
|
|
||||||
{t.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{configFields.map((field) => (
|
|
||||||
<input
|
|
||||||
key={field.key}
|
|
||||||
required={field.key !== 'parse_mode' && field.key !== 'method' && field.key !== 'headers_json' && field.key !== 'body_template' && field.key !== 'server_url' && field.key !== 'tls'}
|
|
||||||
className="input-base"
|
|
||||||
type={field.type || 'text'}
|
|
||||||
placeholder={field.label + (field.placeholder ? ` (${field.placeholder})` : '')}
|
|
||||||
value={form.config[field.key] || ''}
|
|
||||||
onChange={(e) => handleConfigChange(field.key, e.target.value)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs text-slate-500">订阅事件</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{EVENT_OPTIONS.map((event) => (
|
|
||||||
<label
|
|
||||||
key={event.value}
|
|
||||||
className="flex cursor-pointer items-center gap-1 rounded border border-white/10 px-2 py-1 text-xs text-slate-300"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={(form.events || []).includes(event.value)}
|
|
||||||
onChange={() => handleEventToggle(event.value)}
|
|
||||||
className="rounded border-slate-600"
|
|
||||||
/>
|
|
||||||
{event.label}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button type="submit" className="neon-button">
|
|
||||||
创建
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded border border-white/20 px-3 py-1.5 text-sm text-slate-300 hover:bg-white/5"
|
|
||||||
onClick={() => setShowForm(false)}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{channels.map((channel) => (
|
|
||||||
<div
|
|
||||||
key={channel.id}
|
|
||||||
className={`glass-panel flex items-center justify-between ${
|
|
||||||
!channel.enabled ? 'opacity-50' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="rounded-lg bg-green-500/20 p-2 text-green-400">
|
|
||||||
<Bell size={18} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-white">{channel.name}</span>
|
|
||||||
<span className="rounded bg-white/10 px-1.5 py-0.5 text-xs text-slate-400">
|
|
||||||
{TYPE_LABELS[channel.type]}
|
|
||||||
</span>
|
|
||||||
{!channel.enabled && (
|
|
||||||
<span className="rounded bg-red-400/20 px-1.5 py-0.5 text-xs text-red-400">
|
|
||||||
已禁用
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-slate-500">
|
|
||||||
事件: {channel.events || '无'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
className="rounded border border-yellow-400/40 px-2 py-1 text-xs text-yellow-400 hover:bg-yellow-400/10"
|
|
||||||
onClick={() => handleToggleEnabled(channel)}
|
|
||||||
>
|
|
||||||
{channel.enabled ? '禁用' : '启用'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
|
||||||
onClick={() => handleTest(channel.id)}
|
|
||||||
disabled={testing === channel.id}
|
|
||||||
>
|
|
||||||
{testing === channel.id ? (
|
|
||||||
<Loader2 size={12} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
'测试'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
|
||||||
onClick={() => handleDelete(channel.id, channel.name)}
|
|
||||||
>
|
|
||||||
<Trash2 size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{channels.length === 0 && (
|
|
||||||
<div className="py-8 text-center text-sm text-slate-500">
|
|
||||||
暂无通知渠道,点击上方按钮添加
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+136
-21
@@ -13,35 +13,150 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: radial-gradient(
|
background-color: #111110;
|
||||||
ellipse at 20% 50%,
|
color: #e8e4d9;
|
||||||
rgba(0, 240, 255, 0.08) 0%,
|
|
||||||
transparent 50%
|
|
||||||
),
|
|
||||||
radial-gradient(
|
|
||||||
ellipse at 80% 20%,
|
|
||||||
rgba(168, 85, 247, 0.08) 0%,
|
|
||||||
transparent 50%
|
|
||||||
),
|
|
||||||
#060a13;
|
|
||||||
color: #f5f7fa;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── SVG 噪点纹理 ─── */
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.035;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: repeat;
|
||||||
|
background-size: 256px 256px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Scrollbar ─── */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #2e2e2a;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #3a3a35;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Selection ─── */
|
||||||
|
::selection {
|
||||||
|
background: rgba(201, 149, 74, 0.3);
|
||||||
|
color: #e8e4d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Focus ring ─── */
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid #c9954a;
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
/* ─── Surface Card — 替代 glass-panel ─── */
|
||||||
|
.surface-card {
|
||||||
|
@apply rounded-lg border border-cream-900/20 bg-surface-400 px-4 py-3;
|
||||||
|
/* No shadow, no blur. Pure material feel. */
|
||||||
|
}
|
||||||
|
.surface-card-elevated {
|
||||||
|
@apply surface-card;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 暖色分割线 ─── */
|
||||||
|
.warm-divider {
|
||||||
|
@apply border-t border-cream-900/15;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 琥珀色按钮 — 替代 neon-button ─── */
|
||||||
|
.amber-btn {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 rounded-lg border border-brand-500/30
|
||||||
|
bg-brand-500/10 px-4 py-2 text-sm font-medium text-brand-400 transition-all
|
||||||
|
hover:border-brand-500/50 hover:bg-brand-500/20 hover:text-brand-300
|
||||||
|
active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40;
|
||||||
|
}
|
||||||
|
.amber-btn--solid {
|
||||||
|
@apply amber-btn border-transparent bg-brand-600 text-cream-100
|
||||||
|
hover:bg-brand-500 hover:text-white;
|
||||||
|
}
|
||||||
|
.amber-btn--ghost {
|
||||||
|
@apply amber-btn border-transparent bg-transparent hover:bg-cream-900/10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 鼠尾草按钮 ─── */
|
||||||
|
.sage-btn {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 rounded-lg border border-sage-400/20
|
||||||
|
bg-sage-400/10 px-4 py-2 text-sm font-medium text-sage-300 transition-all
|
||||||
|
hover:border-sage-400/40 hover:bg-sage-400/20 hover:text-sage-200
|
||||||
|
active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 表单输入 ─── */
|
||||||
|
.field-input {
|
||||||
|
@apply w-full rounded-lg border border-cream-900/20 bg-surface-600 px-4 py-2.5
|
||||||
|
text-sm text-cream-200 placeholder:text-cream-500/40
|
||||||
|
transition-colors
|
||||||
|
focus:border-brand-500/50 focus:bg-surface-500 focus:outline-none
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
|
}
|
||||||
|
.field-label {
|
||||||
|
@apply block text-xs font-medium uppercase tracking-wider text-cream-400 mb-1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 标签/徽章 ─── */
|
||||||
|
.badge {
|
||||||
|
@apply inline-flex items-center rounded-full px-2 py-0.5 text-2xs font-medium;
|
||||||
|
}
|
||||||
|
.badge--brand {
|
||||||
|
@apply badge bg-brand-500/15 text-brand-400;
|
||||||
|
}
|
||||||
|
.badge--sage {
|
||||||
|
@apply badge bg-sage-400/15 text-sage-300;
|
||||||
|
}
|
||||||
|
.badge--neutral {
|
||||||
|
@apply badge bg-cream-900/20 text-cream-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 页面标题 ─── */
|
||||||
|
.page-heading {
|
||||||
|
@apply font-display text-2xl font-bold tracking-tight text-cream-100;
|
||||||
|
}
|
||||||
|
.page-subtitle {
|
||||||
|
@apply mt-1 text-sm text-cream-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 表格(管理面板用) ─── */
|
||||||
|
.data-table {
|
||||||
|
@apply w-full text-left text-sm;
|
||||||
|
}
|
||||||
|
.data-table thead {
|
||||||
|
@apply border-b border-cream-900/20;
|
||||||
|
}
|
||||||
|
.data-table th {
|
||||||
|
@apply px-4 py-3 text-xs font-medium uppercase tracking-wider text-cream-400;
|
||||||
|
}
|
||||||
|
.data-table td {
|
||||||
|
@apply px-4 py-3 text-cream-300;
|
||||||
|
}
|
||||||
|
.data-table tbody tr {
|
||||||
|
@apply border-b border-cream-900/10 transition-colors hover:bg-cream-900/5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 旧版兼容别名(逐步废弃) ─── */
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
@apply rounded-2xl border border-white/10 bg-surface-800/60 p-6 shadow-lg backdrop-blur-md;
|
@apply surface-card;
|
||||||
}
|
}
|
||||||
|
|
||||||
.neon-button {
|
.neon-button {
|
||||||
@apply inline-flex items-center justify-center gap-2 rounded-lg border border-primary-400/40
|
@apply amber-btn;
|
||||||
bg-primary-400/10 px-4 py-2 font-medium text-primary-400 transition
|
|
||||||
hover:border-primary-400 hover:bg-primary-400/20 hover:text-white
|
|
||||||
active:scale-95 disabled:cursor-not-allowed disabled:opacity-50;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-base {
|
.input-base {
|
||||||
@apply w-full rounded-lg border border-white/10 bg-surface-900/60 px-4 py-2 text-sm
|
@apply field-input;
|
||||||
text-white placeholder:text-slate-500 focus:border-primary-400 focus:outline-none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,26 +4,16 @@ import { Trash2 } from 'lucide-react'
|
|||||||
|
|
||||||
import { adminAPI } from '../api/admin'
|
import { adminAPI } from '../api/admin'
|
||||||
import { libraryAPI } from '../api/library'
|
import { libraryAPI } from '../api/library'
|
||||||
import { schedulerAPI, type JobStatus } from '../api/scheduler'
|
|
||||||
import type { Library, User } from '../types'
|
import type { Library, User } from '../types'
|
||||||
import { DownloadClientCard } from '../components/DownloadClientCard'
|
|
||||||
import { NotifyChannelCard } from '../components/NotifyChannelCard'
|
|
||||||
import { APIConfigsPanel } from '../components/APIConfigsPanel'
|
import { APIConfigsPanel } from '../components/APIConfigsPanel'
|
||||||
import { SitesPage } from './SitesPage'
|
|
||||||
|
|
||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
const [tab, setTab] = useState<
|
const [tab, setTab] = useState<'library' | 'users' | 'settings' | 'api'>('library')
|
||||||
'sites' | 'library' | 'users' | 'settings' | 'api' | 'downloads' | 'notify' | 'scheduler'
|
|
||||||
>('sites')
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'sites' as const, label: '站点管理' },
|
|
||||||
{ key: 'library' as const, label: '媒体库' },
|
{ key: 'library' as const, label: '媒体库' },
|
||||||
{ key: 'users' as const, label: '用户' },
|
{ key: 'users' as const, label: '用户' },
|
||||||
{ key: 'api' as const, label: '外部API' },
|
{ key: 'api' as const, label: '外部API' },
|
||||||
{ key: 'settings' as const, label: '系统设置' },
|
{ key: 'settings' as const, label: '系统设置' },
|
||||||
{ key: 'downloads' as const, label: '下载客户端' },
|
|
||||||
{ key: 'notify' as const, label: '通知渠道' },
|
|
||||||
{ key: 'scheduler' as const, label: '定时任务' },
|
|
||||||
]
|
]
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -45,14 +35,10 @@ export function AdminPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'sites' && <SitesPage />}
|
|
||||||
{tab === 'library' && <LibraryPanel />}
|
{tab === 'library' && <LibraryPanel />}
|
||||||
{tab === 'users' && <UsersPanel />}
|
{tab === 'users' && <UsersPanel />}
|
||||||
{tab === 'api' && <APIConfigsPanel />}
|
{tab === 'api' && <APIConfigsPanel />}
|
||||||
{tab === 'settings' && <SettingsPanel />}
|
{tab === 'settings' && <SettingsPanel />}
|
||||||
{tab === 'downloads' && <DownloadClientCard />}
|
|
||||||
{tab === 'notify' && <NotifyChannelCard />}
|
|
||||||
{tab === 'scheduler' && <SchedulerPanel />}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -267,64 +253,3 @@ function SettingsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SchedulerPanel() {
|
|
||||||
const [tasks, setTasks] = useState<JobStatus[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
const refresh = () => {
|
|
||||||
setLoading(true)
|
|
||||||
schedulerAPI.status().then(setTasks).finally(() => setLoading(false))
|
|
||||||
}
|
|
||||||
useEffect(() => {
|
|
||||||
refresh()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="glass-panel">
|
|
||||||
{loading && <p className="py-4 text-center text-slate-400">加载中...</p>}
|
|
||||||
{!loading && tasks.length === 0 && (
|
|
||||||
<p className="py-4 text-center text-slate-400">暂无定时任务</p>
|
|
||||||
)}
|
|
||||||
{!loading && tasks.length > 0 && (
|
|
||||||
<table className="w-full text-left text-sm">
|
|
||||||
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
|
||||||
<tr>
|
|
||||||
<th className="py-2">名称</th>
|
|
||||||
<th>间隔</th>
|
|
||||||
<th>上次运行</th>
|
|
||||||
<th className="text-right">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{tasks.map((t) => (
|
|
||||||
<tr key={t.name} className="border-t border-white/5">
|
|
||||||
<td className="py-2 text-white">{t.name}</td>
|
|
||||||
<td className="text-slate-300">{t.interval}</td>
|
|
||||||
<td className="text-slate-400">
|
|
||||||
{t.last_run ? new Date(t.last_run).toLocaleString() : '-'}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 text-right">
|
|
||||||
<button
|
|
||||||
className="rounded border border-amber-400/40 px-2 py-1 text-xs text-amber-400 hover:bg-amber-400/10"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await schedulerAPI.run(t.name)
|
|
||||||
toast.success('任务已触发')
|
|
||||||
refresh()
|
|
||||||
} catch {
|
|
||||||
toast.error('触发失败')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
手动运行
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
+211
-43
@@ -1,15 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { ArrowRight, Play, Clock, Film, Compass, Sparkles } from 'lucide-react'
|
||||||
|
|
||||||
import { mediaAPI } from '../api/library'
|
import { mediaAPI } from '../api/library'
|
||||||
import { playbackAPI, type HistoryItem } from '../api/playback'
|
import { playbackAPI, type HistoryItem } from '../api/playback'
|
||||||
import { MediaCard } from '../components/MediaCard'
|
import { imageURL } from '../api/client'
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
|
|
||||||
// Landing page composes three rows:
|
|
||||||
// 1. Continue Watching — items with a positive position from the user's
|
|
||||||
// playback history (excluding completed ones).
|
|
||||||
// 2. Recently Added — most recent media across every library.
|
|
||||||
// 3. Empty state hint — shown when both rows are empty.
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const [recent, setRecent] = useState<Media[]>([])
|
const [recent, setRecent] = useState<Media[]>([])
|
||||||
const [history, setHistory] = useState<HistoryItem[]>([])
|
const [history, setHistory] = useState<HistoryItem[]>([])
|
||||||
@@ -30,50 +27,221 @@ export function HomePage() {
|
|||||||
const empty = !loading && recent.length === 0 && history.length === 0
|
const empty = !loading && recent.length === 0 && history.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="animate-fade-in space-y-12">
|
||||||
<header>
|
{/* ── Header ── */}
|
||||||
<h1 className="font-display text-3xl font-bold text-white">主页</h1>
|
<header className="flex items-end justify-between">
|
||||||
<p className="text-sm text-slate-400">从你上次离开的地方继续</p>
|
<div>
|
||||||
</header>
|
<h1 className="font-display text-3xl font-bold tracking-tight text-cream-100">
|
||||||
|
媒体中心
|
||||||
{history.length > 0 && (
|
</h1>
|
||||||
<Row title="继续观看">
|
<p className="mt-1.5 text-sm text-cream-400">
|
||||||
{history.map((h) => {
|
从你上次离开的地方继续
|
||||||
const m = h.media!
|
|
||||||
const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
|
|
||||||
return <MediaCard key={h.id} media={m} progress={progress} />
|
|
||||||
})}
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{recent.length > 0 && (
|
|
||||||
<Row title="最近添加">
|
|
||||||
{recent.map((m) => (
|
|
||||||
<MediaCard key={m.id} media={m} />
|
|
||||||
))}
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && <p className="text-slate-500">加载中…</p>}
|
|
||||||
{empty && (
|
|
||||||
<div className="glass-panel">
|
|
||||||
<p className="text-slate-300">
|
|
||||||
还没有任何媒体。前往 <span className="text-primary-400">管理后台</span>{' '}
|
|
||||||
创建媒体库,然后触发一次扫描。
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 state ── */}
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Continue Watching — wide horizontal stripe ── */}
|
||||||
|
{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) => {
|
||||||
|
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} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Recently Added — bento grid ── */}
|
||||||
|
{recent.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<SectionHeading icon={<Clock size={16} />} label="最近添加" />
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
|
{recent.slice(0, 12).map((m, i) => {
|
||||||
|
// First 2 items get double-column span on large screens
|
||||||
|
const isHero = i < 2
|
||||||
|
return (
|
||||||
|
<PosterCard
|
||||||
|
key={m.id}
|
||||||
|
media={m}
|
||||||
|
className={isHero ? 'lg:col-span-2 lg:row-span-2' : ''}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({ title, children }: { title: string; children: React.ReactNode }) {
|
/* ─── Section Heading ─── */
|
||||||
|
function SectionHeading({ icon, label }: { icon: React.ReactNode; label: string }) {
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<div className="mb-4 flex items-center gap-2">
|
||||||
<h2 className="font-display text-xl font-semibold text-white">{title}</h2>
|
<span className="text-cream-400">{icon}</span>
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
<h2 className="font-display text-lg font-semibold tracking-tight text-cream-200">
|
||||||
{children}
|
{label}
|
||||||
</div>
|
</h2>
|
||||||
</section>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Wide Continue-Watching Card ─── */
|
||||||
|
function WideContinueCard({ 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"
|
||||||
|
>
|
||||||
|
{/* Poster thumbnail */}
|
||||||
|
<div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-md bg-surface-600">
|
||||||
|
{media.poster_url ? (
|
||||||
|
<img
|
||||||
|
src={imageURL(media.poster_url)}
|
||||||
|
alt={media.title}
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-cream-900/30">
|
||||||
|
<Film size={20} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-cream-200 group-hover:text-cream-100">
|
||||||
|
{media.title}
|
||||||
|
</p>
|
||||||
|
{media.year > 0 && (
|
||||||
|
<p className="mt-0.5 text-xs text-cream-500">{media.year}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</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, className = '' }: { media: Media; className?: string }) {
|
||||||
|
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 ${className}`}
|
||||||
|
>
|
||||||
|
<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={48} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Overlay on hover */}
|
||||||
|
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-3 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<span className="flex items-center gap-1.5 text-xs text-white/90">
|
||||||
|
<Play size={12} />
|
||||||
|
播放
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2.5">
|
||||||
|
<p className="truncate text-sm font-medium text-cream-200 group-hover:text-cream-100">
|
||||||
|
{media.title}
|
||||||
|
</p>
|
||||||
|
{media.year > 0 && (
|
||||||
|
<p className="mt-0.5 text-xs 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { Film } from 'lucide-react'
|
import { Film } from 'lucide-react'
|
||||||
|
|
||||||
|
import { AppFooter } from '../components/AppFooter'
|
||||||
import { authAPI } from '../api/auth'
|
import { authAPI } from '../api/auth'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
@@ -79,6 +80,8 @@ export function LoginPage() {
|
|||||||
首次部署默认账号:<code className="text-primary-400">admin / admin123</code>
|
首次部署默认账号:<code className="text-primary-400">admin / admin123</code>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<AppFooter className="mt-8" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ const TYPE_LABELS: Record<NotifyChannel['channel_type'], string> = {
|
|||||||
wechat: '企业微信',
|
wechat: '企业微信',
|
||||||
bark: 'Bark',
|
bark: 'Bark',
|
||||||
webhook: 'Webhook',
|
webhook: 'Webhook',
|
||||||
|
email: 'Email',
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChannelCard({
|
function ChannelCard({
|
||||||
@@ -188,6 +189,8 @@ function channelSummary(ch: NotifyChannel): string {
|
|||||||
return `Device ${String(cfg.device_key ?? '').slice(0, 10)}…`
|
return `Device ${String(cfg.device_key ?? '').slice(0, 10)}…`
|
||||||
case 'webhook':
|
case 'webhook':
|
||||||
return `${cfg.method ?? 'POST'} ${cfg.url ?? ''}`
|
return `${cfg.method ?? 'POST'} ${cfg.url ?? ''}`
|
||||||
|
case 'email':
|
||||||
|
return `SMTP ${cfg.smtp_host ?? '-'}:${cfg.smtp_port ?? '-'} → ${cfg.to ?? '-'}`
|
||||||
default:
|
default:
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -200,6 +203,7 @@ const EMPTY_CONFIG: Record<NotifyChannel['channel_type'], Record<string, string>
|
|||||||
wechat: { sendkey: '' },
|
wechat: { sendkey: '' },
|
||||||
bark: { device_key: '', server: '' },
|
bark: { device_key: '', server: '' },
|
||||||
webhook: { url: '', method: 'POST', headers: '', body_template: '' },
|
webhook: { url: '', method: 'POST', headers: '', body_template: '' },
|
||||||
|
email: { smtp_host: '', smtp_port: '465', username: '', password: '', from: '', to: '', tls: 'true' },
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChannelFormModal({
|
function ChannelFormModal({
|
||||||
@@ -282,6 +286,7 @@ function ChannelFormModal({
|
|||||||
<option value="wechat">企业微信 / Server酱</option>
|
<option value="wechat">企业微信 / Server酱</option>
|
||||||
<option value="bark">Bark (iOS)</option>
|
<option value="bark">Bark (iOS)</option>
|
||||||
<option value="webhook">Webhook</option>
|
<option value="webhook">Webhook</option>
|
||||||
|
<option value="email">Email (SMTP)</option>
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -383,6 +388,78 @@ function ChannelFormModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{type === 'email' && (
|
||||||
|
<>
|
||||||
|
<Field label="SMTP 地址">
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
className="input-base"
|
||||||
|
placeholder="smtp.gmail.com"
|
||||||
|
value={config.smtp_host ?? ''}
|
||||||
|
onChange={(e) => updateConfig('smtp_host', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="SMTP 端口">
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
className="input-base"
|
||||||
|
placeholder="465"
|
||||||
|
value={config.smtp_port ?? ''}
|
||||||
|
onChange={(e) => updateConfig('smtp_port', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="TLS">
|
||||||
|
<select
|
||||||
|
className="input-base"
|
||||||
|
value={config.tls ?? 'true'}
|
||||||
|
onChange={(e) => updateConfig('tls', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="true">启用</option>
|
||||||
|
<option value="false">关闭</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="用户名">
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
className="input-base"
|
||||||
|
value={config.username ?? ''}
|
||||||
|
onChange={(e) => updateConfig('username', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="密码">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className="input-base"
|
||||||
|
value={config.password ?? ''}
|
||||||
|
onChange={(e) => updateConfig('password', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="发件人">
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
className="input-base"
|
||||||
|
placeholder="noreply@example.com"
|
||||||
|
value={config.from ?? ''}
|
||||||
|
onChange={(e) => updateConfig('from', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="收件人 (多个用逗号分隔)">
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
className="input-base"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
value={config.to ?? ''}
|
||||||
|
onChange={(e) => updateConfig('to', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<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-slate-300">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { FormEvent, useEffect, useState } from 'react'
|
|||||||
import { Loader2, Save, SettingsIcon } from 'lucide-react'
|
import { Loader2, Save, SettingsIcon } from 'lucide-react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
import { AppFooter } from '../components/AppFooter'
|
||||||
import { adminAPI } from '../api/admin'
|
import { adminAPI } from '../api/admin'
|
||||||
import type { Setting } from '../types'
|
import type { Setting } from '../types'
|
||||||
|
|
||||||
@@ -288,6 +289,8 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AppFooter className="mt-6" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ export interface NotifyChannel {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
|
type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
|
||||||
channel_type: 'telegram' | 'wechat' | 'bark' | 'webhook'
|
channel_type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
events: string
|
events: string
|
||||||
config: Record<string, string>
|
config: Record<string, string>
|
||||||
|
|||||||
+96
-13
@@ -4,24 +4,107 @@ export default {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
display: ['Orbitron', 'sans-serif'],
|
display: ['"Cabinet Grotesk"', 'system-ui', 'sans-serif'],
|
||||||
body: ['Inter', 'PingFang SC', 'Microsoft YaHei', 'sans-serif'],
|
body: ['Geist', '"PingFang SC"', '"Microsoft YaHei"', 'sans-serif'],
|
||||||
|
mono: ['"JetBrains Mono"', '"Fira Code"', 'monospace'],
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
primary: {
|
// ── New Warm Industrial palette ──
|
||||||
400: '#00F0FF',
|
brand: {
|
||||||
500: '#00D4E0',
|
DEFAULT: '#c9954a',
|
||||||
600: '#00A8B8',
|
50: '#fdf8f0',
|
||||||
|
100: '#f9edda',
|
||||||
|
200: '#f2d8b0',
|
||||||
|
300: '#e8bc7d',
|
||||||
|
400: '#dca45c',
|
||||||
|
500: '#c9954a',
|
||||||
|
600: '#b07a38',
|
||||||
|
700: '#936031',
|
||||||
|
800: '#794e2d',
|
||||||
|
900: '#654128',
|
||||||
|
950: '#3a2214',
|
||||||
},
|
},
|
||||||
accent: {
|
sage: {
|
||||||
400: '#A855F7',
|
DEFAULT: '#7a9a8a',
|
||||||
500: '#8A2BE2',
|
50: '#f3f7f4',
|
||||||
|
100: '#e4ece6',
|
||||||
|
200: '#c9d9ce',
|
||||||
|
300: '#a6bfad',
|
||||||
|
400: '#7a9a8a',
|
||||||
|
500: '#5e7e6e',
|
||||||
|
600: '#496558',
|
||||||
|
700: '#3c5247',
|
||||||
|
800: '#334339',
|
||||||
|
900: '#2c3831',
|
||||||
|
950: '#151f19',
|
||||||
},
|
},
|
||||||
surface: {
|
surface: {
|
||||||
700: '#1a2332',
|
DEFAULT: '#1a1a18',
|
||||||
800: '#121a27',
|
50: '#2e2e2a',
|
||||||
900: '#0b1120',
|
100: '#2a2a26',
|
||||||
950: '#060a13',
|
200: '#242420',
|
||||||
|
300: '#1e1e1b',
|
||||||
|
400: '#1a1a18',
|
||||||
|
500: '#161614',
|
||||||
|
600: '#111110',
|
||||||
|
// Backward-compat (old 700-950 → warm charcoal)
|
||||||
|
700: '#242420',
|
||||||
|
800: '#1a1a18',
|
||||||
|
900: '#111110',
|
||||||
|
950: '#0d0d0c',
|
||||||
|
},
|
||||||
|
cream: {
|
||||||
|
DEFAULT: '#e8e4d9',
|
||||||
|
50: '#faf8f4',
|
||||||
|
100: '#f3f0e8',
|
||||||
|
200: '#e8e4d9',
|
||||||
|
300: '#c4bfb5',
|
||||||
|
400: '#a09b92',
|
||||||
|
500: '#7d7871',
|
||||||
|
600: '#605c56',
|
||||||
|
700: '#4a4742',
|
||||||
|
800: '#363330',
|
||||||
|
900: '#242220',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Backward-compat aliases (old class names → new warm colors) ──
|
||||||
|
primary: {
|
||||||
|
400: '#dca45c', // was cyan → now brand-400
|
||||||
|
500: '#c9954a', // was cyan → now brand-500
|
||||||
|
600: '#b07a38', // was cyan → now brand-600
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
400: '#7a9a8a', // was purple → now sage-400
|
||||||
|
500: '#5e7e6e', // was purple → now sage-500
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
'2xs': ['0.625rem', { lineHeight: '0.875rem' }],
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
'18': '4.5rem',
|
||||||
|
'88': '22rem',
|
||||||
|
},
|
||||||
|
transitionDuration: {
|
||||||
|
'400': '400ms',
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'slide-in-left': 'slideInLeft 0.3s ease-out',
|
||||||
|
'fade-in': 'fadeIn 0.4s ease-out',
|
||||||
|
'scale-in': 'scaleIn 0.3s ease-out',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
slideInLeft: {
|
||||||
|
'0%': { opacity: '0', transform: 'translateX(-8px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateX(0)' },
|
||||||
|
},
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
scaleIn: {
|
||||||
|
'0%': { opacity: '0', transform: 'scale(0.96)' },
|
||||||
|
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user