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
+31
View File
@@ -0,0 +1,31 @@
{
"files.exclude": {
"**/__pycache__": true,
"**/*.pyc": true,
"**/.venv": true,
"**/venv": true,
"**/env": true,
"**/.mypy_cache": true,
"**/.pytest_cache": true,
"**/.ruff_cache": true
},
"search.exclude": {
"**/__pycache__": true,
"**/*.pyc": true,
"**/.venv": true,
"**/venv": true,
"**/env": true,
"**/.mypy_cache": true,
"**/.pytest_cache": true,
"**/.ruff_cache": true
},
"files.watcherExclude": {
"**/__pycache__/**": true,
"**/.venv/**": true,
"**/venv/**": true,
"**/env/**": true,
"**/.mypy_cache/**": true,
"**/.pytest_cache/**": true,
"**/.ruff_cache/**": true
}
}
+4 -1
View File
@@ -16,7 +16,9 @@
- `platforms/registry.py` 新增 `unregister(pid)`。 - `platforms/registry.py` 新增 `unregister(pid)`。
- 新增接口:`GET /api/plugins`、`POST /api/plugins/{name}/enable|disable`、 - 新增接口:`GET /api/plugins`、`POST /api/plugins/{name}/enable|disable`、
`POST /api/plugins/reload`、`POST /api/plugins/{name}/reload`;失败返回 `{"ok": false, "error": ...}`。 `POST /api/plugins/reload`、`POST /api/plugins/{name}/reload`;失败返回 `{"ok": false, "error": ...}`。
- 引擎 / 路由 / 前端 / 平台实现均未改动。 - 前端顶栏新增「插件管理」弹窗(`web/src/components/PluginsDialog.tsx`):列出插件与状态,
支持启停 / 单个重载 / 全部重载;启停后会重新拉 `/api/platforms`,「添加账号」的平台下拉同步变化。
- 引擎 / 路由 / 平台实现均未改动。
### 怎么测 ### 怎么测
@@ -25,6 +27,7 @@
3. 改 `pugins/gdgxk/main.py` 里一句日志文案 → `POST /api/plugins/gdgxk/reload` → 3. 改 `pugins/gdgxk/main.py` 里一句日志文案 → `POST /api/plugins/gdgxk/reload` →
不重启服务再跑一次学习,日志应变成新文案。 不重启服务再跑一次学习,日志应变成新文案。
4. 重启服务:停用状态应被记住(设置项 `plugins`)。 4. 重启服务:停用状态应被记住(设置项 `plugins`)。
5. 前端顶栏「插件管理」:停用 gdgxk → 「添加账号」的平台下拉立刻少一项;重新启用后恢复。
## 平台契约改为子类实现(注册方式调整) ## 平台契约改为子类实现(注册方式调整)
+1
View File
@@ -108,6 +108,7 @@ src/study_tool/pugins/
启用清单存在设置项 `plugins`(`{"disabled": [...]}`):没列进去的默认启用,新丢进目录的插件自动生效。 启用清单存在设置项 `plugins`(`{"disabled": [...]}`):没列进去的默认启用,新丢进目录的插件自动生效。
停用的插件会 `registry.unregister()`,从 `/api/platforms` 消失,账号的平台下拉里也不会出现。 停用的插件会 `registry.unregister()`,从 `/api/platforms` 消失,账号的平台下拉里也不会出现。
热重载先把旧实现从注册表摘掉再 import;正在跑的学习任务持有的是旧实例,跑到结束不受影响。 热重载先把旧实现从注册表摘掉再 import;正在跑的学习任务持有的是旧实例,跑到结束不受影响。
前端顶栏的「插件管理」弹窗(`web/src/components/PluginsDialog.tsx`)就是这几个接口的界面。
## 槽位契约 ## 槽位契约
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#f6f8fc" /> <meta name="theme-color" content="#f6f8fc" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>莞易学 · 多账号学习控制台</title> <title>莞易学 · 多账号学习控制台</title>
<script type="module" crossorigin src="/assets/index-DsTLd63E.js"></script> <script type="module" crossorigin src="/assets/index-DrnB8PWg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BubgUKHl.css"> <link rel="stylesheet" crossorigin href="/assets/index-Cl1ZyI7J.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+24 -11
View File
@@ -3,6 +3,7 @@ import { Plus, RefreshCw, Users, WifiOff } from 'lucide-react'
import { AccountCard } from './components/AccountCard' import { AccountCard } from './components/AccountCard'
import { AddAccountDialog, type NewAccount } from './components/AddAccountDialog' import { AddAccountDialog, type NewAccount } from './components/AddAccountDialog'
import { PluginsDialog } from './components/PluginsDialog'
import { QuizDialog } from './components/QuizDialog' import { QuizDialog } from './components/QuizDialog'
import { SettingsDialog } from './components/SettingsDialog' import { SettingsDialog } from './components/SettingsDialog'
import { StatBar } from './components/StatBar' import { StatBar } from './components/StatBar'
@@ -48,6 +49,7 @@ interface InlineLog {
type DialogState = type DialogState =
| { type: 'add' } | { type: 'add' }
| { type: 'settings' } | { type: 'settings' }
| { type: 'plugins' }
| { type: 'system' } | { type: 'system' }
| { type: 'quiz'; accountId: string; course: Course } | { type: 'quiz'; accountId: string; course: Course }
| null | null
@@ -194,19 +196,23 @@ export default function App() {
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [load]) }, [load])
useEffect(() => { const loadPlatforms = useCallback(async () => {
api<{ platforms: PlatformInfo[] }>('/api/platforms') try {
.then((response) => { const response = await api<{ platforms: PlatformInfo[] }>('/api/platforms')
const list = response.platforms || [] const list = response.platforms || []
if (!list.length) return setPlatformList(list)
setPlatformList(list) const map: Record<string, string> = {}
const map: Record<string, string> = {} for (const platform of list) map[platform.id] = platform.label
for (const platform of list) map[platform.id] = platform.label setPlatforms(map)
setPlatforms(map) } catch {
}) // 后端未连接时保留上一次的平台列表
.catch(() => {}) }
}, []) }, [])
useEffect(() => {
void loadPlatforms()
}, [loadPlatforms])
const pullLog = useCallback( const pullLog = useCallback(
async (id: string) => { async (id: string) => {
const current = logsRef.current[id] || { const current = logsRef.current[id] || {
@@ -656,6 +662,7 @@ export default function App() {
onRefresh={() => void refreshNow()} onRefresh={() => void refreshNow()}
onSettings={() => setDialog({ type: 'settings' })} onSettings={() => setDialog({ type: 'settings' })}
onSystemLog={() => setDialog({ type: 'system' })} onSystemLog={() => setDialog({ type: 'system' })}
onPlugins={() => setDialog({ type: 'plugins' })}
onAdd={() => setDialog({ type: 'add' })} onAdd={() => setDialog({ type: 'add' })}
/> />
@@ -773,6 +780,12 @@ export default function App() {
notify={notify} notify={notify}
/> />
<SystemLogDialog open={dialog?.type === 'system'} onClose={() => setDialog(null)} /> <SystemLogDialog open={dialog?.type === 'system'} onClose={() => setDialog(null)} />
<PluginsDialog
open={dialog?.type === 'plugins'}
onClose={() => setDialog(null)}
notify={notify}
onChanged={() => void loadPlatforms()}
/>
<QuizDialog <QuizDialog
open={dialog?.type === 'quiz' && !!quizAccount} open={dialog?.type === 'quiz' && !!quizAccount}
account={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' import { Button } from './ui/Button'
@@ -7,12 +7,14 @@ export function TopBar({
onRefresh, onRefresh,
onSettings, onSettings,
onSystemLog, onSystemLog,
onPlugins,
onAdd, onAdd,
}: { }: {
refreshing: boolean refreshing: boolean
onRefresh: () => void onRefresh: () => void
onSettings: () => void onSettings: () => void
onSystemLog: () => void onSystemLog: () => void
onPlugins: () => void
onAdd: () => void onAdd: () => void
}) { }) {
return ( return (
@@ -50,6 +52,15 @@ export function TopBar({
> >
<RefreshCw className="size-4" /> <RefreshCw className="size-4" />
</Button> </Button>
<Button
variant="ghost"
size="icon"
onClick={onPlugins}
title="插件管理"
aria-label="插件管理"
>
<Puzzle className="size-4" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
+10
View File
@@ -60,6 +60,16 @@ export interface PlatformInfo {
label: string label: string
} }
export interface PluginInfo {
name: string
id: string
title: string
enabled: boolean
loaded: boolean
label: string
capabilities: string[]
}
export interface LogResponse { export interface LogResponse {
lines?: string[] lines?: string[]
next?: number next?: number