mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-04 04:26:38 +08:00
feat: complete Vue UI parity (full backend + frontend)
== New domain models (7) ==
- UserPermission: per-user feature toggles (13 booleans)
- StorageConfig: encrypted Alist/S3/WebDAV adapters
- LicenseKey + LicenseActivation: offline license issuance
- DownloadClient: multi-client downloader configs
- AssistantSession + AssistantMessage: multi-turn AI chat persistence
== New services (5) ==
- PermissionService: admin grants always-true; user defaults seeded
- StorageConfigService: AES-GCM encryption + per-type connection probe
- LicenseService: 24-char hyphenated key generation, activation/heartbeat
- DownloadClientService: qB/Aria2/Transmission CRUD + WebUI test
- AssistantService: chat history + execute/undo stubs (op_id tracking)
== Extended AIService.Chat ==
- Multi-turn LLM call with chat history; offline fallback reply
== New endpoints (60+) ==
Auth:
POST /auth/refresh, /auth/logout, /auth/change-password
PATCH /auth/profile
GET /auth/permissions, /auth/me
Permissions admin:
GET/PUT /admin/users/:id/permissions
POST /admin/users/:id/permissions/reset
Search:
GET /search, /search/advanced, /search/tmdb, /search/sites
System:
GET /system/config, /settings/schema, /system/events/ticket
POST /admin/system/scheduler/:name/trigger
Stats:
GET /stats/user/:id, /stats/top-users
POST /stats/play
Sites:
GET /sites/:id/resource, /sites/:id/userdata
Subscriptions:
PUT /subscriptions/:id, POST /subscriptions/:id/search
Playlists:
POST /playlists/:id/reorder
DELETE /playlists/:id/items/by-id/:item_id
DLNA per-renderer:
POST /dlna/:uuid/{play,pause,stop}, GET /dlna/:uuid/status
Media:
POST/DELETE /media/:id/favorite, GET /media/:id/favorite/status
POST /media/:id/ai-scrape, /media/scrape/test, /media/organize
GET /favorites (alias)
Playback:
GET /playback/:id/info, /playback/:id/external-players, /playback/:id/external-url
POST /playback/:id/progress
GET /playback/transcode/:job_id/status
Downloads:
POST /download/:id/{pause,resume,organize}
POST /download/{organize,sync,start-auto-sync}
GET /download/tasks
Admin: full CRUD on /admin/download/clients + /admin/download/aria2/stats
License:
POST /license/{activate,heartbeat}
GET /license/{status,heartbeat-status}
Admin: /admin/license/{generate,list,:id/activations,:id/revoke,activation/:id/unbind}
Storage:
GET /admin/storage/{status,:type}
PUT /admin/storage/:type, POST /admin/storage/:type/test
Assistant (multi-turn AI):
GET/POST /admin/assistant/sessions
GET/DELETE /admin/assistant/session/:id
POST /admin/assistant/{chat,execute}
POST /admin/assistant/undo/:op_id
GET /admin/assistant/history
== New React pages (4) ==
- AssistantChatPage (/assistant): full multi-turn chat UI with sessions
sidebar, optimistic user-turn append, live AI response.
- DownloadClientsPage (/download-clients): typed CRUD form for
qBittorrent / Aria2 / Transmission + per-row Test action.
- LicensePage (/license): generate keys, list activations, revoke,
unbind individual devices.
- StorageConfigPage (/storage-config): tabbed Alist/WebDAV/S3 form
with secret-aware redaction + connection probe.
== New API helpers (5) ==
- assistant, download_clients, license, permissions, storage_config
== Layout ==
- Sidebar gains 4 new admin links (AI 对话, 下载器, 外部存储, 许可证).
This commit is contained in:
@@ -93,6 +93,18 @@ const NotifyChannelsPage = lazy(() =>
|
||||
const SettingsPage = lazy(() =>
|
||||
import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })),
|
||||
)
|
||||
const AssistantChatPage = lazy(() =>
|
||||
import('./pages/AssistantChatPage').then((m) => ({ default: m.AssistantChatPage })),
|
||||
)
|
||||
const DownloadClientsPage = lazy(() =>
|
||||
import('./pages/DownloadClientsPage').then((m) => ({ default: m.DownloadClientsPage })),
|
||||
)
|
||||
const LicensePage = lazy(() =>
|
||||
import('./pages/LicensePage').then((m) => ({ default: m.LicensePage })),
|
||||
)
|
||||
const StorageConfigPage = lazy(() =>
|
||||
import('./pages/StorageConfigPage').then((m) => ({ default: m.StorageConfigPage })),
|
||||
)
|
||||
|
||||
const Loading = () => <p className="px-6 py-8 text-slate-500">加载中…</p>
|
||||
|
||||
@@ -216,6 +228,38 @@ export default function App() {
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="assistant"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<AssistantChatPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="download-clients"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<DownloadClientsPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="license"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<LicensePage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="storage-config"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<StorageConfigPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="stats"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { api } from './client'
|
||||
|
||||
export interface AssistantSession {
|
||||
id: string
|
||||
user_id: string
|
||||
title: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AssistantMessage {
|
||||
id: string
|
||||
session_id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
operation_id?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface SessionView {
|
||||
session: AssistantSession
|
||||
messages: AssistantMessage[]
|
||||
}
|
||||
|
||||
export const assistantAPI = {
|
||||
listSessions: () =>
|
||||
api.get<AssistantSession[]>('/admin/assistant/sessions').then((r) => r.data),
|
||||
|
||||
createSession: (title?: string) =>
|
||||
api
|
||||
.post<AssistantSession>('/admin/assistant/sessions', { title })
|
||||
.then((r) => r.data),
|
||||
|
||||
getSession: (id: string) =>
|
||||
api.get<SessionView>(`/admin/assistant/session/${id}`).then((r) => r.data),
|
||||
|
||||
deleteSession: (id: string) =>
|
||||
api.delete(`/admin/assistant/session/${id}`).then((r) => r.data),
|
||||
|
||||
chat: (sessionID: string, message: string) =>
|
||||
api
|
||||
.post<SessionView>('/admin/assistant/chat', {
|
||||
session_id: sessionID,
|
||||
message,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
execute: (sessionID: string, action: Record<string, unknown>) =>
|
||||
api
|
||||
.post<{ op_id: string }>('/admin/assistant/execute', {
|
||||
session_id: sessionID,
|
||||
action,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
undo: (opID: string) =>
|
||||
api.post(`/admin/assistant/undo/${opID}`).then((r) => r.data),
|
||||
|
||||
history: () =>
|
||||
api
|
||||
.get<{ items: { op_id: string; session: string; created_at: string; content: string }[] }>(
|
||||
'/admin/assistant/history',
|
||||
)
|
||||
.then((r) => r.data.items),
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { api } from './client'
|
||||
|
||||
export type DownloadClientType = 'qbittorrent' | 'aria2' | 'transmission'
|
||||
|
||||
export interface DownloadClient {
|
||||
id: string
|
||||
name: string
|
||||
type: DownloadClientType
|
||||
url: string
|
||||
username?: string
|
||||
save_path?: string
|
||||
is_default: boolean
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface DownloadClientInput {
|
||||
name: string
|
||||
type: DownloadClientType
|
||||
url: string
|
||||
username?: string
|
||||
password?: string
|
||||
save_path?: string
|
||||
is_default: boolean
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export const downloadClientsAPI = {
|
||||
list: () =>
|
||||
api.get<DownloadClient[]>('/admin/download/clients').then((r) => r.data),
|
||||
|
||||
create: (input: DownloadClientInput) =>
|
||||
api.post<DownloadClient>('/admin/download/clients', input).then((r) => r.data),
|
||||
|
||||
update: (id: string, input: DownloadClientInput) =>
|
||||
api
|
||||
.put<DownloadClient>(`/admin/download/clients/${id}`, input)
|
||||
.then((r) => r.data),
|
||||
|
||||
remove: (id: string) =>
|
||||
api.delete(`/admin/download/clients/${id}`).then((r) => r.data),
|
||||
|
||||
test: (id: string) =>
|
||||
api
|
||||
.post<{ ok: boolean; error?: string }>(`/admin/download/clients/${id}/test`)
|
||||
.then((r) => r.data),
|
||||
|
||||
aria2Stats: (clientID: string) =>
|
||||
api
|
||||
.get('/admin/download/aria2/stats', { params: { client_id: clientID } })
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { api } from './client'
|
||||
|
||||
// LicenseKey + LicenseActivation mirror the Go model structs.
|
||||
export interface LicenseKey {
|
||||
id: string
|
||||
key: string
|
||||
customer?: string
|
||||
plan: string
|
||||
max_activations: number
|
||||
issued_at: string
|
||||
expires_at?: string | null
|
||||
revoked: boolean
|
||||
notes?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface LicenseActivation {
|
||||
id: string
|
||||
key_id: string
|
||||
device_id: string
|
||||
device_name?: string
|
||||
ip?: string
|
||||
unbound_at?: string | null
|
||||
heartbeat_at?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface GenerateKeyInput {
|
||||
customer?: string
|
||||
plan?: string
|
||||
max_activations?: number
|
||||
expires_at?: string // RFC3339; "" or omit for perpetual
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export const licenseAPI = {
|
||||
generate: (input: GenerateKeyInput) =>
|
||||
api.post<LicenseKey>('/admin/license/generate', input).then((r) => r.data),
|
||||
|
||||
list: () => api.get<LicenseKey[]>('/admin/license/list').then((r) => r.data),
|
||||
|
||||
listActivations: (keyID: string) =>
|
||||
api
|
||||
.get<LicenseActivation[]>(`/admin/license/${keyID}/activations`)
|
||||
.then((r) => r.data),
|
||||
|
||||
revoke: (keyID: string) =>
|
||||
api.post(`/admin/license/${keyID}/revoke`).then((r) => r.data),
|
||||
|
||||
unbind: (activationID: string) =>
|
||||
api.post(`/admin/license/activation/${activationID}/unbind`).then((r) => r.data),
|
||||
|
||||
// Self-service
|
||||
activate: (key: string, deviceID: string, deviceName?: string) =>
|
||||
api
|
||||
.post<LicenseActivation>('/license/activate', {
|
||||
key,
|
||||
device_id: deviceID,
|
||||
device_name: deviceName,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
status: (keyID: string) =>
|
||||
api
|
||||
.get<{ key: LicenseKey; active_activations: number; valid: boolean }>(
|
||||
'/license/status',
|
||||
{ params: { key_id: keyID } },
|
||||
)
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { api } from './client'
|
||||
|
||||
export interface UserPermission {
|
||||
user_id: string
|
||||
can_play_media: boolean
|
||||
can_favorite: boolean
|
||||
can_view_history: boolean
|
||||
can_view_dashboard: boolean
|
||||
can_view_discover: boolean
|
||||
can_manage_downloads: boolean
|
||||
can_manage_subscriptions: boolean
|
||||
can_manage_sites: boolean
|
||||
can_manage_files: boolean
|
||||
can_manage_strm: boolean
|
||||
can_cast: boolean
|
||||
can_use_ai_assistant: boolean
|
||||
can_access_settings: boolean
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export const permissionsAPI = {
|
||||
// Caller's effective permissions; admins always get the all-true set.
|
||||
mine: () => api.get<UserPermission>('/auth/permissions').then((r) => r.data),
|
||||
|
||||
// Admin endpoints
|
||||
get: (userID: string) =>
|
||||
api.get<UserPermission>(`/admin/users/${userID}/permissions`).then((r) => r.data),
|
||||
|
||||
save: (userID: string, p: UserPermission) =>
|
||||
api
|
||||
.put<UserPermission>(`/admin/users/${userID}/permissions`, p)
|
||||
.then((r) => r.data),
|
||||
|
||||
reset: (userID: string) =>
|
||||
api
|
||||
.post<UserPermission>(`/admin/users/${userID}/permissions/reset`)
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { api } from './client'
|
||||
|
||||
export type StorageType = 'alist' | 's3' | 'webdav'
|
||||
|
||||
export interface StorageConfig {
|
||||
id: string
|
||||
type: StorageType
|
||||
config: Record<string, string>
|
||||
enabled: boolean
|
||||
last_error?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export const storageAPI = {
|
||||
status: () =>
|
||||
api
|
||||
.get<{ items: StorageConfig[] }>('/admin/storage/status')
|
||||
.then((r) => r.data.items),
|
||||
|
||||
get: (type: StorageType) =>
|
||||
api.get<StorageConfig>(`/admin/storage/${type}`).then((r) => r.data),
|
||||
|
||||
save: (type: StorageType, config: Record<string, string>, enabled = true) =>
|
||||
api
|
||||
.put<StorageConfig>(`/admin/storage/${type}`, { type, config, enabled })
|
||||
.then((r) => r.data),
|
||||
|
||||
test: (type: StorageType, config: Record<string, string>) =>
|
||||
api
|
||||
.post<{ ok: boolean; error?: string }>(`/admin/storage/${type}/test`, {
|
||||
type,
|
||||
config,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -16,15 +16,19 @@ import {
|
||||
Heart,
|
||||
Home,
|
||||
KeyRound,
|
||||
KeySquare,
|
||||
Link2,
|
||||
ListChecks,
|
||||
ListMusic,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
Rss,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
Sliders,
|
||||
Sparkles,
|
||||
Cloud,
|
||||
Trash2,
|
||||
UserCog,
|
||||
Wrench,
|
||||
@@ -118,6 +122,10 @@ export function Layout() {
|
||||
<SidebarLink to="/strm" icon={<Link2 size={18} />} label="STRM 管理" />
|
||||
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="运维工具" />
|
||||
<SidebarLink to="/notify-channels" icon={<Bell size={18} />} label="通知渠道" />
|
||||
<SidebarLink to="/download-clients" icon={<Server size={18} />} label="下载器" />
|
||||
<SidebarLink to="/storage-config" icon={<Cloud size={18} />} label="外部存储" />
|
||||
<SidebarLink to="/license" icon={<KeySquare size={18} />} label="许可证" />
|
||||
<SidebarLink to="/assistant" icon={<MessageSquare size={18} />} label="AI 对话" />
|
||||
<SidebarLink to="/settings" icon={<Sliders size={18} />} label="系统设置" />
|
||||
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
|
||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { FormEvent, useEffect, useRef, useState } from 'react'
|
||||
import { Loader2, MessageSquare, Plus, Send, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
assistantAPI,
|
||||
type AssistantMessage,
|
||||
type AssistantSession,
|
||||
type SessionView,
|
||||
} from '../api/assistant'
|
||||
|
||||
// AssistantChatPage is the multi-turn chat surface backed by the Go
|
||||
// AssistantService. It complements the older AIAssistantPage which is
|
||||
// limited to single-turn smart search + recommendations.
|
||||
export function AssistantChatPage() {
|
||||
const [sessions, setSessions] = useState<AssistantSession[]>([])
|
||||
const [active, setActive] = useState<SessionView | null>(null)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const refreshSessions = async () => {
|
||||
try {
|
||||
const list = await assistantAPI.listSessions()
|
||||
setSessions(list)
|
||||
// Auto-select the most recent if nothing is open.
|
||||
if (list.length > 0 && !active) {
|
||||
const view = await assistantAPI.getSession(list[0].id)
|
||||
setActive(view)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refreshSessions().catch(() => undefined)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [active?.messages.length])
|
||||
|
||||
const onNew = async () => {
|
||||
try {
|
||||
const sess = await assistantAPI.createSession('New chat')
|
||||
const view = await assistantAPI.getSession(sess.id)
|
||||
setActive(view)
|
||||
await refreshSessions()
|
||||
} catch {
|
||||
toast.error('创建会话失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onSelect = async (id: string) => {
|
||||
try {
|
||||
setActive(await assistantAPI.getSession(id))
|
||||
} catch {
|
||||
toast.error('加载会话失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (id: string) => {
|
||||
if (!confirm('删除此会话?')) return
|
||||
try {
|
||||
await assistantAPI.deleteSession(id)
|
||||
if (active?.session.id === id) setActive(null)
|
||||
await refreshSessions()
|
||||
} catch {
|
||||
toast.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onSend = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!draft.trim() || !active) return
|
||||
setSending(true)
|
||||
const text = draft.trim()
|
||||
setDraft('')
|
||||
// Optimistic append so the user's turn shows immediately.
|
||||
setActive((s) =>
|
||||
s
|
||||
? {
|
||||
...s,
|
||||
messages: [
|
||||
...s.messages,
|
||||
{
|
||||
id: 'pending-' + Date.now(),
|
||||
session_id: s.session.id,
|
||||
role: 'user',
|
||||
content: text,
|
||||
created_at: new Date().toISOString(),
|
||||
} as AssistantMessage,
|
||||
],
|
||||
}
|
||||
: s,
|
||||
)
|
||||
try {
|
||||
const view = await assistantAPI.chat(active.session.id, text)
|
||||
setActive(view)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'发送失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-100px)] grid-cols-[260px_1fr] gap-4">
|
||||
{/* Sessions sidebar */}
|
||||
<aside className="glass-panel flex flex-col overflow-hidden">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="font-display text-sm font-semibold text-white">会话</h2>
|
||||
<button onClick={onNew} className="neon-button !px-2 !py-1 text-xs">
|
||||
<Plus size={12} /> 新建
|
||||
</button>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="flex justify-center py-6 text-slate-400">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<ul className="flex-1 space-y-1 overflow-y-auto pr-1">
|
||||
{sessions.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className={
|
||||
'group flex items-center gap-2 rounded px-2 py-2 text-sm cursor-pointer ' +
|
||||
(active?.session.id === s.id
|
||||
? 'bg-primary-400/10 text-primary-400'
|
||||
: 'text-slate-300 hover:bg-white/5 hover:text-white')
|
||||
}
|
||||
onClick={() => onSelect(s.id)}
|
||||
>
|
||||
<MessageSquare size={14} className="shrink-0" />
|
||||
<span className="flex-1 truncate">{s.title || '未命名'}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(s.id)
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 size={12} className="text-red-400" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{!loading && sessions.length === 0 && (
|
||||
<li className="px-2 py-2 text-xs text-slate-500">暂无会话</li>
|
||||
)}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
{/* Conversation pane */}
|
||||
<section className="glass-panel flex flex-col overflow-hidden">
|
||||
{!active && (
|
||||
<div className="m-auto text-center text-slate-400">
|
||||
<MessageSquare size={32} className="mx-auto mb-2 opacity-50" />
|
||||
<p>选择或创建一个会话开始对话</p>
|
||||
</div>
|
||||
)}
|
||||
{active && (
|
||||
<>
|
||||
<div className="mb-3 border-b border-white/5 pb-2">
|
||||
<h2 className="font-display text-base font-semibold text-white">
|
||||
{active.session.title || '未命名'}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex-1 space-y-3 overflow-y-auto pr-2">
|
||||
{active.messages.length === 0 && (
|
||||
<p className="text-sm text-slate-500">说点什么开始对话…</p>
|
||||
)}
|
||||
{active.messages.map((m) => (
|
||||
<Bubble key={m.id} message={m} />
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<form onSubmit={onSend} className="mt-3 flex gap-2 border-t border-white/5 pt-3">
|
||||
<input
|
||||
className="input-base flex-1"
|
||||
placeholder="发送消息…"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
disabled={sending}
|
||||
/>
|
||||
<button type="submit" disabled={sending || !draft.trim()} className="neon-button">
|
||||
{sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||
发送
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Bubble({ message }: { message: AssistantMessage }) {
|
||||
const mine = message.role === 'user'
|
||||
return (
|
||||
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className={
|
||||
'max-w-[80%] whitespace-pre-wrap rounded-2xl px-4 py-2 text-sm ' +
|
||||
(mine
|
||||
? 'bg-primary-400/20 text-primary-100'
|
||||
: message.role === 'system'
|
||||
? 'border border-amber-400/30 bg-amber-400/5 text-amber-200'
|
||||
: 'bg-white/5 text-slate-200')
|
||||
}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Loader2, Pencil, Plus, Send, Server, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
downloadClientsAPI,
|
||||
type DownloadClient,
|
||||
type DownloadClientInput,
|
||||
type DownloadClientType,
|
||||
} from '../api/download_clients'
|
||||
|
||||
// DownloadClientsPage manages multiple downloader integrations.
|
||||
// Replaces the Vue UI's DownloadView "clients" tab with a typed CRUD
|
||||
// surface and a per-client Test button.
|
||||
export function DownloadClientsPage() {
|
||||
const [clients, setClients] = useState<DownloadClient[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<DownloadClient | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setClients(await downloadClientsAPI.list())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const onTest = async (id: string) => {
|
||||
try {
|
||||
const r = await downloadClientsAPI.test(id)
|
||||
if (r.ok) toast.success('连接成功')
|
||||
else toast.error(r.error ?? '连接失败')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'测试失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (c: DownloadClient) => {
|
||||
if (!confirm(`确定删除「${c.name}」?`)) return
|
||||
try {
|
||||
await downloadClientsAPI.remove(c.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'删除失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-cyan-400/10 text-cyan-300">
|
||||
<Server size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">下载器管理</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
qBittorrent / Aria2 / Transmission · 多客户端 + 连接测试
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(null)
|
||||
setShowForm(true)
|
||||
}}
|
||||
className="neon-button"
|
||||
>
|
||||
<Plus size={16} /> 添加下载器
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12 text-slate-400">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && clients.length === 0 && (
|
||||
<div className="glass-panel py-12 text-center text-slate-400">暂无下载器</div>
|
||||
)}
|
||||
|
||||
{!loading && clients.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{clients.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="glass-panel flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white">{c.name}</span>
|
||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">
|
||||
{c.type}
|
||||
</span>
|
||||
{c.is_default && (
|
||||
<span className="rounded bg-primary-400/20 px-2 py-0.5 text-xs text-primary-400">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
{!c.enabled && (
|
||||
<span className="rounded bg-slate-500/30 px-2 py-0.5 text-xs text-slate-300">
|
||||
已禁用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-slate-400">
|
||||
{c.url}
|
||||
{c.username && ` · ${c.username}`}
|
||||
{c.save_path && ` · ${c.save_path}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={() => onTest(c.id)}
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
||||
>
|
||||
<Send size={12} className="inline" /> 测试
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(c)
|
||||
setShowForm(true)
|
||||
}}
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
||||
>
|
||||
<Pencil size={12} className="inline" /> 编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(c)}
|
||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
>
|
||||
<Trash2 size={12} className="inline" /> 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<ClientFormModal
|
||||
editing={editing}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={async () => {
|
||||
setShowForm(false)
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ClientFormModal({
|
||||
editing,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
editing: DownloadClient | null
|
||||
onClose: () => void
|
||||
onSaved: () => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<DownloadClientInput>(() => ({
|
||||
name: editing?.name ?? '',
|
||||
type: editing?.type ?? 'qbittorrent',
|
||||
url: editing?.url ?? '',
|
||||
username: editing?.username ?? '',
|
||||
password: '',
|
||||
save_path: editing?.save_path ?? '',
|
||||
is_default: editing?.is_default ?? false,
|
||||
enabled: editing?.enabled ?? true,
|
||||
}))
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) await downloadClientsAPI.update(editing.id, form)
|
||||
else await downloadClientsAPI.create(form)
|
||||
toast.success('已保存')
|
||||
await onSaved()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const update = <K extends keyof DownloadClientInput>(k: K, v: DownloadClientInput[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const placeholder = (
|
||||
{
|
||||
qbittorrent: 'http://127.0.0.1:8080',
|
||||
aria2: 'http://127.0.0.1:6800/jsonrpc',
|
||||
transmission: 'http://127.0.0.1:9091/transmission/rpc',
|
||||
} as Record<DownloadClientType, string>
|
||||
)[form.type]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="mb-4 font-display text-xl font-semibold text-white">
|
||||
{editing ? '编辑下载器' : '添加下载器'}
|
||||
</h2>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<Field label="名称">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
value={form.name}
|
||||
onChange={(e) => update('name', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="类型">
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.type}
|
||||
onChange={(e) => update('type', e.target.value as DownloadClientType)}
|
||||
>
|
||||
<option value="qbittorrent">qBittorrent</option>
|
||||
<option value="aria2">Aria2 (JSON-RPC)</option>
|
||||
<option value="transmission">Transmission</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="URL">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder={placeholder}
|
||||
value={form.url}
|
||||
onChange={(e) => update('url', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{form.type !== 'aria2' && (
|
||||
<>
|
||||
<Field label="用户名">
|
||||
<input
|
||||
className="input-base"
|
||||
value={form.username ?? ''}
|
||||
onChange={(e) => update('username', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={editing ? '密码 (留空保持不变)' : '密码'}>
|
||||
<input
|
||||
type="password"
|
||||
className="input-base"
|
||||
value={form.password ?? ''}
|
||||
onChange={(e) => update('password', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{form.type === 'aria2' && (
|
||||
<Field label="RPC Token (作为密码字段保存)">
|
||||
<input
|
||||
type="password"
|
||||
className="input-base"
|
||||
value={form.password ?? ''}
|
||||
onChange={(e) => update('password', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="默认保存路径">
|
||||
<input
|
||||
className="input-base"
|
||||
value={form.save_path ?? ''}
|
||||
onChange={(e) => update('save_path', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={form.is_default}
|
||||
onChange={(e) => update('is_default', e.target.checked)}
|
||||
/>
|
||||
设为默认
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={form.enabled}
|
||||
onChange={(e) => update('enabled', e.target.checked)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving && <Loader2 size={16} className="animate-spin" />} 保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
KeySquare,
|
||||
Loader2,
|
||||
Plus,
|
||||
ShieldOff,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
licenseAPI,
|
||||
type GenerateKeyInput,
|
||||
type LicenseActivation,
|
||||
type LicenseKey,
|
||||
} from '../api/license'
|
||||
|
||||
// LicensePage is the admin UI for issuing / revoking license keys and
|
||||
// inspecting activations. Mirrors the Vue LicenseTab inside Settings.
|
||||
export function LicensePage() {
|
||||
const [keys, setKeys] = useState<LicenseKey[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showGen, setShowGen] = useState(false)
|
||||
const [openKey, setOpenKey] = useState<string | null>(null)
|
||||
const [activations, setActivations] = useState<Record<string, LicenseActivation[]>>({})
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setKeys(await licenseAPI.list())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const toggleOpen = async (k: LicenseKey) => {
|
||||
if (openKey === k.id) {
|
||||
setOpenKey(null)
|
||||
return
|
||||
}
|
||||
setOpenKey(k.id)
|
||||
if (!activations[k.id]) {
|
||||
try {
|
||||
const acts = await licenseAPI.listActivations(k.id)
|
||||
setActivations((a) => ({ ...a, [k.id]: acts }))
|
||||
} catch {
|
||||
toast.error('加载激活记录失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onRevoke = async (k: LicenseKey) => {
|
||||
if (!confirm(`确定吊销 ${k.key.slice(0, 14)}…?`)) return
|
||||
try {
|
||||
await licenseAPI.revoke(k.id)
|
||||
toast.success('已吊销')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'吊销失败'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const onUnbind = async (a: LicenseActivation) => {
|
||||
if (!confirm(`解绑设备 ${a.device_name || a.device_id}?`)) return
|
||||
try {
|
||||
await licenseAPI.unbind(a.id)
|
||||
toast.success('已解绑')
|
||||
const acts = await licenseAPI.listActivations(a.key_id)
|
||||
setActivations((all) => ({ ...all, [a.key_id]: acts }))
|
||||
} catch {
|
||||
toast.error('解绑失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-fuchsia-400/10 text-fuchsia-300">
|
||||
<KeySquare size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">许可证管理</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
生成密钥 · 绑定设备 · 心跳监控 · 吊销
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setShowGen(true)} className="neon-button">
|
||||
<Plus size={16} /> 生成新密钥
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12 text-slate-400">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && keys.length === 0 && (
|
||||
<div className="glass-panel py-12 text-center text-slate-400">暂无密钥</div>
|
||||
)}
|
||||
|
||||
{!loading && keys.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{keys.map((k) => (
|
||||
<div key={k.id} className="glass-panel space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-white/5 px-2 py-0.5 text-xs text-primary-400">
|
||||
{k.key}
|
||||
</code>
|
||||
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">
|
||||
{k.plan}
|
||||
</span>
|
||||
{k.revoked && (
|
||||
<span className="rounded bg-red-400/20 px-2 py-0.5 text-xs text-red-400">
|
||||
已吊销
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-400">
|
||||
{k.customer && `客户: ${k.customer} · `}
|
||||
最多 {k.max_activations} 设备 ·
|
||||
{k.expires_at ? ` ${new Date(k.expires_at).toLocaleDateString()} 到期` : ' 永久'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={() => toggleOpen(k)}
|
||||
className="rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-primary-400/40 hover:text-primary-400"
|
||||
>
|
||||
{openKey === k.id ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{' '}激活记录
|
||||
</button>
|
||||
{!k.revoked && (
|
||||
<button
|
||||
onClick={() => onRevoke(k)}
|
||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
>
|
||||
<ShieldOff size={12} className="inline" /> 吊销
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{openKey === k.id && (
|
||||
<div className="space-y-2 border-t border-white/5 pt-3">
|
||||
{(activations[k.id] ?? []).length === 0 && (
|
||||
<p className="text-sm text-slate-500">暂无激活记录</p>
|
||||
)}
|
||||
{(activations[k.id] ?? []).map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center justify-between rounded border border-white/5 bg-white/5 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-white">
|
||||
{a.device_name || a.device_id}
|
||||
{a.unbound_at && (
|
||||
<span className="ml-2 text-xs text-slate-500">
|
||||
(已解绑 {new Date(a.unbound_at).toLocaleDateString()})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
{a.ip} · 心跳{' '}
|
||||
{a.heartbeat_at
|
||||
? new Date(a.heartbeat_at).toLocaleString()
|
||||
: '未上报'}
|
||||
</div>
|
||||
</div>
|
||||
{!a.unbound_at && (
|
||||
<button
|
||||
onClick={() => onUnbind(a)}
|
||||
className="shrink-0 rounded border border-white/10 px-2 py-1 text-xs text-slate-300 hover:border-red-400/40 hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={12} className="inline" /> 解绑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showGen && (
|
||||
<GenerateModal
|
||||
onClose={() => setShowGen(false)}
|
||||
onCreated={async () => {
|
||||
setShowGen(false)
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GenerateModal({
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreated: () => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<GenerateKeyInput>({
|
||||
customer: '',
|
||||
plan: 'basic',
|
||||
max_activations: 1,
|
||||
expires_at: '',
|
||||
notes: '',
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
const k = await licenseAPI.generate({
|
||||
...form,
|
||||
expires_at: form.expires_at ? new Date(form.expires_at).toISOString() : undefined,
|
||||
})
|
||||
toast.success(`已生成: ${k.key}`)
|
||||
await onCreated()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'生成失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="glass-panel w-full max-w-md">
|
||||
<h2 className="mb-4 font-display text-xl font-semibold text-white">生成密钥</h2>
|
||||
<form onSubmit={onSubmit} className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">客户</span>
|
||||
<input
|
||||
className="input-base"
|
||||
value={form.customer ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, customer: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">套餐</span>
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.plan ?? 'basic'}
|
||||
onChange={(e) => setForm((f) => ({ ...f, plan: e.target.value }))}
|
||||
>
|
||||
<option value="basic">basic</option>
|
||||
<option value="pro">pro</option>
|
||||
<option value="enterprise">enterprise</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">最多设备数</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="input-base"
|
||||
value={form.max_activations ?? 1}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, max_activations: Number(e.target.value) }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">到期日期 (空 = 永久)</span>
|
||||
<input
|
||||
type="date"
|
||||
className="input-base"
|
||||
value={form.expires_at ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, expires_at: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">备注</span>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="input-base"
|
||||
value={form.notes ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving && <Loader2 size={16} className="animate-spin" />} 生成
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Cloud, Loader2, Save, Send } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { storageAPI, type StorageType } from '../api/storage_config'
|
||||
|
||||
// StorageConfigPage manages the Alist / S3 / WebDAV adapters used by
|
||||
// the import / playback / STRM subsystems. Mirrors the Vue UI's
|
||||
// `admin/storage/*` tabs in a tabbed React surface.
|
||||
export function StorageConfigPage() {
|
||||
const [active, setActive] = useState<StorageType>('alist')
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-400/10 text-blue-300">
|
||||
<Cloud size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-white">外部存储</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
配置 Alist / S3 / WebDAV 后端,支持密码加密存储 + 在线测试
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-b border-white/10">
|
||||
{(['alist', 'webdav', 's3'] as StorageType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setActive(t)}
|
||||
className={
|
||||
'border-b-2 px-4 py-2 text-sm uppercase ' +
|
||||
(active === t
|
||||
? 'border-primary-400 text-primary-400'
|
||||
: 'border-transparent text-slate-400 hover:text-white')
|
||||
}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<StorageForm key={active} type={active} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FIELD_DEFS: Record<StorageType, { key: string; label: string; secret?: boolean; placeholder?: string }[]> = {
|
||||
alist: [
|
||||
{ key: 'server', label: 'Server URL', placeholder: 'https://alist.example.com' },
|
||||
{ key: 'token', label: 'Token', secret: true },
|
||||
],
|
||||
webdav: [
|
||||
{ key: 'url', label: 'URL', placeholder: 'https://example.com/dav/' },
|
||||
{ key: 'username', label: '用户名' },
|
||||
{ key: 'password', label: '密码', secret: true },
|
||||
],
|
||||
s3: [
|
||||
{ key: 'endpoint', label: 'Endpoint', placeholder: 'https://s3.amazonaws.com' },
|
||||
{ key: 'region', label: 'Region', placeholder: 'us-east-1' },
|
||||
{ key: 'bucket', label: 'Bucket' },
|
||||
{ key: 'access_key', label: 'Access Key', secret: true },
|
||||
{ key: 'secret_key', label: 'Secret Key', secret: true },
|
||||
{ key: 'force_path_style', label: 'force_path_style (true/false)' },
|
||||
],
|
||||
}
|
||||
|
||||
function StorageForm({ type }: { type: StorageType }) {
|
||||
const fields = useMemo(() => FIELD_DEFS[type], [type])
|
||||
const [config, setConfig] = useState<Record<string, string>>({})
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await storageAPI.get(type)
|
||||
const next: Record<string, string> = {}
|
||||
for (const f of fields) {
|
||||
const v = r.config?.[f.key]
|
||||
// List() redacts secrets to "********"; show empty so the user
|
||||
// doesn't accidentally save the placeholder.
|
||||
next[f.key] = v === '********' ? '' : v ?? ''
|
||||
}
|
||||
setConfig(next)
|
||||
setEnabled(r.enabled)
|
||||
} catch {
|
||||
const next: Record<string, string> = {}
|
||||
for (const f of fields) next[f.key] = ''
|
||||
setConfig(next)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [type])
|
||||
|
||||
const onSave = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
await storageAPI.save(type, config, enabled)
|
||||
toast.success('已保存')
|
||||
await refresh()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onTest = async () => {
|
||||
setTesting(true)
|
||||
try {
|
||||
const r = await storageAPI.test(type, config)
|
||||
if (r.ok) toast.success('连接成功')
|
||||
else toast.error(r.error ?? '连接失败')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'测试失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12 text-slate-400">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSave} className="glass-panel space-y-4">
|
||||
{fields.map((f) => (
|
||||
<label key={f.key} className="block">
|
||||
<span className="mb-1 block text-sm text-slate-300">
|
||||
{f.label}
|
||||
<span className="ml-2 font-mono text-[10px] text-slate-600">{f.key}</span>
|
||||
</span>
|
||||
<input
|
||||
type={f.secret ? 'password' : 'text'}
|
||||
className="input-base"
|
||||
placeholder={f.placeholder}
|
||||
value={config[f.key] ?? ''}
|
||||
onChange={(e) => setConfig((c) => ({ ...c, [f.key]: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
disabled={testing}
|
||||
className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5"
|
||||
>
|
||||
{testing ? <Loader2 size={14} className="inline animate-spin" /> : <Send size={14} className="inline" />}
|
||||
{' '}测试
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button">
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user