From ef013ce39b4440483699d397a41ac3b503e58823 Mon Sep 17 00:00:00 2001 From: MEMZ_Edge <871412436@qq.com> Date: Sun, 27 Sep 2026 23:51:53 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=B0=B8?= =?UTF-8?q?=E4=B9=85=E6=8D=A2=E8=AF=BE=E3=80=81=E8=BD=AF=E4=BB=B6=E6=B8=85?= =?UTF-8?q?=E5=8D=95=E4=B8=8A=E6=8A=A5=E4=B8=8E=E6=8F=92=E4=BB=B6=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- THIRD_PARTY_NOTICES.md | 1 + .../java/com/remoteci/mobile/data/Protocol.kt | 1 + .../com/remoteci/mobile/ui/HomeScreens.kt | 10 + docs/protocol.md | 27 +- plugin/RemoteCI.Plugin/Plugin.cs | 6 + .../Services/ClassIslandHostControlService.cs | 11 + .../RemoteCI.Plugin/Services/CloudClient.cs | 10 +- .../Services/CommandHandler.cs | 39 +- .../Services/ManagementJoinService.cs | 238 ++ .../Services/PluginManagementService.cs | 286 +++ .../Services/ProfileManagementService.cs | 182 ++ .../Services/RemoteCiService.cs | 19 +- .../Services/RemoteNotificationProvider.cs | 36 +- .../Services/ScheduleChangeExecutor.cs | 9 +- .../Services/SoftwareInventoryService.cs | 397 ++++ .../Settings/PluginSettings.cs | 8 + .../SettingsPages/RemoteCiSettingsPage.cs | 22 +- .../CommandPermissionsTests.cs | 27 + .../RemoteNotificationProviderTests.cs | 55 + .../ScheduleChangeExecutorTests.cs | 25 +- server/RemoteCI.Server/Data/AppDbContext.cs | 16 + server/RemoteCI.Server/Data/Entities.cs | 61 + ...130_AddPluginSoftwareInventory.Designer.cs | 786 +++++++ ...260926144130_AddPluginSoftwareInventory.cs | 40 + ...926181318_AddLoginPageSettings.Designer.cs | 802 +++++++ .../20260926181318_AddLoginPageSettings.cs | 72 + ...927104500_AddLoginCardPosition.Designer.cs | 805 +++++++ .../20260927104500_AddLoginCardPosition.cs | 29 + .../20260927110504_AddApiKeys.Designer.cs | 862 +++++++ .../Migrations/20260927110504_AddApiKeys.cs | 68 + ...iringCodesAndUnassignedDevices.Designer.cs | 875 +++++++ ...ddClassPairingCodesAndUnassignedDevices.cs | 63 + .../Migrations/AppDbContextModelSnapshot.cs | 122 +- server/RemoteCI.Server/Pages/Account.cshtml | 107 + .../RemoteCI.Server/Pages/Account.cshtml.cs | 144 +- .../RemoteCI.Server/Pages/BatchControl.cshtml | 111 +- .../Pages/BatchControl.cshtml.cs | 667 +++++- server/RemoteCI.Server/Pages/Classes.cshtml | 628 ++++-- .../RemoteCI.Server/Pages/Classes.cshtml.cs | 443 +++- server/RemoteCI.Server/Pages/Control.cshtml | 202 +- .../RemoteCI.Server/Pages/Control.cshtml.cs | 46 +- server/RemoteCI.Server/Pages/Index.cshtml | 38 +- .../Pages/LoginSettings.cshtml | 82 + .../Pages/LoginSettings.cshtml.cs | 98 + server/RemoteCI.Server/Pages/Schedule.cshtml | 1 + .../RemoteCI.Server/Pages/Schedule.cshtml.cs | 2 + .../Pages/Shared/_BatchControlActions.cshtml | 639 ++++++ .../Pages/Shared/_ClassGroupTreeNode.cshtml | 62 + .../Pages/Shared/_Layout.cshtml | 117 +- .../RemoteCI.Server/Pages/SystemConfig.cshtml | 3 +- server/RemoteCI.Server/Pages/Users.cshtml | 253 ++- server/RemoteCI.Server/Pages/Users.cshtml.cs | 206 +- server/RemoteCI.Server/Pages/Visitor.cshtml | 21 +- .../RemoteCI.Server/Pages/Visitor.cshtml.cs | 57 +- server/RemoteCI.Server/Program.cs | 49 +- server/RemoteCI.Server/RemoteCI.Server.csproj | 1 + .../Services/ClassBroadcastService.cs | 6 + .../Services/ClassExcelService.cs | 1063 +++++++++ .../Services/ClassroomService.cs | 78 +- .../Services/ConfigurationArchiveService.cs | 45 +- .../Services/DeviceInventoryService.cs | 198 ++ .../Services/IdentityCoordinator.cs | 285 ++- .../Services/LoginPageSettings.cs | 65 + .../Services/MemberExcelService.cs | 779 +++++++ .../RemoteCI.Server/Services/PeerRegistry.cs | 194 ++ .../Services/VisitorAccessSettings.cs | 1 + server/RemoteCI.Server/WebSocketHub.cs | 69 +- server/RemoteCI.Server/wwwroot/app.css | 582 ++++- server/RemoteCI.Server/wwwroot/app.js | 123 +- .../RemoteCI.Server/wwwroot/batch-control.js | 280 +++ .../RemoteCI.Server/wwwroot/login-settings.js | 52 + server/RemoteCI.Server/wwwroot/premium.css | 2008 +++++++++++++++++ .../RemoteCI.Server/wwwroot/voice-message.js | 2 +- .../tests/RemoteCI.Server.Tests/ApiTests.cs | 353 ++- .../RemoteCI.Server.Tests/ClassExcelTests.cs | 176 ++ .../ClassManagementTests.cs | 128 ++ .../LoginSettingsTests.cs | 102 + .../RemoteCI.Server.Tests/MemberExcelTests.cs | 248 ++ .../RemoteCI.Server.Tests.csproj | 1 + .../RoleAndBackupTests.cs | 49 + .../VisitorAccessTests.cs | 38 +- .../WebSocketRelayTests.cs | 113 +- shared/RemoteCI.Shared/Models/ApiModels.cs | 13 + shared/RemoteCI.Shared/Models/AuthModels.cs | 62 + shared/RemoteCI.Shared/Models/ClassModels.cs | 4 + shared/RemoteCI.Shared/Models/Command.cs | 30 + shared/RemoteCI.Shared/Models/Envelope.cs | 3 + .../Models/ManagementOperations.cs | 167 ++ .../RemoteCI.Shared/Models/ScheduleModels.cs | 28 + .../Models/SoftwareInventory.cs | 106 + shared/RemoteCI.Shared/Protocol.cs | 76 +- shared/RemoteCI.Shared/RemoteCI.Shared.csproj | 2 + .../java/com/remoteci/watch/data/Protocol.kt | 1 + .../java/com/remoteci/watch/ui/RemoteCiApp.kt | 4 + .../com/remoteci/watch/ui/WatchScreens.kt | 3 + 95 files changed, 16740 insertions(+), 784 deletions(-) create mode 100644 plugin/RemoteCI.Plugin/Services/ManagementJoinService.cs create mode 100644 plugin/RemoteCI.Plugin/Services/PluginManagementService.cs create mode 100644 plugin/RemoteCI.Plugin/Services/ProfileManagementService.cs create mode 100644 plugin/RemoteCI.Plugin/Services/SoftwareInventoryService.cs create mode 100644 server/RemoteCI.Server/Data/Migrations/20260926144130_AddPluginSoftwareInventory.Designer.cs create mode 100644 server/RemoteCI.Server/Data/Migrations/20260926144130_AddPluginSoftwareInventory.cs create mode 100644 server/RemoteCI.Server/Data/Migrations/20260926181318_AddLoginPageSettings.Designer.cs create mode 100644 server/RemoteCI.Server/Data/Migrations/20260926181318_AddLoginPageSettings.cs create mode 100644 server/RemoteCI.Server/Data/Migrations/20260927104500_AddLoginCardPosition.Designer.cs create mode 100644 server/RemoteCI.Server/Data/Migrations/20260927104500_AddLoginCardPosition.cs create mode 100644 server/RemoteCI.Server/Data/Migrations/20260927110504_AddApiKeys.Designer.cs create mode 100644 server/RemoteCI.Server/Data/Migrations/20260927110504_AddApiKeys.cs create mode 100644 server/RemoteCI.Server/Data/Migrations/20260927125655_AddClassPairingCodesAndUnassignedDevices.Designer.cs create mode 100644 server/RemoteCI.Server/Data/Migrations/20260927125655_AddClassPairingCodesAndUnassignedDevices.cs create mode 100644 server/RemoteCI.Server/Pages/LoginSettings.cshtml create mode 100644 server/RemoteCI.Server/Pages/LoginSettings.cshtml.cs create mode 100644 server/RemoteCI.Server/Pages/Shared/_BatchControlActions.cshtml create mode 100644 server/RemoteCI.Server/Pages/Shared/_ClassGroupTreeNode.cshtml create mode 100644 server/RemoteCI.Server/Services/ClassExcelService.cs create mode 100644 server/RemoteCI.Server/Services/DeviceInventoryService.cs create mode 100644 server/RemoteCI.Server/Services/LoginPageSettings.cs create mode 100644 server/RemoteCI.Server/Services/MemberExcelService.cs create mode 100644 server/RemoteCI.Server/wwwroot/batch-control.js create mode 100644 server/RemoteCI.Server/wwwroot/login-settings.js create mode 100644 server/RemoteCI.Server/wwwroot/premium.css create mode 100644 server/tests/RemoteCI.Server.Tests/ClassExcelTests.cs create mode 100644 server/tests/RemoteCI.Server.Tests/LoginSettingsTests.cs create mode 100644 server/tests/RemoteCI.Server.Tests/MemberExcelTests.cs create mode 100644 shared/RemoteCI.Shared/Models/ManagementOperations.cs create mode 100644 shared/RemoteCI.Shared/Models/SoftwareInventory.cs diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 8ea91c3..376ff75 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -17,6 +17,7 @@ | kotlinx.serialization | 手表端 JSON 序列化 | Apache-2.0 | © JetBrains | | kotlinx.coroutines | 手表端协程 | Apache-2.0 | © JetBrains | | [Bootstrap Icons](https://icons.getbootstrap.com/) | WebUI 导航与操作图标 | MIT | Copyright (c) 2019-2024 The Bootstrap Authors;许可证原文位于 `server/RemoteCI.Server/wwwroot/vendor/bootstrap-icons/LICENSE` | +| [ClosedXML](https://github.com/ClosedXML/ClosedXML) | 服务端成员 Excel 导入、导出与覆盖导入 | MIT | Copyright (c) ClosedXML contributors;传递依赖包括 DocumentFormat.OpenXml(MIT)与 SixLabors.Fonts(Apache-2.0) | ## 合规约定 diff --git a/android/app/src/main/java/com/remoteci/mobile/data/Protocol.kt b/android/app/src/main/java/com/remoteci/mobile/data/Protocol.kt index d235c0c..0d60d44 100644 --- a/android/app/src/main/java/com/remoteci/mobile/data/Protocol.kt +++ b/android/app/src/main/java/com/remoteci/mobile/data/Protocol.kt @@ -244,6 +244,7 @@ data class ScheduleChangeRequest( @SerialName("targetIndex") val targetIndex: Int? = null, @SerialName("replacementSubjectId") val replacementSubjectId: String? = null, @SerialName("expectedRevision") val expectedRevision: String, + val permanent: Boolean = false, ) @Serializable diff --git a/android/app/src/main/java/com/remoteci/mobile/ui/HomeScreens.kt b/android/app/src/main/java/com/remoteci/mobile/ui/HomeScreens.kt index 0aec3b1..bd1980e 100644 --- a/android/app/src/main/java/com/remoteci/mobile/ui/HomeScreens.kt +++ b/android/app/src/main/java/com/remoteci/mobile/ui/HomeScreens.kt @@ -53,6 +53,7 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Tab import androidx.compose.material3.Text @@ -568,6 +569,7 @@ fun SwapScreen(date: String?, index: Int?, onBack: () -> Unit, snackbar: Snackba var sourceIndex by remember { mutableIntStateOf(index ?: courses.firstOrNull()?.index ?: 0) } var targetIndex by remember { mutableIntStateOf(courses.firstOrNull { it.index != sourceIndex }?.index ?: sourceIndex) } var exchange by remember { mutableStateOf(true) } + var permanent by remember { mutableStateOf(false) } var replacementId by remember { mutableStateOf(bundle?.subjects?.firstOrNull()?.id.orEmpty()) } val source = courses.firstOrNull { it.index == sourceIndex } val target = courses.firstOrNull { it.index == targetIndex } @@ -627,6 +629,13 @@ fun SwapScreen(date: String?, index: Int?, onBack: () -> Unit, snackbar: Snackba } } TextButton(onClick = { exchange = !exchange }) { Text(if (exchange) "改为替换课程" else "改为交换课程") } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("永久换课", style = MaterialTheme.typography.bodyLarge) + Text("写入源课表,之后每周继续生效", style = MaterialTheme.typography.bodySmall) + } + Switch(checked = permanent, onCheckedChange = { permanent = it }) + } DropdownMenu(expanded = showSource, onDismissRequest = { showSource = false }) { courses.forEach { DropdownMenuItem({ Text("${it.label} ${it.subject}") }, { @@ -674,6 +683,7 @@ fun SwapScreen(date: String?, index: Int?, onBack: () -> Unit, snackbar: Snackba targetIndex = if (exchange) target?.index else null, replacementSubjectId = if (exchange) null else replacementId, expectedRevision = currentDay.revision, + permanent = permanent, ), ) }, diff --git a/docs/protocol.md b/docs/protocol.md index 96aa22a..893faf9 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -20,8 +20,10 @@ V3 内只能增加可选字段、新消息和新能力,未知字段与未知 | 128 | `RunExtensions` | | 256 | `MainMenuControl` | | 512 | `SendVoiceMessages`(发送语音) | +| 1024 | `ChangeDisplayName`(修改用户名) | +| 2048 | `ApiAccess`(REST API Key;插件协议仅透传该位) | -管理员的有效权限固定为 1023。普通用户固定包含值 1,其余权限来自服务端授权。权限设置界面将值 2 显示为“概览”;七日课表查看和手动拉取只要求账号已登录,值 16 保护换课和自动拉取设置。`TeacherComing` 单独保护“老师来了”,`SendNotifications` 只保护自定义通知与清除提醒,`SendVoiceMessages` 独立保护语音消息,`RunExtensions` 是所有插件扩展的独立权限,`MainMenuControl` 保护主界面显隐,`PowerControl` 保护音量和 Windows 电源操作。 +管理员的有效权限固定为 4095。普通用户固定包含值 1,其余权限来自服务端授权。权限设置界面将值 2 显示为“概览”;七日课表查看和手动拉取只要求账号已登录,值 16 保护换课和自动拉取设置。`TeacherComing` 单独保护“老师来了”,`SendNotifications` 只保护自定义通知与清除提醒,`SendVoiceMessages` 独立保护语音消息,`RunExtensions` 是所有插件扩展的独立权限,`MainMenuControl` 保护主界面显隐,`PowerControl` 保护音量和 Windows 电源操作,`ChangeDisplayName` 允许账号在“个人账号”页自行修改自己的用户可见用户名(DisplayName,登录 ID 不变),`ApiAccess` 允许账号创建 API Key 并调用 REST API;`ChangeDisplayName` 和 `ApiAccess` 默认不授予学生角色,管理员与班管理员默认拥有 API 访问。 账号密码只出现在第一次 `POST /api/auth/login` 的请求内。生产环境必须使用 HTTPS;Android 手机端为兼容尚未配置 TLS 的现有部署,允许用户在持续显示风险提示的情况下明确连接 HTTP 云服务器。响应包含 1 小时 `accessToken`、30 天 `deviceSessionId/deviceSecret` 和用户有效权限。`POST /api/auth/refresh` 会同时轮换访问令牌和设备密钥;旧值立即失效。 @@ -53,13 +55,14 @@ V3 内只能增加可选字段、新消息和新能力,未知字段与未知 | `account_sync` | 服务端 → 插件 | 账号元数据、有效权限、设备验证器、`serverVersion`、可选服务端能力、镜像版本和生成时间 | | `peer_capabilities` | 插件/手表 → 服务端 | 当前端的 `softwareVersion` 和稳定字符串能力列表 | | `capabilities_sync` | 服务端/插件 → 手表 | 服务端能力和当前主插件能力快照 | +| `software_inventory` | 插件 → 服务端 | 设备名称、ClassIsland 主程序与当前已加载插件的版本、可用更新和最近升级状态 | | `state_push` | 插件 → 服务端/手表 | 高频当前课程、提醒播放、主界面显隐与可用电源状态,不含完整课表 | | `schedule_sync` | 插件 → 服务端/手表 | 今天起七天的日期、课程、科目清单和每日修订号 | | `schedule_pull` | 服务端/手表/手机 → 插件 | 只读请求,载荷可含 `{taskId, source, requestedAt}`,要求插件立即重新生成并推送七日课表;手机端 `source` 为 6 | | `schedule_sync_status` | 插件 → 服务端/手表 | 全局课表任务状态:Running、Completed、Failed 或 Busy,以及任务来源和占用任务 ID | | `extensions_sync` | 插件 → 服务端/手表 | 扩展功能清单(id、displayName、icon、requiredPermission、parameters) | | `event_notify` | 插件 → 服务端/手表 | 上课、下课、放学、课表变更、自定义消息、ClassIsland 自动化或第三方插件通知 | -| `command` | 手表/服务端 → 插件 | 结构化换课、老师来了、通知、主界面、音量或电源命令 | +| `command` | 手表/服务端 → 插件 | 结构化换课、老师来了、通知、主界面、音量、电源、软件升级、插件管理、档案分发、时间表与集控命令 | | `command_result` | 插件 → 发起者 | 真实成功、失败码、消息和可选新修订号 | | `settings_sync` | 服务端 → 手表 | 全局通知设置快照(目前含 `forceSenderInTitle`) | | `plugin_network_info` | 插件 → 服务端 → 手表 | 插件局域网直连地址与端口(每次云端重连时重新发现网卡) | @@ -83,7 +86,7 @@ V3 内只能增加可选字段、新消息和新能力,未知字段与未知 ## 能力协商 -V3 的基础能力(自 3.1.0 引入)为 `class-state.read`、`schedule.read`、`schedule.pull`、`schedule.change`、`notification.send`、`notification.clear`、`teacher-coming`、`main-menu.visibility`、`power.control`、`volume.control` 和 `extensions.run`。插件和手表连接后通过 `peer_capabilities` 上报软件版本与能力;服务端通过 `capabilities_sync` 向手表发送自身和当前主插件的能力。未上报能力的旧 V3 端按上述基础能力处理,未知能力标识被忽略。 +V3 的基础能力(自 3.1.0 引入)为 `class-state.read`、`schedule.read`、`schedule.pull`、`schedule.change`、`notification.send`、`notification.clear`、`teacher-coming`、`main-menu.visibility`、`power.control`、`volume.control` 和 `extensions.run`。后续新增的 `voice-message.send`、`software.inventory`、`software.upgrade-plugins`、`software.upgrade-classisland`、`plugin.install`、`plugin.uninstall`、`plugin.enable`、`plugin.management-policy`、`profile.distribute`、`schedule.time-layout` 和 `management.join` 只进入当前版本能力列表,不加入旧 V3 端默认获得的基础能力。插件和手表连接后通过 `peer_capabilities` 上报软件版本与能力;服务端通过 `capabilities_sync` 向手表发送自身和当前主插件的能力。未上报能力的旧 V3 端按上述基础能力处理,未知能力标识被忽略。 WebUI 的有效能力是“服务端 ∩ 当前主插件”,手表的有效能力是“手表本地 ∩ 服务端 ∩ 当前主插件”。多插件时,当前主插件仍是最早接入的健康插件;主插件切换、断开或能力更新后,服务端重新广播能力快照。界面应隐藏缺失能力的入口,服务端转发命令前仍需按统一映射复核主插件能力,缺少能力时返回 `CAPABILITY_UNSUPPORTED`。能力声明不能绕过账号权限或扩展策略检查。 @@ -111,6 +114,7 @@ WebUI 的有效能力是“服务端 ∩ 当前主插件”,手表的有效能 - `targetIndex`:交换模式必填。 - `replacementSubjectId`:替换模式必填。 - `expectedRevision`:客户端读取当天课表时的修订号。 +- `permanent`:为 `true` 时直接修改 ClassIsland 源课表,本周及以后每周持续生效;默认 `false`,只写入当天临时课表层。 插件发现修订号已变化时返回 `SCHEDULE_STALE` 和最新修订号,不覆盖别人刚完成的修改。 @@ -134,6 +138,23 @@ WebUI 通过 `POST /Control?handler=VoiceMessage` 上传 `application/octet-stre Windows 插件自动播放录音,并显示上述 ClassIsland 通知:强调特效开启、通知音效关闭、朗读关闭(仍遵循宿主全局提醒限制)。底部浮窗上滑出现,拖动标题可移动;顶部显示发送人和总时长,中间使用 ClassIsland 原生 Slider 拖动播放位置,底部三个无底色的 ClassIsland Fluent 图标分开放置,分别用于暂停/继续、后退 5 秒和关闭,关闭图标使用系统危险色,播完后可重新播放。浮窗使用宿主 Fluent 浮层背景、描边、圆角、深浅色主题和 UI 字体;点击浮窗外部也会关闭并停止播放。关闭或插件停止时立即释放音频资源。已有浮窗时返回 `BUSY`,不排队、不打断现有语音;离线不缓存重发。成功回执表示播放器已启动,不表示整条语音已经播放完毕。音频不写入文件或通知历史,广播事件仅包含发送人提示。 +## 远程软件升级 + +RemoteCI 插件在云端连接建立后通过 `software_inventory` 上报设备名称、ClassIsland 主程序版本和当前已加载插件版本。服务端按插件长期凭据保存最后一份清单,因此设备离线时 WebUI 仍能显示最近一次版本;重新上线后由新清单覆盖。 + +命令值 10 为 `UpgradePlugins`,命令值 11 为 `UpgradeClassIsland`,命令值 12 为 `RefreshSoftwareInventory`,命令值 20 为 `RestartClassIsland`。前三者都要求账号具有 `ManageUsers` 权限,并分别要求插件声明 `software.upgrade-plugins`、`software.upgrade-classisland`、`software.inventory` 能力;`RestartClassIsland` 仅允许系统管理员使用,不重启 Windows。WebUI 的“批量控制”页按在线插件连接逐台下发,避免同一班级多台设备只收到最早连接的一条命令。 + +升级命令只表示任务已被设备接受;实际下载、部署和重启由 ClassIsland 官方插件市场或官方 `UpdateService` 在后台完成。插件升级写入宿主的 `.cipx` 缓存并在重启后安装;ClassIsland 升级使用官方文件图校验和部署流程。完成后设备会主动重启并重新连接,服务端以新的 `software_inventory` 更新版本和最近升级状态。失败或已是最新版本时不会重启,状态随清单回传。 +## 插件管理、档案分发与集控 + +命令值 13 为 `InstallPlugins`、14 为 `UninstallPlugins`、15 为 `SetPluginEnabled`、16 为 `SetPluginManagementPolicy`、17 为 `DistributeProfile`、18 为 `UpdateTimeLayout`、19 为 `JoinManagement`。这些命令都要求 `ManageUsers` 权限,并在服务端和插件端再次确认系统管理员身份;能力标识分别为 `plugin.install`、`plugin.uninstall`、`plugin.enable`、`plugin.management-policy`、`profile.distribute`、`schedule.time-layout` 和 `management.join`。 + +- 插件安装使用 ClassIsland 官方插件市场解析依赖并下载 `.cipx` 缓存,卸载和启停使用宿主公开的 `PluginInfo` 状态接口;RemoteCI 拒绝操作自身,避免远程控制链路被卸载或禁用。 +- `DistributeProfile` 只接收 ClassIsland 档案 JSON 和分发范围(时间表、课表、科目),不接收任意文件路径;`UpdateTimeLayout` 接收结构化时间点并由插件在 UI 线程写入档案。 +- `JoinManagement` 由管理员上传 ClassIsland 集控配置文件 `ManagementPreset.json`(即宿主 ManagementSettings 的 JSON)发起。插件解析后按配置文件里的服务器类型(Serverless manifest 模板或 ManagementServer 的 API + gRPC)注册,先校验集控清单核心版本,再写入宿主的 Management 配置并重启;配置文件里的 ID(ClassIdentity)由服务端按目标设备所属班级名自动填充,不需要管理员填写。 +- `SetPluginManagementPolicy` 只约束 RemoteCI 后续发起的远程插件安装或卸载。ClassIsland 当前没有公开的宿主级“禁止本地安装/卸载插件”策略 API,因此该策略不阻止用户在 ClassIsland 本地设置页操作。 + +WebUI 批量控制页把功能参数和目标设备分成两步:先填写参数,再选择班级、分组或具体设备。班级级选择只投递到该班最早接入的在线设备;具体设备选择按插件连接逐台投递。命令下发成功只表示设备端已接受任务,下载、部署、重启或档案拉取的最终结果由后续 `software_inventory`、连接状态或用户再次查看时体现。 ## REST API 主要端点: diff --git a/plugin/RemoteCI.Plugin/Plugin.cs b/plugin/RemoteCI.Plugin/Plugin.cs index 490f406..6bb580e 100644 --- a/plugin/RemoteCI.Plugin/Plugin.cs +++ b/plugin/RemoteCI.Plugin/Plugin.cs @@ -66,6 +66,12 @@ public override void Initialize(HostBuilderContext context, IServiceCollection s services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => new PluginManagementService( + PluginConfigFolder, + sp.GetRequiredService>())); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/plugin/RemoteCI.Plugin/Services/ClassIslandHostControlService.cs b/plugin/RemoteCI.Plugin/Services/ClassIslandHostControlService.cs index 3f50baa..83569a4 100644 --- a/plugin/RemoteCI.Plugin/Services/ClassIslandHostControlService.cs +++ b/plugin/RemoteCI.Plugin/Services/ClassIslandHostControlService.cs @@ -2,6 +2,7 @@ using System.Reflection; using System.Runtime.InteropServices; using Avalonia.Threading; +using ClassIsland.Core; using ClassIsland.Core.Abstractions.Services; using Microsoft.Extensions.Logging; using NAudio.CoreAudioApi; @@ -139,6 +140,16 @@ public void SchedulePowerAction(PowerActionKind action) }); } + /// 延迟重启 ClassIsland 宿主,确保远程命令回执先发出。 + public void ScheduleClassIslandRestart() + { + Dispatcher.UIThread.Post(async () => + { + await Task.Delay(750); + AppBase.Current.Restart(); + }); + } + /// 取消尚未执行的睡眠/休眠操作(插件停止时调用)。 public void CancelPendingPowerActions() { diff --git a/plugin/RemoteCI.Plugin/Services/CloudClient.cs b/plugin/RemoteCI.Plugin/Services/CloudClient.cs index 4d16ee3..1a2955d 100644 --- a/plugin/RemoteCI.Plugin/Services/CloudClient.cs +++ b/plugin/RemoteCI.Plugin/Services/CloudClient.cs @@ -109,6 +109,7 @@ public Task StartAsync(CancellationToken stoppingToken = default) public Task SendScheduleSyncStatusAsync(ScheduleSyncStatus value) => TrySendAsync(Envelope.ScheduleSyncStatus(value)); public Task SendEventAsync(ClassEvent value) => SendAsync(Envelope.EventNotify(value)); public Task SendExtensionsAsync(IReadOnlyList value) => SendAsync(Envelope.ExtensionsSync(value)); + public Task SendSoftwareInventoryAsync(SoftwareInventory value) => SendAsync(Envelope.SoftwareInventory(value)); private async Task RunLoopAsync(CancellationToken ct) { @@ -182,7 +183,14 @@ private async Task EnsureTokenAsync(CancellationToken ct) var response = await _http.PostAsJsonAsync( $"{_settings.CloudServerUrl.TrimEnd('/')}/api/plugin/pair", - new PairRequest { PairCode = _settings.PluginPairCode, Role = "plugin" }, + new PairRequest + { + PairCode = _settings.PluginPairCode, + Role = "plugin", + ClassNameRemark = string.IsNullOrWhiteSpace(_settings.ClassNameRemark) + ? null + : _settings.ClassNameRemark.Trim(), + }, ct); // 409=配对码已用、401=配对码无效、403=端点拒绝(反向代理或权限配置),均视为凭据失效。 if (response.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) diff --git a/plugin/RemoteCI.Plugin/Services/CommandHandler.cs b/plugin/RemoteCI.Plugin/Services/CommandHandler.cs index fee017b..8576f14 100644 --- a/plugin/RemoteCI.Plugin/Services/CommandHandler.cs +++ b/plugin/RemoteCI.Plugin/Services/CommandHandler.cs @@ -16,6 +16,10 @@ public sealed class CommandHandler private readonly IProfileWriteOperations _profileOps; private readonly RemoteNotificationProvider _notifications; private readonly ClassIslandHostControlService _hostControl; + private readonly SoftwareInventoryService _softwareInventory; + private readonly PluginManagementService _pluginManagement; + private readonly ProfileManagementService _profileManagement; + private readonly ManagementJoinService _managementJoin; private readonly ILogger _logger; private readonly ExtensionCommandRouter _extensionRouter; private readonly VoiceMessagePlayer _voiceMessages; @@ -25,6 +29,10 @@ public CommandHandler( IScheduleBackend scheduleBackend, IProfileWriteOperations profileOps, ClassIslandHostControlService hostControl, + SoftwareInventoryService softwareInventory, + PluginManagementService pluginManagement, + ProfileManagementService profileManagement, + ManagementJoinService managementJoin, IEnumerable hostedServices, IRemoteCiExtensionRegistry extensions, ILoggerFactory loggerFactory, @@ -34,6 +42,10 @@ public CommandHandler( _scheduleBackend = scheduleBackend; _profileOps = profileOps; _hostControl = hostControl; + _softwareInventory = softwareInventory; + _pluginManagement = pluginManagement; + _profileManagement = profileManagement; + _managementJoin = managementJoin; _notifications = hostedServices.OfType().Single(); _logger = loggerFactory.CreateLogger(); _extensionRouter = new ExtensionCommandRouter(extensions, loggerFactory); @@ -54,6 +66,12 @@ public async Task HandleAsync(CommandMessage command) if (command.Command == CommandKind.RunExtension) return await _extensionRouter.RunAsync(command); + // 远程升级、插件管理和集控操作属于宿主级维护,即使账号被授予 ManageUsers 也只允许系统管理员执行。 + if (command.Command is (CommandKind.UpgradePlugins or CommandKind.UpgradeClassIsland or CommandKind.RefreshSoftwareInventory or + CommandKind.InstallPlugins or CommandKind.UninstallPlugins or CommandKind.SetPluginEnabled or + CommandKind.SetPluginManagementPolicy or CommandKind.DistributeProfile or CommandKind.UpdateTimeLayout or + CommandKind.JoinManagement or CommandKind.RestartClassIsland) && command.RequestedBy?.Role != UserRole.Admin) + return CommandResult.Failure(CommandResultCodes.Forbidden, "仅系统管理员可以执行远程维护操作"); var required = CommandPermissions.Required(command.Command); if (required == UserPermissions.None) return CommandResult.Failure(CommandResultCodes.InvalidRequest, $"未知指令:{command.Command}"); @@ -75,6 +93,16 @@ public async Task HandleAsync(CommandMessage command) CommandKind.SetMainMenuVisibility => await HandleMainMenuVisibilityAsync(command.MainMenuVisible), CommandKind.Power => HandlePowerAction(command.PowerAction), CommandKind.Volume => HandleVolume(command.Volume), + CommandKind.RefreshSoftwareInventory => _softwareInventory.Refresh(), + CommandKind.UpgradePlugins => _softwareInventory.StartPluginUpgrade(command.SoftwareUpgrade), + CommandKind.UpgradeClassIsland => _softwareInventory.StartClassIslandUpgrade(command.SoftwareUpgrade), + CommandKind.InstallPlugins or CommandKind.UninstallPlugins or CommandKind.SetPluginEnabled => + await _pluginManagement.HandleAsync(command.Command, command.PluginManagement), + CommandKind.SetPluginManagementPolicy => _pluginManagement.SetPolicy(command.PluginManagementPolicy), + CommandKind.UpdateTimeLayout => await _profileManagement.UpdateTimeLayoutAsync(command.TimeLayoutUpdate), + CommandKind.DistributeProfile => await _profileManagement.DistributeProfileAsync(command.ProfileDistribution), + CommandKind.JoinManagement => await _managementJoin.JoinAsync(command.ManagementJoin), + CommandKind.RestartClassIsland => HandleClassIslandRestart(), _ => CommandResult.Failure(CommandResultCodes.InvalidRequest, $"未知指令:{command.Command}"), }; } @@ -128,7 +156,10 @@ await _notifications.ShowRemoteNotificationAsync( message, request.IsNotificationEffectEnabled, request.IsNotificationSoundEnabled, - request.IsSpeechEnabled); + request.IsSpeechEnabled, + request.IsNotificationTopmostEnabled, + request.EffectiveDurationSeconds, + request.EffectiveRepeatCounts); NotificationSent?.Invoke(new ClassEvent { Event = ClassEventKind.Custom, @@ -214,6 +245,12 @@ private CommandResult HandlePowerAction(PowerActionKind? action) }); } + private CommandResult HandleClassIslandRestart() + { + _hostControl.ScheduleClassIslandRestart(); + return Success("ClassIsland 即将重启"); + } + private CommandResult HandleVolume(VolumeControlRequest? request) { if (request is null || request is { Level: null, Muted: null }) diff --git a/plugin/RemoteCI.Plugin/Services/ManagementJoinService.cs b/plugin/RemoteCI.Plugin/Services/ManagementJoinService.cs new file mode 100644 index 0000000..c1b4911 --- /dev/null +++ b/plugin/RemoteCI.Plugin/Services/ManagementJoinService.cs @@ -0,0 +1,238 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using Avalonia.Threading; +using ClassIsland.Core; +using ClassIsland.Core.Abstractions.Services.Management; +using ClassIsland.Shared; +using ClassIsland.Shared.Enums; +using ClassIsland.Shared.Models.Management; +using Microsoft.Extensions.Logging; +using RemoteCI.Shared; +using RemoteCI.Shared.Models; + +namespace RemoteCI.Plugin.Services; + +/// +/// 批量加入 ClassIsland 内置集控。输入为管理员上传的集控配置文件(ManagementPreset.json), +/// 实现复刻宿主 JoinManagementAsync 的无交互路径:先注册/下载清单,再写入宿主的 Management +/// 配置,最后重启让宿主完成档案拉取。集控 ID(ClassIdentity)由服务端按设备班级名自动填充。 +/// +public sealed class ManagementJoinService(ILogger logger) +{ + private static readonly HttpClient Http = new() + { + Timeout = TimeSpan.FromSeconds(30), + }; + + private static readonly JsonSerializerOptions PresetJsonOptions = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() }, + }; + + public async Task JoinAsync(ManagementJoinRequest? request, CancellationToken ct = default) + { + if (request is null || string.IsNullOrWhiteSpace(request.PresetJson)) + return CommandResult.Failure(CommandResultCodes.InvalidRequest, "缺少集控配置文件"); + var management = IAppHost.TryGetService(); + if (management is null) + return CommandResult.Failure(CommandResultCodes.CapabilityUnsupported, "当前 ClassIsland 版本不支持集控"); + if (management.IsManagementEnabled) + return CommandResult.Failure(CommandResultCodes.Busy, "设备已加入集控,不能重复加入"); + + ManagementSettings settings; + try + { + settings = ParseSettings(request); + } + catch (JsonException ex) + { + return CommandResult.Failure(CommandResultCodes.InvalidRequest, $"集控配置文件格式无效:{ex.Message}"); + } + if (!IsSettingsComplete(settings)) + return CommandResult.Failure(CommandResultCodes.InvalidRequest, "集控配置文件缺少服务器地址"); + + ManagementManifest? manifest = null; + + var managementType = Type.GetType("ClassIsland.Services.Management.ManagementService, ClassIsland", true)!; + Directory.CreateDirectory(GetStaticString(managementType, "ManagementConfigureFolderPath")); + + try + { + manifest = await ResolveManifestAsync(settings, management.Persist.ClientUniqueId, ct); + if (manifest.CoreVersion.Major != IAppHost.CoreVersion.Major) + return CommandResult.Failure( + CommandResultCodes.CapabilityUnsupported, + $"集控核心版本 {manifest.CoreVersion} 与当前 ClassIsland {IAppHost.CoreVersion} 不兼容"); + + await WriteManagementFilesAsync(settings, manifest, management.Persist.ClientUniqueId, ct); + } + catch (Exception ex) + { + logger.LogError(ex, "加入 ClassIsland 集控失败"); + return CommandResult.Failure(CommandResultCodes.InternalError, $"加入集控失败:{ex.Message}"); + } + + ScheduleRestart(); + return Success($"已加入组织 {manifest!.OrganizationName},ClassIsland 将自动重启"); + } + + /// + /// 解析管理员上传的集控配置文件(ClassIsland 的 ManagementPreset.json,即 ManagementSettings 的 JSON 序列化)。 + /// 加入集控即视为启用;ID(ClassIdentity)统一使用服务端按设备班级名自动下发的值。 + /// + private static ManagementSettings ParseSettings(ManagementJoinRequest request) + { + var settings = JsonSerializer.Deserialize(request.PresetJson, PresetJsonOptions) + ?? throw new JsonException("集控配置文件为空"); + settings.IsManagementEnabled = true; + settings.ManagementServer = settings.ManagementServer?.Trim() ?? string.Empty; + settings.ManagementServerGrpc = settings.ManagementServerGrpc?.Trim() ?? string.Empty; + settings.ManifestUrlTemplate = settings.ManifestUrlTemplate?.Trim() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(request.ClassIdentity)) + settings.ClassIdentity = request.ClassIdentity.Trim(); + return settings; + } + + private static bool IsSettingsComplete(ManagementSettings settings) => + settings.ManagementServerKind == ManagementServerKind.ManagementServer + ? Uri.TryCreate(settings.ManagementServer, UriKind.Absolute, out _) && + Uri.TryCreate(settings.ManagementServerGrpc, UriKind.Absolute, out _) + : Uri.TryCreate(settings.ManifestUrlTemplate, UriKind.Absolute, out _); + + private static async Task ResolveManifestAsync( + ManagementSettings settings, + Guid clientId, + CancellationToken ct) + { + if (settings.ManagementServerKind == ManagementServerKind.Serverless) + { + var url = DecorateUrl(settings.ManifestUrlTemplate, clientId, settings.ClassIdentity, settings.ManagementServer); + return await GetJsonAsync(url, ct) + ?? throw new InvalidOperationException("集控清单为空"); + } + + var connectionType = Type.GetType( + "ClassIsland.Services.Management.ManagementServerConnection, ClassIsland", + throwOnError: false) + ?? throw new InvalidOperationException("找不到 ClassIsland 集控连接实现"); + var connection = Activator.CreateInstance( + connectionType, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + binder: null, + args: [settings, clientId, true], + culture: null) ?? throw new InvalidOperationException("无法创建 ClassIsland 集控连接"); + var register = connectionType.GetMethod("RegisterAsync", BindingFlags.Instance | BindingFlags.Public) + ?? throw new MissingMethodException(connectionType.FullName, "RegisterAsync"); + var task = register.Invoke(connection, null) as Task + ?? throw new InvalidOperationException("ClassIsland 集控注册接口返回了未知结果"); + return await task.WaitAsync(ct); + } + + private static async Task WriteManagementFilesAsync( + ManagementSettings settings, + ManagementManifest manifest, + Guid clientId, + CancellationToken ct) + { + var managementType = Type.GetType("ClassIsland.Services.Management.ManagementService, ClassIsland", true)!; + var profileType = Type.GetType("ClassIsland.Services.ProfileService, ClassIsland", true)!; + var folder = GetStaticString(managementType, "ManagementConfigureFolderPath"); + Directory.CreateDirectory(folder); + + var paths = new[] + { + GetStaticString(managementType, "ManagementManifestPath"), + GetStaticString(managementType, "ManagementPolicyPath"), + GetStaticString(managementType, "ManagementVersionsPath"), + GetStaticString(managementType, "ManagementCredentialsPath"), + GetStaticString(profileType, "ManagementClassPlanPath"), + GetStaticString(profileType, "ManagementSubjectsPath"), + GetStaticString(profileType, "ManagementTimeLayoutPath"), + Path.Combine(GetStaticString(profileType, "ProfilePath"), "_management-profile.json"), + }; + foreach (var path in paths.Where(File.Exists)) + { + File.Delete(path); + if (File.Exists(path + ".bak")) File.Delete(path + ".bak"); + } + + await WriteJsonAsync(GetStaticString(managementType, "ManagementManifestPath"), manifest, ct); + await WriteJsonAsync(GetStaticString(managementType, "ManagementVersionsPath"), new ManagementVersions(), ct); + await DownloadJsonIfPresentAsync( + manifest.PolicySource.Value, + GetStaticString(managementType, "ManagementPolicyPath"), + settings, + clientId, + ct); + await DownloadJsonIfPresentAsync( + manifest.CredentialSource.Value, + GetStaticString(managementType, "ManagementCredentialsPath"), + settings, + clientId, + ct); + await WriteJsonAsync(GetStaticString(managementType, "ManagementSettingsPath"), settings, ct); + } + + private static async Task DownloadJsonIfPresentAsync( + string? source, + string destination, + ManagementSettings settings, + Guid clientId, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(source)) return; + var url = DecorateUrl(source, clientId, settings.ClassIdentity, settings.ManagementServer); + var json = await Http.GetStringAsync(url, ct); + var value = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? throw new InvalidOperationException("集控配置为空"); + await WriteJsonAsync(destination, value, ct); + } + + private static async Task WriteJsonAsync(string path, T value, CancellationToken ct) + { + var json = JsonSerializer.Serialize(value, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + WriteIndented = true, + }); + await File.WriteAllTextAsync(path, json, ct); + } + + private static async Task GetJsonAsync(string url, CancellationToken ct) + { + var json = await Http.GetStringAsync(url, ct); + return JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + + private static string GetStaticString(Type type, string name) => + type.GetField(name, BindingFlags.Public | BindingFlags.Static)?.GetValue(null) as string + ?? throw new MissingFieldException(type.FullName, name); + + private static string DecorateUrl(string url, Guid clientId, string? classIdentity, string host) => url + .Replace("{cuid}", clientId.ToString(), StringComparison.OrdinalIgnoreCase) + .Replace("{id}", classIdentity ?? string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("{host}", host, StringComparison.OrdinalIgnoreCase); + + private static void ScheduleRestart() + { + _ = Task.Run(async () => + { + await Task.Delay(1500); + Dispatcher.UIThread.Post(() => AppBase.Current.Restart()); + }); + } + + private static CommandResult Success(string message) => new() + { + Success = true, + Code = CommandResultCodes.Ok, + Message = message, + }; +} + + + + + diff --git a/plugin/RemoteCI.Plugin/Services/PluginManagementService.cs b/plugin/RemoteCI.Plugin/Services/PluginManagementService.cs new file mode 100644 index 0000000..fa5bd31 --- /dev/null +++ b/plugin/RemoteCI.Plugin/Services/PluginManagementService.cs @@ -0,0 +1,286 @@ +using System.Text.Json; +using Avalonia.Threading; +using ClassIsland.Core; +using ClassIsland.Core.Abstractions.Services; +using ClassIsland.Core.Models.Plugin; +using ClassIsland.Shared; +using Microsoft.Extensions.Logging; +using RemoteCI.Shared; +using RemoteCI.Shared.Models; + +namespace RemoteCI.Plugin.Services; + +/// +/// ClassIsland 插件安装、卸载、启用和禁用。所有操作都通过宿主公开服务或 PluginInfo +/// 的公开状态接口完成,RemoteCI 不直接替换宿主插件目录。 +/// +public sealed class PluginManagementService +{ + public const string RemoteCiPluginId = "remoteci.plugin"; + + private static readonly TimeSpan DownloadTimeout = TimeSpan.FromMinutes(10); + private readonly string _policyPath; + private readonly ILogger _logger; + + public PluginManagementService(string pluginConfigFolder, ILogger logger) + { + _policyPath = Path.Combine(pluginConfigFolder, "PluginManagementPolicy.json"); + _logger = logger; + } + + public CommandResult SetPolicy(PluginManagementPolicyRequest? request) + { + if (request is null) + return CommandResult.Failure(CommandResultCodes.InvalidRequest, "缺少插件管理策略"); + + try + { + File.WriteAllText(_policyPath, JsonSerializer.Serialize(request, JsonDefaults.Options)); + return Success("远程插件管理策略已保存"); + } + catch (Exception ex) + { + _logger.LogError(ex, "保存远程插件管理策略失败"); + return CommandResult.Failure(CommandResultCodes.SaveFailed, $"保存策略失败:{ex.Message}"); + } + } + + public async Task HandleAsync(CommandKind commandKind, PluginManagementRequest? request, CancellationToken ct = default) + { + if (request is null) + return CommandResult.Failure(CommandResultCodes.InvalidRequest, "缺少插件管理参数"); + if (request.PluginIds.Count == 0) + return CommandResult.Failure(CommandResultCodes.InvalidRequest, "请至少选择一个插件"); + + var ids = NormalizeIds(request.PluginIds); + if (ids.Count == 0) + return CommandResult.Failure(CommandResultCodes.InvalidRequest, "插件 Id 不能为空"); + if (ids.Contains(RemoteCiPluginId, StringComparer.OrdinalIgnoreCase)) + return CommandResult.Failure(CommandResultCodes.Forbidden, "不能远程安装、卸载、启用或禁用 RemoteCI 自身"); + + if (!IsActionAllowed(commandKind, request.Action)) + + return CommandResult.Failure(CommandResultCodes.InvalidRequest, "插件操作与命令类型不匹配"); + + + var policy = LoadPolicy(); + return request.Action switch + { + PluginActionKind.Install => await InstallAsync(ids, request.RestartAfter, policy, ct), + PluginActionKind.Uninstall => Uninstall(ids, request.RestartAfter, policy), + PluginActionKind.Enable => SetEnabled(ids, true, request.RestartAfter), + PluginActionKind.Disable => SetEnabled(ids, false, request.RestartAfter), + _ => CommandResult.Failure(CommandResultCodes.InvalidRequest, "未知插件操作"), + }; + } + + private async Task InstallAsync( + IReadOnlyList requestedIds, + bool restartAfter, + PluginManagementPolicyRequest policy, + CancellationToken ct) + { + if (!policy.AllowRemoteInstall) + return CommandResult.Failure(CommandResultCodes.Forbidden, "设备策略禁止远程安装插件"); + + var market = IAppHost.TryGetService(); + if (market is null) + return CommandResult.Failure(CommandResultCodes.CapabilityUnsupported, "当前 ClassIsland 版本不支持插件市场"); + + market.LoadPluginSource(); + var installed = IPluginService.LoadedPlugins + .Select(plugin => plugin.Manifest.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var resolved = new List(); + var missing = new List(); + foreach (var id in requestedIds) + ResolveDependencies(id, market, installed, resolved, missing); + + if (missing.Count > 0) + return CommandResult.Failure( + CommandResultCodes.InvalidRequest, + $"缺少必选依赖:{string.Join("、", missing.Distinct(StringComparer.OrdinalIgnoreCase))}"); + + var toDownload = resolved + .Where(id => !installed.Contains(id)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (toDownload.Count == 0) + return Success("请求的插件均已安装"); + + foreach (var id in toDownload) + market.RequestDownloadPlugin(id); + + if (!restartAfter) + return Success($"已开始下载 {toDownload.Count} 个插件,重启 ClassIsland 后生效"); + + var downloadResult = await WaitForDownloadsAsync(market, toDownload, ct); + if (!downloadResult.Success) + return downloadResult; + + ScheduleRestart("插件安装完成,正在重启 ClassIsland"); + return Success($"已下载 {toDownload.Count} 个插件,ClassIsland 将自动重启"); + } + + private CommandResult Uninstall(IReadOnlyList ids, bool restartAfter, PluginManagementPolicyRequest policy) + { + if (!policy.AllowRemoteUninstall) + return CommandResult.Failure(CommandResultCodes.Forbidden, "设备策略禁止远程卸载插件"); + + var plugins = IPluginService.LoadedPlugins + .Where(plugin => ids.Contains(plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase)) + .ToList(); + var found = plugins.Select(plugin => plugin.Manifest.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); + var missing = ids.Where(id => !found.Contains(id)).ToList(); + if (missing.Count > 0) + return CommandResult.Failure(CommandResultCodes.InvalidRequest, $"未找到插件:{string.Join("、", missing)}"); + + foreach (var plugin in plugins) + plugin.IsUninstalling = true; + + if (restartAfter) + ScheduleRestart("插件卸载标记已写入,正在重启 ClassIsland"); + return Success($"已标记卸载 {plugins.Count} 个插件{(restartAfter ? ",ClassIsland 将自动重启" : ",重启后生效")}"); + } + + private CommandResult SetEnabled(IReadOnlyList ids, bool enabled, bool restartAfter) + { + var plugins = IPluginService.LoadedPlugins + .Where(plugin => ids.Contains(plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase)) + .ToList(); + var found = plugins.Select(plugin => plugin.Manifest.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); + var missing = ids.Where(id => !found.Contains(id)).ToList(); + if (missing.Count > 0) + return CommandResult.Failure(CommandResultCodes.InvalidRequest, $"未找到插件:{string.Join("、", missing)}"); + + foreach (var plugin in plugins) + plugin.IsEnabled = enabled; + + var action = enabled ? "启用" : "禁用"; + if (restartAfter) + ScheduleRestart($"插件已标记{action},正在重启 ClassIsland"); + return Success($"已标记{action} {plugins.Count} 个插件{(restartAfter ? ",ClassIsland 将自动重启" : ",重启后生效")}"); + } + + private async Task WaitForDownloadsAsync( + IPluginMarketService market, + IReadOnlyList ids, + CancellationToken ct) + { + var deadline = DateTimeOffset.UtcNow + DownloadTimeout; + while (DateTimeOffset.UtcNow < deadline) + { + ct.ThrowIfCancellationRequested(); + var pending = 0; + var failures = new List(); + foreach (var id in ids) + { + if (!market.MergedPlugins.TryGetValue(id, out var plugin)) + { + pending++; + continue; + } + + var progress = plugin.DownloadProgress; + if (progress?.Exception is { } exception) + { + failures.Add($"{id}:{exception.Message}"); + continue; + } + + if (progress?.IsDownloading == true || !plugin.RestartRequired) + pending++; + } + + if (failures.Count > 0) + return CommandResult.Failure(CommandResultCodes.InternalError, $"插件下载失败:{string.Join(";", failures)}"); + if (pending == 0) + return Success("插件下载完成"); + + await Task.Delay(250, ct); + } + + return CommandResult.Failure(CommandResultCodes.Timeout, "等待插件下载超时,请稍后在 ClassIsland 插件页确认"); + } + + private void ResolveDependencies( + string id, + IPluginMarketService market, + IReadOnlySet installed, + List resolved, + List missing) + { + if (installed.Contains(id) || resolved.Contains(id, StringComparer.OrdinalIgnoreCase)) + return; + + var item = market.ResolveMarketPlugin(id); + if (item is null) + { + if (!missing.Contains(id, StringComparer.OrdinalIgnoreCase)) + missing.Add(id); + return; + } + + resolved.Add(id); + foreach (var dependency in item.Manifest.Dependencies) + { + if (installed.Contains(dependency.Id) || resolved.Contains(dependency.Id, StringComparer.OrdinalIgnoreCase)) + continue; + if (market.ResolveMarketPlugin(dependency.Id) is null) + { + if (dependency.IsRequired && !missing.Contains(dependency.Id, StringComparer.OrdinalIgnoreCase)) + missing.Add(dependency.Id); + continue; + } + ResolveDependencies(dependency.Id, market, installed, resolved, missing); + } + } + + private PluginManagementPolicyRequest LoadPolicy() + { + try + { + if (!File.Exists(_policyPath)) return new PluginManagementPolicyRequest(); + return JsonSerializer.Deserialize(File.ReadAllText(_policyPath), JsonDefaults.Options) + ?? new PluginManagementPolicyRequest(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "读取远程插件管理策略失败,使用默认允许策略"); + return new PluginManagementPolicyRequest(); + } + } + + private static bool IsActionAllowed(CommandKind commandKind, PluginActionKind action) => commandKind switch + { + CommandKind.InstallPlugins => action == PluginActionKind.Install, + CommandKind.UninstallPlugins => action == PluginActionKind.Uninstall, + CommandKind.SetPluginEnabled => action is PluginActionKind.Enable or PluginActionKind.Disable, + _ => false, + }; + + private static List NormalizeIds(IEnumerable values) => values + .Select(value => value?.Trim()) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Cast() + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + private static void ScheduleRestart(string reason) + { + // 先让命令回执经 WebSocket 发出,再延迟重启,避免发起端只看到连接断开。 + _ = Task.Run(async () => + { + await Task.Delay(1500); + Dispatcher.UIThread.Post(() => AppBase.Current.Restart()); + }); + } + + private static CommandResult Success(string message) => new() + { + Success = true, + Code = CommandResultCodes.Ok, + Message = message, + }; +} + diff --git a/plugin/RemoteCI.Plugin/Services/ProfileManagementService.cs b/plugin/RemoteCI.Plugin/Services/ProfileManagementService.cs new file mode 100644 index 0000000..7356957 --- /dev/null +++ b/plugin/RemoteCI.Plugin/Services/ProfileManagementService.cs @@ -0,0 +1,182 @@ +using System.Text.Json; +using Avalonia.Threading; +using ClassIsland.Core; +using ClassIsland.Core.Abstractions.Services; +using ClassIsland.Shared; +using ClassIsland.Shared.Models.Profile; +using Microsoft.Extensions.Logging; +using RemoteCI.Shared; +using RemoteCI.Shared.Models; + +namespace RemoteCI.Plugin.Services; + +/// +/// ClassIsland 档案分发与时间表修改。所有写入都在 UI 线程执行,并复用宿主的档案保存机制。 +/// +public sealed class ProfileManagementService(ILogger logger) +{ + private const int MaxProfileJsonLength = 5 * 1024 * 1024; + private static readonly JsonSerializerOptions ProfileJsonOptions = new() + { + PropertyNameCaseInsensitive = true, + WriteIndented = false, + }; + + public async Task UpdateTimeLayoutAsync(TimeLayoutUpdateRequest? request, CancellationToken ct = default) + { + if (request is null) + return CommandResult.Failure(CommandResultCodes.InvalidRequest, "缺少时间表参数"); + if (string.IsNullOrWhiteSpace(request.Name)) + return CommandResult.Failure(CommandResultCodes.InvalidRequest, "时间表名称不能为空"); + if (request.Points.Count == 0) + return CommandResult.Failure(CommandResultCodes.InvalidRequest, "时间表至少需要一个时间点"); + if (request.Points.Count > 64) + return CommandResult.Failure(CommandResultCodes.InvalidRequest, "单个时间表最多支持 64 个时间点"); + + var points = new List(request.Points.Count); + foreach (var point in request.Points) + { + if (!TimeSpan.TryParse(point.StartTime, out var start) || + !TimeSpan.TryParse(point.EndTime, out var end)) + return CommandResult.Failure(CommandResultCodes.InvalidRequest, "时间点必须使用 HH:mm 格式"); + if (end < start) + return CommandResult.Failure(CommandResultCodes.InvalidRequest, "时间点结束时间不能早于开始时间"); + if (point.TimeType is < 0 or > 3) + return CommandResult.Failure(CommandResultCodes.InvalidRequest, "时间点类型必须是 0、1、2 或 3"); + + points.Add(new TimeLayoutItem + { + StartTime = start, + EndTime = end, + TimeType = point.TimeType, + BreakName = point.BreakName?.Trim() ?? string.Empty, + }); + } + + try + { + await Dispatcher.UIThread.InvokeAsync(() => + { + var profileService = IAppHost.GetService(); + var profile = profileService.Profile; + var id = request.TimeLayoutId ?? Guid.NewGuid(); + if (request.Activate) + { + foreach (var existing in profile.TimeLayouts.Values) + existing.IsActivated = false; + } + + var layout = new TimeLayout + { + Name = request.Name.Trim(), + IsActivated = request.Activate, + IsActivatedManually = request.Activate, + }; + foreach (var point in points.OrderBy(point => point.StartTime).ThenBy(point => point.EndTime)) + layout.Layouts.Add(point); + + profile.TimeLayouts[id] = layout; + profileService.SaveProfile(); + }); + } + catch (Exception ex) + { + logger.LogError(ex, "修改 ClassIsland 时间表失败"); + return CommandResult.Failure(CommandResultCodes.SaveFailed, $"修改时间表失败:{ex.Message}"); + } + + if (request.RestartAfter) + ScheduleRestart(); + return Success(request.RestartAfter ? "时间表已更新,ClassIsland 将自动重启" : "时间表已更新"); + } + + public async Task DistributeProfileAsync( + ProfileDistributionRequest? request, + CancellationToken ct = default) + { + if (request is null) + return CommandResult.Failure(CommandResultCodes.InvalidRequest, "缺少档案分发参数"); + if (request.Sections == ProfileDistributionSection.None) + return CommandResult.Failure(CommandResultCodes.InvalidRequest, "请至少选择一个分发内容"); + if (string.IsNullOrWhiteSpace(request.ProfileJson)) + return CommandResult.Failure(CommandResultCodes.InvalidRequest, "档案 JSON 不能为空"); + if (request.ProfileJson.Length > MaxProfileJsonLength) + return CommandResult.Failure(CommandResultCodes.InvalidRequest, "档案 JSON 不能超过 5 MB"); + + Profile source; + try + { + source = JsonSerializer.Deserialize(request.ProfileJson, ProfileJsonOptions) + ?? throw new InvalidOperationException("档案 JSON 为空"); + } + catch (Exception ex) + { + return CommandResult.Failure(CommandResultCodes.InvalidRequest, $"档案 JSON 无效:{ex.Message}"); + } + + try + { + await Dispatcher.UIThread.InvokeAsync(() => + { + var profileService = IAppHost.GetService(); + var target = profileService.Profile; + if (request.Sections.HasFlag(ProfileDistributionSection.TimeLayouts)) + ApplyDictionary(target.TimeLayouts, source.TimeLayouts, request.ReplaceExisting); + if (request.Sections.HasFlag(ProfileDistributionSection.ClassPlans)) + ApplyDictionary(target.ClassPlans, source.ClassPlans, request.ReplaceExisting); + if (request.Sections.HasFlag(ProfileDistributionSection.Subjects)) + ApplyDictionary(target.Subjects, source.Subjects, request.ReplaceExisting); + profileService.SaveProfile(); + }); + } + catch (Exception ex) + { + logger.LogError(ex, "分发 ClassIsland 档案失败"); + return CommandResult.Failure(CommandResultCodes.SaveFailed, $"分发档案失败:{ex.Message}"); + } + + if (request.RestartAfter) + ScheduleRestart(); + var sections = DescribeSections(request.Sections); + return Success($"已分发{sections}{(request.RestartAfter ? ",ClassIsland 将自动重启" : string.Empty)}"); + } + + private static void ApplyDictionary( + IDictionary target, + IDictionary source, + bool replaceExisting) + { + if (replaceExisting) + target.Clear(); + foreach (var (id, value) in source) + target[id] = value; + } + + private static string DescribeSections(ProfileDistributionSection sections) + { + var names = new List(); + if (sections.HasFlag(ProfileDistributionSection.TimeLayouts)) names.Add("时间表"); + if (sections.HasFlag(ProfileDistributionSection.ClassPlans)) names.Add("课表"); + if (sections.HasFlag(ProfileDistributionSection.Subjects)) names.Add("科目"); + return string.Join("、", names); + } + + private static void ScheduleRestart() + { + // 档案已通过宿主 PropertyChanged 自动保存;延迟重启是为了让命令回执先发出。 + _ = Task.Run(async () => + { + await Task.Delay(1500); + Dispatcher.UIThread.Post(() => AppBase.Current.Restart()); + }); + } + + private static CommandResult Success(string message) => new() + { + Success = true, + Code = CommandResultCodes.Ok, + Message = message, + }; +} + + diff --git a/plugin/RemoteCI.Plugin/Services/RemoteCiService.cs b/plugin/RemoteCI.Plugin/Services/RemoteCiService.cs index 0710b13..79e21b4 100644 --- a/plugin/RemoteCI.Plugin/Services/RemoteCiService.cs +++ b/plugin/RemoteCI.Plugin/Services/RemoteCiService.cs @@ -21,6 +21,7 @@ public sealed class RemoteCiService : IDisposable private readonly CloudTokenStore _tokenStore; private readonly IRemoteCiExtensionRegistry _extensions; private readonly ScheduleSyncTaskCoordinator _scheduleSync; + private readonly SoftwareInventoryService _softwareInventory; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; private LanServer? _lanServer; @@ -39,6 +40,7 @@ public RemoteCiService( CloudTokenStore tokenStore, IRemoteCiExtensionRegistry extensions, ScheduleSyncTaskCoordinator scheduleSync, + SoftwareInventoryService softwareInventory, ILoggerFactory loggerFactory) { _collector = collector; @@ -49,6 +51,7 @@ public RemoteCiService( _tokenStore = tokenStore; _extensions = extensions; _scheduleSync = scheduleSync; + _softwareInventory = softwareInventory; _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); } @@ -85,6 +88,7 @@ public void Start() _notificationBridge.NotificationCaptured += OnEventOccurred; _extensions.ExtensionsChanged += OnExtensionsChanged; _scheduleSync.StatusChanged += OnScheduleSyncStatusChanged; + _softwareInventory.InventoryChanged += OnSoftwareInventoryChanged; _notificationBridge.Start(); if (_settings.EnableLanServer) @@ -128,6 +132,7 @@ public void Stop() _commandHandler.HostStateChanged -= OnHostStateChanged; _notificationBridge.NotificationCaptured -= OnEventOccurred; _extensions.ExtensionsChanged -= OnExtensionsChanged; + _softwareInventory.InventoryChanged -= OnSoftwareInventoryChanged; if (_scheduleSync.Current is { } active) _scheduleSync.TryFail(active.TaskId, "RemoteCI 服务已停止,课表任务已取消", out _); _scheduleSync.StatusChanged -= OnScheduleSyncStatusChanged; @@ -280,11 +285,23 @@ private void OnEventOccurred(ClassEvent @event) private void OnExtensionsChanged(object? sender, EventArgs e) => PublishExtensions(); // 首次连接或重连时补发当前扩展快照,避免注册早于 WebSocket 就绪时丢失 extensions_sync。 - private void OnCloudConnected(object? sender, EventArgs e) => PublishExtensions(); + private void OnCloudConnected(object? sender, EventArgs e) + { + PublishExtensions(); + PublishSoftwareInventory(); + } + + private void OnSoftwareInventoryChanged() => PublishSoftwareInventory(); private void OnCloudConnectionStatusChanged(CloudConnectionStatus status) => CloudConnectionStatusChanged?.Invoke(status); + private void PublishSoftwareInventory() + { + if (_cloudClient is not { } cloud) return; + Observe(cloud.SendSoftwareInventoryAsync(_softwareInventory.Build()), "软件版本清单"); + } + private void PublishExtensions() { var definitions = _extensions.GetExtensions() diff --git a/plugin/RemoteCI.Plugin/Services/RemoteNotificationProvider.cs b/plugin/RemoteCI.Plugin/Services/RemoteNotificationProvider.cs index 8fe666a..f498a1d 100644 --- a/plugin/RemoteCI.Plugin/Services/RemoteNotificationProvider.cs +++ b/plugin/RemoteCI.Plugin/Services/RemoteNotificationProvider.cs @@ -24,7 +24,10 @@ public async Task ShowRemoteNotificationAsync( string message, bool isNotificationEffectEnabled, bool isNotificationSoundEnabled, - bool isSpeechEnabled) + bool isSpeechEnabled, + bool isNotificationTopmostEnabled = false, + int durationSeconds = 0, + int repeatCounts = 1) { await Dispatcher.UIThread.InvokeAsync(() => ShowNotification(BuildNotificationRequest( Settings, @@ -32,7 +35,10 @@ await Dispatcher.UIThread.InvokeAsync(() => ShowNotification(BuildNotificationRe message, isNotificationEffectEnabled, isNotificationSoundEnabled, - isSpeechEnabled))); + isSpeechEnabled, + isNotificationTopmostEnabled, + durationSeconds, + repeatCounts))); } internal static NotificationRequest BuildNotificationRequest( @@ -41,10 +47,16 @@ internal static NotificationRequest BuildNotificationRequest( string message, bool isNotificationEffectEnabled, bool isNotificationSoundEnabled, - bool isSpeechEnabled) + bool isSpeechEnabled, + bool isNotificationTopmostEnabled = false, + int durationSeconds = 0, + int repeatCounts = 1) { // ClassIsland 的提供方设置优先于请求设置;RemoteCI 的选项来自每条消息,因此禁用前者。 providerSettings.IsSettingsEnabled = false; + // 与 ClassIsland 集控 SendNotification 一致的归一化:显示时长至少 1 秒,重复至少 1 次。 + var duration = durationSeconds <= 0 ? DefaultDurationSeconds : durationSeconds; + var repeats = repeatCounts < 1 ? 1 : repeatCounts; return new NotificationRequest { MaskContent = NotificationContent.CreateTwoIconsMask(title, hasRightIcon: false, factory: x => @@ -53,11 +65,15 @@ internal static NotificationRequest BuildNotificationRequest( x.IsSpeechEnabled = isSpeechEnabled; }), // ClassIsland 以 null 跳过正文阶段;空文本内容仍会显示一个空白正文区域。 - OverlayContent = string.IsNullOrWhiteSpace(message) ? null : NotificationContent.CreateSimpleTextContent(message, factory: x => - { - x.Duration = TimeSpan.FromSeconds(8); - x.IsSpeechEnabled = isSpeechEnabled; - }), + // 重复次数借用 ClassIsland 集控的做法:正文用滚动文本模板重复滚动 N 遍, + // 总时长 = 单条时长 × 次数,因此不需要在插件里排队重复发送同一条提醒。 + OverlayContent = string.IsNullOrWhiteSpace(message) + ? null + : NotificationContent.CreateRollingTextContent( + message, + TimeSpan.FromSeconds(duration) * repeats, + repeats, + factory: x => x.IsSpeechEnabled = isSpeechEnabled), // 每条远程提醒使用发送端选择的效果,不改动插件或 ClassIsland 的全局默认值。 RequestNotificationSettings = { @@ -65,7 +81,11 @@ internal static NotificationRequest BuildNotificationRequest( IsNotificationEffectEnabled = isNotificationEffectEnabled, IsNotificationSoundEnabled = isNotificationSoundEnabled, IsSpeechEnabled = isSpeechEnabled, + IsNotificationTopmostEnabled = isNotificationTopmostEnabled, }, }; } + + /// 与 ClassIsland 集控 SendNotification 一致的默认显示秒数。 + internal const int DefaultDurationSeconds = 5; } diff --git a/plugin/RemoteCI.Plugin/Services/ScheduleChangeExecutor.cs b/plugin/RemoteCI.Plugin/Services/ScheduleChangeExecutor.cs index 9e8dc09..c8d66d3 100644 --- a/plugin/RemoteCI.Plugin/Services/ScheduleChangeExecutor.cs +++ b/plugin/RemoteCI.Plugin/Services/ScheduleChangeExecutor.cs @@ -37,7 +37,7 @@ public static CommandResult Apply( if (validationError is not null) return CommandResult.Failure(CommandResultCodes.InvalidRequest, validationError); - var plan = GetWritablePlan(date, backend, profile); + var plan = GetWritablePlan(date, request.Permanent, backend, profile); if (plan is null) return CommandResult.Failure(CommandResultCodes.ScheduleUnavailable, $"{date:yyyy-MM-dd} 无法创建临时课表层"); validationError = ScheduleMutation.Validate( @@ -66,17 +66,20 @@ public static CommandResult Apply( { Success = true, Code = CommandResultCodes.Ok, - Message = request.Mode == ScheduleChangeMode.Exchange ? "两节课程已临时交换" : "课程已临时替换", + Message = request.Permanent + ? request.Mode == ScheduleChangeMode.Exchange ? "两节课程已永久交换" : "课程已永久替换" + : request.Mode == ScheduleChangeMode.Exchange ? "两节课程已临时交换" : "课程已临时替换", ScheduleRevision = after.Revision, }; } /// 取得可写的课表层:已是临时层直接使用,否则创建临时层并取回。 internal static ClassPlan? GetWritablePlan( - DateTime date, IScheduleBackend backend, IProfileWriteOperations profile) + DateTime date, bool permanent, IScheduleBackend backend, IProfileWriteOperations profile) { var plan = backend.GetClassPlan(date, out var planId); if (plan is null || planId is null) return null; + if (permanent) return plan; if (plan.IsOverlay) return plan; var overlayId = profile.CreateTempClassPlan(planId.Value, date); if (overlayId is null) return null; diff --git a/plugin/RemoteCI.Plugin/Services/SoftwareInventoryService.cs b/plugin/RemoteCI.Plugin/Services/SoftwareInventoryService.cs new file mode 100644 index 0000000..417972a --- /dev/null +++ b/plugin/RemoteCI.Plugin/Services/SoftwareInventoryService.cs @@ -0,0 +1,397 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using Avalonia.Threading; +using ClassIsland.Core; +using ClassIsland.Core.Abstractions.Services; +using ClassIsland.Core.Models.Plugin; +using ClassIsland.Shared; +using Microsoft.Extensions.Logging; +using RemoteCI.Shared; +using RemoteCI.Shared.Models; + +namespace RemoteCI.Plugin.Services; + +/// +/// 采集 ClassIsland 与插件版本,并通过宿主官方服务执行远程升级。 +/// 插件包升级走插件市场缓存,ClassIsland 升级走官方 UpdateService;RemoteCI 不直接替换宿主文件。 +/// +public sealed class SoftwareInventoryService +{ + private static readonly TimeSpan UpdateTimeout = TimeSpan.FromMinutes(15); + private readonly ILogger _logger; + private readonly SemaphoreSlim _operationLock = new(1, 1); + private readonly object _statusLock = new(); + private SoftwareUpdateStatus? _lastUpdate; + + public SoftwareInventoryService(ILogger logger) + { + _logger = logger; + } + + /// 版本清单发生变化时触发;RemoteCiService 监听后推送到服务端。 + public event Action? InventoryChanged; + + public SoftwareInventory Build() + { + var applications = new List(); + var plugins = new List(); + var appVersion = AppBase.AppVersion; + var (appLatestVersion, appCanUpgrade) = GetClassIslandUpdateInfo(); + applications.Add(new SoftwarePackageInfo + { + Id = "classisland", + Name = "ClassIsland", + Version = appVersion, + LatestVersion = appLatestVersion, + IsUpdateAvailable = IsNewerVersion(appLatestVersion, appVersion), + IsEnabled = true, + CanUpgrade = appCanUpgrade, + }); + + try + { + var market = IAppHost.TryGetService(); + // LoadPluginSource 只读取宿主已经缓存的插件索引,不在每次版本上报时访问网络。 + market?.LoadPluginSource(); + foreach (var plugin in IPluginService.LoadedPlugins.OrderBy(x => x.Manifest.Name, StringComparer.CurrentCulture)) + { + var id = plugin.Manifest.Id; + var latestVersion = plugin.Manifest.Version; + var isUpdateAvailable = false; + if (market is not null && market.MergedPlugins.TryGetValue(id, out var merged)) + { + latestVersion = merged.Manifest.Version; + isUpdateAvailable = merged.IsUpdateAvailable; + } + + plugins.Add(new SoftwarePackageInfo + { + Id = id, + Name = plugin.Manifest.Name, + Version = plugin.Manifest.Version, + LatestVersion = latestVersion, + IsUpdateAvailable = isUpdateAvailable, + IsEnabled = IsPluginEnabled(plugin), + CanUpgrade = market is not null, + }); + } + } + catch (Exception ex) + { + // 版本采集不能让云端心跳中断;把宿主能力缺失留给 canUpgrade 表达。 + _logger.LogDebug(ex, "读取 ClassIsland 插件清单失败"); + } + + return new SoftwareInventory + { + DeviceName = Environment.MachineName, + OperatingSystem = RuntimeInformation.OSDescription, + Architecture = RuntimeInformation.OSArchitecture.ToString(), + GeneratedAt = DateTimeOffset.UtcNow, + Applications = applications, + Plugins = plugins, + LastUpdate = GetLastUpdate(), + }; + } + + /// 刷新版本清单并通知上层发送;命令本身不等待网络下载。 + public CommandResult Refresh() + { + SetStatus(SoftwareUpdateOperation.InventoryRefresh, SoftwareUpdateState.Running, "正在读取本机软件版本"); + SetStatus(SoftwareUpdateOperation.InventoryRefresh, SoftwareUpdateState.Completed, "版本清单已刷新"); + RaiseInventoryChanged(); + return Success("版本清单已刷新"); + } + + /// 启动插件批量升级;立即返回,实际下载和重启在后台完成。 + public CommandResult StartPluginUpgrade(SoftwareUpgradeRequest? request) + { + if (!_operationLock.Wait(0)) + return CommandResult.Failure(CommandResultCodes.Busy, "已有软件升级任务正在执行"); + if (IAppHost.TryGetService() is null) + { + _operationLock.Release(); + return CommandResult.Failure(CommandResultCodes.CapabilityUnsupported, "当前 ClassIsland 版本不支持插件市场升级"); + } + + var ids = request?.PluginIds? + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => id.Trim()) + .Distinct(StringComparer.Ordinal) + .ToList(); + _ = Task.Run(() => RunPluginUpgradeAsync(ids, request?.Force == true)); + return Success("已开始升级插件,下载完成后 ClassIsland 将自动重启"); + } + + /// 启动 ClassIsland 主程序升级;立即返回,实际下载和部署在后台完成。 + public CommandResult StartClassIslandUpgrade(SoftwareUpgradeRequest? request) + { + if (!_operationLock.Wait(0)) + return CommandResult.Failure(CommandResultCodes.Busy, "已有软件升级任务正在执行"); + var updateService = TryGetClassIslandUpdateService(); + if (updateService is null) + { + _operationLock.Release(); + return CommandResult.Failure(CommandResultCodes.CapabilityUnsupported, "当前 ClassIsland 版本不支持应用升级"); + } + + _ = Task.Run(() => RunClassIslandUpgradeAsync(updateService, request?.Force == true)); + return Success("已开始升级 ClassIsland,下载并部署完成后将自动重启"); + } + + private async Task RunPluginUpgradeAsync(IReadOnlyCollection? requestedIds, bool force) + { + try + { + SetStatus(SoftwareUpdateOperation.Plugins, SoftwareUpdateState.Running, "正在刷新插件市场索引"); + var market = IAppHost.GetService(); + await market.RefreshPluginSourceAsync(); + market.LoadPluginSource(); + + var requested = requestedIds is { Count: > 0 } + ? new HashSet(requestedIds, StringComparer.Ordinal) + : null; + var candidates = market.MergedPlugins.Values + .Where(plugin => plugin.IsLocal && plugin.IsEnabled) + .Where(plugin => requested is null || requested.Contains(plugin.Manifest.Id)) + .Where(plugin => force || plugin.IsUpdateAvailable) + .ToList(); + if (candidates.Count == 0) + { + SetStatus(SoftwareUpdateOperation.Plugins, SoftwareUpdateState.Completed, "所有插件均已是最新版本"); + RaiseInventoryChanged(); + return; + } + + var targetIds = candidates + .Select(plugin => plugin.Manifest.Id) + .ToHashSet(StringComparer.Ordinal); + SetStatus(SoftwareUpdateOperation.Plugins, SoftwareUpdateState.Running, + $"正在下载 {targetIds.Count} 个插件更新"); + + foreach (var id in targetIds) + market.RequestDownloadPlugin(id); + + var deadline = DateTimeOffset.UtcNow + UpdateTimeout; + while (DateTimeOffset.UtcNow < deadline) + { + var pending = 0; + var succeeded = 0; + var failed = 0; + foreach (var id in targetIds) + { + if (!market.MergedPlugins.TryGetValue(id, out var plugin)) + { + failed++; + continue; + } + if (plugin.DownloadProgress?.Exception is not null) + { + failed++; + continue; + } + if (plugin.RestartRequired) + { + succeeded++; + continue; + } + + // 下载任务尚未建立或仍在进行中,继续等待官方市场服务完成。 + pending++; + } + + if (pending == 0) + { + if (succeeded > 0) + { + SetStatus(SoftwareUpdateOperation.Plugins, SoftwareUpdateState.Completed, + $"已下载 {succeeded} 个插件更新,ClassIsland 即将重启"); + RaiseInventoryChanged(); + await Task.Delay(750); + RestartClassIslandAfterPluginUpdate(); + } + else + { + SetStatus(SoftwareUpdateOperation.Plugins, SoftwareUpdateState.Failed, + $"插件更新失败,失败 {failed} 个"); + RaiseInventoryChanged(); + } + return; + } + + await Task.Delay(500); + } + + SetStatus(SoftwareUpdateOperation.Plugins, SoftwareUpdateState.Failed, "插件更新下载超时"); + RaiseInventoryChanged(); + } + catch (Exception ex) + { + _logger.LogError(ex, "远程升级插件失败"); + SetStatus(SoftwareUpdateOperation.Plugins, SoftwareUpdateState.Failed, + $"插件升级失败:{ex.Message}"); + RaiseInventoryChanged(); + } + finally + { + _operationLock.Release(); + } + } + + private async Task RunClassIslandUpgradeAsync(object updateService, bool force) + { + try + { + SetStatus(SoftwareUpdateOperation.ClassIsland, SoftwareUpdateState.Running, "正在检查 ClassIsland 更新"); + await InvokeTaskAsync(updateService, "CheckUpdateAsync", force, false); + if (GetProperty(updateService, "NetworkErrorException") is { } checkError) + throw new InvalidOperationException(checkError.Message, checkError); + + var (latestVersion, _) = GetClassIslandUpdateInfo(updateService); + var currentVersion = AppBase.AppVersion; + if (!force && !IsNewerVersion(latestVersion, currentVersion)) + { + SetStatus(SoftwareUpdateOperation.ClassIsland, SoftwareUpdateState.Completed, "ClassIsland 已是最新版本"); + RaiseInventoryChanged(); + return; + } + + SetStatus(SoftwareUpdateOperation.ClassIsland, SoftwareUpdateState.Running, "正在下载 ClassIsland 更新"); + await InvokeTaskAsync(updateService, "DownloadUpdateAsync"); + if (GetProperty(updateService, "NetworkErrorException") is { } downloadError) + throw new InvalidOperationException(downloadError.Message, downloadError); + + SetStatus(SoftwareUpdateOperation.ClassIsland, SoftwareUpdateState.Running, "正在部署 ClassIsland 更新"); + await InvokeTaskAsync(updateService, "ExtractUpdateAsync"); + if (GetProperty(updateService, "DeployErrorException") is { } deployError) + throw new InvalidOperationException(deployError.Message, deployError); + + SetStatus(SoftwareUpdateOperation.ClassIsland, SoftwareUpdateState.Completed, + "ClassIsland 更新已部署,即将自动重启"); + RaiseInventoryChanged(); + await Task.Delay(750); + RestartClassIslandAfterAppUpdate(); + } + catch (Exception ex) + { + _logger.LogError(ex, "远程升级 ClassIsland 失败"); + SetStatus(SoftwareUpdateOperation.ClassIsland, SoftwareUpdateState.Failed, + $"ClassIsland 升级失败:{ex.Message}"); + RaiseInventoryChanged(); + } + finally + { + _operationLock.Release(); + } + } + + private SoftwareUpdateStatus? GetLastUpdate() + { + lock (_statusLock) + return _lastUpdate; + } + + private void SetStatus(SoftwareUpdateOperation operation, SoftwareUpdateState state, string message) + { + lock (_statusLock) + { + var now = DateTimeOffset.UtcNow; + var startedAt = _lastUpdate is { } previous && previous.Operation == operation + ? previous.StartedAt + : now; + _lastUpdate = new SoftwareUpdateStatus + { + Operation = operation, + State = state, + Message = message, + StartedAt = startedAt, + CompletedAt = state is SoftwareUpdateState.Completed or SoftwareUpdateState.Failed + ? now + : null, + }; + } + } + + private void RaiseInventoryChanged() + { + try + { + InventoryChanged?.Invoke(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "通知软件版本变化失败"); + } + } + + private static bool IsPluginEnabled(PluginInfo plugin) + { + try { return plugin.IsEnabled; } + catch { return true; } + } + + private (string? LatestVersion, bool CanUpgrade) GetClassIslandUpdateInfo(object? updateService = null) + { + updateService ??= TryGetClassIslandUpdateService(); + if (updateService is null) return (null, false); + + var latestVersion = GetProperty(updateService, "DistributionInfo") is { } info + ? GetProperty(info, "Version") + : null; + var allowedTypes = GetStaticField(updateService.GetType(), "AllowedPackageTypes"); + var canUpgrade = allowedTypes is null || allowedTypes.Contains(AppBase.Current.PackagingType, StringComparer.Ordinal); + return (string.IsNullOrWhiteSpace(latestVersion) ? null : latestVersion.Trim(), canUpgrade); + } + + private static object? TryGetClassIslandUpdateService() + { + var type = AppDomain.CurrentDomain.GetAssemblies() + .Select(assembly => assembly.GetType("ClassIsland.Services.AppUpdating.UpdateService", throwOnError: false)) + .FirstOrDefault(candidate => candidate is not null); + return type is null ? null : IAppHost.Host?.Services.GetService(type); + } + + private static async Task InvokeTaskAsync(object target, string methodName, params object?[] args) + { + var method = target.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public) + ?? throw new MissingMethodException(target.GetType().FullName, methodName); + try + { + if (method.Invoke(target, args) is Task task) + await task; + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + throw ex.InnerException; + } + } + + private static T? GetProperty(object target, string propertyName) + { + var value = target.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public)?.GetValue(target); + return value is T typed ? typed : default; + } + + private static T? GetStaticField(Type type, string fieldName) where T : class => + type.GetField(fieldName, BindingFlags.Static | BindingFlags.Public)?.GetValue(null) as T; + + private static bool IsNewerVersion(string? latest, string current) => + Version.TryParse(latest, out var latestVersion) && + Version.TryParse(current, out var currentVersion) && + latestVersion > currentVersion; + + private static void RestartClassIslandAfterPluginUpdate() => + Dispatcher.UIThread.Post(() => AppBase.Current.Restart()); + + private static void RestartClassIslandAfterAppUpdate() => + Dispatcher.UIThread.Post(() => AppBase.Current.Restart(new[] { "-m" }, true)); + + private static CommandResult Success(string message) => new() + { + Success = true, + Code = CommandResultCodes.Ok, + Message = message, + }; +} + + diff --git a/plugin/RemoteCI.Plugin/Settings/PluginSettings.cs b/plugin/RemoteCI.Plugin/Settings/PluginSettings.cs index cc3e3c0..aed8c07 100644 --- a/plugin/RemoteCI.Plugin/Settings/PluginSettings.cs +++ b/plugin/RemoteCI.Plugin/Settings/PluginSettings.cs @@ -14,6 +14,7 @@ public sealed class PluginSettings : INotifyPropertyChanged private bool _enableCloud = true; private string _cloudServerUrl = "http://localhost:8080"; private string _pluginPairCode = string.Empty; + private string _classNameRemark = string.Empty; private string? _cloudToken; private bool _showDeveloperSettingsMenu; @@ -52,6 +53,13 @@ public string PluginPairCode set => SetField(ref _pluginPairCode, value); } + /// 使用统一连接码时显示在服务端“未分配”列表中的班级名备注。 + public string ClassNameRemark + { + get => _classNameRemark; + set => SetField(ref _classNameRemark, value); + } + /// 是否在 ClassIsland 设置导航中注册 RemoteCI 开发者设置页;默认隐藏。 public bool ShowDeveloperSettingsMenu { diff --git a/plugin/RemoteCI.Plugin/Views/SettingsPages/RemoteCiSettingsPage.cs b/plugin/RemoteCI.Plugin/Views/SettingsPages/RemoteCiSettingsPage.cs index a2d7d66..36f5d3e 100644 --- a/plugin/RemoteCI.Plugin/Views/SettingsPages/RemoteCiSettingsPage.cs +++ b/plugin/RemoteCI.Plugin/Views/SettingsPages/RemoteCiSettingsPage.cs @@ -24,6 +24,7 @@ public sealed class RemoteCiSettingsPage : SettingsPageBase private readonly TextBox _portBox; private readonly TextBox _cloudUrlBox; private readonly TextBox _pairCodeBox; + private readonly TextBox _classNameRemarkBox; private readonly TextBlock _httpWarning; private readonly Button _pushScheduleButton; private readonly Button _testConnectionButton; @@ -40,7 +41,8 @@ public RemoteCiSettingsPage(PluginSettings settings, RemoteCiService? service = _portBox = new TextBox { Text = settings.LanServerPort.ToString(), Watermark = "端口(默认 8765)" }; _cloudUrlBox = new TextBox { Text = settings.CloudServerUrl, Watermark = "云端地址,如 https://nas:8080" }; - _pairCodeBox = new TextBox { Text = settings.PluginPairCode, Watermark = "WebUI 生成的一次性插件配对码" }; + _pairCodeBox = new TextBox { Text = settings.PluginPairCode, Watermark = "班级配对码或统一连接码" }; + _classNameRemarkBox = new TextBox { Text = settings.ClassNameRemark, Watermark = "统一连接码下填写,例如:高一(1)班" }; _showDeveloperSettingsToggle = new ToggleSwitch { IsChecked = settings.ShowDeveloperSettingsMenu, @@ -122,7 +124,22 @@ public RemoteCiSettingsPage(PluginSettings settings, RemoteCiService? service = new StackPanel { Spacing = 6, - Children = { new TextBlock { Text = "一次性插件配对码" }, _pairCodeBox }, + Children = { new TextBlock { Text = "插件连接码" }, _pairCodeBox }, + }, + new StackPanel + { + Spacing = 6, + Children = + { + new TextBlock { Text = "统一连接码班级备注" }, + _classNameRemarkBox, + new TextBlock + { + Text = "填写统一连接码时使用;设备会先进入服务端“未分配”,管理员确认后才能控制。", + Opacity = 0.7, + TextWrapping = Avalonia.Media.TextWrapping.Wrap, + }, + }, }, new Grid { @@ -205,6 +222,7 @@ private void OnSaveClick(object? sender, RoutedEventArgs e) ? "http://localhost:8080" : urlText; _settings.PluginPairCode = _pairCodeBox.Text?.Trim() ?? string.Empty; + _settings.ClassNameRemark = _classNameRemarkBox.Text?.Trim() ?? string.Empty; _settings.ShowDeveloperSettingsMenu = _showDeveloperSettingsToggle.IsChecked == true; // 属性变更已由 Plugin.cs 的 PropertyChanged 订阅自动落盘,无需重复写 Settings.json。 diff --git a/plugin/tests/RemoteCI.Plugin.Tests/CommandPermissionsTests.cs b/plugin/tests/RemoteCI.Plugin.Tests/CommandPermissionsTests.cs index f314b80..a2264a4 100644 --- a/plugin/tests/RemoteCI.Plugin.Tests/CommandPermissionsTests.cs +++ b/plugin/tests/RemoteCI.Plugin.Tests/CommandPermissionsTests.cs @@ -14,6 +14,16 @@ public sealed class CommandPermissionsTests [InlineData(CommandKind.SetMainMenuVisibility, UserPermissions.MainMenuControl)] [InlineData(CommandKind.Power, UserPermissions.PowerControl)] [InlineData(CommandKind.Volume, UserPermissions.PowerControl)] + [InlineData(CommandKind.RefreshSoftwareInventory, UserPermissions.ManageUsers)] + [InlineData(CommandKind.UpgradePlugins, UserPermissions.ManageUsers)] + [InlineData(CommandKind.UpgradeClassIsland, UserPermissions.ManageUsers)] + [InlineData(CommandKind.InstallPlugins, UserPermissions.ManageUsers)] + [InlineData(CommandKind.UninstallPlugins, UserPermissions.ManageUsers)] + [InlineData(CommandKind.SetPluginEnabled, UserPermissions.ManageUsers)] + [InlineData(CommandKind.SetPluginManagementPolicy, UserPermissions.ManageUsers)] + [InlineData(CommandKind.DistributeProfile, UserPermissions.ManageUsers)] + [InlineData(CommandKind.UpdateTimeLayout, UserPermissions.ManageUsers)] + [InlineData(CommandKind.JoinManagement, UserPermissions.ManageUsers)] public void ControlCommands_UseExpectedPermission(CommandKind command, UserPermissions expected) { Assert.Equal(expected, CommandPermissions.Required(command)); @@ -35,6 +45,23 @@ public void BreakingPermissionModelUsesProtocolVersionThree() Assert.Contains("\"protocolVersion\":3", json, StringComparison.Ordinal); } + [Fact] + public void SoftwareUpgradeCapabilitiesAreOptInAndNamed() + { + Assert.DoesNotContain(RemoteCiCapabilities.SoftwareInventory, RemoteCiCapabilities.Baseline); + Assert.DoesNotContain(RemoteCiCapabilities.SoftwareUpgradePlugins, RemoteCiCapabilities.Baseline); + Assert.DoesNotContain(RemoteCiCapabilities.SoftwareUpgradeClassIsland, RemoteCiCapabilities.Baseline); + Assert.Contains(RemoteCiCapabilities.SoftwareInventory, RemoteCiCapabilities.Current); + Assert.Contains(RemoteCiCapabilities.SoftwareUpgradePlugins, RemoteCiCapabilities.Current); + Assert.Contains(RemoteCiCapabilities.SoftwareUpgradeClassIsland, RemoteCiCapabilities.Current); + Assert.NotEqual("未知能力", RemoteCiCapabilities.ChineseName(RemoteCiCapabilities.SoftwareUpgradeClassIsland)); + Assert.DoesNotContain(RemoteCiCapabilities.PluginInstall, RemoteCiCapabilities.Baseline); + Assert.DoesNotContain(RemoteCiCapabilities.ManagementJoin, RemoteCiCapabilities.Baseline); + Assert.Contains(RemoteCiCapabilities.PluginInstall, RemoteCiCapabilities.Current); + Assert.Contains(RemoteCiCapabilities.ProfileDistribute, RemoteCiCapabilities.Current); + Assert.Contains(RemoteCiCapabilities.TimeLayoutUpdate, RemoteCiCapabilities.Current); + Assert.Contains(RemoteCiCapabilities.ManagementJoin, RemoteCiCapabilities.Current); + } [Fact] public void EveryBaselineCapabilityHasChineseDiagnosticName() { diff --git a/plugin/tests/RemoteCI.Plugin.Tests/RemoteNotificationProviderTests.cs b/plugin/tests/RemoteCI.Plugin.Tests/RemoteNotificationProviderTests.cs index 498aa56..01836cc 100644 --- a/plugin/tests/RemoteCI.Plugin.Tests/RemoteNotificationProviderTests.cs +++ b/plugin/tests/RemoteCI.Plugin.Tests/RemoteNotificationProviderTests.cs @@ -34,6 +34,61 @@ public void EmptyBody_OnlyShowsTitle(string message) Assert.Null(request.OverlayContent); } + [Theory] + [InlineData(0, 5)] + [InlineData(-3, 5)] + [InlineData(1, 1)] + [InlineData(15, 15)] + public void DurationSeconds_FallsBackToClassIslandDefault(int requested, int expected) + { + var request = RemoteNotificationProvider.BuildNotificationRequest( + new NotificationSettings(), "标题", "正文", + isNotificationEffectEnabled: false, + isNotificationSoundEnabled: false, + isSpeechEnabled: false, + durationSeconds: requested); + + // 正文滚动 1 次时,总时长等于单条时长。 + Assert.Equal(TimeSpan.FromSeconds(expected), request.OverlayContent!.Duration); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(1, 1)] + [InlineData(3, 3)] + public void RepeatCounts_MultiplyOverlayDuration(int repeatCounts, int expectedRepeats) + { + var request = RemoteNotificationProvider.BuildNotificationRequest( + new NotificationSettings(), "标题", "正文", + isNotificationEffectEnabled: false, + isNotificationSoundEnabled: false, + isSpeechEnabled: false, + durationSeconds: 5, + repeatCounts: repeatCounts); + + // 总显示时长 = 持续时间 × 重复次数,与 ClassIsland 集控 SendNotification 一致。 + Assert.Equal(TimeSpan.FromSeconds(5 * expectedRepeats), request.OverlayContent!.Duration); + var template = Assert.IsType( + request.OverlayContent.Content); + // RepeatCount 只保存在模板的数据上下文里,控件本身不暴露该属性。 + var data = Assert.IsType( + template.DataContext); + Assert.Equal(expectedRepeats, data.RepeatCount); + } + + [Fact] + public void TopmostEnabled_PassesThroughToRequestSettings() + { + var request = RemoteNotificationProvider.BuildNotificationRequest( + new NotificationSettings(), "标题", "正文", + isNotificationEffectEnabled: false, + isNotificationSoundEnabled: false, + isSpeechEnabled: false, + isNotificationTopmostEnabled: true); + + Assert.True(request.RequestNotificationSettings.IsNotificationTopmostEnabled); + } + [Fact] public void PerMessageOptionsOverrideEnabledProviderDefaults() { diff --git a/plugin/tests/RemoteCI.Plugin.Tests/ScheduleChangeExecutorTests.cs b/plugin/tests/RemoteCI.Plugin.Tests/ScheduleChangeExecutorTests.cs index 7efec7f..2f30ee0 100644 --- a/plugin/tests/RemoteCI.Plugin.Tests/ScheduleChangeExecutorTests.cs +++ b/plugin/tests/RemoteCI.Plugin.Tests/ScheduleChangeExecutorTests.cs @@ -147,6 +147,23 @@ public void Apply_ExchangeSuccessSwapsCoursesSavesAndReturnsNewRevision() Assert.NotEqual(before.Revision, result.ScheduleRevision); } + [Fact] + public void Apply_PermanentExchangeWritesSourcePlanWithoutCreatingOverlay() + { + var (catalog, backend, profile, plan, subjectIds) = CreateHarness(overlay: false); + var before = catalog.BuildDay(TestDay); + var request = Exchange(before.Revision); + request.Permanent = true; + + var result = ScheduleChangeExecutor.Apply(TestDay, request, catalog, backend, profile); + + Assert.True(result.Success); + Assert.Contains("永久交换", result.Message); + Assert.Equal(subjectIds[1], plan.Classes[0].SubjectId); + Assert.Equal(1, profile.SaveCount); + Assert.Null(profile.NextOverlayId); + } + [Fact] public void Apply_SaveFailureRollsBackMutationAndReportsSaveFailed() { @@ -168,7 +185,7 @@ public void GetWritablePlan_OverlayPlanIsReturnedDirectlyWithoutCreatingAnother( var (_, backend, profile, plan, _) = CreateHarness(overlay: true); profile.NextOverlayId = Guid.NewGuid(); // 不应被使用。 - var writable = ScheduleChangeExecutor.GetWritablePlan(TestDay, backend, profile); + var writable = ScheduleChangeExecutor.GetWritablePlan(TestDay, false, backend, profile); Assert.Same(plan, writable); } @@ -179,7 +196,7 @@ public void GetWritablePlan_OverlayCreationFailureReturnsNull() var (_, backend, profile, _, _) = CreateHarness(overlay: false); profile.NextOverlayId = null; - Assert.Null(ScheduleChangeExecutor.GetWritablePlan(TestDay, backend, profile)); + Assert.Null(ScheduleChangeExecutor.GetWritablePlan(TestDay, false, backend, profile)); } [Fact] @@ -193,7 +210,7 @@ public void GetWritablePlan_FallsBackToProfileClassPlansWhenBackendStillReturnsS // 第二次解析仍返回源课表(非 overlay),但按 overlayId 查 Profile.ClassPlans。 backend.PlanIdAfterCreation = overlayId; - var writable = ScheduleChangeExecutor.GetWritablePlan(TestDay, backend, profile); + var writable = ScheduleChangeExecutor.GetWritablePlan(TestDay, false, backend, profile); Assert.Same(overlay, writable); } @@ -208,7 +225,7 @@ public void GetWritablePlan_PrefersOverlayRefreshedFromBackend() backend.PlanAfterCreation = refreshed; backend.PlanIdAfterCreation = overlayId; - var writable = ScheduleChangeExecutor.GetWritablePlan(TestDay, backend, profile); + var writable = ScheduleChangeExecutor.GetWritablePlan(TestDay, false, backend, profile); Assert.Same(refreshed, writable); } diff --git a/server/RemoteCI.Server/Data/AppDbContext.cs b/server/RemoteCI.Server/Data/AppDbContext.cs index f06f9fd..b8d93d8 100644 --- a/server/RemoteCI.Server/Data/AppDbContext.cs +++ b/server/RemoteCI.Server/Data/AppDbContext.cs @@ -9,6 +9,7 @@ public sealed class AppDbContext(DbContextOptions options) : IdentityDbContext, Guid>(options) { public DbSet DeviceSessions => Set(); + public DbSet UserApiKeys => Set(); public DbSet PluginCredentials => Set(); public DbSet PluginPairingCodes => Set(); public DbSet SystemMetadata => Set(); @@ -32,6 +33,10 @@ protected override void OnModelCreating(ModelBuilder builder) entity.HasIndex(x => x.Version); entity.HasOne(x => x.RoleDefinition).WithMany(x => x.Users).HasForeignKey(x => x.RoleDefinitionId).OnDelete(DeleteBehavior.Restrict); }); + builder.Entity(entity => + { + entity.Property(x => x.LoginBackgroundContentType).HasMaxLength(64); + }); builder.Entity(entity => { entity.Property(x => x.Name).HasMaxLength(40); @@ -62,11 +67,22 @@ protected override void OnModelCreating(ModelBuilder builder) entity.Property(x => x.AccessTokenHash).HasMaxLength(64); entity.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); }); + builder.Entity(entity => + { + entity.HasIndex(x => x.KeyHash).IsUnique(); + entity.HasIndex(x => new { x.UserId, x.RevokedAt }); + entity.Property(x => x.Name).HasMaxLength(40); + entity.Property(x => x.KeyHash).HasMaxLength(64); + entity.Property(x => x.Prefix).HasMaxLength(16); + entity.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + }); builder.Entity(entity => { entity.HasIndex(x => x.TokenHash).IsUnique(); entity.Property(x => x.TokenHash).HasMaxLength(64); entity.Property(x => x.Name).HasMaxLength(80); + entity.Property(x => x.ClassNameRemark).HasMaxLength(40); + entity.Property(x => x.SoftwareInventoryJson).HasMaxLength(65536); entity.HasOne(x => x.Classroom).WithMany().HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.Cascade); }); builder.Entity(entity => diff --git a/server/RemoteCI.Server/Data/Entities.cs b/server/RemoteCI.Server/Data/Entities.cs index 37f96f3..04d3a75 100644 --- a/server/RemoteCI.Server/Data/Entities.cs +++ b/server/RemoteCI.Server/Data/Entities.cs @@ -15,6 +15,25 @@ public sealed class DeviceSession public DateTimeOffset? RevokedAt { get; set; } } +/// +/// 用户 API Key。密钥明文只在创建时展示一次,数据库只保存 SHA-256 摘要; +/// 权限不复制到密钥上,每次调用都重新按用户当前角色与授权计算。 +/// +public sealed class UserApiKey +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public AppUser User { get; set; } = null!; + public string Name { get; set; } = string.Empty; + public string KeyHash { get; set; } = string.Empty; + /// 用于列表识别的前缀,不是完整密钥。 + public string Prefix { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? LastUsedAt { get; set; } + public DateTimeOffset? ExpiresAt { get; set; } + public DateTimeOffset? RevokedAt { get; set; } +} + public sealed class PluginCredential { public Guid Id { get; set; } @@ -23,8 +42,16 @@ public sealed class PluginCredential public bool Enabled { get; set; } = true; public Guid ClassroomId { get; set; } = Classroom.DefaultId; public Classroom Classroom { get; set; } = null!; + /// 统一连接码创建的凭据先保持未分配;分配后才可参与班级数据和命令路由。 + public bool Assigned { get; set; } = true; + public string? ClassNameRemark { get; set; } public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset LastSeenAt { get; set; } + + /// 最近一次软件版本清单的 JSON 快照;用于设备离线时仍可查看最后版本。 + public string? SoftwareInventoryJson { get; set; } + + public DateTimeOffset? SoftwareInventoryAt { get; set; } } public sealed class PluginPairingCode @@ -34,6 +61,10 @@ public sealed class PluginPairingCode /// 配对成功后插件凭据归属的班级;旧数据回填为默认班级。 public Guid ClassroomId { get; set; } = Classroom.DefaultId; + /// 共享连接码可被无限次消费;班级配对码仍由 UsedAt 控制为一次性。 + public bool IsShared { get; set; } + /// 班级固定配对码:可重复使用,绑定到特定班级,班级创建/导入时由管理员指定。 + public bool IsPersistent { get; set; } public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset ExpiresAt { get; set; } public DateTimeOffset? UsedAt { get; set; } @@ -52,6 +83,36 @@ public sealed class SystemMetadata /// 启用访客功能时,访问 WebUI 落地页直接进入访客课表;没有任何班级启用访客功能时无效。 public bool AutoEnterVisitorPage { get; set; } + + /// 登录页主题:管理员可强制浅色/深色,默认跟随访客本地偏好。 + public LoginTheme LoginTheme { get; set; } = LoginTheme.Follow; + + /// 登录页背景图;null 表示使用默认网格背景。图片存库以便随配置一起备份与迁移。 + public byte[]? LoginBackground { get; set; } + public string? LoginBackgroundContentType { get; set; } + public DateTimeOffset? LoginBackgroundUpdatedAt { get; set; } + + /// 登录页背景图不透明度百分比(0-100);仅在设置了背景图时生效。 + public int LoginBackgroundOpacity { get; set; } = 100; + + /// 登录页卡片在页面中的水平位置;默认居中。 + public LoginCardPosition LoginCardPosition { get; set; } = LoginCardPosition.Center; +} + +/// 登录页主题策略:跟随访客本地偏好,或由管理员强制浅色/深色。 +public enum LoginTheme +{ + Follow = 0, + Light = 1, + Dark = 2, +} + +/// 登录页卡片的水平位置:左、中、右。 +public enum LoginCardPosition +{ + Center = 0, + Left = 1, + Right = 2, } /// 班级:一个班级对应一台教室端 ClassIsland 插件与其课表/状态流。 diff --git a/server/RemoteCI.Server/Data/Migrations/20260926144130_AddPluginSoftwareInventory.Designer.cs b/server/RemoteCI.Server/Data/Migrations/20260926144130_AddPluginSoftwareInventory.Designer.cs new file mode 100644 index 0000000..ef8c28a --- /dev/null +++ b/server/RemoteCI.Server/Data/Migrations/20260926144130_AddPluginSoftwareInventory.Designer.cs @@ -0,0 +1,786 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RemoteCI.Server.Data; + +#nullable disable + +namespace RemoteCI.Server.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260926144130_AddPluginSoftwareInventory")] + partial class AddPluginSoftwareInventory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AccountRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DefaultPermissions") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("AccountRoles"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("GrantedPermissions") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PasswordPending") + .HasColumnType("INTEGER"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("RoleDefinitionId") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("SetupTokenExpiresAt") + .HasColumnType("TEXT"); + + b.Property("SetupTokenHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("RoleDefinitionId"); + + b.HasIndex("Version"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.BackupConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Cadence") + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastScheduledAt") + .HasColumnType("TEXT"); + + b.Property("LastSucceededAt") + .HasColumnType("TEXT"); + + b.Property("MaxBackups") + .HasColumnType("INTEGER"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BackupConfigurations"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ClassGroups"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroupAssignment", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "ClassroomId"); + + b.HasIndex("ClassroomId"); + + b.ToTable("ClassGroupAssignments"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.Property("RoleDefinitionId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "ClassroomId"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("RoleDefinitionId"); + + b.ToTable("ClassMemberships"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.Classroom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Avatar") + .HasColumnType("BLOB"); + + b.Property("AvatarContentType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AvatarUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitorAccessEnabled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Classrooms"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.DeviceSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessExpiresAt") + .HasColumnType("TEXT"); + + b.Property("AccessTokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("VerifierHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessTokenHash") + .IsUnique(); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("DeviceSessions"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ExtensionPolicy", b => + { + b.Property("ExtensionId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AllowNonAdmin") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("ExtensionId"); + + b.ToTable("ExtensionPolicies"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.PluginCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("SoftwareInventoryAt") + .HasColumnType("TEXT"); + + b.Property("SoftwareInventoryJson") + .HasMaxLength(65536) + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PluginCredentials"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.PluginPairingCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("UsedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CodeHash") + .IsUnique(); + + b.ToTable("PluginPairingCodes"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.SystemMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountVersion") + .HasColumnType("INTEGER"); + + b.Property("AutoEnterVisitorPage") + .HasColumnType("INTEGER"); + + b.Property("ForceSenderInTitle") + .HasColumnType("INTEGER"); + + b.Property("SchedulePullIntervalMinutes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SystemMetadata"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.UserExtensionPreference", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ExtensionId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ShowOnWatch") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "ExtensionId"); + + b.ToTable("UserExtensionPreferences"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AppUser", b => + { + b.HasOne("RemoteCI.Server.Data.AccountRole", "RoleDefinition") + .WithMany("Users") + .HasForeignKey("RoleDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("RoleDefinition"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroup", b => + { + b.HasOne("RemoteCI.Server.Data.ClassGroup", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroupAssignment", b => + { + b.HasOne("RemoteCI.Server.Data.Classroom", "Classroom") + .WithMany("GroupAssignments") + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.ClassGroup", "Group") + .WithMany("Assignments") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassMembership", b => + { + b.HasOne("RemoteCI.Server.Data.Classroom", "Classroom") + .WithMany("Memberships") + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.AccountRole", "RoleDefinition") + .WithMany() + .HasForeignKey("RoleDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("RoleDefinition"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.DeviceSession", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.PluginCredential", b => + { + b.HasOne("RemoteCI.Server.Data.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.UserExtensionPreference", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AccountRole", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroup", b => + { + b.Navigation("Assignments"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.Classroom", b => + { + b.Navigation("GroupAssignments"); + + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/RemoteCI.Server/Data/Migrations/20260926144130_AddPluginSoftwareInventory.cs b/server/RemoteCI.Server/Data/Migrations/20260926144130_AddPluginSoftwareInventory.cs new file mode 100644 index 0000000..809444a --- /dev/null +++ b/server/RemoteCI.Server/Data/Migrations/20260926144130_AddPluginSoftwareInventory.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RemoteCI.Server.Data.Migrations +{ + /// + public partial class AddPluginSoftwareInventory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SoftwareInventoryAt", + table: "PluginCredentials", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SoftwareInventoryJson", + table: "PluginCredentials", + type: "TEXT", + maxLength: 65536, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SoftwareInventoryAt", + table: "PluginCredentials"); + + migrationBuilder.DropColumn( + name: "SoftwareInventoryJson", + table: "PluginCredentials"); + } + } +} diff --git a/server/RemoteCI.Server/Data/Migrations/20260926181318_AddLoginPageSettings.Designer.cs b/server/RemoteCI.Server/Data/Migrations/20260926181318_AddLoginPageSettings.Designer.cs new file mode 100644 index 0000000..6da4966 --- /dev/null +++ b/server/RemoteCI.Server/Data/Migrations/20260926181318_AddLoginPageSettings.Designer.cs @@ -0,0 +1,802 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RemoteCI.Server.Data; + +#nullable disable + +namespace RemoteCI.Server.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260926181318_AddLoginPageSettings")] + partial class AddLoginPageSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AccountRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DefaultPermissions") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("AccountRoles"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("GrantedPermissions") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PasswordPending") + .HasColumnType("INTEGER"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("RoleDefinitionId") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("SetupTokenExpiresAt") + .HasColumnType("TEXT"); + + b.Property("SetupTokenHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("RoleDefinitionId"); + + b.HasIndex("Version"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.BackupConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Cadence") + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastScheduledAt") + .HasColumnType("TEXT"); + + b.Property("LastSucceededAt") + .HasColumnType("TEXT"); + + b.Property("MaxBackups") + .HasColumnType("INTEGER"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BackupConfigurations"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ClassGroups"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroupAssignment", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "ClassroomId"); + + b.HasIndex("ClassroomId"); + + b.ToTable("ClassGroupAssignments"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.Property("RoleDefinitionId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "ClassroomId"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("RoleDefinitionId"); + + b.ToTable("ClassMemberships"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.Classroom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Avatar") + .HasColumnType("BLOB"); + + b.Property("AvatarContentType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AvatarUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitorAccessEnabled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Classrooms"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.DeviceSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessExpiresAt") + .HasColumnType("TEXT"); + + b.Property("AccessTokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("VerifierHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessTokenHash") + .IsUnique(); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("DeviceSessions"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ExtensionPolicy", b => + { + b.Property("ExtensionId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AllowNonAdmin") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("ExtensionId"); + + b.ToTable("ExtensionPolicies"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.PluginCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("SoftwareInventoryAt") + .HasColumnType("TEXT"); + + b.Property("SoftwareInventoryJson") + .HasMaxLength(65536) + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PluginCredentials"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.PluginPairingCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("UsedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CodeHash") + .IsUnique(); + + b.ToTable("PluginPairingCodes"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.SystemMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountVersion") + .HasColumnType("INTEGER"); + + b.Property("AutoEnterVisitorPage") + .HasColumnType("INTEGER"); + + b.Property("ForceSenderInTitle") + .HasColumnType("INTEGER"); + + b.Property("LoginBackground") + .HasColumnType("BLOB"); + + b.Property("LoginBackgroundContentType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LoginBackgroundOpacity") + .HasColumnType("INTEGER"); + + b.Property("LoginBackgroundUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("LoginTheme") + .HasColumnType("INTEGER"); + + b.Property("SchedulePullIntervalMinutes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SystemMetadata"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.UserExtensionPreference", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ExtensionId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ShowOnWatch") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "ExtensionId"); + + b.ToTable("UserExtensionPreferences"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AppUser", b => + { + b.HasOne("RemoteCI.Server.Data.AccountRole", "RoleDefinition") + .WithMany("Users") + .HasForeignKey("RoleDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("RoleDefinition"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroup", b => + { + b.HasOne("RemoteCI.Server.Data.ClassGroup", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroupAssignment", b => + { + b.HasOne("RemoteCI.Server.Data.Classroom", "Classroom") + .WithMany("GroupAssignments") + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.ClassGroup", "Group") + .WithMany("Assignments") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassMembership", b => + { + b.HasOne("RemoteCI.Server.Data.Classroom", "Classroom") + .WithMany("Memberships") + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.AccountRole", "RoleDefinition") + .WithMany() + .HasForeignKey("RoleDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("RoleDefinition"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.DeviceSession", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.PluginCredential", b => + { + b.HasOne("RemoteCI.Server.Data.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.UserExtensionPreference", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AccountRole", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroup", b => + { + b.Navigation("Assignments"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.Classroom", b => + { + b.Navigation("GroupAssignments"); + + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/RemoteCI.Server/Data/Migrations/20260926181318_AddLoginPageSettings.cs b/server/RemoteCI.Server/Data/Migrations/20260926181318_AddLoginPageSettings.cs new file mode 100644 index 0000000..cf97287 --- /dev/null +++ b/server/RemoteCI.Server/Data/Migrations/20260926181318_AddLoginPageSettings.cs @@ -0,0 +1,72 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RemoteCI.Server.Data.Migrations +{ + /// + public partial class AddLoginPageSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LoginBackground", + table: "SystemMetadata", + type: "BLOB", + nullable: true); + + migrationBuilder.AddColumn( + name: "LoginBackgroundContentType", + table: "SystemMetadata", + type: "TEXT", + maxLength: 64, + nullable: true); + + migrationBuilder.AddColumn( + name: "LoginBackgroundOpacity", + table: "SystemMetadata", + type: "INTEGER", + nullable: false, + defaultValue: 100); + + migrationBuilder.AddColumn( + name: "LoginBackgroundUpdatedAt", + table: "SystemMetadata", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "LoginTheme", + table: "SystemMetadata", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LoginBackground", + table: "SystemMetadata"); + + migrationBuilder.DropColumn( + name: "LoginBackgroundContentType", + table: "SystemMetadata"); + + migrationBuilder.DropColumn( + name: "LoginBackgroundOpacity", + table: "SystemMetadata"); + + migrationBuilder.DropColumn( + name: "LoginBackgroundUpdatedAt", + table: "SystemMetadata"); + + migrationBuilder.DropColumn( + name: "LoginTheme", + table: "SystemMetadata"); + } + } +} diff --git a/server/RemoteCI.Server/Data/Migrations/20260927104500_AddLoginCardPosition.Designer.cs b/server/RemoteCI.Server/Data/Migrations/20260927104500_AddLoginCardPosition.Designer.cs new file mode 100644 index 0000000..f23a2ae --- /dev/null +++ b/server/RemoteCI.Server/Data/Migrations/20260927104500_AddLoginCardPosition.Designer.cs @@ -0,0 +1,805 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RemoteCI.Server.Data; + +#nullable disable + +namespace RemoteCI.Server.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260927104500_AddLoginCardPosition")] + partial class AddLoginCardPosition + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AccountRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DefaultPermissions") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("AccountRoles"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("GrantedPermissions") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PasswordPending") + .HasColumnType("INTEGER"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("RoleDefinitionId") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("SetupTokenExpiresAt") + .HasColumnType("TEXT"); + + b.Property("SetupTokenHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("RoleDefinitionId"); + + b.HasIndex("Version"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.BackupConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Cadence") + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastScheduledAt") + .HasColumnType("TEXT"); + + b.Property("LastSucceededAt") + .HasColumnType("TEXT"); + + b.Property("MaxBackups") + .HasColumnType("INTEGER"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BackupConfigurations"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ClassGroups"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroupAssignment", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "ClassroomId"); + + b.HasIndex("ClassroomId"); + + b.ToTable("ClassGroupAssignments"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.Property("RoleDefinitionId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "ClassroomId"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("RoleDefinitionId"); + + b.ToTable("ClassMemberships"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.Classroom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Avatar") + .HasColumnType("BLOB"); + + b.Property("AvatarContentType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AvatarUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitorAccessEnabled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Classrooms"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.DeviceSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessExpiresAt") + .HasColumnType("TEXT"); + + b.Property("AccessTokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("VerifierHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessTokenHash") + .IsUnique(); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("DeviceSessions"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ExtensionPolicy", b => + { + b.Property("ExtensionId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AllowNonAdmin") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("ExtensionId"); + + b.ToTable("ExtensionPolicies"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.PluginCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("SoftwareInventoryAt") + .HasColumnType("TEXT"); + + b.Property("SoftwareInventoryJson") + .HasMaxLength(65536) + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PluginCredentials"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.PluginPairingCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("UsedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CodeHash") + .IsUnique(); + + b.ToTable("PluginPairingCodes"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.SystemMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountVersion") + .HasColumnType("INTEGER"); + + b.Property("AutoEnterVisitorPage") + .HasColumnType("INTEGER"); + + b.Property("ForceSenderInTitle") + .HasColumnType("INTEGER"); + + b.Property("LoginBackground") + .HasColumnType("BLOB"); + + b.Property("LoginBackgroundContentType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LoginBackgroundOpacity") + .HasColumnType("INTEGER"); + + b.Property("LoginBackgroundUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("LoginCardPosition") + .HasColumnType("INTEGER"); + + b.Property("LoginTheme") + .HasColumnType("INTEGER"); + + b.Property("SchedulePullIntervalMinutes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SystemMetadata"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.UserExtensionPreference", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ExtensionId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ShowOnWatch") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "ExtensionId"); + + b.ToTable("UserExtensionPreferences"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AppUser", b => + { + b.HasOne("RemoteCI.Server.Data.AccountRole", "RoleDefinition") + .WithMany("Users") + .HasForeignKey("RoleDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("RoleDefinition"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroup", b => + { + b.HasOne("RemoteCI.Server.Data.ClassGroup", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroupAssignment", b => + { + b.HasOne("RemoteCI.Server.Data.Classroom", "Classroom") + .WithMany("GroupAssignments") + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.ClassGroup", "Group") + .WithMany("Assignments") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassMembership", b => + { + b.HasOne("RemoteCI.Server.Data.Classroom", "Classroom") + .WithMany("Memberships") + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.AccountRole", "RoleDefinition") + .WithMany() + .HasForeignKey("RoleDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("RoleDefinition"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.DeviceSession", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.PluginCredential", b => + { + b.HasOne("RemoteCI.Server.Data.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.UserExtensionPreference", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AccountRole", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroup", b => + { + b.Navigation("Assignments"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.Classroom", b => + { + b.Navigation("GroupAssignments"); + + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/RemoteCI.Server/Data/Migrations/20260927104500_AddLoginCardPosition.cs b/server/RemoteCI.Server/Data/Migrations/20260927104500_AddLoginCardPosition.cs new file mode 100644 index 0000000..f5752c7 --- /dev/null +++ b/server/RemoteCI.Server/Data/Migrations/20260927104500_AddLoginCardPosition.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RemoteCI.Server.Data.Migrations +{ + /// + public partial class AddLoginCardPosition : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LoginCardPosition", + table: "SystemMetadata", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LoginCardPosition", + table: "SystemMetadata"); + } + } +} \ No newline at end of file diff --git a/server/RemoteCI.Server/Data/Migrations/20260927110504_AddApiKeys.Designer.cs b/server/RemoteCI.Server/Data/Migrations/20260927110504_AddApiKeys.Designer.cs new file mode 100644 index 0000000..0b7f227 --- /dev/null +++ b/server/RemoteCI.Server/Data/Migrations/20260927110504_AddApiKeys.Designer.cs @@ -0,0 +1,862 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RemoteCI.Server.Data; + +#nullable disable + +namespace RemoteCI.Server.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260927110504_AddApiKeys")] + partial class AddApiKeys + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AccountRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DefaultPermissions") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("AccountRoles"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("GrantedPermissions") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PasswordPending") + .HasColumnType("INTEGER"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("RoleDefinitionId") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("SetupTokenExpiresAt") + .HasColumnType("TEXT"); + + b.Property("SetupTokenHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("RoleDefinitionId"); + + b.HasIndex("Version"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.BackupConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Cadence") + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastScheduledAt") + .HasColumnType("TEXT"); + + b.Property("LastSucceededAt") + .HasColumnType("TEXT"); + + b.Property("MaxBackups") + .HasColumnType("INTEGER"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BackupConfigurations"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ClassGroups"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroupAssignment", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "ClassroomId"); + + b.HasIndex("ClassroomId"); + + b.ToTable("ClassGroupAssignments"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.Property("RoleDefinitionId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "ClassroomId"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("RoleDefinitionId"); + + b.ToTable("ClassMemberships"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.Classroom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Avatar") + .HasColumnType("BLOB"); + + b.Property("AvatarContentType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AvatarUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitorAccessEnabled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Classrooms"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.DeviceSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessExpiresAt") + .HasColumnType("TEXT"); + + b.Property("AccessTokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("VerifierHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessTokenHash") + .IsUnique(); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("DeviceSessions"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ExtensionPolicy", b => + { + b.Property("ExtensionId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AllowNonAdmin") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("ExtensionId"); + + b.ToTable("ExtensionPolicies"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.PluginCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("SoftwareInventoryAt") + .HasColumnType("TEXT"); + + b.Property("SoftwareInventoryJson") + .HasMaxLength(65536) + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PluginCredentials"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.PluginPairingCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("UsedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CodeHash") + .IsUnique(); + + b.ToTable("PluginPairingCodes"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.SystemMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountVersion") + .HasColumnType("INTEGER"); + + b.Property("AutoEnterVisitorPage") + .HasColumnType("INTEGER"); + + b.Property("ForceSenderInTitle") + .HasColumnType("INTEGER"); + + b.Property("LoginBackground") + .HasColumnType("BLOB"); + + b.Property("LoginBackgroundContentType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LoginBackgroundOpacity") + .HasColumnType("INTEGER"); + + b.Property("LoginBackgroundUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("LoginCardPosition") + .HasColumnType("INTEGER"); + + b.Property("LoginTheme") + .HasColumnType("INTEGER"); + + b.Property("SchedulePullIntervalMinutes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SystemMetadata"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.UserApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("UserApiKeys"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.UserExtensionPreference", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ExtensionId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ShowOnWatch") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "ExtensionId"); + + b.ToTable("UserExtensionPreferences"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AppUser", b => + { + b.HasOne("RemoteCI.Server.Data.AccountRole", "RoleDefinition") + .WithMany("Users") + .HasForeignKey("RoleDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("RoleDefinition"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroup", b => + { + b.HasOne("RemoteCI.Server.Data.ClassGroup", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroupAssignment", b => + { + b.HasOne("RemoteCI.Server.Data.Classroom", "Classroom") + .WithMany("GroupAssignments") + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.ClassGroup", "Group") + .WithMany("Assignments") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassMembership", b => + { + b.HasOne("RemoteCI.Server.Data.Classroom", "Classroom") + .WithMany("Memberships") + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.AccountRole", "RoleDefinition") + .WithMany() + .HasForeignKey("RoleDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("RoleDefinition"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.DeviceSession", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.PluginCredential", b => + { + b.HasOne("RemoteCI.Server.Data.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.UserApiKey", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.UserExtensionPreference", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AccountRole", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroup", b => + { + b.Navigation("Assignments"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.Classroom", b => + { + b.Navigation("GroupAssignments"); + + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/RemoteCI.Server/Data/Migrations/20260927110504_AddApiKeys.cs b/server/RemoteCI.Server/Data/Migrations/20260927110504_AddApiKeys.cs new file mode 100644 index 0000000..21ecf1c --- /dev/null +++ b/server/RemoteCI.Server/Data/Migrations/20260927110504_AddApiKeys.cs @@ -0,0 +1,68 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RemoteCI.Server.Data.Migrations +{ + /// + public partial class AddApiKeys : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // 内置班管理员在旧数据库中先补上 API 访问位;后续启动不再覆盖该角色默认权限, + // 这样管理员通过“角色配置”移除或恢复 API 访问时能够持久生效。 + migrationBuilder.Sql( + "UPDATE \"AccountRoles\" SET \"DefaultPermissions\" = \"DefaultPermissions\" | 2048 " + + "WHERE \"Id\" = '44444444-4444-4444-4444-444444444444';"); + + migrationBuilder.CreateTable( + name: "UserApiKeys", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 40, nullable: false), + KeyHash = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Prefix = table.Column(type: "TEXT", maxLength: 16, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + LastUsedAt = table.Column(type: "TEXT", nullable: true), + ExpiresAt = table.Column(type: "TEXT", nullable: true), + RevokedAt = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserApiKeys", x => x.Id); + table.ForeignKey( + name: "FK_UserApiKeys_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserApiKeys_KeyHash", + table: "UserApiKeys", + column: "KeyHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserApiKeys_UserId_RevokedAt", + table: "UserApiKeys", + columns: new[] { "UserId", "RevokedAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserApiKeys"); + + migrationBuilder.Sql( + "UPDATE \"AccountRoles\" SET \"DefaultPermissions\" = \"DefaultPermissions\" & ~2048 " + + "WHERE \"Id\" = '44444444-4444-4444-4444-444444444444';"); + } + } +} diff --git a/server/RemoteCI.Server/Data/Migrations/20260927125655_AddClassPairingCodesAndUnassignedDevices.Designer.cs b/server/RemoteCI.Server/Data/Migrations/20260927125655_AddClassPairingCodesAndUnassignedDevices.Designer.cs new file mode 100644 index 0000000..6584163 --- /dev/null +++ b/server/RemoteCI.Server/Data/Migrations/20260927125655_AddClassPairingCodesAndUnassignedDevices.Designer.cs @@ -0,0 +1,875 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RemoteCI.Server.Data; + +#nullable disable + +namespace RemoteCI.Server.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260927125655_AddClassPairingCodesAndUnassignedDevices")] + partial class AddClassPairingCodesAndUnassignedDevices + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AccountRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DefaultPermissions") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("AccountRoles"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("GrantedPermissions") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PasswordPending") + .HasColumnType("INTEGER"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("RoleDefinitionId") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("SetupTokenExpiresAt") + .HasColumnType("TEXT"); + + b.Property("SetupTokenHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("RoleDefinitionId"); + + b.HasIndex("Version"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.BackupConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Cadence") + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastError") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastScheduledAt") + .HasColumnType("TEXT"); + + b.Property("LastSucceededAt") + .HasColumnType("TEXT"); + + b.Property("MaxBackups") + .HasColumnType("INTEGER"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("BackupConfigurations"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("ParentId"); + + b.ToTable("ClassGroups"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroupAssignment", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "ClassroomId"); + + b.HasIndex("ClassroomId"); + + b.ToTable("ClassGroupAssignments"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.Property("RoleDefinitionId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "ClassroomId"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("RoleDefinitionId"); + + b.ToTable("ClassMemberships"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.Classroom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Avatar") + .HasColumnType("BLOB"); + + b.Property("AvatarContentType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AvatarUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("VisitorAccessEnabled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Classrooms"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.DeviceSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessExpiresAt") + .HasColumnType("TEXT"); + + b.Property("AccessTokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("VerifierHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessTokenHash") + .IsUnique(); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("DeviceSessions"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ExtensionPolicy", b => + { + b.Property("ExtensionId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AllowNonAdmin") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("ExtensionId"); + + b.ToTable("ExtensionPolicies"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.PluginCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Assigned") + .HasColumnType("INTEGER"); + + b.Property("ClassNameRemark") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("SoftwareInventoryAt") + .HasColumnType("TEXT"); + + b.Property("SoftwareInventoryJson") + .HasMaxLength(65536) + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PluginCredentials"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.PluginPairingCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClassroomId") + .HasColumnType("TEXT"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsPersistent") + .HasColumnType("INTEGER"); + + b.Property("IsShared") + .HasColumnType("INTEGER"); + + b.Property("UsedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CodeHash") + .IsUnique(); + + b.ToTable("PluginPairingCodes"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.SystemMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountVersion") + .HasColumnType("INTEGER"); + + b.Property("AutoEnterVisitorPage") + .HasColumnType("INTEGER"); + + b.Property("ForceSenderInTitle") + .HasColumnType("INTEGER"); + + b.Property("LoginBackground") + .HasColumnType("BLOB"); + + b.Property("LoginBackgroundContentType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LoginBackgroundOpacity") + .HasColumnType("INTEGER"); + + b.Property("LoginBackgroundUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("LoginCardPosition") + .HasColumnType("INTEGER"); + + b.Property("LoginTheme") + .HasColumnType("INTEGER"); + + b.Property("SchedulePullIntervalMinutes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SystemMetadata"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.UserApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("UserApiKeys"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.UserExtensionPreference", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ExtensionId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ShowOnWatch") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "ExtensionId"); + + b.ToTable("UserExtensionPreferences"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AppUser", b => + { + b.HasOne("RemoteCI.Server.Data.AccountRole", "RoleDefinition") + .WithMany("Users") + .HasForeignKey("RoleDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("RoleDefinition"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroup", b => + { + b.HasOne("RemoteCI.Server.Data.ClassGroup", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroupAssignment", b => + { + b.HasOne("RemoteCI.Server.Data.Classroom", "Classroom") + .WithMany("GroupAssignments") + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.ClassGroup", "Group") + .WithMany("Assignments") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassMembership", b => + { + b.HasOne("RemoteCI.Server.Data.Classroom", "Classroom") + .WithMany("Memberships") + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.AccountRole", "RoleDefinition") + .WithMany() + .HasForeignKey("RoleDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("RemoteCI.Server.Data.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("RoleDefinition"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.DeviceSession", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.PluginCredential", b => + { + b.HasOne("RemoteCI.Server.Data.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.UserApiKey", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.UserExtensionPreference", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.AccountRole", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroup", b => + { + b.Navigation("Assignments"); + + b.Navigation("Children"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.Classroom", b => + { + b.Navigation("GroupAssignments"); + + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/RemoteCI.Server/Data/Migrations/20260927125655_AddClassPairingCodesAndUnassignedDevices.cs b/server/RemoteCI.Server/Data/Migrations/20260927125655_AddClassPairingCodesAndUnassignedDevices.cs new file mode 100644 index 0000000..004f8c1 --- /dev/null +++ b/server/RemoteCI.Server/Data/Migrations/20260927125655_AddClassPairingCodesAndUnassignedDevices.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RemoteCI.Server.Data.Migrations +{ + /// + public partial class AddClassPairingCodesAndUnassignedDevices : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsPersistent", + table: "PluginPairingCodes", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "IsShared", + table: "PluginPairingCodes", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "Assigned", + table: "PluginCredentials", + type: "INTEGER", + nullable: false, + // 既有凭据都是按班级配对的设备,升级后必须保持“已分配”,否则会全部掉进“未分配”。 + defaultValue: true); + + migrationBuilder.AddColumn( + name: "ClassNameRemark", + table: "PluginCredentials", + type: "TEXT", + maxLength: 40, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsPersistent", + table: "PluginPairingCodes"); + + migrationBuilder.DropColumn( + name: "IsShared", + table: "PluginPairingCodes"); + + migrationBuilder.DropColumn( + name: "Assigned", + table: "PluginCredentials"); + + migrationBuilder.DropColumn( + name: "ClassNameRemark", + table: "PluginCredentials"); + } + } +} diff --git a/server/RemoteCI.Server/Data/Migrations/AppDbContextModelSnapshot.cs b/server/RemoteCI.Server/Data/Migrations/AppDbContextModelSnapshot.cs index 65d3305..54720bd 100644 --- a/server/RemoteCI.Server/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/server/RemoteCI.Server/Data/Migrations/AppDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -248,7 +248,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("SetupTokenExpiresAt") .HasColumnType("TEXT"); - b.Property("SetupTokenHash") + b.Property("SetupTokenHash") .HasMaxLength(64) .HasColumnType("TEXT"); @@ -339,11 +339,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("ParentId"); - b.HasIndex("Name") .IsUnique(); + b.HasIndex("ParentId"); + b.ToTable("ClassGroups"); }); @@ -388,10 +388,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("Avatar") + b.Property("Avatar") .HasColumnType("BLOB"); - b.Property("AvatarContentType") + b.Property("AvatarContentType") .HasMaxLength(64) .HasColumnType("TEXT"); @@ -495,6 +495,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); + b.Property("Assigned") + .HasColumnType("INTEGER"); + + b.Property("ClassNameRemark") + .HasMaxLength(40) + .HasColumnType("TEXT"); + b.Property("ClassroomId") .HasColumnType("TEXT"); @@ -512,6 +519,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(80) .HasColumnType("TEXT"); + b.Property("SoftwareInventoryAt") + .HasColumnType("TEXT"); + + b.Property("SoftwareInventoryJson") + .HasMaxLength(65536) + .HasColumnType("TEXT"); + b.Property("TokenHash") .IsRequired() .HasMaxLength(64) @@ -547,6 +561,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ExpiresAt") .HasColumnType("TEXT"); + b.Property("IsPersistent") + .HasColumnType("INTEGER"); + + b.Property("IsShared") + .HasColumnType("INTEGER"); + b.Property("UsedAt") .HasColumnType("TEXT"); @@ -573,6 +593,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ForceSenderInTitle") .HasColumnType("INTEGER"); + b.Property("LoginBackground") + .HasColumnType("BLOB"); + + b.Property("LoginBackgroundContentType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LoginBackgroundOpacity") + .HasColumnType("INTEGER"); + + b.Property("LoginBackgroundUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("LoginCardPosition") + .HasColumnType("INTEGER"); + + b.Property("LoginTheme") + .HasColumnType("INTEGER"); + b.Property("SchedulePullIntervalMinutes") .HasColumnType("INTEGER"); @@ -581,6 +620,52 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SystemMetadata"); }); + modelBuilder.Entity("RemoteCI.Server.Data.UserApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("UserApiKeys"); + }); + modelBuilder.Entity("RemoteCI.Server.Data.UserExtensionPreference", b => { b.Property("UserId") @@ -675,18 +760,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("RemoteCI.Server.Data.ClassGroupAssignment", b => { - b.HasOne("RemoteCI.Server.Data.ClassGroup", "Group") - .WithMany("Assignments") - .HasForeignKey("GroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - b.HasOne("RemoteCI.Server.Data.Classroom", "Classroom") .WithMany("GroupAssignments") .HasForeignKey("ClassroomId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("RemoteCI.Server.Data.ClassGroup", "Group") + .WithMany("Assignments") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + b.Navigation("Classroom"); b.Navigation("Group"); @@ -741,6 +826,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Classroom"); }); + modelBuilder.Entity("RemoteCI.Server.Data.UserApiKey", b => + { + b.HasOne("RemoteCI.Server.Data.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("RemoteCI.Server.Data.UserExtensionPreference", b => { b.HasOne("RemoteCI.Server.Data.AppUser", "User") @@ -762,8 +858,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Assignments"); b.Navigation("Children"); - - b.Navigation("Parent"); }); modelBuilder.Entity("RemoteCI.Server.Data.Classroom", b => diff --git a/server/RemoteCI.Server/Pages/Account.cshtml b/server/RemoteCI.Server/Pages/Account.cshtml index 71929ef..f85ec3b 100644 --- a/server/RemoteCI.Server/Pages/Account.cshtml +++ b/server/RemoteCI.Server/Pages/Account.cshtml @@ -13,5 +13,112 @@ + @if (Model.CanChangeDisplayName) + { +
+

修改用户名

+
+ @if (Model.DisplayNameError is { } displayNameError) + { + + } + + +
+
+ }

