mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
feat: merge conflict resolution, site management, UI fixes
This commit is contained in:
+10
-8
@@ -51,9 +51,6 @@ const DlnaPage = lazy(() => import('./pages/DlnaPage').then((m) => ({ default: m
|
||||
const FileManagerPage = lazy(() =>
|
||||
import('./pages/FileManagerPage').then((m) => ({ default: m.FileManagerPage })),
|
||||
)
|
||||
const APIConfigsPage = lazy(() =>
|
||||
import('./pages/APIConfigsPage').then((m) => ({ default: m.APIConfigsPage })),
|
||||
)
|
||||
const StoragePage = lazy(() =>
|
||||
import('./pages/StoragePage').then((m) => ({ default: m.StoragePage })),
|
||||
)
|
||||
@@ -69,6 +66,7 @@ const WatchHistoryPage = lazy(() =>
|
||||
const PosterWallPage = lazy(() =>
|
||||
import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })),
|
||||
)
|
||||
const SitesPage = lazy(() => import('./pages/SitesPage').then((m) => ({ default: m.SitesPage })))
|
||||
|
||||
const Loading = () => <p className="px-6 py-8 text-slate-500">加载中…</p>
|
||||
|
||||
@@ -100,6 +98,14 @@ export default function App() {
|
||||
<Route path="dlna" element={<DlnaPage />} />
|
||||
<Route path="history" element={<WatchHistoryPage />} />
|
||||
<Route path="poster-wall" element={<PosterWallPage />} />
|
||||
<Route
|
||||
path="sites"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<SitesPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="files"
|
||||
element={
|
||||
@@ -134,11 +140,7 @@ export default function App() {
|
||||
/>
|
||||
<Route
|
||||
path="api-configs"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<APIConfigsPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
element={<Navigate to="/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="tasks"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// API 配置 API 模块
|
||||
import { api } from './client'
|
||||
import type { ApiConfig, ApiProvider } from '../types'
|
||||
|
||||
// 获取所有 API 配置
|
||||
export async function listApiConfigs(): Promise<ApiConfig[]> {
|
||||
const resp = await api.get<ApiConfig[]>('/api-config')
|
||||
return resp.data as unknown as ApiConfig[]
|
||||
}
|
||||
|
||||
// 获取提供者列表
|
||||
export async function getProviders(): Promise<ApiProvider[]> {
|
||||
const resp = await api.get<ApiProvider[]>('/api-config/providers/list')
|
||||
return resp.data as unknown as ApiProvider[]
|
||||
}
|
||||
|
||||
// 获取指定提供者的配置
|
||||
export async function getApiConfig(provider: string): Promise<ApiConfig> {
|
||||
const resp = await api.get<ApiConfig>(`/api-config/${provider}`)
|
||||
return resp.data as unknown as ApiConfig
|
||||
}
|
||||
|
||||
// 获取生效的配置
|
||||
export async function getEffectiveConfig(provider: string): Promise<ApiConfig> {
|
||||
const resp = await api.get<ApiConfig>(`/api-config/${provider}/effective`)
|
||||
return resp.data as unknown as ApiConfig
|
||||
}
|
||||
|
||||
// 更新 API 配置
|
||||
export interface UpdateApiConfigRequest {
|
||||
api_key?: string
|
||||
base_url?: string
|
||||
extra?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export async function updateApiConfig(
|
||||
provider: string,
|
||||
data: UpdateApiConfigRequest
|
||||
): Promise<ApiConfig> {
|
||||
const resp = await api.post<ApiConfig>(`/api-config/${provider}`, data)
|
||||
return resp.data as unknown as ApiConfig
|
||||
}
|
||||
|
||||
// 删除 API 配置
|
||||
export async function deleteApiConfig(provider: string): Promise<void> {
|
||||
await api.delete(`/api-config/${provider}`)
|
||||
}
|
||||
|
||||
// 测试 API 连接
|
||||
export interface TestApiConfigResponse {
|
||||
result: 'success' | 'error' | 'invalid' | 'unknown'
|
||||
}
|
||||
|
||||
export async function testApiConfig(provider: string): Promise<TestApiConfigResponse> {
|
||||
const resp = await api.post<TestApiConfigResponse>(`/api-config/${provider}/test`)
|
||||
return resp.data as unknown as TestApiConfigResponse
|
||||
}
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
import { api } from './client'
|
||||
import type { User } from '../types'
|
||||
import type { User, TokenPair } from '../types'
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
user: User
|
||||
tokens: TokenPair
|
||||
}
|
||||
|
||||
export const authAPI = {
|
||||
@@ -11,7 +11,7 @@ export const authAPI = {
|
||||
api.post<LoginResponse>('/auth/login', { username, password }).then((r) => r.data),
|
||||
|
||||
register: (username: string, password: string) =>
|
||||
api.post<User>('/auth/register', { username, password }).then((r) => r.data),
|
||||
api.post<LoginResponse>('/auth/register', { username, password }).then((r) => r.data),
|
||||
|
||||
me: () => api.get<User>('/me').then((r) => r.data),
|
||||
|
||||
|
||||
+68
-3
@@ -1,4 +1,4 @@
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
@@ -9,6 +9,22 @@ export const api = axios.create({
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// Flag to prevent multiple simultaneous refresh attempts
|
||||
let isRefreshing = false
|
||||
let refreshSubscribers: Array<(token: string) => void> = []
|
||||
|
||||
// Subscribe to token refresh
|
||||
function subscribeTokenRefresh(callback: (token: string) => void) {
|
||||
refreshSubscribers.push(callback)
|
||||
}
|
||||
|
||||
// Notify all subscribers about new token
|
||||
function onTokenRefreshed(newToken: string) {
|
||||
refreshSubscribers.forEach(callback => callback(newToken))
|
||||
refreshSubscribers = []
|
||||
}
|
||||
|
||||
// Add auth token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().token
|
||||
if (token) {
|
||||
@@ -18,15 +34,54 @@ api.interceptors.request.use((config) => {
|
||||
return config
|
||||
})
|
||||
|
||||
// Handle 401 errors with token refresh
|
||||
api.interceptors.response.use(
|
||||
(resp) => resp,
|
||||
(err: AxiosError) => {
|
||||
if (err.response?.status === 401) {
|
||||
async (err: AxiosError) => {
|
||||
const originalRequest = err.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
// If 401 and not already retried
|
||||
if (err.response?.status === 401 && originalRequest && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
// Wait for token refresh to complete
|
||||
return new Promise((resolve) => {
|
||||
subscribeTokenRefresh((token: string) => {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
resolve(api(originalRequest))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
const refreshed = await useAuthStore.getState().tokenRefresh()
|
||||
if (refreshed) {
|
||||
const newToken = useAuthStore.getState().token
|
||||
if (newToken && originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
}
|
||||
onTokenRefreshed(newToken || '')
|
||||
isRefreshing = false
|
||||
return api(originalRequest)
|
||||
}
|
||||
} catch (refreshError) {
|
||||
isRefreshing = false
|
||||
refreshSubscribers = []
|
||||
}
|
||||
|
||||
// Refresh failed, logout
|
||||
useAuthStore.getState().logout()
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
// For other errors, just reject
|
||||
return Promise.reject(err)
|
||||
},
|
||||
)
|
||||
@@ -55,3 +110,13 @@ export function imageURL(remote?: string): string {
|
||||
if (remote.startsWith('/api/img')) return remote
|
||||
return `/api/img?url=${encodeURIComponent(remote)}&${tokenQuery()}`
|
||||
}
|
||||
|
||||
// getToken returns the current auth token
|
||||
export function getToken(): string | null {
|
||||
return useAuthStore.getState().token
|
||||
}
|
||||
|
||||
// getRefreshToken returns the current refresh token
|
||||
export function getRefreshToken(): string | null {
|
||||
return useAuthStore.getState().refreshToken
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { api } from './client'
|
||||
import type { DownloadClient } from '../types'
|
||||
|
||||
export interface DownloadClientCreateParams {
|
||||
name: string
|
||||
type: 'qbittorrent' | 'transmission' | 'aria2'
|
||||
host: string
|
||||
username?: string
|
||||
password?: string
|
||||
is_default?: boolean
|
||||
extra?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface DownloadClientUpdateParams {
|
||||
name?: string
|
||||
type?: 'qbittorrent' | 'transmission' | 'aria2'
|
||||
host?: string
|
||||
username?: string
|
||||
password?: string
|
||||
is_default?: boolean
|
||||
enabled?: boolean
|
||||
extra?: Record<string, string>
|
||||
}
|
||||
|
||||
export const downloadClientAPI = {
|
||||
list: () =>
|
||||
api
|
||||
.get<{ code: number; data: DownloadClient[] }>('/download-clients')
|
||||
.then((r) => r.data.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api
|
||||
.get<{ code: number; data: DownloadClient }>(`/download-clients/${id}`)
|
||||
.then((r) => r.data.data),
|
||||
|
||||
create: (params: DownloadClientCreateParams) =>
|
||||
api
|
||||
.post<{ code: number; data: DownloadClient }>('/download-clients', params)
|
||||
.then((r) => r.data.data),
|
||||
|
||||
update: (id: string, params: DownloadClientUpdateParams) =>
|
||||
api
|
||||
.put<{ code: number; data: DownloadClient }>(`/download-clients/${id}`, params)
|
||||
.then((r) => r.data.data),
|
||||
|
||||
delete: (id: string) =>
|
||||
api.delete(`/download-clients/${id}`).then((r) => r.data),
|
||||
|
||||
test: (id: string) =>
|
||||
api.post(`/download-clients/${id}/test`).then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { api } from './client'
|
||||
import type { NotifyChannel, NotifyProviderInfo } from '../types'
|
||||
|
||||
export interface NotifyChannelCreateParams {
|
||||
name: string
|
||||
type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
|
||||
enabled?: boolean
|
||||
config: Record<string, string>
|
||||
events?: string[]
|
||||
}
|
||||
|
||||
export interface NotifyChannelUpdateParams {
|
||||
name?: string
|
||||
enabled?: boolean
|
||||
config?: Record<string, string>
|
||||
events?: string[]
|
||||
}
|
||||
|
||||
export const notifyAPI = {
|
||||
list: () =>
|
||||
api
|
||||
.get<{ code: number; data: NotifyChannel[] }>('/notify-channels')
|
||||
.then((r) => r.data.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api
|
||||
.get<{ code: number; data: NotifyChannel }>(`/notify-channels/${id}`)
|
||||
.then((r) => r.data.data),
|
||||
|
||||
getTypes: () =>
|
||||
api
|
||||
.get<{ code: number; data: NotifyProviderInfo[] }>('/notify-channels/types')
|
||||
.then((r) => r.data.data),
|
||||
|
||||
create: (params: NotifyChannelCreateParams) =>
|
||||
api
|
||||
.post<{ code: number; data: NotifyChannel }>('/notify-channels', params)
|
||||
.then((r) => r.data.data),
|
||||
|
||||
update: (id: string, params: NotifyChannelUpdateParams) =>
|
||||
api
|
||||
.put<{ code: number; data: NotifyChannel }>(`/notify-channels/${id}`, params)
|
||||
.then((r) => r.data.data),
|
||||
|
||||
delete: (id: string) =>
|
||||
api.delete(`/notify-channels/${id}`).then((r) => r.data),
|
||||
|
||||
test: (id: string) =>
|
||||
api.post(`/notify-channels/${id}/test`).then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// 权限 API 模块
|
||||
import { api } from './client'
|
||||
import type { UserPermission } from '../types'
|
||||
|
||||
// 获取用户权限
|
||||
export async function getUserPermissions(userId: string): Promise<UserPermission> {
|
||||
const resp = await api.get<UserPermission>(`/admin/users/${userId}/permissions`)
|
||||
return resp.data as unknown as UserPermission
|
||||
}
|
||||
|
||||
// 更新用户权限
|
||||
export async function updateUserPermissions(
|
||||
userId: string,
|
||||
permissions: Record<string, boolean>
|
||||
): Promise<void> {
|
||||
await api.put(`/admin/users/${userId}/permissions`, { permissions })
|
||||
}
|
||||
|
||||
// 重置用户权限为默认值
|
||||
export async function resetUserPermissions(userId: string): Promise<void> {
|
||||
await api.post(`/admin/users/${userId}/permissions/reset`)
|
||||
}
|
||||
|
||||
// 获取当前用户权限
|
||||
export async function getMyPermissions(): Promise<{
|
||||
permissions: Record<string, boolean>
|
||||
role: string
|
||||
tier: string
|
||||
is_super: boolean
|
||||
}> {
|
||||
const resp = await api.get('/auth/permissions')
|
||||
return resp.data as unknown as {
|
||||
permissions: Record<string, boolean>
|
||||
role: string
|
||||
tier: string
|
||||
is_super: boolean
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// 令牌刷新 API 模块
|
||||
import { api } from './client'
|
||||
|
||||
// 刷新令牌请求/响应
|
||||
export interface RefreshTokenRequest {
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
export interface RefreshTokenResponse {
|
||||
token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
// 刷新访问令牌
|
||||
export async function refreshToken(refreshToken: string): Promise<RefreshTokenResponse> {
|
||||
const resp = await api.post<RefreshTokenResponse>('/auth/refresh', {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
return resp.data as unknown as RefreshTokenResponse
|
||||
}
|
||||
|
||||
// 登出
|
||||
export async function logout(): Promise<void> {
|
||||
await api.post('/me/logout')
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { api } from './client'
|
||||
|
||||
// Site management API - matches Go backend /api/sites endpoints
|
||||
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),
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Eye, KeyRound, Save, Trash2, X } from 'lucide-react'
|
||||
|
||||
import { apiConfigsAPI, type APIConfig } from '../api/api_configs'
|
||||
|
||||
// Compact inline-editable provider table for use inside AdminPage's "外部API" tab.
|
||||
export function APIConfigsPanel() {
|
||||
const [items, setItems] = useState<APIConfig[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
|
||||
const refresh = () =>
|
||||
apiConfigsAPI
|
||||
.list()
|
||||
.then(setItems)
|
||||
.finally(() => setLoading(false))
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<KeyRound className="h-5 w-5 text-primary-400" />
|
||||
<div>
|
||||
<p className="font-display text-lg font-semibold text-white">外部 API 配置</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban 密钥管理
|
||||
· AES-GCM 加密存储
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<p className="py-6 text-center text-sm text-slate-500">加载中…</p>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<div className="glass-panel overflow-hidden">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-white/5 text-xs uppercase tracking-wider text-slate-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">服务</th>
|
||||
<th className="px-4 py-3">密钥</th>
|
||||
<th className="px-4 py-3">状态</th>
|
||||
<th className="px-4 py-3 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) =>
|
||||
editing === item.provider ? (
|
||||
<EditingRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null)
|
||||
refresh()
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-t border-white/5 transition hover:bg-white/[0.02]"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-white">{item.provider}</p>
|
||||
{item.description && (
|
||||
<p className="text-xs text-slate-500">{item.description}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">
|
||||
{item.has_key ? (
|
||||
<span className="text-primary-400">{item.masked_key}</span>
|
||||
) : (
|
||||
<span className="text-slate-600">未配置</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{item.has_key ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-400/10 px-2 py-0.5 text-xs text-emerald-400">
|
||||
已配置
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-400/10 px-2 py-0.5 text-xs text-slate-500">
|
||||
未配置
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => setEditing(item.provider)}
|
||||
className="rounded p-1.5 text-slate-400 transition hover:bg-white/5 hover:text-white"
|
||||
title="编辑"
|
||||
>
|
||||
<Save size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
toast(
|
||||
`已配置 ${item.has_key ? '✓' : '✗'} 密钥 (在线测试请用对应功能页面)`,
|
||||
)
|
||||
}}
|
||||
className="rounded p-1.5 text-slate-400 transition hover:bg-white/5 hover:text-slate-200"
|
||||
title="查看状态"
|
||||
>
|
||||
<Eye size={14} />
|
||||
</button>
|
||||
{item.has_key && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`确定清除 ${item.provider} 的 API Key?`)) return
|
||||
await apiConfigsAPI.remove(item.provider)
|
||||
toast.success('已清除')
|
||||
refresh()
|
||||
}}
|
||||
className="rounded p-1.5 text-slate-400 transition hover:bg-red-400/10 hover:text-red-400"
|
||||
title="清除密钥"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditingRow({
|
||||
item,
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: {
|
||||
item: APIConfig
|
||||
onCancel: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [apiKey, setAPIKey] = useState('')
|
||||
const [baseURL, setBaseURL] = useState(item.base_url ?? '')
|
||||
const [enabled, setEnabled] = useState(item.enabled)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
const patch: Record<string, unknown> = { base_url: baseURL, enabled }
|
||||
if (apiKey.trim()) patch.api_key = apiKey.trim()
|
||||
await apiConfigsAPI.update(item.provider, patch)
|
||||
toast.success(`${item.provider} 已保存`)
|
||||
onSaved()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="border-t border-white/5 bg-primary-400/5">
|
||||
<td colSpan={4} className="px-4 py-3">
|
||||
<form onSubmit={submit} className="flex flex-wrap items-end gap-3">
|
||||
<span className="text-sm font-medium text-white">{item.provider}</span>
|
||||
<label className="flex-1 text-xs text-slate-400">
|
||||
API Key
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
type="password"
|
||||
placeholder={item.has_key ? '•••••••••••• (留空保留原值)' : '输入密钥'}
|
||||
value={apiKey}
|
||||
onChange={(e) => setAPIKey(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex-1 text-xs text-slate-400">
|
||||
Base URL
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
placeholder="https://api.themoviedb.org/3"
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
<button type="submit" disabled={saving} className="neon-button !px-3 !py-1.5 !text-xs">
|
||||
<Save size={12} /> 保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded border border-slate-400/30 px-2 py-1.5 text-xs text-slate-400 hover:text-white"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Plug, Trash2, Zap, Settings, Loader2 } from 'lucide-react'
|
||||
|
||||
import { downloadClientAPI, type DownloadClientCreateParams } from '../api/downloadClient'
|
||||
import type { DownloadClient } from '../types'
|
||||
|
||||
const CLIENT_TYPES = [
|
||||
{ value: 'qbittorrent', label: 'qBittorrent' },
|
||||
{ value: 'transmission', label: 'Transmission' },
|
||||
{ value: 'aria2', label: 'Aria2' },
|
||||
] as const
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
qbittorrent: 'qBittorrent',
|
||||
transmission: 'Transmission',
|
||||
aria2: 'Aria2',
|
||||
}
|
||||
|
||||
export function DownloadClientCard() {
|
||||
const [clients, setClients] = useState<DownloadClient[]>([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [testing, setTesting] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<DownloadClientCreateParams>({
|
||||
name: '',
|
||||
type: 'qbittorrent',
|
||||
host: '',
|
||||
username: '',
|
||||
password: '',
|
||||
is_default: false,
|
||||
})
|
||||
|
||||
const refresh = () => downloadClientAPI.list().then(setClients)
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await downloadClientAPI.create(form)
|
||||
toast.success('下载客户端已创建')
|
||||
setForm({ name: '', type: 'qbittorrent', host: '', username: '', password: '', is_default: false })
|
||||
setShowForm(false)
|
||||
await refresh()
|
||||
} catch {
|
||||
toast.error('创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setTesting(id)
|
||||
try {
|
||||
await downloadClientAPI.test(id)
|
||||
toast.success('连接测试成功')
|
||||
} catch {
|
||||
toast.error('连接测试失败')
|
||||
} finally {
|
||||
setTesting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`确定删除「${name}」?`)) return
|
||||
await downloadClientAPI.delete(id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const handleToggleDefault = async (client: DownloadClient) => {
|
||||
await downloadClientAPI.update(client.id, { is_default: true })
|
||||
toast.success(`已设「${client.name}」为默认客户端`)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (client: DownloadClient) => {
|
||||
await downloadClientAPI.update(client.id, { enabled: !client.enabled })
|
||||
toast.success(client.enabled ? '已禁用' : '已启用')
|
||||
await refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">下载客户端</h2>
|
||||
<button
|
||||
className="neon-button flex items-center gap-1 text-sm"
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
>
|
||||
<Plug size={14} />
|
||||
添加客户端
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="glass-panel space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="名称"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.type}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, type: e.target.value as DownloadClientCreateParams['type'] })
|
||||
}
|
||||
>
|
||||
{CLIENT_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="地址 (如 http://127.0.0.1:8080)"
|
||||
value={form.host}
|
||||
onChange={(e) => setForm({ ...form, host: e.target.value })}
|
||||
/>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="用户名"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="input-base"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_default}
|
||||
onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
|
||||
className="rounded border-slate-600"
|
||||
/>
|
||||
设为默认客户端
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="neon-button">
|
||||
创建
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-white/20 px-3 py-1.5 text-sm text-slate-300 hover:bg-white/5"
|
||||
onClick={() => setShowForm(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{clients.map((client) => (
|
||||
<div
|
||||
key={client.id}
|
||||
className={`glass-panel flex items-center justify-between ${
|
||||
!client.enabled ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`rounded-lg p-2 ${
|
||||
client.type === 'qbittorrent'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: client.type === 'transmission'
|
||||
? 'bg-purple-500/20 text-purple-400'
|
||||
: 'bg-orange-500/20 text-orange-400'
|
||||
}`}
|
||||
>
|
||||
<Settings size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white">{client.name}</span>
|
||||
<span className="rounded bg-white/10 px-1.5 py-0.5 text-xs text-slate-400">
|
||||
{TYPE_LABELS[client.type]}
|
||||
</span>
|
||||
{client.is_default && (
|
||||
<span className="rounded bg-primary-400/20 px-1.5 py-0.5 text-xs text-primary-400">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
{!client.enabled && (
|
||||
<span className="rounded bg-red-400/20 px-1.5 py-0.5 text-xs text-red-400">
|
||||
已禁用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">{client.host}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!client.is_default && (
|
||||
<button
|
||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||
onClick={() => handleToggleDefault(client)}
|
||||
title="设为默认"
|
||||
>
|
||||
<Zap size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="rounded border border-yellow-400/40 px-2 py-1 text-xs text-yellow-400 hover:bg-yellow-400/10"
|
||||
onClick={() => handleToggleEnabled(client)}
|
||||
title={client.enabled ? '禁用' : '启用'}
|
||||
>
|
||||
{client.enabled ? '暂停' : '启用'}
|
||||
</button>
|
||||
<button
|
||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||
onClick={() => handleTest(client.id)}
|
||||
disabled={testing === client.id}
|
||||
>
|
||||
{testing === client.id ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
'测试'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
onClick={() => handleDelete(client.id, client.name)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{clients.length === 0 && (
|
||||
<div className="py-8 text-center text-sm text-slate-500">
|
||||
暂无下载客户端,点击上方按钮添加
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
Copy,
|
||||
Film,
|
||||
FolderTree,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Heart,
|
||||
Home,
|
||||
GalleryHorizontalEnd,
|
||||
KeyRound,
|
||||
ListChecks,
|
||||
ListMusic,
|
||||
LogOut,
|
||||
@@ -94,18 +94,18 @@ export function Layout() {
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<>
|
||||
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
|
||||
管理
|
||||
</div>
|
||||
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" />
|
||||
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" />
|
||||
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储" />
|
||||
<SidebarLink to="/files" icon={<FolderTree size={18} />} label="文件浏览" />
|
||||
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="重复文件" />
|
||||
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时任务" />
|
||||
<SidebarLink to="/api-configs" icon={<KeyRound size={18} />} label="API 配置" />
|
||||
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
|
||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
|
||||
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
|
||||
管理
|
||||
</div>
|
||||
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" />
|
||||
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" />
|
||||
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储" />
|
||||
<SidebarLink to="/files" icon={<FolderTree size={18} />} label="文件浏览" />
|
||||
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="重复文件" />
|
||||
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时任务" />
|
||||
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点管理" />
|
||||
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
|
||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Bell, Trash2, Loader2 } from 'lucide-react'
|
||||
|
||||
import { notifyAPI, type NotifyChannelCreateParams } from '../api/notify'
|
||||
import type { NotifyChannel } from '../types'
|
||||
|
||||
const CHANNEL_TYPES = [
|
||||
{ value: 'telegram', label: 'Telegram' },
|
||||
{ value: 'wechat', label: 'Server酱' },
|
||||
{ value: 'bark', label: 'Bark' },
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
{ value: 'email', label: 'Email' },
|
||||
] as const
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
telegram: 'Telegram',
|
||||
wechat: 'Server酱',
|
||||
bark: 'Bark',
|
||||
webhook: 'Webhook',
|
||||
email: 'Email',
|
||||
}
|
||||
|
||||
const EVENT_OPTIONS = [
|
||||
{ value: 'subscription_hit', label: '订阅命中' },
|
||||
{ value: 'download_complete', label: '下载完成' },
|
||||
{ value: 'scrape_failed', label: '刮削失败' },
|
||||
{ value: 'system_alert', label: '系统告警' },
|
||||
]
|
||||
|
||||
const CONFIG_FIELDS: Record<string, { key: string; label: string; type?: string; placeholder: string }[]> = {
|
||||
telegram: [
|
||||
{ key: 'bot_token', label: 'Bot Token', placeholder: '123456:ABC-DEF...' },
|
||||
{ key: 'chat_id', label: 'Chat ID', placeholder: '你的 Chat ID' },
|
||||
{ key: 'parse_mode', label: 'Parse Mode', placeholder: 'HTML (可选)' },
|
||||
],
|
||||
wechat: [{ key: 'sendkey', label: 'SendKey', placeholder: 'SCT...' }],
|
||||
bark: [
|
||||
{ key: 'server_url', label: '服务器地址', placeholder: 'https://api.day.app (可选)' },
|
||||
{ key: 'device_key', label: 'Device Key', placeholder: '你的 Bark Key' },
|
||||
],
|
||||
webhook: [
|
||||
{ key: 'url', label: 'Webhook URL', placeholder: 'https://...' },
|
||||
{ key: 'method', label: 'HTTP 方法', placeholder: 'POST (可选)' },
|
||||
{ key: 'headers_json', label: 'Headers JSON', placeholder: '{"Authorization":"Bearer ..."} (可选)' },
|
||||
{ key: 'body_template', label: 'Body 模板', placeholder: '{{title}}: {{message}} (可选)' },
|
||||
],
|
||||
email: [
|
||||
{ key: 'smtp_host', label: 'SMTP 地址', placeholder: 'smtp.gmail.com' },
|
||||
{ key: 'smtp_port', label: 'SMTP 端口', placeholder: '465' },
|
||||
{ key: 'username', label: '用户名', placeholder: '' },
|
||||
{ key: 'password', label: '密码', placeholder: '', type: 'password' },
|
||||
{ key: 'from', label: '发件人', placeholder: 'noreply@example.com' },
|
||||
{ key: 'to', label: '收件人', placeholder: 'user@example.com (多个用逗号分隔)' },
|
||||
{ key: 'tls', label: 'TLS', placeholder: 'true/false' },
|
||||
],
|
||||
}
|
||||
|
||||
export function NotifyChannelCard() {
|
||||
const [channels, setChannels] = useState<NotifyChannel[]>([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [testing, setTesting] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<NotifyChannelCreateParams>({
|
||||
name: '',
|
||||
type: 'telegram',
|
||||
enabled: true,
|
||||
config: {},
|
||||
events: [],
|
||||
})
|
||||
|
||||
const refresh = () => notifyAPI.list().then(setChannels)
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const handleTypeChange = (type: string) => {
|
||||
setForm({
|
||||
...form,
|
||||
type: type as NotifyChannelCreateParams['type'],
|
||||
config: {},
|
||||
})
|
||||
}
|
||||
|
||||
const handleConfigChange = (key: string, value: string) => {
|
||||
setForm({ ...form, config: { ...form.config, [key]: value } })
|
||||
}
|
||||
|
||||
const handleEventToggle = (event: string) => {
|
||||
const events = form.events || []
|
||||
const idx = events.indexOf(event)
|
||||
if (idx >= 0) {
|
||||
events.splice(idx, 1)
|
||||
} else {
|
||||
events.push(event)
|
||||
}
|
||||
setForm({ ...form, events: [...events] })
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await notifyAPI.create(form)
|
||||
toast.success('通知渠道已创建')
|
||||
setForm({ name: '', type: 'telegram', enabled: true, config: {}, events: [] })
|
||||
setShowForm(false)
|
||||
await refresh()
|
||||
} catch {
|
||||
toast.error('创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setTesting(id)
|
||||
try {
|
||||
await notifyAPI.test(id)
|
||||
toast.success('测试通知已发送')
|
||||
} catch {
|
||||
toast.error('测试通知发送失败')
|
||||
} finally {
|
||||
setTesting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`确定删除「${name}」?`)) return
|
||||
await notifyAPI.delete(id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (channel: NotifyChannel) => {
|
||||
await notifyAPI.update(channel.id, { enabled: !channel.enabled })
|
||||
toast.success(channel.enabled ? '已禁用' : '已启用')
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const configFields = CONFIG_FIELDS[form.type] || []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">通知渠道</h2>
|
||||
<button
|
||||
className="neon-button flex items-center gap-1 text-sm"
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
>
|
||||
<Bell size={14} />
|
||||
添加渠道
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="glass-panel space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="名称"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
<select className="input-base" value={form.type} onChange={(e) => handleTypeChange(e.target.value)}>
|
||||
{CHANNEL_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{configFields.map((field) => (
|
||||
<input
|
||||
key={field.key}
|
||||
required={field.key !== 'parse_mode' && field.key !== 'method' && field.key !== 'headers_json' && field.key !== 'body_template' && field.key !== 'server_url' && field.key !== 'tls'}
|
||||
className="input-base"
|
||||
type={field.type || 'text'}
|
||||
placeholder={field.label + (field.placeholder ? ` (${field.placeholder})` : '')}
|
||||
value={form.config[field.key] || ''}
|
||||
onChange={(e) => handleConfigChange(field.key, e.target.value)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-500">订阅事件</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{EVENT_OPTIONS.map((event) => (
|
||||
<label
|
||||
key={event.value}
|
||||
className="flex cursor-pointer items-center gap-1 rounded border border-white/10 px-2 py-1 text-xs text-slate-300"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(form.events || []).includes(event.value)}
|
||||
onChange={() => handleEventToggle(event.value)}
|
||||
className="rounded border-slate-600"
|
||||
/>
|
||||
{event.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="neon-button">
|
||||
创建
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-white/20 px-3 py-1.5 text-sm text-slate-300 hover:bg-white/5"
|
||||
onClick={() => setShowForm(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{channels.map((channel) => (
|
||||
<div
|
||||
key={channel.id}
|
||||
className={`glass-panel flex items-center justify-between ${
|
||||
!channel.enabled ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-green-500/20 p-2 text-green-400">
|
||||
<Bell size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white">{channel.name}</span>
|
||||
<span className="rounded bg-white/10 px-1.5 py-0.5 text-xs text-slate-400">
|
||||
{TYPE_LABELS[channel.type]}
|
||||
</span>
|
||||
{!channel.enabled && (
|
||||
<span className="rounded bg-red-400/20 px-1.5 py-0.5 text-xs text-red-400">
|
||||
已禁用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">
|
||||
事件: {channel.events || '无'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="rounded border border-yellow-400/40 px-2 py-1 text-xs text-yellow-400 hover:bg-yellow-400/10"
|
||||
onClick={() => handleToggleEnabled(channel)}
|
||||
>
|
||||
{channel.enabled ? '禁用' : '启用'}
|
||||
</button>
|
||||
<button
|
||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||
onClick={() => handleTest(channel.id)}
|
||||
disabled={testing === channel.id}
|
||||
>
|
||||
{testing === channel.id ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
'测试'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
onClick={() => handleDelete(channel.id, channel.name)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{channels.length === 0 && (
|
||||
<div className="py-8 text-center text-sm text-slate-500">
|
||||
暂无通知渠道,点击上方按钮添加
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
import { usePermissionStore } from '../stores/permissions'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
interface PermissionGuardProps {
|
||||
permission: string
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
requireSuperUser?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* PermissionGuard 组件用于根据用户权限控制内容显示。
|
||||
*
|
||||
* @param permission - 需要的权限键
|
||||
* @param children - 有权限时显示的内容
|
||||
* @param fallback - 无权限时显示的内容(可选,默认不显示)
|
||||
* @param requireSuperUser - 是否要求超级用户(admin/plus)绕过权限检查
|
||||
*/
|
||||
export function PermissionGuard({
|
||||
permission,
|
||||
children,
|
||||
fallback = null,
|
||||
requireSuperUser = false,
|
||||
}: PermissionGuardProps) {
|
||||
const { hasPermission, isSuper, permissions, isLoading } = usePermissionStore()
|
||||
const tier = useAuthStore((state) => state.tier)
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
|
||||
// 超级用户(admin 或 plus)默认有所有权限
|
||||
if (isSuper || tier === 'plus' || role === 'admin') {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// 如果 requireSuperUser 为 true 且用户不是超级用户,则不显示
|
||||
if (requireSuperUser && !isSuper) {
|
||||
return <>{fallback}</>
|
||||
}
|
||||
|
||||
// 加载中时显示 fallback
|
||||
if (isLoading && Object.keys(permissions).length === 0) {
|
||||
return <>{fallback}</>
|
||||
}
|
||||
|
||||
// 检查具体权限
|
||||
if (hasPermission(permission)) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return <>{fallback}</>
|
||||
}
|
||||
|
||||
// 权限检查工具函数
|
||||
export function checkPermission(
|
||||
permission: string,
|
||||
isSuper: boolean,
|
||||
tier: string,
|
||||
role: string,
|
||||
permissions: Record<string, boolean>
|
||||
): boolean {
|
||||
// 超级用户有所有权限
|
||||
if (isSuper || tier === 'plus' || role === 'admin') {
|
||||
return true
|
||||
}
|
||||
return permissions[permission] === true
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { usePermissionStore } from '../stores/permissions'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
/**
|
||||
* usePermission hook - 检查用户是否拥有特定权限
|
||||
*
|
||||
* @param key - 权限键名
|
||||
* @param options - 配置选项
|
||||
* @param options.autoFetch - 是否在权限未加载时自动获取(默认 true)
|
||||
* @returns boolean - 用户是否拥有该权限
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const canEdit = usePermission('can_edit_media')
|
||||
*
|
||||
* if (canEdit) {
|
||||
* return <EditButton />
|
||||
* }
|
||||
* return null
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function usePermission(
|
||||
key: string,
|
||||
options: { autoFetch?: boolean } = {}
|
||||
): boolean {
|
||||
const { autoFetch = true } = options
|
||||
const { hasPermission, isSuper, permissions, isLoading, fetchPermissions } = usePermissionStore()
|
||||
const tier = useAuthStore((state) => state.tier)
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
||||
|
||||
// 超级用户有所有权限
|
||||
if (isSuper || tier === 'plus' || role === 'admin') {
|
||||
return true
|
||||
}
|
||||
|
||||
// 权限未加载且未认证时,返回 false
|
||||
if (!isAuthenticated) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 权限未加载时自动获取
|
||||
useEffect(() => {
|
||||
if (autoFetch && Object.keys(permissions).length === 0 && !isLoading) {
|
||||
fetchPermissions()
|
||||
}
|
||||
}, [autoFetch, permissions, isLoading, fetchPermissions])
|
||||
|
||||
return hasPermission(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* usePermissions hook - 获取所有权限
|
||||
*
|
||||
* @returns 权限状态和检查函数
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const { permissions, isSuper, check } = usePermissions()
|
||||
*
|
||||
* if (isSuper) {
|
||||
* return <AdminPanel />
|
||||
* }
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {check('can_view_dashboard') && <Dashboard />}
|
||||
* {check('can_play_media') && <Player />}
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function usePermissions() {
|
||||
const { permissions, isSuper, isLoading, fetchPermissions } = usePermissionStore()
|
||||
const tier = useAuthStore((state) => state.tier)
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
||||
|
||||
const check = (key: string): boolean => {
|
||||
if (isSuper || tier === 'plus' || role === 'admin') {
|
||||
return true
|
||||
}
|
||||
return permissions[key] === true
|
||||
}
|
||||
|
||||
return {
|
||||
permissions,
|
||||
isSuper: isSuper || tier === 'plus' || role === 'admin',
|
||||
isLoading,
|
||||
check,
|
||||
refetch: fetchPermissions,
|
||||
isAuthenticated,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* usePermissionMany hook - 批量检查多个权限
|
||||
*
|
||||
* @param keys - 权限键数组
|
||||
* @returns 每个权限的布尔值映射
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const perms = usePermissionMany([
|
||||
* 'can_edit_media',
|
||||
* 'can_manage_users',
|
||||
* 'can_access_settings',
|
||||
* ])
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {perms['can_edit_media'] && <EditButton />}
|
||||
* {perms['can_manage_users'] && <UserManagement />}
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function usePermissionMany(keys: string[]): Record<string, boolean> {
|
||||
const { check } = usePermissions()
|
||||
|
||||
return keys.reduce((acc, key) => {
|
||||
acc[key] = check(key)
|
||||
return acc
|
||||
}, {} as Record<string, boolean>)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { SSEEvent } from '../types'
|
||||
|
||||
type SSEEventHandler = (event: SSEEvent) => void
|
||||
|
||||
/**
|
||||
* useSSE hook - 管理 Server-Sent Events 连接
|
||||
*
|
||||
* @param onEvent - 事件处理函数
|
||||
* @param options - 配置选项
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const { connect, disconnect } = useSSE((event) => {
|
||||
* if (event.type === 'scan') {
|
||||
* console.log('Scan progress:', event.payload)
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* useEffect(() => {
|
||||
* connect()
|
||||
* return () => disconnect()
|
||||
* }, [])
|
||||
*
|
||||
* return <div>SSE Demo</div>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSSE(
|
||||
onEvent: SSEEventHandler,
|
||||
options: { autoConnect?: boolean } = {}
|
||||
) {
|
||||
const { autoConnect = true } = options
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isConnectedRef = useRef(false)
|
||||
const reconnectAttemptsRef = useRef(0)
|
||||
const maxReconnectAttempts = 5
|
||||
|
||||
const connect = useCallback(() => {
|
||||
// 如果已有连接,先断开
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
}
|
||||
|
||||
const token = useAuthStore.getState().token
|
||||
if (!token) {
|
||||
console.warn('Cannot connect to SSE: No auth token')
|
||||
return
|
||||
}
|
||||
|
||||
const url = `/api/events?token=${encodeURIComponent(token)}`
|
||||
const eventSource = new EventSource(url)
|
||||
eventSourceRef.current = eventSource
|
||||
|
||||
eventSource.onopen = () => {
|
||||
isConnectedRef.current = true
|
||||
reconnectAttemptsRef.current = 0
|
||||
console.log('SSE connected')
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as SSEEvent
|
||||
onEvent(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to parse SSE event:', err)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
isConnectedRef.current = false
|
||||
eventSource.close()
|
||||
|
||||
// 尝试重连
|
||||
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000)
|
||||
reconnectAttemptsRef.current++
|
||||
console.log(`SSE reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current})`)
|
||||
reconnectTimeoutRef.current = setTimeout(connect, delay)
|
||||
} else {
|
||||
console.error('SSE connection failed after max attempts')
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义事件类型
|
||||
eventSource.addEventListener('connected', (event) => {
|
||||
console.log('SSE handshake received:', event)
|
||||
})
|
||||
|
||||
}, [onEvent])
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
reconnectTimeoutRef.current = null
|
||||
}
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
isConnectedRef.current = false
|
||||
reconnectAttemptsRef.current = 0
|
||||
}, [])
|
||||
|
||||
const isConnected = useCallback(() => {
|
||||
return isConnectedRef.current
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoConnect) {
|
||||
connect()
|
||||
}
|
||||
return () => {
|
||||
disconnect()
|
||||
}
|
||||
}, [autoConnect, connect, disconnect])
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
isConnected,
|
||||
}
|
||||
}
|
||||
+90
-13
@@ -4,39 +4,55 @@ import { Trash2 } from 'lucide-react'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { schedulerAPI, type JobStatus } from '../api/scheduler'
|
||||
import type { Library, User } from '../types'
|
||||
import { DownloadClientCard } from '../components/DownloadClientCard'
|
||||
import { NotifyChannelCard } from '../components/NotifyChannelCard'
|
||||
import { APIConfigsPanel } from '../components/APIConfigsPanel'
|
||||
import { SitesPage } from './SitesPage'
|
||||
|
||||
// Single-page admin console. We split the surface into three panels:
|
||||
// • Libraries — create / delete / scan
|
||||
// • Users — list / delete (cannot delete self)
|
||||
// • Settings — generic key/value store viewer
|
||||
//
|
||||
// Each panel manages its own loading / refresh state.
|
||||
export function AdminPage() {
|
||||
const [tab, setTab] = useState<'library' | 'users' | 'settings'>('library')
|
||||
const [tab, setTab] = useState<
|
||||
'sites' | 'library' | 'users' | 'settings' | 'api' | 'downloads' | 'notify' | 'scheduler'
|
||||
>('sites')
|
||||
const tabs = [
|
||||
{ key: 'sites' as const, label: '站点管理' },
|
||||
{ key: 'library' as const, label: '媒体库' },
|
||||
{ key: 'users' as const, label: '用户' },
|
||||
{ key: 'api' as const, label: '外部API' },
|
||||
{ key: 'settings' as const, label: '系统设置' },
|
||||
{ key: 'downloads' as const, label: '下载客户端' },
|
||||
{ key: 'notify' as const, label: '通知渠道' },
|
||||
{ key: 'scheduler' as const, label: '定时任务' },
|
||||
]
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-white">管理后台</h1>
|
||||
<div className="flex gap-2 border-b border-white/10">
|
||||
{(['library', 'users', 'settings'] as const).map((k) => (
|
||||
<div className="flex flex-wrap gap-2 border-b border-white/10">
|
||||
{tabs.map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setTab(k)}
|
||||
key={k.key}
|
||||
onClick={() => setTab(k.key)}
|
||||
className={
|
||||
'border-b-2 px-4 py-2 text-sm transition ' +
|
||||
(tab === k
|
||||
(tab === k.key
|
||||
? 'border-primary-400 text-primary-400'
|
||||
: 'border-transparent text-slate-400 hover:text-white')
|
||||
}
|
||||
>
|
||||
{k === 'library' ? '媒体库' : k === 'users' ? '用户' : '设置'}
|
||||
{k.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'sites' && <SitesPage />}
|
||||
{tab === 'library' && <LibraryPanel />}
|
||||
{tab === 'users' && <UsersPanel />}
|
||||
{tab === 'api' && <APIConfigsPanel />}
|
||||
{tab === 'settings' && <SettingsPanel />}
|
||||
{tab === 'downloads' && <DownloadClientCard />}
|
||||
{tab === 'notify' && <NotifyChannelCard />}
|
||||
{tab === 'scheduler' && <SchedulerPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -251,3 +267,64 @@ function SettingsPanel() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SchedulerPanel() {
|
||||
const [tasks, setTasks] = useState<JobStatus[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true)
|
||||
schedulerAPI.status().then(setTasks).finally(() => setLoading(false))
|
||||
}
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="glass-panel">
|
||||
{loading && <p className="py-4 text-center text-slate-400">加载中...</p>}
|
||||
{!loading && tasks.length === 0 && (
|
||||
<p className="py-4 text-center text-slate-400">暂无定时任务</p>
|
||||
)}
|
||||
{!loading && tasks.length > 0 && (
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
<th>间隔</th>
|
||||
<th>上次运行</th>
|
||||
<th className="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((t) => (
|
||||
<tr key={t.name} className="border-t border-white/5">
|
||||
<td className="py-2 text-white">{t.name}</td>
|
||||
<td className="text-slate-300">{t.interval}</td>
|
||||
<td className="text-slate-400">
|
||||
{t.last_run ? new Date(t.last_run).toLocaleString() : '-'}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
className="rounded border border-amber-400/40 px-2 py-1 text-xs text-amber-400 hover:bg-amber-400/10"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await schedulerAPI.run(t.name)
|
||||
toast.success('任务已触发')
|
||||
refresh()
|
||||
} catch {
|
||||
toast.error('触发失败')
|
||||
}
|
||||
}}
|
||||
>
|
||||
手动运行
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
@@ -7,26 +8,76 @@ import type { Media } from '../types'
|
||||
export function FavouritesPage() {
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
playbackAPI
|
||||
.listFavourites()
|
||||
.then(setItems)
|
||||
.then((data) => setItems(data ?? []))
|
||||
.catch((err) => {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'加载收藏失败'
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const isEmpty = !loading && !error && items.length === 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-white">我的收藏</h1>
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && (
|
||||
<p className="text-slate-400">还没有任何收藏,点击媒体详情页的「收藏」按钮添加。</p>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 py-8 text-slate-400">
|
||||
<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}
|
||||
<button
|
||||
className="ml-3 underline hover:text-red-300"
|
||||
onClick={() => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
playbackAPI
|
||||
.listFavourites()
|
||||
.then((data) => setItems(data ?? []))
|
||||
.catch((err2) => {
|
||||
const msg =
|
||||
(err2 as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'加载收藏失败'
|
||||
setError(msg)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<div className="glass-panel flex flex-col items-center gap-3 p-10 text-center">
|
||||
<p className="text-lg text-slate-300">还没有任何收藏</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
点击媒体详情页的「收藏」按钮添加喜欢的内容
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ export function LoginPage() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await authAPI.login(username, password)
|
||||
setSession(data.token, data.user)
|
||||
toast.success(`欢迎回来,${data.user.username}`)
|
||||
setSession(data.tokens.access_token, data.tokens.refresh_token, data.user)
|
||||
toast.success(`欢迎回来, ${data.user.username}`)
|
||||
navigate('/')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from 'react'
|
||||
import { ChangeEvent, FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
@@ -7,16 +7,15 @@ import { mediaAPI } from '../api/library'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { Media } from '../types'
|
||||
|
||||
// SearchPage runs a fast LIKE search by default, with an optional AI
|
||||
// "smart search" toggle that calls /api/ai/search to produce a structured
|
||||
// intent before executing the library lookup.
|
||||
export function SearchPage() {
|
||||
const [q, setQ] = useState('')
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [aiOn, setAiOn] = useState(false)
|
||||
const [aiAvailable, setAiAvailable] = useState(false)
|
||||
const [intent, setIntent] = useState<SearchIntent | null>(null)
|
||||
const [hasSearched, setHasSearched] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
aiAPI
|
||||
@@ -25,37 +24,63 @@ export function SearchPage() {
|
||||
.catch(() => setAiAvailable(false))
|
||||
}, [])
|
||||
|
||||
const doQuickSearch = useCallback((query: string) => {
|
||||
if (!query.trim()) {
|
||||
setItems([])
|
||||
setHasSearched(false)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setHasSearched(true)
|
||||
setError('')
|
||||
mediaAPI
|
||||
.search(query, 60)
|
||||
.then((d) => {
|
||||
setItems(d.items ?? [])
|
||||
setIntent(null)
|
||||
})
|
||||
.catch((err) => {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'搜索失败'
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// Fast LIKE search-as-you-type when AI mode is OFF.
|
||||
useEffect(() => {
|
||||
if (aiOn) return
|
||||
const t = setTimeout(() => {
|
||||
setLoading(true)
|
||||
mediaAPI
|
||||
.search(q, 60)
|
||||
.then((d) => {
|
||||
setItems(d.items)
|
||||
setIntent(null)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, 300)
|
||||
setLoading(true)
|
||||
const t = setTimeout(() => doQuickSearch(q), 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [q, aiOn])
|
||||
}, [q, aiOn, doQuickSearch])
|
||||
|
||||
const onAISubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!q.trim()) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setHasSearched(true)
|
||||
try {
|
||||
const data = await aiAPI.smartSearch(q)
|
||||
setItems(data.items)
|
||||
setItems(data.items ?? [])
|
||||
setIntent(data.intent)
|
||||
} catch {
|
||||
toast.error('AI 搜索失败')
|
||||
} catch (err) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'AI 搜索失败'
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showEmpty = !loading && !error && hasSearched && items.length === 0
|
||||
const showIdle = !loading && !error && !hasSearched
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
@@ -78,7 +103,7 @@ export function SearchPage() {
|
||||
<input
|
||||
autoFocus
|
||||
className="input-base"
|
||||
placeholder='例如:“2010 年后的科幻电影” / “最近的动漫”'
|
||||
placeholder='例如:"2010 年后的科幻电影" / "最近的动漫"'
|
||||
value={q}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setQ(e.target.value)}
|
||||
/>
|
||||
@@ -103,12 +128,40 @@ export function SearchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <p className="text-slate-500">搜索中…</p>}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
))}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 py-8 text-slate-400">
|
||||
<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-slate-300">输入关键词开始搜索</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
支持电影、电视剧、动漫等媒体内容的快速搜索
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEmpty && (
|
||||
<div className="glass-panel flex flex-col items-center gap-2 p-10 text-center">
|
||||
<p className="text-lg text-slate-300">未找到匹配的媒体</p>
|
||||
<p className="text-sm text-slate-500">尝试其他关键词,或者添加媒体库后执行扫描</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Globe, Plus, Trash2, Wifi, RefreshCw, X, Edit3, CheckCircle, XCircle, HelpCircle } from 'lucide-react'
|
||||
|
||||
import { sitesAPI } from '../api/sites'
|
||||
import type { Site } from '../types'
|
||||
|
||||
// ── 站点类型映射 ──
|
||||
const SITE_TYPE_LABELS: Record<string, string> = {
|
||||
nexusphp: 'NexusPHP',
|
||||
gazelle: 'Gazelle',
|
||||
unit3d: 'UNIT3D',
|
||||
mteam: 'M-Team',
|
||||
discuz: 'Discuz',
|
||||
custom_rss: '自定义 RSS',
|
||||
}
|
||||
|
||||
const SITE_TYPE_ABBR: Record<string, string> = {
|
||||
nexusphp: 'NP',
|
||||
gazelle: 'GZ',
|
||||
unit3d: 'U3',
|
||||
mteam: 'MT',
|
||||
discuz: 'DZ',
|
||||
custom_rss: 'RS',
|
||||
}
|
||||
|
||||
const SITE_TYPE_COLORS: Record<string, string> = {
|
||||
nexusphp: 'bg-blue-500/15 text-blue-400',
|
||||
gazelle: 'bg-purple-500/15 text-purple-400',
|
||||
unit3d: 'bg-orange-500/15 text-orange-400',
|
||||
mteam: 'bg-green-500/15 text-green-400',
|
||||
discuz: 'bg-yellow-500/15 text-yellow-400',
|
||||
custom_rss: 'bg-slate-500/15 text-slate-400',
|
||||
}
|
||||
|
||||
const AUTH_TYPE_LABELS: Record<string, string> = {
|
||||
cookie: 'Cookie',
|
||||
api_key: 'API Key',
|
||||
auth_header: 'Auth Header',
|
||||
}
|
||||
|
||||
// ── 默认表单 ──
|
||||
const defaultForm = () => ({
|
||||
name: '',
|
||||
url: '',
|
||||
type: 'nexusphp',
|
||||
auth_type: 'cookie',
|
||||
cookie: '',
|
||||
api_key: '',
|
||||
auth_header: '',
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
extra: '',
|
||||
})
|
||||
|
||||
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(defaultForm())
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testingId, setTestingId] = useState<string | null>(null)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
|
||||
const loadSites = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await sitesAPI.list()
|
||||
setSites(Array.isArray(res.data) ? res.data : [])
|
||||
} catch {
|
||||
toast.error('加载站点列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadSites()
|
||||
}, [])
|
||||
|
||||
// ── 弹窗操作 ──
|
||||
const openCreate = () => {
|
||||
setEditingId(null)
|
||||
setForm(defaultForm())
|
||||
setAdvancedOpen(false)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const openEdit = async (id: string) => {
|
||||
try {
|
||||
const res = await sitesAPI.get(id)
|
||||
const s = res.data as Site
|
||||
setEditingId(id)
|
||||
setForm({
|
||||
name: s.name || '',
|
||||
url: s.url || '',
|
||||
type: s.type || 'nexusphp',
|
||||
auth_type: s.auth_type || 'cookie',
|
||||
cookie: s.cookie || '',
|
||||
api_key: s.api_key || '',
|
||||
auth_header: s.auth_header || '',
|
||||
enabled: s.enabled !== false,
|
||||
is_default: s.is_default || false,
|
||||
extra: s.extra || '',
|
||||
})
|
||||
setAdvancedOpen(false)
|
||||
setShowModal(true)
|
||||
} catch {
|
||||
toast.error('获取站点详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setShowModal(false)
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
// ── 保存 ──
|
||||
const handleSave = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.name.trim() || !form.url.trim()) {
|
||||
toast.error('站点名称和地址不能为空')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name.trim(),
|
||||
url: form.url.trim(),
|
||||
type: form.type,
|
||||
auth_type: form.auth_type,
|
||||
cookie: form.cookie || '',
|
||||
api_key: form.api_key || '',
|
||||
auth_header: form.auth_header || '',
|
||||
enabled: form.enabled,
|
||||
is_default: form.is_default,
|
||||
extra: form.extra || '',
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
await sitesAPI.update(editingId, payload)
|
||||
toast.success('站点已更新')
|
||||
} else {
|
||||
await sitesAPI.create(payload)
|
||||
toast.success('站点已添加')
|
||||
}
|
||||
closeModal()
|
||||
await loadSites()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
'保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 测试 ──
|
||||
const handleTest = async (id: string) => {
|
||||
setTestingId(id)
|
||||
try {
|
||||
await sitesAPI.test(id)
|
||||
toast.success('连接测试成功')
|
||||
} catch {
|
||||
toast.error('连接测试失败')
|
||||
} finally {
|
||||
setTestingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 删除 ──
|
||||
const handleDelete = async (site: Site) => {
|
||||
if (!confirm(`确定要删除站点「${site.name}」吗?此操作不可撤销。`)) return
|
||||
try {
|
||||
await sitesAPI.remove(site.id)
|
||||
toast.success('站点已删除')
|
||||
await loadSites()
|
||||
} catch {
|
||||
toast.error('删除站点失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ── 站点类型切换时自动切换认证方式 ──
|
||||
const handleTypeChange = (t: string) => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
type: t,
|
||||
auth_type: t === 'mteam' ? 'api_key' : f.auth_type,
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页头 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-display text-3xl font-bold text-white">站点管理</h1>
|
||||
<button onClick={openCreate} className="neon-button flex items-center gap-2">
|
||||
<Plus size={16} />
|
||||
添加站点
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 站点卡片网格 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sites.map((site) => (
|
||||
<div key={site.id} 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-lg flex items-center justify-center text-xs font-bold shrink-0 ${SITE_TYPE_COLORS[site.type] || 'bg-slate-500/15 text-slate-400'}`}>
|
||||
{SITE_TYPE_ABBR[site.type] || '?'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-white truncate">{site.name}</div>
|
||||
<div className="text-xs text-slate-400 truncate max-w-[160px]">{site.url}</div>
|
||||
</div>
|
||||
</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-slate-500" />
|
||||
)}
|
||||
{!site.enabled && <span className="text-xs text-slate-500 ml-1">已停用</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-white/5 text-slate-400">
|
||||
{SITE_TYPE_LABELS[site.type] || site.type}
|
||||
</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-white/5 text-slate-400">
|
||||
{AUTH_TYPE_LABELS[site.auth_type] || site.auth_type}
|
||||
</span>
|
||||
{site.is_default && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-primary-400/15 text-primary-400">默认</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => handleTest(site.id)}
|
||||
disabled={testingId === site.id}
|
||||
className="flex-1 rounded border border-white/10 px-2 py-1.5 text-xs text-slate-300 hover:bg-white/5 disabled:opacity-50 flex items-center justify-center gap-1 transition"
|
||||
>
|
||||
{testingId === site.id ? (
|
||||
<>
|
||||
<RefreshCw size={12} className="animate-spin" />
|
||||
测试中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wifi size={12} />
|
||||
测试连接
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEdit(site.id)}
|
||||
className="rounded border border-white/10 p-1.5 text-slate-400 hover:text-white hover:bg-white/5 transition"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(site)}
|
||||
className="rounded border border-white/10 p-1.5 text-slate-400 hover:text-red-400 hover:bg-red-400/10 transition"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!loading && sites.length === 0 && (
|
||||
<div className="col-span-full py-12 text-center text-slate-400">
|
||||
<Globe size={40} className="mx-auto mb-3 text-slate-600" />
|
||||
<p>暂无站点</p>
|
||||
<p className="text-sm mt-1 text-slate-500">点击「添加站点」添加 PT/BT 站点</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载中 */}
|
||||
{loading && (
|
||||
<div className="col-span-full py-12 text-center text-slate-400">
|
||||
<RefreshCw size={24} className="mx-auto mb-3 animate-spin" />
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 创建/编辑弹窗 ── */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] bg-black/60 backdrop-blur-sm" onClick={closeModal}>
|
||||
<div
|
||||
className="glass-panel w-full max-w-xl max-h-[75vh] overflow-y-auto mx-4 space-y-5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-white">
|
||||
{editingId ? '编辑站点' : '添加站点'}
|
||||
</h2>
|
||||
<button onClick={closeModal} className="text-slate-400 hover:text-white transition">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
{/* 名称 */}
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1.5">站点名称 *</label>
|
||||
<input
|
||||
required
|
||||
className="input-base w-full"
|
||||
placeholder="例如: 馒头、观众、家园"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 地址 */}
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1.5">站点地址 *</label>
|
||||
<input
|
||||
required
|
||||
className="input-base w-full"
|
||||
placeholder="https://www.example.com/"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">格式: https://www.example.com/</p>
|
||||
</div>
|
||||
|
||||
{/* 站点类型 + 状态 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1.5">站点类型</label>
|
||||
<select
|
||||
className="input-base w-full"
|
||||
value={form.type}
|
||||
onChange={(e) => handleTypeChange(e.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="discuz">Discuz 论坛型</option>
|
||||
<option value="custom_rss">自定义 RSS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1.5">状态</label>
|
||||
<div className="flex items-center gap-3 h-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((f) => ({ ...f, enabled: !f.enabled }))}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors cursor-pointer ${form.enabled ? 'bg-primary-500' : 'bg-white/10'}`}
|
||||
>
|
||||
<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-slate-500'}`}>
|
||||
{form.enabled ? '启用' : '停用'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 馒头提示 */}
|
||||
{form.type === 'mteam' && (
|
||||
<div className="p-3 rounded-lg border border-green-500/30 bg-green-500/5">
|
||||
<div className="text-sm font-medium text-green-400 mb-1">馒头站点配置指南</div>
|
||||
<div className="text-xs text-slate-400 space-y-1">
|
||||
<div><b>站点地址:</b><code className="text-green-300">https://api2.m-team.cc</code></div>
|
||||
<div><b>认证方式:</b>推荐使用「API Key / Passkey」</div>
|
||||
<div className="pl-3 text-slate-500">
|
||||
1. 登录馒头站 → 控制台 → 实验室 → 存取令牌<br />
|
||||
2. 点击「创建令牌」,复制生成的 Token<br />
|
||||
3. 将 Token 填入下方「令牌」输入框
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 认证方式 */}
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-2">认证方式</label>
|
||||
<div className="flex gap-2 mb-3">
|
||||
{[
|
||||
{ value: 'cookie', label: 'Cookie' },
|
||||
{ value: 'api_key', label: 'API Key' },
|
||||
{ value: 'auth_header', label: 'Auth Header' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setForm((f) => ({ ...f, auth_type: opt.value }))}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition ${
|
||||
form.auth_type === opt.value
|
||||
? 'bg-primary-500 text-white border-primary-500'
|
||||
: 'border-white/10 text-slate-400 hover:border-primary-500/50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{form.auth_type === 'cookie' && (
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 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={(e) => setForm((f) => ({ ...f, cookie: e.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">从浏览器开发者工具的请求头中获取 Cookie 值</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.auth_type === 'api_key' && (
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">令牌(API Key / Passkey)</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input-base w-full font-mono text-sm"
|
||||
placeholder="输入 API Key 或 Passkey"
|
||||
value={form.api_key}
|
||||
onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
{form.type === 'mteam'
|
||||
? '馒头:控制台 → 实验室 → 存取令牌'
|
||||
: '站点的访问 API Key'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.auth_type === 'auth_header' && (
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">请求头(Authorization)</label>
|
||||
<input
|
||||
className="input-base w-full font-mono text-xs"
|
||||
placeholder="Bearer eyJhbGciOiJIUzI1NiIs..."
|
||||
value={form.auth_header}
|
||||
onChange={(e) => setForm((f) => ({ ...f, auth_header: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 高级选项 */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen(!advancedOpen)}
|
||||
className="flex items-center gap-1 text-xs text-slate-400 hover:text-white transition"
|
||||
>
|
||||
{advancedOpen ? '▾' : '▸'} 高级选项
|
||||
</button>
|
||||
{advancedOpen && (
|
||||
<div className="mt-3 pl-4 space-y-3 border-l border-white/10">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 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={(e) => setForm((f) => ({ ...f, extra: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((f) => ({ ...f, is_default: !f.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-white/10'}`}
|
||||
>
|
||||
<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-slate-400">设为默认站点</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 按钮 */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button type="button" onClick={closeModal} className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5 transition">
|
||||
取消
|
||||
</button>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+69
-6
@@ -2,26 +2,89 @@ import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
import type { User } from '../types'
|
||||
import { refreshToken } from '../api/refresh'
|
||||
|
||||
// Single source of truth for the authenticated user + JWT.
|
||||
// Persisted to localStorage so a page reload does not drop the session.
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
refreshToken: string | null
|
||||
user: User | null
|
||||
setSession: (token: string, user: User) => void
|
||||
tier: string
|
||||
setSession: (token: string, refreshToken: string, user: User) => void
|
||||
setUser: (user: User) => void
|
||||
setToken: (token: string) => void
|
||||
setRefreshToken: (refreshToken: string) => void
|
||||
logout: () => void
|
||||
tokenRefresh: () => Promise<boolean>
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
(set, get) => ({
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
setSession: (token, user) => set({ token, user }),
|
||||
setUser: (user) => set({ user }),
|
||||
logout: () => set({ token: null, user: null }),
|
||||
tier: 'free',
|
||||
setSession: (token, refreshToken, user) => set({
|
||||
token,
|
||||
refreshToken,
|
||||
user,
|
||||
tier: user.tier || 'free'
|
||||
}),
|
||||
setUser: (user) => set({ user, tier: user.tier || 'free' }),
|
||||
setToken: (token) => set({ token }),
|
||||
setRefreshToken: (refreshToken) => set({ refreshToken }),
|
||||
logout: () => set({ token: null, refreshToken: null, user: null, tier: 'free' }),
|
||||
tokenRefresh: async () => {
|
||||
const rt = get().refreshToken
|
||||
if (!rt) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const resp = await refreshToken(rt)
|
||||
set({
|
||||
token: resp.token,
|
||||
refreshToken: resp.refresh_token
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
// Refresh failed, need to logout
|
||||
set({ token: null, refreshToken: null, user: null, tier: 'free' })
|
||||
return false
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ name: 'mediastationgo-auth' },
|
||||
{
|
||||
name: 'mediastationgo-auth',
|
||||
partialize: (state) => ({
|
||||
token: state.token,
|
||||
refreshToken: state.refreshToken,
|
||||
user: state.user,
|
||||
tier: state.tier
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Helper function to check if user is authenticated
|
||||
export function isAuthenticated(): boolean {
|
||||
return useAuthStore.getState().token !== null
|
||||
}
|
||||
|
||||
// Helper function to check if user is admin
|
||||
export function isAdmin(): boolean {
|
||||
const user = useAuthStore.getState().user
|
||||
return user?.role === 'admin'
|
||||
}
|
||||
|
||||
// Helper function to check if user is plus
|
||||
export function isPlus(): boolean {
|
||||
const state = useAuthStore.getState()
|
||||
return state.tier === 'plus' || state.user?.role === 'admin'
|
||||
}
|
||||
|
||||
// Helper function to check if user is super user (admin or plus)
|
||||
export function isSuperUser(): boolean {
|
||||
return isAdmin() || isPlus()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
import { getMyPermissions } from '../api/permission'
|
||||
|
||||
interface PermissionState {
|
||||
permissions: Record<string, boolean>
|
||||
role: string
|
||||
tier: string
|
||||
isSuper: boolean
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
fetchPermissions: () => Promise<void>
|
||||
hasPermission: (key: string) => boolean
|
||||
clearPermissions: () => void
|
||||
}
|
||||
|
||||
export const usePermissionStore = create<PermissionState>((set, get) => ({
|
||||
permissions: {},
|
||||
role: '',
|
||||
tier: 'free',
|
||||
isSuper: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchPermissions: async () => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const result = await getMyPermissions()
|
||||
set({
|
||||
permissions: result.permissions,
|
||||
role: result.role,
|
||||
tier: result.tier,
|
||||
isSuper: result.is_super,
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (err) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to fetch permissions',
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
hasPermission: (key: string) => {
|
||||
const state = get()
|
||||
// Super user (admin or plus) has all permissions
|
||||
if (state.isSuper) {
|
||||
return true
|
||||
}
|
||||
return state.permissions[key] === true
|
||||
},
|
||||
|
||||
clearPermissions: () => {
|
||||
set({
|
||||
permissions: {},
|
||||
role: '',
|
||||
tier: 'free',
|
||||
isSuper: false,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
// Default permissions for new users (without fetching from server)
|
||||
export const defaultPermissions: Record<string, boolean> = {
|
||||
can_view_dashboard: true,
|
||||
can_play_media: true,
|
||||
can_cast: true,
|
||||
can_external_player: true,
|
||||
can_favorite: true,
|
||||
can_view_history: true,
|
||||
can_edit_media: false,
|
||||
can_rescrape: false,
|
||||
can_use_ai: false,
|
||||
can_capture_frames: false,
|
||||
can_manage_downloads: false,
|
||||
can_view_discover: false,
|
||||
can_manage_subscriptions: false,
|
||||
can_manage_sites: false,
|
||||
can_use_ai_assistant: false,
|
||||
can_manage_users: false,
|
||||
can_manage_files: false,
|
||||
can_manage_strm: false,
|
||||
can_access_settings: false,
|
||||
}
|
||||
|
||||
// Permission display names for UI
|
||||
export const permissionDisplayNames: Record<string, string> = {
|
||||
can_view_dashboard: '查看仪表盘',
|
||||
can_play_media: '播放媒体',
|
||||
can_cast: '投屏',
|
||||
can_external_player: '外部播放器',
|
||||
can_favorite: '收藏',
|
||||
can_view_history: '观看历史',
|
||||
can_edit_media: '编辑媒体',
|
||||
can_rescrape: '重新刮削',
|
||||
can_use_ai: '使用 AI 搜索',
|
||||
can_capture_frames: '截图',
|
||||
can_manage_downloads: '管理下载',
|
||||
can_view_discover: '发现',
|
||||
can_manage_subscriptions: '管理订阅',
|
||||
can_manage_sites: '管理站点',
|
||||
can_use_ai_assistant: 'AI 助手',
|
||||
can_manage_users: '管理用户',
|
||||
can_manage_files: '管理文件',
|
||||
can_manage_strm: '管理 STRM',
|
||||
can_access_settings: '访问设置',
|
||||
}
|
||||
|
||||
// Permission categories for grouping
|
||||
export const permissionCategories = {
|
||||
basic: [
|
||||
'can_view_dashboard',
|
||||
'can_play_media',
|
||||
'can_cast',
|
||||
'can_external_player',
|
||||
'can_favorite',
|
||||
'can_view_history',
|
||||
],
|
||||
media: [
|
||||
'can_edit_media',
|
||||
'can_rescrape',
|
||||
'can_capture_frames',
|
||||
'can_manage_files',
|
||||
'can_manage_strm',
|
||||
],
|
||||
advanced: [
|
||||
'can_use_ai',
|
||||
'can_use_ai_assistant',
|
||||
'can_view_discover',
|
||||
'can_manage_downloads',
|
||||
'can_manage_subscriptions',
|
||||
'can_manage_sites',
|
||||
],
|
||||
admin: [
|
||||
'can_manage_users',
|
||||
'can_access_settings',
|
||||
],
|
||||
}
|
||||
@@ -4,14 +4,92 @@ export interface User {
|
||||
id: string
|
||||
username: string
|
||||
role: 'admin' | 'user'
|
||||
tier: 'free' | 'plus'
|
||||
nickname?: string
|
||||
email?: string
|
||||
avatar_url?: string
|
||||
force_password_reset: boolean
|
||||
is_active: boolean
|
||||
last_login_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Token pair
|
||||
export interface TokenPair {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
// User permission (19 granular permissions)
|
||||
export interface UserPermission {
|
||||
id: string
|
||||
user_id: string
|
||||
// Default enabled (6)
|
||||
can_view_dashboard: boolean
|
||||
can_play_media: boolean
|
||||
can_cast: boolean
|
||||
can_external_player: boolean
|
||||
can_favorite: boolean
|
||||
can_view_history: boolean
|
||||
// Default disabled (13)
|
||||
can_edit_media: boolean
|
||||
can_rescrape: boolean
|
||||
can_use_ai: boolean
|
||||
can_capture_frames: boolean
|
||||
can_manage_downloads: boolean
|
||||
can_view_discover: boolean
|
||||
can_manage_subscriptions: boolean
|
||||
can_manage_sites: boolean
|
||||
can_use_ai_assistant: boolean
|
||||
can_manage_users: boolean
|
||||
can_manage_files: boolean
|
||||
can_manage_strm: boolean
|
||||
can_access_settings: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// API config
|
||||
export interface ApiConfig {
|
||||
id: string
|
||||
provider: string
|
||||
api_key?: string
|
||||
base_url?: string
|
||||
extra?: string
|
||||
enabled: boolean
|
||||
description?: string
|
||||
last_tested_at?: string
|
||||
test_result?: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// API provider
|
||||
export interface ApiProvider {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
has_api_key: boolean
|
||||
has_base_url: boolean
|
||||
}
|
||||
|
||||
// Refresh token response
|
||||
export interface RefreshTokenResponse {
|
||||
token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
// Login response
|
||||
export interface LoginResponse {
|
||||
user: User
|
||||
tokens: TokenPair
|
||||
}
|
||||
|
||||
// Library
|
||||
export interface Library {
|
||||
id: string
|
||||
name: string
|
||||
@@ -140,3 +218,107 @@ export interface StatsSnapshot {
|
||||
hardware: Hardware
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
// SSE Event types
|
||||
export interface SSEEvent {
|
||||
type: string
|
||||
payload: unknown
|
||||
}
|
||||
|
||||
// Permission check result
|
||||
export interface PermissionCheckResult {
|
||||
permissions: Record<string, boolean>
|
||||
role: string
|
||||
tier: string
|
||||
is_super: boolean
|
||||
}
|
||||
|
||||
// Download Client
|
||||
export interface DownloadClient {
|
||||
id: string
|
||||
name: string
|
||||
type: 'qbittorrent' | 'transmission' | 'aria2'
|
||||
host: string
|
||||
username: string
|
||||
is_default: boolean
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Download Client type info
|
||||
export interface DownloadClientTypeInfo {
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// Notify Channel
|
||||
export interface NotifyChannel {
|
||||
id: string
|
||||
name: string
|
||||
type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
|
||||
enabled: boolean
|
||||
events: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Notify Provider type info
|
||||
export interface NotifyProviderInfo {
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// Scheduler task config
|
||||
export interface SchedulerTaskConfig {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
interval: number
|
||||
last_run_at?: string
|
||||
next_run_at?: string
|
||||
}
|
||||
|
||||
// Scheduler status
|
||||
export interface SchedulerStatus {
|
||||
running: boolean
|
||||
started_at?: string
|
||||
task_count: number
|
||||
tasks: SchedulerTaskConfig[]
|
||||
}
|
||||
|
||||
// Site configuration
|
||||
export interface Site {
|
||||
id: string
|
||||
name: string
|
||||
type: string // nexusphp / gazelle / unit3d / mteam / discuz / custom_rss
|
||||
url: string
|
||||
auth_type: string // cookie / api_key / auth_header
|
||||
cookie?: string // decrypted only in detail view
|
||||
api_key?: string // decrypted only in detail view
|
||||
auth_header?: string // decrypted only in detail view
|
||||
enabled: boolean
|
||||
is_default: boolean
|
||||
extra?: string
|
||||
last_error?: string
|
||||
last_check_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Site type info
|
||||
export interface SiteTypeInfo {
|
||||
value: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// Auth type info
|
||||
export interface AuthTypeInfo {
|
||||
value: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user