mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 20:16:36 +08:00
初始化
初始化项目
This commit is contained in:
+1
-1
@@ -16,7 +16,7 @@ class AppErrorBoundary extends Component<{ children: ReactNode }, { hasError: bo
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('MediaStationGo UI crashed', error, info)
|
||||
console.error('MMTL UI crashed', error, info)
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { api } from './client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
export interface SearchIntent {
|
||||
query: string
|
||||
year?: number
|
||||
genre?: string
|
||||
type?: string
|
||||
sort?: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
export interface ExternalMediaResult {
|
||||
source: string
|
||||
media_type?: string
|
||||
title: string
|
||||
original_name?: string
|
||||
overview?: string
|
||||
poster_url?: string
|
||||
backdrop_url?: string
|
||||
year?: number
|
||||
rating?: number
|
||||
tmdb_id?: number
|
||||
bangumi_id?: number
|
||||
douban_id?: string
|
||||
subscribe_keyword: string
|
||||
subscribe_aliases?: string[]
|
||||
total_episodes?: number
|
||||
downloaded_episodes?: number
|
||||
local_media_count?: number
|
||||
missing_episodes?: number[]
|
||||
in_library?: boolean
|
||||
}
|
||||
|
||||
export const aiAPI = {
|
||||
status: () =>
|
||||
api
|
||||
.get<{ enabled: boolean; provider: string; model: string }>('/ai/status')
|
||||
.then((r) => r.data),
|
||||
|
||||
smartSearch: (query: string) =>
|
||||
api
|
||||
.post<{ intent: SearchIntent; items: Media[]; external_items: ExternalMediaResult[] }>(
|
||||
'/ai/search',
|
||||
{ query },
|
||||
)
|
||||
.then((r) => r.data),
|
||||
|
||||
recommend: () => api.get<{ titles: string[] }>('/ai/recommend').then((r) => r.data.titles),
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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),
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { api } from './client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
// TMDb-derived "Match" rows used by trending/popular rails. We re-use the
|
||||
// Media interface — only TMDb id / poster / overview are populated.
|
||||
export interface DiscoverItem extends Partial<Media> {
|
||||
source?: string
|
||||
media_type?: string
|
||||
tmdb_id?: number
|
||||
douban_id?: string
|
||||
bangumi_id?: number
|
||||
title: string
|
||||
poster_url?: string
|
||||
backdrop_url?: string
|
||||
overview?: string
|
||||
year?: number
|
||||
rating?: number
|
||||
subscribe_keyword?: string
|
||||
subscribe_aliases?: string[]
|
||||
total_episodes?: number
|
||||
downloaded_episodes?: number
|
||||
local_media_count?: number
|
||||
missing_episodes?: number[]
|
||||
in_library?: boolean
|
||||
}
|
||||
|
||||
export interface DiscoverSection {
|
||||
key: string
|
||||
label: string
|
||||
provider?: string
|
||||
}
|
||||
|
||||
export interface DiscoverFeedMeta {
|
||||
page: number
|
||||
has_next: boolean
|
||||
duration_ms?: number
|
||||
error?: string
|
||||
warning?: string
|
||||
stale?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface DiscoverFeedResult {
|
||||
items: Record<string, DiscoverItem[]>
|
||||
meta: Record<string, DiscoverFeedMeta>
|
||||
}
|
||||
|
||||
// 后端在 TMDb 不可达 / API key 缺失时统一返回 { items: [], error: "..." }
|
||||
// 200 状态码——前端必须能区分这两种情况,不能简单用 items.length === 0
|
||||
// 推断"未配置 API key"。
|
||||
export interface DiscoverResp {
|
||||
items: DiscoverItem[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const discoverAPI = {
|
||||
trending: () =>
|
||||
api.get<DiscoverResp>('/discover/trending').then((r) => ({
|
||||
items: r.data.items ?? [],
|
||||
error: r.data.error,
|
||||
})),
|
||||
popular: () =>
|
||||
api.get<DiscoverResp>('/discover/popular').then((r) => ({
|
||||
items: r.data.items ?? [],
|
||||
error: r.data.error,
|
||||
})),
|
||||
sections: () =>
|
||||
api.get<{ sections: DiscoverSection[] }>('/discover/sections').then((r) => r.data.sections),
|
||||
feed: (sectionKeys: string[], page = 1): Promise<DiscoverFeedResult> =>
|
||||
api
|
||||
.get<Record<string, DiscoverItem[] | DiscoverFeedMeta | Record<string, DiscoverFeedMeta> | null>>('/discover/feed', {
|
||||
params: { sections: sectionKeys.join(','), page },
|
||||
})
|
||||
.then((r) => {
|
||||
const raw = r.data
|
||||
const meta = ((raw._meta as Record<string, DiscoverFeedMeta> | undefined) ?? {})
|
||||
const items: Record<string, DiscoverItem[]> = {}
|
||||
for (const key of sectionKeys) {
|
||||
const row = raw[key]
|
||||
items[key] = Array.isArray(row) ? row : []
|
||||
}
|
||||
return { items, meta }
|
||||
}),
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { api } from './client'
|
||||
|
||||
export type DownloadClientType = 'qbittorrent' | 'aria2' | 'transmission'
|
||||
|
||||
// 与后端 model.DownloadClient 字段对齐(json:"host")。
|
||||
// 之前前端用的 `url` / `save_path` 字段会被后端忽略并触发 400 binding 错误。
|
||||
export interface DownloadClient {
|
||||
id: string
|
||||
name: string
|
||||
type: DownloadClientType
|
||||
host: string
|
||||
username?: string
|
||||
is_default: boolean
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface DownloadClientInput {
|
||||
name: string
|
||||
type: DownloadClientType
|
||||
host: string
|
||||
username?: string
|
||||
password?: 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),
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { api } from './client'
|
||||
import type { DownloadTask, QBitTorrent } from '../types'
|
||||
|
||||
export interface DownloadsState {
|
||||
tasks: DownloadTask[]
|
||||
torrents: QBitTorrent[] | null
|
||||
}
|
||||
|
||||
export interface AddDownloadInput {
|
||||
url: string
|
||||
save_path?: string
|
||||
title?: string
|
||||
poster_url?: string
|
||||
backdrop_url?: string
|
||||
overview?: string
|
||||
media_type?: string
|
||||
media_category?: string
|
||||
source_category?: string
|
||||
}
|
||||
|
||||
export const downloadsAPI = {
|
||||
list: () => api.get<DownloadsState>('/downloads').then((r) => r.data),
|
||||
|
||||
add: (url: string, savePath = '', meta: Omit<AddDownloadInput, 'url' | 'save_path'> = {}) =>
|
||||
api
|
||||
.post<DownloadTask>('/downloads', { url, save_path: savePath, ...meta })
|
||||
.then((r) => r.data),
|
||||
|
||||
remove: (hash: string, clientID: string, deleteFiles = false) =>
|
||||
api
|
||||
.delete(`/downloads/${hash}`, { params: { client_id: clientID, delete_files: deleteFiles } })
|
||||
.then((r) => r.data),
|
||||
|
||||
reload: () => api.post('/downloads/reload').then((r) => r.data),
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { api } from './client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
export interface DuplicateGroup {
|
||||
hash: string
|
||||
primary: Media
|
||||
duplicates: Media[]
|
||||
}
|
||||
|
||||
export interface DuplicateReport {
|
||||
total_scanned: number
|
||||
groups_found: number
|
||||
items_marked: number
|
||||
missing_removed?: number
|
||||
groups: DuplicateGroup[]
|
||||
}
|
||||
|
||||
export const duplicatesAPI = {
|
||||
list: (libraryID = '') =>
|
||||
api
|
||||
.get<DuplicateReport>('/duplicates', {
|
||||
params: libraryID ? { library_id: libraryID } : undefined,
|
||||
})
|
||||
.then((r) => ({ ...r.data, groups: r.data.groups ?? [] })),
|
||||
scan: (libraryID = '') =>
|
||||
api
|
||||
.post<DuplicateReport>('/duplicates/scan', null, {
|
||||
params: libraryID ? { library_id: libraryID } : undefined,
|
||||
})
|
||||
.then((r) => ({ ...r.data, groups: r.data.groups ?? [] })),
|
||||
unmark: (libraryID = '') =>
|
||||
api
|
||||
.post<{ unmarked: number }>('/duplicates/unmark', null, {
|
||||
params: libraryID ? { library_id: libraryID } : undefined,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { api } from './client'
|
||||
|
||||
// ── License Server types (generic, ready for MediaStationGo license integration) ──
|
||||
|
||||
/** Response after binding/activating a license. */
|
||||
export interface LicenseActivation {
|
||||
id: string
|
||||
key_id: string
|
||||
/** The license key string (e.g. MS-XXXX-XXXX-XXXX) */
|
||||
key?: string
|
||||
device_id: string
|
||||
device_name?: string
|
||||
plan?: string
|
||||
max_activations?: number
|
||||
max_users?: number | null
|
||||
unlimited_users?: boolean
|
||||
/** ISO8601 — null means perpetual */
|
||||
expires_at?: string | null
|
||||
valid: boolean
|
||||
ip?: string
|
||||
heartbeat_at?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** Status of the currently bound license on this device. */
|
||||
export interface LicenseStatus {
|
||||
/** Whether a license is currently active */
|
||||
active: boolean
|
||||
activation?: LicenseActivation
|
||||
max_users?: number | null
|
||||
unlimited_users?: boolean
|
||||
/** Error or status message */
|
||||
message?: string
|
||||
}
|
||||
|
||||
// ── API methods ──
|
||||
|
||||
export const licenseAPI = {
|
||||
/** Bind / activate a license key for this device. */
|
||||
bind: (key: string) =>
|
||||
api
|
||||
.post<LicenseActivation>('/license/activate', {
|
||||
key: key.trim(),
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
/** Get the status of the currently active license. */
|
||||
status: () =>
|
||||
api.get<LicenseStatus>('/license/status').then((r) => r.data),
|
||||
|
||||
/** Refresh the heartbeat for the active license. */
|
||||
heartbeat: () =>
|
||||
api.post('/license/heartbeat').then((r) => r.data),
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { api } from './client'
|
||||
import type { NotifyChannel } from '../types'
|
||||
|
||||
// Payload accepted by create / update. `events` and `enabled` are optional.
|
||||
export interface NotifyChannelInput {
|
||||
name: string
|
||||
type: NotifyChannel['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),
|
||||
|
||||
startTelegramPolling: () =>
|
||||
api.post<{
|
||||
message: string
|
||||
started: number
|
||||
already_running: number
|
||||
skipped: number
|
||||
errors?: string[]
|
||||
}>('/admin/telegram/polling/start').then((r) => r.data),
|
||||
|
||||
stopTelegramPolling: () =>
|
||||
api.post<{ message: string; stopped: number }>('/admin/telegram/polling/stop').then((r) => r.data),
|
||||
}
|
||||
@@ -1,21 +1,12 @@
|
||||
import { api } from './client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
export const recycleAPI = {
|
||||
list: () => api.get<{ items: Media[] }>('/recycle').then((r) => r.data.items),
|
||||
|
||||
softDelete: (id: string) => api.delete(`/media/${id}`).then((r) => r.data),
|
||||
|
||||
restore: (id: string) => api.post(`/media/${id}/restore`).then((r) => r.data),
|
||||
|
||||
restoreMany: (ids: string[]) =>
|
||||
api.post<{ applied: number; errors?: string[] }>('/recycle/restore', { media_ids: ids }).then((r) => r.data),
|
||||
|
||||
purge: (id: string) => api.delete(`/media/${id}/purge`).then((r) => r.data),
|
||||
|
||||
purgeMany: (ids: string[]) =>
|
||||
api.post<{ applied: number; errors?: string[] }>('/recycle/purge', { media_ids: ids }).then((r) => r.data),
|
||||
|
||||
exportNFO: (id: string) =>
|
||||
api.post<{ path: string }>(`/media/${id}/nfo`).then((r) => r.data),
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { api } from './client'
|
||||
|
||||
export interface JobStatus {
|
||||
name: string
|
||||
interval: string
|
||||
last_run?: string
|
||||
last_err?: string
|
||||
running?: boolean
|
||||
started_at?: string
|
||||
}
|
||||
|
||||
export const schedulerAPI = {
|
||||
status: () => api.get<{ jobs: JobStatus[] }>('/admin/scheduler').then((r) => r.data.jobs),
|
||||
run: (name: string) => api.post(`/admin/scheduler/${name}/run`).then((r) => r.data),
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { api } from './client'
|
||||
|
||||
// ─── TypeScript interfaces ──────────────────────────────────────────────
|
||||
// Note: the canonical Site type lives in ../types/index.ts
|
||||
// The backend uses json:"url" (not base_url) and json:"type" (not site_type).
|
||||
|
||||
export interface SiteSearchResult {
|
||||
site_name: string
|
||||
site_id: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
torrent_url: string
|
||||
download_url: string
|
||||
category?: string
|
||||
size: number
|
||||
seeders: number
|
||||
leechers: number
|
||||
free: boolean
|
||||
}
|
||||
|
||||
export interface CreateSiteInput {
|
||||
name: string
|
||||
url: string
|
||||
type?: string
|
||||
auth_type?: string
|
||||
cookie?: string
|
||||
api_key?: string
|
||||
auth_header?: string
|
||||
enabled?: boolean
|
||||
is_default?: boolean
|
||||
extra?: string
|
||||
}
|
||||
|
||||
// ─── API client ─────────────────────────────────────────────────────────
|
||||
|
||||
export const sitesAPI = {
|
||||
// List all sites
|
||||
list: () => api.get('/sites').then((r) => r.data),
|
||||
|
||||
// Get single site with decrypted fields
|
||||
get: (id: string | number) => api.get(`/sites/${id}`).then((r) => r.data),
|
||||
|
||||
// Create a new site
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post('/sites', data).then((r) => r.data),
|
||||
|
||||
// Update existing site
|
||||
update: (id: string | number, data: Record<string, unknown>) =>
|
||||
api.put(`/sites/${id}`, data).then((r) => r.data),
|
||||
|
||||
// Delete a site
|
||||
remove: (id: string | number) =>
|
||||
api.delete(`/sites/${id}`).then((r) => r.data),
|
||||
|
||||
// Test site connectivity
|
||||
test: (id: string | number) =>
|
||||
api.post(`/sites/${id}/test`).then((r) => r.data),
|
||||
|
||||
// Get supported site types
|
||||
types: () => api.get('/sites/types').then((r) => r.data),
|
||||
|
||||
// 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),
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { api } from './client'
|
||||
import type { Hardware, StatsSnapshot } from '../types'
|
||||
|
||||
export const statsAPI = {
|
||||
snapshot: () => api.get<StatsSnapshot>('/stats').then((r) => r.data),
|
||||
monitor: () => api.get<Hardware>('/stats/monitor').then((r) => r.data),
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import { api, BATCH_REQUEST_TIMEOUT, LONG_REQUEST_TIMEOUT } from './client'
|
||||
|
||||
export type StorageType = 'alist' | 'openlist' | 'webdav' | 'cloud115' | 'clouddrive2'
|
||||
|
||||
export interface CloudEntry {
|
||||
id: string
|
||||
name: string
|
||||
is_dir: boolean
|
||||
size: number
|
||||
pick_code?: string
|
||||
}
|
||||
|
||||
export interface QRSession {
|
||||
uid: string
|
||||
time: number
|
||||
sign: string
|
||||
qr_image_url: string
|
||||
}
|
||||
|
||||
export interface QRStatus {
|
||||
state: 'waiting' | 'scanned' | 'confirmed' | 'expired'
|
||||
cookie?: string
|
||||
}
|
||||
|
||||
export interface StorageConfig {
|
||||
id: string
|
||||
type: StorageType
|
||||
config: Record<string, string>
|
||||
enabled: boolean
|
||||
last_error?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CloudUploadResult {
|
||||
source_path: string
|
||||
dest_path: string
|
||||
uploaded: number
|
||||
moved?: number
|
||||
skipped: number
|
||||
bytes: number
|
||||
errors?: string[]
|
||||
items?: Array<{
|
||||
source: string
|
||||
target: string
|
||||
action: 'upload' | 'move' | 'skip' | 'error'
|
||||
size?: number
|
||||
reason?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface CloudScanStatus {
|
||||
library_id: string
|
||||
provider: string
|
||||
stage: string
|
||||
state: string
|
||||
dirs: number
|
||||
discovered: number
|
||||
visited: number
|
||||
added: number
|
||||
updated: number
|
||||
skipped: number
|
||||
removed: number
|
||||
error?: string
|
||||
resume_hint?: string
|
||||
estimate_message?: string
|
||||
files_per_second?: number
|
||||
}
|
||||
|
||||
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),
|
||||
|
||||
logout: (type: StorageType) =>
|
||||
api
|
||||
.post<StorageConfig>(`/admin/storage/${type}/logout`)
|
||||
.then((r) => r.data),
|
||||
|
||||
test: (type: StorageType, config: Record<string, string>) =>
|
||||
api
|
||||
.post<{ ok: boolean; error?: string }>(`/admin/storage/${type}/test`, {
|
||||
type,
|
||||
config,
|
||||
}, {
|
||||
timeout: LONG_REQUEST_TIMEOUT,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
uploadLocal: (
|
||||
type: StorageType,
|
||||
input: {
|
||||
source_path: string
|
||||
dest_path: string
|
||||
recursive: boolean
|
||||
include_sidecars: boolean
|
||||
overwrite: boolean
|
||||
transfer_mode?: 'copy' | 'move'
|
||||
},
|
||||
) =>
|
||||
api
|
||||
.post<{ result: CloudUploadResult; error?: string }>(`/admin/storage/${type}/upload-local`, input, {
|
||||
timeout: BATCH_REQUEST_TIMEOUT,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
scanAllCloud: () =>
|
||||
api
|
||||
.post<{ items: CloudScanStatus[]; message?: string; estimate_message?: string }>('/admin/cloud/scan-all')
|
||||
.then((r) => r.data),
|
||||
|
||||
cancelCloudScan: (libraryID = '', provider = '') =>
|
||||
api
|
||||
.post<{ cancelled: number; message?: string }>('/admin/cloud/scan/cancel', null, {
|
||||
params: libraryID ? { library_id: libraryID } : provider ? { provider } : undefined,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
cloudScanStatus: () =>
|
||||
api
|
||||
.get<{ items: CloudScanStatus[] }>('/admin/cloud/scan/status')
|
||||
.then((r) => r.data),
|
||||
}
|
||||
|
||||
// cloudAPI drives 网盘 browsing, QR login and 302 import.
|
||||
export const cloudAPI = {
|
||||
list: (type: StorageType, dir = '') =>
|
||||
api
|
||||
.get<{ items: CloudEntry[]; error?: string }>(`/admin/cloud/${type}/list`, {
|
||||
params: { dir },
|
||||
timeout: LONG_REQUEST_TIMEOUT,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
mkdir: (type: StorageType, dir: string, name: string) =>
|
||||
api
|
||||
.post<{ entry: CloudEntry }>(`/admin/cloud/${type}/mkdir`, { dir, name }, {
|
||||
timeout: LONG_REQUEST_TIMEOUT,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
rename: (type: StorageType, ref: string, name: string) =>
|
||||
api
|
||||
.put<{ entry: CloudEntry }>(`/admin/cloud/${type}/rename`, { ref, name }, {
|
||||
timeout: LONG_REQUEST_TIMEOUT,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
import: (type: StorageType, ref: string, name: string, size: number) =>
|
||||
api
|
||||
.post(`/admin/cloud/${type}/import`, { ref, name, size })
|
||||
.then((r) => r.data),
|
||||
|
||||
mount: (type: StorageType, dir = '', name = '', media_type = 'movie', dir_path = '') =>
|
||||
api
|
||||
.post(`/admin/cloud/${type}/mount`, { dir, dir_path, name, media_type }, {
|
||||
timeout: LONG_REQUEST_TIMEOUT,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
qrStart: (type: StorageType) =>
|
||||
api.post<QRSession>(`/admin/cloud/${type}/qr/start`).then((r) => r.data),
|
||||
|
||||
qrPoll: (type: StorageType, sess: QRSession) =>
|
||||
api.post<QRStatus>(`/admin/cloud/${type}/qr/poll`, sess).then((r) => r.data),
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { api, BATCH_REQUEST_TIMEOUT } from './client'
|
||||
|
||||
export type GenerateSTRMInput = {
|
||||
library_id: string
|
||||
output_dir?: string
|
||||
base_url?: string
|
||||
enabled?: boolean
|
||||
overwrite?: boolean
|
||||
include_local?: boolean
|
||||
preserve_tree?: boolean
|
||||
refresh_library?: boolean
|
||||
scrape_after?: boolean
|
||||
}
|
||||
|
||||
export type GenerateSTRMResult = {
|
||||
library_id: string
|
||||
output_dir: string
|
||||
generated: number
|
||||
updated: number
|
||||
skipped: number
|
||||
cleaned: number
|
||||
total?: number
|
||||
remaining?: number
|
||||
batch_limited?: boolean
|
||||
ignored?: number
|
||||
previewed?: number
|
||||
errors?: string[]
|
||||
ignored_items?: string[]
|
||||
refresh?: STRMRefreshResult
|
||||
items?: Array<{
|
||||
media_id: string
|
||||
title: string
|
||||
file_path: string
|
||||
url?: string
|
||||
action: string
|
||||
reason?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type GenerateSTRMTreeInput = {
|
||||
provider: string
|
||||
tree_text?: string
|
||||
paths?: string[]
|
||||
source_root?: string
|
||||
output_prefix?: string
|
||||
output_dir: string
|
||||
base_url?: string
|
||||
overwrite?: boolean
|
||||
cleanup?: boolean
|
||||
dry_run?: boolean
|
||||
batch_limit?: number
|
||||
recognize_rename?: boolean
|
||||
transfer_subtitles?: boolean
|
||||
missing_only?: boolean
|
||||
refresh_library?: boolean
|
||||
scrape_after?: boolean
|
||||
}
|
||||
|
||||
export type RepairSTRMInput = {
|
||||
output_dir: string
|
||||
base_url?: string
|
||||
dry_run?: boolean
|
||||
refresh_library?: boolean
|
||||
scrape_after?: boolean
|
||||
}
|
||||
|
||||
export type RepairSTRMResult = {
|
||||
output_dir: string
|
||||
repaired: number
|
||||
previewed?: number
|
||||
skipped: number
|
||||
errors?: string[]
|
||||
refresh?: STRMRefreshResult
|
||||
items?: Array<{
|
||||
file_path: string
|
||||
before?: string
|
||||
after?: string
|
||||
action: string
|
||||
reason?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type STRMRefreshResult = {
|
||||
requested: boolean
|
||||
queued: boolean
|
||||
reason?: string
|
||||
scrape_requested?: boolean
|
||||
scrape_queued?: boolean
|
||||
scrape_reason?: string
|
||||
targets?: Array<{
|
||||
library_id: string
|
||||
root_id?: string
|
||||
name: string
|
||||
path: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type STRMOutputPreset = {
|
||||
label: string
|
||||
path: string
|
||||
kind: 'default' | 'library' | string
|
||||
}
|
||||
|
||||
export const strmAPI = {
|
||||
set: (mediaID: string, url: string) =>
|
||||
api.put(`/media/${mediaID}/strm`, { url }).then((r) => r.data),
|
||||
clear: (mediaID: string) => api.delete(`/media/${mediaID}/strm`).then((r) => r.data),
|
||||
outputPresets: () =>
|
||||
api.get<{ items: STRMOutputPreset[] }>('/strm/output-presets').then((r) => r.data.items),
|
||||
importURL: (libraryID: string, title: string, url: string) =>
|
||||
api.post('/strm/import', { library_id: libraryID, title, url }).then((r) => r.data),
|
||||
generate: (input: GenerateSTRMInput) =>
|
||||
api
|
||||
.post<GenerateSTRMResult>('/strm/generate', input, { timeout: BATCH_REQUEST_TIMEOUT })
|
||||
.then((r) => r.data),
|
||||
generateFromTree: (input: GenerateSTRMTreeInput) =>
|
||||
api
|
||||
.post<GenerateSTRMResult>('/strm/generate-from-tree', input, { timeout: BATCH_REQUEST_TIMEOUT })
|
||||
.then((r) => r.data),
|
||||
repair: (input: RepairSTRMInput) =>
|
||||
api
|
||||
.post<RepairSTRMResult>('/strm/repair', input, { timeout: BATCH_REQUEST_TIMEOUT })
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { api } from './client'
|
||||
import type { Subscription } from '../types'
|
||||
|
||||
export function buildSiteSearchFeedURL(keyword: string, source?: string, aliases: string[] = []) {
|
||||
const params = new URLSearchParams()
|
||||
params.set('keyword', keyword)
|
||||
if (source) params.set('source', source)
|
||||
const seen = new Set([keyword.trim().toLowerCase()])
|
||||
aliases
|
||||
.map((alias) => alias.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((alias) => {
|
||||
const key = alias.toLowerCase()
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
params.append('alias', alias)
|
||||
})
|
||||
return `site-search://search?${params.toString()}`
|
||||
}
|
||||
|
||||
export function buildSubscriptionAliases(item: {
|
||||
title?: string
|
||||
original_name?: string
|
||||
subscribe_keyword?: string
|
||||
subscribe_aliases?: string[]
|
||||
year?: number
|
||||
}) {
|
||||
const withYear = (value?: string) => {
|
||||
const title = (value || '').trim()
|
||||
if (!title) return ''
|
||||
return item.year && item.year > 0 ? `${title} ${item.year}` : title
|
||||
}
|
||||
return [
|
||||
...(item.subscribe_aliases || []),
|
||||
item.title || '',
|
||||
item.original_name || '',
|
||||
withYear(item.title),
|
||||
withYear(item.original_name),
|
||||
item.subscribe_keyword || '',
|
||||
]
|
||||
}
|
||||
|
||||
export const subscriptionsAPI = {
|
||||
list: () =>
|
||||
api
|
||||
.get<{ items: Subscription[] }>('/subscriptions', subscriptionListRequestConfig())
|
||||
.then((r) => r.data.items),
|
||||
|
||||
history: () =>
|
||||
api
|
||||
.get<{ items: Subscription[] }>('/subscriptions/history', subscriptionListRequestConfig())
|
||||
.then((r) => r.data.items),
|
||||
|
||||
create: (input: {
|
||||
name: string
|
||||
feed_url: string
|
||||
filter?: string
|
||||
media_type?: string
|
||||
media_category?: string
|
||||
save_path?: string
|
||||
search_mode?: string
|
||||
imdb_id?: string
|
||||
source?: string
|
||||
poster_url?: string
|
||||
backdrop_url?: string
|
||||
overview?: string
|
||||
original_name?: string
|
||||
year?: number
|
||||
resolution?: string
|
||||
quality?: string
|
||||
effects?: string
|
||||
release_groups?: string
|
||||
exclude_words?: string
|
||||
min_seeders?: number
|
||||
max_seeders?: number
|
||||
min_size_gb?: number
|
||||
max_size_gb?: number
|
||||
free_only?: boolean
|
||||
wash_enabled?: boolean
|
||||
wash_priority?: string
|
||||
total_episodes?: number
|
||||
priority?: number
|
||||
enabled?: boolean
|
||||
}) =>
|
||||
api.post<Subscription>('/subscriptions', input).then((r) => r.data),
|
||||
|
||||
update: (id: string, input: Partial<Subscription>) =>
|
||||
api.put(`/subscriptions/${id}`, input).then((r) => r.data),
|
||||
|
||||
remove: (id: string) => api.delete(`/subscriptions/${id}`).then((r) => r.data),
|
||||
|
||||
restore: (id: string) =>
|
||||
api.post<Subscription>(`/subscriptions/${id}/restore`).then((r) => r.data),
|
||||
|
||||
runNow: (id: string) =>
|
||||
api.post<{ queued: number }>(`/subscriptions/${id}/run`).then((r) => r.data),
|
||||
}
|
||||
|
||||
function subscriptionListRequestConfig() {
|
||||
return {
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
params: { _ts: Date.now() },
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { api } from './client'
|
||||
import type { QBitTorrent } from '../types'
|
||||
|
||||
export interface ActiveTranscode {
|
||||
media_id: string
|
||||
encoder: string
|
||||
started_at: string
|
||||
playlist_ok: boolean
|
||||
}
|
||||
|
||||
export type TaskItemStatus = 'pending' | 'running' | 'succeeded' | 'failed'
|
||||
|
||||
export interface TaskItem {
|
||||
id: string
|
||||
kind: string // organize | scan | scrape
|
||||
status: TaskItemStatus
|
||||
name: string
|
||||
source?: string
|
||||
dest_path?: string
|
||||
library_id?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface BackgroundTask {
|
||||
id: string
|
||||
kind: string
|
||||
name: string
|
||||
status: 'running' | 'completed' | 'failed'
|
||||
stage?: string
|
||||
source_path?: string
|
||||
dest_path?: string
|
||||
message?: string
|
||||
error?: string
|
||||
details?: string[]
|
||||
metrics?: Record<string, number>
|
||||
items?: TaskItem[]
|
||||
started_at: string
|
||||
updated_at: string
|
||||
finished_at?: string
|
||||
}
|
||||
|
||||
export interface BackgroundTaskSnapshot {
|
||||
active: BackgroundTask[]
|
||||
recent: BackgroundTask[]
|
||||
}
|
||||
|
||||
export interface TasksSnapshot {
|
||||
transcodes: ActiveTranscode[]
|
||||
torrents: QBitTorrent[] | null
|
||||
background_tasks?: BackgroundTaskSnapshot
|
||||
}
|
||||
|
||||
export const tasksAPI = {
|
||||
snapshot: () => api.get<TasksSnapshot>('/tasks').then((r) => r.data),
|
||||
}
|
||||
+2
-52
@@ -5,7 +5,6 @@ import { Navigate } from 'react-router-dom'
|
||||
const HomePage = lazy(() => import('./pages/HomePage').then((m) => ({ default: m.HomePage })))
|
||||
const LibraryPage = lazy(() => import('./pages/LibraryPage').then((m) => ({ default: m.LibraryPage })))
|
||||
const LibrariesPage = lazy(() => import('./pages/LibrariesPage').then((m) => ({ default: m.LibrariesPage })))
|
||||
const SearchPage = lazy(() => import('./pages/SearchPage').then((m) => ({ default: m.SearchPage })))
|
||||
const FavouritesPage = lazy(() => import('./pages/FavouritesPage').then((m) => ({ default: m.FavouritesPage })))
|
||||
const PlaylistsPage = lazy(() => import('./pages/PlaylistsPage').then((m) => ({ default: m.PlaylistsPage })))
|
||||
const PlaylistDetailPage = lazy(() =>
|
||||
@@ -14,49 +13,19 @@ const PlaylistDetailPage = lazy(() =>
|
||||
const MediaDetailPage = lazy(() => import('./pages/MediaDetailPage').then((m) => ({ default: m.MediaDetailPage })))
|
||||
const PlayerPage = lazy(() => import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage })))
|
||||
const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })))
|
||||
const DownloadsPage = lazy(() => import('./pages/DownloadsPage').then((m) => ({ default: m.DownloadsPage })))
|
||||
const SubscriptionsPage = lazy(() =>
|
||||
import('./pages/SubscriptionsPage').then((m) => ({ default: m.SubscriptionsPage })),
|
||||
)
|
||||
const ProfilePage = lazy(() => import('./pages/ProfilePage').then((m) => ({ default: m.ProfilePage })))
|
||||
const StatsPage = lazy(() => import('./pages/StatsPage').then((m) => ({ default: m.StatsPage })))
|
||||
const DiscoverPage = lazy(() => import('./pages/DiscoverPage').then((m) => ({ default: m.DiscoverPage })))
|
||||
const TasksPage = lazy(() => import('./pages/TasksPage').then((m) => ({ default: m.TasksPage })))
|
||||
const RecycleBinPage = lazy(() => import('./pages/RecycleBinPage').then((m) => ({ default: m.RecycleBinPage })))
|
||||
const DlnaPage = lazy(() => import('./pages/DlnaPage').then((m) => ({ default: m.DlnaPage })))
|
||||
const FileManagerPage = lazy(() =>
|
||||
import('./pages/FileManagerPage').then((m) => ({ default: m.FileManagerPage })),
|
||||
)
|
||||
const StoragePage = lazy(() => import('./pages/StoragePage').then((m) => ({ default: m.StoragePage })))
|
||||
const DuplicatesPage = lazy(() => import('./pages/DuplicatesPage').then((m) => ({ default: m.DuplicatesPage })))
|
||||
const SchedulerPage = lazy(() => import('./pages/SchedulerPage').then((m) => ({ default: m.SchedulerPage })))
|
||||
const WatchHistoryPage = lazy(() =>
|
||||
import('./pages/WatchHistoryPage').then((m) => ({ default: m.WatchHistoryPage })),
|
||||
)
|
||||
const PosterWallPage = lazy(() => import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })))
|
||||
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 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 StorageConfigPage = lazy(() =>
|
||||
import('./pages/StorageConfigPage').then((m) => ({ default: m.StorageConfigPage })),
|
||||
)
|
||||
const LicensePage = lazy(() => import('./pages/LicensePage').then((m) => ({ default: m.LicensePage })))
|
||||
|
||||
export type AppRoute = {
|
||||
path?: string
|
||||
@@ -69,38 +38,19 @@ export const appRoutes: AppRoute[] = [
|
||||
{ index: true, element: <HomePage /> },
|
||||
{ path: 'libraries', element: <LibrariesPage /> },
|
||||
{ path: 'library/:id', element: <LibraryPage /> },
|
||||
{ path: 'discover', element: <DiscoverPage /> },
|
||||
{ path: 'search', element: <SearchPage /> },
|
||||
{ path: 'favourites', element: <FavouritesPage /> },
|
||||
{ path: 'playlists', element: <PlaylistsPage /> },
|
||||
{ path: 'playlist/:id', element: <PlaylistDetailPage /> },
|
||||
{ path: 'media/:id', element: <MediaDetailPage /> },
|
||||
{ path: 'play/:id', element: <PlayerPage /> },
|
||||
{ path: 'downloads', element: <DownloadsPage /> },
|
||||
{ path: 'subscriptions', element: <SubscriptionsPage /> },
|
||||
{ path: 'profile', element: <ProfilePage /> },
|
||||
{ path: 'dlna', element: <DlnaPage /> },
|
||||
{ path: 'history', element: <WatchHistoryPage /> },
|
||||
{ path: 'poster-wall', element: <PosterWallPage /> },
|
||||
{ path: 'site-search', element: <SiteSearchPage /> },
|
||||
{ path: 'ai', element: <AIAssistantPage /> },
|
||||
{ path: 'play-profiles', element: <ProfileManagementPage /> },
|
||||
{ path: 'api-configs', element: <Navigate to="/admin?tab=api" replace /> },
|
||||
{ path: 'tools', element: <Navigate to="/storage" replace /> },
|
||||
{ path: 'sites', element: <SitesPage />, adminOnly: true },
|
||||
{ path: 'api-configs', element: <Navigate to="/settings?group=api-configs" replace /> },
|
||||
{ path: 'tools', element: <Navigate to="/files" replace /> },
|
||||
{ path: 'files', element: <FileManagerPage />, adminOnly: true },
|
||||
{ path: 'storage', element: <StoragePage />, adminOnly: true },
|
||||
{ path: 'duplicates', element: <DuplicatesPage />, adminOnly: true },
|
||||
{ path: 'scheduler', element: <SchedulerPage />, adminOnly: true },
|
||||
{ path: 'tasks', element: <TasksPage />, adminOnly: true },
|
||||
{ path: 'recycle', element: <RecycleBinPage />, adminOnly: true },
|
||||
{ path: 'strm', element: <StrmPage />, adminOnly: true },
|
||||
{ path: 'notify-channels', element: <NotifyChannelsPage />, adminOnly: true },
|
||||
{ path: 'settings', element: <SettingsPage />, adminOnly: true },
|
||||
{ path: 'assistant', element: <AssistantChatPage />, adminOnly: true },
|
||||
{ path: 'download-clients', element: <DownloadClientsPage />, adminOnly: true },
|
||||
{ path: 'license', element: <LicensePage />, adminOnly: true },
|
||||
{ path: 'storage-config', element: <StorageConfigPage />, adminOnly: true },
|
||||
{ path: 'stats', element: <StatsPage />, adminOnly: true },
|
||||
{ path: 'admin', element: <AdminPage />, adminOnly: true },
|
||||
]
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Github, Globe, Send } from 'lucide-react'
|
||||
|
||||
import { publicAPI } from '../api/public'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const LINKS = [
|
||||
{ href: 'https://github.com/ShukeBta/MediaStationGo', icon: Github, label: '开源仓库' },
|
||||
{ href: 'https://github.com/ShukeBta', icon: Globe, label: '作者主页' },
|
||||
{ href: 'https://t.me/MediaStationGo', icon: Send, label: 'TG 群组' },
|
||||
]
|
||||
|
||||
export function AppFooter({ className = '' }: { className?: string }) {
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
const [hideForUsers, setHideForUsers] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
publicAPI.uiConfig().then((config) => setHideForUsers(config.hide_community_links_for_users)).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
if (hideForUsers && role !== 'admin') return null
|
||||
return (
|
||||
<footer className={`flex items-center justify-center gap-1 ${className}`}>
|
||||
{LINKS.map((link, i) => (
|
||||
<span key={link.href} className="flex items-center">
|
||||
{i > 0 && <span className="mx-2 h-3 w-px bg-sand-300" />}
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-xl px-2 py-1.5 text-xs text-sand-500 transition-colors hover:bg-sand-200 hover:text-ink-50"
|
||||
title={link.label}
|
||||
>
|
||||
<link.icon size={14} />
|
||||
<span>{link.label}</span>
|
||||
</a>
|
||||
</span>
|
||||
))}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -3,17 +3,36 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePlayProfileStore } from '../stores/playProfile'
|
||||
import {
|
||||
LayoutFrameFooter,
|
||||
LayoutHeader,
|
||||
LayoutSidebars,
|
||||
LayoutWorkspace,
|
||||
} from './LayoutSections'
|
||||
import { useLayoutSearch } from './useLayoutSearch'
|
||||
import { useLayoutPermissions } from './useLayoutPermissions'
|
||||
import { useLayoutProfiles } from './useLayoutProfiles'
|
||||
import { useLayoutSidebar } from './useLayoutSidebar'
|
||||
import { useThemeMode } from './useThemeMode'
|
||||
|
||||
function isMediaView(pathname: string, search: string): boolean {
|
||||
const params = new URLSearchParams(search)
|
||||
// 从设置/管理后台菜单进入(携带 from=admin 或 from=settings 或 manage=1)时展示左侧栏
|
||||
if (params.get('from') === 'admin' || params.get('from') === 'settings' || params.get('manage') === '1') {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
pathname === '/' ||
|
||||
pathname === '/libraries' ||
|
||||
pathname.startsWith('/library') ||
|
||||
pathname.startsWith('/media') ||
|
||||
pathname.startsWith('/play') ||
|
||||
pathname === '/favourites' ||
|
||||
pathname === '/playlists' ||
|
||||
pathname.startsWith('/playlist') ||
|
||||
pathname === '/history' ||
|
||||
pathname === '/poster-wall'
|
||||
)
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
@@ -22,11 +41,6 @@ export function Layout() {
|
||||
const activeProfileId = usePlayProfileStore((s) => s.activeProfileId)
|
||||
const setActiveProfile = usePlayProfileStore((s) => s.setActiveProfile)
|
||||
const theme = useThemeMode()
|
||||
const search = useLayoutSearch({
|
||||
pathname: location.pathname,
|
||||
locationSearch: location.search,
|
||||
navigate,
|
||||
})
|
||||
const permissions = useLayoutPermissions(user)
|
||||
const sidebar = useLayoutSidebar(location.pathname)
|
||||
const profile = useLayoutProfiles({ activeProfileId, setActiveProfile, user })
|
||||
@@ -34,18 +48,19 @@ export function Layout() {
|
||||
const handleLogout = () => { logout(); navigate('/login') }
|
||||
const closeProfileAndLogout = () => { profile.setIsProfileOpen(false); handleLogout() }
|
||||
|
||||
const showSidebar = !isMediaView(location.pathname, location.search)
|
||||
const hideSearch = location.pathname.startsWith('/settings')
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
|
||||
<LayoutSidebars
|
||||
sidebar={sidebar}
|
||||
isAdmin={permissions.isAdmin}
|
||||
username={user?.username}
|
||||
can={permissions.can}
|
||||
onLogout={handleLogout}
|
||||
showSidebar={showSidebar}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
|
||||
<LayoutHeader
|
||||
search={search}
|
||||
permissions={permissions}
|
||||
theme={theme}
|
||||
onOpenMobileDrawer={() => sidebar.setIsMobileDrawerOpen(true)}
|
||||
@@ -53,9 +68,10 @@ export function Layout() {
|
||||
activeProfileId={activeProfileId}
|
||||
profile={profile}
|
||||
onLogout={closeProfileAndLogout}
|
||||
showSidebar={showSidebar}
|
||||
hideSearch={hideSearch}
|
||||
/>
|
||||
<LayoutWorkspace routeKey={location.pathname} />
|
||||
<LayoutFrameFooter />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Menu, MessageSquareText, Search, Sparkles } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
|
||||
|
||||
import type { PlayProfile, User } from '../types'
|
||||
import { LayoutSearchBox } from './LayoutSearchBox'
|
||||
import { imageURL } from '../api/client'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import type { Media, PlayProfile, User } from '../types'
|
||||
import { LayoutThemeToggle } from './LayoutThemeToggle'
|
||||
import { LayoutUserMenu } from './LayoutUserMenu'
|
||||
import type { useLayoutProfiles } from './useLayoutProfiles'
|
||||
import type { useLayoutSearch } from './useLayoutSearch'
|
||||
import type { ThemeMode, useThemeMode } from './useThemeMode'
|
||||
|
||||
type LayoutSearchState = ReturnType<typeof useLayoutSearch>
|
||||
type LayoutProfileState = ReturnType<typeof useLayoutProfiles>
|
||||
type LayoutThemeState = ReturnType<typeof useThemeMode>
|
||||
|
||||
@@ -19,7 +20,6 @@ type LayoutPermissionState = {
|
||||
}
|
||||
|
||||
type LayoutHeaderProps = {
|
||||
search: LayoutSearchState
|
||||
permissions: LayoutPermissionState
|
||||
theme: LayoutThemeState
|
||||
onOpenMobileDrawer: () => void
|
||||
@@ -27,10 +27,11 @@ type LayoutHeaderProps = {
|
||||
activeProfileId: string | null
|
||||
profile: LayoutProfileState
|
||||
onLogout: () => void
|
||||
showSidebar?: boolean
|
||||
hideSearch?: boolean
|
||||
}
|
||||
|
||||
export function LayoutHeader({
|
||||
search,
|
||||
permissions,
|
||||
theme,
|
||||
onOpenMobileDrawer,
|
||||
@@ -38,10 +39,43 @@ export function LayoutHeader({
|
||||
activeProfileId,
|
||||
profile,
|
||||
onLogout,
|
||||
showSidebar,
|
||||
hideSearch,
|
||||
}: LayoutHeaderProps) {
|
||||
return (
|
||||
<header className="flex h-20 shrink-0 items-center justify-between border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-4 backdrop-blur-md z-30 md:px-8">
|
||||
<LayoutHeaderSearch search={search} onOpenMobileDrawer={onOpenMobileDrawer} />
|
||||
<header className="relative flex h-20 shrink-0 items-center justify-between gap-4 border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-4 backdrop-blur-md z-30 md:px-8">
|
||||
{/* Left: Mobile Menu button or Brand Logo */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={onOpenMobileDrawer}
|
||||
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors lg:hidden"
|
||||
title="打开菜单"
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
className={`flex items-center gap-2.5 transition-transform hover:scale-105 ${
|
||||
showSidebar ? 'lg:hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src="/brand/logo-192.png"
|
||||
alt="MMTL"
|
||||
className="h-9 w-9 rounded-xl object-contain shadow-sm"
|
||||
/>
|
||||
<span className="hidden font-display text-lg font-black tracking-tight text-[var(--app-text)] sm:inline-block">
|
||||
MMTL
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Middle: Search Box */}
|
||||
<div className="flex-1 max-w-xl mx-auto">
|
||||
{!hideSearch && <LayoutHeaderSearch />}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions (Theme Toggle & User Menu) */}
|
||||
<LayoutHeaderActions
|
||||
permissions={permissions}
|
||||
themeMode={theme.mode}
|
||||
@@ -61,32 +95,163 @@ export function LayoutHeader({
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutHeaderSearch({
|
||||
search,
|
||||
onOpenMobileDrawer,
|
||||
}: {
|
||||
search: LayoutSearchState
|
||||
onOpenMobileDrawer: () => void
|
||||
}) {
|
||||
function LayoutHeaderSearch() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [results, setResults] = useState<Media[]>([])
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await mediaAPI.search(trimmed, 8)
|
||||
setResults(res.items || [])
|
||||
setIsOpen(true)
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, 250)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [query])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleSelect = (mediaId: string) => {
|
||||
setIsOpen(false)
|
||||
setQuery('')
|
||||
navigate(`/media/${mediaId}`)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
} else if (e.key === 'Enter' && results.length > 0) {
|
||||
handleSelect(results[0].id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 flex-1 max-w-lg md:gap-4">
|
||||
<button
|
||||
onClick={onOpenMobileDrawer}
|
||||
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors lg:hidden"
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
<LayoutSearchBox
|
||||
query={search.query}
|
||||
focused={search.focused}
|
||||
loading={search.loading}
|
||||
error={search.error}
|
||||
cards={search.cards}
|
||||
total={search.total}
|
||||
onQueryChange={search.setQuery}
|
||||
onFocusedChange={search.setFocused}
|
||||
onSubmit={search.submit}
|
||||
/>
|
||||
<div ref={containerRef} className="relative w-full">
|
||||
<div className="relative flex items-center">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3.5 text-[var(--app-muted)] pointer-events-none transition-colors group-focus-within:text-brand-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
if (e.target.value.trim()) setIsOpen(true)
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (results.length > 0) setIsOpen(true)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="搜索电影、剧集、演员…"
|
||||
className="w-full h-10 pl-10 pr-9 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] shadow-sm outline-none transition-all duration-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:bg-[var(--app-panel-elevated)]"
|
||||
/>
|
||||
{loading ? (
|
||||
<LoaderCircle size={15} className="absolute right-3.5 text-brand-500 animate-spin" />
|
||||
) : query ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className="absolute right-3 text-[var(--app-muted)] hover:text-[var(--app-text)] p-0.5 rounded-lg"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Search Dropdown Results */}
|
||||
<AnimatePresence>
|
||||
{isOpen && query.trim() && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 6 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-2xl z-50 backdrop-blur-xl"
|
||||
>
|
||||
{results.length === 0 && !loading ? (
|
||||
<div className="py-8 text-center text-xs text-[var(--app-muted)]">
|
||||
未搜索到与 “{query}” 相关的媒体内容
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{results.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
className="flex w-full items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-[var(--app-hover)] group"
|
||||
>
|
||||
<div className="relative h-12 w-9 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={imageURL(item.poster_url, item.updated_at)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-[var(--app-muted)]">
|
||||
<Film size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-bold text-[var(--app-text)] group-hover:text-brand-500">
|
||||
{item.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-[10px] text-[var(--app-muted)] mt-0.5">
|
||||
{item.year > 0 && <span>{item.year}</span>}
|
||||
{item.rating > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-[#c9954a]">
|
||||
<Star size={9} fill="currentColor" />
|
||||
{item.rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{item.video_codec && (
|
||||
<span className="uppercase text-[9px] px-1 py-0.2 rounded border border-[var(--app-border)]">
|
||||
{item.video_codec}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -108,7 +273,6 @@ type LayoutHeaderActionsProps = {
|
||||
}
|
||||
|
||||
function LayoutHeaderActions({
|
||||
permissions,
|
||||
themeMode,
|
||||
onThemeChange,
|
||||
user,
|
||||
@@ -124,17 +288,16 @@ function LayoutHeaderActions({
|
||||
}: LayoutHeaderActionsProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2 sm:gap-3 md:gap-4">
|
||||
<LayoutQuickActions permissions={permissions} />
|
||||
<LayoutThemeToggle mode={themeMode} onChange={onThemeChange} />
|
||||
<span className="hidden h-6 w-px bg-[var(--app-border)] sm:block" />
|
||||
<LayoutProfileMenu
|
||||
<LayoutUserMenu
|
||||
user={user}
|
||||
isProfileOpen={isProfileOpen}
|
||||
isOpen={isProfileOpen}
|
||||
profiles={profiles}
|
||||
activeProfileId={activeProfileId}
|
||||
activeProfile={activeProfile}
|
||||
onToggleProfile={onToggleProfile}
|
||||
onCloseProfile={onCloseProfile}
|
||||
onToggle={onToggleProfile}
|
||||
onClose={onCloseProfile}
|
||||
onUseDefaultProfile={onUseDefaultProfile}
|
||||
onSwitchProfile={onSwitchProfile}
|
||||
onLogout={onLogout}
|
||||
@@ -142,63 +305,3 @@ function LayoutHeaderActions({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutQuickActions({ permissions }: { permissions: LayoutPermissionState }) {
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
to="/search"
|
||||
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors sm:hidden"
|
||||
>
|
||||
<Search size={18} />
|
||||
</Link>
|
||||
{permissions.can('can_view_discover') && (
|
||||
<Link
|
||||
to="/discover"
|
||||
className="hidden md:flex items-center gap-2 rounded-xl border border-[var(--app-border)] px-4 py-2.5 text-xs font-bold text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-all"
|
||||
>
|
||||
<Sparkles size={14} className="text-brand-500" />
|
||||
<span>发现新片</span>
|
||||
</Link>
|
||||
)}
|
||||
{permissions.isAdmin && (
|
||||
<Link
|
||||
to="/notify-channels"
|
||||
title="通知配置"
|
||||
aria-label="打开通知配置"
|
||||
className="relative rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-all"
|
||||
>
|
||||
<MessageSquareText size={18} />
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutProfileMenu({
|
||||
user,
|
||||
isProfileOpen,
|
||||
profiles,
|
||||
activeProfileId,
|
||||
activeProfile,
|
||||
onToggleProfile,
|
||||
onCloseProfile,
|
||||
onUseDefaultProfile,
|
||||
onSwitchProfile,
|
||||
onLogout,
|
||||
}: Omit<LayoutHeaderActionsProps, 'permissions' | 'themeMode' | 'onThemeChange'>) {
|
||||
return (
|
||||
<LayoutUserMenu
|
||||
user={user}
|
||||
isOpen={isProfileOpen}
|
||||
profiles={profiles}
|
||||
activeProfileId={activeProfileId}
|
||||
activeProfile={activeProfile}
|
||||
onToggle={onToggleProfile}
|
||||
onClose={onCloseProfile}
|
||||
onUseDefaultProfile={onUseDefaultProfile}
|
||||
onSwitchProfile={onSwitchProfile}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import { FormEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Library as LibraryIcon, Search } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { seriesCardLink, type SeriesCard } from '../utils/groupSeries'
|
||||
|
||||
type LayoutSearchBoxProps = {
|
||||
query: string
|
||||
focused: boolean
|
||||
loading: boolean
|
||||
error: string
|
||||
cards: SeriesCard[]
|
||||
total: number
|
||||
onQueryChange: (value: string) => void
|
||||
onFocusedChange: (focused: boolean) => void
|
||||
onSubmit: (event: FormEvent) => void
|
||||
}
|
||||
|
||||
export function LayoutSearchBox({
|
||||
query,
|
||||
focused,
|
||||
loading,
|
||||
error,
|
||||
cards,
|
||||
total,
|
||||
onQueryChange,
|
||||
onFocusedChange,
|
||||
onSubmit,
|
||||
}: LayoutSearchBoxProps) {
|
||||
const trimmedQuery = query.trim()
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="relative hidden w-full sm:block">
|
||||
<span className={clsx(
|
||||
'absolute left-4 top-1/2 -translate-y-1/2 transition-colors duration-200',
|
||||
focused ? 'text-brand-500' : 'text-[var(--app-muted)]',
|
||||
)}>
|
||||
<Search size={16} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
onMouseDown={() => onFocusedChange(true)}
|
||||
onClick={() => onFocusedChange(true)}
|
||||
onFocus={() => onFocusedChange(true)}
|
||||
onBlur={() => window.setTimeout(() => onFocusedChange(false), 120)}
|
||||
placeholder="搜索电影、电视剧、演员、种子站点..."
|
||||
className="w-full rounded-full border border-[var(--app-border)] bg-[var(--app-control-bg)] py-2.5 pl-11 pr-12 text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] outline-none transition-all duration-300 focus:border-brand-500 focus:bg-[var(--app-panel)] focus:ring-4 focus:ring-brand-100/40"
|
||||
/>
|
||||
<div className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2">
|
||||
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||
Enter
|
||||
</span>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{focused && trimmedQuery && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 6, scale: 0.98 }}
|
||||
transition={{ duration: 0.14 }}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
className="absolute left-0 right-0 top-full z-50 mt-3 overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
|
||||
>
|
||||
<div className="max-h-[420px] overflow-y-auto p-2">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 px-3 py-4 text-sm text-[var(--app-muted)]">
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
|
||||
搜索中...
|
||||
</div>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<div className="px-3 py-4 text-sm text-red-500">{error}</div>
|
||||
)}
|
||||
{!loading && !error && cards.length === 0 && (
|
||||
<div className="px-3 py-4 text-sm text-[var(--app-muted)]">没有找到匹配的本地媒体</div>
|
||||
)}
|
||||
{!loading && !error && cards.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{cards.map((card) => (
|
||||
<SearchResultItem
|
||||
key={card.key}
|
||||
card={card}
|
||||
onClick={() => onFocusedChange(false)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to={`/search?q=${encodeURIComponent(trimmedQuery)}`}
|
||||
onClick={() => onFocusedChange(false)}
|
||||
className="flex items-center justify-between border-t border-[var(--app-border)] px-4 py-3 text-sm font-semibold text-brand-500 hover:bg-[var(--app-hover)]"
|
||||
>
|
||||
<span>查看全部搜索结果</span>
|
||||
<span className="text-xs text-[var(--app-muted)]">
|
||||
{total > 0 ? `${total} 个条目` : 'Enter'}
|
||||
</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchResultItem({ card, onClick }: { card: SeriesCard; onClick: () => void }) {
|
||||
return (
|
||||
<Link
|
||||
to={seriesCardLink(card)}
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-[var(--app-hover)]"
|
||||
>
|
||||
<div className="h-14 w-10 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
|
||||
{card.rep.poster_url ? (
|
||||
<img
|
||||
src={imageURL(card.rep.poster_url, card.rep.updated_at)}
|
||||
alt={card.rep.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-[var(--app-muted)]">
|
||||
<LibraryIcon size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-[var(--app-text)]">
|
||||
{card.rep.title || card.rep.original_name || '未命名媒体'}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-[var(--app-muted)]">
|
||||
{card.rep.year ? <span>{card.rep.year}</span> : null}
|
||||
<span>{card.count > 1 ? `${card.count} 集/条目` : '单条媒体'}</span>
|
||||
{card.rep.width ? <span>{card.rep.width}x{card.rep.height}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { Outlet } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { AppFooter } from './AppFooter'
|
||||
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
|
||||
import { RouteErrorBoundary } from './RouteErrorBoundary'
|
||||
import type { useLayoutSidebar } from './useLayoutSidebar'
|
||||
@@ -22,9 +21,10 @@ type LayoutMobileSidebarProps = {
|
||||
|
||||
type LayoutSidebarsProps = Omit<
|
||||
LayoutSidebarContentProps,
|
||||
'isSidebarOpen' | 'isMobileDrawerOpen' | 'openGroups' | 'isRouteIn' | 'onToggleGroup' | 'onToggleSidebar' | 'onCloseMobileDrawer'
|
||||
'isSidebarOpen' | 'isMobileDrawerOpen' | 'onToggleSidebar' | 'onCloseMobileDrawer'
|
||||
> & {
|
||||
sidebar: LayoutSidebarState
|
||||
showSidebar: boolean
|
||||
}
|
||||
|
||||
type LayoutWorkspaceProps = {
|
||||
@@ -76,26 +76,33 @@ export function LayoutMobileSidebar({ children, isOpen, onClose }: LayoutMobileS
|
||||
export function LayoutSidebars({
|
||||
sidebar,
|
||||
isAdmin,
|
||||
username,
|
||||
can,
|
||||
onLogout,
|
||||
showSidebar,
|
||||
}: LayoutSidebarsProps) {
|
||||
const content = (
|
||||
<LayoutSidebarContent
|
||||
isSidebarOpen={sidebar.isSidebarOpen}
|
||||
isMobileDrawerOpen={sidebar.isMobileDrawerOpen}
|
||||
openGroups={sidebar.openGroups}
|
||||
isAdmin={isAdmin}
|
||||
username={username}
|
||||
can={can}
|
||||
isRouteIn={sidebar.isRouteIn}
|
||||
onToggleGroup={sidebar.toggleGroup}
|
||||
onToggleSidebar={() => sidebar.setIsSidebarOpen((current) => !current)}
|
||||
onToggleSidebar={sidebar.toggleSidebar}
|
||||
onCloseMobileDrawer={() => sidebar.setIsMobileDrawerOpen(false)}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!showSidebar) {
|
||||
return (
|
||||
<>
|
||||
<LayoutMobileSidebar
|
||||
isOpen={sidebar.isMobileDrawerOpen}
|
||||
onClose={() => sidebar.setIsMobileDrawerOpen(false)}
|
||||
>
|
||||
{content}
|
||||
</LayoutMobileSidebar>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<LayoutDesktopSidebar isSidebarOpen={sidebar.isSidebarOpen}>{content}</LayoutDesktopSidebar>
|
||||
@@ -131,10 +138,4 @@ export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function LayoutFrameFooter() {
|
||||
return (
|
||||
<AppFooter className="border-t border-[var(--app-border)] bg-[var(--app-panel)] py-5 text-center text-xs text-[var(--app-muted)]" />
|
||||
)
|
||||
}
|
||||
|
||||
export { LayoutSidebarContent }
|
||||
|
||||
@@ -1,44 +1,29 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { LogOut, Menu, X } from 'lucide-react'
|
||||
import { ArrowLeft, Menu, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { LAYOUT_NAV_GROUPS, NAV_GROUP_PATHS, type LayoutNavGroup, type LayoutNavItem } from './layoutNavigation'
|
||||
import { SidebarGroup, SidebarLink } from './LayoutSidebarNav'
|
||||
import { LAYOUT_NAV_ITEMS, type LayoutNavItem } from './layoutNavigation'
|
||||
import { SidebarLink } from './LayoutSidebarNav'
|
||||
|
||||
export type LayoutSidebarContentProps = {
|
||||
isSidebarOpen: boolean
|
||||
isMobileDrawerOpen: boolean
|
||||
openGroups: Record<string, boolean>
|
||||
isAdmin: boolean
|
||||
username?: string
|
||||
can: (key: string) => boolean
|
||||
isRouteIn: (paths: string[]) => boolean
|
||||
onToggleGroup: (id: string) => void
|
||||
onToggleSidebar: () => void
|
||||
onCloseMobileDrawer: () => void
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
type VisibleLayoutNavGroup = {
|
||||
group: LayoutNavGroup
|
||||
items: LayoutNavItem[]
|
||||
}
|
||||
|
||||
export function LayoutSidebarContent({
|
||||
isSidebarOpen,
|
||||
isMobileDrawerOpen,
|
||||
openGroups,
|
||||
isAdmin,
|
||||
username,
|
||||
can,
|
||||
isRouteIn,
|
||||
onToggleGroup,
|
||||
onToggleSidebar,
|
||||
onCloseMobileDrawer,
|
||||
onLogout,
|
||||
}: LayoutSidebarContentProps) {
|
||||
const sidebarExpanded = isSidebarOpen || isMobileDrawerOpen
|
||||
const visibleGroups = visibleSidebarGroups({ isAdmin, can })
|
||||
const visibleItems = visibleSidebarItems({ isAdmin, can })
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-r border-[var(--app-border)] bg-[var(--app-panel)]">
|
||||
@@ -47,28 +32,19 @@ export function LayoutSidebarContent({
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
onCloseMobileDrawer={onCloseMobileDrawer}
|
||||
/>
|
||||
<LayoutSidebarNav
|
||||
groups={visibleGroups}
|
||||
sidebarExpanded={sidebarExpanded}
|
||||
openGroups={openGroups}
|
||||
isRouteIn={isRouteIn}
|
||||
onToggleGroup={onToggleGroup}
|
||||
/>
|
||||
<LayoutSidebarLogout sidebarExpanded={sidebarExpanded} username={username} onLogout={onLogout} />
|
||||
<LayoutSidebarNav items={visibleItems} sidebarExpanded={sidebarExpanded} />
|
||||
<LayoutSidebarHomeBack sidebarExpanded={sidebarExpanded} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function visibleSidebarGroups({
|
||||
function visibleSidebarItems({
|
||||
isAdmin,
|
||||
can,
|
||||
}: Pick<LayoutSidebarContentProps, 'isAdmin' | 'can'>): VisibleLayoutNavGroup[] {
|
||||
const isItemVisible = (item: LayoutNavItem) =>
|
||||
(!item.adminOnly || isAdmin) && (!item.permission || can(item.permission))
|
||||
return LAYOUT_NAV_GROUPS
|
||||
.filter((group) => !group.adminOnly || isAdmin)
|
||||
.map((group) => ({ group, items: group.items.filter(isItemVisible) }))
|
||||
.filter(({ items }) => items.length > 0)
|
||||
}: Pick<LayoutSidebarContentProps, 'isAdmin' | 'can'>): LayoutNavItem[] {
|
||||
return LAYOUT_NAV_ITEMS.filter(
|
||||
(item) => (!item.adminOnly || isAdmin) && (!item.permission || can(item.permission)),
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutSidebarHeader({
|
||||
@@ -84,8 +60,8 @@ function LayoutSidebarHeader({
|
||||
<div className="flex h-20 items-center justify-between border-b border-[var(--app-border)] px-6">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<img
|
||||
src="/brand/mediastationgo-logo.svg"
|
||||
alt="MediaStationGo"
|
||||
src="/brand/logo-192.png"
|
||||
alt="MMTL"
|
||||
className="h-10 w-10 shrink-0 rounded-xl object-contain shadow-sm"
|
||||
/>
|
||||
{sidebarExpanded && (
|
||||
@@ -94,7 +70,7 @@ function LayoutSidebarHeader({
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="font-display text-lg font-extrabold tracking-tight text-[var(--app-text)]"
|
||||
>
|
||||
MediaStationGo
|
||||
MMTL
|
||||
</motion.span>
|
||||
)}
|
||||
</Link>
|
||||
@@ -109,102 +85,47 @@ function LayoutSidebarHeader({
|
||||
}
|
||||
|
||||
function LayoutSidebarNav({
|
||||
groups,
|
||||
sidebarExpanded,
|
||||
openGroups,
|
||||
isRouteIn,
|
||||
onToggleGroup,
|
||||
}: {
|
||||
groups: VisibleLayoutNavGroup[]
|
||||
sidebarExpanded: boolean
|
||||
openGroups: Record<string, boolean>
|
||||
isRouteIn: (paths: string[]) => boolean
|
||||
onToggleGroup: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<nav className="flex-1 overflow-y-auto px-4 py-5 space-y-2 scrollbar-hide">
|
||||
{groups.map(({ group, items }) => (
|
||||
<LayoutSidebarNavGroup
|
||||
key={group.id}
|
||||
group={group}
|
||||
items={items}
|
||||
sidebarExpanded={sidebarExpanded}
|
||||
open={openGroups[group.id] ?? group.id === 'media'}
|
||||
active={isRouteIn(NAV_GROUP_PATHS[group.id])}
|
||||
onToggleGroup={onToggleGroup}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutSidebarNavGroup({
|
||||
group,
|
||||
items,
|
||||
sidebarExpanded,
|
||||
open,
|
||||
active,
|
||||
onToggleGroup,
|
||||
}: {
|
||||
group: LayoutNavGroup
|
||||
items: LayoutNavItem[]
|
||||
sidebarExpanded: boolean
|
||||
open: boolean
|
||||
active: boolean
|
||||
onToggleGroup: (id: string) => void
|
||||
}) {
|
||||
const GroupIcon = group.icon
|
||||
return (
|
||||
<SidebarGroup
|
||||
id={group.id}
|
||||
icon={<GroupIcon size={18} />}
|
||||
label={group.label}
|
||||
collapsed={!sidebarExpanded}
|
||||
open={open}
|
||||
active={active}
|
||||
onToggle={onToggleGroup}
|
||||
>
|
||||
<nav className="flex-1 overflow-y-auto px-4 py-5 space-y-1 scrollbar-hide">
|
||||
{items.map((item) => {
|
||||
const ItemIcon = item.icon
|
||||
return (
|
||||
<SidebarLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
icon={<ItemIcon size={16} />}
|
||||
icon={<ItemIcon size={18} />}
|
||||
label={item.label}
|
||||
end={item.end}
|
||||
child
|
||||
collapsed={!sidebarExpanded}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</SidebarGroup>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutSidebarLogout({
|
||||
sidebarExpanded,
|
||||
username,
|
||||
onLogout,
|
||||
}: {
|
||||
sidebarExpanded: boolean
|
||||
username?: string
|
||||
onLogout: () => void
|
||||
}) {
|
||||
function LayoutSidebarHomeBack({ sidebarExpanded }: { sidebarExpanded: boolean }) {
|
||||
return (
|
||||
<div className="border-t border-[var(--app-border)] bg-[var(--app-panel-soft)] p-4">
|
||||
<button
|
||||
onClick={onLogout}
|
||||
<Link
|
||||
to="/"
|
||||
className={clsx(
|
||||
'flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 w-full group/logout',
|
||||
'flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 w-full group',
|
||||
sidebarExpanded
|
||||
? 'justify-start text-[var(--app-muted)] hover:bg-[var(--app-danger-soft)] hover:text-red-500'
|
||||
: 'justify-center text-[var(--app-muted)] hover:text-red-500',
|
||||
? 'justify-start text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]'
|
||||
: 'justify-center text-[var(--app-muted)] hover:text-[var(--app-text)]',
|
||||
)}
|
||||
title={`安全登出 (${username ?? ''})`}
|
||||
title="返回系统首页"
|
||||
>
|
||||
<LogOut size={18} className="transition-transform group-hover/logout:-translate-x-0.5" />
|
||||
{sidebarExpanded && <span>安全退出</span>}
|
||||
</button>
|
||||
<ArrowLeft size={18} className="transition-transform group-hover:-translate-x-0.5" />
|
||||
{sidebarExpanded && <span>返回系统首页</span>}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -229,4 +150,4 @@ function SidebarIconButton({
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ChevronDown, Loader2, LogOut, RotateCw, Settings, User as UserIcon, UserCog } from 'lucide-react'
|
||||
import { Cast, ChevronDown, Clock, Heart, ListMusic, LogOut, Settings, UserCog } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import type { PlayProfile } from '../types'
|
||||
|
||||
type LayoutUser = {
|
||||
@@ -41,7 +39,6 @@ export function LayoutUserMenu({
|
||||
const location = useLocation()
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const lastLocationRef = useRef(`${location.pathname}${location.search}`)
|
||||
const [updating, setUpdating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined
|
||||
@@ -71,22 +68,6 @@ export function LayoutUserMenu({
|
||||
if (isOpen) onClose()
|
||||
}, [isOpen, location.pathname, location.search, onClose])
|
||||
|
||||
const applySystemUpdate = async () => {
|
||||
if (updating) return
|
||||
setUpdating(true)
|
||||
try {
|
||||
const status = await adminAPI.systemUpdateApply()
|
||||
toast.success(status.message || '系统更新任务已启动')
|
||||
onClose()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '启动系统更新失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative" data-testid="layout-user-menu">
|
||||
<button
|
||||
@@ -117,21 +98,11 @@ export function LayoutUserMenu({
|
||||
role="menu"
|
||||
className="absolute right-0 z-50 mt-3 w-56 origin-top-right rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-xl"
|
||||
>
|
||||
<UserMenuLink to="/profile" icon={<UserIcon size={16} />} label="个人基本信息" onClick={onClose} />
|
||||
{user?.role === 'admin' && (
|
||||
<UserMenuLink to="/admin" icon={<Settings size={16} />} label="管理主控制台" onClick={onClose} />
|
||||
)}
|
||||
{user?.role === 'admin' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={applySystemUpdate}
|
||||
disabled={updating}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-sm text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{updating ? <Loader2 size={16} className="animate-spin" /> : <RotateCw size={16} />}
|
||||
<span>一键更新系统</span>
|
||||
</button>
|
||||
)}
|
||||
<UserMenuLink to="/profile" icon={<Settings size={16} />} label="设置" onClick={onClose} />
|
||||
<UserMenuLink to="/favourites" icon={<Heart size={16} />} label="我的收藏" onClick={onClose} />
|
||||
<UserMenuLink to="/playlists" icon={<ListMusic size={16} />} label="播放列表" onClick={onClose} />
|
||||
<UserMenuLink to="/history" icon={<Clock size={16} />} label="观看历史" onClick={onClose} />
|
||||
<UserMenuLink to="/dlna" icon={<Cast size={16} />} label="DLNA投屏" onClick={onClose} />
|
||||
<div className="my-1.5 border-t border-[var(--app-border)]" />
|
||||
<div className="px-3 py-2">
|
||||
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
export const MediaCard = ({
|
||||
media, progress, count, rating, linkTo, onClick, actions,
|
||||
media, progress, count, rating, linkTo, onClick, actions, compact,
|
||||
}: {
|
||||
media: Media
|
||||
progress?: number
|
||||
@@ -15,6 +15,7 @@ export const MediaCard = ({
|
||||
linkTo?: string
|
||||
onClick?: () => void
|
||||
actions?: ReactNode
|
||||
compact?: boolean
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const href = linkTo ?? `/media/${media.id}`
|
||||
@@ -95,20 +96,26 @@ export const MediaCard = ({
|
||||
)}
|
||||
|
||||
{/* Premium Hover Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#111827]/90 via-[#111827]/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end p-4">
|
||||
<div className={`absolute inset-0 bg-gradient-to-t from-[#111827]/90 via-[#111827]/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end ${
|
||||
compact ? 'p-3' : 'p-4'
|
||||
}`}>
|
||||
<motion.div
|
||||
initial={{ y: 15, opacity: 0 }}
|
||||
whileHover={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-xl bg-brand-500 px-4 py-2 text-xs font-bold text-white shadow-md shadow-brand-500/20">
|
||||
<Play size={10} fill="currentColor" className="text-white" />
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-xl bg-brand-500 px-4 py-2 font-bold text-white shadow-md shadow-brand-500/20 ${
|
||||
compact ? 'py-1.5 text-[11px]' : 'text-xs py-2'
|
||||
}`}>
|
||||
<Play size={compact ? 8 : 10} fill="currentColor" className="text-white" />
|
||||
<span>立即观影</span>
|
||||
</span>
|
||||
<p className="text-[10px] text-gray-200 font-semibold line-clamp-2 leading-relaxed">
|
||||
{media.overview || "暂无简介内容"}
|
||||
</p>
|
||||
{!compact && (
|
||||
<p className="text-[10px] text-gray-200 font-semibold line-clamp-2 leading-relaxed">
|
||||
{media.overview || "暂无简介内容"}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -124,8 +131,12 @@ export const MediaCard = ({
|
||||
</div>
|
||||
|
||||
{/* Media Metadata Info */}
|
||||
<div className="space-y-1 border-t border-[var(--app-border)] bg-[var(--app-panel)] p-4">
|
||||
<p className="truncate text-sm font-bold text-[var(--app-text)] transition-colors duration-200 group-hover:text-brand-500">
|
||||
<div className={`space-y-1 border-t border-[var(--app-border)] bg-[var(--app-panel)] ${
|
||||
compact ? 'p-2' : 'p-4'
|
||||
}`}>
|
||||
<p className={`truncate font-bold text-[var(--app-text)] transition-colors duration-200 group-hover:text-brand-500 ${
|
||||
compact ? 'text-xs' : 'text-sm'
|
||||
}`}>
|
||||
{media.title}
|
||||
</p>
|
||||
<div className="flex items-center justify-between text-[11px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||
|
||||
@@ -43,7 +43,7 @@ export class RouteErrorBoundary extends Component<{ children: ReactNode }, { has
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('MediaStationGo route crashed', error, info)
|
||||
console.error('MMTL route crashed', error, info)
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Activity,
|
||||
Cast,
|
||||
Clock,
|
||||
CloudDownload,
|
||||
Compass,
|
||||
FileText,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Heart,
|
||||
Home,
|
||||
Image,
|
||||
KeySquare,
|
||||
FolderOpen,
|
||||
Library,
|
||||
ListMusic,
|
||||
Rss,
|
||||
Search,
|
||||
Settings,
|
||||
Sliders,
|
||||
Sparkles,
|
||||
User,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
|
||||
export type LayoutNavGroupID = 'media' | 'personal' | 'downloads' | 'tools' | 'system'
|
||||
|
||||
export type LayoutNavItem = {
|
||||
to: string
|
||||
label: string
|
||||
@@ -33,80 +16,10 @@ export type LayoutNavItem = {
|
||||
adminOnly?: boolean
|
||||
}
|
||||
|
||||
export type LayoutNavGroup = {
|
||||
id: LayoutNavGroupID
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
activePaths: string[]
|
||||
adminOnly?: boolean
|
||||
items: LayoutNavItem[]
|
||||
}
|
||||
|
||||
export const LAYOUT_NAV_GROUPS: LayoutNavGroup[] = [
|
||||
{
|
||||
id: 'media',
|
||||
label: '媒体浏览',
|
||||
icon: Home,
|
||||
activePaths: ['/', '/libraries', '/library', '/poster-wall', '/discover', '/search', '/dlna', '/ai'],
|
||||
items: [
|
||||
{ to: '/', label: '系统首页', icon: Home, end: true },
|
||||
{ to: '/libraries', label: '媒体库', icon: Library },
|
||||
{ to: '/poster-wall', label: '海报墙', icon: Image },
|
||||
{ to: '/discover', label: '精彩发现', icon: Compass, permission: 'can_view_discover' },
|
||||
{ to: '/search', label: '智能搜索', icon: Search, permission: 'can_use_ai' },
|
||||
{ to: '/dlna', label: 'DLNA 投屏', icon: Cast, permission: 'can_cast' },
|
||||
{ to: '/ai', label: 'AI 助理', icon: Sparkles, permission: 'can_use_ai_assistant' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'personal',
|
||||
label: '个人观影',
|
||||
icon: User,
|
||||
activePaths: ['/favourites', '/playlists', '/playlist', '/history', '/profile', '/play-profiles'],
|
||||
items: [
|
||||
{ to: '/favourites', label: '我的收藏', icon: Heart },
|
||||
{ to: '/playlists', label: '播放列表', icon: ListMusic },
|
||||
{ to: '/history', label: '观看历史', icon: Clock },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'downloads',
|
||||
label: '下载与订阅',
|
||||
icon: CloudDownload,
|
||||
activePaths: ['/downloads', '/download-clients', '/subscriptions', '/site-search', '/sites', '/tasks'],
|
||||
items: [
|
||||
{ to: '/downloads', label: '下载中心', icon: CloudDownload, permission: 'can_manage_downloads' },
|
||||
{ to: '/subscriptions', label: '订阅管理', icon: Rss, permission: 'can_manage_subscriptions' },
|
||||
{ to: '/sites', label: '站点管理', icon: Globe, permission: 'can_manage_sites' },
|
||||
{ to: '/tasks', label: '实时任务', icon: Activity },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
label: '文件与自动化',
|
||||
icon: HardDrive,
|
||||
activePaths: ['/storage', '/storage-config', '/files', '/strm', '/duplicates', '/scheduler', '/recycle', '/stats'],
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ to: '/storage', label: '存储与文件', icon: HardDrive },
|
||||
{ to: '/strm', label: 'STRM 管理', icon: FileText },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
label: '系统配置',
|
||||
icon: Settings,
|
||||
activePaths: ['/admin', '/sites', '/notify-channels', '/license', '/settings', '/assistant'],
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ to: '/admin', label: '媒体与用户', icon: Settings },
|
||||
{ to: '/settings', label: '系统设置', icon: Sliders },
|
||||
{ to: '/license', label: '授权许可', icon: KeySquare },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const NAV_GROUP_PATHS: Record<LayoutNavGroupID, string[]> = LAYOUT_NAV_GROUPS.reduce(
|
||||
(paths, group) => ({ ...paths, [group.id]: group.activePaths }),
|
||||
{} as Record<LayoutNavGroupID, string[]>,
|
||||
)
|
||||
export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
|
||||
{ to: '/profile', label: '个人资料', icon: User },
|
||||
{ to: '/libraries?from=admin', label: '媒体库', icon: Library },
|
||||
{ to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
|
||||
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
|
||||
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { AdminLibraryPanel } from '../pages/AdminLibraryPanel'
|
||||
|
||||
export function openManageLibrariesDialog(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const root = createRoot(host)
|
||||
|
||||
const close = () => {
|
||||
root.unmount()
|
||||
host.remove()
|
||||
resolve()
|
||||
}
|
||||
|
||||
root.render(<ManageLibrariesDialog onClose={close} />)
|
||||
})
|
||||
}
|
||||
|
||||
function ManageLibrariesDialog({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="flex h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<h3 className="font-display text-lg font-bold text-ink-600">管理媒体库</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-ink-50 transition hover:bg-gray-100 hover:text-ink-600"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<AdminLibraryPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react'
|
||||
import type { NavigateFunction } from 'react-router-dom'
|
||||
|
||||
import { mediaAPI } from '../api/library'
|
||||
import type { Media } from '../types'
|
||||
import { groupSeries } from '../utils/groupSeries'
|
||||
|
||||
type UseLayoutSearchOptions = {
|
||||
pathname: string
|
||||
locationSearch: string
|
||||
navigate: NavigateFunction
|
||||
}
|
||||
|
||||
export function useLayoutSearch({ pathname, locationSearch, navigate }: UseLayoutSearchOptions) {
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
const searchSeq = useRef(0)
|
||||
const cards = useMemo(() => groupSeries(items).slice(0, 8), [items])
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname === '/search') {
|
||||
setQuery(new URLSearchParams(locationSearch).get('q') ?? '')
|
||||
}
|
||||
}, [pathname, locationSearch])
|
||||
|
||||
useEffect(() => {
|
||||
const trimmedQuery = query.trim()
|
||||
const seq = ++searchSeq.current
|
||||
if (!focused || !trimmedQuery) {
|
||||
setItems([])
|
||||
setTotal(0)
|
||||
setError('')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError('')
|
||||
const timer = window.setTimeout(() => {
|
||||
mediaAPI
|
||||
.search(trimmedQuery, 24)
|
||||
.then((data) => {
|
||||
if (seq !== searchSeq.current) return
|
||||
setItems(data.items ?? [])
|
||||
setTotal(data.total ?? (data.items ?? []).length)
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq !== searchSeq.current) return
|
||||
setItems([])
|
||||
setTotal(0)
|
||||
setError('搜索失败,请稍后再试')
|
||||
})
|
||||
.finally(() => {
|
||||
if (seq === searchSeq.current) setLoading(false)
|
||||
})
|
||||
}, 220)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [focused, query])
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
const trimmedQuery = query.trim()
|
||||
if (trimmedQuery) {
|
||||
navigate(`/search?q=${encodeURIComponent(trimmedQuery)}`)
|
||||
setFocused(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cards,
|
||||
error,
|
||||
focused,
|
||||
loading,
|
||||
query,
|
||||
total,
|
||||
setFocused,
|
||||
setQuery,
|
||||
submit,
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { NAV_GROUP_PATHS } from './layoutNavigation'
|
||||
|
||||
export function useLayoutSidebar(pathname: string) {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
|
||||
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({ media: true })
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
@@ -20,31 +17,15 @@ export function useLayoutSidebar(pathname: string) {
|
||||
setIsMobileDrawerOpen(false)
|
||||
}, [pathname])
|
||||
|
||||
const isRouteIn = useCallback(
|
||||
(paths: string[]) =>
|
||||
paths.some((path) => (path === '/' ? pathname === '/' : pathname.startsWith(path))),
|
||||
[pathname],
|
||||
)
|
||||
|
||||
const toggleGroup = useCallback(
|
||||
(key: string) => setOpenGroups((current) => ({ ...current, [key]: !current[key] })),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const active = Object.entries(NAV_GROUP_PATHS).find(([, paths]) => isRouteIn(paths))?.[0]
|
||||
if (active) {
|
||||
setOpenGroups((current) => (current[active] ? current : { ...current, [active]: true }))
|
||||
}
|
||||
}, [isRouteIn])
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setIsSidebarOpen((current) => !current)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isMobileDrawerOpen,
|
||||
isRouteIn,
|
||||
isSidebarOpen,
|
||||
openGroups,
|
||||
setIsMobileDrawerOpen,
|
||||
setIsSidebarOpen,
|
||||
toggleGroup,
|
||||
toggleSidebar,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { ExternalMediaResult } from '../api/ai'
|
||||
import type { DiscoverItem } from '../api/discover'
|
||||
import { imageURL } from '../api/client'
|
||||
import { DiscoverDetailModal } from './DiscoverDetailModal'
|
||||
|
||||
type AIAssistantExternalResultsProps = {
|
||||
items: ExternalMediaResult[]
|
||||
}
|
||||
|
||||
export function AIAssistantExternalResults({ items }: AIAssistantExternalResultsProps) {
|
||||
const [activeItem, setActiveItem] = useState<ExternalMediaResult | null>(null)
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => {
|
||||
const keyword = item.subscribe_keyword || item.title
|
||||
const key = `${item.source}:${keyword}`
|
||||
return (
|
||||
<article
|
||||
key={key}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setActiveItem(item)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') setActiveItem(item)
|
||||
}}
|
||||
className="cursor-pointer rounded-2xl border border-gray-200 bg-gray-50 p-3 transition hover:-translate-y-0.5 hover:border-primary-300"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="h-24 w-16 shrink-0 overflow-hidden rounded-xl bg-white">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={imageURL(item.poster_url)}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex flex-wrap gap-2 text-[10px] uppercase text-brand-500">
|
||||
<span>{item.source}</span>
|
||||
{item.media_type && <span>{item.media_type}</span>}
|
||||
{item.year ? <span>{item.year}</span> : null}
|
||||
</div>
|
||||
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-ink-50">
|
||||
{item.overview || `订阅关键词:${keyword}`}
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-semibold text-brand-500">详情 / 订阅设置</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{activeItem && (
|
||||
<DiscoverDetailModal
|
||||
item={activeItem as unknown as DiscoverItem}
|
||||
onClose={() => setActiveItem(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
export type AIAssistantStatus = {
|
||||
enabled: boolean
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
type AIAssistantHeaderProps = {
|
||||
status: AIAssistantStatus | null
|
||||
}
|
||||
|
||||
export function AIAssistantHeader({ status }: AIAssistantHeaderProps) {
|
||||
return (
|
||||
<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-ink-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">AI 助手</h1>
|
||||
<p className="text-sm text-ink-50">自然语言搜索 · 基于观影历史的智能推荐</p>
|
||||
</div>
|
||||
</div>
|
||||
{status && (
|
||||
<div className="text-xs text-ink-50">
|
||||
<span
|
||||
className={
|
||||
'mr-2 inline-block h-2 w-2 rounded-full ' +
|
||||
(status.enabled ? 'bg-emerald-400' : 'bg-sand-500/30')
|
||||
}
|
||||
/>
|
||||
{status.enabled
|
||||
? `已连接 · ${status.provider}${status.model ? ' / ' + status.model : ''}`
|
||||
: '未配置 AI 服务,使用本地规则解析'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { AIAssistantHeader } from './AIAssistantHeader'
|
||||
import { AIAssistantRecommendationsSection } from './AIAssistantRecommendationsSection'
|
||||
import { AIAssistantSearchSection } from './AIAssistantSearchSection'
|
||||
import { useAIAssistantPage } from './useAIAssistantPage'
|
||||
|
||||
// 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 assistant = useAIAssistantPage()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AIAssistantHeader status={assistant.status} />
|
||||
|
||||
<AIAssistantSearchSection
|
||||
query={assistant.query}
|
||||
searching={assistant.searching}
|
||||
intent={assistant.intent}
|
||||
items={assistant.items}
|
||||
localCards={assistant.localCards}
|
||||
externalItems={assistant.externalItems}
|
||||
onSearch={assistant.onSearch}
|
||||
setQuery={assistant.setQuery}
|
||||
/>
|
||||
|
||||
<AIAssistantRecommendationsSection
|
||||
recs={assistant.recs}
|
||||
recommending={assistant.recommending}
|
||||
onRecommend={assistant.onRecommend}
|
||||
/>
|
||||
|
||||
{/* Decorative footer (mirrors the Vue page hint that AI runs locally). */}
|
||||
{!assistant.status?.enabled && (
|
||||
<p className="text-xs text-sand-500">
|
||||
提示: 当前未配置外部 AI Provider,系统将使用本地规则引擎解析查询。
|
||||
管理员可在 <Link to="/admin?tab=api" className="text-brand-500">API 配置</Link>{' '}
|
||||
中接入 OpenAI / DeepSeek 等服务以获得更好效果。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Loader2, Search, Wand2 } from 'lucide-react'
|
||||
|
||||
type AIAssistantRecommendationsSectionProps = {
|
||||
recs: string[] | null
|
||||
recommending: boolean
|
||||
onRecommend: () => void
|
||||
}
|
||||
|
||||
export function AIAssistantRecommendationsSection({
|
||||
recs,
|
||||
recommending,
|
||||
onRecommend,
|
||||
}: AIAssistantRecommendationsSectionProps) {
|
||||
return (
|
||||
<section className="glass-panel space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">为你推荐</h2>
|
||||
<button onClick={onRecommend} disabled={recommending} className="neon-button">
|
||||
{recommending ? <Loader2 size={16} className="animate-spin" /> : <Wand2 size={16} />}
|
||||
生成推荐
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-sand-500">推荐基于你的最近观看历史。点击标题在媒体库中查找。</p>
|
||||
|
||||
{recs && recs.length > 0 && (
|
||||
<ul className="grid gap-2 sm:grid-cols-2">
|
||||
{recs.map((title, index) => (
|
||||
<li key={index}>
|
||||
<Link
|
||||
to={`/search?q=${encodeURIComponent(title)}`}
|
||||
className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-ink-200 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
<Search size={14} className="shrink-0 opacity-60" />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{recs && recs.length === 0 && (
|
||||
<p className="text-sm text-ink-50">还没有推荐结果 — 先去看几部片子,我再给你挑。</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import { Loader2, Search } from 'lucide-react'
|
||||
|
||||
import type { ExternalMediaResult, SearchIntent } from '../api/ai'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { Media } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { seriesCardLink } from '../utils/groupSeries'
|
||||
import { AIAssistantExternalResults } from './AIAssistantExternalResults'
|
||||
|
||||
type AIAssistantSearchSectionProps = {
|
||||
query: string
|
||||
searching: boolean
|
||||
intent: SearchIntent | null
|
||||
items: Media[]
|
||||
localCards: SeriesCard[]
|
||||
externalItems: ExternalMediaResult[]
|
||||
onSearch: (event: FormEvent) => void
|
||||
setQuery: (query: string) => void
|
||||
}
|
||||
|
||||
const quickHints = [
|
||||
'2023 年的科幻电影',
|
||||
'评分高的动漫',
|
||||
'最近添加的纪录片',
|
||||
'中文剧集',
|
||||
]
|
||||
|
||||
export function AIAssistantSearchSection({
|
||||
query,
|
||||
searching,
|
||||
intent,
|
||||
items,
|
||||
localCards,
|
||||
externalItems,
|
||||
onSearch,
|
||||
setQuery,
|
||||
}: AIAssistantSearchSectionProps) {
|
||||
return (
|
||||
<section className="glass-panel space-y-4">
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">智能搜索</h2>
|
||||
<form onSubmit={onSearch} className="flex flex-wrap 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((hint) => (
|
||||
<button
|
||||
key={hint}
|
||||
onClick={() => setQuery(hint)}
|
||||
className="rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
{hint}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SearchIntentSummary intent={intent} />
|
||||
<LocalMediaResults localCards={localCards} itemCount={items.length} />
|
||||
<AIAssistantExternalResults items={externalItems} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchIntentSummary({ intent }: { intent: SearchIntent | null }) {
|
||||
if (!intent) return null
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-xs text-ink-100">
|
||||
<div className="mb-1 font-semibold text-ink-200">解析结果</div>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1">
|
||||
<span>
|
||||
查询: <span className="text-brand-500">{intent.query || '—'}</span>
|
||||
</span>
|
||||
{intent.year !== undefined && intent.year > 0 && (
|
||||
<span>
|
||||
年份: <span className="text-brand-500">{intent.year}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.genre && (
|
||||
<span>
|
||||
类型: <span className="text-brand-500">{intent.genre}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.type && (
|
||||
<span>
|
||||
分类: <span className="text-brand-500">{intent.type}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.sort && (
|
||||
<span>
|
||||
排序: <span className="text-brand-500">{intent.sort}</span>
|
||||
</span>
|
||||
)}
|
||||
{intent.language && (
|
||||
<span>
|
||||
语言: <span className="text-brand-500">{intent.language}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LocalMediaResults({
|
||||
localCards,
|
||||
itemCount,
|
||||
}: {
|
||||
localCards: SeriesCard[]
|
||||
itemCount: number
|
||||
}) {
|
||||
if (localCards.length === 0) return null
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-semibold text-ink-100">
|
||||
本地媒体库 · {localCards.length} 个合集 / {itemCount} 个条目
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{localCards.map((card) => (
|
||||
<MediaCard
|
||||
key={card.key}
|
||||
media={card.rep}
|
||||
count={card.count}
|
||||
linkTo={seriesCardLink(card)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { AdminLibraryCreateForm } from './AdminLibraryPanelSections'
|
||||
import { AdminLibraryTable } from './AdminLibraryTable'
|
||||
import { LibraryStorageStats } from './LibraryStorageStats'
|
||||
import { useAdminLibraryPanel } from './useAdminLibraryPanel'
|
||||
|
||||
export function AdminLibraryPanel() {
|
||||
const { libs, createForm, editableRoots, rootActions, libraryActions } = useAdminLibraryPanel()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<AdminLibraryCreateForm
|
||||
name={createForm.name}
|
||||
type={createForm.type}
|
||||
@@ -33,6 +34,7 @@ export function AdminLibraryPanel() {
|
||||
onAddLibraryRoot={libraryActions.addLibraryRoot}
|
||||
onEditLibraryCover={libraryActions.editLibraryCover}
|
||||
/>
|
||||
<LibraryStorageStats />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+11
-61
@@ -1,71 +1,21 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Users } from 'lucide-react'
|
||||
|
||||
import { APIConfigsPanel } from '../components/APIConfigsPanel'
|
||||
import { ManagementShortcuts } from '../components/ManagementShortcuts'
|
||||
import { AdminLibraryPanel } from './AdminLibraryPanel'
|
||||
import { AdminUsersPanel } from './AdminUsersPanel'
|
||||
|
||||
type AdminTab = 'library' | 'users' | 'api'
|
||||
|
||||
function parseAdminTab(value: string | null): AdminTab {
|
||||
if (value === 'users' || value === 'api') return value
|
||||
return 'library'
|
||||
}
|
||||
|
||||
export function AdminPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [tab, setTab] = useState<AdminTab>(() => parseAdminTab(searchParams.get('tab')))
|
||||
const tabs = [
|
||||
{ key: 'library' as const, label: '媒体库' },
|
||||
{ key: 'users' as const, label: '用户' },
|
||||
{ key: 'api' as const, label: '外部API' },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
setTab(parseAdminTab(searchParams.get('tab')))
|
||||
}, [searchParams])
|
||||
|
||||
const selectTab = (next: AdminTab) => {
|
||||
setTab(next)
|
||||
setSearchParams(next === 'library' ? {} : { tab: next })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">管理后台</h1>
|
||||
<ManagementShortcuts
|
||||
title="统一管理入口"
|
||||
description="侧栏保持精简,完整管理能力统一从这里进入。"
|
||||
items={[
|
||||
{ to: '/sites', title: '站点管理', description: '维护 PT 站点、认证方式和检索配置', group: '站点与下载' },
|
||||
{ to: '/download-clients', title: '下载器管理', description: '配置 qBittorrent 等下载器连接', badge: '下载', group: '站点与下载' },
|
||||
{ to: '/files', title: '手动整理', description: '从下载目录选择文件夹并整理入库', group: '文件与入库' },
|
||||
{ to: '/storage', title: '存储与文件', description: '查看占用、清理重复项和管理文件', group: '文件与入库' },
|
||||
{ to: '/notify-channels', title: '通知渠道', description: '配置 Bot、Webhook、邮件等通知出口', group: '系统运维' },
|
||||
{ to: '/assistant', title: 'AI 会话', description: '查看和接管后台 AI 操作记录', group: '系统运维' },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2 border-b border-gray-200">
|
||||
{tabs.map((k) => (
|
||||
<button
|
||||
key={k.key}
|
||||
onClick={() => selectTab(k.key)}
|
||||
className={
|
||||
'border-b-2 px-4 py-2 text-sm transition ' +
|
||||
(tab === k.key
|
||||
? 'border-primary-400 text-brand-500'
|
||||
: 'border-transparent text-ink-50 hover:text-white')
|
||||
}
|
||||
>
|
||||
{k.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<header className="flex items-center gap-3">
|
||||
<Users className="h-6 w-6 text-brand-500" />
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">用户管理</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
管理系统用户账号、角色权限以及密码重置与启禁用操作。
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{tab === 'library' && <AdminLibraryPanel />}
|
||||
{tab === 'users' && <AdminUsersPanel />}
|
||||
{tab === 'api' && <APIConfigsPanel />}
|
||||
<AdminUsersPanel />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import { licenseAPI, type LicenseStatus } from '../api/license'
|
||||
import type { User } from '../types'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import { requestPassword } from '../components/requestPassword'
|
||||
@@ -11,19 +10,13 @@ import { AdminUsersTable } from './AdminUsersTable'
|
||||
|
||||
export function AdminUsersPanel() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [licenseStatus, setLicenseStatus] = useState<LicenseStatus | null>(null)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [editingID, setEditingID] = useState<string | null>(null)
|
||||
const [editingUsername, setEditingUsername] = useState('')
|
||||
const [resettingPasswordID, setResettingPasswordID] = useState<string | null>(null)
|
||||
const refresh = async () => {
|
||||
const [nextUsers, nextLicense] = await Promise.all([
|
||||
adminAPI.listUsers(),
|
||||
licenseAPI.status().catch(() => null),
|
||||
])
|
||||
setUsers(nextUsers)
|
||||
setLicenseStatus(nextLicense)
|
||||
setUsers(await adminAPI.listUsers())
|
||||
}
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
@@ -31,12 +24,9 @@ export function AdminUsersPanel() {
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const unlimitedUsers =
|
||||
licenseStatus?.active === true &&
|
||||
(licenseStatus.unlimited_users === true || licenseStatus.max_users == null)
|
||||
const maxUsers = unlimitedUsers ? null : (licenseStatus?.max_users ?? 20)
|
||||
const userLimitReached = maxUsers != null && users.length >= maxUsers
|
||||
const userLimitLabel = unlimitedUsers ? '不限制' : String(maxUsers)
|
||||
const maxUsers = 20
|
||||
const userLimitReached = users.length >= maxUsers
|
||||
const userLimitLabel = String(maxUsers)
|
||||
|
||||
const handleCreate = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { FormEvent, useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
assistantAPI,
|
||||
type AssistantMessage,
|
||||
type AssistantSession,
|
||||
type SessionView,
|
||||
} from '../api/assistant'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import { AssistantConversationPane, AssistantSessionsSidebar } from './AssistantChatSections'
|
||||
|
||||
// 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 (!(await confirmAction({ title: '删除会话', message: '删除此会话?', confirmText: '删除' }))) 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">
|
||||
<AssistantSessionsSidebar
|
||||
sessions={sessions}
|
||||
activeSessionId={active?.session.id}
|
||||
loading={loading}
|
||||
onNew={onNew}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
<AssistantConversationPane
|
||||
active={active}
|
||||
draft={draft}
|
||||
sending={sending}
|
||||
messagesEndRef={messagesEndRef}
|
||||
onDraftChange={setDraft}
|
||||
onSend={onSend}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import type { FormEvent, RefObject } from 'react'
|
||||
import { Loader2, MessageSquare, Plus, Send, Trash2 } from 'lucide-react'
|
||||
|
||||
import type { AssistantMessage, AssistantSession, SessionView } from '../api/assistant'
|
||||
|
||||
export function AssistantSessionsSidebar({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
loading,
|
||||
onNew,
|
||||
onSelect,
|
||||
onDelete,
|
||||
}: {
|
||||
sessions: AssistantSession[]
|
||||
activeSessionId?: string
|
||||
loading: boolean
|
||||
onNew: () => void
|
||||
onSelect: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<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-ink-600">会话</h2>
|
||||
<button onClick={onNew} className="neon-button !px-2 !py-1 text-xs">
|
||||
<Plus size={12} /> 新建
|
||||
</button>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="flex justify-center py-6 text-ink-50">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<ul className="flex-1 space-y-1 overflow-y-auto pr-1">
|
||||
{sessions.map((session) => (
|
||||
<li
|
||||
key={session.id}
|
||||
className={
|
||||
'group flex items-center gap-2 rounded px-2 py-2 text-sm cursor-pointer ' +
|
||||
(activeSessionId === session.id
|
||||
? 'bg-primary-400/10 text-brand-500'
|
||||
: 'text-ink-100 hover:bg-gray-50 hover:text-white')
|
||||
}
|
||||
onClick={() => onSelect(session.id)}
|
||||
>
|
||||
<MessageSquare size={14} className="shrink-0" />
|
||||
<span className="flex-1 truncate">{session.title || '未命名'}</span>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onDelete(session.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-sand-500">暂无会话</li>
|
||||
)}
|
||||
</ul>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export function AssistantConversationPane({
|
||||
active,
|
||||
draft,
|
||||
sending,
|
||||
messagesEndRef,
|
||||
onDraftChange,
|
||||
onSend,
|
||||
}: {
|
||||
active: SessionView | null
|
||||
draft: string
|
||||
sending: boolean
|
||||
messagesEndRef: RefObject<HTMLDivElement>
|
||||
onDraftChange: (value: string) => void
|
||||
onSend: (event: FormEvent<HTMLFormElement>) => void
|
||||
}) {
|
||||
return (
|
||||
<section className="glass-panel flex flex-col overflow-hidden">
|
||||
{!active && (
|
||||
<div className="m-auto text-center text-ink-50">
|
||||
<MessageSquare size={32} className="mx-auto mb-2 opacity-50" />
|
||||
<p>选择或创建一个会话开始对话</p>
|
||||
</div>
|
||||
)}
|
||||
{active && (
|
||||
<>
|
||||
<div className="mb-3 border-b border-gray-200 pb-2">
|
||||
<h2 className="font-display text-base font-semibold text-ink-600">
|
||||
{active.session.title || '未命名'}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex-1 space-y-3 overflow-y-auto pr-2">
|
||||
{active.messages.length === 0 && (
|
||||
<p className="text-sm text-sand-500">说点什么开始对话…</p>
|
||||
)}
|
||||
{active.messages.map((message) => (
|
||||
<Bubble key={message.id} message={message} />
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<form onSubmit={onSend} className="mt-3 flex gap-2 border-t border-gray-200 pt-3">
|
||||
<input
|
||||
className="input-base flex-1"
|
||||
placeholder="发送消息…"
|
||||
value={draft}
|
||||
onChange={(event) => onDraftChange(event.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>
|
||||
)
|
||||
}
|
||||
|
||||
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-gray-50 text-ink-200')
|
||||
}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { StorageType } from '../api/storage_config'
|
||||
import { CloudBrowserToolbar } from './CloudBrowserToolbar'
|
||||
import { CloudEntryList } from './CloudEntryList'
|
||||
import { CloudMountList } from './CloudMountList'
|
||||
import { CloudScanPanel } from './CloudScanPanel'
|
||||
import { useCloudBrowser } from './useCloudBrowser'
|
||||
|
||||
// Lists cloud directories and imports a file as a 302-backed media.
|
||||
export function CloudBrowser({ type, enabled = true }: { type: StorageType; enabled?: boolean }) {
|
||||
const browser = useCloudBrowser(type, enabled)
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] p-3">
|
||||
<CloudScanPanel
|
||||
scanBusy={browser.scanBusy}
|
||||
cancelBusy={browser.cancelBusy}
|
||||
scanStatuses={browser.scanStatuses}
|
||||
onScanAll={browser.scanAllCloudLibraries}
|
||||
onCancelScans={browser.cancelCloudScans}
|
||||
/>
|
||||
<CloudMountList mounts={browser.mounts} onRemove={browser.removeMount} />
|
||||
<CloudBrowserToolbar
|
||||
stack={browser.stack}
|
||||
mountMediaType={browser.mountMediaType}
|
||||
mounting={browser.mounting}
|
||||
batchMounting={browser.batchMounting}
|
||||
loading={browser.loading}
|
||||
hasDirectories={browser.hasDirectories}
|
||||
onGoTo={browser.goTo}
|
||||
onGoUp={browser.goUp}
|
||||
onCreateFolder={browser.createFolder}
|
||||
onMediaTypeChange={browser.setMountMediaType}
|
||||
onMountCurrent={browser.mountCurrent}
|
||||
onMountVisibleDirectories={browser.mountVisibleDirectories}
|
||||
/>
|
||||
<CloudEntryList
|
||||
loading={browser.loading}
|
||||
error={browser.error}
|
||||
items={browser.items}
|
||||
onEnter={browser.enter}
|
||||
onImport={browser.doImport}
|
||||
onRename={browser.renameFolder}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { ArrowUp, FolderPlus } from 'lucide-react'
|
||||
|
||||
interface CloudBrowserToolbarProps {
|
||||
stack: { id: string; name: string }[]
|
||||
mountMediaType: string
|
||||
mounting: boolean
|
||||
batchMounting: boolean
|
||||
loading: boolean
|
||||
hasDirectories: boolean
|
||||
onGoTo: (index: number) => void
|
||||
onGoUp: () => void
|
||||
onCreateFolder: () => void
|
||||
onMediaTypeChange: (value: string) => void
|
||||
onMountCurrent: () => void
|
||||
onMountVisibleDirectories: () => void
|
||||
}
|
||||
|
||||
export function CloudBrowserToolbar({
|
||||
stack,
|
||||
mountMediaType,
|
||||
mounting,
|
||||
batchMounting,
|
||||
loading,
|
||||
hasDirectories,
|
||||
onGoTo,
|
||||
onGoUp,
|
||||
onCreateFolder,
|
||||
onMediaTypeChange,
|
||||
onMountCurrent,
|
||||
onMountVisibleDirectories,
|
||||
}: CloudBrowserToolbarProps) {
|
||||
return (
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1 text-xs text-[var(--app-muted)]">
|
||||
<span className="text-[var(--app-subtle)]">网盘资源:</span>
|
||||
{stack.map((item, index) => (
|
||||
<span key={index}>
|
||||
<button type="button" className="hover:text-brand-500" onClick={() => onGoTo(index)}>
|
||||
{item.name}
|
||||
</button>
|
||||
{index < stack.length - 1 && <span className="mx-1">/</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="basis-full text-xs text-[var(--app-muted)]">
|
||||
挂载后不会复制网盘文件;后台会递归读取该目录里的子文件夹和媒体文件,扫描到的影片会自动加入对应媒体库。小目录通常几十秒,大目录取决于网盘接口速度。
|
||||
如果已有同名同类型媒体库,会在首页和 Emby/SenPlayer 中自动归并显示。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border border-[var(--app-border)] px-2 py-0.5 text-xs text-[var(--app-subtle)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={loading || stack.length <= 1}
|
||||
onClick={onGoUp}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
返回上级
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border border-[var(--app-border)] px-2 py-0.5 text-xs text-[var(--app-subtle)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={loading}
|
||||
onClick={onCreateFolder}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
新建文件夹
|
||||
</button>
|
||||
<select
|
||||
className="rounded border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-0.5 text-xs text-[var(--app-subtle)]"
|
||||
value={mountMediaType}
|
||||
onChange={(event) => onMediaTypeChange(event.target.value)}
|
||||
>
|
||||
<option value="auto">自动识别</option>
|
||||
<option value="movie">电影</option>
|
||||
<option value="tv">剧集</option>
|
||||
<option value="anime">动漫</option>
|
||||
<option value="variety">综艺</option>
|
||||
<option value="adult">成人</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-brand-400/40 px-2 py-0.5 text-xs text-brand-500 hover:bg-brand-400/10 disabled:opacity-50"
|
||||
disabled={mounting || batchMounting || loading}
|
||||
onClick={onMountCurrent}
|
||||
>
|
||||
{mounting ? '挂载中…' : '挂载当前目录并归并到媒体库'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-blue-300/70 px-2 py-0.5 text-xs text-blue-500 hover:bg-blue-500/10 disabled:opacity-50"
|
||||
disabled={mounting || batchMounting || loading || !hasDirectories}
|
||||
onClick={onMountVisibleDirectories}
|
||||
>
|
||||
{batchMounting ? '批量挂载中…' : '一键挂载当前目录下所有文件夹'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { FileVideo, Folder, Loader2, Pencil } from 'lucide-react'
|
||||
|
||||
import type { CloudEntry } from '../api/storage_config'
|
||||
|
||||
interface CloudEntryListProps {
|
||||
loading: boolean
|
||||
error: string
|
||||
items: CloudEntry[]
|
||||
onEnter: (entry: CloudEntry) => void
|
||||
onImport: (entry: CloudEntry) => void
|
||||
onRename: (entry: CloudEntry) => void
|
||||
}
|
||||
|
||||
export function CloudEntryList({ loading, error, items, onEnter, onImport, onRename }: CloudEntryListProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-4 text-[var(--app-muted)]">
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) return <p className="py-2 text-sm text-red-400">{error}</p>
|
||||
if (items.length === 0) return <p className="py-2 text-sm text-[var(--app-muted)]">该目录为空</p>
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-[var(--app-border)]">
|
||||
{items.map((entry) => (
|
||||
<li key={entry.id} className="flex items-center gap-2 py-1.5 text-sm">
|
||||
{entry.is_dir ? <Folder size={15} className="text-amber-400" /> : <FileVideo size={15} className="text-blue-300" />}
|
||||
{entry.is_dir ? (
|
||||
<>
|
||||
<button type="button" className="flex-1 text-left text-[var(--app-subtle)] hover:text-brand-500" onClick={() => onEnter(entry)}>
|
||||
{entry.name}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="重命名文件夹"
|
||||
className="rounded border border-[var(--app-border)] p-1 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
onClick={() => onRename(entry)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 truncate text-[var(--app-subtle)]">{entry.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-[var(--app-border)] px-2 py-0.5 text-xs text-[var(--app-subtle)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
onClick={() => onImport(entry)}
|
||||
>
|
||||
导入
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Trash2 } from 'lucide-react'
|
||||
|
||||
import type { Library } from '../types'
|
||||
import { cloudLibraryLabel } from './storageConfigModel'
|
||||
|
||||
interface CloudMountListProps {
|
||||
mounts: Library[]
|
||||
onRemove: (library: Library) => void
|
||||
}
|
||||
|
||||
export function CloudMountList({ mounts, onRemove }: CloudMountListProps) {
|
||||
if (mounts.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mb-3 rounded border border-blue-300/30 bg-blue-500/10 p-2">
|
||||
<div className="mb-1 text-xs font-semibold text-[var(--app-subtle)]">已挂载目录</div>
|
||||
<div className="space-y-1">
|
||||
{mounts.map((lib) => (
|
||||
<div key={lib.id} className="flex items-center gap-2 rounded border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 text-xs">
|
||||
<span className="min-w-0 flex-1 truncate text-[var(--app-subtle)]">
|
||||
{lib.name} · {cloudLibraryLabel(lib.path)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-red-300/60 px-1.5 py-0.5 text-red-500 hover:bg-[var(--app-danger-soft)]"
|
||||
onClick={() => onRemove(lib)}
|
||||
title="移除挂载"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Loader2, PauseCircle, RefreshCw } from 'lucide-react'
|
||||
|
||||
import type { CloudScanStatus } from '../api/storage_config'
|
||||
|
||||
interface CloudScanPanelProps {
|
||||
scanBusy: boolean
|
||||
cancelBusy: boolean
|
||||
scanStatuses: CloudScanStatus[]
|
||||
onScanAll: () => void
|
||||
onCancelScans: () => void
|
||||
}
|
||||
|
||||
export function CloudScanPanel({
|
||||
scanBusy,
|
||||
cancelBusy,
|
||||
scanStatuses,
|
||||
onScanAll,
|
||||
onCancelScans,
|
||||
}: CloudScanPanelProps) {
|
||||
return (
|
||||
<div className="mb-3 rounded border border-emerald-300/30 bg-emerald-500/10 p-2">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-[var(--app-subtle)]">网盘媒体库扫描</div>
|
||||
<p className="text-xs text-[var(--app-muted)]">
|
||||
只需在系统设置填写公开域名,扫描会自动为网盘媒体生成 STRM/302 播放入口;中断后再次扫描会去重补齐。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-emerald-300/70 px-2 py-1 text-xs text-emerald-600 hover:bg-emerald-500/10"
|
||||
disabled={scanBusy}
|
||||
onClick={onScanAll}
|
||||
>
|
||||
{scanBusy ? <Loader2 size={13} className="inline animate-spin" /> : <RefreshCw size={13} className="inline" />}
|
||||
{' '}一键扫描全部网盘库
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-red-300/60 px-2 py-1 text-xs text-red-500 hover:bg-[var(--app-danger-soft)]"
|
||||
disabled={cancelBusy}
|
||||
onClick={onCancelScans}
|
||||
>
|
||||
{cancelBusy ? <Loader2 size={13} className="inline animate-spin" /> : <PauseCircle size={13} className="inline" />}
|
||||
{' '}中断当前网盘扫描
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{scanStatuses.length > 0 && (
|
||||
<div className="grid gap-1 text-xs text-[var(--app-muted)] md:grid-cols-2">
|
||||
{scanStatuses.slice(0, 6).map((item) => (
|
||||
<div key={item.library_id} className="rounded border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1">
|
||||
<span className="font-mono text-[var(--app-subtle)]">{item.state}</span>
|
||||
{' · '}
|
||||
{item.provider}
|
||||
{' · 目录 '}
|
||||
{item.dirs}
|
||||
{' · 发现 '}
|
||||
{item.discovered}
|
||||
{' · 入库 '}
|
||||
{item.added + item.updated}
|
||||
{item.error ? <span className="text-red-500"> · {item.error}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Info } from 'lucide-react'
|
||||
|
||||
import type { DiscoverItem } from '../api/discover'
|
||||
import { imageURL } from '../api/client'
|
||||
import { discoverItemSource } from './discoverPageModel'
|
||||
|
||||
export function ContentRow({
|
||||
title,
|
||||
items,
|
||||
page = 1,
|
||||
canNext = false,
|
||||
imageVersion,
|
||||
refreshImageVersion,
|
||||
onPageChange,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string
|
||||
items: DiscoverItem[]
|
||||
page?: number
|
||||
canNext?: boolean
|
||||
imageVersion?: string
|
||||
refreshImageVersion?: string
|
||||
onPageChange?: (delta: number) => void
|
||||
onSelect: (item: DiscoverItem) => void
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="pl-1 font-display text-2xl font-semibold text-ink-600">{title}</h2>
|
||||
{onPageChange && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${title} 上一页`}
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(-1)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-gray-200 bg-white text-ink-100 transition hover:border-primary-300 hover:text-brand-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="min-w-10 text-center text-xs font-semibold text-sand-500">第 {page} 页</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${title} 下一页`}
|
||||
disabled={!canNext}
|
||||
onClick={() => onPageChange(1)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-gray-200 bg-white text-ink-100 transition hover:border-primary-300 hover:text-brand-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{items.map((item, index) => (
|
||||
<DiscoverCard
|
||||
key={discoverKey(item, index)}
|
||||
item={item}
|
||||
imageVersion={imageVersion}
|
||||
refreshImageVersion={refreshImageVersion}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function DiscoverSkeleton() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{[1, 2, 3].map((section) => (
|
||||
<section key={section} className="space-y-4">
|
||||
<div className="h-8 w-48 animate-pulse rounded-xl bg-gray-100" />
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((item) => (
|
||||
<div key={item} className="aspect-[2/3] animate-pulse rounded-xl bg-gray-100" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DiscoverCard({
|
||||
item,
|
||||
imageVersion,
|
||||
refreshImageVersion,
|
||||
onSelect,
|
||||
}: {
|
||||
item: DiscoverItem
|
||||
imageVersion?: string
|
||||
refreshImageVersion?: string
|
||||
onSelect: (item: DiscoverItem) => void
|
||||
}) {
|
||||
const source = discoverItemSource(item)
|
||||
const imageCandidates = useMemo(
|
||||
() =>
|
||||
[item.poster_url, item.backdrop_url]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value, index, values): value is string => Boolean(value) && values.indexOf(value) === index),
|
||||
[item.poster_url, item.backdrop_url],
|
||||
)
|
||||
const [imageIndex, setImageIndex] = useState(0)
|
||||
const [posterRetry, setPosterRetry] = useState(0)
|
||||
const [posterUnavailable, setPosterUnavailable] = useState(false)
|
||||
const posterVersion = [imageVersion, posterRetry > 0 ? `r${posterRetry}` : ''].filter(Boolean).join('-')
|
||||
const activeImage = imageCandidates[imageIndex] ?? ''
|
||||
const shouldRefreshCache = Boolean(
|
||||
(imageVersion && refreshImageVersion === imageVersion) || posterRetry > 0,
|
||||
)
|
||||
const posterSrc = useMemo(
|
||||
() =>
|
||||
imageURL(activeImage, posterVersion, {
|
||||
refreshCache: shouldRefreshCache,
|
||||
retryFailed: true,
|
||||
}),
|
||||
[activeImage, posterVersion, shouldRefreshCache],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setImageIndex(0)
|
||||
setPosterRetry(0)
|
||||
setPosterUnavailable(false)
|
||||
}, [item.poster_url, item.backdrop_url, imageVersion])
|
||||
|
||||
useEffect(() => {
|
||||
if (!posterUnavailable) return
|
||||
if (imageIndex + 1 < imageCandidates.length) {
|
||||
const timer = window.setTimeout(() => {
|
||||
setImageIndex((current) => Math.min(current + 1, imageCandidates.length - 1))
|
||||
setPosterRetry(0)
|
||||
setPosterUnavailable(false)
|
||||
}, 150)
|
||||
return () => window.clearTimeout(timer)
|
||||
}
|
||||
if (posterRetry >= 3) return
|
||||
const timer = window.setTimeout(() => {
|
||||
setPosterRetry((current) => current + 1)
|
||||
setPosterUnavailable(false)
|
||||
}, 1200 * (posterRetry + 1))
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [imageCandidates.length, imageIndex, posterRetry, posterUnavailable])
|
||||
|
||||
const markPosterUnavailable = () => setPosterUnavailable(true)
|
||||
|
||||
if (!posterSrc || posterUnavailable) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(item)}
|
||||
className="group relative overflow-hidden rounded-xl border border-gray-200 bg-gray-50 text-left transition-all duration-300 hover:-translate-y-1 hover:border-primary-500/30 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-primary-400/40"
|
||||
>
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-surface-900">
|
||||
{posterSrc && (
|
||||
<img
|
||||
src={posterSrc}
|
||||
alt={item.title}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={markPosterUnavailable}
|
||||
onLoad={(event) => {
|
||||
const img = event.currentTarget
|
||||
if (img.naturalWidth <= 1 && img.naturalHeight <= 1) {
|
||||
markPosterUnavailable()
|
||||
}
|
||||
}}
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute left-1.5 top-1.5 rounded-xl border border-white/20 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-white backdrop-blur-sm">
|
||||
{source}
|
||||
</div>
|
||||
{(item.rating ?? 0) > 0 && (
|
||||
<div className="absolute right-1.5 top-1.5 rounded-xl border border-yellow-400/30 bg-black/70 px-1.5 py-0.5 text-[11px] font-semibold text-yellow-400 backdrop-blur-sm">
|
||||
★ {(item.rating ?? 0).toFixed(1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5 px-2.5 py-2">
|
||||
<p className="truncate text-xs font-medium text-ink-600 transition-colors group-hover:text-brand-500">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-[11px] text-sand-500">
|
||||
{[item.media_type, item.year && item.year > 0 ? item.year : ''].filter(Boolean).join(' · ') || '推荐'}
|
||||
</p>
|
||||
<p className="flex items-center gap-1 pt-1 text-[10px] font-semibold text-brand-500">
|
||||
<Info size={10} />
|
||||
详情 / 订阅
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function discoverKey(item: DiscoverItem, index: number): string {
|
||||
return `${item.source || 'source'}:${item.tmdb_id || item.douban_id || item.bangumi_id || item.title}:${index}`
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import type { DiscoverItem } from '../api/discover'
|
||||
import { subscriptionsAPI } from '../api/subscriptions'
|
||||
import { discoverItemSource } from './discoverPageModel'
|
||||
import {
|
||||
DiscoverArtworkPanel,
|
||||
DiscoverModalHeader,
|
||||
DiscoverOverviewPanel,
|
||||
DiscoverSubscriptionRules,
|
||||
} from './DiscoverDetailModalSections'
|
||||
import {
|
||||
apiErrorMessage,
|
||||
buildDiscoverSubscriptionInput,
|
||||
initialDiscoverSubscriptionForm,
|
||||
} from './discoverDetailModalModel'
|
||||
|
||||
export function DiscoverDetailModal({ item, onClose }: { item: DiscoverItem; onClose: () => void }) {
|
||||
const source = discoverItemSource(item)
|
||||
const [form, setForm] = useState(() => initialDiscoverSubscriptionForm(item))
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const sub = await subscriptionsAPI.create(buildDiscoverSubscriptionInput(item, form, source))
|
||||
if (form.run_now) {
|
||||
const run = await subscriptionsAPI.runNow(sub.id)
|
||||
toast.success(run.queued > 0 ? `已订阅并加入 ${run.queued} 个下载` : '已订阅,暂未命中可下载资源')
|
||||
} else {
|
||||
toast.success('已创建订阅')
|
||||
}
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err, '订阅失败'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm">
|
||||
<div className="max-h-[92vh] w-full max-w-5xl overflow-y-auto rounded-3xl border border-white/60 bg-white p-5 shadow-2xl">
|
||||
<DiscoverModalHeader item={item} source={source} onClose={onClose} />
|
||||
<div className="grid gap-5 lg:grid-cols-[260px_1fr]">
|
||||
<DiscoverArtworkPanel item={item} />
|
||||
<div className="space-y-5">
|
||||
<DiscoverOverviewPanel overview={item.overview} />
|
||||
<DiscoverSubscriptionRules
|
||||
form={form}
|
||||
busy={busy}
|
||||
onChange={(patch) => setForm((current) => ({ ...current, ...patch }))}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import { Download, Rss, X } from 'lucide-react'
|
||||
|
||||
import type { DiscoverItem } from '../api/discover'
|
||||
import { imageURL } from '../api/client'
|
||||
import { discoverItemMetaText, type DiscoverSubscriptionForm } from './discoverDetailModalModel'
|
||||
|
||||
type FormPatch = Partial<DiscoverSubscriptionForm>
|
||||
|
||||
type FieldOption = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const searchModeOptions: FieldOption[] = [
|
||||
{ value: 'keyword', label: '标题关键词' },
|
||||
{ value: 'imdb', label: 'IMDB ID' },
|
||||
]
|
||||
|
||||
const mediaTypeOptions: FieldOption[] = [
|
||||
{ value: '', label: '自动识别' },
|
||||
{ value: 'movie', label: '电影' },
|
||||
{ value: 'tv', label: '电视剧' },
|
||||
{ value: 'anime', label: '动漫' },
|
||||
{ value: 'variety', label: '综艺' },
|
||||
]
|
||||
|
||||
const resolutionOptions: FieldOption[] = [
|
||||
{ value: 'best', label: '自动择优' },
|
||||
{ value: '2160p', label: '2160p / 4K' },
|
||||
{ value: '1080p', label: '1080p' },
|
||||
{ value: '720p', label: '720p' },
|
||||
]
|
||||
|
||||
const qualityOptions: FieldOption[] = [
|
||||
{ value: '', label: '不限' },
|
||||
{ value: 'remux', label: 'REMUX' },
|
||||
{ value: 'bluray', label: 'BluRay' },
|
||||
{ value: 'web-dl', label: 'WEB-DL' },
|
||||
{ value: 'hdtv', label: 'HDTV' },
|
||||
]
|
||||
|
||||
const washPriorityOptions: FieldOption[] = [
|
||||
{ value: 'balanced', label: '均衡' },
|
||||
{ value: 'resolution', label: '分辨率优先' },
|
||||
{ value: 'quality', label: '片源质量优先' },
|
||||
{ value: 'effects', label: 'HDR/DV/Atmos 优先' },
|
||||
{ value: 'seeders', label: '做种数优先' },
|
||||
]
|
||||
|
||||
export function DiscoverModalHeader({ item, source, onClose }: { item: DiscoverItem; source: string; onClose: () => void }) {
|
||||
return (
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-brand-500">{source}</p>
|
||||
<h2 className="font-display text-2xl font-bold text-ink-600">{item.title}</h2>
|
||||
<p className="mt-1 text-sm text-sand-500">{discoverItemMetaText(item)}</p>
|
||||
</div>
|
||||
<button className="rounded-full border border-gray-200 p-2 text-ink-50 hover:bg-gray-50" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DiscoverArtworkPanel({ item }: { item: DiscoverItem }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-hidden rounded-2xl bg-gray-100">
|
||||
{item.poster_url ? (
|
||||
<img src={imageURL(item.poster_url)} alt={item.title} className="aspect-[2/3] w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex aspect-[2/3] items-center justify-center text-sand-500">无海报</div>
|
||||
)}
|
||||
</div>
|
||||
{item.backdrop_url && (
|
||||
<img src={imageURL(item.backdrop_url)} alt="" className="h-24 w-full rounded-2xl object-cover" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DiscoverOverviewPanel({ overview }: { overview?: string }) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 className="mb-2 font-semibold text-ink-600">简介</h3>
|
||||
<p className="text-sm leading-6 text-ink-100">{overview || '当前数据源没有返回简介。'}</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function DiscoverSubscriptionRules({
|
||||
form,
|
||||
busy,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
form: DiscoverSubscriptionForm
|
||||
busy: boolean
|
||||
onChange: (patch: FormPatch) => void
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-primary-400/20 bg-primary-400/5 p-4">
|
||||
<h3 className="mb-3 flex items-center gap-2 font-semibold text-ink-600">
|
||||
<Rss size={16} />
|
||||
订阅下载规则
|
||||
</h3>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<TextField label="搜索关键词" value={form.keyword} onChange={(keyword) => onChange({ keyword })} className="md:col-span-2" />
|
||||
<SelectField label="搜索方式" value={form.search_mode} options={searchModeOptions} onChange={(search_mode) => onChange({ search_mode })} />
|
||||
<TextField label="IMDB ID" value={form.imdb_id} placeholder="tt1160419" onChange={(imdb_id) => onChange({ imdb_id })} />
|
||||
<SelectField label="类型" value={form.media_type} options={mediaTypeOptions} onChange={(media_type) => onChange({ media_type })} />
|
||||
<SelectField label="分辨率" value={form.resolution} options={resolutionOptions} onChange={(resolution) => onChange({ resolution })} />
|
||||
<SelectField label="质量" value={form.quality} options={qualityOptions} onChange={(quality) => onChange({ quality })} />
|
||||
<TextField label="特效 / 音轨" value={form.effects} placeholder="hdr,dolby-vision,atmos" onChange={(effects) => onChange({ effects })} />
|
||||
<SelectField label="洗版优先级" value={form.wash_priority} options={washPriorityOptions} disabled={!form.wash_enabled} onChange={(wash_priority) => onChange({ wash_priority })} />
|
||||
<CheckboxField label="启用洗版择优" checked={form.wash_enabled} onChange={(wash_enabled) => onChange({ wash_enabled })} />
|
||||
<TextField label="发布组" value={form.release_groups} placeholder="如 FRDS,OurTV" onChange={(release_groups) => onChange({ release_groups })} />
|
||||
<TextField label="排除词" value={form.exclude_words} onChange={(exclude_words) => onChange({ exclude_words })} />
|
||||
<TextField label="分类覆盖" value={form.media_category} placeholder="综艺 / 日番 / 欧美剧" onChange={(media_category) => onChange({ media_category })} />
|
||||
<TextField label="保存路径覆盖" value={form.save_path} onChange={(save_path) => onChange({ save_path })} />
|
||||
</div>
|
||||
<DiscoverSubmitBar busy={busy} runNow={form.run_now} onRunNowChange={(run_now) => onChange({ run_now })} onSubmit={onSubmit} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function TextField({ label, value, placeholder, className = '', onChange }: { label: string; value: string; placeholder?: string; className?: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<label className={`text-xs text-sand-500 ${className}`}>
|
||||
{label}
|
||||
<input className="input-base mt-1" placeholder={placeholder} value={value} onChange={(event) => onChange(event.target.value)} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectField({ label, value, options, disabled, onChange }: { label: string; value: string; options: FieldOption[]; disabled?: boolean; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<label className="text-xs text-sand-500">
|
||||
{label}
|
||||
<select className="input-base mt-1 disabled:opacity-50" disabled={disabled} value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map((option) => (
|
||||
<option key={option.value || option.label} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckboxField({ label, checked, onChange }: { label: string; checked: boolean; onChange: (checked: boolean) => void }) {
|
||||
return (
|
||||
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs text-ink-100">
|
||||
<input type="checkbox" checked={checked} onChange={(event) => onChange(event.target.checked)} />
|
||||
{label}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function DiscoverSubmitBar({ busy, runNow, onRunNowChange, onSubmit }: { busy: boolean; runNow: boolean; onRunNowChange: (value: boolean) => void; onSubmit: () => void }) {
|
||||
return (
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<label className="flex items-center gap-2 text-sm text-ink-100">
|
||||
<input type="checkbox" checked={runNow} onChange={(event) => onRunNowChange(event.target.checked)} />
|
||||
创建后立即搜索并下载
|
||||
</label>
|
||||
<button disabled={busy} onClick={onSubmit} className="neon-button disabled:opacity-60">
|
||||
<Download size={16} />
|
||||
{busy ? '处理中…' : '创建订阅'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { discoverAPI, type DiscoverItem, type DiscoverSection } from '../api/discover'
|
||||
import { DiscoverSkeleton } from './DiscoverContentRow'
|
||||
import { DiscoverDetailModal } from './DiscoverDetailModal'
|
||||
import { DiscoverEmptySelection, DiscoverHeader, DiscoverResults } from './DiscoverPageSections'
|
||||
import {
|
||||
defaultSections,
|
||||
discoverStorageKey,
|
||||
readCachedDiscoverRows,
|
||||
readSavedSections,
|
||||
serializeSavedSections,
|
||||
writeCachedDiscoverRow,
|
||||
} from './discoverPageModel'
|
||||
|
||||
export function DiscoverPage() {
|
||||
const [sections, setSections] = useState<DiscoverSection[]>([])
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [rows, setRows] = useState<Record<string, DiscoverItem[]>>({})
|
||||
const [rowPages, setRowPages] = useState<Record<string, number>>({})
|
||||
const [rowCanNext, setRowCanNext] = useState<Record<string, boolean>>({})
|
||||
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({})
|
||||
const [rowErrors, setRowErrors] = useState<Record<string, string>>({})
|
||||
const [sectionsReady, setSectionsReady] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeItem, setActiveItem] = useState<DiscoverItem | null>(null)
|
||||
const [reloadSeq, setReloadSeq] = useState(0)
|
||||
const [imageVersion, setImageVersion] = useState(() => String(Date.now()))
|
||||
const [refreshImageVersion, setRefreshImageVersion] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setSectionsReady(false)
|
||||
discoverAPI
|
||||
.sections()
|
||||
.then((items) => {
|
||||
if (cancelled) return
|
||||
setSections(items)
|
||||
const saved = readSavedSections(items)
|
||||
const available = new Set(items.map((item) => item.key))
|
||||
const fallback = defaultSections.filter((key) => available.has(key))
|
||||
const nextSelected = saved.length > 0 ? saved : fallback
|
||||
const cached = readCachedDiscoverRows(nextSelected)
|
||||
setSelected(nextSelected)
|
||||
setRowPages(Object.fromEntries(nextSelected.map((key) => [key, 1])))
|
||||
setRows(cached.rows)
|
||||
setRowCanNext(cached.rowCanNext)
|
||||
setSectionsReady(true)
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setSections([])
|
||||
setSelected([])
|
||||
setSectionsReady(true)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sectionsReady) return
|
||||
const available = new Set(sections.map((section) => section.key))
|
||||
const activeSelected = selected.filter((key) => available.has(key))
|
||||
if (activeSelected.length !== selected.length) {
|
||||
setSelected(activeSelected)
|
||||
return
|
||||
}
|
||||
if (selected.length === 0) {
|
||||
setRows({})
|
||||
setRowLoading({})
|
||||
setRowCanNext({})
|
||||
setRowErrors({})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setRowErrors({})
|
||||
setRowLoading(Object.fromEntries(selected.map((key) => [key, true])))
|
||||
setRows((current) => {
|
||||
const next: Record<string, DiscoverItem[]> = {}
|
||||
for (const key of selected) {
|
||||
next[key] = current[key] ?? []
|
||||
}
|
||||
return next
|
||||
})
|
||||
window.localStorage.setItem(discoverStorageKey, serializeSavedSections(selected))
|
||||
|
||||
let pending = selected.length
|
||||
const markDone = () => {
|
||||
pending -= 1
|
||||
if (!cancelled && pending <= 0) setLoading(false)
|
||||
}
|
||||
for (const key of selected) {
|
||||
const page = rowPages[key] ?? 1
|
||||
discoverAPI
|
||||
.feed([key], page)
|
||||
.then((feed) => {
|
||||
if (cancelled) return
|
||||
const error = feed.meta[key]?.error
|
||||
const nextItems = feed.items[key] ?? []
|
||||
const nextCanNext = Boolean(feed.meta[key]?.has_next)
|
||||
setRows((current) => {
|
||||
if (error && nextItems.length === 0 && (current[key]?.length ?? 0) > 0) {
|
||||
return current
|
||||
}
|
||||
return { ...current, [key]: nextItems }
|
||||
})
|
||||
setRowCanNext((current) => {
|
||||
if (error && nextItems.length === 0 && key in current) {
|
||||
return current
|
||||
}
|
||||
return { ...current, [key]: nextCanNext }
|
||||
})
|
||||
if (!error) {
|
||||
writeCachedDiscoverRow(key, page, nextItems, nextCanNext)
|
||||
}
|
||||
setRowErrors((current) => updateDiscoverRowError(current, key, error))
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
const message = discoverRequestErrorMessage(err)
|
||||
setRows((current) => ((current[key]?.length ?? 0) > 0 ? current : { ...current, [key]: [] }))
|
||||
setRowCanNext((current) => (key in current ? current : { ...current, [key]: false }))
|
||||
setRowErrors((current) => ({ ...current, [key]: message }))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setRowLoading((current) => ({ ...current, [key]: false }))
|
||||
}
|
||||
markDone()
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [sections, sectionsReady, selected, rowPages, reloadSeq])
|
||||
|
||||
const sectionMap = useMemo(
|
||||
() => new Map(sections.map((section) => [section.key, section])),
|
||||
[sections],
|
||||
)
|
||||
const hasContent = selected.some((key) => (rows[key] ?? []).length > 0)
|
||||
const sectionLabel = (key: string) => sectionMap.get(key)?.label ?? key
|
||||
|
||||
const toggleSection = (key: string) => {
|
||||
setSelected((current) => {
|
||||
if (current.includes(key)) {
|
||||
return current.filter((item) => item !== key)
|
||||
}
|
||||
return [...current, key]
|
||||
})
|
||||
setRowPages((current) => ({ ...current, [key]: current[key] ?? 1 }))
|
||||
}
|
||||
|
||||
const changeDiscoverPage = (key: string, delta: number) => {
|
||||
setRowPages((current) => {
|
||||
const nextPage = Math.max(1, (current[key] ?? 1) + delta)
|
||||
if (nextPage === (current[key] ?? 1)) return current
|
||||
return { ...current, [key]: nextPage }
|
||||
})
|
||||
}
|
||||
|
||||
const refreshDiscover = () => {
|
||||
const nextImageVersion = String(Date.now())
|
||||
setImageVersion(nextImageVersion)
|
||||
setRefreshImageVersion(nextImageVersion)
|
||||
setReloadSeq((current) => current + 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-8 px-4 py-6">
|
||||
<DiscoverHeader
|
||||
sections={sections}
|
||||
selected={selected}
|
||||
sectionsReady={sectionsReady}
|
||||
loading={loading}
|
||||
onRefresh={refreshDiscover}
|
||||
onToggleSection={toggleSection}
|
||||
/>
|
||||
|
||||
{!sectionsReady && <DiscoverSkeleton />}
|
||||
|
||||
{sectionsReady && !loading && selected.length === 0 && (
|
||||
<DiscoverEmptySelection />
|
||||
)}
|
||||
|
||||
{sectionsReady && selected.length > 0 && (
|
||||
<DiscoverResults
|
||||
selected={selected}
|
||||
rows={rows}
|
||||
rowLoading={rowLoading}
|
||||
rowErrors={rowErrors}
|
||||
rowPages={rowPages}
|
||||
rowCanNext={rowCanNext}
|
||||
loading={loading}
|
||||
hasContent={hasContent}
|
||||
imageVersion={imageVersion}
|
||||
refreshImageVersion={refreshImageVersion}
|
||||
sectionLabel={sectionLabel}
|
||||
onPageChange={changeDiscoverPage}
|
||||
onSelect={setActiveItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeItem && (
|
||||
<DiscoverDetailModal
|
||||
item={activeItem}
|
||||
onClose={() => setActiveItem(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function updateDiscoverRowError(
|
||||
current: Record<string, string>,
|
||||
key: string,
|
||||
error?: string,
|
||||
): Record<string, string> {
|
||||
if (error) return { ...current, [key]: error }
|
||||
if (!(key in current)) return current
|
||||
const next = { ...current }
|
||||
delete next[key]
|
||||
return next
|
||||
}
|
||||
|
||||
function discoverRequestErrorMessage(err: unknown): string {
|
||||
const raw = err instanceof Error ? err.message : String(err)
|
||||
const lower = raw.toLowerCase()
|
||||
if (lower.includes('timeout') || lower.includes('deadline')) {
|
||||
return '推荐源请求超时,已跳过本次加载'
|
||||
}
|
||||
if (lower.includes('network')) {
|
||||
return '推荐源网络不可用,已跳过本次加载'
|
||||
}
|
||||
return '推荐源暂时不可用,已跳过本次加载'
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { AlertTriangle, RefreshCw, Sparkles } from 'lucide-react'
|
||||
|
||||
import type { DiscoverItem, DiscoverSection } from '../api/discover'
|
||||
import { ContentRow } from './DiscoverContentRow'
|
||||
|
||||
type SectionLabel = (key: string) => string
|
||||
|
||||
export function DiscoverHeader({
|
||||
sections,
|
||||
selected,
|
||||
sectionsReady,
|
||||
loading,
|
||||
onRefresh,
|
||||
onToggleSection,
|
||||
}: {
|
||||
sections: DiscoverSection[]
|
||||
selected: string[]
|
||||
sectionsReady: boolean
|
||||
loading: boolean
|
||||
onRefresh: () => void
|
||||
onToggleSection: (key: string) => void
|
||||
}) {
|
||||
return (
|
||||
<header className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="rounded-2xl border border-primary-500/20 bg-gradient-to-br from-primary-500/20 to-primary-600/10 p-3">
|
||||
<Sparkles className="h-8 w-8 text-brand-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-4xl font-bold tracking-tight text-ink-600">
|
||||
发现
|
||||
</h1>
|
||||
<p className="mt-1 text-base text-ink-50">
|
||||
多源推荐:TMDb / 豆瓣 / Bangumi,可按需组合显示
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 lg:items-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={!sectionsReady || selected.length === 0}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-ink-600 transition hover:border-primary-300 hover:text-brand-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</button>
|
||||
<div className="flex flex-wrap justify-start gap-2 lg:justify-end">
|
||||
{sections.map((section) => {
|
||||
const active = selected.includes(section.key)
|
||||
return (
|
||||
<button
|
||||
key={section.key}
|
||||
type="button"
|
||||
onClick={() => onToggleSection(section.key)}
|
||||
className={
|
||||
'rounded-full border px-3 py-1.5 text-xs font-semibold transition ' +
|
||||
(active
|
||||
? 'border-primary-400 bg-primary-400/15 text-brand-500'
|
||||
: 'border-gray-200 bg-white text-gray-500 hover:border-primary-300 hover:text-ink-600')
|
||||
}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export function DiscoverEmptySelection() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-10 text-center text-sand-500">
|
||||
至少选择一个推荐源,小宇宙才会开始转动。
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DiscoverResults({
|
||||
selected,
|
||||
rows,
|
||||
rowLoading,
|
||||
rowErrors,
|
||||
rowPages,
|
||||
rowCanNext,
|
||||
loading,
|
||||
hasContent,
|
||||
imageVersion,
|
||||
refreshImageVersion,
|
||||
sectionLabel,
|
||||
onPageChange,
|
||||
onSelect,
|
||||
}: {
|
||||
selected: string[]
|
||||
rows: Record<string, DiscoverItem[]>
|
||||
rowLoading: Record<string, boolean>
|
||||
rowErrors: Record<string, string>
|
||||
rowPages: Record<string, number>
|
||||
rowCanNext: Record<string, boolean>
|
||||
loading: boolean
|
||||
hasContent: boolean
|
||||
imageVersion: string
|
||||
refreshImageVersion?: string
|
||||
sectionLabel: SectionLabel
|
||||
onPageChange: (key: string, delta: number) => void
|
||||
onSelect: (item: DiscoverItem) => void
|
||||
}) {
|
||||
const hasRowErrors = Object.keys(rowErrors).length > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{selected.map((key) => {
|
||||
const items = rows[key] ?? []
|
||||
if (items.length === 0) {
|
||||
if (rowLoading[key]) {
|
||||
return <DiscoverRowSkeleton key={key} title={sectionLabel(key)} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<ContentRow
|
||||
key={key}
|
||||
title={sectionLabel(key)}
|
||||
items={items}
|
||||
page={rowPages[key] ?? 1}
|
||||
canNext={Boolean(rowCanNext[key])}
|
||||
imageVersion={imageVersion}
|
||||
refreshImageVersion={refreshImageVersion}
|
||||
onPageChange={(delta) => onPageChange(key, delta)}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{hasRowErrors && (
|
||||
<DiscoverRowErrors rowErrors={rowErrors} sectionLabel={sectionLabel} />
|
||||
)}
|
||||
|
||||
{!loading && !hasContent && !hasRowErrors && <DiscoverNoContent />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DiscoverRowErrors({
|
||||
rowErrors,
|
||||
sectionLabel,
|
||||
}: {
|
||||
rowErrors: Record<string, string>
|
||||
sectionLabel: SectionLabel
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-amber-300/70 bg-amber-50 px-3 py-2 text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-500" />
|
||||
<div className="space-y-1 text-xs">
|
||||
<p className="font-semibold">部分推荐源暂不可用,其他已加载内容不受影响。</p>
|
||||
{Object.entries(rowErrors).map(([key, message]) => (
|
||||
<p key={key}>{sectionLabel(key)}:{message}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DiscoverNoContent() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-10 text-center">
|
||||
<p className="text-sand-500">
|
||||
当前选择的推荐源暂未返回内容,可切换豆瓣 / Bangumi 或检查网络代理。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DiscoverRowSkeleton({ title }: { title: string }) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="pl-1 font-display text-2xl font-semibold text-ink-600">{title}</h2>
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((item) => (
|
||||
<div key={item} className="aspect-[2/3] animate-pulse rounded-xl bg-gray-100" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Loader2, Pencil, Send, Trash2 } from 'lucide-react'
|
||||
|
||||
import type { DownloadClient } from '../api/download_clients'
|
||||
|
||||
export function DownloadClientCard({
|
||||
client,
|
||||
testing,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onTest,
|
||||
}: {
|
||||
client: DownloadClient
|
||||
testing: boolean
|
||||
onDelete: (client: DownloadClient) => void
|
||||
onEdit: (client: DownloadClient) => void
|
||||
onTest: (id: string) => void
|
||||
}) {
|
||||
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-ink-600">{client.name}</span>
|
||||
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs text-ink-50">
|
||||
{client.type}
|
||||
</span>
|
||||
{client.is_default && (
|
||||
<span className="rounded-lg bg-primary-400/20 px-2 py-0.5 text-xs text-brand-500">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
{!client.enabled && (
|
||||
<span className="rounded-lg bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">
|
||||
已禁用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-ink-50">
|
||||
{client.host}
|
||||
{client.username && ` · ${client.username}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={() => onTest(client.id)}
|
||||
disabled={testing}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 size={12} className="inline animate-spin" />
|
||||
) : (
|
||||
<Send size={12} className="inline" />
|
||||
)}{' '}
|
||||
测试
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onEdit(client)}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<Pencil size={12} className="inline" /> 编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(client)}
|
||||
className="rounded-lg 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>
|
||||
)
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import { type FormEvent, type ReactNode, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
downloadClientsAPI,
|
||||
type DownloadClient,
|
||||
type DownloadClientInput,
|
||||
type DownloadClientType,
|
||||
} from '../api/download_clients'
|
||||
import { apiErrorMessage } from './downloadClientPageModel'
|
||||
|
||||
export 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',
|
||||
host: editing?.host ?? '',
|
||||
username: editing?.username ?? '',
|
||||
password: '',
|
||||
is_default: editing?.is_default ?? false,
|
||||
enabled: editing?.enabled ?? true,
|
||||
}))
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (saving) return
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) await downloadClientsAPI.update(editing.id, form)
|
||||
else await downloadClientsAPI.create(form)
|
||||
toast.success('已保存')
|
||||
setSaving(false)
|
||||
onSaved()
|
||||
} catch (err: unknown) {
|
||||
const msg = apiErrorMessage(err, '保存失败')
|
||||
toast.error(msg)
|
||||
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-ink-600">
|
||||
{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.host}
|
||||
onChange={(e) => update('host', 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>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-ink-100">
|
||||
<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-ink-100">
|
||||
<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-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</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: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-ink-100">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loader2, Plus, Server } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
downloadClientsAPI,
|
||||
type DownloadClient,
|
||||
} from '../api/download_clients'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import { DownloadClientCard } from './DownloadClientCard'
|
||||
import { ClientFormModal } from './DownloadClientFormModal'
|
||||
import { apiErrorMessage } from './downloadClientPageModel'
|
||||
|
||||
// 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 [testing, setTesting] = useState<Record<string, boolean>>({})
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setClients(await downloadClientsAPI.list())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const onTest = async (id: string) => {
|
||||
if (testing[id]) return
|
||||
setTesting((current) => ({ ...current, [id]: true }))
|
||||
try {
|
||||
const r = await downloadClientsAPI.test(id)
|
||||
if (r.ok) toast.success('连接成功')
|
||||
else toast.error(r.error ?? '连接失败')
|
||||
} catch (err: unknown) {
|
||||
const msg = apiErrorMessage(err, '测试失败')
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setTesting((current) => ({ ...current, [id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (c: DownloadClient) => {
|
||||
if (!(await confirmAction({ title: '删除下载器', message: `确定删除「${c.name}」?`, confirmText: '删除' }))) return
|
||||
try {
|
||||
await downloadClientsAPI.remove(c.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg = apiErrorMessage(err, '删除失败')
|
||||
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-ink-600">下载器管理</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
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-ink-50">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && clients.length === 0 && (
|
||||
<div className="glass-panel py-12 text-center text-ink-50">暂无下载器</div>
|
||||
)}
|
||||
|
||||
{!loading && clients.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{clients.map((c) => (
|
||||
<DownloadClientCard
|
||||
key={c.id}
|
||||
client={c}
|
||||
testing={Boolean(testing[c.id])}
|
||||
onDelete={onDelete}
|
||||
onEdit={(client) => {
|
||||
setEditing(client)
|
||||
setShowForm(true)
|
||||
}}
|
||||
onTest={onTest}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<ClientFormModal
|
||||
editing={editing}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={async () => {
|
||||
setShowForm(false)
|
||||
refresh().catch((err: unknown) => toast.error(apiErrorMessage(err, '刷新下载器列表失败')))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ArrowDown, ArrowUp, Film, HardDrive, Rss, Trash2 } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import type { DownloadCardItem } from './downloadTaskCardModel'
|
||||
|
||||
export function DownloadTaskCard({
|
||||
item,
|
||||
removable,
|
||||
onRemove,
|
||||
}: {
|
||||
item: DownloadCardItem
|
||||
removable?: boolean
|
||||
onRemove?: () => Promise<void>
|
||||
}) {
|
||||
const progress = pct(item.progress)
|
||||
const visual = item.poster_url || item.backdrop_url
|
||||
const downloaded = item.downloaded || (item.size ? Math.round(item.size * (item.progress || 0)) : 0)
|
||||
|
||||
return (
|
||||
<article className="group overflow-hidden rounded-3xl border border-white/70 bg-white shadow-sm transition hover:-translate-y-1 hover:shadow-xl">
|
||||
<div className="relative flex gap-4 p-4">
|
||||
<div className="relative h-40 w-28 flex-shrink-0 overflow-hidden rounded-2xl bg-gradient-to-br from-primary-400/15 via-white to-surface-200 shadow-inner">
|
||||
{visual ? (
|
||||
<img
|
||||
src={imageURL(visual, item.updated_at)}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-3 text-center text-xs font-semibold text-brand-500">
|
||||
<Film size={24} />
|
||||
<span className="line-clamp-3">{item.title}</span>
|
||||
</div>
|
||||
)}
|
||||
<span className={`absolute left-2 top-2 rounded-full px-2 py-0.5 text-[10px] font-semibold ${statusTone(item)}`}>
|
||||
{stateLabel(item)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div>
|
||||
<h2 className="line-clamp-2 font-display text-lg font-semibold leading-snug text-ink-600" title={item.title}>
|
||||
{item.title}
|
||||
</h2>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-ink-50">
|
||||
{item.overview || item.save_path || '已隐藏原始种子 URL,避免泄露私有 Token。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-ink-100">
|
||||
<span>进度 {progress.toFixed(1)}%</span>
|
||||
<span>{fmtBytes(downloaded)} / {fmtBytes(item.size)}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-primary-400 to-brand-500 transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-ink-100">
|
||||
<DownloadMetric icon={<ArrowDown size={13} className="mr-1 inline text-brand-500" />} value={fmtSpeed(item.dlspeed)} />
|
||||
<DownloadMetric icon={<ArrowUp size={13} className="mr-1 inline text-brand-500" />} value={fmtSpeed(item.upspeed)} />
|
||||
<DownloadMetric icon={<Rss size={13} className="mr-1 inline text-brand-500" />} value={`${item.num_seeds ?? 0} / ${item.num_leechs ?? 0}`} />
|
||||
<DownloadMetric icon={<HardDrive size={13} className="mr-1 inline text-brand-500" />} value={fmtBytes(item.size)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-ink-50">
|
||||
<span className="truncate" title={item.save_path || ''}>
|
||||
{item.save_path || '默认下载目录'}
|
||||
</span>
|
||||
{item.created_at && <span>{new Date(item.created_at).toLocaleString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{removable && onRemove && (
|
||||
<div className="flex justify-end border-t border-gray-100 bg-gray-50/70 px-4 py-3">
|
||||
<button
|
||||
className="rounded-xl border border-red-400/40 bg-white px-3 py-1.5 text-xs font-semibold text-red-400 hover:bg-red-400/10"
|
||||
onClick={() => void onRemove()}
|
||||
>
|
||||
<Trash2 size={13} className="mr-1 inline" />
|
||||
删除任务
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function DownloadMetric({ icon, value }: { icon: ReactNode; value: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-gray-50 px-3 py-2">
|
||||
{icon}
|
||||
{value}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtBytes(n?: number): string {
|
||||
if (!n || n <= 0) return '0 B'
|
||||
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(v >= 100 ? 0 : 1)} ${u[i]}`
|
||||
}
|
||||
|
||||
function fmtSpeed(n?: number): string {
|
||||
return `${fmtBytes(n)}/s`
|
||||
}
|
||||
|
||||
function pct(progress?: number): number {
|
||||
if (!Number.isFinite(progress)) return 0
|
||||
return Math.min(100, Math.max(0, Math.round((progress ?? 0) * 1000) / 10))
|
||||
}
|
||||
|
||||
function stateLabel(item: DownloadCardItem): string {
|
||||
const state = (item.state || item.status || 'queued').toLowerCase()
|
||||
if (state.includes('down') || state.includes('meta')) return '下载中'
|
||||
if (state.includes('up') || state.includes('seed')) return '做种中'
|
||||
if (state.includes('pause')) return '已暂停'
|
||||
if (state.includes('error')) return '出错'
|
||||
if (state.includes('complete') || pct(item.progress) >= 100) return '已完成'
|
||||
if (state.includes('queue')) return '排队中'
|
||||
return item.state || item.status || '等待中'
|
||||
}
|
||||
|
||||
function statusTone(item: DownloadCardItem): string {
|
||||
const state = stateLabel(item)
|
||||
if (state === '已完成' || state === '做种中') return 'bg-emerald-50 text-emerald-600'
|
||||
if (state === '出错') return 'bg-red-50 text-red-500'
|
||||
if (state === '已暂停') return 'bg-amber-50 text-amber-600'
|
||||
return 'bg-primary-400/10 text-brand-500'
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Download, ShieldCheck } from 'lucide-react'
|
||||
|
||||
import { downloadsAPI } from '../api/downloads'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import type { DownloadTask, QBitTorrent } from '../types'
|
||||
import { DownloadTaskCard } from './DownloadTaskCard'
|
||||
import { toLiveCard, toTaskCard } from './downloadTaskCardModel'
|
||||
|
||||
export function DownloadsPage() {
|
||||
const role = useAuthStore((s) => s.user?.role)
|
||||
const [tasks, setTasks] = useState<DownloadTask[]>([])
|
||||
const [torrents, setTorrents] = useState<QBitTorrent[] | null>(null)
|
||||
const [url, setURL] = useState('')
|
||||
const [savePath, setSavePath] = useState('')
|
||||
|
||||
const refresh = () =>
|
||||
downloadsAPI.list().then((d) => {
|
||||
setTasks(d.tasks)
|
||||
setTorrents(d.torrents)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
void refresh().catch(() => undefined)
|
||||
const id = window.setInterval(() => void refresh().catch(() => undefined), 5_000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const onAdd = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await downloadsAPI.add(url, savePath)
|
||||
toast.success('已加入下载队列')
|
||||
setURL('')
|
||||
setSavePath('')
|
||||
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">
|
||||
<header className="space-y-2">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">下载管理</h1>
|
||||
<p className="flex items-center gap-2 text-sm text-ink-50">
|
||||
<ShieldCheck size={16} className="text-brand-500" />
|
||||
页面仅展示安全标题、海报和进度信息,不再暴露种子原始 URL 或私有 Token。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={onAdd} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
||||
<input
|
||||
required
|
||||
className="input-base md:col-span-2"
|
||||
placeholder="磁力链接 / .torrent URL(提交后不会在页面公开显示)"
|
||||
value={url}
|
||||
onChange={(e) => setURL(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="保存路径 (可选)"
|
||||
value={savePath}
|
||||
onChange={(e) => setSavePath(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="neon-button md:col-span-3">
|
||||
<Download size={16} /> 添加
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-display text-xl font-semibold text-ink-600">当前下载</h2>
|
||||
<span className="text-xs text-ink-50">每 5 秒自动刷新</span>
|
||||
</div>
|
||||
{torrents === null && (
|
||||
<div className="glass-panel text-sand-500">
|
||||
尚未连接到下载器 —{' '}
|
||||
{role === 'admin' ? (
|
||||
<>
|
||||
请到{' '}
|
||||
<Link to="/download-clients" className="text-brand-500 hover:underline">
|
||||
下载器
|
||||
</Link>{' '}
|
||||
页面添加并测试连接。
|
||||
</>
|
||||
) : (
|
||||
'请联系管理员添加并测试下载器连接。'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{torrents && torrents.length === 0 && (
|
||||
<div className="glass-panel text-sand-500">暂无运行中任务。</div>
|
||||
)}
|
||||
{torrents && torrents.length > 0 && (
|
||||
<div className="grid gap-5 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
{torrents.map((torrent) => (
|
||||
<DownloadTaskCard
|
||||
key={`${torrent.client_id}:${torrent.hash}`}
|
||||
item={toLiveCard(torrent)}
|
||||
removable={role === 'admin'}
|
||||
onRemove={async () => {
|
||||
if (!(await confirmAction({ title: '删除下载任务', message: `删除「${torrent.title || torrent.name}」?`, confirmText: '删除' }))) return
|
||||
await downloadsAPI.remove(torrent.hash, torrent.client_id, false)
|
||||
toast.success('已删除任务')
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-xl font-semibold text-ink-600">下载历史</h2>
|
||||
{tasks.length === 0 ? (
|
||||
<div className="glass-panel text-sand-500">暂无历史下载。</div>
|
||||
) : (
|
||||
<div className="grid gap-5 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
{tasks.map((task) => (
|
||||
<DownloadTaskCard key={task.id} item={toTaskCard(task)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Copy, Trash2 } from 'lucide-react'
|
||||
|
||||
import { duplicatesAPI, type DuplicateReport } from '../api/duplicates'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import type { Library } from '../types'
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (!n) return '0 B'
|
||||
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]}`
|
||||
}
|
||||
|
||||
export function DuplicatesPage() {
|
||||
const [libs, setLibs] = useState<Library[]>([])
|
||||
const [libID, setLibID] = useState('')
|
||||
const [report, setReport] = useState<DuplicateReport | null>(null)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
libraryAPI.list().then(setLibs)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
duplicatesAPI.list(libID).then(setReport).catch(() => setReport(null))
|
||||
}, [libID])
|
||||
|
||||
const scan = async () => {
|
||||
setScanning(true)
|
||||
try {
|
||||
const r = await duplicatesAPI.scan(libID)
|
||||
setReport(r)
|
||||
const cleaned = r.missing_removed ? `, 清理 ${r.missing_removed} 条失效记录` : ''
|
||||
toast.success(`扫描完成: ${r.groups_found} 组重复, ${r.items_marked} 项标记${cleaned}`)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'扫描失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const unmark = async () => {
|
||||
if (!(await confirmAction({ title: '清除重复标记', message: '清除所有重复标记?(磁盘文件不会被删除)', confirmText: '清除' }))) return
|
||||
const r = await duplicatesAPI.unmark(libID)
|
||||
toast.success(`已清除 ${r.unmarked} 项`)
|
||||
setReport(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<Copy className="h-6 w-6 text-brand-500" />
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">重复文件</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
通过稀疏采样 MD5(头部 / 中部 / 尾部各 1 MiB + 文件大小)检测重复媒体,
|
||||
同一组中保留刮削过的较大文件作为主条目,其余标记为重复。
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="glass-panel grid gap-3 md:grid-cols-[1fr_auto_auto]">
|
||||
<select
|
||||
className="input-base"
|
||||
value={libID}
|
||||
onChange={(e) => setLibID(e.target.value)}
|
||||
>
|
||||
<option value="">所有媒体库</option>
|
||||
{libs.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={scan} disabled={scanning} className="neon-button">
|
||||
{scanning ? '扫描中…' : '开始扫描'}
|
||||
</button>
|
||||
<button onClick={unmark} className="neon-button !border-red-400/40 !text-red-400">
|
||||
<Trash2 size={14} /> 清除标记
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{report && report.groups_found === 0 && (
|
||||
<p className="text-ink-50">扫描了 {report.total_scanned} 项,未发现重复。</p>
|
||||
)}
|
||||
|
||||
{report && report.missing_removed ? (
|
||||
<p className="rounded-2xl border border-amber-300/50 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
已清理 {report.missing_removed} 条文件不存在的媒体记录,统计容量会在刷新后恢复正常。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{report && (report.groups ?? []).map((g) => (
|
||||
<section key={g.hash} className="glass-panel space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-mono text-xs text-sand-500">{g.hash}</p>
|
||||
<span className="rounded-lg border border-emerald-400/40 px-2 py-0.5 text-xs text-emerald-400">
|
||||
主条目
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-medium text-ink-600">{g.primary.title}</p>
|
||||
<p className="font-mono text-xs text-ink-50">
|
||||
{g.primary.path} · {fmtBytes(g.primary.size_bytes)}
|
||||
</p>
|
||||
<div className="space-y-1 border-t border-gray-200 pt-2">
|
||||
<p className="text-xs uppercase tracking-wider text-red-400">
|
||||
重复 ({g.duplicates.length})
|
||||
</p>
|
||||
{g.duplicates.map((d) => (
|
||||
<div key={d.id} className="text-xs text-ink-50">
|
||||
<span className="font-medium text-ink-600">{d.title}</span> · {d.path} · {fmtBytes(d.size_bytes)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+147
-32
@@ -1,62 +1,157 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { libraryAPI, mediaAPI } from '../api/library'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { playbackAPI, type HistoryItem } from '../api/playback'
|
||||
import type { Library, Media } from '../types'
|
||||
import { groupSeries, type SeriesCard } from '../utils/groupSeries'
|
||||
import {
|
||||
ContinueWatchingSection,
|
||||
HomeCarouselSection,
|
||||
HomeEmptyState,
|
||||
HomeFeaturedSection,
|
||||
HomeLibrariesSection,
|
||||
HomeLibraryRowSection,
|
||||
HomeLoadingState,
|
||||
RecentMediaSection,
|
||||
} from './HomePageSections'
|
||||
|
||||
const CAROUSEL_STORAGE_KEY = 'mmtl.home.carousel_libraries'
|
||||
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
|
||||
const asArray = <T,>(value: unknown): T[] => (Array.isArray(value) ? value as T[] : [])
|
||||
const asArray = <T,>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : [])
|
||||
|
||||
export function HomePage() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [recentCards, setRecentCards] = useState<SeriesCard[]>([])
|
||||
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>>({})
|
||||
const [selectedLibraryIds, setSelectedLibraryIds] = useState<string[]>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(CAROUSEL_STORAGE_KEY)
|
||||
return saved ? JSON.parse(saved) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const [history, setHistory] = useState<HistoryItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [libs, recentItems, hist] = await Promise.all([
|
||||
const [libs, hist] = await Promise.all([
|
||||
libraryAPI.list().then((rows) => asArray<Library>(rows)).catch(() => [] as Library[]),
|
||||
mediaAPI.recent(24).then((rows) => asArray<SeriesCard>(rows)).catch(async () => {
|
||||
const fallback = await mediaAPI.search('', 120).then((d) => asArray<Media>(d?.items)).catch(() => [] as Media[])
|
||||
return groupSeries(fallback).slice(0, 24)
|
||||
}),
|
||||
playbackAPI.recentHistory().then((rows) => asArray<HistoryItem>(rows)).catch(() => [] as HistoryItem[]),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
setLibraries(libs)
|
||||
setRecentCards(recentItems)
|
||||
setHistory(hist.filter((h) => h && !h.completed && !!h.media))
|
||||
|
||||
// Set default selected libraries if none saved yet
|
||||
setSelectedLibraryIds((current) => {
|
||||
if (current.length > 0) return current
|
||||
const allIds = libs.map((l) => l.id)
|
||||
try {
|
||||
localStorage.setItem(CAROUSEL_STORAGE_KEY, JSON.stringify(allIds))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return allIds
|
||||
})
|
||||
|
||||
// Fetch media items for all libraries in parallel
|
||||
const results = await Promise.allSettled(
|
||||
libs.map(async (lib) => {
|
||||
const page = await libraryAPI.listMedia(lib.id, 1, 30)
|
||||
const items = asArray<Media>(page?.items)
|
||||
const cards = groupSeries(items)
|
||||
return {
|
||||
id: lib.id,
|
||||
cards,
|
||||
items,
|
||||
total: page?.total ?? items.length,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
if (cancelled) return
|
||||
const mapData: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }> = {}
|
||||
for (const res of results) {
|
||||
if (res.status === 'fulfilled' && res.value) {
|
||||
mapData[res.value.id] = {
|
||||
cards: res.value.cards,
|
||||
items: res.value.items,
|
||||
total: res.value.total,
|
||||
}
|
||||
}
|
||||
}
|
||||
setLibraryData(mapData)
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
return () => { cancelled = true }
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const featuredItem = useMemo(() => {
|
||||
const candidates = [
|
||||
...(history.map((h) => h.media).filter(Boolean) as Media[]),
|
||||
...recentCards.map((card) => card.rep),
|
||||
]
|
||||
return candidates.find(hasArtwork) ?? candidates[0] ?? null
|
||||
}, [history, recentCards])
|
||||
const featuredVisual = featuredItem?.backdrop_url || featuredItem?.poster_url || ''
|
||||
const featuredPoster = featuredItem?.poster_url || featuredItem?.backdrop_url || ''
|
||||
const featuredMark = (featuredItem?.title || 'MS').trim().slice(0, 4).toUpperCase()
|
||||
const empty = !loading && libraries.length === 0 && recentCards.length === 0 && history.length === 0
|
||||
// Quick lookup map for libraries
|
||||
const libraryMap = useMemo(() => {
|
||||
const map = new Map<string, Library>()
|
||||
for (const lib of libraries) {
|
||||
map.set(lib.id, lib)
|
||||
}
|
||||
return map
|
||||
}, [libraries])
|
||||
|
||||
// Counts lookup
|
||||
const libraryCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const lib of libraries) {
|
||||
counts[lib.id] = libraryData[lib.id]?.total ?? 0
|
||||
}
|
||||
return counts
|
||||
}, [libraries, libraryData])
|
||||
|
||||
// Compute items to show in the Hero Carousel
|
||||
const carouselItems = useMemo(() => {
|
||||
const candidateMedia: Media[] = []
|
||||
const effectiveSelectedIds =
|
||||
selectedLibraryIds.length > 0 ? selectedLibraryIds : libraries.map((l) => l.id)
|
||||
|
||||
for (const libId of effectiveSelectedIds) {
|
||||
const data = libraryData[libId]
|
||||
if (data && data.items.length > 0) {
|
||||
// Pick representative items from series cards or raw items
|
||||
for (const card of data.cards) {
|
||||
if (hasArtwork(card.rep)) {
|
||||
candidateMedia.push(card.rep)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by artwork score / rating or shuffle / interleave
|
||||
if (candidateMedia.length === 0) {
|
||||
// Fallback to all loaded items with artwork
|
||||
for (const lib of libraries) {
|
||||
const data = libraryData[lib.id]
|
||||
if (data) {
|
||||
for (const card of data.cards) {
|
||||
candidateMedia.push(card.rep)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidateMedia.slice(0, 10)
|
||||
}, [selectedLibraryIds, libraries, libraryData])
|
||||
|
||||
const empty =
|
||||
!loading &&
|
||||
libraries.length === 0 &&
|
||||
history.length === 0
|
||||
|
||||
if (loading) {
|
||||
return <HomeLoadingState />
|
||||
@@ -67,18 +162,38 @@ export function HomePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
{featuredItem && (
|
||||
<HomeFeaturedSection
|
||||
featuredItem={featuredItem}
|
||||
featuredVisual={featuredVisual}
|
||||
featuredPoster={featuredPoster}
|
||||
featuredMark={featuredMark}
|
||||
<div className="space-y-12 pb-16">
|
||||
{/* 1. 顶部海报轮播区 */}
|
||||
{carouselItems.length > 0 && (
|
||||
<HomeCarouselSection items={carouselItems} libraryMap={libraryMap} />
|
||||
)}
|
||||
|
||||
{/* 2. 继续观看(若有历史) */}
|
||||
{history.length > 0 && <ContinueWatchingSection history={history} />}
|
||||
|
||||
{/* 3. 媒体库卡片区 */}
|
||||
{libraries.length > 0 && (
|
||||
<HomeLibrariesSection
|
||||
libraries={libraries}
|
||||
libraryData={libraryData}
|
||||
libraryCounts={libraryCounts}
|
||||
/>
|
||||
)}
|
||||
|
||||
{history.length > 0 && <ContinueWatchingSection history={history} />}
|
||||
{recentCards.length > 0 && <RecentMediaSection recentCards={recentCards} />}
|
||||
{/* 4. 各媒体库内容展示行 */}
|
||||
<div className="space-y-10">
|
||||
{libraries.map((lib) => {
|
||||
const cards = libraryData[lib.id]?.cards || []
|
||||
if (cards.length === 0) return null
|
||||
return (
|
||||
<HomeLibraryRowSection
|
||||
key={lib.id}
|
||||
library={lib}
|
||||
cards={cards}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+523
-122
@@ -1,23 +1,69 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowRight, Clock, Film, Play, Sparkles } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Film,
|
||||
FolderOpen,
|
||||
Library as LibraryIcon,
|
||||
Music,
|
||||
Play,
|
||||
PlayCircle,
|
||||
Sparkles,
|
||||
Star,
|
||||
Tv,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { HistoryItem } from '../api/playback'
|
||||
import type { Media } from '../types'
|
||||
import type { Library, Media } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { seriesCardLink } from '../utils/groupSeries'
|
||||
import { getLibraryArtworks } from './librariesPageModel'
|
||||
|
||||
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||
movie: <Film size={18} />,
|
||||
movies: <Film size={18} />,
|
||||
tv: <Tv size={18} />,
|
||||
series: <Tv size={18} />,
|
||||
anime: <PlayCircle size={18} />,
|
||||
shows: <Tv size={18} />,
|
||||
variety: <Tv size={18} />,
|
||||
music: <Music size={18} />,
|
||||
adult: <Film size={18} />,
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
movie: '电影',
|
||||
movies: '电影',
|
||||
tv: '剧集',
|
||||
series: '剧集',
|
||||
anime: '动漫',
|
||||
shows: '综艺',
|
||||
variety: '综艺',
|
||||
music: '音乐',
|
||||
adult: 'Adult',
|
||||
}
|
||||
|
||||
export function HomeLoadingState() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-48">
|
||||
<motion.div animate={{ opacity: [0.4, 1, 0.4] }} transition={{ repeat: Infinity, duration: 1.5 }} className="flex flex-col items-center gap-4">
|
||||
<motion.div
|
||||
animate={{ opacity: [0.4, 1, 0.4] }}
|
||||
transition={{ repeat: Infinity, duration: 1.5 }}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-2 border-[var(--app-border)] border-t-[var(--app-active-bg)]" />
|
||||
<Film className="absolute h-4 w-4 text-brand-500" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold uppercase tracking-widest text-[var(--app-muted)]">首页内容准备中…</span>
|
||||
<span className="text-sm font-semibold uppercase tracking-widest text-[var(--app-muted)]">
|
||||
首页内容准备中…
|
||||
</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
@@ -31,165 +77,512 @@ export function HomeEmptyState() {
|
||||
</div>
|
||||
<p className="text-xl font-bold text-[var(--app-text)]">您的家庭影视站暂无内容</p>
|
||||
<p className="mt-2 text-sm leading-relaxed text-[var(--app-muted)]">
|
||||
前往管理后台添加媒体目录,扫描后首页将展示本周力荐、继续观看和最近入库。
|
||||
前往媒体库添加媒体目录,扫描后首页将展示海报轮播、媒体库和最新入库。
|
||||
</p>
|
||||
<Link to="/admin" className="mt-8 btn-primary">
|
||||
前往管理后台
|
||||
<Link to="/libraries" className="mt-8 btn-primary">
|
||||
前往媒体库
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeFeaturedSection({
|
||||
featuredItem,
|
||||
featuredVisual,
|
||||
featuredPoster,
|
||||
featuredMark,
|
||||
/* =========================================================================
|
||||
1. 海报轮播区 (Hero Carousel Section)
|
||||
========================================================================= */
|
||||
|
||||
export function HomeCarouselSection({
|
||||
items,
|
||||
libraryMap,
|
||||
}: {
|
||||
featuredItem: Media
|
||||
featuredVisual: string
|
||||
featuredPoster: string
|
||||
featuredMark: string
|
||||
items: Media[]
|
||||
libraryMap: Map<string, Library>
|
||||
}) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
|
||||
const currentItem = items[currentIndex] || items[0]
|
||||
const count = items.length
|
||||
|
||||
useEffect(() => {
|
||||
if (count <= 1 || isPaused) return
|
||||
const timer = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % count)
|
||||
}, 5000)
|
||||
return () => clearInterval(timer)
|
||||
}, [count, isPaused])
|
||||
|
||||
if (!currentItem) return null
|
||||
|
||||
const handlePrev = () => {
|
||||
setCurrentIndex((prev) => (prev - 1 + count) % count)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
setCurrentIndex((prev) => (prev + 1) % count)
|
||||
}
|
||||
|
||||
const visual = currentItem.backdrop_url || currentItem.poster_url || ''
|
||||
const poster = currentItem.poster_url || currentItem.backdrop_url || ''
|
||||
const lib = libraryMap.get(currentItem.display_library_id || currentItem.library_id)
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden rounded-[2rem] border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_24px_80px_var(--app-shadow)]">
|
||||
<section
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
className="group relative overflow-hidden rounded-[2rem] border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_24px_80px_var(--app-shadow)] min-h-[420px] md:min-h-[460px] flex flex-col justify-end"
|
||||
>
|
||||
{/* Background Backdrop Image with Crossfade */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
<div className="theme-hero-bg h-full w-full" />
|
||||
{featuredVisual && (
|
||||
<img
|
||||
src={imageURL(featuredVisual, featuredItem.updated_at)}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full scale-105 object-cover object-center opacity-[0.34] blur-[1px]"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(event) => { event.currentTarget.style.display = 'none' }}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence mode="wait">
|
||||
{visual && (
|
||||
<motion.img
|
||||
key={visual + currentItem.id}
|
||||
initial={{ opacity: 0, scale: 1.08 }}
|
||||
animate={{ opacity: 0.38, scale: 1.02 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||
src={imageURL(visual, currentItem.updated_at)}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover object-center blur-[1px]"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="theme-hero-overlay absolute inset-0" />
|
||||
<div className="theme-hero-fade absolute inset-x-0 bottom-0 h-32" />
|
||||
<div className="theme-hero-fade absolute inset-x-0 bottom-0 h-36" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 grid gap-8 px-6 py-8 sm:px-8 md:grid-cols-[minmax(0,1fr)_280px] md:px-12 md:py-12 lg:grid-cols-[minmax(0,1fr)_340px] lg:px-14 lg:py-14">
|
||||
<div className="flex min-w-0 flex-col justify-center space-y-5">
|
||||
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-[var(--app-brand-border)] bg-[var(--app-brand-soft)] px-3.5 py-1.5 text-xs font-bold uppercase tracking-widest text-[var(--app-brand-text)] shadow-sm backdrop-blur">
|
||||
<Sparkles size={12} fill="currentColor" />
|
||||
<span>本周力荐 / Featured</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="inline-flex max-w-full items-center gap-2 rounded-2xl bg-[var(--app-active-bg)] px-3 py-2 text-[var(--app-active-text)] shadow-lg">
|
||||
<span className="h-2 w-2 rounded-full bg-[#d4af37]" />
|
||||
<span className="truncate text-xs font-black tracking-[0.26em]">{featuredMark}</span>
|
||||
{/* Main Content Layout */}
|
||||
<div className="relative z-10 grid gap-8 px-6 py-8 sm:px-8 md:grid-cols-[minmax(0,1fr)_260px] md:px-12 md:py-12 lg:grid-cols-[minmax(0,1fr)_320px] lg:px-14 lg:py-14">
|
||||
{/* Left Column: Metadata & Actions */}
|
||||
<div className="flex min-w-0 flex-col justify-center space-y-4 sm:space-y-5">
|
||||
{/* Tag / Library Label */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="inline-flex w-fit items-center gap-1.5 rounded-full border border-[var(--app-brand-border)] bg-[var(--app-brand-soft)] px-3 py-1 text-xs font-bold uppercase tracking-widest text-[var(--app-brand-text)] shadow-sm backdrop-blur">
|
||||
<Sparkles size={12} fill="currentColor" />
|
||||
<span>焦点推荐</span>
|
||||
</div>
|
||||
<h1 className="font-display text-3xl font-extrabold leading-tight tracking-tight text-[var(--app-text)] sm:text-4xl md:text-5xl">
|
||||
{featuredItem.title}
|
||||
</h1>
|
||||
{lib && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-[var(--app-border)] bg-[var(--app-panel)]/80 px-2.5 py-0.5 text-xs font-bold text-[var(--app-text)] shadow-sm backdrop-blur">
|
||||
{TYPE_ICONS[lib.type] || <FolderOpen size={12} />}
|
||||
<span>{lib.name}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="line-clamp-3 max-w-2xl text-sm font-semibold leading-relaxed text-[var(--app-subtle)] sm:text-base">
|
||||
{featuredItem.overview || '家庭私人媒体中心收藏。支持多端播放、外部播放器、智能刮削与订阅下载。'}
|
||||
{/* Title */}
|
||||
<div className="space-y-2">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.h1
|
||||
key={currentItem.title + currentItem.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="font-display text-2xl font-extrabold leading-tight tracking-tight text-[var(--app-text)] sm:text-3xl md:text-4xl lg:text-5xl"
|
||||
>
|
||||
{currentItem.title}
|
||||
</motion.h1>
|
||||
</AnimatePresence>
|
||||
{currentItem.original_name && currentItem.original_name !== currentItem.title && (
|
||||
<p className="text-xs font-semibold text-[var(--app-muted)] tracking-wide">
|
||||
{currentItem.original_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata Chips */}
|
||||
<div className="flex flex-wrap items-center gap-2.5 text-xs font-bold text-[var(--app-muted)]">
|
||||
{currentItem.rating > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-xl border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-xs font-bold text-amber-500 shadow-sm">
|
||||
<Star size={12} fill="currentColor" />
|
||||
<span>{currentItem.rating.toFixed(1)}</span>
|
||||
</span>
|
||||
)}
|
||||
{currentItem.year > 0 && (
|
||||
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-2.5 py-1 text-[var(--app-text)] shadow-sm">
|
||||
{currentItem.year} 年
|
||||
</span>
|
||||
)}
|
||||
{currentItem.video_codec && (
|
||||
<span className="rounded-lg border border-[var(--app-brand-border)] bg-[var(--app-brand-soft)] px-2 py-1 text-[10px] font-bold uppercase text-[var(--app-brand-text)]">
|
||||
{currentItem.video_codec}
|
||||
</span>
|
||||
)}
|
||||
{currentItem.container && (
|
||||
<span className="rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 font-mono text-[10px] uppercase text-[var(--app-subtle)]">
|
||||
{currentItem.container}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Overview */}
|
||||
<p className="line-clamp-3 max-w-2xl text-xs font-semibold leading-relaxed text-[var(--app-subtle)] sm:text-sm">
|
||||
{currentItem.overview || '家庭私人媒体中心收藏。支持多端播放、高码率串流与智能刮削。'}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs font-bold text-[var(--app-muted)]">
|
||||
{featuredItem.year > 0 && (
|
||||
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-2.5 py-1 text-[var(--app-text)] shadow-sm">{featuredItem.year} 年</span>
|
||||
)}
|
||||
{featuredItem.video_codec && (
|
||||
<span className="rounded-lg border border-[var(--app-brand-border)] bg-[var(--app-brand-soft)] px-2 py-1 text-[10px] font-bold uppercase text-[var(--app-brand-text)]">
|
||||
{featuredItem.video_codec}
|
||||
</span>
|
||||
)}
|
||||
{featuredItem.container && (
|
||||
<span className="rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] px-2 py-1 font-mono text-[10px] uppercase text-[var(--app-subtle)]">
|
||||
{featuredItem.container}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap items-center gap-4 pt-2">
|
||||
<Link to={`/media/${featuredItem.id}`} className="inline-flex items-center justify-center gap-2 rounded-xl bg-[var(--app-command-bg)] px-6 py-3.5 text-sm font-bold text-[var(--app-command-text)] shadow-lg transition-all hover:-translate-y-0.5">
|
||||
<Link
|
||||
to={`/play/${currentItem.id}`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-[var(--app-command-bg)] px-6 py-3.5 text-sm font-bold text-[var(--app-command-text)] shadow-lg transition-all hover:-translate-y-0.5 hover:shadow-brand-500/25"
|
||||
>
|
||||
<Play size={16} fill="currentColor" />
|
||||
<span>立即播放</span>
|
||||
</Link>
|
||||
<Link to="/discover" className="inline-flex items-center justify-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-5 py-3.5 text-sm font-bold text-[var(--app-subtle)] shadow-sm transition-all hover:-translate-y-0.5 hover:border-brand-500/40 hover:text-[var(--app-text)]">
|
||||
<span>发现更多精彩</span>
|
||||
<Link
|
||||
to={`/media/${currentItem.id}`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-5 py-3.5 text-sm font-bold text-[var(--app-subtle)] shadow-sm transition-all hover:-translate-y-0.5 hover:border-brand-500/40 hover:text-[var(--app-text)]"
|
||||
>
|
||||
<span>查看详情</span>
|
||||
<ArrowRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative order-first mx-auto flex w-full max-w-[220px] items-center md:order-none md:max-w-[260px] lg:max-w-[310px]">
|
||||
<div className="absolute -right-6 top-5 h-32 w-32 rounded-full bg-[#d4af37]/20 blur-3xl" />
|
||||
{/* Right Column: Floating 3D Poster Card */}
|
||||
<div className="relative order-first mx-auto flex w-full max-w-[200px] items-center md:order-none md:max-w-[240px] lg:max-w-[280px]">
|
||||
<div className="absolute -right-6 top-5 h-32 w-32 rounded-full bg-brand-500/20 blur-3xl" />
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden rounded-[1.7rem] border border-[var(--app-border)] bg-[var(--app-poster-shell)] p-2 shadow-[0_32px_80px_var(--app-shadow)]">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center rounded-[1.25rem] text-center" style={{ background: 'var(--app-poster-empty)' }}>
|
||||
<div
|
||||
className="flex h-full w-full flex-col items-center justify-center rounded-[1.25rem] text-center"
|
||||
style={{ background: 'var(--app-poster-empty)' }}
|
||||
>
|
||||
<Film className="mb-4 h-12 w-12 text-[#c9954a]" />
|
||||
<span className="px-6 font-display text-3xl font-black tracking-tight text-[var(--app-text)]">{featuredItem.title}</span>
|
||||
<span className="px-6 font-display text-2xl font-black tracking-tight text-[var(--app-text)]">
|
||||
{currentItem.title}
|
||||
</span>
|
||||
</div>
|
||||
{featuredPoster && (
|
||||
{poster && (
|
||||
<img
|
||||
src={imageURL(featuredPoster, featuredItem.updated_at)}
|
||||
alt={featuredItem.title}
|
||||
src={imageURL(poster, currentItem.updated_at)}
|
||||
alt={currentItem.title}
|
||||
className="absolute inset-2 h-[calc(100%-1rem)] w-[calc(100%-1rem)] rounded-[1.25rem] object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(event) => { event.currentTarget.style.display = 'none' }}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Carousel Controls & Dots */}
|
||||
{count > 1 && (
|
||||
<div className="relative z-20 flex items-center justify-between border-t border-[var(--app-border)]/40 bg-[var(--app-panel)]/40 px-6 py-3 backdrop-blur-md">
|
||||
{/* Navigation Indicators */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{items.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => setCurrentIndex(idx)}
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
idx === currentIndex
|
||||
? 'w-7 bg-brand-500'
|
||||
: 'w-2 bg-[var(--app-border)] hover:bg-[var(--app-muted)]'
|
||||
}`}
|
||||
title={`第 ${idx + 1} 张海报`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Slide Number & Arrow Buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-xs font-bold text-[var(--app-muted)]">
|
||||
{String(currentIndex + 1).padStart(2, '0')} / {String(count).padStart(2, '0')}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrev}
|
||||
className="rounded-xl border border-[var(--app-border)] p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors"
|
||||
title="上一张"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
className="rounded-xl border border-[var(--app-border)] p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors"
|
||||
title="下一张"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContinueWatchingSection({ history }: { history: HistoryItem[] }) {
|
||||
/* =========================================================================
|
||||
2. 媒体库卡片区 (Libraries Grid)
|
||||
========================================================================= */
|
||||
|
||||
export function HomeLibrariesSection({
|
||||
libraries,
|
||||
libraryData,
|
||||
libraryCounts,
|
||||
}: {
|
||||
libraries: Library[]
|
||||
libraryData?: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>
|
||||
libraryCounts: Record<string, number>
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-1.5 text-[var(--app-text)]">
|
||||
<Clock size={16} />
|
||||
</span>
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">继续观看</h2>
|
||||
<span className="rounded-full border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2.5 py-0.5 text-xs font-bold text-[var(--app-muted)]">{history.length} 个记录</span>
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--app-border)] pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-1.5 text-[var(--app-text)]">
|
||||
<LibraryIcon size={18} />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">
|
||||
媒体库
|
||||
</h2>
|
||||
<p className="text-xs text-[var(--app-muted)]">
|
||||
点击卡片浏览对应媒体库精选内容
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/libraries"
|
||||
className="group inline-flex items-center gap-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:text-brand-500"
|
||||
>
|
||||
<span>全部媒体库</span>
|
||||
<ArrowRight size={14} className="transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
|
||||
{history.slice(0, 8).map((h) => {
|
||||
const media = h.media!
|
||||
const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
|
||||
return <ContinueCard key={h.id} media={media} progress={progress} />
|
||||
{/* Libraries Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-6">
|
||||
{libraries.map((lib) => {
|
||||
const count = libraryCounts[lib.id] ?? 0
|
||||
const cards = libraryData?.[lib.id]?.cards || []
|
||||
const artwork = getLibraryArtworks(lib, cards)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={lib.id}
|
||||
to={`/library/${lib.id}`}
|
||||
className="group relative flex flex-col justify-between overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3 transition-all duration-300 hover:-translate-y-1 hover:border-brand-500/50 hover:bg-[var(--app-hover)]/40 hover:shadow-lg hover:shadow-brand-500/10"
|
||||
>
|
||||
{/* 封面图片展示区:和媒体库页面一样,显示设置好的或生成的图片 */}
|
||||
<div
|
||||
className={`relative h-28 w-full overflow-hidden rounded-xl bg-[linear-gradient(135deg,var(--app-panel-soft),var(--app-panel))] shadow-inner ${
|
||||
artwork.length > 1 ? 'grid grid-cols-2 gap-0.5' : ''
|
||||
}`}
|
||||
>
|
||||
{artwork.length > 0 ? (
|
||||
artwork.map(({ src, version }, index) => (
|
||||
<img
|
||||
key={`${src}-${index}`}
|
||||
src={imageURL(src, version)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.visibility = 'hidden'
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-brand-500">
|
||||
{TYPE_ICONS[lib.type] || <FolderOpen size={28} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 浮动类型标签 */}
|
||||
<div className="absolute top-2 right-2 rounded-lg border border-white/20 bg-black/60 px-2 py-0.5 text-[10px] font-bold text-white backdrop-blur-md shadow-sm">
|
||||
{TYPE_LABELS[lib.type] || '自定义'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 媒体库信息 */}
|
||||
<div className="mt-3 flex flex-col justify-between">
|
||||
<h3 className="truncate font-display text-sm font-bold text-[var(--app-text)] group-hover:text-brand-500">
|
||||
{lib.name}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-[var(--app-muted)]">
|
||||
{count > 0 ? `${count} 部媒体` : '暂无条目'}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function RecentMediaSection({ recentCards }: { recentCards: SeriesCard[] }) {
|
||||
/* =========================================================================
|
||||
3. 单个媒体库内容行 (Horizontal Scroll Row)
|
||||
========================================================================= */
|
||||
|
||||
export function HomeLibraryRowSection({
|
||||
library,
|
||||
cards,
|
||||
}: {
|
||||
library: Library
|
||||
cards: SeriesCard[]
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
if (!cards || cards.length === 0) return null
|
||||
|
||||
const scroll = (direction: 'left' | 'right') => {
|
||||
if (scrollRef.current) {
|
||||
const scrollAmount = direction === 'left' ? -480 : 480
|
||||
scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<section className="space-y-4">
|
||||
{/* Row Header */}
|
||||
<div className="flex items-center justify-between border-b border-[var(--app-border)] pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-1.5 text-brand-500">
|
||||
{TYPE_ICONS[library.type] || <FolderOpen size={18} />}
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">
|
||||
{library.name}
|
||||
</h2>
|
||||
<span className="text-xs text-[var(--app-muted)]">
|
||||
共 {cards.length} 部精选内容
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to={`/library/${library.id}`}
|
||||
className="group inline-flex items-center gap-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:text-brand-500"
|
||||
>
|
||||
<span>查看全部</span>
|
||||
<ArrowRight size={14} className="transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
|
||||
{/* Scroll Buttons */}
|
||||
<div className="hidden sm:flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scroll('left')}
|
||||
className="rounded-xl border border-[var(--app-border)] p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors"
|
||||
title="向左滚动"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scroll('right')}
|
||||
className="rounded-xl border border-[var(--app-border)] p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors"
|
||||
title="向右滚动"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Horizontal Carousel Row */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-4 overflow-x-auto pb-3 pt-1 no-scrollbar scroll-smooth snap-x"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.key}
|
||||
className="w-28 sm:w-32 md:w-36 lg:w-40 shrink-0 snap-start"
|
||||
>
|
||||
<MediaCard
|
||||
media={card.rep}
|
||||
count={card.count}
|
||||
linkTo={seriesCardLink(card)}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
4. 继续观看行 (Continue Watching)
|
||||
========================================================================= */
|
||||
|
||||
export function ContinueWatchingSection({ history }: { history: HistoryItem[] }) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const scroll = (direction: 'left' | 'right') => {
|
||||
if (scrollRef.current) {
|
||||
const scrollAmount = direction === 'left' ? -400 : 400
|
||||
scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-[var(--app-border)] pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-1.5 text-[var(--app-text)]">
|
||||
<Clock size={18} />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">最近入库</h2>
|
||||
<p className="text-xs text-[var(--app-muted)]">按整部电影、剧集、番剧和综艺合集展示新增内容。</p>
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">
|
||||
继续观看
|
||||
</h2>
|
||||
<span className="text-xs text-[var(--app-muted)]">
|
||||
{history.length} 条播放记录
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/poster-wall" className="group inline-flex items-center gap-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:text-brand-500">
|
||||
<span>海报墙</span>
|
||||
<ArrowRight size={14} className="transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
|
||||
<div className="hidden sm:flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scroll('left')}
|
||||
className="rounded-xl border border-[var(--app-border)] p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors"
|
||||
title="向左滚动"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scroll('right')}
|
||||
className="rounded-xl border border-[var(--app-border)] p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors"
|
||||
title="向右滚动"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7">
|
||||
{recentCards.map((card) => (
|
||||
<MediaCard
|
||||
key={card.key}
|
||||
media={card.rep}
|
||||
count={card.count}
|
||||
linkTo={seriesCardLink(card)}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-4 overflow-x-auto pb-3 pt-1 no-scrollbar scroll-smooth"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{history.slice(0, 12).map((h) => {
|
||||
const media = h.media!
|
||||
const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
|
||||
return (
|
||||
<div key={h.id} className="w-64 sm:w-72 shrink-0">
|
||||
<ContinueCard media={media} progress={progress} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
@@ -197,42 +590,50 @@ export function RecentMediaSection({ recentCards }: { recentCards: SeriesCard[]
|
||||
|
||||
function ContinueCard({ media, progress }: { media: Media; progress: number }) {
|
||||
return (
|
||||
<Link to={`/media/${media.id}`} className="group flex items-center gap-4 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3.5 shadow-[0_1px_3px_rgba(0,0,0,0.01)] transition-all duration-300 hover:border-brand-500/30 hover:bg-[var(--app-panel-soft)] hover:shadow-md">
|
||||
<div className="relative h-18 w-12 shrink-0 overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
|
||||
<Link
|
||||
to={`/media/${media.id}`}
|
||||
className="group flex items-center gap-3.5 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3 shadow-sm transition-all duration-300 hover:border-brand-500/30 hover:bg-[var(--app-panel-soft)] hover:shadow-md"
|
||||
>
|
||||
<div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-xl bg-[var(--app-panel-soft)]">
|
||||
{media.poster_url ? (
|
||||
<img
|
||||
src={imageURL(media.poster_url, media.updated_at)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-[var(--app-panel-soft)] text-[var(--app-muted)]">
|
||||
<Film size={16} />
|
||||
<div className="flex h-full w-full items-center justify-center text-[var(--app-muted)]">
|
||||
<Film size={18} />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Play size={14} fill="white" className="text-white" />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Play size={16} fill="currentColor" className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<p className="truncate text-sm font-bold text-[var(--app-text)] transition-colors group-hover:text-brand-500">
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="truncate text-xs font-bold text-[var(--app-text)] group-hover:text-brand-500">
|
||||
{media.title}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-[var(--app-hover)]">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${Math.round(progress * 100)}%` }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="h-full rounded-full bg-gradient-to-r from-brand-400 to-brand-500"
|
||||
<div className="flex items-center gap-2 text-[10px] text-[var(--app-muted)]">
|
||||
{media.year > 0 && <span>{media.year}</span>}
|
||||
{media.season_num !== undefined && media.episode_num !== undefined && (
|
||||
<span>
|
||||
S{media.season_num}E{media.episode_num}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{progress > 0 && progress < 1 && (
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-[var(--app-hover)]">
|
||||
<div
|
||||
className="h-full bg-brand-500 rounded-full"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wide text-[var(--app-muted)]">
|
||||
已观看到 {Math.round(progress * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -6,9 +6,10 @@ import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, PlayCircle
|
||||
import { imageURL } from '../api/client'
|
||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import { artworkScore, seriesCardLink, type SeriesCard } from '../utils/groupSeries'
|
||||
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
|
||||
import { seriesCardLink } from '../utils/groupSeries'
|
||||
import { libraryDisplayPath } from './libraryDisplayModel'
|
||||
import { mediaTime, type LibraryPreview } from './librariesPageModel'
|
||||
import { libraryArtworkItems, type LibraryPreview } from './librariesPageModel'
|
||||
|
||||
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||
movie: <Film size={18} />,
|
||||
@@ -71,10 +72,9 @@ export function LibrariesHeader({
|
||||
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
|
||||
{repairing ? '正在启动…' : '全库修复+重刮'}
|
||||
</button>
|
||||
<Link to="/admin" className="btn-outline">
|
||||
<button type="button" onClick={() => openManageLibrariesDialog()} className="btn-outline">
|
||||
管理媒体库
|
||||
<ArrowRight size={14} />
|
||||
</Link>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -223,14 +223,3 @@ function LibraryShelf({ preview }: { preview: LibraryPreview }) {
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function libraryArtworkItems(cards: SeriesCard[]): Array<{ src: string; version?: string }> {
|
||||
return [...cards]
|
||||
.sort((a, b) => artworkScore(b.rep) - artworkScore(a.rep) || mediaTime(b.rep) - mediaTime(a.rep))
|
||||
.map((card) => ({
|
||||
src: card.rep.poster_url || card.rep.backdrop_url || '',
|
||||
version: card.rep.updated_at,
|
||||
}))
|
||||
.filter((item) => Boolean(item.src))
|
||||
.slice(0, 4)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Check,
|
||||
CheckSquare,
|
||||
Film,
|
||||
FolderOpen,
|
||||
HeartHandshake,
|
||||
Layers,
|
||||
Loader2,
|
||||
Music,
|
||||
Save,
|
||||
SlidersHorizontal,
|
||||
Square,
|
||||
Tv,
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import { imageURL } from '../api/client'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library, Setting } from '../types'
|
||||
import { groupSeries, type SeriesCard } from '../utils/groupSeries'
|
||||
import { getLibraryArtworks } from './librariesPageModel'
|
||||
|
||||
const CAROUSEL_STORAGE_KEY = 'mmtl.home.carousel_libraries'
|
||||
const SETTING_KEY_CAROUSEL = 'home.carousel_libraries'
|
||||
|
||||
const TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
movie: <Film size={20} />,
|
||||
movies: <Film size={20} />,
|
||||
tv: <Tv size={20} />,
|
||||
series: <Tv size={20} />,
|
||||
anime: <Layers size={20} />,
|
||||
shows: <Tv size={20} />,
|
||||
variety: <Tv size={20} />,
|
||||
music: <Music size={20} />,
|
||||
adult: <HeartHandshake size={20} />,
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
movie: '电影',
|
||||
movies: '电影',
|
||||
tv: '剧集',
|
||||
series: '剧集',
|
||||
anime: '动漫',
|
||||
shows: '综艺',
|
||||
variety: '综艺',
|
||||
music: '音乐',
|
||||
adult: 'Adult',
|
||||
}
|
||||
|
||||
export function LibrarySettingsPanel() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [libraryCards, setLibraryCards] = useState<Record<string, SeriesCard[]>>({})
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [libs, settings] = await Promise.all([
|
||||
libraryAPI.list({ includeHidden: true }).catch(() => [] as Library[]),
|
||||
adminAPI.listSettings().catch(() => [] as Setting[]),
|
||||
])
|
||||
|
||||
const libList = Array.isArray(libs) ? libs : []
|
||||
setLibraries(libList)
|
||||
|
||||
// 异步拉取各个媒体库的前几个条目用于封面展示
|
||||
Promise.allSettled(
|
||||
libList.map(async (lib) => {
|
||||
const page = await libraryAPI.listMedia(lib.id, 1, 10)
|
||||
const items = Array.isArray(page?.items) ? page.items : []
|
||||
return { id: lib.id, cards: groupSeries(items) }
|
||||
}),
|
||||
).then((results) => {
|
||||
const map: Record<string, SeriesCard[]> = {}
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled' && r.value) {
|
||||
map[r.value.id] = r.value.cards
|
||||
}
|
||||
}
|
||||
setLibraryCards(map)
|
||||
})
|
||||
|
||||
// 优先读取系统配置,其次读取 localStorage,默认全选
|
||||
const settingItem = Array.isArray(settings)
|
||||
? settings.find((s) => s.key === SETTING_KEY_CAROUSEL)
|
||||
: undefined
|
||||
|
||||
let initialIds: string[] | null = null
|
||||
if (settingItem?.value) {
|
||||
try {
|
||||
initialIds = JSON.parse(settingItem.value)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!initialIds) {
|
||||
try {
|
||||
const saved = localStorage.getItem(CAROUSEL_STORAGE_KEY)
|
||||
if (saved) initialIds = JSON.parse(saved)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(initialIds)) {
|
||||
setSelectedIds(initialIds)
|
||||
} else {
|
||||
setSelectedIds(libList.map((l) => l.id))
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const toggleLibrary = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]
|
||||
setDirty(true)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedIds(libraries.map((l) => l.id))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const deselectAll = () => {
|
||||
setSelectedIds([])
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const jsonValue = JSON.stringify(selectedIds)
|
||||
await adminAPI.updateSetting(SETTING_KEY_CAROUSEL, jsonValue)
|
||||
try {
|
||||
localStorage.setItem(CAROUSEL_STORAGE_KEY, jsonValue)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
toast.success('海报轮播设置已保存')
|
||||
setDirty(false)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'保存设置失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12 text-ink-50">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-panel space-y-6">
|
||||
{/* 头部说明与快捷操作 */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between border-b border-gray-200/80 pb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-xl border border-primary-400/40 bg-primary-400/10 p-2 text-brand-500 mt-0.5">
|
||||
<SlidersHorizontal size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-display text-lg font-bold text-ink-600">首页海报轮播设置</h3>
|
||||
<p className="text-xs text-sand-500 mt-0.5">
|
||||
选择参与首页顶部大图海报轮播推荐的媒体库。勾选的媒体库内容将轮流展示在系统首页顶部。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectAll}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-ink-100 transition hover:border-primary-400/50 hover:text-brand-500"
|
||||
>
|
||||
<CheckSquare size={14} />
|
||||
<span>全选</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={deselectAll}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-semibold text-ink-100 transition hover:border-primary-400/50 hover:text-brand-500"
|
||||
>
|
||||
<Square size={14} />
|
||||
<span>清空</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 媒体库列表卡片 */}
|
||||
{libraries.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-sand-500">
|
||||
暂无可用媒体库,请先添加媒体库后再配置海报轮播。
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{libraries.map((lib) => {
|
||||
const isSelected = selectedIds.includes(lib.id)
|
||||
const cards = libraryCards[lib.id] || []
|
||||
const artwork = getLibraryArtworks(lib, cards)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lib.id}
|
||||
onClick={() => toggleLibrary(lib.id)}
|
||||
className={`flex cursor-pointer items-center justify-between rounded-2xl border p-4 transition-all duration-200 select-none ${
|
||||
isSelected
|
||||
? 'border-brand-500/60 bg-primary-400/10 shadow-sm'
|
||||
: 'border-gray-200 bg-white/70 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className={`grid h-12 w-16 shrink-0 gap-0.5 overflow-hidden rounded-xl bg-gray-100 shadow-inner transition-colors ${
|
||||
artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'
|
||||
} ${
|
||||
isSelected ? 'ring-2 ring-brand-500/30' : ''
|
||||
}`}
|
||||
>
|
||||
{artwork.length > 0 ? (
|
||||
artwork.map(({ src, version }, index) => (
|
||||
<img
|
||||
key={`${src}-${index}`}
|
||||
src={imageURL(src, version)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-ink-50">
|
||||
{TYPE_ICONS[lib.type] || <FolderOpen size={20} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-display text-sm font-bold text-ink-600">
|
||||
{lib.name}
|
||||
</span>
|
||||
<span className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold border border-gray-200 bg-gray-50 text-ink-50">
|
||||
{TYPE_LABELS[lib.type] || '自定义'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-sand-500 mt-0.5">
|
||||
{isSelected ? '已启用轮播' : '未参与轮播'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border transition-colors ${
|
||||
isSelected
|
||||
? 'border-brand-500 bg-brand-500 text-white'
|
||||
: 'border-gray-300 bg-white text-transparent'
|
||||
}`}
|
||||
>
|
||||
<Check size={14} strokeWidth={3} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部保存按钮 */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-200/80">
|
||||
<span className="text-xs text-sand-500">
|
||||
已选择 {selectedIds.length} / {libraries.length} 个媒体库参与轮播
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
className="neon-button disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
保存设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Database, HardDrive, PieChart, RefreshCw } from 'lucide-react'
|
||||
|
||||
import { storageAPI, type StorageBreakdown } from '../api/storage'
|
||||
|
||||
export function fmtBytes(n: number): string {
|
||||
if (!n) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < u.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(2)} ${u[i]}`
|
||||
}
|
||||
|
||||
export function fmtHours(seconds: number): string {
|
||||
if (!seconds) return '—'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
return `${h.toLocaleString()} h`
|
||||
}
|
||||
|
||||
export function LibraryStorageStats() {
|
||||
const [data, setData] = useState<StorageBreakdown | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const loadData = () => {
|
||||
setRefreshing(true)
|
||||
storageAPI
|
||||
.breakdown()
|
||||
.then(setData)
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="glass-panel py-8 text-center text-sm text-sand-500">
|
||||
加载统计数据中…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="glass-panel py-8 text-center text-sm text-sand-500">
|
||||
无法获取存储数据
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const totalBytes = data.total_bytes || 1
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-display text-xl font-semibold text-ink-600">存储与统计</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadData}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white/80 px-2.5 py-1 text-xs text-ink-100 transition hover:border-primary-400/50 hover:text-brand-500 disabled:opacity-50"
|
||||
title="刷新统计数据"
|
||||
>
|
||||
<RefreshCw size={13} className={refreshing ? 'animate-spin' : ''} />
|
||||
<span>刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-4 sm:grid-cols-3">
|
||||
<StatTile icon={<Database size={20} />} label="总占用" value={fmtBytes(data.total_bytes)} />
|
||||
<StatTile icon={<PieChart size={20} />} label="媒体库" value={`${data.by_library.length}`} />
|
||||
<StatTile icon={<HardDrive size={20} />} label="累计时长" value={fmtHours(data.total_seconds)} />
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-display text-lg font-semibold text-ink-600">按媒体库</h3>
|
||||
<div className="glass-panel overflow-x-auto !p-3">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
<th>类型</th>
|
||||
<th>媒体数</th>
|
||||
<th>占用</th>
|
||||
<th>占比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.by_library.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-4 text-center text-xs text-sand-500">
|
||||
暂无媒体库数据
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.by_library.map((l) => {
|
||||
const pct = (l.total_bytes / totalBytes) * 100
|
||||
return (
|
||||
<tr key={l.library_id} className="border-t border-gray-200">
|
||||
<td className="py-2 text-ink-600 font-medium">{l.name}</td>
|
||||
<td className="text-ink-100">{l.type}</td>
|
||||
<td className="text-ink-100">{l.media_count}</td>
|
||||
<td className="text-ink-100">{fmtBytes(l.total_bytes)}</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-24 overflow-hidden rounded bg-gray-200">
|
||||
<div
|
||||
className="h-full bg-primary-400"
|
||||
style={{ width: `${Math.min(100, Math.max(0, pct))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-ink-50">{pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-display text-lg font-semibold text-ink-600">按容器格式</h3>
|
||||
{data.by_container.length === 0 ? (
|
||||
<div className="glass-panel py-4 text-center text-xs text-sand-500">
|
||||
暂无格式统计
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||
{data.by_container.map((c) => (
|
||||
<div
|
||||
key={c.container}
|
||||
className="glass-panel flex items-center justify-between !p-4"
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-sand-500">{c.container || '未知'}</p>
|
||||
<p className="font-display text-lg font-semibold text-ink-600">{c.count} 项</p>
|
||||
</div>
|
||||
<p className="text-sm text-ink-100">{fmtBytes(c.bytes)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatTile({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-panel flex items-center gap-3 !p-4">
|
||||
<div className="rounded-xl border border-primary-400/40 bg-primary-400/10 p-2 text-brand-500">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-sand-500">{label}</p>
|
||||
<p className="font-display text-lg font-semibold text-ink-600">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { licenseAPI, type LicenseStatus } from '../api/license'
|
||||
import {
|
||||
LicenseBindPanel,
|
||||
LicenseHeader,
|
||||
LicenseInactiveTip,
|
||||
LicenseStatusPanel,
|
||||
} from './LicensePageSections'
|
||||
|
||||
export function LicensePage() {
|
||||
const [status, setStatus] = useState<LicenseStatus | null>(null)
|
||||
const [loadingStatus, setLoadingStatus] = useState(true)
|
||||
const [bindKey, setBindKey] = useState('')
|
||||
const [binding, setBinding] = useState(false)
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
setLoadingStatus(true)
|
||||
try {
|
||||
const s = await licenseAPI.status()
|
||||
setStatus(s)
|
||||
} catch {
|
||||
setStatus({ active: false })
|
||||
} finally {
|
||||
setLoadingStatus(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatus()
|
||||
}, [refreshStatus])
|
||||
|
||||
const onBind = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
const key = bindKey.trim()
|
||||
if (!key) {
|
||||
toast.error('请输入许可证密钥')
|
||||
return
|
||||
}
|
||||
setBinding(true)
|
||||
try {
|
||||
const activation = await licenseAPI.bind(key)
|
||||
toast.success('许可证绑定成功!')
|
||||
setBindKey('')
|
||||
// Optimistically update status
|
||||
setStatus({
|
||||
active: true,
|
||||
activation,
|
||||
max_users: activation.max_users,
|
||||
unlimited_users: activation.unlimited_users,
|
||||
message: '已激活',
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'绑定失败,请检查密钥是否正确'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setBinding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onHeartbeat = async () => {
|
||||
try {
|
||||
await licenseAPI.heartbeat()
|
||||
toast.success('心跳上报成功')
|
||||
} catch {
|
||||
toast.error('心跳上报失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Derive display values ──
|
||||
const active = status?.active === true
|
||||
const activation = status?.activation
|
||||
const isExpired =
|
||||
activation?.expires_at != null && new Date(activation.expires_at).getTime() < Date.now()
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-8">
|
||||
<LicenseHeader />
|
||||
<LicenseBindPanel
|
||||
bindKey={bindKey}
|
||||
binding={binding}
|
||||
onBind={onBind}
|
||||
onBindKeyChange={setBindKey}
|
||||
/>
|
||||
<LicenseStatusPanel
|
||||
status={status}
|
||||
loadingStatus={loadingStatus}
|
||||
active={active}
|
||||
isExpired={isExpired}
|
||||
onRefresh={refreshStatus}
|
||||
onHeartbeat={onHeartbeat}
|
||||
/>
|
||||
<LicenseInactiveTip active={active} loadingStatus={loadingStatus} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import {
|
||||
CheckCircle2,
|
||||
KeySquare,
|
||||
Link2,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
import type { LicenseStatus } from '../api/license'
|
||||
|
||||
export function LicenseHeader() {
|
||||
return (
|
||||
<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-ink-600">许可证</h1>
|
||||
<p className="text-sm text-ink-50">绑定授权密钥以提升多用户容量</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LicenseBindPanel({
|
||||
bindKey,
|
||||
binding,
|
||||
onBind,
|
||||
onBindKeyChange,
|
||||
}: {
|
||||
bindKey: string
|
||||
binding: boolean
|
||||
onBind: (event: FormEvent<HTMLFormElement>) => void
|
||||
onBindKeyChange: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-panel space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 size={18} className="text-brand-500" />
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">绑定许可证</h2>
|
||||
</div>
|
||||
<form onSubmit={onBind} className="flex gap-3">
|
||||
<input
|
||||
className="input-base flex-1 font-mono text-sm tracking-wider"
|
||||
placeholder="MS-XXXX-XXXX-XXXX"
|
||||
value={bindKey}
|
||||
onChange={(event) => onBindKeyChange(event.target.value)}
|
||||
disabled={binding}
|
||||
/>
|
||||
<button type="submit" disabled={binding} className="neon-button shrink-0">
|
||||
{binding ? <Loader2 size={16} className="animate-spin" /> : <Link2 size={16} />}
|
||||
绑定
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-xs text-sand-500">
|
||||
输入从授权服务器获取的许可证密钥,激活后按授权额度开放更多平台用户。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LicenseStatusPanel({
|
||||
status,
|
||||
loadingStatus,
|
||||
active,
|
||||
isExpired,
|
||||
onRefresh,
|
||||
onHeartbeat,
|
||||
}: {
|
||||
status: LicenseStatus | null
|
||||
loadingStatus: boolean
|
||||
active: boolean
|
||||
isExpired: boolean
|
||||
onRefresh: () => void
|
||||
onHeartbeat: () => void
|
||||
}) {
|
||||
const activation = status?.activation
|
||||
|
||||
if (loadingStatus) {
|
||||
return (
|
||||
<div className="flex justify-center py-8 text-ink-50">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-panel space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{active && !isExpired ? (
|
||||
<CheckCircle2 size={18} className="text-emerald-400" />
|
||||
) : (
|
||||
<XCircle size={18} className="text-sand-500" />
|
||||
)}
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">当前状态</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="rounded-lg border border-gray-200 p-2 text-ink-50 hover:bg-gray-50 hover:text-white"
|
||||
title="刷新状态"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
{active && (
|
||||
<button
|
||||
onClick={onHeartbeat}
|
||||
className="rounded-lg border border-primary-400/30 px-3 py-1.5 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
>
|
||||
心跳上报
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!active && (
|
||||
<div className="space-y-3 rounded-xl border border-gray-200 bg-gray-50 p-4 text-center">
|
||||
<p className="text-ink-50">
|
||||
尚未绑定许可证。请在上方输入密钥完成激活。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active && activation && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatusBadge label="密钥" value={activation.key || activation.key_id} mono />
|
||||
<StatusBadge
|
||||
label="套餐"
|
||||
value={activation.plan ?? 'standard'}
|
||||
className={
|
||||
activation.plan === 'enterprise'
|
||||
? 'text-amber-300'
|
||||
: activation.plan === 'pro'
|
||||
? 'text-brand-500'
|
||||
: 'text-ink-100'
|
||||
}
|
||||
/>
|
||||
<StatusBadge label="设备名称" value={activation.device_name || 'Web Client'} />
|
||||
<StatusBadge label="设备数" value={`${activation.max_activations ?? 1} 台`} />
|
||||
<StatusBadge label="绑定时间" value={fmtDate(activation.created_at)} />
|
||||
<StatusBadge
|
||||
label="到期时间"
|
||||
value={fmtDate(activation.expires_at)}
|
||||
className={isExpired ? 'text-red-400' : undefined}
|
||||
/>
|
||||
<StatusBadge label="最近心跳" value={fmtDateTime(activation.heartbeat_at)} />
|
||||
<StatusBadge label="客户端 IP" value={activation.ip ?? '—'} />
|
||||
<StatusBadge
|
||||
label="用户额度"
|
||||
value={fmtUserLimit(status?.max_users, status?.unlimited_users)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpired && (
|
||||
<div className="rounded-xl border border-red-400/20 bg-red-400/5 px-4 py-3 text-sm text-red-400">
|
||||
此许可证已过期,部分功能可能受限。请获取新的许可证密钥。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LicenseInactiveTip({ active, loadingStatus }: { active: boolean; loadingStatus: boolean }) {
|
||||
if (active || loadingStatus) return null
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-5 text-center">
|
||||
<p className="text-sm text-sand-500">
|
||||
需要获取许可证?请联系管理员获取 MediaStationGo 授权密钥。
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
授权服务器地址可在系统设置中配置
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
className,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
mono?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3">
|
||||
<p className="mb-0.5 text-xs text-sand-500">{label}</p>
|
||||
<p className={`text-sm text-ink-600 ${mono ? 'font-mono' : ''} ${className ?? ''}`}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '永久'
|
||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function fmtUserLimit(maxUsers: number | null | undefined, unlimited?: boolean): string {
|
||||
if (unlimited || maxUsers == null) return '不限制'
|
||||
return `${maxUsers} 人`
|
||||
}
|
||||
@@ -2,8 +2,6 @@ import type { FormEvent, ReactNode } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowRight, Eye, EyeOff, Lock, User } from 'lucide-react'
|
||||
|
||||
import { AppFooter } from '../components/AppFooter'
|
||||
|
||||
type LoginPageShellProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
@@ -37,7 +35,6 @@ export function LoginPageShell({ children }: LoginPageShellProps) {
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-gray-50/50 px-4">
|
||||
<LoginBackground />
|
||||
{children}
|
||||
<LoginFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -75,8 +72,8 @@ function LoginBrandHeader() {
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.15, type: 'spring', stiffness: 200 }}
|
||||
src="/brand/mediastationgo-logo.svg"
|
||||
alt="MediaStationGo"
|
||||
src="/brand/logo-192.png"
|
||||
alt="MMTL"
|
||||
className="mb-4 h-14 w-14 rounded-2xl object-contain shadow-sm"
|
||||
/>
|
||||
|
||||
@@ -86,7 +83,7 @@ function LoginBrandHeader() {
|
||||
transition={{ delay: 0.25 }}
|
||||
className="font-display text-2xl font-extrabold tracking-tight text-gray-900"
|
||||
>
|
||||
MediaStationGo
|
||||
MMTL
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
@@ -219,16 +216,3 @@ function LoginReadyLabel() {
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginFooter() {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="relative z-10 mt-10 text-gray-500 transition-colors hover:text-gray-600"
|
||||
>
|
||||
<AppFooter />
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Loader2, Pencil, Send, Trash2 } from 'lucide-react'
|
||||
|
||||
import type { NotifyChannel } from '../types'
|
||||
import { channelSummary, eventSummary, TYPE_LABELS } from './notifyChannelsModel'
|
||||
|
||||
type NotifyChannelCardProps = {
|
||||
channel: NotifyChannel
|
||||
onTest: () => void
|
||||
testing?: boolean
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
export function NotifyChannelCard({
|
||||
channel,
|
||||
onTest,
|
||||
testing,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: NotifyChannelCardProps) {
|
||||
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-ink-600">{channel.name}</span>
|
||||
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs text-ink-50">
|
||||
{TYPE_LABELS[channel.type] ?? channel.type}
|
||||
</span>
|
||||
{!channel.enabled && (
|
||||
<span className="rounded-lg bg-sand-500/30 px-2 py-0.5 text-xs text-ink-100">已禁用</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-ink-50">{summary}</div>
|
||||
<div className="mt-1 truncate text-xs text-sand-500">{eventSummary(channel.events)}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
{testing ? <Loader2 size={12} className="inline animate-spin" /> : <Send size={12} className="inline" />} 测试
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
<Pencil size={12} className="inline" /> 编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="rounded-lg 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>
|
||||
)
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Field } from './NotifyChannelFormField'
|
||||
|
||||
type ConfigControlProps = {
|
||||
config: Record<string, string>
|
||||
updateConfig: (key: string, value: string) => void
|
||||
}
|
||||
|
||||
type ConfigInputProps = ConfigControlProps & {
|
||||
label: string
|
||||
name: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
type?: string
|
||||
}
|
||||
|
||||
export function ConfigInput({
|
||||
config,
|
||||
updateConfig,
|
||||
label,
|
||||
name,
|
||||
placeholder,
|
||||
required,
|
||||
type,
|
||||
}: ConfigInputProps) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<input
|
||||
required={required}
|
||||
type={type}
|
||||
className="input-base"
|
||||
placeholder={placeholder}
|
||||
value={config[name] ?? ''}
|
||||
onChange={(event) => updateConfig(name, event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
type ConfigSelectProps = ConfigControlProps & {
|
||||
label: string
|
||||
name: string
|
||||
options: Array<{ value: string; label: string }>
|
||||
defaultValue?: string
|
||||
}
|
||||
|
||||
export function ConfigSelect({
|
||||
config,
|
||||
updateConfig,
|
||||
label,
|
||||
name,
|
||||
options,
|
||||
defaultValue = '',
|
||||
}: ConfigSelectProps) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<select
|
||||
className="input-base"
|
||||
value={config[name] ?? defaultValue}
|
||||
onChange={(event) => updateConfig(name, event.target.value)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
type ConfigTextareaProps = ConfigControlProps & {
|
||||
label: string
|
||||
name: string
|
||||
placeholder?: string
|
||||
rows: number
|
||||
}
|
||||
|
||||
export function ConfigTextarea({
|
||||
config,
|
||||
updateConfig,
|
||||
label,
|
||||
name,
|
||||
placeholder,
|
||||
rows,
|
||||
}: ConfigTextareaProps) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<textarea
|
||||
rows={rows}
|
||||
className="input-base font-mono text-xs"
|
||||
placeholder={placeholder}
|
||||
value={config[name] ?? ''}
|
||||
onChange={(event) => updateConfig(name, event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { EVENT_OPTIONS, type EventMode } from './notifyChannelsModel'
|
||||
import { Field } from './NotifyChannelFormField'
|
||||
|
||||
type NotifyChannelEventFieldsProps = {
|
||||
events: string[]
|
||||
eventMode: EventMode
|
||||
setEventMode: (mode: EventMode) => void
|
||||
toggleEvent: (event: string) => void
|
||||
}
|
||||
|
||||
export function NotifyChannelEventFields({
|
||||
events,
|
||||
eventMode,
|
||||
setEventMode,
|
||||
toggleEvent,
|
||||
}: NotifyChannelEventFieldsProps) {
|
||||
return (
|
||||
<Field label="推送事件">
|
||||
<div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50/60 p-3">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="radio"
|
||||
name="notify-event-mode"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={eventMode === 'all'}
|
||||
onChange={() => setEventMode('all')}
|
||||
/>
|
||||
全部事件
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="radio"
|
||||
name="notify-event-mode"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={eventMode === 'none'}
|
||||
onChange={() => setEventMode('none')}
|
||||
/>
|
||||
关闭全部推送事件
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="radio"
|
||||
name="notify-event-mode"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={eventMode === 'custom'}
|
||||
onChange={() => setEventMode('custom')}
|
||||
/>
|
||||
仅推送勾选事件
|
||||
</label>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{EVENT_OPTIONS.map((event) => (
|
||||
<label key={event.value} className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
disabled={eventMode !== 'custom'}
|
||||
checked={events.includes(event.value)}
|
||||
onChange={() => toggleEvent(event.value)}
|
||||
/>
|
||||
{event.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-ink-100">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
notifyChannelsAPI,
|
||||
type NotifyChannelInput,
|
||||
} from '../api/notify_channels'
|
||||
import type { NotifyChannel } from '../types'
|
||||
import {
|
||||
EMPTY_CONFIG,
|
||||
EVENT_ALL,
|
||||
EVENT_NONE,
|
||||
EVENT_OPTIONS,
|
||||
type EventMode,
|
||||
initialEventMode,
|
||||
normalizeInitialConfig,
|
||||
} from './notifyChannelsModel'
|
||||
import { Field } from './NotifyChannelFormField'
|
||||
import { NotifyChannelEventFields } from './NotifyChannelEventFields'
|
||||
import {
|
||||
BarkFields,
|
||||
EmailFields,
|
||||
TelegramFields,
|
||||
WebhookFields,
|
||||
WechatFields,
|
||||
} from './NotifyChannelProviderFields'
|
||||
|
||||
type NotifyChannelFormModalProps = {
|
||||
editing: NotifyChannel | null
|
||||
onClose: () => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function NotifyChannelFormModal({
|
||||
editing,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: NotifyChannelFormModalProps) {
|
||||
const [name, setName] = useState(editing?.name ?? '')
|
||||
const [type, setType] = useState<NotifyChannel['type']>(
|
||||
editing?.type ?? 'telegram',
|
||||
)
|
||||
const [config, setConfig] = useState<Record<string, string>>(
|
||||
normalizeInitialConfig(editing?.type ?? 'telegram', editing?.config ?? {}),
|
||||
)
|
||||
const [events, setEvents] = useState<string[]>(editing?.events ?? [])
|
||||
const [eventMode, setEventMode] = useState<EventMode>(initialEventMode(editing?.events))
|
||||
const [enabled, setEnabled] = useState(editing?.enabled ?? true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const onTypeChange = (t: NotifyChannel['type']) => {
|
||||
setType(t)
|
||||
setConfig({ ...EMPTY_CONFIG[t] })
|
||||
}
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (type === 'telegram') {
|
||||
if (!String(config.bot_token ?? '').trim()) {
|
||||
toast.error('请填写 Telegram Bot Token')
|
||||
return
|
||||
}
|
||||
if (!String(config.admin_user_ids ?? '').trim()) {
|
||||
toast.error('请填写管理员 Telegram ID')
|
||||
return
|
||||
}
|
||||
}
|
||||
const selectedEvents = events.filter((event) => EVENT_OPTIONS.some((item) => item.value === event))
|
||||
if (eventMode === 'custom' && selectedEvents.length === 0) {
|
||||
toast.error('请至少选择一个推送事件,或选择关闭全部推送事件')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const cleanedConfig = Object.fromEntries(
|
||||
Object.entries(config).map(([key, value]) => [key, String(value ?? '').trim()]),
|
||||
)
|
||||
delete cleanedConfig.chat_id
|
||||
const input: NotifyChannelInput = {
|
||||
name: name.trim(),
|
||||
type: type,
|
||||
config: cleanedConfig,
|
||||
events: eventMode === 'all' ? [EVENT_ALL] : eventMode === 'none' ? [EVENT_NONE] : selectedEvents,
|
||||
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 }))
|
||||
|
||||
const toggleEvent = (event: string) => {
|
||||
setEvents((current) =>
|
||||
current.includes(event)
|
||||
? current.filter((item) => item !== event)
|
||||
: [...current.filter((item) => item !== EVENT_ALL && item !== EVENT_NONE), event],
|
||||
)
|
||||
}
|
||||
|
||||
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-ink-600">
|
||||
{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['type'])}
|
||||
>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="wechat">企业微信 / Server酱</option>
|
||||
<option value="bark">Bark (iOS)</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
<option value="email">Email (SMTP)</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{type === 'telegram' && <TelegramFields config={config} updateConfig={updateConfig} />}
|
||||
{type === 'wechat' && <WechatFields config={config} updateConfig={updateConfig} />}
|
||||
{type === 'bark' && <BarkFields config={config} updateConfig={updateConfig} />}
|
||||
{type === 'webhook' && <WebhookFields config={config} updateConfig={updateConfig} />}
|
||||
{type === 'email' && <EmailFields config={config} updateConfig={updateConfig} />}
|
||||
|
||||
<NotifyChannelEventFields
|
||||
events={events}
|
||||
eventMode={eventMode}
|
||||
setEventMode={setEventMode}
|
||||
toggleEvent={toggleEvent}
|
||||
/>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
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-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving && <Loader2 size={16} className="animate-spin" />} 保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import {
|
||||
ConfigInput,
|
||||
ConfigSelect,
|
||||
ConfigTextarea,
|
||||
} from './NotifyChannelConfigControls'
|
||||
|
||||
type ProviderFieldsProps = {
|
||||
config: Record<string, string>
|
||||
updateConfig: (key: string, value: string) => void
|
||||
}
|
||||
|
||||
const METHOD_OPTIONS = [
|
||||
{ value: 'POST', label: 'POST' },
|
||||
{ value: 'GET', label: 'GET' },
|
||||
]
|
||||
|
||||
const TLS_OPTIONS = [
|
||||
{ value: 'true', label: '启用' },
|
||||
{ value: 'false', label: '关闭' },
|
||||
]
|
||||
|
||||
export function TelegramFields({ config, updateConfig }: ProviderFieldsProps) {
|
||||
const control = { config, updateConfig }
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfigInput
|
||||
{...control}
|
||||
required
|
||||
label="Bot Token"
|
||||
name="bot_token"
|
||||
placeholder="123456:ABC-DEF…"
|
||||
/>
|
||||
<ConfigInput
|
||||
{...control}
|
||||
required
|
||||
label="管理员 Telegram ID"
|
||||
name="admin_user_ids"
|
||||
placeholder="多个用逗号分隔,如 123456789,987654321"
|
||||
/>
|
||||
<ConfigInput
|
||||
{...control}
|
||||
label="绑定群组 ID"
|
||||
name="group_chat_id"
|
||||
placeholder="选填,如 -1001234567890;填写后群组成员可唤醒/绑定"
|
||||
/>
|
||||
<ConfigInput
|
||||
{...control}
|
||||
label="绑定频道 ID"
|
||||
name="channel_chat_id"
|
||||
placeholder="选填,如 -1009876543210;填写后频道成员可唤醒/绑定"
|
||||
/>
|
||||
<ConfigInput
|
||||
{...control}
|
||||
label="Telegram API 地址 (可选)"
|
||||
name="api_base_url"
|
||||
placeholder="默认 https://api.telegram.org;反代可填 https://tg.example.com"
|
||||
/>
|
||||
<ConfigInput
|
||||
{...control}
|
||||
label="Telegram 代理 (可选)"
|
||||
name="proxy_url"
|
||||
placeholder="如 http://172.17.0.1:7890 或 socks5://172.17.0.1:1080"
|
||||
/>
|
||||
<div className="rounded-2xl border border-primary-400/15 bg-primary-400/5 px-4 py-3 text-xs leading-6 text-ink-50">
|
||||
群组 ID、频道 ID 均为选填,可填一个、两个都填,也可以不填。管理功能始终仅管理员 Telegram ID 或已绑定的本地管理员可用;普通用户需要在已配置的群组或频道中,才能使用 <code>/start 用户名 密码</code> 绑定账号、切换隐藏成人媒体库和目录。不配置群组/频道时,普通用户不会被放行。若测试通知超时,可填写反代 API 地址或代理地址。
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function WechatFields({ config, updateConfig }: ProviderFieldsProps) {
|
||||
const control = { config, updateConfig }
|
||||
|
||||
return (
|
||||
<ConfigInput
|
||||
{...control}
|
||||
required
|
||||
label="SendKey"
|
||||
name="sendkey"
|
||||
placeholder="SCT…"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function BarkFields({ config, updateConfig }: ProviderFieldsProps) {
|
||||
const control = { config, updateConfig }
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfigInput
|
||||
{...control}
|
||||
required
|
||||
label="设备 Key"
|
||||
name="device_key"
|
||||
/>
|
||||
<ConfigInput
|
||||
{...control}
|
||||
label="服务器地址 (可选)"
|
||||
name="server"
|
||||
placeholder="https://api.day.app"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function WebhookFields({ config, updateConfig }: ProviderFieldsProps) {
|
||||
const control = { config, updateConfig }
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfigInput
|
||||
{...control}
|
||||
required
|
||||
label="URL"
|
||||
name="url"
|
||||
placeholder="https://example.com/notify"
|
||||
/>
|
||||
<ConfigSelect
|
||||
{...control}
|
||||
label="Method"
|
||||
name="method"
|
||||
options={METHOD_OPTIONS}
|
||||
defaultValue="POST"
|
||||
/>
|
||||
<ConfigTextarea
|
||||
{...control}
|
||||
label="Headers (JSON)"
|
||||
name="headers"
|
||||
rows={2}
|
||||
placeholder='{"Content-Type":"application/json"}'
|
||||
/>
|
||||
<ConfigTextarea
|
||||
{...control}
|
||||
label="Body 模板 (支持 {{title}} {{message}})"
|
||||
name="body_template"
|
||||
rows={3}
|
||||
placeholder='{"title":"{{title}}","message":"{{message}}"}'
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmailFields({ config, updateConfig }: ProviderFieldsProps) {
|
||||
const control = { config, updateConfig }
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfigInput
|
||||
{...control}
|
||||
required
|
||||
label="SMTP 地址"
|
||||
name="smtp_host"
|
||||
placeholder="smtp.gmail.com"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ConfigInput
|
||||
{...control}
|
||||
required
|
||||
label="SMTP 端口"
|
||||
name="smtp_port"
|
||||
placeholder="465"
|
||||
/>
|
||||
<ConfigSelect
|
||||
{...control}
|
||||
label="TLS"
|
||||
name="tls"
|
||||
options={TLS_OPTIONS}
|
||||
defaultValue="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ConfigInput
|
||||
{...control}
|
||||
required
|
||||
label="用户名"
|
||||
name="username"
|
||||
/>
|
||||
<ConfigInput
|
||||
{...control}
|
||||
required
|
||||
label="密码"
|
||||
name="password"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
<ConfigInput
|
||||
{...control}
|
||||
required
|
||||
label="发件人"
|
||||
name="from"
|
||||
placeholder="noreply@example.com"
|
||||
/>
|
||||
<ConfigInput
|
||||
{...control}
|
||||
required
|
||||
label="收件人 (多个用逗号分隔)"
|
||||
name="to"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Bell, Loader2, Play, Plus, Square } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { notifyChannelsAPI } from '../api/notify_channels'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import type { NotifyChannel } from '../types'
|
||||
import { NotifyChannelCard } from './NotifyChannelCard'
|
||||
import { NotifyChannelFormModal } from './NotifyChannelFormModal'
|
||||
|
||||
// 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 [testingID, setTestingID] = useState<string | null>(null)
|
||||
const [pollingBusy, setPollingBusy] = useState<'start' | 'stop' | null>(null)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setChannels(await notifyChannelsAPI.list())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const onTest = async (id: string) => {
|
||||
setTestingID(id)
|
||||
try {
|
||||
await notifyChannelsAPI.test(id)
|
||||
toast.success('测试消息已发送')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '发送失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setTestingID(null)
|
||||
}
|
||||
}
|
||||
|
||||
const onStartPolling = async () => {
|
||||
setPollingBusy('start')
|
||||
try {
|
||||
const res = await notifyChannelsAPI.startTelegramPolling()
|
||||
if ((res.started ?? 0) > 0) {
|
||||
toast.success(`已启动 ${res.started} 个 Telegram Bot 轮询`)
|
||||
} else if ((res.already_running ?? 0) > 0) {
|
||||
toast.success('Telegram Bot 轮询已在运行')
|
||||
} else {
|
||||
toast.error(res.errors?.[0] ?? '没有可启动的 Telegram Bot 渠道')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '启动轮询失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setPollingBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const onStopPolling = async () => {
|
||||
setPollingBusy('stop')
|
||||
try {
|
||||
const res = await notifyChannelsAPI.stopTelegramPolling()
|
||||
toast.success(res.stopped > 0 ? `已停止 ${res.stopped} 个 Telegram Bot 轮询` : '当前没有运行中的轮询')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '停止轮询失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setPollingBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (ch: NotifyChannel) => {
|
||||
if (!(await confirmAction({ title: '删除通知渠道', message: `确定删除「${ch.name}」?`, confirmText: '删除' }))) 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-ink-600">通知渠道</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
配置 Telegram / Bark / 企业微信 / Webhook 多通道推送
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
onClick={onStartPolling}
|
||||
disabled={pollingBusy !== null}
|
||||
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:border-primary-400/40 hover:text-brand-500 disabled:opacity-50"
|
||||
title="启动 Telegram Bot 长轮询"
|
||||
>
|
||||
{pollingBusy === 'start' ? <Loader2 size={16} className="inline animate-spin" /> : <Play size={16} className="inline" />}
|
||||
{' '}启动 Bot 轮询
|
||||
</button>
|
||||
<button
|
||||
onClick={onStopPolling}
|
||||
disabled={pollingBusy !== null}
|
||||
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:border-primary-400/40 hover:text-brand-500 disabled:opacity-50"
|
||||
title="停止本地长轮询。"
|
||||
>
|
||||
{pollingBusy === 'stop' ? <Loader2 size={16} className="inline animate-spin" /> : <Square size={16} className="inline" />}
|
||||
{' '}停止轮询
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(null)
|
||||
setShowForm(true)
|
||||
}}
|
||||
className="neon-button"
|
||||
>
|
||||
<Plus size={16} /> 添加渠道
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12 text-ink-50">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && channels.length === 0 && (
|
||||
<div className="glass-panel py-12 text-center text-ink-50">暂无通知渠道</div>
|
||||
)}
|
||||
|
||||
{!loading && channels.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{channels.map((ch) => (
|
||||
<NotifyChannelCard
|
||||
key={ch.id}
|
||||
channel={ch}
|
||||
onTest={() => onTest(ch.id)}
|
||||
testing={testingID === ch.id}
|
||||
onEdit={() => {
|
||||
setEditing(ch)
|
||||
setShowForm(true)
|
||||
}}
|
||||
onDelete={() => onDelete(ch)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<NotifyChannelFormModal
|
||||
editing={editing}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={async () => {
|
||||
setShowForm(false)
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loader2, QrCode } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { cloudAPI, type QRSession, type StorageType } from '../api/storage_config'
|
||||
|
||||
interface QRLoginPanelProps {
|
||||
type: StorageType
|
||||
onCookie: (cookie: string) => void
|
||||
}
|
||||
|
||||
export function QRLoginPanel({ type, onCookie }: QRLoginPanelProps) {
|
||||
const [sess, setSess] = useState<QRSession | null>(null)
|
||||
const [state, setState] = useState<string>('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sess) return
|
||||
let alive = true
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const st = await cloudAPI.qrPoll(type, sess)
|
||||
if (!alive) return
|
||||
setState(st.state)
|
||||
if (st.state === 'confirmed' && st.cookie) {
|
||||
onCookie(st.cookie)
|
||||
toast.success('扫码登录成功,Cookie 已填入,请点击保存')
|
||||
setSess(null)
|
||||
} else if (st.state === 'expired') {
|
||||
toast.error('二维码已过期,请重新获取')
|
||||
setSess(null)
|
||||
}
|
||||
} catch {
|
||||
/* keep polling */
|
||||
}
|
||||
}, 2000)
|
||||
return () => {
|
||||
alive = false
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [onCookie, sess, type])
|
||||
|
||||
const start = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const nextSession = await cloudAPI.qrStart(type)
|
||||
setSess(nextSession)
|
||||
setState('waiting')
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '获取二维码失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={start}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : <QrCode size={14} />}
|
||||
使用 115 App 扫码登录
|
||||
</button>
|
||||
{sess && (
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<img src={sess.qr_image_url} alt="115 QR" className="h-40 w-40 rounded bg-white p-1" />
|
||||
<span className="text-sm text-ink-50">
|
||||
{state === 'scanned' ? '已扫描,请在手机上确认登录…' : '请使用 115 手机 App 扫描二维码…'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Home, RotateCcw, Square, CheckSquare, Trash2 } from 'lucide-react'
|
||||
|
||||
import { recycleAPI } from '../api/recycle'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import type { Media } from '../types'
|
||||
|
||||
export function RecycleBinPage() {
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [batchBusy, setBatchBusy] = useState('')
|
||||
|
||||
const refresh = () =>
|
||||
recycleAPI
|
||||
.list()
|
||||
.then((rows) => {
|
||||
setItems(rows)
|
||||
setSelectedIds((current) => current.filter((id) => rows.some((item) => item.id === id)))
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const allSelected = items.length > 0 && selectedIds.length === items.length
|
||||
const toggleAll = () => {
|
||||
setSelectedIds(allSelected ? [] : items.map((item) => item.id))
|
||||
}
|
||||
const toggleOne = (id: string) => {
|
||||
setSelectedIds((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id])
|
||||
}
|
||||
const runBatch = async (mode: 'restore' | 'purge') => {
|
||||
if (selectedIds.length === 0) return
|
||||
if (mode === 'purge' && !(await confirmAction({
|
||||
title: '批量彻底删除记录',
|
||||
message: `彻底删除选中的 ${selectedIds.length} 条记录? (磁盘文件保留)`,
|
||||
confirmText: '彻底删除',
|
||||
}))) return
|
||||
setBatchBusy(mode)
|
||||
try {
|
||||
const result = await runRecycleBatchRequest(mode, selectedIds)
|
||||
toast.success(`${mode === 'restore' ? '恢复' : '彻底删除'}完成:${result.applied} 条`)
|
||||
setSelectedIds([])
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const response = (err as { response?: { status?: number; data?: { error?: string } }; message?: string })?.response
|
||||
const msg = response?.data?.error || (response?.status ? `批量操作失败 (${response.status})` : (err as { message?: string })?.message || '批量操作失败')
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setBatchBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const runRecycleBatchRequest = async (mode: 'restore' | 'purge', ids: string[]) => {
|
||||
try {
|
||||
return mode === 'restore'
|
||||
? await recycleAPI.restoreMany(ids)
|
||||
: await recycleAPI.purgeMany(ids)
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
if (status !== 404 && status !== 405) throw err
|
||||
let applied = 0
|
||||
for (const id of ids) {
|
||||
if (mode === 'restore') {
|
||||
await recycleAPI.restore(id)
|
||||
} else {
|
||||
await recycleAPI.purge(id)
|
||||
}
|
||||
applied += 1
|
||||
}
|
||||
return { applied, errors: [] as string[] }
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">回收站</h1>
|
||||
<p className="mt-2 text-sm text-ink-50">
|
||||
软删除的媒体保留在数据库中,可以恢复。彻底删除不会移除磁盘上的文件,只会从数据库清除条目。
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-sand-500">系统最多保留最新 200 条回收站记录,超过后会自动清理旧记录。</p>
|
||||
</div>
|
||||
<Link to="/" className="btn-outline shrink-0 py-2.5 px-4 text-sm">
|
||||
<Home size={15} />
|
||||
返回系统首页
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-sand-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && <p className="text-ink-50">回收站为空。</p>}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="glass-panel">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-xs font-semibold text-sand-500">
|
||||
已选择 {selectedIds.length} / {items.length} 条
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => runBatch('restore')}
|
||||
disabled={selectedIds.length === 0 || !!batchBusy}
|
||||
className="btn-outline py-2 px-3 text-xs"
|
||||
>
|
||||
<RotateCcw size={13} />
|
||||
{batchBusy === 'restore' ? '恢复中…' : '批量恢复'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => runBatch('purge')}
|
||||
disabled={selectedIds.length === 0 || !!batchBusy}
|
||||
className="btn-outline py-2 px-3 text-xs !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
{batchBusy === 'purge' ? '删除中…' : '批量彻底删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="w-10 py-2">
|
||||
<button onClick={toggleAll} className="text-sand-500 hover:text-brand-500" aria-label="全选">
|
||||
{allSelected ? <CheckSquare size={16} /> : <Square size={16} />}
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-2">标题</th>
|
||||
<th>路径</th>
|
||||
<th className="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((m) => (
|
||||
<tr key={m.id} className="border-t border-gray-200">
|
||||
<td className="py-2">
|
||||
<button onClick={() => toggleOne(m.id)} className="text-sand-500 hover:text-brand-500" aria-label={`选择 ${m.title}`}>
|
||||
{selectedIds.includes(m.id) ? <CheckSquare size={16} /> : <Square size={16} />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-2 text-ink-600">{m.title}</td>
|
||||
<td className="max-w-md truncate text-ink-50" title={m.path}>
|
||||
{m.path}
|
||||
</td>
|
||||
<td className="space-x-2 py-2 text-right">
|
||||
<button
|
||||
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
onClick={async () => {
|
||||
await recycleAPI.restore(m.id)
|
||||
toast.success('已恢复')
|
||||
await refresh()
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
onClick={async () => {
|
||||
if (!(await confirmAction({ title: '彻底删除记录', message: `彻底删除「${m.title}」? (磁盘文件保留)`, confirmText: '彻底删除' }))) return
|
||||
await recycleAPI.purge(m.id)
|
||||
toast.success('已彻底删除')
|
||||
await refresh()
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Clock, Play } from 'lucide-react'
|
||||
|
||||
import { schedulerAPI, type JobStatus } from '../api/scheduler'
|
||||
|
||||
export function SchedulerPage() {
|
||||
const [jobs, setJobs] = useState<JobStatus[]>([])
|
||||
const [running, setRunning] = useState<string>('')
|
||||
|
||||
const refresh = () => schedulerAPI.status().then(setJobs)
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
const id = window.setInterval(refresh, 5_000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const runNow = async (name: string) => {
|
||||
setRunning(name)
|
||||
try {
|
||||
await schedulerAPI.run(name)
|
||||
toast.success(`${name} 已运行`)
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'运行失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setRunning('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center gap-3">
|
||||
<Clock className="h-6 w-6 text-brand-500" />
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">定时任务</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
后端周期性任务(媒体库扫描、转码缓存清理、回收站自动清理),每 5 秒刷新状态。
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="glass-panel">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">任务</th>
|
||||
<th>间隔</th>
|
||||
<th>上次运行</th>
|
||||
<th>错误</th>
|
||||
<th className="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((j) => (
|
||||
<tr key={j.name} className="border-t border-gray-200">
|
||||
<td className="py-2 font-mono text-ink-600">{j.name}</td>
|
||||
<td className="text-ink-100">{j.interval}</td>
|
||||
<td className="text-ink-50">
|
||||
{j.last_run && new Date(j.last_run).getFullYear() > 2000
|
||||
? new Date(j.last_run).toLocaleString()
|
||||
: '尚未运行'}
|
||||
</td>
|
||||
<td className="text-red-400">{j.last_err || '—'}</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
onClick={() => runNow(j.name)}
|
||||
disabled={running === j.name}
|
||||
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
>
|
||||
<Play size={12} className="inline" /> 立即运行
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle2, Info, Rss } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import type { DiscoverItem } from '../api/discover'
|
||||
import type { ExternalMediaResult } from '../api/ai'
|
||||
import { imageURL } from '../api/client'
|
||||
import { subscriptionsAPI } from '../api/subscriptions'
|
||||
import { DiscoverSubscriptionRules } from './DiscoverDetailModalSections'
|
||||
import {
|
||||
apiErrorMessage,
|
||||
buildDiscoverSubscriptionInput,
|
||||
initialDiscoverSubscriptionForm,
|
||||
} from './discoverDetailModalModel'
|
||||
|
||||
export function ExternalResults({
|
||||
items,
|
||||
}: {
|
||||
items: ExternalMediaResult[]
|
||||
}) {
|
||||
const [detail, setDetail] = useState<ExternalMediaResult | null>(null)
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-semibold text-ink-600">外部数据源</h2>
|
||||
<p className="text-xs text-ink-50">
|
||||
来自 TMDb / 豆瓣 / Bangumi。电影入队最佳资源;剧集/动漫优先整季或全集包,否则按集批量入队。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => {
|
||||
const keyword = item.subscribe_keyword || item.title
|
||||
const key = `${item.source}:${keyword}`
|
||||
return (
|
||||
<article
|
||||
key={key}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setDetail(item)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') setDetail(item)
|
||||
}}
|
||||
className="glass-panel flex cursor-pointer gap-3 !p-3 transition hover:-translate-y-0.5 hover:shadow-lg"
|
||||
>
|
||||
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-xl bg-gray-100">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={imageURL(item.poster_url)}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full bg-primary-400/10 px-2 py-0.5 text-[10px] uppercase text-brand-500">
|
||||
{item.source}
|
||||
</span>
|
||||
{item.media_type && <span className="text-xs text-sand-500">{item.media_type}</span>}
|
||||
{item.year ? <span className="text-xs text-sand-500">{item.year}</span> : null}
|
||||
{item.rating ? <span className="text-xs text-amber-500">★ {item.rating.toFixed(1)}</span> : null}
|
||||
</div>
|
||||
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-ink-50">
|
||||
{item.overview || `订阅关键词:${keyword}`}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-[10px]">
|
||||
<span className={'rounded-full px-2 py-0.5 font-semibold ' + (item.in_library ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-600')}>
|
||||
{item.in_library ? '本地已入库' : '本地未入库'}
|
||||
</span>
|
||||
{isSeriesItem(item) ? (
|
||||
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-ink-100">
|
||||
已有 {item.downloaded_episodes || 0}/{item.total_episodes || '未知'} 集
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setDetail(item)
|
||||
}}
|
||||
className="mt-3 rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10 disabled:opacity-50"
|
||||
>
|
||||
<Rss size={12} className="mr-1 inline" />
|
||||
订阅规则
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{detail && (
|
||||
<ExternalDetailModal
|
||||
item={detail}
|
||||
onClose={() => setDetail(null)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ExternalDetailModal({
|
||||
item,
|
||||
onClose,
|
||||
}: {
|
||||
item: ExternalMediaResult
|
||||
onClose: () => void
|
||||
}) {
|
||||
const missing = item.missing_episodes ?? []
|
||||
const discoverItem = item as unknown as DiscoverItem
|
||||
const [form, setForm] = useState(() => initialDiscoverSubscriptionForm(discoverItem))
|
||||
const [formBusy, setFormBusy] = useState(false)
|
||||
const submit = async () => {
|
||||
setFormBusy(true)
|
||||
try {
|
||||
const sub = await subscriptionsAPI.create(
|
||||
buildDiscoverSubscriptionInput(discoverItem, form, item.source || 'tmdb'),
|
||||
)
|
||||
if (form.run_now) {
|
||||
const run = await subscriptionsAPI.runNow(sub.id)
|
||||
toast.success(run.queued > 0 ? `已订阅并加入 ${run.queued} 个下载` : '已订阅,暂未命中可下载资源')
|
||||
} else {
|
||||
toast.success('已创建订阅')
|
||||
}
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err, '订阅失败'))
|
||||
} finally {
|
||||
setFormBusy(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="max-h-[88vh] w-full max-w-3xl overflow-y-auto rounded-3xl bg-white shadow-2xl" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="grid gap-0 md:grid-cols-[220px,1fr]">
|
||||
<div className="min-h-72 bg-gray-100">
|
||||
{item.poster_url ? (
|
||||
<img src={imageURL(item.poster_url)} alt={item.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full min-h-72 items-center justify-center text-brand-500">
|
||||
<Info size={42} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4 p-5">
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap gap-2 text-xs">
|
||||
<span className="rounded-full bg-primary-400/10 px-2 py-0.5 font-semibold uppercase text-brand-500">{item.source}</span>
|
||||
{item.media_type ? <span className="rounded-full bg-gray-100 px-2 py-0.5 text-ink-100">{item.media_type}</span> : null}
|
||||
{item.year ? <span className="rounded-full bg-gray-100 px-2 py-0.5 text-ink-100">{item.year}</span> : null}
|
||||
{item.rating ? <span className="rounded-full bg-amber-50 px-2 py-0.5 text-amber-600">★ {item.rating.toFixed(1)}</span> : null}
|
||||
</div>
|
||||
<h3 className="font-display text-2xl font-bold text-ink-600">{item.title}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-ink-50">{item.overview || '暂无简介。'}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<StatusBox label="入库状态" value={item.in_library ? '已入库' : '未入库'} ok={item.in_library} />
|
||||
<StatusBox label="本地条目" value={`${item.local_media_count || 0} 个`} />
|
||||
<StatusBox label="剧集进度" value={isSeriesItem(item) ? `${item.downloaded_episodes || 0}/${item.total_episodes || '未知'} 集` : '单部影片'} />
|
||||
</div>
|
||||
|
||||
{isSeriesItem(item) && (
|
||||
<div className="rounded-2xl border border-gray-100 bg-gray-50 p-3 text-sm">
|
||||
<div className="mb-2 font-semibold text-ink-600">缺失情况</div>
|
||||
{missing.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{missing.slice(0, 80).map((episode) => (
|
||||
<span key={episode} className="rounded-full bg-white px-2 py-0.5 text-xs text-ink-100 shadow-sm">第 {episode} 集</span>
|
||||
))}
|
||||
{missing.length > 80 ? <span className="text-xs text-sand-500">还有 {missing.length - 80} 集…</span> : null}
|
||||
</div>
|
||||
) : item.in_library && item.total_episodes ? (
|
||||
<p className="flex items-center gap-1.5 text-emerald-600"><CheckCircle2 size={15} /> 已完整入库</p>
|
||||
) : (
|
||||
<p className="text-sand-500">总集数未知,订阅时会跳过本地已有单集,优先补新集。</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DiscoverSubscriptionRules
|
||||
form={form}
|
||||
busy={formBusy}
|
||||
onChange={(patch) => setForm((current) => ({ ...current, ...patch }))}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button onClick={onClose} className="rounded-xl border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBox({ label, value, ok }: { label: string; value: string; ok?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-100 bg-gray-50 p-3">
|
||||
<div className="text-xs text-sand-500">{label}</div>
|
||||
<div className={'mt-1 font-semibold ' + (ok ? 'text-emerald-600' : 'text-ink-600')}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isSeriesItem(item: ExternalMediaResult) {
|
||||
return ['tv', 'anime', 'variety'].includes((item.media_type || '').toLowerCase())
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
type SearchHeaderProps = {
|
||||
aiOn: boolean
|
||||
aiAvailable: boolean
|
||||
onToggleAI: () => void
|
||||
}
|
||||
|
||||
export function SearchHeader({ aiOn, aiAvailable, onToggleAI }: SearchHeaderProps) {
|
||||
return (
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">搜索</h1>
|
||||
<button
|
||||
className={
|
||||
'neon-button !px-3 !py-1 !text-xs ' +
|
||||
(aiOn ? '!border-accent-400 !bg-accent-400/20 !text-accent-400' : '')
|
||||
}
|
||||
onClick={onToggleAI}
|
||||
title={aiAvailable ? '启用 AI 智能搜索' : '使用本地规则 + 外部数据源搜索'}
|
||||
>
|
||||
<Sparkles size={12} /> {aiOn ? '智能搜索已开启' : '智能搜索'}
|
||||
</button>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { ChangeEvent, FormEvent } from 'react'
|
||||
|
||||
type SearchInputBarProps = {
|
||||
aiOn: boolean
|
||||
query: string
|
||||
onQueryChange: (query: string) => void
|
||||
onAISubmit: (event: FormEvent) => void
|
||||
}
|
||||
|
||||
export function SearchInputBar({ aiOn, query, onQueryChange, onAISubmit }: SearchInputBarProps) {
|
||||
if (aiOn) {
|
||||
return (
|
||||
<form onSubmit={onAISubmit} className="flex flex-wrap gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
className="input-base"
|
||||
placeholder='例如:"2010 年后的科幻电影" / "最近的动漫"'
|
||||
value={query}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onQueryChange(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="neon-button">
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
className="input-base"
|
||||
placeholder="按标题搜索…"
|
||||
value={query}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onQueryChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { seriesCardLink } from '../utils/groupSeries'
|
||||
|
||||
type SearchLocalResultsProps = {
|
||||
localCards: SeriesCard[]
|
||||
itemCount: number
|
||||
searchTotal: number
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export function SearchLocalResults({
|
||||
localCards,
|
||||
itemCount,
|
||||
searchTotal,
|
||||
loading,
|
||||
}: SearchLocalResultsProps) {
|
||||
if (localCards.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm font-semibold text-ink-100">
|
||||
本地媒体库 · {localCards.length} 个合集 / {itemCount} 个条目
|
||||
{loading && searchTotal > itemCount ? ` · 正在加载全部结果 ${itemCount}/${searchTotal}` : ''}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{localCards.map((card) => (
|
||||
<MediaCard
|
||||
key={card.key}
|
||||
media={card.rep}
|
||||
count={card.count}
|
||||
linkTo={seriesCardLink(card)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { ExternalResults } from './SearchExternalResults'
|
||||
import { SearchHeader } from './SearchHeader'
|
||||
import { SearchInputBar } from './SearchInputBar'
|
||||
import { SearchLocalResults } from './SearchLocalResults'
|
||||
import { SearchStatusPanels } from './SearchStatusPanels'
|
||||
import { useSearchPage } from './useSearchPage'
|
||||
|
||||
export function SearchPage() {
|
||||
const search = useSearchPage()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SearchHeader
|
||||
aiOn={search.aiOn}
|
||||
aiAvailable={search.aiAvailable}
|
||||
onToggleAI={() => search.setAiOn((on) => !on)}
|
||||
/>
|
||||
|
||||
<SearchInputBar
|
||||
aiOn={search.aiOn}
|
||||
query={search.q}
|
||||
onQueryChange={search.setQ}
|
||||
onAISubmit={search.onAISubmit}
|
||||
/>
|
||||
|
||||
{search.intent && (
|
||||
<div className="glass-panel !p-3 text-xs text-ink-100">
|
||||
AI 解析:
|
||||
<span className="ml-2 font-mono text-brand-500">{JSON.stringify(search.intent)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchStatusPanels
|
||||
loading={search.loading}
|
||||
error={search.error}
|
||||
showIdle={search.showIdle}
|
||||
showEmpty={search.showEmpty}
|
||||
/>
|
||||
|
||||
<SearchLocalResults
|
||||
localCards={search.localCards}
|
||||
itemCount={search.itemCount}
|
||||
searchTotal={search.searchTotal}
|
||||
loading={search.loading}
|
||||
/>
|
||||
|
||||
{search.externalItems.length > 0 && (
|
||||
<ExternalResults
|
||||
items={search.externalItems}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
type SearchStatusPanelsProps = {
|
||||
loading: boolean
|
||||
error: string
|
||||
showIdle: boolean
|
||||
showEmpty: boolean
|
||||
}
|
||||
|
||||
export function SearchStatusPanels({
|
||||
loading,
|
||||
error,
|
||||
showIdle,
|
||||
showEmpty,
|
||||
}: SearchStatusPanelsProps) {
|
||||
return (
|
||||
<>
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 py-8 text-ink-50">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-primary-400 border-t-transparent" />
|
||||
搜索中…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="glass-panel !border-red-400/30 p-4 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
{showIdle && (
|
||||
<div className="glass-panel flex flex-col items-center gap-2 p-10 text-center">
|
||||
<p className="text-lg text-ink-100">输入关键词开始搜索</p>
|
||||
<p className="text-sm text-sand-500">支持电影、电视剧、动漫等媒体内容的快速搜索</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEmpty && (
|
||||
<div className="glass-panel flex flex-col items-center gap-2 p-10 text-center">
|
||||
<p className="text-lg text-ink-100">未找到匹配的媒体</p>
|
||||
<p className="text-sm text-sand-500">尝试其他关键词,或者添加媒体库后执行扫描</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,37 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Loader2, Save, SettingsIcon } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library, Setting } from '../types'
|
||||
import { APIConfigsPanel } from '../components/APIConfigsPanel'
|
||||
import { AdultSettingsPanel } from './AdultSettingsPanel'
|
||||
import { LibrarySettingsPanel } from './LibrarySettingsPanel'
|
||||
import { RecognitionWordsPanel } from './RecognitionWordsPanel'
|
||||
import { SettingRow } from './SettingsRow'
|
||||
import { SystemUpdatePanel } from './SystemUpdatePanel'
|
||||
import { ALL_KEYS, GROUPS } from './settingsGroups'
|
||||
|
||||
export function SettingsPage() {
|
||||
const [activeGroup, setActiveGroup] = useState(GROUPS[0].key)
|
||||
const [searchParams] = useSearchParams()
|
||||
const [activeGroup, setActiveGroup] = useState(() => {
|
||||
const fromQuery = searchParams.get('group')
|
||||
return GROUPS.some((g) => g.key === fromQuery) ? (fromQuery as string) : 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 [libraries, setLibraries] = useState<Library[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const fromQuery = searchParams.get('group')
|
||||
if (fromQuery && GROUPS.some((g) => g.key === fromQuery)) {
|
||||
setActiveGroup(fromQuery)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -108,10 +121,11 @@ export function SettingsPage() {
|
||||
|
||||
{!loading && (
|
||||
<div className="space-y-4">
|
||||
{group.key === 'system-update' && <SystemUpdatePanel />}
|
||||
{group.key === 'library' && <LibrarySettingsPanel />}
|
||||
{group.key === 'api-configs' && <APIConfigsPanel />}
|
||||
{group.key === 'recognition-words' && <RecognitionWordsPanel />}
|
||||
{group.key === 'adult' && <AdultSettingsPanel />}
|
||||
{group.key !== 'adult' && group.items.length > 0 && (
|
||||
{group.key !== 'adult' && group.key !== 'library' && group.items.length > 0 && (
|
||||
<form onSubmit={onSave} className="glass-panel space-y-4">
|
||||
{group.description && <p className="text-xs text-sand-500">{group.description}</p>}
|
||||
{group.items.map((it) => (
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import {
|
||||
CheckCircle,
|
||||
Edit3,
|
||||
HelpCircle,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Wifi,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { Site } from "../types";
|
||||
import {
|
||||
AUTH_TYPE_LABELS,
|
||||
SITE_TYPE_ABBR,
|
||||
SITE_TYPE_COLORS,
|
||||
SITE_TYPE_LABELS,
|
||||
} from "./sitesPageModel";
|
||||
|
||||
type SiteCardProps = {
|
||||
site: Site;
|
||||
testing: boolean;
|
||||
onTest: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export function SiteCard({
|
||||
site,
|
||||
testing,
|
||||
onTest,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: SiteCardProps) {
|
||||
return (
|
||||
<div className="glass-panel p-4 space-y-3 transition-all hover:border-primary-400/30">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-xl flex items-center justify-center text-xs font-bold shrink-0 ${SITE_TYPE_COLORS[site.type] || "bg-sand-500/15 text-ink-50"}`}
|
||||
>
|
||||
{SITE_TYPE_ABBR[site.type] || "?"}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-ink-600 truncate">{site.name}</div>
|
||||
<div className="text-xs text-ink-50 truncate max-w-[160px]">
|
||||
{site.url}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 状态指示 */}
|
||||
<div className="flex items-center gap-1 shrink-0 ml-2">
|
||||
{site.last_check_at ? (
|
||||
site.last_error ? (
|
||||
<XCircle size={14} className="text-red-400" />
|
||||
) : (
|
||||
<CheckCircle size={14} className="text-green-400" />
|
||||
)
|
||||
) : (
|
||||
<HelpCircle size={14} className="text-sand-500" />
|
||||
)}
|
||||
{!site.enabled && (
|
||||
<span className="text-xs text-sand-500 ml-1">已停用</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-gray-50 text-ink-50">
|
||||
{SITE_TYPE_LABELS[site.type] || site.type}
|
||||
</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-gray-50 text-ink-50">
|
||||
{AUTH_TYPE_LABELS[site.auth_type] || site.auth_type}
|
||||
</span>
|
||||
{site.is_default && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-primary-400/15 text-brand-500">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
{site.use_proxy && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-blue-500/15 text-blue-400">
|
||||
代理
|
||||
</span>
|
||||
)}
|
||||
{site.rate_limit && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-yellow-500/15 text-yellow-400">
|
||||
限流
|
||||
</span>
|
||||
)}
|
||||
{site.browser_emulation && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-lg bg-purple-500/15 text-purple-400">
|
||||
浏览器
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 状态与统计(只读) */}
|
||||
<div className="text-xs text-sand-500 space-y-0.5">
|
||||
<div>
|
||||
状态:
|
||||
<span
|
||||
className={
|
||||
site.login_status === "ok"
|
||||
? "text-green-400"
|
||||
: site.login_status === "failed"
|
||||
? "text-red-400"
|
||||
: "text-ink-50"
|
||||
}
|
||||
>
|
||||
{site.login_status || "unknown"}
|
||||
</span>
|
||||
</div>
|
||||
{(site.upload_bytes || 0) > 0 && (
|
||||
<div>
|
||||
↑ {Math.round(((site.upload_bytes ?? 0) / 1073741824) * 100) / 100}{" "}
|
||||
GB / ↓{" "}
|
||||
{Math.round(((site.download_bytes ?? 0) / 1073741824) * 100) / 100}{" "}
|
||||
GB
|
||||
</div>
|
||||
)}
|
||||
{site.priority !== 50 && <div>优先级:{site.priority}</div>}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => onTest()}
|
||||
disabled={testing}
|
||||
className="flex-1 rounded-lg border border-gray-200 px-2 py-1.5 text-xs text-ink-100 hover:bg-gray-50 disabled:opacity-50 flex items-center justify-center gap-1 transition"
|
||||
>
|
||||
{testing ? (
|
||||
<>
|
||||
<RefreshCw size={12} className="animate-spin" />
|
||||
测试中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wifi size={12} />
|
||||
测试连接
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onEdit()}
|
||||
className="rounded-lg border border-gray-200 p-1.5 text-ink-50 hover:text-white hover:bg-gray-50 transition"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete()}
|
||||
className="rounded-lg border border-gray-200 p-1.5 text-ink-50 hover:text-red-400 hover:bg-red-400/10 transition"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
import type { SiteForm } from "./sitesPageModel";
|
||||
|
||||
type SiteFormActionsProps = {
|
||||
form: SiteForm;
|
||||
saving: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function SiteFormActions({
|
||||
form,
|
||||
saving,
|
||||
onClose,
|
||||
}: SiteFormActionsProps) {
|
||||
return (
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50 transition"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !form.name.trim() || !form.url.trim()}
|
||||
className="neon-button text-sm disabled:opacity-50 flex items-center gap-1.5"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import type { SiteFormSectionProps } from "./siteFormModalTypes";
|
||||
|
||||
type SiteFormAdvancedOptionsProps = SiteFormSectionProps & {
|
||||
advancedOpen: boolean;
|
||||
setAdvancedOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function SiteFormAdvancedOptions({
|
||||
advancedOpen,
|
||||
setAdvancedOpen,
|
||||
form,
|
||||
setForm,
|
||||
}: SiteFormAdvancedOptionsProps) {
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen(!advancedOpen)}
|
||||
className="flex items-center gap-1 text-xs text-ink-50 hover:text-white transition"
|
||||
>
|
||||
{advancedOpen ? "▾" : "▸"} 高级选项
|
||||
</button>
|
||||
{advancedOpen && (
|
||||
<div className="mt-3 pl-4 space-y-3 border-l border-gray-200">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
User-Agent
|
||||
</label>
|
||||
<input
|
||||
className="input-base w-full text-xs"
|
||||
placeholder="自定义 UA,留空使用默认"
|
||||
value={form.user_agent}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
user_agent: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
请求超时 (秒)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-base w-full"
|
||||
min={1}
|
||||
max={300}
|
||||
value={form.timeout}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
timeout: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
优先级 (数字越大越优先)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-base w-full"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.priority}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
priority: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
关联下载器
|
||||
</label>
|
||||
<input
|
||||
className="input-base w-full text-xs"
|
||||
placeholder="下载器 ID 或名称"
|
||||
value={form.downloader}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
downloader: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<AdvancedCheckbox
|
||||
checked={form.use_proxy}
|
||||
label="使用代理"
|
||||
onChange={(checked) =>
|
||||
setForm((current) => ({ ...current, use_proxy: checked }))
|
||||
}
|
||||
/>
|
||||
<AdvancedCheckbox
|
||||
checked={form.rate_limit}
|
||||
label="启用限流"
|
||||
onChange={(checked) =>
|
||||
setForm((current) => ({ ...current, rate_limit: checked }))
|
||||
}
|
||||
/>
|
||||
<AdvancedCheckbox
|
||||
checked={form.browser_emulation}
|
||||
label="浏览器模拟"
|
||||
onChange={(checked) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
browser_emulation: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
Extra 扩展配置 (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base w-full resize-none text-xs font-mono"
|
||||
placeholder='{"key":"value"}'
|
||||
value={form.extra}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
extra: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
is_default: !current.is_default,
|
||||
}))
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors cursor-pointer ${form.is_default ? "bg-primary-500" : "bg-gray-200"}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${form.is_default ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-ink-50">设为默认站点</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdvancedCheckbox({
|
||||
checked,
|
||||
label,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
label: string;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
<span className="text-xs text-ink-100">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import type { SiteFormSectionProps } from "./siteFormModalTypes";
|
||||
|
||||
type SiteFormAuthFieldsProps = SiteFormSectionProps & {
|
||||
editingId: string | null;
|
||||
};
|
||||
|
||||
const authOptions = [
|
||||
{ value: "cookie", label: "Cookie" },
|
||||
{ value: "api_key", label: "API Key" },
|
||||
{ value: "auth_header", label: "Auth Header" },
|
||||
];
|
||||
|
||||
export function SiteFormAuthFields({
|
||||
editingId,
|
||||
form,
|
||||
setForm,
|
||||
}: SiteFormAuthFieldsProps) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-2">认证方式</label>
|
||||
<div className="flex gap-2 mb-3">
|
||||
{authOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
auth_type: option.value,
|
||||
}))
|
||||
}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-medium border transition ${
|
||||
form.auth_type === option.value
|
||||
? "bg-primary-500 text-ink-600 border-primary-500"
|
||||
: "border-gray-200 text-ink-50 hover:border-primary-500/50"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{form.auth_type === "cookie" && (
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">Cookie</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base w-full resize-none text-xs font-mono"
|
||||
placeholder="uid=xxx; pass=xxx; ..."
|
||||
value={form.cookie}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
cookie: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">
|
||||
从浏览器开发者工具的请求头中获取 Cookie 值
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.auth_type === "api_key" && (
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
令牌(API Key / Passkey)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input-base w-full font-mono text-sm"
|
||||
placeholder={
|
||||
editingId
|
||||
? "留空则保留原令牌,输入新值则替换"
|
||||
: "输入 API Access Token"
|
||||
}
|
||||
value={form.api_key}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
api_key: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">
|
||||
{form.type === "mteam"
|
||||
? "馒头:控制台 → 实验室 → 存取令牌;第三方工具通过 x-api-key 请求头访问"
|
||||
: form.type === "yemapt"
|
||||
? "YemaPT:个人详情页 → 第三方对接专用 auth;通过 Authorization 请求头原样访问"
|
||||
: "站点的访问 API Key"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.auth_type === "auth_header" && (
|
||||
<div>
|
||||
<label className="block text-xs text-ink-50 mb-1">
|
||||
请求头(Authorization)
|
||||
</label>
|
||||
<input
|
||||
className="input-base w-full font-mono text-xs"
|
||||
placeholder="Bearer eyJhbGciOiJIUzI1NiIs..."
|
||||
value={form.auth_header}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
auth_header: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="block text-xs text-ink-50 mb-1">RSS 地址</label>
|
||||
<input
|
||||
className="input-base w-full text-xs font-mono"
|
||||
placeholder="https://.../torrents/rss?..."
|
||||
value={form.rss_url}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
rss_url: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">
|
||||
站点 RSS 订阅地址,用于获取最新资源
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { SiteFormSectionProps } from "./siteFormModalTypes";
|
||||
|
||||
type SiteFormBasicFieldsProps = SiteFormSectionProps & {
|
||||
onTypeChange: (type: string) => void;
|
||||
};
|
||||
|
||||
export function SiteFormBasicFields({
|
||||
form,
|
||||
setForm,
|
||||
onTypeChange,
|
||||
}: SiteFormBasicFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">
|
||||
站点名称 *
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
className="input-base w-full"
|
||||
placeholder="例如: 馒头、观众、家园"
|
||||
value={form.name}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, name: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">
|
||||
站点地址 *
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
className="input-base w-full"
|
||||
placeholder="https://www.example.com/"
|
||||
value={form.url}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, url: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-sand-500 mt-1">
|
||||
格式: https://www.example.com/
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">
|
||||
站点类型
|
||||
</label>
|
||||
<select
|
||||
className="input-base w-full"
|
||||
value={form.type}
|
||||
onChange={(event) => onTypeChange(event.target.value)}
|
||||
>
|
||||
<option value="nexusphp">NexusPHP(国内主流PT)</option>
|
||||
<option value="gazelle">Gazelle(HDBits等)</option>
|
||||
<option value="unit3d">UNIT3D(BeyondHD等)</option>
|
||||
<option value="mteam">馒头 M-Team(专用API)</option>
|
||||
<option value="yemapt">YemaPT(API Auth)</option>
|
||||
<option value="discuz">Discuz 论坛型</option>
|
||||
<option value="custom_rss">自定义 RSS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-ink-50 mb-1.5">状态</label>
|
||||
<div className="flex items-center gap-3 h-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
enabled: !current.enabled,
|
||||
}))
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors cursor-pointer ${form.enabled ? "bg-primary-500" : "bg-gray-200"}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${form.enabled ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
className={`text-sm ${form.enabled ? "text-white" : "text-sand-500"}`}
|
||||
>
|
||||
{form.enabled ? "启用" : "停用"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SiteTypeGuide type={form.type} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteTypeGuide({ type }: { type: string }) {
|
||||
if (type === "mteam") {
|
||||
return (
|
||||
<div className="p-3 rounded-xl border border-green-500/30 bg-green-500/5">
|
||||
<div className="text-sm font-medium text-green-400 mb-1">
|
||||
馒头站点配置指南
|
||||
</div>
|
||||
<div className="text-xs text-ink-50 space-y-1">
|
||||
<div>
|
||||
<b>认证方式:</b>必须使用「API Access Token」,不要使用 Cookie
|
||||
</div>
|
||||
<div className="pl-3 text-sand-500">
|
||||
1. 登录馒头站 → 控制台 → 实验室 → 存取令牌
|
||||
<br />
|
||||
2. 点击「创建令牌」,复制生成的 Token
|
||||
<br />
|
||||
3. 将 Token 填入下方「令牌」输入框
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { Dispatch, FormEvent, SetStateAction } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import type { SiteForm } from "./sitesPageModel";
|
||||
import { SiteFormActions } from "./SiteFormActions";
|
||||
import { SiteFormAdvancedOptions } from "./SiteFormAdvancedOptions";
|
||||
import { SiteFormAuthFields } from "./SiteFormAuthFields";
|
||||
import { SiteFormBasicFields } from "./SiteFormBasicFields";
|
||||
|
||||
type SiteFormModalProps = {
|
||||
editingId: string | null;
|
||||
form: SiteForm;
|
||||
setForm: Dispatch<SetStateAction<SiteForm>>;
|
||||
saving: boolean;
|
||||
advancedOpen: boolean;
|
||||
setAdvancedOpen: Dispatch<SetStateAction<boolean>>;
|
||||
onClose: () => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
onTypeChange: (type: string) => void;
|
||||
};
|
||||
|
||||
export function SiteFormModal({
|
||||
editingId,
|
||||
form,
|
||||
setForm,
|
||||
saving,
|
||||
advancedOpen,
|
||||
setAdvancedOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onTypeChange,
|
||||
}: SiteFormModalProps) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="glass-panel w-full max-w-xl max-h-[75vh] overflow-y-auto mx-4 space-y-5"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-ink-600">
|
||||
{editingId ? "编辑站点" : "添加站点"}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-ink-50 hover:text-white transition"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<SiteFormBasicFields
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
onTypeChange={onTypeChange}
|
||||
/>
|
||||
<SiteFormAuthFields
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
/>
|
||||
<SiteFormAdvancedOptions
|
||||
advancedOpen={advancedOpen}
|
||||
setAdvancedOpen={setAdvancedOpen}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
/>
|
||||
<SiteFormActions form={form} saving={saving} onClose={onClose} />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
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, '', {
|
||||
title: item.title,
|
||||
source_category: item.category,
|
||||
})
|
||||
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-brand-500" />
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">站点搜索</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
跨站搜索所有已启用的 PT/BT 站点,结果按做种数排序。
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form onSubmit={onSubmit} className="flex flex-wrap 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-sand-500">正在查询所有站点…</p>}
|
||||
|
||||
{!loading && searched && results.length === 0 && (
|
||||
<div className="glass-panel">
|
||||
<p className="text-ink-100">
|
||||
未找到结果。请确认已在「站点管理」中添加并启用了至少一个站点,且 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-sand-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-gray-200 align-top">
|
||||
<td className="py-2 text-brand-500">{item.site_name}</td>
|
||||
<td className="max-w-md py-2">
|
||||
<a
|
||||
href={item.torrent_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-ink-600 transition hover:text-brand-500"
|
||||
title={item.title}
|
||||
>
|
||||
{item.title.length > 80
|
||||
? item.title.substring(0, 80) + '…'
|
||||
: item.title}
|
||||
</a>
|
||||
{item.subtitle && (
|
||||
<div className="mt-1 line-clamp-2 text-xs text-ink-50" title={item.subtitle}>
|
||||
{item.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap text-ink-100">
|
||||
{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-lg 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-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
|
||||
title="加入下载"
|
||||
>
|
||||
<Download size={12} className="inline" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { sitesAPI } from "../api/sites";
|
||||
import type { Site } from "../types";
|
||||
import { confirmAction } from "../components/confirmAction";
|
||||
|
||||
import {
|
||||
API_SITE_TIMEOUT_SECONDS,
|
||||
API_SITE_TYPES,
|
||||
DEFAULT_SITE_TIMEOUT_SECONDS,
|
||||
defaultSiteForm,
|
||||
siteFormToPayload,
|
||||
siteToForm,
|
||||
} from "./sitesPageModel";
|
||||
import { SiteFormModal } from "./SiteFormModal";
|
||||
import {
|
||||
SitesGrid,
|
||||
SitesManagementShortcuts,
|
||||
SitesPageHeader,
|
||||
} from "./SitesPageSections";
|
||||
|
||||
function apiErrorMessage(err: unknown, fallback: string): string {
|
||||
const data = (err as { response?: { data?: { message?: string; error?: string } } })
|
||||
?.response?.data;
|
||||
return data?.message || data?.error || (err instanceof Error ? err.message : fallback);
|
||||
}
|
||||
|
||||
export function SitesPage() {
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState(defaultSiteForm());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
const loadSites = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await sitesAPI.list();
|
||||
setSites(Array.isArray(response.data) ? response.data : []);
|
||||
} catch {
|
||||
toast.error("加载站点列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSites();
|
||||
}, []);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
setForm(defaultSiteForm());
|
||||
setAdvancedOpen(false);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEdit = async (id: string) => {
|
||||
try {
|
||||
const response = await sitesAPI.get(id);
|
||||
setEditingId(id);
|
||||
setForm(siteToForm(response.data as Site));
|
||||
setAdvancedOpen(false);
|
||||
setShowModal(true);
|
||||
} catch {
|
||||
toast.error("获取站点详情失败");
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setShowModal(false);
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const silentSave = async (): Promise<{ ok: boolean; message?: string }> => {
|
||||
if (!form.name.trim() || !form.url.trim()) {
|
||||
return { ok: false, message: "站点名称和地址不能为空" };
|
||||
}
|
||||
const payload = siteFormToPayload(form, !editingId);
|
||||
try {
|
||||
if (editingId) {
|
||||
await sitesAPI.update(editingId, payload);
|
||||
} else {
|
||||
const response = await sitesAPI.create(payload);
|
||||
setEditingId((response.data as Site)?.id ?? null);
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err: unknown) {
|
||||
return { ok: false, message: apiErrorMessage(err, "保存失败") };
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
const result = await silentSave();
|
||||
if (result.ok) {
|
||||
toast.success(editingId ? "站点已更新" : "站点已添加");
|
||||
closeModal();
|
||||
await loadSites();
|
||||
} else {
|
||||
toast.error(result.message || "保存失败");
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
if (editingId === id) {
|
||||
setSaving(true);
|
||||
const result = await silentSave();
|
||||
setSaving(false);
|
||||
if (!result.ok) {
|
||||
toast.error(result.message || "保存失败,无法测试");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setTestingId(id);
|
||||
try {
|
||||
const response = await sitesAPI.test(id);
|
||||
toast.success(response?.message || "连接测试成功");
|
||||
} catch (err: unknown) {
|
||||
toast.error(apiErrorMessage(err, "连接测试失败"));
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (site: Site) => {
|
||||
const confirmed = await confirmAction({
|
||||
title: "删除站点",
|
||||
message: `确定要删除站点「${site.name}」吗?此操作不可撤销。`,
|
||||
confirmText: "删除",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await sitesAPI.remove(site.id);
|
||||
toast.success("站点已删除");
|
||||
await loadSites();
|
||||
} catch {
|
||||
toast.error("删除站点失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: string) => {
|
||||
const isAPISite = API_SITE_TYPES.has(type);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
type,
|
||||
auth_type: isAPISite ? "api_key" : current.auth_type,
|
||||
timeout:
|
||||
isAPISite && current.timeout === DEFAULT_SITE_TIMEOUT_SECONDS
|
||||
? API_SITE_TIMEOUT_SECONDS
|
||||
: current.timeout,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SitesManagementShortcuts />
|
||||
<SitesPageHeader onCreate={openCreate} />
|
||||
<SitesGrid
|
||||
sites={sites}
|
||||
loading={loading}
|
||||
testingId={testingId}
|
||||
onTest={handleTest}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{showModal && (
|
||||
<SiteFormModal
|
||||
editingId={editingId}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
saving={saving}
|
||||
advancedOpen={advancedOpen}
|
||||
setAdvancedOpen={setAdvancedOpen}
|
||||
onClose={closeModal}
|
||||
onSubmit={handleSave}
|
||||
onTypeChange={handleTypeChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { Globe, Plus, RefreshCw } from "lucide-react";
|
||||
|
||||
import { ManagementShortcuts } from "../components/ManagementShortcuts";
|
||||
import type { Site } from "../types";
|
||||
import { SiteCard } from "./SiteCard";
|
||||
|
||||
export function SitesManagementShortcuts() {
|
||||
return (
|
||||
<ManagementShortcuts
|
||||
title="站点与下载链路"
|
||||
description="把站点、搜索、订阅和下载器放在同一工作流里,避免功能入口被隐藏。"
|
||||
items={[
|
||||
{
|
||||
to: "/download-clients",
|
||||
title: "下载器管理",
|
||||
description: "添加、测试和维护下载器连接",
|
||||
badge: "必需",
|
||||
group: "基础配置",
|
||||
},
|
||||
{
|
||||
to: "/site-search",
|
||||
title: "站点检索",
|
||||
description: "跨 PT 站点搜索资源并创建下载任务",
|
||||
group: "资源获取",
|
||||
},
|
||||
{
|
||||
to: "/subscriptions",
|
||||
title: "订阅管理",
|
||||
description: "管理追剧追番和自动下载规则",
|
||||
group: "资源获取",
|
||||
},
|
||||
{
|
||||
to: "/downloads",
|
||||
title: "下载中心",
|
||||
description: "查看下载任务状态和历史记录",
|
||||
group: "任务状态",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SitesPageHeader({ onCreate }: { onCreate: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">
|
||||
站点管理
|
||||
</h1>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="neon-button flex items-center gap-2"
|
||||
>
|
||||
<Plus size={16} />
|
||||
添加站点
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SitesGrid({
|
||||
sites,
|
||||
loading,
|
||||
testingId,
|
||||
onTest,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
sites: Site[];
|
||||
loading: boolean;
|
||||
testingId: string | null;
|
||||
onTest: (id: string) => void;
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (site: Site) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sites.map((site) => (
|
||||
<SiteCard
|
||||
key={site.id}
|
||||
site={site}
|
||||
testing={testingId === site.id}
|
||||
onTest={() => onTest(site.id)}
|
||||
onEdit={() => onEdit(site.id)}
|
||||
onDelete={() => onDelete(site)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!loading && sites.length === 0 && <SitesEmptyState />}
|
||||
{loading && <SitesLoadingState />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SitesEmptyState() {
|
||||
return (
|
||||
<div className="col-span-full py-12 text-center text-ink-50">
|
||||
<Globe size={40} className="mx-auto mb-3 text-gray-500" />
|
||||
<p>暂无站点</p>
|
||||
<p className="text-sm mt-1 text-sand-500">
|
||||
点击「添加站点」添加 PT/BT 站点
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SitesLoadingState() {
|
||||
return (
|
||||
<div className="col-span-full py-12 text-center text-ink-50">
|
||||
<RefreshCw size={24} className="mx-auto mb-3 animate-spin" />
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { statsAPI } from '../api/stats'
|
||||
import type { Hardware, StatsSnapshot } from '../types'
|
||||
import {
|
||||
AggregateStatsSection,
|
||||
StatsHeader,
|
||||
StatsTiles,
|
||||
SystemMonitorSection,
|
||||
} from './StatsPageSections'
|
||||
|
||||
// StatsPage renders the operator dashboard. Aggregate stats refresh less often,
|
||||
// while hardware metrics are polled every 2 s for a real-time monitoring feel.
|
||||
export function StatsPage() {
|
||||
const [snap, setSnap] = useState<StatsSnapshot | null>(null)
|
||||
const [hardware, setHardware] = useState<Hardware | null>(null)
|
||||
const [lastMonitorAt, setLastMonitorAt] = useState<string>('')
|
||||
const [monitorError, setMonitorError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const tick = () =>
|
||||
statsAPI.snapshot().then((s) => {
|
||||
if (!cancelled) {
|
||||
setSnap(s)
|
||||
setHardware((current) => current ?? s.hardware)
|
||||
setLastMonitorAt((current) => current || s.generated_at)
|
||||
}
|
||||
})
|
||||
tick().finally(() => setLoading(false))
|
||||
const id = window.setInterval(tick, 30_000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const tick = () =>
|
||||
statsAPI
|
||||
.monitor()
|
||||
.then((m) => {
|
||||
if (!cancelled) {
|
||||
setHardware(m)
|
||||
setLastMonitorAt(new Date().toISOString())
|
||||
setMonitorError('')
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setMonitorError((err as { message?: string })?.message ?? '实时监控暂不可用')
|
||||
}
|
||||
})
|
||||
tick()
|
||||
const id = window.setInterval(tick, 2_000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) return <p className="text-sand-500">加载中…</p>
|
||||
if (!snap) return <p className="text-sand-500">无法获取统计数据</p>
|
||||
|
||||
const live = hardware ?? snap.hardware
|
||||
const memPct =
|
||||
live.memory_total > 0
|
||||
? (live.memory_used / live.memory_total) * 100
|
||||
: 0
|
||||
const diskPct =
|
||||
live.disk_total > 0
|
||||
? (live.disk_used / live.disk_total) * 100
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<StatsHeader
|
||||
generatedAt={snap.generated_at}
|
||||
lastMonitorAt={lastMonitorAt}
|
||||
monitorError={monitorError}
|
||||
/>
|
||||
<StatsTiles snap={snap} live={live} memPct={memPct} diskPct={diskPct} />
|
||||
<SystemMonitorSection
|
||||
live={live}
|
||||
memPct={memPct}
|
||||
diskPct={diskPct}
|
||||
monitorError={monitorError}
|
||||
/>
|
||||
<AggregateStatsSection generatedAt={snap.generated_at} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Activity, Cpu, Database, Film, HardDrive, Radio, Users } from 'lucide-react'
|
||||
|
||||
import type { Hardware, StatsSnapshot } from '../types'
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (!n || n <= 0) return '—'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < u.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(2)} ${u[i]}`
|
||||
}
|
||||
|
||||
function fmtHours(seconds: number): string {
|
||||
if (!seconds || seconds <= 0) return '—'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
return `${h.toLocaleString()} h`
|
||||
}
|
||||
|
||||
export function StatsHeader({
|
||||
generatedAt,
|
||||
lastMonitorAt,
|
||||
monitorError,
|
||||
}: {
|
||||
generatedAt: string
|
||||
lastMonitorAt: string
|
||||
monitorError: string
|
||||
}) {
|
||||
return (
|
||||
<header className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">运行状态</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
聚合快照:{new Date(generatedAt).toLocaleString()} · 实时监控每 2 秒刷新
|
||||
</p>
|
||||
</div>
|
||||
<div className="glass-panel inline-flex items-center gap-3 !px-4 !py-3">
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-70" />
|
||||
<span className="relative inline-flex h-3 w-3 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-emerald-600">
|
||||
{monitorError ? '监控重试中' : '实时监控中'}
|
||||
</p>
|
||||
<p className="text-xs text-ink-50">
|
||||
最近刷新:{lastMonitorAt ? new Date(lastMonitorAt).toLocaleTimeString() : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatsTiles({
|
||||
snap,
|
||||
live,
|
||||
memPct,
|
||||
diskPct,
|
||||
}: {
|
||||
snap: StatsSnapshot
|
||||
live: Hardware
|
||||
memPct: number
|
||||
diskPct: number
|
||||
}) {
|
||||
return (
|
||||
<section className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<Tile icon={<Database size={20} />} label="媒体库" value={snap.libraries.toLocaleString()} />
|
||||
<Tile icon={<Film size={20} />} label="媒体总数" value={snap.media_count.toLocaleString()} />
|
||||
<Tile icon={<Users size={20} />} label="用户" value={snap.users_count.toLocaleString()} />
|
||||
<Tile icon={<HardDrive size={20} />} label="入库容量" value={fmtBytes(snap.total_size_bytes)} />
|
||||
<Tile icon={<Activity size={20} />} label="累计时长" value={fmtHours(snap.total_seconds)} />
|
||||
<Tile icon={<Cpu size={20} />} label="CPU 占用" value={`${live.cpu_percent.toFixed(1)}%`} meter={live.cpu_percent} />
|
||||
<Tile icon={<Cpu size={20} />} label="内存占用" value={`${memPct.toFixed(1)}%`} />
|
||||
<Tile icon={<HardDrive size={20} />} label="数据盘占用" value={`${diskPct.toFixed(1)}%`} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function SystemMonitorSection({
|
||||
live,
|
||||
memPct,
|
||||
diskPct,
|
||||
monitorError,
|
||||
}: {
|
||||
live: Hardware
|
||||
memPct: number
|
||||
diskPct: number
|
||||
monitorError: string
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="font-display text-xl font-semibold text-ink-600">系统实时监控</h2>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">
|
||||
<Radio size={12} />
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
<div className="glass-panel grid gap-4 text-sm lg:grid-cols-[1fr_1.4fr]">
|
||||
<div className="grid gap-2">
|
||||
<Row label="Go 运行时" value={live.go_version} />
|
||||
<Row label="Goroutines" value={live.goroutines.toLocaleString()} />
|
||||
<Row
|
||||
label="内存"
|
||||
value={`${fmtBytes(live.memory_used)} / ${fmtBytes(live.memory_total)}`}
|
||||
/>
|
||||
<Row
|
||||
label="数据盘"
|
||||
value={`${fmtBytes(live.disk_used)} / ${fmtBytes(live.disk_total)}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Meter label="CPU" value={live.cpu_percent} />
|
||||
<Meter label="内存" value={memPct} />
|
||||
<Meter label="数据盘" value={diskPct} />
|
||||
{monitorError && <p className="text-xs text-red-500">实时监控错误:{monitorError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function AggregateStatsSection({ generatedAt }: { generatedAt: string }) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-xl font-semibold text-ink-600">聚合统计</h2>
|
||||
<div className="glass-panel grid gap-2 text-sm">
|
||||
<Row
|
||||
label="统计刷新"
|
||||
value={new Date(generatedAt).toLocaleString()}
|
||||
/>
|
||||
<Row label="统计口径" value="媒体库、用户、容量每 30 秒刷新" />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Tile({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
meter,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
value: string
|
||||
meter?: number
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-panel flex items-center gap-3 !p-4">
|
||||
<div className="rounded-xl border border-primary-400/40 bg-primary-400/10 p-2 text-brand-500">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs uppercase tracking-wider text-sand-500">{label}</p>
|
||||
<p className="font-display text-lg font-semibold text-ink-600">{value}</p>
|
||||
{typeof meter === 'number' && <Bar value={meter} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Meter({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-semibold text-ink-600">{label}</span>
|
||||
<span className="font-mono text-ink-50">{value.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Bar value={value} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Bar({ value }: { value: number }) {
|
||||
const pct = Math.max(0, Math.min(100, value || 0))
|
||||
const color = pct > 85 ? 'bg-red-500' : pct > 65 ? 'bg-amber-500' : 'bg-emerald-500'
|
||||
return (
|
||||
<div className="h-2 overflow-hidden rounded-full bg-gray-100">
|
||||
<div className={`h-full rounded-full transition-all duration-700 ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-gray-200 pb-1 text-sm last:border-0">
|
||||
<span className="text-ink-50">{label}</span>
|
||||
<span className="font-mono text-ink-600">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Cloud } from 'lucide-react'
|
||||
|
||||
import {
|
||||
type StorageType,
|
||||
} from '../api/storage_config'
|
||||
import {
|
||||
STORAGE_TABS,
|
||||
TYPE_LABEL,
|
||||
} from './storageConfigModel'
|
||||
import { StorageForm } from './StorageForm'
|
||||
|
||||
// StorageConfigPage manages the OpenList / Alist / WebDAV / CloudDrive2 / 115 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>('openlist')
|
||||
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-[var(--app-text)]">外部存储</h1>
|
||||
<p className="text-sm text-[var(--app-muted)]">
|
||||
配置 OpenList / Alist / WebDAV / CloudDrive2 / 115 后端,支持本地转存、网盘挂载和 302/反代播放
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-b border-[var(--app-border)]">
|
||||
{STORAGE_TABS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setActive(t)}
|
||||
className={
|
||||
'border-b-2 px-4 py-2 text-sm ' +
|
||||
(active === t
|
||||
? 'border-primary-400 text-brand-500'
|
||||
: 'border-transparent text-[var(--app-muted)] hover:text-[var(--app-text)]')
|
||||
}
|
||||
>
|
||||
{TYPE_LABEL[t] ?? t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<StorageForm key={active} type={active} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
storageAPI,
|
||||
type StorageType,
|
||||
} from '../api/storage_config'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import { CloudBrowser } from './CloudBrowser'
|
||||
import { QRLoginPanel } from './QRLoginPanel'
|
||||
import { StorageFormActions } from './StorageFormActions'
|
||||
import { StorageTransferSettings } from './StorageTransferSettings'
|
||||
import { StorageUploadPanel } from './StorageUploadPanel'
|
||||
import {
|
||||
FIELD_DEFS,
|
||||
TRANSFER_SUPPORTED_TYPES,
|
||||
TYPE_LABEL,
|
||||
isCloud,
|
||||
} from './storageConfigModel'
|
||||
|
||||
export function StorageForm({ type }: { type: StorageType }) {
|
||||
const fields = useMemo(() => FIELD_DEFS[type], [type])
|
||||
const transferSupported = TRANSFER_SUPPORTED_TYPES.has(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 [loggingOut, setLoggingOut] = useState(false)
|
||||
const [browserVersion, setBrowserVersion] = useState(0)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await storageAPI.get(type)
|
||||
const next: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(r.config ?? {})) {
|
||||
next[key] = value === '********' ? '' : String(value ?? '')
|
||||
}
|
||||
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 ?? true)
|
||||
} 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()
|
||||
setBrowserVersion((version) => version + 1)
|
||||
} 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.error(r.error ?? '连接失败')
|
||||
return
|
||||
}
|
||||
if (isCloud(type)) {
|
||||
await storageAPI.save(type, config, true)
|
||||
setEnabled(true)
|
||||
toast.success('连接成功,已保存并启用')
|
||||
await refresh()
|
||||
setBrowserVersion((version) => version + 1)
|
||||
return
|
||||
}
|
||||
toast.success('连接成功')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'测试失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onLogout = async () => {
|
||||
const ok = await confirmAction({
|
||||
title: '退出云盘登录',
|
||||
message: `将清空「${TYPE_LABEL[type] ?? type}」保存的 Cookie / Token / 密码并停用该外部存储,同时移除本项目中的对应网盘挂载和媒体记录;不会删除网盘里的真实文件。`,
|
||||
confirmText: '退出登录',
|
||||
})
|
||||
if (!ok) return
|
||||
setLoggingOut(true)
|
||||
try {
|
||||
await storageAPI.logout(type)
|
||||
toast.success('已退出云盘登录、停用该存储并清理本项目挂载')
|
||||
await refresh()
|
||||
setBrowserVersion((version) => version + 1)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'退出登录失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setLoggingOut(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12 text-ink-50">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const transferEnabled = (config.transfer_enabled ?? '').toLowerCase() === 'true'
|
||||
const transferMode = config.transfer_mode === 'move' ? 'move' : 'copy'
|
||||
|
||||
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-ink-100">
|
||||
{f.label}
|
||||
<span className="ml-2 font-mono text-[10px] text-gray-500">{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>
|
||||
))}
|
||||
{type === 'cloud115' && (
|
||||
<QRLoginPanel
|
||||
type={type}
|
||||
onCookie={(c) => setConfig((cfg) => ({ ...cfg, cookie: c }))}
|
||||
/>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
{transferSupported && (
|
||||
<StorageTransferSettings
|
||||
transferEnabled={transferEnabled}
|
||||
transferMode={transferMode}
|
||||
onTransferEnabledChange={(checked) => {
|
||||
setConfig((current) => ({ ...current, transfer_enabled: checked ? 'true' : 'false' }))
|
||||
}}
|
||||
onTransferModeChange={(mode) => {
|
||||
setConfig((current) => ({ ...current, transfer_mode: mode }))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<StorageFormActions
|
||||
showLogout={isCloud(type)}
|
||||
loggingOut={loggingOut}
|
||||
testing={testing}
|
||||
saving={saving}
|
||||
onLogout={onLogout}
|
||||
onTest={onTest}
|
||||
/>
|
||||
<StorageUploadPanel
|
||||
type={type}
|
||||
transferEnabled={transferEnabled}
|
||||
transferMode={transferMode}
|
||||
/>
|
||||
{isCloud(type) && <CloudBrowser key={`${type}-${browserVersion}-${enabled ? 'on' : 'off'}`} type={type} enabled={enabled} />}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Loader2, LogOut, Save, Send } from 'lucide-react'
|
||||
|
||||
type StorageFormActionsProps = {
|
||||
showLogout: boolean
|
||||
loggingOut: boolean
|
||||
testing: boolean
|
||||
saving: boolean
|
||||
onLogout: () => void
|
||||
onTest: () => void
|
||||
}
|
||||
|
||||
export function StorageFormActions({
|
||||
showLogout,
|
||||
loggingOut,
|
||||
testing,
|
||||
saving,
|
||||
onLogout,
|
||||
onTest,
|
||||
}: StorageFormActionsProps) {
|
||||
return (
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
{showLogout && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
disabled={loggingOut}
|
||||
className="rounded-lg border border-red-300/60 px-4 py-2 text-sm text-red-500 hover:bg-[var(--app-danger-soft)] disabled:opacity-50"
|
||||
>
|
||||
{loggingOut ? <Loader2 size={14} className="inline animate-spin" /> : <LogOut size={14} className="inline" />}
|
||||
{' '}退出登录
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
className="rounded-lg border border-[var(--app-border)] px-4 py-2 text-sm text-[var(--app-subtle)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Database, HardDrive, PieChart } from 'lucide-react'
|
||||
|
||||
import { storageAPI, type StorageBreakdown } from '../api/storage'
|
||||
import { ManagementShortcuts } from '../components/ManagementShortcuts'
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (!n) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < u.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(2)} ${u[i]}`
|
||||
}
|
||||
|
||||
function fmtHours(seconds: number): string {
|
||||
if (!seconds) return '—'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
return `${h.toLocaleString()} h`
|
||||
}
|
||||
|
||||
// StoragePage shows disk usage broken down by library and by container.
|
||||
export function StoragePage() {
|
||||
const [data, setData] = useState<StorageBreakdown | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
storageAPI
|
||||
.breakdown()
|
||||
.then(setData)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) return <p className="text-sand-500">加载中…</p>
|
||||
if (!data) return <p className="text-sand-500">无法获取存储数据</p>
|
||||
|
||||
const totalBytes = data.total_bytes || 1
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex items-center gap-3">
|
||||
<HardDrive className="h-6 w-6 text-brand-500" />
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">存储与文件</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
文件浏览、整理入库、排重与存储统计统一在这里,避免入口重复。
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ManagementShortcuts
|
||||
title="核心操作"
|
||||
description="保留常用入口:先整理/入库,再按需进入文件或清理功能。"
|
||||
items={[
|
||||
{ to: '/files', title: '文件管理', description: '浏览服务器文件,做少量安全文件操作', group: '整理入库' },
|
||||
{ to: '/storage-config', title: '存储配置', description: '维护媒体存储路径和容量策略', group: '整理入库' },
|
||||
{ to: '/duplicates', title: '重复清理', description: '扫描重复媒体并进行安全清理', badge: '清理', group: '空间维护' },
|
||||
{ to: '/recycle', title: '回收站', description: '查看已删除资源并执行恢复或释放空间', group: '空间维护' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<details className="glass-panel group">
|
||||
<summary className="cursor-pointer list-none font-display text-lg font-semibold text-ink-600">
|
||||
低频维护入口 <span className="text-xs font-normal text-sand-500">(点击展开)</span>
|
||||
</summary>
|
||||
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-6">
|
||||
<MaintenanceLink to="/strm" title="STRM 生成" />
|
||||
<MaintenanceLink to="/scheduler" title="定时任务" />
|
||||
<MaintenanceLink to="/tasks" title="任务队列" />
|
||||
<MaintenanceLink to="/stats" title="运行状态" />
|
||||
<MaintenanceLink to="/notify-channels" title="通知渠道" />
|
||||
<MaintenanceLink to="/assistant" title="AI 对话台" />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<section className="grid gap-4 sm:grid-cols-3">
|
||||
<Tile icon={<Database size={20} />} label="总占用" value={fmtBytes(data.total_bytes)} />
|
||||
<Tile icon={<PieChart size={20} />} label="媒体库" value={`${data.by_library.length}`} />
|
||||
<Tile icon={<HardDrive size={20} />} label="累计时长" value={fmtHours(data.total_seconds)} />
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-xl font-semibold text-ink-600">按媒体库</h2>
|
||||
<div className="glass-panel">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
<th>类型</th>
|
||||
<th>媒体数</th>
|
||||
<th>占用</th>
|
||||
<th>占比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.by_library.map((l) => {
|
||||
const pct = (l.total_bytes / totalBytes) * 100
|
||||
return (
|
||||
<tr key={l.library_id} className="border-t border-gray-200">
|
||||
<td className="py-2 text-ink-600">{l.name}</td>
|
||||
<td className="text-ink-100">{l.type}</td>
|
||||
<td className="text-ink-100">{l.media_count}</td>
|
||||
<td className="text-ink-100">{fmtBytes(l.total_bytes)}</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1 w-24 overflow-hidden rounded bg-gray-200">
|
||||
<div
|
||||
className="h-full bg-primary-400"
|
||||
style={{ width: `${pct.toFixed(1)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-ink-50">{pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-xl font-semibold text-ink-600">按容器格式</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||
{data.by_container.map((c) => (
|
||||
<div
|
||||
key={c.container}
|
||||
className="glass-panel flex items-center justify-between !p-4"
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-sand-500">{c.container}</p>
|
||||
<p className="font-display text-lg font-semibold text-ink-600">{c.count} 项</p>
|
||||
</div>
|
||||
<p className="text-sm text-ink-100">{fmtBytes(c.bytes)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Tile({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-panel flex items-center gap-3 !p-4">
|
||||
<div className="rounded-xl border border-primary-400/40 bg-primary-400/10 p-2 text-brand-500">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-sand-500">{label}</p>
|
||||
<p className="font-display text-lg font-semibold text-ink-600">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MaintenanceLink({ to, title }: { to: string; title: string }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-ink-100 transition hover:border-primary-400/40 hover:text-brand-500"
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
type TransferMode = 'copy' | 'move'
|
||||
|
||||
type StorageTransferSettingsProps = {
|
||||
transferEnabled: boolean
|
||||
transferMode: TransferMode
|
||||
onTransferEnabledChange: (enabled: boolean) => void
|
||||
onTransferModeChange: (mode: TransferMode) => void
|
||||
}
|
||||
|
||||
export function StorageTransferSettings({
|
||||
transferEnabled,
|
||||
transferMode,
|
||||
onTransferEnabledChange,
|
||||
onTransferModeChange,
|
||||
}: StorageTransferSettingsProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-blue-300/30 bg-blue-500/10 p-3">
|
||||
<div className="mb-2 text-sm font-semibold text-[var(--app-text)]">转存写入权限</div>
|
||||
<p className="mb-3 text-xs text-[var(--app-muted)]">
|
||||
挂载/扫描只需要读取;只有手动开启这里,才允许把本地文件转存写入到该外部存储。可随时关闭。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-[var(--app-subtle)]">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={transferEnabled}
|
||||
onChange={(event) => onTransferEnabledChange(event.target.checked)}
|
||||
/>
|
||||
允许转存写入
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<span>转存方式</span>
|
||||
<select
|
||||
className="input-base w-32"
|
||||
value={transferMode}
|
||||
onChange={(event) => onTransferModeChange(event.target.value === 'move' ? 'move' : 'copy')}
|
||||
>
|
||||
<option value="copy">复制</option>
|
||||
<option value="move">移动</option>
|
||||
</select>
|
||||
</label>
|
||||
<span className="text-xs text-[var(--app-muted)]">
|
||||
复制保留本地源文件;移动只在上传成功后删除本地文件。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Copy, Loader2, Move, Upload } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { storageAPI, type StorageType } from '../api/storage_config'
|
||||
import { TRANSFER_SUPPORTED_TYPES, fmtBytes } from './storageConfigModel'
|
||||
|
||||
interface StorageUploadPanelProps {
|
||||
type: StorageType
|
||||
transferEnabled: boolean
|
||||
transferMode: 'copy' | 'move'
|
||||
}
|
||||
|
||||
export function StorageUploadPanel({ type, transferEnabled, transferMode }: StorageUploadPanelProps) {
|
||||
const [sourcePath, setSourcePath] = useState('')
|
||||
const [destPath, setDestPath] = useState('/MediaStationGo')
|
||||
const [recursive, setRecursive] = useState(true)
|
||||
const [includeSidecars, setIncludeSidecars] = useState(true)
|
||||
const [overwrite, setOverwrite] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const supported = TRANSFER_SUPPORTED_TYPES.has(type)
|
||||
|
||||
const submit = async () => {
|
||||
if (!supported) {
|
||||
toast.error('本地直传目前支持 OpenList / Alist / WebDAV / CloudDrive2。115 建议通过 OpenList、CloudDrive2 或 Alist 桥接后转存。')
|
||||
return
|
||||
}
|
||||
if (!sourcePath.trim()) {
|
||||
toast.error('请填写本地源目录或文件路径')
|
||||
return
|
||||
}
|
||||
if (!transferEnabled) {
|
||||
toast.error('请先开启“允许转存写入”并保存该外部存储配置')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
const { result, error } = await storageAPI.uploadLocal(type, {
|
||||
source_path: sourcePath.trim(),
|
||||
dest_path: destPath.trim() || '/',
|
||||
recursive,
|
||||
include_sidecars: includeSidecars,
|
||||
overwrite,
|
||||
transfer_mode: transferMode,
|
||||
})
|
||||
const errText = error || (result.errors && result.errors.length > 0 ? ` · 错误 ${result.errors.length}` : '')
|
||||
const movedText = result.moved ? ` · 移动 ${result.moved}` : ''
|
||||
toast.success(`转存完成:上传 ${result.uploaded}${movedText} · 跳过 ${result.skipped} · ${fmtBytes(result.bytes)}${errText}`)
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '转存失败')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-4">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 font-display text-base font-semibold text-[var(--app-text)]">
|
||||
{transferMode === 'move' ? <Move size={16} /> : <Copy size={16} />} 本地媒体转存到此存储
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-[var(--app-muted)]">
|
||||
{transferMode === 'move'
|
||||
? '移动模式会先上传到外部存储,上传成功后才删除本地源文件;远端已存在且未覆盖时不会删除本地。'
|
||||
: '复制本地媒体文件到外部存储,保留本地源文件;自动跳过远端已存在文件。'}
|
||||
</p>
|
||||
</div>
|
||||
{!supported && (
|
||||
<span className="rounded-full border border-amber-300/50 bg-amber-500/10 px-2 py-1 text-xs text-amber-600">
|
||||
直传待接
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!supported && (
|
||||
<p className="mb-3 rounded-lg border border-amber-300/50 bg-amber-500/10 px-3 py-2 text-xs text-amber-600">
|
||||
115 原生上传需要私有分片上传协议。推荐把 115、123、阿里等网盘挂载到 OpenList、CloudDrive2 或 Alist 后,在这里选择 OpenList / CloudDrive2 / Alist 转存。
|
||||
</p>
|
||||
)}
|
||||
{type === 'openlist' && (
|
||||
<p className="mb-3 rounded-lg border border-blue-300/50 bg-blue-500/10 px-3 py-2 text-xs text-blue-500">
|
||||
OpenList 优先使用服务地址 + 用户名密码/Token 调用 API 进行浏览、挂载、转存和获取播放直链;WebDAV URL 只是兼容备用。默认端口常见为 5244,未配置 HTTPS 反代时请填写 http://。
|
||||
</p>
|
||||
)}
|
||||
{type === 'clouddrive2' && (
|
||||
<p className="mb-3 rounded-lg border border-blue-300/50 bg-blue-500/10 px-3 py-2 text-xs text-blue-500">
|
||||
CloudDrive2 已经对接 115、123、阿里等网盘;这里通过它的 WebDAV 入口浏览、挂载和上传,播放默认走服务端反代以携带认证头。
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-[var(--app-subtle)]">本地源目录 / 文件</span>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="例如 /media/电影 或 F:\\media\\Movies"
|
||||
value={sourcePath}
|
||||
onChange={(event) => setSourcePath(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-[var(--app-subtle)]">网盘目标目录</span>
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="/MediaStationGo"
|
||||
value={destPath}
|
||||
onChange={(event) => setDestPath(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4 text-sm text-[var(--app-subtle)]">
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={recursive} onChange={(event) => setRecursive(event.target.checked)} />
|
||||
递归目录
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={includeSidecars} onChange={(event) => setIncludeSidecars(event.target.checked)} />
|
||||
同步 NFO / 海报 / 字幕
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={overwrite} onChange={(event) => setOverwrite(event.target.checked)} />
|
||||
覆盖远端同名文件
|
||||
</label>
|
||||
<button type="button" className="neon-button ml-auto" disabled={busy || !supported || !transferEnabled} onClick={submit}>
|
||||
{busy ? <Loader2 size={16} className="animate-spin" /> : transferMode === 'move' ? <Move size={16} /> : <Upload size={16} />}
|
||||
{busy ? '转存中…' : transferMode === 'move' ? '开始移动转存' : '开始复制转存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import { Search, Trash2 } from 'lucide-react'
|
||||
|
||||
import type { Media } from '../types'
|
||||
|
||||
type StrmAttachSectionProps = {
|
||||
query: string
|
||||
searching: boolean
|
||||
results: Media[]
|
||||
drafts: Record<string, string>
|
||||
doSearch: (event?: FormEvent) => void
|
||||
setQuery: (value: string) => void
|
||||
setDrafts: (updater: (drafts: Record<string, string>) => Record<string, string>) => void
|
||||
onAttach: (media: Media) => void
|
||||
onDetach: (media: Media) => void
|
||||
}
|
||||
|
||||
export function StrmAttachSection({
|
||||
query,
|
||||
searching,
|
||||
results,
|
||||
drafts,
|
||||
doSearch,
|
||||
setQuery,
|
||||
setDrafts,
|
||||
onAttach,
|
||||
onDetach,
|
||||
}: StrmAttachSectionProps) {
|
||||
return (
|
||||
<section className="glass-panel space-y-4">
|
||||
<h2 className="font-display text-lg font-semibold text-ink-600">附加 STRM URL 到已有媒体</h2>
|
||||
<form onSubmit={doSearch} className="flex flex-wrap 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((media) => {
|
||||
const isStrm = media.container === 'strm'
|
||||
return (
|
||||
<div
|
||||
key={media.id}
|
||||
className="space-y-3 rounded-xl border border-gray-200 bg-gray-50 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-ink-600">{media.title}</div>
|
||||
<div className="truncate text-xs text-ink-50">
|
||||
{media.year > 0 && `${media.year} · `}
|
||||
{media.container || '本地文件'}
|
||||
</div>
|
||||
{isStrm && (
|
||||
<div className="mt-1 break-all rounded-lg bg-emerald-400/10 px-2 py-0.5 text-xs text-emerald-300">
|
||||
已设置 STRM
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isStrm && (
|
||||
<button
|
||||
onClick={() => onDetach(media)}
|
||||
className="rounded-lg 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 flex-wrap gap-2">
|
||||
<input
|
||||
className="input-base flex-1"
|
||||
placeholder="https://example.com/stream.m3u8"
|
||||
value={drafts[media.id] ?? ''}
|
||||
onChange={(e) =>
|
||||
setDrafts((current) => ({ ...current, [media.id]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<button
|
||||
onClick={() => onAttach(media)}
|
||||
disabled={!(drafts[media.id] ?? '').trim()}
|
||||
className="neon-button"
|
||||
>
|
||||
{isStrm ? '替换 URL' : '设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!searching && query && results.length === 0 && (
|
||||
<p className="text-sm text-ink-50">未找到匹配的媒体。</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user