已登录设备

@if (Model.Sessions.Count == 0) {

暂无手表设备会话。

} else {
@foreach (var session in Model.Sessions) {
@session.DeviceName最近使用 @session.LastSeenAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")
}
}
+@if (Model.ShowApiPanel) +{ +
+
+
+

API Key

+

使用 Authorization: Bearer <API Key> 调用 RemoteCI REST API。密钥只在创建时显示一次,权限始终跟随当前账号。

+
+
+ + @if (Model.CreatedApiKey is { } created) + { +
+
+ @created.ApiKey.Name 已创建 +

请立即复制并妥善保存;离开页面后无法再次查看完整密钥。

+
+
+ @created.Key + +
+
+ } + + @if (!Model.CanUseApi) + { +

当前账号没有 API 访问权限,已有密钥已失效;仍可吊销不再使用的密钥。

+ } + else + { +
+ + +
+ } + + @if (Model.ApiKeys.Count == 0) + { +

暂无 API Key。

+ } + else + { + + } +
+} diff --git a/server/RemoteCI.Server/Pages/Account.cshtml.cs b/server/RemoteCI.Server/Pages/Account.cshtml.cs index 7755b48..6772361 100644 --- a/server/RemoteCI.Server/Pages/Account.cshtml.cs +++ b/server/RemoteCI.Server/Pages/Account.cshtml.cs @@ -2,8 +2,10 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; using RemoteCI.Server.Data; using RemoteCI.Server.Services; +using RemoteCI.Shared; using RemoteCI.Shared.Models; namespace RemoteCI.Server.Pages; @@ -13,26 +15,56 @@ public sealed class AccountModel( UserManager users, IdentityCoordinator identities, AuthorizationSyncService authorizationSync, - SignInManager signIn) : WebPageModel(users) + SignInManager signIn, + IMemoryCache cache) : WebPageModel(users) { [BindProperty] public PasswordInput Password { get; set; } = new(); + [BindProperty] + public DisplayNameInput DisplayName { get; set; } = new(); + + [BindProperty] + public CreateApiKeyRequest NewApiKey { get; set; } = new(); + public IReadOnlyList Sessions { get; private set; } = []; + public IReadOnlyList ApiKeys { get; private set; } = []; + + /// 创建成功后仅在本次重定向回显一次;明文不写入数据库或日志。 + public ApiKeyCreationResult? CreatedApiKey { get; private set; } + + /// 是否显示“修改用户名”表单:由“修改用户名”权限决定,默认不给学生角色开放。 + public bool CanChangeDisplayName => Permissions.HasFlag(UserPermissions.ChangeDisplayName); + + /// 是否显示 API Key 管理:学生默认没有,管理员和班管理员默认拥有,也可单独授权。 + public bool CanUseApi => Permissions.HasFlag(UserPermissions.ApiAccess); + + /// 没有权限但已有历史密钥时仍展示吊销入口,避免遗留凭据无法清理。 + public bool ShowApiPanel => CanUseApi || ApiKeys.Count > 0; + + /// + /// 修改用户名表单的服务端校验信息。该页同时承载修改密码表单,两者共用 ModelState; + /// 为免互相串错,修改用户名提交时清空 ModelState,只在此属性中呈现本表单的错误。 + /// + public string? DisplayNameError { get; private set; } public async Task OnGetAsync(CancellationToken ct) { if (await RequireAsync() is { } denied) return denied; - Sessions = await identities.ListSessionsAsync(CurrentUser.Id, null, ct); + await LoadAsync(ct); return Page(); } public async Task OnPostPasswordAsync(CancellationToken ct) { if (await RequireAsync() is { } denied) return denied; + // 页面同时绑定修改密码与修改用户名两个表单,先剔除另一表单的校验项,避免互相干扰。 + KeepModelStateEntries(nameof(Password)); + // 空字段可能不会被复杂类型模型绑定生成条目,显式验证保证必填提示稳定出现。 + TryValidateModel(Password, nameof(Password)); if (!ModelState.IsValid) { - Sessions = await identities.ListSessionsAsync(CurrentUser.Id, null, ct); + await LoadAsync(ct); return Page(); } try @@ -50,7 +82,42 @@ public async Task OnPostPasswordAsync(CancellationToken ct) catch (IdentityOperationException ex) { ModelState.AddModelError(string.Empty, ex.Message); - Sessions = await identities.ListSessionsAsync(CurrentUser.Id, null, ct); + await LoadAsync(ct); + return Page(); + } + } + + public async Task OnPostDisplayNameAsync(CancellationToken ct) + { + if (await RequireAsync() is { } denied) return denied; + if (!Permissions.HasFlag(UserPermissions.ChangeDisplayName)) + { + TempData["Error"] = "当前账号没有修改用户名的权限。"; + return RedirectToPage(); + } + // 清空“修改密码”表单模型绑定产生的校验项,避免其错误串入用户名表单(反之亦然)。 + ModelState.Clear(); + var displayName = DisplayName.DisplayName?.Trim() ?? string.Empty; + if (displayName.Length is < 1 or > 40) + { + DisplayNameError = "用户名需为 1-40 个字符"; + await LoadAsync(ct); + return Page(); + } + try + { + await identities.ChangeDisplayNameAsync(CurrentUser.Id, new ChangeDisplayNameRequest + { + DisplayName = displayName, + }, ct); + await authorizationSync.SyncAsync(ct); + TempData["Message"] = "用户名已更新。"; + return RedirectToPage(); + } + catch (IdentityOperationException ex) + { + DisplayNameError = ex.Message; + await LoadAsync(ct); return Page(); } } @@ -68,6 +135,68 @@ public async Task OnPostRevokeAsync(Guid id, CancellationToken ct return RedirectToPage(); } + public async Task OnPostCreateApiKeyAsync(CancellationToken ct) + { + if (await RequireAsync() is { } denied) return denied; + if (!Permissions.HasFlag(UserPermissions.ApiAccess)) + { + TempData["Error"] = "当前账号没有 API 访问权限。"; + return RedirectToPage(); + } + try + { + var created = await identities.CreateApiKeyAsync(CurrentUser.Id, NewApiKey.Name, ct); + StoreCreatedApiKey(created); + TempData["Message"] = "API Key 已生成,请立即复制;离开页面后无法再次查看。"; + } + catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } + return RedirectToPage(); + } + + public async Task OnPostRevokeApiKeyAsync(Guid id, CancellationToken ct) + { + if (await RequireAsync() is { } denied) return denied; + try + { + await identities.RevokeApiKeyAsync(CurrentUser.Id, id, ct); + TempData["Message"] = "API Key 已吊销。"; + } + catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } + return RedirectToPage(); + } + + private async Task LoadAsync(CancellationToken ct) + { + Sessions = await identities.ListSessionsAsync(CurrentUser.Id, null, ct); + ApiKeys = await identities.ListApiKeysAsync(CurrentUser.Id, ct); + CreatedApiKey = TakeCreatedApiKey(); + } + + /// 把一次性明文放进服务端内存缓存,TempData 只保存随机引用,避免明文进入客户端 Cookie。 + private void StoreCreatedApiKey(ApiKeyCreationResult result) + { + var id = Guid.NewGuid().ToString("N"); + cache.Set(id, result, TimeSpan.FromMinutes(10)); + TempData["ApiKeyRevealId"] = id; + } + + private ApiKeyCreationResult? TakeCreatedApiKey() + { + if (TempData["ApiKeyRevealId"] is not string id || + !cache.TryGetValue(id, out ApiKeyCreationResult? result)) return null; + cache.Remove(id); + return result; + } + + /// 仅保留指定前缀的绑定校验项;用于同一页面多个独立表单互不干扰。 + private void KeepModelStateEntries(string prefix) + { + foreach (var key in ModelState.Keys.Where(key => + !key.Equals(prefix, StringComparison.OrdinalIgnoreCase) && + !key.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase)).ToArray()) + ModelState.Remove(key); + } + public sealed class PasswordInput { [Required, StringLength(128, MinimumLength = 8)] @@ -80,4 +209,11 @@ public sealed class PasswordInput [Compare(nameof(NewPassword), ErrorMessage = "两次输入的新密码不一致。")] public string ConfirmNewPassword { get; set; } = string.Empty; } + + public sealed class DisplayNameInput + { + [Required(ErrorMessage = "请输入用户名。")] + [StringLength(40, MinimumLength = 1, ErrorMessage = "用户名需为 1-40 个字符")] + public string DisplayName { get; set; } = string.Empty; + } } diff --git a/server/RemoteCI.Server/Pages/BatchControl.cshtml b/server/RemoteCI.Server/Pages/BatchControl.cshtml index 89ddc4c..9683a0e 100644 --- a/server/RemoteCI.Server/Pages/BatchControl.cshtml +++ b/server/RemoteCI.Server/Pages/BatchControl.cshtml @@ -2,117 +2,38 @@ @model BatchControlModel @{ ViewData["Title"] = "批量控制"; + var onlineDevices = Model.Devices.Count(device => device.Online); }

系统管理员

批量控制

+

选择一个统一功能,填写参数,然后选择要执行的班级、分组或具体设备。

+ @onlineDevices 台设备在线
@if (Model.LastResults.Count > 0) {
-

上次执行结果

+
+
+

上次执行结果

+

按设备回报;离线设备或没有在线设备的班级会单独列出。

+
+ @Model.LastResults.Count(x => x.Success) / @Model.LastResults.Count 成功 +
    @foreach (var item in Model.LastResults) { - var className = Model.Classes.FirstOrDefault(x => x.Id == item.ClassId)?.Name ?? item.ClassId.ToString(); - if (item.Success) - { -
  • @className — 成功
  • - } - else - { -
  • @item.Message
  • - } +
  • + + @item.TargetName@(item.Success ? " — 成功" : $" — {item.Message}") +
  • }
} -
-
-

选择目标班级

-

勾选分组即对整组生效;下方操作会逐班执行并回报每个班级的结果。

- @if (Model.Groups.Count > 0) - { -

整组执行

-
- @foreach (var group in Model.Groups) - { - - } -
- } -

逐班勾选

-
- @foreach (var classroom in Model.Classes) - { - - } -
-
- -
-

广播通知

-
- - - -
-
- -
-

清除通知

-

清除勾选班级正在显示的提醒。

- -
- -
-

电源控制

-

对勾选班级的教室端设备执行电源操作,请谨慎使用。

-
- - - -
-
-
- -
-

语音消息

-

录制一段最长 60 秒的语音,同时推送到勾选班级的教室端播放。

-
- @Html.AntiForgeryToken() -
- - - - -
- -

先在上方勾选班级,录制后点击发送即同时广播到所有勾选班级。

-
-
+@* 动作卡片、参数弹窗与目标选择复用班级控制页共享的局部视图;此处保留完整的目标选择。 *@ +@await Html.PartialAsync("_BatchControlActions", Model) diff --git a/server/RemoteCI.Server/Pages/BatchControl.cshtml.cs b/server/RemoteCI.Server/Pages/BatchControl.cshtml.cs index 7dffd31..2a2fcf0 100644 --- a/server/RemoteCI.Server/Pages/BatchControl.cshtml.cs +++ b/server/RemoteCI.Server/Pages/BatchControl.cshtml.cs @@ -10,17 +10,23 @@ namespace RemoteCI.Server.Pages; /// -/// 系统管理员的批量控制台:勾选班级/分组后,批量发送通知、语音消息、清除通知或执行电源操作。 -/// 与“班级管理”一样属于管理员管理器,在导航中与单个班级的操作分区展示。 +/// 系统管理员的批量控制台:先选择统一功能,再填写该功能的参数,最后选择班级、分组或具体设备。 +/// 页面只负责收集参数;实际投递由 DeviceInventoryService 统一按插件连接执行。 /// [Authorize] -public sealed class BatchControlModel( +public class BatchControlModel( UserManager users, ClassroomService classrooms, - ClassBroadcastService broadcast, + DeviceInventoryService devices, IdentityCoordinator identities) : WebPageModel(users) { private const string ResultsKey = "BatchControlResults"; + protected static readonly TimeSpan BatchCommandTimeout = TimeSpan.FromSeconds(20); + private const int MaxProfileJsonLength = 5 * 1024 * 1024; + private const int MaxManagementPresetJsonLength = 256 * 1024; + + [BindProperty] + public BatchOperationKind Operation { get; set; } [BindProperty] public string? BroadcastTitle { get; set; } @@ -28,11 +34,26 @@ public sealed class BatchControlModel( [BindProperty] public string? BroadcastMessage { get; set; } + /// 通知单条显示秒数;null 或 <= 0 时插件按 ClassIsland 集控默认 5 秒处理。 [BindProperty] - public List SelectedClassIds { get; set; } = []; + public int? BroadcastDurationSeconds { get; set; } + /// 正文滚动重复次数;null 或 < 1 时按 1 次处理。 [BindProperty] - public List SelectedGroupIds { get; set; } = []; + public int? BroadcastRepeatCounts { get; set; } + + [BindProperty] + public bool BroadcastIsSpeechEnabled { get; set; } + + [BindProperty] + public bool BroadcastIsSoundEnabled { get; set; } + + [BindProperty] + public bool BroadcastIsEffectEnabled { get; set; } + + /// 提醒时置顶 ClassIsland 主界面。 + [BindProperty] + public bool BroadcastIsTopmostEnabled { get; set; } [BindProperty] public PowerActionKind PowerAction { get; set; } @@ -40,70 +61,221 @@ public sealed class BatchControlModel( [BindProperty] public bool ConfirmPower { get; set; } + [BindProperty] + public bool ConfirmHighRisk { get; set; } + + [BindProperty] + public PluginActionKind PluginAction { get; set; } + + [BindProperty] + public List PluginIds { get; set; } = []; + + [BindProperty] + public string? PluginIdsText { get; set; } + + [BindProperty] + public string? TimeLayoutName { get; set; } + + [BindProperty] + public Guid? TimeLayoutId { get; set; } + + [BindProperty] + public bool TimeLayoutActivate { get; set; } + + [BindProperty] + public string? TimeLayoutPointsJson { get; set; } + + [BindProperty] + public string? ProfileJson { get; set; } + + [BindProperty] + public ProfileDistributionSection ProfileSections { get; set; } + + [BindProperty] + public bool ProfileReplaceExisting { get; set; } + + /// 管理员上传的 ClassIsland 集控配置文件(ManagementPreset.json)内容。 + [BindProperty] + public string? ManagementPresetJson { get; set; } + + [BindProperty] + public bool AllowRemotePluginInstall { get; set; } = true; + + [BindProperty] + public bool AllowRemotePluginUninstall { get; set; } = true; + + [BindProperty] + public bool Force { get; set; } + + [BindProperty] + public List SelectedClassIds { get; set; } = []; + + [BindProperty] + public List SelectedGroupIds { get; set; } = []; + + [BindProperty] + public List SelectedConnectionIds { get; set; } = []; + public IReadOnlyList Classes { get; private set; } = []; public IReadOnlyList Groups { get; private set; } = []; + public IReadOnlyList Devices { get; private set; } = []; + public IReadOnlyList KnownPlugins { get; private set; } = []; + public IReadOnlyList LastResults { get; protected set; } = []; + protected ClassroomService Classrooms { get; } = classrooms; + protected DeviceInventoryService DevicesService { get; } = devices; + protected IdentityCoordinator Identities { get; } = identities; + public virtual bool IsSingleControl => false; - /// 上一次批量操作逐班结果,从 TempData 恢复后展示。 - public IReadOnlyList LastResults { get; private set; } = []; + /// 系统管理员:批量页整体只对管理员开放,单班页用它决定高风险操作是否可见。 + public bool IsAdmin => CurrentUser.Role == UserRole.Admin; - public async Task OnGetAsync(CancellationToken ct) + /// + /// 卡片是否对该用户可见。批量控制页本身已限定管理员,因此全部显示; + /// 单班控制页按协议权限表判断,避免出现看得见却执行不了(或被拒绝)的操作。 + /// + public bool CanUseOperation(BatchOperationKind operation) { - if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; - if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); - await LoadAsync(ct); - RestoreResults(); - return Page(); + if (!IsSingleControl) return true; + if (CommandForOperation(operation) is not { } command) return false; + var required = CommandPermissions.Required(command); + return required != UserPermissions.None && ClassPermissions.HasFlag(required); } - public async Task OnPostNotifyAsync(CancellationToken ct) + /// 是否存在任一可见操作:无权限时连目标选择弹窗也不渲染,避免把设备清单泄露到页面。 + public bool HasAnyOperations => HasClassroomOperations || HasProfileOperations || HasSoftwareOperations; + + /// 分组可见性:某一组全部操作都无权限时整组隐藏,避免出现空面板。 + public bool HasClassroomOperations => + CanUseOperation(BatchOperationKind.Notify) || + CanUseOperation(BatchOperationKind.ClearNotifications) || + CanUseOperation(BatchOperationKind.Power) || + CanUseOperation(BatchOperationKind.VoiceMessage); + + public bool HasProfileOperations => + CanUseOperation(BatchOperationKind.UpdateTimeLayout) || + CanUseOperation(BatchOperationKind.DistributeProfile) || + CanUseOperation(BatchOperationKind.JoinManagement) || + CanUseOperation(BatchOperationKind.SetPluginManagementPolicy); + + public bool HasSoftwareOperations => + CanUseOperation(BatchOperationKind.InstallPlugins) || + CanUseOperation(BatchOperationKind.UninstallPlugins) || + CanUseOperation(BatchOperationKind.SetPluginEnabled) || + CanUseOperation(BatchOperationKind.RefreshSoftwareInventory) || + CanUseOperation(BatchOperationKind.UpgradePlugins) || + CanUseOperation(BatchOperationKind.UpgradeClassIsland) || + CanUseOperation(BatchOperationKind.RestartClassIsland); + + /// 批量操作对应的协议命令,用于复用 CommandPermissions 的权限口径。 + private static CommandKind? CommandForOperation(BatchOperationKind operation) => operation switch { - if (await RequireAdminAsync() is { } denied) return denied; - if (!TargetsSelected()) return Back("请先勾选要通知的班级或分组。"); - var result = await DispatchAsync(new BroadcastCommandRequest - { - Command = CommandKind.SendNotification, - Notification = new NotificationRequest - { - Title = string.IsNullOrWhiteSpace(BroadcastTitle) ? "RemoteCI 通知" : BroadcastTitle.Trim(), - Message = BroadcastMessage?.Trim() ?? string.Empty, - }, - }, ct); - SaveResults(result); - TempData[Summary(result, out _) ? "Message" : "Error"] = Summarize(result); - return RedirectToPage(); - } + BatchOperationKind.Notify => CommandKind.SendNotification, + BatchOperationKind.ClearNotifications => CommandKind.ClearNotifications, + BatchOperationKind.Power => CommandKind.Power, + BatchOperationKind.VoiceMessage => CommandKind.SendVoiceMessage, + BatchOperationKind.UpdateTimeLayout => CommandKind.UpdateTimeLayout, + BatchOperationKind.DistributeProfile => CommandKind.DistributeProfile, + BatchOperationKind.InstallPlugins => CommandKind.InstallPlugins, + BatchOperationKind.UninstallPlugins => CommandKind.UninstallPlugins, + BatchOperationKind.SetPluginEnabled => CommandKind.SetPluginEnabled, + BatchOperationKind.SetPluginManagementPolicy => CommandKind.SetPluginManagementPolicy, + BatchOperationKind.RefreshSoftwareInventory => CommandKind.RefreshSoftwareInventory, + BatchOperationKind.UpgradePlugins => CommandKind.UpgradePlugins, + BatchOperationKind.UpgradeClassIsland => CommandKind.UpgradeClassIsland, + BatchOperationKind.RestartClassIsland => CommandKind.RestartClassIsland, + BatchOperationKind.JoinManagement => CommandKind.JoinManagement, + _ => null, + }; - public async Task OnPostClearAsync(CancellationToken ct) + public virtual async Task OnGetAsync(CancellationToken ct) { if (await RequireAdminAsync() is { } denied) return denied; - if (!TargetsSelected()) return Back("请先勾选要操作的班级或分组。"); - var result = await DispatchAsync(new BroadcastCommandRequest { Command = CommandKind.ClearNotifications }, ct); - SaveResults(result); - TempData[Summary(result, out _) ? "Message" : "Error"] = Summarize(result); - return RedirectToPage(); + await LoadBatchAsync(ct); + RestoreResults(); + return Page(); } - public async Task OnPostPowerAsync(CancellationToken ct) + public async Task OnPostExecuteAsync(CancellationToken ct) { if (await RequireAdminAsync() is { } denied) return denied; - if (!TargetsSelected()) return Back("请先勾选要操作的班级或分组。"); - if (!ConfirmPower) + await LoadBatchAsync(ct); + if (!TargetsSelected()) return Back("请先选择要控制的班级、分组或设备。"); + if (RequiresRiskConfirmation(Operation) && !ConfirmHighRisk) + return Back("该操作会影响教室端设备,请先勾选确认后执行。"); + + try { - TempData["Error"] = "电源操作影响教室端设备,请先勾选确认后执行。"; + var plan = await DevicesService.ResolveAsync(SelectedClassIds, SelectedGroupIds, SelectedConnectionIds, ct); + if (plan.Targets.Count == 0 && plan.Failures.Count == 0) + return Back("没有找到可用的在线设备。"); + + var commandFactory = await BuildCommandFactoryAsync(ct); + var dispatchResults = await DevicesService.DispatchAsync( + plan.Targets, + commandFactory, + OperationName(Operation), + BatchCommandTimeout, + ct); + var results = plan.Failures + .Select(ToItemResult) + .Concat(dispatchResults.Select(ToItemResult)) + .ToList(); + if (Operation == BatchOperationKind.RefreshSoftwareInventory && results.Any(x => x.Success)) + await Task.Delay(200, ct); + + SaveResults(results); + TempData[results.Any(x => x.Success) ? "Message" : "Error"] = Summarize(results); return RedirectToPage(); } - if (!Enum.IsDefined(PowerAction)) + catch (InvalidOperationException ex) { - TempData["Error"] = "未知电源操作。"; - return RedirectToPage(); + return Back(ex.Message); + } + } + + /// 班级控制页复用批量命令构造,但目标固定为当前班级。 + public async Task OnPostSingleExecuteAsync(CancellationToken ct) + { + if (await RequireAsync() is { } denied) return denied; + if (!IsSingleControl) return RedirectToPage("/Denied"); + if (!CanExecuteInCurrentClass(Operation)) return RedirectToPage("/Denied"); + if (RequiresRiskConfirmation(Operation) && !ConfirmHighRisk) + return SingleBack("该操作会影响教室端设备,请先勾选确认后执行。"); + + try + { + var plan = await DevicesService.ResolveAsync([CurrentClassId], [], [], ct); + if (plan.Targets.Count == 0 && plan.Failures.Count == 0) + return SingleBack("当前班级没有在线设备。"); + var results = plan.Failures + .Select(ToItemResult) + .Concat((await DevicesService.DispatchAsync( + plan.Targets, + await BuildCommandFactoryAsync(ct), + OperationName(Operation), + BatchCommandTimeout, + ct)).Select(ToItemResult)) + .ToList(); + TempData[results.Any(x => x.Success) ? "Message" : "Error"] = Summarize(results); + return RedirectToPage("/Control"); + } + catch (InvalidOperationException ex) + { + return SingleBack(ex.Message); } - var result = await DispatchAsync(new BroadcastCommandRequest { Command = CommandKind.Power, PowerAction = PowerAction }, ct); - SaveResults(result); - TempData[Summary(result, out _) ? "Message" : "Error"] = Summarize(result); - return RedirectToPage(); } - /// 批量语音消息:voice-message.js 直接上传原始 PCM,目标班级经查询参数传递。 + /// 单班执行前复核:与卡片可见性使用同一权限口径,防止绕过前端直接提交。 + private bool CanExecuteInCurrentClass(BatchOperationKind operation) => + IsSingleControl && CanUseOperation(operation); + + private IActionResult SingleBack(string message) + { + TempData["Error"] = message; + return RedirectToPage("/Control"); + } + + /// 语音消息沿用原始 PCM 上传;目标由共享选择弹窗写入查询参数。 public async Task OnPostBroadcastVoiceAsync(CancellationToken ct) { if (await RequireAdminAsync() is { } denied) return denied; @@ -118,46 +290,306 @@ public async Task OnPostBroadcastVoiceAsync(CancellationToken ct) return new JsonResult(CommandResult.Failure(CommandResultCodes.InvalidRequest, "语音不能超过 60 秒")); audio.Write(buffer, 0, count); } - var classIds = Request.Query["classIds"] - .SelectMany(value => (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - .Select(value => Guid.TryParse(value, out var id) ? id : Guid.Empty) - .Where(id => id != Guid.Empty) - .ToList(); - var groupIds = Request.Query["groupIds"] - .SelectMany(value => (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - .Select(value => Guid.TryParse(value, out var id) ? id : Guid.Empty) - .Where(id => id != Guid.Empty) - .ToList(); - if (classIds.Count == 0 && groupIds.Count == 0) - return new JsonResult(CommandResult.Failure(CommandResultCodes.InvalidRequest, "请先勾选要广播的班级或分组")); - - var result = await DispatchAsync(new BroadcastCommandRequest - { - Command = CommandKind.SendVoiceMessage, - VoiceMessage = new VoiceMessageRequest { AudioBase64 = Convert.ToBase64String(audio.ToArray()) }, - ClassIds = classIds, - GroupIds = groupIds, - }, ct); - var ok = result.Results.Count(x => x.Success); - var failures = result.Results.Where(x => !x.Success).ToList(); - var message = failures.Count == 0 - ? $"语音消息已广播到 {ok} 个班级。" - : $"语音广播完成 {ok} 个班级,{failures.Count} 个失败:{string.Join(";", failures.Select(x => x.Message))}"; - SaveResults(result); + + var classIds = ParseGuidQuery("classIds"); + var groupIds = ParseGuidQuery("groupIds"); + var connectionIds = ParseGuidQuery("connectionIds"); + if (classIds.Count == 0 && groupIds.Count == 0 && connectionIds.Count == 0) + return new JsonResult(CommandResult.Failure(CommandResultCodes.InvalidRequest, "请先选择要广播的设备、班级或分组")); + + var plan = await DevicesService.ResolveAsync(classIds, groupIds, connectionIds, ct); + var profile = await Identities.GetProfileAsync(CurrentUser.Id, ct); + var dispatchResults = await DevicesService.DispatchAsync( + plan.Targets, + _ => new CommandMessage + { + Command = CommandKind.SendVoiceMessage, + VoiceMessage = new VoiceMessageRequest { AudioBase64 = Convert.ToBase64String(audio.ToArray()) }, + RequestedBy = profile, + }, + "语音广播", + BatchCommandTimeout, + ct); + var results = plan.Failures.Select(ToItemResult).Concat(dispatchResults.Select(ToItemResult)).ToList(); + SaveResults(results); + var ok = results.Count(x => x.Success); + var message = results.Count == 0 || ok == results.Count + ? $"语音消息已发送到 {ok} 台设备。" + : $"语音广播完成 {ok} 台,失败 {results.Count - ok} 台:{string.Join(";", results.Where(x => !x.Success).Select(x => $"{x.TargetName}:{x.Message}"))}"; return new JsonResult(new { success = ok > 0, message }); } - private async Task DispatchAsync(BroadcastCommandRequest request, CancellationToken ct) + public async Task OnPostSingleVoiceAsync(CancellationToken ct) + { + if (await RequireAsync() is { } denied) return denied; + if (!IsSingleControl || !ClassPermissions.HasFlag(UserPermissions.SendVoiceMessages)) + return new JsonResult(CommandResult.Failure(CommandResultCodes.InvalidRequest, "没有语音消息权限")); + if (Request.ContentType != "application/octet-stream" || Request.ContentLength is > VoiceMessageRequest.MaxBytes) + return new JsonResult(CommandResult.Failure(CommandResultCodes.InvalidRequest, "语音格式无效或超过 60 秒")); + using var audio = new MemoryStream(); + await Request.Body.CopyToAsync(audio, ct); + if (audio.Length < 2 || audio.Length % 2 != 0) + return new JsonResult(CommandResult.Failure(CommandResultCodes.InvalidRequest, "没有有效录音,请重新录制")); + var plan = await DevicesService.ResolveAsync([CurrentClassId], [], [], ct); + var profile = await Identities.GetProfileAsync(CurrentUser.Id, ct); + var results = plan.Failures.Select(ToItemResult).Concat((await DevicesService.DispatchAsync( + plan.Targets, + _ => new CommandMessage + { + Command = CommandKind.SendVoiceMessage, + VoiceMessage = new VoiceMessageRequest { AudioBase64 = Convert.ToBase64String(audio.ToArray()) }, + RequestedBy = profile, + }, + "语音消息", + BatchCommandTimeout, + ct)).Select(ToItemResult)).ToList(); + var ok = results.Count(x => x.Success); + return new JsonResult(new { success = ok > 0, message = $"语音消息已发送到当前班级 {ok} 台设备。" }); + } + + protected async Task> BuildCommandFactoryAsync(CancellationToken ct) + { + var profile = await Identities.GetProfileAsync(CurrentUser.Id, ct); + var forceSender = await Identities.GetForceSenderInTitleAsync(ct); + return Operation switch + { + BatchOperationKind.Notify => _ => new CommandMessage + { + Command = CommandKind.SendNotification, + Notification = new NotificationRequest + { + Title = string.IsNullOrWhiteSpace(BroadcastTitle) ? "RemoteCI 通知" : BroadcastTitle.Trim(), + Message = BroadcastMessage?.Trim() ?? string.Empty, + ForceSenderInTitle = forceSender, + IsSpeechEnabled = BroadcastIsSpeechEnabled, + IsNotificationSoundEnabled = BroadcastIsSoundEnabled, + IsNotificationEffectEnabled = BroadcastIsEffectEnabled, + IsNotificationTopmostEnabled = BroadcastIsTopmostEnabled, + DurationSeconds = BroadcastDurationSeconds, + RepeatCounts = BroadcastRepeatCounts, + }, + RequestedBy = profile, + }, + BatchOperationKind.ClearNotifications => _ => new CommandMessage + { + Command = CommandKind.ClearNotifications, + RequestedBy = profile, + }, + BatchOperationKind.Power => _ => new CommandMessage + { + Command = CommandKind.Power, + PowerAction = BuildPowerAction(), + RequestedBy = profile, + }, + BatchOperationKind.UpdateTimeLayout => _ => new CommandMessage + { + Command = CommandKind.UpdateTimeLayout, + TimeLayoutUpdate = BuildTimeLayoutRequest(), + RequestedBy = profile, + }, + BatchOperationKind.DistributeProfile => _ => new CommandMessage + { + Command = CommandKind.DistributeProfile, + ProfileDistribution = BuildProfileDistributionRequest(), + RequestedBy = profile, + }, + BatchOperationKind.InstallPlugins => _ => new CommandMessage + { + Command = CommandKind.InstallPlugins, + PluginManagement = BuildPluginRequest(PluginActionKind.Install), + RequestedBy = profile, + }, + BatchOperationKind.UninstallPlugins => _ => new CommandMessage + { + Command = CommandKind.UninstallPlugins, + PluginManagement = BuildPluginRequest(PluginActionKind.Uninstall), + RequestedBy = profile, + }, + BatchOperationKind.SetPluginEnabled => _ => new CommandMessage + { + Command = CommandKind.SetPluginEnabled, + PluginManagement = BuildPluginToggleRequest(), + RequestedBy = profile, + }, + BatchOperationKind.SetPluginManagementPolicy => _ => new CommandMessage + { + Command = CommandKind.SetPluginManagementPolicy, + PluginManagementPolicy = new PluginManagementPolicyRequest + { + AllowRemoteInstall = AllowRemotePluginInstall, + AllowRemoteUninstall = AllowRemotePluginUninstall, + }, + RequestedBy = profile, + }, + BatchOperationKind.RefreshSoftwareInventory => _ => new CommandMessage + { + Command = CommandKind.RefreshSoftwareInventory, + RequestedBy = profile, + }, + BatchOperationKind.UpgradePlugins => _ => new CommandMessage + { + Command = CommandKind.UpgradePlugins, + SoftwareUpgrade = new SoftwareUpgradeRequest { Force = Force }, + RequestedBy = profile, + }, + BatchOperationKind.UpgradeClassIsland => _ => new CommandMessage + { + Command = CommandKind.UpgradeClassIsland, + SoftwareUpgrade = new SoftwareUpgradeRequest { Force = Force }, + RequestedBy = profile, + }, + BatchOperationKind.RestartClassIsland => _ => new CommandMessage + { + Command = CommandKind.RestartClassIsland, + RequestedBy = profile, + }, + BatchOperationKind.JoinManagement => device => new CommandMessage + { + Command = CommandKind.JoinManagement, + ManagementJoin = BuildManagementJoinRequest(device), + RequestedBy = profile, + }, + _ => throw new InvalidOperationException("不支持的批量操作。"), + }; + } + + private PowerActionKind BuildPowerAction() + { + if (!Enum.IsDefined(PowerAction)) + throw new InvalidOperationException("未知电源操作。"); + return PowerAction; + } + + private PluginManagementRequest BuildPluginToggleRequest() { - request.ClassIds = SelectedClassIds; - request.GroupIds = SelectedGroupIds; - return await broadcast.BroadcastAsync( - new AuthPrincipal(PeerRole.Watch, - await identities.GetProfileAsync(CurrentUser.Id, ct), null, null, null), - request, ct); + if (PluginAction is not PluginActionKind.Enable and not PluginActionKind.Disable) + throw new InvalidOperationException("请选择启用或禁用插件。"); + return BuildPluginRequest(PluginAction); } - private bool TargetsSelected() => SelectedClassIds.Count > 0 || SelectedGroupIds.Count > 0; + private PluginManagementRequest BuildPluginRequest(PluginActionKind action) + { + var ids = PluginIds + .Concat((PluginIdsText ?? string.Empty) + .Split([',', ',', ';', ';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Select(value => value.Trim()) + .Where(value => value.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (ids.Count == 0) + throw new InvalidOperationException("请至少选择一个插件,或填写插件 Id。"); + if (action is not PluginActionKind.Install and not PluginActionKind.Uninstall and not PluginActionKind.Enable and not PluginActionKind.Disable) + throw new InvalidOperationException("未知插件操作。"); + return new PluginManagementRequest + { + Action = action, + PluginIds = ids, + RestartAfter = true, + }; + } + + private TimeLayoutUpdateRequest BuildTimeLayoutRequest() + { + if (string.IsNullOrWhiteSpace(TimeLayoutPointsJson)) + throw new InvalidOperationException("请至少填写一个时间点。"); + List? points; + try + { + points = JsonSerializer.Deserialize>(TimeLayoutPointsJson, JsonDefaults.Options); + } + catch (JsonException) + { + throw new InvalidOperationException("时间点数据格式无效,请重新填写。"); + } + if (points is null || points.Count == 0) + throw new InvalidOperationException("请至少填写一个时间点。"); + return new TimeLayoutUpdateRequest + { + TimeLayoutId = TimeLayoutId, + Name = string.IsNullOrWhiteSpace(TimeLayoutName) ? "RemoteCI 时间表" : TimeLayoutName.Trim(), + Activate = TimeLayoutActivate, + Points = points, + RestartAfter = true, + }; + } + + private ProfileDistributionRequest BuildProfileDistributionRequest() + { + if (string.IsNullOrWhiteSpace(ProfileJson)) + throw new InvalidOperationException("请粘贴或上传 ClassIsland 档案 JSON。"); + if (ProfileJson.Length > MaxProfileJsonLength) + throw new InvalidOperationException("档案 JSON 不能超过 5 MB。"); + if (ProfileSections == ProfileDistributionSection.None) + throw new InvalidOperationException("请至少选择一个分发内容。"); + return new ProfileDistributionRequest + { + ProfileJson = ProfileJson, + Sections = ProfileSections, + ReplaceExisting = ProfileReplaceExisting, + RestartAfter = true, + }; + } + + /// + /// 用管理员上传的集控配置文件构造加入请求;ID(ClassIdentity)按目标设备所属班级名自动填充。 + /// 只校验 JSON 结构与大小,具体字段由插件按 ClassIsland 的 ManagementSettings 解析。 + /// + private ManagementJoinRequest BuildManagementJoinRequest(DeviceInventory device) + { + var presetJson = ManagementPresetJson?.Trim(); + if (string.IsNullOrWhiteSpace(presetJson)) + throw new InvalidOperationException("请上传或粘贴 ClassIsland 集控配置文件(ManagementPreset.json)。"); + if (presetJson.Length > MaxManagementPresetJsonLength) + throw new InvalidOperationException("集控配置文件不能超过 256 KB。"); + try + { + using var document = JsonDocument.Parse(presetJson); + if (document.RootElement.ValueKind != JsonValueKind.Object) + throw new InvalidOperationException("集控配置文件必须是 JSON 对象。"); + } + catch (JsonException) + { + throw new InvalidOperationException("集控配置文件不是有效的 JSON。"); + } + + return new ManagementJoinRequest + { + PresetJson = presetJson, + ClassIdentity = string.IsNullOrWhiteSpace(device.ClassName) ? null : device.ClassName.Trim(), + }; + } + + private List ParseGuidQuery(string name) => Request.Query[name] + .SelectMany(value => (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Select(value => Guid.TryParse(value, out var id) ? id : Guid.Empty) + .Where(id => id != Guid.Empty) + .ToList(); + + protected async Task LoadBatchAsync(CancellationToken ct) + { + Classes = await Classrooms.ListAsync(ct); + Groups = await Classrooms.ListGroupsAsync(ct); + Devices = await DevicesService.ListAsync(ct); + KnownPlugins = Devices + .SelectMany(device => device.Plugins.Select(plugin => (device, plugin))) + .Where(x => !string.IsNullOrWhiteSpace(x.plugin.Id)) + .GroupBy(x => x.plugin.Id, StringComparer.OrdinalIgnoreCase) + .Select(group => + { + var first = group.First().plugin; + return new KnownPlugin( + first.Id, + string.IsNullOrWhiteSpace(first.Name) ? first.Id : first.Name, + first.Version, + first.IsEnabled, + group.Select(x => x.device.DeviceName).Distinct(StringComparer.CurrentCulture).ToList()); + }) + .OrderBy(x => x.Name, StringComparer.CurrentCulture) + .ToList(); + } + + private bool TargetsSelected() => + SelectedClassIds.Count > 0 || SelectedGroupIds.Count > 0 || SelectedConnectionIds.Count > 0; private IActionResult Back(string message) { @@ -165,30 +597,64 @@ private IActionResult Back(string message) return RedirectToPage(); } - private static bool Summary(BatchClassOperationResult result, out int ok) + protected static bool RequiresRiskConfirmation(BatchOperationKind operation) => operation is + BatchOperationKind.Power or + BatchOperationKind.DistributeProfile or + BatchOperationKind.UninstallPlugins or + BatchOperationKind.SetPluginManagementPolicy or + BatchOperationKind.UpgradePlugins or + BatchOperationKind.UpgradeClassIsland or + BatchOperationKind.RestartClassIsland or + BatchOperationKind.JoinManagement; + + protected static string OperationName(BatchOperationKind operation) => operation switch { - ok = result.Results.Count(x => x.Success); - return ok > 0; - } + BatchOperationKind.Notify => "通知广播", + BatchOperationKind.ClearNotifications => "清除通知", + BatchOperationKind.Power => "电源操作", + BatchOperationKind.VoiceMessage => "语音消息", + BatchOperationKind.UpdateTimeLayout => "时间表更新", + BatchOperationKind.DistributeProfile => "档案分发", + BatchOperationKind.InstallPlugins => "插件安装", + BatchOperationKind.UninstallPlugins => "插件卸载", + BatchOperationKind.SetPluginEnabled => "插件启停", + BatchOperationKind.SetPluginManagementPolicy => "插件管理策略", + BatchOperationKind.RefreshSoftwareInventory => "版本刷新", + BatchOperationKind.UpgradePlugins => "插件升级", + BatchOperationKind.UpgradeClassIsland => "ClassIsland 升级", + BatchOperationKind.RestartClassIsland => "重启 ClassIsland", + BatchOperationKind.JoinManagement => "加入集控", + _ => "批量操作", + }; - private static string Summarize(BatchClassOperationResult result) + protected static BatchDeviceItemResult ToItemResult(DeviceCommandResult result) => new() { - var ok = result.Results.Count(x => x.Success); - var failures = result.Results.Where(x => !x.Success).ToList(); + TargetId = result.CredentialId == Guid.Empty ? result.ClassId : result.CredentialId, + ConnectionId = result.ConnectionId, + ClassId = result.ClassId, + TargetName = result.DeviceName, + Success = result.Success, + Message = result.Success ? null : result.Message, + }; + + protected static string Summarize(IReadOnlyList results) + { + var ok = results.Count(x => x.Success); + var failures = results.Where(x => !x.Success).ToList(); return failures.Count == 0 - ? $"批量操作已完成({ok} 个班级)。" - : $"批量操作完成 {ok} 个,失败 {failures.Count} 个,详见下方逐班结果。"; + ? $"批量操作已完成({ok} 台设备)。" + : $"批量操作完成 {ok} 台,失败 {failures.Count} 台,详见下方逐设备结果。"; } - private void SaveResults(BatchClassOperationResult result) => - TempData[ResultsKey] = JsonSerializer.Serialize(result.Results, JsonDefaults.Options); + private void SaveResults(IReadOnlyList results) => + TempData[ResultsKey] = JsonSerializer.Serialize(results, JsonDefaults.Options); private void RestoreResults() { if (TempData[ResultsKey] is not string json) return; try { - LastResults = JsonSerializer.Deserialize>(json, JsonDefaults.Options) ?? []; + LastResults = JsonSerializer.Deserialize>(json, JsonDefaults.Options) ?? []; } catch (JsonException) { @@ -196,15 +662,16 @@ private void RestoreResults() } } - private async Task LoadAsync(CancellationToken ct) - { - Classes = await classrooms.ListAsync(ct); - Groups = await classrooms.ListGroupsAsync(ct); - } - private async Task RequireAdminAsync() { if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; return CurrentUser.Role == UserRole.Admin ? null : RedirectToPage("/Denied"); } + + public sealed record KnownPlugin( + string Id, + string Name, + string Version, + bool Enabled, + IReadOnlyList DeviceNames); } diff --git a/server/RemoteCI.Server/Pages/Classes.cshtml b/server/RemoteCI.Server/Pages/Classes.cshtml index 332d7cd..16dd1e1 100644 --- a/server/RemoteCI.Server/Pages/Classes.cshtml +++ b/server/RemoteCI.Server/Pages/Classes.cshtml @@ -5,252 +5,442 @@ }
-

班级 · 分组 · 访客

+

班级 · 分组 · 批量

班级管理

+

按学校、年级或校区组织班级;选择左侧分组后,右侧可直接批量管理范围内班级。

-
-

新建班级

-

每个班级对应一个教室端的 ClassIsland 插件与独立课表;创建后在概览页生成绑定该班级的配对码接入插件。

-
- - -
-
- -
-

班级分组

-

分组支持层级(如学校 → 年级 → 班级组),同一个班级可同时属于多个分组;批量操作与广播通知按组展开时包含全部子分组中的班级。

-
- - - -
- - @if (Model.Groups.Count > 0) - { -
- @foreach (var group in Model.Groups) + + @if (Model.Groups.Count == 0) { -
- - @group.Name - @if (group.ParentId is not null) +

还没有分组。可以先创建学校、年级或校区节点。

+ } + +
+ 新建分组 +
+ + +
+ + + + + + + +
+
+
+

当前范围

+

@Model.ScopeTitle

+

@Model.ScopeDescription

+
+
+
+ 新建班级 +
+ + + +

每个班级只允许一台插件设备。设置固定配对码后,插件用该码连接会自动绑定到本班;连接新设备会断开旧设备。

+ +
- } +
- } - else - { -
还没有分组,创建后可以把班级划入分组。
- } -
-
-
-

