第二阶段已完成:统一插件平台已从“内置功能开关”扩展为可安装、签名校验、版本激活和回滚的插件系统。

主要成果:
- 新增独立插件 SDK:[PluginContracts.cs (line 11)](E:/jiaowu/src/Jiaowu.Plugin.Abstractions/PluginContracts.cs:11)
- 支持 RSA-PSS/SHA-256 签名插件包、Host API 兼容性和 ZIP 安全检查:[PluginPackageValidator.cs (line 14)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Plugins/PluginPackageValidator.cs:14)
- 支持暂存、待激活、重启装载、失败记录、版本回滚:[PluginPackageService.cs (line 32)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Plugins/PluginPackageService.cs:32)
- 插件可注册控制器、服务、数据库迁移、后台任务和事件处理器
- 外部插件 API 统一使用 /api/plugin-extensions/{pluginId},并受启用状态中间件控制
- 插件中心新增 ZIP/SIG 上传、状态展示、激活、回滚和删除功能:[PluginsView.vue (line 212)](E:/jiaowu/web/src/views/PluginsView.vue:212)
- 提供可运行示例插件:[SamplePlugin.cs (line 10)](E:/jiaowu/samples/Jiaowu.SamplePlugin/SamplePlugin.cs:10)
- 提供打包签名脚本:[package-plugin.ps1](E:/jiaowu/scripts/package-plugin.ps1)
- 完整开发文档:[plugin-sdk.md (line 1)](E:/jiaowu/docs/plugin-sdk.md:1)
- 系统版本已调整为 3.0.0-beta1
This commit is contained in:
biss committed 2026-10-01 10:22:46 +08:00
1 parent 7e3f138875
commit 05683e502a
35 files changed
+11247 -24

No files matched your search

