feat(web): 插件管理弹窗(启停 / 重载)

- 顶栏新增「插件管理」图标按钮,打开 PluginsDialog
- 弹窗列出 pugins/ 下的插件:标题 / 目录名 / 是否启用与已加载 / 平台 label / 能力清单
- 每个插件可启用、停用、重载,底部有「全部重载」
- 启停成功后重新拉 /api/platforms,「添加账号」的平台下拉同步刷新
- types.ts 新增 PluginInfo;App.tsx 把平台列表加载提成 loadPlatforms 复用
- static/ 重新构建(pnpm typecheck + pnpm build 均通过)
- 顺带把 .vscode/ 纳入跟踪
This commit is contained in:
study-tool
2026-10-31 16:01:44 +08:00
parent f8458cff51
commit cfa960221e
12 changed files with 471 additions and 244 deletions
+24 -11
View File
@@ -3,6 +3,7 @@ import { Plus, RefreshCw, Users, WifiOff } from 'lucide-react'
import { AccountCard } from './components/AccountCard'
import { AddAccountDialog, type NewAccount } from './components/AddAccountDialog'
import { PluginsDialog } from './components/PluginsDialog'
import { QuizDialog } from './components/QuizDialog'
import { SettingsDialog } from './components/SettingsDialog'
import { StatBar } from './components/StatBar'
@@ -48,6 +49,7 @@ interface InlineLog {
type DialogState =
| { type: 'add' }
| { type: 'settings' }
| { type: 'plugins' }
| { type: 'system' }
| { type: 'quiz'; accountId: string; course: Course }
| null
@@ -194,19 +196,23 @@ export default function App() {
return () => window.clearInterval(timer)
}, [load])
useEffect(() => {
api<{ platforms: PlatformInfo[] }>('/api/platforms')
.then((response) => {
const list = response.platforms || []
if (!list.length) return
setPlatformList(list)
const map: Record<string, string> = {}
for (const platform of list) map[platform.id] = platform.label
setPlatforms(map)
})
.catch(() => {})
const loadPlatforms = useCallback(async () => {
try {
const response = await api<{ platforms: PlatformInfo[] }>('/api/platforms')
const list = response.platforms || []
setPlatformList(list)
const map: Record<string, string> = {}
for (const platform of list) map[platform.id] = platform.label
setPlatforms(map)
} catch {
// 后端未连接时保留上一次的平台列表
}
}, [])
useEffect(() => {
void loadPlatforms()
}, [loadPlatforms])
const pullLog = useCallback(
async (id: string) => {
const current = logsRef.current[id] || {
@@ -656,6 +662,7 @@ export default function App() {
onRefresh={() => void refreshNow()}
onSettings={() => setDialog({ type: 'settings' })}
onSystemLog={() => setDialog({ type: 'system' })}
onPlugins={() => setDialog({ type: 'plugins' })}
onAdd={() => setDialog({ type: 'add' })}
/>
@@ -773,6 +780,12 @@ export default function App() {
notify={notify}
/>
<SystemLogDialog open={dialog?.type === 'system'} onClose={() => setDialog(null)} />
<PluginsDialog
open={dialog?.type === 'plugins'}
onClose={() => setDialog(null)}
notify={notify}
onChanged={() => void loadPlatforms()}
/>
<QuizDialog
open={dialog?.type === 'quiz' && !!quizAccount}
account={quizAccount}
+153
View File
@@ -0,0 +1,153 @@
import { useCallback, useEffect, useState } from 'react'
import { Puzzle, RefreshCw } from 'lucide-react'
import { api } from '../lib/api'
import { toastMessage } from '../lib/utils'
import type { PluginInfo } from '../types'
import { Badge } from './ui/Badge'
import { Button } from './ui/Button'
import { Modal } from './ui/Modal'
interface PluginsResponse {
ok?: boolean
error?: string
plugins?: PluginInfo[]
}
export function PluginsDialog({
open,
onClose,
notify,
onChanged,
}: {
open: boolean
onClose: () => void
notify: (toast: { message: string; tone?: 'info' | 'success' | 'error' }) => void
onChanged: () => void
}) {
const [plugins, setPlugins] = useState<PluginInfo[]>([])
const [busy, setBusy] = useState('')
const [loading, setLoading] = useState(false)
const pull = useCallback(async () => {
setLoading(true)
try {
const response = await api<PluginsResponse>('/api/plugins')
setPlugins(response.plugins || [])
} catch (error) {
notify({ message: toastMessage(error), tone: 'error' })
} finally {
setLoading(false)
}
}, [notify])
useEffect(() => {
if (open) void pull()
}, [open, pull])
async function run(name: string, path: string, okText: string) {
setBusy(name)
try {
const response = await api<PluginsResponse>(path, { method: 'POST' })
if (response.plugins) setPlugins(response.plugins)
if (response.ok === false) {
notify({ message: response.error || '插件操作失败', tone: 'error' })
} else {
notify({ message: okText, tone: 'success' })
onChanged()
}
} catch (error) {
notify({ message: toastMessage(error), tone: 'error' })
} finally {
setBusy('')
}
}
return (
<Modal
open={open}
onClose={onClose}
title="插件管理"
subtitle="pugins/<名字>/ 下的平台插件:启停立即生效,改完代码重载即可,不用重启服务"
size="lg"
footer={
<>
<span className="mr-auto text-[11px] text-slate-400">
共 {plugins.length} 个 · 停用后该平台会从「添加账号」的平台下拉里消失
</span>
<Button
variant="soft"
size="sm"
icon={<RefreshCw className="size-3.5" />}
loading={busy === '*'}
onClick={() => void run('*', '/api/plugins/reload', '全部插件已重载')}
>
全部重载
</Button>
</>
}
>
<div className="space-y-2.5">
{plugins.map((plugin) => (
<div key={plugin.name} className="rounded-xl border border-line bg-slate-50/70 px-3.5 py-3">
<div className="flex flex-wrap items-center gap-2">
<Puzzle className="size-4 shrink-0 text-brand-600" />
<span className="text-[13px] font-semibold text-slate-900">
{plugin.title || plugin.name}
</span>
<span className="text-[11px] text-slate-400">{plugin.name}</span>
<Badge tone={plugin.enabled ? 'success' : 'neutral'} dot>
{plugin.enabled ? '已启用' : '已停用'}
</Badge>
{plugin.enabled && (
<Badge tone={plugin.loaded ? 'brand' : 'warning'}>
{plugin.loaded ? '已加载' : '未加载'}
</Badge>
)}
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-slate-500">
<span>平台 {plugin.label || '—'}</span>
<span>id {plugin.id || '—'}</span>
<span>能力 {plugin.capabilities.length ? plugin.capabilities.join(' / ') : '无'}</span>
</div>
<div className="mt-2.5 flex items-center gap-2">
<Button
size="sm"
variant={plugin.enabled ? 'secondary' : 'primary'}
loading={busy === plugin.name}
onClick={() =>
void run(
plugin.name,
`/api/plugins/${plugin.name}/${plugin.enabled ? 'disable' : 'enable'}`,
plugin.enabled ? `${plugin.name} 已停用` : `${plugin.name} 已启用`,
)
}
>
{plugin.enabled ? '停用' : '启用'}
</Button>
<Button
size="sm"
variant="ghost"
disabled={!plugin.enabled}
loading={busy === plugin.name}
onClick={() =>
void run(plugin.name, `/api/plugins/${plugin.name}/reload`, `${plugin.name} 已重载`)
}
>
重载
</Button>
</div>
</div>
))}
{!plugins.length && !loading && (
<p className="py-8 text-center text-[12.5px] text-slate-400">
pugins/ 下还没有插件。建一个含 pugin.json + main.py 的目录丢进去,刷新即可看到。
</p>
)}
</div>
</Modal>
)
}
+12 -1
View File
@@ -1,4 +1,4 @@
import { Plus, RefreshCw, ScrollText, Settings2 } from 'lucide-react'
import { Plus, Puzzle, RefreshCw, ScrollText, Settings2 } from 'lucide-react'
import { Button } from './ui/Button'
@@ -7,12 +7,14 @@ export function TopBar({
onRefresh,
onSettings,
onSystemLog,
onPlugins,
onAdd,
}: {
refreshing: boolean
onRefresh: () => void
onSettings: () => void
onSystemLog: () => void
onPlugins: () => void
onAdd: () => void
}) {
return (
@@ -50,6 +52,15 @@ export function TopBar({
>
<RefreshCw className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onPlugins}
title="插件管理"
aria-label="插件管理"
>
<Puzzle className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
+10
View File
@@ -60,6 +60,16 @@ export interface PlatformInfo {
label: string
}
export interface PluginInfo {
name: string
id: string
title: string
enabled: boolean
loaded: boolean
label: string
capabilities: string[]
}
export interface LogResponse {
lines?: string[]
next?: number