Files
study-tool/web/src/components/PluginsDialog.tsx
T
study-tool aa48145557 feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8
体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
2026-11-01 17:47:22 +08:00

204 lines
6.8 KiB
TypeScript

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[]
}
// 能力名(contract.CAPABILITIES 的键)→ 界面文案
const CAPABILITY_LABELS: Record<string, string> = {
login: '登录',
session: '会话',
renew: '续期',
profile: '账号信息',
courses: '课程列表',
discover: '课程详情',
study: '学习',
quiz: '测验',
capture: '抓包',
push: '推送',
}
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="插件管理"
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-2 space-y-1.5 rounded-lg border border-line/80 bg-white px-3 py-2">
<div className="flex flex-wrap items-center gap-1.5">
<span className="w-[52px] shrink-0 text-[11px] text-slate-400">适配网站</span>
{plugin.sites?.length ? (
plugin.sites.map((site) => (
<span
key={site}
className="rounded-md border border-line bg-slate-50 px-1.5 py-0.5 font-mono text-[11px] text-slate-600"
>
{site}
</span>
))
) : (
<span className="text-[11px] text-slate-400">—</span>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5">
<span className="w-[52px] shrink-0 text-[11px] text-slate-400">能力</span>
{plugin.capabilities.length ? (
plugin.capabilities.map((cap) => (
<span
key={cap}
className="rounded-md bg-brand-50 px-1.5 py-0.5 text-[11px] font-medium text-brand-700 ring-1 ring-inset ring-brand-100"
>
{CAPABILITY_LABELS[cap] || cap}
</span>
))
) : (
<span className="text-[11px] text-slate-400">无</span>
)}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 pl-[52px] text-[11px] text-slate-400">
<span>
平台 <span className="text-slate-600">{plugin.label || '—'}</span>
</span>
<span>
id <span className="text-slate-600">{plugin.id || '—'}</span>
</span>
</div>
</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">
plugins/ 下还没有插件。建一个含 plugin.json + main.py 的目录丢进去,刷新即可看到。
</p>
)}
</div>
</Modal>
)
}