+3
View File
@@ -12,6 +12,9 @@ export interface PluginRuntimeItem {
navigationPaths: string[]
capabilities: string[]
stateChangedAt?: string
source: 'built-in' | 'package'
status: string
requiresRestart: boolean
}
export const usePluginStore = defineStore('plugins', () => {
+188 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Connection, Lock, SwitchButton } from '@element-plus/icons-vue'
import { Connection, Lock, SwitchButton, Upload } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import http, { apiErrorMessage } from '../api/http'
import { type PluginRuntimeItem, usePluginStore } from '../stores/plugins'
@@ -8,6 +8,28 @@ import { type PluginRuntimeItem, usePluginStore } from '../stores/plugins'
const pluginStore = usePluginStore()
const loading = ref(true)
const changingId = ref('')
const uploading = ref(false)
const packageInput = ref<HTMLInputElement>()
const signatureInput = ref<HTMLInputElement>()
const packageFile = ref<File>()
const signatureFile = ref<File>()
interface PackageRelease {
id: string
pluginId: string
name: string
version: string
publisherId: string
hostApiVersion: string
sha256: string
packageSize: number
status: 'Staged' | 'PendingActivation' | 'Active' | 'Superseded' | 'Failed'
createdAt: string
activationRequestedAt?: string
failureMessage?: string
}
const packageReleases = ref<PackageRelease[]>([])
const groupedPlugins = computed(() => {
const groups = new Map<string, PluginRuntimeItem[]>()
@@ -26,7 +48,11 @@ const enabledCount = computed(() =>
async function loadPlugins() {
loading.value = true
try {
await pluginStore.load(true)
const [, packageResponse] = await Promise.all([
pluginStore.load(true),
http.get<PackageRelease[]>('/plugins/packages'),
])
packageReleases.value = packageResponse.data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
@@ -34,6 +60,86 @@ async function loadPlugins() {
}
}
function selectFile(event: Event, target: 'package' | 'signature') {
const file = (event.target as HTMLInputElement).files?.[0]
if (target === 'package') packageFile.value = file
else signatureFile.value = file
}
async function uploadPackage() {
if (!packageFile.value || !signatureFile.value) {
ElMessage.warning('请选择插件 ZIP 包和对应的签名文件。')
return
}
uploading.value = true
try {
const body = new FormData()
body.append('package', packageFile.value)
body.append('signature', signatureFile.value)
await http.post('/plugins/packages', body)
packageFile.value = undefined
signatureFile.value = undefined
if (packageInput.value) packageInput.value.value = ''
if (signatureInput.value) signatureInput.value.value = ''
ElMessage.success('签名校验通过,插件包已进入暂存区。')
await loadPlugins()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
uploading.value = false
}
}
async function activatePackage(row: unknown) {
const release = row as PackageRelease
try {
await ElMessageBox.confirm(
`版本 ${release.version} 将在下次服务重启时激活。当前活动版本会保留,可用同样方式回滚。`,
`激活 ${release.name}`,
{ confirmButtonText: '标记并等待重启', cancelButtonText: '取消', type: 'warning' },
)
await http.post(`/plugins/packages/${release.id}/activate`)
ElMessage.success('已标记为待激活,请按维护流程重启服务。')
await loadPlugins()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
async function deletePackage(row: unknown) {
const release = row as PackageRelease
try {
await ElMessageBox.confirm(
`确认删除 ${release.name} ${release.version} 的包记录?`,
'删除插件包',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' },
)
await http.delete(`/plugins/packages/${release.id}`)
ElMessage.success('插件包记录已删除。')
await loadPlugins()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
function packageStatus(status: PackageRelease['status']) {
return {
Staged: '已暂存',
PendingActivation: '待重启激活',
Active: '活动版本',
Superseded: '历史版本',
Failed: '装载失败',
}[status]
}
function formatSize(bytes: number) {
return bytes < 1024 * 1024
? `${(bytes / 1024).toFixed(1)} KB`
: `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
async function changeState(plugin: PluginRuntimeItem, value: string | number | boolean) {
if (typeof value !== 'boolean' || value === plugin.isEnabled) return
const action = value ? '启用' : '停用'
@@ -99,6 +205,71 @@ onMounted(loadPlugins)
</div>
</section>
<section class="package-panel">
<div class="package-heading">
<div>
<span>SIGNED PACKAGE PIPELINE</span>
<h3>签名插件包</h3>
<p>上传只进入暂存区;校验通过后仍需标记激活并重启服务。</p>
</div>
<div class="package-upload">
<label>
<input ref="packageInput" type="file" accept=".zip" @change="selectFile($event, 'package')">
{{ packageFile?.name || '选择 ZIP 包' }}
</label>
<label>
<input ref="signatureInput" type="file" accept=".sig,.bin" @change="selectFile($event, 'signature')">
{{ signatureFile?.name || '选择签名文件' }}
</label>
<el-button type="primary" :icon="Upload" :loading="uploading" @click="uploadPackage">
校验并暂存
</el-button>
</div>
</div>
<el-table :data="packageReleases" empty-text="尚未上传签名插件包">
<el-table-column label="插件 / 版本" min-width="210">
<template #default="{ row }">
<strong>{{ row.name }}</strong>
<small>{{ row.pluginId }} · v{{ row.version }}</small>
</template>
</el-table-column>
<el-table-column label="发布者" min-width="130">
<template #default="{ row }">{{ row.publisherId }}</template>
</el-table-column>
<el-table-column label="校验" min-width="190">
<template #default="{ row }">
<code>API {{ row.hostApiVersion }} · {{ formatSize(row.packageSize) }}</code>
<small>SHA-256 {{ row.sha256.slice(0, 12) }}…</small>
</template>
</el-table-column>
<el-table-column label="状态" width="130">
<template #default="{ row }">
<el-tag :type="row.status === 'Active' ? 'success' : row.status === 'Failed' ? 'danger' : row.status === 'PendingActivation' ? 'warning' : 'info'">
{{ packageStatus(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.status !== 'Active' && row.status !== 'PendingActivation'"
link
type="primary"
@click="activatePackage(row)"
>
{{ row.status === 'Superseded' ? '回滚到此版本' : '激活' }}
</el-button>
<el-button
v-if="row.status === 'Staged' || row.status === 'Superseded'"
link
type="danger"
@click="deletePackage(row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
</section>
<section v-for="group in groupedPlugins" :key="group.category" class="plugin-group">
<div class="group-title">
<span>{{ group.category }}</span>
@@ -117,12 +288,12 @@ onMounted(loadPlugins)
</div>
<div class="plugin-state">
<span :class="plugin.isEnabled ? 'online' : 'offline'">
{{ plugin.isEnabled ? '运行中' : '已停用' }}
{{ plugin.requiresRestart ? '等待重启' : plugin.isEnabled ? '运行中' : '已停用' }}
</span>
<el-switch
:model-value="plugin.isEnabled"
:loading="changingId === plugin.id"
:disabled="Boolean(changingId)"
:disabled="Boolean(changingId) || (plugin.source === 'package' && plugin.status !== 'Active')"
@change="(value) => changeState(plugin, value)"
/>
</div>
@@ -200,6 +371,17 @@ onMounted(loadPlugins)
.safety-strip strong { font-size: 13px; }
.safety-strip p { font-size: 12px; margin-top: 3px; }
.plugin-group { display: grid; gap: 10px; }
.package-panel { background: #fff; border: 1px solid var(--line); }
.package-heading { align-items: center; display: flex; gap: 24px; justify-content: space-between; padding: 18px; }
.package-heading span { color: #75828a; font-family: "Cascadia Mono", Consolas, monospace; font-size: 10px; letter-spacing: .1em; }
.package-heading h3 { margin: 5px 0; }
.package-heading p { color: var(--muted); font-size: 12px; margin: 0; }
.package-upload { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
.package-upload label { border: 1px dashed #b9c4c7; color: #637078; cursor: pointer; font-size: 11px; max-width: 160px; overflow: hidden; padding: 8px 10px; text-overflow: ellipsis; white-space: nowrap; }
.package-upload input { display: none; }
.package-panel strong,.package-panel small { display: block; }
.package-panel small { color: #7d898f; font-size: 10px; margin-top: 4px; }
.package-panel code { color: #53636b; font-size: 10px; }
.group-title { align-items: center; display: flex; gap: 8px; }
.group-title i { background: #e8eded; border-radius: 9px; font-size: 10px; font-style: normal; padding: 2px 7px; }
.plugin-grid { display: grid; gap: 14px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -237,5 +419,7 @@ onMounted(loadPlugins)
.plugin-summary { text-align: left; }
.safety-strip { align-items: flex-start; }
.plugin-card footer { align-items: flex-start; flex-direction: column; gap: 5px; }
.package-heading { align-items: flex-start; flex-direction: column; }
.package-upload { justify-content: flex-start; }
}
</style>