mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 03:36:37 +08:00
Resolve merge conflicts: merge all Go backend and React frontend changes
This commit is contained in:
+103
-1
@@ -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={
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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="管理后台" />
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">整理 & 重命名</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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user