全部班级

默认班级不可删除;删除班级会同时移除成员关系与插件凭据(需重新配对)。勾选班级或按分组批量执行。

-
- @if (Model.Classes.Count == 0) - { -
还没有班级。
- } - else - { -
- - @if (Model.Groups.Count > 0) - { -
+ + + +@if (Model.CurrentUser.Role == UserRole.Admin) +{ +
+
+
+

连接码与设备分配

+

班级固定配对码让插件连接后自动绑定到对应班级,每个班级同时只保留一台设备;统一连接码可连接无限台设备,但设备先进入“未分配”,需在此指派班级后才能操控该班设备。

+
+ 统一连接码@(Model.HasUnifiedCode ? "已设置" : "未设置") +
+
+
+

统一连接码

+

使用统一连接码接入的设备会归入“未分配”,插件端可填写班级名备注,方便你在此对照。重新保存会替换旧的统一连接码。

+
+ + + +
+
+
+

未分配设备(@Model.UnassignedDevices.Count)

+ @if (Model.UnassignedDevices.Count == 0) + { +

暂无未分配设备。使用统一连接码接入的插件会显示在这里。

+ } + else + { +
    + @foreach (var device in Model.UnassignedDevices) + { +
  • +
    + @device.DeviceName + @(device.Online ? "在线" : "离线") +
    + 班级备注:@(string.IsNullOrWhiteSpace(device.Remark) ? "未填写" : device.Remark) +
    +
    + + + + +
    +
    + + + +
    +
    +
  • + } +
+ } +
+
+
+} + +@if (Model.CurrentUser.Role == UserRole.Admin) +{ +
+
+
+

批量导入与导出

+

用 Excel 批量新建或修改班级与分组。普通导入只新建;覆盖导入使用 RemoteCI 导出的 Excel,按隐藏的“系统标识”更新已有对象并新增行,不会因为删除某一行而删除班级或分组。

+
+
+
+
+

1. 下载模板

+

模板包含“分组”和“班级”两个工作表。分组可用“上级分组”建立层级;班级可填写多个分组,用分号“;”分隔。

+ 下载 Excel 模板 +
+
+

2. 导入新建

+

填写模板后上传。名称已存在、上级分组缺失、层级超限或字段非法会逐条列出,其他合法行仍会创建。

+
+ + + +
+
+
+

3. 导出并覆盖修改

+

导出当前范围内的班级和全部分组;可修改名称、分组归属与访客开关后上传同一文件覆盖。覆盖导入只新增与修改,不会删除。

+
+ + +
+
+ + + +
+
+
+ + @if (Model.ExcelImportResult is { } report) + { +
+
+
+ @(report.Overwrite ? "覆盖导入结果" : "导入结果") + @report.Summary +
+
+ @if (report.HasFailures) + { + + }
}
+} diff --git a/server/RemoteCI.Server/Pages/Classes.cshtml.cs b/server/RemoteCI.Server/Pages/Classes.cshtml.cs index c45f366..f6da1a0 100644 --- a/server/RemoteCI.Server/Pages/Classes.cshtml.cs +++ b/server/RemoteCI.Server/Pages/Classes.cshtml.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; using RemoteCI.Server.Data; using RemoteCI.Server.Services; using RemoteCI.Shared; @@ -15,17 +16,33 @@ public sealed class ClassesModel( IdentityCoordinator identities, AccountRoleService roleService, AuthorizationSyncService authorizationSync, - VisitorAccessSettings visitorAccess) + VisitorAccessSettings visitorAccess, + ClassExcelService classExcel, + IMemoryCache reportCache, + PeerRegistry peers) : WebPageModel(users) { [BindProperty] public string NewClassName { get; set; } = string.Empty; + /// 新建班级时可选的固定配对码;留空则创建后再单独设置。 [BindProperty] - public List SelectedClassIds { get; set; } = []; + public string? NewClassPairCode { get; set; } + + /// 班级管理页设置统一连接码时的可选输入;留空表示自动生成。 + [BindProperty] + public string? UnifiedPairCodeInput { get; set; } + + /// 为某个班级设置/重置固定配对码时的可选输入;留空表示自动生成。 + [BindProperty] + public string? ClassPairCodeInput { get; set; } + /// 把未分配设备指派到目标班级时提交的班级 Id。 [BindProperty] - public List SelectedGroupIds { get; set; } = []; + public Guid AssignDeviceClassId { get; set; } + + [BindProperty] + public List SelectedClassIds { get; set; } = []; [BindProperty] public string BatchOperation { get; set; } = string.Empty; @@ -33,14 +50,59 @@ public sealed class ClassesModel( [BindProperty] public string NewGroupName { get; set; } = string.Empty; + [BindProperty] + public List BatchTargetGroupIds { get; set; } = []; + + /// 当前树中选中的分组;为空表示查看全部班级。 + [BindProperty(SupportsGet = true, Name = "groupId")] + public Guid? SelectedGroupId { get; set; } + public IReadOnlyList Classes { get; private set; } = []; public IReadOnlyList Groups { get; private set; } = []; + public IReadOnlyList GroupTree { get; private set; } = []; + public IReadOnlyList VisibleClasses { get; private set; } = []; + public ClassGroupInfo? SelectedGroup { get; private set; } + public string ScopeTitle => SelectedGroup?.Name ?? "全部班级"; + public string ScopeDescription => SelectedGroup is null + ? "显示全部班级;勾选后可批量调整访客、分组或删除。" + : $"显示“{SelectedGroup.Name}”及其全部子分组中的班级;可直接批量管理。"; + public int OnlineClassCount => VisibleClasses.Count(x => x.PluginCount > 0); + public int VisitorClassCount => VisibleClasses.Count(x => x.VisitorEnabled); public Dictionary> MembersByClass { get; private set; } = new(); + + /// 树节点只承载渲染所需的派生信息,避免在 Razor 中重复计算层级与范围。 + public sealed class ClassGroupTreeNode + { + public ClassGroupInfo Group { get; init; } = null!; + public IReadOnlyList Children { get; init; } = []; + public IReadOnlyList SubtreeGroupIds { get; init; } = []; + public IReadOnlyList ParentCandidates { get; init; } = []; + public int TotalClassCount { get; init; } + public bool IsSelected { get; init; } + } public IReadOnlyList Accounts { get; private set; } = []; public IReadOnlyList RoleDefinitions { get; private set; } = []; public bool AnyVisitorClass { get; private set; } public bool AutoEnter { get; private set; } + /// 是否已设置统一连接码;不展示明文。 + public bool HasUnifiedCode { get; private set; } + + /// 使用统一连接码接入、尚未分配班级的设备。 + public IReadOnlyList UnassignedDevices { get; private set; } = []; + + /// 一次性生成的固定配对码明文,仅本次响应展示一次。 + public string? GeneratedPairCode { get; private set; } + + /// 刚生成的固定码属于哪个班级,用于在对应行高亮提示。 + public Guid? GeneratedPairCodeClassId { get; private set; } + + public sealed record UnassignedDevice( + Guid CredentialId, string DeviceName, string? Remark, bool Online, DateTimeOffset LastSeenAt); + + /// 最近一次 Excel 导入结果;完整失败列表通过短时缓存跨重定向传递。 + public ClassExcelImportResult? ExcelImportResult { get; private set; } + public async Task OnGetAsync(CancellationToken ct) { if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; @@ -55,11 +117,93 @@ public async Task OnPostCreateAsync(CancellationToken ct) if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); try { - await classrooms.CreateAsync(NewClassName, ct); - TempData["Message"] = "班级已创建。"; + var created = await classrooms.CreateAsync(NewClassName, ct); + // 新建时可一并指定固定配对码;留空则先建班,之后在行内单独设置。 + if (!string.IsNullOrWhiteSpace(NewClassPairCode)) + { + await identities.SetClassPairingCodeAsync(created.Id, NewClassPairCode, ct); + TempData["Message"] = "班级已创建并设置固定配对码。"; + } + else + { + TempData["Message"] = "班级已创建。"; + } } catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } - return RedirectToPage(); + return RedirectToClasses(); + } + + /// + /// 设置统一连接码:使用该码接入的设备可无限连接,但一律进入“未分配”, + /// 需管理员在本页指派班级后才能参与该班的数据与控制。 + /// + public async Task OnPostSetUnifiedCodeAsync(CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + try + { + var code = await identities.CreateSharedPluginPairingCodeAsync(UnifiedPairCodeInput, ct); + TempData["Message"] = $"统一连接码已更新:{code}(仅本次显示,请立即记录)。"; + } + catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } + return RedirectToClasses(); + } + + /// 设置或重置某个班级的固定配对码;生成的明文只在本次响应展示一次。 + public async Task OnPostSetClassPairCodeAsync(Guid id, CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + try + { + GeneratedPairCode = await identities.SetClassPairingCodeAsync(id, ClassPairCodeInput, ct); + GeneratedPairCodeClassId = id; + TempData["Message"] = "班级固定配对码已设置,插件使用该码连接后会自动绑定到本班。"; + } + catch (IdentityOperationException ex) + { + TempData["Error"] = ex.Message; + return RedirectToClasses(); + } + await LoadAsync(ct); + return Page(); + } + + /// 把未分配设备指派到班级;指派后立即推送该班授权镜像,无需插件重连。 + public async Task OnPostAssignDeviceAsync(Guid credentialId, CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + if (AssignDeviceClassId == Guid.Empty) + { + TempData["Error"] = "请选择要指派到的班级。"; + return RedirectToClasses(); + } + try + { + await identities.AssignPluginCredentialAsync(credentialId, AssignDeviceClassId, ct); + var sync = await identities.CreateSyncAsync(AssignDeviceClassId, ct); + await peers.ReassignPluginConnectionsAsync(credentialId, AssignDeviceClassId, sync, ct); + TempData["Message"] = "设备已指派到班级。"; + } + catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } + return RedirectToClasses(); + } + + /// 吊销未分配设备的凭据,清理误接入或废弃的连接。 + public async Task OnPostRevokeUnassignedAsync(Guid credentialId, CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + try + { + await identities.RevokePluginCredentialAsync(credentialId, ct); + await peers.DisconnectPluginCredentialAsync(credentialId, ct); + TempData["Message"] = "未分配设备已移除。"; + } + catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } + return RedirectToClasses(); } public async Task OnPostRenameAsync(Guid id, string name, CancellationToken ct) @@ -72,7 +216,7 @@ public async Task OnPostRenameAsync(Guid id, string name, Cancell TempData["Message"] = "班级名称已更新。"; } catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } - return RedirectToPage(); + return RedirectToClasses(); } public async Task OnPostDeleteAsync(Guid id, CancellationToken ct) @@ -87,7 +231,7 @@ public async Task OnPostDeleteAsync(Guid id, CancellationToken ct TempData["Message"] = "班级已删除,成员关系与插件凭据一并移除。"; } catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } - return RedirectToPage(); + return RedirectToClasses(); } public async Task OnPostToggleVisitorAsync(Guid id, bool enabled, CancellationToken ct) @@ -100,7 +244,7 @@ public async Task OnPostToggleVisitorAsync(Guid id, bool enabled, TempData["Message"] = enabled ? "已开启该班级的访客功能。" : "已关闭该班级的访客功能。"; } catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } - return RedirectToPage(); + return RedirectToClasses(); } public async Task OnPostAddMemberAsync(Guid id, Guid userId, Guid roleId, CancellationToken ct) @@ -120,7 +264,7 @@ public async Task OnPostAddMemberAsync(Guid id, Guid userId, Guid TempData["Message"] = "成员已添加。"; } catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } - return RedirectToPage(new { classId = (Guid?)id }); + return RedirectToPage(new { classId = (Guid?)id, groupId = SelectedGroupId }); } public async Task OnPostRemoveMemberAsync(Guid id, Guid userId, CancellationToken ct) @@ -139,7 +283,7 @@ public async Task OnPostRemoveMemberAsync(Guid id, Guid userId, C TempData["Message"] = "成员已移出班级。"; } catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } - return RedirectToPage(new { classId = (Guid?)id }); + return RedirectToPage(new { classId = (Guid?)id, groupId = SelectedGroupId }); } public async Task OnPostBatchAsync(CancellationToken ct) @@ -149,28 +293,57 @@ public async Task OnPostBatchAsync(CancellationToken ct) if (SelectedClassIds.Count == 0) { TempData["Error"] = "请先勾选要操作的班级。"; - return RedirectToPage(); + return RedirectToClasses(); } - var result = await classrooms.BatchAsync( - new BatchClassOperationRequest + + var operation = BatchOperation.Trim(); + var isGroupOperation = IsBatchGroupOperation(operation); + BatchClassOperationResult result; + try + { + if (isGroupOperation) { - ClassIds = SelectedClassIds, - GroupIds = SelectedGroupIds, - Operation = BatchOperation, - }, ct); + if (!operation.Equals("clearGroups", StringComparison.OrdinalIgnoreCase) && BatchTargetGroupIds.Count == 0) + { + TempData["Error"] = "请选择要批量调整到的目标分组。"; + return RedirectToClasses(); + } + + result = await classrooms.BatchSetClassGroupsAsync( + SelectedClassIds, BatchTargetGroupIds, operation, ct); + } + else + { + result = await classrooms.BatchAsync( + new BatchClassOperationRequest + { + ClassIds = SelectedClassIds, + Operation = operation, + }, ct); + } + } + catch (IdentityOperationException ex) + { + TempData["Error"] = ex.Message; + return RedirectToClasses(); + } + var succeeded = result.Results.Count(x => x.Success); if (succeeded > 0) { - if (BatchOperation.Equals("delete", StringComparison.OrdinalIgnoreCase)) + if (operation.Equals("delete", StringComparison.OrdinalIgnoreCase)) foreach (var item in result.Results.Where(x => x.Success)) await peersDisconnect(item.ClassId, ct); - await authorizationSync.SyncAsync(ct); + if (!isGroupOperation) + await authorizationSync.SyncAsync(ct); } + var failures = result.Results.Where(x => !x.Success).ToList(); + var operationName = isGroupOperation ? "批量分组调整" : "批量操作"; TempData[succeeded > 0 ? "Message" : "Error"] = failures.Count == 0 - ? $"批量操作已完成({succeeded} 个班级)。" - : $"批量操作完成 {succeeded} 个,失败 {failures.Count} 个:{string.Join(";", failures.Select(x => x.Message))}"; - return RedirectToPage(); + ? $"{operationName}已完成({succeeded} 个班级)。" + : $"{operationName}完成 {succeeded} 个,失败 {failures.Count} 个:{string.Join(";", failures.Select(x => x.Message))}"; + return RedirectToClasses(); } public async Task OnPostCreateGroupAsync(Guid? parentGroupId, CancellationToken ct) @@ -179,11 +352,15 @@ public async Task OnPostCreateGroupAsync(Guid? parentGroupId, Can if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); try { - await classrooms.CreateGroupAsync(NewGroupName, parentGroupId, ct); + var created = await classrooms.CreateGroupAsync(NewGroupName, parentGroupId, ct); TempData["Message"] = "分组已创建。"; + return RedirectToPage(new { groupId = created.Id }); + } + catch (IdentityOperationException ex) + { + TempData["Error"] = ex.Message; + return RedirectToClasses(); } - catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } - return RedirectToPage(); } public async Task OnPostRenameGroupAsync(Guid id, string name, Guid? parentGroupId, CancellationToken ct) @@ -196,7 +373,7 @@ public async Task OnPostRenameGroupAsync(Guid id, string name, Gu TempData["Message"] = "分组已更新。"; } catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } - return RedirectToPage(); + return RedirectToClasses(); } /// 整体替换一个分组包含的班级。 @@ -210,7 +387,7 @@ public async Task OnPostSetGroupClassesAsync(Guid id, List TempData["Message"] = "分组成员班级已更新。"; } catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } - return RedirectToPage(); + return RedirectToClasses(); } /// 整体替换一个班级所属的分组(多归属)。 @@ -224,7 +401,7 @@ public async Task OnPostSetClassGroupsAsync(Guid id, List g TempData["Message"] = "班级分组已更新。"; } catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } - return RedirectToPage(); + return RedirectToClasses(); } public async Task OnPostDeleteGroupAsync(Guid id, CancellationToken ct) @@ -237,7 +414,103 @@ public async Task OnPostDeleteGroupAsync(Guid id, CancellationTok TempData["Message"] = "分组已删除,子分组上移一级,班级本身不受影响。"; } catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } - return RedirectToPage(); + return RedirectToClasses(); + } + + // ---------- Excel 批量导入 / 导出 ---------- + + public async Task OnGetTemplateAsync(CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + var content = await classExcel.CreateTemplateAsync(ct); + return File(content, ClassExcelService.ExcelContentType, ClassExcelService.TemplateFileName); + } + + public async Task OnGetExportAsync(Guid? groupId, CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + try + { + var export = await classExcel.ExportAsync(groupId, ct); + return File(export.Content, ClassExcelService.ExcelContentType, export.FileName); + } + catch (IdentityOperationException ex) + { + TempData["Error"] = ex.Message; + return RedirectToClasses(); + } + } + + public async Task OnPostImportExcelAsync(IFormFile? excelFile, CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + var bytes = await ReadExcelFileAsync(excelFile, ct); + if (bytes is null) return RedirectToClasses(); + try + { + using var stream = new MemoryStream(bytes); + var result = await classExcel.ImportAsync(stream, ct); + StoreExcelResult(result); + } + catch (IdentityOperationException ex) + { + TempData["Error"] = ex.Message; + } + return RedirectToClasses(); + } + + public async Task OnPostOverwriteExcelAsync(IFormFile? excelFile, CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + var bytes = await ReadExcelFileAsync(excelFile, ct); + if (bytes is null) return RedirectToClasses(); + try + { + using var stream = new MemoryStream(bytes); + var result = await classExcel.OverwriteAsync(stream, ct); + StoreExcelResult(result); + } + catch (IdentityOperationException ex) + { + TempData["Error"] = ex.Message; + } + return RedirectToClasses(); + } + + /// 把完整导入结果放进短时缓存,TempData 只保存一个很小的键,避免 Cookie 超限。 + private void StoreExcelResult(ClassExcelImportResult result) + { + var key = Guid.NewGuid().ToString("N"); + reportCache.Set(key, result, TimeSpan.FromMinutes(10)); + TempData["ClassExcelReportId"] = key; + TempData[result.HasFailures ? "Error" : "Message"] = result.Summary; + } + + private async Task ReadExcelFileAsync(IFormFile? file, CancellationToken ct) + { + if (file is null || file.Length == 0) + { + TempData["Error"] = "请选择要上传的 .xlsx 文件。"; + return null; + } + if (file.Length > 20 * 1024 * 1024) + { + TempData["Error"] = "Excel 文件不能超过 20 MB。"; + return null; + } + if (!string.Equals(Path.GetExtension(file.FileName), ".xlsx", StringComparison.OrdinalIgnoreCase)) + { + TempData["Error"] = "仅支持 .xlsx 格式的 Excel 文件。"; + return null; + } + using var buffer = new MemoryStream(); + await using var stream = file.OpenReadStream(); + await stream.CopyToAsync(buffer, ct); + return buffer.ToArray(); } private async Task LoadAsync(CancellationToken ct) @@ -250,6 +523,116 @@ private async Task LoadAsync(CancellationToken ct) MembersByClass[classroom.Id] = await classrooms.ListMembersAsync(classroom.Id, ct); AnyVisitorClass = await visitorAccess.AnyVisitorClassEnabledAsync(ct); AutoEnter = await visitorAccess.GetAutoEnterAsync(ct); + HasUnifiedCode = await identities.HasSharedPluginPairingCodeAsync(ct); + // 未分配设备把凭据(含班级名备注)与在线设备名合并展示,离线时仍能看到最后状态。 + var unassigned = await identities.ListUnassignedPluginCredentialsAsync(ct); + var onlineByName = peers.GetPluginDeviceSnapshots() + .Where(x => !x.Assigned && x.PluginCredentialId is not null) + .GroupBy(x => x.PluginCredentialId!.Value) + .ToDictionary( + group => group.Key, + group => group.OrderByDescending(x => x.SoftwareInventoryAt ?? DateTimeOffset.MinValue).First()); + UnassignedDevices = unassigned.Select(credential => + { + onlineByName.TryGetValue(credential.Id, out var snapshot); + return new UnassignedDevice( + credential.Id, + snapshot?.DisplayName ?? credential.Name, + credential.ClassNameRemark, + snapshot is not null, + credential.LastSeenAt); + }).ToList(); + if (TempData["ClassExcelReportId"] is string reportId && + reportCache.TryGetValue(reportId, out ClassExcelImportResult? report)) + { + ExcelImportResult = report; + } + + // 无效的 query 值不应让页面停留在“已选中但无内容”的状态。 + if (SelectedGroupId is { } selectedId && Groups.All(x => x.Id != selectedId)) + SelectedGroupId = null; + SelectedGroup = SelectedGroupId is { } id ? Groups.First(x => x.Id == id) : null; + GroupTree = BuildGroupTree(Groups, SelectedGroupId); + VisibleClasses = ResolveVisibleClasses(Classes, GroupTree, SelectedGroupId); + } + + private IActionResult RedirectToClasses() => RedirectToPage(new { groupId = SelectedGroupId }); + + private static bool IsBatchGroupOperation(string operation) => + operation.Equals("addGroups", StringComparison.OrdinalIgnoreCase) || + operation.Equals("removeGroups", StringComparison.OrdinalIgnoreCase) || + operation.Equals("replaceGroups", StringComparison.OrdinalIgnoreCase) || + operation.Equals("clearGroups", StringComparison.OrdinalIgnoreCase); + + private static IReadOnlyList BuildGroupTree( + IReadOnlyList groups, Guid? selectedGroupId) + { + var childrenByParent = groups + .GroupBy(x => x.ParentId ?? Guid.Empty) + .ToDictionary(x => x.Key, x => x.OrderBy(group => group.Name, StringComparer.CurrentCulture).ToList()); + var knownIds = groups.Select(x => x.Id).ToHashSet(); + + ClassGroupTreeNode Build(ClassGroupInfo group, HashSet path) + { + // 数据库外键会阻止正常环,但损坏数据也不应让页面递归溢出。 + if (!path.Add(group.Id)) + return new ClassGroupTreeNode { Group = group, IsSelected = group.Id == selectedGroupId }; + + var children = childrenByParent.GetValueOrDefault(group.Id)? + .Where(child => !path.Contains(child.Id)) + .Select(child => Build(child, path)) + .ToList() ?? []; + path.Remove(group.Id); + + var subtreeGroupIds = new List { group.Id }; + foreach (var child in children) + subtreeGroupIds.AddRange(child.SubtreeGroupIds); + + return new ClassGroupTreeNode + { + Group = group, + Children = children, + SubtreeGroupIds = subtreeGroupIds, + ParentCandidates = groups, + TotalClassCount = group.ClassCount + children.Sum(x => x.TotalClassCount), + IsSelected = group.Id == selectedGroupId, + }; + } + + var roots = groups + .Where(group => group.ParentId is null || !knownIds.Contains(group.ParentId.Value)) + .OrderBy(group => group.Name, StringComparer.CurrentCulture) + .ToList(); + return roots.Select(group => Build(group, [])).ToList(); + } + + private static IReadOnlyList ResolveVisibleClasses( + IReadOnlyList classes, IReadOnlyList tree, Guid? selectedGroupId) + { + if (selectedGroupId is not { } id) + return classes; + + var node = FindGroupNode(tree, id); + if (node is null) + return classes; + + var groupIds = node.SubtreeGroupIds.ToHashSet(); + return classes + .Where(classroom => classroom.GroupIds is { Count: > 0 } && classroom.GroupIds.Any(groupIds.Contains)) + .ToList(); + } + + private static ClassGroupTreeNode? FindGroupNode(IReadOnlyList nodes, Guid id) + { + foreach (var node in nodes) + { + if (node.Group.Id == id) + return node; + var child = FindGroupNode(node.Children, id); + if (child is not null) + return child; + } + return null; } private async Task peersDisconnect(Guid classId, CancellationToken ct) diff --git a/server/RemoteCI.Server/Pages/Control.cshtml b/server/RemoteCI.Server/Pages/Control.cshtml index a1c0b53..b48443c 100644 --- a/server/RemoteCI.Server/Pages/Control.cshtml +++ b/server/RemoteCI.Server/Pages/Control.cshtml @@ -10,44 +10,43 @@

ClassIsland 远程操作

控制

-

这里汇总手表控制页中的操作,命令仅在插件在线且账号具有对应权限时执行。

+

所有操作默认作用于当前班级;需要参数的功能会先弹出填写窗口,其余操作点击后直接执行。

@(Model.PluginOnline ? "插件在线" : "插件离线") -
- @if (Model.CanSendVoiceMessages) - { -
-
- -

语音消息

录制并试听后发送,ClassIsland 会自动播放。每条最长 60 秒。

-
-
-
- - - - -
- -

需要允许麦克风权限;浏览器录音需要 HTTPS 或 localhost。

+@* 复用批量控制页的动作卡片与参数弹窗;SingleControl 模式下目标固定为当前班级。 *@ +@await Html.PartialAsync("_BatchControlActions", Model) + +@* 班级控制页独有、且无需填写参数的操作:点击后直接执行或跳到下方编辑区。 *@ +
+

当前班级

无需填写参数的操作会直接执行。

+
+ @if (Model.CanControlMainMenu) + { + + + -
- } - @if (Model.CanTeacherComing) - { -
-
- -

老师来了

-
-
- + } + @if (Model.CanTeacherComing) + { + +
-
- } + } + @if (Model.CanControlVolume) + { + 音量调节打开当前班级设备音量控制。 + } + @if (Model.CanManageClassInfo) + { + 班级设置修改当前班级名称与头像。 + } +
+
+
@if (Model.CanManageClassInfo) {
@@ -78,26 +77,49 @@
} - @if (Model.CanSendNotifications) + @if (Model.CanControlVolume) { -
+
- -

发送通知

向 ClassIsland 发送自定义通知,成功后广播给在线手表。

+ +

音量

当前音量 @(snapshot?.VolumePercent ?? 0)% · @(snapshot?.IsMuted == true ? "已静音" : "未静音")

-
-
- - - +
+ + + 拖动后自动应用 -
-

发送人显示

- -

此设置对 WebUI 和手表发送的自定义通知全局生效。

- + + +
+ @if (!volumeAvailable) + { +

插件尚未报告可用的 Windows 默认播放设备。

+ } +
+ } + + @if (Model.CanSendNotifications) + { +
+
+ +

发送人显示

自定义通知是否在标题中附带发送人。

+
+
+ +

此设置对 WebUI 和手表发送的自定义通知全局生效。

+ +
@if (Model.ShowBroadcast) @@ -109,6 +131,18 @@
+
+ + +
+
+ 提醒选项 + + + + +
+

重复次数让正文横向滚动指定遍数,总显示时长为“持续时间 × 重复次数”。

@foreach (var target in Model.BroadcastTargets) { @@ -131,84 +165,6 @@
} - - @if (Model.CanClearNotifications) - { -
-
- -

清除通知

清除 ClassIsland 当前正在显示的提醒。

-
-
- -
-
- } - } - - @if (Model.CanControlMainMenu) - { - - } - - @if (Model.CanControlVolume) - { -
-
- -

音量

当前音量 @(snapshot?.VolumePercent ?? 0)% · @(snapshot?.IsMuted == true ? "已静音" : "未静音")

-
-
-
- - 拖动后自动应用 -
-
- - -
-
- @if (!volumeAvailable) - { -

插件尚未报告可用的 Windows 默认播放设备。

- } -
- - } - - @if (Model.CanControlPower) - { -
-
- -

电源

直接控制运行 ClassIsland 的教室电脑,请在执行前确认。

-
-
-
-
-
- @if (snapshot?.IsHibernateAvailable == true) - { -
- } -
-
} @if (Model.CanUseExtensions) diff --git a/server/RemoteCI.Server/Pages/Control.cshtml.cs b/server/RemoteCI.Server/Pages/Control.cshtml.cs index 483d59f..807a3cc 100644 --- a/server/RemoteCI.Server/Pages/Control.cshtml.cs +++ b/server/RemoteCI.Server/Pages/Control.cshtml.cs @@ -12,13 +12,15 @@ namespace RemoteCI.Server.Pages; [Authorize] public sealed class ControlModel( UserManager users, + ClassroomService classrooms, + DeviceInventoryService devices, PeerRegistry peers, IStateStore store, IdentityCoordinator identities, ExtensionPolicyService extensionPolicies, AuthorizationSyncService authorizationSync, ClassBroadcastService broadcast, - ClassroomService classesService) : WebPageModel(users) + ClassroomService classesService) : BatchControlModel(users, classrooms, devices, identities) { private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(15); @@ -59,7 +61,7 @@ public sealed class ControlModel( public bool CanControlPower => ClassPermissions.HasFlag(UserPermissions.PowerControl) && Supports(RemoteCiCapabilities.PowerControl); public bool CanControlVolume => ClassPermissions.HasFlag(UserPermissions.PowerControl) && Supports(RemoteCiCapabilities.VolumeControl); public bool CanUseExtensions => ClassPermissions.HasFlag(UserPermissions.RunExtensions) && Supports(RemoteCiCapabilities.ExtensionsRun); - public bool IsAdmin => CurrentUser.Role == UserRole.Admin; + public override bool IsSingleControl => true; /// 广播面板的候选班级:当前用户有通知发送权限的班级(不含当前班——单班用上方表单即可)。 public IReadOnlyList BroadcastTargets { get; private set; } = []; @@ -67,7 +69,7 @@ public sealed class ControlModel( /// 有至少两个可广播班级时才展示广播面板,单班级部署保持原有操作路径。 public bool ShowBroadcast => BroadcastTargets.Count >= 2; - public async Task OnGetAsync(CancellationToken ct) + public override async Task OnGetAsync(CancellationToken ct) { if (await LoadAsync(ct) is { } denied) return denied; return Page(); @@ -124,6 +126,12 @@ public async Task OnPostNotificationAsync(CancellationToken ct) Title = string.IsNullOrWhiteSpace(Input.Title) ? "RemoteCI 通知" : Input.Title.Trim(), Message = Input.Message?.Trim() ?? string.Empty, ForceSenderInTitle = await identities.GetForceSenderInTitleAsync(ct), + IsSpeechEnabled = Input.IsSpeechEnabled, + IsNotificationSoundEnabled = Input.IsNotificationSoundEnabled, + IsNotificationEffectEnabled = Input.IsNotificationEffectEnabled, + IsNotificationTopmostEnabled = Input.IsNotificationTopmostEnabled, + DurationSeconds = Input.DurationSeconds, + RepeatCounts = Input.RepeatCounts, }, }, ct); return RedirectWithResult(result); @@ -202,6 +210,18 @@ public async Task OnPostPowerAsync(PowerActionKind action, Cancel }, ct)); } + public async Task OnPostRestartClassIslandAsync(CancellationToken ct) + { + if (await RequireAsync() is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) + return RedirectToPage("/Denied"); + if (RequireClass(UserPermissions.ManageUsers) is { } classDenied) return classDenied; + return RedirectWithResult(await SendAsync(new CommandMessage + { + Command = CommandKind.RestartClassIsland, + }, ct)); + } + public async Task OnPostExtensionAsync(string extensionId, CancellationToken ct) { if (await RequireAsync() is { } denied) return denied; @@ -363,6 +383,12 @@ await identities.GetProfileAsync(CurrentUser.Id, ct), { Title = BroadcastInput.Title, Message = BroadcastInput.Message, + IsSpeechEnabled = BroadcastInput.IsSpeechEnabled, + IsNotificationSoundEnabled = BroadcastInput.IsNotificationSoundEnabled, + IsNotificationEffectEnabled = BroadcastInput.IsNotificationEffectEnabled, + IsNotificationTopmostEnabled = BroadcastInput.IsNotificationTopmostEnabled, + DurationSeconds = BroadcastInput.DurationSeconds, + RepeatCounts = BroadcastInput.RepeatCounts, }, ClassIds = BroadcastClassIds, }, ct); @@ -377,6 +403,7 @@ await identities.GetProfileAsync(CurrentUser.Id, ct), private async Task LoadAsync(CancellationToken ct) { if (await RequireAsync() is { } denied) return denied; + await LoadBatchAsync(ct); Snapshot = store.GetLatestSnapshot(CurrentClassId); VolumeLevel = Snapshot?.VolumePercent ?? 0; if (CanSendNotifications) ForceSenderInTitle = await identities.GetForceSenderInTitleAsync(); @@ -441,6 +468,19 @@ public sealed class NoticeInput { [StringLength(60)] public string? Title { get; set; } [StringLength(500)] public string? Message { get; set; } + + /// 单条显示秒数;null 或 <= 0 时插件按 ClassIsland 集控默认 5 秒处理。 + [Range(0, 3600)] public int? DurationSeconds { get; set; } + + /// 正文滚动重复次数;null 或 < 1 时按 1 次处理。 + [Range(0, 10)] public int? RepeatCounts { get; set; } + + public bool IsSpeechEnabled { get; set; } + public bool IsNotificationSoundEnabled { get; set; } + public bool IsNotificationEffectEnabled { get; set; } + + /// 提醒时置顶 ClassIsland 主界面。 + public bool IsNotificationTopmostEnabled { get; set; } } public sealed class ExtensionInput diff --git a/server/RemoteCI.Server/Pages/Index.cshtml b/server/RemoteCI.Server/Pages/Index.cshtml index 96b1a5e..66bdb20 100644 --- a/server/RemoteCI.Server/Pages/Index.cshtml +++ b/server/RemoteCI.Server/Pages/Index.cshtml @@ -4,7 +4,7 @@
-

REMOTE CLASSROOM OPERATIONS

+

课堂工作台

课堂控制总览

集中查看课程、连接与同步状态,并快速进入常用操作。

@@ -27,19 +27,6 @@
} - - @if (Model.PluginProtocolMismatch is { } mismatch) {