openlearn-next 0.7.12 → 0.7.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +1 -1
  2. package/cli-data.mjs +2 -2
  3. package/cli-doctor.mjs +2 -1
  4. package/dist/assets/{AcademicYearFilter-Dm3no4Nb.js → AcademicYearFilter-pCzZgimU.js} +1 -1
  5. package/dist/assets/ActivityWorkspaceWidget-KJQdLnvf.js +1 -0
  6. package/dist/assets/AdminDirectoryView-DDxX8RO2.js +5 -0
  7. package/dist/assets/AssessmentCenter-D5cVUqvy.js +1 -0
  8. package/dist/assets/{AssignmentFilePreview-dVhnTYmq.js → AssignmentFilePreview-DK29gdpQ.js} +2 -2
  9. package/dist/assets/BatchPickerModal-Cw4OZqOJ.js +1 -0
  10. package/dist/assets/ClassesView-Czx7hU2V.js +32 -0
  11. package/dist/assets/ClassroomCountdownWidget-BeAM37Cq.js +2 -0
  12. package/dist/assets/CloudDriveModal-GNZzWVCP.js +1 -0
  13. package/dist/assets/ComputerLabView-C_0c7sjz.js +1 -0
  14. package/dist/assets/CourseManagement-DzLB0Xmp.js +1 -0
  15. package/dist/assets/CourseWizardModal-DmQu237c.js +3 -0
  16. package/dist/assets/CoursewareHubPanel-Xn5WbXg9.js +1 -0
  17. package/dist/assets/Dashboard-CX83FUeF.js +8 -0
  18. package/dist/assets/ExportWeightModal-C0SuUhHM.js +1 -0
  19. package/dist/assets/HelpTour-D8hVFA6N.js +1 -0
  20. package/dist/assets/HelpView-C0PP_51D.js +204 -0
  21. package/dist/assets/ImportLessonsModal-p8Uc1U_h.js +1 -0
  22. package/dist/assets/{ImportModal-Ct0I5cAL.js → ImportModal-DNaPHV0I.js} +2 -2
  23. package/dist/assets/InteractiveCoursewareViewer-D9YTgasv.js +1 -0
  24. package/dist/assets/InteractiveWhiteboard-CHYQfRjR.js +19 -0
  25. package/dist/assets/LazyCourseware-dtuOSiyR.js +2 -0
  26. package/dist/assets/LazyWhiteboard-DuBKAh4-.js +2 -0
  27. package/dist/assets/LessonEditorView-BgI7C_FK.js +1 -0
  28. package/dist/assets/LiveClassroomView-CJK_WMqD.js +67 -0
  29. package/dist/assets/NotificationDetailModal-BVYuS4uR.js +1 -0
  30. package/dist/assets/PluginView-CpWjy0Xf.js +66 -0
  31. package/dist/assets/ProcessLogsModal-D-Ez_YZA.js +1 -0
  32. package/dist/assets/QuizGeneratorModal-Lf5QMvkq.js +1 -0
  33. package/dist/assets/RevealPresentationWrapper-Dnf4v72i.js +48 -0
  34. package/dist/assets/StudentAssignmentEvalPanel-C-7YSfEQ.js +1 -0
  35. package/dist/assets/StudentAssignmentView-LuMaNpq8.js +1 -0
  36. package/dist/assets/StudentLessonView-DUG8I1Zy.js +1 -0
  37. package/dist/assets/StudentView-mMwGPiiF.js +2 -0
  38. package/dist/assets/SystemResourceLibraryModal-LUDNOwgW.js +1 -0
  39. package/dist/assets/TeacherView-ofB-3SEJ.js +2 -0
  40. package/dist/assets/TimetableView-DrZbyCOQ.js +6 -0
  41. package/dist/assets/{UTIF-BtQhea94.js → UTIF-DbcUtUKZ.js} +1 -1
  42. package/dist/assets/index-CG-g_9my.css +1 -0
  43. package/dist/assets/index-CLtQGl25.js +169 -0
  44. package/dist/assets/{index.es-CijVATXs.js → index.es-D1mLdmCj.js} +1 -1
  45. package/dist/assets/keyboard-Rs007iOv.js +1 -0
  46. package/dist/assets/{pluginParsers-DD3QL1Xl.js → pluginParsers-C46aCYkl.js} +1 -1
  47. package/dist/assets/student-workspace-BFPfR77J.js +1 -0
  48. package/dist/assets/{vendor-charts-skggaP9D.js → vendor-charts-CK-CU_m7.js} +1 -1
  49. package/dist/assets/{vendor-icons-CaXLq01o.js → vendor-icons-__JLt-CP.js} +2 -2
  50. package/dist/assets/{vendor-konva-BOuJWFuE.js → vendor-konva-DGELdczq.js} +1 -1
  51. package/dist/assets/{vendor-motion-BFAPOs-b.js → vendor-motion-DtyD8eRQ.js} +1 -1
  52. package/dist/assets/{vendor-pdf-DVWGghIL.js → vendor-pdf-DNexfcCO.js} +3 -3
  53. package/dist/assets/{vendor-pptx-BqU7v5zS.js → vendor-pptx-ByQkJ0Xi.js} +178 -178
  54. package/dist/assets/{vendor-react-DHoBZpn2.js → vendor-react-lfWn1oGs.js} +1 -1
  55. package/dist/assets/{vendor-utils-BxOuk3id.js → vendor-utils-CsoI4M9x.js} +1 -1
  56. package/dist/cli-migrator.cjs +23 -7
  57. package/dist/index.html +10 -6
  58. package/dist/server.cjs +16125 -9457
  59. package/docs_plugin_guide.md +1023 -0
  60. package/migrations/046_logout_clear_site_data.sql +9 -0
  61. package/migrations/047_third_party_theme_system.sql +55 -0
  62. package/migrations/048_file_storage_extension.sql +63 -0
  63. package/migrations/049_ai_personas.sql +25 -0
  64. package/migrations/050_ai_persona_governance.sql +11 -0
  65. package/migrations/051_community_registry_url.sql +9 -0
  66. package/migrations/052_community_install_enabled.sql +12 -0
  67. package/migrations/053_runtime_admin_policies.sql +28 -0
  68. package/migrations/054_user_onboarding_tour_status.sql +10 -0
  69. package/migrations/055_classroom_collab_whiteboard.sql +27 -0
  70. package/migrations/056_public_student_lookup.sql +9 -0
  71. package/migrations/README.md +59 -48
  72. package/migrations/schema-golden.json +98 -2
  73. package/package.json +8 -6
  74. package/scripts/assert-node-runtime.mjs +4 -3
  75. package/dist/assets/ActivityWorkspaceWidget-TtBuNORs.js +0 -1
  76. package/dist/assets/AdminDirectoryView-D03WOMc-.js +0 -5
  77. package/dist/assets/AssessmentCenter-MUvoJySb.js +0 -1
  78. package/dist/assets/BatchPickerModal-QALdhrX6.js +0 -1
  79. package/dist/assets/ClassesView-CKwlRz-A.js +0 -32
  80. package/dist/assets/ClassroomCountdownWidget-DyWyForY.js +0 -2
  81. package/dist/assets/CloudDriveModal-C8UAn3MO.js +0 -1
  82. package/dist/assets/ComputerLabView-E5b3XTkk.js +0 -1
  83. package/dist/assets/CourseManagement-3jSlq7lV.js +0 -1
  84. package/dist/assets/CourseWizardModal-BAkW5VJX.js +0 -3
  85. package/dist/assets/CoursewareHubPanel-BkjcusjZ.js +0 -1
  86. package/dist/assets/Dashboard-Ha2fZAqD.js +0 -8
  87. package/dist/assets/ExportWeightModal-D2Y13384.js +0 -1
  88. package/dist/assets/HelpTour-DBq2otcV.js +0 -1
  89. package/dist/assets/HelpView-BVLRnMxl.js +0 -204
  90. package/dist/assets/ImportLessonsModal-CQlq4b81.js +0 -1
  91. package/dist/assets/InteractiveCoursewareViewer-DGKh1gO8.js +0 -1
  92. package/dist/assets/InteractiveWhiteboard-BbZlYpRu.js +0 -65
  93. package/dist/assets/LazyCourseware-zONqinCM.js +0 -2
  94. package/dist/assets/LazyWhiteboard-BV98LEZ8.js +0 -2
  95. package/dist/assets/LessonEditorView-LXRkKxdY.js +0 -1
  96. package/dist/assets/LiveClassroomView-TaHtHWkY.js +0 -63
  97. package/dist/assets/NotificationDetailModal-BF_-pn0c.js +0 -1
  98. package/dist/assets/PluginView-sVMPXae9.js +0 -77
  99. package/dist/assets/ProcessLogsModal-D97xLgnL.js +0 -1
  100. package/dist/assets/QuizGeneratorModal-CNY8ISjR.js +0 -1
  101. package/dist/assets/StudentAssignmentEvalPanel-CUmM5DbT.js +0 -1
  102. package/dist/assets/StudentAssignmentView-B-1MtwKj.js +0 -1
  103. package/dist/assets/StudentLessonView-CqrcvVND.js +0 -1
  104. package/dist/assets/StudentView-Bi9kgSrN.js +0 -2
  105. package/dist/assets/SystemResourceLibraryModal-DDALKrJC.js +0 -1
  106. package/dist/assets/TeacherView-C5JmoKHk.js +0 -2
  107. package/dist/assets/TimetableView-BOLB-kZC.js +0 -6
  108. package/dist/assets/index-Bc9003lB.js +0 -131
  109. package/dist/assets/index-D4aq9poS.css +0 -1
  110. package/dist/assets/student-workspace-C4UgD58C.js +0 -1
  111. package/dist/assets/vendor-reveal-DcQQzuI9.css +0 -1
