Resolve merge conflicts: merge all Go backend and React frontend changes

This commit is contained in:
ShukeBta
2026-05-16 22:48:30 +08:00
66 changed files with 8526 additions and 40 deletions
+103 -1
View File
@@ -66,7 +66,42 @@ const WatchHistoryPage = lazy(() =>
const PosterWallPage = lazy(() =>
import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })),
)
const SitesPage = lazy(() => import('./pages/SitesPage').then((m) => ({ default: m.SitesPage })))
const SitesPage = lazy(() =>
import('./pages/SitesPage').then((m) => ({ default: m.SitesPage })),
)
const SiteSearchPage = lazy(() =>
import('./pages/SiteSearchPage').then((m) => ({ default: m.SiteSearchPage })),
)
const AIAssistantPage = lazy(() =>
import('./pages/AIAssistantPage').then((m) => ({ default: m.AIAssistantPage })),
)
const StrmPage = lazy(() =>
import('./pages/StrmPage').then((m) => ({ default: m.StrmPage })),
)
const ToolsPage = lazy(() =>
import('./pages/ToolsPage').then((m) => ({ default: m.ToolsPage })),
)
const ProfileManagementPage = lazy(() =>
import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })),
)
const NotifyChannelsPage = lazy(() =>
import('./pages/NotifyChannelsPage').then((m) => ({ default: m.NotifyChannelsPage })),
)
const SettingsPage = lazy(() =>
import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })),
)
const AssistantChatPage = lazy(() =>
import('./pages/AssistantChatPage').then((m) => ({ default: m.AssistantChatPage })),
)
const DownloadClientsPage = lazy(() =>
import('./pages/DownloadClientsPage').then((m) => ({ default: m.DownloadClientsPage })),
)
const LicensePage = lazy(() =>
import('./pages/LicensePage').then((m) => ({ default: m.LicensePage })),
)
const StorageConfigPage = lazy(() =>
import('./pages/StorageConfigPage').then((m) => ({ default: m.StorageConfigPage })),
)
const Loading = () => <p className="px-6 py-8 text-slate-500">加载中…</p>
@@ -98,6 +133,9 @@ export default function App() {
<Route path="dlna" element={<DlnaPage />} />
<Route path="history" element={<WatchHistoryPage />} />
<Route path="poster-wall" element={<PosterWallPage />} />
<Route path="site-search" element={<SiteSearchPage />} />
<Route path="ai" element={<AIAssistantPage />} />
<Route path="play-profiles" element={<ProfileManagementPage />} />
<Route
path="sites"
element={
@@ -158,6 +196,70 @@ export default function App() {
</RequireAdmin>
}
/>
<Route
path="strm"
element={
<RequireAdmin>
<StrmPage />
</RequireAdmin>
}
/>
<Route
path="tools"
element={
<RequireAdmin>
<ToolsPage />
</RequireAdmin>
}
/>
<Route
path="notify-channels"
element={
<RequireAdmin>
<NotifyChannelsPage />
</RequireAdmin>
}
/>
<Route
path="settings"
element={
<RequireAdmin>
<SettingsPage />
</RequireAdmin>
}
/>
<Route
path="assistant"
element={
<RequireAdmin>
<AssistantChatPage />
</RequireAdmin>
}
/>
<Route
path="download-clients"
element={
<RequireAdmin>
<DownloadClientsPage />
</RequireAdmin>
}
/>
<Route
path="license"
element={
<RequireAdmin>
<LicensePage />
</RequireAdmin>
}
/>
<Route
path="storage-config"
element={
<RequireAdmin>
<StorageConfigPage />
</RequireAdmin>
}
/>
<Route
path="stats"
element={
+65
View File
@@ -0,0 +1,65 @@
import { api } from './client'
export interface AssistantSession {
id: string
user_id: string
title: string
created_at: string
updated_at: string
}
export interface AssistantMessage {
id: string
session_id: string
role: 'user' | 'assistant' | 'system'
content: string
operation_id?: string
created_at: string
}
export interface SessionView {
session: AssistantSession
messages: AssistantMessage[]
}
export const assistantAPI = {
listSessions: () =>
api.get<AssistantSession[]>('/admin/assistant/sessions').then((r) => r.data),
createSession: (title?: string) =>
api
.post<AssistantSession>('/admin/assistant/sessions', { title })
.then((r) => r.data),
getSession: (id: string) =>
api.get<SessionView>(`/admin/assistant/session/${id}`).then((r) => r.data),
deleteSession: (id: string) =>
api.delete(`/admin/assistant/session/${id}`).then((r) => r.data),
chat: (sessionID: string, message: string) =>
api
.post<SessionView>('/admin/assistant/chat', {
session_id: sessionID,
message,
})
.then((r) => r.data),
execute: (sessionID: string, action: Record<string, unknown>) =>
api
.post<{ op_id: string }>('/admin/assistant/execute', {
session_id: sessionID,
action,
})
.then((r) => r.data),
undo: (opID: string) =>
api.post(`/admin/assistant/undo/${opID}`).then((r) => r.data),
history: () =>
api
.get<{ items: { op_id: string; session: string; created_at: string; content: string }[] }>(
'/admin/assistant/history',
)
.then((r) => r.data.items),
}
+17
View File
@@ -0,0 +1,17 @@
import { api } from './client'
import type { DiscoverItem, DiscoverSection } from '../types'
// discoverExtraAPI wraps the Vue-style multi-section feed used by the
// React DiscoverPage rails. Use the existing /discover/trending and
// /discover/popular helpers for the simple cases.
export const discoverExtraAPI = {
sections: () =>
api.get<{ sections: DiscoverSection[] }>('/discover/sections').then((r) => r.data.sections),
feed: (sectionKeys: string[]) =>
api
.get<Record<string, DiscoverItem[] | null>>('/discover/feed', {
params: { sections: sectionKeys.join(',') },
})
.then((r) => r.data),
}
+53
View File
@@ -0,0 +1,53 @@
import { api } from './client'
export type DownloadClientType = 'qbittorrent' | 'aria2' | 'transmission'
export interface DownloadClient {
id: string
name: string
type: DownloadClientType
url: string
username?: string
save_path?: string
is_default: boolean
enabled: boolean
created_at: string
updated_at: string
}
export interface DownloadClientInput {
name: string
type: DownloadClientType
url: string
username?: string
password?: string
save_path?: string
is_default: boolean
enabled: boolean
}
export const downloadClientsAPI = {
list: () =>
api.get<DownloadClient[]>('/admin/download/clients').then((r) => r.data),
create: (input: DownloadClientInput) =>
api.post<DownloadClient>('/admin/download/clients', input).then((r) => r.data),
update: (id: string, input: DownloadClientInput) =>
api
.put<DownloadClient>(`/admin/download/clients/${id}`, input)
.then((r) => r.data),
remove: (id: string) =>
api.delete(`/admin/download/clients/${id}`).then((r) => r.data),
test: (id: string) =>
api
.post<{ ok: boolean; error?: string }>(`/admin/download/clients/${id}/test`)
.then((r) => r.data),
aria2Stats: (clientID: string) =>
api
.get('/admin/download/aria2/stats', { params: { client_id: clientID } })
.then((r) => r.data),
}
+30
View File
@@ -0,0 +1,30 @@
import { api } from './client'
import type { HistoryItem, HistoryStats, Media } from '../types'
// historyAPI wraps /watch-history and /history. The two share storage on
// the backend; we treat /watch-history as the rich admin/dashboard
// surface and /history as the legacy resume-position write.
export const historyAPI = {
list: (limit = 50) =>
api
.get<HistoryItem[]>('/watch-history', { params: { limit } })
.then((r) => r.data),
stats: () => api.get<HistoryStats>('/watch-history/stats').then((r) => r.data),
continueWatching: (limit = 10) =>
api
.get<{ history: HistoryItem; media: Media }[]>('/watch-history/continue', {
params: { limit },
})
.then((r) => r.data),
clear: (mediaID?: string) =>
api
.delete('/watch-history', {
params: mediaID ? { media_id: mediaID } : {},
})
.then((r) => r.data),
remove: (id: string) => api.delete(`/watch-history/${id}`).then((r) => r.data),
}
+71
View File
@@ -0,0 +1,71 @@
import { api } from './client'
// LicenseKey + LicenseActivation mirror the Go model structs.
export interface LicenseKey {
id: string
key: string
customer?: string
plan: string
max_activations: number
issued_at: string
expires_at?: string | null
revoked: boolean
notes?: string
created_at: string
updated_at: string
}
export interface LicenseActivation {
id: string
key_id: string
device_id: string
device_name?: string
ip?: string
unbound_at?: string | null
heartbeat_at?: string | null
created_at: string
}
export interface GenerateKeyInput {
customer?: string
plan?: string
max_activations?: number
expires_at?: string // RFC3339; "" or omit for perpetual
notes?: string
}
export const licenseAPI = {
generate: (input: GenerateKeyInput) =>
api.post<LicenseKey>('/admin/license/generate', input).then((r) => r.data),
list: () => api.get<LicenseKey[]>('/admin/license/list').then((r) => r.data),
listActivations: (keyID: string) =>
api
.get<LicenseActivation[]>(`/admin/license/${keyID}/activations`)
.then((r) => r.data),
revoke: (keyID: string) =>
api.post(`/admin/license/${keyID}/revoke`).then((r) => r.data),
unbind: (activationID: string) =>
api.post(`/admin/license/activation/${activationID}/unbind`).then((r) => r.data),
// Self-service
activate: (key: string, deviceID: string, deviceName?: string) =>
api
.post<LicenseActivation>('/license/activate', {
key,
device_id: deviceID,
device_name: deviceName,
})
.then((r) => r.data),
status: (keyID: string) =>
api
.get<{ key: LicenseKey; active_activations: number; valid: boolean }>(
'/license/status',
{ params: { key_id: keyID } },
)
.then((r) => r.data),
}
+19
View File
@@ -0,0 +1,19 @@
import { api } from './client'
import type { Media } from '../types'
// Auxiliary media surfaces used by the home page rails and the admin
// dashboard "library composition" card.
export const mediaExtraAPI = {
recent: (limit = 12) =>
api.get<Media[]>('/media/recent', { params: { limit } }).then((r) => r.data),
stats: () =>
api
.get<{
by_type: { movies: number; tv: number; anime: number; music: number; unscraped: number }
total: number
total_size: number
total_seconds: number
}>('/media/stats')
.then((r) => r.data),
}
+29
View File
@@ -0,0 +1,29 @@
import { api } from './client'
import type { NotifyChannel } from '../types'
// Payload accepted by create / update. `events` and `enabled` are optional.
export interface NotifyChannelInput {
name: string
channel_type: NotifyChannel['channel_type']
config: Record<string, string>
events?: string[]
enabled?: boolean
}
// notifyChannelsAPI wraps the admin /admin/notify/channels surface.
export const notifyChannelsAPI = {
list: () =>
api.get<NotifyChannel[]>('/admin/notify/channels').then((r) => r.data),
create: (input: NotifyChannelInput) =>
api.post<NotifyChannel>('/admin/notify/channels', input).then((r) => r.data),
update: (id: string, input: NotifyChannelInput) =>
api.put<NotifyChannel>(`/admin/notify/channels/${id}`, input).then((r) => r.data),
remove: (id: string) =>
api.delete(`/admin/notify/channels/${id}`).then((r) => r.data),
test: (id: string) =>
api.post<{ message: string }>(`/admin/notify/channels/${id}/test`).then((r) => r.data),
}
+38
View File
@@ -0,0 +1,38 @@
import { api } from './client'
export interface UserPermission {
user_id: string
can_play_media: boolean
can_favorite: boolean
can_view_history: boolean
can_view_dashboard: boolean
can_view_discover: boolean
can_manage_downloads: boolean
can_manage_subscriptions: boolean
can_manage_sites: boolean
can_manage_files: boolean
can_manage_strm: boolean
can_cast: boolean
can_use_ai_assistant: boolean
can_access_settings: boolean
updated_at: string
}
export const permissionsAPI = {
// Caller's effective permissions; admins always get the all-true set.
mine: () => api.get<UserPermission>('/auth/permissions').then((r) => r.data),
// Admin endpoints
get: (userID: string) =>
api.get<UserPermission>(`/admin/users/${userID}/permissions`).then((r) => r.data),
save: (userID: string, p: UserPermission) =>
api
.put<UserPermission>(`/admin/users/${userID}/permissions`, p)
.then((r) => r.data),
reset: (userID: string) =>
api
.post<UserPermission>(`/admin/users/${userID}/permissions/reset`)
.then((r) => r.data),
}
+35
View File
@@ -0,0 +1,35 @@
import { api } from './client'
import type { PlayProfile } from '../types'
// Payload accepted by create / update.
export interface PlayProfileInput {
user_id?: string
name: string
is_default: boolean
content_rating_limit?: string
allow_adult: boolean
require_pin: boolean
pin?: string
preferred_subtitle_lang?: string
preferred_audio_lang?: string
autoplay_next: boolean
skip_intro: boolean
allowed_library_ids: string[]
}
// playProfilesAPI wraps /play-profiles. The admin variant adds ?all=true.
export const playProfilesAPI = {
list: (all = false) =>
api
.get<PlayProfile[]>('/play-profiles', { params: all ? { all: 'true' } : {} })
.then((r) => r.data),
create: (input: PlayProfileInput) =>
api.post<PlayProfile>('/play-profiles', input).then((r) => r.data),
update: (id: string, input: PlayProfileInput) =>
api.put<PlayProfile>(`/play-profiles/${id}`, input).then((r) => r.data),
remove: (id: string) =>
api.delete(`/play-profiles/${id}`).then((r) => r.data),
}
+60 -1
View File
@@ -1,6 +1,59 @@
import { api } from './client'
// Site management API - matches Go backend /api/sites endpoints
// ─── TypeScript interfaces ──────────────────────────────────────────────
export interface Site {
id: string
name: string
base_url: string
site_type: string
auth_type: string
cookie?: string
api_key?: string
auth_header?: string
user_agent?: string
rss_url?: string
timeout: number
priority: number
use_proxy: boolean
enabled: boolean
login_status: string
downloader?: string
created_at: string
updated_at: string
}
export interface SiteSearchResult {
site_name: string
site_id: string
title: string
torrent_url: string
download_url: string
size: number
seeders: number
leechers: number
free: boolean
}
export interface CreateSiteInput {
name: string
base_url: string
site_type?: string
auth_type?: string
cookie?: string
api_key?: string
auth_header?: string
user_agent?: string
rss_url?: string
timeout?: number
priority?: number
use_proxy?: boolean
enabled?: boolean
downloader?: string
}
// ─── API client ─────────────────────────────────────────────────────────
export const sitesAPI = {
// List all sites
list: () => api.get('/sites').then((r) => r.data),
@@ -29,4 +82,10 @@ export const sitesAPI = {
// Get supported auth types
authTypes: () => api.get('/sites/auth-types').then((r) => r.data),
// Search across all sites
search: (keyword: string) =>
api
.get('/sites/search', { params: { keyword } })
.then((r) => r.data),
}
+40
View File
@@ -0,0 +1,40 @@
import { api } from './client'
import type { Hardware, Library, Media } from '../types'
// statsExtraAPI exposes the admin dashboard surfaces beyond /stats.
export const statsExtraAPI = {
overview: () =>
api
.get<{
libraries: number
media_count: number
users_count: number
total_size: number
total_seconds: number
generated_at: string
}>('/stats/overview')
.then((r) => r.data),
trend: (days = 14) =>
api
.get<{ trend: { day: string; count: number }[]; days: number }>('/stats/trend', {
params: { days },
})
.then((r) => r.data),
topContent: (limit = 10) =>
api
.get<{
items: { media: Media; play_count: number; last_played: string }[]
}>('/stats/top-content', { params: { limit } })
.then((r) => r.data),
libraries: () =>
api
.get<{
libraries: { library: Library; item_count: number; total_size: number }[]
}>('/stats/libraries')
.then((r) => r.data),
monitor: () => api.get<Hardware>('/stats/monitor').then((r) => r.data),
}
+36
View File
@@ -0,0 +1,36 @@
import { api } from './client'
export type StorageType = 'alist' | 's3' | 'webdav'
export interface StorageConfig {
id: string
type: StorageType
config: Record<string, string>
enabled: boolean
last_error?: string
created_at: string
updated_at: string
}
export const storageAPI = {
status: () =>
api
.get<{ items: StorageConfig[] }>('/admin/storage/status')
.then((r) => r.data.items),
get: (type: StorageType) =>
api.get<StorageConfig>(`/admin/storage/${type}`).then((r) => r.data),
save: (type: StorageType, config: Record<string, string>, enabled = true) =>
api
.put<StorageConfig>(`/admin/storage/${type}`, { type, config, enabled })
.then((r) => r.data),
test: (type: StorageType, config: Record<string, string>) =>
api
.post<{ ok: boolean; error?: string }>(`/admin/storage/${type}/test`, {
type,
config,
})
.then((r) => r.data),
}
+38
View File
@@ -0,0 +1,38 @@
import { api } from './client'
// systemAPI wraps the read-only /system/* metadata endpoints used by the
// React shell (footer, admin dashboard, scheduler page).
export const systemAPI = {
info: () =>
api
.get<{
name: string
version: string
go: string
os: string
arch: string
data_dir: string
cache_dir: string
}>('/system/info')
.then((r) => r.data),
status: () =>
api
.get<{
uptime_seconds: number
goroutines: number
cpu_percent?: number
memory_used?: number
memory_total?: number
disk_used?: number
disk_total?: number
}>('/system/status')
.then((r) => r.data),
scheduler: () =>
api
.get<{ jobs: { name: string; cron: string; next_run?: string; last_run?: string }[] }>(
'/system/scheduler',
)
.then((r) => r.data),
}
+21
View File
@@ -0,0 +1,21 @@
import { api } from './client'
// toolsAPI groups admin-only endpoints that don't fit the other domain
// modules: organizing media files into the canonical naming layout, and
// dispatching a test notification through the configured channels.
export const toolsAPI = {
organizeMedia: (mediaID: string) =>
api
.post<{ path: string }>(`/admin/media/${mediaID}/organize`)
.then((r) => r.data),
organizeLibrary: (libraryID: string) =>
api
.post<Record<string, unknown>>(`/admin/libraries/${libraryID}/organize`)
.then((r) => r.data),
notifyTest: (title: string, body: string) =>
api
.post<{ message: string }>('/admin/notify/test', { title, body })
.then((r) => r.data),
}
+23
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
import {
Activity,
Bell,
Cast,
Clock,
CloudDownload,
@@ -14,13 +15,23 @@ import {
Heart,
Home,
GalleryHorizontalEnd,
KeyRound,
KeySquare,
Link2,
ListChecks,
ListMusic,
LogOut,
MessageSquare,
Rss,
Search,
Server,
Settings,
Sliders,
Sparkles,
Cloud,
Trash2,
UserCog,
Wrench,
Library as LibraryIcon,
User as UserIcon,
} from 'lucide-react'
@@ -60,6 +71,7 @@ export function Layout() {
<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="观看历史" />
@@ -86,11 +98,14 @@ export function Layout() {
<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' && (
<>
@@ -104,6 +119,14 @@ export function Layout() {
<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="管理后台" />
</>
+242
View File
@@ -0,0 +1,242 @@
import { FormEvent, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loader2, Search, Sparkles, Wand2 } from 'lucide-react'
import toast from 'react-hot-toast'
import { aiAPI, type SearchIntent } from '../api/ai'
import { MediaCard } from '../components/MediaCard'
import type { Media } from '../types'
// AIAssistantPage exposes the two AI helpers backed by the Go server:
// - smart search: parses a natural-language query into a SearchIntent +
// a list of matching local media items.
// - recommendations: returns a list of recommended titles based on the
// current user's recent watch history.
//
// The Vue version had a full chat surface; the Go backend has no chat or
// operation-execute endpoints, so we render the same two capabilities as
// a focused two-panel screen.
export function AIAssistantPage() {
const [status, setStatus] = useState<{
enabled: boolean
provider: string
model: string
} | null>(null)
const [query, setQuery] = useState('')
const [searching, setSearching] = useState(false)
const [intent, setIntent] = useState<SearchIntent | null>(null)
const [items, setItems] = useState<Media[]>([])
const [recs, setRecs] = useState<string[] | null>(null)
const [recommending, setRecommending] = useState(false)
useEffect(() => {
aiAPI
.status()
.then(setStatus)
.catch(() => setStatus({ enabled: false, provider: '', model: '' }))
}, [])
const onSearch = async (e: FormEvent) => {
e.preventDefault()
if (!query.trim()) return
setSearching(true)
setIntent(null)
setItems([])
try {
const r = await aiAPI.smartSearch(query.trim())
setIntent(r.intent)
setItems(r.items)
if (r.items.length === 0) toast('未找到匹配项')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'搜索失败'
toast.error(msg)
} finally {
setSearching(false)
}
}
const onRecommend = async () => {
setRecommending(true)
try {
const titles = await aiAPI.recommend()
setRecs(titles)
if (titles.length === 0) toast('暂无可推荐内容,请先观看一些媒体')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'获取推荐失败'
toast.error(msg)
} finally {
setRecommending(false)
}
}
const quickHints = [
'2023 年的科幻电影',
'评分高的动漫',
'最近添加的纪录片',
'中文剧集',
]
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-primary-400 to-purple-500">
<Sparkles className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-white">AI 助手</h1>
<p className="text-sm text-slate-400">
自然语言搜索 · 基于观影历史的智能推荐
</p>
</div>
</div>
{status && (
<div className="text-xs text-slate-400">
<span
className={
'mr-2 inline-block h-2 w-2 rounded-full ' +
(status.enabled ? 'bg-emerald-400' : 'bg-slate-500')
}
/>
{status.enabled
? `已连接 · ${status.provider}${status.model ? ' / ' + status.model : ''}`
: '未配置 AI 服务,使用本地规则解析'}
</div>
)}
</div>
{/* Smart search */}
<section className="glass-panel space-y-4">
<h2 className="font-display text-lg font-semibold text-white">智能搜索</h2>
<form onSubmit={onSearch} className="flex gap-2">
<input
className="input-base flex-1"
placeholder="试试: 2023 年的高分动作片"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit" disabled={searching} className="neon-button">
{searching ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Search size={16} />
)}
搜索
</button>
</form>
<div className="flex flex-wrap gap-2">
{quickHints.map((h) => (
<button
key={h}
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"
>
{h}
</button>
))}
</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="flex flex-wrap gap-x-6 gap-y-1">
<span>
查询: <span className="text-primary-400">{intent.query || '—'}</span>
</span>
{intent.year !== undefined && intent.year > 0 && (
<span>
年份: <span className="text-primary-400">{intent.year}</span>
</span>
)}
{intent.genre && (
<span>
类型: <span className="text-primary-400">{intent.genre}</span>
</span>
)}
{intent.type && (
<span>
分类: <span className="text-primary-400">{intent.type}</span>
</span>
)}
{intent.sort && (
<span>
排序: <span className="text-primary-400">{intent.sort}</span>
</span>
)}
{intent.language && (
<span>
语言: <span className="text-primary-400">{intent.language}</span>
</span>
)}
</div>
</div>
)}
{items.length > 0 && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{items.map((m) => (
<MediaCard key={m.id} media={m} />
))}
</div>
)}
</section>
{/* Recommendations */}
<section className="glass-panel space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-display text-lg font-semibold text-white">为你推荐</h2>
<button onClick={onRecommend} disabled={recommending} className="neon-button">
{recommending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Wand2 size={16} />
)}
生成推荐
</button>
</div>
<p className="text-xs text-slate-500">
推荐基于你的最近观看历史。点击标题在媒体库中查找。
</p>
{recs && recs.length > 0 && (
<ul className="grid gap-2 sm:grid-cols-2">
{recs.map((t, i) => (
<li key={i}>
<Link
to={`/search?q=${encodeURIComponent(t)}`}
className="flex items-center justify-between rounded-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"
>
<span className="truncate">{t}</span>
<Search size={14} className="shrink-0 opacity-60" />
</Link>
</li>
))}
</ul>
)}
{recs && recs.length === 0 && (
<p className="text-sm text-slate-400">
还没有推荐结果 — 先去看几部片子,我再给你挑。
</p>
)}
</section>
{/* Decorative footer (mirrors the Vue page hint that AI runs locally). */}
{!status?.enabled && (
<p className="text-xs text-slate-500">
提示: 当前未配置外部 AI Provider,系统将使用本地规则引擎解析查询。
管理员可在 <Link to="/api-configs" className="text-primary-400">API 配置</Link>{' '}
中接入 OpenAI / DeepSeek 等服务以获得更好效果。
</p>
)}
</div>
)
}
+221
View File
@@ -0,0 +1,221 @@
import { FormEvent, useEffect, useRef, useState } from 'react'
import { Loader2, MessageSquare, Plus, Send, Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
import {
assistantAPI,
type AssistantMessage,
type AssistantSession,
type SessionView,
} from '../api/assistant'
// AssistantChatPage is the multi-turn chat surface backed by the Go
// AssistantService. It complements the older AIAssistantPage which is
// limited to single-turn smart search + recommendations.
export function AssistantChatPage() {
const [sessions, setSessions] = useState<AssistantSession[]>([])
const [active, setActive] = useState<SessionView | null>(null)
const [draft, setDraft] = useState('')
const [sending, setSending] = useState(false)
const [loading, setLoading] = useState(true)
const messagesEndRef = useRef<HTMLDivElement | null>(null)
const refreshSessions = async () => {
try {
const list = await assistantAPI.listSessions()
setSessions(list)
// Auto-select the most recent if nothing is open.
if (list.length > 0 && !active) {
const view = await assistantAPI.getSession(list[0].id)
setActive(view)
}
} finally {
setLoading(false)
}
}
useEffect(() => {
refreshSessions().catch(() => undefined)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [active?.messages.length])
const onNew = async () => {
try {
const sess = await assistantAPI.createSession('New chat')
const view = await assistantAPI.getSession(sess.id)
setActive(view)
await refreshSessions()
} catch {
toast.error('创建会话失败')
}
}
const onSelect = async (id: string) => {
try {
setActive(await assistantAPI.getSession(id))
} catch {
toast.error('加载会话失败')
}
}
const onDelete = async (id: string) => {
if (!confirm('删除此会话?')) return
try {
await assistantAPI.deleteSession(id)
if (active?.session.id === id) setActive(null)
await refreshSessions()
} catch {
toast.error('删除失败')
}
}
const onSend = async (e: FormEvent) => {
e.preventDefault()
if (!draft.trim() || !active) return
setSending(true)
const text = draft.trim()
setDraft('')
// Optimistic append so the user's turn shows immediately.
setActive((s) =>
s
? {
...s,
messages: [
...s.messages,
{
id: 'pending-' + Date.now(),
session_id: s.session.id,
role: 'user',
content: text,
created_at: new Date().toISOString(),
} as AssistantMessage,
],
}
: s,
)
try {
const view = await assistantAPI.chat(active.session.id, text)
setActive(view)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'发送失败'
toast.error(msg)
} finally {
setSending(false)
}
}
return (
<div className="grid h-[calc(100vh-100px)] grid-cols-[260px_1fr] gap-4">
{/* 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>
<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">
<Loader2 className="animate-spin" />
</div>
)}
<ul className="flex-1 space-y-1 overflow-y-auto pr-1">
{sessions.map((s) => (
<li
key={s.id}
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')
}
onClick={() => onSelect(s.id)}
>
<MessageSquare size={14} className="shrink-0" />
<span className="flex-1 truncate">{s.title || '未命名'}</span>
<button
onClick={(e) => {
e.stopPropagation()
onDelete(s.id)
}}
className="opacity-0 group-hover:opacity-100"
>
<Trash2 size={12} className="text-red-400" />
</button>
</li>
))}
{!loading && sessions.length === 0 && (
<li className="px-2 py-2 text-xs text-slate-500">暂无会话</li>
)}
</ul>
</aside>
{/* Conversation pane */}
<section className="glass-panel flex flex-col overflow-hidden">
{!active && (
<div className="m-auto text-center text-slate-400">
<MessageSquare size={32} className="mx-auto mb-2 opacity-50" />
<p>选择或创建一个会话开始对话</p>
</div>
)}
{active && (
<>
<div className="mb-3 border-b border-white/5 pb-2">
<h2 className="font-display text-base font-semibold text-white">
{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>
)}
{active.messages.map((m) => (
<Bubble key={m.id} message={m} />
))}
<div ref={messagesEndRef} />
</div>
<form onSubmit={onSend} className="mt-3 flex gap-2 border-t border-white/5 pt-3">
<input
className="input-base flex-1"
placeholder="发送消息…"
value={draft}
onChange={(e) => setDraft(e.target.value)}
disabled={sending}
/>
<button type="submit" disabled={sending || !draft.trim()} className="neon-button">
{sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
发送
</button>
</form>
</>
)}
</section>
</div>
)
}
function Bubble({ message }: { message: AssistantMessage }) {
const mine = message.role === 'user'
return (
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
<div
className={
'max-w-[80%] whitespace-pre-wrap rounded-2xl px-4 py-2 text-sm ' +
(mine
? 'bg-primary-400/20 text-primary-100'
: message.role === 'system'
? 'border border-amber-400/30 bg-amber-400/5 text-amber-200'
: 'bg-white/5 text-slate-200')
}
>
{message.content}
</div>
</div>
)
}
+334
View File
@@ -0,0 +1,334 @@
import { FormEvent, useEffect, useState } from 'react'
import { Loader2, Pencil, Plus, Send, Server, Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
import {
downloadClientsAPI,
type DownloadClient,
type DownloadClientInput,
type DownloadClientType,
} from '../api/download_clients'
// DownloadClientsPage manages multiple downloader integrations.
// Replaces the Vue UI's DownloadView "clients" tab with a typed CRUD
// surface and a per-client Test button.
export function DownloadClientsPage() {
const [clients, setClients] = useState<DownloadClient[]>([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState<DownloadClient | null>(null)
const [showForm, setShowForm] = useState(false)
const refresh = async () => {
setLoading(true)
try {
setClients(await downloadClientsAPI.list())
} finally {
setLoading(false)
}
}
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const onTest = async (id: string) => {
try {
const r = await downloadClientsAPI.test(id)
if (r.ok) toast.success('连接成功')
else toast.error(r.error ?? '连接失败')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'测试失败'
toast.error(msg)
}
}
const onDelete = async (c: DownloadClient) => {
if (!confirm(`确定删除「${c.name}」?`)) return
try {
await downloadClientsAPI.remove(c.id)
toast.success('已删除')
await refresh()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'删除失败'
toast.error(msg)
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-cyan-400/10 text-cyan-300">
<Server size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-white">下载器管理</h1>
<p className="text-sm text-slate-400">
qBittorrent / Aria2 / Transmission · 多客户端 + 连接测试
</p>
</div>
</div>
<button
onClick={() => {
setEditing(null)
setShowForm(true)
}}
className="neon-button"
>
<Plus size={16} /> 添加下载器
</button>
</div>
{loading && (
<div className="flex justify-center py-12 text-slate-400">
<Loader2 className="animate-spin" />
</div>
)}
{!loading && clients.length === 0 && (
<div className="glass-panel py-12 text-center text-slate-400">暂无下载器</div>
)}
{!loading && clients.length > 0 && (
<div className="space-y-3">
{clients.map((c) => (
<div
key={c.id}
className="glass-panel flex items-center justify-between gap-3"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-white">{c.name}</span>
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">
{c.type}
</span>
{c.is_default && (
<span className="rounded bg-primary-400/20 px-2 py-0.5 text-xs text-primary-400">
默认
</span>
)}
{!c.enabled && (
<span className="rounded bg-slate-500/30 px-2 py-0.5 text-xs text-slate-300">
已禁用
</span>
)}
</div>
<div className="mt-1 truncate text-xs text-slate-400">
{c.url}
{c.username && ` · ${c.username}`}
{c.save_path && ` · ${c.save_path}`}
</div>
</div>
<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"
>
<Send size={12} className="inline" /> 测试
</button>
<button
onClick={() => {
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"
>
<Pencil size={12} className="inline" /> 编辑
</button>
<button
onClick={() => onDelete(c)}
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
>
<Trash2 size={12} className="inline" /> 删除
</button>
</div>
</div>
))}
</div>
)}
{showForm && (
<ClientFormModal
editing={editing}
onClose={() => setShowForm(false)}
onSaved={async () => {
setShowForm(false)
await refresh()
}}
/>
)}
</div>
)
}
function ClientFormModal({
editing,
onClose,
onSaved,
}: {
editing: DownloadClient | null
onClose: () => void
onSaved: () => void | Promise<void>
}) {
const [form, setForm] = useState<DownloadClientInput>(() => ({
name: editing?.name ?? '',
type: editing?.type ?? 'qbittorrent',
url: editing?.url ?? '',
username: editing?.username ?? '',
password: '',
save_path: editing?.save_path ?? '',
is_default: editing?.is_default ?? false,
enabled: editing?.enabled ?? true,
}))
const [saving, setSaving] = useState(false)
const onSubmit = async (e: FormEvent) => {
e.preventDefault()
setSaving(true)
try {
if (editing) await downloadClientsAPI.update(editing.id, form)
else await downloadClientsAPI.create(form)
toast.success('已保存')
await onSaved()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'保存失败'
toast.error(msg)
} finally {
setSaving(false)
}
}
const update = <K extends keyof DownloadClientInput>(k: K, v: DownloadClientInput[K]) =>
setForm((f) => ({ ...f, [k]: v }))
const placeholder = (
{
qbittorrent: 'http://127.0.0.1:8080',
aria2: 'http://127.0.0.1:6800/jsonrpc',
transmission: 'http://127.0.0.1:9091/transmission/rpc',
} as Record<DownloadClientType, string>
)[form.type]
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
<h2 className="mb-4 font-display text-xl font-semibold text-white">
{editing ? '编辑下载器' : '添加下载器'}
</h2>
<form onSubmit={onSubmit} className="space-y-4">
<Field label="名称">
<input
required
className="input-base"
value={form.name}
onChange={(e) => update('name', e.target.value)}
/>
</Field>
<Field label="类型">
<select
className="input-base"
value={form.type}
onChange={(e) => update('type', e.target.value as DownloadClientType)}
>
<option value="qbittorrent">qBittorrent</option>
<option value="aria2">Aria2 (JSON-RPC)</option>
<option value="transmission">Transmission</option>
</select>
</Field>
<Field label="URL">
<input
required
className="input-base"
placeholder={placeholder}
value={form.url}
onChange={(e) => update('url', e.target.value)}
/>
</Field>
{form.type !== 'aria2' && (
<>
<Field label="用户名">
<input
className="input-base"
value={form.username ?? ''}
onChange={(e) => update('username', e.target.value)}
/>
</Field>
<Field label={editing ? '密码 (留空保持不变)' : '密码'}>
<input
type="password"
className="input-base"
value={form.password ?? ''}
onChange={(e) => update('password', e.target.value)}
/>
</Field>
</>
)}
{form.type === 'aria2' && (
<Field label="RPC Token (作为密码字段保存)">
<input
type="password"
className="input-base"
value={form.password ?? ''}
onChange={(e) => update('password', e.target.value)}
/>
</Field>
)}
<Field label="默认保存路径">
<input
className="input-base"
value={form.save_path ?? ''}
onChange={(e) => update('save_path', e.target.value)}
/>
</Field>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={form.is_default}
onChange={(e) => update('is_default', e.target.checked)}
/>
设为默认
</label>
<label className="flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={form.enabled}
onChange={(e) => update('enabled', e.target.checked)}
/>
启用
</label>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
>
取消
</button>
<button type="submit" disabled={saving} className="neon-button">
{saving && <Loader2 size={16} className="animate-spin" />} 保存
</button>
</div>
</form>
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-sm text-slate-300">{label}</span>
{children}
</label>
)
}
+319
View File
@@ -0,0 +1,319 @@
import { FormEvent, useEffect, useState } from 'react'
import {
ChevronDown,
ChevronUp,
KeySquare,
Loader2,
Plus,
ShieldOff,
Trash2,
} from 'lucide-react'
import toast from 'react-hot-toast'
import {
licenseAPI,
type GenerateKeyInput,
type LicenseActivation,
type LicenseKey,
} from '../api/license'
// LicensePage is the admin UI for issuing / revoking license keys and
// inspecting activations. Mirrors the Vue LicenseTab inside Settings.
export function LicensePage() {
const [keys, setKeys] = useState<LicenseKey[]>([])
const [loading, setLoading] = useState(true)
const [showGen, setShowGen] = useState(false)
const [openKey, setOpenKey] = useState<string | null>(null)
const [activations, setActivations] = useState<Record<string, LicenseActivation[]>>({})
const refresh = async () => {
setLoading(true)
try {
setKeys(await licenseAPI.list())
} finally {
setLoading(false)
}
}
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const toggleOpen = async (k: LicenseKey) => {
if (openKey === k.id) {
setOpenKey(null)
return
}
setOpenKey(k.id)
if (!activations[k.id]) {
try {
const acts = await licenseAPI.listActivations(k.id)
setActivations((a) => ({ ...a, [k.id]: acts }))
} catch {
toast.error('加载激活记录失败')
}
}
}
const onRevoke = async (k: LicenseKey) => {
if (!confirm(`确定吊销 ${k.key.slice(0, 14)}…?`)) return
try {
await licenseAPI.revoke(k.id)
toast.success('已吊销')
await refresh()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'吊销失败'
toast.error(msg)
}
}
const onUnbind = async (a: LicenseActivation) => {
if (!confirm(`解绑设备 ${a.device_name || a.device_id}?`)) return
try {
await licenseAPI.unbind(a.id)
toast.success('已解绑')
const acts = await licenseAPI.listActivations(a.key_id)
setActivations((all) => ({ ...all, [a.key_id]: acts }))
} catch {
toast.error('解绑失败')
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-fuchsia-400/10 text-fuchsia-300">
<KeySquare size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-white">许可证管理</h1>
<p className="text-sm text-slate-400">
生成密钥 · 绑定设备 · 心跳监控 · 吊销
</p>
</div>
</div>
<button onClick={() => setShowGen(true)} className="neon-button">
<Plus size={16} /> 生成新密钥
</button>
</div>
{loading && (
<div className="flex justify-center py-12 text-slate-400">
<Loader2 className="animate-spin" />
</div>
)}
{!loading && keys.length === 0 && (
<div className="glass-panel py-12 text-center text-slate-400">暂无密钥</div>
)}
{!loading && keys.length > 0 && (
<div className="space-y-3">
{keys.map((k) => (
<div key={k.id} className="glass-panel space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<code className="rounded bg-white/5 px-2 py-0.5 text-xs text-primary-400">
{k.key}
</code>
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">
{k.plan}
</span>
{k.revoked && (
<span className="rounded bg-red-400/20 px-2 py-0.5 text-xs text-red-400">
已吊销
</span>
)}
</div>
<div className="mt-1 text-xs text-slate-400">
{k.customer && `客户: ${k.customer} · `}
最多 {k.max_activations} 设备 ·
{k.expires_at ? ` ${new Date(k.expires_at).toLocaleDateString()} 到期` : ' 永久'}
</div>
</div>
<div className="flex shrink-0 gap-2">
<button
onClick={() => toggleOpen(k)}
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
>
{openKey === k.id ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{' '}激活记录
</button>
{!k.revoked && (
<button
onClick={() => onRevoke(k)}
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
>
<ShieldOff size={12} className="inline" /> 吊销
</button>
)}
</div>
</div>
{openKey === k.id && (
<div className="space-y-2 border-t border-white/5 pt-3">
{(activations[k.id] ?? []).length === 0 && (
<p className="text-sm text-slate-500">暂无激活记录</p>
)}
{(activations[k.id] ?? []).map((a) => (
<div
key={a.id}
className="flex items-center justify-between rounded border border-white/5 bg-white/5 px-3 py-2 text-sm"
>
<div className="min-w-0">
<div className="text-white">
{a.device_name || a.device_id}
{a.unbound_at && (
<span className="ml-2 text-xs text-slate-500">
(已解绑 {new Date(a.unbound_at).toLocaleDateString()})
</span>
)}
</div>
<div className="text-xs text-slate-400">
{a.ip} · 心跳{' '}
{a.heartbeat_at
? new Date(a.heartbeat_at).toLocaleString()
: '未上报'}
</div>
</div>
{!a.unbound_at && (
<button
onClick={() => onUnbind(a)}
className="shrink-0 rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-red-400/40 hover:text-red-400"
>
<Trash2 size={12} className="inline" /> 解绑
</button>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{showGen && (
<GenerateModal
onClose={() => setShowGen(false)}
onCreated={async () => {
setShowGen(false)
await refresh()
}}
/>
)}
</div>
)
}
function GenerateModal({
onClose,
onCreated,
}: {
onClose: () => void
onCreated: () => void | Promise<void>
}) {
const [form, setForm] = useState<GenerateKeyInput>({
customer: '',
plan: 'basic',
max_activations: 1,
expires_at: '',
notes: '',
})
const [saving, setSaving] = useState(false)
const onSubmit = async (e: FormEvent) => {
e.preventDefault()
setSaving(true)
try {
const k = await licenseAPI.generate({
...form,
expires_at: form.expires_at ? new Date(form.expires_at).toISOString() : undefined,
})
toast.success(`已生成: ${k.key}`)
await onCreated()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'生成失败'
toast.error(msg)
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="glass-panel w-full max-w-md">
<h2 className="mb-4 font-display text-xl font-semibold text-white">生成密钥</h2>
<form onSubmit={onSubmit} className="space-y-3">
<label className="block">
<span className="mb-1 block text-sm text-slate-300">客户</span>
<input
className="input-base"
value={form.customer ?? ''}
onChange={(e) => setForm((f) => ({ ...f, customer: e.target.value }))}
/>
</label>
<label className="block">
<span className="mb-1 block text-sm text-slate-300">套餐</span>
<select
className="input-base"
value={form.plan ?? 'basic'}
onChange={(e) => setForm((f) => ({ ...f, plan: e.target.value }))}
>
<option value="basic">basic</option>
<option value="pro">pro</option>
<option value="enterprise">enterprise</option>
</select>
</label>
<label className="block">
<span className="mb-1 block text-sm text-slate-300">最多设备数</span>
<input
type="number"
min={1}
className="input-base"
value={form.max_activations ?? 1}
onChange={(e) =>
setForm((f) => ({ ...f, max_activations: Number(e.target.value) }))
}
/>
</label>
<label className="block">
<span className="mb-1 block text-sm text-slate-300">到期日期 (空 = 永久)</span>
<input
type="date"
className="input-base"
value={form.expires_at ?? ''}
onChange={(e) => setForm((f) => ({ ...f, expires_at: e.target.value }))}
/>
</label>
<label className="block">
<span className="mb-1 block text-sm text-slate-300">备注</span>
<textarea
rows={2}
className="input-base"
value={form.notes ?? ''}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
/>
</label>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
>
取消
</button>
<button type="submit" disabled={saving} className="neon-button">
{saving && <Loader2 size={16} className="animate-spin" />} 生成
</button>
</div>
</form>
</div>
</div>
)
}
+421
View File
@@ -0,0 +1,421 @@
import { FormEvent, useEffect, useState } from 'react'
import { Bell, Loader2, Pencil, Plus, Send, Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
import {
notifyChannelsAPI,
type NotifyChannelInput,
} from '../api/notify_channels'
import type { NotifyChannel } from '../types'
// NotifyChannelsPage replaces the Vue NotifyTab. Operators can register
// multiple Telegram bots / Bark devices / WeChat keys / Webhooks and
// fire a test notification on demand.
export function NotifyChannelsPage() {
const [channels, setChannels] = useState<NotifyChannel[]>([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState<NotifyChannel | null>(null)
const [showForm, setShowForm] = useState(false)
const refresh = async () => {
setLoading(true)
try {
setChannels(await notifyChannelsAPI.list())
} finally {
setLoading(false)
}
}
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const onTest = async (id: string) => {
try {
await notifyChannelsAPI.test(id)
toast.success('测试消息已发送')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '发送失败'
toast.error(msg)
}
}
const onDelete = async (ch: NotifyChannel) => {
if (!confirm(`确定删除「${ch.name}」?`)) return
try {
await notifyChannelsAPI.remove(ch.id)
toast.success('已删除')
await refresh()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '删除失败'
toast.error(msg)
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-400/10 text-amber-300">
<Bell size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-white">通知渠道</h1>
<p className="text-sm text-slate-400">
配置 Telegram / Bark / 企业微信 / Webhook 多通道推送
</p>
</div>
</div>
<button
onClick={() => {
setEditing(null)
setShowForm(true)
}}
className="neon-button"
>
<Plus size={16} /> 添加渠道
</button>
</div>
{loading && (
<div className="flex justify-center py-12 text-slate-400">
<Loader2 className="animate-spin" />
</div>
)}
{!loading && channels.length === 0 && (
<div className="glass-panel py-12 text-center text-slate-400">暂无通知渠道</div>
)}
{!loading && channels.length > 0 && (
<div className="space-y-3">
{channels.map((ch) => (
<ChannelCard
key={ch.id}
channel={ch}
onTest={() => onTest(ch.id)}
onEdit={() => {
setEditing(ch)
setShowForm(true)
}}
onDelete={() => onDelete(ch)}
/>
))}
</div>
)}
{showForm && (
<ChannelFormModal
editing={editing}
onClose={() => setShowForm(false)}
onSaved={async () => {
setShowForm(false)
await refresh()
}}
/>
)}
</div>
)
}
const TYPE_LABELS: Record<NotifyChannel['channel_type'], string> = {
telegram: 'Telegram',
wechat: '企业微信',
bark: 'Bark',
webhook: 'Webhook',
}
function ChannelCard({
channel,
onTest,
onEdit,
onDelete,
}: {
channel: NotifyChannel
onTest: () => void
onEdit: () => void
onDelete: () => void
}) {
const summary = channelSummary(channel)
return (
<div className="glass-panel flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-white">{channel.name}</span>
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">
{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>
)}
</div>
<div className="mt-1 truncate text-xs text-slate-400">{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"
>
<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"
>
<Pencil size={12} className="inline" /> 编辑
</button>
<button
onClick={onDelete}
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
>
<Trash2 size={12} className="inline" /> 删除
</button>
</div>
</div>
)
}
function channelSummary(ch: NotifyChannel): string {
const cfg = ch.config ?? {}
switch (ch.channel_type) {
case 'telegram':
return `Bot ${String(cfg.bot_token ?? '').slice(0, 10)}… → chat ${cfg.chat_id ?? '-'}`
case 'wechat':
return `SendKey ${String(cfg.sendkey ?? '').slice(0, 10)}…`
case 'bark':
return `Device ${String(cfg.device_key ?? '').slice(0, 10)}…`
case 'webhook':
return `${cfg.method ?? 'POST'} ${cfg.url ?? ''}`
default:
return ''
}
}
// ─── Form Modal ─────────────────────────────────────────────────────────────
const EMPTY_CONFIG: Record<NotifyChannel['channel_type'], Record<string, string>> = {
telegram: { bot_token: '', chat_id: '' },
wechat: { sendkey: '' },
bark: { device_key: '', server: '' },
webhook: { url: '', method: 'POST', headers: '', body_template: '' },
}
function ChannelFormModal({
editing,
onClose,
onSaved,
}: {
editing: NotifyChannel | null
onClose: () => void
onSaved: () => void | Promise<void>
}) {
const [name, setName] = useState(editing?.name ?? '')
const [type, setType] = useState<NotifyChannel['channel_type']>(
editing?.channel_type ?? 'telegram',
)
const [config, setConfig] = useState<Record<string, string>>(
editing?.config ?? EMPTY_CONFIG.telegram,
)
const [enabled, setEnabled] = useState(editing?.enabled ?? true)
const [saving, setSaving] = useState(false)
const onTypeChange = (t: NotifyChannel['channel_type']) => {
setType(t)
setConfig({ ...EMPTY_CONFIG[t] })
}
const onSubmit = async (e: FormEvent) => {
e.preventDefault()
setSaving(true)
try {
const input: NotifyChannelInput = {
name: name.trim(),
channel_type: type,
config,
enabled,
}
if (editing) {
await notifyChannelsAPI.update(editing.id, input)
} else {
await notifyChannelsAPI.create(input)
}
toast.success('已保存')
await onSaved()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
toast.error(msg)
} finally {
setSaving(false)
}
}
const updateConfig = (k: string, v: string) =>
setConfig((c) => ({ ...c, [k]: v }))
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">
{editing ? '编辑通知渠道' : '添加通知渠道'}
</h2>
<form onSubmit={onSubmit} className="space-y-4">
<Field label="名称">
<input
required
className="input-base"
placeholder="如: Telegram 通知"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<Field label="渠道类型">
<select
className="input-base"
value={type}
onChange={(e) => onTypeChange(e.target.value as NotifyChannel['channel_type'])}
>
<option value="telegram">Telegram</option>
<option value="wechat">企业微信 / Server酱</option>
<option value="bark">Bark (iOS)</option>
<option value="webhook">Webhook</option>
</select>
</Field>
{type === 'telegram' && (
<>
<Field label="Bot Token">
<input
required
className="input-base"
placeholder="123456:ABC-DEF…"
value={config.bot_token ?? ''}
onChange={(e) => updateConfig('bot_token', e.target.value)}
/>
</Field>
<Field label="Chat ID">
<input
required
className="input-base"
placeholder="-100123456"
value={config.chat_id ?? ''}
onChange={(e) => updateConfig('chat_id', e.target.value)}
/>
</Field>
</>
)}
{type === 'wechat' && (
<Field label="SendKey">
<input
required
className="input-base"
placeholder="SCT…"
value={config.sendkey ?? ''}
onChange={(e) => updateConfig('sendkey', e.target.value)}
/>
</Field>
)}
{type === 'bark' && (
<>
<Field label="设备 Key">
<input
required
className="input-base"
value={config.device_key ?? ''}
onChange={(e) => updateConfig('device_key', e.target.value)}
/>
</Field>
<Field label="服务器地址 (可选)">
<input
className="input-base"
placeholder="https://api.day.app"
value={config.server ?? ''}
onChange={(e) => updateConfig('server', e.target.value)}
/>
</Field>
</>
)}
{type === 'webhook' && (
<>
<Field label="URL">
<input
required
className="input-base"
placeholder="https://example.com/notify"
value={config.url ?? ''}
onChange={(e) => updateConfig('url', e.target.value)}
/>
</Field>
<Field label="Method">
<select
className="input-base"
value={config.method ?? 'POST'}
onChange={(e) => updateConfig('method', e.target.value)}
>
<option value="POST">POST</option>
<option value="GET">GET</option>
</select>
</Field>
<Field label="Headers (JSON)">
<textarea
rows={2}
className="input-base font-mono text-xs"
placeholder='{"Content-Type":"application/json"}'
value={config.headers ?? ''}
onChange={(e) => updateConfig('headers', e.target.value)}
/>
</Field>
<Field label="Body 模板 (支持 {{title}} {{message}})">
<textarea
rows={3}
className="input-base font-mono text-xs"
placeholder='{"title":"{{title}}","message":"{{message}}"}'
value={config.body_template ?? ''}
onChange={(e) => updateConfig('body_template', e.target.value)}
/>
</Field>
</>
)}
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
启用
</label>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
>
取消
</button>
<button type="submit" disabled={saving} className="neon-button">
{saving && <Loader2 size={16} className="animate-spin" />} 保存
</button>
</div>
</form>
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-sm text-slate-300">{label}</span>
{children}
</label>
)
}
+457
View File
@@ -0,0 +1,457 @@
import { FormEvent, useEffect, useMemo, useState } from 'react'
import { Loader2, Pencil, Plus, Trash2, UserCog } from 'lucide-react'
import toast from 'react-hot-toast'
import { libraryAPI } from '../api/library'
import { playProfilesAPI, type PlayProfileInput } from '../api/play_profiles'
import { useAuthStore } from '../stores/auth'
import type { Library, PlayProfile } from '../types'
// ProfileManagementPage replicates the Vue ProfileManagementView. It
// lets a user (or admin) define multiple "viewing personas" with
// different content-rating gates, library access, and player defaults.
//
// All persistence is real: data is written to /api/play-profiles which
// is backed by the Go PlayProfileService.
export function ProfileManagementPage() {
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
const userID = useAuthStore((s) => s.user?.id ?? '')
const [profiles, setProfiles] = useState<PlayProfile[]>([])
const [libraries, setLibraries] = useState<Library[]>([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState<PlayProfile | null>(null)
const [showForm, setShowForm] = useState(false)
const refresh = async () => {
setLoading(true)
try {
const [p, l] = await Promise.all([
playProfilesAPI.list(isAdmin),
libraryAPI.list().catch(() => [] as Library[]),
])
setProfiles(p)
setLibraries(l)
} finally {
setLoading(false)
}
}
useEffect(() => {
refresh().catch(() => undefined)
}, [isAdmin])
const onDelete = async (p: PlayProfile) => {
if (!confirm(`确定删除 Profile「${p.name}」?`)) return
try {
await playProfilesAPI.remove(p.id)
toast.success('已删除')
await refresh()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '删除失败'
toast.error(msg)
}
}
const openCreate = () => {
setEditing(null)
setShowForm(true)
}
const openEdit = (p: PlayProfile) => {
setEditing(p)
setShowForm(true)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-purple-400/10 text-purple-400">
<UserCog size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-white">观影 Profile</h1>
<p className="text-sm text-slate-400">
为不同场景(儿童 / 影院 / 成人)定义独立的内容分级和媒体库访问规则
</p>
</div>
</div>
<button onClick={openCreate} className="neon-button">
<Plus size={16} /> 创建 Profile
</button>
</div>
{loading && (
<div className="flex justify-center py-12 text-slate-400">
<Loader2 className="animate-spin" />
</div>
)}
{!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>
</div>
)}
{!loading && profiles.length > 0 && (
<div className="grid gap-3">
{profiles.map((p) => (
<ProfileCard
key={p.id}
profile={p}
libraries={libraries}
onEdit={() => openEdit(p)}
onDelete={() => onDelete(p)}
/>
))}
</div>
)}
{showForm && (
<ProfileFormModal
editing={editing}
libraries={libraries}
defaultUserID={userID}
isAdmin={isAdmin}
onClose={() => setShowForm(false)}
onSaved={async () => {
setShowForm(false)
await refresh()
}}
/>
)}
</div>
)
}
function ProfileCard({
profile,
libraries,
onEdit,
onDelete,
}: {
profile: PlayProfile
libraries: Library[]
onEdit: () => void
onDelete: () => void
}) {
const libNames = useMemo(() => {
if (!profile.allowed_library_ids?.length) return '全部'
const idx = new Map(libraries.map((l) => [l.id, l.name]))
return profile.allowed_library_ids.map((id) => idx.get(id) ?? id).join(', ')
}, [profile, libraries])
return (
<div className="glass-panel flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<div
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-lg font-bold text-white"
style={{
background: `hsl(${(profile.name.charCodeAt(0) * 47) % 360}, 60%, 35%)`,
}}
>
{profile.name[0]?.toUpperCase()}
</div>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-white">{profile.name}</span>
{profile.is_default && (
<span className="rounded bg-primary-400/20 px-2 py-0.5 text-xs text-primary-400">
默认
</span>
)}
{profile.allow_adult && (
<span className="rounded bg-red-400/20 px-2 py-0.5 text-xs text-red-400">
成人内容
</span>
)}
{profile.require_pin && (
<span className="rounded bg-amber-400/20 px-2 py-0.5 text-xs text-amber-400">
🔒 PIN
</span>
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-400">
{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">
观看时长 {Math.round(profile.total_watch_time / 3600)} 小时
{profile.last_active_at && ` · 最近活跃 ${new Date(profile.last_active_at).toLocaleDateString()}`}
</div>
</div>
</div>
<div className="flex shrink-0 gap-2">
<button
onClick={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"
>
<Pencil size={12} className="inline" /> 编辑
</button>
<button
onClick={onDelete}
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
>
<Trash2 size={12} className="inline" /> 删除
</button>
</div>
</div>
)
}
function ProfileFormModal({
editing,
libraries,
defaultUserID,
isAdmin,
onClose,
onSaved,
}: {
editing: PlayProfile | null
libraries: Library[]
defaultUserID: string
isAdmin: boolean
onClose: () => void
onSaved: () => void | Promise<void>
}) {
const [form, setForm] = useState<PlayProfileInput>(() => ({
user_id: editing?.user_id ?? defaultUserID,
name: editing?.name ?? '',
is_default: editing?.is_default ?? false,
content_rating_limit: editing?.content_rating_limit ?? '',
allow_adult: editing?.allow_adult ?? false,
require_pin: editing?.require_pin ?? false,
pin: '',
preferred_subtitle_lang: editing?.preferred_subtitle_lang ?? '',
preferred_audio_lang: editing?.preferred_audio_lang ?? '',
autoplay_next: editing?.autoplay_next ?? true,
skip_intro: editing?.skip_intro ?? false,
allowed_library_ids: editing?.allowed_library_ids ?? [],
}))
const [saving, setSaving] = useState(false)
const update = (patch: Partial<PlayProfileInput>) => setForm((f) => ({ ...f, ...patch }))
const toggleLib = (id: string) => {
const next = form.allowed_library_ids.includes(id)
? form.allowed_library_ids.filter((x) => x !== id)
: [...form.allowed_library_ids, id]
update({ allowed_library_ids: next })
}
const onSubmit = async (e: FormEvent) => {
e.preventDefault()
setSaving(true)
try {
if (editing) {
await playProfilesAPI.update(editing.id, form)
} else {
await playProfilesAPI.create(form)
}
toast.success('已保存')
await onSaved()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
toast.error(msg)
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
<h2 className="mb-4 font-display text-xl font-semibold text-white">
{editing ? '编辑 Profile' : '创建 Profile'}
</h2>
<form onSubmit={onSubmit} className="space-y-4">
{!editing && isAdmin && (
<Field label="用户 ID">
<input
className="input-base"
value={form.user_id ?? ''}
onChange={(e) => update({ user_id: e.target.value })}
/>
</Field>
)}
<Field label="名称">
<input
required
className="input-base"
maxLength={50}
placeholder="如:儿童模式、影院模式"
value={form.name}
onChange={(e) => update({ name: e.target.value })}
/>
</Field>
<Toggle
label="设为默认 Profile"
hint="登录后默认使用"
checked={form.is_default}
onChange={(v) => update({ is_default: v })}
/>
<Field label="内容分级限制">
<select
className="input-base"
value={form.content_rating_limit ?? ''}
onChange={(e) => update({ content_rating_limit: e.target.value })}
>
<option value="">不限制</option>
<option value="G">G</option>
<option value="PG">PG</option>
<option value="PG-13">PG-13</option>
<option value="R">R</option>
<option value="NC-17">NC-17</option>
</select>
</Field>
<Toggle
label="允许成人内容"
hint="开启后可访问 NSFW 媒体"
checked={form.allow_adult}
onChange={(v) => update({ allow_adult: v })}
/>
<Toggle
label="切换时需要 PIN"
hint="切换到此 Profile 需输入 PIN 码"
checked={form.require_pin}
onChange={(v) => update({ require_pin: v })}
/>
{form.require_pin && (
<Field label="PIN 码 (4-8 位)">
<input
type="password"
className="input-base"
maxLength={8}
placeholder={editing ? '留空保持不变' : '设置 PIN'}
value={form.pin ?? ''}
onChange={(e) => update({ pin: e.target.value })}
/>
</Field>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="首选字幕">
<select
className="input-base"
value={form.preferred_subtitle_lang ?? ''}
onChange={(e) => update({ preferred_subtitle_lang: e.target.value })}
>
<option value="">跟随系统</option>
<option value="zh">中文</option>
<option value="zh-CN">简体中文</option>
<option value="en">English</option>
<option value="ja">日语</option>
</select>
</Field>
<Field label="首选音轨">
<select
className="input-base"
value={form.preferred_audio_lang ?? ''}
onChange={(e) => update({ preferred_audio_lang: e.target.value })}
>
<option value="">跟随系统</option>
<option value="zh">中文</option>
<option value="ja">日语</option>
<option value="en">English</option>
</select>
</Field>
</div>
<Toggle
label="自动播放下一集"
checked={form.autoplay_next}
onChange={(v) => update({ autoplay_next: v })}
/>
<Toggle
label="自动跳过片头"
checked={form.skip_intro}
onChange={(v) => update({ skip_intro: v })}
/>
<Field label="允许访问的媒体库 (空 = 全部)">
<div className="flex flex-wrap gap-2">
{libraries.map((l) => (
<button
key={l.id}
type="button"
onClick={() => toggleLib(l.id)}
className={
'rounded-full border px-3 py-1 text-xs ' +
(form.allowed_library_ids.includes(l.id)
? 'border-primary-400/60 bg-primary-400/10 text-primary-400'
: 'border-white/10 text-slate-400 hover:text-white')
}
>
{l.name}
</button>
))}
{libraries.length === 0 && (
<span className="text-xs text-slate-500">暂无媒体库</span>
)}
</div>
</Field>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
>
取消
</button>
<button type="submit" disabled={saving} className="neon-button">
{saving && <Loader2 size={16} className="animate-spin" />}
保存
</button>
</div>
</form>
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-sm text-slate-300">{label}</span>
{children}
</label>
)
}
function Toggle({
label,
hint,
checked,
onChange,
}: {
label: string
hint?: string
checked: boolean
onChange: (v: boolean) => void
}) {
return (
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-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>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 accent-primary-400"
/>
</label>
)
}
+362
View File
@@ -0,0 +1,362 @@
import { FormEvent, useEffect, useState } from 'react'
import { Loader2, Save, SettingsIcon } from 'lucide-react'
import toast from 'react-hot-toast'
import { adminAPI } from '../api/admin'
import type { Setting } from '../types'
// SettingsPage replaces the Vue SettingsView's General / Organize /
// Scrape / Adult tabs. The Go backend stores settings as a single
// key/value table; we group the most useful keys client-side and let
// the operator edit them with typed widgets (select / toggle / input).
//
// Any key not in the curated list still works via the AdminPage generic
// key/value editor — this page is the curated UX over the same store.
interface SettingDef {
key: string
label: string
type: 'text' | 'select' | 'toggle' | 'number' | 'textarea'
hint?: string
options?: { value: string; label: string }[]
placeholder?: string
}
interface SettingGroup {
key: string
label: string
description?: string
items: SettingDef[]
}
const GROUPS: SettingGroup[] = [
{
key: 'general',
label: '常规',
description: 'TMDb / 转码引擎默认参数',
items: [
{
key: 'tmdb.language',
label: 'TMDb 元数据语言',
type: 'select',
options: [
{ value: 'zh-CN', label: '简体中文' },
{ value: 'zh-TW', label: '繁体中文' },
{ value: 'en-US', label: 'English' },
{ value: 'ja-JP', label: '日本語' },
],
},
{
key: 'transcode.enabled',
label: '启用转码',
type: 'toggle',
hint: '关闭后所有视频直连播放',
},
{
key: 'transcode.hw_accel',
label: '硬件加速',
type: 'select',
options: [
{ value: 'auto', label: '自动检测' },
{ value: 'none', label: '软件转码' },
{ value: 'nvenc', label: 'NVIDIA NVENC' },
{ value: 'qsv', label: 'Intel QSV' },
{ value: 'vaapi', label: 'VAAPI (Linux)' },
{ value: 'videotoolbox', label: 'VideoToolbox (macOS)' },
],
},
{
key: 'transcode.max_jobs',
label: '最大并发转码任务',
type: 'number',
hint: '建议 1-4',
},
{
key: 'ffmpeg.path',
label: 'FFmpeg 路径',
type: 'text',
placeholder: 'ffmpeg',
},
{
key: 'ffprobe.path',
label: 'FFprobe 路径',
type: 'text',
placeholder: 'ffprobe',
},
],
},
{
key: 'organize',
label: '整理 & 刮削',
description: '媒体文件命名 + 自动刮削开关',
items: [
{
key: 'organize.auto',
label: '入库时自动整理',
type: 'toggle',
},
{
key: 'organize.movie_format',
label: '电影命名格式',
type: 'text',
hint: '例: {title} ({year})/{title} ({year})',
placeholder: '{title} ({year})/{title} ({year})',
},
{
key: 'organize.tv_format',
label: '剧集命名格式',
type: 'text',
placeholder: '{title} ({year})/Season {season}/{title} S{season:02}E{episode:02}',
},
{
key: 'organize.anime_format',
label: '动漫命名格式',
type: 'text',
placeholder: '{title}/Season {season}/{title} S{season:02}E{episode:02}',
},
{
key: 'scrape.auto_on_scan',
label: '扫描后自动刮削',
type: 'toggle',
},
{
key: 'scrape.providers',
label: '刮削源优先级',
type: 'text',
hint: '逗号分隔: tmdb,bangumi,thetvdb,fanart',
placeholder: 'tmdb,bangumi,thetvdb,fanart',
},
{
key: 'scrape.language',
label: '刮削首选语言',
type: 'text',
placeholder: 'zh-CN',
},
],
},
{
key: 'adult',
label: 'Adult / NSFW',
description: '成人内容隔离开关 (默认隐藏)',
items: [
{
key: 'adult.enabled',
label: '启用成人内容',
type: 'toggle',
hint: '关闭后 NSFW 媒体不会出现在列表中',
},
{
key: 'adult.require_pin',
label: '访问需要 PIN',
type: 'toggle',
},
{
key: 'adult.pin',
label: 'PIN 码',
type: 'text',
hint: '4-8 位数字',
},
],
},
{
key: 'qbittorrent',
label: 'qBittorrent',
description: '默认下载器配置',
items: [
{ key: 'qbittorrent.url', label: 'WebUI URL', type: 'text', placeholder: 'http://127.0.0.1:8080' },
{ key: 'qbittorrent.username', label: '用户名', type: 'text' },
{ key: 'qbittorrent.password', label: '密码', type: 'text' },
{ key: 'qbittorrent.savepath', label: '默认保存目录', type: 'text' },
],
},
]
const ALL_KEYS = new Set(GROUPS.flatMap((g) => g.items.map((i) => i.key)))
export function SettingsPage() {
const [activeGroup, setActiveGroup] = useState(GROUPS[0].key)
const [values, setValues] = useState<Record<string, string>>({})
const [dirty, setDirty] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const refresh = async () => {
setLoading(true)
try {
const all = await adminAPI.listSettings()
const idx: Record<string, string> = {}
for (const s of all as Setting[]) {
if (ALL_KEYS.has(s.key)) idx[s.key] = s.value
}
setValues(idx)
setDirty(new Set())
} finally {
setLoading(false)
}
}
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const onChange = (key: string, value: string) => {
setValues((v) => ({ ...v, [key]: value }))
setDirty((d) => new Set(d).add(key))
}
const onSave = async (e: FormEvent) => {
e.preventDefault()
if (dirty.size === 0) return
setSaving(true)
try {
// Backend exposes a single-key updater; loop through dirty keys.
for (const key of dirty) {
await adminAPI.updateSetting(key, values[key] ?? '')
}
toast.success(`已保存 ${dirty.size} 项配置`)
setDirty(new Set())
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
toast.error(msg)
} finally {
setSaving(false)
}
}
const group = GROUPS.find((g) => g.key === activeGroup)!
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">
<SettingsIcon size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-white">系统设置</h1>
<p className="text-sm text-slate-400">
按分组编辑 TMDb / 整理 / 刮削 / 下载器等关键配置
</p>
</div>
</div>
<div className="flex gap-2 overflow-x-auto border-b border-white/10">
{GROUPS.map((g) => (
<button
key={g.key}
onClick={() => setActiveGroup(g.key)}
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')
}
>
{g.label}
</button>
))}
</div>
{loading && (
<div className="flex justify-center py-12 text-slate-400">
<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.items.map((it) => (
<SettingRow
key={it.key}
def={it}
value={values[it.key] ?? ''}
onChange={(v) => onChange(it.key, v)}
/>
))}
<div className="flex items-center justify-between pt-2">
<span className="text-xs text-slate-500">
{dirty.size > 0 ? `有 ${dirty.size} 项未保存` : '所有更改已保存'}
</span>
<button
type="submit"
disabled={saving || dirty.size === 0}
className="neon-button disabled:opacity-50"
>
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
保存
</button>
</div>
</form>
)}
</div>
)
}
function SettingRow({
def,
value,
onChange,
}: {
def: SettingDef
value: string
onChange: (v: string) => void
}) {
return (
<div className="grid items-start gap-2 md:grid-cols-[280px_1fr]">
<label className="text-sm text-slate-300">
<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>
</label>
<div>
{def.type === 'text' && (
<input
className="input-base"
value={value}
placeholder={def.placeholder}
onChange={(e) => onChange(e.target.value)}
/>
)}
{def.type === 'number' && (
<input
type="number"
className="input-base"
value={value}
placeholder={def.placeholder}
onChange={(e) => onChange(e.target.value)}
/>
)}
{def.type === 'textarea' && (
<textarea
rows={3}
className="input-base font-mono text-xs"
value={value}
placeholder={def.placeholder}
onChange={(e) => onChange(e.target.value)}
/>
)}
{def.type === 'select' && (
<select className="input-base" value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">(未设置)</option>
{def.options?.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
)}
{def.type === 'toggle' && (
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={value === 'true' || value === '1' || value === 'on'}
onChange={(e) => onChange(e.target.checked ? 'true' : 'false')}
/>
<span className="text-sm text-slate-300">{value === 'true' ? '已启用' : '已关闭'}</span>
</label>
)}
</div>
</div>
)
}
+161
View File
@@ -0,0 +1,161 @@
import { FormEvent, useState } from 'react'
import toast from 'react-hot-toast'
import { Download, Search } from 'lucide-react'
import { sitesAPI, type SiteSearchResult } from '../api/sites'
import { downloadsAPI } from '../api/downloads'
function fmtBytes(n: number): string {
if (!n || n <= 0) return '—'
const u = ['B', 'KB', 'MB', 'GB', 'TB']
let v = n
let i = 0
while (v >= 1024 && i < u.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(1)} ${u[i]}`
}
// SiteSearchPage performs cross-site torrent search across all enabled
// sites and displays a merged result table sorted by seeders.
export function SiteSearchPage() {
const [keyword, setKeyword] = useState('')
const [results, setResults] = useState<SiteSearchResult[]>([])
const [loading, setLoading] = useState(false)
const [searched, setSearched] = useState(false)
const onSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!keyword.trim()) return
setLoading(true)
setSearched(true)
try {
const data = await sitesAPI.search(keyword.trim())
setResults(data.items || [])
if ((data.items || []).length === 0) {
toast('未找到结果,请检查站点配置或更换关键词')
}
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'搜索失败'
toast.error(msg)
} finally {
setLoading(false)
}
}
const downloadTorrent = async (item: SiteSearchResult) => {
const url = item.download_url || item.torrent_url
if (!url) {
toast.error('无可用下载链接')
return
}
try {
await downloadsAPI.add(url)
toast.success(`已加入下载: ${item.title.substring(0, 40)}...`)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'下载失败'
toast.error(msg)
}
}
return (
<div className="space-y-6">
<header className="flex items-center gap-3">
<Search className="h-6 w-6 text-primary-400" />
<div>
<h1 className="font-display text-3xl font-bold text-white">站点搜索</h1>
<p className="text-sm text-slate-400">
跨站搜索所有已启用的 PT/BT 站点,结果按做种数排序。
</p>
</div>
</header>
<form onSubmit={onSubmit} className="flex gap-2">
<input
autoFocus
className="input-base flex-1"
placeholder="输入关键词搜索(如: 流浪地球 / Dune 2024)"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<button type="submit" disabled={loading} className="neon-button">
{loading ? '搜索中…' : '搜索'}
</button>
</form>
{loading && <p className="text-slate-500">正在查询所有站点…</p>}
{!loading && searched && results.length === 0 && (
<div className="glass-panel">
<p className="text-slate-300">
未找到结果。请确认已在「站点管理」中添加并启用了至少一个站点,且 Cookie / API Key 有效。
</p>
</div>
)}
{results.length > 0 && (
<div className="glass-panel overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<tr>
<th className="py-2">站点</th>
<th>标题</th>
<th>大小</th>
<th>S</th>
<th>L</th>
<th>Free</th>
<th className="text-right">操作</th>
</tr>
</thead>
<tbody>
{results.map((item, idx) => (
<tr key={idx} className="border-t border-white/5 align-top">
<td className="py-2 text-primary-400">{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"
title={item.title}
>
{item.title.length > 80
? item.title.substring(0, 80) + '…'
: item.title}
</a>
</td>
<td className="whitespace-nowrap text-slate-300">
{fmtBytes(item.size)}
</td>
<td className="text-emerald-400">{item.seeders || '—'}</td>
<td className="text-red-400">{item.leechers || '—'}</td>
<td>
{item.free && (
<span className="rounded border border-emerald-400/40 px-1.5 py-0.5 text-xs text-emerald-400">
Free
</span>
)}
</td>
<td className="py-2 text-right">
<button
onClick={() => downloadTorrent(item)}
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
title="加入下载"
>
<Download size={12} className="inline" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
+187
View File
@@ -0,0 +1,187 @@
import { FormEvent, useEffect, useMemo, useState } from 'react'
import { Cloud, Loader2, Save, Send } from 'lucide-react'
import toast from 'react-hot-toast'
import { storageAPI, type StorageType } from '../api/storage_config'
// StorageConfigPage manages the Alist / S3 / WebDAV adapters used by
// the import / playback / STRM subsystems. Mirrors the Vue UI's
// `admin/storage/*` tabs in a tabbed React surface.
export function StorageConfigPage() {
const [active, setActive] = useState<StorageType>('alist')
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-400/10 text-blue-300">
<Cloud size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-white">外部存储</h1>
<p className="text-sm text-slate-400">
配置 Alist / S3 / WebDAV 后端,支持密码加密存储 + 在线测试
</p>
</div>
</div>
<div className="flex gap-2 border-b border-white/10">
{(['alist', 'webdav', 's3'] as StorageType[]).map((t) => (
<button
key={t}
onClick={() => setActive(t)}
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')
}
>
{t}
</button>
))}
</div>
<StorageForm key={active} type={active} />
</div>
)
}
const FIELD_DEFS: Record<StorageType, { key: string; label: string; secret?: boolean; placeholder?: string }[]> = {
alist: [
{ key: 'server', label: 'Server URL', placeholder: 'https://alist.example.com' },
{ key: 'token', label: 'Token', secret: true },
],
webdav: [
{ key: 'url', label: 'URL', placeholder: 'https://example.com/dav/' },
{ key: 'username', label: '用户名' },
{ key: 'password', label: '密码', secret: true },
],
s3: [
{ key: 'endpoint', label: 'Endpoint', placeholder: 'https://s3.amazonaws.com' },
{ key: 'region', label: 'Region', placeholder: 'us-east-1' },
{ key: 'bucket', label: 'Bucket' },
{ key: 'access_key', label: 'Access Key', secret: true },
{ key: 'secret_key', label: 'Secret Key', secret: true },
{ key: 'force_path_style', label: 'force_path_style (true/false)' },
],
}
function StorageForm({ type }: { type: StorageType }) {
const fields = useMemo(() => FIELD_DEFS[type], [type])
const [config, setConfig] = useState<Record<string, string>>({})
const [enabled, setEnabled] = useState(true)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const refresh = async () => {
setLoading(true)
try {
const r = await storageAPI.get(type)
const next: Record<string, string> = {}
for (const f of fields) {
const v = r.config?.[f.key]
// List() redacts secrets to "********"; show empty so the user
// doesn't accidentally save the placeholder.
next[f.key] = v === '********' ? '' : v ?? ''
}
setConfig(next)
setEnabled(r.enabled)
} catch {
const next: Record<string, string> = {}
for (const f of fields) next[f.key] = ''
setConfig(next)
} finally {
setLoading(false)
}
}
useEffect(() => {
refresh().catch(() => undefined)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [type])
const onSave = async (e: FormEvent) => {
e.preventDefault()
setSaving(true)
try {
await storageAPI.save(type, config, enabled)
toast.success('已保存')
await refresh()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'保存失败'
toast.error(msg)
} finally {
setSaving(false)
}
}
const onTest = async () => {
setTesting(true)
try {
const r = await storageAPI.test(type, config)
if (r.ok) toast.success('连接成功')
else toast.error(r.error ?? '连接失败')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'测试失败'
toast.error(msg)
} finally {
setTesting(false)
}
}
if (loading) {
return (
<div className="flex justify-center py-12 text-slate-400">
<Loader2 className="animate-spin" />
</div>
)
}
return (
<form onSubmit={onSave} className="glass-panel space-y-4">
{fields.map((f) => (
<label key={f.key} className="block">
<span className="mb-1 block text-sm text-slate-300">
{f.label}
<span className="ml-2 font-mono text-[10px] text-slate-600">{f.key}</span>
</span>
<input
type={f.secret ? 'password' : 'text'}
className="input-base"
placeholder={f.placeholder}
value={config[f.key] ?? ''}
onChange={(e) => setConfig((c) => ({ ...c, [f.key]: e.target.value }))}
/>
</label>
))}
<label className="flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
启用
</label>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onTest}
disabled={testing}
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
>
{testing ? <Loader2 size={14} className="inline animate-spin" /> : <Send size={14} className="inline" />}
{' '}测试
</button>
<button type="submit" disabled={saving} className="neon-button">
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
保存
</button>
</div>
</form>
)
}
+248
View File
@@ -0,0 +1,248 @@
import { FormEvent, useEffect, useState } from 'react'
import { Link as LinkIcon, Plus, Search, Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
import { libraryAPI, mediaAPI } from '../api/library'
import { strmAPI } from '../api/strm'
import type { Library, Media } from '../types'
// StrmPage exposes the URL-as-file admin tooling backed by the Go server:
// - import a brand-new media row directly from a (library, title, url)
// tuple — useful for streaming-only entries with no on-disk file.
// - search existing media and attach / detach a STRM URL so the player
// issues a 302 redirect to the remote source instead of opening a
// local file.
export function StrmPage() {
const [libraries, setLibraries] = useState<Library[]>([])
// Import form state
const [libraryID, setLibraryID] = useState('')
const [title, setTitle] = useState('')
const [url, setURL] = useState('')
const [importing, setImporting] = useState(false)
// Search + attach state
const [query, setQuery] = useState('')
const [searching, setSearching] = useState(false)
const [results, setResults] = useState<Media[]>([])
const [drafts, setDrafts] = useState<Record<string, string>>({})
useEffect(() => {
libraryAPI.list().then(setLibraries).catch(() => undefined)
}, [])
// Default the import library to the first available one once loaded.
useEffect(() => {
if (!libraryID && libraries[0]) setLibraryID(libraries[0].id)
}, [libraries, libraryID])
const onImport = async (e: FormEvent) => {
e.preventDefault()
if (!libraryID || !title.trim() || !url.trim()) return
if (!/^https?:\/\//i.test(url.trim())) {
toast.error('URL 必须以 http:// 或 https:// 开头')
return
}
setImporting(true)
try {
await strmAPI.importURL(libraryID, title.trim(), url.trim())
toast.success(`已导入「${title.trim()}」`)
setTitle('')
setURL('')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'导入失败'
toast.error(msg)
} finally {
setImporting(false)
}
}
const doSearch = async (e?: FormEvent) => {
e?.preventDefault()
if (!query.trim()) return
setSearching(true)
try {
const r = await mediaAPI.search(query.trim(), 30)
setResults(r.items ?? [])
} catch {
toast.error('搜索失败')
} finally {
setSearching(false)
}
}
const onAttach = async (m: Media) => {
const next = (drafts[m.id] ?? '').trim()
if (!next) return
if (!/^https?:\/\//i.test(next)) {
toast.error('URL 必须以 http:// 或 https:// 开头')
return
}
try {
await strmAPI.set(m.id, next)
toast.success('已设置 STRM URL')
// Optimistic update so the user sees the new state without a re-search.
setResults((rs) =>
rs.map((x) => (x.id === m.id ? ({ ...x, container: 'strm' } as Media) : x)),
)
setDrafts((d) => ({ ...d, [m.id]: '' }))
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'设置失败'
toast.error(msg)
}
}
const onDetach = async (m: Media) => {
if (!confirm(`清除「${m.title}」的 STRM URL?`)) return
try {
await strmAPI.clear(m.id)
toast.success('已清除')
setResults((rs) =>
rs.map((x) =>
x.id === m.id ? ({ ...x, container: x.container === 'strm' ? '' : x.container } as Media) : x,
),
)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'清除失败'
toast.error(msg)
}
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-400/10 text-amber-400">
<LinkIcon size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-white">STRM 管理</h1>
<p className="text-sm text-slate-400">
将外部 HTTP / WebDAV / Alist 直链以"虚拟文件"形式纳入媒体库
</p>
</div>
</div>
{/* 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>
<form onSubmit={onImport} className="grid gap-3 md:grid-cols-4">
<select
required
className="input-base"
value={libraryID}
onChange={(e) => setLibraryID(e.target.value)}
>
<option value="" disabled>
选择媒体库
</option>
{libraries.map((l) => (
<option key={l.id} value={l.id}>
{l.name} ({l.type})
</option>
))}
</select>
<input
required
className="input-base"
placeholder="标题"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<input
required
className="input-base md:col-span-2"
placeholder="https://example.com/movie.mp4"
value={url}
onChange={(e) => setURL(e.target.value)}
/>
<button type="submit" disabled={importing} className="neon-button md:col-span-4">
<Plus size={16} /> {importing ? '导入中…' : '导入'}
</button>
</form>
<p className="text-xs text-slate-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>
<form onSubmit={doSearch} className="flex gap-2">
<input
className="input-base flex-1"
placeholder="搜索媒体标题…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit" disabled={searching} className="neon-button">
<Search size={16} /> {searching ? '搜索中…' : '搜索'}
</button>
</form>
{results.length > 0 && (
<div className="space-y-3">
{results.map((m) => {
const isStrm = m.container === 'strm'
return (
<div
key={m.id}
className="rounded-xl border border-white/5 bg-white/5 p-4 space-y-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium text-white">{m.title}</div>
<div className="truncate text-xs text-slate-400">
{m.year > 0 && `${m.year} · `}
{m.container || '本地文件'}
</div>
{isStrm && (
<div className="mt-1 break-all rounded bg-emerald-400/10 px-2 py-0.5 text-xs text-emerald-300">
已设置 STRM
</div>
)}
</div>
{isStrm && (
<button
onClick={() => onDetach(m)}
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
>
<Trash2 size={12} className="inline" /> 清除
</button>
)}
</div>
<div className="flex gap-2">
<input
className="input-base flex-1"
placeholder="https://example.com/stream.m3u8"
value={drafts[m.id] ?? ''}
onChange={(e) =>
setDrafts((d) => ({ ...d, [m.id]: e.target.value }))
}
/>
<button
onClick={() => onAttach(m)}
disabled={!(drafts[m.id] ?? '').trim()}
className="neon-button"
>
{isStrm ? '替换 URL' : '设置'}
</button>
</div>
</div>
)
})}
</div>
)}
{!searching && query && results.length === 0 && (
<p className="text-sm text-slate-400">未找到匹配的媒体。</p>
)}
</section>
</div>
)
}
+244
View File
@@ -0,0 +1,244 @@
import { FormEvent, useEffect, useState } from 'react'
import { Bell, FolderCog, Loader2, Search, Wrench } from 'lucide-react'
import toast from 'react-hot-toast'
import { libraryAPI, mediaAPI } from '../api/library'
import { toolsAPI } from '../api/tools'
import type { Library, Media } from '../types'
// ToolsPage gathers admin-only one-off operations that don't belong on a
// dedicated screen of their own:
//
// - Organize one media item or an entire library: renames + moves files
// into the canonical layout (`Library/Year/Title (Year)/...`).
// - Send a test notification through every configured channel.
//
// The original Vue version surfaces these as tabs inside SettingsView; here
// we co-locate them on a single admin tools page.
export function ToolsPage() {
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-violet-400/10 text-violet-400">
<Wrench size={20} />
</div>
<div>
<h1 className="font-display text-3xl font-bold text-white">运维工具</h1>
<p className="text-sm text-slate-400">整理媒体文件 · 测试通知渠道</p>
</div>
</div>
<OrganizePanel />
<NotifyPanel />
</div>
)
}
function OrganizePanel() {
const [libraries, setLibraries] = useState<Library[]>([])
const [libraryID, setLibraryID] = useState('')
const [running, setRunning] = useState(false)
const [query, setQuery] = useState('')
const [searching, setSearching] = useState(false)
const [results, setResults] = useState<Media[]>([])
const [busyID, setBusyID] = useState<string | null>(null)
useEffect(() => {
libraryAPI.list().then(setLibraries).catch(() => undefined)
}, [])
const onOrganizeLibrary = async (e: FormEvent) => {
e.preventDefault()
if (!libraryID) return
setRunning(true)
try {
await toolsAPI.organizeLibrary(libraryID)
toast.success('已触发媒体库整理')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'整理失败'
toast.error(msg)
} finally {
setRunning(false)
}
}
const doSearch = async (e?: FormEvent) => {
e?.preventDefault()
if (!query.trim()) return
setSearching(true)
try {
const r = await mediaAPI.search(query.trim(), 20)
setResults(r.items ?? [])
} catch {
toast.error('搜索失败')
} finally {
setSearching(false)
}
}
const onOrganizeOne = async (m: Media) => {
setBusyID(m.id)
try {
const r = await toolsAPI.organizeMedia(m.id)
toast.success(`已移动到 ${r.path}`)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'整理失败'
toast.error(msg)
} finally {
setBusyID(null)
}
}
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>
</div>
<p className="text-xs text-slate-500">
将媒体文件按 <code className="rounded bg-white/5 px-1">媒体库/年份/标题 (年份)/标题.ext</code>{' '}
的规范布局移动并重命名,确保已先完成刮削。
</p>
<form onSubmit={onOrganizeLibrary} className="flex flex-wrap gap-2">
<select
required
className="input-base flex-1 min-w-[200px]"
value={libraryID}
onChange={(e) => setLibraryID(e.target.value)}
>
<option value="" disabled>
选择要整理的媒体库
</option>
{libraries.map((l) => (
<option key={l.id} value={l.id}>
{l.name} ({l.type})
</option>
))}
</select>
<button type="submit" disabled={running || !libraryID} className="neon-button">
{running ? <Loader2 size={16} className="animate-spin" /> : <FolderCog size={16} />}
整理整个库
</button>
</form>
<div className="border-t border-white/5 pt-4">
<form onSubmit={doSearch} className="flex gap-2">
<input
className="input-base flex-1"
placeholder="或搜索单个媒体进行整理…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit" disabled={searching} className="neon-button">
<Search size={16} /> {searching ? '搜索中…' : '搜索'}
</button>
</form>
{results.length > 0 && (
<ul className="mt-4 space-y-2">
{results.map((m) => (
<li
key={m.id}
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>
<button
onClick={() => onOrganizeOne(m)}
disabled={busyID === m.id}
className="neon-button !px-3 !py-1 text-xs"
>
{busyID === m.id ? (
<Loader2 size={12} className="animate-spin" />
) : (
<FolderCog size={12} />
)}
整理
</button>
</li>
))}
</ul>
)}
{!searching && query && results.length === 0 && (
<p className="mt-3 text-sm text-slate-400">未找到匹配的媒体。</p>
)}
</div>
</section>
)
}
function NotifyPanel() {
const [title, setTitle] = useState('MediaStation 测试通知')
const [body, setBody] = useState('如果你收到这条消息,说明通知渠道工作正常。')
const [sending, setSending] = useState(false)
const onSend = async (e: FormEvent) => {
e.preventDefault()
if (!title.trim() || !body.trim()) return
setSending(true)
try {
await toolsAPI.notifyTest(title.trim(), body.trim())
toast.success('已派发到所有已启用通道')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'发送失败'
toast.error(msg)
} finally {
setSending(false)
}
}
return (
<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>
</div>
<p className="text-xs text-slate-500">
会向所有已配置的通知渠道(Telegram / Bark / Webhook 等)发送一条测试消息。
</p>
<form onSubmit={onSend} className="space-y-3">
<Field label="标题">
<input
required
className="input-base"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</Field>
<Field label="内容">
<textarea
required
rows={3}
className="input-base resize-none"
value={body}
onChange={(e) => setBody(e.target.value)}
/>
</Field>
<button type="submit" disabled={sending} className="neon-button">
{sending ? <Loader2 size={16} className="animate-spin" /> : <Bell size={16} />}
发送测试通知
</button>
</form>
</section>
)
}
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>
{children}
</label>
)
}
+68
View File
@@ -271,6 +271,27 @@ export interface NotifyProviderInfo {
description: string
}
// ─── Play Profiles ──────────────────────────────────────────────────────────
export interface PlayProfile {
id: string
user_id: string
name: string
is_default: boolean
content_rating_limit?: string
allow_adult: boolean
require_pin: boolean
preferred_subtitle_lang?: string
preferred_audio_lang?: string
autoplay_next: boolean
skip_intro: boolean
allowed_library_ids: string[]
total_watch_time: number
last_active_at?: string
created_at: string
updated_at: string
}
// Scheduler task config
export interface SchedulerTaskConfig {
id: string
@@ -322,3 +343,50 @@ export interface AuthTypeInfo {
name: string
description: string
}
// ─── History helpers ────────────────────────────────────────────────────────
export interface HistoryItem {
id: string
user_id: string
media_id: string
position_ms: number
duration_ms: number
watched_at: string
completed: boolean
media?: Media
}
export interface HistoryStats {
total: number
completed: number
watched_ms: number
watched_hours: number
last_watched?: string
}
// ─── Discover ───────────────────────────────────────────────────────────────
export interface DiscoverSection {
key: string
label: string
}
export interface DiscoverItem {
TMDbID?: number
Title?: string
Overview?: string
Rating?: number
Year?: number
PosterURL?: string
BackdropURL?: string
// Match struct (Go) is exported with capitalised JSON keys; the API
// returns lower-cased aliases below for convenience.
tmdb_id?: number
title?: string
overview?: string
rating?: number
year?: number
poster_url?: string
backdrop_url?: string
}