mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-21 09:39:03 +08:00
feat: 插件系统 V1
This commit is contained in:
+34
-2
@@ -22,6 +22,7 @@ import {
|
||||
UpOutlined,
|
||||
DownOutlined,
|
||||
HolderOutlined,
|
||||
CrownOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { HashRouter, useLocation, useNavigate, Routes, Route } from "react-router-dom"
|
||||
@@ -34,6 +35,7 @@ import { OAuthCallback } from "./components/OAuth/OAuthCallback"
|
||||
import { ThemeProvider, useTheme } from "./contexts/ThemeContext"
|
||||
import { MOBILE_NAV_ITEMS, MobileNavKey, sanitizeMobileNavKeys } from "./shared/mobileNavigation"
|
||||
import { resolveStoredFontFamily } from "./shared/fontFamily"
|
||||
import { getPluginRuntime } from "./plugins/runtime"
|
||||
|
||||
const DEFAULT_MOBILE_BOTTOM_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key)
|
||||
const DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM_NAV_ITEMS.slice(
|
||||
@@ -129,6 +131,7 @@ function MainContent(): React.JSX.Element {
|
||||
const syncCheckingRef = useRef(false)
|
||||
const syncApplyLoadingRef = useRef(false)
|
||||
const lastLocalMutationAtRef = useRef(0)
|
||||
const pluginRuntimeRef = useRef(getPluginRuntime())
|
||||
|
||||
const activeMenu = useMemo(() => {
|
||||
const p = location.pathname
|
||||
@@ -141,10 +144,37 @@ function MainContent(): React.JSX.Element {
|
||||
if (p.startsWith("/reasons")) return "reasons"
|
||||
if (p.startsWith("/auto-score")) return "auto-score"
|
||||
if (p.startsWith("/reward-settings")) return "reward-settings"
|
||||
if (p.startsWith("/plugins")) return "plugins"
|
||||
if (p.startsWith("/settings")) return "settings"
|
||||
return "home"
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
const runtime = pluginRuntimeRef.current
|
||||
runtime.start().catch((error) => {
|
||||
console.error("Failed to start plugin runtime:", error)
|
||||
})
|
||||
|
||||
const handlePluginsUpdated = () => {
|
||||
runtime.reload().catch((error) => {
|
||||
console.error("Failed to reload plugin runtime:", error)
|
||||
})
|
||||
}
|
||||
window.addEventListener("ss:plugins-updated", handlePluginsUpdated)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("ss:plugins-updated", handlePluginsUpdated)
|
||||
runtime.stop().catch((error) => {
|
||||
console.error("Failed to stop plugin runtime:", error)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedPath = location.pathname === "/" ? "/home" : location.pathname
|
||||
window.dispatchEvent(new CustomEvent("ss:route-changed", { detail: { path: normalizedPath } }))
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
const checkWizard = async () => {
|
||||
if (!(window as any).api) return
|
||||
@@ -429,12 +459,13 @@ function MainContent(): React.JSX.Element {
|
||||
if (key === "home") navigate("/")
|
||||
if (key === "students") navigate("/students")
|
||||
if (key === "score") navigate("/score")
|
||||
if (key === "auto-score") navigate("/auto-score")
|
||||
if (key === "reward-settings") navigate("/reward-settings")
|
||||
if (key === "boards") navigate("/boards")
|
||||
if (key === "leaderboard") navigate("/leaderboard")
|
||||
if (key === "settlements") navigate("/settlements")
|
||||
if (key === "reasons") navigate("/reasons")
|
||||
if (key === "auto-score") navigate("/auto-score")
|
||||
if (key === "reward-settings") navigate("/reward-settings")
|
||||
if (key === "plugins") navigate("/plugins")
|
||||
if (key === "settings") navigate("/settings")
|
||||
}
|
||||
|
||||
@@ -481,6 +512,7 @@ function MainContent(): React.JSX.Element {
|
||||
leaderboard: <UnorderedListOutlined style={{ fontSize: "18px" }} />,
|
||||
settlements: <FileTextOutlined style={{ fontSize: "18px" }} />,
|
||||
reasons: <UnorderedListOutlined style={{ fontSize: "18px" }} />,
|
||||
plugins: <CrownOutlined style={{ fontSize: "18px" }} />,
|
||||
settings: <SettingOutlined style={{ fontSize: "18px" }} />,
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Tooltip,
|
||||
message,
|
||||
} from "antd"
|
||||
import { InfoCircleOutlined } from "@ant-design/icons"
|
||||
import { type ImmutableTree } from "@react-awesome-query-builder/antd"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import dayjs from "dayjs"
|
||||
@@ -855,7 +856,18 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
>
|
||||
<InputNumber min={1} style={{ width: "100%" }} disabled={!canEdit} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("autoScore.startAt")} extra={intervalElapsedHint}>
|
||||
<Form.Item
|
||||
label={
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||
<span>{t("autoScore.startAt")}</span>
|
||||
<Tooltip title={intervalElapsedHint} trigger={["hover", "click"]}>
|
||||
<InfoCircleOutlined
|
||||
style={{ color: "var(--ss-text-secondary)", cursor: "pointer" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<DatePicker
|
||||
showTime
|
||||
allowClear
|
||||
|
||||
@@ -22,6 +22,7 @@ const loadLeaderboard = () => import("./Leaderboard")
|
||||
const loadSettlementHistory = () => import("./SettlementHistory")
|
||||
const loadRewardSettings = () => import("./RewardSettings")
|
||||
const loadBoardManager = () => import("./BoardManager")
|
||||
const loadPluginManager = () => import("./PluginManager")
|
||||
|
||||
const Home = lazy(() => loadHome().then((m) => ({ default: m.Home })))
|
||||
const StudentManager = lazy(() => loadStudentManager().then((m) => ({ default: m.StudentManager })))
|
||||
@@ -35,6 +36,7 @@ const SettlementHistory = lazy(() =>
|
||||
)
|
||||
const RewardSettings = lazy(() => loadRewardSettings().then((m) => ({ default: m.RewardSettings })))
|
||||
const BoardManager = lazy(() => loadBoardManager().then((m) => ({ default: m.BoardManager })))
|
||||
const PluginManager = lazy(() => loadPluginManager().then((m) => ({ default: m.PluginManager })))
|
||||
|
||||
const warmupRouteChunks = () =>
|
||||
Promise.allSettled([
|
||||
@@ -48,6 +50,7 @@ const warmupRouteChunks = () =>
|
||||
loadSettlementHistory(),
|
||||
loadRewardSettings(),
|
||||
loadBoardManager(),
|
||||
loadPluginManager(),
|
||||
])
|
||||
|
||||
const { Content } = Layout
|
||||
@@ -110,6 +113,7 @@ export function ContentArea({
|
||||
if (normalizedPath.startsWith("/settlements")) return t("sidebar.settlements")
|
||||
if (normalizedPath.startsWith("/reasons")) return t("sidebar.reasons")
|
||||
if (normalizedPath.startsWith("/reward-settings")) return t("sidebar.rewardSettings")
|
||||
if (normalizedPath.startsWith("/plugins")) return t("sidebar.plugins")
|
||||
if (normalizedPath.startsWith("/settings")) return t("sidebar.settings")
|
||||
return "SecScore"
|
||||
})()
|
||||
@@ -380,6 +384,7 @@ export function ContentArea({
|
||||
path="/reward-settings"
|
||||
element={<RewardSettings canEdit={permission === "admin"} />}
|
||||
/>
|
||||
<Route path="/plugins" element={<PluginManager canEdit={permission === "admin"} />} />
|
||||
<Route path="/settings" element={<Settings permission={permission} />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import React, { useState, useEffect, useCallback } from "react"
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Switch,
|
||||
message,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Card,
|
||||
Space,
|
||||
Descriptions,
|
||||
Upload,
|
||||
Tooltip,
|
||||
} from "antd"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { UploadOutlined, DeleteOutlined, FolderOpenOutlined } from "@ant-design/icons"
|
||||
|
||||
interface Plugin {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
description?: string
|
||||
author?: string
|
||||
enabled: boolean
|
||||
installed_at?: string
|
||||
manifest_path?: string
|
||||
}
|
||||
|
||||
interface PluginStats {
|
||||
total_plugins: number
|
||||
enabled_plugins: number
|
||||
disabled_plugins: number
|
||||
}
|
||||
|
||||
export const PluginManager: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||||
const { t } = useTranslation()
|
||||
const [data, setData] = useState<Plugin[]>([])
|
||||
const [stats, setStats] = useState<PluginStats>({ total_plugins: 0, enabled_plugins: 0, disabled_plugins: 0 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [installModalVisible, setInstallModalVisible] = useState(false)
|
||||
const [installLoading, setInstallLoading] = useState(false)
|
||||
const [selectedPath, setSelectedPath] = useState<string>("")
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const emitPluginsUpdated = (action: "install" | "uninstall" | "toggle", pluginId?: string) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("ss:plugins-updated", {
|
||||
detail: {
|
||||
source: "plugin-manager",
|
||||
action,
|
||||
pluginId,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const fetchPlugins = useCallback(async () => {
|
||||
if (!(window as any).api) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await (window as any).api.pluginGetList()
|
||||
if (res.success && res.data) {
|
||||
setData(res.data)
|
||||
}
|
||||
const statsRes = await (window as any).api.pluginGetStats()
|
||||
if (statsRes.success && statsRes.data) {
|
||||
setStats(statsRes.data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch plugins:", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlugins()
|
||||
}, [fetchPlugins])
|
||||
|
||||
const handleToggle = async (plugin: Plugin, enabled: boolean) => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await (window as any).api.pluginToggle(plugin.id, enabled)
|
||||
if (res.success) {
|
||||
messageApi.success(enabled ? t("plugin.enabled") : t("plugin.disabled"))
|
||||
fetchPlugins()
|
||||
emitPluginsUpdated("toggle", plugin.id)
|
||||
} else {
|
||||
messageApi.error(res.message || t("plugin.toggleFailed"))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to toggle plugin:", e)
|
||||
messageApi.error(t("plugin.toggleFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
const handleUninstall = async (pluginId: string) => {
|
||||
if (!(window as any).api) return
|
||||
if (!canEdit) {
|
||||
messageApi.error(t("common.readOnly"))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await (window as any).api.pluginUninstall(pluginId)
|
||||
if (res.success) {
|
||||
messageApi.success(t("plugin.uninstallSuccess"))
|
||||
fetchPlugins()
|
||||
emitPluginsUpdated("uninstall", pluginId)
|
||||
} else {
|
||||
messageApi.error(res.message || t("plugin.uninstallFailed"))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to uninstall plugin:", e)
|
||||
messageApi.error(t("plugin.uninstallFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!selectedPath) {
|
||||
messageApi.warning(t("plugin.selectFolder"))
|
||||
return
|
||||
}
|
||||
setInstallLoading(true)
|
||||
try {
|
||||
const manifestRes = await (window as any).api.pluginLoadManifest(selectedPath)
|
||||
if (!manifestRes.success) {
|
||||
messageApi.error(manifestRes.message || t("plugin.invalidManifest"))
|
||||
setInstallLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const installRes = await (window as any).api.pluginInstall(manifestRes.data, selectedPath)
|
||||
if (installRes.success) {
|
||||
messageApi.success(t("plugin.installSuccess"))
|
||||
setInstallModalVisible(false)
|
||||
setSelectedPath("")
|
||||
fetchPlugins()
|
||||
emitPluginsUpdated("install", installRes.data?.id)
|
||||
} else {
|
||||
messageApi.error(installRes.message || t("plugin.installFailed"))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to install plugin:", e)
|
||||
messageApi.error(t("plugin.installFailed"))
|
||||
} finally {
|
||||
setInstallLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Plugin> = [
|
||||
{
|
||||
title: t("plugin.name"),
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
width: 150,
|
||||
render: (name: string, record: Plugin) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<span style={{ fontWeight: 500 }}>{name}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--ss-text-secondary)" }}>v{record.version}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("plugin.author"),
|
||||
dataIndex: "author",
|
||||
key: "author",
|
||||
width: 120,
|
||||
render: (author?: string) => author || "-",
|
||||
},
|
||||
{
|
||||
title: t("plugin.description"),
|
||||
dataIndex: "description",
|
||||
key: "description",
|
||||
render: (desc?: string) => (
|
||||
<Tooltip title={desc}>
|
||||
<span style={{ maxWidth: 200, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", display: "block" }}>
|
||||
{desc || "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("plugin.status"),
|
||||
dataIndex: "enabled",
|
||||
key: "enabled",
|
||||
width: 100,
|
||||
render: (enabled: boolean, record: Plugin) => (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(checked) => handleToggle(record, checked)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("common.operation"),
|
||||
key: "operation",
|
||||
width: 100,
|
||||
render: (_, record: Plugin) => (
|
||||
<Popconfirm
|
||||
title={t("plugin.uninstallConfirm")}
|
||||
description={t("plugin.uninstallDescription")}
|
||||
onConfirm={() => handleUninstall(record.id)}
|
||||
disabled={!canEdit}
|
||||
okText={t("common.yes")}
|
||||
cancelText={t("common.no")}
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} disabled={!canEdit}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ padding: "24px" }}>
|
||||
{contextHolder}
|
||||
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions size="small" column={3}>
|
||||
<Descriptions.Item label={t("plugin.totalPlugins")}>
|
||||
<Tag color="blue">{stats.total_plugins}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t("plugin.enabledPlugins")}>
|
||||
<Tag color="success">{stats.enabled_plugins}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t("plugin.disabledPlugins")}>
|
||||
<Tag color="default">{stats.disabled_plugins}</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between" }}>
|
||||
<h2 style={{ margin: 0, color: "var(--ss-text-main)" }}>{t("plugin.title")}</h2>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<FolderOpenOutlined />}
|
||||
disabled={!canEdit}
|
||||
onClick={() => setInstallModalVisible(true)}
|
||||
>
|
||||
{t("plugin.install")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
locale={{ emptyText: t("plugin.noPlugins") }}
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t("plugin.install")}
|
||||
open={installModalVisible}
|
||||
onCancel={() => {
|
||||
setInstallModalVisible(false)
|
||||
setSelectedPath("")
|
||||
}}
|
||||
onOk={handleInstall}
|
||||
okText={t("common.confirm")}
|
||||
cancelText={t("common.cancel")}
|
||||
confirmLoading={installLoading}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label={t("plugin.pluginFolder")}>
|
||||
<Input
|
||||
placeholder={t("plugin.folderPlaceholder")}
|
||||
value={selectedPath}
|
||||
onChange={(e) => setSelectedPath(e.target.value)}
|
||||
addonAfter={
|
||||
<Upload
|
||||
showUploadList={false}
|
||||
directory
|
||||
beforeUpload={(file) => {
|
||||
const path = (file as any).path || file.name
|
||||
setSelectedPath(path)
|
||||
return false
|
||||
}}
|
||||
>
|
||||
<Button type="text" size="small" icon={<UploadOutlined />} />
|
||||
</Upload>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div style={{ color: "var(--ss-text-secondary)", fontSize: 12 }}>
|
||||
{t("plugin.installHint")}
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
UploadOutlined,
|
||||
AppstoreAddOutlined,
|
||||
ApartmentOutlined,
|
||||
CrownOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
@@ -196,6 +197,12 @@ export function Sidebar({
|
||||
label: t("sidebar.reasons"),
|
||||
disabled: permission !== "admin",
|
||||
},
|
||||
{
|
||||
key: "plugins",
|
||||
icon: <CrownOutlined />,
|
||||
label: t("sidebar.plugins"),
|
||||
disabled: permission !== "admin",
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
icon: <SettingOutlined />,
|
||||
|
||||
@@ -369,7 +369,8 @@
|
||||
"syncFailed": "Sync failed",
|
||||
"getDbStatusFailed": "Failed to get database status",
|
||||
"notRemoteMode": "Not in remote database mode, please restart application",
|
||||
"dbNotConnected": "Database not connected"
|
||||
"dbNotConnected": "Database not connected",
|
||||
"plugins": "Plugins"
|
||||
},
|
||||
"auth": {
|
||||
"unlock": "Unlock Permission",
|
||||
@@ -947,5 +948,35 @@
|
||||
"export": "Export Text File",
|
||||
"exportHint": "It is recommended to export and store offline. If lost, it cannot be recovered.",
|
||||
"hint": "Recommended to save offline after export, cannot be recovered if lost."
|
||||
},
|
||||
"plugin": {
|
||||
"title": "Plugin Manager",
|
||||
"name": "Plugin Name",
|
||||
"author": "Author",
|
||||
"description": "Description",
|
||||
"version": "Version",
|
||||
"status": "Status",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"operation": "Operation",
|
||||
"install": "Install Plugin",
|
||||
"uninstall": "Uninstall",
|
||||
"uninstallConfirm": "Confirm uninstall plugin?",
|
||||
"uninstallDescription": "Uninstall removes the plugin copy installed inside the app plugin directory.",
|
||||
"installSuccess": "Plugin installed successfully",
|
||||
"installFailed": "Plugin installation failed",
|
||||
"uninstallSuccess": "Plugin uninstalled successfully",
|
||||
"uninstallFailed": "Plugin uninstallation failed",
|
||||
"toggleFailed": "Failed to toggle plugin status",
|
||||
"invalidManifest": "Invalid plugin manifest file",
|
||||
"pluginFolder": "Plugin Folder",
|
||||
"folderPlaceholder": "Select folder containing manifest.json",
|
||||
"installHint": "Select a folder containing manifest.json to install the plugin.",
|
||||
"selectFolder": "Please select plugin folder",
|
||||
"totalPlugins": "Total Plugins",
|
||||
"enabledPlugins": "Enabled",
|
||||
"disabledPlugins": "Disabled",
|
||||
"noPlugins": "No plugins installed",
|
||||
"installedAt": "Installed At"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,7 +369,8 @@
|
||||
"syncFailed": "同步失败",
|
||||
"getDbStatusFailed": "获取数据库状态失败",
|
||||
"notRemoteMode": "当前不是远程数据库模式,请重启应用后重试",
|
||||
"dbNotConnected": "数据库未连接"
|
||||
"dbNotConnected": "数据库未连接",
|
||||
"plugins": "插件管理"
|
||||
},
|
||||
"auth": {
|
||||
"unlock": "权限解锁",
|
||||
@@ -937,5 +938,35 @@
|
||||
"export": "导出文本文件",
|
||||
"exportHint": "建议导出后离线保存,遗失将无法找回。",
|
||||
"hint": "建议导出后离线保存,遗失将无法找回。"
|
||||
},
|
||||
"plugin": {
|
||||
"title": "插件管理",
|
||||
"name": "插件名称",
|
||||
"author": "作者",
|
||||
"description": "描述",
|
||||
"version": "版本",
|
||||
"status": "状态",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"operation": "操作",
|
||||
"install": "安装插件",
|
||||
"uninstall": "卸载",
|
||||
"uninstallConfirm": "确认卸载插件?",
|
||||
"uninstallDescription": "卸载后会移除已安装到应用目录中的插件副本。",
|
||||
"installSuccess": "插件安装成功",
|
||||
"installFailed": "插件安装失败",
|
||||
"uninstallSuccess": "插件卸载成功",
|
||||
"uninstallFailed": "插件卸载失败",
|
||||
"toggleFailed": "切换插件状态失败",
|
||||
"invalidManifest": "无效的插件清单文件",
|
||||
"pluginFolder": "插件文件夹",
|
||||
"folderPlaceholder": "选择包含 manifest.json 的文件夹",
|
||||
"installHint": "选择一个包含 manifest.json 的文件夹来安装插件。",
|
||||
"selectFolder": "请选择插件文件夹",
|
||||
"totalPlugins": "插件总数",
|
||||
"enabledPlugins": "已启用",
|
||||
"disabledPlugins": "已禁用",
|
||||
"noPlugins": "暂无已安装的插件",
|
||||
"installedAt": "安装时间"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { pluginRuntimeModule } from "../preload/types"
|
||||
|
||||
type PluginHostEvent = "data-updated" | "route-changed" | "plugins-updated"
|
||||
type PluginCleanup = (() => void | Promise<void>) | void
|
||||
type PluginSetup = (context: PluginContext) => PluginCleanup | Promise<PluginCleanup>
|
||||
|
||||
interface PluginEntryModule {
|
||||
setup?: PluginSetup
|
||||
}
|
||||
|
||||
interface LoadedPlugin {
|
||||
id: string
|
||||
moduleUrl: string
|
||||
cleanup?: () => void | Promise<void>
|
||||
disposers: Array<() => void>
|
||||
}
|
||||
|
||||
const PLUGIN_EVENT_MAP: Record<PluginHostEvent, string> = {
|
||||
"data-updated": "ss:data-updated",
|
||||
"route-changed": "ss:route-changed",
|
||||
"plugins-updated": "ss:plugins-updated",
|
||||
}
|
||||
|
||||
export interface PluginContext {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
permissions: string[]
|
||||
api: any
|
||||
on: (event: PluginHostEvent, handler: (detail: any) => void) => () => void
|
||||
log: (message: string, meta?: unknown) => void
|
||||
}
|
||||
|
||||
export class PluginRuntime {
|
||||
private loadedPlugins: LoadedPlugin[] = []
|
||||
private started = false
|
||||
private loading = false
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.started) return
|
||||
this.started = true
|
||||
await this.reload()
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.started = false
|
||||
await this.unloadAllPlugins()
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
if (this.loading) return
|
||||
this.loading = true
|
||||
try {
|
||||
await this.unloadAllPlugins()
|
||||
if (!this.started) return
|
||||
await this.loadEnabledPlugins()
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
private async loadEnabledPlugins(): Promise<void> {
|
||||
const api = (window as any).api
|
||||
if (!api?.pluginGetRuntimeModules) return
|
||||
|
||||
let response:
|
||||
| {
|
||||
success: boolean
|
||||
data?: pluginRuntimeModule[]
|
||||
message?: string
|
||||
}
|
||||
| undefined
|
||||
try {
|
||||
response = await api.pluginGetRuntimeModules()
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch runtime plugins:", error)
|
||||
return
|
||||
}
|
||||
if (!response?.success || !Array.isArray(response.data)) {
|
||||
if (response?.message) {
|
||||
console.warn("Plugin runtime response failed:", response.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for (const runtimeModule of response.data) {
|
||||
await this.loadSinglePlugin(runtimeModule)
|
||||
}
|
||||
}
|
||||
|
||||
private async loadSinglePlugin(runtimeModule: pluginRuntimeModule): Promise<void> {
|
||||
const sourceWithTrace = `${runtimeModule.code}\n//# sourceURL=secscore-plugin:${runtimeModule.id}/${runtimeModule.main}\n`
|
||||
const moduleUrl = URL.createObjectURL(
|
||||
new Blob([sourceWithTrace], {
|
||||
type: "text/javascript",
|
||||
})
|
||||
)
|
||||
const disposers: Array<() => void> = []
|
||||
let cleanup: (() => void | Promise<void>) | undefined
|
||||
|
||||
try {
|
||||
const importedModule = await import(/* @vite-ignore */ moduleUrl)
|
||||
const pluginModule = this.normalizeModule(importedModule)
|
||||
if (!pluginModule?.setup) {
|
||||
console.warn(`Plugin ${runtimeModule.id} has no setup() and was skipped`)
|
||||
URL.revokeObjectURL(moduleUrl)
|
||||
return
|
||||
}
|
||||
|
||||
const context = this.createContext(runtimeModule, disposers)
|
||||
const setupResult = await pluginModule.setup(context)
|
||||
if (typeof setupResult === "function") {
|
||||
cleanup = setupResult
|
||||
}
|
||||
|
||||
this.loadedPlugins.push({
|
||||
id: runtimeModule.id,
|
||||
moduleUrl,
|
||||
cleanup,
|
||||
disposers,
|
||||
})
|
||||
console.info(`Plugin loaded: ${runtimeModule.id}@${runtimeModule.version}`)
|
||||
} catch (error) {
|
||||
for (const dispose of [...disposers].reverse()) {
|
||||
try {
|
||||
dispose()
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
URL.revokeObjectURL(moduleUrl)
|
||||
console.error(`Failed to load plugin ${runtimeModule.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeModule(moduleValue: unknown): PluginEntryModule | null {
|
||||
if (!moduleValue || typeof moduleValue !== "object") return null
|
||||
const moduleRecord = moduleValue as Record<string, unknown>
|
||||
|
||||
if (typeof moduleRecord.setup === "function") {
|
||||
return { setup: moduleRecord.setup as PluginSetup }
|
||||
}
|
||||
|
||||
const defaultExport = moduleRecord.default as unknown
|
||||
if (typeof defaultExport === "function") {
|
||||
return { setup: defaultExport as PluginSetup }
|
||||
}
|
||||
if (
|
||||
defaultExport &&
|
||||
typeof defaultExport === "object" &&
|
||||
typeof (defaultExport as Record<string, unknown>).setup === "function"
|
||||
) {
|
||||
return {
|
||||
setup: ((defaultExport as Record<string, unknown>).setup as PluginSetup).bind(defaultExport),
|
||||
}
|
||||
}
|
||||
|
||||
const pluginExport = moduleRecord.plugin as unknown
|
||||
if (
|
||||
pluginExport &&
|
||||
typeof pluginExport === "object" &&
|
||||
typeof (pluginExport as Record<string, unknown>).setup === "function"
|
||||
) {
|
||||
return {
|
||||
setup: ((pluginExport as Record<string, unknown>).setup as PluginSetup).bind(pluginExport),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private createContext(
|
||||
runtimeModule: pluginRuntimeModule,
|
||||
disposers: Array<() => void>
|
||||
): PluginContext {
|
||||
const registerEvent = (event: PluginHostEvent, handler: (detail: any) => void): (() => void) => {
|
||||
const eventName = PLUGIN_EVENT_MAP[event]
|
||||
const listener = (nativeEvent: Event) => {
|
||||
handler((nativeEvent as CustomEvent<any>)?.detail)
|
||||
}
|
||||
window.addEventListener(eventName, listener)
|
||||
const dispose = () => {
|
||||
window.removeEventListener(eventName, listener)
|
||||
}
|
||||
disposers.push(dispose)
|
||||
return () => {
|
||||
dispose()
|
||||
const index = disposers.indexOf(dispose)
|
||||
if (index >= 0) {
|
||||
disposers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: runtimeModule.id,
|
||||
name: runtimeModule.name,
|
||||
version: runtimeModule.version,
|
||||
permissions: runtimeModule.permissions || [],
|
||||
api: (window as any).api,
|
||||
on: registerEvent,
|
||||
log: (message: string, meta?: unknown) => {
|
||||
if (meta === undefined) {
|
||||
console.log(`[Plugin:${runtimeModule.id}] ${message}`)
|
||||
return
|
||||
}
|
||||
console.log(`[Plugin:${runtimeModule.id}] ${message}`, meta)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private async unloadAllPlugins(): Promise<void> {
|
||||
for (const plugin of [...this.loadedPlugins].reverse()) {
|
||||
if (plugin.cleanup) {
|
||||
try {
|
||||
await plugin.cleanup()
|
||||
} catch (error) {
|
||||
console.error(`Plugin cleanup failed: ${plugin.id}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
for (const dispose of [...plugin.disposers].reverse()) {
|
||||
try {
|
||||
dispose()
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(plugin.moduleUrl)
|
||||
}
|
||||
|
||||
this.loadedPlugins = []
|
||||
}
|
||||
}
|
||||
|
||||
let runtimeInstance: PluginRuntime | null = null
|
||||
|
||||
export const getPluginRuntime = (): PluginRuntime => {
|
||||
if (!runtimeInstance) {
|
||||
runtimeInstance = new PluginRuntime()
|
||||
}
|
||||
return runtimeInstance
|
||||
}
|
||||
@@ -118,6 +118,17 @@ export interface settingsSpec {
|
||||
mobile_bottom_nav_items: string[]
|
||||
}
|
||||
|
||||
export interface pluginRuntimeModule {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
description?: string | null
|
||||
author?: string | null
|
||||
main: string
|
||||
code: string
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
const api = {
|
||||
// Theme
|
||||
getThemes: (): Promise<{ success: boolean; data: themeConfig[] }> => invoke("theme_list"),
|
||||
@@ -701,6 +712,60 @@ const api = {
|
||||
appQuit: (): Promise<void> => invoke("app_quit"),
|
||||
appRestart: (): Promise<void> => invoke("app_restart"),
|
||||
|
||||
// Plugin
|
||||
pluginGetAll: (): Promise<{
|
||||
success: boolean
|
||||
data?: any[]
|
||||
message?: string
|
||||
}> => invoke("plugin_get_all"),
|
||||
pluginGet: (pluginId: string): Promise<{
|
||||
success: boolean
|
||||
data?: any
|
||||
message?: string
|
||||
}> => invoke("plugin_get", { pluginId }),
|
||||
pluginGetStats: (): Promise<{
|
||||
success: boolean
|
||||
data?: {
|
||||
total_plugins: number
|
||||
enabled_plugins: number
|
||||
disabled_plugins: number
|
||||
}
|
||||
message?: string
|
||||
}> => invoke("plugin_get_stats"),
|
||||
pluginToggle: (pluginId: string, enabled: boolean): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
}> => invoke("plugin_toggle", { pluginId, enabled }),
|
||||
pluginInstall: (manifest: any, pluginDir: string): Promise<{
|
||||
success: boolean
|
||||
data?: any
|
||||
message?: string
|
||||
}> => invoke("plugin_install", { manifest, pluginDir }),
|
||||
pluginUninstall: (pluginId: string): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
}> => invoke("plugin_uninstall", { pluginId }),
|
||||
pluginLoadManifest: (path: string): Promise<{
|
||||
success: boolean
|
||||
data?: any
|
||||
message?: string
|
||||
}> => invoke("plugin_load_manifest", { path }),
|
||||
pluginGetDir: (pluginId: string): Promise<{
|
||||
success: boolean
|
||||
data?: string
|
||||
message?: string
|
||||
}> => invoke("plugin_get_dir", { pluginId }),
|
||||
pluginGetList: (): Promise<{
|
||||
success: boolean
|
||||
data?: any[]
|
||||
message?: string
|
||||
}> => invoke("plugin_get_list"),
|
||||
pluginGetRuntimeModules: (): Promise<{
|
||||
success: boolean
|
||||
data?: pluginRuntimeModule[]
|
||||
message?: string
|
||||
}> => invoke("plugin_get_runtime_modules"),
|
||||
|
||||
// Generic invoke wrapper for backward compatibility with callers using `api.invoke`
|
||||
invoke: async (channel: string, ..._args: any[]): Promise<any> => {
|
||||
switch (channel) {
|
||||
|
||||
@@ -8,6 +8,7 @@ export const MOBILE_NAV_ALL_KEYS = [
|
||||
"leaderboard",
|
||||
"settlements",
|
||||
"reasons",
|
||||
"plugins",
|
||||
"settings",
|
||||
] as const
|
||||
|
||||
@@ -35,6 +36,7 @@ export const MOBILE_NAV_ITEMS: MobileNavItemConfig[] = [
|
||||
{ key: "leaderboard", path: "/leaderboard", labelKey: "sidebar.leaderboard" },
|
||||
{ key: "settlements", path: "/settlements", labelKey: "sidebar.settlements" },
|
||||
{ key: "reasons", path: "/reasons", labelKey: "sidebar.reasons", adminOnly: true },
|
||||
{ key: "plugins", path: "/plugins", labelKey: "sidebar.plugins", adminOnly: true },
|
||||
{ key: "settings", path: "/settings", labelKey: "sidebar.settings" },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user