@@ -0,0 +1,1023 @@
1
+ # OpenLearn 插件开发权威参考指南 (Openlearn Next Plugin SDK Reference Guide)
2
+
3
+ 本指南是 OpenLearnV2(基于命令-事件总线微内核架构的教育实验操作系统)的第三方插件开发完整手册。本文档旨在提供自包含的、详尽无遗的 API 契约、类型声明、安全规范、数据库设计、UI 扩展槽位定义以及双运行时交互机制。可以直接作为提示词上下文提供给 AI 大模型,帮助其百分之百正确地生成可直接运行的第三方插件。
4
+
5
+ ---
6
+
7
+ ## 目录
8
+
9
+ 1. [架构概述与核心机制](#1-架构概述与核心机制)
10
+ 2. [编码规范与命名约定](#2-编码规范与命名约定)
11
+ 3. [快速开始与项目结构](#3-快速开始与项目结构)
12
+ 4. [完整 API 类型契约 (TypeScript)](#4-完整-api-类型契约-typescript)
13
+ 5. [后端核心服务详解 (Backend Services)](#5-后端核心服务详解-backend-services)
14
+ 6. [指令隔离与数据库隔离 (Namespace Sandboxing)](#6-指令隔离与数据库隔离-namespace-sandboxing)
15
+ 7. [前端核心服务与视图扩展 (Frontend SDK)](#7-前端核心服务与视图扩展-frontend-sdk)
16
+ 8. [完整的全栈插件开发范例](#8-完整的全栈插件开发范例)
17
+ 9. [单元测试与调试指南](#9-单元测试与调试指南)
18
+
19
+ ---
20
+
21
+ ## 1. 架构概述与核心机制
22
+
23
+ ### 1.1 命令-事件总线架构
24
+
25
+ OpenLearn 采用 **微内核 (Microkernel) + 命令事件总线** 架构。核心仅提供底层的通用能力(依赖注入、用户管理、基本路由等),所有具体的业务功能(如白板绘制、作业管理、虚拟文件系统、AI 助手规划等)均以插件的形式挂载。
26
+
27
+ - **Action (AI 描述体)**:定义了 AI Agent 可以理解和调用的工具,并绑定了它需要触发的命令类型 (`commandType`)。描述采用 Google GenAI Schema 语法。
28
+ - **Command (平台指令)**:对系统状态进行修改的唯一手段。每个命令由全局命令总线 (`CommandBus`) 路由到已注册的对应 Handler 执行。
29
+ - **Event (平台事件)**:指令执行完毕后,由 Handler 广播发布。事件以过去式命名(如 `quiz.created`),允许其他插件解耦监听,并会自动通过 Socket.IO 推送给在线前端。
30
+
31
+ ```
32
+ AI Agent 调用
33
+ │
34
+ ┌───────▼────────┐
35
+ │ Action 注册 │ ← ctx.services.actionRegistry.register()
36
+ │ (工具描述+参数)│ 声明工具与 commandType
37
+ └───────┬────────┘
38
+ │ 转化为
39
+ ┌───────▼────────┐
40
+ │ Command 指令 │ ← ctx.services.commandBus.execute()
41
+ │ (执行业务逻辑)│ 调用对应的 CommandHandler
42
+ └───────┬────────┘
43
+ │ 触发发布
44
+ ┌───────▼────────┐
45
+ │ Event 事件 │ ← ctx.services.eventBus.publish()
46
+ │ (系统事件广播)│ 推送给订阅者与前端在线客户端
47
+ └────────────────┘
48
+ ```
49
+
50
+ ### 1.2 双运行时架构 (Dual Runtime)
51
+
52
+ - **服务端运行时 (Node.js)**:运行插件的后台逻辑,使用 `inline`(直接在主进程中执行)或 `worker`(在独立 Worker Thread 中隔离执行)模式。可以使用 `better-sqlite3` 数据库。
53
+ - **前端运行时 (浏览器)**:由 `FrontendPluginHost` 动态加载编译后的 `frontend.js` 模块,执行 `activate(ctx)` 并将插件的前端 React 组件注册到系统预置的各个 UI 扩展槽位。
54
+
55
+ ---
56
+
57
+ ## 2. 编码规范与命名约定
58
+
59
+ 为确保第三方插件与主应用完美兼容,大模型在生成代码时必须严格遵守以下规范:
60
+
61
+ - **命名约定 (Naming Conventions)**:
62
+ - **命令类型 (Command Type)**:点号分隔的层级命名空间,全部小写。如 `quiz.create`、`whiteboard.draw`。
63
+ - **事件类型 (Event Type)**:以过去式命名,点号分隔。如 `quiz.created`、`assignment.submitted`。
64
+ - **插件 ID (Plugin ID)**:采用 `snake_case` 或 `@scope/kebab-case` 格式,且唯一。
65
+ - **常量**:使用大写蛇形命名法 (`UPPER_SNAKE_CASE`)。
66
+ - **构建约定**:
67
+ - 前端组件文件使用 `.tsx` 扩展名,使用 **PascalCase** 命名,且**必须 default export** 扩展组件本身。
68
+ - 服务端入口文件使用 `.ts` 扩展名,**必须 default export** 包含 `manifest`、`activate` 和可选 `deactivate` 的对象。
69
+ - 依赖注入服务(DI Service)仅通过定义的 **Token** 进行解析。
70
+
71
+ ---
72
+
73
+ ## 3. 快速开始与项目结构
74
+
75
+ ### 3.1 命令行脚手架工具
76
+
77
+ 使用官方 SDK 快速创建和打包插件:
78
+
79
+ ```bash
80
+ # 交互式初始化项目
81
+ npx @openlearn/plugin-sdk init
82
+
83
+ # 非交互式快速创建 full-stack 项目
84
+ npx @openlearn/plugin-sdk init --name ext-exam-helper --template full-stack --author myname
85
+
86
+ # 构建打包项目
87
+ npx @openlearn/plugin-sdk build
88
+
89
+ # 构建打包并开启热监听 (Watch Mode)
90
+ npx @openlearn/plugin-sdk build --watch
91
+ ```
92
+
93
+ ### 3.2 插件项目 file 结构
94
+
95
+ ```
96
+ ext-exam-helper/
97
+ ├── package.json # 插件元信息和打包依赖配置
98
+ ├── tsconfig.json # TypeScript 配置文件
99
+ ├── src/
100
+ │ ├── index.ts # 服务端入口 (Export default { manifest, activate })
101
+ │ └── frontend.tsx # 前端 React 界面组件 (Default Export React.Component)
102
+ └── dist/ # 自动生成的构建输出目录
103
+ ├── index.js # 服务端打包编译后产物
104
+ ├── frontend.js # 前端打包编译后产物
105
+ └── ext-exam-helper.zip # 最终发布的插件压缩包 (包含 index.js + manifest.json + frontend.js)
106
+ ```
107
+
108
+ ---
109
+
110
+ ## 4. 完整 API 类型契约 (TypeScript)
111
+
112
+ 以下是 `@openlearn/plugin-sdk` 的核心接口类型定义,大模型生成代码时可直接参考此契约:
113
+
114
+ ```typescript
115
+ // ── 基础 Token 定义 ──
116
+ export declare class Token<T> {
117
+ readonly name: string;
118
+ readonly version: string;
119
+ constructor(name: string, version?: string);
120
+ }
121
+
122
+ // ── 状态枚举 ──
123
+ export declare enum PluginState {
124
+ INSTALLED = 'installed',
125
+ ACTIVATING = 'activating',
126
+ ACTIVE = 'active',
127
+ DEACTIVATING = 'deactivating',
128
+ INACTIVE = 'inactive',
129
+ ERROR = 'error',
130
+ UNINSTALLED = 'uninstalled',
131
+ }
132
+
133
+ // ── Manifest 配置描述 ──
134
+ export interface Manifest {
135
+ id: string; // 唯一标识 (建议以 ext- 或 @scope/ 开头)
136
+ name: string; // 人类可读名称
137
+ version: string; // SemVer 版本号 (如 "1.0.0")
138
+ main: string; // 服务端入口文件名 (通常为 "index.js")
139
+ requires?: string[]; // 强依赖的服务 Token 列表 (带版本区间,例如 "@openlearn/core:IDatabase@^1.0.0")
140
+ optional?: string[]; // 可选依赖的服务 Token 列表
141
+ capabilitiesProposed?: string[]; // 声明申请的权限 (如 ["lesson:write", "whiteboard:read"])
142
+ engines?: { openlearn: string }; // OpenLearn 宿主版本兼容性 (如 ">=0.7.0")
143
+ pluginDependencies?: string[]; // 依赖的其他插件 ID 列表
144
+ configuration?: {
145
+ // 插件的可配置项定义
146
+ properties?: Record<
147
+ string,
148
+ {
149
+ type: 'string' | 'number' | 'boolean' | 'integer';
150
+ default?: unknown;
151
+ description?: string;
152
+ enum?: unknown[];
153
+ minimum?: number;
154
+ maximum?: number;
155
+ }
156
+ >;
157
+ };
158
+ classroomTools?: Array<{
159
+ // 自动桥接的前端课堂白板工具栏按钮
160
+ id: string;
161
+ name: string;
162
+ icon?: string; // Lucide React 图标名称
163
+ description?: string;
164
+ commandType: string; // 触发的后台命令类型
165
+ payload?: Record<string, unknown>; // 默认负载参数
166
+ }>;
167
+ }
168
+
169
+ // ── 平台底层命令与事件 ──
170
+ export interface PlatformCommand<T = unknown> {
171
+ id: string; // 命令唯一 UUID (V7)
172
+ type: string; // 命令类型 (如 "quiz.create")
173
+ actorId: string; // 执行命令的用户 ID 或系统别名
174
+ payload: T; // 具体参数载荷
175
+ timestamp: number; // Unix 时间戳 (毫秒)
176
+ metadata?: Record<string, unknown>; // 额外元数据 (如 approved: 审批状态)
177
+ }
178
+
179
+ export interface CommandHandler {
180
+ execute(command: PlatformCommand): Promise<unknown>;
181
+ }
182
+
183
+ export interface PlatformEvent<T = unknown> {
184
+ id: string; // 事件唯一 UUID
185
+ type: string; // 事件类型 (如 "quiz.created")
186
+ source: string; // 事件来源 (通常为 "plugin.插件ID")
187
+ payload: T; // 广播事件的内容载荷
188
+ timestamp: number; // Unix 时间戳
189
+ correlationId?: string; // 触发此事件的原始命令 ID
190
+ }
191
+
192
+ export type EventSubscriber = (event: PlatformEvent) => void | Promise<void>;
193
+
194
+ // ── AI 接口 Actions 描述 ──
195
+ export interface ActionDescriptor {
196
+ readonly id: string; // Action 的唯一 ID
197
+ readonly commandType: string; // 对应命令类型
198
+ readonly description: string; // 提供给 AI 的自然语言功能描述 (中文)
199
+ readonly inputSchema: unknown; // Google GenAI 格式的 JSON 校验规范
200
+ readonly capabilityRequired: string; // 触发此工具必须具备的权限字符串
201
+ readonly isHighRisk?: boolean; // 是否属于高危操作 (设为 true 时,AI 触发执行需人工审批)
202
+ }
203
+
204
+ // ── 隔离数据库 API ──
205
+ export interface PluginDatabaseAPI {
206
+ ensureTable(tableName: string, schema: string): Promise<void>;
207
+ table(tableName: string): string; // 返回带插件命名空间的表名 plugin_{uuid}_{tableName}
208
+ dropAllTables(): Promise<void>;
209
+ migrate(targetVersion: number, upgradeFn: (db: unknown) => Promise<void> | void): Promise<void>;
210
+ }
211
+
212
+ // ── 插件日志接口 ──
213
+ export interface IPluginLogger {
214
+ debug(message: string, meta?: Record<string, unknown>): void;
215
+ info(message: string, meta?: Record<string, unknown>): void;
216
+ warn(message: string, meta?: Record<string, unknown>): void;
217
+ error(message: string, meta?: Record<string, unknown>): void;
218
+ }
219
+
220
+ // ── 配置管理服务 ──
221
+ export interface IConfigService {
222
+ get<T = unknown>(key: string): T;
223
+ getAll(): Record<string, unknown>;
224
+ set(key: string, value: unknown): Promise<void>;
225
+ onChange(callback: (key: string, newValue: unknown, oldValue: unknown) => void): () => void;
226
+ }
227
+
228
+ // ── 核心服务端上下文 (PluginContext) ──
229
+ export interface PluginContext {
230
+ services: {
231
+ commandBus: ICommandBusService;
232
+ eventBus: IEventBusService;
233
+ actionRegistry: IActionRegistryService;
234
+ capability: ICapabilityService;
235
+ processManager: IProcessService;
236
+ storage: IStorageService;
237
+ ai: IAIService;
238
+ };
239
+ pluginId: string; // 插件在数据库中的真实 UUID 标识
240
+ manifest: Manifest; // 插件原始清单配置
241
+ resolve<T>(token: Token<T>): Promise<T>; // 依赖注入:解析全局平台级服务
242
+ provide(tokenName: string, instance: unknown): Promise<void>;
243
+ db: PluginDatabaseAPI; // 带命名空间隔离的数据库助手
244
+ log: IPluginLogger; // 自带插件 ID 前缀的日志打印器
245
+ config: IConfigService; // 插件专属参数配置服务
246
+ contributions: {
247
+ list(): Array<{ slot: string; count: number; items: Array<{ id: string; label: string }> }>;
248
+ };
249
+ require(moduleName: string): unknown; // 导入系统共享的 npm 包组件
250
+ }
251
+
252
+ // ── 平台常用 Token 常量 (服务端) ──
253
+ export declare const ICommandBusServiceToken: Token<ICommandBusService>;
254
+ export declare const IEventBusServiceToken: Token<IEventBusService>;
255
+ export declare const IActionRegistryServiceToken: Token<IActionRegistryService>;
256
+ export declare const ICapabilityServiceToken: Token<ICapabilityService>;
257
+ export declare const IProcessServiceToken: Token<IProcessService>;
258
+ export declare const IStorageServiceToken: Token<IStorageService>;
259
+ export declare const IAIServiceToken: Token<IAIService>;
260
+ export declare const IDatabaseToken: Token<unknown>; // 解析出 raw better-sqlite3 Database 对象
261
+ export declare const IStageGuardServiceToken: Token<IStageGuardService>; // 教学环节流转门禁服务
262
+ ```
263
+
264
+ ---
265
+
266
+ ## 5. 后端核心服务详解 (Backend Services)
267
+
268
+ ### 5.1 CommandBus (命令总线)
269
+
270
+ 用于注册平台命令处理器或派发执行系统核心指令:
271
+
272
+ ```typescript
273
+ const commandBus = ctx.services.commandBus;
274
+
275
+ // 1. 注册指令处理器 (资源会自动随插件注销而解除绑定)
276
+ await commandBus.registerHandler('myplugin.do_work', {
277
+ async execute(command: PlatformCommand) {
278
+ const { arg1 } = command.payload as { arg1: string };
279
+ ctx.log.info(`执行指令,发送人:${command.actorId}`);
280
+ return { success: true, message: `已处理 ${arg1}` };
281
+ },
282
+ });
283
+
284
+ // 2. 调用命令总线执行其他指令 (必须是已注册的系统指令,如 whiteboard.draw)
285
+ const result = await commandBus.execute({
286
+ id: crypto.randomUUID(),
287
+ type: 'whiteboard.draw',
288
+ actorId: `plugin:${ctx.manifest.id}`,
289
+ payload: {
290
+ lessonId: 'current-lesson-id',
291
+ type: 'rectangle',
292
+ data: JSON.stringify({ x: 10, y: 10, width: 100, height: 100, fill: '#ff0000' }),
293
+ },
294
+ });
295
+ ```
296
+
297
+ ### 5.2 EventBus (事件总线)
298
+
299
+ 订阅核心应用或发布自定义插件事件:
300
+
301
+ ```typescript
302
+ const eventBus = ctx.services.eventBus;
303
+
304
+ // 1. 发布事件 (建议事件类型采用过去式命名,且指明 correlationId)
305
+ await eventBus.publish({
306
+ id: crypto.randomUUID(),
307
+ type: 'myplugin.work_completed',
308
+ source: `plugin.${ctx.manifest.id}`,
309
+ payload: { fileId: '123', status: 'done' },
310
+ timestamp: Date.now(),
311
+ correlationId: 'triggering-command-uuid',
312
+ });
313
+
314
+ // 2. 订阅特定事件
315
+ await eventBus.subscribe('lesson.created', (event) => {
316
+ ctx.log.info(`检测到新课堂课时创建: ${event.payload.lessonId}`);
317
+ });
318
+
319
+ // 3. 通配符订阅所有事件
320
+ await eventBus.subscribe('*', (event) => {
321
+ ctx.log.info(`总线捕获事件: ${event.type}`);
322
+ });
323
+ ```
324
+
325
+ ### 5.3 ActionRegistry (AI 接口注册)
326
+
327
+ 第三方插件注册 AI 助手可感知的 Action。**`inputSchema` 必须使用大写的 Google GenAI 类型描述字符**(`OBJECT`、`STRING`、`NUMBER`、`INTEGER`、`BOOLEAN`、`ARRAY`):
328
+
329
+ ```typescript
330
+ const actionRegistry = ctx.services.actionRegistry;
331
+
332
+ await actionRegistry.register({
333
+ id: 'ext-quiz-engine',
334
+ commandType: 'quiz.generate',
335
+ description: '根据教师提供的主题与数量,自动调用大模型生成随堂测验题并导入系统。',
336
+ capabilityRequired: 'lesson:write',
337
+ isHighRisk: false, // 设为 true 时,AI 自动执行此工具时会被系统拦截,生成待审批工单,需教师在界面手动批准后才真正执行
338
+ inputSchema: {
339
+ type: 'OBJECT',
340
+ properties: {
341
+ lessonId: { type: 'STRING', description: '当前课堂的 Lesson ID' },
342
+ topic: { type: 'STRING', description: '随堂测试的考核知识点主题' },
343
+ count: { type: 'INTEGER', description: '生成测试题的总题数,默认 3 题' },
344
+ },
345
+ required: ['lessonId', 'topic'],
346
+ },
347
+ });
348
+ ```
349
+
350
+ ### 5.4 Database (SQLite 数据库直连)
351
+
352
+ 插件的 `ctx.db` 提供了基础的建表能力,如果需要更灵活的 SQL 拼装或直接查询,需使用平台提供的依赖注入 `IDatabaseToken` 解析为 `better-sqlite3` 数据库对象:
353
+
354
+ ```typescript
355
+ import { IDatabaseToken } from '@openlearn/plugin-sdk';
356
+
357
+ const db = await ctx.resolve(IDatabaseToken); // 返回 raw better-sqlite3 数据库实例
358
+
359
+ // 使用 ctx.db.table 获得自动带独立 UUID 命名空间前缀的表名
360
+ const targetTable = ctx.db.table('student_scores');
361
+
362
+ // 准备并执行查询
363
+ const row = db.prepare(`SELECT * FROM ${targetTable} WHERE student_id = ?`).get('student-112');
364
+ ```
365
+
366
+ > [!NOTE]
367
+ > `better-sqlite3` 是同步 API,无需在 `prepare` / `run` / `get` / `all` 前面加 `await`。
368
+
369
+ ### 5.5 ProcessManager (后台长任务进程)
370
+
371
+ 插件如果需要异步处理高耗时业务(如大模型批量生成、文件转码等),必须通过平台进程管理器登记,从而避免主进程阻塞并方便监控:
372
+
373
+ ```typescript
374
+ const pm = ctx.services.processManager;
375
+
376
+ // 1. 注册进程处理器
377
+ await pm.registerHandler('my_heavy_task', async (processId, payload, state, log, updateState) => {
378
+ log('开始读取数据并转换格式...');
379
+ // 模拟耗时循环
380
+ for (let i = 0; i <= 10; i++) {
381
+ await new Promise((r) => setTimeout(r, 1000));
382
+ log(`任务执行中: ${i * 10}%`);
383
+ updateState({ progress: i * 10 }); // 更新进度状态,前端会自动监听该进度变化并更新进度条
384
+ }
385
+ log('任务处理全部完成!');
386
+ });
387
+
388
+ // 2. 派发生命周期进程
389
+ const pid = await pm.spawn('数据转换进程', 'my_heavy_task', { file: '/path/to/data' });
390
+ ```
391
+
392
+ ### 5.6 StageGuardService (教学环节流转门禁服务)
393
+
394
+ 第三方插件可通过 `IStageGuardServiceToken` 注册环节准入守卫。当学生自主点击切换环节时,系统将自动执行所有注册的门禁守卫:
395
+
396
+ ```typescript
397
+ import { IStageGuardServiceToken } from '@openlearn/plugin-sdk';
398
+ import type { StageGuard } from '@openlearn/plugin-sdk';
399
+
400
+ const stageGuardService = await ctx.resolve(IStageGuardServiceToken);
401
+
402
+ // 注册随堂测验及格门禁
403
+ const unregister = stageGuardService.registerGuard({
404
+ id: 'my-quiz-gate',
405
+ name: '随堂测验及格门禁',
406
+ priority: 50,
407
+ async canEnterStage({ studentId, lessonId, targetStageId }) {
408
+ // 业务检查逻辑
409
+ const passed = await checkStudentQuizPassed(studentId, lessonId);
410
+ if (!passed) {
411
+ return {
412
+ allowed: false,
413
+ reason: '需要先完成随堂测验并及格方可解锁此环节',
414
+ };
415
+ }
416
+ return { allowed: true };
417
+ },
418
+ });
419
+ ```
420
+
421
+ > **系统保护机制**:
422
+ >
423
+ > 1. **Fail-Close 门禁(D-3,默认)**:守卫执行超时(1500ms)或抛出异常时拒绝流转并告警,杜绝第三方插件故障放行教学环节;需显式 `guardPolicy: 'fail-open'` 才可逃生。
424
+ > 2. **AND 全部满足**:多个插件配置门禁时,必须全部满足方可放行;未满足原因自动以分号汇总展示。
425
+ > 3. **教师特权**:教师端广播跳转拥有最高控制权,可穿透门禁保证教学节奏。
426
+
427
+ ---
428
+
429
+ ## 6. 指令隔离与数据库隔离 (Namespace Sandboxing)
430
+
431
+ ### 6.1 指令隔离安全限制 (Command Namespace Isolation)
432
+
433
+ 内核在激活第三方插件时会强制执行命名空间检查,以防止第三方恶意注册、假冒或拦截敏感的系统/其他插件命令:
434
+
435
+ 1. **点号命名空间指令**:如果插件注册的命令名中含有点号(如 `quiz.create`),总线将直接在全局注册该全名。此类方法一般用于明确语义归属的协作系统。
436
+ 2. **裸字符指令**:如果注册的命令名不含点号(如 `execute_script`),微内核会自动在注册时为其添加 `[插件ID].` 作为防冲突前缀。
437
+ 3. **防篡改越权校验**:如果第三方插件试图注册以**非本插件专属 UUID** 开头的带点号指令,例如插件 A 企图调用 `registerHandler` 劫持插件 B 的指令,总线会拒绝注册并直接置插件为 `ERROR` 崩溃状态。
438
+
439
+ ### 6.2 数据库表名防冲突沙箱
440
+
441
+ 插件通过 `ctx.db.ensureTable` 建立本地表,表名会自动映射:
442
+
443
+ - `ctx.db.ensureTable('configs', ...)`
444
+ - 内部表名生成规则:`plugin_` + `插件数据库唯一 UUID` + `_` + `自选表名`。
445
+ - 在拼装 SQL 时,必须使用 `ctx.db.table('configs')` 转换获得最终映射后的真实表名:
446
+
447
+ ```typescript
448
+ // 错误示范❌:
449
+ db.prepare('SELECT * FROM configs').all();
450
+
451
+ // 正确示范✅:
452
+ db.prepare(`SELECT * FROM ${ctx.db.table('configs')}`).all();
453
+ ```
454
+
455
+ ---
456
+
457
+ ## 7. 前端核心服务与视图扩展 (Frontend SDK)
458
+
459
+ 当构建为 `full-stack` 或 `frontend-only` 插件时,打包产生的 `frontend.js` 会在前端被动态 import。前端插件入口对象的 default export 必须包含 `activate(ctx)`:
460
+
461
+ ```typescript
462
+ // 前端接口定义
463
+ export interface FrontendPluginContext {
464
+ services: {
465
+ frontendApi: IFrontendAPI; // 前端专用 HTTP 客户端封装
466
+ socketService: ISocketService; // Socket.IO 事件收发
467
+ uiService: IUIService; // Toast 气泡、Modal 模态框、文件下载组件
468
+ storageService: IStorageService; // 基于本地命名空间隔离的 LocalStorage 包装
469
+ };
470
+ pluginId: string;
471
+ manifest: FrontendPluginManifest;
472
+ ui: {
473
+ // 动态注册前端 UI 扩展槽位
474
+ registerExtensionPoint(slot: ExtensionSlot, config: ExtensionPointConfig): void;
475
+ unregisterExtensionPoint(slot: ExtensionSlot, id: string): void;
476
+ };
477
+ // 跨运行时通信:直接异步触发服务端的 Command Handler,支持自动补全命名空间前缀
478
+ invokeCommand<T = any>(type: string, payload?: any): Promise<T>;
479
+ }
480
+ ```
481
+
482
+ ### 7.1 可用 UI 视图扩展槽位 (Extension Slots)
483
+
484
+ 通过 `ctx.ui.registerExtensionPoint` 可将开发的 React 组件无缝渲染进系统的对应面板中:
485
+
486
+ | 槽位 ID (`ExtensionSlot`) | 渲染位置 / 用途 | 适用场景 |
487
+ | :------------------------- | :----------------------------- | :---------------------------------- |
488
+ | `classroom.tool` | 白板侧边课堂工具栏按钮 | 课堂互动工具 (如随堂答题器、投票器) |
489
+ | `teacher.tab` | 教师主控制台侧边导航 Tab 页签 | 教师核心管理功能面板 |
490
+ | `teacher.panel` | 教师独立全宽内容展示区 | 复杂报表看板、大屏控制中心 |
491
+ | `teacher.dashboard.widget` | 教师个人仪表盘的快捷卡片组件 | 数据简报、快捷入口卡片 |
492
+ | `student.view` | 学生主学习区域内容视图 | 学生端课程内容互动展示 |
493
+ | `student.fullscreen` | 学生端全屏沉浸展示区域 | 在线测验、电子问卷、考试场景 |
494
+ | `student.lesson.tool` | 包含在课程内部的学生操作小工具 | 课堂笔记区、词典、互动问答 |
495
+ | `global.setting` | 系统全局设置页的扩展配置栏 | 配置插件专用的 API Key 或系统设置 |
496
+
497
+ ### 7.2 前端槽位注册规范与参数
498
+
499
+ 注册插槽时,React 组件必须通过懒加载函数声明注入,并指明排序位置:
500
+
501
+ ```typescript
502
+ ctx.ui.registerExtensionPoint('teacher.tab', {
503
+ id: 'my-plugin-tab',
504
+ label: '我的后台大屏',
505
+ icon: 'BarChart3', // Lucide 图标名称
506
+ // 打包构建工具会处理此 dynamic import。也可以在外部使用 () => Promise.resolve({ default: Component }) 传入
507
+ component: () => import('./MyTabPanelComponent'),
508
+ position: 10, // 数值越小排在越前
509
+ pluginId: ctx.pluginId,
510
+ });
511
+ ```
512
+
513
+ ### 7.3 侧边栏 Tab 扩展 (`teacher.tab`) 的特殊处理
514
+
515
+ 在 OpenLearn 系统中,`teacher.tab` 槽位是一个双重渲染插槽。它既会在系统侧边导航栏(`NavigationSidebar`)中作为入口按钮进行渲染,也会在主体展示区(`App.tsx`)中作为面板内容页进行渲染。
516
+
517
+ 为了帮助组件识别当前所在的渲染场景,系统会通过 React props(或 DOM 渲染函数的上下文参数)向插件注入 `slotProps.renderType`:
518
+
519
+ - `renderType === 'button'`:处于侧边栏导航按钮渲染状态,此时应展示轻量级的 Tab 页签按钮,并绑定点击跳转。
520
+ - `renderType === 'panel'`:处于主体内容大屏面板渲染状态,此时应展示完整的后台管理、图表统计、打分筛选等功能页面。
521
+
522
+ 此外,前端上下文 `frontendCtx` 提供了 `navigation` 对象用来读取和切换系统当前选中的 Tab:
523
+
524
+ - `frontendCtx.navigation.getTeacherTab()`:获取当前活动的 Tab ID。
525
+ - `frontendCtx.navigation.setTeacherTab(tabId)`:跳转到指定的 Tab ID,激活对应的面板页。
526
+ - `frontendCtx.navigation.subscribeTeacherTab((activeTab) => void)`:订阅激活 Tab 的状态变更,常用于高亮选中态的实时更新。
527
+
528
+ #### 纯 JavaScript DOM 插件的 `teacher.tab` 完整适配示例:
529
+
530
+ ```javascript
531
+ export default {
532
+ activate: async (frontendCtx) => {
533
+ frontendCtx.registerPanel({
534
+ slot: 'teacher.tab',
535
+ id: 'homework-tab-view',
536
+ title: '作业大屏',
537
+ render: async (domNode, context) => {
538
+ const { renderType } = context;
539
+
540
+ if (renderType === 'button') {
541
+ // 1. 渲染侧边栏导航入口
542
+ domNode.innerHTML = `
543
+ <div id="btn-homework-tab" style="padding: 12px; border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 10px; transition: background-color 0.2s;">
544
+ <span style="font-size: 16px;">📝</span>
545
+ <span style="font-size: 14px; font-weight: 500; color: #475569;">作业中心</span>
546
+ </div>
547
+ `;
548
+
549
+ const btn = domNode.querySelector('#btn-homework-tab');
550
+
551
+ // 2. 点击时触发全局 Tab 导航切换
552
+ btn.addEventListener('click', () => {
553
+ frontendCtx.navigation.setTeacherTab('homework-tab-view');
554
+ });
555
+
556
+ // 3. 监听 Tab 状态更新按钮选中高亮态
557
+ frontendCtx.navigation.subscribeTeacherTab((activeTab) => {
558
+ if (activeTab === 'homework-tab-view') {
559
+ btn.style.backgroundColor = '#e0e7ff';
560
+ btn.querySelector('span:last-child').style.color = '#4f46e5';
561
+ } else {
562
+ btn.style.backgroundColor = 'transparent';
563
+ btn.querySelector('span:last-child').style.color = '#475569';
564
+ }
565
+ });
566
+ } else {
567
+ // 4. 渲染完整的大屏内容管理及打分面板
568
+ domNode.innerHTML = `
569
+ <div style="padding: 24px; font-family: sans-serif; display: flex; flex-direction: column; gap: 20px;">
570
+ <h2>📝 作业管理与打分中心</h2>
571
+ <!-- 放置作业列表筛选展示、打分管理、统计图表等具体业务 UI -->
572
+ </div>
573
+ `;
574
+ }
575
+ },
576
+ });
577
+ },
578
+ };
579
+ ```
580
+
581
+ ---
582
+
583
+ ## 8. 完整的全栈插件开发范例
584
+
585
+ 下面给出一个“全栈随堂测验”插件的完整实现源码,展示了如何建表、提供 AI 工具、发布总线指令与事件,以及前端如何拉取数据渲染页面并调用后端命令。
586
+
587
+ ### 8.1 后端服务模块 (`src/index.ts`)
588
+
589
+ ```typescript
590
+ import type { PluginContext } from '@openlearn/plugin-sdk';
591
+ import { IDatabaseToken } from '@openlearn/plugin-sdk';
592
+
593
+ export default {
594
+ manifest: {
595
+ id: 'ext-classroom-quiz',
596
+ name: '全栈随堂小测验',
597
+ version: '1.2.0',
598
+ description: '允许教师一键下发测验题目,学生实时作答并进行简单的统计排名。',
599
+ author: 'developer-team',
600
+ requires: [
601
+ '@openlearn/core:ICommandBusService@^1.0.0',
602
+ '@openlearn/core:IActionRegistryService@^1.0.0',
603
+ '@openlearn/core:IEventBusService@^1.0.0',
604
+ '@openlearn/core:IDatabase@^1.0.0',
605
+ ],
606
+ capabilitiesProposed: ['lesson:write', 'lesson:read'],
607
+ classroomTools: [
608
+ {
609
+ id: 'classroom-quiz-tool',
610
+ name: '随堂测验',
611
+ icon: 'ClipboardList',
612
+ commandType: 'ext-classroom-quiz.open_ui',
613
+ },
614
+ ],
615
+ },
616
+
617
+ async activate(ctx: PluginContext) {
618
+ const commandBus = ctx.services.commandBus;
619
+ const actionRegistry = ctx.services.actionRegistry;
620
+ const eventBus = ctx.services.eventBus;
621
+ const db = await ctx.resolve(IDatabaseToken);
622
+
623
+ // 1. 初始化插件自建数据表
624
+ await ctx.db.ensureTable(
625
+ 'quiz_questions',
626
+ `
627
+ id TEXT PRIMARY KEY,
628
+ lesson_id TEXT NOT NULL,
629
+ question TEXT NOT NULL,
630
+ options TEXT NOT NULL, -- JSON 数组:["选项A","选项B",...]
631
+ correct_answer TEXT NOT NULL,
632
+ is_active INTEGER DEFAULT 1,
633
+ created_at INTEGER NOT NULL
634
+ `,
635
+ );
636
+
637
+ await ctx.db.ensureTable(
638
+ 'quiz_answers',
639
+ `
640
+ id TEXT PRIMARY KEY,
641
+ question_id TEXT NOT NULL,
642
+ student_id TEXT NOT NULL,
643
+ answer TEXT NOT NULL,
644
+ is_correct INTEGER NOT NULL,
645
+ submitted_at INTEGER NOT NULL,
646
+ UNIQUE(question_id, student_id)
647
+ `,
648
+ );
649
+
650
+ const qTable = ctx.db.table('quiz_questions');
651
+ const aTable = ctx.db.table('quiz_answers');
652
+
653
+ // 2. 注册 AI Action:教师可以直接让 AI 助手创建随堂测验
654
+ await actionRegistry.register({
655
+ id: 'ext-quiz-create-action',
656
+ commandType: 'ext-classroom-quiz.create_quiz',
657
+ description: '在指定课堂课时下创建一道测试题,由选项和正确答案组成。',
658
+ capabilityRequired: 'lesson:write',
659
+ inputSchema: {
660
+ type: 'OBJECT',
661
+ properties: {
662
+ lessonId: { type: 'STRING', description: '课堂课时 ID' },
663
+ question: { type: 'STRING', description: '题目描述' },
664
+ options: { type: 'ARRAY', items: { type: 'STRING' }, description: '供选择的选项列表' },
665
+ correctAnswer: { type: 'STRING', description: '正确答案 (如 "选项A")' },
666
+ },
667
+ required: ['lessonId', 'question', 'options', 'correctAnswer'],
668
+ },
669
+ });
670
+
671
+ // 3. 注册命令:创建测验
672
+ await commandBus.registerHandler('ext-classroom-quiz.create_quiz', {
673
+ async execute(command) {
674
+ const { lessonId, question, options, correctAnswer } = command.payload as any;
675
+ const quizId = crypto.randomUUID();
676
+ const optionsStr = JSON.stringify(options);
677
+
678
+ db.prepare(
679
+ `
680
+ INSERT INTO ${qTable} (id, lesson_id, question, options, correct_answer, created_at)
681
+ VALUES (?, ?, ?, ?, ?, ?)
682
+ `,
683
+ ).run(quizId, lessonId, question, optionsStr, correctAnswer, Date.now());
684
+
685
+ // 发布测验已发布事件
686
+ await eventBus.publish({
687
+ id: crypto.randomUUID(),
688
+ type: 'ext-classroom-quiz.quiz_created',
689
+ source: `plugin.${ctx.manifest.id}`,
690
+ payload: { quizId, lessonId, question, options },
691
+ timestamp: Date.now(),
692
+ correlationId: command.id,
693
+ });
694
+
695
+ return { success: true, quizId, message: '测验题目已成功下发!' };
696
+ },
697
+ });
698
+
699
+ // 4. 注册命令:学生端提交答案
700
+ await commandBus.registerHandler('ext-classroom-quiz.submit_answer', {
701
+ async execute(command) {
702
+ const { questionId, answer } = command.payload as any;
703
+ const studentId = command.actorId;
704
+
705
+ // 获取正确答案校验
706
+ const quiz = db.prepare(`SELECT correct_answer FROM ${qTable} WHERE id = ?`).get(questionId) as any;
707
+ if (!quiz) {
708
+ throw new Error('未找到该测试题!');
709
+ }
710
+
711
+ const isCorrect = quiz.correct_answer === answer ? 1 : 0;
712
+ const answerId = crypto.randomUUID();
713
+
714
+ db.prepare(
715
+ `
716
+ INSERT OR REPLACE INTO ${aTable} (id, question_id, student_id, answer, is_correct, submitted_at)
717
+ VALUES (?, ?, ?, ?, ?, ?)
718
+ `,
719
+ ).run(answerId, questionId, studentId, answer, isCorrect, Date.now());
720
+
721
+ // 发送事件广播,前端通过 WebSocket 收到实时更新数据
722
+ await eventBus.publish({
723
+ id: crypto.randomUUID(),
724
+ type: 'ext-classroom-quiz.answer_submitted',
725
+ source: `plugin.${ctx.manifest.id}`,
726
+ payload: { questionId, studentId, isCorrect },
727
+ timestamp: Date.now(),
728
+ correlationId: command.id,
729
+ });
730
+
731
+ return { success: true, isCorrect: isCorrect === 1 };
732
+ },
733
+ });
734
+
735
+ // 5. 注册命令:查询答题统计数据 (API 供前端面板直连)
736
+ await commandBus.registerHandler('ext-classroom-quiz.get_stats', {
737
+ async execute(command) {
738
+ const { questionId } = command.payload as any;
739
+ const stats = db
740
+ .prepare(
741
+ `
742
+ SELECT answer, COUNT(*) as count
743
+ FROM ${aTable}
744
+ WHERE question_id = ?
745
+ GROUP BY answer
746
+ `,
747
+ )
748
+ .all(questionId);
749
+
750
+ return { success: true, stats };
751
+ },
752
+ });
753
+
754
+ // 6. 注册白板菜单命令,唤醒 UI
755
+ await commandBus.registerHandler('ext-classroom-quiz.open_ui', {
756
+ async execute(command) {
757
+ return { opened: true, pluginId: ctx.pluginId };
758
+ },
759
+ });
760
+
761
+ ctx.log.info('全栈小测验服务端逻辑加载就绪');
762
+ },
763
+
764
+ async deactivate() {
765
+ console.log('全栈小测验服务端停用清理完毕');
766
+ },
767
+ };
768
+ ```
769
+
770
+ ### 8.2 前端视图模块 (`src/frontend.tsx`)
771
+
772
+ ```tsx
773
+ /**
774
+ * 全栈随堂测验前端 UI 界面组件
775
+ *
776
+ * 前端模块必须 default export 一个 { manifest, activate(ctx) } 对象(不是裸组件)。
777
+ * UI 组件在 activate 中通过 ctx.ui.registerExtensionPoint 挂到扩展槽位。
778
+ * React, Recharts, LucideReact 不需要打包在 ZIP 内,由 Host 平台运行时全局注入。
779
+ */
780
+ import React, { useState, useEffect } from 'react';
781
+ import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
782
+ import { Play, Award, CheckCircle, XCircle } from 'lucide-react';
783
+
784
+ function ClassroomQuizWidget({ context, ctx }: { context?: any; ctx: any }) {
785
+ // context 会由宿主渲染插件时传入,例如包含当前 { lessonId, userRole, userId }
786
+ const lessonId = context?.lessonId || 'default-lesson-id';
787
+ const isTeacher = context?.userRole === 'teacher';
788
+
789
+ const [activeQuiz, setActiveQuiz] = useState<any>(null);
790
+ const [selectedAnswer, setSelectedAnswer] = useState<string>('');
791
+ const [submitted, setSubmitted] = useState<boolean>(false);
792
+ const [resultMsg, setResultMsg] = useState<string>('');
793
+ const [chartData, setChartData] = useState<any[]>([]);
794
+
795
+ // 跨运行时通信:由 activate(ctx) 注入的 FrontendPluginContext 提供,
796
+ // 不存在 window.HostSharedDeps.socketService / window.FrontendHost。
797
+ const { uiService, socketService } = ctx.services;
798
+
799
+ useEffect(() => {
800
+ // 监听实时 WebSocket 推送过来的新题目创建事件
801
+ const handleNewQuiz = (event: any) => {
802
+ if (event.type === 'ext-classroom-quiz.quiz_created' && event.payload.lessonId === lessonId) {
803
+ setActiveQuiz(event.payload);
804
+ setSubmitted(false);
805
+ setSelectedAnswer('');
806
+ setResultMsg('');
807
+ setChartData([]);
808
+ }
809
+ };
810
+
811
+ // 监听答题提交更新图表事件
812
+ const handleAnswerCast = async (event: any) => {
813
+ if (
814
+ event.type === 'ext-classroom-quiz.answer_submitted' &&
815
+ activeQuiz &&
816
+ event.payload.questionId === activeQuiz.quizId
817
+ ) {
818
+ await refreshStats();
819
+ }
820
+ };
821
+
822
+ socketService.on('platform_event', handleNewQuiz);
823
+ socketService.on('platform_event', handleAnswerCast);
824
+
825
+ return () => {
826
+ socketService.off('platform_event', handleNewQuiz);
827
+ socketService.off('platform_event', handleAnswerCast);
828
+ };
829
+ }, [lessonId, activeQuiz]);
830
+
831
+ const refreshStats = async () => {
832
+ if (!activeQuiz) return;
833
+ try {
834
+ // 通过 invokeCommand 执行服务端对应的 Handler
835
+ const res = await ctx.invokeCommand('ext-classroom-quiz.get_stats', {
836
+ questionId: activeQuiz.quizId,
837
+ });
838
+ if (res && res.stats) {
839
+ const formatted = res.stats.map((s: any) => ({ name: s.answer, 票数: s.count }));
840
+ setChartData(formatted);
841
+ }
842
+ } catch (e: any) {
843
+ console.error('获取统计数据失败', e);
844
+ }
845
+ };
846
+
847
+ const handleVote = async () => {
848
+ if (!selectedAnswer) {
849
+ uiService.showToast('警告', '请先选择一个选项!', 'warning');
850
+ return;
851
+ }
852
+ try {
853
+ const res = await ctx.invokeCommand('ext-classroom-quiz.submit_answer', {
854
+ questionId: activeQuiz.quizId,
855
+ answer: selectedAnswer,
856
+ });
857
+ setSubmitted(true);
858
+ if (res.isCorrect) {
859
+ setResultMsg('回答正确!🎉');
860
+ uiService.showToast('恭喜', '回答正确!', 'success');
861
+ } else {
862
+ setResultMsg('回答错误,继续加油!❌');
863
+ uiService.showToast('很遗憾', '回答错误。', 'info');
864
+ }
865
+ } catch (e: any) {
866
+ uiService.showToast('错误', e.message || '答题提交失败', 'warning');
867
+ }
868
+ };
869
+
870
+ return (
871
+ <div style={{ padding: 20, background: '#fff', borderRadius: 12, border: '1px solid #e2e8f0' }}>
872
+ <h3 style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '0 0 16px 0', color: '#1e293b' }}>
873
+ <Award style={{ color: '#6366f1' }} />
874
+ 课堂互动小测试
875
+ </h3>
876
+
877
+ {!activeQuiz ? (
878
+ <div style={{ textAlign: 'center', padding: '40px 0', color: '#64748b' }}>
879
+ <p>⏳ 等待教师下发新的测试题目...</p>
880
+ </div>
881
+ ) : (
882
+ <div>
883
+ <div style={{ fontWeight: 'bold', fontSize: 16, marginBottom: 12 }}>Q: {activeQuiz.question}</div>
884
+
885
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
886
+ {activeQuiz.options.map((opt: string) => (
887
+ <label
888
+ key={opt}
889
+ style={{
890
+ padding: 12,
891
+ border: '1px solid #e2e8f0',
892
+ borderRadius: 8,
893
+ cursor: submitted ? 'not-allowed' : 'pointer',
894
+ display: 'flex',
895
+ alignItems: 'center',
896
+ gap: 10,
897
+ background: selectedAnswer === opt ? '#f0fdf4' : '#fff',
898
+ }}
899
+ >
900
+ <input
901
+ type="radio"
902
+ name="quiz"
903
+ disabled={submitted}
904
+ value={opt}
905
+ checked={selectedAnswer === opt}
906
+ onChange={(e) => setSelectedAnswer(e.target.value)}
907
+ />
908
+ {opt}
909
+ </label>
910
+ ))}
911
+ </div>
912
+
913
+ {!submitted ? (
914
+ <button
915
+ onClick={handleVote}
916
+ style={{
917
+ width: '100%',
918
+ padding: '12px',
919
+ background: '#4f46e5',
920
+ color: '#fff',
921
+ border: 'none',
922
+ borderRadius: 8,
923
+ fontWeight: 'bold',
924
+ cursor: 'pointer',
925
+ }}
926
+ >
927
+ 提交选择
928
+ </button>
929
+ ) : (
930
+ <div style={{ marginTop: 12, padding: 12, borderRadius: 8, background: '#f8fafc' }}>
931
+ <div style={{ fontWeight: 'bold', color: '#0f172a', marginBottom: 8 }}>{resultMsg}</div>
932
+
933
+ {chartData.length > 0 && (
934
+ <div style={{ height: 160, marginTop: 10 }}>
935
+ <ResponsiveContainer width="100%" height="100%">
936
+ <BarChart data={chartData}>
937
+ <XAxis dataKey="name" />
938
+ <YAxis allowDecimals={false} />
939
+ <Tooltip />
940
+ <Bar dataKey="票数" fill="#818cf8" radius={[4, 4, 0, 0]} />
941
+ </BarChart>
942
+ </ResponsiveContainer>
943
+ </div>
944
+ )}
945
+ </div>
946
+ )}
947
+ </div>
948
+ )}
949
+ </div>
950
+ );
951
+ }
952
+
953
+ /**
954
+ * 前端插件入口:default export 必须是 { manifest, activate(ctx) } 对象。
955
+ * activate 里把组件注册到宿主扩展槽位;宿主会注入 pluginId 与统一 props。
956
+ */
957
+ export default {
958
+ manifest: {
959
+ id: 'ext-classroom-quiz',
960
+ name: '课堂互动小测验',
961
+ version: '1.0.0',
962
+ main: 'frontend.js',
963
+ engines: { openlearn: '>=0.7.0' },
964
+ },
965
+ async activate(ctx: any) {
966
+ ctx.ui.registerExtensionPoint('teacher.tab', {
967
+ id: 'ext-classroom-quiz.tab',
968
+ label: '随堂测验',
969
+ pluginId: ctx.pluginId,
970
+ position: 20,
971
+ component: (props: any) => <ClassroomQuizWidget {...props} ctx={ctx} />,
972
+ });
973
+ },
974
+ };
975
+ ```
976
+
977
+ ---
978
+
979
+ ## 9. 单元测试与调试指南
980
+
981
+ ### 9.1 使用 `@openlearn/plugin-test-kit` 编写单元测试
982
+
983
+ 测试套件基于 `vitest` 与 `jsdom`。SDK 提供 mock 机制来隔离微内核总线,开发者无需启动主应用即可验证插件逻辑:
984
+
985
+ ```typescript
986
+ // __tests__/classroom-quiz.test.ts
987
+ import { describe, it, expect } from 'vitest';
988
+ import { createMockContext, MockCommandBus } from '@openlearn/plugin-test-kit';
989
+ import myPlugin from '../src/index';
990
+
991
+ describe('随堂小测验插件单元测试', () => {
992
+ it('能够成功在激活时注册自定义命令', async () => {
993
+ // 1. 创建仿真的插件运行上下文
994
+ const ctx = createMockContext({
995
+ pluginId: 'test-quiz-uuid',
996
+ manifest: myPlugin.manifest,
997
+ });
998
+
999
+ // 2. 触发插件的 activate 函数
1000
+ await myPlugin.activate(ctx);
1001
+
1002
+ // 3. 校验对应命令处理器是否已注册至 Mock 总线上
1003
+ const handlers = Array.from((ctx.services.commandBus as MockCommandBus).handlers.keys());
1004
+ expect(handlers).toContain('ext-classroom-quiz.create_quiz');
1005
+ expect(handlers).toContain('ext-classroom-quiz.submit_answer');
1006
+ });
1007
+ });
1008
+ ```
1009
+
1010
+ ### 9.2 SQL 实时审计
1011
+
1012
+ 插件执行过程中广播的全部 Event 事件或自建表写入的数据库记录,都可以通过 SQL 命令行直接审计检查:
1013
+
1014
+ ```sql
1015
+ -- 查询指定插件的全部事件上报日志
1016
+ SELECT type, source, payload, timestamp
1017
+ FROM events
1018
+ WHERE source = 'plugin.ext-classroom-quiz'
1019
+ ORDER BY timestamp DESC;
1020
+
1021
+ -- 查看插件自动带命名空间的私有表格数据
1022
+ SELECT * FROM "plugin_test-quiz-uuid_quiz_questions";
1023
+ ```