diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ecbc0f..d00f84f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,22 @@ jobs: chmod +x gradlew ./gradlew :app:assembleDebug :app:testDebugUnitTest --console=plain + + android: + name: android-mobile + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + - name: Build and test phone app + working-directory: android + run: | + chmod +x gradlew + ./gradlew :app:assembleDebug :app:testDebugUnitTest --console=plain + fnos: name: fnos fpk 打包检查 runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8518aa..7945a4b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -164,6 +164,56 @@ jobs: name: watch-apk path: RemoteCI.Watch-*.apk + + build-android: + name: Phone APK + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + - name: Prepare signing key + env: + ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + for name in ANDROID_KEYSTORE_B64 ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD; do + if [ -z "${!name}" ]; then + echo "::error::missing $name" + exit 1 + fi + done + mkdir -p android/keystore + echo "$ANDROID_KEYSTORE_B64" | base64 -d > android/keystore/remoteci-release.jks + { + echo "storeFile=keystore/remoteci-release.jks" + echo "storePassword=$ANDROID_KEYSTORE_PASSWORD" + echo "keyAlias=$ANDROID_KEY_ALIAS" + echo "keyPassword=$ANDROID_KEY_PASSWORD" + } > android/keystore.properties + - name: Build Release APK + working-directory: android + run: | + source ../.github/scripts/resolve-release-version.sh "$GITHUB_REF_NAME" >/dev/null + grep -Fq 'const val VERSION = 3' app/src/main/java/com/remoteci/mobile/data/Protocol.kt + chmod +x gradlew + ./gradlew :app:assembleRelease -PreleaseVersion="$version" --console=plain + grep -Fq "\"versionName\": \"$version\"" app/build/outputs/apk/release/output-metadata.json + - name: Rename APK + run: | + source .github/scripts/resolve-release-version.sh "$GITHUB_REF_NAME" >/dev/null + cp android/app/build/outputs/apk/release/app-release.apk "RemoteCI.Mobile-${version}.apk" + apksigner="$(find "$ANDROID_HOME/build-tools" -type f -name apksigner | sort -V | tail -n 1)" + "$apksigner" verify --print-certs "RemoteCI.Mobile-${version}.apk" + - uses: actions/upload-artifact@v4 + with: + name: mobile-apk + path: RemoteCI.Mobile-*.apk + build-fnos: name: fnOS 应用 (镜像 + fpk) runs-on: ubuntu-latest @@ -301,7 +351,7 @@ jobs: release: name: 创建 GitHub Release - needs: [build-server, build-plugin, build-wearos, build-fnos] + needs: [build-server, build-plugin, build-wearos, build-android, build-fnos] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -321,6 +371,10 @@ jobs: with: name: watch-apk path: artifacts/watch-apk + - uses: actions/download-artifact@v4 + with: + name: mobile-apk + path: artifacts/mobile-apk - uses: actions/download-artifact@v4 with: name: fnos-fpk @@ -334,6 +388,7 @@ jobs: test -f artifacts/plugin-cipx/checksums.md test -f artifacts/plugin-cipx/remoteci.plugin.yml test -f "artifacts/watch-apk/RemoteCI.Watch-${version}.apk" + test -f "artifacts/mobile-apk/RemoteCI.Mobile-${version}.apk" test -f "artifacts/fnos-fpk/RemoteCI-${version}.fpk" test -f "artifacts/fnos-fpk/RemoteCI-${version}-fnos-x86_64-offline.fpk" test -f "artifacts/fnos-fpk/RemoteCI-${version}-fnos-arm64-offline.fpk" @@ -348,6 +403,7 @@ jobs: artifacts/plugin-cipx/checksums.md artifacts/plugin-cipx/remoteci.plugin.yml artifacts/watch-apk/** + artifacts/mobile-apk/** artifacts/fnos-fpk/** body_path: artifacts/plugin-cipx/checksums.md generate_release_notes: true diff --git a/.gitignore b/.gitignore index cbe392c..5da034f 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,11 @@ yarn-error.log* # 运行时 DataProtection 密钥 server/RemoteCI.Server/Data/keys/ + +android/.gradle/ +android/build/ +android/**/build/ +android/local.properties +android/.idea/ +android/keystore.properties +android/keystore/ diff --git a/.zcode/plans/plan-sess_2d4801a2-c711-4856-a907-f824e8711c02.md b/.zcode/plans/plan-sess_2d4801a2-c711-4856-a907-f824e8711c02.md new file mode 100644 index 0000000..2f3cdc8 --- /dev/null +++ b/.zcode/plans/plan-sess_2d4801a2-c711-4856-a907-f824e8711c02.md @@ -0,0 +1,78 @@ +# 服务端多班级管理 — 实施计划 + +## 已确认的设计决策(探索后按推荐方案定,可否决) + +1. **成员模型**:新建 `ClassMembership` 多对多成员表,用户可属于多个班级且在每个班级有独立角色(引用现有 `AccountRole` 角色体系)。系统管理员(`UserRole.Admin`)自动可访问所有班级。切换班级 = 在自己有成员关系的班级间切换。 +2. **班管理员**:新增内置角色"班管理员"(新 `AccountRoleKind.ClassAdministrator`,固定 GUID,名字锁定但权限可自定义,不授予系统管理员身份),默认权限 = 查看课表、访问 WebUI、拉取课表、发送通知、语音消息、教师来电、调用扩展(不含用户管理/电源控制/主菜单控制/系统设置)。 +3. **范围**:服务端(RemoteCI.Server)+ Razor WebUI + shared 协议模型 + 测试。旧插件/手表/手机客户端保持基本可用:配对码创建时绑定班级(旧插件无感),未指定班级的 API 请求默认路由到默认班级/用户唯一成员班级。插件、Android、WearOS 的完整多班级 UI 为后续工作。 +4. **批量操作**(系统管理员):批量开启/关闭访客功能、批量删除班级。 +5. **访客开关迁移**:全局开关 → 每班级开关;`AutoEnterVisitorPage`(登录页自动进入访客页)保持全局。迁移时把原全局值落到默认班级。 + +## 1. 数据模型(`Data/Entities.cs`、`Data/AppDbContext.cs`、EF 迁移) + +- 新实体 `Classroom { Id, Name, VisitorAccessEnabled, CreatedAt, UpdatedAt }`,固定默认班级 GUID `33333333-…`(静态 `Classroom.DefaultId`,模式同 `AccountRole.StudentId`)。 +- 新实体 `ClassMembership { UserId, ClassroomId, RoleDefinitionId }`,复合主键,级联删除(删班级/删用户连带删成员关系)。 +- `PluginCredential` 增加 `ClassroomId`(插件归属班级);删除班级时级联删除其插件凭据(需重新配对)。 +- `SystemMetadata.VisitorAccessEnabled` 列删除(迁移内先把值写入默认班级);`AutoEnterVisitorPage` 保留。 +- 新迁移 `AddClassrooms`:建表 + 数据回填(`INSERT OR IGNORE` 默认班级;`UPDATE PluginCredentials SET ClassroomId=默认 WHERE NULL`;为全部现有用户按其 `RoleDefinitionId` 在默认班级建成员关系)。 +- `AppDbContext`:新增 DbSet、索引(ClassMemberships 按 ClassroomId/UserId)、唯一约束(班级名唯一)。 +- `IdentityCoordinator.BootstrapAsync`:幂等种子默认班级与"班管理员"角色(GUID `44444444-…`);新用户创建时(`CreateUserAsync`)同时在其默认班级建成员关系(保持单班级部署的现有体验)。 +- `AccountRoleService`:`ClassAdministrator` 与 Administrator 一样不可删除/改名,权限可改(同 Student 的规则)。 + +## 2. 按班级分桶的状态(`Services/IStateStore.cs`、`StateStore.cs`) + +- `IStateStore` 全部方法加 `Guid classId` 参数(`SaveSnapshot(classId, …)` / `GetLatestSnapshot(classId)` 等 4 组),实现改为 `ConcurrentDictionary`(Bucket 含现有 4 个字段,保持 Volatile 原子换引用模式)。更新所有调用点:`WebSocketHub`、`Program.cs` 各端点、`SchedulePullWorker`、`Visitor/Index/Schedule/Control` 页面。 + +## 3. 鉴权与按班级权限 + +- `AuthPrincipal` 增加插件侧 `ClassId`(`ValidatePluginTokenAsync` 从凭据读取)与用户侧 `AccessibleClassIds`(登录/刷新/WS 校验时计算;管理员=全部班级,普通用户=成员班级;`PeerAuthorizationRefreshWorker` 周期刷新,沿用现有机制)。 +- 新服务 `ClassAccessService`(scoped,请求内缓存): + - `GetAccessibleClassesAsync(user)`:管理员→全部;普通用户→成员班级(含每班角色)。 + - `EffectiveClassPermissions(user, classId)`:管理员→`All`;成员→`ViewCurrentCourse | 成员角色.DefaultPermissions | user.GrantedPermissions`;非成员→`None`。 +- `Program.cs` 端点:`/api/state`、`/api/schedule`、`/api/commands`、`/api/extensions` 增加可选 `?classId=`,缺省→用户唯一/首个成员班级(管理员→默认班级);权限判断从全局 `principal.User.Permissions` 改为按班级计算。 +- `Pages/WebPageModel.RequireAsync` 扩展:解析 Cookie `RemoteCI.Class`(校验可访问性,否则回退首个可访问班级),暴露 `CurrentClass`、`ClassPermissions`、`AccessibleClasses` 供页面/切换器使用。 + +## 4. WebSocket 路由(`WebSocketHub.cs`、`Services/PeerRegistry.cs`、`ScheduleSyncService`) + +- 插件连接按班级注册:`PeerRegistry` 增加按班取插件连接(`HasPlugin(classId)`、`SendToPluginAsync(classId, …)`、`PrimaryPluginSupports(classId, capability)`);同班多插件沿用现有"取最早连接+警告"策略。 +- 插件推送(state/schedule/event/extensions)写入对应班级桶,并只扇出给 `AccessibleClassIds` 包含该班的 watches/mobile。 +- 用户命令:`CommandMessage` 增加**可选** `classId`(shared 协议,向后兼容;缺省同上),校验按班级权限后路由到该班插件;扩展校验用该班 `GetLatestExtensions`。 +- `AccountSync` 按班级生成(`CreateSyncAsync(classId)`:该班成员 ∪ 启用的系统管理员,权限按成员角色计算),发给该班插件;`AuthorizationSyncService`/`ScheduleSyncService`/`SchedulePullWorker` 改为按班级遍历在线插件。 + +## 5. 班级管理 API(`Program.cs` + 新 `Services/ClassroomService.cs`) + +- `GET /api/me/classes`:当前用户可访问班级(id、名称、角色、有效权限)。 +- `GET /api/classes`(管理员):全量列表(成员数、插件在线数、访客开关)。 +- `POST /api/classes {name}`、`PUT /api/classes/{id} {name}`、`DELETE /api/classes/{id}`(默认班级不可删,409)。 +- `PUT /api/classes/{id}/visitor {enabled}`(每班访客开关)。 +- `GET/PUT /api/classes/{id}/members`(成员与角色编辑,管理员)。 +- `POST /api/classes/batch {classIds, operation: enableVisitor|disableVisitor|delete}`(管理员;返回逐班结果)。 +- `POST /api/plugin/pairing-code` 增加可选 `classId`(缺省默认班级)。 +- `/api/visitor` 改为只管理全局 `autoEnter`;`/api/admin/status` 增加分班插件在线统计;插件凭据列表带班级名。 +- shared 新增 `Models/ClassModels.cs`(`ClassSummary`/`ClassDetail`/`ClassMemberInput`/批量请求响应);`UserProfile` 增加可选 `classes` 字段(additive,旧客户端忽略)。 + +## 6. WebUI(Razor Pages) + +- 新页 `Pages/Classes.cshtml`(管理员):班级列表(名称、成员数、插件在线、访客开关)、新建/改名/删除、多选批量开关访客与批量删除、每班成员管理(添加用户+选角色)、创建绑定班级的配对码入口;侧边栏加入口。 +- 布局头部班级切换器:列出可访问班级,POST 设置 `RemoteCI.Class` Cookie 后回跳(`Pages/ClassSwitch.cshtml` 或共享 handler)。 +- 访客页 `Visitor`:`?class=` 选择班级;服务端渲染"开启访客功能的班级"下拉/标签;无任何班级开启→跳 `/Login`(现状行为)。 +- 登录落地:设置当前班级 Cookie(首个可访问班级);`AutoEnter` 逻辑改为"全局 autoEnter 且存在开启访客的班级"。 +- `Index/Schedule/Control/Notifications` 改用当前班级数据与按班权限(Control 的按钮按 `ClassPermissions` 显隐);`SystemConfig` 的访客区改为 autoEnter 开关(班级级开关移到 Classes 页)。 + +## 7. 备份/恢复(`ConfigurationArchiveService`) + +- 快照版本 2→3:新增 `ClassroomSnapshot`/`MembershipSnapshot`,`PluginSnapshot` 带 `ClassroomId`,`MetadataSnapshot` 移除 `VisitorAccessEnabled`(导入 v2 旧包时映射到默认班级的访客开关,沿 `UpgradeImportedPermissions` 的升级模式)。`ApplyAsync` 恢复班级/成员关系。 + +## 8. 测试(server/tests/RemoteCI.Server.Tests) + +- 新增 `ClassManagementTests`:班级 CRUD 鉴权(仅管理员)、每班访客开关、批量操作(含默认班级保护)、成员编辑、班级间权限隔离(A 班学生读不到 B 班 state;班管理员只能在本班发命令)、切换班级校验。 +- 新增 StateStore 分桶单测;`WebSocketRelayTests` 扩展按班级路由(A 班插件状态不推给 B 班 watch)。 +- 更新 `VisitorAccessTests`(多班级访客:下拉列出开启班级、`?class=` 切换、全关→跳登录)、`RoleAndBackupTests`(v3 备份往返 + v2 旧包导入)、受影响的 `ApiTests` 等。 + +## 9. 实施顺序 + +实体+迁移+Bootstrap → ClassAccessService/StateStore 分桶 → WS 路由 → API → WebUI → 备份 → 测试修补与新增。每步跑 `dotnet test`(server/tests)验证。 + +## 明确不做(后续工作) + +ClassIsland 插件、Android、WearOS 客户端的多班级 UI 与按班数据流过滤(服务端已向后兼容);班内用户自助管理;批量通知/命令(批量端点预留 operation 扩展位)。 \ No newline at end of file diff --git a/.zcodeignore b/.zcodeignore new file mode 100644 index 0000000..789dc0f --- /dev/null +++ b/.zcodeignore @@ -0,0 +1,111 @@ +# ── .NET ────────────────────────────────────────────── +bin/ +obj/ +*.user +*.suo +*.userosscache +*.sln.docstates +packages/ +artifacts/ +.tools/ +.vs/ +# ClassIsland 插件打包产物(可重新生成) +*.cipx +cipx/ +plugin/RemoteCI.Plugin/publish/ + +# ── Android / Gradle ───────────────────────────────── +*.apk +*.aab +*.ap_ +*.dex +wearos/.gradle/ +wearos/build/ +wearos/**/build/ +wearos/local.properties +wearos/.idea/ +wearos/keystore.properties +wearos/keystore/ +*.iml +captures/ +.externalNativeBuild/ +.cxx/ + +# ── IDE / OS ───────────────────────────────────────── +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store +Thumbs.db + +# ── Runtime / logs ─────────────────────────────────── +*.log +*.pid +*.seed +server/RemoteCI.Server/Data/*.db* +server/RemoteCI.Server/Data/updates/ +server/RemoteCI.Server/Data/backups/ +*.rcibak +# 防止把直接 publish 到项目目录产生的运行文件误提交。 +server/RemoteCI.Server/*.dll +server/RemoteCI.Server/*.exe +server/RemoteCI.Server/*.pdb +server/RemoteCI.Server/*.deps.json +server/RemoteCI.Server/*.runtimeconfig.json +server/RemoteCI.Server/*.staticwebassets.endpoints.json +server/RemoteCI.Server/web.config +server/RemoteCI.Server/wwwroot/**/*.br +server/RemoteCI.Server/wwwroot/**/*.gz +# 直接以 dotnet 从仓库根目录运行服务端时产生的本地 SQLite 数据 +/data/ +*.pid.lock +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# ── .codex 会话数据库 ───────────────────────────────── +.codex-design/*.db* + +# 运行时 DataProtection 密钥 +server/RemoteCI.Server/Data/keys/ + +android/.gradle/ +android/build/ +android/**/build/ +android/local.properties +android/.idea/ +android/keystore.properties +android/keystore/ + +# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)===== +.git/ +.hg/ +.svn/ +node_modules/ +bower_components/ +jspm_packages/ +__pycache__/ +site-packages/ +venv/ +coverage/ +htmlcov/ +lcov-report/ +cmakefiles/ +cmake-build-*/ +bazel-*/ +pods/ +deriveddata/ +storybook-static/ +playwright-report/ +test-results/ +allure-results/ +allure-report/ +cdk.out/ +*.egg-info/ +*.dist-info/ +eggs/ +pip-wheel-metadata/ +wheels/ +# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)----- +# 自定义规则写在下方(本行提示可删除) diff --git a/README.md b/README.md index 77bde22..4704095 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,16 @@ [![Release](https://img.shields.io/github/v/release/Edge-HH/RemoteCI?include_prereleases)](https://github.com/Edge-HH/RemoteCI/releases) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE) -> 把 ClassIsland 的课表、通知和受控操作,安全地延伸到 Web 与 Wear OS。 +> 把 ClassIsland 的课表、通知和受控操作,安全地延伸到 Web、手机和 Wear OS。 -RemoteCI 是面向 ClassIsland 2.x 的跨设备联动系统,由 ClassIsland 插件、ASP.NET Core 服务端、WebUI 和 Wear OS 应用组成。它让用户可以在浏览器或手表上查看当前课程与未来七日课表、接收课堂事件,并在权限允许时执行通知、换课和扩展操作。 +RemoteCI 是面向 ClassIsland 2.x 的跨设备联动系统,由 ClassIsland 插件、ASP.NET Core 服务端、WebUI、Android 手机和 Wear OS 应用组成。它让用户可以在浏览器、手机或手表上查看当前课程与未来七日课表、接收课堂事件,并在权限允许时执行通知、换课和扩展操作。 当前稳定软件版本为 `3.2.1.4`,通信协议为 V3。稳定版使用 ClassIsland 要求的四段纯数字版本;保留的 Beta 使用 `v3.x.x-beta.y`,仅用于测试且不会进入插件市场。 ## ✨ 主要能力 - **课程状态同步**:插件按秒推送当前课程状态,并独立同步未来七日课表。 -- **Web 与手表联动**:在 WebUI 和 Wear OS 上查看课表、课堂事件与连接状态。 +- **Web、手机与手表联动**:在 WebUI、Android 手机和 Wear OS 上查看课表、课堂事件与连接状态。 - **通知与“老师来了”**:从 WebUI 或手表触发 ClassIsland 正式通知,并把结果同步到在线设备。 - **课表操作**:支持手动拉取、自动拉取、换课与并发修订校验;多个入口共用任务锁,避免重复执行。 - **细粒度权限**:按账号分别控制概览、人员管理、通知、换课、扩展、主界面和电源等能力,命令会在服务端与插件端再次鉴权。 @@ -42,7 +42,7 @@ ClassIsland + RemoteCI 插件 ⇅ WebSocket / 局域网 ASP.NET Core 服务端 ─── WebUI ⇅ 云端或局域网 - Wear OS 应用 + Android 手机 / Wear OS 应用 ``` | 目录 | 职责 | @@ -51,6 +51,7 @@ ASP.NET Core 服务端 ─── WebUI | `server/` | ASP.NET Core 服务端、Razor WebUI、Identity 与 SQLite | | `plugin/` | ClassIsland 2.x 插件、远程命令执行与 CIPX 构建 | | `wearos/` | Kotlin / Compose for Wear OS 应用 | +| `android/` | Kotlin / Jetpack Compose Material 3 Expressive 手机应用 | | `fnos/` | 飞牛 fnOS FPK 工程与打包脚本 | | `docs/` | 部署、协议和平台说明 | @@ -64,6 +65,7 @@ ASP.NET Core 服务端 ─── WebUI - `RemoteCI.Plugin.cipx`:ClassIsland 插件市场使用的固定名称插件包。 - `RemoteCI.Watch-<版本>.apk`:Wear OS 应用。 +- `RemoteCI.Mobile-<版本>.apk`:Android 手机应用。 - 服务端压缩包:Windows 或 Linux 部署。 - `RemoteCI-<版本>.fpk`:fnOS 在线多架构包,或 x86_64 / ARM64 单架构离线包。 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..d7f95b9 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,143 @@ +import java.util.Properties + +// 软件版本与协议版本独立,Release 工作流会用标签覆盖此默认值。 +val releaseVersion = providers.gradleProperty("releaseVersion").orNull ?: "3.2.1.4" + +// Android versionCode 必须是正整数且 Beta 版本也要严格排在同核心版本的稳定版之前。 +// 版本槽位为 major*100000000 + minor*1000000 + patch*10000 + revision*1000, +// 稳定版使用槽位末尾 999,v3.x.x-beta.y 使用 y(1..998)。 +val releaseVersionMatch = Regex( + """^(?[0-9]+)\.(?[0-9]+)\.(?[0-9]+)(?:(?:\.(?[0-9]+))|(?:-beta\.(?[0-9]+)))$""", +).matchEntire(releaseVersion) + ?: error("无效的 Android 版本号:$releaseVersion") + +fun versionPart(name: String): Int = + releaseVersionMatch.groups[name]?.value?.toIntOrNull() + ?: 0 + +val releaseVersionCode = run { + val major = versionPart("major") + val minor = versionPart("minor") + val patch = versionPart("patch") + val revision = versionPart("revision") + val beta = releaseVersionMatch.groups["beta"]?.value?.toIntOrNull() + require(minor in 0..999 && patch in 0..999 && revision in 0..999) { + "版本号分段必须处于 0..999:$releaseVersion" + } + val suffix = if (beta != null) { + require(beta in 1..998) { "Beta 序号必须处于 1..998:$releaseVersion" } + beta + } else { + 999 + } + val code = major.toLong() * 100_000_000L + + minor.toLong() * 1_000_000L + + patch.toLong() * 10_000L + + revision.toLong() * 1_000L + + suffix + require(code in 1L..2_100_000_000L) { + "版本号推导出的 versionCode 超出 Android 范围:$code" + } + code.toInt() +} + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) +} + +// 签名信息从 android/keystore.properties(本机)或 CI 环境变量读取; +// 文件与密钥均不入库,缺失时 release 构建产物不签名(仅用于本地调试)。 +val keystoreProperties = Properties().apply { + val file = rootProject.file("keystore.properties") + if (file.exists()) file.inputStream().use { load(it) } +} +val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")?.let { rootProject.file(it) } + ?: keystoreProperties.getProperty("storeFile")?.let { rootProject.file(it) } +val keystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD") + ?: keystoreProperties.getProperty("storePassword") +val keystoreKeyAlias = System.getenv("ANDROID_KEY_ALIAS") ?: keystoreProperties.getProperty("keyAlias") +val keystoreKeyPassword = System.getenv("ANDROID_KEY_PASSWORD") + ?: keystoreProperties.getProperty("keyPassword") + +android { + namespace = "com.remoteci.mobile" + compileSdk = 37 + + defaultConfig { + applicationId = "com.remoteci.mobile" + minSdk = 26 + targetSdk = 37 + versionCode = releaseVersionCode + versionName = releaseVersion + testInstrumentationRunner = + "com.remoteci.mobile.NetworkSecurityPolicyInstrumentation" + } + + signingConfigs { + create("release") { + if (keystoreFile != null && keystoreFile.exists()) { + storeFile = keystoreFile + storePassword = keystorePassword + keyAlias = keystoreKeyAlias + keyPassword = keystoreKeyPassword + } + } + } + + buildTypes { + release { + // release 启用 R8 压缩;kotlinx.serialization 保留规则见 proguard-rules.pro。 + isMinifyEnabled = true + signingConfig = if (keystoreFile != null && keystoreFile.exists()) { + signingConfigs.getByName("release") + } else { + null + } + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + buildConfig = true + compose = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.foundation) + implementation(libs.compose.material.icons.extended) + implementation(libs.compose.material3) + implementation(libs.navigation.compose) + implementation(libs.activity.compose) + implementation(libs.lifecycle.viewmodel.compose) + implementation(libs.lifecycle.runtime.ktx) + implementation(libs.zxing.embedded) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.okhttp) + testImplementation(kotlin("test")) + testImplementation(libs.mockwebserver3) + debugImplementation(libs.compose.ui.tooling) +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..c6e1822 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,7 @@ +# 协议模型使用 kotlinx.serialization,混淆时保留序列化注解与生成的 Serializer。 +-keepattributes *Annotation*, InnerClasses +-keepclassmembers class kotlinx.serialization.json.** { *** Companion; } +-keepclasseswithmembers class com.remoteci.mobile.data.** { + kotlinx.serialization.KSerializer serializer(...); +} +-keep,includedescriptorclasses class com.remoteci.mobile.data.**$$serializer { *; } diff --git a/android/app/src/androidTest/java/com/remoteci/mobile/NetworkSecurityPolicyInstrumentation.kt b/android/app/src/androidTest/java/com/remoteci/mobile/NetworkSecurityPolicyInstrumentation.kt new file mode 100644 index 0000000..e33488d --- /dev/null +++ b/android/app/src/androidTest/java/com/remoteci/mobile/NetworkSecurityPolicyInstrumentation.kt @@ -0,0 +1,43 @@ +package com.remoteci.mobile + +import android.app.Activity +import android.app.Instrumentation +import android.os.Bundle +import android.os.Build +import android.security.NetworkSecurityPolicy +import com.remoteci.mobile.data.UpdateManager +import com.remoteci.mobile.notif.UpdateReceiver + +/** + * 在目标应用进程中读取 Android 网络安全策略,不依赖额外测试框架。 + * 命令输出的 cleartextPermitted 字段可作为局域网 ws:// 回归测试信号。 + */ +class NetworkSecurityPolicyInstrumentation : Instrumentation() { + override fun onCreate(arguments: Bundle?) { + super.onCreate(arguments) + start() + } + + override fun onStart() { + val permitted = NetworkSecurityPolicy.getInstance() + .isCleartextTrafficPermitted(LAN_TEST_HOST) + val installIntent = UpdateManager.createInstallResultIntent(targetContext) + val installCallback = UpdateManager.createInstallResultPendingIntent(targetContext, 42) + val callbackMutable = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || !installCallback.isImmutable + val callbackExplicit = installIntent.component?.className == UpdateReceiver::class.java.name + val passed = permitted && callbackMutable && callbackExplicit + finish( + if (passed) Activity.RESULT_OK else Activity.RESULT_CANCELED, + Bundle().apply { + putString("cleartextPermitted", permitted.toString()) + putString("testedHost", LAN_TEST_HOST) + putString("installCallbackMutable", callbackMutable.toString()) + putString("installCallbackExplicit", callbackExplicit.toString()) + }, + ) + } + + private companion object { + const val LAN_TEST_HOST = "192.168.1.100" + } +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..11bed86 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..01ac366 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/remoteci/mobile/MainActivity.kt b/android/app/src/main/java/com/remoteci/mobile/MainActivity.kt new file mode 100644 index 0000000..67cf3ea --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/MainActivity.kt @@ -0,0 +1,46 @@ +package com.remoteci.mobile + +import android.Manifest +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import android.content.pm.PackageManager +import com.remoteci.mobile.data.ConnectionManager +import com.remoteci.mobile.data.hasLocalNetworkPermission +import com.remoteci.mobile.notif.NotificationHelper +import com.remoteci.mobile.ui.RemoteCiApp + +class MainActivity : ComponentActivity() { + // 先完成本地网络授权再创建应用界面,防止自动续登在系统授权框出现前就发出请求。 + private val localNetworkPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { + showApp() + } + private val notificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { } + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + NotificationHelper.ensureChannel(this) + ConnectionManager.initialize(applicationContext) + if (!hasLocalNetworkPermission(this)) { + localNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + } else { + showApp() + } + } + + private fun showApp() { + // 即使用户拒绝本地网络权限,仍允许进入应用使用公网云端连接。 + setContent { RemoteCiApp(applicationContext) } + if (Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } +} diff --git a/android/app/src/main/java/com/remoteci/mobile/RemoteCiApplication.kt b/android/app/src/main/java/com/remoteci/mobile/RemoteCiApplication.kt new file mode 100644 index 0000000..7a004d4 --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/RemoteCiApplication.kt @@ -0,0 +1,6 @@ +package com.remoteci.mobile + +import android.app.Application + +/** 应用入口。 */ +class RemoteCiApplication : Application() diff --git a/android/app/src/main/java/com/remoteci/mobile/data/AdminApi.kt b/android/app/src/main/java/com/remoteci/mobile/data/AdminApi.kt new file mode 100644 index 0000000..6b57c7e --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/data/AdminApi.kt @@ -0,0 +1,266 @@ +package com.remoteci.mobile.data + +import java.io.IOException +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +class AdminApiException(message: String) : IOException(message) + +object AdminApi { + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + private val http = OkHttpClient.Builder() + .connectTimeout(8, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .build() + private val media = "application/json; charset=utf-8".toMediaType() + + suspend fun users(): List = get("/api/users", ListSerializer(UserListItem.serializer())) + suspend fun createUser(body: CreateUserRequest): UserListItem = post("/api/users", body, CreateUserRequest.serializer(), UserListItem.serializer()) + suspend fun updateUser(id: String, body: UpdateUserRequest): UserListItem = put("/api/users/$id", body, UpdateUserRequest.serializer(), UserListItem.serializer()) + suspend fun deleteUser(id: String) { request("DELETE", "/api/users/$id") } + suspend fun resetPassword(id: String, password: String) { + post("/api/users/$id/password", ResetPasswordRequest(password), ResetPasswordRequest.serializer(), UnitSerializer) + } + + suspend fun roles(): List = get("/api/roles", ListSerializer(AccountRoleInfo.serializer())) + suspend fun createRole(body: AccountRoleMutation): AccountRoleInfo = + post("/api/roles", body, AccountRoleMutation.serializer(), AccountRoleInfo.serializer()) + suspend fun updateRole(id: String, body: AccountRoleMutation) { + put("/api/roles/$id", body, AccountRoleMutation.serializer(), UnitSerializer) + } + suspend fun deleteRole(id: String) { request("DELETE", "/api/roles/$id") } + + suspend fun visitor(): VisitorAccessState = get("/api/visitor", VisitorAccessState.serializer()) + suspend fun setVisitor(body: VisitorAccessState): VisitorAccessState = + put("/api/visitor", body, VisitorAccessState.serializer(), VisitorAccessState.serializer()) + + suspend fun sessions(): List = get("/api/me/sessions", ListSerializer(DeviceSessionSummary.serializer())) + suspend fun revokeSession(id: String) { request("DELETE", "/api/me/sessions/$id") } + suspend fun changePassword(current: String, next: String) { + post("/api/me/password", ChangePasswordRequest(current, next), ChangePasswordRequest.serializer(), UnitSerializer) + } + + suspend fun pairingCode(): PairingCodeResponse = post("/api/plugin/pairing-code", EmptyBody(), EmptyBody.serializer(), PairingCodeResponse.serializer()) + suspend fun pluginCredentials(): List = + get("/api/plugins/credentials", ListSerializer(PluginCredentialInfo.serializer())) + suspend fun revokePluginCredential(id: String) { request("DELETE", "/api/plugins/credentials/$id") } + + suspend fun adminStatus(): AdminStatus = get("/api/admin/status", AdminStatus.serializer()) + suspend fun notificationSettings(): SettingsSync = get("/api/settings/notifications", SettingsSync.serializer()) + suspend fun setNotificationSettings(forceSenderInTitle: Boolean): SettingsSync = + put("/api/settings/notifications", SettingsSync(forceSenderInTitle), SettingsSync.serializer(), SettingsSync.serializer()) + + suspend fun schedulePull(): SchedulePullSetting = get("/api/settings/schedule-pull", SchedulePullSetting.serializer()) + suspend fun setSchedulePull(intervalMinutes: Int): SchedulePullSetting = + put("/api/settings/schedule-pull", SchedulePullSetting(intervalMinutes), SchedulePullSetting.serializer(), SchedulePullSetting.serializer()) + + suspend fun extensions(classId: String? = null): List = + get( + if (classId == null) "/api/extensions" + else "/api/extensions?classId=${java.net.URLEncoder.encode(classId, Charsets.UTF_8.name())}", + ListSerializer(ExtensionPolicyItem.serializer()), + ) + suspend fun updateExtensionPolicy(id: String, body: ExtensionPolicyUpdate) { + put("/api/extensions/${java.net.URLEncoder.encode(id, Charsets.UTF_8.name())}", body, ExtensionPolicyUpdate.serializer(), UnitSerializer) + } + + suspend fun backups(): List = get("/api/admin/backups", ListSerializer(BackupFileInfo.serializer())) + suspend fun createBackup() { post("/api/admin/backups", EmptyBody(), EmptyBody.serializer(), UnitSerializer) } + suspend fun deleteBackup(name: String) { request("DELETE", "/api/admin/backups/${enc(name)}") } + suspend fun restoreBackup(name: String) { post("/api/admin/backups/${enc(name)}/restore", EmptyBody(), EmptyBody.serializer(), UnitSerializer) } + + suspend fun systemInfo(): SystemInfo = get("/api/admin/system", SystemInfo.serializer()) + suspend fun checkUpdate(channel: String): ReleaseInfo? = post("/api/admin/updates/check", UpdateCheckRequest(channel), UpdateCheckRequest.serializer(), ReleaseInfo.serializer()) + suspend fun applyUpdate(channel: String, force: Boolean) { + post("/api/admin/updates/apply", UpdateApplyRequest(channel, force), UpdateApplyRequest.serializer(), UnitSerializer) + } + + private fun enc(value: String) = java.net.URLEncoder.encode(value, Charsets.UTF_8.name()) + + private suspend fun get(path: String, serializer: kotlinx.serialization.DeserializationStrategy): T = + decode(request("GET", path), serializer) + + private suspend fun post( + path: String, + body: T, + input: kotlinx.serialization.SerializationStrategy, + output: kotlinx.serialization.DeserializationStrategy, + ): R = decode(request("POST", path, json.encodeToString(input, body)), output) + + private suspend fun put( + path: String, + body: T, + input: kotlinx.serialization.SerializationStrategy, + output: kotlinx.serialization.DeserializationStrategy, + ): R = decode(request("PUT", path, json.encodeToString(input, body)), output) + + private fun decode(text: String, serializer: kotlinx.serialization.DeserializationStrategy): T { + if (serializer === UnitSerializer) return Unit as T + if (text.isBlank()) { + @Suppress("UNCHECKED_CAST") + if (serializer === UnitSerializer) return Unit as T + } + return json.decodeFromString(serializer, text) + } + + private suspend fun request(method: String, path: String, body: String? = null): String = withContext(Dispatchers.IO) { + val base = ConnectionManager.restBaseUrl() ?: throw AdminApiException("尚未连接服务器") + val token = ConnectionManager.restToken() ?: throw AdminApiException("尚未登录") + val builder = Request.Builder() + .url("$base$path") + .header("Authorization", "Bearer $token") + when (method) { + "GET" -> builder.get() + "DELETE" -> builder.delete() + "POST" -> builder.post((body ?: "{}").toRequestBody(media)) + "PUT" -> builder.put((body ?: "{}").toRequestBody(media)) + else -> error(method) + } + http.newCall(builder.build()).execute().use { response -> + val text = response.body.string() + if (!response.isSuccessful) { + val message = runCatching { json.decodeFromString(ApiError.serializer(), text).message }.getOrNull() + throw AdminApiException(message ?: "HTTP ${response.code}") + } + text + } + } +} + +private object UnitSerializer : kotlinx.serialization.DeserializationStrategy { + override val descriptor = kotlinx.serialization.descriptors.buildClassSerialDescriptor("Unit") + override fun deserialize(decoder: kotlinx.serialization.encoding.Decoder) = Unit +} + +@Serializable data class EmptyBody(val ok: Boolean = true) + +@Serializable +data class UserListItem( + val id: String = "", + val username: String = "", + @SerialName("displayName") val displayName: String = "", + val role: Int = Protocol.ROLE_USER, + @SerialName("roleId") val roleId: String? = null, + @SerialName("roleName") val roleName: String? = null, + @SerialName("grantedPermissions") val grantedPermissions: Int = 0, + @SerialName("effectivePermissions") val effectivePermissions: Int = 0, + val enabled: Boolean = true, + @SerialName("updatedAt") val updatedAt: String? = null, +) + +@Serializable +data class CreateUserRequest( + val username: String, + @SerialName("displayName") val displayName: String, + val password: String, + val role: Int = Protocol.ROLE_USER, + @SerialName("roleId") val roleId: String? = null, + @SerialName("grantedPermissions") val grantedPermissions: Int = 0, +) + +@Serializable +data class UpdateUserRequest( + @SerialName("displayName") val displayName: String, + val role: Int, + @SerialName("roleId") val roleId: String? = null, + @SerialName("grantedPermissions") val grantedPermissions: Int = 0, + val enabled: Boolean, +) + +@Serializable data class ResetPasswordRequest(val password: String) +@Serializable +data class ChangePasswordRequest( + @SerialName("currentPassword") val currentPassword: String, + @SerialName("newPassword") val newPassword: String, +) + +@Serializable +data class AccountRoleInfo( + val id: String = "", + val name: String = "", + val kind: String = "", + @SerialName("defaultPermissions") val defaultPermissions: Int = 0, + @SerialName("userCount") val userCount: Int = 0, +) + +@Serializable +data class AccountRoleMutation( + val name: String, + @SerialName("defaultPermissions") val defaultPermissions: Int, +) + +@Serializable +data class VisitorAccessState(val enabled: Boolean = false, @SerialName("autoEnter") val autoEnter: Boolean = false) + +@Serializable +data class DeviceSessionSummary( + val id: String = "", + @SerialName("deviceName") val deviceName: String = "", + @SerialName("createdAt") val createdAt: String? = null, + @SerialName("lastSeenAt") val lastSeenAt: String? = null, + @SerialName("expiresAt") val expiresAt: String? = null, + val current: Boolean = false, +) + +@Serializable data class PairingCodeResponse(@SerialName("pairCode") val pairCode: String = "") + +@Serializable +data class PluginCredentialInfo( + val id: String = "", + val name: String = "", + val enabled: Boolean = true, + @SerialName("createdAt") val createdAt: String? = null, + @SerialName("lastSeenAt") val lastSeenAt: String? = null, +) + +@Serializable +data class AdminStatus( + val pluginOnline: Boolean = false, + val pluginConnections: Int = 0, + val watchConnections: Int = 0, + val mobileConnections: Int = 0, + val accountCount: Int = 0, + val latestStateAt: String? = null, + val latestScheduleAt: String? = null, + val protocolVersion: Int = Protocol.VERSION, +) + +@Serializable +data class ExtensionPolicyItem( + val id: String = "", + @SerialName("displayName") val displayName: String = "", + val enabled: Boolean = true, + @SerialName("allowNonAdmin") val allowNonAdmin: Boolean = false, + @SerialName("showOnWatch") val showOnWatch: Boolean = true, + @SerialName("canInvoke") val canInvoke: Boolean = false, +) + +@Serializable +data class ExtensionPolicyUpdate( + val enabled: Boolean? = null, + @SerialName("allowNonAdmin") val allowNonAdmin: Boolean? = null, + @SerialName("showOnWatch") val showOnWatch: Boolean? = null, +) + +@Serializable data class SchedulePullSetting(@SerialName("intervalMinutes") val intervalMinutes: Int = 0) +@Serializable +data class BackupFileInfo( + val name: String = "", + @SerialName("createdAt") val createdAt: String? = null, + val size: Long = 0, + val source: String = "", +) +@Serializable data class SystemInfo(val currentVersion: String = "", val canSelfUpdate: Boolean = false, val message: String = "") +@Serializable data class UpdateCheckRequest(val channel: String) +@Serializable data class UpdateApplyRequest(val channel: String, val force: Boolean = false) +@Serializable data class ReleaseInfo(val tag: String = "", val name: String? = null) +@Serializable data class ApiError(val code: String = "", val message: String = "") diff --git a/android/app/src/main/java/com/remoteci/mobile/data/ConnectionManager.kt b/android/app/src/main/java/com/remoteci/mobile/data/ConnectionManager.kt new file mode 100644 index 0000000..085173b --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/data/ConnectionManager.kt @@ -0,0 +1,1092 @@ +package com.remoteci.mobile.data + +import android.content.Context +import android.os.Build +import com.remoteci.mobile.BuildConfig +import java.io.IOException +import java.security.MessageDigest +import java.time.Duration +import java.time.OffsetDateTime +import java.util.UUID +import java.util.concurrent.TimeUnit +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import kotlin.coroutines.resume +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.KSerializer +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.JsonElement +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener + +/** 云端和局域网共用的认证、状态与命令入口。 */ +object ConnectionManager { + sealed interface State { + data object Idle : State + data object Connecting : State + data object LanConnected : State + data object CloudConnected : State + data class Error(val message: String) : State + } + + sealed interface SchedulePullState { + data object Idle : SchedulePullState + data class Pulling(val message: String) : SchedulePullState + data class Success(val message: String) : SchedulePullState + data class Error(val message: String) : SchedulePullState + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val json = protocolJson + private val okHttp = OkHttpClient.Builder() + .connectTimeout(6, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + // 静默断网(Wi-Fi 掉线、NAT 失效)时 TCP 不会通知应用,依赖 WebSocket ping 主动探测, + // 否则连接会“看起来还活着”却永远收不到数据、永不重连。 + .pingInterval(20, TimeUnit.SECONDS) + .build() + // 无效的局域网候选只等待很短时间;云端请求仍使用上面的 6 秒超时。 + private val lanOkHttp = okHttp.newBuilder() + .connectTimeout(1_500, TimeUnit.MILLISECONDS) + .build() + private val lanDiscoveryClient = LanDiscoveryClient(lanOkHttp, json) + private lateinit var sessions: SessionStorage + private var appContext: Context? = null + // 以下字段会被 OkHttp 回调线程、IO 协程与主线程并发读写,必须保证跨线程可见性。 + @Volatile private var webSocket: WebSocket? = null + private var activeJob: Job? = null + private var refreshJob: Job? = null + private var volumeJob: Job? = null + private var discoveryJob: Job? = null + private var schedulePullJob: Job? = null + @Volatile private var desiredSettings: WatchSettings? = null + @Volatile private var accessToken: String? = null + @Volatile private var lastLanAdvertisement: PluginNetworkInfo? = null + // OkHttp 回调线程、主线程与协程会并发读写 generation,必须用原子操作保证 attempt 校验可靠。 + private val generation = java.util.concurrent.atomic.AtomicInteger(0) + // 断线自动重连的指数退避(连接成功后复位);避免服务端抖动时 5 秒固定间隔造成重连风暴。 + @Volatile private var reconnectDelayMs = InitialReconnectDelayMs + + val state = MutableStateFlow(State.Idle) + /** 当前认证连接所属的 WebUI 版本;未知时禁止手表自行升级。 */ + val serverVersion = MutableStateFlow(null) + /** 本机、服务端与当前主插件共同支持的功能;旧 V3 端缺少声明时按基础能力回退。 */ + val availableCapabilities = MutableStateFlow(Protocol.BASELINE_CAPABILITIES) + val currentUser = MutableStateFlow(null) + /** 首登设置密码挑战:批量导入且未设密码的账号空密码登录时下发,界面据此展示设置表单。 */ + val pendingPasswordSetup = MutableStateFlow(null) + /** 账号可访问的班级与当前选中班级;单班级部署列表只有一个元素,界面据此隐藏切换器。 */ + val classes = MutableStateFlow>(emptyList()) + val currentClassId = MutableStateFlow(null) + val snapshot = MutableStateFlow(null) + val schedule = MutableStateFlow(null) + val extensions = MutableStateFlow>(emptyList()) + val settings = MutableStateFlow(null) + val events = MutableSharedFlow(extraBufferCapacity = 32) + val lastCommandResult = MutableStateFlow(null) + private val voiceReplies = java.util.concurrent.ConcurrentHashMap>() + + /** 语音体积较大,在后台编码,并只接受本条消息的回执;断线时不自动重发。 */ + suspend fun sendVoiceMessage(audio: ByteArray): CommandResult = withContext(Dispatchers.IO) { + if (!hasClassPermission(Protocol.PERMISSION_SEND_VOICE_MESSAGES)) + return@withContext CommandResult(false, "FORBIDDEN", "没有发送语音权限") + if (!supports(Protocol.CAP_VOICE_MESSAGE_SEND)) + return@withContext CommandResult(false, "CAPABILITY_UNSUPPORTED", "请更新服务端和插件以支持语音") + if (audio.isEmpty() || audio.size > 16000 * 2 * 60 || audio.size % 2 != 0) + return@withContext CommandResult(false, "INVALID_REQUEST", "录音无效或超过 60 秒") + val id = newMessageId() + val reply = kotlinx.coroutines.CompletableDeferred() + voiceReplies[id] = reply + try { + val command = CommandMessage(command = Protocol.CMD_SEND_VOICE_MESSAGE, + classId = currentClassId.value, + voiceMessage = VoiceMessageRequest(audioBase64 = android.util.Base64.encodeToString(audio, android.util.Base64.NO_WRAP))) + val envelope = Envelope(type = Protocol.TYPE_COMMAND, messageId = id, + payload = json.encodeToJsonElement(CommandMessage.serializer(), command)) + if (webSocket?.send(encodeEnvelope(envelope)) != true) + return@withContext CommandResult(false, "OFFLINE", "连接已断开,语音未发送") + withTimeout(20_000) { reply.await() } + } catch (_: TimeoutCancellationException) { + CommandResult(false, "COMMAND_TIMEOUT", "未收到回执,请确认课表端是否已播放后再重试") + } finally { voiceReplies.remove(id) } + } + val schedulePullState = MutableStateFlow(SchedulePullState.Idle) + /** 网络发现或成功直连后产生的本地设置更新,由界面层持久化。 */ + val discoveredSettings = MutableSharedFlow(extraBufferCapacity = 1) + val lanPlugins = MutableStateFlow>(emptyList()) + val lanDiscoveryStatus = MutableStateFlow(null) + val lanDiscoveryScanning = MutableStateFlow(false) + /** 引导返回的云服务器地址与上次使用的不同:等待用户二次确认的候选(TOFU 强阻断)。 */ + val lanBootstrapPending = MutableStateFlow?>(null) + + fun initialize(context: Context) { + appContext = context.applicationContext + if (!::sessions.isInitialized) sessions = SecureSessionStore(context.applicationContext) + } + + private fun localNetworkAllowed(): Boolean = appContext?.let(::hasLocalNetworkPermission) ?: true + + /** 仅供 JVM 单元测试注入内存会话存储,绕开 Android Keystore。 */ + internal fun installSessionStorageForTest(storage: SessionStorage) { + sessions = storage + } + + fun hasSavedSession(): Boolean = ::sessions.isInitialized && sessions.load() != null + fun restToken(): String? = accessToken + fun restBaseUrl(): String? = desiredSettings?.cloudServerUrl?.trim()?.trimEnd('/') + + fun supports(capability: String): Boolean = capability in availableCapabilities.value + + /** 统一写入用户档案:同时刷新班级列表;已选班级失效时回退到第一个可访问班级。 */ + private fun applyUserProfile(user: UserProfile?) { + currentUser.value = user + val accessible = user?.classes.orEmpty() + classes.value = accessible + val selected = currentClassId.value + currentClassId.value = + if (selected != null && accessible.any { it.id == selected }) selected + else accessible.firstOrNull()?.id + } + + /** 班级内的有效权限;服务端未下发班级信息(旧服务端)时回退到全局权限。 */ + fun hasClassPermission(permission: Int): Boolean { + val classContext = classes.value.firstOrNull { it.id == currentClassId.value } + val effective = classContext?.effectivePermissions ?: currentUser.value?.permissions ?: 0 + return effective and permission == permission + } + + /** 切换当前班级:清空旧班数据并立即拉取新班课表,之后的命令都会路由到新班级。 */ + fun switchClass(classId: String) { + if (classes.value.none { it.id == classId }) return + if (currentClassId.value == classId) return + currentClassId.value = classId + snapshot.value = null + schedule.value = null + extensions.value = emptyList() + if (state.value == State.LanConnected || state.value == State.CloudConnected) requestSchedulePull() + } + + fun scanLanPlugins() { + discoveryJob?.cancel() + lanPlugins.value = emptyList() + if (!localNetworkAllowed()) { + lanDiscoveryScanning.value = false + lanDiscoveryStatus.value = LocalNetworkPermissionMessage + return + } + lanDiscoveryStatus.value = "正在扫描同一局域网中的 RemoteCI 插件…" + lanDiscoveryScanning.value = true + discoveryJob = scope.launch { + try { + val found = lanDiscoveryClient.scan() + lanPlugins.value = found + lanDiscoveryStatus.value = if (found.isEmpty()) + "未发现插件,可检查 Wi-Fi、UDP ${Protocol.LAN_DISCOVERY_PORT} 防火墙或手动填写地址" + else + "发现 ${found.size} 台插件,请选择要连接的电脑" + } catch (error: Exception) { + if (error is CancellationException) throw error + lanDiscoveryStatus.value = "扫描失败:${error.message ?: "网络不可用"}" + } finally { + lanDiscoveryScanning.value = false + } + } + } + + suspend fun loadLanBootstrap( + settings: WatchSettings, + candidate: LanPluginCandidate, + ): WatchSettings? { + lanDiscoveryStatus.value = "正在连接 ${candidate.instanceName}…" + return try { + if (!localNetworkAllowed()) throw IOException(LocalNetworkPermissionMessage) + val bootstrap = lanDiscoveryClient.fetchBootstrap(candidate) + val updated = mergeLanBootstrapInfo(settings, candidate, bootstrap) + lanPlugins.value = emptyList() + // 局域网发现与 bootstrap 均无认证:明文 HTTP 提示窃听风险; + // 首次引导或与上次实际使用的地址不同时都强制二次确认(TOFU),防止伪造引导诱导输入密码。 + val insecure = updated.cloudServerUrl.startsWith("http://") + val changed = bootstrapUrlChanged(settings.cloudServerUrl, updated.cloudServerUrl) + if (changed) { + lanBootstrapPending.value = candidate to updated + val httpWarning = if (insecure) "(且为明文 HTTP)" else "" + lanDiscoveryStatus.value = if (settings.cloudServerUrl.isBlank()) + "已发现云服务器:${updated.cloudServerUrl}。请点击确认后再登录" + httpWarning + else + "云服务器与上次使用的不同:${updated.cloudServerUrl}。请再次点击确认,否则不要登录" + httpWarning + return null + } + lanBootstrapPending.value = null + lanDiscoveryStatus.value = "已获取云服务器:${updated.cloudServerUrl},确认后请点安全登录" + + if (insecure) "(明文 HTTP,请确认网络可信)" else "" + updated + } catch (error: Exception) { + if (error is CancellationException) throw error + lanDiscoveryStatus.value = "连接插件失败:${error.message ?: "未返回云服务器信息"}" + null + } + } + + /** 用户对 TOFU 候选二次确认;返回待应用的设置(界面层持久化)。 */ + fun confirmLanBootstrap(): WatchSettings? { + val pending = lanBootstrapPending.value ?: return null + lanBootstrapPending.value = null + val updated = pending.second + val insecure = updated.cloudServerUrl.startsWith("http://") + lanDiscoveryStatus.value = "已确认使用 ${updated.cloudServerUrl},请点安全登录" + + if (insecure) "(明文 HTTP)" else "" + return updated + } + + /** password 仅用于本次 HTTPS 登录;成功后只保存 Keystore 加密的设备会话密钥。 */ + fun connect(settings: WatchSettings, password: String? = null) { + check(::sessions.isInitialized) { "ConnectionManager 尚未初始化" } + discoveryJob?.cancel() + lanDiscoveryScanning.value = false + val attempt = generation.incrementAndGet() + desiredSettings = settings + activeJob?.cancel() + refreshJob?.cancel() + volumeJob?.cancel() + webSocket?.close(1000, "switch") + webSocket = null + state.value = State.Connecting + serverVersion.value = null + lastCommandResult.value = null + pendingPasswordSetup.value = null + extensions.value = emptyList() + // 重新连接后以服务端下发的设置快照为准,未同步前 UI 按默认开启处理。 + this@ConnectionManager.settings.value = null + val plan = planConnection(settings, password) + + activeJob = scope.launch { + try { + if (plan.bootstrapCloudAuthentication) { + val auth = loginCloud(settings, password ?: "") + if (auth.passwordPending == true) { + // 待激活账号:展示首登设置密码表单,设置成功后由界面重新登录。 + pendingPasswordSetup.value = + PasswordSetupChallenge(settings.username.trim(), auth.setupToken.orEmpty()) + state.value = State.Error("首次登录,请设置新密码") + return@launch + } + persist(auth) + // 即使随后优先走局域网 WebSocket,WebUI 管理 API 仍需要云端访问令牌。 + // 令牌不能只在 connectCloud() 中保存,否则“局域网已连接”时所有管理页都会报未登录。 + accessToken = auth.accessToken + val session = sessions.load() ?: throw MissingSessionException() + if (plan.preferLanAfterCloudAuthentication) { + // 先直连一次;插件镜像尚未同步时走云端,后续地址上报会触发重新直连。 + if (connectLan(settings, session, attempt)) { + scheduleAccessRefresh(settings, auth.accessExpiresAt, attempt) + return@launch + } + } + if (!plan.allowCloudFallback) + throw IOException("云端认证已完成,但局域网连接失败且云端中转已关闭") + connectCloud(settings, auth, attempt) + return@launch + } + + val saved = sessions.load() ?: throw MissingSessionException() + if (settings.username.isNotBlank() && saved.username != settings.username) + throw MissingSessionException() + + // 已保存会话优先走局域网,和手表保持相同的低延迟路径。 + // 令牌刷新只服务于 WebUI 管理 API,不能阻塞本地 WebSocket 的首屏连接。 + // 已保存的设备会话不需要等待登录端点传播;重复握手会把一次失败的 + // 超时放大成几十秒。手表端也只走一次,失败后直接使用云端回退。 + val lanOk = settings.lanConnectionEnabled && lanEndpointHosts(settings).isNotEmpty() && + connectLan(settings, saved, attempt) + if (lanOk) { + // 局域网连接已可用后,再后台刷新短期令牌。这样人员、设置、备份等 + // WebUI 管理接口仍然可用,同时不会把云端往返放在连接关键路径上。 + if (plan.allowCloudFallback) { + scope.launch { + runCatching { refreshCloud(settings, saved) } + .onSuccess { refreshed -> + if (attempt == generation.get()) { + persist(refreshed) + accessToken = refreshed.accessToken + scheduleAccessRefresh(settings, refreshed.accessExpiresAt, attempt) + } + } + } + } + return@launch + } + if (!plan.allowCloudFallback) { + throw IOException(if (localNetworkAllowed()) "局域网连接失败" else LocalNetworkPermissionMessage) + } + val refreshed = refreshCloud(settings, saved) + persist(refreshed) + accessToken = refreshed.accessToken + connectCloud(settings, refreshed, attempt) + } catch (_: AuthenticationException) { + state.value = State.Error("用户名或密码错误") + serverVersion.value = null + applyUserProfile(null) + } catch (_: MissingSessionException) { + state.value = State.Error("请先使用账号密码登录") + serverVersion.value = null + applyUserProfile(null) + } catch (error: CancellationException) { + // 旧连接任务被新连接取消:直接透出,不得用旧任务的取消异常 + // 覆盖新任务刚写入的 Connecting 状态或清空用户信息。 + throw error + } catch (error: Exception) { + // 连接过程中已写入的更具体错误(协议版本不兼容、登录已失效等)不被通用消息覆盖。 + if (state.value !is State.Error) { + state.value = State.Error(error.message ?: "连接失败") + } + serverVersion.value = null + // 登录虽成功但连接已失败,残留的用户信息会让界面误判为“在线”。 + applyUserProfile(null) + } + } + } + + fun disconnect(clearUser: Boolean = false) { + generation.incrementAndGet() + desiredSettings = null + activeJob?.cancel() + refreshJob?.cancel() + volumeJob?.cancel() + schedulePullJob?.cancel() + schedulePullState.value = SchedulePullState.Idle + webSocket?.close(1000, "disconnect") + webSocket = null + serverVersion.value = null + accessToken = null + if (clearUser) applyUserProfile(null) + extensions.value = emptyList() + this@ConnectionManager.settings.value = null + state.value = State.Idle + } + + fun logout(settings: WatchSettings) { + val token = accessToken + val saved = sessions.load() + if (token != null && saved != null) { + scope.launch { + runCatching { + okHttp.newCall( + Request.Builder() + .url("${settings.cloudServerUrl.trimEnd('/')}/api/auth/logout") + .header("Authorization", "Bearer $token") + .post(ByteArray(0).toRequestBody(null)) + .build(), + ).execute().close() + } + } + } + sessions.clear() + disconnect(clearUser = true) + } + + fun sendScheduleChange(request: ScheduleChangeRequest) { + sendCommand( + CommandMessage(command = Protocol.CMD_CHANGE_SCHEDULE, scheduleChange = request), + Protocol.PERMISSION_MANAGE_SCHEDULE, + ) + } + + /** 请求当前连接对应的插件重新生成课表;所有端共享任务状态,运行中不会重复发送。 */ + fun requestSchedulePull() { + if (schedulePullState.value is SchedulePullState.Pulling) { + schedulePullState.value = SchedulePullState.Pulling("已有课表拉取或推送任务正在执行,请稍候") + return + } + val socket = webSocket + if (socket == null || state.value !in setOf(State.LanConnected, State.CloudConnected)) { + schedulePullState.value = SchedulePullState.Error("当前未连接,无法拉取课表") + return + } + + lastCommandResult.value = null + val envelope = schedulePullEnvelope(currentClassId.value) + schedulePullState.value = SchedulePullState.Pulling("正在连接插件…") + if (!socket.send(encodeEnvelope(envelope))) { + schedulePullState.value = SchedulePullState.Error("发送拉取请求失败,请重试") + return + } + + armSchedulePullTimeout("请求已发送,正在等待插件返回最新课表…") + } + + fun teacherComing() { + sendCommand( + CommandMessage(command = Protocol.CMD_TEACHER_COMING), + Protocol.PERMISSION_TEACHER_COMING, + ) + } + + fun sendNotification( + title: String, + message: String, + isNotificationEffectEnabled: Boolean, + isNotificationSoundEnabled: Boolean, + isSpeechEnabled: Boolean, + ) { + sendCommand( + CommandMessage( + command = Protocol.CMD_SEND_NOTIFICATION, + notification = NotificationRequest( + title = title.trim(), + message = message.trim(), + isNotificationEffectEnabled = isNotificationEffectEnabled, + isNotificationSoundEnabled = isNotificationSoundEnabled, + isSpeechEnabled = isSpeechEnabled, + ), + ), + Protocol.PERMISSION_SEND_NOTIFICATIONS, + ) + } + + fun clearNotifications() { + sendCommand( + CommandMessage(command = Protocol.CMD_CLEAR_NOTIFICATIONS), + Protocol.PERMISSION_SEND_NOTIFICATIONS, + ) + } + + fun setMainMenuVisible(visible: Boolean) { + sendCommand( + CommandMessage(command = Protocol.CMD_SET_MAIN_MENU_VISIBILITY, mainMenuVisible = visible), + Protocol.PERMISSION_MAIN_MENU_CONTROL, + ) + } + + fun sendPowerAction(action: Int) { + sendCommand( + CommandMessage(command = Protocol.CMD_POWER, powerAction = action), + Protocol.PERMISSION_POWER_CONTROL, + ) + } + + fun setVolume(level: Int) { + // 表冠会高频产生事件,只发送短时间内的最后一个值,避免淹没 WebSocket 命令队列。 + volumeJob?.cancel() + volumeJob = scope.launch { + delay(80) + sendCommand( + CommandMessage( + command = Protocol.CMD_VOLUME, + volume = VolumeControlRequest(level = level.coerceIn(0, 100)), + ), + Protocol.PERMISSION_POWER_CONTROL, + ) + } + } + + fun setMuted(muted: Boolean) { + sendCommand( + CommandMessage(command = Protocol.CMD_VOLUME, volume = VolumeControlRequest(muted = muted)), + Protocol.PERMISSION_POWER_CONTROL, + ) + } + + fun runExtension(extension: ExtensionDefinition, args: Map = emptyMap()) { + if (currentUser.value?.canInvoke(extension) != true) { + lastCommandResult.value = CommandResult(false, "FORBIDDEN", "权限不足或扩展未开放") + return + } + sendCommand( + CommandMessage( + command = Protocol.CMD_RUN_EXTENSION, + extensionId = extension.id, + extensionArgs = args, + ), + Protocol.PERMISSION_RUN_EXTENSIONS, + ) + } + + private fun sendCommand(command: CommandMessage, requiredPermission: Int) { + // 权限按当前班级计算(班管理员只在所属班级有管理权限),命令随班级路由。 + if (!hasClassPermission(requiredPermission)) { + lastCommandResult.value = CommandResult(false, "FORBIDDEN", "权限不足") + return + } + lastCommandResult.value = null + sendEnvelope( + Envelope( + type = Protocol.TYPE_COMMAND, + messageId = newMessageId(), + payload = json.encodeToJsonElement( + CommandMessage.serializer(), + command.copy(classId = command.classId ?: currentClassId.value), + ), + ), + ) + } + + private suspend fun connectLan( + settings: WatchSettings, + session: PersistedDeviceSession, + attempt: Int, + ): Boolean { + if (!localNetworkAllowed()) return false + // 明文 ws:// 直连只允许私网/环回主机,公网候选一律跳过。 + for (host in lanEndpointHosts(settings).filter(::isCleartextSafeHost)) { + if (!connectWebSocket( + url = lanWebSocketUrl(host, settings.lanPort), + successState = State.LanConnected, + session = session, + attempt = attempt, + client = lanOkHttp, + handshakeTimeoutMs = LanAuthHandshakeTimeoutMs, + ) + ) continue + + val updated = settings.copy( + lanHost = host, + lanHostCandidates = listOf(host) + lanEndpointHosts(settings).filterNot { it == host }, + ) + desiredSettings = updated + if (updated != settings) discoveredSettings.tryEmit(updated) + return true + } + return false + } + + /** + * 首登设置密码:凭空密码登录下发的一次性令牌补设密码。 + * 成功后清除挑战并返回,界面应携带新密码重新登录。 + */ + suspend fun setupInitialPassword(newPassword: String): Boolean { + val challenge = pendingPasswordSetup.value ?: return false + return withContext(Dispatchers.IO) { + val body = json.encodeToString( + SetupPasswordRequest( + username = challenge.username, + setupToken = challenge.setupToken, + newPassword = newPassword, + ), + ) + val request = Request.Builder() + .url("${restBaseUrl()!!}/api/auth/setup-password") + .post(body.toRequestBody("application/json".toMediaType())) + .build() + okHttp.newCall(request).execute().use { response -> + if (response.code == 401) throw AuthenticationException() + if (!response.isSuccessful) throw IOException("设置密码失败(HTTP ${response.code})") + pendingPasswordSetup.value = null + true + } + } + } + + private suspend fun connectCloud(settings: WatchSettings, auth: AuthResponse, attempt: Int) { + requireCloudServerUrl(settings.cloudServerUrl) + accessToken = auth.accessToken + // 服务端令牌是标准 Base64,含 +/;不编码时 + 会被服务端解码成空格导致 401。 + if (!connectWebSocket( + cloudWebSocketUrl(settings.cloudServerUrl, auth.accessToken), + State.CloudConnected, + null, + attempt, + ) + ) throw IOException("云端连接失败") + // 收到插件网络信息后可能已经切换到新的局域网连接尝试,旧云端协程不得再覆盖刷新任务。 + if (attempt != generation.get()) return + scheduleAccessRefresh(settings, auth.accessExpiresAt, attempt) + } + + private suspend fun loginCloud(settings: WatchSettings, password: String): AuthResponse = + postAuth( + settings, + "/api/auth/login", + json.encodeToString(LoginRequest(settings.username.trim(), password, "Android · ${Build.MANUFACTURER} ${Build.MODEL}")), + ) + + private suspend fun refreshCloud(settings: WatchSettings, session: PersistedDeviceSession): AuthResponse = + try { + postAuth( + settings, + "/api/auth/refresh", + json.encodeToString(RefreshSessionRequest(session.deviceSessionId, session.deviceSecret)), + ) + } catch (error: AuthenticationException) { + sessions.clear() + throw MissingSessionException() + } + + private suspend fun postAuth(settings: WatchSettings, path: String, bodyJson: String): AuthResponse = + withContext(Dispatchers.IO) { + requireCloudServerUrl(settings.cloudServerUrl) + if (!localNetworkAllowed() && isLocalServerUrl(settings.cloudServerUrl)) { + throw IOException(LocalNetworkPermissionMessage) + } + val request = Request.Builder() + .url("${settings.cloudServerUrl.trimEnd('/')}$path") + .post(bodyJson.toRequestBody("application/json".toMediaType())) + .build() + okHttp.newCall(request).execute().use { response -> + if (response.code == 401) throw AuthenticationException() + if (!response.isSuccessful) throw IOException("登录服务返回 HTTP ${response.code}") + json.decodeFromString(AuthResponse.serializer(), response.body.string()) + } + } + + private fun persist(auth: AuthResponse) { + sessions.save( + PersistedDeviceSession( + username = auth.user.username, + deviceSessionId = auth.deviceSessionId, + deviceSecret = auth.deviceSecret, + deviceExpiresAt = auth.deviceExpiresAt, + ), + ) + applyUserProfile(auth.user) + } + + private suspend fun connectWebSocket( + url: String, + successState: State, + session: PersistedDeviceSession?, + attempt: Int, + client: OkHttpClient = okHttp, + handshakeTimeoutMs: Long = AuthHandshakeTimeoutMs, + ): Boolean = try { + // 认证阶段限时:故障或恶意对端完成握手后从不回 auth_state 时(readTimeout=0 + ping 保活 + // 会让死连接永久存活),超时后按连接失败走外层回退,而不是永久停在“连接中”。 + withTimeout(handshakeTimeoutMs) { + awaitAuthenticatedSocket(url, successState, session, attempt, client) + } + } catch (_: TimeoutCancellationException) { + false + } + + private suspend fun awaitAuthenticatedSocket( + url: String, + successState: State, + session: PersistedDeviceSession?, + attempt: Int, + client: OkHttpClient, + ): Boolean = suspendCancellableCoroutine { continuation -> + // 只有“认证成功进入工作状态”的连接在断开后才允许自动重连; + // 连接失败或认证被拒的 socket 由外层流程决定回退,否则每次失败都会调度一次全量重连形成风暴。 + var authenticated = false + val listener = object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + // 超时或已切换到新尝试:不得让过期 socket 占据活跃连接字段。 + if (attempt != generation.get() || !continuation.isActive) webSocket.close(1000, "superseded") + else ConnectionManager.webSocket = webSocket + } + + override fun onMessage(webSocket: WebSocket, text: String) { + if (attempt != generation.get()) { + webSocket.close(1000, "superseded") + if (continuation.isActive) continuation.resume(false) + return + } + val envelope = runCatching { json.decodeFromString(Envelope.serializer(), text) }.getOrNull() ?: return + if (envelope.protocolVersion != Protocol.VERSION) { + state.value = State.Error("协议版本不兼容,需要 v${Protocol.VERSION}") + webSocket.close(1008, "protocol") + if (continuation.isActive) continuation.resume(false) + return + } + if (envelope.type == Protocol.TYPE_AUTH_CHALLENGE && session != null) { + // 解码失败忽略该条消息;异常从 OkHttp 回调线程逃逸会直接击断连接。 + val challenge = runCatching { + envelope.payload?.let { json.decodeFromJsonElement(AuthChallenge.serializer(), it) } + }.getOrNull() ?: return + val proof = createAuthProof(challenge, session) + webSocket.send( + encodeEnvelope( + Envelope( + type = Protocol.TYPE_AUTH_PROOF, + messageId = newMessageId(), + payload = json.encodeToJsonElement(AuthProof.serializer(), proof), + ), + ), + ) + return + } + + val auth = handleEnvelope(envelope) + if (auth != null && continuation.isActive) { + if (auth.authenticated && auth.user != null) { + sendCapabilitiesReport(webSocket) + state.value = successState + authenticated = true + reconnectDelayMs = InitialReconnectDelayMs // 连接成功即复位退避。 + continuation.resume(true) + } else { + webSocket.close(1008, "auth failed") + continuation.resume(false) + } + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + if (continuation.isActive) continuation.resume(false) + clearActiveConnection(webSocket) + // OkHttp 中 onFailure 与 onClosed 互斥:异常断开(Wi-Fi 掉线、NAT 失效等) + // 只会触发 onFailure,必须在此调度自动重连,否则断线后永远无法恢复。 + if (authenticated && attempt == generation.get()) { + state.value = State.Error("连接已断开:${t.message ?: "网络不可用"}") + scheduleReconnect(attempt) + } + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(code, reason) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + clearActiveConnection(webSocket) + if (authenticated && attempt == generation.get() && !continuation.isActive) scheduleReconnect(attempt) + } + } + val socket = client.newWebSocket(Request.Builder().url(url).build(), listener) + continuation.invokeOnCancellation { socket.close(1000, "cancelled") } + } + + /** 只清理由当前活跃连接持有的版本,避免旧连接的迟到回调清空新连接状态。 */ + private fun clearActiveConnection(socket: WebSocket) { + if (webSocket !== socket) return + webSocket = null + serverVersion.value = null + availableCapabilities.value = Protocol.BASELINE_CAPABILITIES + } + + private fun handleEnvelope(envelope: Envelope): AuthState? = when (envelope.type) { + Protocol.TYPE_AUTH_STATE -> decodePayload(envelope.payload, AuthState.serializer())?.also { auth -> + applyUserProfile(if (auth.authenticated) auth.user else null) + serverVersion.value = if (auth.authenticated) auth.serverVersion else null + if (!auth.authenticated) state.value = State.Error(auth.error ?: "登录已失效") + } + Protocol.TYPE_STATE_PUSH -> { + decodePayload(envelope.payload, ClassStateSnapshot.serializer())?.let { incoming -> + // 多班级:只接受当前班级(或旧服务端不带班级标识)的推送。 + if (incoming.classId == null || incoming.classId == currentClassId.value) snapshot.value = incoming + } + null + } + Protocol.TYPE_SCHEDULE_SYNC -> { + decodePayload(envelope.payload, ScheduleBundle.serializer())?.let { incoming -> + if (incoming.classId == null || incoming.classId == currentClassId.value) { + schedule.value = incoming + // 兼容未实现状态消息的旧插件:收到新课表本身也可作为成功终态。 + if (schedulePullState.value is SchedulePullState.Pulling) + finishSchedulePull(SchedulePullState.Success("课表拉取完成,已使用插件最新课表")) + } + } + null + } + Protocol.TYPE_SCHEDULE_SYNC_STATUS -> { + decodePayload(envelope.payload, ScheduleSyncStatus.serializer())?.let(::applyScheduleSyncStatus) + null + } + Protocol.TYPE_EXTENSIONS_SYNC -> { + decodePayload(envelope.payload, ListSerializer(ExtensionDefinition.serializer()))?.let { incoming -> + if (incoming.firstOrNull()?.classId == null || + incoming.firstOrNull()?.classId == currentClassId.value + ) extensions.value = incoming + } + null + } + Protocol.TYPE_SETTINGS_SYNC -> { + decodePayload(envelope.payload, SettingsSync.serializer())?.let { settings.value = it } + null + } + Protocol.TYPE_PLUGIN_NETWORK_INFO -> { + decodePayload(envelope.payload, PluginNetworkInfo.serializer())?.let { handlePluginNetworkInfo(it) } + null + } + Protocol.TYPE_CAPABILITIES_SYNC -> { + decodePayload(envelope.payload, CapabilitiesSync.serializer())?.let { sync -> + availableCapabilities.value = effectiveCapabilities(sync) + } + null + } + Protocol.TYPE_EVENT_NOTIFY -> { + decodePayload(envelope.payload, ClassEvent.serializer())?.let { incoming -> + if (incoming.classId == null || incoming.classId == currentClassId.value) events.tryEmit(incoming) + } + null + } + Protocol.TYPE_COMMAND_RESULT -> { + decodePayload(envelope.payload, CommandResult.serializer())?.let { + lastCommandResult.value = it + envelope.replyToMessageId?.let { id -> voiceReplies[id]?.complete(it) } + } + null + } + else -> null + } + + private fun sendCapabilitiesReport(socket: WebSocket) { + val report = PeerCapabilities( + softwareVersion = BuildConfig.VERSION_NAME, + capabilities = Protocol.CURRENT_CAPABILITIES.toList(), + ) + socket.send( + json.encodeToString( + Envelope.serializer(), + Envelope( + type = Protocol.TYPE_PEER_CAPABILITIES, + messageId = newMessageId(), + payload = json.encodeToJsonElement(PeerCapabilities.serializer(), report), + ), + ), + ) + } + + private fun applyScheduleSyncStatus(status: ScheduleSyncStatus) { + when (status.state) { + Protocol.SCHEDULE_TASK_RUNNING -> { + armSchedulePullTimeout(status.message.ifBlank { "课表任务正在执行…" }) + } + Protocol.SCHEDULE_TASK_BUSY -> { + armSchedulePullTimeout(status.message.ifBlank { "已有课表任务正在执行,请稍候" }) + } + Protocol.SCHEDULE_TASK_COMPLETED -> + finishSchedulePull(SchedulePullState.Success(status.message.ifBlank { "课表同步完成" })) + Protocol.SCHEDULE_TASK_FAILED -> + finishSchedulePull(SchedulePullState.Error(status.message.ifBlank { "课表同步失败" })) + } + } + + private fun armSchedulePullTimeout(message: String) { + schedulePullState.value = SchedulePullState.Pulling(message) + schedulePullJob?.cancel() + schedulePullJob = scope.launch { + delay(SchedulePullTimeoutMs) + if (schedulePullState.value is SchedulePullState.Pulling) + finishSchedulePull(SchedulePullState.Error("等待课表任务完成超时")) + } + } + + private fun finishSchedulePull(result: SchedulePullState) { + schedulePullJob?.cancel() + schedulePullState.value = result + if (result is SchedulePullState.Success) { + schedulePullJob = scope.launch { + delay(3_000) + if (schedulePullState.value is SchedulePullState.Success) + schedulePullState.value = SchedulePullState.Idle + } + } + } + + /** 反序列化失败的载荷忽略不处理:版本不兼容或脏数据不得从 OkHttp 回调线程逃逸击断连接。 */ + private fun decodePayload(payload: JsonElement?, serializer: KSerializer): T? = + payload?.let { runCatching { json.decodeFromJsonElement(serializer, it) }.getOrNull() } + + private fun handlePluginNetworkInfo(info: PluginNetworkInfo) { + val current = desiredSettings ?: return + val updated = mergePluginNetworkInfo(current, info) + desiredSettings = updated + if (updated != current) discoveredSettings.tryEmit(updated) + + // 同一份不可达地址回退到云端后不反复重试;网卡或端口变化时才重新优先直连。 + val isNewAdvertisement = info != lastLanAdvertisement + lastLanAdvertisement = info + if (isNewAdvertisement && info.lanServerEnabled && updated.lanConnectionEnabled && + lanEndpointHosts(updated).isNotEmpty() && state.value == State.CloudConnected + ) { + connect(updated) + } + } + + private fun scheduleAccessRefresh(settings: WatchSettings, expiresAt: String, attempt: Int) { + refreshJob?.cancel() + val delayMillis = runCatching { + (Duration.between(OffsetDateTime.now(), OffsetDateTime.parse(expiresAt)).toMillis() - 60_000) + .coerceAtLeast(30_000) + }.getOrDefault(50 * 60_000L) + refreshJob = scope.launch { + delay(delayMillis) + if (attempt == generation.get()) connect(settings) + } + } + + private fun scheduleReconnect(attempt: Int) { + val settings = desiredSettings ?: return + val delayMs = reconnectDelayMs + reconnectDelayMs = nextReconnectDelay(reconnectDelayMs) + // 退避期间保留断线原因展示,connect() 真正发起时才切换到 Connecting。 + scope.launch { + delay(delayMs) + if (attempt == generation.get()) connect(settings) + } + } + + private fun sendEnvelope(envelope: Envelope) { + val sent = webSocket?.send(encodeEnvelope(envelope)) == true + if (!sent) { + lastCommandResult.value = CommandResult(false, "OFFLINE", "连接已断开,操作未发送") + } + } + + private fun newMessageId(): String = UUID.randomUUID().toString().replace("-", "") + private class MissingSessionException : Exception() + private class AuthenticationException : Exception() +} + +internal fun effectiveCapabilities(sync: CapabilitiesSync): Set = + Protocol.CURRENT_CAPABILITIES + .intersect(sync.server.capabilities.toSet()) + .intersect(sync.plugin?.capabilities?.toSet() ?: emptySet()) + +internal data class ConnectionPlan( + val bootstrapCloudAuthentication: Boolean, + val preferLanAfterCloudAuthentication: Boolean, + val allowCloudFallback: Boolean, +) + +internal const val InitialReconnectDelayMs = 5_000L +internal const val MaxReconnectDelayMs = 60_000L + +/** 认证握手限时:对端只完成 WebSocket 握手但从不回 auth_state 时不得永久挂起。 */ +internal const val AuthHandshakeTimeoutMs = 15_000L + +/** 局域网认证应在一次短探测内失败,避免坏的虚拟网卡地址拖慢整个登录流程。 */ +internal const val LanAuthHandshakeTimeoutMs = 4_000L + +/** 手动拉取课表等待插件回传的最长时间,与 WebUI 的等待上限保持一致。 */ +internal const val SchedulePullTimeoutMs = 15_000L + +/** 断线重连的指数退避:每次翻倍并封顶 [MaxReconnectDelayMs];连接成功后调用方复位为 [InitialReconnectDelayMs]。 */ +internal fun nextReconnectDelay(currentMs: Long): Long = (currentMs * 2).coerceAtMost(MaxReconnectDelayMs) + +/** + * 依据 protocol.md 构造局域网 HMAC 挑战证明: + * 密钥 = SHA-256(deviceSecret),消息 = `版本|challengeId|nonce|clientNonce|无横线小写 sessionId`。 + * [clientNonceBytes] 默认取 24 字节安全随机数,测试可注入固定值复现向量。 + */ +internal fun createAuthProof( + challenge: AuthChallenge, + session: PersistedDeviceSession, + // generateSeed 走熵采集路径,个别设备会长时间阻塞;nextBytes 非阻塞且足够安全。 + clientNonceBytes: ByteArray = ByteArray(24).also { java.security.SecureRandom().nextBytes(it) }, +): AuthProof { + val clientNonce = java.util.Base64.getEncoder().encodeToString(clientNonceBytes) + val verifier = MessageDigest.getInstance("SHA-256").digest(session.deviceSecret.encodeToByteArray()) + val canonical = "${Protocol.VERSION}|${challenge.challengeId}|${challenge.nonce}|$clientNonce|" + + session.deviceSessionId.replace("-", "").lowercase() + val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(verifier, "HmacSHA256")) } + return AuthProof( + challengeId = challenge.challengeId, + deviceSessionId = session.deviceSessionId, + clientNonce = clientNonce, + proof = java.util.Base64.getEncoder().encodeToString(mac.doFinal(canonical.encodeToByteArray())), + ) +} + +/** 云端 WebSocket 地址;访问令牌必须做 URL 编码,服务端 Base64 令牌中的 + 在查询串里会被解码成空格。 */ +internal fun cloudWebSocketUrl(cloudServerUrl: String, accessToken: String): String { + val schemeUrl = cloudServerUrl.trimEnd('/') + .replaceFirst("https://", "wss://") + .replaceFirst("http://", "ws://") + return "$schemeUrl/ws?token=${java.net.URLEncoder.encode(accessToken, Charsets.UTF_8.name())}&client=mobile" +} + +/** 引导返回的云服务器地址与上次实际使用的地址不同(默认开发地址不算“用过”)时提示用户。 */ +internal fun bootstrapUrlChanged(previous: String, current: String): Boolean { + val old = previous.trim().trimEnd('/') + val fresh = current.trim().trimEnd('/') + if (fresh.isBlank()) return false + // 开发用模拟器宿主机地址不参与变更比较。 + if (old.startsWith("http://10.0.2.2")) return false + // 首次引导(无历史记录)同样要求用户显式确认,防止伪造的 UDP 发现诱导登录。 + return old.isBlank() || !old.equals(fresh, ignoreCase = true) +} + +/** + * 明文连接允许的目标主机:RFC1918 私网 IPv4 字面量,或本机环回 + * (localhost/127.x,无窃听面,模拟器与本地调试必需); + * 其余主机与所有域名一律拒绝,避免 DNS 解析把明文流量带出私网。 + */ +internal fun isCleartextSafeHost(hostname: String): Boolean = + hostname.equals("localhost", ignoreCase = true) || + isRfc1918Host(hostname) || + isLoopbackHost(hostname) + +/** 仅当 hostname 是 RFC1918 私有网段(10/8、172.16/12、192.168/16)的 IPv4 字面量时返回 true。 */ +internal fun isRfc1918Host(hostname: String): Boolean { + val octets = hostname.split('.') + if (octets.size != 4) return false + val values = IntArray(4) + for (i in 0..3) { + val octet = octets[i] + if (octet.isEmpty() || !octet.all(Char::isDigit)) return false + val value = octet.toIntOrNull() ?: return false + if (value > 255) return false + values[i] = value + } + return values[0] == 10 || + (values[0] == 172 && values[1] in 16..31) || + (values[0] == 192 && values[1] == 168) +} + +/** 环回地址段 127.0.0.0/8 的 IPv4 字面量。 */ +internal fun isLoopbackHost(hostname: String): Boolean { + val octets = hostname.split('.') + if (octets.size != 4 || octets[0] != "127") return false + return octets.drop(1).all { it.isNotEmpty() && it.all(Char::isDigit) && (it.toIntOrNull() ?: -1) in 0..255 } +} + +/** + * 明文(http/ws)连接只允许指向私网/环回主机,其余立即拒绝; + * 与 networkSecurityConfig 配合,确保凭据类明文流量永远不出私网。 + */ +internal fun requireCleartextPrivateUrl(url: String) { + if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("ws://", ignoreCase = true)) return + val host = url.substringAfter("://").substringBefore('/').substringBefore(':').substringBefore('?') + if (!isCleartextSafeHost(host)) throw IOException("明文连接拒绝:$host 不是 RFC1918 私网地址") +} + +/** + * 用户明确填写的云服务器允许使用 HTTP 或 HTTPS;HTTP 的风险由登录界面持续提示。 + * 局域网发现和直连仍调用 [requireCleartextPrivateUrl],不得借此放开公网 ws:// 端点。 + */ +internal fun requireCloudServerUrl(url: String) { + val parsed = runCatching { java.net.URI(url.trim()) }.getOrNull() + ?: throw IOException("服务器地址格式无效") + if (parsed.scheme?.lowercase() !in setOf("http", "https") || parsed.host.isNullOrBlank()) { + throw IOException("服务器地址必须是有效的 HTTP 或 HTTPS 地址") + } +} + +/** 密码只能由云端验证,因此密码登录始终允许一次云端引导;开发者开关只控制后续连接回退。 */ +internal fun planConnection(settings: WatchSettings, password: String?): ConnectionPlan = ConnectionPlan( + bootstrapCloudAuthentication = !password.isNullOrEmpty(), + preferLanAfterCloudAuthentication = !password.isNullOrEmpty() && settings.lanConnectionEnabled && + lanEndpointHosts(settings).isNotEmpty(), + allowCloudFallback = settings.cloudConnectionEnabled, +) + +internal fun schedulePullEnvelope(classId: String? = null): Envelope { + val taskId = UUID.randomUUID().toString().replace("-", "") + return Envelope( + type = Protocol.TYPE_SCHEDULE_PULL, + messageId = taskId, + payload = protocolJson.encodeToJsonElement( + ScheduleSyncRequest.serializer(), + ScheduleSyncRequest(taskId = taskId, source = Protocol.SCHEDULE_SOURCE_MOBILE, classId = classId), + ), + ) +} diff --git a/android/app/src/main/java/com/remoteci/mobile/data/EventHistory.kt b/android/app/src/main/java/com/remoteci/mobile/data/EventHistory.kt new file mode 100644 index 0000000..e05aba3 --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/data/EventHistory.kt @@ -0,0 +1,23 @@ +package com.remoteci.mobile.data + +import android.content.Context + +/** 无论事件是否启用系统通知,都先记录其标识,避免重连后重复提醒。 */ +class EventHistory(context: Context) { + private val prefs = context.getSharedPreferences("remoteci_events", Context.MODE_PRIVATE) + + @Synchronized + fun markIfNew(event: ClassEvent): Boolean { + val key = event.id.ifBlank { "${event.event}|${event.occurredAt}|${event.message}" } + val existing = prefs.getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet() + if (!existing.add(key)) return false + val trimmed = existing.toList().takeLast(MAX_IDS).toSet() + prefs.edit().putStringSet(KEY_IDS, trimmed).apply() + return true + } + + private companion object { + const val KEY_IDS = "ids" + const val MAX_IDS = 100 + } +} diff --git a/android/app/src/main/java/com/remoteci/mobile/data/LanDiscoveryClient.kt b/android/app/src/main/java/com/remoteci/mobile/data/LanDiscoveryClient.kt new file mode 100644 index 0000000..fd3b967 --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/data/LanDiscoveryClient.kt @@ -0,0 +1,124 @@ +package com.remoteci.mobile.data + +import java.io.IOException +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.NetworkInterface +import java.net.SocketTimeoutException +import java.util.Collections +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener + +internal fun lanPluginCandidate(response: LanDiscoveryResponse, packetSourceHost: String): LanPluginCandidate? { + if (response.protocolVersion != Protocol.VERSION || response.port !in 1..65535) return null + if (response.instanceName.isBlank() || packetSourceHost.isBlank()) return null + return LanPluginCandidate(response.instanceName.trim(), packetSourceHost, response.port) +} + +/** UDP 广播发现插件,再通过选中插件的 WebSocket 获取云端引导信息。 */ +internal class LanDiscoveryClient( + private val okHttp: OkHttpClient, + private val json: Json, +) { + suspend fun scan(timeoutMillis: Long = 2_500): List = withContext(Dispatchers.IO) { + DatagramSocket(null).use { socket -> + socket.reuseAddress = true + socket.broadcast = true + socket.soTimeout = 200 + socket.bind(InetSocketAddress(0)) + val request = Protocol.LAN_DISCOVERY_REQUEST.encodeToByteArray() + discoveryBroadcastAddresses().forEach { address -> + runCatching { + socket.send(DatagramPacket(request, request.size, address, Protocol.LAN_DISCOVERY_PORT)) + } + } + + val found = linkedMapOf() + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis) + while (System.nanoTime() < deadline) { + currentCoroutineContext().ensureActive() + val buffer = ByteArray(2_048) + val packet = DatagramPacket(buffer, buffer.size) + try { + socket.receive(packet) + } catch (_: SocketTimeoutException) { + continue + } + val response = runCatching { + json.decodeFromString( + LanDiscoveryResponse.serializer(), + packet.data.decodeToString(packet.offset, packet.offset + packet.length), + ) + }.getOrNull() ?: continue + val candidate = lanPluginCandidate(response, packet.address.hostAddress ?: continue) ?: continue + found["${candidate.host}:${candidate.port}"] = candidate + } + found.values.toList() + } + } + + suspend fun fetchBootstrap(candidate: LanPluginCandidate): ConnectionBootstrapInfo = withTimeout(6_000) { + // 引导通道同样走明文 ws://,只允许 RFC1918 私网主机。 + requireCleartextPrivateUrl(lanBootstrapUrl(candidate.host, candidate.port)) + suspendCancellableCoroutine { continuation -> + val listener = object : WebSocketListener() { + override fun onMessage(webSocket: WebSocket, text: String) { + val envelope = runCatching { json.decodeFromString(Envelope.serializer(), text) }.getOrNull() + if (envelope?.protocolVersion != Protocol.VERSION || + envelope.type != Protocol.TYPE_CONNECTION_BOOTSTRAP + ) return + val info = envelope.payload?.let { + runCatching { + json.decodeFromJsonElement(ConnectionBootstrapInfo.serializer(), it) + }.getOrNull() + } ?: return + if (continuation.isActive) continuation.resume(info) + webSocket.close(1000, "bootstrap received") + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + if (continuation.isActive) continuation.resumeWithException(t) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + if (continuation.isActive) + continuation.resumeWithException(IOException("插件未返回云服务器信息")) + } + } + val socket = okHttp.newWebSocket( + Request.Builder().url(lanBootstrapUrl(candidate.host, candidate.port)).build(), + listener, + ) + continuation.invokeOnCancellation { socket.close(1000, "cancelled") } + } + } + + private fun discoveryBroadcastAddresses(): List { + val addresses = mutableSetOf(InetAddress.getByName("255.255.255.255")) + runCatching { + Collections.list(NetworkInterface.getNetworkInterfaces()) + .filter { it.isUp && !it.isLoopback } + .flatMap { it.interfaceAddresses } + .mapNotNullTo(addresses) { it.broadcast } + } + return addresses.toList() + } +} + +private fun lanBootstrapUrl(host: String, port: Int): String = + lanWebSocketUrl(host, port).removeSuffix("/ws") + "/bootstrap" diff --git a/android/app/src/main/java/com/remoteci/mobile/data/LocalNetworkPermission.kt b/android/app/src/main/java/com/remoteci/mobile/data/LocalNetworkPermission.kt new file mode 100644 index 0000000..9de6975 --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/data/LocalNetworkPermission.kt @@ -0,0 +1,28 @@ +package com.remoteci.mobile.data + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import java.net.URI + +internal const val LocalNetworkPermissionMessage = + "未允许访问本地网络,请在系统设置 → 应用 → RemoteCI → 权限中允许附近的设备,然后重新连接" + +/** Android 17 起,INTERNET 权限不再隐含本地网络访问;旧系统无需请求新权限。 */ +internal fun hasLocalNetworkPermission(context: Context): Boolean = + Build.VERSION.SDK_INT < 37 || + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK) == + PackageManager.PERMISSION_GRANTED + +/** 为明确使用局域网地址的登录请求提前报权限错误,避免让用户白等 TCP 超时。 */ +internal fun isLocalServerUrl(url: String): Boolean { + val host = runCatching { URI(url.trim()).host?.removePrefix("[")?.removeSuffix("]") } + .getOrNull() ?: return false + return isCleartextSafeHost(host) || host.endsWith(".local", ignoreCase = true) || + host.equals("::1", ignoreCase = true) || + host.startsWith("fc", ignoreCase = true) && ':' in host || + host.startsWith("fd", ignoreCase = true) && ':' in host || + host.startsWith("fe80:", ignoreCase = true) +} 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 new file mode 100644 index 0000000..d235c0c --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/data/Protocol.kt @@ -0,0 +1,443 @@ +package com.remoteci.mobile.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement + +/** 协议消息必须写出默认字段,尤其是每个 WebSocket 信封都必需的 protocolVersion。 */ +internal val protocolJson = Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true +} + +internal fun encodeEnvelope(envelope: Envelope): String = + protocolJson.encodeToString(Envelope.serializer(), envelope) + +object Protocol { + const val VERSION = 3 + const val TYPE_STATE_PUSH = "state_push" + const val TYPE_SCHEDULE_SYNC = "schedule_sync" + const val TYPE_SCHEDULE_PULL = "schedule_pull" + const val TYPE_SCHEDULE_SYNC_STATUS = "schedule_sync_status" + const val TYPE_EVENT_NOTIFY = "event_notify" + const val TYPE_EXTENSIONS_SYNC = "extensions_sync" + const val TYPE_COMMAND = "command" + const val TYPE_COMMAND_RESULT = "command_result" + const val TYPE_AUTH_CHALLENGE = "auth_challenge" + const val TYPE_AUTH_PROOF = "auth_proof" + const val TYPE_AUTH_STATE = "auth_state" + const val TYPE_SETTINGS_SYNC = "settings_sync" + const val TYPE_PLUGIN_NETWORK_INFO = "plugin_network_info" + const val TYPE_CONNECTION_BOOTSTRAP = "connection_bootstrap" + const val TYPE_PEER_CAPABILITIES = "peer_capabilities" + const val TYPE_CAPABILITIES_SYNC = "capabilities_sync" + const val LAN_DISCOVERY_PORT = 48765 + const val LAN_DISCOVERY_REQUEST = "REMOTECI_DISCOVER_V3" + + const val STATE_NONE = 0 + const val STATE_CLASS = 1 + const val STATE_BREAKING = 2 + const val STATE_AFTER_SCHOOL = 3 + const val STATE_PREPARE_CLASS = 4 + + const val EVENT_ON_CLASS = 1 + const val EVENT_ON_BREAKING = 2 + const val EVENT_AFTER_SCHOOL = 3 + const val EVENT_SCHEDULE_CHANGED = 4 + const val EVENT_CUSTOM = 5 + const val EVENT_AUTOMATION_NOTIFICATION = 6 + const val EVENT_PLUGIN_NOTIFICATION = 7 + + const val CMD_CHANGE_SCHEDULE = 1 + const val CMD_SEND_NOTIFICATION = 2 + const val CMD_CLEAR_NOTIFICATIONS = 3 + const val CMD_SET_MAIN_MENU_VISIBILITY = 4 + const val CMD_POWER = 5 + const val CMD_VOLUME = 6 + const val CMD_RUN_EXTENSION = 7 + const val CMD_TEACHER_COMING = 8 + const val CMD_SEND_VOICE_MESSAGE = 9 + const val POWER_SHUTDOWN = 1 + const val POWER_RESTART = 2 + const val POWER_SLEEP = 3 + const val POWER_HIBERNATE = 4 + const val CHANGE_EXCHANGE = 1 + const val CHANGE_REPLACE = 2 + + const val EXT_PARAM_TEXT = 1 + const val EXT_PARAM_NUMBER = 2 + const val EXT_PARAM_SWITCH = 3 + const val EXT_PARAM_SELECT = 4 + + const val ROLE_USER = 1 + const val ROLE_ADMIN = 2 + const val PERMISSION_VIEW_CURRENT = 1 + const val PERMISSION_ACCESS_WEB_UI = 2 + const val PERMISSION_MANAGE_USERS = 4 + const val PERMISSION_SEND_NOTIFICATIONS = 8 + const val PERMISSION_MANAGE_SCHEDULE = 16 + const val PERMISSION_POWER_CONTROL = 32 + const val PERMISSION_SYSTEM_CONTROL = PERMISSION_POWER_CONTROL + const val PERMISSION_TEACHER_COMING = 64 + const val PERMISSION_RUN_EXTENSIONS = 128 + const val PERMISSION_MAIN_MENU_CONTROL = 256 + const val PERMISSION_SEND_VOICE_MESSAGES = 512 + + const val SCHEDULE_SOURCE_PLUGIN = 1 + const val SCHEDULE_SOURCE_WEB_UI = 2 + const val SCHEDULE_SOURCE_WATCH = 3 + const val SCHEDULE_SOURCE_AUTOMATIC = 4 + const val SCHEDULE_SOURCE_CONNECTION = 5 + const val SCHEDULE_SOURCE_MOBILE = 6 + + const val SCHEDULE_TASK_RUNNING = 1 + const val SCHEDULE_TASK_COMPLETED = 2 + const val SCHEDULE_TASK_FAILED = 3 + const val SCHEDULE_TASK_BUSY = 4 + + const val CAP_CLASS_STATE_READ = "class-state.read" + const val CAP_SCHEDULE_READ = "schedule.read" + const val CAP_SCHEDULE_PULL = "schedule.pull" + const val CAP_SCHEDULE_CHANGE = "schedule.change" + const val CAP_NOTIFICATION_SEND = "notification.send" + const val CAP_VOICE_MESSAGE_SEND = "voice-message.send" + const val CAP_NOTIFICATION_CLEAR = "notification.clear" + const val CAP_TEACHER_COMING = "teacher-coming" + const val CAP_MAIN_MENU_VISIBILITY = "main-menu.visibility" + const val CAP_POWER_CONTROL = "power.control" + const val CAP_VOLUME_CONTROL = "volume.control" + const val CAP_EXTENSIONS_RUN = "extensions.run" + + val BASELINE_CAPABILITIES = setOf( + CAP_CLASS_STATE_READ, + CAP_SCHEDULE_READ, + CAP_SCHEDULE_PULL, + CAP_SCHEDULE_CHANGE, + CAP_NOTIFICATION_SEND, + CAP_NOTIFICATION_CLEAR, + CAP_TEACHER_COMING, + CAP_MAIN_MENU_VISIBILITY, + CAP_POWER_CONTROL, + CAP_VOLUME_CONTROL, + CAP_EXTENSIONS_RUN, + ) + val CURRENT_CAPABILITIES = BASELINE_CAPABILITIES + CAP_VOICE_MESSAGE_SEND +} + +@Serializable +data class Envelope( + @SerialName("protocolVersion") val protocolVersion: Int = Protocol.VERSION, + val type: String, + @SerialName("messageId") val messageId: String = "", + @SerialName("replyToMessageId") val replyToMessageId: String? = null, + val timestamp: String = "", + val sender: Int? = null, + val payload: JsonElement? = null, +) + +@Serializable +data class PeerCapabilities( + @SerialName("softwareVersion") val softwareVersion: String = "", + val capabilities: List = emptyList(), +) + +@Serializable +data class CapabilitiesSync( + val server: PeerCapabilities = PeerCapabilities(capabilities = Protocol.BASELINE_CAPABILITIES.toList()), + val plugin: PeerCapabilities? = null, +) + +@Serializable +data class ClassStateSnapshot( + @SerialName("classId") val classId: String? = null, + @SerialName("scheduleDate") val scheduleDate: String? = null, + @SerialName("currentSubject") val currentSubject: String? = null, + @SerialName("nextClassSubject") val nextClassSubject: String? = null, + @SerialName("currentState") val currentState: Int = Protocol.STATE_NONE, + @SerialName("currentTimeLayoutItem") val currentTimeLayoutItem: String? = null, + @SerialName("timeZoneOffsetMinutes") val timeZoneOffsetMinutes: Int? = null, + @SerialName("nextClassTimeLayoutItem") val nextClassTimeLayoutItem: String? = null, + @SerialName("classPlanName") val classPlanName: String? = null, + @SerialName("isClassPlanEnabled") val isClassPlanEnabled: Boolean = false, + @SerialName("isClassPlanLoaded") val isClassPlanLoaded: Boolean = false, + @SerialName("onClassLeftTime") val onClassLeftTime: String? = null, + @SerialName("onBreakingLeftTime") val onBreakingLeftTime: String? = null, + @SerialName("lessonConfirmed") val lessonConfirmed: Boolean = false, + @SerialName("isNotificationPlaying") val isNotificationPlaying: Boolean = false, + @SerialName("isMainMenuVisible") val isMainMenuVisible: Boolean = true, + @SerialName("isSleepAvailable") val isSleepAvailable: Boolean = false, + @SerialName("isHibernateAvailable") val isHibernateAvailable: Boolean = false, + @SerialName("isVolumeControlAvailable") val isVolumeControlAvailable: Boolean = false, + @SerialName("volumePercent") val volumePercent: Int = 0, + @SerialName("isMuted") val isMuted: Boolean = false, + @SerialName("generatedAt") val generatedAt: String? = null, +) + +@Serializable +data class ScheduleSyncRequest( + @SerialName("taskId") val taskId: String, + val source: Int = Protocol.SCHEDULE_SOURCE_WATCH, + @SerialName("classId") val classId: String? = null, +) + +@Serializable +data class ScheduleSyncStatus( + @SerialName("taskId") val taskId: String = "", + val source: Int = 0, + val state: Int = Protocol.SCHEDULE_TASK_RUNNING, + val message: String = "", + @SerialName("startedAt") val startedAt: String? = null, + @SerialName("finishedAt") val finishedAt: String? = null, + @SerialName("activeTaskId") val activeTaskId: String? = null, +) + +@Serializable +data class ScheduleBundle( + @SerialName("classId") val classId: String? = null, + @SerialName("fromDate") val fromDate: String = "", + @SerialName("generatedAt") val generatedAt: String? = null, + val days: List = emptyList(), + val subjects: List = emptyList(), +) + +@Serializable +data class ScheduleDay( + val date: String, + val revision: String, + @SerialName("classPlanName") val classPlanName: String? = null, + val enabled: Boolean = false, + val courses: List = emptyList(), +) + +@Serializable +data class CourseEntry( + val index: Int, + val label: String, + @SerialName("subjectId") val subjectId: String, + val subject: String, + @SerialName("startTime") val startTime: String? = null, + @SerialName("endTime") val endTime: String? = null, + val enabled: Boolean = true, +) + +@Serializable +data class SubjectEntry(val id: String, val name: String) + +@Serializable +data class ClassEvent( + val id: String = "", + @SerialName("classId") val classId: String? = null, + val event: Int, + val subject: String? = null, + val message: String? = null, + @SerialName("occurredAt") val occurredAt: String? = null, +) + +@Serializable +data class ScheduleChangeRequest( + val date: String, + val mode: Int, + @SerialName("sourceIndex") val sourceIndex: Int, + @SerialName("targetIndex") val targetIndex: Int? = null, + @SerialName("replacementSubjectId") val replacementSubjectId: String? = null, + @SerialName("expectedRevision") val expectedRevision: String, +) + +@Serializable +data class NotificationRequest( + val title: String, + val message: String, + @SerialName("forceSenderInTitle") val forceSenderInTitle: Boolean? = null, + @SerialName("isNotificationEffectEnabled") val isNotificationEffectEnabled: Boolean = false, + @SerialName("isNotificationSoundEnabled") val isNotificationSoundEnabled: Boolean = false, + @SerialName("isSpeechEnabled") val isSpeechEnabled: Boolean = false, +) + +@Serializable +data class SettingsSync( + @SerialName("forceSenderInTitle") val forceSenderInTitle: Boolean = true, + @SerialName("updatedAt") val updatedAt: String? = null, +) + +@Serializable +data class PluginNetworkInfo( + @SerialName("lanServerEnabled") val lanServerEnabled: Boolean = false, + val addresses: List = emptyList(), + val port: Int = 8765, +) + +@Serializable +data class LanDiscoveryResponse( + @SerialName("protocolVersion") val protocolVersion: Int = Protocol.VERSION, + @SerialName("instanceName") val instanceName: String, + val port: Int, +) + +data class LanPluginCandidate( + val instanceName: String, + val host: String, + val port: Int, +) + +@Serializable +data class ConnectionBootstrapInfo( + @SerialName("instanceName") val instanceName: String, + @SerialName("cloudServerUrl") val cloudServerUrl: String, +) + +@Serializable +data class CommandMessage( + val command: Int, + @SerialName("classId") val classId: String? = null, + @SerialName("scheduleChange") val scheduleChange: ScheduleChangeRequest? = null, + val notification: NotificationRequest? = null, + @SerialName("voiceMessage") val voiceMessage: VoiceMessageRequest? = null, + @SerialName("mainMenuVisible") val mainMenuVisible: Boolean? = null, + @SerialName("powerAction") val powerAction: Int? = null, + val volume: VolumeControlRequest? = null, + @SerialName("extensionId") val extensionId: String? = null, + @SerialName("extensionArgs") val extensionArgs: Map? = null, +) + +@Serializable +data class VoiceMessageRequest( + val format: String = "pcm_s16le_16000_mono", + @SerialName("audioBase64") val audioBase64: String, +) + +@Serializable +data class VolumeControlRequest( + val level: Int? = null, + val muted: Boolean? = null, +) + +@Serializable +data class CommandResult( + val success: Boolean = false, + val code: String = "", + val message: String = "", + @SerialName("scheduleRevision") val scheduleRevision: String? = null, +) + +@Serializable +data class ExtensionDefinition( + val id: String, + @SerialName("classId") val classId: String? = null, + @SerialName("displayName") val displayName: String, + val icon: String? = null, + @SerialName("requiredPermission") val requiredPermission: Int = Protocol.PERMISSION_VIEW_CURRENT, + val parameters: List = emptyList(), +) + +@Serializable +data class ExtensionParameter( + val key: String, + val label: String, + val type: Int = Protocol.EXT_PARAM_TEXT, + @SerialName("defaultValue") val defaultValue: String? = null, + val required: Boolean = false, + val options: List = emptyList(), +) + +@Serializable +data class ClassSummary( + val id: String = "", + val name: String = "", + @SerialName("roleName") val roleName: String? = null, + val permissions: Int? = null, + @SerialName("visitorEnabled") val visitorEnabled: Boolean = false, + @SerialName("groupName") val groupName: String? = null, +) { + val effectivePermissions: Int + get() = permissions ?: Protocol.PERMISSION_VIEW_CURRENT +} + +@Serializable +data class UserProfile( + val id: String = "", + val username: String = "", + @SerialName("displayName") val displayName: String = "", + val role: Int = Protocol.ROLE_USER, + @SerialName("grantedPermissions") val grantedPermissions: Int = 0, + val permissions: Int = Protocol.PERMISSION_VIEW_CURRENT, + @SerialName("allowedExtensionIds") val allowedExtensionIds: List? = null, + @SerialName("visibleExtensionIds") val visibleExtensionIds: List? = null, + val classes: List? = null, + val version: Long = 0, +) { + val isAdmin: Boolean get() = role == Protocol.ROLE_ADMIN + fun has(permission: Int): Boolean = permissions and permission == permission + fun canInvoke(extension: ExtensionDefinition): Boolean = + has(Protocol.PERMISSION_RUN_EXTENSIONS) && + (allowedExtensionIds == null || extension.id in allowedExtensionIds) + + fun showsOnWatch(extension: ExtensionDefinition): Boolean = + canInvoke(extension) && + (visibleExtensionIds == null || extension.id in visibleExtensionIds) +} + +@Serializable +data class LoginRequest(val username: String, val password: String, @SerialName("deviceName") val deviceName: String) + +@Serializable +data class SetupPasswordRequest( + val username: String, + @SerialName("setupToken") val setupToken: String, + @SerialName("newPassword") val newPassword: String, +) + +/** 首登设置密码挑战:界面据此展示设置表单。 */ +data class PasswordSetupChallenge(val username: String, val setupToken: String) + +@Serializable +data class RefreshSessionRequest( + @SerialName("deviceSessionId") val deviceSessionId: String, + @SerialName("deviceSecret") val deviceSecret: String, +) + +@Serializable +data class AuthResponse( + @SerialName("accessToken") val accessToken: String, + @SerialName("passwordPending") val passwordPending: Boolean? = null, + @SerialName("setupToken") val setupToken: String? = null, + @SerialName("accessExpiresAt") val accessExpiresAt: String, + @SerialName("deviceSessionId") val deviceSessionId: String, + @SerialName("deviceSecret") val deviceSecret: String, + @SerialName("deviceExpiresAt") val deviceExpiresAt: String, + val user: UserProfile = UserProfile(), +) + +@Serializable +data class AuthChallenge( + @SerialName("challengeId") val challengeId: String, + val nonce: String, + @SerialName("expiresAt") val expiresAt: String, +) + +@Serializable +data class AuthProof( + @SerialName("challengeId") val challengeId: String, + @SerialName("deviceSessionId") val deviceSessionId: String, + @SerialName("clientNonce") val clientNonce: String, + val proof: String, +) + +@Serializable +data class AuthState( + val authenticated: Boolean, + @SerialName("serverVersion") val serverVersion: String? = null, + val user: UserProfile? = null, + @SerialName("errorCode") val errorCode: String? = null, + val error: String? = null, +) + +@Serializable +data class PersistedDeviceSession( + val username: String, + @SerialName("deviceSessionId") val deviceSessionId: String, + @SerialName("deviceSecret") val deviceSecret: String, + @SerialName("deviceExpiresAt") val deviceExpiresAt: String, +) diff --git a/android/app/src/main/java/com/remoteci/mobile/data/SecureSessionStore.kt b/android/app/src/main/java/com/remoteci/mobile/data/SecureSessionStore.kt new file mode 100644 index 0000000..b2a5b3a --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/data/SecureSessionStore.kt @@ -0,0 +1,90 @@ +package com.remoteci.mobile.data + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** 设备会话持久化的最小接口:生产实现为 Android Keystore 加密的 [SecureSessionStore],测试注入内存假实现。 */ +interface SessionStorage { + fun save(session: PersistedDeviceSession) + fun load(): PersistedDeviceSession? + fun clear() +} + +/** 使用 Android Keystore AES-GCM 保护设备会话密钥,设置中永不出现明文密码。 */ +class SecureSessionStore(context: Context) : SessionStorage { + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val json = Json { ignoreUnknownKeys = true } + + @Synchronized + override fun save(session: PersistedDeviceSession) { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()) + cipher.updateAAD(AAD) + val encrypted = cipher.doFinal(json.encodeToString(session).encodeToByteArray()) + val value = listOf( + Base64.encodeToString(cipher.iv, Base64.NO_WRAP), + Base64.encodeToString(encrypted, Base64.NO_WRAP), + ).joinToString(".") + prefs.edit().putString(KEY_SESSION, value).apply() + } + + @Synchronized + override fun load(): PersistedDeviceSession? { + val parts = prefs.getString(KEY_SESSION, null)?.split('.') ?: return null + if (parts.size != 2) return null + return runCatching { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init( + Cipher.DECRYPT_MODE, + getOrCreateKey(), + GCMParameterSpec(128, Base64.decode(parts[0], Base64.NO_WRAP)), + ) + cipher.updateAAD(AAD) + json.decodeFromString( + cipher.doFinal(Base64.decode(parts[1], Base64.NO_WRAP)).decodeToString(), + ) + }.getOrElse { + clear() + null + } + } + + override fun clear() { + prefs.edit().remove(KEY_SESSION).apply() + } + + private fun getOrCreateKey(): SecretKey { + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (store.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + generateKey() + } + } + + private companion object { + const val PREFS_NAME = "remoteci_secure_session" + const val KEY_SESSION = "session" + const val KEY_ALIAS = "RemoteCI.Mobile.DeviceSession.v1" + const val TRANSFORMATION = "AES/GCM/NoPadding" + val AAD = "com.remoteci.mobile:device-session:v1".encodeToByteArray() + } +} diff --git a/android/app/src/main/java/com/remoteci/mobile/data/SettingsStore.kt b/android/app/src/main/java/com/remoteci/mobile/data/SettingsStore.kt new file mode 100644 index 0000000..0aef51b --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/data/SettingsStore.kt @@ -0,0 +1,160 @@ +package com.remoteci.mobile.data + +import android.content.Context +import java.net.URI + +data class WatchSettings( + val username: String = "", + val cloudConnectionEnabled: Boolean = true, + val cloudServerUrl: String = "http://10.0.2.2:8080", + val lanConnectionEnabled: Boolean = true, + val lanHost: String = "", + val lanHostCandidates: List = emptyList(), + val lanPort: Int = 8765, + val themeId: String = "lavender", + val updateChannel: UpdateChannel = UpdateChannel.STABLE, + val forceUpdateEnabled: Boolean = false, + val receiveOnClass: Boolean = true, + val receiveOnBreaking: Boolean = true, + val receiveAfterSchool: Boolean = true, + val receiveScheduleChanged: Boolean = true, + val receiveCustom: Boolean = true, + val receiveAutomationNotifications: Boolean = true, + val receivePluginNotifications: Boolean = true, + val appearanceMode: String = "system", +) + +class SettingsStore(context: Context) { + private val prefs = context.getSharedPreferences("remoteci", Context.MODE_PRIVATE) + + fun load(): WatchSettings = WatchSettings( + username = prefs.getString(KEY_USERNAME, "") ?: "", + cloudConnectionEnabled = prefs.getBoolean(KEY_CLOUD_CONNECTION_ENABLED, true), + cloudServerUrl = prefs.getString(KEY_CLOUD_URL, "http://10.0.2.2:8080") ?: "http://10.0.2.2:8080", + lanConnectionEnabled = prefs.getBoolean(KEY_LAN_CONNECTION_ENABLED, true), + lanHost = prefs.getString(KEY_LAN_HOST, "") ?: "", + lanHostCandidates = prefs.getString(KEY_LAN_HOST_CANDIDATES, "") + ?.lineSequence()?.filter(String::isNotBlank)?.distinct()?.toList().orEmpty(), + lanPort = prefs.getInt(KEY_LAN_PORT, 8765), + themeId = prefs.getString(KEY_THEME_ID, "lavender") ?: "lavender", + updateChannel = runCatching { + UpdateChannel.valueOf(prefs.getString(KEY_UPDATE_CHANNEL, UpdateChannel.STABLE.name)!!) + }.getOrDefault(UpdateChannel.STABLE), + forceUpdateEnabled = prefs.getBoolean(KEY_FORCE_UPDATE, false), + receiveOnClass = prefs.getBoolean(KEY_ON_CLASS, true), + receiveOnBreaking = prefs.getBoolean(KEY_ON_BREAKING, true), + receiveAfterSchool = prefs.getBoolean(KEY_AFTER_SCHOOL, true), + receiveScheduleChanged = prefs.getBoolean(KEY_SCHEDULE, true), + receiveCustom = prefs.getBoolean(KEY_CUSTOM, true), + receiveAutomationNotifications = prefs.getBoolean(KEY_AUTOMATION_NOTIFICATIONS, true), + receivePluginNotifications = prefs.getBoolean(KEY_PLUGIN_NOTIFICATIONS, true), + appearanceMode = prefs.getString(KEY_APPEARANCE, "system") ?: "system", + ) + + fun save(settings: WatchSettings) { + prefs.edit() + .putString(KEY_USERNAME, settings.username) + .putBoolean(KEY_CLOUD_CONNECTION_ENABLED, settings.cloudConnectionEnabled) + .putString(KEY_CLOUD_URL, settings.cloudServerUrl) + .putBoolean(KEY_LAN_CONNECTION_ENABLED, settings.lanConnectionEnabled) + .putString(KEY_LAN_HOST, settings.lanHost) + .putString(KEY_LAN_HOST_CANDIDATES, settings.lanHostCandidates.joinToString("\n")) + .putInt(KEY_LAN_PORT, settings.lanPort) + .putString(KEY_THEME_ID, settings.themeId) + .putString(KEY_UPDATE_CHANNEL, settings.updateChannel.name) + .putBoolean(KEY_FORCE_UPDATE, settings.forceUpdateEnabled) + .putBoolean(KEY_ON_CLASS, settings.receiveOnClass) + .putBoolean(KEY_ON_BREAKING, settings.receiveOnBreaking) + .putBoolean(KEY_AFTER_SCHOOL, settings.receiveAfterSchool) + .putBoolean(KEY_SCHEDULE, settings.receiveScheduleChanged) + .putBoolean(KEY_CUSTOM, settings.receiveCustom) + .putBoolean(KEY_AUTOMATION_NOTIFICATIONS, settings.receiveAutomationNotifications) + .putBoolean(KEY_PLUGIN_NOTIFICATIONS, settings.receivePluginNotifications) + .putString(KEY_APPEARANCE, settings.appearanceMode) + .apply() + } + + private companion object { + const val KEY_USERNAME = "username" + const val KEY_CLOUD_CONNECTION_ENABLED = "cloudConnectionEnabled" + const val KEY_CLOUD_URL = "cloudServerUrl" + const val KEY_LAN_CONNECTION_ENABLED = "lanConnectionEnabled" + const val KEY_LAN_HOST = "lanHost" + const val KEY_LAN_HOST_CANDIDATES = "lanHostCandidates" + const val KEY_LAN_PORT = "lanPort" + const val KEY_THEME_ID = "themeId" + const val KEY_UPDATE_CHANNEL = "updateChannel" + const val KEY_FORCE_UPDATE = "forceUpdateEnabled" + const val KEY_ON_CLASS = "receiveOnClass" + const val KEY_ON_BREAKING = "receiveOnBreaking" + const val KEY_AFTER_SCHOOL = "receiveAfterSchool" + const val KEY_SCHEDULE = "receiveScheduleChanged" + const val KEY_CUSTOM = "receiveCustom" + const val KEY_AUTOMATION_NOTIFICATIONS = "receiveAutomationNotifications" + const val KEY_PLUGIN_NOTIFICATIONS = "receivePluginNotifications" + const val KEY_APPEARANCE = "appearanceMode" + } +} + +/** 合并服务端下发的候选地址;仍可用的当前首选地址保持优先,避免每次重连重复试错。 */ +internal fun mergePluginNetworkInfo(settings: WatchSettings, info: PluginNetworkInfo): WatchSettings { + if (info.port !in 1..65535) return settings + val advertised = info.addresses.map(String::trim).filter(String::isNotEmpty).distinct() + if (advertised.isEmpty()) return settings.copy(lanPort = info.port) + val preferred = settings.lanHost.takeIf(advertised::contains) ?: advertised.first() + return settings.copy( + lanHost = preferred, + lanHostCandidates = listOf(preferred) + advertised.filterNot { it == preferred }, + lanPort = info.port, + ) +} + +internal fun lanEndpointHosts(settings: WatchSettings): List = + (listOf(settings.lanHost) + settings.lanHostCandidates) + .map(String::trim) + .filter(String::isNotEmpty) + .distinct() + +internal fun lanWebSocketUrl(host: String, port: Int): String { + val urlHost = if (':' in host && !host.startsWith("[")) "[$host]" else host + return "ws://$urlHost:$port/ws" +} + +/** 用选中的插件补齐两条连接信息;localhost 必须替换成手表实际连到的插件地址。 */ +internal fun mergeLanBootstrapInfo( + settings: WatchSettings, + candidate: LanPluginCandidate, + bootstrap: ConnectionBootstrapInfo, +): WatchSettings { + val cloudUrl = reachableCloudServerUrl(bootstrap.cloudServerUrl, candidate.host) + ?: throw IllegalArgumentException("插件返回的云服务器地址无效") + return settings.copy( + cloudServerUrl = cloudUrl, + lanHost = candidate.host, + lanHostCandidates = listOf(candidate.host), + lanPort = candidate.port, + ) +} + +private fun reachableCloudServerUrl(value: String, pluginHost: String): String? = runCatching { + val parsed = URI(value.trim()) + val parsedHost = parsed.host?.takeIf(String::isNotBlank) ?: return null + if (parsed.scheme !in listOf("http", "https")) return null + val host = if (parsedHost.lowercase() in setOf("localhost", "127.0.0.1", "0.0.0.0", "::1", "::")) + pluginHost + else + parsedHost + URI(parsed.scheme, parsed.userInfo, host, parsed.port, parsed.path, parsed.query, parsed.fragment) + .toString().trimEnd('/') +}.getOrNull() + +internal fun WatchSettings.receives(event: ClassEvent): Boolean = when (event.event) { + Protocol.EVENT_ON_CLASS -> receiveOnClass + Protocol.EVENT_ON_BREAKING -> receiveOnBreaking + Protocol.EVENT_AFTER_SCHOOL -> receiveAfterSchool + Protocol.EVENT_SCHEDULE_CHANGED -> receiveScheduleChanged + Protocol.EVENT_CUSTOM -> receiveCustom + Protocol.EVENT_AUTOMATION_NOTIFICATION -> receiveAutomationNotifications + Protocol.EVENT_PLUGIN_NOTIFICATION -> receivePluginNotifications + else -> false +} diff --git a/android/app/src/main/java/com/remoteci/mobile/data/SnapshotStore.kt b/android/app/src/main/java/com/remoteci/mobile/data/SnapshotStore.kt new file mode 100644 index 0000000..5b1ba9d --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/data/SnapshotStore.kt @@ -0,0 +1,38 @@ +package com.remoteci.mobile.data + +import android.content.Context +import androidx.core.content.edit +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** 持久化最后一次有效课表,让手表在断网时仍能展示最近课程。 */ +class SnapshotStore(context: Context) { + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val json = Json { ignoreUnknownKeys = true } + + fun load(): ClassStateSnapshot? { + val raw = prefs.getString(KEY_SNAPSHOT, null) ?: return null + return runCatching { json.decodeFromString(raw) }.getOrNull() + } + + fun save(snapshot: ClassStateSnapshot) { + prefs.edit { + putString(KEY_SNAPSHOT, json.encodeToString(snapshot)) + } + } + + fun loadSchedule(): ScheduleBundle? { + val raw = prefs.getString(KEY_SCHEDULE, null) ?: return null + return runCatching { json.decodeFromString(raw) }.getOrNull() + } + + fun saveSchedule(schedule: ScheduleBundle) { + prefs.edit { putString(KEY_SCHEDULE, json.encodeToString(schedule)) } + } + + private companion object { + const val PREFS_NAME = "remoteci_snapshot" + const val KEY_SNAPSHOT = "latest" + const val KEY_SCHEDULE = "schedule" + } +} diff --git a/android/app/src/main/java/com/remoteci/mobile/data/UpdateManager.kt b/android/app/src/main/java/com/remoteci/mobile/data/UpdateManager.kt new file mode 100644 index 0000000..8b828a2 --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/data/UpdateManager.kt @@ -0,0 +1,222 @@ +package com.remoteci.mobile.data + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import java.io.File +import java.io.IOException +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import com.remoteci.mobile.BuildConfig +import com.remoteci.mobile.notif.UpdateReceiver + +/** GitHub release 响应中与本应用相关的字段。 */ +@Serializable +data class GitHubRelease( + @SerialName("tag_name") val tagName: String = "", + @SerialName("name") val name: String? = null, + @SerialName("body") val body: String? = null, + @SerialName("prerelease") val prerelease: Boolean = false, + @SerialName("draft") val draft: Boolean = false, + @SerialName("assets") val assets: List = emptyList(), +) + +@Serializable +data class GitHubAsset( + @SerialName("name") val name: String = "", + @SerialName("browser_download_url") val browserDownloadUrl: String = "", + @SerialName("size") val size: Long = 0, +) + +data class CompatibleUpdate( + val release: GitHubRelease, + val asset: GitHubAsset, +) + +enum class UpdateChannel +{ + STABLE, + BETA, +} + +/** + * 手表端更新入口:从 GitHub 仓库发布列表选择 APK 并安装。 + * + * 安装沿用 [PackageInstaller] 官方机制,要求发布 APK 与当前安装包签名一致, + * 首次安装正式签名版后后续更新即可在同一签名下自动覆盖。 + */ +object UpdateManager { + private const val REPO = "Edge-HH/RemoteCI" + private const val API_RELEASES = "https://api.github.com/repos/$REPO/releases?per_page=20" + private val USER_AGENT = "RemoteCI-Mobile/${BuildConfig.VERSION_NAME}" + private val STABLE_RELEASE_TAG = Regex("""^3\.[0-9]+\.[0-9]+\.[0-9]+$""") + private val BETA_RELEASE_TAG = Regex("""^v3\.[0-9]+\.[0-9]+-beta\.[1-9][0-9]*$""") + const val INSTALL_RESULT_ACTION = "com.remoteci.mobile.UPDATE_INSTALL_RESULT" + + private val json = Json { ignoreUnknownKeys = true } + private val okHttp = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build() + + /** 拉取最近发布列表,选择当前协议代内最高兼容手表版本。 */ + suspend fun fetchReleases(): List = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url(API_RELEASES) + .header("User-Agent", USER_AGENT) + .build() + okHttp.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw IOException("检查更新失败(HTTP ${response.code})") + json.decodeFromString>(response.body.string()) + } + } + + /** 在 release 附件中寻找与该 tag 完全对应的手表 APK。 */ + fun findApkAsset(release: GitHubRelease): GitHubAsset? { + val expectedName = "RemoteCI.Mobile-${versionFromTag(release.tagName)}.apk" + return release.assets.firstOrNull { it.name == expectedName } + } + + /** 按渠道选择同一 V3 协议代内可升级或可强制覆盖的最高版本。 */ + fun selectCompatibleUpdate( + releases: List, + currentVersion: String, + channel: UpdateChannel = UpdateChannel.STABLE, + force: Boolean = false, + ): CompatibleUpdate? = releases + .filterNot { it.draft } + .filter { release -> + val stable = STABLE_RELEASE_TAG.matches(release.tagName) && !release.prerelease + val beta = BETA_RELEASE_TAG.matches(release.tagName) && release.prerelease + if (channel == UpdateChannel.STABLE) stable else stable || beta + } + .mapNotNull { release -> findApkAsset(release)?.let { CompatibleUpdate(release, it) } } + .filter { candidate -> + val version = versionFromTag(candidate.release.tagName) + val currentComparison = compareVersions(version, currentVersion) + (currentComparison > 0 || force && currentComparison == 0) && + version.substringBefore("-").substringBefore(".").toIntOrNull() == Protocol.VERSION + } + .maxWithOrNull { left, right -> + compareVersions(left.release.tagName, right.release.tagName) + } + + /** 去掉 tag 前缀 v,得到可比较的版本号。 */ + fun versionFromTag(tag: String): String = tag.removePrefix("v").trim() + + /** latest > current 时返回 true。 */ + fun isNewer(latest: String, current: String): Boolean = compareVersions(latest, current) > 0 + + /** 语义版本比较,正式版在相同核心版本的预发布版之后。 */ + fun compareVersions(a: String, b: String): Int { + val left = parseVersion(a) + val right = parseVersion(b) + val size = maxOf(left.core.size, right.core.size) + for (i in 0 until size) { + val comparison = left.core.getOrElse(i) { 0 }.compareTo(right.core.getOrElse(i) { 0 }) + if (comparison != 0) return comparison + } + + if (left.prerelease.isEmpty()) return if (right.prerelease.isEmpty()) 0 else 1 + if (right.prerelease.isEmpty()) return -1 + for (i in 0 until maxOf(left.prerelease.size, right.prerelease.size)) { + if (i >= left.prerelease.size) return -1 + if (i >= right.prerelease.size) return 1 + val leftPart = left.prerelease[i] + val rightPart = right.prerelease[i] + val leftNumber = leftPart.toIntOrNull() + val rightNumber = rightPart.toIntOrNull() + val comparison = when { + leftNumber != null && rightNumber != null -> leftNumber.compareTo(rightNumber) + leftNumber != null -> -1 + rightNumber != null -> 1 + else -> leftPart.compareTo(rightPart) + } + if (comparison != 0) return comparison + } + return 0 + } + + private data class ParsedVersion(val core: List, val prerelease: List) + + private fun parseVersion(value: String): ParsedVersion { + val withoutBuild = value.removePrefix("v").substringBefore("+") + val core = withoutBuild.substringBefore("-").split(".").map { it.toIntOrNull() ?: 0 } + val prerelease = withoutBuild.substringAfter("-", "") + .split(".") + .filter { it.isNotEmpty() } + return ParsedVersion(core, prerelease) + } + + /** 下载 APK 到缓存目录,校验长度后返回本地文件。 */ + suspend fun downloadApk(context: Context, asset: GitHubAsset): File = withContext(Dispatchers.IO) { + val target = File(context.cacheDir, asset.name) + if (target.exists()) target.delete() + val request = Request.Builder() + .url(asset.browserDownloadUrl) + .header("User-Agent", USER_AGENT) + .build() + okHttp.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw IOException("下载失败(HTTP ${response.code})") + val expected = asset.size + val announced = response.body.contentLength() + if (expected > 0 && announced >= 0 && announced != expected) + throw IOException("下载长度不匹配:发布清单 $expected 字节,实际 $announced 字节") + val body = response.body + body.byteStream().use { input -> + target.outputStream().use { output -> input.copyTo(output) } + } + } + // 传输中断但连接“正常”关闭时落盘文件会缺尾,落盘后再校验一次;安装器只负责验签不负责验全。 + if (asset.size > 0 && target.length() != asset.size) { + target.delete() + throw IOException("APK 下载不完整:预期 ${asset.size} 字节,实际 ${target.length()} 字节") + } + target + } + + /** 通过系统 PackageInstaller 安装,结果由 UpdateReceiver 接收并提示;失败时回收会话。 */ + suspend fun installApk(context: Context, apk: File): Unit = withContext(Dispatchers.IO) { + val installer = context.packageManager.packageInstaller + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + params.setAppPackageName(context.packageName) + val sessionId = installer.createSession(params) + try { + val session = installer.openSession(sessionId) + try { + // openWrite + copyTo 会同步写入几十 MB,绝不能在主线程执行(ANR 风险)。 + session.openWrite(apk.name, 0, apk.length()).use { output -> + apk.inputStream().use { input -> input.copyTo(output) } + session.fsync(output) + } + val pending = createInstallResultPendingIntent(context, sessionId) + session.commit(pending.intentSender) + } finally { + session.close() + } + } catch (error: Exception) { + // 写入失败或 commit 前异常时释放会话,避免 PackageInstaller 留下僵尸会话占空间。 + runCatching { installer.abandonSession(sessionId) } + throw error + } + } + + /** PackageInstaller 会补充状态字段,因此回调必须可变;显式组件避免可变隐式 Intent 风险。 */ + fun createInstallResultIntent(context: Context): Intent = + Intent(context, UpdateReceiver::class.java).setAction(INSTALL_RESULT_ACTION) + + fun createInstallResultPendingIntent(context: Context, sessionId: Int): PendingIntent = + PendingIntent.getBroadcast( + context, + sessionId, + createInstallResultIntent(context), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, + ) +} diff --git a/android/app/src/main/java/com/remoteci/mobile/data/VoiceRecorder.kt b/android/app/src/main/java/com/remoteci/mobile/data/VoiceRecorder.kt new file mode 100644 index 0000000..1aef6fe --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/data/VoiceRecorder.kt @@ -0,0 +1,54 @@ +package com.remoteci.mobile.data + +import android.annotation.SuppressLint +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import android.os.SystemClock +import java.io.ByteArrayOutputStream +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext + +/** 只在前台且获得授权后使用;固定 PCM 格式使云端和局域网共用一套协议。 */ +internal object VoiceRecorder { + const val SAMPLE_RATE = 16000 + const val MAX_BYTES = SAMPLE_RATE * 2 * 60 + + @SuppressLint("MissingPermission") // 调用者通过运行时权限结果后才开始采集。 + suspend fun record(stop: AtomicBoolean, onSeconds: (Int) -> Unit): ByteArray = withContext(Dispatchers.IO) { + val minimum = AudioRecord.getMinBufferSize(SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) + check(minimum > 0) { "设备不支持语音录制" } + val recorder = AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, maxOf(minimum, 6400)) + try { + check(recorder.state == AudioRecord.STATE_INITIALIZED) { "麦克风不可用" } + recorder.startRecording() + check(recorder.recordingState == AudioRecord.RECORDSTATE_RECORDING) { "麦克风启动失败" } + val bytes = ByteArrayOutputStream() + val buffer = ByteArray(3200) + var reportedSeconds = -1 + val started = SystemClock.elapsedRealtime() + while (!stop.get() && bytes.size() < MAX_BYTES && SystemClock.elapsedRealtime() - started < 61_000) { + currentCoroutineContext().ensureActive() + // 非阻塞读取让取消/退出及时进入 finally,绝不把录音留在后台。 + val count = recorder.read(buffer, 0, minOf(buffer.size, MAX_BYTES - bytes.size()), AudioRecord.READ_NON_BLOCKING) + check(count >= 0) { "麦克风读取失败" } + if (count == 0) { delay(20); continue } + bytes.write(buffer, 0, count) + val seconds = bytes.size() / (SAMPLE_RATE * 2) + if (seconds != reportedSeconds) { + reportedSeconds = seconds + withContext(Dispatchers.Main) { onSeconds(seconds) } + } + } + bytes.toByteArray() + } finally { + runCatching { recorder.stop() } + recorder.release() + } + } +} diff --git a/android/app/src/main/java/com/remoteci/mobile/notif/NotificationHelper.kt b/android/app/src/main/java/com/remoteci/mobile/notif/NotificationHelper.kt new file mode 100644 index 0000000..20fab5a --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/notif/NotificationHelper.kt @@ -0,0 +1,85 @@ +package com.remoteci.mobile.notif + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.remoteci.mobile.R +import com.remoteci.mobile.data.ClassEvent +import com.remoteci.mobile.data.EventHistory +import com.remoteci.mobile.data.Protocol +import com.remoteci.mobile.data.WatchSettings +import com.remoteci.mobile.data.receives +import java.util.concurrent.atomic.AtomicInteger + +/** + * 通知+振动助手:课程事件到达时发系统通知并振动。 + */ +object NotificationHelper { + internal const val CHANNEL_ID = "remoteci_class" + + // 通知 ID 用自增序号:哈希作 ID 时不同事件可能碰撞互相覆盖,导致通知丢失。 + private val notificationIds = AtomicInteger(1) + + fun ensureChannel(context: Context) { + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = context.getString(R.string.notification_channel_description) + enableVibration(true) + } + context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + + fun handle(context: Context, event: ClassEvent, settings: WatchSettings, history: EventHistory) { + if (!history.markIfNew(event) || !settings.receives(event)) return + // Android 13+ 需要通知权限 + if (Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + return + } + + val title = when (event.event) { + Protocol.EVENT_ON_CLASS -> context.getString(R.string.notification_title_on_class) + Protocol.EVENT_ON_BREAKING -> context.getString(R.string.notification_title_on_breaking) + Protocol.EVENT_AFTER_SCHOOL -> context.getString(R.string.notification_title_after_school) + Protocol.EVENT_SCHEDULE_CHANGED -> context.getString(R.string.notification_title_schedule_changed) + Protocol.EVENT_CUSTOM -> event.subject ?: context.getString(R.string.notification_title_remote_ci) + Protocol.EVENT_AUTOMATION_NOTIFICATION, + Protocol.EVENT_PLUGIN_NOTIFICATION -> event.subject ?: context.getString(R.string.notification_title_classisland) + else -> context.getString(R.string.notification_title_fallback) + } + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat) + .setContentTitle(title) + .setContentText(event.message ?: event.subject ?: "") + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .build() + + NotificationManagerCompat.from(context).notify(notificationIds.getAndIncrement(), notification) + vibrate(context) + } + + private fun vibrate(context: Context) { + val vibrator = if (Build.VERSION.SDK_INT >= 31) { + context.getSystemService(Vibrator::class.java) + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + vibrator.vibrate(VibrationEffect.createOneShot(800, VibrationEffect.DEFAULT_AMPLITUDE)) + } +} diff --git a/android/app/src/main/java/com/remoteci/mobile/notif/UpdateReceiver.kt b/android/app/src/main/java/com/remoteci/mobile/notif/UpdateReceiver.kt new file mode 100644 index 0000000..1c0a3d9 --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/notif/UpdateReceiver.kt @@ -0,0 +1,80 @@ +package com.remoteci.mobile.notif + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.remoteci.mobile.R + +enum class InstallStatusAction { + RequestUserConfirmation, + Success, + Failure, +} + +/** + * 接收 PackageInstaller 的安装结果并提示用户。 + * Manifest 中声明为不导出的显式结果接收器,回调只能通过本应用创建的 PendingIntent 到达。 + */ +class UpdateReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) + val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + when (actionForStatus(status)) { + InstallStatusAction.RequestUserConfirmation -> { + val confirmation = userConfirmationIntent(intent) + if (confirmation != null && runCatching { + context.startActivity(confirmation.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + }.isSuccess + ) return + notify(context, false, "无法打开系统安装确认界面,请重新尝试。") + } + InstallStatusAction.Success -> notify(context, true, message) + InstallStatusAction.Failure -> notify(context, false, message) + } + } + + private fun notify(context: Context, success: Boolean, message: String?) { + // 进程可能只为接收安装结果而启动,App 尚未创建通知渠道;这里幂等补建,避免 API 26+ 静默丢通知。 + NotificationHelper.ensureChannel(context) + val title = if (success) "更新完成" else "更新失败" + val text = when { + success -> "RemoteCI 已更新到新版本,请重新打开应用。" + !message.isNullOrBlank() -> message + else -> "安装未完成,请稍后重试。" + } + val notification = NotificationCompat.Builder(context, NotificationHelper.CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat) + .setContentTitle(title) + .setContentText(text) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .build() + try { + NotificationManagerCompat.from(context).notify(UPDATE_NOTIFICATION_ID, notification) + } catch (_: SecurityException) { + // 未授予通知权限时静默失败,安装结果仍以系统安装页为准。 + } + } + + companion object { + fun actionForStatus(status: Int): InstallStatusAction = when (status) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> InstallStatusAction.RequestUserConfirmation + PackageInstaller.STATUS_SUCCESS -> InstallStatusAction.Success + else -> InstallStatusAction.Failure + } + + @Suppress("DEPRECATION") + private fun userConfirmationIntent(result: Intent): Intent? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + result.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + } else { + result.getParcelableExtra(Intent.EXTRA_INTENT) + } + + const val UPDATE_NOTIFICATION_ID = 2026 + } +} diff --git a/android/app/src/main/java/com/remoteci/mobile/ui/Components.kt b/android/app/src/main/java/com/remoteci/mobile/ui/Components.kt new file mode 100644 index 0000000..184742f --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/ui/Components.kt @@ -0,0 +1,210 @@ +package com.remoteci.mobile.ui + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +fun connectedShape(index: Int, count: Int, outer: Dp = 28.dp, inner: Dp = 8.dp): Shape { + if (count <= 1) return RoundedCornerShape(outer) + return when (index) { + 0 -> RoundedCornerShape(topStart = outer, topEnd = outer, bottomStart = inner, bottomEnd = inner) + count - 1 -> RoundedCornerShape(topStart = inner, topEnd = inner, bottomStart = outer, bottomEnd = outer) + else -> RoundedCornerShape(inner) + } +} + +fun connectedButtonShape(index: Int, count: Int, outer: Dp = 28.dp, inner: Dp = 8.dp): Shape { + if (count <= 1) return RoundedCornerShape(outer) + return when (index) { + 0 -> RoundedCornerShape(topStart = outer, bottomStart = outer, topEnd = inner, bottomEnd = inner) + count - 1 -> RoundedCornerShape(topStart = inner, bottomStart = inner, topEnd = outer, bottomEnd = outer) + else -> RoundedCornerShape(inner) + } +} + +@Composable +fun Pressable( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable () -> Unit, +) { + val source = remember { MutableInteractionSource() } + val pressed by source.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.97f else 1f, label = "press-scale") + Box( + modifier = modifier + .scale(scale) + .clip(RoundedCornerShape(20.dp)) + .clickable(enabled = enabled, interactionSource = source, indication = null, onClick = onClick), + ) { content() } +} + +@Composable +fun ConnectedListCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(3.dp), content = content) +} + +@Composable +fun AppListItem( + title: String, + supporting: String? = null, + leading: ImageVector, + trailing: ImageVector? = null, + trailingText: String? = null, + switchChecked: Boolean? = null, + onSwitch: ((Boolean) -> Unit)? = null, + index: Int = 0, + count: Int = 1, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, +) { + val colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) + val shape = connectedShape(index, count) + val source = remember { MutableInteractionSource() } + val pressed by source.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.97f else 1f, label = "list-item-press") + ListItem( + headlineContent = { Text(title, style = MaterialTheme.typography.bodyLarge) }, + supportingContent = supporting?.let { { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } }, + leadingContent = { + Box( + Modifier.size(40.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon(leading, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(24.dp)) + } + }, + trailingContent = { + when { + switchChecked != null -> Switch(checked = switchChecked, onCheckedChange = onSwitch) + trailing != null -> Icon(trailing, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + trailingText != null -> Text(trailingText, style = MaterialTheme.typography.labelLarge) + } + }, + colors = colors, + modifier = modifier + .fillMaxWidth() + .scale(scale) + .clip(shape) + .then( + if (onClick != null) Modifier.clickable( + interactionSource = source, + indication = null, + onClick = onClick, + ) else Modifier, + ) + .heightIn(min = 72.dp), + ) +} + +@Composable +fun ConnectedButtons( + modifier: Modifier = Modifier, + firstLabel: String, + secondLabel: String, + onFirst: () -> Unit, + onSecond: () -> Unit, + firstFilled: Boolean = true, +) { + Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(3.dp)) { + val firstShape = connectedButtonShape(0, 2) + val secondShape = connectedButtonShape(1, 2) + if (firstFilled) { + Button( + onClick = onFirst, + modifier = Modifier.weight(1f).height(56.dp), + shape = firstShape, + ) { Text(firstLabel) } + FilledTonalButton( + onClick = onSecond, + modifier = Modifier.weight(1f).height(56.dp), + shape = secondShape, + colors = ButtonDefaults.filledTonalButtonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), + ) { Text(secondLabel) } + } else { + OutlinedButton( + onClick = onFirst, + modifier = Modifier.weight(1f).height(56.dp), + shape = firstShape, + ) { Text(firstLabel) } + Button( + onClick = onSecond, + modifier = Modifier.weight(1f).height(56.dp), + shape = secondShape, + ) { Text(secondLabel) } + } + } +} + +@Composable +fun ScreenScaffold( + snackbarHostState: SnackbarHostState, + bottomBar: @Composable () -> Unit = {}, + content: @Composable (PaddingValues) -> Unit, +) { + androidx.compose.material3.Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + bottomBar = bottomBar, + containerColor = MaterialTheme.colorScheme.surface, + content = content, + ) +} + +@Composable +fun EmptyState(title: String, body: String, modifier: Modifier = Modifier) { + Column(modifier.fillMaxWidth().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text(title, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +fun PermissionHint(text: String) { + Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(16.dp)) +} diff --git a/android/app/src/main/java/com/remoteci/mobile/ui/ControlScreens.kt b/android/app/src/main/java/com/remoteci/mobile/ui/ControlScreens.kt new file mode 100644 index 0000000..58543f2 --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/ui/ControlScreens.kt @@ -0,0 +1,376 @@ +package com.remoteci.mobile.ui + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Campaign +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.Extension +import androidx.compose.material.icons.rounded.Mic +import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material.icons.rounded.NotificationsActive +import androidx.compose.material.icons.rounded.NotificationsOff +import androidx.compose.material.icons.rounded.PowerSettingsNew +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material.icons.rounded.VolumeUp +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import com.remoteci.mobile.data.AdminApi +import com.remoteci.mobile.data.ClassEvent +import com.remoteci.mobile.data.ConnectionManager +import com.remoteci.mobile.data.ExtensionDefinition +import com.remoteci.mobile.data.ExtensionPolicyUpdate +import com.remoteci.mobile.data.Protocol +import com.remoteci.mobile.data.UserProfile +import com.remoteci.mobile.data.VoiceRecorder +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ControlListScreen(embedded: Boolean, onOpen: (Screen) -> Unit, onBack: (() -> Unit)?) { + val user by ConnectionManager.currentUser.collectAsState() + val snapshot by ConnectionManager.snapshot.collectAsState() + val caps by ConnectionManager.availableCapabilities.collectAsState() + data class Row( + val title: String, + val supporting: String, + val icon: androidx.compose.ui.graphics.vector.ImageVector, + val visible: Boolean, + val screen: Screen?, + val action: (() -> Unit)? = null, + ) + val rows = listOf( + Row("发送通知", "标题、正文、强调特效、音效和语音朗读", Icons.Rounded.NotificationsActive, + user?.has(Protocol.PERMISSION_SEND_NOTIFICATIONS) == true && Protocol.CAP_NOTIFICATION_SEND in caps, Screen.Notify), + Row("发送语音", "最长 60 秒的语音消息", Icons.Rounded.Mic, + user?.has(Protocol.PERMISSION_SEND_VOICE_MESSAGES) == true && Protocol.CAP_VOICE_MESSAGE_SEND in caps, Screen.Voice), + Row("清除提醒", "清除 ClassIsland 当前提醒与手表提示", Icons.Rounded.NotificationsOff, + user?.has(Protocol.PERMISSION_SEND_NOTIFICATIONS) == true && Protocol.CAP_NOTIFICATION_CLEAR in caps, null, + { ConnectionManager.clearNotifications() }), + Row("老师来了", "显示提醒,等待一秒后自动清除", Icons.Rounded.Campaign, + user?.has(Protocol.PERMISSION_TEACHER_COMING) == true && Protocol.CAP_TEACHER_COMING in caps, null, + { ConnectionManager.teacherComing() }), + Row("主界面", if (snapshot?.isMainMenuVisible == true) "当前显示中,点击可隐藏" else "当前已隐藏,点击可显示", + Icons.Rounded.Visibility, + user?.has(Protocol.PERMISSION_MAIN_MENU_CONTROL) == true && Protocol.CAP_MAIN_MENU_VISIBILITY in caps, Screen.MainMenu), + Row("音量", "音量调节和切换静音", Icons.Rounded.VolumeUp, + user?.has(Protocol.PERMISSION_POWER_CONTROL) == true && Protocol.CAP_VOLUME_CONTROL in caps, Screen.Volume), + Row("电源控制", "关机、睡眠或休眠", Icons.Rounded.PowerSettingsNew, + user?.has(Protocol.PERMISSION_POWER_CONTROL) == true && Protocol.CAP_POWER_CONTROL in caps, Screen.Power), + Row("扩展功能", "运行已注册扩展", Icons.Rounded.Extension, + user?.has(Protocol.PERMISSION_RUN_EXTENSIONS) == true && Protocol.CAP_EXTENSIONS_RUN in caps, Screen.Extensions), + ).filter { it.visible } + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { Text("课堂控制", style = MaterialTheme.typography.titleLarge) }, + navigationIcon = { IconButton(onClick = { onBack?.invoke() }) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }, + ) + if (rows.isEmpty()) { + EmptyState("没有可用的控制项", "这取决于账号权限,以及当前插件是否在线。") + } else { + ConnectedListCard(Modifier.padding(16.dp)) { + rows.forEachIndexed { index, row -> + AppListItem( + title = row.title, + supporting = row.supporting, + leading = row.icon, + trailing = Icons.Rounded.ChevronRight, + index = index, + count = rows.size, + onClick = { row.screen?.let(onOpen) ?: row.action?.invoke() }, + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotifyScreen(onBack: () -> Unit) { + var title by remember { mutableStateOf("") } + var message by remember { mutableStateOf("") } + var effect by remember { mutableStateOf(true) } + var sound by remember { mutableStateOf(false) } + var speech by remember { mutableStateOf(false) } + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + TopAppBar(title = { Text("发送通知") }, navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }) + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField(title, { title = it }, label = { Text("标题") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(message, { message = it }, label = { Text("正文") }, modifier = Modifier.fillMaxWidth().height(140.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text("强调特效"); Switch(effect, { effect = it }) } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text("通知音效"); Switch(sound, { sound = it }) } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text("语音朗读"); Switch(speech, { speech = it }) } + Button( + onClick = { ConnectionManager.sendNotification(title, message, effect, sound, speech) }, + modifier = Modifier.fillMaxWidth().height(56.dp), + enabled = title.isNotBlank() || message.isNotBlank(), + ) { Text("发送") } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VoiceScreen(onBack: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var seconds by remember { mutableIntStateOf(0) } + var recording by remember { mutableStateOf(false) } + var sending by remember { mutableStateOf(false) } + var audio by remember { mutableStateOf(null) } + val stop = remember { AtomicBoolean(false) } + val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) startRecord(scope, stop, { seconds = it }, { recording = it }, { audio = it }) + } + Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + TopAppBar(title = { Text("发送语音") }, navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }) + Text(if (recording) "正在录音 $seconds 秒 / 60" else audio?.let { "已录制 ${it.size / (16000 * 2)} 秒" } ?: "最多录制 60 秒,发送后由 ClassIsland 播放。") + Button( + onClick = { + if (recording) { + stop.set(true) + } else { + audio = null + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + if (granted) startRecord(scope, stop, { seconds = it }, { recording = it }, { audio = it }) + else permission.launch(Manifest.permission.RECORD_AUDIO) + } + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { Text(if (recording) "停止" else "开始录音") } + Button( + onClick = { + val data = audio ?: return@Button + sending = true + scope.launch { + val result = ConnectionManager.sendVoiceMessage(data) + sending = false + if (result.success) onBack() + } + }, + enabled = audio != null && !recording && !sending, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { Text(if (sending) "发送中…" else "发送语音") } + } +} + +private fun startRecord( + scope: kotlinx.coroutines.CoroutineScope, + stop: AtomicBoolean, + onSeconds: (Int) -> Unit, + onRecording: (Boolean) -> Unit, + onAudio: (ByteArray) -> Unit, +) { + stop.set(false) + onRecording(true) + scope.launch { + val data = runCatching { VoiceRecorder.record(stop, onSeconds) }.getOrDefault(ByteArray(0)) + onRecording(false) + if (data.isNotEmpty()) onAudio(data) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VolumeScreen(onBack: () -> Unit) { + val snapshot by ConnectionManager.snapshot.collectAsState() + var level by remember(snapshot?.volumePercent) { mutableFloatStateOf((snapshot?.volumePercent ?: 0).toFloat()) } + Column(Modifier.fillMaxSize()) { + TopAppBar(title = { Text("音量") }, navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }) + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (snapshot?.isVolumeControlAvailable != true) { + PermissionHint("当前没有可控制的播放设备。") + } else { + Text(if (snapshot?.isMuted == true) "已静音 · ${level.toInt()}%" else "当前音量 ${level.toInt()}%") + Slider(value = level, onValueChange = { level = it }, valueRange = 0f..100f, onValueChangeFinished = { ConnectionManager.setVolume(level.toInt()) }) + Button(onClick = { ConnectionManager.setMuted(!(snapshot?.isMuted ?: false)) }, modifier = Modifier.fillMaxWidth().height(56.dp)) { + Text(if (snapshot?.isMuted == true) "取消静音" else "静音") + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PowerScreen(onBack: () -> Unit) { + var pending by remember { mutableStateOf?>(null) } + val snapshot by ConnectionManager.snapshot.collectAsState() + Column(Modifier.fillMaxSize()) { + TopAppBar(title = { Text("电源控制") }, navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }) + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + listOf( + Protocol.POWER_SHUTDOWN to "关机", + Protocol.POWER_RESTART to "重启", + Protocol.POWER_SLEEP to "睡眠", + Protocol.POWER_HIBERNATE to "休眠", + ).forEach { (action, label) -> + val enabled = when (action) { + Protocol.POWER_SLEEP -> snapshot?.isSleepAvailable == true + Protocol.POWER_HIBERNATE -> snapshot?.isHibernateAvailable == true + else -> true + } + Button(onClick = { pending = action to label }, enabled = enabled, modifier = Modifier.fillMaxWidth().height(56.dp)) { Text(label) } + } + } + } + pending?.let { (action, label) -> + AlertDialog( + onDismissRequest = { pending = null }, + title = { Text("确认$label") }, + text = { Text("将向教室电脑发送“$label”。请确认现场情况后再继续。") }, + confirmButton = { TextButton({ ConnectionManager.sendPowerAction(action); pending = null }) { Text("确认") } }, + dismissButton = { TextButton({ pending = null }) { Text("取消") } }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainMenuScreen(onBack: () -> Unit) { + val snapshot by ConnectionManager.snapshot.collectAsState() + val visible = snapshot?.isMainMenuVisible == true + Column(Modifier.fillMaxSize()) { + TopAppBar(title = { Text("主界面") }, navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }) + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(if (visible) "ClassIsland 主界面当前可见。" else "ClassIsland 主界面当前已隐藏。") + Button(onClick = { ConnectionManager.setMainMenuVisible(!visible) }, modifier = Modifier.fillMaxWidth().height(56.dp)) { + Text(if (visible) "隐藏主界面" else "显示主界面") + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExtensionsScreen(onBack: () -> Unit) { + val user by ConnectionManager.currentUser.collectAsState() + val extensions by ConnectionManager.extensions.collectAsState() + val scope = rememberCoroutineScope() + var policies by remember { mutableStateOf>(emptyList()) } + var selected by remember { mutableStateOf(null) } + var args by remember { mutableStateOf>(emptyMap()) } + androidx.compose.runtime.LaunchedEffect(user) { + policies = runCatching { AdminApi.extensions(ConnectionManager.currentClassId.value) }.getOrDefault(emptyList()) + } + Column(Modifier.fillMaxSize()) { + TopAppBar(title = { Text("扩展功能") }, navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }) + if (extensions.isEmpty()) { + EmptyState("没有扩展", "其他 ClassIsland 插件注册后会出现在这里。") + } else { + ConnectedListCard(Modifier.padding(16.dp)) { + extensions.filter { user?.showsOnWatch(it) == true || user?.isAdmin == true }.forEachIndexed { index, item -> + val visible = extensions.filter { user?.showsOnWatch(it) == true || user?.isAdmin == true } + AppListItem( + title = item.displayName, + supporting = item.id, + leading = Icons.Rounded.Extension, + trailing = Icons.Rounded.ChevronRight, + index = index, + count = visible.size, + onClick = { + selected = item + args = item.parameters.associate { it.key to it.defaultValue } + }, + ) + } + } + } + if (user?.isAdmin == true) { + Text("管理策略", modifier = Modifier.padding(horizontal = 16.dp), style = MaterialTheme.typography.titleMedium) + policies.forEach { policy -> + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Column(Modifier.weight(1f)) { Text(policy.displayName.ifBlank { policy.id }); Text("允许普通账号:${policy.allowNonAdmin}", style = MaterialTheme.typography.bodySmall) } + Switch(policy.enabled, { + scope.launch { + runCatching { AdminApi.updateExtensionPolicy(policy.id, ExtensionPolicyUpdate(enabled = it, allowNonAdmin = policy.allowNonAdmin, showOnWatch = policy.showOnWatch)) } + policies = runCatching { AdminApi.extensions(ConnectionManager.currentClassId.value) }.getOrDefault(policies) + } + }) + } + } + } + } + selected?.let { extension -> + AlertDialog( + onDismissRequest = { selected = null }, + title = { Text(extension.displayName) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + extension.parameters.forEach { parameter -> + OutlinedTextField( + value = args[parameter.key].orEmpty(), + onValueChange = { args = args + (parameter.key to it) }, + label = { Text(parameter.label) }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + }, + confirmButton = { TextButton({ ConnectionManager.runExtension(extension, args); selected = null }) { Text("运行") } }, + dismissButton = { TextButton({ selected = null }) { Text("取消") } }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InboxScreen(onBack: () -> Unit) { + var events by remember { mutableStateOf(listOf()) } + androidx.compose.runtime.LaunchedEffect(Unit) { + ConnectionManager.events.collect { event -> events = (listOf(event) + events).take(50) } + } + Column(Modifier.fillMaxSize()) { + TopAppBar(title = { Text("通知") }, navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }) + if (events.isEmpty()) EmptyState("暂无通知", "上课、下课、课表变更和自定义提醒会显示在这里。") + else ConnectedListCard(Modifier.padding(16.dp)) { + events.forEachIndexed { index, event -> + AppListItem( + title = event.message ?: event.subject ?: "RemoteCI 通知", + supporting = event.occurredAt, + leading = Icons.Rounded.Notifications, + index = index, + count = events.size, + ) + } + } + } +} diff --git a/android/app/src/main/java/com/remoteci/mobile/ui/ExtensionIcons.kt b/android/app/src/main/java/com/remoteci/mobile/ui/ExtensionIcons.kt new file mode 100644 index 0000000..e1bfeee --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/ui/ExtensionIcons.kt @@ -0,0 +1,292 @@ +package com.remoteci.mobile.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Announcement +import androidx.compose.material.icons.automirrored.rounded.Assignment +import androidx.compose.material.icons.automirrored.rounded.EventNote +import androidx.compose.material.icons.automirrored.rounded.Grading +import androidx.compose.material.icons.automirrored.rounded.Help +import androidx.compose.material.icons.automirrored.rounded.LibraryBooks +import androidx.compose.material.icons.automirrored.rounded.Login +import androidx.compose.material.icons.automirrored.rounded.Logout +import androidx.compose.material.icons.automirrored.rounded.MenuBook +import androidx.compose.material.icons.automirrored.rounded.ScreenShare +import androidx.compose.material.icons.automirrored.rounded.VolumeDown +import androidx.compose.material.icons.automirrored.rounded.VolumeMute +import androidx.compose.material.icons.automirrored.rounded.VolumeOff +import androidx.compose.material.icons.automirrored.rounded.VolumeUp +import androidx.compose.material.icons.rounded.AccountCircle +import androidx.compose.material.icons.rounded.AcUnit +import androidx.compose.material.icons.rounded.AdminPanelSettings +import androidx.compose.material.icons.rounded.Air +import androidx.compose.material.icons.rounded.Alarm +import androidx.compose.material.icons.rounded.Autorenew +import androidx.compose.material.icons.rounded.BatterySaver +import androidx.compose.material.icons.rounded.Bluetooth +import androidx.compose.material.icons.rounded.Bolt +import androidx.compose.material.icons.rounded.BroadcastOnHome +import androidx.compose.material.icons.rounded.Cached +import androidx.compose.material.icons.rounded.CalendarMonth +import androidx.compose.material.icons.rounded.Campaign +import androidx.compose.material.icons.rounded.Cancel +import androidx.compose.material.icons.rounded.Casino +import androidx.compose.material.icons.rounded.Cast +import androidx.compose.material.icons.rounded.CastConnected +import androidx.compose.material.icons.rounded.CastForEducation +import androidx.compose.material.icons.rounded.Celebration +import androidx.compose.material.icons.rounded.Chat +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Class +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.CloudDownload +import androidx.compose.material.icons.rounded.CloudUpload +import androidx.compose.material.icons.rounded.Code +import androidx.compose.material.icons.rounded.DoNotDisturb +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.EditNotifications +import androidx.compose.material.icons.rounded.Email +import androidx.compose.material.icons.rounded.Emergency +import androidx.compose.material.icons.rounded.Error +import androidx.compose.material.icons.rounded.FitnessCenter +import androidx.compose.material.icons.rounded.Fullscreen +import androidx.compose.material.icons.rounded.Group +import androidx.compose.material.icons.rounded.Groups +import androidx.compose.material.icons.rounded.Headphones +import androidx.compose.material.icons.rounded.Lightbulb +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.LocalHospital +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material.icons.rounded.LockOpen +import androidx.compose.material.icons.rounded.Mic +import androidx.compose.material.icons.rounded.MicOff +import androidx.compose.material.icons.rounded.Monitor +import androidx.compose.material.icons.rounded.Nightlight +import androidx.compose.material.icons.rounded.NotificationImportant +import androidx.compose.material.icons.rounded.NotificationsActive +import androidx.compose.material.icons.rounded.NotificationsOff +import androidx.compose.material.icons.rounded.NotificationsPaused +import androidx.compose.material.icons.rounded.PauseCircle +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.PlayCircle +import androidx.compose.material.icons.rounded.PowerSettingsNew +import androidx.compose.material.icons.rounded.PresentToAll +import androidx.compose.material.icons.rounded.Quiz +import androidx.compose.material.icons.rounded.RestartAlt +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material.icons.rounded.RingVolume +import androidx.compose.material.icons.rounded.Router +import androidx.compose.material.icons.rounded.Save +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material.icons.rounded.School +import androidx.compose.material.icons.rounded.Science +import androidx.compose.material.icons.rounded.Security +import androidx.compose.material.icons.rounded.SensorDoor +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.SettingsPower +import androidx.compose.material.icons.rounded.Shield +import androidx.compose.material.icons.rounded.Shuffle +import androidx.compose.material.icons.rounded.SmartDisplay +import androidx.compose.material.icons.rounded.Sms +import androidx.compose.material.icons.rounded.Speaker +import androidx.compose.material.icons.rounded.SwapHoriz +import androidx.compose.material.icons.rounded.Sync +import androidx.compose.material.icons.rounded.SystemUpdate +import androidx.compose.material.icons.rounded.Terminal +import androidx.compose.material.icons.rounded.Thermostat +import androidx.compose.material.icons.rounded.Timer +import androidx.compose.material.icons.rounded.Today +import androidx.compose.material.icons.rounded.Translate +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material.icons.rounded.Tv +import androidx.compose.material.icons.rounded.Upload +import androidx.compose.material.icons.rounded.VerifiedUser +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.material.icons.rounded.WifiOff +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * 扩展功能图标的归一化解析。 + * + * Material 图标在 Compose 中是编译期常量,无法按字符串动态查表,因此这里维护显式白名单。 + * 归一化规则:转小写、取最后一个 `.` 之后的部分(兼容 `Icons.Rounded.Foo` 写法)、 + * 去掉所有非字母数字字符。 + */ +internal fun normalizeExtensionIconName(icon: String?): String? = icon + ?.trim() + ?.lowercase() + ?.substringAfterLast('.') + ?.filter { it.isLetterOrDigit() } + ?.takeIf { it.isNotEmpty() } + +/** + * 解析扩展图标;未知或缺失时返回 null,手表界面回退为纯文字按钮。 + * + * 历史别名优先于图标本体,保证 3.2 之前已在使用这些名字的插件外观不变。 + */ +internal fun extensionIcon(icon: String?): ImageVector? { + val name = normalizeExtensionIconName(icon) ?: return null + return LEGACY_EXTENSION_ICONS[name] ?: EXTENSION_ICONS[name] +} + +/** + * 历史别名:这些名字在扩展图标白名单只有 13 项时已经被使用,为避免旧插件失效而保留。 + * 其中 `message`、`notifications`、`power`、`clear` 等与 Material 同名图标语义不同, + * 这里统一保留历史含义,新插件应优先使用 `[EXTENSION_ICONS]` 中的图标名。 + */ +private val LEGACY_EXTENSION_ICONS: Map = mapOf( + "notification" to Icons.Rounded.EditNotifications, + "notifications" to Icons.Rounded.EditNotifications, + "message" to Icons.Rounded.EditNotifications, + "volume" to Icons.AutoMirrored.Rounded.VolumeUp, + "power" to Icons.Rounded.PowerSettingsNew, + "poweroff" to Icons.Rounded.PowerSettingsNew, + "gear" to Icons.Rounded.Settings, + "update" to Icons.Rounded.SystemUpdate, + "restart" to Icons.Rounded.RestartAlt, + "reboot" to Icons.Rounded.RestartAlt, + "swap" to Icons.Rounded.SwapHoriz, + "exchange" to Icons.Rounded.SwapHoriz, + "connect" to Icons.Rounded.Wifi, + "show" to Icons.Rounded.Visibility, + "hide" to Icons.Rounded.VisibilityOff, + "hidden" to Icons.Rounded.VisibilityOff, + "clear" to Icons.Rounded.NotificationsOff, + "clearnotifications" to Icons.Rounded.NotificationsOff, +) + +/** + * 扩展图标白名单;键为 [normalizeExtensionIconName] 归一化后的 Material 图标名。 + * 新增图标时需同步更新 `WatchScreensTest` 与文档站 extensions 说明。 + */ +private val EXTENSION_ICONS: Map = mapOf( + // 教学与班级 + "school" to Icons.Rounded.School, + "class" to Icons.Rounded.Class, + "assignment" to Icons.AutoMirrored.Rounded.Assignment, + "grading" to Icons.AutoMirrored.Rounded.Grading, + "quiz" to Icons.Rounded.Quiz, + "menubook" to Icons.AutoMirrored.Rounded.MenuBook, + "librarybooks" to Icons.AutoMirrored.Rounded.LibraryBooks, + "castforeducation" to Icons.Rounded.CastForEducation, + "science" to Icons.Rounded.Science, + "translate" to Icons.Rounded.Translate, + + // 通知与消息 + "editnotifications" to Icons.Rounded.EditNotifications, + "notificationsactive" to Icons.Rounded.NotificationsActive, + "notificationsoff" to Icons.Rounded.NotificationsOff, + "notificationspaused" to Icons.Rounded.NotificationsPaused, + "notificationimportant" to Icons.Rounded.NotificationImportant, + "campaign" to Icons.Rounded.Campaign, + "announcement" to Icons.AutoMirrored.Rounded.Announcement, + "chat" to Icons.Rounded.Chat, + "sms" to Icons.Rounded.Sms, + "email" to Icons.Rounded.Email, + "ringvolume" to Icons.Rounded.RingVolume, + + // 显示与投屏 + "cast" to Icons.Rounded.Cast, + "castconnected" to Icons.Rounded.CastConnected, + "presenttoall" to Icons.Rounded.PresentToAll, + "broadcastonhome" to Icons.Rounded.BroadcastOnHome, + "tv" to Icons.Rounded.Tv, + "monitor" to Icons.Rounded.Monitor, + "smartdisplay" to Icons.Rounded.SmartDisplay, + "fullscreen" to Icons.Rounded.Fullscreen, + "screenshare" to Icons.AutoMirrored.Rounded.ScreenShare, + + // 设备与环境 + "powersettingsnew" to Icons.Rounded.PowerSettingsNew, + "settingspower" to Icons.Rounded.SettingsPower, + "bolt" to Icons.Rounded.Bolt, + "lightbulb" to Icons.Rounded.Lightbulb, + "nightlight" to Icons.Rounded.Nightlight, + "thermostat" to Icons.Rounded.Thermostat, + "acunit" to Icons.Rounded.AcUnit, + "sensordoor" to Icons.Rounded.SensorDoor, + "air" to Icons.Rounded.Air, + "batterysaver" to Icons.Rounded.BatterySaver, + "router" to Icons.Rounded.Router, + + // 音视频与媒体 + "volumeup" to Icons.AutoMirrored.Rounded.VolumeUp, + "volumedown" to Icons.AutoMirrored.Rounded.VolumeDown, + "volumeoff" to Icons.AutoMirrored.Rounded.VolumeOff, + "volumemute" to Icons.AutoMirrored.Rounded.VolumeMute, + "mic" to Icons.Rounded.Mic, + "micoff" to Icons.Rounded.MicOff, + "headphones" to Icons.Rounded.Headphones, + "speaker" to Icons.Rounded.Speaker, + "playcircle" to Icons.Rounded.PlayCircle, + "pausecircle" to Icons.Rounded.PauseCircle, + + // 系统与运维 + "settings" to Icons.Rounded.Settings, + "tune" to Icons.Rounded.Tune, + "systemupdate" to Icons.Rounded.SystemUpdate, + "download" to Icons.Rounded.Download, + "upload" to Icons.Rounded.Upload, + "cloudupload" to Icons.Rounded.CloudUpload, + "clouddownload" to Icons.Rounded.CloudDownload, + "sync" to Icons.Rounded.Sync, + "restartalt" to Icons.Rounded.RestartAlt, + "swaphoriz" to Icons.Rounded.SwapHoriz, + "terminal" to Icons.Rounded.Terminal, + "code" to Icons.Rounded.Code, + "save" to Icons.Rounded.Save, + + // 网络与连接 + "wifi" to Icons.Rounded.Wifi, + "wifioff" to Icons.Rounded.WifiOff, + "bluetooth" to Icons.Rounded.Bluetooth, + "link" to Icons.Rounded.Link, + + // 账号与权限 + "person" to Icons.Rounded.Person, + "group" to Icons.Rounded.Group, + "groups" to Icons.Rounded.Groups, + "accountcircle" to Icons.Rounded.AccountCircle, + "adminpanelsettings" to Icons.Rounded.AdminPanelSettings, + "security" to Icons.Rounded.Security, + "shield" to Icons.Rounded.Shield, + "verifieduser" to Icons.Rounded.VerifiedUser, + "login" to Icons.AutoMirrored.Rounded.Login, + "logout" to Icons.AutoMirrored.Rounded.Logout, + + // 状态与提示 + "visibility" to Icons.Rounded.Visibility, + "visibilityoff" to Icons.Rounded.VisibilityOff, + "lock" to Icons.Rounded.Lock, + "lockopen" to Icons.Rounded.LockOpen, + "checkcircle" to Icons.Rounded.CheckCircle, + "cancel" to Icons.Rounded.Cancel, + "close" to Icons.Rounded.Close, + "warning" to Icons.Rounded.Warning, + "error" to Icons.Rounded.Error, + "help" to Icons.AutoMirrored.Rounded.Help, + "celebration" to Icons.Rounded.Celebration, + + // 时间与课表 + "schedule" to Icons.Rounded.Schedule, + "calendarmonth" to Icons.Rounded.CalendarMonth, + "today" to Icons.Rounded.Today, + "eventnote" to Icons.AutoMirrored.Rounded.EventNote, + "alarm" to Icons.Rounded.Alarm, + "timer" to Icons.Rounded.Timer, + + // 场景与其他 + "localhospital" to Icons.Rounded.LocalHospital, + "emergency" to Icons.Rounded.Emergency, + "fitnesscenter" to Icons.Rounded.FitnessCenter, + "restaurant" to Icons.Rounded.Restaurant, + "donotdisturb" to Icons.Rounded.DoNotDisturb, + + // 随机与刷新 + "shuffle" to Icons.Rounded.Shuffle, + "casino" to Icons.Rounded.Casino, + "autorenew" to Icons.Rounded.Autorenew, + "cached" to Icons.Rounded.Cached, +) 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 new file mode 100644 index 0000000..0aec3b1 --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/ui/HomeScreens.kt @@ -0,0 +1,716 @@ +package com.remoteci.mobile.ui + +import android.os.SystemClock +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.CalendarMonth +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.CloudDone +import androidx.compose.material.icons.rounded.CloudOff +import androidx.compose.material.icons.rounded.Computer +import androidx.compose.material.icons.rounded.DirectionsRun +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.Functions +import androidx.compose.material.icons.rounded.Group +import androidx.compose.material.icons.rounded.Help +import androidx.compose.material.icons.rounded.Menu +import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material.icons.rounded.QrCodeScanner +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.SwapHoriz +import androidx.compose.material.icons.rounded.Today +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material3.Card +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Surface +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.Button +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import com.remoteci.mobile.data.ConnectionManager +import com.remoteci.mobile.data.CourseEntry +import com.remoteci.mobile.data.Protocol +import com.remoteci.mobile.data.ScheduleChangeRequest +import com.remoteci.mobile.data.ScheduleDay +import com.remoteci.mobile.data.WatchSettings +import java.time.LocalDate +import java.time.format.TextStyle +import java.util.Locale +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoginScreen( + settings: WatchSettings, + connection: ConnectionManager.State, + snackbar: SnackbarHostState, + onSettings: (WatchSettings) -> Unit, + onLoggedIn: () -> Unit, +) { + var username by remember { mutableStateOf(settings.username) } + var password by remember { mutableStateOf("") } + var server by remember { mutableStateOf(settings.cloudServerUrl) } + val pendingSetup by ConnectionManager.pendingPasswordSetup.collectAsState() + var newPassword by remember { mutableStateOf("") } + var confirmNewPassword by remember { mutableStateOf("") } + var settingUp by remember { mutableStateOf(false) } + val plugins by ConnectionManager.lanPlugins.collectAsState() + val scanStatus by ConnectionManager.lanDiscoveryStatus.collectAsState() + val scanning by ConnectionManager.lanDiscoveryScanning.collectAsState() + val pending by ConnectionManager.lanBootstrapPending.collectAsState() + val scope = rememberCoroutineScope() + val scanner = rememberLauncherForActivityResult(ScanContract()) { result -> + result.contents?.let { server = it; onSettings(settings.copy(cloudServerUrl = it)) } + } + LaunchedEffect(connection, ConnectionManager.currentUser.collectAsState().value) { + if (connection is ConnectionManager.State.CloudConnected || connection is ConnectionManager.State.LanConnected) { + if (ConnectionManager.currentUser.value != null) onLoggedIn() + } + } + Scaffold { padding -> + Column( + Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("登录 RemoteCI", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text("输入或扫描服务器地址,然后使用账号登录。", color = MaterialTheme.colorScheme.onSurfaceVariant) + OutlinedTextField(server, { server = it }, label = { Text("服务器地址") }, modifier = Modifier.fillMaxWidth(), trailingIcon = { + IconButton({ scanner.launch(ScanOptions().setDesiredBarcodeFormats(ScanOptions.QR_CODE).setPrompt("扫描服务器地址")) }) { + Icon(Icons.Rounded.QrCodeScanner, contentDescription = "扫描") + } + }) + OutlinedTextField(username, { username = it }, label = { Text("ID") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField( + password, + { password = it }, + label = { Text("密码") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + if (server.trim().startsWith("http://")) { + Text("当前是明文 HTTP,请确认网络可信。", color = MaterialTheme.colorScheme.error) + } + if (pendingSetup != null) { + // 首登设置密码:批量导入的账号首次登录时在这里补设密码。 + Card { + Column( + Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("首次登录 · 设置密码", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + newPassword, + { newPassword = it }, + label = { Text("新密码") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + OutlinedTextField( + confirmNewPassword, + { confirmNewPassword = it }, + label = { Text("确认新密码") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { + if (newPassword.length < 8) { + scope.launch { snackbar.showSnackbar("密码需为 8-128 个字符") } + } else if (newPassword != confirmNewPassword) { + scope.launch { snackbar.showSnackbar("两次输入的新密码不一致") } + } else { + settingUp = true + scope.launch { + val ok = runCatching { + ConnectionManager.setupInitialPassword(newPassword) + }.getOrElse { false } + settingUp = false + if (ok) { + password = newPassword + newPassword = "" + confirmNewPassword = "" + ConnectionManager.connect(settings.copy(username = pendingSetup!!.username), password) + snackbar.showSnackbar("密码已设置,正在登录") + } else { + snackbar.showSnackbar("设置失败,请重试或联系管理员") + } + } + } + }, + enabled = !settingUp, + ) { Text(if (settingUp) "正在保存…" else "保存密码并登录") } + } + } + } + } + Button( + onClick = { + val next = settings.copy(username = username.trim(), cloudServerUrl = server.trim()) + onSettings(next) + if (username.isBlank() || password.isBlank() || server.isBlank()) { + scope.launch { snackbar.showSnackbar("请填写服务器、ID 和密码") } + } else ConnectionManager.connect(next, password) + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + enabled = connection !is ConnectionManager.State.Connecting, + ) { Text(if (connection is ConnectionManager.State.Connecting) "正在连接…" else "登录") } + OutlinedButton(onClick = { ConnectionManager.scanLanPlugins() }, modifier = Modifier.fillMaxWidth().height(56.dp), enabled = !scanning) { + Text(if (scanning) "正在扫描…" else "扫描局域网插件") + } + scanStatus?.let { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) } + plugins.forEach { plugin -> + FilledTonalButton(onClick = { + scope.launch { + val updated = ConnectionManager.loadLanBootstrap(settings.copy(username = username.trim()), plugin) + if (updated != null) { + onSettings(updated) + server = updated.cloudServerUrl + } + } + }, modifier = Modifier.fillMaxWidth()) { Text("${plugin.instanceName} · ${plugin.host}") } + } + pending?.let { (candidate, updated) -> + Button(onClick = { + ConnectionManager.confirmLanBootstrap()?.let { + onSettings(it) + server = it.cloudServerUrl + } + }, modifier = Modifier.fillMaxWidth()) { Text("确认使用 ${candidate.instanceName} 的云服务器") } + } + if (connection is ConnectionManager.State.Error) { + Text(connection.message, color = MaterialTheme.colorScheme.error) + } + } + } +} + +/** 登录后的班级选择页:多班级账号先选一个进入,进入后仍可在首页顶部随时切换。 */ +@Composable +fun ClassPickerScreen(onPicked: () -> Unit) { + val classes by ConnectionManager.classes.collectAsState() + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + ) { + Text("选择进入的班级", style = MaterialTheme.typography.headlineSmall) + Text( + "该账号拥有多个班级的访问权限,进入后可随时在顶部切换。", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + classes.forEach { classroom -> + Card(onClick = { + ConnectionManager.switchClass(classroom.id) + onPicked() + }) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(classroom.name, style = MaterialTheme.typography.titleMedium) + Text( + listOfNotNull( + classroom.groupName, + classroom.roleName, + if (classroom.visitorEnabled) "访客开放" else null, + ).joinToString(" · ").ifBlank { "班级" }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +/** 首页顶部的班级切换行:仅在账号可访问多个班级时显示。 */ +@Composable +private fun ClassSwitcherRow() { + val classes by ConnectionManager.classes.collectAsState() + val currentClassId by ConnectionManager.currentClassId.collectAsState() + if (classes.size <= 1) return + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + classes.forEach { classroom -> + FilterChip( + selected = classroom.id == currentClassId, + onClick = { ConnectionManager.switchClass(classroom.id) }, + label = { Text(classroom.name) }, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeShell( + tab: HomeTab, + settings: WatchSettings, + snackbar: SnackbarHostState, + onTab: (HomeTab) -> Unit, + onOpen: (Screen) -> Unit, + onPersist: (WatchSettings) -> Unit, +) { + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + bottomBar = { + NavigationBar(containerColor = MaterialTheme.colorScheme.surfaceContainer) { + data class Item(val tab: HomeTab, val label: String, val icon: ImageVector) + listOf( + Item(HomeTab.Today, "今天", Icons.Rounded.Today), + Item(HomeTab.Schedule, "课表", Icons.Rounded.CalendarMonth), + Item(HomeTab.Control, "控制", Icons.Rounded.Tune), + Item(HomeTab.People, "人员", Icons.Rounded.Group), + ).forEach { item -> + NavigationBarItem( + selected = tab == item.tab, + onClick = { onTab(item.tab) }, + icon = { Icon(item.icon, contentDescription = item.label) }, + label = { Text(item.label, style = MaterialTheme.typography.labelMedium) }, + ) + } + } + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + Column { + ClassSwitcherRow() + when (tab) { + HomeTab.Today -> TodayScreen(snackbar, onOpen, onTab) + HomeTab.Schedule -> ScheduleScreen(embedded = true, snackbar = snackbar, onOpen = onOpen, onBack = { onTab(HomeTab.Today) }) + HomeTab.Control -> ControlListScreen(embedded = true, onOpen = onOpen, onBack = { onTab(HomeTab.Today) }) + HomeTab.People -> PeopleScreen(embedded = true, snackbar = snackbar, onOpen = onOpen, onBack = { onTab(HomeTab.Today) }) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TodayScreen(snackbar: SnackbarHostState, onOpen: (Screen) -> Unit, onTab: (HomeTab) -> Unit) { + val snapshot by ConnectionManager.snapshot.collectAsState() + val schedule by ConnectionManager.schedule.collectAsState() + val connection by ConnectionManager.state.collectAsState() + val status by ConnectionManager.schedulePullState.collectAsState() + val admin = remember { mutableStateOf(null) } + var nowTick by remember { mutableLongStateOf(SystemClock.elapsedRealtime()) } + var generatedElapsed by remember { mutableLongStateOf(SystemClock.elapsedRealtime()) } + LaunchedEffect(snapshot?.generatedAt) { generatedElapsed = SystemClock.elapsedRealtime() } + LaunchedEffect(Unit) { while (true) { delay(1000); nowTick = SystemClock.elapsedRealtime() } } + LaunchedEffect(connection) { + admin.value = runCatching { com.remoteci.mobile.data.AdminApi.adminStatus() }.getOrNull() + } + val today = pluginToday(snapshot?.generatedAt, snapshot?.timeZoneOffsetMinutes, generatedElapsed, nowTick) + val now = pluginLocalNow(snapshot?.generatedAt, snapshot?.timeZoneOffsetMinutes, generatedElapsed, nowTick) + val day = schedule?.days?.firstOrNull { it.date == today.toString() } + val home = homeCourseContent(snapshot) + val progress = if (shouldShowStateProgress(snapshot)) lessonProgress(snapshot?.currentTimeLayoutItem, now) else 0f + val period = extractPeriod(home.timeLayoutItem) ?: snapshot?.currentTimeLayoutItem?.substringBefore(' ') ?: "—" + val room = home.timeLayoutItem?.substringAfter(' ', missingDelimiterValue = "")?.ifBlank { snapshot?.classPlanName }.orEmpty() + val timeRange = extractTimeRange(home.timeLayoutItem).ifBlank { "—" } + val stateTitle = when (snapshot?.currentState) { + Protocol.STATE_CLASS -> "上课" + Protocol.STATE_BREAKING -> "课间" + Protocol.STATE_PREPARE_CLASS -> "即将上课" + Protocol.STATE_AFTER_SCHOOL -> "放学" + else -> "待机" + } + val connected = connection is ConnectionManager.State.CloudConnected || connection is ConnectionManager.State.LanConnected + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + TopAppBar( + title = { Text("RemoteCI", style = MaterialTheme.typography.titleLarge) }, + navigationIcon = { IconButton({ onOpen(Screen.Account) }) { Icon(Icons.Rounded.Menu, contentDescription = "菜单") } }, + actions = { IconButton({ onOpen(Screen.Inbox) }) { Icon(Icons.Rounded.Notifications, contentDescription = "通知") } }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface), + ) + Card( + onClick = { + val index = homeQuickSwapLessonIndex(day, snapshot) + onOpen(Screen.Swap(day?.date, index)) + }, + modifier = Modifier.fillMaxWidth().height(168.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp), + ) { + Box(Modifier.fillMaxSize().padding(20.dp)) { + Column { + Text(stateTitle, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onPrimaryContainer) + Text("$period ${room.ifBlank { "" }}".trim(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onPrimaryContainer) + } + Row( + Modifier.align(Alignment.BottomStart).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + ) { + Text( + if (home.isAvailable) home.subject else "暂无课程", + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.weight(1f), + ) + Column(horizontalAlignment = Alignment.End) { + Text(timeRange, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimaryContainer) + Spacer(Modifier.height(8.dp)) + CircularProgressIndicator( + progress = { progress }, + modifier = Modifier.size(36.dp), + strokeWidth = 7.dp, + strokeCap = StrokeCap.Round, + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f), + ) + } + } + } + } + val next = snapshot?.nextClassSubject + ConnectedListCard { + if (shouldShowNextLessonSummary(snapshot?.currentState) && !next.isNullOrBlank()) { + AppListItem( + title = "下一节 · $next", + supporting = listOfNotNull(extractPeriod(snapshot?.nextClassTimeLayoutItem), extractTimeRange(snapshot?.nextClassTimeLayoutItem)).joinToString(" ") + + (snapshot?.nextClassTimeLayoutItem?.substringAfter(' ', "")?.let { " · $it" } ?: ""), + leading = Icons.Rounded.DirectionsRun, + trailing = Icons.Rounded.SwapHoriz, + index = 0, + count = 2, + onClick = { onOpen(Screen.Swap(day?.date, nextQuickSwapLessonIndex(day, snapshot))) }, + ) + } else { + AppListItem("下一节", "目前没有下一节课程", Icons.Rounded.DirectionsRun, index = 0, count = 2) + } + val syncText = when (val currentStatus = status) { + is ConnectionManager.SchedulePullState.Pulling -> currentStatus.message + is ConnectionManager.SchedulePullState.Success -> currentStatus.message + is ConnectionManager.SchedulePullState.Error -> currentStatus.message + else -> snapshot?.generatedAt?.let { "课表已于 ${it.substring(11, 16)} 同步" } ?: "尚未同步课表" + } + val watchText = if ((admin.value?.watchConnections ?: 0) > 0) "手表已连接" else "手表未连接" + AppListItem( + title = if (connected) "RemoteCI 已连接" else "RemoteCI 未连接", + supporting = "$syncText · $watchText", + leading = if (connected) Icons.Rounded.CloudDone else Icons.Rounded.CloudOff, + trailing = if (connected) Icons.Rounded.CheckCircle else null, + index = 1, + count = 2, + ) + } + ConnectedButtons( + firstLabel = "课表", + secondLabel = "控制", + onFirst = { onTab(HomeTab.Schedule) }, + onSecond = { onTab(HomeTab.Control) }, + ) + Spacer(Modifier.height(16.dp)) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScheduleScreen( + embedded: Boolean, + snackbar: SnackbarHostState, + onOpen: (Screen) -> Unit, + onBack: (() -> Unit)?, +) { + val bundle by ConnectionManager.schedule.collectAsState() + val snapshot by ConnectionManager.snapshot.collectAsState() + val pull by ConnectionManager.schedulePullState.collectAsState() + val user by ConnectionManager.currentUser.collectAsState() + val afterSchool = snapshot?.currentState == Protocol.STATE_AFTER_SCHOOL + val days = availableScheduleDays(bundle, afterSchool) + var selected by remember { mutableIntStateOf(0) } + if (selected >= days.size) selected = 0 + val canChange = user?.has(Protocol.PERMISSION_MANAGE_SCHEDULE) == true && ConnectionManager.supports(Protocol.CAP_SCHEDULE_CHANGE) + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { Text("课表", style = MaterialTheme.typography.titleLarge) }, + navigationIcon = { + IconButton(onClick = { onBack?.invoke() ?: onOpen(Screen.Home(HomeTab.Today)) }) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") + } + }, + actions = { + IconButton(onClick = { ConnectionManager.requestSchedulePull() }, enabled = schedulePullActionEnabled(pull)) { + Icon(Icons.Rounded.Refresh, contentDescription = "刷新") + } + }, + ) + if (days.isEmpty()) { + EmptyState("还没有课表", "连接成功后会显示未来七日课程。也可以点击右上角刷新。") + } else { + PrimaryScrollableTabRow(selectedTabIndex = selected.coerceAtMost(days.lastIndex)) { + days.forEachIndexed { index, day -> + val date = parseScheduleDate(day.date) + val label = if (date == null) day.date.takeLast(5) else { + val week = date.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.CHINA) + "$week ${date.dayOfMonth}" + } + Tab(selected = index == selected, onClick = { selected = index }, text = { Text(label, style = MaterialTheme.typography.titleSmall) }) + } + } + val day = days[selected] + val courses = day.courses.filter { it.enabled } + Column(Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (courses.isEmpty()) { + EmptyState("这一天没有课程", "可以拉取最新课表,或选择其他日期。") + } else { + ConnectedListCard { + courses.forEachIndexed { index, course -> + AppListItem( + title = "${course.label} · ${course.subject}", + supporting = listOfNotNull(course.startTime, course.endTime).joinToString("–"), + leading = subjectIcon(course.subject), + trailing = if (canChange) Icons.Rounded.Edit else null, + index = index, + count = courses.size, + onClick = if (canChange) ({ onOpen(Screen.Swap(day.date, course.index)) }) else null, + ) + } + } + } + ConnectedButtons( + firstLabel = "拉取课表", + secondLabel = "换课", + onFirst = { ConnectionManager.requestSchedulePull() }, + onSecond = { onOpen(Screen.Swap(day.date, courses.firstOrNull()?.index)) }, + firstFilled = false, + ) + val pullText = when (val currentPull = pull) { + is ConnectionManager.SchedulePullState.Pulling -> currentPull.message + is ConnectionManager.SchedulePullState.Success -> currentPull.message + is ConnectionManager.SchedulePullState.Error -> currentPull.message + else -> null + } + pullText?.let { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SwapScreen(date: String?, index: Int?, onBack: () -> Unit, snackbar: SnackbarHostState, onOpen: (Screen) -> Unit = {}) { + val bundle by ConnectionManager.schedule.collectAsState() + val snapshot by ConnectionManager.snapshot.collectAsState() + val user by ConnectionManager.currentUser.collectAsState() + val afterSchool = snapshot?.currentState == Protocol.STATE_AFTER_SCHOOL + val days = availableScheduleDays(bundle, afterSchool) + var selectedDate by remember { mutableStateOf(date ?: days.firstOrNull()?.date) } + val day = days.firstOrNull { it.date == selectedDate } ?: days.firstOrNull() + val courses = day?.courses.orEmpty().filter { it.enabled } + 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 replacementId by remember { mutableStateOf(bundle?.subjects?.firstOrNull()?.id.orEmpty()) } + val source = courses.firstOrNull { it.index == sourceIndex } + val target = courses.firstOrNull { it.index == targetIndex } + val canChange = user?.has(Protocol.PERMISSION_MANAGE_SCHEDULE) == true + val scope = rememberCoroutineScope() + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + TopAppBar( + title = { Text("快速换课", style = MaterialTheme.typography.titleLarge) }, + navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }, + actions = { IconButton({ onOpen(Screen.Help) }) { Icon(Icons.Rounded.Help, contentDescription = "说明") } }, + ) + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Card( + modifier = Modifier.fillMaxWidth().height(136.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp), + ) { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("当前选择 · ${source?.label ?: "未选择"} ${source?.subject ?: ""}", style = MaterialTheme.typography.titleMedium) + Text( + listOfNotNull(source?.startTime, source?.endTime).joinToString("–").ifBlank { "请选择一节课" } + + "\n从首页进入时会预选当前课或下一节课。", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + if (!canChange) { + PermissionHint("当前账号没有换课权限。") + } else if (day == null) { + EmptyState("没有可换课的日期", "请先同步课表。") + } else { + Row(horizontalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = { exchange = true }, + modifier = Modifier.weight(1f).height(56.dp), + shape = connectedButtonShape(0, 2), + ) { Text(if (exchange) "交换课程" else "改为交换") } + OutlinedButton( + onClick = { exchange = false }, + modifier = Modifier.weight(1f).height(56.dp), + shape = connectedButtonShape(1, 2), + ) { Text("目标 · ${target?.label ?: "未选"} ${target?.subject ?: ""}") } + } + var showTarget by remember { mutableStateOf(false) } + var showSource by remember { mutableStateOf(false) } + var showSubject by remember { mutableStateOf(false) } + OutlinedButton(onClick = { showSource = true }, modifier = Modifier.fillMaxWidth()) { + Text("源课程:${source?.label ?: "未选"} ${source?.subject ?: ""}") + } + if (exchange) { + OutlinedButton(onClick = { showTarget = true }, modifier = Modifier.fillMaxWidth()) { + Text("目标课程:${target?.label ?: "未选"} ${target?.subject ?: ""}") + } + } else { + OutlinedButton(onClick = { showSubject = true }, modifier = Modifier.fillMaxWidth()) { + Text("替换为:${bundle?.subjects?.firstOrNull { it.id == replacementId }?.name ?: "选择科目"}") + } + } + TextButton(onClick = { exchange = !exchange }) { Text(if (exchange) "改为替换课程" else "改为交换课程") } + DropdownMenu(expanded = showSource, onDismissRequest = { showSource = false }) { + courses.forEach { + DropdownMenuItem({ Text("${it.label} ${it.subject}") }, { + sourceIndex = it.index + showSource = false + }) + } + } + DropdownMenu(expanded = showTarget, onDismissRequest = { showTarget = false }) { + courses.filter { it.index != sourceIndex }.forEach { + DropdownMenuItem({ Text("${it.label} ${it.subject}") }, { + targetIndex = it.index + showTarget = false + }) + } + } + DropdownMenu(expanded = showSubject, onDismissRequest = { showSubject = false }) { + bundle?.subjects.orEmpty().forEach { + DropdownMenuItem({ Text(it.name) }, { + replacementId = it.id + showSubject = false + }) + } + } + Button( + onClick = { + val currentDay = day + if (currentDay == null || source == null) { + scope.launch { snackbar.showSnackbar("请选择要调整的课程") } + return@Button + } + if (exchange && (target == null || target.index == source.index)) { + scope.launch { snackbar.showSnackbar("请选择不同的目标节次") } + return@Button + } + if (!exchange && replacementId.isBlank()) { + scope.launch { snackbar.showSnackbar("请选择替换科目") } + return@Button + } + ConnectionManager.sendScheduleChange( + ScheduleChangeRequest( + date = currentDay.date, + mode = if (exchange) Protocol.CHANGE_EXCHANGE else Protocol.CHANGE_REPLACE, + sourceIndex = source.index, + targetIndex = if (exchange) target?.index else null, + replacementSubjectId = if (exchange) null else replacementId, + expectedRevision = currentDay.revision, + ), + ) + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + enabled = canChange && ConnectionManager.supports(Protocol.CAP_SCHEDULE_CHANGE), + ) { Text("确认换课") } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HelpScreen(onBack: () -> Unit) { + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { Text("换课说明") }, + navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }, + ) + Text( + "交换会互换两节课;替换会把源课程改成选定科目。提交前会校验课表修订号,避免覆盖别人刚改过的课表。成功后今日课堂、七日课表和在线手表都会更新。", + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyLarge, + ) + } +} + +@Composable +fun subjectIcon(subject: String): ImageVector { + val name = subject + return when { + "体育" in name || "跑" in name -> Icons.Rounded.DirectionsRun + "信息" in name || "计算机" in name || "编程" in name -> Icons.Rounded.Computer + "数学" in name -> Icons.Rounded.Functions + else -> Icons.Rounded.CalendarMonth + } +} + +fun schedulePullActionEnabled(state: ConnectionManager.SchedulePullState): Boolean = + state !is ConnectionManager.SchedulePullState.Pulling diff --git a/android/app/src/main/java/com/remoteci/mobile/ui/PeopleScreens.kt b/android/app/src/main/java/com/remoteci/mobile/ui/PeopleScreens.kt new file mode 100644 index 0000000..631f6b7 --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/ui/PeopleScreens.kt @@ -0,0 +1,477 @@ +package com.remoteci.mobile.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.AdminPanelSettings +import androidx.compose.material.icons.rounded.ArrowDropDown +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Group +import androidx.compose.material.icons.rounded.Key +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.PersonAdd +import androidx.compose.material.icons.rounded.PersonOutline +import androidx.compose.material.icons.rounded.Smartphone +import androidx.compose.material.icons.rounded.Watch +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.remoteci.mobile.data.AccountRoleMutation +import com.remoteci.mobile.data.AdminApi +import com.remoteci.mobile.data.CreateUserRequest +import com.remoteci.mobile.data.Protocol +import com.remoteci.mobile.data.UpdateUserRequest +import com.remoteci.mobile.data.VisitorAccessState +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PeopleScreen(embedded: Boolean, snackbar: androidx.compose.material3.SnackbarHostState, onOpen: (Screen) -> Unit, onBack: (() -> Unit)?) { + val user by ConnectionManagerUser() + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { Text("人员与凭证", style = MaterialTheme.typography.titleLarge) }, + navigationIcon = { IconButton(onClick = { onBack?.invoke() }) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }, + actions = { + if (user?.has(Protocol.PERMISSION_MANAGE_USERS) == true) { + IconButton({ onOpen(Screen.Users) }) { Icon(Icons.Rounded.PersonAdd, contentDescription = "添加账号") } + } + }, + ) + if (user?.has(Protocol.PERMISSION_MANAGE_USERS) != true) { + EmptyState("无权管理人员", "人员、角色和凭证由管理员或拥有人员管理权限的账号处理。") + } else { + val items = listOf( + Triple("访客访问", "访客课表与自动进入设置", Icons.Rounded.PersonOutline), + Triple("角色配置", "创建自定义角色,设置课表、通知、语音、控制与扩展权限", Icons.Rounded.AdminPanelSettings), + Triple("账号管理", "新建、编辑、启停账号,分配角色与个人附加权限", Icons.Rounded.Group), + Triple("密码与设备会话", "重置密码时可撤销所有手表登录", Icons.Rounded.Key), + Triple("插件配对码", "添加、查看和撤销 ClassIsland 插件长期凭证", Icons.Rounded.Link), + ) + ConnectedListCard(Modifier.padding(16.dp)) { + items.forEachIndexed { index, (title, supporting, icon) -> + AppListItem( + title = title, + supporting = supporting, + leading = icon, + trailing = Icons.Rounded.ChevronRight, + index = index, + count = items.size, + onClick = { + when (index) { + 0 -> onOpen(Screen.VisitorAccess) + 1 -> onOpen(Screen.Roles) + 2 -> onOpen(Screen.Users) + 3 -> onOpen(Screen.Sessions) + 4 -> onOpen(Screen.Pairing) + } + }, + ) + } + } + } + } +} + +@Composable +private fun ConnectionManagerUser() = com.remoteci.mobile.data.ConnectionManager.currentUser.collectAsState() + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VisitorAccessScreen(onBack: () -> Unit, snackbar: androidx.compose.material3.SnackbarHostState) { + val scope = rememberCoroutineScope() + var visitor by remember { mutableStateOf(null) } + var loadError by remember { mutableStateOf(null) } + var saving by remember { mutableStateOf(false) } + + fun load() { + scope.launch { + loadError = null + runCatching { AdminApi.visitor() } + .onSuccess { visitor = it } + .onFailure { loadError = it.message ?: "读取访客设置失败" } + } + } + + fun save(next: VisitorAccessState) { + if (saving) return + scope.launch { + saving = true + runCatching { AdminApi.setVisitor(next) } + .onSuccess { + visitor = it + snackbar.showSnackbar("访客设置已保存") + } + .onFailure { snackbar.showSnackbar(it.message ?: "保存访客设置失败") } + saving = false + } + } + + LaunchedEffect(Unit) { load() } + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { Text("访客访问") }, + navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }, + ) + val state = visitor + when { + state != null -> ConnectedListCard(Modifier.padding(16.dp)) { + AppListItem( + title = "启用访客访问", + supporting = "允许未登录用户只读查看访客课表", + leading = Icons.Rounded.PersonOutline, + switchChecked = state.enabled, + onSwitch = { save(state.copy(enabled = it)) }, + index = 0, + count = 2, + ) + AppListItem( + title = "自动进入访客页", + supporting = "仅在访客访问已启用时生效", + leading = Icons.Rounded.PersonOutline, + switchChecked = state.autoEnter, + onSwitch = { save(state.copy(autoEnter = it)) }, + index = 1, + count = 2, + ) + } + loadError != null -> EmptyState("无法读取访客设置", loadError ?: "请稍后重试") + else -> EmptyState("正在读取访客设置", "请稍候…") + } + if (loadError != null) { + TextButton(onClick = ::load, modifier = Modifier.padding(horizontal = 16.dp)) { Text("重试") } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RolesScreen(onBack: () -> Unit, snackbar: androidx.compose.material3.SnackbarHostState) { + val scope = rememberCoroutineScope() + var roles by remember { mutableStateOf(listOf()) } + var name by remember { mutableStateOf("") } + var grants by remember { mutableIntStateOf(0) } + LaunchedEffect(Unit) { roles = runCatching { AdminApi.roles() }.getOrDefault(emptyList()) } + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + TopAppBar(title = { Text("角色配置") }, navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }) + if (roles.isEmpty()) EmptyState("没有角色", "管理员可以创建自定义角色。") + ConnectedListCard(Modifier.padding(16.dp)) { + roles.forEachIndexed { index, role -> + AppListItem( + title = role.name, + supporting = "${role.kind} · ${role.userCount} 个账号", + leading = Icons.Rounded.AdminPanelSettings, + index = index, + count = roles.size, + onClick = { + name = role.name + grants = role.defaultPermissions + }, + ) + } + } + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(name, { name = it }, label = { Text("角色名称") }, modifier = Modifier.fillMaxWidth()) + PermissionEditor(grants) { grants = it } + Button(onClick = { + scope.launch { + runCatching { AdminApi.createRole(AccountRoleMutation(name, grants)) } + .onSuccess { roles = AdminApi.roles(); snackbar.showSnackbar("角色已保存") } + .onFailure { snackbar.showSnackbar(it.message ?: "保存失败") } + } + }, modifier = Modifier.fillMaxWidth().height(56.dp), enabled = name.isNotBlank()) { Text("新建角色") } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UsersScreen(onBack: () -> Unit, snackbar: androidx.compose.material3.SnackbarHostState) { + val scope = rememberCoroutineScope() + val currentUser by ConnectionManagerUser() + var users by remember { mutableStateOf(listOf()) } + var roles by remember { mutableStateOf(listOf()) } + var username by remember { mutableStateOf("") } + var displayName by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var grants by remember { mutableIntStateOf(0) } + var selectedRoleId by remember { mutableStateOf(null) } + var roleMenuExpanded by remember { mutableStateOf(false) } + var editing by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + runCatching { AdminApi.users() } + .onSuccess { users = it } + .onFailure { snackbar.showSnackbar(it.message ?: "读取账号失败") } + runCatching { AdminApi.roles() } + .onSuccess { roles = it } + .onFailure { snackbar.showSnackbar(it.message ?: "读取角色失败") } + selectedRoleId = roles.firstOrNull { it.kind == "Student" }?.id + ?: roles.firstOrNull { it.kind != "Administrator" }?.id + } + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + TopAppBar(title = { Text("账号管理") }, navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }) + if (users.isEmpty()) EmptyState("还没有账号", "创建后可分配角色和个人权限。") + ConnectedListCard(Modifier.padding(16.dp)) { + users.forEachIndexed { index, item -> + AppListItem( + title = "${item.displayName} · ${item.roleName ?: if (item.role == Protocol.ROLE_ADMIN) "管理员" else "用户"}", + supporting = item.username + if (item.enabled) "" else " · 已停用", + leading = Icons.Rounded.Group, + index = index, + count = users.size, + onClick = { editing = item; displayName = item.displayName; grants = item.grantedPermissions }, + ) + } + } + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(username, { username = it }, label = { Text("ID") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(displayName, { displayName = it }, label = { Text("显示名") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(password, { password = it }, label = { Text("密码") }, modifier = Modifier.fillMaxWidth()) + Text("角色", style = MaterialTheme.typography.titleSmall) + val selectableRoles = roles.filter { currentUser?.isAdmin == true || it.kind != "Administrator" } + Box { + OutlinedButton( + onClick = { roleMenuExpanded = true }, + modifier = Modifier.fillMaxWidth().height(56.dp), + enabled = selectableRoles.isNotEmpty(), + ) { + Text( + selectableRoles.firstOrNull { it.id == selectedRoleId }?.name ?: "选择账号角色", + modifier = Modifier.weight(1f), + ) + Icon(Icons.Rounded.ArrowDropDown, contentDescription = null) + } + DropdownMenu( + expanded = roleMenuExpanded, + onDismissRequest = { roleMenuExpanded = false }, + modifier = Modifier.fillMaxWidth(.9f), + ) { + selectableRoles.forEach { role -> + DropdownMenuItem( + text = { Text(role.name) }, + onClick = { + selectedRoleId = role.id + roleMenuExpanded = false + }, + ) + } + } + } + if (selectableRoles.isEmpty()) Text("没有可用角色,请先检查连接或在角色配置中创建角色。", color = MaterialTheme.colorScheme.error) + PermissionEditor(grants) { grants = it } + Button(onClick = { + scope.launch { + runCatching { + val selectedRole = roles.firstOrNull { it.id == selectedRoleId } + ?: error("请选择账号角色") + AdminApi.createUser( + CreateUserRequest( + username.trim(), + displayName.trim(), + password, + if (selectedRole.kind == "Administrator") Protocol.ROLE_ADMIN else Protocol.ROLE_USER, + selectedRole.id, + grants, + ), + ) + }.onSuccess { + users = AdminApi.users() + username = "" + displayName = "" + password = "" + grants = 0 + snackbar.showSnackbar("账号已创建") + } + .onFailure { snackbar.showSnackbar(it.message ?: "创建失败") } + } + }, modifier = Modifier.fillMaxWidth().height(56.dp), enabled = selectedRoleId != null && username.length >= 3 && displayName.isNotBlank() && password.length >= 8) { Text("新建账号") } + } + } + editing?.let { item -> + AlertDialog( + onDismissRequest = { editing = null }, + title = { Text("编辑 ${item.displayName}") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(displayName, { displayName = it }, label = { Text("显示名") }) + PermissionEditor(grants) { grants = it } + } + }, + confirmButton = { + TextButton({ + scope.launch { + runCatching { + AdminApi.updateUser(item.id, UpdateUserRequest(displayName, item.role, item.roleId, grants, item.enabled)) + }.onSuccess { users = AdminApi.users(); editing = null } + .onFailure { snackbar.showSnackbar(it.message ?: "更新失败") } + } + }) { Text("保存") } + }, + dismissButton = { TextButton({ editing = null }) { Text("取消") } }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SessionsScreen(onBack: () -> Unit, snackbar: androidx.compose.material3.SnackbarHostState) { + val scope = rememberCoroutineScope() + var sessions by remember { mutableStateOf(listOf()) } + var current by remember { mutableStateOf("") } + var next by remember { mutableStateOf("") } + LaunchedEffect(Unit) { sessions = runCatching { AdminApi.sessions() }.getOrDefault(emptyList()) } + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + TopAppBar(title = { Text("密码与设备") }, navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }) + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(current, { current = it }, label = { Text("当前密码") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(next, { next = it }, label = { Text("新密码") }, modifier = Modifier.fillMaxWidth()) + Button(onClick = { + scope.launch { + runCatching { AdminApi.changePassword(current, next) } + .onSuccess { snackbar.showSnackbar("密码已更新,其他设备登录将失效") } + .onFailure { snackbar.showSnackbar(it.message ?: "修改失败") } + } + }, modifier = Modifier.fillMaxWidth().height(56.dp), enabled = current.isNotBlank() && next.length >= 8) { Text("修改密码") } + } + if (sessions.isEmpty()) EmptyState("没有其他登录设备", "当前账号的设备会话会显示在这里。") + ConnectedListCard(Modifier.padding(16.dp)) { + sessions.forEachIndexed { index, item -> + AppListItem( + title = item.deviceName.ifBlank { "未命名设备" } + if (item.current) " · 本机" else "", + supporting = "最近使用 ${item.lastSeenAt ?: "未知"}", + leading = if (item.current || item.deviceName.contains("Android", ignoreCase = true)) Icons.Rounded.Smartphone else Icons.Rounded.Watch, + trailingText = "吊销", + index = index, + count = sessions.size, + onClick = { + scope.launch { + runCatching { AdminApi.revokeSession(item.id) } + sessions = runCatching { AdminApi.sessions() }.getOrDefault(sessions) + } + }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PairingScreen(onBack: () -> Unit, snackbar: androidx.compose.material3.SnackbarHostState) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + var credentials by remember { mutableStateOf(listOf()) } + var code by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { credentials = runCatching { AdminApi.pluginCredentials() }.getOrDefault(emptyList()) } + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + TopAppBar(title = { Text("插件配对码") }, navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }) + Button(onClick = { + scope.launch { + code = runCatching { AdminApi.pairingCode().pairCode }.getOrElse { + snackbar.showSnackbar(it.message ?: "无法生成配对码"); null + } + } + }, modifier = Modifier.padding(16.dp).fillMaxWidth().height(56.dp)) { Text("生成配对码") } + code?.let { value -> + Row( + modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SelectionContainer(Modifier.weight(1f)) { + Text(value, style = MaterialTheme.typography.headlineSmall) + } + FilledTonalButton(onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("RemoteCI 插件配对码", value)) + scope.launch { snackbar.showSnackbar("配对码已复制") } + }) { + Icon(Icons.Rounded.ContentCopy, contentDescription = null) + Text("复制") + } + } + Text("配对码只能使用一次,请立即粘贴到 ClassIsland 插件。", modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) + } + if (credentials.isEmpty()) EmptyState("没有插件凭证", "生成配对码并在 ClassIsland 插件中使用后,会出现在这里。") + ConnectedListCard(Modifier.padding(16.dp)) { + credentials.forEachIndexed { index, item -> + AppListItem( + title = item.name.ifBlank { "插件凭证" }, + supporting = "最近 ${item.lastSeenAt ?: "尚未连接"}", + leading = Icons.Rounded.Link, + trailingText = "撤销", + index = index, + count = credentials.size, + onClick = { + scope.launch { + runCatching { AdminApi.revokePluginCredential(item.id) } + credentials = runCatching { AdminApi.pluginCredentials() }.getOrDefault(credentials) + } + }, + ) + } + } + } +} + +@Composable +fun PermissionEditor(value: Int, onChange: (Int) -> Unit) { + val flags = listOf( + Protocol.PERMISSION_ACCESS_WEB_UI to "概览", + Protocol.PERMISSION_MANAGE_USERS to "人员管理", + Protocol.PERMISSION_SEND_NOTIFICATIONS to "通知", + Protocol.PERMISSION_SEND_VOICE_MESSAGES to "语音", + Protocol.PERMISSION_TEACHER_COMING to "老师来了", + Protocol.PERMISSION_MANAGE_SCHEDULE to "换课", + Protocol.PERMISSION_RUN_EXTENSIONS to "扩展", + Protocol.PERMISSION_MAIN_MENU_CONTROL to "主界面", + Protocol.PERMISSION_POWER_CONTROL to "电源", + ) + Column { + flags.forEach { (flag, label) -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label) + Switch(checked = value and flag == flag, onCheckedChange = { on -> + onChange(if (on) value or flag else value and flag.inv()) + }) + } + } + } +} diff --git a/android/app/src/main/java/com/remoteci/mobile/ui/RemoteCiApp.kt b/android/app/src/main/java/com/remoteci/mobile/ui/RemoteCiApp.kt new file mode 100644 index 0000000..54dbd78 --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/ui/RemoteCiApp.kt @@ -0,0 +1,191 @@ +package com.remoteci.mobile.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.remoteci.mobile.data.ConnectionManager +import com.remoteci.mobile.data.EventHistory +import com.remoteci.mobile.data.SettingsStore +import com.remoteci.mobile.data.WatchSettings +import com.remoteci.mobile.notif.NotificationHelper +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +enum class HomeTab { Today, Schedule, Control, People } + +sealed interface Screen { + data object Login : Screen + data class Home(val tab: HomeTab = HomeTab.Today) : Screen + data object ClassPicker : Screen + data class Swap(val date: String?, val index: Int?) : Screen + data object Account : Screen + data object Inbox : Screen + data object Notify : Screen + data object Voice : Screen + data object Volume : Screen + data object Power : Screen + data object MainMenu : Screen + data object Extensions : Screen + data object VisitorAccess : Screen + data object Roles : Screen + data object Users : Screen + data object Sessions : Screen + data object Pairing : Screen + data object Connection : Screen + data object NotificationSettings : Screen + data object ScheduleSettings : Screen + data object Appearance : Screen + data object Updates : Screen + data object Developer : Screen + data object System : Screen + data object Help : Screen + data object Replace : Screen +} + +@Composable +fun RemoteCiApp(appContext: android.content.Context) { + val store = remember { SettingsStore(appContext) } + var settings by remember { mutableStateOf(store.load()) } + val snackbar = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val connection by ConnectionManager.state.collectAsState() + val user by ConnectionManager.currentUser.collectAsState() + var stack by remember { mutableStateOf(listOf(if (ConnectionManager.hasSavedSession()) Screen.Home() else Screen.Login)) } + val current = stack.last() + val history = remember { EventHistory(appContext) } + + DisposableEffect(Unit) { + ConnectionManager.initialize(appContext) + if (ConnectionManager.hasSavedSession()) ConnectionManager.connect(settings) + onDispose { } + } + + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_START && ConnectionManager.hasSavedSession()) { + ConnectionManager.connect(settings) + } + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + + LaunchedEffect(Unit) { + ConnectionManager.events.collectLatest { event -> + NotificationHelper.handle(appContext, event, settings, history) + } + } + LaunchedEffect(Unit) { + ConnectionManager.lastCommandResult.collectLatest { result -> + result ?: return@collectLatest + snackbar.showSnackbar(if (result.success) "已完成:${result.message.ifBlank { "成功" }}" else "失败:${result.message.ifBlank { result.code }}") + } + } + LaunchedEffect(Unit) { + ConnectionManager.discoveredSettings.collectLatest { + settings = it + store.save(it) + } + } + + fun persist(next: WatchSettings) { + settings = next + store.save(next) + } + + fun push(screen: Screen) { stack = stack + screen } + fun pop() { if (stack.size > 1) stack = stack.dropLast(1) } + fun goHome(tab: HomeTab) { stack = listOf(Screen.Home(tab)) } + + val appearance = when (settings.appearanceMode) { + "light" -> AppearanceMode.Light + "dark" -> AppearanceMode.Dark + else -> AppearanceMode.System + } + + RemoteCiTheme(appearance, MobilePalette.fromId(settings.themeId)) { + Box(Modifier.fillMaxSize().imePadding()) { + AnimatedContent( + targetState = current, + modifier = Modifier.fillMaxSize(), + // 四个首页板块共用同一个 HomeShell;切换底栏只替换正文,不让底栏跟着横向滑动。 + contentKey = ::screenTransitionKey, + transitionSpec = { + (fadeIn(tween(180)) + slideInHorizontally(tween(220)) { it / 12 }) togetherWith + (fadeOut(tween(140)) + slideOutHorizontally(tween(180)) { -it / 16 }) + }, + label = "screen-transition", + ) { screen -> + when (screen) { + Screen.Login -> LoginScreen( + settings = settings, + connection = connection, + snackbar = snackbar, + onSettings = { persist(it) }, + onLoggedIn = { + // 多班级账号登录后先选择进入的班级,单班级账号直接进首页。 + if (ConnectionManager.classes.value.size > 1) push(Screen.ClassPicker) + else goHome(HomeTab.Today) + }, + ) + Screen.ClassPicker -> ClassPickerScreen( + onPicked = { goHome(HomeTab.Today) }, + ) + is Screen.Home -> HomeShell( + tab = screen.tab, + settings = settings, + snackbar = snackbar, + onTab = { tab -> stack = listOf(Screen.Home(tab)) }, + onOpen = ::push, + onPersist = ::persist, + ) + else -> SecondaryHost( + screen = screen, + settings = settings, + snackbar = snackbar, + onBack = ::pop, + onOpen = ::push, + onHome = ::goHome, + onPersist = ::persist, + onLoggedOut = { stack = listOf(Screen.Login) }, + ) + } + } + SnackbarHost( + hostState = snackbar, + modifier = Modifier.align(Alignment.BottomCenter) + .padding(horizontal = 16.dp, vertical = if (current is Screen.Home) 84.dp else 16.dp), + ) + } + } +} + +/** 首页底栏属于固定应用框架,四个板块必须共享动画 key,仅二级页面导航参与整页转场。 */ +internal fun screenTransitionKey(screen: Screen): Any = if (screen is Screen.Home) "home" else screen diff --git a/android/app/src/main/java/com/remoteci/mobile/ui/ScheduleLogic.kt b/android/app/src/main/java/com/remoteci/mobile/ui/ScheduleLogic.kt new file mode 100644 index 0000000..b1ddc92 --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/ui/ScheduleLogic.kt @@ -0,0 +1,197 @@ +package com.remoteci.mobile.ui + +import com.remoteci.mobile.data.ClassStateSnapshot +import com.remoteci.mobile.data.CourseEntry +import com.remoteci.mobile.data.Protocol +import com.remoteci.mobile.data.ScheduleBundle +import com.remoteci.mobile.data.ScheduleDay +import java.time.Duration +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.OffsetDateTime +import java.time.ZoneOffset + +data class LessonChoice( + val id: String, + val index: Int, + val periodLabel: String, + val subject: String, + val time: String, + val commandValue: String, + val enabled: Boolean, +) + +internal fun availableScheduleDays( + bundle: ScheduleBundle?, + afterSchool: Boolean, + today: LocalDate = LocalDate.now(), +): List { + val firstVisibleDate = if (afterSchool) today.plusDays(1) else today + return bundle?.days.orEmpty() + .filter { day -> parseScheduleDate(day.date)?.isBefore(firstVisibleDate) != true } + .sortedBy { day -> parseScheduleDate(day.date) ?: LocalDate.MAX } +} + +internal fun initialScheduleDate( + bundle: ScheduleBundle?, + afterSchool: Boolean, + today: LocalDate = LocalDate.now(), +): String? = availableScheduleDays(bundle, afterSchool, today).firstOrNull()?.date + +internal fun scheduleDateTitle(date: String, today: LocalDate = LocalDate.now()): String { + val suffix = when (parseScheduleDate(date)) { + today -> "-今天" + today.plusDays(1) -> "-明天" + else -> "" + } + return "${date.takeLast(5)}$suffix" +} + +internal fun parseScheduleDate(date: String): LocalDate? = runCatching { LocalDate.parse(date) }.getOrNull() + +internal fun buildLessonChoices(day: ScheduleDay?): List = day?.courses?.map { it.toChoice() }.orEmpty() + +/** 保留当前课程映射测试,产品换课路径使用上面的 ScheduleDay 重载。 */ +internal fun buildLessonChoices(snapshot: ClassStateSnapshot?): List = listOf( + LessonChoice("current", 0, extractPeriod(snapshot?.currentTimeLayoutItem) ?: "当前课", snapshot?.currentSubject ?: "未加载课表", extractTimeRange(snapshot?.currentTimeLayoutItem), extractPeriod(snapshot?.currentTimeLayoutItem) ?: "当前课", !snapshot?.currentSubject.isNullOrBlank()), + LessonChoice("next", 1, extractPeriod(snapshot?.nextClassTimeLayoutItem) ?: "下一节", snapshot?.nextClassSubject ?: "暂无下一节", extractTimeRange(snapshot?.nextClassTimeLayoutItem), extractPeriod(snapshot?.nextClassTimeLayoutItem) ?: "下一节", !snapshot?.nextClassSubject.isNullOrBlank()), +) + +internal fun CourseEntry.toChoice() = LessonChoice( + id = index.toString(), index = index, periodLabel = label, subject = subject, + time = listOfNotNull(startTime, endTime).joinToString("-"), commandValue = index.toString(), enabled = enabled, +) + +internal val TimeRangeRegex = Regex("(\\d{1,2}:\\d{2})\\s*[-–—~至]\\s*(\\d{1,2}:\\d{2})") +internal val LessonPeriodRegex = Regex("第[一二三四五六七八九十\\d]+节") +internal fun extractPeriod(value: String?): String? = value?.let(LessonPeriodRegex::find)?.value +internal fun extractTimeRange(value: String?): String { + val match = value?.let(TimeRangeRegex::find) ?: return "" + return "${match.groupValues[1]}-${match.groupValues[2]}" +} + +/** + * 把当前时间段(如 "16:30-17:10 语文")匹配到当天课表的课程零基索引, + * 供主界面课程按钮“点击快速选中该课换课”预选源课使用。 + * 匹配不到(课表未同步、当前无课或时间段不一致)时返回 null。 + */ +internal fun currentLessonIndex(day: ScheduleDay?, value: String?): Int? { + val range = extractTimeRange(value) + if (range.isBlank() || day == null) return null + val parts = range.split("-") + if (parts.size != 2) return null + val (start, end) = parts + return day.courses.firstOrNull { it.enabled && it.startTime == start && it.endTime == end }?.index +} + +internal data class HomeCourseContent( + val subject: String, + val timeLayoutItem: String?, + val targetsNextLesson: Boolean, + val isAvailable: Boolean, +) + +/** + * 决定主页主课程按钮代表当前课还是下一节课。 + * 下课/即将上课阶段没有正在进行的课程,因此按钮直接承载下一节课及其快速换课入口。 + */ +internal fun homeCourseContent(snapshot: ClassStateSnapshot?): HomeCourseContent { + val targetsNextLesson = snapshot?.currentState in setOf( + Protocol.STATE_BREAKING, + Protocol.STATE_PREPARE_CLASS, + ) + val subject = if (targetsNextLesson) snapshot?.nextClassSubject else snapshot?.currentSubject + val availableSubject = subject?.trim()?.takeIf(String::isNotEmpty) + return if (targetsNextLesson) { + HomeCourseContent( + subject = availableSubject ?: "暂无下一节", + timeLayoutItem = snapshot?.nextClassTimeLayoutItem, + targetsNextLesson = true, + isAvailable = availableSubject != null, + ) + } else { + HomeCourseContent( + subject = availableSubject ?: "暂无课程", + timeLayoutItem = snapshot?.currentTimeLayoutItem, + targetsNextLesson = false, + isAvailable = availableSubject != null, + ) + } +} + +/** 主按钮已经显示下一节课时,不再在页面底部重复显示“下一节课是”。 */ +internal fun shouldShowNextLessonSummary(currentState: Int?): Boolean = + currentState !in setOf(Protocol.STATE_BREAKING, Protocol.STATE_PREPARE_CLASS) + +/** 快速换课预选项必须与主页按钮当前显示的课程一致。 */ +internal fun homeQuickSwapLessonIndex(day: ScheduleDay?, snapshot: ClassStateSnapshot?): Int? = + currentLessonIndex(day, homeCourseContent(snapshot).timeLayoutItem) + +/** “暂无课程”状态下的下一节课入口始终按下一时间段匹配,不能回退到空的当前时间段。 */ +internal fun nextQuickSwapLessonIndex(day: ScheduleDay?, snapshot: ClassStateSnapshot?): Int? = + currentLessonIndex(day, snapshot?.nextClassTimeLayoutItem) + +/** 仅在截图所示的“当前无课、但有下一节课”状态展示下一节课描边操作入口。 */ +internal fun shouldHighlightNextLessonAction(snapshot: ClassStateSnapshot?): Boolean = + snapshot?.currentState == Protocol.STATE_NONE && + !homeCourseContent(snapshot).isAvailable && + !snapshot.nextClassSubject.isNullOrBlank() + +/** 只有具有明确起止时间的课程阶段才显示环状进度,避免放学等开放状态产生伪进度。 */ +internal fun shouldShowStateProgress(snapshot: ClassStateSnapshot?): Boolean = + snapshot?.currentState in setOf( + Protocol.STATE_CLASS, + Protocol.STATE_PREPARE_CLASS, + Protocol.STATE_BREAKING, + ) && extractTimeRange(snapshot?.currentTimeLayoutItem).isNotBlank() + +internal fun lessonProgress(value: String?, now: LocalTime): Float { + val match = value?.let(TimeRangeRegex::find) ?: return 0f + val start = runCatching { LocalTime.parse(match.groupValues[1]) }.getOrNull() ?: return 0f + val end = runCatching { LocalTime.parse(match.groupValues[2]) }.getOrNull() ?: return 0f + val total = Duration.between(start, end).seconds + return if (total <= 0) 0f else (Duration.between(start, now).seconds.toFloat() / total).coerceIn(0f, 1f) +} + +/** + * 根据插件快照推算“插件本地当前时间”: + * 以快照的 UTC 生成时间为基准,加上插件时区偏移和本机经过的真实时间, + * 即使手表时区与插件不一致,课程进度也能按插件时间轴正确计算。 + * + * @param generatedAt 快照的 UTC 生成时间(ISO-8601,如 "2026-08-12T08:46:55+00:00")。 + * @param offsetMinutes 插件本地时区相对 UTC 的偏移分钟数;为 null 表示旧版插件无偏移信息。 + * @param baseElapsedMs 收到当前快照时本机 elapsedRealtime 毫秒数。 + * @param nowElapsedMs 当前本机 elapsedRealtime 毫秒数。 + */ +internal fun pluginLocalNow( + generatedAt: String?, + offsetMinutes: Int?, + baseElapsedMs: Long, + nowElapsedMs: Long, +): LocalTime { + val offset = offsetMinutes?.let { runCatching { ZoneOffset.ofTotalSeconds(it * 60) }.getOrNull() } + if (offset == null) return LocalTime.now() + val base = generatedAt?.let { raw -> + runCatching { OffsetDateTime.parse(raw).toInstant().atOffset(offset).toLocalTime() }.getOrNull() + } ?: LocalTime.now(offset) + return base.plusNanos((nowElapsedMs - baseElapsedMs) * 1_000_000L) +} + +/** + * 推算插件侧“今天”的日期,与 [pluginLocalNow] 同源:以快照 UTC 生成时间加插件时区偏移为基准, + * 再按本机流逝时间外推。手表与插件时区不一致时不会取错日期;缺时区信息退回表端日期。 + */ +internal fun pluginToday( + generatedAt: String?, + offsetMinutes: Int?, + baseElapsedMs: Long, + nowElapsedMs: Long, +): LocalDate { + val offset = offsetMinutes?.let { runCatching { ZoneOffset.ofTotalSeconds(it * 60) }.getOrNull() } + if (offset == null) return LocalDate.now() + val base = generatedAt?.let { raw -> + runCatching { OffsetDateTime.parse(raw).toInstant().atOffset(offset).toLocalDateTime() }.getOrNull() + } ?: LocalDateTime.now(offset) + return base.plusNanos((nowElapsedMs - baseElapsedMs) * 1_000_000L).toLocalDate() +} diff --git a/android/app/src/main/java/com/remoteci/mobile/ui/SettingsScreens.kt b/android/app/src/main/java/com/remoteci/mobile/ui/SettingsScreens.kt new file mode 100644 index 0000000..81672a1 --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/ui/SettingsScreens.kt @@ -0,0 +1,498 @@ +package com.remoteci.mobile.ui + +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.Logout +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.Code +import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material.icons.rounded.Palette +import androidx.compose.material.icons.rounded.QrCodeScanner +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material.icons.rounded.SystemUpdate +import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import com.remoteci.mobile.data.AdminApi +import com.remoteci.mobile.data.CompatibleUpdate +import com.remoteci.mobile.data.ConnectionManager +import com.remoteci.mobile.data.Protocol +import com.remoteci.mobile.data.UpdateChannel +import com.remoteci.mobile.data.UpdateManager +import com.remoteci.mobile.data.WatchSettings +import kotlinx.coroutines.launch + +@Composable +fun SecondaryHost( + screen: Screen, + settings: WatchSettings, + snackbar: SnackbarHostState, + onBack: () -> Unit, + onOpen: (Screen) -> Unit, + onHome: (HomeTab) -> Unit, + onPersist: (WatchSettings) -> Unit, + onLoggedOut: () -> Unit, +) { + BackHandler(onBack = onBack) + when (screen) { + is Screen.Swap -> SwapScreen(screen.date, screen.index, onBack, snackbar, onOpen) + Screen.Help -> HelpScreen(onBack) + Screen.Account -> AccountScreen(settings, onBack, onOpen, onPersist, onLoggedOut) + Screen.Inbox -> InboxScreen(onBack) + Screen.Notify -> NotifyScreen(onBack) + Screen.Voice -> VoiceScreen(onBack) + Screen.Volume -> VolumeScreen(onBack) + Screen.Power -> PowerScreen(onBack) + Screen.MainMenu -> MainMenuScreen(onBack) + Screen.Extensions -> ExtensionsScreen(onBack) + Screen.VisitorAccess -> VisitorAccessScreen(onBack, snackbar) + Screen.Roles -> RolesScreen(onBack, snackbar) + Screen.Users -> UsersScreen(onBack, snackbar) + Screen.Sessions -> SessionsScreen(onBack, snackbar) + Screen.Pairing -> PairingScreen(onBack, snackbar) + Screen.Connection -> ConnectionScreen(settings, onBack, onPersist) + Screen.NotificationSettings -> NotificationSettingsScreen(settings, onBack, onPersist) + Screen.ScheduleSettings -> ScheduleSettingsScreen(onBack) + Screen.Appearance -> AppearanceScreen(settings, onBack, onPersist) + Screen.Updates -> UpdatesScreen(settings, onBack, onPersist) + Screen.Developer -> DeveloperScreen(settings, onBack, onPersist) + Screen.System -> SystemScreen(onBack, snackbar) + Screen.Replace -> SwapScreen(null, null, onBack, snackbar, onOpen) + else -> AccountScreen(settings, onBack, onOpen, onPersist, onLoggedOut) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountScreen( + settings: WatchSettings, + onBack: () -> Unit, + onOpen: (Screen) -> Unit, + onPersist: (WatchSettings) -> Unit, + onLoggedOut: () -> Unit, +) { + val user by ConnectionManager.currentUser.collectAsState() + val connection by ConnectionManager.state.collectAsState() + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + TopAppBar( + title = { Text("账号与设置", style = MaterialTheme.typography.titleLarge) }, + navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }, + actions = { + IconButton({ + ConnectionManager.logout(settings) + onLoggedOut() + }) { Icon(Icons.AutoMirrored.Rounded.Logout, contentDescription = "退出登录") } + }, + ) + Card( + modifier = Modifier.padding(16.dp).fillMaxWidth().height(136.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), + shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp), + onClick = { onOpen(Screen.Connection) }, + ) { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + val role = if (user?.isAdmin == true) "管理员" else (user?.username ?: "未登录") + Text("${user?.displayName ?: settings.username} - $role", style = MaterialTheme.typography.titleMedium) + Text( + when (val currentConnection = connection) { + ConnectionManager.State.CloudConnected -> "已连接到 RemoteCI 服务器(云端)" + ConnectionManager.State.LanConnected -> "已连接到 RemoteCI 服务器(局域网)" + ConnectionManager.State.Connecting -> "正在连接…" + is ConnectionManager.State.Error -> currentConnection.message + else -> "点击切换账号或连接其他服务器" + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + val rows = listOf( + Triple("连接与服务器", "账号、云端地址、局域网插件发现与重新连接", Screen.Connection to Icons.Rounded.Wifi), + Triple("通知设置", "课程、自动化和第三方插件提醒的同步开关", Screen.NotificationSettings to Icons.Rounded.Notifications), + Triple("自动拉取课表", "设置在线插件自动刷新课表的周期", Screen.ScheduleSettings to Icons.Rounded.Schedule), + Triple("外观", "主题与显示偏好", Screen.Appearance to Icons.Rounded.Palette), + Triple("更新", "检查更新与同版本强制覆盖", Screen.Updates to Icons.Rounded.SystemUpdate), + Triple("开发者设置", "云端中转、局域网连接开关与重新连接", Screen.Developer to Icons.Rounded.Code), + Triple("系统配置", "服务端更新与备份", Screen.System to Icons.Rounded.Settings), + ) + ConnectedListCard(Modifier.padding(horizontal = 16.dp)) { + rows.forEachIndexed { index, (title, supporting, dest) -> + val (screen, icon) = dest + AppListItem( + title = title, + supporting = supporting, + leading = icon, + trailing = Icons.Rounded.ChevronRight, + index = index, + count = rows.size, + onClick = { onOpen(screen) }, + ) + } + } + } +} + +/** 与 WebUI“自动拉取课表”使用同一个管理 API;旧服务端不支持时保留当前页面并提示原因。 */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScheduleSettingsScreen(onBack: () -> Unit) { + val user by ConnectionManager.currentUser.collectAsState() + val scope = rememberCoroutineScope() + var interval by remember { mutableStateOf(0) } + var status by remember { mutableStateOf("正在读取设置…") } + val choices = listOf(0 to "关闭", 15 to "每 15 分钟", 60 to "每小时", 360 to "每 6 小时", 1440 to "每天") + LaunchedEffect(user) { + if (user != null) { + runCatching { AdminApi.schedulePull() } + .onSuccess { interval = it.intervalMinutes; status = "已读取服务端设置" } + .onFailure { status = it.message ?: "旧版服务端未提供自动拉取设置" } + } + } + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + TopAppBar(title = { Text("自动拉取课表") }, navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }) + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("插件每次连接时都会拉取课表;这里设置在线期间的额外刷新周期。", style = MaterialTheme.typography.bodyMedium) + choices.forEach { (value, label) -> + FilterChip( + selected = interval == value, + onClick = { interval = value }, + label = { Text(label) }, + modifier = Modifier.fillMaxWidth(), + ) + } + Text(status, color = MaterialTheme.colorScheme.onSurfaceVariant) + Button( + onClick = { + scope.launch { + runCatching { AdminApi.setSchedulePull(interval) } + .onSuccess { saved -> status = "已保存:${choices.firstOrNull { it.first == saved.intervalMinutes }?.second ?: "自定义"}" } + .onFailure { status = it.message ?: "保存失败" } + } + }, + enabled = user?.has(Protocol.PERMISSION_MANAGE_SCHEDULE) == true, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { Text("保存设置") } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConnectionScreen(settings: WatchSettings, onBack: () -> Unit, onPersist: (WatchSettings) -> Unit) { + val scope = rememberCoroutineScope() + var server by remember { mutableStateOf(settings.cloudServerUrl) } + var username by remember { mutableStateOf(settings.username) } + val scanStatus by ConnectionManager.lanDiscoveryStatus.collectAsState() + val plugins by ConnectionManager.lanPlugins.collectAsState() + val scanner = rememberLauncherForActivityResult(ScanContract()) { result -> + result.contents?.let { server = it } + } + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + TopAppBar(title = { Text("连接与服务器") }, navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }) + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField(server, { server = it }, label = { Text("服务器地址") }, modifier = Modifier.fillMaxWidth(), trailingIcon = { + IconButton({ scanner.launch(ScanOptions().setDesiredBarcodeFormats(ScanOptions.QR_CODE).setPrompt("扫描服务器地址")) }) { + Icon(Icons.Rounded.QrCodeScanner, contentDescription = "扫描") + } + }) + OutlinedTextField(username, { username = it }, label = { Text("ID") }, modifier = Modifier.fillMaxWidth()) + Button(onClick = { + val next = settings.copy(cloudServerUrl = server.trim(), username = username.trim()) + onPersist(next) + ConnectionManager.connect(next) + }, modifier = Modifier.fillMaxWidth().height(56.dp)) { Text("保存并重新连接") } + Button(onClick = { ConnectionManager.scanLanPlugins() }, modifier = Modifier.fillMaxWidth().height(56.dp)) { Text("扫描局域网插件") } + scanStatus?.let { Text(it) } + plugins.forEach { plugin -> + Button(onClick = { + scope.launch { + ConnectionManager.loadLanBootstrap(settings, plugin)?.let(onPersist) + } + }, modifier = Modifier.fillMaxWidth()) { Text(plugin.instanceName) } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificationSettingsScreen(settings: WatchSettings, onBack: () -> Unit, onPersist: (WatchSettings) -> Unit) { + val scope = rememberCoroutineScope() + var force by remember { mutableStateOf(true) } + LaunchedEffect(Unit) { + force = runCatching { AdminApi.notificationSettings().forceSenderInTitle }.getOrDefault(true) + } + fun toggle(update: WatchSettings.() -> WatchSettings) { + val next = settings.update() + onPersist(next) + } + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + TopAppBar(title = { Text("通知设置") }, navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }) + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + listOf( + "上课" to settings.receiveOnClass, + "下课" to settings.receiveOnBreaking, + "放学" to settings.receiveAfterSchool, + "课表变更" to settings.receiveScheduleChanged, + "自定义消息" to settings.receiveCustom, + "自动化" to settings.receiveAutomationNotifications, + "第三方插件" to settings.receivePluginNotifications, + ).forEachIndexed { index, (label, checked) -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label) + Switch(checked, { + toggle { + when (index) { + 0 -> copy(receiveOnClass = it) + 1 -> copy(receiveOnBreaking = it) + 2 -> copy(receiveAfterSchool = it) + 3 -> copy(receiveScheduleChanged = it) + 4 -> copy(receiveCustom = it) + 5 -> copy(receiveAutomationNotifications = it) + else -> copy(receivePluginNotifications = it) + } + } + }) + } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("通知标题包含发送人") + Switch(force, { + force = it + scope.launch { runCatching { AdminApi.setNotificationSettings(it) } } + }) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppearanceScreen(settings: WatchSettings, onBack: () -> Unit, onPersist: (WatchSettings) -> Unit) { + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + TopAppBar(title = { Text("外观") }, navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }) + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("显示模式", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp))) { + listOf( + Triple("system", "跟随系统", "自动匹配系统的浅色或深色外观"), + Triple("light", "浅色", "始终使用浅色外观"), + Triple("dark", "深色", "始终使用深色外观"), + ).forEach { (id, label, supporting) -> + AppearanceChoice( + title = label, + supporting = supporting, + selected = settings.appearanceMode == id, + onClick = { onPersist(settings.copy(appearanceMode = id)) }, + ) + } + } + Text("配色方案", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp))) { + MobilePalette.All.forEach { palette -> + AppearanceChoice( + title = palette.label, + supporting = "与 Wear OS ${palette.label}方案一致", + swatch = palette.lightPrimary, + selected = settings.themeId == palette.id, + onClick = { onPersist(settings.copy(themeId = palette.id)) }, + ) + } + } + } + } +} + +@Composable +private fun AppearanceChoice( + title: String, + supporting: String, + selected: Boolean, + swatch: androidx.compose.ui.graphics.Color? = null, + onClick: () -> Unit, +) { + ListItem( + headlineContent = { Text(title) }, + supportingContent = { Text(supporting) }, + leadingContent = swatch?.let { color -> + { Box(Modifier.size(24.dp).clip(CircleShape).background(color)) } + }, + trailingContent = { RadioButton(selected = selected, onClick = null) }, + colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UpdatesScreen(settings: WatchSettings, onBack: () -> Unit, onPersist: (WatchSettings) -> Unit) { + val context = androidx.compose.ui.platform.LocalContext.current + val scope = rememberCoroutineScope() + var status by remember { mutableStateOf("尚未检查") } + var update by remember { mutableStateOf(null) } + Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + TopAppBar(title = { Text("更新") }, navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = settings.updateChannel == UpdateChannel.STABLE, onClick = { onPersist(settings.copy(updateChannel = UpdateChannel.STABLE)) }, label = { Text("正式版") }) + FilterChip(selected = settings.updateChannel == UpdateChannel.BETA, onClick = { onPersist(settings.copy(updateChannel = UpdateChannel.BETA)) }, label = { Text("Beta") }) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("同版本强制覆盖") + Switch(settings.forceUpdateEnabled, { onPersist(settings.copy(forceUpdateEnabled = it)) }) + } + Text(status) + Button(onClick = { + scope.launch { + status = "正在检查…" + val found = runCatching { + val releases = UpdateManager.fetchReleases() + UpdateManager.selectCompatibleUpdate(releases, com.remoteci.mobile.BuildConfig.VERSION_NAME, settings.updateChannel, settings.forceUpdateEnabled) + }.getOrElse { status = it.message ?: "检查失败"; null } + update = found + status = found?.let { "发现 ${it.release.tagName}" } ?: "已是当前渠道可用的最新版本" + } + }, modifier = Modifier.fillMaxWidth().height(56.dp)) { Text("检查更新") } + update?.let { candidate -> + Button(onClick = { + scope.launch { + status = "正在下载…" + runCatching { + val file = UpdateManager.downloadApk(context, candidate.asset) + UpdateManager.installApk(context, file) + }.onSuccess { status = "请按系统提示完成安装" } + .onFailure { status = it.message ?: "安装失败" } + } + }, modifier = Modifier.fillMaxWidth().height(56.dp)) { Text("下载并安装") } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeveloperScreen(settings: WatchSettings, onBack: () -> Unit, onPersist: (WatchSettings) -> Unit) { + Column(Modifier.fillMaxSize()) { + TopAppBar(title = { Text("开发者设置") }, navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }) + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("关闭云端后只走局域网直连;关闭局域网则只使用云端。重新登录时仍使用云端账号认证。") + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("云服务器") + Switch(settings.cloudConnectionEnabled, { onPersist(settings.copy(cloudConnectionEnabled = it)) }) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("局域网直连") + Switch(settings.lanConnectionEnabled, { onPersist(settings.copy(lanConnectionEnabled = it)) }) + } + Button(onClick = { ConnectionManager.connect(settings) }, modifier = Modifier.fillMaxWidth().height(56.dp)) { Text("应用并重连") } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SystemScreen(onBack: () -> Unit, snackbar: SnackbarHostState) { + val user by ConnectionManager.currentUser.collectAsState() + val scope = rememberCoroutineScope() + var info by remember { mutableStateOf(null) } + var backups by remember { mutableStateOf(listOf()) } + LaunchedEffect(user) { + if (user?.isAdmin == true) { + info = runCatching { AdminApi.systemInfo() }.getOrNull() + backups = runCatching { AdminApi.backups() }.getOrDefault(emptyList()) + } + } + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + TopAppBar(title = { Text("系统配置") }, navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }) + if (user?.isAdmin != true) { + EmptyState("仅管理员可管理系统", "服务端更新和备份不会对普通账号开放。") + } else { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("当前服务端 ${info?.currentVersion ?: "未知"}") + info?.message?.takeIf { it.isNotBlank() }?.let { Text(it) } + Button(onClick = { + scope.launch { + runCatching { AdminApi.checkUpdate("stable") } + .onSuccess { snackbar.showSnackbar(it?.tag ?: "没有可用更新") } + .onFailure { snackbar.showSnackbar(it.message ?: "检查失败") } + } + }, modifier = Modifier.fillMaxWidth().height(56.dp), enabled = info?.canSelfUpdate == true) { Text("检查服务端更新") } + Button(onClick = { + scope.launch { + runCatching { AdminApi.createBackup() } + backups = runCatching { AdminApi.backups() }.getOrDefault(backups) + } + }, modifier = Modifier.fillMaxWidth().height(56.dp)) { Text("立即备份") } + if (backups.isEmpty()) EmptyState("没有备份", "创建后可在这里恢复或删除。") + backups.forEach { backup -> + Row(Modifier.fillMaxWidth(), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + AppListItem( + title = backup.name, + supporting = "${backup.createdAt ?: ""} · ${backup.size} 字节", + leading = Icons.Rounded.Settings, + trailingText = "恢复", + modifier = Modifier.weight(1f), + onClick = { + scope.launch { + runCatching { AdminApi.restoreBackup(backup.name) } + .onSuccess { snackbar.showSnackbar("已请求恢复") } + .onFailure { snackbar.showSnackbar(it.message ?: "恢复失败") } + } + }, + ) + TextButton(onClick = { + scope.launch { + runCatching { AdminApi.deleteBackup(backup.name) } + .onSuccess { backups = AdminApi.backups(); snackbar.showSnackbar("备份已删除") } + .onFailure { snackbar.showSnackbar(it.message ?: "删除失败") } + } + }) { Text("删除") } + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/remoteci/mobile/ui/Theme.kt b/android/app/src/main/java/com/remoteci/mobile/ui/Theme.kt new file mode 100644 index 0000000..af5c90f --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/ui/Theme.kt @@ -0,0 +1,135 @@ +package com.remoteci.mobile.ui + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val LightBase = lightColorScheme( + background = Color(0xFFFAF9FD), + onBackground = Color(0xFF1B1B1F), + surface = Color(0xFFFAF9FD), + onSurface = Color(0xFF1B1B1F), + surfaceVariant = Color(0xFFE3E2E6), + onSurfaceVariant = Color(0xFF44474E), + surfaceContainerLowest = Color(0xFFFFFFFF), + surfaceContainerLow = Color(0xFFF3F3FA), + surfaceContainer = Color(0xFFEEEDF3), + surfaceContainerHigh = Color(0xFFE9E8EF), + surfaceContainerHighest = Color(0xFFE3E2E6), + outline = Color(0xFF74777F), + outlineVariant = Color(0xFFC4C6D0), +) + +private val DarkBase = darkColorScheme( + background = Color(0xFF131317), + onBackground = Color(0xFFE2E2E8), + surface = Color(0xFF131317), + onSurface = Color(0xFFE2E2E8), + surfaceVariant = Color(0xFF343439), + onSurfaceVariant = Color(0xFFC6C5D2), + surfaceContainerLowest = Color(0xFF0E0E12), + surfaceContainerLow = Color(0xFF1B1B1F), + surfaceContainer = Color(0xFF1F1F23), + surfaceContainerHigh = Color(0xFF2A2A2E), + surfaceContainerHighest = Color(0xFF343439), + outline = Color(0xFF90909C), + outlineVariant = Color(0xFF464651), +) + +data class MobilePalette( + val id: String, + val label: String, + val lightPrimary: Color, + val lightContainer: Color, + val lightOnContainer: Color, + val darkPrimary: Color, + val darkContainer: Color, + val darkOnContainer: Color, +) { + fun colorScheme(dark: Boolean): ColorScheme { + val primary = if (dark) darkPrimary else lightPrimary + val container = if (dark) darkContainer else lightContainer + val onContainer = if (dark) darkOnContainer else lightOnContainer + val onPrimary = if (dark) lightOnContainer else Color.White + return (if (dark) DarkBase else LightBase).copy( + primary = primary, + onPrimary = onPrimary, + primaryContainer = container, + onPrimaryContainer = onContainer, + secondary = primary, + onSecondary = onPrimary, + secondaryContainer = container, + onSecondaryContainer = onContainer, + tertiary = primary, + onTertiary = onPrimary, + tertiaryContainer = container, + onTertiaryContainer = onContainer, + inversePrimary = if (dark) lightPrimary else darkPrimary, + ) + } + + companion object { + val All = listOf( + // 与 Wear OS 的 WatchPalette 共用同一组 M3 tonal palette,避免两端同名主题颜色不同。 + MobilePalette("lavender", "淡紫", Color(0xFF6750A4), Color(0xFFD8D4FF), Color(0xFF4A4459), Color(0xFFD8D4FF), Color(0xFFE8DEF8), Color(0xFF4A4459)), + MobilePalette("purple", "经典紫", Color(0xFF6750A4), Color(0xFFEADDFF), Color(0xFF4F378B), Color(0xFFEADDFF), Color(0xFFE8DEF8), Color(0xFF4F378B)), + MobilePalette("blue", "蓝色", Color(0xFF4660D9), Color(0xFFD8E2FF), Color(0xFF000E62), Color(0xFFD8E2FF), Color(0xFFE0E0FF), Color(0xFF000E62)), + MobilePalette("green", "绿色", Color(0xFF416F43), Color(0xFFC3F0C2), Color(0xFF101F10), Color(0xFFC3F0C2), Color(0xFFD5E8D0), Color(0xFF101F10)), + MobilePalette("orange", "橙色", Color(0xFF8B5000), Color(0xFFFFDBB8), Color(0xFF2A1707), Color(0xFFFFDBB8), Color(0xFFFFDBBD), Color(0xFF2A1707)), + MobilePalette("pink", "粉色", Color(0xFF984061), Color(0xFFFFD8E2), Color(0xFF2B151B), Color(0xFFFFD8E2), Color(0xFFFFDAE3), Color(0xFF2B151B)), + ) + + fun fromId(id: String): MobilePalette = All.firstOrNull { it.id == id } ?: All.first() + } +} + +enum class AppearanceMode { System, Light, Dark } + +fun AppearanceMode.resolve(systemDark: Boolean): Boolean = when (this) { + AppearanceMode.System -> systemDark + AppearanceMode.Light -> false + AppearanceMode.Dark -> true +} + +@Composable +fun RemoteCiTheme( + appearanceMode: AppearanceMode = AppearanceMode.System, + palette: MobilePalette = MobilePalette.fromId("lavender"), + content: @Composable () -> Unit, +) { + val dark = appearanceMode.resolve(isSystemInDarkTheme()) + val scheme = palette.colorScheme(dark) + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + view.context.findActivity()?.window?.let { window -> + WindowCompat.getInsetsController(window, view).apply { + isAppearanceLightStatusBars = !dark + isAppearanceLightNavigationBars = !dark + } + } + } + } + MaterialTheme(colorScheme = scheme) { + Surface(modifier = Modifier.fillMaxSize(), color = scheme.background, content = content) + } +} + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} diff --git a/android/app/src/main/res/drawable/ic_stat.xml b/android/app/src/main/res/drawable/ic_stat.xml new file mode 100644 index 0000000..61bccfd --- /dev/null +++ b/android/app/src/main/res/drawable/ic_stat.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f9ba29f Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..973ef3d Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..5cdcb5d Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..cba6710 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..9372548 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..b6b35a2 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,152 @@ + + + RemoteCI + + + 登录 RemoteCI + ID + 密码 + 云端地址 + 云端地址使用明文 HTTP,账号密码可能被同一局域网窃听,建议改用 HTTPS + 扫描局域网设备 + 正在扫描… + 确认使用 %1$s 提供的云服务器 + 安全登录 + + + 下一节课是:%1$s + 下一节课是: + 无 + 菜单 + + + 课表 + 尚未同步课表 + 向插件拉取 + 正在拉取 + 当天没有课程 + 选择日期 + 没有可查看的课表 + 没有可换课的日期 + + + 换课 + 原课 + 目标 + 选择 + 选择科目 + 确认交换 + 确认替换 + 选择原课 + 选择目标课 + 选择科目 + 交换 + 替换 + + + 控制 + 老师来了 + 发送通知 + 清除通知 + 执行 + + + 音量 + 当前音量 %1$d%% + 当前已静音 · 音量 %1$d%% + 可触摸拖动,也可旋转表冠调节 + 静音 + 取消静音 + + + 电源 + 关机 + 重启 + 睡眠 + 休眠 + + + 发送消息 + 标题 + 将显示发送人 + 正文 + 启用提醒强调特效 + 启用提醒音效 + 启用提醒语言 + 发送并等待回执 + + + 设置 + 连接 + 通知 + 外观 + 更新 + 开发者 + 选择配色方案 + + + 请先连接 WebUI,再检查手表更新 + 已是当前渠道允许的最新版本 %1$s + 检查更新失败 + 当前版本 %1$s + 已连接 WebUI %1$s + 未知 + 更新渠道 + 强制更新 + 允许同版本重新下载并覆盖安装,不允许降级 + 检查更新 + 重新检查 + 发现新版本 %1$s + 可强制覆盖安装 %1$s + 下载并安装 + 重新下载并覆盖 + 更新失败 + 正在下载更新包… + 正在安装,请按系统提示确认… + 正式版 + Beta版 + + + 电脑 IP + 局域网端口 + 保存并重连 + 退出账号 + + + 关闭云端后只能依赖局域网直连;关闭局域网后只使用云端。密码登录仍会临时使用云端完成认证。 + 云服务器 + 局域网直连 + 应用并重连 + + + 上课 + 下课 + 放学 + 课表变更 + 自定义消息 + ClassIsland 自动化 + 其他插件通知 + + + 未连接 + 连接中… + 局域网直连 + 云端中转 + 错误:%1$s + 已完成:%1$s + 失败:%1$s + + + 返回 + + + RemoteCI 通知 + 课程、自动化和 ClassIsland 插件提醒 + 上课了 + 课间休息 + 放学啦 + 课表已更新 + RemoteCI 通知 + ClassIsland 通知 + RemoteCI + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..324a386 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..9630905 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/android/app/src/test/java/com/remoteci/mobile/LauncherIconResourceTest.kt b/android/app/src/test/java/com/remoteci/mobile/LauncherIconResourceTest.kt new file mode 100644 index 0000000..c85a1f9 --- /dev/null +++ b/android/app/src/test/java/com/remoteci/mobile/LauncherIconResourceTest.kt @@ -0,0 +1,46 @@ +package com.remoteci.mobile + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertTrue + +class LauncherIconResourceTest { + private val mainSourceDirectory = + sequenceOf(File("app/src/main"), File("src/main")) + .first { it.isDirectory } + + @Test + fun launcherIconKeepsTransparentPixels() { + val manifest = File(mainSourceDirectory, "AndroidManifest.xml").readText() + assertTrue( + "android:icon=\"@mipmap/ic_launcher_foreground\"" in manifest, + "应用图标必须直接使用透明 PNG,不能再通过带不透明背景的 adaptive icon 合成。", + ) + + val resourceDirectory = File(mainSourceDirectory, "res") + val icons = + resourceDirectory + .listFiles() + .orEmpty() + .filter { it.name.startsWith("mipmap-") } + .map { File(it, "ic_launcher_foreground.png") } + .filter { it.isFile } + + assertTrue(icons.isNotEmpty(), "没有找到 Android 启动器图标资源。") + icons.forEach { icon -> + val header = icon.inputStream().use { it.readNBytes(26) } + assertTrue(header.size == 26, "PNG 文件头不完整:${icon.path}") + assertContentEquals( + byteArrayOf(-119, 80, 78, 71, 13, 10, 26, 10), + header.copyOfRange(0, 8), + "不是有效的 PNG 文件:${icon.path}", + ) + // PNG 的 IHDR color type 位于第 25 字节;4 和 6 分别表示灰度/RGB 带 Alpha。 + assertTrue( + header[25].toInt() in setOf(4, 6), + "图标没有 Alpha 通道:${icon.path}", + ) + } + } +} diff --git a/android/app/src/test/java/com/remoteci/mobile/data/AuthorizationAndNotificationTest.kt b/android/app/src/test/java/com/remoteci/mobile/data/AuthorizationAndNotificationTest.kt new file mode 100644 index 0000000..3efe16c --- /dev/null +++ b/android/app/src/test/java/com/remoteci/mobile/data/AuthorizationAndNotificationTest.kt @@ -0,0 +1,259 @@ +package com.remoteci.mobile.data + +import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlinx.serialization.json.Json +import kotlin.test.assertTrue + +class AuthorizationAndNotificationTest { + @Test + fun `voice requires explicit capability from both server and plugin`() { + assertFalse(Protocol.CAP_VOICE_MESSAGE_SEND in Protocol.BASELINE_CAPABILITIES) + assertFalse(Protocol.CAP_VOICE_MESSAGE_SEND in effectiveCapabilities(CapabilitiesSync( + server = PeerCapabilities(capabilities = Protocol.CURRENT_CAPABILITIES.toList()), + plugin = PeerCapabilities(capabilities = Protocol.BASELINE_CAPABILITIES.toList()), + ))) + assertTrue(Protocol.CAP_VOICE_MESSAGE_SEND in effectiveCapabilities(CapabilitiesSync( + server = PeerCapabilities(capabilities = Protocol.CURRENT_CAPABILITIES.toList()), + plugin = PeerCapabilities(capabilities = Protocol.CURRENT_CAPABILITIES.toList()), + ))) + val command = Json.decodeFromString("""{"command":9,"voiceMessage":{"format":"pcm_s16le_16000_mono","audioBase64":"AAA="}}""") + assertEquals(Protocol.CMD_SEND_VOICE_MESSAGE, command.command) + assertEquals("AAA=", command.voiceMessage?.audioBase64) + assertEquals(0, Protocol.PERMISSION_SEND_VOICE_MESSAGES and Protocol.PERMISSION_SEND_NOTIFICATIONS) + } + + @Test + fun `effective capabilities require watch server and plugin support`() { + val effective = effectiveCapabilities( + CapabilitiesSync( + server = PeerCapabilities("3.5.0", listOf(Protocol.CAP_SCHEDULE_READ, Protocol.CAP_VOLUME_CONTROL)), + plugin = PeerCapabilities("3.1.0", listOf(Protocol.CAP_SCHEDULE_READ)), + ), + ) + + assertEquals(setOf(Protocol.CAP_SCHEDULE_READ), effective) + assertTrue(effectiveCapabilities(CapabilitiesSync()).isEmpty()) + } + + @Test + fun `breaking permission model uses protocol version three`() { + assertEquals(3, Protocol.VERSION) + assertEquals("REMOTECI_DISCOVER_V3", Protocol.LAN_DISCOVERY_REQUEST) + } + + @Test + fun `selected lan plugin fills lan endpoint and reachable cloud url`() { + val updated = mergeLanBootstrapInfo( + WatchSettings(), + LanPluginCandidate("Classroom-PC", "192.168.50.8", 9123), + ConnectionBootstrapInfo("Classroom-PC", "http://localhost:8080"), + ) + + assertEquals("192.168.50.8", updated.lanHost) + assertEquals(9123, updated.lanPort) + assertEquals("http://192.168.50.8:8080", updated.cloudServerUrl) + assertEquals( + "http://192.168.50.8:8080", + mergeLanBootstrapInfo( + WatchSettings(), + LanPluginCandidate("Classroom-PC", "192.168.50.8", 9123), + ConnectionBootstrapInfo("Classroom-PC", "http://0.0.0.0:8080"), + ).cloudServerUrl, + ) + assertFailsWith { + mergeLanBootstrapInfo( + WatchSettings(), + LanPluginCandidate("Classroom-PC", "192.168.50.8", 9123), + ConnectionBootstrapInfo("Classroom-PC", "file:///tmp/not-a-server"), + ) + } + } + + @Test + fun `lan discovery candidate uses packet source and rejects incompatible response`() { + val response = LanDiscoveryResponse(Protocol.VERSION, "Classroom-PC", 9123) + + assertEquals( + LanPluginCandidate("Classroom-PC", "192.168.50.8", 9123), + lanPluginCandidate(response, "192.168.50.8"), + ) + assertNull(lanPluginCandidate(response.copy(protocolVersion = 99), "192.168.50.8")) + assertNull(lanPluginCandidate(response.copy(port = 0), "192.168.50.8")) + } + + @Test + fun `plugin network info updates candidates while preserving a working preferred host`() { + val current = WatchSettings(lanHost = "10.0.0.8", lanPort = 8765) + val advertised = PluginNetworkInfo( + lanServerEnabled = true, + addresses = listOf("192.168.50.8", "10.0.0.8"), + port = 9876, + ) + + val updated = mergePluginNetworkInfo(current, advertised) + + assertEquals("10.0.0.8", updated.lanHost) + assertEquals(9876, updated.lanPort) + assertEquals(listOf("10.0.0.8", "192.168.50.8"), lanEndpointHosts(updated)) + } + + @Test + fun `lan url supports editable ports and ipv6 literals`() { + assertEquals("ws://192.168.1.20:9123/ws", lanWebSocketUrl("192.168.1.20", 9123)) + assertEquals("ws://[fd00::10]:9123/ws", lanWebSocketUrl("fd00::10", 9123)) + } + + @Test + fun `password login bootstraps cloud even when developer disabled cloud connection`() { + val settings = WatchSettings(cloudConnectionEnabled = false) + + val plan = planConnection(settings, password = "valid-password") + + assertTrue(plan.bootstrapCloudAuthentication) + assertFalse(plan.allowCloudFallback) + } + + @Test + fun `password login prefers selected lan plugin after cloud authentication`() { + val plan = planConnection( + WatchSettings(lanConnectionEnabled = true, lanHost = "192.168.50.8", lanPort = 9123), + password = "valid-password", + ) + + assertTrue(plan.bootstrapCloudAuthentication) + assertTrue(plan.preferLanAfterCloudAuthentication) + } + + @Test + fun `schedule pull envelope uses dedicated read-only protocol message`() { + val envelope = schedulePullEnvelope() + + assertEquals(Protocol.TYPE_SCHEDULE_PULL, envelope.type) + assertTrue(envelope.messageId.isNotBlank()) + val request = Json.decodeFromJsonElement(ScheduleSyncRequest.serializer(), assertNotNull(envelope.payload)) + assertEquals(envelope.messageId, request.taskId) + assertEquals(Protocol.SCHEDULE_SOURCE_MOBILE, request.source) + } + + @Test + fun `new devices receive every supported event type`() { + val settings = WatchSettings() + + listOf( + Protocol.EVENT_ON_CLASS, + Protocol.EVENT_ON_BREAKING, + Protocol.EVENT_AFTER_SCHOOL, + Protocol.EVENT_SCHEDULE_CHANGED, + Protocol.EVENT_CUSTOM, + Protocol.EVENT_AUTOMATION_NOTIFICATION, + Protocol.EVENT_PLUGIN_NOTIFICATION, + ).forEach { eventType -> + assertTrue(settings.receives(ClassEvent(id = "event-$eventType", event = eventType))) + } + } + + @Test + fun `event preferences are independent`() { + val settings = WatchSettings(receiveCustom = false, receiveAutomationNotifications = false) + + assertFalse(settings.receives(ClassEvent(id = "custom", event = Protocol.EVENT_CUSTOM))) + assertTrue(settings.receives(ClassEvent(id = "class", event = Protocol.EVENT_ON_CLASS))) + assertFalse(settings.receives(ClassEvent(id = "automation", event = Protocol.EVENT_AUTOMATION_NOTIFICATION))) + assertTrue(settings.receives(ClassEvent(id = "plugin", event = Protocol.EVENT_PLUGIN_NOTIFICATION))) + } + + @Test + fun `effective permission gates privileged features`() { + val user = UserProfile( + permissions = Protocol.PERMISSION_VIEW_CURRENT or Protocol.PERMISSION_SEND_NOTIFICATIONS, + ) + + assertTrue(user.has(Protocol.PERMISSION_VIEW_CURRENT)) + assertTrue(user.has(Protocol.PERMISSION_SEND_NOTIFICATIONS)) + assertFalse(user.has(Protocol.PERMISSION_MANAGE_SCHEDULE)) + } + + @Test + fun `cloud websocket url url-encodes base64 access token`() { + // 服务端令牌是标准 Base64,+ 在查询串里会被解码成空格导致 401。 + assertEquals( + "wss://ci.example.com/ws?token=Ab%2BCd%2Fe%3D%3D&client=mobile", + cloudWebSocketUrl("https://ci.example.com", "Ab+Cd/e=="), + ) + assertEquals( + "ws://192.168.1.5:8080/ws?token=plainToken&client=mobile", + cloudWebSocketUrl("http://192.168.1.5:8080/", "plainToken"), + ) + } + + @Test + fun `local server addresses need network permission regardless of tls`() { + assertTrue(isLocalServerUrl("http://10.0.2.2:8080")) + assertTrue(isLocalServerUrl("https://192.168.1.5:8443")) + assertTrue(isLocalServerUrl("http://localhost:8080")) + assertTrue(isLocalServerUrl("https://server.local")) + assertTrue(isLocalServerUrl("http://[fd00::10]:8080")) + assertFalse(isLocalServerUrl("https://ci.example.com")) + assertFalse(isLocalServerUrl("https://1.1.1.1")) + } + + @Test + fun `lan endpoint hosts deduplicate and preserve preferred order`() { + assertEquals( + listOf("192.168.50.8", "10.0.0.9"), + lanEndpointHosts( + WatchSettings(lanHost = "192.168.50.8", lanHostCandidates = listOf("192.168.50.8", "10.0.0.9")), + ), + ) + assertEquals(emptyList(), lanEndpointHosts(WatchSettings(lanHost = " ", lanHostCandidates = listOf(" ")))) + } + + @Test + fun `bootstrap change detection ignores defaults and matches case-insensitively`() { + // 默认开发地址视为“从未使用”,不触发变更警告。 + assertFalse(bootstrapUrlChanged("http://10.0.2.2:8080", "https://ci.example.com")) + // 首次引导(无历史记录)必须要求用户显式确认云服务器,防 UDP 发现钓鱼。 + assertTrue(bootstrapUrlChanged("", "https://ci.example.com")) + // 与上次实际使用的地址不同才警告;大小写与结尾斜杠差异不算变更。 + assertTrue(bootstrapUrlChanged("https://old.example.com", "https://new.example.com")) + assertFalse(bootstrapUrlChanged("https://old.example.com", "https://OLD.example.com/")) + } + + @Test + fun `cleartext is permitted only for rfc1918 ipv4 literals`() { + assertTrue(isRfc1918Host("192.168.1.100")) + assertTrue(isRfc1918Host("10.0.2.2")) + assertTrue(isRfc1918Host("172.16.0.1")) + assertTrue(isRfc1918Host("172.31.255.254")) + assertFalse(isRfc1918Host("172.32.0.1")) + assertFalse(isRfc1918Host("8.8.8.8")) + assertFalse(isRfc1918Host("ci.example.com")) + assertFalse(isRfc1918Host("192.168.1")) + assertFalse(isRfc1918Host("192.168.1.256")) + assertFalse(isRfc1918Host("192.168.1.+1")) + } + + @Test + fun `cleartext urls are rejected outside rfc1918`() { + // 私网、环回明文与任何 TLS 地址放行。 + requireCleartextPrivateUrl("http://192.168.1.5:8080/api") + requireCleartextPrivateUrl("ws://10.0.2.2:9123/ws") + requireCleartextPrivateUrl("ws://localhost:9123/ws") + requireCleartextPrivateUrl("https://ci.example.com") + // 公网主机的明文连接一律拒绝。 + assertFailsWith { requireCleartextPrivateUrl("http://ci.example.com") } + assertFailsWith { requireCleartextPrivateUrl("ws://8.8.8.8:9123/ws") } + } + + @Test + fun `explicit cloud server allows warned public http endpoint`() { + requireCloudServerUrl("http://203.0.113.10:42089") + requireCloudServerUrl("https://ci.example.com") + } +} diff --git a/android/app/src/test/java/com/remoteci/mobile/data/ConnectionManagerFlowTest.kt b/android/app/src/test/java/com/remoteci/mobile/data/ConnectionManagerFlowTest.kt new file mode 100644 index 0000000..108b297 --- /dev/null +++ b/android/app/src/test/java/com/remoteci/mobile/data/ConnectionManagerFlowTest.kt @@ -0,0 +1,382 @@ +package com.remoteci.mobile.data + +import java.security.MessageDigest +import java.util.Base64 +import java.util.concurrent.TimeUnit +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.encodeToString +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener + +/** + * ConnectionManager 完整连接流程测试:真实 OkHttp + MockWebServer 端到端, + * 通过 SessionStorage 内存假实现绕开 Android Keystore,纯 JVM 运行。 + * 覆盖:密码登录、错误密码、局域网 HMAC 挑战握手、协议版本不兼容、命令回环与断线自动重连。 + */ +class ConnectionManagerFlowTest { + private lateinit var server: MockWebServer + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + + private companion object { + const val SESSION_ID = "ABCDEF12-3456-7890-ABCD-EF1234567890" + const val DEVICE_SECRET = "secret-value" + val TEACHER = UserProfile( + id = "u1", + username = "teacher", + displayName = "王老师", + role = Protocol.ROLE_ADMIN, + permissions = Protocol.PERMISSION_VIEW_CURRENT or Protocol.PERMISSION_SEND_NOTIFICATIONS or + Protocol.PERMISSION_MANAGE_SCHEDULE, + ) + } + + private class FakeSessionStorage(initial: PersistedDeviceSession? = null) : SessionStorage { + var stored: PersistedDeviceSession? = initial + override fun save(session: PersistedDeviceSession) { + stored = session + } + + override fun load(): PersistedDeviceSession? = stored + override fun clear() { + stored = null + } + } + + @BeforeTest + fun setUp() { + server = MockWebServer() + server.start() + } + + @AfterTest + fun tearDown() { + ConnectionManager.disconnect(clearUser = true) + server.close() + } + + private fun authResponse() = AuthResponse( + accessToken = "tok-abc", + accessExpiresAt = "2099-01-01T00:00:00Z", + deviceSessionId = SESSION_ID, + deviceSecret = DEVICE_SECRET, + deviceExpiresAt = "2099-01-01T00:00:00Z", + user = TEACHER, + ) + + private fun session(username: String = "teacher") = PersistedDeviceSession( + username = username, + deviceSessionId = SESSION_ID, + deviceSecret = DEVICE_SECRET, + deviceExpiresAt = "2099-01-01T00:00:00Z", + ) + + private fun envelopeJson(type: String, protocolVersion: Int = Protocol.VERSION, payload: JsonElement? = null) = + json.encodeToString( + Envelope.serializer(), + Envelope(protocolVersion = protocolVersion, type = type, payload = payload), + ) + + private fun authStateJson() = envelopeJson( + Protocol.TYPE_AUTH_STATE, + payload = json.encodeToJsonElement( + AuthState.serializer(), + AuthState(authenticated = true, serverVersion = "0.4.0", user = TEACHER), + ), + ) + + /** 打开即下发认证成功状态并保持连接(不主动关闭,由测试断开触发重连)。 */ + private fun authStateListener() = object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + webSocket.send(authStateJson()) + } + + // 必须回显关闭帧,客户端与服务端才能完成关闭握手,MockWebServer 才能干净停机。 + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(code, reason) + } + } + + private fun mockServerSettings() = WatchSettings( + cloudServerUrl = server.url("/").toString().trimEnd('/'), + lanConnectionEnabled = false, + ) + + private suspend fun awaitState( + predicate: (ConnectionManager.State) -> Boolean, + timeoutMs: Long = 15_000, + ): ConnectionManager.State = withTimeout(timeoutMs) { ConnectionManager.state.first(predicate) } + + @Test + fun `password login persists device session and reaches cloud connected`() = runBlocking { + val storage = FakeSessionStorage() + ConnectionManager.installSessionStorageForTest(storage) + server.enqueue( + MockResponse.Builder().code(200) + .body(json.encodeToString(AuthResponse.serializer(), authResponse())).build(), + ) + server.enqueue(MockResponse.Builder().webSocketUpgrade(authStateListener()).build()) + + ConnectionManager.connect(mockServerSettings(), "correct-password") + + awaitState({ it is ConnectionManager.State.CloudConnected }) + assertEquals("teacher", ConnectionManager.currentUser.value?.username) + assertEquals("0.4.0", ConnectionManager.serverVersion.value) + assertEquals(SESSION_ID, storage.stored?.deviceSessionId) + + val loginRequest = server.takeRequest() + assertTrue(loginRequest.requestLine.startsWith("POST /api/auth/login ")) + val wsRequest = server.takeRequest() + assertTrue(wsRequest.requestLine.contains("/ws?token=tok-abc")) + } + + @Test + fun `wrong password surfaces clear authentication error`() = runBlocking { + ConnectionManager.installSessionStorageForTest(FakeSessionStorage()) + server.enqueue(MockResponse.Builder().code(401).body("unauthorized").build()) + + ConnectionManager.connect(mockServerSettings(), "wrong-password") + + val error = awaitState({ it is ConnectionManager.State.Error }) + assertEquals("用户名或密码错误", (error as ConnectionManager.State.Error).message) + assertNull(ConnectionManager.currentUser.value) + } + + @Test + fun `lan challenge proof verifies server-side and reaches lan connected`() = runBlocking { + ConnectionManager.installSessionStorageForTest(FakeSessionStorage(session())) + val challenge = AuthChallenge(challengeId = "c1", nonce = "n1", expiresAt = "2099-01-01T00:00:00Z") + var proofVerified = false + val wsListener = object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + webSocket.send( + envelopeJson( + Protocol.TYPE_AUTH_CHALLENGE, + payload = json.encodeToJsonElement(AuthChallenge.serializer(), challenge), + ), + ) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val envelope = json.decodeFromString(Envelope.serializer(), text) + if (envelope.type == Protocol.TYPE_PEER_CAPABILITIES) { + assertTrue(proofVerified) + return + } + assertEquals(Protocol.TYPE_AUTH_PROOF, envelope.type) + val proof = json.decodeFromJsonElement(AuthProof.serializer(), envelope.payload!!) + assertEquals("c1", proof.challengeId) + assertEquals(SESSION_ID, proof.deviceSessionId) + // 服务端独立重算 HMAC:密钥 = SHA-256(deviceSecret),消息 = 3|c1|n1|clientNonce|无横线小写 sessionId + val verifier = MessageDigest.getInstance("SHA-256").digest(DEVICE_SECRET.encodeToByteArray()) + val canonical = "3|c1|n1|${proof.clientNonce}|${SESSION_ID.replace("-", "").lowercase()}" + val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(verifier, "HmacSHA256")) } + assertEquals( + Base64.getEncoder().encodeToString(mac.doFinal(canonical.encodeToByteArray())), + proof.proof, + ) + proofVerified = true + webSocket.send(authStateJson()) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(code, reason) + } + } + server.enqueue(MockResponse.Builder().webSocketUpgrade(wsListener).build()) + // 已保存会话的局域网连接先于管理 API 令牌刷新;刷新请求在 WebSocket 成功后后台发送。 + server.enqueue( + MockResponse.Builder().code(200) + .body(json.encodeToString(AuthResponse.serializer(), authResponse())).build(), + ) + val settings = WatchSettings( + cloudServerUrl = server.url("/").toString().trimEnd('/'), + lanConnectionEnabled = true, + lanHost = "localhost", + lanPort = server.port, + ) + + ConnectionManager.connect(settings) + + awaitState({ it is ConnectionManager.State.LanConnected }) + assertTrue(proofVerified) + assertEquals("teacher", ConnectionManager.currentUser.value?.username) + // 局域网连接不再等待云端往返;管理 API 令牌在连接成功后后台刷新。 + withTimeout(5_000) { + while (ConnectionManager.restToken() == null) delay(10) + } + assertEquals("tok-abc", ConnectionManager.restToken()) + } + + @Test + fun `protocol version mismatch keeps specific error message`() = runBlocking { + ConnectionManager.installSessionStorageForTest(FakeSessionStorage()) + server.enqueue( + MockResponse.Builder().code(200) + .body(json.encodeToString(AuthResponse.serializer(), authResponse())).build(), + ) + server.enqueue( + MockResponse.Builder().webSocketUpgrade(object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + webSocket.send(envelopeJson(Protocol.TYPE_AUTH_STATE, protocolVersion = 99)) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(code, reason) + } + }).build(), + ) + + ConnectionManager.connect(mockServerSettings(), "correct-password") + + val error = awaitState({ it is ConnectionManager.State.Error }) + assertEquals("协议版本不兼容,需要 v3", (error as ConnectionManager.State.Error).message) + } + + @Test + fun `command round trip updates last command result`() = runBlocking { + ConnectionManager.installSessionStorageForTest(FakeSessionStorage()) + server.enqueue( + MockResponse.Builder().code(200) + .body(json.encodeToString(AuthResponse.serializer(), authResponse())).build(), + ) + server.enqueue( + MockResponse.Builder().webSocketUpgrade(object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + webSocket.send(authStateJson()) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val envelope = json.decodeFromString(Envelope.serializer(), text) + if (envelope.type == Protocol.TYPE_COMMAND) { + webSocket.send( + envelopeJson( + Protocol.TYPE_COMMAND_RESULT, + payload = json.encodeToJsonElement( + CommandResult.serializer(), + CommandResult(success = true, code = "OK", message = "已发送"), + ), + ), + ) + } + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(code, reason) + } + }).build(), + ) + + ConnectionManager.connect(mockServerSettings(), "correct-password") + awaitState({ it is ConnectionManager.State.CloudConnected }) + + ConnectionManager.sendNotification("标题", "内容", false, false, false) + + withTimeout(10_000) { ConnectionManager.lastCommandResult.first { it != null } } + assertEquals("OK", ConnectionManager.lastCommandResult.value?.code) + assertTrue(ConnectionManager.lastCommandResult.value?.success == true) + } + + @Test + fun `authenticated connection drop schedules reconnect via refresh`() = runBlocking { + val storage = FakeSessionStorage() + ConnectionManager.installSessionStorageForTest(storage) + server.enqueue( + MockResponse.Builder().code(200) + .body(json.encodeToString(AuthResponse.serializer(), authResponse())).build(), + ) + var firstSocket: WebSocket? = null + server.enqueue( + MockResponse.Builder().webSocketUpgrade(object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + firstSocket = webSocket + webSocket.send(authStateJson()) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(code, reason) + } + }).build(), + ) + // 断线后的自动重连:先刷新会话,再升级第二条 WebSocket。 + server.enqueue( + MockResponse.Builder().code(200) + .body(json.encodeToString(AuthResponse.serializer(), authResponse())).build(), + ) + server.enqueue(MockResponse.Builder().webSocketUpgrade(authStateListener()).build()) + + ConnectionManager.connect(mockServerSettings(), "correct-password") + awaitState({ it is ConnectionManager.State.CloudConnected }) + + firstSocket!!.close(1000, "network dropped") + + // 先离开已连接状态(Error/Connecting),再等待 5 秒退避后的重连完成。 + awaitState({ it !is ConnectionManager.State.CloudConnected }, timeoutMs = 10_000) + awaitState({ it is ConnectionManager.State.CloudConnected }, timeoutMs = 25_000) + + val requestLines = mutableListOf() + while (true) { + val request = server.takeRequest(500, TimeUnit.MILLISECONDS) ?: break + requestLines.add(request.requestLine) + } + assertTrue( + requestLines.any { it.startsWith("POST /api/auth/refresh ") }, + "expected refresh request, got $requestLines", + ) + assertEquals("teacher", ConnectionManager.currentUser.value?.username) + } + + @Test + fun `superseded connect cancellation does not corrupt the new attempt state`() = runBlocking { + ConnectionManager.installSessionStorageForTest(FakeSessionStorage()) + // 第一次连接:登录成功但升级后对端从不回 auth_state,协程挂起在认证等待中。 + server.enqueue( + MockResponse.Builder().code(200) + .body(json.encodeToString(AuthResponse.serializer(), authResponse())).build(), + ) + server.enqueue( + MockResponse.Builder().webSocketUpgrade(object : WebSocketListener() { + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(code, reason) + } + }).build(), + ) + // 第二次连接:正常登录并下发 auth_state。 + server.enqueue( + MockResponse.Builder().code(200) + .body(json.encodeToString(AuthResponse.serializer(), authResponse())).build(), + ) + server.enqueue(MockResponse.Builder().webSocketUpgrade(authStateListener()).build()) + + ConnectionManager.connect(mockServerSettings(), "correct-password") + // 等待旧尝试已发出登录与升级请求、挂起等待 auth_state。 + server.takeRequest() + server.takeRequest() + + ConnectionManager.connect(mockServerSettings(), "correct-password") + awaitState({ it is ConnectionManager.State.CloudConnected }) + + // 旧任务被取消后必须直接透出 CancellationException: + // 不得把新连接的已连接状态覆盖为 Error,也不得清空用户信息。 + delay(500) + assertTrue(ConnectionManager.state.value is ConnectionManager.State.CloudConnected) + assertEquals("teacher", ConnectionManager.currentUser.value?.username) + } +} diff --git a/android/app/src/test/java/com/remoteci/mobile/data/ConnectionManagerLogicTest.kt b/android/app/src/test/java/com/remoteci/mobile/data/ConnectionManagerLogicTest.kt new file mode 100644 index 0000000..e20c34e --- /dev/null +++ b/android/app/src/test/java/com/remoteci/mobile/data/ConnectionManagerLogicTest.kt @@ -0,0 +1,92 @@ +package com.remoteci.mobile.data + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * 覆盖 ConnectionManager 中与网络无关的纯逻辑: + * 局域网 HMAC 挑战证明(protocol.md 规范向量)与断线重连指数退避。 + * 这些逻辑原先内嵌在 OkHttp/Android 生命周期里,必须 Robolectric 才能触达; + * 提取为顶层函数后用普通 JVM 单测锁定协议兼容性。 + */ +class ConnectionManagerLogicTest { + @Test + fun `outgoing envelope always declares protocol version`() { + val encoded = encodeEnvelope(Envelope(type = Protocol.TYPE_PEER_CAPABILITIES)) + + assertTrue(encoded.contains("\"protocolVersion\":3"), encoded) + } + + @Test + fun `lan proof matches server canonical hmac vector`() { + val challenge = AuthChallenge(challengeId = "c1", nonce = "n1", expiresAt = "2030-01-01T00:00:00Z") + val session = PersistedDeviceSession( + username = "teacher", + deviceSessionId = "ABCDEF12-3456-7890-ABCD-EF1234567890", + deviceSecret = "secret-value", + deviceExpiresAt = "2030-01-01T00:00:00Z", + ) + // 固定 nonce = 字节 0..23,与服务器端 .NET 实现独立计算的向量比对。 + val fixedNonce = ByteArray(24) { it.toByte() } + + val proof = createAuthProof(challenge, session, fixedNonce) + + assertEquals("AAECAwQFBgcICQoLDA0ODxAREhMUFRYX", proof.clientNonce) + assertEquals("c1", proof.challengeId) + assertEquals("ABCDEF12-3456-7890-ABCD-EF1234567890", proof.deviceSessionId) + // canonical = "3|c1|n1||<无横线小写 sessionId>",密钥 = SHA-256(deviceSecret) + assertEquals("7PhyfRLbHDrbGDrVbAWSkP4GbqgO4QCY7k9JxILypjM=", proof.proof) + } + + @Test + fun `lan proof normalizes session id dashes and case`() { + val challenge = AuthChallenge(challengeId = "c2", nonce = "n2", expiresAt = "2030-01-01T00:00:00Z") + val fixedNonce = ByteArray(24) { it.toByte() } + val mixed = PersistedDeviceSession( + username = "teacher", + deviceSessionId = "ABCDEF12-3456-7890-ABCD-EF1234567890", + deviceSecret = "secret-value", + deviceExpiresAt = "2030-01-01T00:00:00Z", + ) + val normalized = mixed.copy(deviceSessionId = "abcdef1234567890abcdef1234567890") + + assertEquals( + createAuthProof(challenge, mixed, fixedNonce).proof, + createAuthProof(challenge, normalized, fixedNonce).proof, + ) + } + + @Test + fun `default client nonce is 24 random bytes`() { + val challenge = AuthChallenge(challengeId = "c3", nonce = "n3", expiresAt = "2030-01-01T00:00:00Z") + val session = PersistedDeviceSession( + username = "teacher", + deviceSessionId = "abcdef1234567890abcdef1234567890", + deviceSecret = "secret-value", + deviceExpiresAt = "2030-01-01T00:00:00Z", + ) + + val first = createAuthProof(challenge, session) + val second = createAuthProof(challenge, session) + + assertEquals(24, java.util.Base64.getDecoder().decode(first.clientNonce).size) + assertNotEquals(first.clientNonce, second.clientNonce) + } + + @Test + fun `reconnect delay doubles each attempt and caps at max`() { + assertEquals(10_000L, nextReconnectDelay(InitialReconnectDelayMs)) + + var delay = InitialReconnectDelayMs + val sequence = buildList { + repeat(6) { + delay = nextReconnectDelay(delay) + add(delay) + } + } + assertEquals(listOf(10_000L, 20_000L, 40_000L, 60_000L, 60_000L, 60_000L), sequence) + assertEquals(60_000L, MaxReconnectDelayMs) + } +} diff --git a/android/app/src/test/java/com/remoteci/mobile/data/UpdateManagerTest.kt b/android/app/src/test/java/com/remoteci/mobile/data/UpdateManagerTest.kt new file mode 100644 index 0000000..2e5646e --- /dev/null +++ b/android/app/src/test/java/com/remoteci/mobile/data/UpdateManagerTest.kt @@ -0,0 +1,193 @@ +package com.remoteci.mobile.data + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class UpdateManagerTest { + @Test + fun `versionFromTag strips v prefix`() { + assertEquals("0.2.0", UpdateManager.versionFromTag("v0.2.0")) + assertEquals("0.2.0", UpdateManager.versionFromTag("0.2.0")) + } + + @Test + fun `compareVersions follows semver ordering`() { + assertTrue(UpdateManager.isNewer("0.3.0", "0.2.0")) + assertTrue(UpdateManager.isNewer("1.0.0", "0.9.9")) + assertTrue(UpdateManager.isNewer("0.10.0", "0.9.0")) + assertFalse(UpdateManager.isNewer("0.2.0", "0.2.0")) + assertFalse(UpdateManager.isNewer("0.2.0", "0.3.0")) + assertTrue(UpdateManager.isNewer("3.2.1.0", "3.2.0.9")) + assertTrue(UpdateManager.isNewer("3.2.1.1", "3.2.1.0")) + assertFalse(UpdateManager.isNewer("3.2.1.0", "3.2.1")) + } + + @Test + fun `compareVersions follows prerelease precedence`() { + assertTrue(UpdateManager.isNewer("0.4.0-beta.2", "0.4.0-beta.1")) + assertTrue(UpdateManager.isNewer("0.4.0", "0.4.0-beta.2")) + assertFalse(UpdateManager.isNewer("0.4.0-beta.2", "0.4.0")) + } + + @Test + fun `findApkAsset picks the mobile apk only`() { + val release = GitHubRelease( + tagName = "v0.2.0", + assets = listOf( + GitHubAsset(name = "RemoteCI.Server-0.2.0-linux-x64.zip", browserDownloadUrl = "https://example/server.zip"), + GitHubAsset(name = "RemoteCI.Mobile-0.2.0.apk", browserDownloadUrl = "https://example/watch.apk"), + ), + ) + + assertEquals("RemoteCI.Mobile-0.2.0.apk", UpdateManager.findApkAsset(release)?.name) + } + + @Test + fun `findApkAsset returns null when missing`() { + val release = GitHubRelease( + tagName = "v0.2.0", + assets = listOf( + GitHubAsset(name = "RemoteCI.Plugin-0.2.0.cipx", browserDownloadUrl = "https://example/plugin.cipx"), + ), + ) + + assertNull(UpdateManager.findApkAsset(release)) + } + + @Test + fun `selectCompatibleUpdate allows newer version within protocol three`() { + val releases = listOf( + release("3.4.0.0"), + release("3.2.0.0"), + release("3.1.0.0"), + ) + + val selected = UpdateManager.selectCompatibleUpdate( + releases = releases, + currentVersion = "3.1.0.0", + ) + + assertEquals("3.4.0.0", selected?.release?.tagName) + assertEquals("RemoteCI.Mobile-3.4.0.0.apk", selected?.asset?.name) + } + + @Test + fun `selectCompatibleUpdate excludes protocol four`() { + val selected = UpdateManager.selectCompatibleUpdate( + releases = listOf(release("4.0.0.0")), + currentVersion = "3.1.0.0", + ) + + assertNull(selected) + } + + @Test + fun `selectCompatibleUpdate accepts canonical stable and rejects legacy v tag`() { + val legacyRelease = release("v3.2.0") + + val selected = UpdateManager.selectCompatibleUpdate( + releases = listOf(release("3.2.0.0"), legacyRelease), + currentVersion = "3.1.0.0", + ) + + assertEquals("3.2.0.0", selected?.release?.tagName) + } + + @Test + fun `selectCompatibleUpdate ignores beta and draft releases`() { + val selected = UpdateManager.selectCompatibleUpdate( + releases = listOf( + release("v0.3.2-beta.1", prerelease = true), + release("3.2.0.0", draft = true), + release("3.1.1.0"), + ), + currentVersion = "3.1.0.0", + ) + + assertEquals("3.1.1.0", selected?.release?.tagName) + } + + @Test + fun `stable channel excludes prereleases`() { + val selected = UpdateManager.selectCompatibleUpdate( + releases = listOf( + release("v3.2.0-beta.1", prerelease = true), + release("3.1.1.0"), + ), + currentVersion = "3.1.0.0", + channel = UpdateChannel.STABLE, + force = false, + ) + + assertEquals("3.1.1.0", selected?.release?.tagName) + } + + @Test + fun `beta channel includes prereleases`() { + val selected = UpdateManager.selectCompatibleUpdate( + releases = listOf( + release("v3.3.0-beta.1", prerelease = true, draft = true), + release("v3.2.0-beta.1", prerelease = true), + release("3.1.1.0"), + ), + currentVersion = "3.1.0.0", + channel = UpdateChannel.BETA, + force = false, + ) + + assertEquals("v3.2.0-beta.1", selected?.release?.tagName) + } + + @Test + fun `force update allows same version but never downgrades`() { + val sameVersion = UpdateManager.selectCompatibleUpdate( + releases = listOf(release("3.1.0.0")), + currentVersion = "3.1.0.0", + channel = UpdateChannel.STABLE, + force = true, + ) + val downgrade = UpdateManager.selectCompatibleUpdate( + releases = listOf(release("3.0.9.0")), + currentVersion = "3.1.0.0", + channel = UpdateChannel.STABLE, + force = true, + ) + + assertEquals("3.1.0.0", sameVersion?.release?.tagName) + assertNull(downgrade) + } + + @Test + fun `beta channel can select newer protocol three release`() { + val selected = UpdateManager.selectCompatibleUpdate( + releases = listOf( + release("3.2.0.0"), + release("v3.2.0-beta.2", prerelease = true), + ), + currentVersion = "3.1.0.0", + channel = UpdateChannel.BETA, + force = true, + ) + + assertEquals("3.2.0.0", selected?.release?.tagName) + } + + private fun release( + version: String, + prerelease: Boolean = false, + draft: Boolean = false, + ) = GitHubRelease( + tagName = version, + prerelease = prerelease, + draft = draft, + assets = listOf( + GitHubAsset( + name = "RemoteCI.Mobile-${version.removePrefix("v")}.apk", + browserDownloadUrl = "https://example/${version.removePrefix("v")}.apk", + ), + ), + ) +} diff --git a/android/app/src/test/java/com/remoteci/mobile/notif/UpdateReceiverTest.kt b/android/app/src/test/java/com/remoteci/mobile/notif/UpdateReceiverTest.kt new file mode 100644 index 0000000..9bcbaa5 --- /dev/null +++ b/android/app/src/test/java/com/remoteci/mobile/notif/UpdateReceiverTest.kt @@ -0,0 +1,27 @@ +package com.remoteci.mobile.notif + +import android.content.pm.PackageInstaller +import kotlin.test.Test +import kotlin.test.assertEquals + +class UpdateReceiverTest { + @Test + fun `pending user action opens system installer confirmation`() { + assertEquals( + InstallStatusAction.RequestUserConfirmation, + UpdateReceiver.actionForStatus(PackageInstaller.STATUS_PENDING_USER_ACTION), + ) + } + + @Test + fun `terminal package installer statuses become success or failure`() { + assertEquals( + InstallStatusAction.Success, + UpdateReceiver.actionForStatus(PackageInstaller.STATUS_SUCCESS), + ) + assertEquals( + InstallStatusAction.Failure, + UpdateReceiver.actionForStatus(PackageInstaller.STATUS_FAILURE_INVALID), + ) + } +} diff --git a/android/app/src/test/java/com/remoteci/mobile/ui/RemoteCiAppTest.kt b/android/app/src/test/java/com/remoteci/mobile/ui/RemoteCiAppTest.kt new file mode 100644 index 0000000..da10f52 --- /dev/null +++ b/android/app/src/test/java/com/remoteci/mobile/ui/RemoteCiAppTest.kt @@ -0,0 +1,15 @@ +package com.remoteci.mobile.ui + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class RemoteCiAppTest { + @Test + fun `home tabs share transition key so bottom navigation stays fixed`() { + val keys = HomeTab.entries.map { screenTransitionKey(Screen.Home(it)) }.toSet() + + assertEquals(setOf("home"), keys) + assertNotEquals(screenTransitionKey(Screen.Account), screenTransitionKey(Screen.Home())) + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..22e18dd --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,26 @@ +import java.util.Properties + +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false +} + +// 云盘、NAS 按需文件系统会把 build 中的普通文件转换为重解析占位文件, +// Gradle 9 无法对这类输出做增量快照;允许本机把生成目录放到普通本地磁盘。 +val localProperties = Properties().apply { + val file = rootProject.file("local.properties") + if (file.isFile) file.inputStream().use { load(it) } +} +val externalBuildRootPath = providers.gradleProperty("remoteci.buildDir").orNull + ?: System.getenv("REMOTECI_ANDROID_BUILD_DIR") + ?: localProperties.getProperty("remoteci.buildDir") +externalBuildRootPath?.trim()?.takeIf(String::isNotEmpty)?.let { configuredPath -> + val externalBuildRoot = rootProject.file(configuredPath) + layout.buildDirectory.set(externalBuildRoot.resolve("root")) + subprojects { + val projectDirectoryName = path.removePrefix(":").replace(':', '/') + layout.buildDirectory.set(externalBuildRoot.resolve(projectDirectoryName)) + } +} diff --git a/android/dev.ps1 b/android/dev.ps1 new file mode 100644 index 0000000..7d88f20 --- /dev/null +++ b/android/dev.ps1 @@ -0,0 +1,62 @@ +# RemoteCI Wear OS 开发辅助脚本 +# 用法: +# .\dev.ps1 emulator - 启动 Wear OS 模拟器(已运行时跳过) +# .\dev.ps1 run - 构建 APK -> 安装 -> 启动应用 +# 说明: 路径为本机开发环境配置,换机器时按 docs/platform-notes.md 调整。 +param([string]$Action = "run") + +$ErrorActionPreference = "Stop" +$Sdk = "C:\Users\YangTianming\AppData\Local\Android\Sdk" +$Jbr = "E:\Android Studio\jbr" +$Avd = "Pixel_8_API35" + +$env:JAVA_HOME = $Jbr +$env:ANDROID_HOME = $Sdk +$adb = "$Sdk\platform-tools\adb.exe" +$emulator = "$Sdk\emulator\emulator.exe" + +switch ($Action) { + "emulator" { + $running = & $adb devices | Select-String "emulator-\d+\s+device" + if (-not $running) { + Start-Process -FilePath $emulator -ArgumentList "-avd", $Avd, "-no-snapshot", "-no-boot-anim", "-gpu", "auto" -WindowStyle Hidden + Write-Host "模拟器启动中: $Avd (首次冷启动约 2-3 分钟,可用 adb devices 查看状态)" + } else { + Write-Host "模拟器已在运行: $($running.Line.Trim())" + } + } + "run" { + Write-Host "==> 构建 APK" + & .\gradlew.bat assembleDebug + if ($LASTEXITCODE -ne 0) { throw "构建失败,请查看上方日志" } + + # 与 build.gradle.kts 使用相同的本机构建目录优先级,避免云盘项目中的重解析文件导致 Gradle 快照失败。 + $externalBuildRoot = $env:REMOTECI_ANDROID_BUILD_DIR + if (-not $externalBuildRoot -and (Test-Path ".\local.properties")) { + $buildDirEntry = Get-Content ".\local.properties" | + Where-Object { $_ -match '^remoteci\.buildDir=' } | + Select-Object -Last 1 + if ($buildDirEntry) { + $externalBuildRoot = ($buildDirEntry -split '=', 2)[1].Trim().Replace('\:', ':') + } + } + if ($externalBuildRoot -and -not [IO.Path]::IsPathRooted($externalBuildRoot)) { + $externalBuildRoot = Join-Path $PSScriptRoot $externalBuildRoot + } + $apk = if ($externalBuildRoot) { + Join-Path $externalBuildRoot "app\outputs\apk\debug\app-debug.apk" + } else { + "app\build\outputs\apk\debug\app-debug.apk" + } + Write-Host "==> 安装 $apk" + & $adb install -r $apk + if ($LASTEXITCODE -ne 0) { throw "安装失败(模拟器是否已启动?)" } + + Write-Host "==> 启动应用" + & $adb shell am start -n com.remoteci.mobile/.MainActivity + Write-Host "完成。" + } + default { + Write-Host "用法: .\dev.ps1 emulator|run" + } +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..a1f2d0c --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,8 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +# AGP 9 默认启用内置 Kotlin;本项目显式使用经典 KGP(Kotlin 2.4.10 + Compose/Serialization 插件版本自洽), +# 当前通过 android.newDsl=false 回退到旧 DSL 以兼容经典 KGP(AGP 9 官方过渡方案)。 +# 注意:AGP 10 将移除 builtInKotlin/newDsl 开关,届时需迁移到 AGP 内置 Kotlin。 +android.builtInKotlin=false +android.newDsl=false diff --git a/android/gradle/gradle-daemon-jvm.properties b/android/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..fa4ed51 --- /dev/null +++ b/android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect +toolchainVersion=25 diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 0000000..edc4532 --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,38 @@ +[versions] +agp = "9.3.1" +kotlin = "2.4.10" +composeBom = "2026.06.01" +activityCompose = "1.13.0" +lifecycle = "2.11.0" +coreKtx = "1.19.0" +appcompat = "1.7.1" +navigationCompose = "2.9.6" +kotlinxSerialization = "1.11.0" +coroutines = "1.11.0" +okhttp = "5.4.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version = "4.3.0" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +mockwebserver3 = { group = "com.squareup.okhttp3", name = "mockwebserver3", version.ref = "okhttp" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..eddabd2 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1e922f4 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..203529c --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..7e60b72 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..975e525 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "RemoteCI" +include(":app") diff --git a/docs/deployment.md b/docs/deployment.md index 386349c..d4e9954 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -33,10 +33,11 @@ Compose 只把 `127.0.0.1:8080` 暴露给宿主机,并把 SQLite 挂载到命 ## 3. 首次登录与账号 1. 打开公开 HTTPS 地址,使用 `admin` 和初始密码登录。 -2. 在“个人账号”中修改密码;改密会撤销所有手表设备会话。 -3. 在“人员权限”中为每个同学建立唯一 ID、账号名和初始密码;登录时只输入 ID。 -4. 管理员拥有全部权限且不显示附加权限选项;学生默认只看当前课,可按需勾选六项附加权限。 -5. 学生登录后可以在个人页面修改自己的密码和撤销设备。 +2. 登录页勾选“保持登录”可在关闭浏览器后继续保留 WebUI 会话;未勾选时会话随浏览器关闭失效。 +3. 在“个人账号”中修改密码;改密会撤销所有手表设备会话。 +4. 在“人员权限”中为每个同学建立唯一 ID、账号名和初始密码;登录时只输入 ID。 +5. 管理员拥有全部权限且不显示附加权限选项;学生默认只看当前课,可按需勾选六项附加权限。 +6. 学生登录后可以在个人页面修改自己的密码和撤销设备。 现有账号区域只列出账号名、ID 和角色。需要修改账号资料、启用状态、学生附加权限、密码或删除账号时,先点击该行右侧的“编辑”打开对话框。 diff --git a/docs/platform-notes.md b/docs/platform-notes.md index bd03729..4b367d4 100644 --- a/docs/platform-notes.md +++ b/docs/platform-notes.md @@ -169,3 +169,25 @@ cd wearos ``` 本机 2026-08-11 验证使用 Eclipse Temurin 17.0.20 和现有 Android SDK,Debug APK 构建成功。 + + +## Android 手机应用 + +源码在 `android/`,包名 `com.remoteci.mobile`,minSdk 26。构建方式与手表端相同,使用同一套发布签名。 + +- WebUI 概览页生成当前访问地址的登录二维码;二维码不包含账号、密码或令牌,手机端扫码后仅填写服务器地址。 +- 手机端设备名以 `Android · 厂商 型号` 上报,设备管理据此显示手机图标;旧会话在重新登录前仍可能沿用原设备名。 +- 浅色、深色或跟随系统与六套 Material 3 配色由根主题统一提供,系统状态栏和导航栏图标亮暗随主题同步。 +- 控制操作统一通过根级 Snackbar 显示真实回执;WebSocket 拒绝发送时立即提示连接已断开。 +- 局域网 WebSocket 连接仍会先刷新并保留云端短期访问令牌,供账号、角色、访客、凭证、备份等 WebUI 管理 API 使用;此前只连局域网时令牌为空会导致这些页面整体失效。 +- 访客访问与自动进入访客页收纳在独立子页面,账号创建通过下拉列表选择服务端角色,配对码支持选择与复制。 +- 首页底栏的四个板块共享同一个页面动画容器,切换板块只替换正文,底栏不参与横向转场。 +- 手机端六套主题与 Wear OS `WatchPalette` 使用相同色值,外观页采用 Material 3 `ListItem` 与单选控件。 + +```powershell +$env:JAVA_HOME = 'E:\Android Studio\jbr' +cd android +.\gradlew.bat :app:assembleRelease +``` + +本机可将构建目录放到 `android/local.properties` 的 `remoteci.buildDir`。Debug/Release APK 默认在 `android/app/build/outputs/apk/`,若配置了外部目录则在该目录的 `app/outputs/apk/`。 diff --git a/docs/protocol.md b/docs/protocol.md index 6b8d457..96aa22a 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -23,7 +23,7 @@ V3 内只能增加可选字段、新消息和新能力,未知字段与未知 管理员的有效权限固定为 1023。普通用户固定包含值 1,其余权限来自服务端授权。权限设置界面将值 2 显示为“概览”;七日课表查看和手动拉取只要求账号已登录,值 16 保护换课和自动拉取设置。`TeacherComing` 单独保护“老师来了”,`SendNotifications` 只保护自定义通知与清除提醒,`SendVoiceMessages` 独立保护语音消息,`RunExtensions` 是所有插件扩展的独立权限,`MainMenuControl` 保护主界面显隐,`PowerControl` 保护音量和 Windows 电源操作。 -账号密码只出现在第一次 HTTPS `POST /api/auth/login` 的请求内。响应包含 1 小时 `accessToken`、30 天 `deviceSessionId/deviceSecret` 和用户有效权限。`POST /api/auth/refresh` 会同时轮换访问令牌和设备密钥;旧值立即失效。 +账号密码只出现在第一次 `POST /api/auth/login` 的请求内。生产环境必须使用 HTTPS;Android 手机端为兼容尚未配置 TLS 的现有部署,允许用户在持续显示风险提示的情况下明确连接 HTTP 云服务器。响应包含 1 小时 `accessToken`、30 天 `deviceSessionId/deviceSecret` 和用户有效权限。`POST /api/auth/refresh` 会同时轮换访问令牌和设备密钥;旧值立即失效。 ## 局域网挑战认证 @@ -55,7 +55,7 @@ V3 内只能增加可选字段、新消息和新能力,未知字段与未知 | `capabilities_sync` | 服务端/插件 → 手表 | 服务端能力和当前主插件能力快照 | | `state_push` | 插件 → 服务端/手表 | 高频当前课程、提醒播放、主界面显隐与可用电源状态,不含完整课表 | | `schedule_sync` | 插件 → 服务端/手表 | 今天起七天的日期、课程、科目清单和每日修订号 | -| `schedule_pull` | 服务端/手表 → 插件 | 只读请求,载荷可含 `{taskId, source, requestedAt}`,要求插件立即重新生成并推送七日课表 | +| `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 自动化或第三方插件通知 | @@ -144,10 +144,14 @@ Windows 插件自动播放录音,并显示上述 ClassIsland 通知:强调 - `GET/DELETE /api/me/sessions` - `GET /api/state`、`GET /api/schedule` - `POST /api/commands` -- `GET/POST/PUT/DELETE /api/users` +- `GET/POST/PUT/DELETE /api/users`、`/api/roles`、`/api/visitor` - `POST /api/users/{id}/password` - `POST /api/plugin/pairing-code` - `GET /api/admin/status` +- `GET/POST/PUT/DELETE /api/roles`、`GET/PUT /api/visitor` +- `GET/PUT /api/settings/notifications`、`GET/PUT /api/settings/schedule-pull` +- `GET/PUT /api/extensions` +- `GET /api/admin/system`、`POST /api/admin/updates/check`、`GET/POST/DELETE /api/admin/backups` REST 手表请求使用 `Authorization: Bearer `。Razor WebUI 使用 HttpOnly、SameSite Cookie 和表单防伪令牌,不把令牌放入浏览器存储。 diff --git a/plugin/RemoteCI.Plugin/Services/AccountMirror.cs b/plugin/RemoteCI.Plugin/Services/AccountMirror.cs index 49602d3..f095fc8 100644 --- a/plugin/RemoteCI.Plugin/Services/AccountMirror.cs +++ b/plugin/RemoteCI.Plugin/Services/AccountMirror.cs @@ -28,6 +28,8 @@ public AccountMirror(string path) } public long Version { get { lock (_gate) return _sync.Version; } } public string ServerVersion { get { lock (_gate) return _sync.ServerVersion; } } + /// 插件归属班级名称(服务端按班级生成授权镜像时下发);旧版服务端为 null。 + public string? ClassName { get { lock (_gate) return _sync.ClassName; } } public IReadOnlyList ServerCapabilities { get diff --git a/plugin/RemoteCI.Plugin/Services/RemoteCiService.cs b/plugin/RemoteCI.Plugin/Services/RemoteCiService.cs index 3c015b9..0710b13 100644 --- a/plugin/RemoteCI.Plugin/Services/RemoteCiService.cs +++ b/plugin/RemoteCI.Plugin/Services/RemoteCiService.cs @@ -60,6 +60,9 @@ public RemoteCiService( public CloudConnectionStatus CurrentCloudConnectionStatus => _cloudClient?.CurrentStatus ?? CloudConnectionStatus.Stopped(); + /// 插件归属班级名称(来自按班级生成的授权镜像);旧版服务端为 null。 + public string? CurrentClassName => _accounts.ClassName; + public void Start() { if (_cts is not null) diff --git a/plugin/RemoteCI.Plugin/Services/ScheduleSyncTaskCoordinator.cs b/plugin/RemoteCI.Plugin/Services/ScheduleSyncTaskCoordinator.cs index a6be418..4e9106f 100644 --- a/plugin/RemoteCI.Plugin/Services/ScheduleSyncTaskCoordinator.cs +++ b/plugin/RemoteCI.Plugin/Services/ScheduleSyncTaskCoordinator.cs @@ -96,6 +96,7 @@ private bool TryFinish(string taskId, ScheduleSyncTaskState state, string messag ScheduleSyncSource.Watch => "手表端拉取", ScheduleSyncSource.Automatic => "自动拉取", ScheduleSyncSource.Connection => "连接初始化拉取", + ScheduleSyncSource.Mobile => "手机端拉取", _ => "远端拉取", }; } diff --git a/plugin/RemoteCI.Plugin/Views/SettingsPages/RemoteCiSettingsPage.cs b/plugin/RemoteCI.Plugin/Views/SettingsPages/RemoteCiSettingsPage.cs index cd15283..a2d7d66 100644 --- a/plugin/RemoteCI.Plugin/Views/SettingsPages/RemoteCiSettingsPage.cs +++ b/plugin/RemoteCI.Plugin/Views/SettingsPages/RemoteCiSettingsPage.cs @@ -250,8 +250,14 @@ private void OnScheduleSyncStatusChanged(ScheduleSyncStatus status) => private void OnCloudConnectionStatusChanged(CloudConnectionStatus status) => Dispatcher.UIThread.Post(() => ApplyCloudConnectionStatus(status)); - internal static string ConnectionStatusText(CloudConnectionStatus status) => - $"服务器状态:{status.Summary}"; + internal string ConnectionStatusText(CloudConnectionStatus status) => + ConnectionStatusText(status, _service?.CurrentClassName); + + internal static string ConnectionStatusText(CloudConnectionStatus status, string? className) + { + var baseText = $"服务器状态:{status.Summary}"; + return string.IsNullOrWhiteSpace(className) ? baseText : $"{baseText} · 所属班级:{className}"; + } private void ApplyCloudConnectionStatus(CloudConnectionStatus status) { diff --git a/plugin/tests/RemoteCI.Plugin.Tests/SettingsPageTests.cs b/plugin/tests/RemoteCI.Plugin.Tests/SettingsPageTests.cs index c3f58ce..adedc36 100644 --- a/plugin/tests/RemoteCI.Plugin.Tests/SettingsPageTests.cs +++ b/plugin/tests/RemoteCI.Plugin.Tests/SettingsPageTests.cs @@ -60,7 +60,16 @@ public void ConnectionStatusText_ShowsCurrentServerState() Assert.Equal( "服务器状态:连接失败,将在 5 秒后自动重试", - RemoteCiSettingsPage.ConnectionStatusText(status)); + RemoteCiSettingsPage.ConnectionStatusText(status, null)); + Assert.Equal( + "服务器状态:已连接 · 所属班级:高一(1)班", + RemoteCiSettingsPage.ConnectionStatusText( + new CloudConnectionStatus( + CloudConnectionState.Connected, + "已连接", + null, + DateTimeOffset.UtcNow), + "高一(1)班")); } [Fact] diff --git a/remoteci-mobile-m3e-lite.json b/remoteci-mobile-m3e-lite.json index a923301..4b746ae 100644 --- a/remoteci-mobile-m3e-lite.json +++ b/remoteci-mobile-m3e-lite.json @@ -1,93 +1,955 @@ { - "title": "RemoteCI Mobile", - "brief": "RemoteCI 手机版:集中处理设备控制、实时查看、任务告警和人员权限。", - "frame": "phone", - "platform": "android", - "paletteKey": "teal", - "theme": { "dark": false, "bothModes": true, "contrast": "standard", "shape": "rounded", "font": "roboto", "emphasized": false, "motion": "expressive" }, + "groups": [ + { + "id": "home-bar-group", + "x": 0, + "y": 0, + "axis": "x", + "items": [ + { + "id": "home-bar", + "kind": "topAppBar", + "label": "RemoteCI", + "icon": "menu", + "icon2": "notifications", + "variant": "filled", + "actions": { + "icon": { + "to": "account", + "transition": "slide" + } + }, + "note": "点击左上角菜单显示账号切换、退出登录和服务器连接入口。" + } + ] + }, + { + "id": "home-current-group", + "x": 16, + "y": 104, + "axis": "x", + "items": [ + { + "id": "home-current", + "kind": "card", + "label": "上课", + "supporting": "第二节 机房", + "icon": "computer", + "variant": "filled", + "fill": "primaryContainer", + "size": 380, + "size2": 168, + "noImage": true, + "action": { + "to": "quick-change", + "transition": "expand" + }, + "note": "点击当前课程,带入第二节作为源课程并快速换课。" + } + ] + }, + { + "id": "home-next-group", + "x": 16, + "y": 288, + "axis": "y", + "items": [ + { + "id": "home-next", + "kind": "listItem", + "label": "下一节 · 体育", + "supporting": "第三节 10:05–10:50 · 操场", + "icon": "directions_run", + "icon2": "swap_horiz", + "variant": "filled", + "action": { + "to": "quick-change", + "transition": "slide" + }, + "note": "点击下一节,带入第三节作为源课程并快速换课。" + }, + { + "id": "home-sync", + "kind": "listItem", + "label": "RemoteCI 已连接", + "supporting": "课表已于 08:02 同步 · 手表已连接", + "icon": "cloud_done", + "icon2": "check_circle", + "variant": "filled" + } + ] + }, + { + "id": "home-actions-group", + "x": 16, + "y": 451, + "axis": "x", + "items": [ + { + "id": "home-schedule", + "kind": "button", + "label": "课表", + "icon": "calendar_month", + "variant": "filled", + "size": 182, + "action": { + "to": "schedule", + "transition": "slide" + } + }, + { + "id": "home-controls", + "kind": "button", + "label": "控制", + "icon": "tune", + "variant": "tonal", + "size": 182, + "action": { + "to": "controls", + "transition": "slide" + } + } + ] + }, + { + "id": "home-nav-group", + "x": 0, + "y": 788, + "axis": "x", + "items": [ + { + "id": "home-nav", + "kind": "bottomNav", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "icon": "today", + "label": "今天" + }, + { + "icon": "calendar_month", + "label": "课表" + }, + { + "icon": "tune", + "label": "控制" + }, + { + "icon": "group", + "label": "人员" + } + ], + "selected": 0, + "actions": { + "tab:1": { + "to": "schedule", + "transition": "fade" + }, + "tab:2": { + "to": "controls", + "transition": "fade" + }, + "tab:3": { + "to": "people", + "transition": "fade" + } + } + } + ] + }, + { + "id": "schedule-bar-group", + "x": 492, + "y": 0, + "axis": "x", + "items": [ + { + "id": "schedule-bar", + "kind": "topAppBar", + "label": "课表", + "icon": "arrow_back", + "icon2": "refresh", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + } + } + ] + }, + { + "id": "schedule-tabs-group", + "x": 492, + "y": 88, + "axis": "x", + "items": [ + { + "id": "schedule-tabs", + "kind": "tabs", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "label": "周一 15" + }, + { + "label": "周二 16" + }, + { + "label": "周三 17" + }, + { + "label": "周四 18" + }, + { + "label": "周五 19" + } + ], + "selected": 0 + } + ] + }, + { + "id": "schedule-list-group", + "x": 508, + "y": 160, + "axis": "y", + "items": [ + { + "id": "schedule-first", + "kind": "listItem", + "label": "第一节 · 数学", + "supporting": "08:00–08:45 · 教学楼 301", + "icon": "functions", + "icon2": "edit", + "variant": "filled" + }, + { + "id": "schedule-second", + "kind": "listItem", + "label": "第二节 · 信息技术", + "supporting": "09:00–09:45 · 机房", + "icon": "computer", + "icon2": "edit", + "variant": "filled" + }, + { + "id": "schedule-third", + "kind": "listItem", + "label": "第三节 · 体育", + "supporting": "10:05–10:50 · 操场", + "icon": "directions_run", + "icon2": "edit", + "variant": "filled" + } + ] + }, + { + "id": "schedule-actions-group", + "x": 508, + "y": 424, + "axis": "x", + "items": [ + { + "id": "schedule-pull", + "kind": "button", + "label": "拉取课表", + "icon": "sync", + "variant": "outlined", + "size": 182, + "note": "向在线插件请求重新生成七日课表;任务运行中会显示来源并拒绝重复请求。" + }, + { + "id": "schedule-change", + "kind": "button", + "label": "换课", + "icon": "swap_horiz", + "variant": "filled", + "size": 182, + "note": "选择日期和源、目标节次;提交前校验课表修订号,成功后同步在线手表。" + } + ] + }, + { + "id": "schedule-nav-group", + "x": 492, + "y": 788, + "axis": "x", + "items": [ + { + "id": "schedule-nav", + "kind": "bottomNav", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "icon": "today", + "label": "今天" + }, + { + "icon": "calendar_month", + "label": "课表" + }, + { + "icon": "tune", + "label": "控制" + }, + { + "icon": "group", + "label": "人员" + } + ], + "selected": 1, + "actions": { + "tab:0": { + "to": "home", + "transition": "fade" + }, + "tab:2": { + "to": "controls", + "transition": "fade" + }, + "tab:3": { + "to": "people", + "transition": "fade" + } + } + } + ] + }, + { + "id": "quick-change-bar-group", + "x": 1476, + "y": 0, + "axis": "x", + "items": [ + { + "id": "quick-change-bar", + "kind": "topAppBar", + "label": "快速换课", + "icon": "arrow_back", + "icon2": "help", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + } + } + ] + }, + { + "id": "quick-change-source-group", + "x": 1492, + "y": 104, + "axis": "x", + "items": [ + { + "id": "quick-change-source", + "kind": "card", + "label": "当前选择 · 第二节 信息技术", + "supporting": "09:00–09:45 · 机房\n从首页进入时自动预选被点击的当前课或下一节课。", + "icon": "computer", + "variant": "filled", + "fill": "primaryContainer", + "size": 380, + "size2": 136, + "noImage": true + } + ] + }, + { + "id": "quick-change-target-group", + "x": 1492, + "y": 264, + "axis": "y", + "items": [ + { + "id": "quick-change-mode", + "kind": "select", + "label": "交换课程", + "supporting": "也可切换为替换课程", + "icon": "swap_horiz", + "variant": "outlined", + "tabs": [ + { + "label": "交换课程" + }, + { + "label": "替换课程" + } + ], + "selected": 0 + }, + { + "id": "quick-change-target", + "kind": "select", + "label": "目标 · 第三节 体育", + "supporting": "10:05–10:50 · 操场", + "icon": "directions_run", + "variant": "outlined", + "tabs": [ + { + "label": "第一节 · 数学" + }, + { + "label": "第三节 · 体育" + }, + { + "label": "第四节 · 英语" + } + ], + "selected": 1 + } + ] + }, + { + "id": "quick-change-submit-group", + "x": 1492, + "y": 424, + "axis": "x", + "items": [ + { + "id": "quick-change-submit", + "kind": "button", + "label": "确认换课", + "icon": "check", + "variant": "filled", + "size": 380, + "action": { + "to": "home", + "transition": "slide" + }, + "note": "提交前校验课表修订号;成功后更新今日课堂、七日课表和在线手表。" + } + ] + }, + { + "id": "controls-bar-group", + "x": 984, + "y": 0, + "axis": "x", + "items": [ + { + "id": "controls-bar", + "kind": "topAppBar", + "label": "课堂控制", + "icon": "arrow_back", + "icon2": "more_vert", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + } + } + ] + }, + { + "id": "controls-list-group", + "x": 1000, + "y": 104, + "axis": "y", + "items": [ + { + "id": "control-notify", + "kind": "listItem", + "label": "发送通知", + "supporting": "标题、正文、强调特效、音效和语音朗读", + "icon": "notifications_active", + "icon2": "chevron_right", + "variant": "filled", + "note": "发送到 ClassIsland,并广播给在线手表。" + }, + { + "id": "control-voice", + "kind": "listItem", + "label": "发送语音", + "supporting": "最长 60 秒的语音消息", + "icon": "mic", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-clear", + "kind": "listItem", + "label": "清除提醒", + "supporting": "清除 ClassIsland 当前提醒与手表提示", + "icon": "notifications_off", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-teacher", + "kind": "listItem", + "label": "老师来了", + "supporting": "显示提醒,等待一秒后自动清除", + "icon": "campaign", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-main-menu", + "kind": "listItem", + "label": "主界面", + "supporting": "显示或隐藏 ClassIsland 主界面", + "icon": "visibility", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-volume", + "kind": "listItem", + "label": "音量", + "supporting": "音量调节和切换静音", + "icon": "volume_up", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-power", + "kind": "listItem", + "label": "电源控制", + "supporting": "关机、睡眠或休眠", + "icon": "power_settings_new", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-extensions", + "kind": "listItem", + "label": "扩展功能", + "supporting": "运行已注册扩展", + "icon": "extension", + "icon2": "chevron_right", + "variant": "filled", + "note": "管理员可逐项管理扩展策略;普通账号只管理自己的手表展示偏好。" + } + ] + }, + { + "id": "controls-nav-group", + "x": 984, + "y": 788, + "axis": "x", + "items": [ + { + "id": "controls-nav", + "kind": "bottomNav", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "icon": "today", + "label": "今天" + }, + { + "icon": "calendar_month", + "label": "课表" + }, + { + "icon": "tune", + "label": "控制" + }, + { + "icon": "group", + "label": "人员" + } + ], + "selected": 2, + "actions": { + "tab:0": { + "to": "home", + "transition": "fade" + }, + "tab:1": { + "to": "schedule", + "transition": "fade" + }, + "tab:3": { + "to": "people", + "transition": "fade" + } + } + } + ] + }, + { + "id": "people-bar-group", + "x": 0, + "y": 972, + "axis": "x", + "items": [ + { + "id": "people-bar", + "kind": "topAppBar", + "label": "人员与凭证", + "icon": "arrow_back", + "icon2": "person_add", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + } + } + ] + }, + { + "id": "people-list-group", + "x": 16, + "y": 1080, + "axis": "y", + "items": [ + { + "id": "people-visitor", + "kind": "listItem", + "label": "访客访问", + "supporting": "启用访客功能与自动进入访客页", + "icon": "person_outline", + "switch": true, + "checked": false, + "variant": "filled" + }, + { + "id": "people-roles", + "kind": "listItem", + "label": "角色配置", + "supporting": "创建自定义角色,设置课表、通知、语音、控制与扩展权限", + "icon": "admin_panel_settings", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "people-users", + "kind": "listItem", + "label": "账号管理", + "supporting": "新建、编辑、启停账号,分配角色与个人附加权限", + "icon": "group", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "people-reset", + "kind": "listItem", + "label": "密码与设备会话", + "supporting": "重置密码时可撤销所有手表登录", + "icon": "key", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "people-pairing", + "kind": "listItem", + "label": "插件配对码", + "supporting": "添加、查看和撤销 ClassIsland 插件长期凭证", + "icon": "link", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "people-revoke", + "kind": "listItem", + "label": "已登录设备", + "supporting": "查看手表名称与最近使用时间,逐台吊销凭证", + "icon": "watch", + "icon2": "chevron_right", + "variant": "filled" + } + ] + }, + { + "id": "people-nav-group", + "x": 0, + "y": 1760, + "axis": "x", + "items": [ + { + "id": "people-nav", + "kind": "bottomNav", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "icon": "today", + "label": "今天" + }, + { + "icon": "calendar_month", + "label": "课表" + }, + { + "icon": "tune", + "label": "控制" + }, + { + "icon": "group", + "label": "人员" + } + ], + "selected": 3, + "actions": { + "tab:0": { + "to": "home", + "transition": "fade" + }, + "tab:1": { + "to": "schedule", + "transition": "fade" + }, + "tab:2": { + "to": "controls", + "transition": "fade" + } + } + } + ] + }, + { + "id": "account-bar-group", + "x": 492, + "y": 972, + "axis": "x", + "items": [ + { + "id": "account-bar", + "kind": "topAppBar", + "label": "账号与设置", + "icon": "arrow_back", + "icon2": "logout", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + }, + "note": "右上角退出登录;返回首页时保留当前账号会话。" + } + ] + }, + { + "id": "account-user-group", + "x": 508, + "y": 1080, + "axis": "x", + "items": [ + { + "id": "account-user", + "kind": "card", + "label": "杨 - 管理员", + "supporting": "已连接到 RemoteCI 服务器\n点击切换账号或连接其他服务器", + "icon": "account_circle", + "variant": "filled", + "fill": "secondaryContainer", + "size": 380, + "size2": 136, + "noImage": true, + "note": "支持选择保存的账号、退出当前账号,以及输入或扫描新的服务器地址后连接。" + } + ] + }, + { + "id": "account-list-group", + "x": 508, + "y": 1240, + "axis": "y", + "items": [ + { + "id": "account-connection", + "kind": "listItem", + "label": "连接与服务器", + "supporting": "连接", + "icon": "wifi", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-notifications", + "kind": "listItem", + "label": "通知设置", + "supporting": "课程、自动化和第三方插件提醒的同步开关", + "icon": "notifications", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-appearance", + "kind": "listItem", + "label": "外观", + "supporting": "主题与显示偏好", + "icon": "palette", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-update", + "kind": "listItem", + "label": "更新", + "supporting": "检查更新与同版本强制覆盖", + "icon": "system_update", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-developer", + "kind": "listItem", + "label": "手表开发者设置", + "supporting": "更多设置", + "icon": "code", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-system", + "kind": "listItem", + "label": "系统配置", + "supporting": "服务端更新与备份", + "icon": "settings", + "icon2": "chevron_right", + "variant": "filled" + } + ] + }, + { + "id": "sy4e0vvu", + "x": -424, + "y": 188, + "axis": "x", + "items": [ + { + "id": "hxpevg9n", + "kind": "topAppBar", + "label": "标题", + "icon": "menu", + "variant": "filled", + "icon2": "more_vert", + "size": 412 + } + ] + }, + { + "id": "dl9v07ip", + "x": 324, + "y": 203, + "axis": "x", + "items": [ + { + "id": "qbd6wzou", + "kind": "circularProgress", + "label": "", + "icon": null, + "variant": "filled", + "size": 48, + "value": 60, + "wavy": false, + "trackThickness": 7 + } + ] + }, + { + "id": "d1lf0ipy", + "x": 32, + "y": 220, + "axis": "x", + "items": [ + { + "id": "9uw594jt", + "kind": "text", + "label": "信息技术", + "icon": null, + "variant": "filled", + "size": 32, + "bold": true + } + ] + }, + { + "id": "3096gm0l", + "x": 32, + "y": 203, + "axis": "x", + "items": [ + { + "id": "8d5h5izc", + "kind": "text", + "label": " 09:00–09:45", + "icon": null, + "variant": "filled", + "size": 14, + "bold": true + } + ] + } + ], "frames": [ - { "id": "devices", "name": "设备", "x": 0, "y": 0, "note": "设备总览;按状态找到需要处置的设备。" }, - { "id": "control", "name": "设备控制", "x": 492, "y": 0, "note": "查看单台设备状态并发起受审计的远程操作。" }, - { "id": "monitor", "name": "实时监看", "x": 984, "y": 0, "note": "查看远程设备的实时画面及连接状态。" }, - { "id": "tasks", "name": "任务与告警", "x": 0, "y": 972, "note": "值班员的进行中任务、失败任务与告警队列。" }, - { "id": "team", "name": "团队与权限", "x": 492, "y": 972, "note": "管理成员、角色和可访问的设备组。" } + { + "id": "home", + "name": "今日课堂", + "x": 0, + "y": 0, + "note": "首页显示 ClassIsland 当前课堂、倒计时、下一节和同步状态;左上角菜单打开账号与连接面板。" + }, + { + "id": "schedule", + "name": "七日课表与调课", + "x": 492, + "y": 0, + "note": "查看七日课表、拉取最新数据;拥有权限时可交换或替换课程。" + }, + { + "id": "controls", + "name": "课堂控制", + "x": 984, + "y": 0, + "note": "每项 WebUI 与手表控制能力单独列出,根据账号权限和在线插件能力显示。" + }, + { + "id": "quick-change", + "name": "快速换课", + "x": 1476, + "y": 0, + "note": "从正在上课或下一节课程直接进入;预选所点课程为源课程,快速选择目标节次后提交。" + }, + { + "id": "people", + "name": "人员与凭证", + "x": 0, + "y": 972, + "note": "管理员管理访客访问、角色、账号、插件配对码与已登录设备凭证。" + }, + { + "id": "account", + "name": "账号与设置", + "x": 492, + "y": 972, + "note": "切换账号、退出登录、连接服务器,并集中管理与手表端一致的连接、通知、外观、更新和开发者设置。" + } ], - "groups": [ - { "id": "devices-bar-group", "x": 0, "y": 0, "axis": "x", "items": [ - { "id": "devices-bar", "kind": "topAppBar", "label": "设备", "icon": "menu", "icon2": "notifications", "variant": "filled" } - ] }, - { "id": "devices-search-group", "x": 16, "y": 104, "axis": "x", "items": [ - { "id": "devices-search", "kind": "searchBar", "label": "搜索设备、标签或负责人", "icon": "search", "icon2": "tune", "variant": "filled" } - ] }, - { "id": "devices-list-group", "x": 16, "y": 184, "axis": "y", "items": [ - { "id": "device-build", "kind": "listItem", "label": "build-agent-01", "supporting": "在线 · Windows · 构建中 72%", "icon": "computer", "icon2": "chevron_right", "variant": "filled", "action": { "to": "control", "transition": "slide" } }, - { "id": "device-deploy", "kind": "listItem", "label": "deploy-node-03", "supporting": "在线 · Linux · 正在部署", "icon": "dns", "icon2": "chevron_right", "variant": "filled", "action": { "to": "control", "transition": "slide" } }, - { "id": "device-phone", "kind": "listItem", "label": "lab-phone-07", "supporting": "告警 · Android · 9 分钟未响应", "icon": "smartphone", "icon2": "warning", "variant": "filled", "action": { "to": "control", "transition": "slide" } } - ] }, - { "id": "devices-nav-group", "x": 0, "y": 788, "axis": "x", "items": [ - { "id": "devices-nav", "kind": "bottomNav", "label": "", "icon": null, "variant": "filled", "tabs": [{ "icon": "devices", "label": "设备" }, { "icon": "visibility", "label": "监看" }, { "icon": "assignment", "label": "任务" }, { "icon": "group", "label": "团队" }], "selected": 0, "actions": { "tab:1": { "to": "monitor", "transition": "fade" }, "tab:2": { "to": "tasks", "transition": "fade" }, "tab:3": { "to": "team", "transition": "fade" } } } - ] }, - { "id": "control-bar-group", "x": 492, "y": 0, "axis": "x", "items": [ - { "id": "control-bar", "kind": "topAppBar", "label": "build-agent-01", "icon": "arrow_back", "icon2": "more_vert", "variant": "filled", "actions": { "icon": { "to": "back", "transition": "slide" } } } - ] }, - { "id": "control-summary-group", "x": 508, "y": 104, "axis": "x", "items": [ - { "id": "control-summary", "kind": "card", "label": "在线 · 运行正常", "supporting": "Windows 11 · CPU 48% · 内存 61%\n当前任务:release/3.2.1.5", "icon": "check_circle", "variant": "filled", "size": 380, "size2": 144, "noImage": true, "action": { "to": "monitor", "transition": "expand" } } - ] }, - { "id": "control-buttons-group", "x": 508, "y": 272, "axis": "x", "items": [ - { "id": "view-screen", "kind": "button", "label": "查看画面", "icon": "visibility", "variant": "filled", "size": 182, "action": { "to": "monitor", "transition": "expand" } }, - { "id": "open-terminal", "kind": "button", "label": "远程终端", "icon": "terminal", "variant": "tonal", "size": 182 } - ] }, - { "id": "control-maintenance-group", "x": 508, "y": 344, "axis": "x", "items": [ - { "id": "restart-device", "kind": "button", "label": "重启设备", "icon": "restart_alt", "variant": "outlined", "size": 182, "note": "需要二次确认,操作写入审计日志。" }, - { "id": "lock-session", "kind": "button", "label": "锁定会话", "icon": "lock", "variant": "outlined", "size": 182 } - ] }, - { "id": "control-list-group", "x": 508, "y": 440, "axis": "y", "items": [ - { "id": "control-job", "kind": "listItem", "label": "正在运行的任务", "supporting": "发布 RemoteCI 3.2.1.5 · 预计 6 分钟", "icon": "play_circle", "icon2": "chevron_right", "variant": "filled", "action": { "to": "tasks", "transition": "slide" } }, - { "id": "control-access", "kind": "listItem", "label": "设备访问者", "supporting": "5 人有控制权限", "icon": "group", "icon2": "chevron_right", "variant": "filled", "action": { "to": "team", "transition": "slide" } } - ] }, - { "id": "monitor-bar-group", "x": 984, "y": 0, "axis": "x", "items": [ - { "id": "monitor-bar", "kind": "topAppBar", "label": "实时监看", "icon": "arrow_back", "icon2": "fullscreen", "variant": "filled", "actions": { "icon": { "to": "back", "transition": "slide" } } } - ] }, - { "id": "monitor-device-group", "x": 1000, "y": 104, "axis": "x", "items": [ - { "id": "monitor-device", "kind": "select", "label": "build-agent-01", "supporting": "远程输入已连接 · 操作受审计", "icon": "computer", "variant": "outlined", "tabs": [{ "label": "build-agent-01" }, { "label": "deploy-node-03" }, { "label": "lab-phone-07" }], "selected": 0 } - ] }, - { "id": "monitor-preview-group", "x": 1000, "y": 192, "axis": "x", "items": [ - { "id": "monitor-preview", "kind": "card", "label": "远程桌面实时画面", "supporting": "低延迟画面 · 点击后进入全屏监看", "icon": "desktop_windows", "variant": "filled", "size": 380, "size2": 224, "noImage": true } - ] }, - { "id": "monitor-controls-group", "x": 1000, "y": 440, "axis": "x", "items": [ - { "id": "monitor-input", "kind": "button", "label": "鼠标与键盘", "icon": "keyboard", "variant": "filled", "size": 182 }, - { "id": "monitor-clipboard", "kind": "button", "label": "同步剪贴板", "icon": "content_copy", "variant": "tonal", "size": 182 } - ] }, - { "id": "monitor-nav-group", "x": 984, "y": 788, "axis": "x", "items": [ - { "id": "monitor-nav", "kind": "bottomNav", "label": "", "icon": null, "variant": "filled", "tabs": [{ "icon": "devices", "label": "设备" }, { "icon": "visibility", "label": "监看" }, { "icon": "assignment", "label": "任务" }, { "icon": "group", "label": "团队" }], "selected": 1, "actions": { "tab:0": { "to": "devices", "transition": "fade" }, "tab:2": { "to": "tasks", "transition": "fade" }, "tab:3": { "to": "team", "transition": "fade" } } } - ] }, - { "id": "tasks-bar-group", "x": 0, "y": 972, "axis": "x", "items": [ - { "id": "tasks-bar", "kind": "topAppBar", "label": "任务与告警", "icon": "menu", "icon2": "filter_list", "variant": "filled" } - ] }, - { "id": "tasks-list-group", "x": 16, "y": 1080, "axis": "y", "items": [ - { "id": "task-release", "kind": "listItem", "label": "发布 RemoteCI 3.2.1.5", "supporting": "build-agent-01 · 进行中 · 72%", "icon": "rocket_launch", "icon2": "chevron_right", "variant": "filled", "action": { "to": "control", "transition": "slide" } }, - { "id": "task-alert", "kind": "listItem", "label": "lab-phone-07 未响应", "supporting": "9 分钟前 · 需要确认设备状态", "icon": "warning", "icon2": "chevron_right", "variant": "filled", "action": { "to": "control", "transition": "slide" } }, - { "id": "task-deploy", "kind": "listItem", "label": "部署生产环境", "supporting": "deploy-node-03 · 等待审批", "icon": "cloud_upload", "icon2": "chevron_right", "variant": "filled" } - ] }, - { "id": "tasks-nav-group", "x": 0, "y": 1760, "axis": "x", "items": [ - { "id": "tasks-nav", "kind": "bottomNav", "label": "", "icon": null, "variant": "filled", "tabs": [{ "icon": "devices", "label": "设备" }, { "icon": "visibility", "label": "监看" }, { "icon": "assignment", "label": "任务" }, { "icon": "group", "label": "团队" }], "selected": 2, "actions": { "tab:0": { "to": "devices", "transition": "fade" }, "tab:1": { "to": "monitor", "transition": "fade" }, "tab:3": { "to": "team", "transition": "fade" } } } - ] }, - { "id": "team-bar-group", "x": 492, "y": 972, "axis": "x", "items": [ - { "id": "team-bar", "kind": "topAppBar", "label": "团队与权限", "icon": "menu", "icon2": "person_add", "variant": "filled" } - ] }, - { "id": "team-summary-group", "x": 508, "y": 1080, "axis": "x", "items": [ - { "id": "team-summary", "kind": "card", "label": "12 位成员 · 24 台设备", "supporting": "3 位管理员 · 4 个访问组\n权限变更均会写入审计日志。", "icon": "admin_panel_settings", "variant": "filled", "size": 380, "size2": 144, "noImage": true } - ] }, - { "id": "team-list-group", "x": 508, "y": 1248, "axis": "y", "items": [ - { "id": "member-owner", "kind": "listItem", "label": "杨天明", "supporting": "所有者 · 全部设备 · 最近在线", "icon": "account_circle", "icon2": "chevron_right", "variant": "filled" }, - { "id": "member-admin", "kind": "listItem", "label": "李明", "supporting": "管理员 · 发布环境", "icon": "account_circle", "icon2": "chevron_right", "variant": "filled" }, - { "id": "member-dev", "kind": "listItem", "label": "陈晓", "supporting": "开发者 · 测试设备组", "icon": "account_circle", "icon2": "chevron_right", "variant": "filled" } - ] }, - { "id": "team-invite-group", "x": 816, "y": 1656, "axis": "x", "items": [ - { "id": "team-invite", "kind": "fab", "label": "", "icon": "person_add", "variant": "filled", "size": 56, "note": "邀请成员并配置角色和设备组。" } - ] }, - { "id": "team-nav-group", "x": 492, "y": 1760, "axis": "x", "items": [ - { "id": "team-nav", "kind": "bottomNav", "label": "", "icon": null, "variant": "filled", "tabs": [{ "icon": "devices", "label": "设备" }, { "icon": "visibility", "label": "监看" }, { "icon": "assignment", "label": "任务" }, { "icon": "group", "label": "团队" }], "selected": 3, "actions": { "tab:0": { "to": "devices", "transition": "fade" }, "tab:1": { "to": "monitor", "transition": "fade" }, "tab:2": { "to": "tasks", "transition": "fade" } } } - ] } - ] -} + "paletteKey": "blue", + "frame": "phone", + "title": "RemoteCI", + "brief": "RemoteCI 课表手机版:同步 ClassIsland 课表与课堂状态,并完整承接 WebUI、手表控制、账号、角色和凭证管理。", + "platform": "android", + "dynamicColor": false, + "theme": { + "dark": false, + "bothModes": true, + "contrast": "standard", + "shape": "rounded", + "font": "roboto", + "emphasized": true, + "motion": "expressive" + } +} \ No newline at end of file diff --git a/remoteci-mobile-m3e.json b/remoteci-mobile-m3e.json index ea258af..4b746ae 100644 --- a/remoteci-mobile-m3e.json +++ b/remoteci-mobile-m3e.json @@ -1,120 +1,955 @@ { - "title": "RemoteCI Mobile", - "brief": "将 RemoteCI 的设备控制、实时查看、任务协作和人员管理浓缩到手机端。面向值班管理员:先看到风险,再用一次明确操作完成处置。", - "frame": "phone", - "platform": "android", - "paletteKey": "teal", - "theme": { "dark": true, "bothModes": true, "contrast": "standard", "shape": "rounded", "font": "robotoFlex", "emphasized": true, "motion": "expressive" }, + "groups": [ + { + "id": "home-bar-group", + "x": 0, + "y": 0, + "axis": "x", + "items": [ + { + "id": "home-bar", + "kind": "topAppBar", + "label": "RemoteCI", + "icon": "menu", + "icon2": "notifications", + "variant": "filled", + "actions": { + "icon": { + "to": "account", + "transition": "slide" + } + }, + "note": "点击左上角菜单显示账号切换、退出登录和服务器连接入口。" + } + ] + }, + { + "id": "home-current-group", + "x": 16, + "y": 104, + "axis": "x", + "items": [ + { + "id": "home-current", + "kind": "card", + "label": "上课", + "supporting": "第二节 机房", + "icon": "computer", + "variant": "filled", + "fill": "primaryContainer", + "size": 380, + "size2": 168, + "noImage": true, + "action": { + "to": "quick-change", + "transition": "expand" + }, + "note": "点击当前课程,带入第二节作为源课程并快速换课。" + } + ] + }, + { + "id": "home-next-group", + "x": 16, + "y": 288, + "axis": "y", + "items": [ + { + "id": "home-next", + "kind": "listItem", + "label": "下一节 · 体育", + "supporting": "第三节 10:05–10:50 · 操场", + "icon": "directions_run", + "icon2": "swap_horiz", + "variant": "filled", + "action": { + "to": "quick-change", + "transition": "slide" + }, + "note": "点击下一节,带入第三节作为源课程并快速换课。" + }, + { + "id": "home-sync", + "kind": "listItem", + "label": "RemoteCI 已连接", + "supporting": "课表已于 08:02 同步 · 手表已连接", + "icon": "cloud_done", + "icon2": "check_circle", + "variant": "filled" + } + ] + }, + { + "id": "home-actions-group", + "x": 16, + "y": 451, + "axis": "x", + "items": [ + { + "id": "home-schedule", + "kind": "button", + "label": "课表", + "icon": "calendar_month", + "variant": "filled", + "size": 182, + "action": { + "to": "schedule", + "transition": "slide" + } + }, + { + "id": "home-controls", + "kind": "button", + "label": "控制", + "icon": "tune", + "variant": "tonal", + "size": 182, + "action": { + "to": "controls", + "transition": "slide" + } + } + ] + }, + { + "id": "home-nav-group", + "x": 0, + "y": 788, + "axis": "x", + "items": [ + { + "id": "home-nav", + "kind": "bottomNav", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "icon": "today", + "label": "今天" + }, + { + "icon": "calendar_month", + "label": "课表" + }, + { + "icon": "tune", + "label": "控制" + }, + { + "icon": "group", + "label": "人员" + } + ], + "selected": 0, + "actions": { + "tab:1": { + "to": "schedule", + "transition": "fade" + }, + "tab:2": { + "to": "controls", + "transition": "fade" + }, + "tab:3": { + "to": "people", + "transition": "fade" + } + } + } + ] + }, + { + "id": "schedule-bar-group", + "x": 492, + "y": 0, + "axis": "x", + "items": [ + { + "id": "schedule-bar", + "kind": "topAppBar", + "label": "课表", + "icon": "arrow_back", + "icon2": "refresh", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + } + } + ] + }, + { + "id": "schedule-tabs-group", + "x": 492, + "y": 88, + "axis": "x", + "items": [ + { + "id": "schedule-tabs", + "kind": "tabs", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "label": "周一 15" + }, + { + "label": "周二 16" + }, + { + "label": "周三 17" + }, + { + "label": "周四 18" + }, + { + "label": "周五 19" + } + ], + "selected": 0 + } + ] + }, + { + "id": "schedule-list-group", + "x": 508, + "y": 160, + "axis": "y", + "items": [ + { + "id": "schedule-first", + "kind": "listItem", + "label": "第一节 · 数学", + "supporting": "08:00–08:45 · 教学楼 301", + "icon": "functions", + "icon2": "edit", + "variant": "filled" + }, + { + "id": "schedule-second", + "kind": "listItem", + "label": "第二节 · 信息技术", + "supporting": "09:00–09:45 · 机房", + "icon": "computer", + "icon2": "edit", + "variant": "filled" + }, + { + "id": "schedule-third", + "kind": "listItem", + "label": "第三节 · 体育", + "supporting": "10:05–10:50 · 操场", + "icon": "directions_run", + "icon2": "edit", + "variant": "filled" + } + ] + }, + { + "id": "schedule-actions-group", + "x": 508, + "y": 424, + "axis": "x", + "items": [ + { + "id": "schedule-pull", + "kind": "button", + "label": "拉取课表", + "icon": "sync", + "variant": "outlined", + "size": 182, + "note": "向在线插件请求重新生成七日课表;任务运行中会显示来源并拒绝重复请求。" + }, + { + "id": "schedule-change", + "kind": "button", + "label": "换课", + "icon": "swap_horiz", + "variant": "filled", + "size": 182, + "note": "选择日期和源、目标节次;提交前校验课表修订号,成功后同步在线手表。" + } + ] + }, + { + "id": "schedule-nav-group", + "x": 492, + "y": 788, + "axis": "x", + "items": [ + { + "id": "schedule-nav", + "kind": "bottomNav", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "icon": "today", + "label": "今天" + }, + { + "icon": "calendar_month", + "label": "课表" + }, + { + "icon": "tune", + "label": "控制" + }, + { + "icon": "group", + "label": "人员" + } + ], + "selected": 1, + "actions": { + "tab:0": { + "to": "home", + "transition": "fade" + }, + "tab:2": { + "to": "controls", + "transition": "fade" + }, + "tab:3": { + "to": "people", + "transition": "fade" + } + } + } + ] + }, + { + "id": "quick-change-bar-group", + "x": 1476, + "y": 0, + "axis": "x", + "items": [ + { + "id": "quick-change-bar", + "kind": "topAppBar", + "label": "快速换课", + "icon": "arrow_back", + "icon2": "help", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + } + } + ] + }, + { + "id": "quick-change-source-group", + "x": 1492, + "y": 104, + "axis": "x", + "items": [ + { + "id": "quick-change-source", + "kind": "card", + "label": "当前选择 · 第二节 信息技术", + "supporting": "09:00–09:45 · 机房\n从首页进入时自动预选被点击的当前课或下一节课。", + "icon": "computer", + "variant": "filled", + "fill": "primaryContainer", + "size": 380, + "size2": 136, + "noImage": true + } + ] + }, + { + "id": "quick-change-target-group", + "x": 1492, + "y": 264, + "axis": "y", + "items": [ + { + "id": "quick-change-mode", + "kind": "select", + "label": "交换课程", + "supporting": "也可切换为替换课程", + "icon": "swap_horiz", + "variant": "outlined", + "tabs": [ + { + "label": "交换课程" + }, + { + "label": "替换课程" + } + ], + "selected": 0 + }, + { + "id": "quick-change-target", + "kind": "select", + "label": "目标 · 第三节 体育", + "supporting": "10:05–10:50 · 操场", + "icon": "directions_run", + "variant": "outlined", + "tabs": [ + { + "label": "第一节 · 数学" + }, + { + "label": "第三节 · 体育" + }, + { + "label": "第四节 · 英语" + } + ], + "selected": 1 + } + ] + }, + { + "id": "quick-change-submit-group", + "x": 1492, + "y": 424, + "axis": "x", + "items": [ + { + "id": "quick-change-submit", + "kind": "button", + "label": "确认换课", + "icon": "check", + "variant": "filled", + "size": 380, + "action": { + "to": "home", + "transition": "slide" + }, + "note": "提交前校验课表修订号;成功后更新今日课堂、七日课表和在线手表。" + } + ] + }, + { + "id": "controls-bar-group", + "x": 984, + "y": 0, + "axis": "x", + "items": [ + { + "id": "controls-bar", + "kind": "topAppBar", + "label": "课堂控制", + "icon": "arrow_back", + "icon2": "more_vert", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + } + } + ] + }, + { + "id": "controls-list-group", + "x": 1000, + "y": 104, + "axis": "y", + "items": [ + { + "id": "control-notify", + "kind": "listItem", + "label": "发送通知", + "supporting": "标题、正文、强调特效、音效和语音朗读", + "icon": "notifications_active", + "icon2": "chevron_right", + "variant": "filled", + "note": "发送到 ClassIsland,并广播给在线手表。" + }, + { + "id": "control-voice", + "kind": "listItem", + "label": "发送语音", + "supporting": "最长 60 秒的语音消息", + "icon": "mic", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-clear", + "kind": "listItem", + "label": "清除提醒", + "supporting": "清除 ClassIsland 当前提醒与手表提示", + "icon": "notifications_off", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-teacher", + "kind": "listItem", + "label": "老师来了", + "supporting": "显示提醒,等待一秒后自动清除", + "icon": "campaign", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-main-menu", + "kind": "listItem", + "label": "主界面", + "supporting": "显示或隐藏 ClassIsland 主界面", + "icon": "visibility", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-volume", + "kind": "listItem", + "label": "音量", + "supporting": "音量调节和切换静音", + "icon": "volume_up", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-power", + "kind": "listItem", + "label": "电源控制", + "supporting": "关机、睡眠或休眠", + "icon": "power_settings_new", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-extensions", + "kind": "listItem", + "label": "扩展功能", + "supporting": "运行已注册扩展", + "icon": "extension", + "icon2": "chevron_right", + "variant": "filled", + "note": "管理员可逐项管理扩展策略;普通账号只管理自己的手表展示偏好。" + } + ] + }, + { + "id": "controls-nav-group", + "x": 984, + "y": 788, + "axis": "x", + "items": [ + { + "id": "controls-nav", + "kind": "bottomNav", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "icon": "today", + "label": "今天" + }, + { + "icon": "calendar_month", + "label": "课表" + }, + { + "icon": "tune", + "label": "控制" + }, + { + "icon": "group", + "label": "人员" + } + ], + "selected": 2, + "actions": { + "tab:0": { + "to": "home", + "transition": "fade" + }, + "tab:1": { + "to": "schedule", + "transition": "fade" + }, + "tab:3": { + "to": "people", + "transition": "fade" + } + } + } + ] + }, + { + "id": "people-bar-group", + "x": 0, + "y": 972, + "axis": "x", + "items": [ + { + "id": "people-bar", + "kind": "topAppBar", + "label": "人员与凭证", + "icon": "arrow_back", + "icon2": "person_add", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + } + } + ] + }, + { + "id": "people-list-group", + "x": 16, + "y": 1080, + "axis": "y", + "items": [ + { + "id": "people-visitor", + "kind": "listItem", + "label": "访客访问", + "supporting": "启用访客功能与自动进入访客页", + "icon": "person_outline", + "switch": true, + "checked": false, + "variant": "filled" + }, + { + "id": "people-roles", + "kind": "listItem", + "label": "角色配置", + "supporting": "创建自定义角色,设置课表、通知、语音、控制与扩展权限", + "icon": "admin_panel_settings", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "people-users", + "kind": "listItem", + "label": "账号管理", + "supporting": "新建、编辑、启停账号,分配角色与个人附加权限", + "icon": "group", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "people-reset", + "kind": "listItem", + "label": "密码与设备会话", + "supporting": "重置密码时可撤销所有手表登录", + "icon": "key", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "people-pairing", + "kind": "listItem", + "label": "插件配对码", + "supporting": "添加、查看和撤销 ClassIsland 插件长期凭证", + "icon": "link", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "people-revoke", + "kind": "listItem", + "label": "已登录设备", + "supporting": "查看手表名称与最近使用时间,逐台吊销凭证", + "icon": "watch", + "icon2": "chevron_right", + "variant": "filled" + } + ] + }, + { + "id": "people-nav-group", + "x": 0, + "y": 1760, + "axis": "x", + "items": [ + { + "id": "people-nav", + "kind": "bottomNav", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "icon": "today", + "label": "今天" + }, + { + "icon": "calendar_month", + "label": "课表" + }, + { + "icon": "tune", + "label": "控制" + }, + { + "icon": "group", + "label": "人员" + } + ], + "selected": 3, + "actions": { + "tab:0": { + "to": "home", + "transition": "fade" + }, + "tab:1": { + "to": "schedule", + "transition": "fade" + }, + "tab:2": { + "to": "controls", + "transition": "fade" + } + } + } + ] + }, + { + "id": "account-bar-group", + "x": 492, + "y": 972, + "axis": "x", + "items": [ + { + "id": "account-bar", + "kind": "topAppBar", + "label": "账号与设置", + "icon": "arrow_back", + "icon2": "logout", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + }, + "note": "右上角退出登录;返回首页时保留当前账号会话。" + } + ] + }, + { + "id": "account-user-group", + "x": 508, + "y": 1080, + "axis": "x", + "items": [ + { + "id": "account-user", + "kind": "card", + "label": "杨 - 管理员", + "supporting": "已连接到 RemoteCI 服务器\n点击切换账号或连接其他服务器", + "icon": "account_circle", + "variant": "filled", + "fill": "secondaryContainer", + "size": 380, + "size2": 136, + "noImage": true, + "note": "支持选择保存的账号、退出当前账号,以及输入或扫描新的服务器地址后连接。" + } + ] + }, + { + "id": "account-list-group", + "x": 508, + "y": 1240, + "axis": "y", + "items": [ + { + "id": "account-connection", + "kind": "listItem", + "label": "连接与服务器", + "supporting": "连接", + "icon": "wifi", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-notifications", + "kind": "listItem", + "label": "通知设置", + "supporting": "课程、自动化和第三方插件提醒的同步开关", + "icon": "notifications", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-appearance", + "kind": "listItem", + "label": "外观", + "supporting": "主题与显示偏好", + "icon": "palette", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-update", + "kind": "listItem", + "label": "更新", + "supporting": "检查更新与同版本强制覆盖", + "icon": "system_update", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-developer", + "kind": "listItem", + "label": "手表开发者设置", + "supporting": "更多设置", + "icon": "code", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-system", + "kind": "listItem", + "label": "系统配置", + "supporting": "服务端更新与备份", + "icon": "settings", + "icon2": "chevron_right", + "variant": "filled" + } + ] + }, + { + "id": "sy4e0vvu", + "x": -424, + "y": 188, + "axis": "x", + "items": [ + { + "id": "hxpevg9n", + "kind": "topAppBar", + "label": "标题", + "icon": "menu", + "variant": "filled", + "icon2": "more_vert", + "size": 412 + } + ] + }, + { + "id": "dl9v07ip", + "x": 324, + "y": 203, + "axis": "x", + "items": [ + { + "id": "qbd6wzou", + "kind": "circularProgress", + "label": "", + "icon": null, + "variant": "filled", + "size": 48, + "value": 60, + "wavy": false, + "trackThickness": 7 + } + ] + }, + { + "id": "d1lf0ipy", + "x": 32, + "y": 220, + "axis": "x", + "items": [ + { + "id": "9uw594jt", + "kind": "text", + "label": "信息技术", + "icon": null, + "variant": "filled", + "size": 32, + "bold": true + } + ] + }, + { + "id": "3096gm0l", + "x": 32, + "y": 203, + "axis": "x", + "items": [ + { + "id": "8d5h5izc", + "kind": "text", + "label": " 09:00–09:45", + "icon": null, + "variant": "filled", + "size": 14, + "bold": true + } + ] + } + ], "frames": [ - { "id": "devices", "name": "设备总览", "x": 0, "y": 0, "note": "按状态展示已配对设备;管理员可快速进入单台设备的控制面板。", "bg": "surface" }, - { "id": "device-control", "name": "设备控制", "x": 492, "y": 0, "note": "单台设备的实时状态、远程操作与维护入口;高风险操作需要二次确认。", "bg": "surface" }, - { "id": "live-view", "name": "实时监看", "x": 984, "y": 0, "note": "查看设备的实时画面和近期日志;远程输入始终显示连接状态。", "bg": "surface" }, - { "id": "jobs", "name": "任务与告警", "x": 0, "y": 972, "note": "集中查看构建任务、部署进度和需要值班员处理的告警。", "bg": "surface" }, - { "id": "team", "name": "团队与权限", "x": 492, "y": 972, "note": "管理成员、角色和设备访问范围;邀请与权限变更可追溯。", "bg": "surface" } + { + "id": "home", + "name": "今日课堂", + "x": 0, + "y": 0, + "note": "首页显示 ClassIsland 当前课堂、倒计时、下一节和同步状态;左上角菜单打开账号与连接面板。" + }, + { + "id": "schedule", + "name": "七日课表与调课", + "x": 492, + "y": 0, + "note": "查看七日课表、拉取最新数据;拥有权限时可交换或替换课程。" + }, + { + "id": "controls", + "name": "课堂控制", + "x": 984, + "y": 0, + "note": "每项 WebUI 与手表控制能力单独列出,根据账号权限和在线插件能力显示。" + }, + { + "id": "quick-change", + "name": "快速换课", + "x": 1476, + "y": 0, + "note": "从正在上课或下一节课程直接进入;预选所点课程为源课程,快速选择目标节次后提交。" + }, + { + "id": "people", + "name": "人员与凭证", + "x": 0, + "y": 972, + "note": "管理员管理访客访问、角色、账号、插件配对码与已登录设备凭证。" + }, + { + "id": "account", + "name": "账号与设置", + "x": 492, + "y": 972, + "note": "切换账号、退出登录、连接服务器,并集中管理与手表端一致的连接、通知、外观、更新和开发者设置。" + } ], - "groups": [ - { "id": "devices-bar-group", "x": 0, "y": 0, "axis": "x", "items": [ - { "id": "devices-bar", "kind": "topAppBar", "label": "设备", "icon": "menu", "icon2": "notifications", "variant": "filled", "actions": { "icon": { "to": "team", "transition": "slide" } } } - ] }, - { "id": "devices-search-group", "x": 16, "y": 104, "axis": "x", "items": [ - { "id": "devices-search", "kind": "searchBar", "label": "搜索设备、标签或负责人", "icon": "search", "icon2": "tune", "variant": "filled" } - ] }, - { "id": "devices-filter-group", "x": 16, "y": 176, "axis": "x", "items": [ - { "id": "filter-all", "kind": "chip", "label": "全部 24", "icon": null, "variant": "filled", "checked": true }, - { "id": "filter-online", "kind": "chip", "label": "在线 18", "icon": "wifi", "variant": "filled" }, - { "id": "filter-alert", "kind": "chip", "label": "需处理 3", "icon": "warning", "variant": "filled" } - ] }, - { "id": "devices-list-group", "x": 16, "y": 232, "axis": "y", "items": [ - { "id": "device-build-01", "kind": "listItem", "label": "build-agent-01", "supporting": "在线 · Windows · 构建中 72%", "icon": "computer", "icon2": "chevron_right", "variant": "filled", "action": { "to": "device-control", "transition": "slide" } }, - { "id": "device-deploy-03", "kind": "listItem", "label": "deploy-node-03", "supporting": "在线 · Linux · 正在部署", "icon": "dns", "icon2": "chevron_right", "variant": "filled", "action": { "to": "device-control", "transition": "slide" } }, - { "id": "device-lab-07", "kind": "listItem", "label": "lab-phone-07", "supporting": "告警 · Android · 9 分钟未响应", "icon": "smartphone", "icon2": "warning", "variant": "filled", "action": { "to": "device-control", "transition": "slide" } }, - { "id": "device-mac-02", "kind": "listItem", "label": "mac-runner-02", "supporting": "离线 · 上次在线 26 分钟前", "icon": "laptop_mac", "icon2": "chevron_right", "variant": "filled", "action": { "to": "device-control", "transition": "slide" } } - ] }, - { "id": "devices-nav-group", "x": 0, "y": 788, "axis": "x", "items": [ - { "id": "devices-nav", "kind": "bottomNav", "label": "", "icon": null, "variant": "filled", "tabs": [ { "icon": "devices", "label": "设备" }, { "icon": "visibility", "label": "监看" }, { "icon": "assignment", "label": "任务" }, { "icon": "group", "label": "团队" } ], "selected": 0, "actions": { "tab:1": { "to": "live-view", "transition": "fade" }, "tab:2": { "to": "jobs", "transition": "fade" }, "tab:3": { "to": "team", "transition": "fade" } } } - ] }, - - { "id": "control-bar-group", "x": 492, "y": 0, "axis": "x", "items": [ - { "id": "control-bar", "kind": "topAppBar", "label": "build-agent-01", "icon": "arrow_back", "icon2": "more_vert", "variant": "filled", "actions": { "icon": { "to": "back", "transition": "slide" } } } - ] }, - { "id": "control-status-group", "x": 508, "y": 104, "axis": "x", "items": [ - { "id": "control-status", "kind": "card", "label": "在线 · 运行正常", "supporting": "Windows 11 · CPU 48% · 内存 61%\n当前任务:release/3.2.1.5", "icon": "check_circle", "variant": "filled", "fill": "primaryContainer", "size": 380, "size2": 156, "noImage": true, "action": { "to": "live-view", "transition": "expand" } } - ] }, - { "id": "control-heading-group", "x": 508, "y": 282, "axis": "x", "items": [ - { "id": "control-heading", "kind": "text", "label": "远程控制", "icon": null, "variant": "filled", "size": 20, "bold": true } - ] }, - { "id": "control-actions-group", "x": 508, "y": 322, "axis": "x", "items": [ - { "id": "control-view", "kind": "button", "label": "查看画面", "icon": "visibility", "variant": "filled", "size": 182, "action": { "to": "live-view", "transition": "expand" } }, - { "id": "control-terminal", "kind": "button", "label": "终端", "icon": "terminal", "variant": "tonal", "size": 182, "note": "打开受审计的远程终端会话。" } - ] }, - { "id": "control-maintenance-group", "x": 508, "y": 394, "axis": "x", "items": [ - { "id": "control-restart", "kind": "button", "label": "重启设备", "icon": "restart_alt", "variant": "outlined", "size": 182, "note": "请求二次确认后重启,并记录操作者与原因。" }, - { "id": "control-lock", "kind": "button", "label": "锁定会话", "icon": "lock", "variant": "outlined", "size": 182, "note": "立即中止远程输入,保留查看权限。" } - ] }, - { "id": "control-info-group", "x": 508, "y": 482, "axis": "y", "items": [ - { "id": "control-job", "kind": "listItem", "label": "正在运行的任务", "supporting": "发布 RemoteCI 3.2.1.5 · 预计 6 分钟", "icon": "play_circle", "icon2": "chevron_right", "variant": "filled", "action": { "to": "jobs", "transition": "slide" } }, - { "id": "control-members", "kind": "listItem", "label": "设备访问者", "supporting": "5 人有控制权限", "icon": "group", "icon2": "chevron_right", "variant": "filled", "action": { "to": "team", "transition": "slide" } } - ] }, - - { "id": "live-bar-group", "x": 984, "y": 0, "axis": "x", "items": [ - { "id": "live-bar", "kind": "topAppBar", "label": "实时监看", "icon": "arrow_back", "icon2": "fullscreen", "variant": "filled", "actions": { "icon": { "to": "back", "transition": "slide" } } } - ] }, - { "id": "live-device-group", "x": 1000, "y": 96, "axis": "x", "items": [ - { "id": "live-device", "kind": "select", "label": "build-agent-01", "icon": "computer", "variant": "outlined", "tabs": [ { "label": "build-agent-01" }, { "label": "deploy-node-03" }, { "label": "lab-phone-07" } ], "selected": 0 } - ] }, - { "id": "live-camera-group", "x": 1000, "y": 176, "axis": "x", "items": [ - { "id": "live-camera", "kind": "camera", "label": "", "icon": null, "variant": "filled", "size": 380, "size2": 300, "note": "设备桌面的低延迟画面;顶部状态常驻显示远程会话是否可输入。" } - ] }, - { "id": "live-toolbar-group", "x": 1082, "y": 500, "axis": "x", "items": [ - { "id": "live-toolbar", "kind": "toolbar", "label": "", "icon": null, "variant": "tonal", "tabs": [ { "icon": "mouse" }, { "icon": "keyboard" }, { "icon": "content_copy" }, { "icon": "mic" } ], "note": "提供远程鼠标、键盘、剪贴板和语音消息入口。" } - ] }, - { "id": "live-log-group", "x": 1000, "y": 592, "axis": "y", "items": [ - { "id": "live-connected", "kind": "listItem", "label": "远程输入已连接", "supporting": "操作者:杨天明 · 受审计", "icon": "verified_user", "icon2": "check_circle", "variant": "filled" }, - { "id": "live-log", "kind": "listItem", "label": "最近事件", "supporting": "14:32 构建任务开始", "icon": "history", "icon2": "chevron_right", "variant": "filled", "action": { "to": "jobs", "transition": "slide" } } - ] }, - { "id": "live-nav-group", "x": 984, "y": 788, "axis": "x", "items": [ - { "id": "live-nav", "kind": "bottomNav", "label": "", "icon": null, "variant": "filled", "tabs": [ { "icon": "devices", "label": "设备" }, { "icon": "visibility", "label": "监看" }, { "icon": "assignment", "label": "任务" }, { "icon": "group", "label": "团队" } ], "selected": 1, "actions": { "tab:0": { "to": "devices", "transition": "fade" }, "tab:2": { "to": "jobs", "transition": "fade" }, "tab:3": { "to": "team", "transition": "fade" } } } - ] }, - - { "id": "jobs-bar-group", "x": 0, "y": 972, "axis": "x", "items": [ - { "id": "jobs-bar", "kind": "topAppBar", "label": "任务与告警", "icon": "menu", "icon2": "filter_list", "variant": "filled" } - ] }, - { "id": "jobs-tabs-group", "x": 0, "y": 1060, "axis": "x", "items": [ - { "id": "jobs-tabs", "kind": "tabs", "label": "", "icon": null, "variant": "filled", "tabs": [ { "label": "进行中 4" }, { "label": "告警 3" }, { "label": "已完成" } ], "selected": 0 } - ] }, - { "id": "jobs-progress-group", "x": 16, "y": 1120, "axis": "x", "items": [ - { "id": "jobs-progress", "kind": "linearProgress", "label": "", "icon": null, "variant": "filled", "value": 72, "wavy": true, "trackThickness": 8, "note": "release/3.2.1.5 的构建进度为 72%。" } - ] }, - { "id": "jobs-list-group", "x": 16, "y": 1160, "axis": "y", "items": [ - { "id": "job-release", "kind": "listItem", "label": "发布 RemoteCI 3.2.1.5", "supporting": "build-agent-01 · 进行中 · 72%", "icon": "rocket_launch", "icon2": "chevron_right", "variant": "filled", "action": { "to": "device-control", "transition": "slide" } }, - { "id": "alert-phone", "kind": "listItem", "label": "lab-phone-07 未响应", "supporting": "9 分钟前 · 需要确认设备状态", "icon": "warning", "icon2": "chevron_right", "variant": "filled", "action": { "to": "device-control", "transition": "slide" } }, - { "id": "job-deploy", "kind": "listItem", "label": "部署生产环境", "supporting": "deploy-node-03 · 等待审批", "icon": "cloud_upload", "icon2": "chevron_right", "variant": "filled", "action": { "to": "team", "transition": "slide" } }, - { "id": "job-test", "kind": "listItem", "label": "Wear OS 单元测试", "supporting": "mac-runner-02 · 已失败", "icon": "error", "icon2": "chevron_right", "variant": "filled" } - ] }, - { "id": "jobs-nav-group", "x": 0, "y": 1760, "axis": "x", "items": [ - { "id": "jobs-nav", "kind": "bottomNav", "label": "", "icon": null, "variant": "filled", "tabs": [ { "icon": "devices", "label": "设备" }, { "icon": "visibility", "label": "监看" }, { "icon": "assignment", "label": "任务" }, { "icon": "group", "label": "团队" } ], "selected": 2, "actions": { "tab:0": { "to": "devices", "transition": "fade" }, "tab:1": { "to": "live-view", "transition": "fade" }, "tab:3": { "to": "team", "transition": "fade" } } } - ] }, - - { "id": "team-bar-group", "x": 492, "y": 972, "axis": "x", "items": [ - { "id": "team-bar", "kind": "topAppBar", "label": "团队与权限", "icon": "menu", "icon2": "person_add", "variant": "filled", "note": "邀请新成员,邀请链接仅在设定时间内有效。" } - ] }, - { "id": "team-summary-group", "x": 508, "y": 1076, "axis": "x", "items": [ - { "id": "team-summary", "kind": "card", "label": "12 位成员 · 24 台设备", "supporting": "3 位管理员 · 4 个访问组\n所有权限变更均记录在审计日志中。", "icon": "admin_panel_settings", "variant": "filled", "fill": "secondaryContainer", "size": 380, "size2": 144, "noImage": true } - ] }, - { "id": "team-heading-group", "x": 508, "y": 1242, "axis": "x", "items": [ - { "id": "team-heading", "kind": "text", "label": "成员", "icon": null, "variant": "filled", "size": 20, "bold": true } - ] }, - { "id": "team-list-group", "x": 508, "y": 1280, "axis": "y", "items": [ - { "id": "member-yang", "kind": "listItem", "label": "杨天明", "supporting": "所有者 · 全部设备 · 最近在线", "icon": "account_circle", "icon2": "chevron_right", "variant": "filled" }, - { "id": "member-li", "kind": "listItem", "label": "李明", "supporting": "管理员 · 发布环境", "icon": "account_circle", "icon2": "chevron_right", "variant": "filled" }, - { "id": "member-chen", "kind": "listItem", "label": "陈晓", "supporting": "开发者 · 测试设备组", "icon": "account_circle", "icon2": "chevron_right", "variant": "filled" }, - { "id": "member-pending", "kind": "listItem", "label": "待接受邀请", "supporting": "王宁 · 访客 · 还剩 18 小时", "icon": "mail", "icon2": "more_vert", "variant": "filled" } - ] }, - { "id": "team-add-group", "x": 832, "y": 1656, "axis": "x", "items": [ - { "id": "team-add", "kind": "fab", "label": "", "icon": "person_add", "variant": "filled", "size": 56, "note": "邀请成员并选择角色、设备组和邀请有效期。" } - ] }, - { "id": "team-nav-group", "x": 492, "y": 1760, "axis": "x", "items": [ - { "id": "team-nav", "kind": "bottomNav", "label": "", "icon": null, "variant": "filled", "tabs": [ { "icon": "devices", "label": "设备" }, { "icon": "visibility", "label": "监看" }, { "icon": "assignment", "label": "任务" }, { "icon": "group", "label": "团队" } ], "selected": 3, "actions": { "tab:0": { "to": "devices", "transition": "fade" }, "tab:1": { "to": "live-view", "transition": "fade" }, "tab:2": { "to": "jobs", "transition": "fade" } } } - ] } - ] -} + "paletteKey": "blue", + "frame": "phone", + "title": "RemoteCI", + "brief": "RemoteCI 课表手机版:同步 ClassIsland 课表与课堂状态,并完整承接 WebUI、手表控制、账号、角色和凭证管理。", + "platform": "android", + "dynamicColor": false, + "theme": { + "dark": false, + "bothModes": true, + "contrast": "standard", + "shape": "rounded", + "font": "roboto", + "emphasized": true, + "motion": "expressive" + } +} \ No newline at end of file diff --git a/remoteci-schedule-mobile-m3e.json b/remoteci-schedule-mobile-m3e.json index 3eec7ba..4b746ae 100644 --- a/remoteci-schedule-mobile-m3e.json +++ b/remoteci-schedule-mobile-m3e.json @@ -1,100 +1,955 @@ { - "title": "RemoteCI 课表", - "brief": "把 RemoteCI WebUI 和手表端的课表、课堂状态、换课、提醒与快捷控制整合到手机。", - "frame": "phone", - "platform": "android", - "paletteKey": "blue", - "theme": { "dark": false, "bothModes": true, "contrast": "standard", "shape": "rounded", "font": "roboto", "emphasized": true, "motion": "expressive" }, + "groups": [ + { + "id": "home-bar-group", + "x": 0, + "y": 0, + "axis": "x", + "items": [ + { + "id": "home-bar", + "kind": "topAppBar", + "label": "RemoteCI", + "icon": "menu", + "icon2": "notifications", + "variant": "filled", + "actions": { + "icon": { + "to": "account", + "transition": "slide" + } + }, + "note": "点击左上角菜单显示账号切换、退出登录和服务器连接入口。" + } + ] + }, + { + "id": "home-current-group", + "x": 16, + "y": 104, + "axis": "x", + "items": [ + { + "id": "home-current", + "kind": "card", + "label": "上课", + "supporting": "第二节 机房", + "icon": "computer", + "variant": "filled", + "fill": "primaryContainer", + "size": 380, + "size2": 168, + "noImage": true, + "action": { + "to": "quick-change", + "transition": "expand" + }, + "note": "点击当前课程,带入第二节作为源课程并快速换课。" + } + ] + }, + { + "id": "home-next-group", + "x": 16, + "y": 288, + "axis": "y", + "items": [ + { + "id": "home-next", + "kind": "listItem", + "label": "下一节 · 体育", + "supporting": "第三节 10:05–10:50 · 操场", + "icon": "directions_run", + "icon2": "swap_horiz", + "variant": "filled", + "action": { + "to": "quick-change", + "transition": "slide" + }, + "note": "点击下一节,带入第三节作为源课程并快速换课。" + }, + { + "id": "home-sync", + "kind": "listItem", + "label": "RemoteCI 已连接", + "supporting": "课表已于 08:02 同步 · 手表已连接", + "icon": "cloud_done", + "icon2": "check_circle", + "variant": "filled" + } + ] + }, + { + "id": "home-actions-group", + "x": 16, + "y": 451, + "axis": "x", + "items": [ + { + "id": "home-schedule", + "kind": "button", + "label": "课表", + "icon": "calendar_month", + "variant": "filled", + "size": 182, + "action": { + "to": "schedule", + "transition": "slide" + } + }, + { + "id": "home-controls", + "kind": "button", + "label": "控制", + "icon": "tune", + "variant": "tonal", + "size": 182, + "action": { + "to": "controls", + "transition": "slide" + } + } + ] + }, + { + "id": "home-nav-group", + "x": 0, + "y": 788, + "axis": "x", + "items": [ + { + "id": "home-nav", + "kind": "bottomNav", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "icon": "today", + "label": "今天" + }, + { + "icon": "calendar_month", + "label": "课表" + }, + { + "icon": "tune", + "label": "控制" + }, + { + "icon": "group", + "label": "人员" + } + ], + "selected": 0, + "actions": { + "tab:1": { + "to": "schedule", + "transition": "fade" + }, + "tab:2": { + "to": "controls", + "transition": "fade" + }, + "tab:3": { + "to": "people", + "transition": "fade" + } + } + } + ] + }, + { + "id": "schedule-bar-group", + "x": 492, + "y": 0, + "axis": "x", + "items": [ + { + "id": "schedule-bar", + "kind": "topAppBar", + "label": "课表", + "icon": "arrow_back", + "icon2": "refresh", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + } + } + ] + }, + { + "id": "schedule-tabs-group", + "x": 492, + "y": 88, + "axis": "x", + "items": [ + { + "id": "schedule-tabs", + "kind": "tabs", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "label": "周一 15" + }, + { + "label": "周二 16" + }, + { + "label": "周三 17" + }, + { + "label": "周四 18" + }, + { + "label": "周五 19" + } + ], + "selected": 0 + } + ] + }, + { + "id": "schedule-list-group", + "x": 508, + "y": 160, + "axis": "y", + "items": [ + { + "id": "schedule-first", + "kind": "listItem", + "label": "第一节 · 数学", + "supporting": "08:00–08:45 · 教学楼 301", + "icon": "functions", + "icon2": "edit", + "variant": "filled" + }, + { + "id": "schedule-second", + "kind": "listItem", + "label": "第二节 · 信息技术", + "supporting": "09:00–09:45 · 机房", + "icon": "computer", + "icon2": "edit", + "variant": "filled" + }, + { + "id": "schedule-third", + "kind": "listItem", + "label": "第三节 · 体育", + "supporting": "10:05–10:50 · 操场", + "icon": "directions_run", + "icon2": "edit", + "variant": "filled" + } + ] + }, + { + "id": "schedule-actions-group", + "x": 508, + "y": 424, + "axis": "x", + "items": [ + { + "id": "schedule-pull", + "kind": "button", + "label": "拉取课表", + "icon": "sync", + "variant": "outlined", + "size": 182, + "note": "向在线插件请求重新生成七日课表;任务运行中会显示来源并拒绝重复请求。" + }, + { + "id": "schedule-change", + "kind": "button", + "label": "换课", + "icon": "swap_horiz", + "variant": "filled", + "size": 182, + "note": "选择日期和源、目标节次;提交前校验课表修订号,成功后同步在线手表。" + } + ] + }, + { + "id": "schedule-nav-group", + "x": 492, + "y": 788, + "axis": "x", + "items": [ + { + "id": "schedule-nav", + "kind": "bottomNav", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "icon": "today", + "label": "今天" + }, + { + "icon": "calendar_month", + "label": "课表" + }, + { + "icon": "tune", + "label": "控制" + }, + { + "icon": "group", + "label": "人员" + } + ], + "selected": 1, + "actions": { + "tab:0": { + "to": "home", + "transition": "fade" + }, + "tab:2": { + "to": "controls", + "transition": "fade" + }, + "tab:3": { + "to": "people", + "transition": "fade" + } + } + } + ] + }, + { + "id": "quick-change-bar-group", + "x": 1476, + "y": 0, + "axis": "x", + "items": [ + { + "id": "quick-change-bar", + "kind": "topAppBar", + "label": "快速换课", + "icon": "arrow_back", + "icon2": "help", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + } + } + ] + }, + { + "id": "quick-change-source-group", + "x": 1492, + "y": 104, + "axis": "x", + "items": [ + { + "id": "quick-change-source", + "kind": "card", + "label": "当前选择 · 第二节 信息技术", + "supporting": "09:00–09:45 · 机房\n从首页进入时自动预选被点击的当前课或下一节课。", + "icon": "computer", + "variant": "filled", + "fill": "primaryContainer", + "size": 380, + "size2": 136, + "noImage": true + } + ] + }, + { + "id": "quick-change-target-group", + "x": 1492, + "y": 264, + "axis": "y", + "items": [ + { + "id": "quick-change-mode", + "kind": "select", + "label": "交换课程", + "supporting": "也可切换为替换课程", + "icon": "swap_horiz", + "variant": "outlined", + "tabs": [ + { + "label": "交换课程" + }, + { + "label": "替换课程" + } + ], + "selected": 0 + }, + { + "id": "quick-change-target", + "kind": "select", + "label": "目标 · 第三节 体育", + "supporting": "10:05–10:50 · 操场", + "icon": "directions_run", + "variant": "outlined", + "tabs": [ + { + "label": "第一节 · 数学" + }, + { + "label": "第三节 · 体育" + }, + { + "label": "第四节 · 英语" + } + ], + "selected": 1 + } + ] + }, + { + "id": "quick-change-submit-group", + "x": 1492, + "y": 424, + "axis": "x", + "items": [ + { + "id": "quick-change-submit", + "kind": "button", + "label": "确认换课", + "icon": "check", + "variant": "filled", + "size": 380, + "action": { + "to": "home", + "transition": "slide" + }, + "note": "提交前校验课表修订号;成功后更新今日课堂、七日课表和在线手表。" + } + ] + }, + { + "id": "controls-bar-group", + "x": 984, + "y": 0, + "axis": "x", + "items": [ + { + "id": "controls-bar", + "kind": "topAppBar", + "label": "课堂控制", + "icon": "arrow_back", + "icon2": "more_vert", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + } + } + ] + }, + { + "id": "controls-list-group", + "x": 1000, + "y": 104, + "axis": "y", + "items": [ + { + "id": "control-notify", + "kind": "listItem", + "label": "发送通知", + "supporting": "标题、正文、强调特效、音效和语音朗读", + "icon": "notifications_active", + "icon2": "chevron_right", + "variant": "filled", + "note": "发送到 ClassIsland,并广播给在线手表。" + }, + { + "id": "control-voice", + "kind": "listItem", + "label": "发送语音", + "supporting": "最长 60 秒的语音消息", + "icon": "mic", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-clear", + "kind": "listItem", + "label": "清除提醒", + "supporting": "清除 ClassIsland 当前提醒与手表提示", + "icon": "notifications_off", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-teacher", + "kind": "listItem", + "label": "老师来了", + "supporting": "显示提醒,等待一秒后自动清除", + "icon": "campaign", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-main-menu", + "kind": "listItem", + "label": "主界面", + "supporting": "显示或隐藏 ClassIsland 主界面", + "icon": "visibility", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-volume", + "kind": "listItem", + "label": "音量", + "supporting": "音量调节和切换静音", + "icon": "volume_up", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-power", + "kind": "listItem", + "label": "电源控制", + "supporting": "关机、睡眠或休眠", + "icon": "power_settings_new", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-extensions", + "kind": "listItem", + "label": "扩展功能", + "supporting": "运行已注册扩展", + "icon": "extension", + "icon2": "chevron_right", + "variant": "filled", + "note": "管理员可逐项管理扩展策略;普通账号只管理自己的手表展示偏好。" + } + ] + }, + { + "id": "controls-nav-group", + "x": 984, + "y": 788, + "axis": "x", + "items": [ + { + "id": "controls-nav", + "kind": "bottomNav", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "icon": "today", + "label": "今天" + }, + { + "icon": "calendar_month", + "label": "课表" + }, + { + "icon": "tune", + "label": "控制" + }, + { + "icon": "group", + "label": "人员" + } + ], + "selected": 2, + "actions": { + "tab:0": { + "to": "home", + "transition": "fade" + }, + "tab:1": { + "to": "schedule", + "transition": "fade" + }, + "tab:3": { + "to": "people", + "transition": "fade" + } + } + } + ] + }, + { + "id": "people-bar-group", + "x": 0, + "y": 972, + "axis": "x", + "items": [ + { + "id": "people-bar", + "kind": "topAppBar", + "label": "人员与凭证", + "icon": "arrow_back", + "icon2": "person_add", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + } + } + ] + }, + { + "id": "people-list-group", + "x": 16, + "y": 1080, + "axis": "y", + "items": [ + { + "id": "people-visitor", + "kind": "listItem", + "label": "访客访问", + "supporting": "启用访客功能与自动进入访客页", + "icon": "person_outline", + "switch": true, + "checked": false, + "variant": "filled" + }, + { + "id": "people-roles", + "kind": "listItem", + "label": "角色配置", + "supporting": "创建自定义角色,设置课表、通知、语音、控制与扩展权限", + "icon": "admin_panel_settings", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "people-users", + "kind": "listItem", + "label": "账号管理", + "supporting": "新建、编辑、启停账号,分配角色与个人附加权限", + "icon": "group", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "people-reset", + "kind": "listItem", + "label": "密码与设备会话", + "supporting": "重置密码时可撤销所有手表登录", + "icon": "key", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "people-pairing", + "kind": "listItem", + "label": "插件配对码", + "supporting": "添加、查看和撤销 ClassIsland 插件长期凭证", + "icon": "link", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "people-revoke", + "kind": "listItem", + "label": "已登录设备", + "supporting": "查看手表名称与最近使用时间,逐台吊销凭证", + "icon": "watch", + "icon2": "chevron_right", + "variant": "filled" + } + ] + }, + { + "id": "people-nav-group", + "x": 0, + "y": 1760, + "axis": "x", + "items": [ + { + "id": "people-nav", + "kind": "bottomNav", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "icon": "today", + "label": "今天" + }, + { + "icon": "calendar_month", + "label": "课表" + }, + { + "icon": "tune", + "label": "控制" + }, + { + "icon": "group", + "label": "人员" + } + ], + "selected": 3, + "actions": { + "tab:0": { + "to": "home", + "transition": "fade" + }, + "tab:1": { + "to": "schedule", + "transition": "fade" + }, + "tab:2": { + "to": "controls", + "transition": "fade" + } + } + } + ] + }, + { + "id": "account-bar-group", + "x": 492, + "y": 972, + "axis": "x", + "items": [ + { + "id": "account-bar", + "kind": "topAppBar", + "label": "账号与设置", + "icon": "arrow_back", + "icon2": "logout", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + }, + "note": "右上角退出登录;返回首页时保留当前账号会话。" + } + ] + }, + { + "id": "account-user-group", + "x": 508, + "y": 1080, + "axis": "x", + "items": [ + { + "id": "account-user", + "kind": "card", + "label": "杨 - 管理员", + "supporting": "已连接到 RemoteCI 服务器\n点击切换账号或连接其他服务器", + "icon": "account_circle", + "variant": "filled", + "fill": "secondaryContainer", + "size": 380, + "size2": 136, + "noImage": true, + "note": "支持选择保存的账号、退出当前账号,以及输入或扫描新的服务器地址后连接。" + } + ] + }, + { + "id": "account-list-group", + "x": 508, + "y": 1240, + "axis": "y", + "items": [ + { + "id": "account-connection", + "kind": "listItem", + "label": "连接与服务器", + "supporting": "连接", + "icon": "wifi", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-notifications", + "kind": "listItem", + "label": "通知设置", + "supporting": "课程、自动化和第三方插件提醒的同步开关", + "icon": "notifications", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-appearance", + "kind": "listItem", + "label": "外观", + "supporting": "主题与显示偏好", + "icon": "palette", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-update", + "kind": "listItem", + "label": "更新", + "supporting": "检查更新与同版本强制覆盖", + "icon": "system_update", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-developer", + "kind": "listItem", + "label": "手表开发者设置", + "supporting": "更多设置", + "icon": "code", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-system", + "kind": "listItem", + "label": "系统配置", + "supporting": "服务端更新与备份", + "icon": "settings", + "icon2": "chevron_right", + "variant": "filled" + } + ] + }, + { + "id": "sy4e0vvu", + "x": -424, + "y": 188, + "axis": "x", + "items": [ + { + "id": "hxpevg9n", + "kind": "topAppBar", + "label": "标题", + "icon": "menu", + "variant": "filled", + "icon2": "more_vert", + "size": 412 + } + ] + }, + { + "id": "dl9v07ip", + "x": 324, + "y": 203, + "axis": "x", + "items": [ + { + "id": "qbd6wzou", + "kind": "circularProgress", + "label": "", + "icon": null, + "variant": "filled", + "size": 48, + "value": 60, + "wavy": false, + "trackThickness": 7 + } + ] + }, + { + "id": "d1lf0ipy", + "x": 32, + "y": 220, + "axis": "x", + "items": [ + { + "id": "9uw594jt", + "kind": "text", + "label": "信息技术", + "icon": null, + "variant": "filled", + "size": 32, + "bold": true + } + ] + }, + { + "id": "3096gm0l", + "x": 32, + "y": 203, + "axis": "x", + "items": [ + { + "id": "8d5h5izc", + "kind": "text", + "label": " 09:00–09:45", + "icon": null, + "variant": "filled", + "size": 14, + "bold": true + } + ] + } + ], "frames": [ - { "id": "today", "name": "今日课堂", "x": 0, "y": 0, "note": "首页优先显示当前课程、下节课程和课堂倒计时;这是手表端首页信息的手机化版本。" }, - { "id": "week", "name": "七日课表", "x": 492, "y": 0, "note": "查看从今天开始的七日课表,并可立即向 ClassIsland 拉取最新课表。" }, - { "id": "change", "name": "调课", "x": 984, "y": 0, "note": "有换课权限时,可交换两节课或用指定课程替换目标节次。" }, - { "id": "control", "name": "课堂控制", "x": 0, "y": 972, "note": "发送自定义提醒、老师来了、主界面显示和音量控制等 WebUI 与手表共有操作。" }, - { "id": "watch", "name": "手表与提醒", "x": 492, "y": 972, "note": "管理已登录手表、查看连接状态,并设置需要同步到手表的提醒与扩展入口。" } + { + "id": "home", + "name": "今日课堂", + "x": 0, + "y": 0, + "note": "首页显示 ClassIsland 当前课堂、倒计时、下一节和同步状态;左上角菜单打开账号与连接面板。" + }, + { + "id": "schedule", + "name": "七日课表与调课", + "x": 492, + "y": 0, + "note": "查看七日课表、拉取最新数据;拥有权限时可交换或替换课程。" + }, + { + "id": "controls", + "name": "课堂控制", + "x": 984, + "y": 0, + "note": "每项 WebUI 与手表控制能力单独列出,根据账号权限和在线插件能力显示。" + }, + { + "id": "quick-change", + "name": "快速换课", + "x": 1476, + "y": 0, + "note": "从正在上课或下一节课程直接进入;预选所点课程为源课程,快速选择目标节次后提交。" + }, + { + "id": "people", + "name": "人员与凭证", + "x": 0, + "y": 972, + "note": "管理员管理访客访问、角色、账号、插件配对码与已登录设备凭证。" + }, + { + "id": "account", + "name": "账号与设置", + "x": 492, + "y": 972, + "note": "切换账号、退出登录、连接服务器,并集中管理与手表端一致的连接、通知、外观、更新和开发者设置。" + } ], - "groups": [ - { "id": "today-bar-group", "x": 0, "y": 0, "axis": "x", "items": [ - { "id": "today-bar", "kind": "topAppBar", "label": "今日课堂", "icon": "menu", "icon2": "notifications", "variant": "filled" } - ] }, - { "id": "today-current-group", "x": 16, "y": 104, "axis": "x", "items": [ - { "id": "today-current", "kind": "card", "label": "正在上课 · 信息技术", "supporting": "第二节 09:00–09:45\n距离下课还有 18 分钟", "icon": "schedule", "variant": "filled", "fill": "primaryContainer", "size": 380, "size2": 168, "noImage": true, "action": { "to": "change", "transition": "expand" } } - ] }, - { "id": "today-next-group", "x": 16, "y": 296, "axis": "y", "items": [ - { "id": "today-next", "kind": "listItem", "label": "下一节 · 体育", "supporting": "第三节 10:05–10:50 · 操场", "icon": "directions_run", "icon2": "chevron_right", "variant": "filled", "action": { "to": "change", "transition": "slide" } }, - { "id": "today-status", "kind": "listItem", "label": "ClassIsland 已连接", "supporting": "课表已于 08:02 同步", "icon": "cloud_done", "icon2": "check_circle", "variant": "filled" } - ] }, - { "id": "today-actions-group", "x": 16, "y": 456, "axis": "x", "items": [ - { "id": "today-schedule", "kind": "button", "label": "查看七日课表", "icon": "calendar_month", "variant": "filled", "size": 182, "action": { "to": "week", "transition": "slide" } }, - { "id": "today-control", "kind": "button", "label": "课堂控制", "icon": "tune", "variant": "tonal", "size": 182, "action": { "to": "control", "transition": "slide" } } - ] }, - { "id": "today-nav-group", "x": 0, "y": 788, "axis": "x", "items": [ - { "id": "today-nav", "kind": "bottomNav", "label": "", "icon": null, "variant": "filled", "tabs": [{ "icon": "today", "label": "今天" }, { "icon": "calendar_month", "label": "课表" }, { "icon": "tune", "label": "控制" }, { "icon": "watch", "label": "手表" }], "selected": 0, "actions": { "tab:1": { "to": "week", "transition": "fade" }, "tab:2": { "to": "control", "transition": "fade" }, "tab:3": { "to": "watch", "transition": "fade" } } } - ] }, - { "id": "week-bar-group", "x": 492, "y": 0, "axis": "x", "items": [ - { "id": "week-bar", "kind": "topAppBar", "label": "七日课表", "icon": "arrow_back", "icon2": "refresh", "variant": "filled", "actions": { "icon": { "to": "back", "transition": "slide" } }, "note": "刷新会向在线 ClassIsland 插件请求最新课表;任务执行中时显示来源并禁止重复请求。" } - ] }, - { "id": "week-tabs-group", "x": 492, "y": 88, "axis": "x", "items": [ - { "id": "week-tabs", "kind": "tabs", "label": "", "icon": null, "variant": "filled", "tabs": [{ "label": "周一 15" }, { "label": "周二 16" }, { "label": "周三 17" }, { "label": "周四 18" }, { "label": "周五 19" }], "selected": 0 } - ] }, - { "id": "week-list-group", "x": 508, "y": 160, "axis": "y", "items": [ - { "id": "week-first", "kind": "listItem", "label": "第一节 · 数学", "supporting": "08:00–08:45 · 教学楼 301", "icon": "functions", "icon2": "chevron_right", "variant": "filled", "action": { "to": "change", "transition": "slide" } }, - { "id": "week-second", "kind": "listItem", "label": "第二节 · 信息技术", "supporting": "09:00–09:45 · 机房", "icon": "computer", "icon2": "chevron_right", "variant": "filled", "action": { "to": "change", "transition": "slide" } }, - { "id": "week-third", "kind": "listItem", "label": "第三节 · 体育", "supporting": "10:05–10:50 · 操场", "icon": "directions_run", "icon2": "chevron_right", "variant": "filled", "action": { "to": "change", "transition": "slide" } }, - { "id": "week-fourth", "kind": "listItem", "label": "第四节 · 英语", "supporting": "11:00–11:45 · 教学楼 204", "icon": "translate", "icon2": "chevron_right", "variant": "filled", "action": { "to": "change", "transition": "slide" } } - ] }, - { "id": "week-pull-group", "x": 508, "y": 504, "axis": "x", "items": [ - { "id": "week-pull", "kind": "button", "label": "立即拉取课表", "icon": "sync", "variant": "outlined", "size": 380, "note": "请求插件重新生成并同步七日课表;插件离线时立即提示失败,不排队。" } - ] }, - { "id": "week-nav-group", "x": 492, "y": 788, "axis": "x", "items": [ - { "id": "week-nav", "kind": "bottomNav", "label": "", "icon": null, "variant": "filled", "tabs": [{ "icon": "today", "label": "今天" }, { "icon": "calendar_month", "label": "课表" }, { "icon": "tune", "label": "控制" }, { "icon": "watch", "label": "手表" }], "selected": 1, "actions": { "tab:0": { "to": "today", "transition": "fade" }, "tab:2": { "to": "control", "transition": "fade" }, "tab:3": { "to": "watch", "transition": "fade" } } } - ] }, - { "id": "change-bar-group", "x": 984, "y": 0, "axis": "x", "items": [ - { "id": "change-bar", "kind": "topAppBar", "label": "调课", "icon": "arrow_back", "icon2": "help", "variant": "filled", "actions": { "icon": { "to": "back", "transition": "slide" } } } - ] }, - { "id": "change-mode-group", "x": 1000, "y": 104, "axis": "x", "items": [ - { "id": "change-mode", "kind": "select", "label": "交换课程", "supporting": "选择操作方式", "icon": "swap_horiz", "variant": "outlined", "tabs": [{ "label": "交换课程" }, { "label": "替换课程" }], "selected": 0 } - ] }, - { "id": "change-date-group", "x": 1000, "y": 184, "axis": "x", "items": [ - { "id": "change-date", "kind": "select", "label": "2026 年 9 月 15 日 · 周一", "supporting": "目标日期", "icon": "calendar_today", "variant": "outlined", "tabs": [{ "label": "2026 年 9 月 15 日 · 周一" }, { "label": "2026 年 9 月 16 日 · 周二" }], "selected": 0 } - ] }, - { "id": "change-classes-group", "x": 1000, "y": 272, "axis": "y", "items": [ - { "id": "change-source", "kind": "listItem", "label": "源课程 · 第二节 信息技术", "supporting": "09:00–09:45 · 机房", "icon": "drag_indicator", "icon2": "edit", "variant": "filled" }, - { "id": "change-target", "kind": "listItem", "label": "目标课程 · 第三节 体育", "supporting": "10:05–10:50 · 操场", "icon": "drag_indicator", "icon2": "edit", "variant": "filled" } - ] }, - { "id": "change-submit-group", "x": 1000, "y": 440, "axis": "x", "items": [ - { "id": "change-submit", "kind": "button", "label": "提交修改", "icon": "check", "variant": "filled", "size": 380, "note": "提交前校验当前课表修订号;成功后刷新课表并同步在线手表。" } - ] }, - { "id": "control-bar-group", "x": 0, "y": 972, "axis": "x", "items": [ - { "id": "control-bar", "kind": "topAppBar", "label": "课堂控制", "icon": "menu", "icon2": "more_vert", "variant": "filled" } - ] }, - { "id": "control-quick-group", "x": 16, "y": 1088, "axis": "x", "items": [ - { "id": "control-teacher", "kind": "button", "label": "老师来了", "icon": "campaign", "variant": "filled", "size": 182, "note": "触发 ClassIsland 的老师来了流程:显示提醒、一秒后清除。" }, - { "id": "control-menu", "kind": "button", "label": "显示主界面", "icon": "visibility", "variant": "tonal", "size": 182 } - ] }, - { "id": "control-notification-group", "x": 16, "y": 1172, "axis": "x", "items": [ - { "id": "control-notification", "kind": "card", "label": "发送自定义提醒", "supporting": "向 ClassIsland 发送标题与正文,也同步给在线手表。", "icon": "notifications_active", "variant": "filled", "size": 380, "size2": 136, "noImage": true } - ] }, - { "id": "control-settings-group", "x": 16, "y": 1332, "axis": "y", "items": [ - { "id": "control-sound", "kind": "switch", "label": "播放提醒音效", "icon": null, "variant": "filled", "checked": true, "size": 380 }, - { "id": "control-speech", "kind": "switch", "label": "语音朗读提醒", "icon": null, "variant": "filled", "checked": false, "size": 380 }, - { "id": "control-volume", "kind": "slider", "label": "", "icon": null, "variant": "filled", "value": 65, "note": "显示并调整 ClassIsland 主设备的可用音量。" } - ] }, - { "id": "control-nav-group", "x": 0, "y": 1760, "axis": "x", "items": [ - { "id": "control-nav", "kind": "bottomNav", "label": "", "icon": null, "variant": "filled", "tabs": [{ "icon": "today", "label": "今天" }, { "icon": "calendar_month", "label": "课表" }, { "icon": "tune", "label": "控制" }, { "icon": "watch", "label": "手表" }], "selected": 2, "actions": { "tab:0": { "to": "today", "transition": "fade" }, "tab:1": { "to": "week", "transition": "fade" }, "tab:3": { "to": "watch", "transition": "fade" } } } - ] }, - { "id": "watch-bar-group", "x": 492, "y": 972, "axis": "x", "items": [ - { "id": "watch-bar", "kind": "topAppBar", "label": "手表与提醒", "icon": "menu", "icon2": "settings", "variant": "filled" } - ] }, - { "id": "watch-status-group", "x": 508, "y": 1088, "axis": "x", "items": [ - { "id": "watch-status", "kind": "card", "label": "Galaxy Watch 已连接", "supporting": "最近同步:刚刚\n当前展示:今日课堂、课表、课堂控制", "icon": "watch", "variant": "filled", "fill": "secondaryContainer", "size": 380, "size2": 144, "noImage": true } - ] }, - { "id": "watch-list-group", "x": 508, "y": 1256, "axis": "y", "items": [ - { "id": "watch-notification", "kind": "listItem", "label": "提醒同步", "supporting": "课程、自动化和第三方插件提醒", "icon": "notifications", "switch": true, "checked": true, "variant": "filled" }, - { "id": "watch-extensions", "kind": "listItem", "label": "手表快捷入口", "supporting": "老师来了、显示主界面等已授权功能", "icon": "apps", "icon2": "chevron_right", "variant": "filled", "action": { "to": "control", "transition": "slide" } }, - { "id": "watch-pull", "kind": "listItem", "label": "从手表拉取课表", "supporting": "课表任务运行时会显示进度并防止重复操作", "icon": "sync", "icon2": "chevron_right", "variant": "filled", "action": { "to": "week", "transition": "slide" } } - ] }, - { "id": "watch-nav-group", "x": 492, "y": 1760, "axis": "x", "items": [ - { "id": "watch-nav", "kind": "bottomNav", "label": "", "icon": null, "variant": "filled", "tabs": [{ "icon": "today", "label": "今天" }, { "icon": "calendar_month", "label": "课表" }, { "icon": "tune", "label": "控制" }, { "icon": "watch", "label": "手表" }], "selected": 3, "actions": { "tab:0": { "to": "today", "transition": "fade" }, "tab:1": { "to": "week", "transition": "fade" }, "tab:2": { "to": "control", "transition": "fade" } } } - ] } - ] -} + "paletteKey": "blue", + "frame": "phone", + "title": "RemoteCI", + "brief": "RemoteCI 课表手机版:同步 ClassIsland 课表与课堂状态,并完整承接 WebUI、手表控制、账号、角色和凭证管理。", + "platform": "android", + "dynamicColor": false, + "theme": { + "dark": false, + "bothModes": true, + "contrast": "standard", + "shape": "rounded", + "font": "roboto", + "emphasized": true, + "motion": "expressive" + } +} \ No newline at end of file diff --git a/remoteci-schedule-mobile-v2-m3e.json b/remoteci-schedule-mobile-v2-m3e.json index 2769b86..4b746ae 100644 --- a/remoteci-schedule-mobile-v2-m3e.json +++ b/remoteci-schedule-mobile-v2-m3e.json @@ -1,118 +1,955 @@ { + "groups": [ + { + "id": "home-bar-group", + "x": 0, + "y": 0, + "axis": "x", + "items": [ + { + "id": "home-bar", + "kind": "topAppBar", + "label": "RemoteCI", + "icon": "menu", + "icon2": "notifications", + "variant": "filled", + "actions": { + "icon": { + "to": "account", + "transition": "slide" + } + }, + "note": "点击左上角菜单显示账号切换、退出登录和服务器连接入口。" + } + ] + }, + { + "id": "home-current-group", + "x": 16, + "y": 104, + "axis": "x", + "items": [ + { + "id": "home-current", + "kind": "card", + "label": "上课", + "supporting": "第二节 机房", + "icon": "computer", + "variant": "filled", + "fill": "primaryContainer", + "size": 380, + "size2": 168, + "noImage": true, + "action": { + "to": "quick-change", + "transition": "expand" + }, + "note": "点击当前课程,带入第二节作为源课程并快速换课。" + } + ] + }, + { + "id": "home-next-group", + "x": 16, + "y": 288, + "axis": "y", + "items": [ + { + "id": "home-next", + "kind": "listItem", + "label": "下一节 · 体育", + "supporting": "第三节 10:05–10:50 · 操场", + "icon": "directions_run", + "icon2": "swap_horiz", + "variant": "filled", + "action": { + "to": "quick-change", + "transition": "slide" + }, + "note": "点击下一节,带入第三节作为源课程并快速换课。" + }, + { + "id": "home-sync", + "kind": "listItem", + "label": "RemoteCI 已连接", + "supporting": "课表已于 08:02 同步 · 手表已连接", + "icon": "cloud_done", + "icon2": "check_circle", + "variant": "filled" + } + ] + }, + { + "id": "home-actions-group", + "x": 16, + "y": 451, + "axis": "x", + "items": [ + { + "id": "home-schedule", + "kind": "button", + "label": "课表", + "icon": "calendar_month", + "variant": "filled", + "size": 182, + "action": { + "to": "schedule", + "transition": "slide" + } + }, + { + "id": "home-controls", + "kind": "button", + "label": "控制", + "icon": "tune", + "variant": "tonal", + "size": 182, + "action": { + "to": "controls", + "transition": "slide" + } + } + ] + }, + { + "id": "home-nav-group", + "x": 0, + "y": 788, + "axis": "x", + "items": [ + { + "id": "home-nav", + "kind": "bottomNav", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "icon": "today", + "label": "今天" + }, + { + "icon": "calendar_month", + "label": "课表" + }, + { + "icon": "tune", + "label": "控制" + }, + { + "icon": "group", + "label": "人员" + } + ], + "selected": 0, + "actions": { + "tab:1": { + "to": "schedule", + "transition": "fade" + }, + "tab:2": { + "to": "controls", + "transition": "fade" + }, + "tab:3": { + "to": "people", + "transition": "fade" + } + } + } + ] + }, + { + "id": "schedule-bar-group", + "x": 492, + "y": 0, + "axis": "x", + "items": [ + { + "id": "schedule-bar", + "kind": "topAppBar", + "label": "课表", + "icon": "arrow_back", + "icon2": "refresh", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + } + } + ] + }, + { + "id": "schedule-tabs-group", + "x": 492, + "y": 88, + "axis": "x", + "items": [ + { + "id": "schedule-tabs", + "kind": "tabs", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "label": "周一 15" + }, + { + "label": "周二 16" + }, + { + "label": "周三 17" + }, + { + "label": "周四 18" + }, + { + "label": "周五 19" + } + ], + "selected": 0 + } + ] + }, + { + "id": "schedule-list-group", + "x": 508, + "y": 160, + "axis": "y", + "items": [ + { + "id": "schedule-first", + "kind": "listItem", + "label": "第一节 · 数学", + "supporting": "08:00–08:45 · 教学楼 301", + "icon": "functions", + "icon2": "edit", + "variant": "filled" + }, + { + "id": "schedule-second", + "kind": "listItem", + "label": "第二节 · 信息技术", + "supporting": "09:00–09:45 · 机房", + "icon": "computer", + "icon2": "edit", + "variant": "filled" + }, + { + "id": "schedule-third", + "kind": "listItem", + "label": "第三节 · 体育", + "supporting": "10:05–10:50 · 操场", + "icon": "directions_run", + "icon2": "edit", + "variant": "filled" + } + ] + }, + { + "id": "schedule-actions-group", + "x": 508, + "y": 424, + "axis": "x", + "items": [ + { + "id": "schedule-pull", + "kind": "button", + "label": "拉取课表", + "icon": "sync", + "variant": "outlined", + "size": 182, + "note": "向在线插件请求重新生成七日课表;任务运行中会显示来源并拒绝重复请求。" + }, + { + "id": "schedule-change", + "kind": "button", + "label": "换课", + "icon": "swap_horiz", + "variant": "filled", + "size": 182, + "note": "选择日期和源、目标节次;提交前校验课表修订号,成功后同步在线手表。" + } + ] + }, + { + "id": "schedule-nav-group", + "x": 492, + "y": 788, + "axis": "x", + "items": [ + { + "id": "schedule-nav", + "kind": "bottomNav", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "icon": "today", + "label": "今天" + }, + { + "icon": "calendar_month", + "label": "课表" + }, + { + "icon": "tune", + "label": "控制" + }, + { + "icon": "group", + "label": "人员" + } + ], + "selected": 1, + "actions": { + "tab:0": { + "to": "home", + "transition": "fade" + }, + "tab:2": { + "to": "controls", + "transition": "fade" + }, + "tab:3": { + "to": "people", + "transition": "fade" + } + } + } + ] + }, + { + "id": "quick-change-bar-group", + "x": 1476, + "y": 0, + "axis": "x", + "items": [ + { + "id": "quick-change-bar", + "kind": "topAppBar", + "label": "快速换课", + "icon": "arrow_back", + "icon2": "help", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + } + } + ] + }, + { + "id": "quick-change-source-group", + "x": 1492, + "y": 104, + "axis": "x", + "items": [ + { + "id": "quick-change-source", + "kind": "card", + "label": "当前选择 · 第二节 信息技术", + "supporting": "09:00–09:45 · 机房\n从首页进入时自动预选被点击的当前课或下一节课。", + "icon": "computer", + "variant": "filled", + "fill": "primaryContainer", + "size": 380, + "size2": 136, + "noImage": true + } + ] + }, + { + "id": "quick-change-target-group", + "x": 1492, + "y": 264, + "axis": "y", + "items": [ + { + "id": "quick-change-mode", + "kind": "select", + "label": "交换课程", + "supporting": "也可切换为替换课程", + "icon": "swap_horiz", + "variant": "outlined", + "tabs": [ + { + "label": "交换课程" + }, + { + "label": "替换课程" + } + ], + "selected": 0 + }, + { + "id": "quick-change-target", + "kind": "select", + "label": "目标 · 第三节 体育", + "supporting": "10:05–10:50 · 操场", + "icon": "directions_run", + "variant": "outlined", + "tabs": [ + { + "label": "第一节 · 数学" + }, + { + "label": "第三节 · 体育" + }, + { + "label": "第四节 · 英语" + } + ], + "selected": 1 + } + ] + }, + { + "id": "quick-change-submit-group", + "x": 1492, + "y": 424, + "axis": "x", + "items": [ + { + "id": "quick-change-submit", + "kind": "button", + "label": "确认换课", + "icon": "check", + "variant": "filled", + "size": 380, + "action": { + "to": "home", + "transition": "slide" + }, + "note": "提交前校验课表修订号;成功后更新今日课堂、七日课表和在线手表。" + } + ] + }, + { + "id": "controls-bar-group", + "x": 984, + "y": 0, + "axis": "x", + "items": [ + { + "id": "controls-bar", + "kind": "topAppBar", + "label": "课堂控制", + "icon": "arrow_back", + "icon2": "more_vert", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + } + } + ] + }, + { + "id": "controls-list-group", + "x": 1000, + "y": 104, + "axis": "y", + "items": [ + { + "id": "control-notify", + "kind": "listItem", + "label": "发送通知", + "supporting": "标题、正文、强调特效、音效和语音朗读", + "icon": "notifications_active", + "icon2": "chevron_right", + "variant": "filled", + "note": "发送到 ClassIsland,并广播给在线手表。" + }, + { + "id": "control-voice", + "kind": "listItem", + "label": "发送语音", + "supporting": "最长 60 秒的语音消息", + "icon": "mic", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-clear", + "kind": "listItem", + "label": "清除提醒", + "supporting": "清除 ClassIsland 当前提醒与手表提示", + "icon": "notifications_off", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-teacher", + "kind": "listItem", + "label": "老师来了", + "supporting": "显示提醒,等待一秒后自动清除", + "icon": "campaign", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-main-menu", + "kind": "listItem", + "label": "主界面", + "supporting": "显示或隐藏 ClassIsland 主界面", + "icon": "visibility", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-volume", + "kind": "listItem", + "label": "音量", + "supporting": "音量调节和切换静音", + "icon": "volume_up", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-power", + "kind": "listItem", + "label": "电源控制", + "supporting": "关机、睡眠或休眠", + "icon": "power_settings_new", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "control-extensions", + "kind": "listItem", + "label": "扩展功能", + "supporting": "运行已注册扩展", + "icon": "extension", + "icon2": "chevron_right", + "variant": "filled", + "note": "管理员可逐项管理扩展策略;普通账号只管理自己的手表展示偏好。" + } + ] + }, + { + "id": "controls-nav-group", + "x": 984, + "y": 788, + "axis": "x", + "items": [ + { + "id": "controls-nav", + "kind": "bottomNav", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "icon": "today", + "label": "今天" + }, + { + "icon": "calendar_month", + "label": "课表" + }, + { + "icon": "tune", + "label": "控制" + }, + { + "icon": "group", + "label": "人员" + } + ], + "selected": 2, + "actions": { + "tab:0": { + "to": "home", + "transition": "fade" + }, + "tab:1": { + "to": "schedule", + "transition": "fade" + }, + "tab:3": { + "to": "people", + "transition": "fade" + } + } + } + ] + }, + { + "id": "people-bar-group", + "x": 0, + "y": 972, + "axis": "x", + "items": [ + { + "id": "people-bar", + "kind": "topAppBar", + "label": "人员与凭证", + "icon": "arrow_back", + "icon2": "person_add", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + } + } + ] + }, + { + "id": "people-list-group", + "x": 16, + "y": 1080, + "axis": "y", + "items": [ + { + "id": "people-visitor", + "kind": "listItem", + "label": "访客访问", + "supporting": "启用访客功能与自动进入访客页", + "icon": "person_outline", + "switch": true, + "checked": false, + "variant": "filled" + }, + { + "id": "people-roles", + "kind": "listItem", + "label": "角色配置", + "supporting": "创建自定义角色,设置课表、通知、语音、控制与扩展权限", + "icon": "admin_panel_settings", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "people-users", + "kind": "listItem", + "label": "账号管理", + "supporting": "新建、编辑、启停账号,分配角色与个人附加权限", + "icon": "group", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "people-reset", + "kind": "listItem", + "label": "密码与设备会话", + "supporting": "重置密码时可撤销所有手表登录", + "icon": "key", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "people-pairing", + "kind": "listItem", + "label": "插件配对码", + "supporting": "添加、查看和撤销 ClassIsland 插件长期凭证", + "icon": "link", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "people-revoke", + "kind": "listItem", + "label": "已登录设备", + "supporting": "查看手表名称与最近使用时间,逐台吊销凭证", + "icon": "watch", + "icon2": "chevron_right", + "variant": "filled" + } + ] + }, + { + "id": "people-nav-group", + "x": 0, + "y": 1760, + "axis": "x", + "items": [ + { + "id": "people-nav", + "kind": "bottomNav", + "label": "", + "icon": null, + "variant": "filled", + "tabs": [ + { + "icon": "today", + "label": "今天" + }, + { + "icon": "calendar_month", + "label": "课表" + }, + { + "icon": "tune", + "label": "控制" + }, + { + "icon": "group", + "label": "人员" + } + ], + "selected": 3, + "actions": { + "tab:0": { + "to": "home", + "transition": "fade" + }, + "tab:1": { + "to": "schedule", + "transition": "fade" + }, + "tab:2": { + "to": "controls", + "transition": "fade" + } + } + } + ] + }, + { + "id": "account-bar-group", + "x": 492, + "y": 972, + "axis": "x", + "items": [ + { + "id": "account-bar", + "kind": "topAppBar", + "label": "账号与设置", + "icon": "arrow_back", + "icon2": "logout", + "variant": "filled", + "actions": { + "icon": { + "to": "back", + "transition": "slide" + } + }, + "note": "右上角退出登录;返回首页时保留当前账号会话。" + } + ] + }, + { + "id": "account-user-group", + "x": 508, + "y": 1080, + "axis": "x", + "items": [ + { + "id": "account-user", + "kind": "card", + "label": "杨 - 管理员", + "supporting": "已连接到 RemoteCI 服务器\n点击切换账号或连接其他服务器", + "icon": "account_circle", + "variant": "filled", + "fill": "secondaryContainer", + "size": 380, + "size2": 136, + "noImage": true, + "note": "支持选择保存的账号、退出当前账号,以及输入或扫描新的服务器地址后连接。" + } + ] + }, + { + "id": "account-list-group", + "x": 508, + "y": 1240, + "axis": "y", + "items": [ + { + "id": "account-connection", + "kind": "listItem", + "label": "连接与服务器", + "supporting": "连接", + "icon": "wifi", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-notifications", + "kind": "listItem", + "label": "通知设置", + "supporting": "课程、自动化和第三方插件提醒的同步开关", + "icon": "notifications", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-appearance", + "kind": "listItem", + "label": "外观", + "supporting": "主题与显示偏好", + "icon": "palette", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-update", + "kind": "listItem", + "label": "更新", + "supporting": "检查更新与同版本强制覆盖", + "icon": "system_update", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-developer", + "kind": "listItem", + "label": "手表开发者设置", + "supporting": "更多设置", + "icon": "code", + "icon2": "chevron_right", + "variant": "filled" + }, + { + "id": "account-system", + "kind": "listItem", + "label": "系统配置", + "supporting": "服务端更新与备份", + "icon": "settings", + "icon2": "chevron_right", + "variant": "filled" + } + ] + }, + { + "id": "sy4e0vvu", + "x": -424, + "y": 188, + "axis": "x", + "items": [ + { + "id": "hxpevg9n", + "kind": "topAppBar", + "label": "标题", + "icon": "menu", + "variant": "filled", + "icon2": "more_vert", + "size": 412 + } + ] + }, + { + "id": "dl9v07ip", + "x": 324, + "y": 203, + "axis": "x", + "items": [ + { + "id": "qbd6wzou", + "kind": "circularProgress", + "label": "", + "icon": null, + "variant": "filled", + "size": 48, + "value": 60, + "wavy": false, + "trackThickness": 7 + } + ] + }, + { + "id": "d1lf0ipy", + "x": 32, + "y": 220, + "axis": "x", + "items": [ + { + "id": "9uw594jt", + "kind": "text", + "label": "信息技术", + "icon": null, + "variant": "filled", + "size": 32, + "bold": true + } + ] + }, + { + "id": "3096gm0l", + "x": 32, + "y": 203, + "axis": "x", + "items": [ + { + "id": "8d5h5izc", + "kind": "text", + "label": " 09:00–09:45", + "icon": null, + "variant": "filled", + "size": 14, + "bold": true + } + ] + } + ], + "frames": [ + { + "id": "home", + "name": "今日课堂", + "x": 0, + "y": 0, + "note": "首页显示 ClassIsland 当前课堂、倒计时、下一节和同步状态;左上角菜单打开账号与连接面板。" + }, + { + "id": "schedule", + "name": "七日课表与调课", + "x": 492, + "y": 0, + "note": "查看七日课表、拉取最新数据;拥有权限时可交换或替换课程。" + }, + { + "id": "controls", + "name": "课堂控制", + "x": 984, + "y": 0, + "note": "每项 WebUI 与手表控制能力单独列出,根据账号权限和在线插件能力显示。" + }, + { + "id": "quick-change", + "name": "快速换课", + "x": 1476, + "y": 0, + "note": "从正在上课或下一节课程直接进入;预选所点课程为源课程,快速选择目标节次后提交。" + }, + { + "id": "people", + "name": "人员与凭证", + "x": 0, + "y": 972, + "note": "管理员管理访客访问、角色、账号、插件配对码与已登录设备凭证。" + }, + { + "id": "account", + "name": "账号与设置", + "x": 492, + "y": 972, + "note": "切换账号、退出登录、连接服务器,并集中管理与手表端一致的连接、通知、外观、更新和开发者设置。" + } + ], + "paletteKey": "blue", + "frame": "phone", "title": "RemoteCI", "brief": "RemoteCI 课表手机版:同步 ClassIsland 课表与课堂状态,并完整承接 WebUI、手表控制、账号、角色和凭证管理。", - "frame": "phone", "platform": "android", - "paletteKey": "blue", - "theme": { "dark": false, "bothModes": true, "contrast": "standard", "shape": "rounded", "font": "roboto", "emphasized": true, "motion": "expressive" }, - "frames": [ - { "id": "home", "name": "今日课堂", "x": 0, "y": 0, "note": "首页显示 ClassIsland 当前课堂、倒计时、下一节和同步状态;左上角菜单打开账号与连接面板。" }, - { "id": "schedule", "name": "七日课表与调课", "x": 492, "y": 0, "note": "查看七日课表、拉取最新数据;拥有权限时可交换或替换课程。" }, - { "id": "controls", "name": "课堂控制", "x": 984, "y": 0, "note": "每项 WebUI 与手表控制能力单独列出,根据账号权限和在线插件能力显示。" }, - { "id": "quick-change", "name": "快速换课", "x": 1476, "y": 0, "note": "从正在上课或下一节课程直接进入;预选所点课程为源课程,快速选择目标节次后提交。" }, - { "id": "people", "name": "人员与凭证", "x": 0, "y": 972, "note": "管理员管理访客访问、角色、账号、插件配对码与已登录设备凭证。" }, - { "id": "account", "name": "账号与设置", "x": 492, "y": 972, "note": "切换账号、退出登录、连接服务器,并集中管理与手表端一致的连接、通知、外观、更新和开发者设置。" } - ], - "groups": [ - { "id": "home-bar-group", "x": 0, "y": 0, "axis": "x", "items": [ - { "id": "home-bar", "kind": "topAppBar", "label": "今日课堂", "icon": "menu", "icon2": "notifications", "variant": "filled", "actions": { "icon": { "to": "account", "transition": "slide" } }, "note": "点击左上角菜单显示账号切换、退出登录和服务器连接入口。" } - ] }, - { "id": "home-course-shadow-group", "x": 44, "y": 64, "axis": "x", "items": [ - { "id": "home-course-shadow", "kind": "badge", "label": "●", "icon": null, "variant": "filled", "note": "菜单旁的课程小图标影子:当前有课时显示对应科目色点,放学后隐藏。" } - ] }, - { "id": "home-current-group", "x": 16, "y": 104, "axis": "x", "items": [ - { "id": "home-current", "kind": "card", "label": "正在上课 · 信息技术", "supporting": "第二节 09:00–09:45 · 机房\n距离下课还有 18 分钟", "icon": "computer", "variant": "filled", "fill": "primaryContainer", "size": 380, "size2": 168, "noImage": true, "action": { "to": "quick-change", "transition": "expand" }, "note": "点击当前课程,带入第二节作为源课程并快速换课。" } - ] }, - { "id": "home-next-group", "x": 16, "y": 296, "axis": "y", "items": [ - { "id": "home-next", "kind": "listItem", "label": "下一节 · 体育", "supporting": "第三节 10:05–10:50 · 操场", "icon": "directions_run", "icon2": "swap_horiz", "variant": "filled", "action": { "to": "quick-change", "transition": "slide" }, "note": "点击下一节,带入第三节作为源课程并快速换课。" }, - { "id": "home-sync", "kind": "listItem", "label": "ClassIsland 已连接", "supporting": "课表已于 08:02 同步 · 手表已连接", "icon": "cloud_done", "icon2": "check_circle", "variant": "filled" } - ] }, - { "id": "home-actions-group", "x": 16, "y": 456, "axis": "x", "items": [ - { "id": "home-schedule", "kind": "button", "label": "七日课表", "icon": "calendar_month", "variant": "filled", "size": 182, "action": { "to": "schedule", "transition": "slide" } }, - { "id": "home-controls", "kind": "button", "label": "课堂控制", "icon": "tune", "variant": "tonal", "size": 182, "action": { "to": "controls", "transition": "slide" } } - ] }, - { "id": "home-nav-group", "x": 0, "y": 788, "axis": "x", "items": [ - { "id": "home-nav", "kind": "bottomNav", "label": "", "icon": null, "variant": "filled", "tabs": [{ "icon": "today", "label": "今天" }, { "icon": "calendar_month", "label": "课表" }, { "icon": "tune", "label": "控制" }, { "icon": "group", "label": "人员" }], "selected": 0, "actions": { "tab:1": { "to": "schedule", "transition": "fade" }, "tab:2": { "to": "controls", "transition": "fade" }, "tab:3": { "to": "people", "transition": "fade" } } } - ] }, - - { "id": "schedule-bar-group", "x": 492, "y": 0, "axis": "x", "items": [ - { "id": "schedule-bar", "kind": "topAppBar", "label": "七日课表", "icon": "arrow_back", "icon2": "refresh", "variant": "filled", "actions": { "icon": { "to": "back", "transition": "slide" } } } - ] }, - { "id": "schedule-tabs-group", "x": 492, "y": 88, "axis": "x", "items": [ - { "id": "schedule-tabs", "kind": "tabs", "label": "", "icon": null, "variant": "filled", "tabs": [{ "label": "周一 15" }, { "label": "周二 16" }, { "label": "周三 17" }, { "label": "周四 18" }, { "label": "周五 19" }], "selected": 0 } - ] }, - { "id": "schedule-list-group", "x": 508, "y": 160, "axis": "y", "items": [ - { "id": "schedule-first", "kind": "listItem", "label": "第一节 · 数学", "supporting": "08:00–08:45 · 教学楼 301", "icon": "functions", "icon2": "edit", "variant": "filled" }, - { "id": "schedule-second", "kind": "listItem", "label": "第二节 · 信息技术", "supporting": "09:00–09:45 · 机房", "icon": "computer", "icon2": "edit", "variant": "filled" }, - { "id": "schedule-third", "kind": "listItem", "label": "第三节 · 体育", "supporting": "10:05–10:50 · 操场", "icon": "directions_run", "icon2": "edit", "variant": "filled" } - ] }, - { "id": "schedule-actions-group", "x": 508, "y": 424, "axis": "x", "items": [ - { "id": "schedule-pull", "kind": "button", "label": "立即拉取课表", "icon": "sync", "variant": "outlined", "size": 182, "note": "向在线插件请求重新生成七日课表;任务运行中会显示来源并拒绝重复请求。" }, - { "id": "schedule-change", "kind": "button", "label": "交换或替换", "icon": "swap_horiz", "variant": "filled", "size": 182, "note": "选择日期和源、目标节次;提交前校验课表修订号,成功后同步在线手表。" } - ] }, - { "id": "schedule-nav-group", "x": 492, "y": 788, "axis": "x", "items": [ - { "id": "schedule-nav", "kind": "bottomNav", "label": "", "icon": null, "variant": "filled", "tabs": [{ "icon": "today", "label": "今天" }, { "icon": "calendar_month", "label": "课表" }, { "icon": "tune", "label": "控制" }, { "icon": "group", "label": "人员" }], "selected": 1, "actions": { "tab:0": { "to": "home", "transition": "fade" }, "tab:2": { "to": "controls", "transition": "fade" }, "tab:3": { "to": "people", "transition": "fade" } } } - ] }, - - { "id": "quick-change-bar-group", "x": 1476, "y": 0, "axis": "x", "items": [ - { "id": "quick-change-bar", "kind": "topAppBar", "label": "快速换课", "icon": "arrow_back", "icon2": "help", "variant": "filled", "actions": { "icon": { "to": "back", "transition": "slide" } } } - ] }, - { "id": "quick-change-source-group", "x": 1492, "y": 104, "axis": "x", "items": [ - { "id": "quick-change-source", "kind": "card", "label": "当前选择 · 第二节 信息技术", "supporting": "09:00–09:45 · 机房\n从首页进入时自动预选被点击的当前课或下一节课。", "icon": "computer", "variant": "filled", "fill": "primaryContainer", "size": 380, "size2": 136, "noImage": true } - ] }, - { "id": "quick-change-target-group", "x": 1492, "y": 264, "axis": "y", "items": [ - { "id": "quick-change-mode", "kind": "select", "label": "交换课程", "supporting": "也可切换为替换课程", "icon": "swap_horiz", "variant": "outlined", "tabs": [{ "label": "交换课程" }, { "label": "替换课程" }], "selected": 0 }, - { "id": "quick-change-target", "kind": "select", "label": "目标 · 第三节 体育", "supporting": "10:05–10:50 · 操场", "icon": "directions_run", "variant": "outlined", "tabs": [{ "label": "第一节 · 数学" }, { "label": "第三节 · 体育" }, { "label": "第四节 · 英语" }], "selected": 1 } - ] }, - { "id": "quick-change-submit-group", "x": 1492, "y": 424, "axis": "x", "items": [ - { "id": "quick-change-submit", "kind": "button", "label": "确认换课", "icon": "check", "variant": "filled", "size": 380, "action": { "to": "home", "transition": "slide" }, "note": "提交前校验课表修订号;成功后更新今日课堂、七日课表和在线手表。" } - ] }, - - { "id": "controls-bar-group", "x": 984, "y": 0, "axis": "x", "items": [ - { "id": "controls-bar", "kind": "topAppBar", "label": "课堂控制", "icon": "arrow_back", "icon2": "more_vert", "variant": "filled", "actions": { "icon": { "to": "back", "transition": "slide" } } } - ] }, - { "id": "controls-list-group", "x": 1000, "y": 104, "axis": "y", "items": [ - { "id": "control-notify", "kind": "listItem", "label": "发送通知", "supporting": "标题、正文、强调特效、音效和语音朗读", "icon": "notifications_active", "icon2": "chevron_right", "variant": "filled", "note": "发送到 ClassIsland,并广播给在线手表。" }, - { "id": "control-voice", "kind": "listItem", "label": "发送语音", "supporting": "最长 60 秒的语音消息,录制后可试听再发送", "icon": "mic", "icon2": "chevron_right", "variant": "filled" }, - { "id": "control-clear", "kind": "listItem", "label": "清除提醒", "supporting": "清除 ClassIsland 当前提醒与手表提示", "icon": "notifications_off", "icon2": "chevron_right", "variant": "filled" }, - { "id": "control-teacher", "kind": "listItem", "label": "老师来了", "supporting": "显示提醒,等待一秒后自动清除", "icon": "campaign", "icon2": "chevron_right", "variant": "filled" }, - { "id": "control-main-menu", "kind": "listItem", "label": "主界面", "supporting": "显示或隐藏 ClassIsland 主界面", "icon": "visibility", "icon2": "chevron_right", "variant": "filled" }, - { "id": "control-volume", "kind": "listItem", "label": "音量", "supporting": "查看音量、调节百分比和切换静音", "icon": "volume_up", "icon2": "chevron_right", "variant": "filled" }, - { "id": "control-power", "kind": "listItem", "label": "电源控制", "supporting": "关机、睡眠或休眠,需要单独权限", "icon": "power_settings_new", "icon2": "chevron_right", "variant": "filled" }, - { "id": "control-extensions", "kind": "listItem", "label": "扩展功能", "supporting": "运行已注册扩展,并配置启用、普通用户开放和手表展示", "icon": "extension", "icon2": "chevron_right", "variant": "filled", "note": "管理员可逐项管理扩展策略;普通账号只管理自己的手表展示偏好。" } - ] }, - { "id": "controls-nav-group", "x": 984, "y": 788, "axis": "x", "items": [ - { "id": "controls-nav", "kind": "bottomNav", "label": "", "icon": null, "variant": "filled", "tabs": [{ "icon": "today", "label": "今天" }, { "icon": "calendar_month", "label": "课表" }, { "icon": "tune", "label": "控制" }, { "icon": "group", "label": "人员" }], "selected": 2, "actions": { "tab:0": { "to": "home", "transition": "fade" }, "tab:1": { "to": "schedule", "transition": "fade" }, "tab:3": { "to": "people", "transition": "fade" } } } - ] }, - - { "id": "people-bar-group", "x": 0, "y": 972, "axis": "x", "items": [ - { "id": "people-bar", "kind": "topAppBar", "label": "人员与凭证", "icon": "arrow_back", "icon2": "person_add", "variant": "filled", "actions": { "icon": { "to": "back", "transition": "slide" } } } - ] }, - { "id": "people-list-group", "x": 16, "y": 1080, "axis": "y", "items": [ - { "id": "people-visitor", "kind": "listItem", "label": "访客访问", "supporting": "启用访客功能与自动进入访客页", "icon": "person_outline", "switch": true, "checked": false, "variant": "filled" }, - { "id": "people-roles", "kind": "listItem", "label": "角色配置", "supporting": "创建自定义角色,设置课表、通知、语音、控制与扩展权限", "icon": "admin_panel_settings", "icon2": "chevron_right", "variant": "filled" }, - { "id": "people-users", "kind": "listItem", "label": "账号管理", "supporting": "新建、编辑、启停账号,分配角色与个人附加权限", "icon": "group", "icon2": "chevron_right", "variant": "filled" }, - { "id": "people-reset", "kind": "listItem", "label": "密码与设备会话", "supporting": "重置密码时可撤销所有手表登录", "icon": "key", "icon2": "chevron_right", "variant": "filled" }, - { "id": "people-pairing", "kind": "listItem", "label": "插件配对码", "supporting": "添加、查看和撤销 ClassIsland 插件长期凭证", "icon": "link", "icon2": "chevron_right", "variant": "filled" }, - { "id": "people-revoke", "kind": "listItem", "label": "已登录设备", "supporting": "查看手表名称与最近使用时间,逐台吊销凭证", "icon": "watch", "icon2": "chevron_right", "variant": "filled" } - ] }, - { "id": "people-nav-group", "x": 0, "y": 1760, "axis": "x", "items": [ - { "id": "people-nav", "kind": "bottomNav", "label": "", "icon": null, "variant": "filled", "tabs": [{ "icon": "today", "label": "今天" }, { "icon": "calendar_month", "label": "课表" }, { "icon": "tune", "label": "控制" }, { "icon": "group", "label": "人员" }], "selected": 3, "actions": { "tab:0": { "to": "home", "transition": "fade" }, "tab:1": { "to": "schedule", "transition": "fade" }, "tab:2": { "to": "controls", "transition": "fade" } } } - ] }, - - { "id": "account-bar-group", "x": 492, "y": 972, "axis": "x", "items": [ - { "id": "account-bar", "kind": "topAppBar", "label": "账号与设置", "icon": "arrow_back", "icon2": "logout", "variant": "filled", "actions": { "icon": { "to": "back", "transition": "slide" } }, "note": "右上角退出登录;返回首页时保留当前账号会话。" } - ] }, - { "id": "account-user-group", "x": 508, "y": 1080, "axis": "x", "items": [ - { "id": "account-user", "kind": "card", "label": "杨天明 · 管理员", "supporting": "已连接到 RemoteCI 服务器\n点击切换账号或连接其他服务器", "icon": "account_circle", "variant": "filled", "fill": "secondaryContainer", "size": 380, "size2": 136, "noImage": true, "note": "支持选择保存的账号、退出当前账号,以及输入或扫描新的服务器地址后连接。" } - ] }, - { "id": "account-list-group", "x": 508, "y": 1240, "axis": "y", "items": [ - { "id": "account-connection", "kind": "listItem", "label": "连接与服务器", "supporting": "账号、云端地址、局域网插件发现与重新连接", "icon": "wifi", "icon2": "chevron_right", "variant": "filled" }, - { "id": "account-notifications", "kind": "listItem", "label": "手表通知设置", "supporting": "课程、自动化和第三方插件提醒的同步开关", "icon": "notifications", "icon2": "chevron_right", "variant": "filled" }, - { "id": "account-appearance", "kind": "listItem", "label": "手表外观", "supporting": "同步手表主题与显示偏好", "icon": "palette", "icon2": "chevron_right", "variant": "filled" }, - { "id": "account-update", "kind": "listItem", "label": "手表更新", "supporting": "正式版或 Beta 渠道、检查更新与同版本强制覆盖", "icon": "system_update", "icon2": "chevron_right", "variant": "filled" }, - { "id": "account-developer", "kind": "listItem", "label": "手表开发者设置", "supporting": "云端中转、局域网连接开关与重新连接", "icon": "code", "icon2": "chevron_right", "variant": "filled" }, - { "id": "account-system", "kind": "listItem", "label": "系统配置", "supporting": "服务端更新、自动备份、配置导入导出与重启", "icon": "settings", "icon2": "chevron_right", "variant": "filled" } - ] } - ] -} + "dynamicColor": false, + "theme": { + "dark": false, + "bothModes": true, + "contrast": "standard", + "shape": "rounded", + "font": "roboto", + "emphasized": true, + "motion": "expressive" + } +} \ No newline at end of file diff --git a/server/RemoteCI.Server/Data/AppDbContext.cs b/server/RemoteCI.Server/Data/AppDbContext.cs index 17a406a..f06f9fd 100644 --- a/server/RemoteCI.Server/Data/AppDbContext.cs +++ b/server/RemoteCI.Server/Data/AppDbContext.cs @@ -16,6 +16,10 @@ public sealed class AppDbContext(DbContextOptions options) public DbSet BackupConfigurations => Set(); public DbSet ExtensionPolicies => Set(); public DbSet UserExtensionPreferences => Set(); + public DbSet Classrooms => Set(); + public DbSet ClassMemberships => Set(); + public DbSet ClassGroups => Set(); + public DbSet ClassGroupAssignments => Set(); protected override void OnModelCreating(ModelBuilder builder) { @@ -24,6 +28,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity(entity => { entity.Property(x => x.DisplayName).HasMaxLength(40); + entity.Property(x => x.SetupTokenHash).HasMaxLength(64); entity.HasIndex(x => x.Version); entity.HasOne(x => x.RoleDefinition).WithMany(x => x.Users).HasForeignKey(x => x.RoleDefinitionId).OnDelete(DeleteBehavior.Restrict); }); @@ -62,11 +67,39 @@ protected override void OnModelCreating(ModelBuilder builder) entity.HasIndex(x => x.TokenHash).IsUnique(); entity.Property(x => x.TokenHash).HasMaxLength(64); entity.Property(x => x.Name).HasMaxLength(80); + entity.HasOne(x => x.Classroom).WithMany().HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.Cascade); }); builder.Entity(entity => { entity.HasIndex(x => x.CodeHash).IsUnique(); entity.Property(x => x.CodeHash).HasMaxLength(64); }); + builder.Entity(entity => + { + entity.Property(x => x.Name).HasMaxLength(40); + entity.HasIndex(x => x.Name).IsUnique(); + entity.Property(x => x.AvatarContentType).HasMaxLength(64); + }); + builder.Entity(entity => + { + entity.Property(x => x.Name).HasMaxLength(40); + entity.HasIndex(x => x.Name).IsUnique(); + entity.HasOne(x => x.Parent).WithMany(x => x.Children).HasForeignKey(x => x.ParentId).OnDelete(DeleteBehavior.SetNull); + }); + builder.Entity(entity => + { + entity.HasKey(x => new { x.GroupId, x.ClassroomId }); + entity.HasIndex(x => x.ClassroomId); + entity.HasOne(x => x.Group).WithMany(x => x.Assignments).HasForeignKey(x => x.GroupId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.Classroom).WithMany(x => x.GroupAssignments).HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.Cascade); + }); + builder.Entity(entity => + { + entity.HasKey(x => new { x.UserId, x.ClassroomId }); + entity.HasIndex(x => x.ClassroomId); + entity.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.Classroom).WithMany(x => x.Memberships).HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.RoleDefinition).WithMany().HasForeignKey(x => x.RoleDefinitionId).OnDelete(DeleteBehavior.Restrict); + }); } } diff --git a/server/RemoteCI.Server/Data/AppUser.cs b/server/RemoteCI.Server/Data/AppUser.cs index c90dc8d..e038d10 100644 --- a/server/RemoteCI.Server/Data/AppUser.cs +++ b/server/RemoteCI.Server/Data/AppUser.cs @@ -13,4 +13,11 @@ public sealed class AppUser : IdentityUser, UserProfileLike public bool Enabled { get; set; } = true; public long Version { get; set; } public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + /// 批量导入时未设置密码:首次登录强制设置密码,设置完成后清除。 + public bool PasswordPending { get; set; } + + /// 首登设置密码的一次性令牌摘要(SHA-256);15 分钟有效。 + public string? SetupTokenHash { get; set; } + public DateTimeOffset? SetupTokenExpiresAt { get; set; } } diff --git a/server/RemoteCI.Server/Data/Entities.cs b/server/RemoteCI.Server/Data/Entities.cs index d508a5a..37f96f3 100644 --- a/server/RemoteCI.Server/Data/Entities.cs +++ b/server/RemoteCI.Server/Data/Entities.cs @@ -21,6 +21,8 @@ public sealed class PluginCredential public string Name { get; set; } = string.Empty; public string TokenHash { get; set; } = string.Empty; public bool Enabled { get; set; } = true; + public Guid ClassroomId { get; set; } = Classroom.DefaultId; + public Classroom Classroom { get; set; } = null!; public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset LastSeenAt { get; set; } } @@ -29,6 +31,9 @@ public sealed class PluginPairingCode { public Guid Id { get; set; } public string CodeHash { get; set; } = string.Empty; + + /// 配对成功后插件凭据归属的班级;旧数据回填为默认班级。 + public Guid ClassroomId { get; set; } = Classroom.DefaultId; public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset ExpiresAt { get; set; } public DateTimeOffset? UsedAt { get; set; } @@ -45,11 +50,73 @@ public sealed class SystemMetadata /// 服务端主动向插件拉取课表的间隔分钟数;0 表示关闭定时拉取。 public int SchedulePullIntervalMinutes { get; set; } - /// 启用后未登录访客可只读查看课表。 + /// 启用访客功能时,访问 WebUI 落地页直接进入访客课表;没有任何班级启用访客功能时无效。 + public bool AutoEnterVisitorPage { get; set; } +} + +/// 班级:一个班级对应一台教室端 ClassIsland 插件与其课表/状态流。 +public sealed class Classroom +{ + /// 升级自单班级版本的部署统一落到默认班级;不可删除。 + public static readonly Guid DefaultId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + + /// 启用后未登录访客可只读查看本班课表。 public bool VisitorAccessEnabled { get; set; } - /// 启用访客功能时,访问 WebUI 落地页直接进入访客课表;关闭访客功能时无效。 - public bool AutoEnterVisitorPage { get; set; } + /// 班级头像(小尺寸图片,≤256KB);null 表示未设置。 + public byte[]? Avatar { get; set; } + public string? AvatarContentType { get; set; } + public DateTimeOffset? AvatarUpdatedAt { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public ICollection Memberships { get; set; } = []; + public ICollection GroupAssignments { get; set; } = []; +} + +/// +/// 班级分组:管理员组织班级的维度(如年级、校区),支持父子层级; +/// 批量操作与广播通知按组展开时包含全部子分组中的班级。一个班级可属于多个分组。 +/// +public sealed class ClassGroup +{ + /// 分组最大层级深度:根为 1,超出后不能再建子分组。 + public const int MaxDepth = 4; + + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + + /// 父分组;null 表示根分组。 + public Guid? ParentId { get; set; } + public ClassGroup? Parent { get; set; } + public ICollection Children { get; set; } = []; + public ICollection Assignments { get; set; } = []; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } +} + +/// 班级与分组的多对多归属;一个班级可同时属于多个分组。 +public sealed class ClassGroupAssignment +{ + public Guid GroupId { get; set; } + public ClassGroup Group { get; set; } = null!; + public Guid ClassroomId { get; set; } + public Classroom Classroom { get; set; } = null!; +} + +/// 用户在某个班级中的成员关系与班内角色;一个用户可属于多个班级。 +public sealed class ClassMembership +{ + public Guid UserId { get; set; } + public AppUser User { get; set; } = null!; + public Guid ClassroomId { get; set; } + public Classroom Classroom { get; set; } = null!; + public Guid RoleDefinitionId { get; set; } + public AccountRole RoleDefinition { get; set; } = null!; } public enum AccountRoleKind @@ -57,12 +124,15 @@ public enum AccountRoleKind Student = 1, Administrator = 2, Custom = 3, + /// 内置“班管理员”:仅在所属班级内生效的班级管理角色,不授予系统管理员身份。 + ClassAdministrator = 4, } public sealed class AccountRole { public static readonly Guid StudentId = Guid.Parse("11111111-1111-1111-1111-111111111111"); public static readonly Guid AdministratorId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + public static readonly Guid ClassAdministratorId = Guid.Parse("44444444-4444-4444-4444-444444444444"); public Guid Id { get; set; } public string Name { get; set; } = string.Empty; diff --git a/server/RemoteCI.Server/Data/Migrations/20260926080157_AddClassrooms.Designer.cs b/server/RemoteCI.Server/Data/Migrations/20260926080157_AddClassrooms.Designer.cs new file mode 100644 index 0000000..0446763 --- /dev/null +++ b/server/RemoteCI.Server/Data/Migrations/20260926080157_AddClassrooms.Designer.cs @@ -0,0 +1,676 @@ +// +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("20260926080157_AddClassrooms")] + partial class AddClassrooms + { + /// + 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("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("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.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("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("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.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.Classroom", b => + { + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/RemoteCI.Server/Data/Migrations/20260926080157_AddClassrooms.cs b/server/RemoteCI.Server/Data/Migrations/20260926080157_AddClassrooms.cs new file mode 100644 index 0000000..0a3ad39 --- /dev/null +++ b/server/RemoteCI.Server/Data/Migrations/20260926080157_AddClassrooms.cs @@ -0,0 +1,186 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RemoteCI.Server.Data.Migrations +{ + /// + public partial class AddClassrooms : Migration + { + private const string DefaultClassId = "33333333-3333-3333-3333-333333333333"; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Classrooms", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 40, nullable: false), + VisitorAccessEnabled = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Classrooms", x => x.Id); + }); + + // 升级自单班级版本:默认班级承接原全局访客开关,所有存量数据都归属它。 + migrationBuilder.Sql($@" + INSERT INTO Classrooms (Id, Name, VisitorAccessEnabled, CreatedAt, UpdatedAt) + SELECT '{DefaultClassId}', '默认班级', + COALESCE((SELECT VisitorAccessEnabled FROM SystemMetadata WHERE Id = 1), 0), + '2026-01-01 00:00:00+00:00', '2026-01-01 00:00:00+00:00' + WHERE NOT EXISTS (SELECT 1 FROM Classrooms WHERE Id = '{DefaultClassId}');"); + + migrationBuilder.AddColumn( + name: "ClassroomId", + table: "PluginCredentials", + type: "TEXT", + nullable: false, + defaultValue: new Guid(DefaultClassId)); + + migrationBuilder.AddColumn( + name: "ClassroomId", + table: "PluginPairingCodes", + type: "TEXT", + nullable: false, + defaultValue: new Guid(DefaultClassId)); + + migrationBuilder.CreateTable( + name: "ClassMemberships", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + ClassroomId = table.Column(type: "TEXT", nullable: false), + RoleDefinitionId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ClassMemberships", x => new { x.UserId, x.ClassroomId }); + table.ForeignKey( + name: "FK_ClassMemberships_AccountRoles_RoleDefinitionId", + column: x => x.RoleDefinitionId, + principalTable: "AccountRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ClassMemberships_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ClassMemberships_Classrooms_ClassroomId", + column: x => x.ClassroomId, + principalTable: "Classrooms", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + // 存量用户按其当前角色在默认班级建立成员关系,升级前权限保持不变。 + migrationBuilder.Sql($@" + INSERT INTO ClassMemberships (UserId, ClassroomId, RoleDefinitionId) + SELECT Id, '{DefaultClassId}', RoleDefinitionId + FROM AspNetUsers + WHERE NOT EXISTS ( + SELECT 1 FROM ClassMemberships WHERE ClassMemberships.UserId = AspNetUsers.Id);"); + + migrationBuilder.CreateIndex( + name: "IX_PluginCredentials_ClassroomId", + table: "PluginCredentials", + column: "ClassroomId"); + + migrationBuilder.CreateIndex( + name: "IX_ClassMemberships_ClassroomId", + table: "ClassMemberships", + column: "ClassroomId"); + + migrationBuilder.CreateIndex( + name: "IX_ClassMemberships_RoleDefinitionId", + table: "ClassMemberships", + column: "RoleDefinitionId"); + + migrationBuilder.CreateIndex( + name: "IX_Classrooms_Name", + table: "Classrooms", + column: "Name", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_PluginCredentials_Classrooms_ClassroomId", + table: "PluginCredentials", + column: "ClassroomId", + principalTable: "Classrooms", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + // 删除全局访客开关。SQLite 上 EF 的 DropColumn 会整表重建并丢失其余列的默认值, + // 而 Bootstrap 启动引导依赖这些默认值;这里改为显式重建并保留全部默认值。 + migrationBuilder.Sql(""" + CREATE TABLE "SystemMetadata_new" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_SystemMetadata" PRIMARY KEY AUTOINCREMENT, + "AccountVersion" INTEGER NOT NULL DEFAULT 0, + "ForceSenderInTitle" INTEGER NOT NULL DEFAULT 1, + "SchedulePullIntervalMinutes" INTEGER NOT NULL DEFAULT 0, + "AutoEnterVisitorPage" INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO "SystemMetadata_new" ("Id", "AccountVersion", "ForceSenderInTitle", "SchedulePullIntervalMinutes", "AutoEnterVisitorPage") + SELECT "Id", "AccountVersion", "ForceSenderInTitle", "SchedulePullIntervalMinutes", "AutoEnterVisitorPage" FROM "SystemMetadata"; + DROP TABLE "SystemMetadata"; + ALTER TABLE "SystemMetadata_new" RENAME TO "SystemMetadata"; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // 还原全局访客开关列;旧 schema 的列默认值随表重建一并恢复。 + migrationBuilder.Sql(""" + CREATE TABLE "SystemMetadata_new" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_SystemMetadata" PRIMARY KEY AUTOINCREMENT, + "AccountVersion" INTEGER NOT NULL DEFAULT 0, + "ForceSenderInTitle" INTEGER NOT NULL DEFAULT 1, + "SchedulePullIntervalMinutes" INTEGER NOT NULL DEFAULT 0, + "VisitorAccessEnabled" INTEGER NOT NULL DEFAULT 0, + "AutoEnterVisitorPage" INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO "SystemMetadata_new" ("Id", "AccountVersion", "ForceSenderInTitle", "SchedulePullIntervalMinutes", "VisitorAccessEnabled", "AutoEnterVisitorPage") + SELECT "Id", "AccountVersion", "ForceSenderInTitle", "SchedulePullIntervalMinutes", 0, "AutoEnterVisitorPage" FROM "SystemMetadata"; + DROP TABLE "SystemMetadata"; + ALTER TABLE "SystemMetadata_new" RENAME TO "SystemMetadata"; + """); + + migrationBuilder.Sql($@" + UPDATE SystemMetadata + SET VisitorAccessEnabled = COALESCE(( + SELECT VisitorAccessEnabled FROM Classrooms WHERE Id = '{DefaultClassId}'), 0) + WHERE Id = 1;"); + + migrationBuilder.DropForeignKey( + name: "FK_PluginCredentials_Classrooms_ClassroomId", + table: "PluginCredentials"); + + migrationBuilder.DropTable( + name: "ClassMemberships"); + + migrationBuilder.DropTable( + name: "Classrooms"); + + migrationBuilder.DropIndex( + name: "IX_PluginCredentials_ClassroomId", + table: "PluginCredentials"); + + migrationBuilder.DropColumn( + name: "ClassroomId", + table: "PluginPairingCodes"); + + migrationBuilder.DropColumn( + name: "ClassroomId", + table: "PluginCredentials"); + } + } +} diff --git a/server/RemoteCI.Server/Data/Migrations/20260926120000_AddClassGroups.Designer.cs b/server/RemoteCI.Server/Data/Migrations/20260926120000_AddClassGroups.Designer.cs new file mode 100644 index 0000000..4fbb57d --- /dev/null +++ b/server/RemoteCI.Server/Data/Migrations/20260926120000_AddClassGroups.Designer.cs @@ -0,0 +1,723 @@ +// +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("20260926120000_AddClassGroups")] + partial class AddClassGroups + { + /// + 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("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("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("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ClassGroups"); + }); + + 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("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .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("GroupId"); + + 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("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.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.Classroom", b => + { + b.HasOne("RemoteCI.Server.Data.ClassGroup", "Group") + .WithMany("Classrooms") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Group"); + }); + + 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("Classrooms"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.Classroom", b => + { + b.Navigation("Group"); + + b.Navigation("Memberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/RemoteCI.Server/Data/Migrations/20260926120000_AddClassGroups.cs b/server/RemoteCI.Server/Data/Migrations/20260926120000_AddClassGroups.cs new file mode 100644 index 0000000..af518f8 --- /dev/null +++ b/server/RemoteCI.Server/Data/Migrations/20260926120000_AddClassGroups.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RemoteCI.Server.Data.Migrations +{ + /// + public partial class AddClassGroups : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ClassGroups", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 40, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ClassGroups", x => x.Id); + }); + + migrationBuilder.AddColumn( + name: "GroupId", + table: "Classrooms", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Classrooms_GroupId", + table: "Classrooms", + column: "GroupId"); + + migrationBuilder.AddForeignKey( + name: "FK_Classrooms_ClassGroups_GroupId", + table: "Classrooms", + column: "GroupId", + principalTable: "ClassGroups", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Classrooms_ClassGroups_GroupId", + table: "Classrooms"); + + migrationBuilder.DropIndex( + name: "IX_Classrooms_GroupId", + table: "Classrooms"); + + migrationBuilder.DropColumn( + name: "GroupId", + table: "Classrooms"); + + migrationBuilder.DropTable( + name: "ClassGroups"); + } + } +} diff --git a/server/RemoteCI.Server/Data/Migrations/20260926130000_AddClassSystemV2.Designer.cs b/server/RemoteCI.Server/Data/Migrations/20260926130000_AddClassSystemV2.Designer.cs new file mode 100644 index 0000000..bc3f35b --- /dev/null +++ b/server/RemoteCI.Server/Data/Migrations/20260926130000_AddClassSystemV2.Designer.cs @@ -0,0 +1,781 @@ +// +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("20260926130000_AddClassSystemV2")] + partial class AddClassSystemV2 + { + /// + 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("ParentId"); + + b.HasIndex("Name") + .IsUnique(); + + 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("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.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.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"); + + b.Navigation("Parent"); + }); + + 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/20260926130000_AddClassSystemV2.cs b/server/RemoteCI.Server/Data/Migrations/20260926130000_AddClassSystemV2.cs new file mode 100644 index 0000000..19ba704 --- /dev/null +++ b/server/RemoteCI.Server/Data/Migrations/20260926130000_AddClassSystemV2.cs @@ -0,0 +1,184 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RemoteCI.Server.Data.Migrations +{ + /// + public partial class AddClassSystemV2 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // 首登设置密码:导入时可以不设密码,首次登录强制补设。 + migrationBuilder.AddColumn( + name: "PasswordPending", + table: "AspNetUsers", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SetupTokenHash", + table: "AspNetUsers", + type: "TEXT", + maxLength: 64, + nullable: true); + + migrationBuilder.AddColumn( + name: "SetupTokenExpiresAt", + table: "AspNetUsers", + type: "TEXT", + nullable: true); + + // 分组层级:父子自引用,删除父分组时子分组回到根。 + migrationBuilder.AddColumn( + name: "ParentId", + table: "ClassGroups", + type: "TEXT", + nullable: true); + + // 班级头像:小尺寸图片直接存库,经匿名接口对外提供。 + migrationBuilder.AddColumn( + name: "Avatar", + table: "Classrooms", + type: "BLOB", + nullable: true); + + migrationBuilder.AddColumn( + name: "AvatarContentType", + table: "Classrooms", + type: "TEXT", + maxLength: 64, + nullable: true); + + migrationBuilder.AddColumn( + name: "AvatarUpdatedAt", + table: "Classrooms", + type: "TEXT", + nullable: true); + + // 班级与分组改为多对多:先把单分组归属迁到新表,再移除旧列。 + migrationBuilder.CreateTable( + name: "ClassGroupAssignments", + columns: table => new + { + GroupId = table.Column(type: "TEXT", nullable: false), + ClassroomId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ClassGroupAssignments", x => new { x.GroupId, x.ClassroomId }); + table.ForeignKey( + name: "FK_ClassGroupAssignments_ClassGroups_GroupId", + column: x => x.GroupId, + principalTable: "ClassGroups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ClassGroupAssignments_Classrooms_ClassroomId", + column: x => x.ClassroomId, + principalTable: "Classrooms", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.Sql( + "INSERT OR IGNORE INTO ClassGroupAssignments (GroupId, ClassroomId) " + + "SELECT GroupId, Id FROM Classrooms WHERE GroupId IS NOT NULL;"); + + migrationBuilder.CreateIndex( + name: "IX_ClassGroups_ParentId", + table: "ClassGroups", + column: "ParentId"); + + migrationBuilder.CreateIndex( + name: "IX_ClassGroupAssignments_ClassroomId", + table: "ClassGroupAssignments", + column: "ClassroomId"); + + migrationBuilder.AddForeignKey( + name: "FK_ClassGroups_ClassGroups_ParentId", + table: "ClassGroups", + column: "ParentId", + principalTable: "ClassGroups", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.DropForeignKey( + name: "FK_Classrooms_ClassGroups_GroupId", + table: "Classrooms"); + + migrationBuilder.DropIndex( + name: "IX_Classrooms_GroupId", + table: "Classrooms"); + + migrationBuilder.DropColumn( + name: "GroupId", + table: "Classrooms"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // 回滚到单分组:每班取一个分组(按分配顺序);多于一个分组的班级会丢失其余归属。 + migrationBuilder.AddColumn( + name: "GroupId", + table: "Classrooms", + type: "TEXT", + nullable: true); + + migrationBuilder.Sql( + "UPDATE Classrooms SET GroupId = COALESCE((" + + "SELECT a.GroupId FROM ClassGroupAssignments a WHERE a.ClassroomId = Classrooms.Id LIMIT 1), NULL);"); + + migrationBuilder.DropForeignKey( + name: "FK_ClassGroups_ClassGroups_ParentId", + table: "ClassGroups"); + + migrationBuilder.DropTable( + name: "ClassGroupAssignments"); + + migrationBuilder.DropIndex( + name: "IX_ClassGroups_ParentId", + table: "ClassGroups"); + + migrationBuilder.DropColumn( + name: "ParentId", + table: "ClassGroups"); + + migrationBuilder.DropColumn( + name: "PasswordPending", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "SetupTokenHash", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "SetupTokenExpiresAt", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "Avatar", + table: "Classrooms"); + + migrationBuilder.DropColumn( + name: "AvatarContentType", + table: "Classrooms"); + + migrationBuilder.DropColumn( + name: "AvatarUpdatedAt", + table: "Classrooms"); + + migrationBuilder.AddForeignKey( + name: "FK_Classrooms_ClassGroups_GroupId", + table: "Classrooms", + column: "GroupId", + principalTable: "ClassGroups", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + } +} diff --git a/server/RemoteCI.Server/Data/Migrations/AppDbContextModelSnapshot.cs b/server/RemoteCI.Server/Data/Migrations/AppDbContextModelSnapshot.cs index 17148f1..65d3305 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; @@ -227,6 +227,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("PasswordHash") .HasColumnType("TEXT"); + b.Property("PasswordPending") + .HasColumnType("INTEGER"); + b.Property("PhoneNumber") .HasColumnType("TEXT"); @@ -242,6 +245,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("SecurityStamp") .HasColumnType("TEXT"); + b.Property("SetupTokenExpiresAt") + .HasColumnType("TEXT"); + + b.Property("SetupTokenHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + b.Property("TwoFactorEnabled") .HasColumnType("INTEGER"); @@ -307,6 +317,109 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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("ParentId"); + + b.HasIndex("Name") + .IsUnique(); + + 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") @@ -382,6 +495,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); + b.Property("ClassroomId") + .HasColumnType("TEXT"); + b.Property("CreatedAt") .HasColumnType("TEXT"); @@ -403,6 +519,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("ClassroomId"); + b.HasIndex("TokenHash") .IsUnique(); @@ -415,6 +533,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); + b.Property("ClassroomId") + .HasColumnType("TEXT"); + b.Property("CodeHash") .IsRequired() .HasMaxLength(64) @@ -455,9 +576,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("SchedulePullIntervalMinutes") .HasColumnType("INTEGER"); - b.Property("VisitorAccessEnabled") - .HasColumnType("INTEGER"); - b.HasKey("Id"); b.ToTable("SystemMetadata"); @@ -545,6 +663,62 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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.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.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") @@ -556,6 +730,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -571,6 +756,22 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("Users"); }); + + modelBuilder.Entity("RemoteCI.Server.Data.ClassGroup", b => + { + b.Navigation("Assignments"); + + b.Navigation("Children"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("RemoteCI.Server.Data.Classroom", b => + { + b.Navigation("GroupAssignments"); + + b.Navigation("Memberships"); + }); #pragma warning restore 612, 618 } } diff --git a/server/RemoteCI.Server/Pages/BatchControl.cshtml b/server/RemoteCI.Server/Pages/BatchControl.cshtml new file mode 100644 index 0000000..89ddc4c --- /dev/null +++ b/server/RemoteCI.Server/Pages/BatchControl.cshtml @@ -0,0 +1,118 @@ +@page +@model BatchControlModel +@{ + ViewData["Title"] = "批量控制"; +} +
+
+

系统管理员

+

批量控制

+
+
+ +@if (Model.LastResults.Count > 0) +{ +
+

上次执行结果

+
    + @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
  • + } + } +
+
+} + +
+
+

选择目标班级

+

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

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

整组执行

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

逐班勾选

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

广播通知

+
+ + + +
+
+ +
+

清除通知

+

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

+ +
+ +
+

电源控制

+

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

+
+ + + +
+
+
+ +
+

语音消息

+

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

+
+ @Html.AntiForgeryToken() +
+ + + + +
+ +

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

+
+
diff --git a/server/RemoteCI.Server/Pages/BatchControl.cshtml.cs b/server/RemoteCI.Server/Pages/BatchControl.cshtml.cs new file mode 100644 index 0000000..7dffd31 --- /dev/null +++ b/server/RemoteCI.Server/Pages/BatchControl.cshtml.cs @@ -0,0 +1,210 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using RemoteCI.Server.Data; +using RemoteCI.Server.Services; +using RemoteCI.Shared; +using RemoteCI.Shared.Models; + +namespace RemoteCI.Server.Pages; + +/// +/// 系统管理员的批量控制台:勾选班级/分组后,批量发送通知、语音消息、清除通知或执行电源操作。 +/// 与“班级管理”一样属于管理员管理器,在导航中与单个班级的操作分区展示。 +/// +[Authorize] +public sealed class BatchControlModel( + UserManager users, + ClassroomService classrooms, + ClassBroadcastService broadcast, + IdentityCoordinator identities) : WebPageModel(users) +{ + private const string ResultsKey = "BatchControlResults"; + + [BindProperty] + public string? BroadcastTitle { get; set; } + + [BindProperty] + public string? BroadcastMessage { get; set; } + + [BindProperty] + public List SelectedClassIds { get; set; } = []; + + [BindProperty] + public List SelectedGroupIds { get; set; } = []; + + [BindProperty] + public PowerActionKind PowerAction { get; set; } + + [BindProperty] + public bool ConfirmPower { get; set; } + + public IReadOnlyList Classes { get; private set; } = []; + public IReadOnlyList Groups { get; private set; } = []; + + /// 上一次批量操作逐班结果,从 TempData 恢复后展示。 + public IReadOnlyList LastResults { get; private set; } = []; + + public async Task OnGetAsync(CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + await LoadAsync(ct); + RestoreResults(); + return Page(); + } + + public async Task OnPostNotifyAsync(CancellationToken ct) + { + 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(); + } + + public async Task OnPostClearAsync(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(); + } + + public async Task OnPostPowerAsync(CancellationToken ct) + { + if (await RequireAdminAsync() is { } denied) return denied; + if (!TargetsSelected()) return Back("请先勾选要操作的班级或分组。"); + if (!ConfirmPower) + { + TempData["Error"] = "电源操作影响教室端设备,请先勾选确认后执行。"; + return RedirectToPage(); + } + if (!Enum.IsDefined(PowerAction)) + { + TempData["Error"] = "未知电源操作。"; + return RedirectToPage(); + } + 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,目标班级经查询参数传递。 + public async Task OnPostBroadcastVoiceAsync(CancellationToken ct) + { + if (await RequireAdminAsync() is { } denied) return denied; + if (Request.ContentType != "application/octet-stream" || Request.ContentLength is > VoiceMessageRequest.MaxBytes) + return new JsonResult(CommandResult.Failure(CommandResultCodes.InvalidRequest, "语音格式无效或超过 60 秒")); + using var audio = new MemoryStream(); + var buffer = new byte[16 * 1024]; + int count; + while ((count = await Request.Body.ReadAsync(buffer, ct)) > 0) + { + if (audio.Length + count > VoiceMessageRequest.MaxBytes) + 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); + return new JsonResult(new { success = ok > 0, message }); + } + + private async Task DispatchAsync(BroadcastCommandRequest request, CancellationToken ct) + { + 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); + } + + private bool TargetsSelected() => SelectedClassIds.Count > 0 || SelectedGroupIds.Count > 0; + + private IActionResult Back(string message) + { + TempData["Error"] = message; + return RedirectToPage(); + } + + private static bool Summary(BatchClassOperationResult result, out int ok) + { + ok = result.Results.Count(x => x.Success); + return ok > 0; + } + + private static string Summarize(BatchClassOperationResult result) + { + var ok = result.Results.Count(x => x.Success); + var failures = result.Results.Where(x => !x.Success).ToList(); + return failures.Count == 0 + ? $"批量操作已完成({ok} 个班级)。" + : $"批量操作完成 {ok} 个,失败 {failures.Count} 个,详见下方逐班结果。"; + } + + private void SaveResults(BatchClassOperationResult result) => + TempData[ResultsKey] = JsonSerializer.Serialize(result.Results, JsonDefaults.Options); + + private void RestoreResults() + { + if (TempData[ResultsKey] is not string json) return; + try + { + LastResults = JsonSerializer.Deserialize>(json, JsonDefaults.Options) ?? []; + } + catch (JsonException) + { + LastResults = []; + } + } + + 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"); + } +} diff --git a/server/RemoteCI.Server/Pages/ClassSelect.cshtml b/server/RemoteCI.Server/Pages/ClassSelect.cshtml new file mode 100644 index 0000000..88a530c --- /dev/null +++ b/server/RemoteCI.Server/Pages/ClassSelect.cshtml @@ -0,0 +1,38 @@ +@page +@model ClassSelectModel +@{ + ViewData["Title"] = "选择班级"; +} +
+
+

多班级账号

+

选择进入的班级

+
+
+

该账号拥有多个班级的访问权限,选择一个进入;进入后可随时在页面右上角切换。

+
+ @foreach (var classroom in Model.AccessibleClasses) + { +
+ + + +
+ } +
diff --git a/server/RemoteCI.Server/Pages/ClassSelect.cshtml.cs b/server/RemoteCI.Server/Pages/ClassSelect.cshtml.cs new file mode 100644 index 0000000..18b7064 --- /dev/null +++ b/server/RemoteCI.Server/Pages/ClassSelect.cshtml.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using RemoteCI.Server.Data; +using RemoteCI.Shared; + +namespace RemoteCI.Server.Pages; + +/// +/// 登录后的班级选择页:账号可访问多个班级时,先选择进入哪个班级再落地; +/// 进入后仍可使用顶栏的班级切换器随时更换。 +/// +[Authorize] +public sealed class ClassSelectModel(UserManager users) : WebPageModel(users) +{ + public async Task OnGetAsync() + { + if (await RequireAsync() is { } denied) return denied; + // 单班级(或无班级)账号没有选择的意义,直接走默认落地。 + if (AccessibleClasses.Count <= 1) + return RedirectToPage(Permissions.HasFlag(UserPermissions.AccessWebUi) ? "/Index" : "/Account"); + return Page(); + } +} diff --git a/server/RemoteCI.Server/Pages/Classes.cshtml b/server/RemoteCI.Server/Pages/Classes.cshtml new file mode 100644 index 0000000..332d7cd --- /dev/null +++ b/server/RemoteCI.Server/Pages/Classes.cshtml @@ -0,0 +1,256 @@ +@page +@model ClassesModel +@{ + ViewData["Title"] = "班级管理"; +} +
+
+

班级 · 分组 · 访客

+

班级管理

+
+
+ +
+

新建班级

+

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

+
+ + +
+
+ +
+

班级分组

+

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

+
+ + + +
+ + @if (Model.Groups.Count > 0) + { +
+ @foreach (var group in Model.Groups) + { +
+ + @group.Name + @if (group.ParentId is not null) + { + 上级:@group.ParentName + } + 直接班级 @group.ClassCount 个 + +
+
+ + + + +
+
+ + +
+
+ + + +
+
+
+ } +
+ } + else + { +
还没有分组,创建后可以把班级划入分组。
+ } +
+ +
+
+

全部班级

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

+
+ @if (Model.Classes.Count == 0) + { +
还没有班级。
+ } + else + { +
+ + @if (Model.Groups.Count > 0) + { + + } + +
+ + } +
diff --git a/server/RemoteCI.Server/Pages/Classes.cshtml.cs b/server/RemoteCI.Server/Pages/Classes.cshtml.cs new file mode 100644 index 0000000..c45f366 --- /dev/null +++ b/server/RemoteCI.Server/Pages/Classes.cshtml.cs @@ -0,0 +1,261 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using RemoteCI.Server.Data; +using RemoteCI.Server.Services; +using RemoteCI.Shared; +using RemoteCI.Shared.Models; + +namespace RemoteCI.Server.Pages; + +[Authorize] +public sealed class ClassesModel( + UserManager users, + ClassroomService classrooms, + IdentityCoordinator identities, + AccountRoleService roleService, + AuthorizationSyncService authorizationSync, + VisitorAccessSettings visitorAccess) + : WebPageModel(users) +{ + [BindProperty] + public string NewClassName { get; set; } = string.Empty; + + [BindProperty] + public List SelectedClassIds { get; set; } = []; + + [BindProperty] + public List SelectedGroupIds { get; set; } = []; + + [BindProperty] + public string BatchOperation { get; set; } = string.Empty; + + [BindProperty] + public string NewGroupName { get; set; } = string.Empty; + + public IReadOnlyList Classes { get; private set; } = []; + public IReadOnlyList Groups { get; private set; } = []; + public Dictionary> MembersByClass { get; private set; } = new(); + 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 async Task OnGetAsync(CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + await LoadAsync(ct); + return Page(); + } + + public async Task OnPostCreateAsync(CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + try + { + await classrooms.CreateAsync(NewClassName, ct); + TempData["Message"] = "班级已创建。"; + } + catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } + return RedirectToPage(); + } + + public async Task OnPostRenameAsync(Guid id, string name, CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + try + { + await classrooms.RenameAsync(id, name, ct); + TempData["Message"] = "班级名称已更新。"; + } + catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } + return RedirectToPage(); + } + + public async Task OnPostDeleteAsync(Guid id, CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + try + { + await classrooms.DeleteAsync(id, ct); + await peersDisconnect(id, ct); + await authorizationSync.SyncAsync(ct); + TempData["Message"] = "班级已删除,成员关系与插件凭据一并移除。"; + } + catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } + return RedirectToPage(); + } + + public async Task OnPostToggleVisitorAsync(Guid id, bool enabled, CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + try + { + await classrooms.SetVisitorAccessAsync(id, enabled, ct); + TempData["Message"] = enabled ? "已开启该班级的访客功能。" : "已关闭该班级的访客功能。"; + } + catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } + return RedirectToPage(); + } + + public async Task OnPostAddMemberAsync(Guid id, Guid userId, Guid roleId, CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + try + { + var members = (await classrooms.ListMembersAsync(id, ct)).ToList(); + if (members.All(x => x.UserId != userId)) + members.Add(new ClassMemberInfo { UserId = userId, RoleId = roleId }); + await classrooms.UpdateMembersAsync(id, new UpdateClassMembersRequest + { + Members = members.Select(x => new ClassMemberInput { UserId = x.UserId, RoleId = x.RoleId }).ToList(), + }, ct); + await authorizationSync.SyncAsync(ct); + TempData["Message"] = "成员已添加。"; + } + catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } + return RedirectToPage(new { classId = (Guid?)id }); + } + + public async Task OnPostRemoveMemberAsync(Guid id, Guid userId, CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + try + { + var members = (await classrooms.ListMembersAsync(id, ct)).ToList(); + await classrooms.UpdateMembersAsync(id, new UpdateClassMembersRequest + { + Members = members.Where(x => x.UserId != userId) + .Select(x => new ClassMemberInput { UserId = x.UserId, RoleId = x.RoleId }).ToList(), + }, ct); + await authorizationSync.SyncAsync(ct); + TempData["Message"] = "成员已移出班级。"; + } + catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } + return RedirectToPage(new { classId = (Guid?)id }); + } + + public async Task OnPostBatchAsync(CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + if (SelectedClassIds.Count == 0) + { + TempData["Error"] = "请先勾选要操作的班级。"; + return RedirectToPage(); + } + var result = await classrooms.BatchAsync( + new BatchClassOperationRequest + { + ClassIds = SelectedClassIds, + GroupIds = SelectedGroupIds, + Operation = BatchOperation, + }, ct); + var succeeded = result.Results.Count(x => x.Success); + if (succeeded > 0) + { + if (BatchOperation.Equals("delete", StringComparison.OrdinalIgnoreCase)) + foreach (var item in result.Results.Where(x => x.Success)) + await peersDisconnect(item.ClassId, ct); + await authorizationSync.SyncAsync(ct); + } + var failures = result.Results.Where(x => !x.Success).ToList(); + TempData[succeeded > 0 ? "Message" : "Error"] = failures.Count == 0 + ? $"批量操作已完成({succeeded} 个班级)。" + : $"批量操作完成 {succeeded} 个,失败 {failures.Count} 个:{string.Join(";", failures.Select(x => x.Message))}"; + return RedirectToPage(); + } + + public async Task OnPostCreateGroupAsync(Guid? parentGroupId, CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + try + { + await classrooms.CreateGroupAsync(NewGroupName, parentGroupId, ct); + TempData["Message"] = "分组已创建。"; + } + catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } + return RedirectToPage(); + } + + public async Task OnPostRenameGroupAsync(Guid id, string name, Guid? parentGroupId, CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + try + { + await classrooms.RenameGroupAsync(id, name, parentGroupId, ct); + TempData["Message"] = "分组已更新。"; + } + catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } + return RedirectToPage(); + } + + /// 整体替换一个分组包含的班级。 + public async Task OnPostSetGroupClassesAsync(Guid id, List classIds, CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + try + { + await classrooms.SetGroupClassesAsync(id, classIds ?? [], ct); + TempData["Message"] = "分组成员班级已更新。"; + } + catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } + return RedirectToPage(); + } + + /// 整体替换一个班级所属的分组(多归属)。 + public async Task OnPostSetClassGroupsAsync(Guid id, List groupIds, CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + try + { + await classrooms.SetClassGroupsAsync(id, groupIds ?? [], ct); + TempData["Message"] = "班级分组已更新。"; + } + catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } + return RedirectToPage(); + } + + public async Task OnPostDeleteGroupAsync(Guid id, CancellationToken ct) + { + if (await RequireAsync(UserPermissions.AccessWebUi) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + try + { + await classrooms.DeleteGroupAsync(id, ct); + TempData["Message"] = "分组已删除,子分组上移一级,班级本身不受影响。"; + } + catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } + return RedirectToPage(); + } + + private async Task LoadAsync(CancellationToken ct) + { + Classes = await classrooms.ListAsync(ct); + Groups = await classrooms.ListGroupsAsync(ct); + Accounts = await identities.ListUsersAsync(ct); + RoleDefinitions = await roleService.ListAsync(ct); + foreach (var classroom in Classes) + MembersByClass[classroom.Id] = await classrooms.ListMembersAsync(classroom.Id, ct); + AnyVisitorClass = await visitorAccess.AnyVisitorClassEnabledAsync(ct); + AutoEnter = await visitorAccess.GetAutoEnterAsync(ct); + } + + private async Task peersDisconnect(Guid classId, CancellationToken ct) + { + // 班级删除后其插件凭据已被级联清理,主动断开对应在线连接。 + var registry = HttpContext.RequestServices.GetRequiredService(); + await registry.DisconnectPluginClassAsync(classId, ct); + } +} diff --git a/server/RemoteCI.Server/Pages/Control.cshtml b/server/RemoteCI.Server/Pages/Control.cshtml index e74b4b3..a1c0b53 100644 --- a/server/RemoteCI.Server/Pages/Control.cshtml +++ b/server/RemoteCI.Server/Pages/Control.cshtml @@ -48,6 +48,36 @@ } + @if (Model.CanManageClassInfo) + { +
+
+ +

班级设置

修改当前班级的名称与头像;头像会展示在选班页、访客页与客户端班级列表。

+
+
+ @if (Model.HasAvatar) + { + 班头像 + } +
+ + +
+
+ + +
+ @if (Model.HasAvatar) + { +
+ +
+ } +
+
+ } + @if (Model.CanSendNotifications) {
@@ -70,6 +100,38 @@
+ @if (Model.ShowBroadcast) + { +
+
+

广播通知(集控)

一次向多个班级同时发送通知;逐班校验权限并回报各班插件的执行结果。

+
+
+ + +
+ @foreach (var target in Model.BroadcastTargets) + { + + } +
+
+ +
+
+
+ } + @if (Model.CanClearNotifications) {
diff --git a/server/RemoteCI.Server/Pages/Control.cshtml.cs b/server/RemoteCI.Server/Pages/Control.cshtml.cs index 8df0b46..483d59f 100644 --- a/server/RemoteCI.Server/Pages/Control.cshtml.cs +++ b/server/RemoteCI.Server/Pages/Control.cshtml.cs @@ -16,7 +16,9 @@ public sealed class ControlModel( IStateStore store, IdentityCoordinator identities, ExtensionPolicyService extensionPolicies, - AuthorizationSyncService authorizationSync) : WebPageModel(users) + AuthorizationSyncService authorizationSync, + ClassBroadcastService broadcast, + ClassroomService classesService) : WebPageModel(users) { private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(15); @@ -32,19 +34,39 @@ public sealed class ControlModel( [BindProperty] public List ExtensionInputs { get; set; } = []; - public bool PluginOnline => peers.HasPlugin; + [BindProperty] + public NoticeInput BroadcastInput { get; set; } = new(); + + [BindProperty] + public string? ClassNameInput { get; set; } + + [BindProperty] + public IFormFile? AvatarFile { get; set; } + + public bool HasAvatar => CurrentClass?.HasAvatar == true; + + [BindProperty] + public List BroadcastClassIds { get; set; } = []; + + public bool PluginOnline => peers.HasPluginFor(CurrentClassId); public ClassStateSnapshot? Snapshot { get; private set; } public IReadOnlyList Extensions { get; private set; } = []; - public bool CanTeacherComing => Permissions.HasFlag(UserPermissions.TeacherComing) && Supports(RemoteCiCapabilities.TeacherComing); - public bool CanSendNotifications => Permissions.HasFlag(UserPermissions.SendNotifications) && Supports(RemoteCiCapabilities.NotificationSend); - public bool CanSendVoiceMessages => Permissions.HasFlag(UserPermissions.SendVoiceMessages) && Supports(RemoteCiCapabilities.VoiceMessageSend); - public bool CanClearNotifications => Permissions.HasFlag(UserPermissions.SendNotifications) && Supports(RemoteCiCapabilities.NotificationClear); - public bool CanControlMainMenu => Permissions.HasFlag(UserPermissions.MainMenuControl) && Supports(RemoteCiCapabilities.MainMenuVisibility); - public bool CanControlPower => Permissions.HasFlag(UserPermissions.PowerControl) && Supports(RemoteCiCapabilities.PowerControl); - public bool CanControlVolume => Permissions.HasFlag(UserPermissions.PowerControl) && Supports(RemoteCiCapabilities.VolumeControl); - public bool CanUseExtensions => Permissions.HasFlag(UserPermissions.RunExtensions) && Supports(RemoteCiCapabilities.ExtensionsRun); + public bool CanTeacherComing => ClassPermissions.HasFlag(UserPermissions.TeacherComing) && Supports(RemoteCiCapabilities.TeacherComing); + public bool CanSendNotifications => ClassPermissions.HasFlag(UserPermissions.SendNotifications) && Supports(RemoteCiCapabilities.NotificationSend); + public bool CanSendVoiceMessages => ClassPermissions.HasFlag(UserPermissions.SendVoiceMessages) && Supports(RemoteCiCapabilities.VoiceMessageSend); + public bool CanClearNotifications => ClassPermissions.HasFlag(UserPermissions.SendNotifications) && Supports(RemoteCiCapabilities.NotificationClear); + public bool CanControlMainMenu => ClassPermissions.HasFlag(UserPermissions.MainMenuControl) && Supports(RemoteCiCapabilities.MainMenuVisibility); + 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 IReadOnlyList BroadcastTargets { get; private set; } = []; + + /// 有至少两个可广播班级时才展示广播面板,单班级部署保持原有操作路径。 + public bool ShowBroadcast => BroadcastTargets.Count >= 2; + public async Task OnGetAsync(CancellationToken ct) { if (await LoadAsync(ct) is { } denied) return denied; @@ -53,14 +75,16 @@ public async Task OnGetAsync(CancellationToken ct) public async Task OnPostTeacherComingAsync(CancellationToken ct) { - if (await RequireAsync(UserPermissions.TeacherComing) is { } denied) return denied; + if (await RequireAsync() is { } denied) return denied; + if (RequireClass(UserPermissions.TeacherComing) is { } classDenied) return classDenied; return RedirectWithResult(await SendAsync( new CommandMessage { Command = CommandKind.TeacherComing }, ct)); } public async Task OnPostVoiceMessageAsync(CancellationToken ct) { - if (await RequireAsync(UserPermissions.SendVoiceMessages) is { } denied) return denied; + if (await RequireAsync() is { } denied) return denied; + if (RequireClass(UserPermissions.SendVoiceMessages) is { } classDenied) return classDenied; // 原始 PCM 请求不经过表单文件缓存,不在服务器临时目录保留录音。 if (Request.ContentType != "application/octet-stream" || Request.ContentLength is > VoiceMessageRequest.MaxBytes) return new JsonResult(CommandResult.Failure(CommandResultCodes.InvalidRequest, "语音格式无效或超过 60 秒")); @@ -84,7 +108,8 @@ public async Task OnPostVoiceMessageAsync(CancellationToken ct) public async Task OnPostNotificationAsync(CancellationToken ct) { - if (await RequireAsync(UserPermissions.SendNotifications) is { } denied) return denied; + if (await RequireAsync() is { } denied) return denied; + if (RequireClass(UserPermissions.SendNotifications) is { } classDenied) return classDenied; if (!ModelState.IsValid) { if (await LoadAsync(ct) is { } loadDenied) return loadDenied; @@ -106,7 +131,8 @@ public async Task OnPostNotificationAsync(CancellationToken ct) public async Task OnPostNotificationSettingsAsync(CancellationToken ct) { - if (await RequireAsync(UserPermissions.SendNotifications) is { } denied) return denied; + if (await RequireAsync() is { } denied) return denied; + if (RequireClass(UserPermissions.SendNotifications) is { } classDenied) return classDenied; var settings = await identities.SetForceSenderInTitleAsync(ForceSenderInTitle, ct); await peers.SendSettingsToWatchesAsync(settings, ct); TempData["Message"] = ForceSenderInTitle ? "已开启强制显示发送人" : "已关闭强制显示发送人"; @@ -115,14 +141,16 @@ public async Task OnPostNotificationSettingsAsync(CancellationTok public async Task OnPostClearNotificationsAsync(CancellationToken ct) { - if (await RequireAsync(UserPermissions.SendNotifications) is { } denied) return denied; + if (await RequireAsync() is { } denied) return denied; + if (RequireClass(UserPermissions.SendNotifications) is { } classDenied) return classDenied; return RedirectWithResult(await SendAsync( new CommandMessage { Command = CommandKind.ClearNotifications }, ct)); } public async Task OnPostMainMenuAsync(bool visible, CancellationToken ct) { - if (await RequireAsync(UserPermissions.MainMenuControl) is { } denied) return denied; + if (await RequireAsync() is { } denied) return denied; + if (RequireClass(UserPermissions.MainMenuControl) is { } classDenied) return classDenied; return RedirectWithResult(await SendAsync(new CommandMessage { Command = CommandKind.SetMainMenuVisibility, @@ -132,7 +160,8 @@ public async Task OnPostMainMenuAsync(bool visible, CancellationT public async Task OnPostVolumeAsync(bool unmute, CancellationToken ct) { - if (await RequireAsync(UserPermissions.PowerControl) is { } denied) return denied; + if (await RequireAsync() is { } denied) return denied; + if (RequireClass(UserPermissions.PowerControl) is { } classDenied) return classDenied; if (VolumeLevel is < 0 or > 100) return VolumeResult(CommandResult.Failure( CommandResultCodes.InvalidRequest, "音量必须在 0 到 100 之间")); @@ -148,7 +177,8 @@ public async Task OnPostVolumeAsync(bool unmute, CancellationToke public async Task OnPostMuteAsync(bool muted, CancellationToken ct) { - if (await RequireAsync(UserPermissions.PowerControl) is { } denied) return denied; + if (await RequireAsync() is { } denied) return denied; + if (RequireClass(UserPermissions.PowerControl) is { } classDenied) return classDenied; return RedirectWithResult(await SendAsync(new CommandMessage { Command = CommandKind.Volume, @@ -158,7 +188,8 @@ public async Task OnPostMuteAsync(bool muted, CancellationToken c public async Task OnPostPowerAsync(PowerActionKind action, CancellationToken ct) { - if (await RequireAsync(UserPermissions.PowerControl) is { } denied) return denied; + if (await RequireAsync() is { } denied) return denied; + if (RequireClass(UserPermissions.PowerControl) is { } classDenied) return classDenied; if (!Enum.IsDefined(action)) { TempData["Error"] = "未知电源操作"; @@ -173,8 +204,9 @@ public async Task OnPostPowerAsync(PowerActionKind action, Cancel public async Task OnPostExtensionAsync(string extensionId, CancellationToken ct) { - if (await RequireAsync(UserPermissions.RunExtensions) is { } denied) return denied; - var definition = store.GetLatestExtensions()?.FirstOrDefault(extension => + if (await RequireAsync() is { } denied) return denied; + if (RequireClass(UserPermissions.RunExtensions) is { } classDenied) return classDenied; + var definition = store.GetLatestExtensions(CurrentClassId)?.FirstOrDefault(extension => string.Equals(extension.Id, extensionId, StringComparison.Ordinal)); if (definition is null) { @@ -182,7 +214,7 @@ public async Task OnPostExtensionAsync(string extensionId, Cancel return RedirectToPage(); } var item = (await extensionPolicies.ListForUserAsync( - CurrentUser.Id, CurrentUser.Role, Permissions, [definition], ct)) + CurrentUser.Id, CurrentUser.Role, ClassPermissions, [definition], ct)) .SingleOrDefault(); if (item?.CanInvoke != true) return RedirectToPage("/Denied"); @@ -244,8 +276,9 @@ public async Task OnPostExtensionPreferenceAsync( bool showOnWatch, CancellationToken ct) { - if (await RequireAsync(UserPermissions.RunExtensions) is { } denied) return denied; - var definition = store.GetLatestExtensions()?.FirstOrDefault(x => x.Id == extensionId); + if (await RequireAsync() is { } denied) return denied; + if (RequireClass(UserPermissions.RunExtensions) is { } classDenied) return classDenied; + var definition = store.GetLatestExtensions(CurrentClassId)?.FirstOrDefault(x => x.Id == extensionId); if (definition is null) return RedirectToPage("/Denied"); try @@ -258,17 +291,105 @@ public async Task OnPostExtensionPreferenceAsync( return RedirectToPage(); } + // ---------- 班级设置(班名/头像):系统管理员或本班班管理员 ---------- + + public async Task OnPostClassInfoAsync(CancellationToken ct) + { + if (await RequireAsync() is { } denied) return denied; + if (!CanManageClassInfo) return RedirectToPage("/Denied"); + if (string.IsNullOrWhiteSpace(ClassNameInput)) + { + TempData["Error"] = "班名不能为空。"; + return RedirectToPage(); + } + try + { + await classesService.RenameClassAsync(CurrentClassId, ClassNameInput, ct); + TempData["Message"] = "班名已更新。"; + } + catch (IdentityOperationException ex) { TempData["Error"] = ex.Message; } + return RedirectToPage(); + } + + public async Task OnPostAvatarAsync(CancellationToken ct) + { + if (await RequireAsync() is { } denied) return denied; + if (!CanManageClassInfo) return RedirectToPage("/Denied"); + var allowed = new[] { "image/png", "image/jpeg", "image/webp" }; + if (AvatarFile is null || AvatarFile.Length is 0 or > 256 * 1024) + { + TempData["Error"] = "头像需为不超过 256KB 的图片。"; + return RedirectToPage(); + } + if (!allowed.Contains(AvatarFile.ContentType)) + { + TempData["Error"] = "头像仅支持 PNG/JPEG/WebP。"; + return RedirectToPage(); + } + using var memory = new MemoryStream(); + await AvatarFile.CopyToAsync(memory, ct); + await classesService.SetAvatarAsync(CurrentClassId, memory.ToArray(), AvatarFile.ContentType, ct); + TempData["Message"] = "班头像已更新。"; + return RedirectToPage(); + } + + public async Task OnPostRemoveAvatarAsync(CancellationToken ct) + { + if (await RequireAsync() is { } denied) return denied; + if (!CanManageClassInfo) return RedirectToPage("/Denied"); + await classesService.SetAvatarAsync(CurrentClassId, null, null, ct); + TempData["Message"] = "班头像已清除。"; + return RedirectToPage(); + } + + public async Task OnPostBroadcastAsync(CancellationToken ct) + { + if (await RequireAsync() is { } denied) return denied; + if (RequireClass(UserPermissions.SendNotifications) is { } classDenied) return classDenied; + if (BroadcastClassIds.Count == 0) + { + TempData["Error"] = "请先勾选要通知的班级。"; + return RedirectToPage(); + } + + var result = await broadcast.BroadcastAsync( + new AuthPrincipal(PeerRole.Watch, + await identities.GetProfileAsync(CurrentUser.Id, ct), + null, null, null), + new BroadcastCommandRequest + { + Command = CommandKind.SendNotification, + Notification = new NotificationRequest + { + Title = BroadcastInput.Title, + Message = BroadcastInput.Message, + }, + ClassIds = BroadcastClassIds, + }, ct); + var ok = result.Results.Count(x => x.Success); + var failures = result.Results.Where(x => !x.Success).ToList(); + TempData[ok > 0 ? "Message" : "Error"] = failures.Count == 0 + ? $"广播通知已发送到 {ok} 个班级。" + : $"广播完成 {ok} 个班级,{failures.Count} 个失败:{string.Join(";", failures.Select(x => x.Message))}"; + return RedirectToPage(); + } + private async Task LoadAsync(CancellationToken ct) { if (await RequireAsync() is { } denied) return denied; - Snapshot = store.GetLatestSnapshot(); + Snapshot = store.GetLatestSnapshot(CurrentClassId); VolumeLevel = Snapshot?.VolumePercent ?? 0; if (CanSendNotifications) ForceSenderInTitle = await identities.GetForceSenderInTitleAsync(); + // 广播候选:有通知权限的可访问班级,排除当前班级(当前班用上方单班表单)。 + BroadcastTargets = AccessibleClasses + .Where(x => x.Id != CurrentClassId && x.Permissions?.HasFlag(UserPermissions.SendNotifications) == true) + .OrderBy(x => x.Name) + .ToList(); Extensions = await extensionPolicies.ListForUserAsync( CurrentUser.Id, CurrentUser.Role, - Permissions, - store.GetLatestExtensions() ?? [], + ClassPermissions, + store.GetLatestExtensions(CurrentClassId) ?? [], ct); return !CanTeacherComing && !CanSendNotifications && !CanSendVoiceMessages && !CanClearNotifications && !CanControlMainMenu && !CanControlPower && !CanControlVolume && !CanUseExtensions @@ -285,13 +406,15 @@ public async Task OnPostExtensionPreferenceAsync( private async Task SendAsync(CommandMessage command, CancellationToken ct) { command.RequestedBy = await identities.GetProfileAsync(CurrentUser.Id, ct); - return await peers.SendCommandAndWaitAsync(command, CommandTimeout, ct); + command.ClassId = CurrentClassId; + return await peers.SendCommandAndWaitAsync(command, CurrentClassId, CommandTimeout, ct); } private bool HasCurrentExtension(string extensionId) => - store.GetLatestExtensions()?.Any(x => x.Id == extensionId) == true; + store.GetLatestExtensions(CurrentClassId)?.Any(x => x.Id == extensionId) == true; - private bool Supports(string capability) => !peers.HasPlugin || peers.PrimaryPluginSupports(capability); + private bool Supports(string capability) => + !PluginOnline || peers.PrimaryPluginSupports(CurrentClassId, capability); private IActionResult VolumeResult(CommandResult result, bool unmuted = false) { diff --git a/server/RemoteCI.Server/Pages/Index.cshtml b/server/RemoteCI.Server/Pages/Index.cshtml index 821048e..96b1a5e 100644 --- a/server/RemoteCI.Server/Pages/Index.cshtml +++ b/server/RemoteCI.Server/Pages/Index.cshtml @@ -27,6 +27,19 @@ } + + @if (Model.PluginProtocolMismatch is { } mismatch) {
+ @if (accessibleClasses.Count > 1) + { +
+ + + +
+ } + else if (accessibleClasses.Count == 1) + { + @currentClassName + } diff --git a/server/RemoteCI.Server/Pages/Users.cshtml b/server/RemoteCI.Server/Pages/Users.cshtml index d335106..c7c31ab 100644 --- a/server/RemoteCI.Server/Pages/Users.cshtml +++ b/server/RemoteCI.Server/Pages/Users.cshtml @@ -13,16 +13,41 @@

访客访问

-

启用后,登录页会出现「仅查看」,未登录访客只能浏览课表。自动进入访客页默认关闭,且仅在启用访客功能时可开启。

-
- - +

在班级管理中逐班开启访客功能后,登录页会出现「仅查看」,未登录访客只能浏览开放班级的课表。自动进入访客页默认关闭,且仅在存在开放访客的班级时可开启。

+ +
@if (Model.CurrentUser.Role == UserRole.Admin) { +
+

批量导入人员

+

每行一个账号,推荐格式:ID,用户名,班级,角色,密码(班级不存在会自动创建;角色按名称匹配角色预设;密码可留空,该账号首次登录时会被要求设置密码;也支持制表符分隔,# 开头的行忽略)。旧 3 列格式 ID,用户名,密码 仍可用,班级与角色取下方默认值。

+
+ + + + +
+
+

角色配置

角色默认权限会统一应用到所有同角色账号。

diff --git a/server/RemoteCI.Server/Pages/Users.cshtml.cs b/server/RemoteCI.Server/Pages/Users.cshtml.cs index b0d07d6..e13ec69 100644 --- a/server/RemoteCI.Server/Pages/Users.cshtml.cs +++ b/server/RemoteCI.Server/Pages/Users.cshtml.cs @@ -15,7 +15,9 @@ public sealed class UsersModel( IdentityCoordinator identities, AccountRoleService roleService, AuthorizationSyncService authorizationSync, - VisitorAccessSettings visitorAccess) + VisitorAccessSettings visitorAccess, + ClassroomService classrooms, + UserImportService importService) : WebPageModel(users) { [BindProperty] @@ -25,9 +27,14 @@ public sealed class UsersModel( public IReadOnlyList Accounts { get; private set; } = []; public IReadOnlyList RoleDefinitions { get; private set; } = []; [BindProperty] public RoleInput RoleEdit { get; set; } = new(); - [BindProperty] public bool VisitorAccessEnabled { get; set; } [BindProperty] public bool AutoEnterVisitorPage { get; set; } + // 批量导入:每行一个账号(用户名,显示名,密码),整批落入所选班级并使用所选角色预设。 + [BindProperty] public string? ImportText { get; set; } + [BindProperty] public Guid? ImportClassId { get; set; } + [BindProperty] public Guid? ImportRoleId { get; set; } + public IReadOnlyList ImportClasses { get; private set; } = []; + public async Task OnGetAsync(CancellationToken ct) => await LoadAsync(ct); public async Task OnPostCreateAsync(CancellationToken ct) @@ -153,10 +160,31 @@ await identities.GetRoleAsync(id, ct) == UserRole.Admin) public async Task OnPostVisitorAccessAsync(CancellationToken ct) { if (await RequireAsync(UserPermissions.ManageUsers) is { } denied) return denied; - var state = await visitorAccess.SetAsync(VisitorAccessEnabled, AutoEnterVisitorPage, ct); - TempData["Message"] = state.Enabled - ? state.AutoEnter ? "已启用访客功能,访问 WebUI 将直接进入访客课表。" : "已启用访客功能。" - : "已关闭访客功能。"; + // 班级级“开放访客”开关在班级管理页逐班配置;这里只保留登录页自动进入的全局行为。 + var autoEnter = await visitorAccess.SetAutoEnterAsync(AutoEnterVisitorPage, ct); + AutoEnterVisitorPage = autoEnter; + TempData["Message"] = autoEnter + ? "已开启自动进入访客页。" + : "已关闭自动进入访客页。"; + return RedirectToPage(); + } + + public async Task OnPostBatchImportAsync(CancellationToken ct) + { + if (await RequireAsync(UserPermissions.ManageUsers) is { } denied) return denied; + if (CurrentUser.Role != UserRole.Admin) return RedirectToPage("/Denied"); + try + { + var result = await importService.ImportAsync(ImportText, ImportClassId, ImportRoleId, ct); + await authorizationSync.SyncAsync(ct); + TempData[result.Failures.Count == 0 ? "Message" : "Error"] = result.Failures.Count == 0 + ? $"批量导入完成:创建 {result.Created} 个账号。" + : $"批量导入完成 {result.Created} 个,失败 {result.Failures.Count} 个:{string.Join(";", result.Failures.Take(5))}{(result.Failures.Count > 5 ? "…" : "")}"; + } + catch (IdentityOperationException ex) + { + TempData["Error"] = ex.Message; + } return RedirectToPage(); } @@ -192,9 +220,8 @@ private async Task LoadAsync(CancellationToken ct) if (await RequireAsync(UserPermissions.ManageUsers) is { } denied) return denied; Accounts = await identities.ListUsersAsync(ct); RoleDefinitions = await roleService.ListAsync(ct); - var visitor = await visitorAccess.GetAsync(ct); - VisitorAccessEnabled = visitor.Enabled; - AutoEnterVisitorPage = visitor.AutoEnter; + ImportClasses = await classrooms.ListAsync(ct); + AutoEnterVisitorPage = await visitorAccess.GetAutoEnterAsync(ct); return Page(); } @@ -237,9 +264,7 @@ private async Task CreateFailureAsync( TempData["Error"] = message; Accounts = await identities.ListUsersAsync(ct); RoleDefinitions = await roleService.ListAsync(ct); - var visitor = await visitorAccess.GetAsync(ct); - VisitorAccessEnabled = visitor.Enabled; - AutoEnterVisitorPage = visitor.AutoEnter; + AutoEnterVisitorPage = await visitorAccess.GetAutoEnterAsync(ct); return Page(); } diff --git a/server/RemoteCI.Server/Pages/Visitor.cshtml b/server/RemoteCI.Server/Pages/Visitor.cshtml index d8a7fd0..55ba03d 100644 --- a/server/RemoteCI.Server/Pages/Visitor.cshtml +++ b/server/RemoteCI.Server/Pages/Visitor.cshtml @@ -9,6 +9,22 @@
返回登录 +@if (Model.VisitorClasses.Count > 0) +{ + +} @if (Model.Bundle is null) {
插件尚未同步课表。
diff --git a/server/RemoteCI.Server/Pages/Visitor.cshtml.cs b/server/RemoteCI.Server/Pages/Visitor.cshtml.cs index 9f148d7..ec469b7 100644 --- a/server/RemoteCI.Server/Pages/Visitor.cshtml.cs +++ b/server/RemoteCI.Server/Pages/Visitor.cshtml.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using RemoteCI.Server.Data; using RemoteCI.Server.Services; using RemoteCI.Shared.Models; @@ -11,12 +12,21 @@ public sealed class VisitorModel(VisitorAccessSettings visitorAccess, IStateStor { public ScheduleBundle? Bundle { get; private set; } - public async Task OnGetAsync(CancellationToken ct) + /// 开放访客功能的班级;访客页只能在它们之间切换。 + public IReadOnlyList VisitorClasses { get; private set; } = []; + + public Guid CurrentClassId { get; private set; } + + public async Task OnGetAsync(Guid? @class, CancellationToken ct) { - if (!(await visitorAccess.GetAsync(ct)).Enabled) + VisitorClasses = await visitorAccess.ListVisitorClassroomsAsync(ct); + // 没有任何班级开放访客时与旧行为一致:直接回登录页。 + if (VisitorClasses.Count == 0) return RedirectToPage("/Login"); - - Bundle = state.GetLatestSchedule(); + CurrentClassId = @class is { } id && VisitorClasses.Any(x => x.Id == id) + ? id + : VisitorClasses[0].Id; + Bundle = state.GetLatestSchedule(CurrentClassId); return Page(); } } diff --git a/server/RemoteCI.Server/Pages/WebPageModel.cs b/server/RemoteCI.Server/Pages/WebPageModel.cs index 940bf46..819b5b4 100644 --- a/server/RemoteCI.Server/Pages/WebPageModel.cs +++ b/server/RemoteCI.Server/Pages/WebPageModel.cs @@ -3,16 +3,37 @@ using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; using RemoteCI.Server.Data; +using RemoteCI.Server.Services; using RemoteCI.Shared; +using RemoteCI.Shared.Models; namespace RemoteCI.Server.Pages; public abstract class WebPageModel(UserManager users) : PageModel { + public const string CurrentClassCookie = "RemoteCI.CurrentClass"; + protected UserManager Users { get; } = users; public AppUser CurrentUser { get; private set; } = null!; public UserPermissions Permissions { get; private set; } + /// 当前选中的班级(Cookie 优先,否则落到第一个可访问班级);为 null 表示账号没有任何可访问班级。 + public ClassSummary? CurrentClass { get; private set; } + + /// 当前班级内的有效权限(按班内成员角色计算;系统管理员为 All)。 + public UserPermissions ClassPermissions { get; private set; } + + public IReadOnlyList AccessibleClasses { get; private set; } = []; + + /// 当前用户是否可以管理当前班级的信息(班名/班头像):系统管理员或本班班管理员。 + public bool CanManageClassInfo { get; private set; } + + public Guid CurrentClassId => CurrentClass?.Id ?? Classroom.DefaultId; + + /// + /// 加载当前用户并解析班级上下文。permission 针对账号全局权限(如 AccessWebUi/ManageUsers); + /// 班级内的操作请用 ClassPermissions 判断。 + /// protected async Task RequireAsync(UserPermissions? permission = null) { var id = Users.GetUserId(User); @@ -25,8 +46,40 @@ public abstract class WebPageModel(UserManager users) : PageModel user.Role, user.GrantedPermissions, user.RoleDefinition.DefaultPermissions); + + var access = HttpContext.RequestServices.GetRequiredService(); + AccessibleClasses = await access.GetAccessibleClassesAsync( + user.Id, user.Role, user.GrantedPermissions, HttpContext.RequestAborted); + var cookieClass = Guid.TryParse(Request.Cookies[CurrentClassCookie], out var parsed) ? parsed : (Guid?)null; + CurrentClass = AccessibleClasses.FirstOrDefault(x => x.Id == cookieClass) ?? AccessibleClasses.FirstOrDefault(); + // 没有任何可访问班级时退回默认班级占位,权限为 None,页面自然呈现无权限状态。 + ClassPermissions = CurrentClass?.Permissions ?? UserPermissions.None; + CanManageClassInfo = await access.IsClassAdminAsync(user.Id, user.Role, CurrentClassId, HttpContext.RequestAborted); return permission is not null && !Permissions.HasFlag(permission.Value) ? RedirectToPage("/Denied") : null; } + + /// 切换当前班级;仅允许切换到可访问的班级,成功后写 Cookie 供后续请求使用。 + public async Task OnPostSwitchClassAsync(Guid classId, string? returnUrl) + { + if (await RequireAsync() is { } denied) return denied; + if (AccessibleClasses.Any(x => x.Id == classId)) + { + Response.Cookies.Append(CurrentClassCookie, classId.ToString(), new CookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Strict, + IsEssential = true, + MaxAge = TimeSpan.FromDays(30), + }); + } + if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) + return LocalRedirect(returnUrl); + return RedirectToPage(); + } + + /// RequireAsync 之后按当前班级的有效权限做检查;班级内操作(通知/课表/控制)使用此方法。 + protected IActionResult? RequireClass(UserPermissions permission) => + ClassPermissions.HasFlag(permission) ? null : RedirectToPage("/Denied"); } diff --git a/server/RemoteCI.Server/Program.cs b/server/RemoteCI.Server/Program.cs index 8fb419b..0d1cbf6 100644 --- a/server/RemoteCI.Server/Program.cs +++ b/server/RemoteCI.Server/Program.cs @@ -79,6 +79,7 @@ { options.Conventions.AllowAnonymousToPage("/Login"); options.Conventions.AllowAnonymousToPage("/Visitor"); + options.Conventions.AllowAnonymousToPage("/SetupPassword"); }); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -87,6 +88,17 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(sp => +{ + // 插件在线数来自单例连接注册表;scoped 服务通过回调取数,避免直接依赖单例链。 + var service = new ClassroomService(sp.GetRequiredService()); + var registry = sp.GetRequiredService(); + service.OnlinePluginCounter = classId => registry.GetOnlinePluginConnections().Count(x => x.ClassId == classId); + return service; +}); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -154,6 +166,7 @@ await WebSocketHub.HandleAsync( context.RequestServices.GetRequiredService(), context.RequestServices.GetRequiredService(), context.RequestServices.GetRequiredService(), + context.RequestServices.GetRequiredService(), logger); }); @@ -169,7 +182,7 @@ await WebSocketHub.HandleAsync( app.MapPost("/api/auth/login", async ( LoginRequest request, IdentityCoordinator identities, AuthorizationSyncService authorizationSync, CancellationToken ct) => { - if (MissingFields(request.Username, request.Password) is { } bad) return bad; + if (MissingFields(request.Username) is { } bad) return bad; try { var response = await identities.LoginAsync(request, ct); @@ -192,6 +205,20 @@ await WebSocketHub.HandleAsync( catch (IdentityOperationException ex) { return OperationError(ex); } }).RequireRateLimiting("auth"); +// 首登设置密码:批量导入的待激活账号凭一次性令牌补设密码后即可正常登录。 +app.MapPost("/api/auth/setup-password", async ( + SetupPasswordRequest request, IdentityCoordinator identities, AuthorizationSyncService authorizationSync, CancellationToken ct) => +{ + if (MissingFields(request.Username, request.SetupToken, request.NewPassword) is { } bad) return bad; + try + { + await identities.SetupPasswordAsync(request, ct); + await authorizationSync.SyncAsync(ct); + return Results.NoContent(); + } + catch (IdentityOperationException ex) { return OperationError(ex); } +}).RequireRateLimiting("auth"); + app.MapPost("/api/auth/logout", async ( HttpContext ctx, IdentityCoordinator identities, AuthorizationSyncService authorizationSync, CancellationToken ct) => { @@ -244,45 +271,56 @@ await AuthorizeAsync(ctx, identities, ct) is { User: not null } principal catch (IdentityOperationException ex) { return OperationError(ex); } }); -app.MapGet("/api/state", async (HttpContext ctx, IdentityCoordinator identities, IStateStore store, CancellationToken ct) => +app.MapGet("/api/state", async ( + HttpContext ctx, Guid? classId, IdentityCoordinator identities, ClassAccessService access, IStateStore store, CancellationToken ct) => { - if (await AuthorizeAsync(ctx, identities, ct) is null) return Unauthorized(); - return store.GetLatestSnapshot() is { } snapshot + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (await ResolveClassAsync(principal, classId, access, ct) is not { } target) return Forbidden(); + return store.GetLatestSnapshot(target) is { } snapshot ? Results.Ok(snapshot) : Results.Json(Error(ApiErrorCodes.NotFound, "尚无课程状态"), statusCode: StatusCodes.Status404NotFound); }); -app.MapGet("/api/schedule", async (HttpContext ctx, IdentityCoordinator identities, IStateStore store, CancellationToken ct) => +app.MapGet("/api/schedule", async ( + HttpContext ctx, Guid? classId, IdentityCoordinator identities, ClassAccessService access, IStateStore store, CancellationToken ct) => { - if (await AuthorizeAsync(ctx, identities, ct) is null) return Unauthorized(); - return store.GetLatestSchedule() is { } schedule + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (await ResolveClassAsync(principal, classId, access, ct) is not { } target) return Forbidden(); + return store.GetLatestSchedule(target) is { } schedule ? Results.Ok(schedule) : Results.Json(Error(ApiErrorCodes.NotFound, "尚无课表"), statusCode: StatusCodes.Status404NotFound); }); app.MapPost("/api/commands", async ( - HttpContext ctx, CommandMessage command, IdentityCoordinator identities, PeerRegistry peers, IStateStore store, CancellationToken ct) => + HttpContext ctx, CommandMessage command, Guid? classId, IdentityCoordinator identities, + ClassAccessService access, PeerRegistry peers, IStateStore store, CancellationToken ct) => { var principal = await AuthorizeAsync(ctx, identities, ct); if (principal?.User is null) return Unauthorized(); + if (await ResolveClassAsync(principal, command.ClassId ?? classId, access, ct) is not { } target) return Forbidden(); + command.ClassId = target; + var classPermissions = await access.GetEffectivePermissionsAsync( + principal.User.Id, principal.User.Role, target, principal.User.GrantedPermissions, ct); if (command.Command == CommandKind.RunExtension) { // 与 WS 路径一致:独立扩展权限和管理员逐扩展策略必须同时通过。 if (string.IsNullOrEmpty(command.ExtensionId)) return Results.BadRequest(Error(ApiErrorCodes.InvalidRequest, "缺少扩展 Id")); - var definition = store.GetLatestExtensions()?.FirstOrDefault(x => x.Id == command.ExtensionId); + var definition = store.GetLatestExtensions(target)?.FirstOrDefault(x => x.Id == command.ExtensionId); if (definition is null) return Results.BadRequest(Error(ApiErrorCodes.InvalidRequest, "扩展功能不存在或尚未同步")); - if (!ExtensionAccess.CanInvoke(principal.User, definition)) return Forbidden(); + if (!ExtensionAccess.CanInvoke(classPermissions, principal.User.AllowedExtensionIds, definition)) return Forbidden(); } else { var required = CommandPermissions.Required(command.Command); if (required == UserPermissions.None) return Results.BadRequest(Error(ApiErrorCodes.InvalidRequest, "未知命令")); - if (!principal.User.Permissions.HasFlag(required)) return Forbidden(); + if (!classPermissions.HasFlag(required)) return Forbidden(); } command.RequestedBy = principal.User; - var result = await peers.SendCommandAndWaitAsync(command, TimeSpan.FromSeconds(15), ct); + var result = await peers.SendCommandAndWaitAsync(command, target, TimeSpan.FromSeconds(15), ct); return Results.Json(result, statusCode: CommandStatus(result)); }); @@ -350,6 +388,22 @@ await identities.GetRoleAsync(id, ct) == UserRole.Admin) } catch (IdentityOperationException ex) { return OperationError(ex); } }); +// 批量导入人员:行格式 ID,用户名,班级,角色,密码(密码可空=待激活),逐行回报结果。 +usersApi.MapPost("/batch-import", async ( + HttpContext ctx, BatchImportRequest request, IdentityCoordinator identities, UserImportService importService, + AuthorizationSyncService authorizationSync, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + try + { + var result = await importService.ImportAsync(request.Text, request.DefaultClassId, request.DefaultRoleId, ct); + if (result.Created > 0) await authorizationSync.SyncAsync(ct); + return Results.Ok(result); + } + catch (IdentityOperationException ex) { return OperationError(ex); } +}); usersApi.MapDelete("/{id:guid}", async ( Guid id, HttpContext ctx, IdentityCoordinator identities, AuthorizationSyncService authorizationSync, CancellationToken ct) => { @@ -370,15 +424,291 @@ await identities.GetRoleAsync(id, ct) == UserRole.Admin) }); app.MapPost("/api/plugin/pairing-code", async ( - HttpContext ctx, IdentityCoordinator identities, CancellationToken ct) => + HttpContext ctx, PairingCodeBody? body, IdentityCoordinator identities, CancellationToken ct) => { var principal = await AuthorizeAsync(ctx, identities, ct); if (principal?.User is null) return Unauthorized(); return HasPermission(principal, UserPermissions.ManageUsers) - ? Results.Ok(new { pairCode = await identities.CreatePluginPairingCodeAsync(ct) }) + ? Results.Ok(new { pairCode = await identities.CreatePluginPairingCodeAsync(body?.ClassId ?? Classroom.DefaultId, ct) }) : Forbidden(); }); +// 班级分组:管理员组织班级(如按年级),批量操作与广播通知可按组展开。 +var groupApi = app.MapGroup("/api/class-groups"); +groupApi.MapGet("/", async ( + HttpContext ctx, IdentityCoordinator identities, ClassroomService classrooms, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + return Results.Ok(await classrooms.ListGroupsAsync(ct)); +}); +groupApi.MapPost("/", async ( + HttpContext ctx, CreateClassGroupRequest request, IdentityCoordinator identities, ClassroomService classrooms, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + if (MissingFields(request.Name) is { } bad) return bad; + try { return Results.Ok(await classrooms.CreateGroupAsync(request.Name, request.ParentId, ct)); } + catch (IdentityOperationException ex) { return OperationError(ex); } +}); +groupApi.MapPut("/{id:guid}", async ( + Guid id, HttpContext ctx, UpdateClassGroupRequest request, IdentityCoordinator identities, ClassroomService classrooms, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + if (MissingFields(request.Name) is { } bad) return bad; + try { await classrooms.RenameGroupAsync(id, request.Name, request.ParentId, ct); return Results.NoContent(); } + catch (IdentityOperationException ex) { return OperationError(ex); } +}); +// 整体替换一个分组包含的班级(仅直接归属)。 +groupApi.MapPut("/{id:guid}/classes", async ( + Guid id, HttpContext ctx, UpdateGroupClassesRequest request, IdentityCoordinator identities, ClassroomService classrooms, + AuthorizationSyncService authorizationSync, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + try + { + await classrooms.SetGroupClassesAsync(id, request.ClassIds, ct); + await authorizationSync.SyncAsync(ct); + return Results.NoContent(); + } + catch (IdentityOperationException ex) { return OperationError(ex); } +}); +groupApi.MapDelete("/{id:guid}", async ( + Guid id, HttpContext ctx, IdentityCoordinator identities, ClassroomService classrooms, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + try { await classrooms.DeleteGroupAsync(id, ct); return Results.NoContent(); } + catch (IdentityOperationException ex) { return OperationError(ex); } +}); + +// 集控广播:一次向多个班级/分组发送通知、清除通知、电源或语音消息,逐班按班内权限鉴权并回报结果。 +app.MapPost("/api/commands/broadcast", async ( + HttpContext ctx, BroadcastCommandRequest request, IdentityCoordinator identities, + ClassBroadcastService broadcast, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (request.ClassIds.Count == 0 && (request.GroupIds?.Count ?? 0) == 0) + return Results.BadRequest(Error(ApiErrorCodes.InvalidRequest, "请选择要操作的班级或分组")); + if (request.Command is CommandKind.SendNotification && request.Notification is null) + return Results.BadRequest(Error(ApiErrorCodes.InvalidRequest, "缺少通知内容")); + if (request.Command is CommandKind.SendVoiceMessage && + !VoiceMessageRequest.TryDecode(request.VoiceMessage, out _)) + return Results.BadRequest(Error(ApiErrorCodes.InvalidRequest, "语音格式无效或超过 60 秒")); + try + { + return Results.Ok(await broadcast.BroadcastAsync(principal, request, ct)); + } + catch (IdentityOperationException ex) { return OperationError(ex); } +}); + +// 当前账号可访问的班级:客户端班级选择/切换的数据源。 +app.MapGet("/api/me/classes", async ( + HttpContext ctx, IdentityCoordinator identities, ClassAccessService access, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + return Results.Ok(await access.GetAccessibleClassesAsync(principal.User.Id, principal.User.Role, principal.User.GrantedPermissions, ct)); +}); + +// 班级管理:仅系统管理员。 +var classesApi = app.MapGroup("/api/classes"); +classesApi.MapGet("/", async ( + HttpContext ctx, IdentityCoordinator identities, ClassroomService classrooms, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + return Results.Ok(await classrooms.ListAsync(ct)); +}); +classesApi.MapPost("/", async ( + HttpContext ctx, CreateClassRequest request, IdentityCoordinator identities, ClassroomService classrooms, + AuthorizationSyncService authorizationSync, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + if (MissingFields(request.Name) is { } bad) return bad; + try + { + var created = await classrooms.CreateAsync(request.Name, ct); + await authorizationSync.SyncAsync(ct); + return Results.Created($"/api/classes/{created.Id}", created); + } + catch (IdentityOperationException ex) { return OperationError(ex); } +}); +classesApi.MapPut("/{id:guid}", async ( + Guid id, HttpContext ctx, UpdateClassRequest request, IdentityCoordinator identities, ClassroomService classrooms, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + if (MissingFields(request.Name) is { } bad) return bad; + try { await classrooms.RenameAsync(id, request.Name, ct); return Results.NoContent(); } + catch (IdentityOperationException ex) { return OperationError(ex); } +}); +classesApi.MapDelete("/{id:guid}", async ( + Guid id, HttpContext ctx, IdentityCoordinator identities, ClassroomService classrooms, + AuthorizationSyncService authorizationSync, PeerRegistry peers, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + try + { + await classrooms.DeleteAsync(id, ct); + // 班级删除会级联清理插件凭据;对应在线连接立即断开,重新配对前不再接收命令。 + await peers.DisconnectPluginClassAsync(id, ct); + await authorizationSync.SyncAsync(ct); + return Results.NoContent(); + } + catch (IdentityOperationException ex) { return OperationError(ex); } +}); +// 班级信息:系统管理员或本班班管理员可改班名;头像小图直存数据库。 +classesApi.MapPut("/{id:guid}/info", async ( + Guid id, HttpContext ctx, UpdateClassRequest request, IdentityCoordinator identities, ClassAccessService access, + ClassroomService classrooms, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (!await access.IsClassAdminAsync(principal.User.Id, principal.User.Role, id, ct)) return Forbidden(); + if (MissingFields(request.Name) is { } bad) return bad; + try + { + await classrooms.RenameClassAsync(id, request.Name, ct); + return Results.NoContent(); + } + catch (IdentityOperationException ex) { return OperationError(ex); } +}); +// 头像走原始字节上传(Bearer 客户端没有 antiforgery 令牌,不用 multipart 表单)。 +classesApi.MapPut("/{id:guid}/avatar", async ( + Guid id, HttpContext ctx, IdentityCoordinator identities, ClassAccessService access, + ClassroomService classrooms, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (!await access.IsClassAdminAsync(principal.User.Id, principal.User.Role, id, ct)) return Forbidden(); + var contentType = ctx.Request.Headers["X-Avatar-Type"].ToString(); + var allowed = new[] { "image/png", "image/jpeg", "image/webp" }; + if (!allowed.Contains(contentType)) + return Results.BadRequest(Error(ApiErrorCodes.InvalidRequest, "头像仅支持 PNG/JPEG/WebP")); + if (ctx.Request.ContentLength is null or 0 or > 256 * 1024) + return Results.BadRequest(Error(ApiErrorCodes.InvalidRequest, "头像需为不超过 256KB 的图片")); + using var memory = new MemoryStream(); + await ctx.Request.Body.CopyToAsync(memory, ct); + await classrooms.SetAvatarAsync(id, memory.ToArray(), contentType, ct); + return Results.NoContent(); +}); +classesApi.MapDelete("/{id:guid}/avatar", async ( + Guid id, HttpContext ctx, IdentityCoordinator identities, ClassAccessService access, + ClassroomService classrooms, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (!await access.IsClassAdminAsync(principal.User.Id, principal.User.Role, id, ct)) return Forbidden(); + await classrooms.SetAvatarAsync(id, null, null, ct); + return Results.NoContent(); +}); +// 头像属于班级公开信息(访客页也展示),无需鉴权;ETag 支持客户端缓存。 +app.MapGet("/api/classes/{id:guid}/avatar", async ( + Guid id, HttpContext ctx, ClassroomService classrooms, CancellationToken ct) => +{ + var classroom = await classrooms.RequireAsync(id, ct); + if (classroom.Avatar is null) return Results.NotFound(); + var etag = $"\"{classroom.AvatarUpdatedAt?.Ticks ?? 0:x}\""; + if (ctx.Request.Headers.IfNoneMatch == etag) + return Results.StatusCode(StatusCodes.Status304NotModified); + ctx.Response.Headers.ETag = etag; + return Results.File(classroom.Avatar, classroom.AvatarContentType ?? "image/png"); +}); + +classesApi.MapPut("/{id:guid}/visitor", async ( + Guid id, HttpContext ctx, ClassVisitorRequest request, IdentityCoordinator identities, ClassroomService classrooms, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + try + { + await classrooms.SetVisitorAccessAsync(id, request.Enabled, ct); + return Results.NoContent(); + } + catch (IdentityOperationException ex) { return OperationError(ex); } +}); +// 整体替换一个班级所属的分组(多归属)。 +classesApi.MapPut("/{id:guid}/groups", async ( + Guid id, HttpContext ctx, UpdateClassGroupsRequest request, IdentityCoordinator identities, ClassroomService classrooms, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + try + { + await classrooms.SetClassGroupsAsync(id, request.GroupIds, ct); + return Results.NoContent(); + } + catch (IdentityOperationException ex) { return OperationError(ex); } +}); +classesApi.MapGet("/{id:guid}/members", async ( + Guid id, HttpContext ctx, IdentityCoordinator identities, ClassroomService classrooms, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + try { return Results.Ok(await classrooms.ListMembersAsync(id, ct)); } + catch (IdentityOperationException ex) { return OperationError(ex); } +}); +classesApi.MapPut("/{id:guid}/members", async ( + Guid id, HttpContext ctx, UpdateClassMembersRequest request, IdentityCoordinator identities, ClassroomService classrooms, + AuthorizationSyncService authorizationSync, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + try + { + await classrooms.UpdateMembersAsync(id, request, ct); + await authorizationSync.SyncAsync(ct); + return Results.NoContent(); + } + catch (IdentityOperationException ex) { return OperationError(ex); } +}); +classesApi.MapPost("/batch", async ( + HttpContext ctx, BatchClassOperationRequest request, IdentityCoordinator identities, ClassroomService classrooms, + AuthorizationSyncService authorizationSync, PeerRegistry peers, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + if (MissingFields(request.Operation) is { } bad) return bad; + BatchClassOperationResult result; + try + { + result = await classrooms.BatchAsync(request, ct); + } + catch (IdentityOperationException ex) { return OperationError(ex); } + if (request.Operation.Trim().Equals("delete", StringComparison.OrdinalIgnoreCase) || + request.Operation.Trim().Equals("enablevisitor", StringComparison.OrdinalIgnoreCase) || + request.Operation.Trim().Equals("disablevisitor", StringComparison.OrdinalIgnoreCase)) + { + if (result.Results.Any(x => x.Success)) + { + if (request.Operation.Trim().Equals("delete", StringComparison.OrdinalIgnoreCase)) + await peers.DisconnectPluginClassAsync([.. request.ClassIds.Distinct()], ct); + await authorizationSync.SyncAsync(ct); + } + } + return Results.Ok(result); +}); + // 插件长期凭据管理:仅管理员可列举与吊销;吊销后通过连接注册表立即断开对应插件。 app.MapGet("/api/plugins/credentials", async ( HttpContext ctx, IdentityCoordinator identities, CancellationToken ct) => @@ -405,23 +735,215 @@ await identities.GetRoleAsync(id, ct) == UserRole.Admin) }); app.MapGet("/api/admin/status", async ( - HttpContext ctx, IdentityCoordinator identities, PeerRegistry peers, IStateStore store, CancellationToken ct) => + HttpContext ctx, IdentityCoordinator identities, PeerRegistry peers, ClassroomService classrooms, CancellationToken ct) => { var principal = await AuthorizeAsync(ctx, identities, ct); if (principal?.User is null) return Unauthorized(); if (!HasPermission(principal, UserPermissions.AccessWebUi)) return Forbidden(); + var classes = await classrooms.ListAsync(ct); return Results.Ok(new { pluginOnline = peers.HasPlugin, pluginConnections = peers.PluginCount, watchConnections = peers.WatchCount, + mobileConnections = peers.MobileCount, accountCount = (await identities.ListUsersAsync(ct)).Count, - latestStateAt = store.GetLatestSnapshot()?.GeneratedAt, - latestScheduleAt = store.GetLatestSchedule()?.GeneratedAt, + classes = classes.Select(x => new + { + id = x.Id, + name = x.Name, + pluginOnline = x.PluginCount > 0, + }), protocolVersion = Protocol.Version, }); }); +app.MapGet("/api/roles", async (HttpContext ctx, IdentityCoordinator identities, AccountRoleService roles, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (!HasPermission(principal, UserPermissions.ManageUsers)) return Forbidden(); + return Results.Ok(await roles.ListAsync(ct)); +}); +app.MapPost("/api/roles", async (HttpContext ctx, CreateAccountRoleRequest request, AccountRoleService roles, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities: ctx.RequestServices.GetRequiredService(), ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + if (MissingFields(request.Name) is { } bad) return bad; + try { return Results.Ok(await roles.CreateAsync(request.Name, request.DefaultPermissions, ct)); } + catch (IdentityOperationException ex) { return OperationError(ex); } +}); +app.MapPut("/api/roles/{id:guid}", async (Guid id, HttpContext ctx, UpdateAccountRoleRequest request, AccountRoleService roles, AuthorizationSyncService authorizationSync, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, ctx.RequestServices.GetRequiredService(), ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + try + { + await roles.UpdateAsync(id, request.Name, request.DefaultPermissions, ct); + await authorizationSync.SyncAsync(ct); + return Results.NoContent(); + } + catch (IdentityOperationException ex) { return OperationError(ex); } +}); +app.MapDelete("/api/roles/{id:guid}", async (Guid id, HttpContext ctx, AccountRoleService roles, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, ctx.RequestServices.GetRequiredService(), ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + try { await roles.DeleteAsync(id, ct); return Results.NoContent(); } + catch (IdentityOperationException ex) { return OperationError(ex); } +}); +// 访客设置:autoEnter 是全局登录页行为;“哪些班级开放访客”在班级管理中逐班配置。 +app.MapGet("/api/visitor", async ( + HttpContext ctx, IdentityCoordinator identities, VisitorAccessSettings visitor, ClassroomService classrooms, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (!HasPermission(principal, UserPermissions.ManageUsers)) return Forbidden(); + var classes = await classrooms.ListAsync(ct); + return Results.Ok(new + { + autoEnter = await visitor.GetAutoEnterAsync(ct), + anyVisitorClass = classes.Any(x => x.VisitorEnabled), + classes = classes.Select(x => new { id = x.Id, name = x.Name, visitorEnabled = x.VisitorEnabled }), + }); +}); +app.MapPut("/api/visitor", async ( + VisitorAutoEnterBody body, HttpContext ctx, IdentityCoordinator identities, VisitorAccessSettings visitor, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (!HasPermission(principal, UserPermissions.ManageUsers)) return Forbidden(); + return Results.Ok(new { autoEnter = await visitor.SetAutoEnterAsync(body.AutoEnter, ct) }); +}); +app.MapGet("/api/settings/notifications", async (HttpContext ctx, IdentityCoordinator identities, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + return Results.Ok(new SettingsSync { ForceSenderInTitle = await identities.GetForceSenderInTitleAsync(ct) }); +}); +app.MapPut("/api/settings/notifications", async (SettingsSync body, HttpContext ctx, IdentityCoordinator identities, PeerRegistry peers, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (!HasPermission(principal, UserPermissions.SendNotifications) && principal.User.Role != UserRole.Admin) + return Forbidden(); + var updated = await identities.SetForceSenderInTitleAsync(body.ForceSenderInTitle, ct); + await peers.SendSettingsToWatchesAsync(updated, ct); + return Results.Ok(updated); +}); +app.MapGet("/api/settings/schedule-pull", async (HttpContext ctx, IdentityCoordinator identities, SchedulePullSettings pull, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + return Results.Ok(new { intervalMinutes = (int)await pull.GetIntervalAsync(ct) }); +}); +app.MapPut("/api/settings/schedule-pull", async (HttpContext ctx, SchedulePullIntervalBody body, IdentityCoordinator identities, SchedulePullSettings pull, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (!HasPermission(principal, UserPermissions.ManageSchedule)) return Forbidden(); + var interval = Enum.IsDefined(typeof(SchedulePullInterval), body.IntervalMinutes) + ? (SchedulePullInterval)body.IntervalMinutes + : SchedulePullInterval.Disabled; + await pull.SetIntervalAsync(interval, ct); + return Results.Ok(new { intervalMinutes = (int)interval }); +}); +app.MapGet("/api/extensions", async ( + HttpContext ctx, Guid? classId, IdentityCoordinator identities, ClassAccessService access, + ExtensionPolicyService policies, IStateStore store, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (await ResolveClassAsync(principal, classId, access, ct) is not { } target) return Forbidden(); + var definitions = store.GetLatestExtensions(target) ?? []; + var classPermissions = await access.GetEffectivePermissionsAsync(principal.User.Id, principal.User.Role, target, principal.User.GrantedPermissions, ct); + var items = await policies.ListForUserAsync(principal.User.Id, principal.User.Role, classPermissions, definitions, ct); + return Results.Ok(items.Select(item => new + { + id = item.Definition.Id, + displayName = item.Definition.DisplayName, + enabled = item.Enabled, + allowNonAdmin = item.AllowNonAdmin, + showOnWatch = item.ShowOnWatch, + canInvoke = item.CanInvoke, + classId = target, + })); +}); +app.MapPut("/api/extensions/{id}", async (string id, ExtensionPolicyBody body, HttpContext ctx, IdentityCoordinator identities, ExtensionPolicyService policies, AuthorizationSyncService authorizationSync, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + try + { + if (principal.User.Role == UserRole.Admin) + await policies.UpdateAdminAsync(principal.User.Id, id, body.Enabled ?? true, body.AllowNonAdmin ?? false, body.ShowOnWatch ?? true, ct); + else + await policies.UpdatePersonalAsync(principal.User.Id, id, body.ShowOnWatch ?? true, ct); + await authorizationSync.SyncAsync(ct); + return Results.NoContent(); + } + catch (Exception ex) { return Results.Json(Error(ApiErrorCodes.InvalidRequest, ex.Message), statusCode: 400); } +}); +app.MapGet("/api/admin/system", async (HttpContext ctx, IdentityCoordinator identities, UpdateService updates, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + var development = ctx.RequestServices.GetRequiredService().IsDevelopment(); + return Results.Ok(new + { + currentVersion = updates.CurrentVersion, + canSelfUpdate = UpdateService.CanSelfUpdate(development, UpdateService.IsFnosRuntime), + message = UpdateService.IsFnosRuntime ? UpdateService.FnosManagedMessage : "", + }); +}); +app.MapPost("/api/admin/updates/check", async (UpdateCheckBody body, HttpContext ctx, IdentityCoordinator identities, UpdateService updates, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + var channel = string.Equals(body.Channel, "beta", StringComparison.OrdinalIgnoreCase) ? UpdateChannel.Beta : UpdateChannel.Stable; + var release = await updates.FetchLatestReleaseAsync(channel, ct); + return Results.Ok(new { tag = release?.Tag, name = release?.Name }); +}); +app.MapGet("/api/admin/backups", async (HttpContext ctx, IdentityCoordinator identities, ConfigurationArchiveService archives, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + return Results.Ok(archives.ListBackups()); +}); +app.MapPost("/api/admin/backups", async (HttpContext ctx, IdentityCoordinator identities, ConfigurationArchiveService archives, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + await archives.CreateLocalBackupAsync("manual", ct); + return Results.NoContent(); +}); +app.MapDelete("/api/admin/backups/{name}", async (string name, HttpContext ctx, IdentityCoordinator identities, ConfigurationArchiveService archives, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + try { archives.DeleteBackup(name); return Results.NoContent(); } + catch { return Results.Json(Error(ApiErrorCodes.NotFound, "备份不存在"), statusCode: 404); } +}); +app.MapPost("/api/admin/backups/{name}/restore", async (string name, HttpContext ctx, IdentityCoordinator identities, ConfigurationArchiveService archives, PeerRegistry peers, IHostApplicationLifetime lifetime, IHostEnvironment environment, CancellationToken ct) => +{ + var principal = await AuthorizeAsync(ctx, identities, ct); + if (principal?.User is null) return Unauthorized(); + if (principal.User.Role != UserRole.Admin) return Forbidden(); + await archives.CreateLocalBackupAsync("preimport", ct); + await archives.ApplyAsync(archives.ParseLocalBackup(archives.ReadBackup(name)), ct); + await peers.DisconnectAllAsync(ct); + ApplicationRestartCoordinator.ScheduleRestart(lifetime, environment); + return Results.NoContent(); +}); + app.MapGet("/api/health", () => Results.Ok(new { status = "ok", protocolVersion = Protocol.Version })); app.MapRazorPages(); app.Run(); @@ -436,6 +958,18 @@ await identities.GetRoleAsync(id, ct) == UserRole.Admin) static bool HasPermission(AuthPrincipal? principal, UserPermissions permission) => principal?.User?.Permissions.HasFlag(permission) == true; +/// +/// 解析请求的目标班级:显式 classId 必须可访问(否则 null→403),缺省落到默认班级或第一个成员班级。 +/// +static async Task ResolveClassAsync( + AuthPrincipal principal, Guid? requested, ClassAccessService access, CancellationToken ct) +{ + var user = principal.User!; + if (requested is { } id) + return await access.CanAccessAsync(user.Id, user.Role, id, ct) ? id : null; + return await access.ResolveDefaultClassIdAsync(user.Id, user.Role, ct); +} + /// 认证端点的必填字段缺失(含 JSON 显式传 null)时返回 400 而不是内部 500。 static IResult? MissingFields(params string?[] values) => values.Any(string.IsNullOrEmpty) @@ -468,4 +1002,10 @@ static IResult OperationError(IdentityOperationException ex) => Results.Json( }); static ApiError Error(string code, string message) => new() { Code = code, Message = message }; +public sealed record SchedulePullIntervalBody(int IntervalMinutes); +public sealed record ExtensionPolicyBody(bool? Enabled, bool? AllowNonAdmin, bool? ShowOnWatch); +public sealed record UpdateCheckBody(string Channel, bool Force = false); +public sealed record PairingCodeBody(Guid? ClassId); +public sealed record VisitorAutoEnterBody(bool AutoEnter); + public partial class Program; diff --git a/server/RemoteCI.Server/RemoteCI.Server.csproj b/server/RemoteCI.Server/RemoteCI.Server.csproj index 1e44e05..6171cb0 100644 --- a/server/RemoteCI.Server/RemoteCI.Server.csproj +++ b/server/RemoteCI.Server/RemoteCI.Server.csproj @@ -30,6 +30,7 @@ + diff --git a/server/RemoteCI.Server/Services/AccountRoleService.cs b/server/RemoteCI.Server/Services/AccountRoleService.cs index 3fefbee..7f9696c 100644 --- a/server/RemoteCI.Server/Services/AccountRoleService.cs +++ b/server/RemoteCI.Server/Services/AccountRoleService.cs @@ -39,9 +39,11 @@ public async Task UpdateAsync(Guid id, string name, UserPermissions permissions, { var role = await db.AccountRoles.SingleOrDefaultAsync(x => x.Id == id, ct) ?? throw new IdentityOperationException(ApiErrorCodes.NotFound, "Role not found"); - if (role.Kind == AccountRoleKind.Administrator) + if (role.Kind is AccountRoleKind.Administrator) throw new IdentityOperationException(ApiErrorCodes.Forbidden, "Administrator role is immutable"); - var normalized = role.Kind == AccountRoleKind.Student ? role.NormalizedName : NormalizeName(name); + var normalized = role.Kind is AccountRoleKind.Student or AccountRoleKind.ClassAdministrator + ? role.NormalizedName + : NormalizeName(name); if (role.Kind == AccountRoleKind.Custom && await db.AccountRoles.AnyAsync(x => x.Id != id && x.NormalizedName == normalized, ct)) throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, "Role name already exists"); if (role.Kind == AccountRoleKind.Custom) { role.Name = name.Trim(); role.NormalizedName = normalized; } @@ -64,10 +66,22 @@ public async Task DeleteAsync(Guid id, CancellationToken ct = default) throw new IdentityOperationException(ApiErrorCodes.Forbidden, "Built-in roles cannot be deleted"); if (await db.Users.AnyAsync(x => x.RoleDefinitionId == id, ct)) throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, "Role is still assigned to accounts"); + if (await db.ClassMemberships.AnyAsync(x => x.RoleDefinitionId == id, ct)) + throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, "Role is still assigned to class members"); db.AccountRoles.Remove(role); await db.SaveChangesAsync(ct); } + /// 按显示名或规范化名查找角色(批量导入按角色名引用)。 + public async Task FindByNameAsync(string name, CancellationToken ct = default) + { + var trimmed = name?.Trim() ?? string.Empty; + if (trimmed.Length == 0) return null; + var normalized = trimmed.ToUpperInvariant(); + return await db.AccountRoles.AsNoTracking() + .SingleOrDefaultAsync(x => x.Name == trimmed || x.NormalizedName == normalized, ct); + } + private static string NormalizeName(string value) { var trimmed = value?.Trim() ?? string.Empty; diff --git a/server/RemoteCI.Server/Services/AuthorizationSyncService.cs b/server/RemoteCI.Server/Services/AuthorizationSyncService.cs index cd5f045..30e2b7d 100644 --- a/server/RemoteCI.Server/Services/AuthorizationSyncService.cs +++ b/server/RemoteCI.Server/Services/AuthorizationSyncService.cs @@ -1,14 +1,21 @@ +using RemoteCI.Shared.Models; + namespace RemoteCI.Server.Services; /// -/// 把最新账号授权镜像推送给插件,并立即刷新所有在线手表的缓存授权。 +/// 把最新账号授权镜像推送给在线插件,并立即刷新所有在线手表的缓存授权。 /// 所有账号、角色和扩展策略变更都通过此入口同步,避免漏掉任一消费者。 +/// 授权镜像按班级生成:每个在线插件只收到自己班级的账号与班内角色。 /// public sealed class AuthorizationSyncService(IdentityCoordinator identities, PeerRegistry peers) { public async Task SyncAsync(CancellationToken ct = default) { - await peers.SendAccountSyncToPluginsAsync(await identities.CreateSyncAsync(ct), ct); + foreach (var (connectionId, classId) in peers.GetOnlinePluginConnections()) + { + var sync = await identities.CreateSyncAsync(classId, ct); + await peers.SendToPluginConnectionAsync(connectionId, Envelope.AccountSync(sync), ct); + } await peers.RefreshWatchAuthorizationsAsync(ct); } } diff --git a/server/RemoteCI.Server/Services/ClassAccessService.cs b/server/RemoteCI.Server/Services/ClassAccessService.cs new file mode 100644 index 0000000..e436350 --- /dev/null +++ b/server/RemoteCI.Server/Services/ClassAccessService.cs @@ -0,0 +1,130 @@ +using Microsoft.EntityFrameworkCore; +using RemoteCI.Server.Data; +using RemoteCI.Shared; +using RemoteCI.Shared.Models; + +namespace RemoteCI.Server.Services; + +/// +/// 用户与班级的访问关系及按班级的有效权限计算。 +/// 系统管理员自动可访问全部班级;普通用户按 ClassMembership 成员关系与班内角色判定。 +/// 方法以 userId/role/granted 表达身份,REST 主体(UserProfile)与页面实体(AppUser)都能调用。 +/// +public sealed class ClassAccessService(AppDbContext db) +{ + /// 用户可访问的班级列表(含每班角色与有效权限),供班级切换器与客户端使用。 + public async Task> GetAccessibleClassesAsync( + Guid userId, UserRole role, UserPermissions granted = UserPermissions.None, CancellationToken ct = default) + { + if (role == UserRole.Admin) + { + // SQLite 不支持 DateTimeOffset 排序的 SQL 翻译;班级数量极少,取回后内存排序。 + var all = await db.Classrooms + .Include(x => x.GroupAssignments).ThenInclude(a => a.Group) + .AsNoTracking().ToListAsync(ct); + return all.OrderBy(x => x.CreatedAt).Select(x => new ClassSummary + { + Id = x.Id, + Name = x.Name, + RoleName = "管理员", + Permissions = UserPermissions.All, + VisitorEnabled = x.VisitorAccessEnabled, + GroupNames = x.GroupAssignments.Select(a => a.Group.Name).ToList(), + HasAvatar = x.Avatar != null, + }).ToList(); + } + + var memberships = await db.ClassMemberships.AsNoTracking() + .Where(x => x.UserId == userId) + .Join(db.Classrooms, x => x.ClassroomId, y => y.Id, (x, y) => new { Membership = x, Classroom = y }) + .Join(db.AccountRoles, x => x.Membership.RoleDefinitionId, y => y.Id, + (x, y) => new + { + x.Classroom.Id, + x.Classroom.Name, + x.Classroom.VisitorAccessEnabled, + x.Classroom.CreatedAt, + x.Classroom.Avatar, + GroupNames = x.Classroom.GroupAssignments.Select(a => a.Group.Name).ToList(), + RoleName = y.Name, + RoleDefaults = y.DefaultPermissions, + }) + .ToListAsync(ct); + return memberships.OrderBy(x => x.CreatedAt).Select(x => new ClassSummary + { + Id = x.Id, + Name = x.Name, + RoleName = x.RoleName, + Permissions = EffectiveForMembership(role, x.RoleDefaults, granted), + VisitorEnabled = x.VisitorAccessEnabled, + GroupNames = x.GroupNames, + HasAvatar = x.Avatar != null, + }).ToList(); + } + + public async Task> GetAccessibleClassIdsAsync( + Guid userId, UserRole role, CancellationToken ct = default) + { + if (role == UserRole.Admin) + { + var ids = await db.Classrooms.AsNoTracking().ToListAsync(ct); + return ids.OrderBy(x => x.CreatedAt).Select(x => x.Id).ToList(); + } + return await db.ClassMemberships.AsNoTracking() + .Where(x => x.UserId == userId) + .OrderBy(x => x.ClassroomId) + .Select(x => x.ClassroomId) + .ToListAsync(ct); + } + + /// 用户在指定班级的有效权限;非成员返回 None。 + public async Task GetEffectivePermissionsAsync( + Guid userId, UserRole role, Guid classId, UserPermissions granted = UserPermissions.None, CancellationToken ct = default) + { + if (role == UserRole.Admin) return UserPermissions.All; + var roleDefaults = await db.ClassMemberships.AsNoTracking() + .Where(x => x.UserId == userId && x.ClassroomId == classId) + .Join(db.AccountRoles, x => x.RoleDefinitionId, y => y.Id, (x, y) => (UserPermissions?)y.DefaultPermissions) + .SingleOrDefaultAsync(ct); + return roleDefaults is null ? UserPermissions.None : EffectiveForMembership(role, roleDefaults.Value, granted); + } + + /// 用户是否可以访问指定班级(访问本身,不含具体权限位判断)。 + public Task CanAccessAsync(Guid userId, UserRole role, Guid classId, CancellationToken ct = default) => + role == UserRole.Admin + ? db.Classrooms.AnyAsync(x => x.Id == classId, ct) + : db.ClassMemberships.AnyAsync(x => x.UserId == userId && x.ClassroomId == classId, ct); + + /// + /// 是否可以管理班级信息(班名/班头像):系统管理员,或该班级中班内角色为“班管理员”的成员。 + /// + public Task IsClassAdminAsync(Guid userId, UserRole role, Guid classId, CancellationToken ct = default) => + role == UserRole.Admin + ? Task.FromResult(true) + : db.ClassMemberships.AsNoTracking() + .Where(x => x.UserId == userId && x.ClassroomId == classId) + .Join(db.AccountRoles, x => x.RoleDefinitionId, y => y.Id, (x, y) => y.Kind) + .AnyAsync(kind => kind == AccountRoleKind.ClassAdministrator, ct); + + /// 解析用户未显式选择班级时的默认班级:第一个可访问班级;没有可访问班级返回 null。 + public async Task ResolveDefaultClassIdAsync(Guid userId, UserRole role, CancellationToken ct = default) + { + if (role == UserRole.Admin) + { + // 管理员优先落默认班级,保持单班级部署的管理体验。 + var classes = await db.Classrooms.AsNoTracking().ToListAsync(ct); + return classes.OrderBy(x => x.Id == Classroom.DefaultId ? 0 : 1).ThenBy(x => x.CreatedAt) + .Select(x => (Guid?)x.Id).FirstOrDefault(); + } + return await db.ClassMemberships.AsNoTracking().Where(x => x.UserId == userId) + .OrderBy(x => x.ClassroomId) + .Select(x => (Guid?)x.ClassroomId).FirstOrDefaultAsync(ct); + } + + /// 按成员角色的有效权限:与全局角色同一套规则(ViewCurrentCourse 为底 + 角色默认 + 个人授予)。 + public static UserPermissions EffectiveForMembership( + UserRole role, UserPermissions membershipRoleDefaults, UserPermissions granted = UserPermissions.None) => + role == UserRole.Admin + ? UserPermissions.All + : RolePermissions.Effective(UserRole.User, granted, membershipRoleDefaults); +} diff --git a/server/RemoteCI.Server/Services/ClassBroadcastService.cs b/server/RemoteCI.Server/Services/ClassBroadcastService.cs new file mode 100644 index 0000000..b0feabb --- /dev/null +++ b/server/RemoteCI.Server/Services/ClassBroadcastService.cs @@ -0,0 +1,98 @@ +using RemoteCI.Shared; +using RemoteCI.Shared.Models; + +namespace RemoteCI.Server.Services; + +/// +/// 集控广播:向多个班级(可按分组展开)同时发送同一条命令(通知/清除通知/电源/语音消息)。 +/// 每个班级独立按发送者的班内有效权限鉴权、独立路由到该班插件,单班失败不影响其余班级。 +/// +public sealed class ClassBroadcastService( + IdentityCoordinator identities, + ClassroomService classrooms, + ClassAccessService access, + PeerRegistry peers) +{ + private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(15); + + /// 支持广播的命令白名单:单发路径已覆盖逐班操作,广播只开放有集体意义的命令。 + private static bool IsBroadcastable(CommandKind command) => command is + CommandKind.SendNotification or + CommandKind.ClearNotifications or + CommandKind.Power or + CommandKind.SendVoiceMessage or + CommandKind.TeacherComing; + + public async Task BroadcastAsync( + AuthPrincipal sender, BroadcastCommandRequest request, CancellationToken ct = default) + { + var required = CommandPermissions.Required(request.Command); + if (required == UserPermissions.None || !IsBroadcastable(request.Command)) + throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, "该命令不支持批量广播"); + + var classIds = await classrooms.ResolveTargetClassIdsAsync(request.ClassIds, request.GroupIds, ct); + var forceSender = request.Command == CommandKind.SendNotification + ? await identities.GetForceSenderInTitleAsync(ct) + : false; + var classNameById = (await classrooms.ListAsync(ct)).ToDictionary(x => x.Id, x => x.Name); + + var tasks = classIds.Select(classId => + SendToOneAsync(sender, request, required, classId, forceSender, classNameById, ct)); + var results = await Task.WhenAll(tasks); + return new BatchClassOperationResult { Results = [.. results] }; + } + + private async Task SendToOneAsync( + AuthPrincipal sender, + BroadcastCommandRequest request, + UserPermissions required, + Guid classId, + bool forceSender, + IReadOnlyDictionary classNameById, + CancellationToken ct) + { + var user = sender.User!; + try + { + var classPermissions = await access.GetEffectivePermissionsAsync( + user.Id, user.Role, classId, user.GrantedPermissions, ct); + if (!classPermissions.HasFlag(required)) + return Fail(classId, "没有该班级的操作权限"); + + var result = await peers.SendCommandAndWaitAsync(new CommandMessage + { + Command = request.Command, + ClassId = classId, + RequestedBy = sender.User.WithPermissions(classPermissions), + Notification = request.Notification is { } notification + ? new NotificationRequest + { + Title = notification.Title, + Message = notification.Message, + ForceSenderInTitle = forceSender, + } + : null, + PowerAction = request.PowerAction, + VoiceMessage = request.VoiceMessage, + }, classId, CommandTimeout, ct); + + return new BatchClassItemResult + { + ClassId = classId, + Success = result.Success, + Message = result.Success ? null : result.Message, + }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return Fail(classId, ex.Message); + } + + BatchClassItemResult Fail(Guid id, string message) => new() + { + ClassId = id, + Success = false, + Message = $"{classNameById.GetValueOrDefault(id, id.ToString())}:{message}", + }; + } +} diff --git a/server/RemoteCI.Server/Services/ClassroomService.cs b/server/RemoteCI.Server/Services/ClassroomService.cs new file mode 100644 index 0000000..0e9ec4f --- /dev/null +++ b/server/RemoteCI.Server/Services/ClassroomService.cs @@ -0,0 +1,423 @@ +using Microsoft.EntityFrameworkCore; +using RemoteCI.Server.Data; +using RemoteCI.Shared; +using RemoteCI.Shared.Models; + +namespace RemoteCI.Server.Services; + +/// 班级管理:增删改查、每班访客开关、班名/头像、分组(支持层级与多归属)与批量操作。 +public sealed class ClassroomService(AppDbContext db) +{ + public async Task> ListAsync(CancellationToken ct = default) + { + // SQLite 不支持 DateTimeOffset 排序的 SQL 翻译;班级数量极少,取回后在内存排序。 + var classrooms = (await db.Classrooms + .Include(x => x.GroupAssignments).ThenInclude(a => a.Group) + .AsNoTracking().ToListAsync(ct)) + .OrderBy(x => x.CreatedAt).ToList(); + var memberCounts = await db.ClassMemberships.AsNoTracking() + .GroupBy(x => x.ClassroomId) + .Select(x => new { ClassroomId = x.Key, Count = x.Count() }) + .ToDictionaryAsync(x => x.ClassroomId, x => x.Count, ct); + // 插件在线数来自连接注册表;服务是 scoped,通过注入的在线快照函数避免引用单例链。 + return classrooms.Select(x => new ClassDetail + { + Id = x.Id, + Name = x.Name, + VisitorEnabled = x.VisitorAccessEnabled, + MemberCount = memberCounts.GetValueOrDefault(x.Id), + PluginCount = OnlinePluginCounter?.Invoke(x.Id) ?? 0, + GroupIds = x.GroupAssignments.Select(a => a.GroupId).ToList(), + GroupNames = x.GroupAssignments.Select(a => a.Group.Name).ToList(), + HasAvatar = x.Avatar != null, + CreatedAt = x.CreatedAt, + }).ToList(); + } + + /// 由 DI 注入的在线插件统计回调(PeerRegistry 是单例,服务是 scoped)。 + public Func? OnlinePluginCounter { get; set; } + + public async Task RequireAsync(Guid id, CancellationToken ct = default) => + await db.Classrooms.SingleOrDefaultAsync(x => x.Id == id, ct) + ?? throw new IdentityOperationException(ApiErrorCodes.NotFound, "班级不存在"); + + public async Task CreateAsync(string name, CancellationToken ct = default) + { + var trimmed = ValidateName(name); + if (await db.Classrooms.AnyAsync(x => x.Name == trimmed, ct)) + throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, "班级名称已存在"); + var now = DateTimeOffset.UtcNow; + var classroom = new Classroom { Id = Guid.NewGuid(), Name = trimmed, CreatedAt = now, UpdatedAt = now }; + db.Classrooms.Add(classroom); + await db.SaveChangesAsync(ct); + return new ClassDetail { Id = classroom.Id, Name = classroom.Name, CreatedAt = classroom.CreatedAt }; + } + + public async Task RenameAsync(Guid id, string name, CancellationToken ct = default) + { + var classroom = await RequireAsync(id, ct); + var trimmed = ValidateName(name); + if (await db.Classrooms.AnyAsync(x => x.Name == trimmed && x.Id != id, ct)) + throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, "班级名称已存在"); + classroom.Name = trimmed; + classroom.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + } + + public async Task SetVisitorAccessAsync(Guid id, bool enabled, CancellationToken ct = default) + { + var classroom = await RequireAsync(id, ct); + if (classroom.VisitorAccessEnabled == enabled) return; + classroom.VisitorAccessEnabled = enabled; + classroom.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + } + + /// 删除班级并级联清理成员关系、分组归属与插件凭据(凭据需重新配对);默认班级不可删除。 + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + { + if (id == Classroom.DefaultId) + throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, "默认班级不能删除"); + var classroom = await RequireAsync(id, ct); + db.Classrooms.Remove(classroom); + await db.SaveChangesAsync(ct); + } + + // ---------- 班级信息(班名/头像):班管理员与系统管理员可用 ---------- + + /// 更新班名;调用方负责先做权限校验(系统管理员或本班班管理员)。 + public Task RenameClassAsync(Guid classId, string name, CancellationToken ct = default) => RenameAsync(classId, name, ct); + + /// 设置或清除班级头像;avatar 为 null 表示清除。 + public async Task SetAvatarAsync(Guid classId, byte[]? avatar, string? contentType, CancellationToken ct = default) + { + var classroom = await RequireAsync(classId, ct); + classroom.Avatar = avatar; + classroom.AvatarContentType = avatar is null ? null : contentType; + classroom.AvatarUpdatedAt = avatar is null ? null : DateTimeOffset.UtcNow; + classroom.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + } + + /// 按名称查找班级;不存在时自动创建(批量导入用)。 + public async Task EnsureClassByNameAsync(string name, CancellationToken ct = default) + { + var trimmed = ValidateName(name); + var existing = await db.Classrooms.SingleOrDefaultAsync(x => x.Name == trimmed, ct); + if (existing is not null) return existing; + var now = DateTimeOffset.UtcNow; + var classroom = new Classroom { Id = Guid.NewGuid(), Name = trimmed, CreatedAt = now, UpdatedAt = now }; + db.Classrooms.Add(classroom); + await db.SaveChangesAsync(ct); + return classroom; + } + + // ---------- 成员 ---------- + + /// 向班级添加单个成员(幂等);用户与角色必须存在。 + public async Task AddMemberAsync(Guid classId, Guid userId, Guid roleId, CancellationToken ct = default) + { + await RequireAsync(classId, ct); + if (await db.ClassMemberships.AnyAsync(x => x.ClassroomId == classId && x.UserId == userId, ct)) return; + if (!await db.Users.AnyAsync(x => x.Id == userId, ct)) + throw new IdentityOperationException(ApiErrorCodes.NotFound, "用户不存在"); + if (!await db.AccountRoles.AnyAsync(x => x.Id == roleId, ct)) + throw new IdentityOperationException(ApiErrorCodes.NotFound, "角色不存在"); + db.ClassMemberships.Add(new ClassMembership + { + UserId = userId, + ClassroomId = classId, + RoleDefinitionId = roleId, + }); + await db.SaveChangesAsync(ct); + } + + /// 整体替换班级成员列表;请求中引用的用户与角色必须存在。 + public async Task UpdateMembersAsync(Guid id, UpdateClassMembersRequest request, CancellationToken ct = default) + { + await RequireAsync(id, ct); + var seenUsers = new HashSet(); + foreach (var member in request.Members) + { + if (!seenUsers.Add(member.UserId)) + throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, "成员列表中存在重复用户"); + } + var userIds = request.Members.Select(x => x.UserId).ToList(); + var roleIds = request.Members.Select(x => x.RoleId).ToList(); + var foundUsers = await db.Users.Where(x => userIds.Contains(x.Id)).Select(x => x.Id).ToListAsync(ct); + if (foundUsers.Count != seenUsers.Count) + throw new IdentityOperationException(ApiErrorCodes.NotFound, "成员中包含不存在的用户"); + var roleCount = await db.AccountRoles.Where(x => roleIds.Distinct().Contains(x.Id)).CountAsync(ct); + if (roleCount != roleIds.Distinct().Count()) + throw new IdentityOperationException(ApiErrorCodes.NotFound, "成员角色中包含不存在的角色"); + + await using var transaction = await db.Database.BeginTransactionAsync(ct); + await db.ClassMemberships.Where(x => x.ClassroomId == id).ExecuteDeleteAsync(ct); + db.ClassMemberships.AddRange(request.Members.Select(x => new ClassMembership + { + UserId = x.UserId, + ClassroomId = id, + RoleDefinitionId = x.RoleId, + })); + await db.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + } + + // ---------- 班级分组:层级 + 多归属 ---------- + + public async Task> ListGroupsAsync(CancellationToken ct = default) + { + var groups = (await db.ClassGroups.Include(x => x.Parent).AsNoTracking().ToListAsync(ct)) + .OrderBy(x => x.CreatedAt).ToList(); + var counts = await db.ClassGroupAssignments.AsNoTracking() + .GroupBy(x => x.GroupId) + .Select(x => new { GroupId = x.Key, Count = x.Count() }) + .ToDictionaryAsync(x => x.GroupId, x => x.Count, ct); + return groups.Select(x => new ClassGroupInfo + { + Id = x.Id, + Name = x.Name, + ParentId = x.ParentId, + ParentName = x.Parent?.Name, + Depth = DepthOf(x, groups), + ClassCount = counts.GetValueOrDefault(x.Id), + }).ToList(); + } + + public async Task CreateGroupAsync(string name, Guid? parentId, CancellationToken ct = default) + { + var trimmed = ValidateGroupName(name); + if (await db.ClassGroups.AnyAsync(x => x.Name == trimmed, ct)) + throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, "分组名称已存在"); + var parent = await RequireGroupIfAnyAsync(parentId, ct); + if (parent is not null) + { + var all = await db.ClassGroups.AsNoTracking().ToListAsync(ct); + if (DepthOf(parent, all) + 1 > ClassGroup.MaxDepth) + throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, $"分组最多支持 {ClassGroup.MaxDepth} 层"); + } + var now = DateTimeOffset.UtcNow; + var group = new ClassGroup { Id = Guid.NewGuid(), Name = trimmed, ParentId = parent?.Id, CreatedAt = now, UpdatedAt = now }; + db.ClassGroups.Add(group); + await db.SaveChangesAsync(ct); + return new ClassGroupInfo { Id = group.Id, Name = group.Name, ParentId = group.ParentId }; + } + + public async Task RenameGroupAsync(Guid id, string name, Guid? parentId, CancellationToken ct = default) + { + var group = await RequireGroupAsync(id, ct); + var trimmed = ValidateGroupName(name); + if (await db.ClassGroups.AnyAsync(x => x.Name == trimmed && x.Id != id, ct)) + throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, "分组名称已存在"); + + var parent = await RequireGroupIfAnyAsync(parentId, ct); + if (parent is not null) + { + if (parent.Id == id) + throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, "不能把分组移动到自身之下"); + var all = await db.ClassGroups.AsNoTracking().ToListAsync(ct); + if (IsDescendant(all, parent.Id, id)) + throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, "不能把分组移动到自己的子分组之下"); + if (DepthOf(parent, all) + 1 > ClassGroup.MaxDepth) + throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, $"分组最多支持 {ClassGroup.MaxDepth} 层"); + } + + group.Name = trimmed; + group.ParentId = parent?.Id; + group.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + } + + /// 删除分组:子分组上移到被删分组的父级,班级归属随级联清理;班级本身不受影响。 + public async Task DeleteGroupAsync(Guid id, CancellationToken ct = default) + { + var group = await RequireGroupAsync(id, ct); + await db.ClassGroups.Where(x => x.ParentId == id) + .ExecuteUpdateAsync(setters => setters.SetProperty(x => x.ParentId, group.ParentId), ct); + db.ClassGroups.Remove(group); + await db.SaveChangesAsync(ct); + } + + /// 整体替换一个班级所属的分组。 + public async Task SetClassGroupsAsync(Guid classId, IReadOnlyCollection groupIds, CancellationToken ct = default) + { + await RequireAsync(classId, ct); + var wanted = groupIds.Distinct().ToList(); + var found = await db.ClassGroups.Where(x => wanted.Contains(x.Id)).CountAsync(ct); + if (found != wanted.Count) + throw new IdentityOperationException(ApiErrorCodes.NotFound, "包含不存在的分组"); + await using var transaction = await db.Database.BeginTransactionAsync(ct); + await db.ClassGroupAssignments.Where(x => x.ClassroomId == classId).ExecuteDeleteAsync(ct); + db.ClassGroupAssignments.AddRange(wanted.Select(groupId => new ClassGroupAssignment + { + GroupId = groupId, + ClassroomId = classId, + })); + await db.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + } + + /// 整体替换一个分组包含的班级(仅直接归属;子分组的班级不受影响)。 + public async Task SetGroupClassesAsync(Guid groupId, IReadOnlyCollection classIds, CancellationToken ct = default) + { + await RequireGroupAsync(groupId, ct); + var wanted = classIds.Distinct().ToList(); + var found = await db.Classrooms.Where(x => wanted.Contains(x.Id)).CountAsync(ct); + if (found != wanted.Count) + throw new IdentityOperationException(ApiErrorCodes.NotFound, "包含不存在的班级"); + await using var transaction = await db.Database.BeginTransactionAsync(ct); + await db.ClassGroupAssignments.Where(x => x.GroupId == groupId).ExecuteDeleteAsync(ct); + db.ClassGroupAssignments.AddRange(wanted.Select(classId => new ClassGroupAssignment + { + GroupId = groupId, + ClassroomId = classId, + })); + await db.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + } + + /// + /// 展开批量/广播的目标班级:分组递归展开(含全部子分组)后与显式 classIds 合并去重, + /// 不存在的班级/分组直接以异常呈现而不是静默忽略。 + /// + public async Task> ResolveTargetClassIdsAsync( + IReadOnlyCollection classIds, IReadOnlyCollection? groupIds, CancellationToken ct = default) + { + var targets = new List(); + foreach (var classId in classIds) targets.Add(classId); + if (groupIds is { Count: > 0 }) + { + var all = await db.ClassGroups.AsNoTracking().Select(x => new { x.Id, x.ParentId }).ToListAsync(ct); + var wanted = new HashSet(groupIds); + foreach (var id in groupIds) + if (all.All(x => x.Id != id)) + throw new IdentityOperationException(ApiErrorCodes.NotFound, "包含不存在的分组"); + // 递归收入全部子分组。 + bool added = true; + while (added) + { + added = false; + foreach (var group in all) + { + if (!wanted.Contains(group.Id) && group.ParentId is { } parent && wanted.Contains(parent)) + { + wanted.Add(group.Id); + added = true; + } + } + } + var groupedIds = await db.ClassGroupAssignments.AsNoTracking() + .Where(x => wanted.Contains(x.GroupId)) + .Select(x => x.ClassroomId) + .ToListAsync(ct); + targets.AddRange(groupedIds); + } + return targets.Distinct().ToList(); + } + + /// 批量操作:返回每个班级的独立结果,单个失败不影响其余班级。 + public async Task BatchAsync( + BatchClassOperationRequest request, CancellationToken ct = default) + { + var targetIds = await ResolveTargetClassIdsAsync(request.ClassIds, request.GroupIds, ct); + var results = new List(); + foreach (var classId in targetIds) + { + try + { + switch (request.Operation.Trim().ToLowerInvariant()) + { + case "enablevisitor": + await SetVisitorAccessAsync(classId, true, ct); + break; + case "disablevisitor": + await SetVisitorAccessAsync(classId, false, ct); + break; + case "delete": + await DeleteAsync(classId, ct); + break; + default: + throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, "未知批量操作"); + } + results.Add(new BatchClassItemResult { ClassId = classId, Success = true }); + } + catch (IdentityOperationException ex) + { + results.Add(new BatchClassItemResult { ClassId = classId, Success = false, Message = ex.Message }); + } + } + return new BatchClassOperationResult { Results = results }; + } + + public async Task> ListMembersAsync(Guid id, CancellationToken ct = default) + { + await RequireAsync(id, ct); + var members = await db.ClassMemberships.AsNoTracking() + .Where(x => x.ClassroomId == id) + .Join(db.Users.AsNoTracking(), x => x.UserId, y => y.Id, (x, y) => new { x.RoleDefinitionId, y.Id, y.UserName, y.DisplayName }) + .Join(db.AccountRoles.AsNoTracking(), x => x.RoleDefinitionId, y => y.Id, + (x, y) => new ClassMemberInfo + { + UserId = x.Id, + Username = x.UserName ?? string.Empty, + DisplayName = x.DisplayName, + RoleId = y.Id, + RoleName = y.Name, + }) + .OrderBy(x => x.Username) + .ToListAsync(ct); + return members; + } + + // ---------- 辅助 ---------- + + private async Task RequireGroupAsync(Guid id, CancellationToken ct = default) => + await db.ClassGroups.SingleOrDefaultAsync(x => x.Id == id, ct) + ?? throw new IdentityOperationException(ApiErrorCodes.NotFound, "分组不存在"); + + private async Task RequireGroupIfAnyAsync(Guid? parentId, CancellationToken ct = default) => + parentId is not { } id + ? null + : await db.ClassGroups.SingleOrDefaultAsync(x => x.Id == id, ct) + ?? throw new IdentityOperationException(ApiErrorCodes.NotFound, "父分组不存在"); + + private static int DepthOf(ClassGroup group, IReadOnlyList all) + { + var depth = 1; + var parent = group.ParentId; + while (parent is { } current) + { + depth++; + parent = all.FirstOrDefault(x => x.Id == current)?.ParentId; + } + return depth; + } + + /// candidate 是否是 ancestor 的后代(用于阻止把分组移入自己的子树)。 + private static bool IsDescendant(IReadOnlyList all, Guid candidate, Guid ancestor) + { + var current = all.FirstOrDefault(x => x.Id == candidate)?.ParentId; + while (current is { } step) + { + if (step == ancestor) return true; + current = all.FirstOrDefault(x => x.Id == step)?.ParentId; + } + return false; + } + + private static string ValidateGroupName(string name) + { + var trimmed = name?.Trim() ?? string.Empty; + if (trimmed.Length is < 1 or > 40) + throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, "分组名称需为 1-40 个字符"); + return trimmed; + } + + private static string ValidateName(string name) + { + var trimmed = name?.Trim() ?? string.Empty; + if (trimmed.Length is < 1 or > 40) + throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, "班级名称需为 1-40 个字符"); + return trimmed; + } +} diff --git a/server/RemoteCI.Server/Services/ConfigurationArchiveService.cs b/server/RemoteCI.Server/Services/ConfigurationArchiveService.cs index 7aaab3b..cff52c1 100644 --- a/server/RemoteCI.Server/Services/ConfigurationArchiveService.cs +++ b/server/RemoteCI.Server/Services/ConfigurationArchiveService.cs @@ -30,22 +30,25 @@ public async Task CaptureAsync(CancellationToken ct = def var users = await db.Users.AsNoTracking().Select(x => new UserSnapshot( x.Id, x.UserName!, x.NormalizedUserName!, x.DisplayName, x.PasswordHash!, x.SecurityStamp!, x.ConcurrencyStamp!, x.Role, x.RoleDefinitionId, x.GrantedPermissions, x.Enabled, x.Version, x.UpdatedAt)).ToListAsync(ct); - var plugins = await db.PluginCredentials.AsNoTracking().Select(x => new PluginSnapshot(x.Id, x.Name, x.TokenHash, x.Enabled, x.CreatedAt, x.LastSeenAt)).ToListAsync(ct); + var groups = await db.ClassGroups.AsNoTracking().Select(x => new GroupSnapshot(x.Id, x.Name, x.ParentId, x.CreatedAt, x.UpdatedAt)).ToListAsync(ct); + var classrooms = await db.Classrooms.AsNoTracking().Select(x => new ClassroomSnapshot(x.Id, x.Name, x.VisitorAccessEnabled, x.CreatedAt, x.UpdatedAt)).ToListAsync(ct); + var memberships = await db.ClassMemberships.AsNoTracking().Select(x => new MembershipSnapshot(x.UserId, x.ClassroomId, x.RoleDefinitionId)).ToListAsync(ct); + var plugins = await db.PluginCredentials.AsNoTracking().Select(x => new PluginSnapshot(x.Id, x.Name, x.TokenHash, x.Enabled, x.CreatedAt, x.LastSeenAt, x.ClassroomId)).ToListAsync(ct); var extensionPolicies = await db.ExtensionPolicies.AsNoTracking() .Select(x => new ExtensionPolicySnapshot(x.ExtensionId, x.Enabled, x.AllowNonAdmin, x.UpdatedAt)).ToListAsync(ct); var extensionPreferences = await db.UserExtensionPreferences.AsNoTracking() .Select(x => new ExtensionPreferenceSnapshot(x.UserId, x.ExtensionId, x.ShowOnWatch, x.UpdatedAt)).ToListAsync(ct); var metadata = await db.SystemMetadata.AsNoTracking().SingleAsync(x => x.Id == 1, ct); var backup = await db.BackupConfigurations.AsNoTracking().SingleAsync(x => x.Id == 1, ct); - return new ConfigurationSnapshot(2, DateTimeOffset.UtcNow, roles, users, plugins, + return new ConfigurationSnapshot(3, DateTimeOffset.UtcNow, roles, users, plugins, new MetadataSnapshot( metadata.AccountVersion, metadata.ForceSenderInTitle, metadata.SchedulePullIntervalMinutes, - metadata.VisitorAccessEnabled, - metadata.AutoEnterVisitorPage), + AutoEnterVisitorPage: metadata.AutoEnterVisitorPage), new BackupSettingsSnapshot(backup.Enabled, backup.Cadence, backup.TimeOfDay, backup.DayOfWeek, backup.MaxBackups), - state.GetLatestSchedule(), extensionPolicies, extensionPreferences); + state.GetLatestSchedule(Classroom.DefaultId), extensionPolicies, extensionPreferences, + classrooms, memberships, groups); } public async Task CreateLocalBackupAsync(string source, CancellationToken ct = default) @@ -106,24 +109,60 @@ public async Task ApplyAsync(ConfigurationSnapshot snapshot, CancellationToken c await db.Users.ExecuteDeleteAsync(ct); await db.AccountRoles.ExecuteDeleteAsync(ct); await db.PluginCredentials.ExecuteDeleteAsync(ct); + await db.ClassMemberships.ExecuteDeleteAsync(ct); + await db.Classrooms.ExecuteDeleteAsync(ct); + await db.ClassGroups.ExecuteDeleteAsync(ct); db.ChangeTracker.Clear(); db.AccountRoles.AddRange(snapshot.Roles.Select(x => new AccountRole { Id=x.Id, Name=x.Name, NormalizedName=x.Name.Trim().ToUpperInvariant(), Kind=x.Kind, DefaultPermissions=UpgradeImportedPermissions(snapshot.Version, x.DefaultPermissions), CreatedAt=x.CreatedAt, UpdatedAt=x.UpdatedAt })); await db.SaveChangesAsync(ct); db.Users.AddRange(snapshot.Users.Select(x => new AppUser { Id=x.Id, UserName=x.Username, NormalizedUserName=x.NormalizedUsername, DisplayName=x.DisplayName, PasswordHash=x.PasswordHash, SecurityStamp=x.SecurityStamp, ConcurrencyStamp=x.ConcurrencyStamp, Role=x.Role, RoleDefinitionId=x.RoleId, GrantedPermissions=UpgradeImportedPermissions(snapshot.Version, x.GrantedPermissions), Enabled=x.Enabled, Version=x.Version, UpdatedAt=x.UpdatedAt, EmailConfirmed=false, PhoneNumberConfirmed=false, TwoFactorEnabled=false, LockoutEnabled=true })); - db.PluginCredentials.AddRange(snapshot.Plugins.Select(x => new PluginCredential { Id=x.Id, Name=x.Name, TokenHash=x.TokenHash, Enabled=x.Enabled, CreatedAt=x.CreatedAt, LastSeenAt=x.LastSeenAt })); + // v1/v2 旧包没有班级数据:落到默认班级,访客开关沿用包内全局设置。 + var classrooms = (snapshot.Classrooms ?? []).ToList(); + if (classrooms.All(x => x.Id != Classroom.DefaultId)) + { + classrooms.Add(new ClassroomSnapshot( + Classroom.DefaultId, "默认班级", + snapshot.Metadata.VisitorAccessEnabled, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)); + } + var groups = (snapshot.ClassGroups ?? []).ToList(); + // 分组支持层级:父分组必须先插入(按快照内推算的深度排序)。 + var byId = groups.ToDictionary(x => x.Id, x => x); + int DepthOf(GroupSnapshot g) + { + var depth = 1; + var parent = g.ParentId; + while (parent is { } current && byId.TryGetValue(current, out var found)) + { + depth++; + parent = found.ParentId; + } + return depth; + } + db.ClassGroups.AddRange(groups.OrderBy(DepthOf).Select(x => new ClassGroup { Id=x.Id, Name=x.Name, ParentId=x.ParentId, CreatedAt=x.CreatedAt, UpdatedAt=x.UpdatedAt })); + db.Classrooms.AddRange(classrooms.Select(x => new Classroom { Id=x.Id, Name=x.Name, VisitorAccessEnabled=x.VisitorAccessEnabled, CreatedAt=x.CreatedAt, UpdatedAt=x.UpdatedAt })); + var memberships = (snapshot.Memberships ?? []).ToList(); + if (snapshot.Memberships is null) + { + // 旧包:为每个用户按其全局角色在默认班级补建成员关系,保持升级前权限不变。 + memberships = snapshot.Users.Select(x => new MembershipSnapshot(x.Id, Classroom.DefaultId, x.RoleId)).ToList(); + } + db.ClassMemberships.AddRange(memberships.Select(x => new ClassMembership { UserId=x.UserId, ClassroomId=x.ClassroomId, RoleDefinitionId=x.RoleDefinitionId })); + db.PluginCredentials.AddRange(snapshot.Plugins.Select(x => new PluginCredential { Id=x.Id, Name=x.Name, TokenHash=x.TokenHash, Enabled=x.Enabled, ClassroomId=x.ClassroomId ?? Classroom.DefaultId, CreatedAt=x.CreatedAt, LastSeenAt=x.LastSeenAt })); db.ExtensionPolicies.AddRange((snapshot.ExtensionPolicies ?? []).Select(x => new ExtensionPolicy { ExtensionId=x.ExtensionId, Enabled=x.Enabled, AllowNonAdmin=x.AllowNonAdmin, UpdatedAt=x.UpdatedAt })); db.UserExtensionPreferences.AddRange((snapshot.ExtensionPreferences ?? []).Select(x => new UserExtensionPreference { UserId=x.UserId, ExtensionId=x.ExtensionId, ShowOnWatch=x.ShowOnWatch, UpdatedAt=x.UpdatedAt })); var metadata = await db.SystemMetadata.SingleAsync(x => x.Id == 1, ct); metadata.AccountVersion = snapshot.Metadata.AccountVersion + 1; metadata.ForceSenderInTitle = snapshot.Metadata.ForceSenderInTitle; metadata.SchedulePullIntervalMinutes = snapshot.Metadata.SchedulePullIntervalMinutes; - metadata.VisitorAccessEnabled = snapshot.Metadata.VisitorAccessEnabled; - metadata.AutoEnterVisitorPage = snapshot.Metadata.VisitorAccessEnabled && snapshot.Metadata.AutoEnterVisitorPage; + // autoEnter 只在存在开放访客的班级时有效;v1/v2 旧包的全局访客开关已映射到默认班级。 + metadata.AutoEnterVisitorPage = + (classrooms.Any(x => x.VisitorAccessEnabled) || snapshot.Metadata.VisitorAccessEnabled) && + snapshot.Metadata.AutoEnterVisitorPage; var backup = await db.BackupConfigurations.SingleAsync(x => x.Id == 1, ct); backup.Enabled=snapshot.Backup.Enabled; backup.Cadence=snapshot.Backup.Cadence; backup.TimeOfDay=snapshot.Backup.TimeOfDay; backup.DayOfWeek=snapshot.Backup.DayOfWeek; backup.MaxBackups=Math.Clamp(snapshot.Backup.MaxBackups,1,100); backup.LastScheduledAt=null; backup.LastSucceededAt=null; backup.LastError=null; await db.SaveChangesAsync(ct); await transaction.CommitAsync(ct); - if (snapshot.Schedule is not null) state.SaveSchedule(snapshot.Schedule); + if (snapshot.Schedule is not null) state.SaveSchedule(Classroom.DefaultId, snapshot.Schedule); } public IReadOnlyList ListBackups() { Directory.CreateDirectory(_backupDirectory); return Directory.EnumerateFiles(_backupDirectory,"*.rcibak").Select(x=>ToInfo(new FileInfo(x))).OrderByDescending(x=>x.CreatedAt).ToList(); } @@ -141,20 +180,24 @@ private static UserPermissions UpgradeImportedPermissions(int snapshotVersion, U snapshotVersion == 1 && permissions.HasFlag(UserPermissions.PowerControl) ? permissions | UserPermissions.MainMenuControl : permissions; - private static void Validate(ConfigurationSnapshot value) { if(value.Version is not (1 or 2) || value.Roles.Count==0 || value.Users.Count==0) throw new InvalidDataException("Invalid backup schema"); var roleIds=value.Roles.Select(x=>x.Id).ToHashSet(); if(value.Users.Any(x=>!roleIds.Contains(x.RoleId))) throw new InvalidDataException("Unknown role reference"); if(!value.Users.Any(x=>x.Enabled && x.Role==UserRole.Admin)) throw new InvalidDataException("At least one enabled administrator is required"); if(value.Users.Select(x=>x.Username.ToUpperInvariant()).Distinct().Count()!=value.Users.Count) throw new InvalidDataException("Duplicate account ID"); var userIds=value.Users.Select(x=>x.Id).ToHashSet(); if((value.ExtensionPreferences??[]).Any(x=>!userIds.Contains(x.UserId))) throw new InvalidDataException("Unknown extension preference user"); } + private static void Validate(ConfigurationSnapshot value) { if(value.Version is not (1 or 2 or 3) || value.Roles.Count==0 || value.Users.Count==0) throw new InvalidDataException("Invalid backup schema"); var roleIds=value.Roles.Select(x=>x.Id).ToHashSet(); if(value.Users.Any(x=>!roleIds.Contains(x.RoleId))) throw new InvalidDataException("Unknown role reference"); if(!value.Users.Any(x=>x.Enabled && x.Role==UserRole.Admin)) throw new InvalidDataException("At least one enabled administrator is required"); if(value.Users.Select(x=>x.Username.ToUpperInvariant()).Distinct().Count()!=value.Users.Count) throw new InvalidDataException("Duplicate account ID"); var userIds=value.Users.Select(x=>x.Id).ToHashSet(); if((value.ExtensionPreferences??[]).Any(x=>!userIds.Contains(x.UserId))) throw new InvalidDataException("Unknown extension preference user"); if((value.Memberships??[]).Any(x=>!userIds.Contains(x.UserId))) throw new InvalidDataException("Unknown membership user"); var classroomIds=(value.Classrooms??[]).Select(x=>x.Id).ToHashSet(); if((value.Memberships??[]).Any(x=>!classroomIds.Contains(x.ClassroomId))) throw new InvalidDataException("Unknown membership classroom"); } } public sealed record BackupFileInfo(string Name, DateTimeOffset CreatedAt, long Size, string Source); -public sealed record ConfigurationSnapshot(int Version, DateTimeOffset CreatedAt, List Roles, List Users, List Plugins, MetadataSnapshot Metadata, BackupSettingsSnapshot Backup, ScheduleBundle? Schedule, List? ExtensionPolicies = null, List? ExtensionPreferences = null); +public sealed record ConfigurationSnapshot(int Version, DateTimeOffset CreatedAt, List Roles, List Users, List Plugins, MetadataSnapshot Metadata, BackupSettingsSnapshot Backup, ScheduleBundle? Schedule, List? ExtensionPolicies = null, List? ExtensionPreferences = null, List? Classrooms = null, List? Memberships = null, List? ClassGroups = null); public sealed record RoleSnapshot(Guid Id,string Name,AccountRoleKind Kind,UserPermissions DefaultPermissions,DateTimeOffset CreatedAt,DateTimeOffset UpdatedAt); public sealed record UserSnapshot(Guid Id,string Username,string NormalizedUsername,string DisplayName,string PasswordHash,string SecurityStamp,string ConcurrencyStamp,UserRole Role,Guid RoleId,UserPermissions GrantedPermissions,bool Enabled,long Version,DateTimeOffset UpdatedAt); -public sealed record PluginSnapshot(Guid Id,string Name,string TokenHash,bool Enabled,DateTimeOffset CreatedAt,DateTimeOffset LastSeenAt); +public sealed record ClassroomSnapshot(Guid Id,string Name,bool VisitorAccessEnabled,DateTimeOffset CreatedAt,DateTimeOffset UpdatedAt); +public sealed record GroupSnapshot(Guid Id,string Name,Guid? ParentId,DateTimeOffset CreatedAt,DateTimeOffset UpdatedAt); +public sealed record MembershipSnapshot(Guid UserId,Guid ClassroomId,Guid RoleDefinitionId); +public sealed record PluginSnapshot(Guid Id,string Name,string TokenHash,bool Enabled,DateTimeOffset CreatedAt,DateTimeOffset LastSeenAt,Guid? ClassroomId = null); public sealed record ExtensionPolicySnapshot(string ExtensionId,bool Enabled,bool AllowNonAdmin,DateTimeOffset UpdatedAt); public sealed record ExtensionPreferenceSnapshot(Guid UserId,string ExtensionId,bool ShowOnWatch,DateTimeOffset UpdatedAt); public sealed record MetadataSnapshot( long AccountVersion, bool ForceSenderInTitle, int SchedulePullIntervalMinutes, + // v1/v2 旧包字段:VisitorAccessEnabled 是升级前的全局开关,v3 起按班级存储,导入时映射到默认班级。 bool VisitorAccessEnabled = false, bool AutoEnterVisitorPage = false); public sealed record BackupSettingsSnapshot(bool Enabled,BackupCadence Cadence,TimeSpan TimeOfDay,DayOfWeek DayOfWeek,int MaxBackups); diff --git a/server/RemoteCI.Server/Services/IStateStore.cs b/server/RemoteCI.Server/Services/IStateStore.cs index 50f6407..864b35b 100644 --- a/server/RemoteCI.Server/Services/IStateStore.cs +++ b/server/RemoteCI.Server/Services/IStateStore.cs @@ -3,16 +3,16 @@ namespace RemoteCI.Server.Services; /// -/// 内存状态缓存:保存插件推送的最新快照与最近事件,供新连接/HTTP 查询获取。 -/// 单班级场景,后续多班级扩展为按 room/class 维度分桶。 +/// 内存状态缓存:按班级分桶保存各插件推送的最新快照与最近事件,供新连接/HTTP 查询获取。 /// public interface IStateStore { - void SaveSnapshot(ClassStateSnapshot snapshot); - ClassStateSnapshot? GetLatestSnapshot(); - void SaveSchedule(ScheduleBundle schedule); - ScheduleBundle? GetLatestSchedule(); - void SaveEvent(ClassEvent @event); - void SaveExtensions(IReadOnlyList extensions); - IReadOnlyList? GetLatestExtensions(); + void SaveSnapshot(Guid classId, ClassStateSnapshot snapshot); + ClassStateSnapshot? GetLatestSnapshot(Guid classId); + void SaveSchedule(Guid classId, ScheduleBundle schedule); + ScheduleBundle? GetLatestSchedule(Guid classId); + void SaveEvent(Guid classId, ClassEvent @event); + ClassEvent? GetLatestEvent(Guid classId); + void SaveExtensions(Guid classId, IReadOnlyList extensions); + IReadOnlyList? GetLatestExtensions(Guid classId); } diff --git a/server/RemoteCI.Server/Services/IdentityCoordinator.cs b/server/RemoteCI.Server/Services/IdentityCoordinator.cs index aa549c7..0241bcd 100644 --- a/server/RemoteCI.Server/Services/IdentityCoordinator.cs +++ b/server/RemoteCI.Server/Services/IdentityCoordinator.cs @@ -17,6 +17,7 @@ public sealed partial class IdentityCoordinator( AppDbContext db, UserManager users, ExtensionPolicyService extensionPolicies, + ClassAccessService classAccess, IOptions options, ILogger logger) { @@ -25,6 +26,9 @@ public sealed partial class IdentityCoordinator( /// 插件配对码有效期:遗忘在聊天记录/页面上的配对码不能永久可用。 private static readonly TimeSpan PairCodeLifetime = TimeSpan.FromMinutes(30); + /// 首登设置密码令牌有效期:过期后需重新走一次空密码登录获取新令牌。 + private static readonly TimeSpan PasswordSetupTokenLifetime = TimeSpan.FromMinutes(15); + /// /// 当前服务端进程实例标识,随授权镜像下发。服务端重启或数据库重建后变化, /// 供插件在镜像版本号回退时识别实例变化并强制覆盖,避免旧镜像永久滞留。 @@ -37,18 +41,54 @@ public sealed partial class IdentityCoordinator( /// 每个账号同时保持的设备会话上限,超出时撤销最早创建的会话。 private const int MaxActiveSessionsPerUser = 20; + /// 内置“班管理员”角色的默认权限:班内日常管理,不含用户管理、电源/主菜单控制等系统级权限。 + public const UserPermissions ClassAdministratorDefaultPermissions = + UserPermissions.ViewCurrentCourse | UserPermissions.AccessWebUi | UserPermissions.ManageSchedule | + UserPermissions.SendNotifications | UserPermissions.SendVoiceMessages | UserPermissions.TeacherComing | + UserPermissions.RunExtensions; + + /// 确保默认班级存在;迁移或首次启动都依赖它承接升级前的全部数据。 + private async Task SeedDefaultClassroomAsync(CancellationToken ct) + { + var now = DateTimeOffset.UtcNow; + if (await db.Classrooms.AnyAsync(x => x.Id == Classroom.DefaultId, ct)) + { + await db.Classrooms.Where(x => x.Id == Classroom.DefaultId).ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Name, "默认班级"), ct); + return; + } + db.Classrooms.Add(new Classroom + { + Id = Classroom.DefaultId, + Name = "默认班级", + VisitorAccessEnabled = false, + CreatedAt = now, + UpdatedAt = now, + }); + await db.SaveChangesAsync(ct); + } + public async Task BootstrapAsync(CancellationToken ct = default) { await db.Database.MigrateAsync(ct); + // 显式列出全部列:不依赖数据库 schema 中的列默认值,INSERT OR IGNORE 才不会因 + // NOT NULL 约束被静默跳过。 await db.Database.ExecuteSqlRawAsync( - "INSERT OR IGNORE INTO SystemMetadata (Id, AccountVersion) VALUES (1, 0);", ct); + "INSERT OR IGNORE INTO SystemMetadata (Id, AccountVersion, ForceSenderInTitle, SchedulePullIntervalMinutes, AutoEnterVisitorPage) VALUES (1, 0, 1, 0, 0);", ct); var now = DateTimeOffset.UtcNow; if (!await db.AccountRoles.AnyAsync(ct)) { db.AccountRoles.AddRange( new AccountRole { Id = AccountRole.StudentId, Name = "Student", NormalizedName = "STUDENT", Kind = AccountRoleKind.Student, DefaultPermissions = UserPermissions.None, CreatedAt = now, UpdatedAt = now }, - new AccountRole { Id = AccountRole.AdministratorId, Name = "Administrator", NormalizedName = "ADMINISTRATOR", Kind = AccountRoleKind.Administrator, DefaultPermissions = UserPermissions.All, CreatedAt = now, UpdatedAt = now }); + new AccountRole { Id = AccountRole.AdministratorId, Name = "Administrator", NormalizedName = "ADMINISTRATOR", Kind = AccountRoleKind.Administrator, DefaultPermissions = UserPermissions.All, CreatedAt = now, UpdatedAt = now }, + new AccountRole { Id = AccountRole.ClassAdministratorId, Name = "ClassAdministrator", NormalizedName = "CLASSADMINISTRATOR", Kind = AccountRoleKind.ClassAdministrator, DefaultPermissions = ClassAdministratorDefaultPermissions, CreatedAt = now, UpdatedAt = now }); } + // 历史迁移 AddRolesAndBackups 会在建库时直接插入两个内置角色,导致上面的 AddRange 被跳过; + // 班管理员角色是新增的,必须独立幂等种子才能同时覆盖全新与已升级的数据库。 + await db.Database.ExecuteSqlRawAsync($""" + INSERT OR IGNORE INTO AccountRoles (Id, Name, NormalizedName, Kind, DefaultPermissions, CreatedAt, UpdatedAt) + VALUES ('{AccountRole.ClassAdministratorId}', 'ClassAdministrator', 'CLASSADMINISTRATOR', 4, {(int)ClassAdministratorDefaultPermissions}, '{now:O}', '{now:O}'); + """, ct); if (!await db.BackupConfigurations.AnyAsync(ct)) db.BackupConfigurations.Add(new BackupConfiguration()); await db.SaveChangesAsync(ct); await db.AccountRoles.Where(x => x.Id == AccountRole.StudentId).ExecuteUpdateAsync(setters => setters @@ -58,6 +98,12 @@ await db.AccountRoles.Where(x => x.Id == AccountRole.AdministratorId).ExecuteUpd .SetProperty(x => x.Name, "管理员") .SetProperty(x => x.NormalizedName, "管理员") .SetProperty(x => x.DefaultPermissions, UserPermissions.All), ct); + await db.AccountRoles.Where(x => x.Id == AccountRole.ClassAdministratorId).ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Name, "班管理员") + .SetProperty(x => x.NormalizedName, "班管理员") + .SetProperty(x => x.Kind, AccountRoleKind.ClassAdministrator) + .SetProperty(x => x.DefaultPermissions, ClassAdministratorDefaultPermissions), ct); + await SeedDefaultClassroomAsync(ct); // 启动时清理过期超过 30 天的会话行,避免 DeviceSessions 表长期无界增长。 // SQLite 不支持 DateTimeOffset 比较的 SQL 翻译,先投影再在内存过滤。 @@ -136,6 +182,9 @@ public async Task LoginAsync(LoginRequest request, CancellationTok await users.CheckPasswordAsync(TimingUser(), request.Password); throw new IdentityOperationException(ApiErrorCodes.Unauthorized, "ID 或密码错误"); } + // 批量导入且未设密码的账号:首次登录(空密码)下发一次性设置令牌,设置密码后重新登录。 + if (user.PasswordPending && string.IsNullOrEmpty(request.Password)) + return await BeginPasswordSetupAsync(user, ct); if (await users.IsLockedOutAsync(user)) throw new IdentityOperationException(ApiErrorCodes.Unauthorized, "失败次数过多,账号已临时锁定,请稍后再试"); if (!await users.CheckPasswordAsync(user, request.Password)) @@ -148,6 +197,46 @@ public async Task LoginAsync(LoginRequest request, CancellationTok return await CreateOrRotateSessionAsync(user, request.DeviceName, null, ct); } + /// 为首登账号生成一次性密码设置令牌(15 分钟有效,只保存 SHA-256 摘要)。 + public async Task BeginPasswordSetupAsync(AppUser user, CancellationToken ct = default) + { + var token = CreateSecret(32); + user.SetupTokenHash = Hash(token); + user.SetupTokenExpiresAt = DateTimeOffset.UtcNow + PasswordSetupTokenLifetime; + user.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + return new AuthResponse { PasswordPending = true, SetupToken = token }; + } + + /// 首登设置密码:校验一次性令牌与有效期,补设密码后账号立即恢复正常登录。 + public async Task SetupPasswordAsync(SetupPasswordRequest request, CancellationToken ct = default) + { + var user = await users.FindByNameAsync(request.Username.Trim()); + var now = DateTimeOffset.UtcNow; + var invalid = user is null + || !user.PasswordPending + || user.SetupTokenHash is null + || user.SetupTokenExpiresAt is not { } expires + || expires <= now + || !FixedEquals(user.SetupTokenHash, Hash(request.SetupToken)); + if (invalid) + { + // 恒定时间:对无效请求也执行一次等成本哈希校验,避免枚举可设置密码的账号。 + await users.CheckPasswordAsync(TimingUser(), request.NewPassword); + throw new IdentityOperationException(ApiErrorCodes.Unauthorized, "设置链接无效或已过期,请重新登录"); + } + ValidatePassword(request.NewPassword); + var target = user!; + EnsureIdentitySucceeded(await users.AddPasswordAsync(target, request.NewPassword)); + target.PasswordPending = false; + target.SetupTokenHash = null; + target.SetupTokenExpiresAt = null; + target.UpdatedAt = now; + target.Version = await NextVersionAsync(ct); + EnsureIdentitySucceeded(await users.UpdateAsync(target)); + await users.UpdateSecurityStampAsync(target); + } + public async Task RefreshAsync(RefreshSessionRequest request, CancellationToken ct = default) { var verifier = Hash(request.DeviceSecret); @@ -182,7 +271,8 @@ public async Task RefreshAsync(RefreshSessionRequest request, Canc await ToProfileAsync(session.User, ct), session.Id, PluginCredentialId: null, - ValidUntil: session.AccessExpiresAt < session.ExpiresAt ? session.AccessExpiresAt : session.ExpiresAt); + ValidUntil: session.AccessExpiresAt < session.ExpiresAt ? session.AccessExpiresAt : session.ExpiresAt, + AccessibleClassIds: await classAccess.GetAccessibleClassIdsAsync(session.User.Id, session.User.Role, ct)); } public async Task ValidatePluginTokenAsync(string token, CancellationToken ct = default) @@ -202,7 +292,8 @@ await ToProfileAsync(session.User, ct), User: null, DeviceSessionId: null, PluginCredentialId: credential.Id, - ValidUntil: null); + ValidUntil: null, + ClassId: credential.ClassroomId); } public async Task ValidateAnyTokenAsync(string token, CancellationToken ct = default) => @@ -211,13 +302,14 @@ await ToProfileAsync(session.User, ct), public async Task> ListPluginCredentialsAsync(CancellationToken ct = default) { // SQLite 不支持 DateTimeOffset 排序的 SQL 翻译;凭证数量极少,取回后在内存排序。 - var credentials = await db.PluginCredentials.AsNoTracking().ToListAsync(ct); + var credentials = await db.PluginCredentials.Include(x => x.Classroom).AsNoTracking().ToListAsync(ct); return credentials .OrderByDescending(x => x.CreatedAt) .Select(x => new PluginCredentialInfo { Id = x.Id, Name = x.Name, + ClassName = x.Classroom?.Name, CreatedAt = x.CreatedAt, LastSeenAt = x.LastSeenAt, Enabled = x.Enabled, @@ -250,12 +342,14 @@ public async Task PairPluginAsync(PairRequest request, Cancellatio if (consumed == 0) throw new IdentityOperationException(ApiErrorCodes.PairCodeInvalid, "插件配对码无效、已使用或已过期"); + // 配对码创建时已绑定班级;旧版请求不携带班级信息的配对码统一归属默认班级。 var token = CreateSecret(32); db.PluginCredentials.Add(new PluginCredential { Id = Guid.NewGuid(), Name = "ClassIsland 插件", TokenHash = Hash(token), + ClassroomId = candidate.ClassroomId, CreatedAt = now, LastSeenAt = now, Enabled = true, @@ -264,10 +358,12 @@ public async Task PairPluginAsync(PairRequest request, Cancellatio return new PairResponse { Token = token, Role = "plugin", ExpiresAt = null }; } - public async Task CreatePluginPairingCodeAsync(CancellationToken ct = default) + public async Task CreatePluginPairingCodeAsync(Guid classroomId, CancellationToken ct = default) { + if (!await db.Classrooms.AnyAsync(x => x.Id == classroomId, ct)) + throw new IdentityOperationException(ApiErrorCodes.NotFound, "班级不存在"); var code = CreateReadableSecret(12); - await AddPairingCodeAsync(code, ct); + await AddPairingCodeAsync(code, ct, classroomId: classroomId); return code; } @@ -310,6 +406,8 @@ public async Task CreateUserAsync(CreateUserRequest request, Cance ValidateUserInput(request.Username, request.DisplayName, request.Password, request.Role); var role = await ResolveRoleAsync(request.RoleId, request.Role, ct); var protocolRole = role.Kind == AccountRoleKind.Administrator ? UserRole.Admin : UserRole.User; + // 密码留空 = 批量导入待激活账号:首次登录时强制设置密码。 + var hasPassword = !string.IsNullOrWhiteSpace(request.Password); var user = new AppUser { Id = Guid.NewGuid(), @@ -319,10 +417,20 @@ public async Task CreateUserAsync(CreateUserRequest request, Cance RoleDefinitionId = role.Id, GrantedPermissions = NormalizeGrants(protocolRole, request.GrantedPermissions), Enabled = true, + PasswordPending = !hasPassword, UpdatedAt = DateTimeOffset.UtcNow, }; user.Version = await NextVersionAsync(ct); - EnsureIdentitySucceeded(await users.CreateAsync(user, request.Password)); + EnsureIdentitySucceeded(hasPassword + ? await users.CreateAsync(user, request.Password) + : await users.CreateAsync(user)); + db.ClassMemberships.Add(new ClassMembership + { + UserId = user.Id, + ClassroomId = Classroom.DefaultId, + RoleDefinitionId = role.Id, + }); + await db.SaveChangesAsync(ct); return await ToListItemAsync(user, ct); } @@ -348,6 +456,11 @@ public async Task UpdateUserAsync(Guid id, UpdateUserRequest reque user.UpdatedAt = DateTimeOffset.UtcNow; user.Version = await NextVersionAsync(ct); EnsureIdentitySucceeded(await users.UpdateAsync(user)); + // Users 页编辑的是账号的全局默认角色;默认班级成员关系跟随它,其他班级成员角色独立管理。 + var defaultMembership = await db.ClassMemberships.SingleOrDefaultAsync( + x => x.UserId == user.Id && x.ClassroomId == Classroom.DefaultId, ct); + if (defaultMembership is not null) defaultMembership.RoleDefinitionId = targetRole.Id; + await db.SaveChangesAsync(ct); if (mustRevoke) await RevokeAllSessionsAsync(user.Id, ct); return await ToListItemAsync(user, ct); } @@ -422,25 +535,33 @@ public async Task RevokeSessionAsync(Guid ownerId, Guid sessionId, CancellationT await NextVersionAsync(ct); } - public async Task CreateSyncAsync(CancellationToken ct = default) + /// + /// 生成面向指定班级插件的授权镜像:账号为该班成员 ∪ 启用的系统管理员, + /// 有效权限按该班成员角色计算;插件局域网认证只需要能连到自己的账号。 + /// + public async Task CreateSyncAsync(Guid classId, CancellationToken ct = default) { var now = DateTimeOffset.UtcNow; var accountVersion = await db.SystemMetadata.Where(x => x.Id == 1).Select(x => x.AccountVersion).SingleAsync(ct); - var accounts = await users.Users.Include(x => x.RoleDefinition).Select(x => new SyncedAccount - { - Id = x.Id, - Username = x.UserName!, - DisplayName = x.DisplayName, - Role = x.Role, - RoleId = x.RoleDefinitionId, - RoleName = x.RoleDefinition.Name, - GrantedPermissions = x.GrantedPermissions, - EffectivePermissions = x.Role == UserRole.Admin - ? UserPermissions.All - : UserPermissions.ViewCurrentCourse | x.RoleDefinition.DefaultPermissions | x.GrantedPermissions, - Enabled = x.Enabled, - Version = x.Version, - }).ToListAsync(ct); + + // 班级成员关系一次性取回,再与启用的系统管理员合并。 + var memberships = await db.ClassMemberships.AsNoTracking() + .Where(x => x.ClassroomId == classId) + .Select(x => new { x.UserId, x.RoleDefinitionId, x.RoleDefinition.DefaultPermissions }) + .ToListAsync(ct); + var memberIds = memberships.Select(x => x.UserId).ToHashSet(); + var memberRoles = memberships.ToDictionary(x => x.UserId, x => (x.RoleDefinitionId, x.DefaultPermissions)); + + var accounts = await users.Users.Include(x => x.RoleDefinition) + .Where(x => x.Enabled && (x.Role == UserRole.Admin || memberIds.Contains(x.Id))) + .ToListAsync(ct); + var roleIds = accounts.Select(x => x.RoleDefinitionId) + .Concat(memberRoles.Values.Select(x => x.RoleDefinitionId)) + .Distinct().ToList(); + var roleNames = await db.AccountRoles.AsNoTracking() + .Where(x => roleIds.Contains(x.Id)) + .ToDictionaryAsync(x => x.Id, x => x.Name, ct); + var extensionPolicyRows = await db.ExtensionPolicies.AsNoTracking() .Where(x => x.Enabled).ToListAsync(ct); var hiddenByUser = (await db.UserExtensionPreferences.AsNoTracking() @@ -449,8 +570,30 @@ public async Task CreateSyncAsync(CancellationToken ct = default) .ToListAsync(ct)) .GroupBy(x => x.UserId) .ToDictionary(group => group.Key, group => (IReadOnlyCollection)group.Select(x => x.ExtensionId).ToArray()); - foreach (var account in accounts) + + var syncedAccounts = new List(); + foreach (var user in accounts) { + var isAdmin = user.Role == UserRole.Admin; + var (memberRoleId, memberDefaults) = memberRoles.TryGetValue(user.Id, out var membership) + ? membership + : (user.RoleDefinitionId, user.RoleDefinition?.DefaultPermissions ?? UserPermissions.None); + var effective = isAdmin + ? UserPermissions.All + : UserPermissions.ViewCurrentCourse | memberDefaults | user.GrantedPermissions; + var account = new SyncedAccount + { + Id = user.Id, + Username = user.UserName!, + DisplayName = user.DisplayName, + Role = user.Role, + RoleId = isAdmin ? user.RoleDefinitionId : memberRoleId, + RoleName = roleNames.GetValueOrDefault(isAdmin ? user.RoleDefinitionId : memberRoleId), + GrantedPermissions = user.GrantedPermissions, + EffectivePermissions = effective, + Enabled = user.Enabled, + Version = user.Version, + }; var profile = account.ToProfile(); ExtensionPolicyService.ApplyAccess( profile, @@ -458,10 +601,13 @@ public async Task CreateSyncAsync(CancellationToken ct = default) hiddenByUser.GetValueOrDefault(account.Id, Array.Empty())); account.AllowedExtensionIds = profile.AllowedExtensionIds; account.VisibleExtensionIds = profile.VisibleExtensionIds; + syncedAccounts.Add(account); } - var activeSessions = await db.DeviceSessions.Include(x => x.User).ThenInclude(x => x.RoleDefinition) - .Where(x => x.RevokedAt == null).ToListAsync(ct); - var sessions = activeSessions.Where(x => x.ExpiresAt > now && x.User.Enabled) + + var accountIds = syncedAccounts.Select(x => x.Id).ToHashSet(); + var sessions = (await db.DeviceSessions.AsNoTracking() + .Where(x => x.RevokedAt == null).ToListAsync(ct)) + .Where(x => x.ExpiresAt > now && accountIds.Contains(x.UserId)) .Select(x => new SyncedDeviceSession { Id = x.Id, @@ -469,6 +615,10 @@ public async Task CreateSyncAsync(CancellationToken ct = default) Verifier = x.VerifierHash, ExpiresAt = x.ExpiresAt, }).ToList(); + var className = await db.Classrooms.AsNoTracking() + .Where(x => x.Id == classId) + .Select(x => x.Name) + .SingleOrDefaultAsync(ct); return new AccountSync { Version = accountVersion, @@ -476,7 +626,8 @@ public async Task CreateSyncAsync(CancellationToken ct = default) ServerVersion = AppVersion.Version, ServerCapabilities = RemoteCiCapabilities.Current.ToList(), GeneratedAt = now, - Accounts = accounts, + ClassName = className, + Accounts = syncedAccounts, Sessions = sessions, }; } @@ -528,13 +679,15 @@ private async Task CreateOrRotateSessionAsync( }; } - private async Task AddPairingCodeAsync(string code, CancellationToken ct, bool timeLimited = true) + private async Task AddPairingCodeAsync( + string code, CancellationToken ct, bool timeLimited = true, Guid? classroomId = null) { var now = DateTimeOffset.UtcNow; db.PluginPairingCodes.Add(new PluginPairingCode { Id = Guid.NewGuid(), CodeHash = Hash(code), + ClassroomId = classroomId ?? Classroom.DefaultId, CreatedAt = now, // WebUI 生成的配对码限时 30 分钟;一次性消费仍由 UsedAt 原子控制。 ExpiresAt = timeLimited ? now.Add(PairCodeLifetime) : DateTimeOffset.MaxValue, @@ -608,6 +761,7 @@ private async Task ToProfileAsync(AppUser user, CancellationToken c RoleName = role.Name, GrantedPermissions = user.GrantedPermissions, Permissions = user.Role == UserRole.Admin ? UserPermissions.All : UserPermissions.ViewCurrentCourse | role.DefaultPermissions | user.GrantedPermissions, + Classes = [.. await classAccess.GetAccessibleClassesAsync(user.Id, user.Role, user.GrantedPermissions, ct)], Version = user.Version, }; await extensionPolicies.ApplyAccessAsync(profile, ct); @@ -673,7 +827,8 @@ private static void ValidateUserInput(string username, string displayName, strin if (string.IsNullOrWhiteSpace(username) || !UsernameRegex().IsMatch(username.Trim())) throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, "ID 需为 3-32 位字母、数字、点、下划线或短横线"); ValidateDisplayName(displayName); - ValidatePassword(password); + // 密码留空表示批量导入待激活账号(首次登录强制设置密码)。 + if (!string.IsNullOrWhiteSpace(password)) ValidatePassword(password); ValidateRole(role); } @@ -707,15 +862,26 @@ private static void EnsureIdentitySucceeded(IdentityResult result) private static partial Regex UsernameRegex(); } +/// +/// 已认证连接/请求的主体。插件连接带其归属班级 ClassId; +/// 用户连接带可访问班级列表 AccessibleClassIds(管理员为全部班级快照,由刷新任务周期更新)。 +/// public sealed record AuthPrincipal( PeerRole PeerRole, UserProfile? User, Guid? DeviceSessionId, Guid? PluginCredentialId, - DateTimeOffset? ValidUntil) + DateTimeOffset? ValidUntil, + Guid? ClassId = null, + IReadOnlyList? AccessibleClassIds = null) { public bool IsPlugin => PeerRole == PeerRole.Plugin; public bool IsAdmin => User?.Role == UserRole.Admin; + + /// 该主体的连接是否覆盖指定班级(插件看归属班,用户看成员/管理范围)。 + public bool CoversClass(Guid classId) => IsPlugin + ? ClassId == classId + : IsAdmin || (AccessibleClassIds?.Contains(classId) ?? false); } public sealed class IdentityOperationException(string code, string message) : Exception(message) diff --git a/server/RemoteCI.Server/Services/PeerRegistry.cs b/server/RemoteCI.Server/Services/PeerRegistry.cs index 5077ebd..b40c446 100644 --- a/server/RemoteCI.Server/Services/PeerRegistry.cs +++ b/server/RemoteCI.Server/Services/PeerRegistry.cs @@ -2,6 +2,7 @@ using System.Net.WebSockets; using System.Text.Json; using Microsoft.Data.Sqlite; +using RemoteCI.Server.Data; using RemoteCI.Shared; using RemoteCI.Shared.Models; @@ -17,17 +18,35 @@ public sealed class PeerRegistry( private readonly ConcurrentDictionary _pluginPeers = new(); private readonly ConcurrentDictionary _watchPeers = new(); private readonly ConcurrentDictionary _pendingCommands = new(StringComparer.Ordinal); - private PluginNetworkInfo? _latestPluginNetworkInfo; + private readonly ConcurrentDictionary _latestPluginNetworkInfo = new(); private PluginProtocolMismatch? _latestPluginProtocolMismatch; public bool HasPlugin => !_pluginPeers.IsEmpty; - public int WatchCount => _watchPeers.Count; + public int WatchCount => _watchPeers.Values.Count(peer => peer.Principal.PeerRole == PeerRole.Watch); + public int MobileCount => _watchPeers.Values.Count(peer => peer.Principal.PeerRole == PeerRole.Mobile); public int PluginCount => _pluginPeers.Count; public PluginProtocolMismatch? LatestPluginProtocolMismatch => Volatile.Read(ref _latestPluginProtocolMismatch); - public bool PrimaryPluginSupports(string capability) => - PrimaryPlugin() is { } peer && EffectiveCapabilities(peer).Contains(capability, StringComparer.Ordinal); + public bool HasPluginFor(Guid classId) => PluginPeersFor(classId).Any(); + + public bool PrimaryPluginSupports(Guid classId, string capability) => + PluginPeersFor(classId).FirstOrDefault() is { } peer && + EffectiveCapabilities(peer).Contains(capability, StringComparer.Ordinal); + + /// 归属指定班级的健康插件,最早接入优先。 + private IEnumerable PluginPeersFor(Guid classId) => + _pluginPeers.Values + .Where(peer => IsLocallyAuthorized(peer) && peer.Principal.ClassId == classId) + .OrderBy(peer => peer.RegisteredAt); + + /// 在线插件连接及其归属班级,供按班级生成授权镜像使用。 + public IReadOnlyList<(Guid ConnectionId, Guid ClassId)> GetOnlinePluginConnections() => + _pluginPeers.Values.Where(IsLocallyAuthorized) + .Where(x => x.Principal.ClassId is { } classId) + .OrderBy(x => x.RegisteredAt) + .Select(x => (x.Id, x.Principal.ClassId!.Value)) + .ToList(); public Guid Register(WebSocket socket, string token, AuthPrincipal principal) { @@ -135,6 +154,27 @@ public async Task DisconnectPluginCredentialAsync(Guid credentialId, Cancellatio } } + /// 班级删除后断开其插件连接;凭据已随班级级联清理,插件需重新配对。 + public async Task DisconnectPluginClassAsync(Guid classId, CancellationToken ct = default) + { + foreach (var peer in _pluginPeers.Values + .Where(peer => peer.Principal.ClassId == classId) + .ToList()) + { + ct.ThrowIfCancellationRequested(); + await UnregisterAsync(peer.Id, WebSocketCloseStatus.PolicyViolation); + } + } + + public async Task DisconnectPluginClassAsync(IReadOnlyCollection classIds, CancellationToken ct = default) + { + foreach (var classId in classIds) + { + ct.ThrowIfCancellationRequested(); + await DisconnectPluginClassAsync(classId, ct); + } + } + public async Task UnregisterAsync(Guid connectionId, WebSocketCloseStatus? status = null) { var peer = _pluginPeers.TryRemove(connectionId, out var plugin) ? plugin @@ -186,87 +226,95 @@ await peer.Socket.CloseOutputAsync( await BroadcastCapabilitiesToWatchesAsync(CancellationToken.None); } - public Task SendSnapshotToWatchesAsync(ClassStateSnapshot value, CancellationToken ct = default) => - BroadcastWatchesAsync(Envelope.StatePush(value), ct); + public Task SendSnapshotToWatchesAsync(Guid classId, ClassStateSnapshot value, CancellationToken ct = default) => + BroadcastClassWatchesAsync(Envelope.StatePush(value), classId, ct); - public Task SendScheduleToWatchesAsync(ScheduleBundle value, CancellationToken ct = default) => - BroadcastWatchesAsync(Envelope.ScheduleSync(value), ct); + public Task SendScheduleToWatchesAsync(Guid classId, ScheduleBundle value, CancellationToken ct = default) => + BroadcastClassWatchesAsync(Envelope.ScheduleSync(value), classId, ct); public Task SendScheduleSyncStatusToWatchesAsync(ScheduleSyncStatus value, CancellationToken ct = default) => - BroadcastWatchesAsync(Envelope.ScheduleSyncStatus(value), ct); + BroadcastClassWatchesAsync(Envelope.ScheduleSyncStatus(value), value.ClassId, ct); - public Task SendEventToWatchesAsync(ClassEvent value, CancellationToken ct = default) => - BroadcastWatchesAsync(Envelope.EventNotify(value), ct); + public Task SendEventToWatchesAsync(Guid classId, ClassEvent value, CancellationToken ct = default) => + BroadcastClassWatchesAsync(Envelope.EventNotify(value), classId, ct); - public Task SendExtensionsToWatchesAsync(IReadOnlyList value, CancellationToken ct = default) => - BroadcastWatchesAsync(Envelope.ExtensionsSync(value), ct); + public Task SendExtensionsToWatchesAsync(Guid classId, IReadOnlyList value, CancellationToken ct = default) => + BroadcastClassWatchesAsync(Envelope.ExtensionsSync(value), classId, ct); public Task SendSettingsToWatchesAsync(SettingsSync value, CancellationToken ct = default) => - BroadcastWatchesAsync(Envelope.SettingsSync(value), ct); + BroadcastClassWatchesAsync(Envelope.SettingsSync(value), null, ct); - /// 缓存插件最近一次网卡发现结果,并同步给所有在线手表。 + /// 缓存插件最近一次网卡发现结果(按班级),并同步给覆盖该班级的在线手表。 public Task PublishPluginNetworkInfoAsync(PluginNetworkInfo value, CancellationToken ct = default) { - Volatile.Write(ref _latestPluginNetworkInfo, value); - return BroadcastWatchesAsync(Envelope.PluginNetworkInfo(value), ct); + var classId = value.ClassId ?? Classroom.DefaultId; + _latestPluginNetworkInfo[classId] = value; + return BroadcastClassWatchesAsync(Envelope.PluginNetworkInfo(value), classId, ct); } - public Task SendLatestPluginNetworkInfoToWatchAsync(Guid connectionId, CancellationToken ct = default) => - Volatile.Read(ref _latestPluginNetworkInfo) is { } value - ? SendToWatchAsync(connectionId, Envelope.PluginNetworkInfo(value), ct) - : Task.CompletedTask; + /// 把该手表可访问班级的插件局域网信息补发给新连接。 + public async Task SendLatestPluginNetworkInfoToWatchAsync(Guid connectionId, CancellationToken ct = default) + { + var principal = GetPrincipal(connectionId); + if (principal is null) return; + foreach (var value in _latestPluginNetworkInfo.Values) + { + if (!principal.CoversClass(value.ClassId ?? Classroom.DefaultId)) continue; + await SendToWatchAsync(connectionId, Envelope.PluginNetworkInfo(value), ct); + } + } + + public async Task BroadcastWatchesAsync(Envelope envelope, CancellationToken ct = default) => + await BroadcastClassWatchesAsync(envelope, null, ct); - public async Task BroadcastWatchesAsync(Envelope envelope, CancellationToken ct = default) + /// + /// 广播给手表/手机连接;classId 非空时只投递给可访问该班级的连接, + /// 避免其他班级的课表/状态流串到无关设备上。 + /// + private async Task BroadcastClassWatchesAsync(Envelope envelope, Guid? classId, CancellationToken ct = default) { foreach (var peer in _watchPeers.Values) { + if (classId is { } target && !peer.Principal.CoversClass(target)) continue; if (!IsLocallyAuthorized(peer) || !await TrySendAsync(peer, envelope, ct)) await UnregisterAsync(peer.Id, WebSocketCloseStatus.PolicyViolation); } } - /// 广播给全部插件(幂等内容:授权镜像同步)。失效连接顺带清理。 - public async Task BroadcastToPluginsAsync(Envelope envelope, CancellationToken ct = default) + /// 向单个插件连接定向发送(如按班级生成的授权镜像)。 + public async Task SendToPluginConnectionAsync(Guid connectionId, Envelope envelope, CancellationToken ct = default) { - var sent = false; - foreach (var peer in _pluginPeers.Values) - { - if (IsLocallyAuthorized(peer) && await TrySendAsync(peer, envelope, ct)) - { - sent = true; - continue; - } - - // 发送失败说明注册表中的连接已经失效,清理后由插件自身的重连循环重新接入。 - await UnregisterAsync(peer.Id, WebSocketCloseStatus.PolicyViolation); - } - return sent; + if (!_pluginPeers.TryGetValue(connectionId, out var peer) || !IsLocallyAuthorized(peer)) return false; + if (await TrySendAsync(peer, envelope, ct)) return true; + await UnregisterAsync(peer.Id, WebSocketCloseStatus.PolicyViolation); + return false; } /// - /// 命令与只读请求只投递给一个插件:多插件在线时选择最早接入的健康插件, + /// 向指定班级在线的插件发送:命令与只读请求只投递给该班最早接入的健康插件, /// 避免同一命令被多个 ClassIsland 实例重复执行(换课/关机/通知各执行一次以上)。 /// - public async Task SendToPluginAsync(Envelope envelope, CancellationToken ct = default) + public async Task SendToPluginAsync(Guid classId, Envelope envelope, CancellationToken ct = default) { // 按注册时间排序才是真正的“最早接入优先”;Guid 顺序与接入时间无关。 - foreach (var peer in _pluginPeers.Values.OrderBy(x => x.RegisteredAt)) + foreach (var peer in _pluginPeers.Values.Where(x => x.Principal.ClassId == classId).OrderBy(x => x.RegisteredAt)) { if (IsLocallyAuthorized(peer) && await TrySendAsync(peer, envelope, ct)) return true; + // 发送失败说明注册表中的连接已经失效,清理后由插件自身的重连循环重新接入。 await UnregisterAsync(peer.Id, WebSocketCloseStatus.PolicyViolation); } return false; } - public Task SendAccountSyncToPluginsAsync(AccountSync sync, CancellationToken ct = default) => - BroadcastToPluginsAsync(Envelope.AccountSync(sync), ct); - - /// 请求最早接入的在线插件立即重新生成课表;返回是否成功发送。 - public Task RequestSchedulePullAsync(ScheduleSyncRequest request, CancellationToken ct = default) => - PrimaryPluginSupports(RemoteCiCapabilities.SchedulePull) - ? SendToPluginAsync(Envelope.SchedulePull(request), ct) + /// 请求指定班级最早接入的在线插件立即重新生成课表;返回是否成功发送。 + public Task RequestSchedulePullAsync(ScheduleSyncRequest request, CancellationToken ct = default) + { + var classId = request.ClassId ?? Classroom.DefaultId; + return PrimaryPluginSupports(classId, RemoteCiCapabilities.SchedulePull) + ? SendToPluginAsync(classId, Envelope.SchedulePull(request), ct) : Task.FromResult(false); + } /// /// 向指定插件连接发送课表拉取请求:新插件接入时的补齐拉取必须定向发给它自己, @@ -307,20 +355,20 @@ private async Task ExpireWatchCommandAsync(string messageId, Guid connectionId) } public async Task SendCommandAndWaitAsync( - CommandMessage command, TimeSpan timeout, CancellationToken ct = default) + CommandMessage command, Guid classId, TimeSpan timeout, CancellationToken ct = default) { if (command.Command == CommandKind.SendVoiceMessage && !VoiceMessageRequest.TryDecode(command.VoiceMessage, out _)) return CommandResult.Failure(CommandResultCodes.InvalidRequest, "语音格式无效或超过 60 秒"); - if (!HasPlugin) return CommandResult.Failure(CommandResultCodes.PluginOffline, "插件未在线,操作未执行"); + if (!HasPluginFor(classId)) return CommandResult.Failure(CommandResultCodes.PluginOffline, "插件未在线,操作未执行"); if (RemoteCiCapabilities.Required(command.Command) is { } capability && - !PrimaryPluginSupports(capability)) + !PrimaryPluginSupports(classId, capability)) return CommandResult.Failure( CommandResultCodes.CapabilityUnsupported, - $"当前主插件未声明能力 {capability}"); + $"当前班级的插件未声明能力 {capability}"); var envelope = Envelope.Command(command); var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pendingCommands[envelope.MessageId] = new PendingCommand(null, completion); - if (!await SendToPluginAsync(envelope, ct)) + if (!await SendToPluginAsync(classId, envelope, ct)) { _pendingCommands.TryRemove(envelope.MessageId, out _); return CommandResult.Failure(CommandResultCodes.PluginOffline, "插件未在线,操作未执行"); diff --git a/server/RemoteCI.Server/Services/SchedulePullWorker.cs b/server/RemoteCI.Server/Services/SchedulePullWorker.cs index 650ea82..be0ce8d 100644 --- a/server/RemoteCI.Server/Services/SchedulePullWorker.cs +++ b/server/RemoteCI.Server/Services/SchedulePullWorker.cs @@ -1,8 +1,10 @@ +using Microsoft.EntityFrameworkCore; +using RemoteCI.Server.Data; using RemoteCI.Shared; namespace RemoteCI.Server.Services; -/// 按数据库中的管理员设置定时请求插件重新生成七日课表。 +/// 按数据库中的管理员设置定时请求各班级在线插件重新生成七日课表。 public sealed class SchedulePullWorker( IServiceScopeFactory scopeFactory, ScheduleSyncService scheduleSync, @@ -32,13 +34,19 @@ internal async Task CheckOnceAsync(DateTimeOffset now, CancellationToken ct) var interval = await settings.GetIntervalAsync(ct); if (!_cadence.IsDue(interval, now)) return; + // 拉取间隔是全局设置;每个班级的插件各自执行互斥的任务,班级间互不挤占。 + var classIds = await scope.ServiceProvider.GetRequiredService() + .Classrooms.AsNoTracking().Select(x => x.Id).ToListAsync(ct); _cadence.MarkAttempt(now); - var status = await scheduleSync.StartAsync(ScheduleSyncSource.Automatic, ct); - if (status.State == ScheduleSyncTaskState.Running) - logger.LogInformation("已按 {Interval} 分钟周期请求插件刷新课表", (int)interval); - else if (status.State == ScheduleSyncTaskState.Busy) - logger.LogInformation("已到课表拉取周期,但已有课表任务正在执行,本次自动拉取已跳过"); - else - logger.LogDebug("自动课表拉取未启动:{Message}", status.Message); + foreach (var classId in classIds) + { + var status = await scheduleSync.StartAsync(ScheduleSyncSource.Automatic, classId, ct); + if (status.State == ScheduleSyncTaskState.Running) + logger.LogInformation("已按 {Interval} 分钟周期请求班级 {ClassId} 的插件刷新课表", (int)interval, classId); + else if (status.State == ScheduleSyncTaskState.Busy) + logger.LogInformation("班级 {ClassId} 已到课表拉取周期,但已有课表任务正在执行,本次自动拉取已跳过", classId); + else + logger.LogDebug("班级 {ClassId} 自动课表拉取未启动:{Message}", classId, status.Message); + } } } diff --git a/server/RemoteCI.Server/Services/ScheduleSyncService.cs b/server/RemoteCI.Server/Services/ScheduleSyncService.cs index 2d2a626..bef35cd 100644 --- a/server/RemoteCI.Server/Services/ScheduleSyncService.cs +++ b/server/RemoteCI.Server/Services/ScheduleSyncService.cs @@ -1,10 +1,11 @@ +using RemoteCI.Server.Data; using RemoteCI.Shared; using RemoteCI.Shared.Models; namespace RemoteCI.Server.Services; /// -/// 服务端课表任务编排:在 WebUI、云端手表、自动任务和连接初始化之间共享运行状态, +/// 服务端课表任务编排:在 WebUI、云端手表、自动任务和连接初始化之间按班级共享运行状态, /// 插件端仍执行最终互斥,以覆盖局域网直连和插件本地按钮。 /// public sealed class ScheduleSyncService( @@ -13,20 +14,25 @@ public sealed class ScheduleSyncService( ILogger logger) { private static readonly TimeSpan TaskTimeout = TimeSpan.FromSeconds(15); - private CancellationTokenSource? _timeout; + private readonly Dictionary _timeouts = new(); - public ScheduleSyncStatus? Current => tracker.Current; + public ScheduleSyncStatus? Current(Guid classId) => tracker.Current(classId); public Task StartAsync( - ScheduleSyncSource source, CancellationToken ct = default, string? taskId = null) => - StartCoreAsync(ScheduleSyncRequest.Create(source, taskId), null, ct); + ScheduleSyncSource source, Guid classId, CancellationToken ct = default, string? taskId = null) + { + var request = ScheduleSyncRequest.Create(source, taskId); + request.ClassId = classId; + return StartCoreAsync(request, null, ct); + } public async Task StartFromPluginAsync( - Guid pluginConnectionId, ScheduleSyncSource source, CancellationToken ct = default) + Guid pluginConnectionId, Guid classId, ScheduleSyncSource source, CancellationToken ct = default) { // 新插件接入拉取直接定向发送;插件端全局闸门负责最终互斥, // 避免握手阶段尚未回传 Running 状态时把其他真实请求误判为重复。 var request = ScheduleSyncRequest.Create(source); + request.ClassId = classId; var sent = await peers.RequestSchedulePullFromAsync(pluginConnectionId, request, ct); return new ScheduleSyncStatus { @@ -36,12 +42,13 @@ public async Task StartFromPluginAsync( Message = sent ? "已请求新连接插件同步课表" : "插件连接已失效,无法同步课表", StartedAt = DateTimeOffset.UtcNow, FinishedAt = sent ? null : DateTimeOffset.UtcNow, + ClassId = classId, }; } - public async Task CompleteFromScheduleAsync(CancellationToken ct = default) + public async Task CompleteFromScheduleAsync(Guid classId, CancellationToken ct = default) { - if (tracker.Current is not { } running) return; + if (tracker.Current(classId) is not { } running) return; await ObserveAndPublishAsync(new ScheduleSyncStatus { TaskId = running.TaskId, @@ -50,19 +57,20 @@ await ObserveAndPublishAsync(new ScheduleSyncStatus Message = "课表已生成并推送完成", StartedAt = running.StartedAt, FinishedAt = DateTimeOffset.UtcNow, + ClassId = classId, }, ct); } - public async Task FailActiveAsync(string message, CancellationToken ct = default) + public async Task FailActiveAsync(string message, Guid classId, CancellationToken ct = default) { - if (tracker.Current is not { } running) return; + if (tracker.Current(classId) is not { } running) return; await ObserveAndPublishAsync(Failure(running, message), ct); } public async Task StartAndWaitAsync( - ScheduleSyncSource source, CancellationToken ct = default) + ScheduleSyncSource source, Guid classId, CancellationToken ct = default) { - var initial = await StartAsync(source, ct); + var initial = await StartAsync(source, classId, ct); if (initial.State != ScheduleSyncTaskState.Running) return initial; try { @@ -82,16 +90,18 @@ public async Task ObserveFromPluginAsync(ScheduleSyncStatus status, Cancellation "插件课表任务状态:{TaskId} {Source} {State} - {Message}", status.TaskId, status.Source, status.State, status.Message); tracker.Observe(status); - if (status.State == ScheduleSyncTaskState.Running) ArmTimeout(status); - else if (status.State == ScheduleSyncTaskState.Busy && tracker.Current is { } active) ArmTimeout(active); - else if (status.State is ScheduleSyncTaskState.Completed or ScheduleSyncTaskState.Failed) CancelTimeout(); + var classId = status.ClassId ?? Classroom.DefaultId; + if (status.State == ScheduleSyncTaskState.Running) ArmTimeout(status, classId); + else if (status.State == ScheduleSyncTaskState.Busy && tracker.Current(classId) is { } active) ArmTimeout(active, classId); + else if (status.State is ScheduleSyncTaskState.Completed or ScheduleSyncTaskState.Failed) CancelTimeout(classId); await peers.SendScheduleSyncStatusToWatchesAsync(status, ct); } private async Task StartCoreAsync( ScheduleSyncRequest request, Guid? pluginConnectionId, CancellationToken ct) { - var initial = tracker.TryBegin(request); + var classId = request.ClassId ?? Classroom.DefaultId; + var initial = tracker.TryBegin(request, classId); if (initial.State == ScheduleSyncTaskState.Busy) { await peers.SendScheduleSyncStatusToWatchesAsync(initial, ct); @@ -109,32 +119,34 @@ private async Task StartCoreAsync( return failed; } - ArmTimeout(initial); + ArmTimeout(initial, classId); return initial; } private async Task ObserveAndPublishAsync(ScheduleSyncStatus status, CancellationToken ct) { tracker.Observe(status); - if (status.State is ScheduleSyncTaskState.Completed or ScheduleSyncTaskState.Failed) CancelTimeout(); + if (status.State is ScheduleSyncTaskState.Completed or ScheduleSyncTaskState.Failed) + CancelTimeout(status.ClassId ?? Classroom.DefaultId); await peers.SendScheduleSyncStatusToWatchesAsync(status, ct); } - private void ArmTimeout(ScheduleSyncStatus running) + private void ArmTimeout(ScheduleSyncStatus running, Guid classId) { - CancelTimeout(); + CancelTimeout(classId); var timeout = new CancellationTokenSource(); var token = timeout.Token; - _timeout = timeout; + _timeouts[classId] = timeout; _ = Task.Run(async () => { try { await Task.Delay(TaskTimeout, token); - if (tracker.Current?.TaskId != running.TaskId) return; + if (tracker.Current(classId)?.TaskId != running.TaskId) return; var failed = Failure(running, "课表任务执行超时,请检查插件日志和网络连接"); await ObserveAndPublishAsync(failed, CancellationToken.None); - logger.LogWarning("课表任务 {TaskId} 执行超时,来源 {Source}", running.TaskId, running.Source); + logger.LogWarning("班级 {ClassId} 课表任务 {TaskId} 执行超时,来源 {Source}", + classId, running.TaskId, running.Source); } catch (OperationCanceledException) when (token.IsCancellationRequested) { @@ -143,11 +155,11 @@ private void ArmTimeout(ScheduleSyncStatus running) }); } - private void CancelTimeout() + private void CancelTimeout(Guid classId) { - var timeout = Interlocked.Exchange(ref _timeout, null); - timeout?.Cancel(); - timeout?.Dispose(); + if (!_timeouts.Remove(classId, out var timeout)) return; + timeout.Cancel(); + timeout.Dispose(); } private static ScheduleSyncStatus Failure(ScheduleSyncStatus running, string message) => new() @@ -158,5 +170,6 @@ private void CancelTimeout() Message = message, StartedAt = running.StartedAt, FinishedAt = DateTimeOffset.UtcNow, + ClassId = running.ClassId, }; } diff --git a/server/RemoteCI.Server/Services/ScheduleSyncTaskTracker.cs b/server/RemoteCI.Server/Services/ScheduleSyncTaskTracker.cs index ff48256..927a946 100644 --- a/server/RemoteCI.Server/Services/ScheduleSyncTaskTracker.cs +++ b/server/RemoteCI.Server/Services/ScheduleSyncTaskTracker.cs @@ -1,73 +1,74 @@ using System.Collections.Concurrent; +using RemoteCI.Server.Data; using RemoteCI.Shared; using RemoteCI.Shared.Models; namespace RemoteCI.Server.Services; -/// 服务端保存当前课表任务并为 WebUI 提供可等待的终态。 +/// 服务端按班级保存当前课表任务并为 WebUI 提供可等待的终态。 public sealed class ScheduleSyncTaskTracker { - private readonly object _gate = new(); + private readonly ConcurrentDictionary _current = new(); private readonly ConcurrentDictionary> _waiters = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _terminal = new(StringComparer.Ordinal); - private ScheduleSyncStatus? _current; - public ScheduleSyncStatus? Current - { - get - { - lock (_gate) return _current; - } - } + public ScheduleSyncStatus? Current(Guid classId) => + _current.TryGetValue(classId, out var status) ? status : null; - public ScheduleSyncStatus TryBegin(ScheduleSyncRequest request) + public ScheduleSyncStatus TryBegin(ScheduleSyncRequest request, Guid classId) { - lock (_gate) + if (_current.TryGetValue(classId, out var active) && active.State == ScheduleSyncTaskState.Running) { - if (_current is { State: ScheduleSyncTaskState.Running } active) - { - return new ScheduleSyncStatus - { - TaskId = request.TaskId, - Source = request.Source, - State = ScheduleSyncTaskState.Busy, - Message = $"已有{SourceName(active.Source)}课表任务正在执行,请稍候", - StartedAt = active.StartedAt, - FinishedAt = DateTimeOffset.UtcNow, - ActiveTaskId = active.TaskId, - }; - } - - _current = new ScheduleSyncStatus + return new ScheduleSyncStatus { TaskId = request.TaskId, Source = request.Source, - State = ScheduleSyncTaskState.Running, - Message = $"正在连接插件执行{SourceName(request.Source)}任务", - StartedAt = DateTimeOffset.UtcNow, + State = ScheduleSyncTaskState.Busy, + Message = $"已有{SourceName(active.Source)}课表任务正在执行,请稍候", + StartedAt = active.StartedAt, + FinishedAt = DateTimeOffset.UtcNow, + ActiveTaskId = active.TaskId, + ClassId = classId, }; - return _current; } + + var started = new ScheduleSyncStatus + { + TaskId = request.TaskId, + Source = request.Source, + State = ScheduleSyncTaskState.Running, + Message = $"正在连接插件执行{SourceName(request.Source)}任务", + StartedAt = DateTimeOffset.UtcNow, + ClassId = classId, + }; + _current[classId] = started; + return started; } public void Observe(ScheduleSyncStatus status) { - lock (_gate) + var classId = status.ClassId ?? Classroom.DefaultId; + if (status.State == ScheduleSyncTaskState.Running) + { + _current[classId] = status; + } + else if (_current.TryGetValue(classId, out var current) && current.TaskId == status.TaskId) { - if (status.State == ScheduleSyncTaskState.Running) - _current = status; - else if (_current?.TaskId == status.TaskId) + if (status.State == ScheduleSyncTaskState.Busy && !string.IsNullOrWhiteSpace(status.ActiveTaskId)) + { + _current[classId] = new ScheduleSyncStatus + { + TaskId = status.ActiveTaskId, + Source = ScheduleSyncSource.Unknown, + State = ScheduleSyncTaskState.Running, + Message = status.Message, + StartedAt = status.StartedAt, + ClassId = classId, + }; + } + else { - _current = status.State == ScheduleSyncTaskState.Busy && !string.IsNullOrWhiteSpace(status.ActiveTaskId) - ? new ScheduleSyncStatus - { - TaskId = status.ActiveTaskId, - Source = ScheduleSyncSource.Unknown, - State = ScheduleSyncTaskState.Running, - Message = status.Message, - StartedAt = status.StartedAt, - } - : null; + _current.TryRemove(classId, out _); } } diff --git a/server/RemoteCI.Server/Services/StateStore.cs b/server/RemoteCI.Server/Services/StateStore.cs index 6bd02c2..762eae1 100644 --- a/server/RemoteCI.Server/Services/StateStore.cs +++ b/server/RemoteCI.Server/Services/StateStore.cs @@ -1,26 +1,51 @@ +using System.Collections.Concurrent; using RemoteCI.Shared.Models; namespace RemoteCI.Server.Services; public sealed class StateStore : IStateStore { - private ClassStateSnapshot? _snapshot; - private ScheduleBundle? _schedule; - private ClassEvent? _event; - private IReadOnlyList? _extensions; + private readonly ConcurrentDictionary _buckets = new(); - // 单引用原子发布:读写都是整体替换不可变快照,Volatile 保证跨线程可见性,无需锁。 - public void SaveSnapshot(ClassStateSnapshot snapshot) => Volatile.Write(ref _snapshot, snapshot); + private ClassStateBucket Bucket(Guid classId) => _buckets.GetOrAdd(classId, _ => new ClassStateBucket()); - public ClassStateSnapshot? GetLatestSnapshot() => Volatile.Read(ref _snapshot); + public void SaveSnapshot(Guid classId, ClassStateSnapshot snapshot) => Bucket(classId).SaveSnapshot(snapshot); - public void SaveSchedule(ScheduleBundle schedule) => Volatile.Write(ref _schedule, schedule); + public ClassStateSnapshot? GetLatestSnapshot(Guid classId) => Bucket(classId).GetLatestSnapshot(); - public ScheduleBundle? GetLatestSchedule() => Volatile.Read(ref _schedule); + public void SaveSchedule(Guid classId, ScheduleBundle schedule) => Bucket(classId).SaveSchedule(schedule); - public void SaveEvent(ClassEvent @event) => Volatile.Write(ref _event, @event); + public ScheduleBundle? GetLatestSchedule(Guid classId) => Bucket(classId).GetLatestSchedule(); - public void SaveExtensions(IReadOnlyList extensions) => Volatile.Write(ref _extensions, extensions); + public void SaveEvent(Guid classId, ClassEvent @event) => Bucket(classId).SaveEvent(@event); - public IReadOnlyList? GetLatestExtensions() => Volatile.Read(ref _extensions); + public ClassEvent? GetLatestEvent(Guid classId) => Bucket(classId).GetLatestEvent(); + + public void SaveExtensions(Guid classId, IReadOnlyList extensions) => + Bucket(classId).SaveExtensions(extensions); + + public IReadOnlyList? GetLatestExtensions(Guid classId) => + Bucket(classId).GetLatestExtensions(); + + /// 单个班级的不可变快照桶:读写在各自字段上整体替换,Volatile 保证跨线程可见性,无需锁。 + private sealed class ClassStateBucket + { + private ClassStateSnapshot? _snapshot; + private ScheduleBundle? _schedule; + private ClassEvent? _event; + private IReadOnlyList? _extensions; + + public void SaveSnapshot(ClassStateSnapshot snapshot) => Volatile.Write(ref _snapshot, snapshot); + public ClassStateSnapshot? GetLatestSnapshot() => Volatile.Read(ref _snapshot); + + public void SaveSchedule(ScheduleBundle schedule) => Volatile.Write(ref _schedule, schedule); + public ScheduleBundle? GetLatestSchedule() => Volatile.Read(ref _schedule); + + public void SaveEvent(ClassEvent @event) => Volatile.Write(ref _event, @event); + public ClassEvent? GetLatestEvent() => Volatile.Read(ref _event); + + public void SaveExtensions(IReadOnlyList extensions) => + Volatile.Write(ref _extensions, extensions); + public IReadOnlyList? GetLatestExtensions() => Volatile.Read(ref _extensions); + } } diff --git a/server/RemoteCI.Server/Services/UserImportService.cs b/server/RemoteCI.Server/Services/UserImportService.cs new file mode 100644 index 0000000..7fc8a64 --- /dev/null +++ b/server/RemoteCI.Server/Services/UserImportService.cs @@ -0,0 +1,94 @@ +using Microsoft.EntityFrameworkCore; +using RemoteCI.Server.Data; +using RemoteCI.Shared; +using RemoteCI.Shared.Models; + +namespace RemoteCI.Server.Services; + +/// +/// 批量导入人员:行格式 ID,用户名,班级,角色,密码(密码可空=待激活账号,首次登录强制设置密码; +/// 班级不存在自动创建;角色按名称匹配角色预设)。兼容 3 列旧格式 ID,用户名,密码,班级/角色取默认值。 +/// +public sealed class UserImportService( + IdentityCoordinator identities, + ClassroomService classrooms, + AccountRoleService roleService) +{ + public async Task ImportAsync( + string? text, Guid? defaultClassId, Guid? defaultRoleId, CancellationToken ct = default) + { + var separators = new[] { '\n', '\r' }; + var lines = (text ?? string.Empty).Split(separators) + .Select(line => line.Trim()) + .Where(line => line.Length > 0 && !line.StartsWith('#')) + .ToList(); + if (lines.Count == 0) + throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, "请粘贴至少一行账号信息。"); + + var created = 0; + var failures = new List(); + foreach (var line in lines) + { + var parts = line.Split(',', '\t', StringSplitOptions.TrimEntries) + .Where(part => part.Length > 0).ToArray(); + if (parts.Length is < 3 or > 5) + { + failures.Add($"列数无效(应为 ID,用户名,班级,角色,密码):{Truncate(line, 48)}"); + continue; + } + var username = parts[0]; + var displayName = parts[1]; + // 5 列:ID,用户名,班级,角色,密码(密码可空);4 列:ID,用户名,班级,角色(待激活); + // 3 列旧格式:ID,用户名,密码,班级/角色取默认值。 + var className = parts.Length >= 4 ? parts[2] : null; + var roleName = parts.Length >= 4 ? parts[3] : null; + var password = parts.Length switch + { + 5 => parts[4], + 4 => string.Empty, + _ => parts[2], + }; + try + { + Guid classId; + if (className is not null) + { + classId = (await classrooms.EnsureClassByNameAsync(className, ct)).Id; + } + else + { + classId = defaultClassId + ?? throw new IdentityOperationException(ApiErrorCodes.InvalidRequest, "行内缺少班级,且未提供默认班级"); + } + + var roleId = defaultRoleId ?? AccountRole.StudentId; + if (roleName is not null) + { + var role = await roleService.FindByNameAsync(roleName, ct) + ?? throw new IdentityOperationException(ApiErrorCodes.NotFound, $"角色“{roleName}”不存在"); + roleId = role.Id; + } + + var createdUser = await identities.CreateUserAsync(new CreateUserRequest + { + Username = username, + DisplayName = displayName, + Password = password, + Role = UserRole.User, + RoleId = roleId, + GrantedPermissions = UserPermissions.None, + }, ct); + await classrooms.AddMemberAsync(classId, createdUser.Id, roleId, ct); + created++; + } + catch (IdentityOperationException ex) + { + failures.Add($"{username} — {ex.Message}"); + } + } + return new BatchImportResult { Created = created, Failures = failures }; + } + + private static string Truncate(string value, int maxLength) => + value.Length <= maxLength ? value : value[..maxLength] + "…"; +} diff --git a/server/RemoteCI.Server/Services/VisitorAccessSettings.cs b/server/RemoteCI.Server/Services/VisitorAccessSettings.cs index 80698a2..a2c30a4 100644 --- a/server/RemoteCI.Server/Services/VisitorAccessSettings.cs +++ b/server/RemoteCI.Server/Services/VisitorAccessSettings.cs @@ -3,29 +3,37 @@ namespace RemoteCI.Server.Services; -public sealed record VisitorAccessState(bool Enabled, bool AutoEnter); - +/// 全局访客入口设置;班级级“是否开放访客”开关在 Classroom.VisitorAccessEnabled 上。 public sealed class VisitorAccessSettings(AppDbContext db) { - public async Task GetAsync(CancellationToken ct = default) + public async Task GetAutoEnterAsync(CancellationToken ct = default) { - var row = await db.SystemMetadata.AsNoTracking() + return await db.SystemMetadata.AsNoTracking() .Where(metadata => metadata.Id == 1) - .Select(metadata => new { metadata.VisitorAccessEnabled, metadata.AutoEnterVisitorPage }) + .Select(metadata => metadata.AutoEnterVisitorPage) .SingleAsync(ct); - return Normalize(row.VisitorAccessEnabled, row.AutoEnterVisitorPage); } - public async Task SetAsync(bool enabled, bool autoEnter, CancellationToken ct = default) + public async Task SetAutoEnterAsync(bool autoEnter, CancellationToken ct = default) { var metadata = await db.SystemMetadata.SingleAsync(row => row.Id == 1, ct); - var state = Normalize(enabled, autoEnter); - metadata.VisitorAccessEnabled = state.Enabled; - metadata.AutoEnterVisitorPage = state.AutoEnter; + // 只保存管理员意图;是否真正生效由读取方结合“存在开放访客的班级”判断, + // 避免开关班级后全局意图被静默改写。 + metadata.AutoEnterVisitorPage = autoEnter; await db.SaveChangesAsync(ct); - return state; + return metadata.AutoEnterVisitorPage; } - private static VisitorAccessState Normalize(bool enabled, bool autoEnter) => - new(enabled, enabled && autoEnter); + /// 是否存在开放访客的班级;决定访客页与自动进入是否可用。 + public async Task AnyVisitorClassEnabledAsync(CancellationToken ct = default) => + await db.Classrooms.AsNoTracking().AnyAsync(x => x.VisitorAccessEnabled, ct); + + /// 开放访客的班级,供访客页渲染班级选择;访客数量级很小,直接取回。 + public async Task> ListVisitorClassroomsAsync(CancellationToken ct = default) + { + var classrooms = await db.Classrooms.AsNoTracking() + .Where(x => x.VisitorAccessEnabled) + .ToListAsync(ct); + return classrooms.OrderBy(x => x.CreatedAt).ToList(); + } } diff --git a/server/RemoteCI.Server/WebSocketHub.cs b/server/RemoteCI.Server/WebSocketHub.cs index 69fe025..049d67f 100644 --- a/server/RemoteCI.Server/WebSocketHub.cs +++ b/server/RemoteCI.Server/WebSocketHub.cs @@ -1,6 +1,7 @@ using System.Net.WebSockets; using System.Text; using System.Text.Json; +using RemoteCI.Server.Data; using RemoteCI.Server.Services; using RemoteCI.Shared; using RemoteCI.Shared.Models; @@ -19,6 +20,7 @@ public static async Task HandleAsync( ExtensionPolicyService extensionPolicies, AuthorizationSyncService authorizationSync, ScheduleSyncService scheduleSync, + ClassAccessService classAccess, ILogger logger) { if (!context.WebSockets.IsWebSocketRequest) @@ -34,11 +36,15 @@ public static async Task HandleAsync( context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } + if (!principal.IsPlugin && + string.Equals(context.Request.Query["client"], "mobile", StringComparison.OrdinalIgnoreCase)) + principal = principal with { PeerRole = PeerRole.Mobile }; using var socket = await context.WebSockets.AcceptWebSocketAsync(); var connectionId = registry.Register(socket, token, principal); var session = new ConnectionSession( - context, identities, registry, store, extensionPolicies, authorizationSync, scheduleSync, logger, socket, connectionId, principal); + context, identities, registry, store, extensionPolicies, authorizationSync, scheduleSync, classAccess, + logger, socket, connectionId, principal); logger.LogInformation( "WebSocket connected: {Role}/{User} ({Id})", principal.PeerRole, @@ -69,19 +75,24 @@ private static async Task InitializeConnectionAsync(ConnectionSession sess private static async Task InitializePluginAsync(ConnectionSession session) { - if (session.Registry.PluginCount > 1) + var classId = session.Principal.ClassId ?? Classroom.DefaultId; + var sameClassPlugins = session.Registry.GetOnlinePluginConnections() + .Count(x => x.ClassId == classId); + if (sameClassPlugins > 1) { session.Logger.LogWarning( - "检测到 {Count} 个插件同时在线,命令将只投递给最早接入的插件,请确认是否为预期部署", - session.Registry.PluginCount); + "班级 {ClassId} 检测到 {Count} 个插件同时在线,命令将只投递给最早接入的插件,请确认是否为预期部署", + classId, sameClassPlugins); } var ct = session.CancellationToken; - await session.Registry.SendAccountSyncToPluginsAsync( - await session.Identities.CreateSyncAsync(ct), ct); + // 授权镜像按班级生成并定向发给本连接:插件只需要自己班级的账号。 + var sync = await session.Identities.CreateSyncAsync(classId, ct); + await session.Registry.SendToPluginConnectionAsync( + session.ConnectionId, Envelope.AccountSync(sync), ct); await session.Registry.BroadcastCapabilitiesToWatchesAsync(ct); await session.ScheduleSync.StartFromPluginAsync( - session.ConnectionId, ScheduleSyncSource.Connection, ct); + session.ConnectionId, classId, ScheduleSyncSource.Connection, ct); } private static async Task InitializeWatchAsync(ConnectionSession session) @@ -94,14 +105,19 @@ await session.Registry.SendToWatchAsync( await session.Registry.SendCurrentCapabilitiesToWatchAsync(session.ConnectionId, ct); await session.Registry.SendLatestPluginNetworkInfoToWatchAsync(session.ConnectionId, ct); - if (session.Store.GetLatestSnapshot() is { } snapshot) - await session.Registry.SendToWatchAsync(session.ConnectionId, Envelope.StatePush(snapshot), ct); - if (session.Store.GetLatestSchedule() is { } schedule) - await session.Registry.SendToWatchAsync(session.ConnectionId, Envelope.ScheduleSync(schedule), ct); - if (session.Store.GetLatestExtensions() is { } extensions) - await session.Registry.SendToWatchAsync(session.ConnectionId, Envelope.ExtensionsSync(extensions), ct); - if (session.ScheduleSync.Current is { } task) - await session.Registry.SendToWatchAsync(session.ConnectionId, Envelope.ScheduleSyncStatus(task), ct); + // 该账号可访问的每个班级各推一份最新数据;消息负载带 classId 供新版客户端区分, + // 旧客户端在单班级部署下只会收到一份,行为不变。 + foreach (var classId in session.Principal.AccessibleClassIds ?? []) + { + if (session.Store.GetLatestSnapshot(classId) is { } snapshot) + await session.Registry.SendToWatchAsync(session.ConnectionId, Envelope.StatePush(snapshot), ct); + if (session.Store.GetLatestSchedule(classId) is { } schedule) + await session.Registry.SendToWatchAsync(session.ConnectionId, Envelope.ScheduleSync(schedule), ct); + if (session.Store.GetLatestExtensions(classId) is { } extensions) + await session.Registry.SendToWatchAsync(session.ConnectionId, Envelope.ExtensionsSync(extensions), ct); + if (session.ScheduleSync.Current(classId) is { } task) + await session.Registry.SendToWatchAsync(session.ConnectionId, Envelope.ScheduleSyncStatus(task), ct); + } await session.Registry.SendToWatchAsync( session.ConnectionId, @@ -145,7 +161,9 @@ private static async Task RunConnectionAsync(ConnectionSession session) finally { if (session.Principal.IsPlugin) - await session.ScheduleSync.FailActiveAsync("插件连接已断开,课表任务未完成"); + await session.ScheduleSync.FailActiveAsync( + "插件连接已断开,课表任务未完成", + session.Principal.ClassId ?? Classroom.DefaultId); await session.Registry.UnregisterAsync(session.ConnectionId); session.Logger.LogInformation("WebSocket disconnected: {Id}", session.ConnectionId); } @@ -292,38 +310,44 @@ private static async Task TryDispatchPluginStateAsync( ConnectionSession session) { var ct = session.CancellationToken; + var classId = session.Principal.ClassId ?? Classroom.DefaultId; switch (envelope.Type) { case Protocol.MessageTypeStatePush: if (ConvertPayload(envelope.Payload) is { } snapshot) { - session.Store.SaveSnapshot(snapshot); - await session.Registry.SendSnapshotToWatchesAsync(snapshot, ct); + // 负载携带班级标识,客户端据此区分多个班级的推送。 + snapshot.ClassId = classId; + session.Store.SaveSnapshot(classId, snapshot); + await session.Registry.SendSnapshotToWatchesAsync(classId, snapshot, ct); } return true; case Protocol.MessageTypeScheduleSync: if (ConvertPayload(envelope.Payload) is { } schedule) { - session.Store.SaveSchedule(schedule); - await session.Registry.SendScheduleToWatchesAsync(schedule, ct); - await session.ScheduleSync.CompleteFromScheduleAsync(ct); + schedule.ClassId = classId; + session.Store.SaveSchedule(classId, schedule); + await session.Registry.SendScheduleToWatchesAsync(classId, schedule, ct); + await session.ScheduleSync.CompleteFromScheduleAsync(classId, ct); } return true; case Protocol.MessageTypeEventNotify: if (ConvertPayload(envelope.Payload) is { } value) { - session.Store.SaveEvent(value); - await session.Registry.SendEventToWatchesAsync(value, ct); + value.ClassId = classId; + session.Store.SaveEvent(classId, value); + await session.Registry.SendEventToWatchesAsync(classId, value, ct); } return true; case Protocol.MessageTypeExtensionsSync: if (ConvertPayload>(envelope.Payload) is { } extensions) { + foreach (var definition in extensions) definition.ClassId = classId; var accessChanged = await session.ExtensionPolicies.EnsureRegisteredAsync(extensions, ct); - session.Store.SaveExtensions(extensions); + session.Store.SaveExtensions(classId, extensions); if (accessChanged) await session.AuthorizationSync.SyncAsync(ct); - await session.Registry.SendExtensionsToWatchesAsync(extensions, ct); + await session.Registry.SendExtensionsToWatchesAsync(classId, extensions, ct); } return true; default: @@ -340,7 +364,10 @@ private static async Task TryDispatchPluginControlAsync( { case Protocol.MessageTypePluginNetworkInfo: if (NormalizePluginNetworkInfo(ConvertPayload(envelope.Payload)) is { } info) + { + info.ClassId = session.Principal.ClassId ?? Classroom.DefaultId; await session.Registry.PublishPluginNetworkInfoAsync(info, ct); + } else session.Logger.LogWarning("插件上报了无效的局域网地址或端口"); return true; @@ -369,10 +396,18 @@ private static async Task DispatchUserAsync( return; case Protocol.MessageTypeSchedulePull: var request = ConvertPayload(envelope.Payload); + var source = request?.Source == ScheduleSyncSource.Mobile + ? ScheduleSyncSource.Mobile + : ScheduleSyncSource.Watch; + var classId = await ResolveClassAsync(request?.ClassId, session); + if (classId is null) return; + request ??= new ScheduleSyncRequest { Source = source }; + request.ClassId = classId; await session.ScheduleSync.StartAsync( - ScheduleSyncSource.Watch, + source, + classId.Value, session.CancellationToken, - request?.TaskId ?? envelope.MessageId); + request.TaskId); return; default: LogUnhandled(envelope, session); @@ -386,6 +421,17 @@ private static void LogUnhandled(Envelope envelope, ConnectionSession session) = envelope.Type, session.Principal.PeerRole); + /// 解析命令/拉取请求的目标班级:显式指定时校验可访问性,否则落到默认班级。 + private static async Task ResolveClassAsync(Guid? requested, ConnectionSession session) + { + var user = session.Principal.User!; + if (requested is { } classId) + return await session.ClassAccess.CanAccessAsync(user.Id, user.Role, classId, session.CancellationToken) + ? classId + : null; + return await session.ClassAccess.ResolveDefaultClassIdAsync(user.Id, user.Role, session.CancellationToken); + } + private static async Task ForwardUserCommandAsync( Envelope envelope, ConnectionSession session, @@ -414,13 +460,29 @@ await SendFailureAsync( return; } + // 命令按班级路由与鉴权:用户只能在有权限的班级上操作该班的插件。 + var classId = await ResolveClassAsync(command.ClassId, session); + if (classId is null) + { + await SendFailureAsync( + envelope, session.ConnectionId, session.Registry, + CommandResultCodes.Forbidden, "没有该班级的操作权限", session.CancellationToken); + return; + } + command.ClassId = classId; + if (command.Notification is not null) { command.Notification.ForceSenderInTitle = await session.Identities.GetForceSenderInTitleAsync(session.CancellationToken); } - if (GetCommandValidationError(command, session.Principal.User!, session.Store) is { } error) + var classPermissions = await session.ClassAccess.GetEffectivePermissionsAsync( + session.Principal.User!.Id, session.Principal.User!.Role, classId.Value, + session.Principal.User!.GrantedPermissions, session.CancellationToken); + if (GetCommandValidationError( + command, classPermissions, session.Principal.User!.AllowedExtensionIds, + session.Store, classId.Value) is { } error) { await SendFailureAsync( envelope, session.ConnectionId, session.Registry, @@ -428,32 +490,34 @@ await SendFailureAsync( return; } - if (session.Registry.HasPlugin && + if (session.Registry.HasPluginFor(classId.Value) && RemoteCiCapabilities.Required(command.Command) is { } capability && - !session.Registry.PrimaryPluginSupports(capability)) + !session.Registry.PrimaryPluginSupports(classId.Value, capability)) { await SendFailureAsync( envelope, session.ConnectionId, session.Registry, CommandResultCodes.CapabilityUnsupported, - $"当前主插件未声明能力 {capability}", session.CancellationToken); + $"当前班级的插件未声明能力 {capability}", session.CancellationToken); return; } - await ForwardValidatedCommandAsync(envelope, command, session); + await ForwardValidatedCommandAsync(envelope, command, session, classId.Value); } private static CommandError? GetCommandValidationError( CommandMessage command, - UserProfile user, - IStateStore store) + UserPermissions classPermissions, + IReadOnlyList? allowedExtensionIds, + IStateStore store, + Guid classId) { if (command.Command == CommandKind.RunExtension) - return GetExtensionValidationError(command, user, store); + return GetExtensionValidationError(command, classPermissions, allowedExtensionIds, store, classId); var required = CommandPermissions.Required(command.Command); if (required == UserPermissions.None) return new CommandError(CommandResultCodes.InvalidRequest, "未知命令"); - if (!user.Permissions.HasFlag(required)) + if (!classPermissions.HasFlag(required)) return new CommandError(CommandResultCodes.Forbidden, "权限不足"); if (command.Command == CommandKind.SendVoiceMessage && !VoiceMessageRequest.TryDecode(command.VoiceMessage, out _)) return new CommandError(CommandResultCodes.InvalidRequest, "语音格式无效或超过 60 秒"); @@ -462,17 +526,19 @@ await SendFailureAsync( private static CommandError? GetExtensionValidationError( CommandMessage command, - UserProfile user, - IStateStore store) + UserPermissions classPermissions, + IReadOnlyList? allowedExtensionIds, + IStateStore store, + Guid classId) { if (string.IsNullOrWhiteSpace(command.ExtensionId)) return new CommandError(CommandResultCodes.InvalidRequest, "缺少扩展 Id"); - var definition = store.GetLatestExtensions()?.FirstOrDefault( + var definition = store.GetLatestExtensions(classId)?.FirstOrDefault( extension => extension.Id == command.ExtensionId); if (definition is null) return new CommandError(CommandResultCodes.InvalidRequest, "扩展功能不存在或尚未同步"); - return ExtensionAccess.CanInvoke(user, definition) + return ExtensionAccess.CanInvoke(classPermissions, allowedExtensionIds, definition) ? null : new CommandError(CommandResultCodes.Forbidden, "权限不足或扩展未开放"); } @@ -480,12 +546,13 @@ await SendFailureAsync( private static async Task ForwardValidatedCommandAsync( Envelope envelope, CommandMessage command, - ConnectionSession session) + ConnectionSession session, + Guid classId) { command.RequestedBy = session.Principal.User; envelope.Payload = command; session.Registry.RegisterWatchCommand(envelope.MessageId, session.ConnectionId); - if (await session.Registry.SendToPluginAsync(envelope, session.CancellationToken)) return; + if (await session.Registry.SendToPluginAsync(classId, envelope, session.CancellationToken)) return; await session.Registry.CompleteCommandAsync( new Envelope @@ -513,6 +580,7 @@ private sealed class ConnectionSession( ExtensionPolicyService extensionPolicies, AuthorizationSyncService authorizationSync, ScheduleSyncService scheduleSync, + ClassAccessService classAccess, ILogger logger, WebSocket socket, Guid connectionId, @@ -525,6 +593,7 @@ private sealed class ConnectionSession( public ExtensionPolicyService ExtensionPolicies { get; } = extensionPolicies; public AuthorizationSyncService AuthorizationSync { get; } = authorizationSync; public ScheduleSyncService ScheduleSync { get; } = scheduleSync; + public ClassAccessService ClassAccess { get; } = classAccess; public ILogger Logger { get; } = logger; public WebSocket Socket { get; } = socket; public Guid ConnectionId { get; } = connectionId; diff --git a/server/RemoteCI.Server/patch_views1.py b/server/RemoteCI.Server/patch_views1.py new file mode 100644 index 0000000..0122571 --- /dev/null +++ b/server/RemoteCI.Server/patch_views1.py @@ -0,0 +1,132 @@ +s = open('Pages/BatchControl.cshtml', encoding='utf-8').read() + + +def rep(old, new): + global s + assert old in s, 'NOT FOUND: ' + old[:70] + s = s.replace(old, new, 1) + + +# target list: groups as chips (multi-group), no ambiguous section headers +rep('''

逐班勾选

+
+ @{ + var lastGroup = "\\u0000"; + foreach (var classroom in Model.Classes) + { + if (!string.Equals(lastGroup, classroom.GroupName ?? string.Empty, StringComparison.Ordinal)) + { + lastGroup = classroom.GroupName ?? string.Empty; +

@(lastGroup.Length == 0 ? "未分组" : lastGroup)

+ } + + } + } +
''', + '''

逐班勾选

+
+ @foreach (var classroom in Model.Classes) + { + + } +
''') + +with open('Pages/BatchControl.cshtml', 'w', encoding='utf-8', newline='') as fh: + fh.write(s) +print('BatchControl ok') + +# ClassSelect: group chips + avatar +s = open('Pages/ClassSelect.cshtml', encoding='utf-8').read() +rep(''' ''', + ''' ''') +with open('Pages/ClassSelect.cshtml', 'w', encoding='utf-8', newline='') as fh: + fh.write(s) +print('ClassSelect ok') + +# Control: broadcast targets chips + class settings panel +s = open('Pages/Control.cshtml', encoding='utf-8').read() +rep('''
+ @{ + var lastGroup = "\\u0000"; + foreach (var target in Model.BroadcastTargets) + { + if (!string.Equals(lastGroup, target.GroupName ?? string.Empty, StringComparison.Ordinal)) + { + lastGroup = target.GroupName ?? string.Empty; +

@(lastGroup.Length == 0 ? "未分组" : lastGroup)

+ } + + } + } +
''', + '''
+ @foreach (var target in Model.BroadcastTargets) + { + + } +
''') +with open('Pages/Control.cshtml', 'w', encoding='utf-8', newline='') as fh: + fh.write(s) +print('Control targets ok') + +# Control.cshtml.cs: drop GroupName ordering +s = open('Pages/Control.cshtml.cs', encoding='utf-8').read() +rep(''' BroadcastTargets = AccessibleClasses + .Where(x => x.Id != CurrentClassId && x.Permissions?.HasFlag(UserPermissions.SendNotifications) == true) + .OrderBy(x => x.GroupName).ThenBy(x => x.Name) + .ToList();''', + ''' BroadcastTargets = AccessibleClasses + .Where(x => x.Id != CurrentClassId && x.Permissions?.HasFlag(UserPermissions.SendNotifications) == true) + .OrderBy(x => x.Name) + .ToList();''') +with open('Pages/Control.cshtml.cs', 'w', encoding='utf-8', newline='') as fh: + fh.write(s) +print('Control model ok') diff --git a/server/RemoteCI.Server/wwwroot/app.css b/server/RemoteCI.Server/wwwroot/app.css index 0d0aaba..943d16a 100644 --- a/server/RemoteCI.Server/wwwroot/app.css +++ b/server/RemoteCI.Server/wwwroot/app.css @@ -435,3 +435,112 @@ textarea { resize: vertical; } .account-row-actions form { display: inline-flex; margin: 0; } .account-row-actions .user-delete-button, .account-row-actions .user-edit-button { min-height: 36px; padding: 6px 14px; } @media (max-width: 700px) { .account-row-actions { width: 100%; } .account-row-actions form, .account-row-actions button { flex: 1; } } +.mobile-login-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 1.5rem; + margin-bottom: 1.25rem; + padding: 1.25rem; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); +} + +.mobile-login-copy h2 { margin: 0 0 .45rem; } +.mobile-login-copy p { margin: 0; } +.mobile-login-url { display: flex; align-items: center; gap: .6rem; margin-top: 1rem; } +.mobile-login-url code { overflow-wrap: anywhere; } +.mobile-login-qr { width: 10rem; height: 10rem; padding: .55rem; border-radius: 1rem; background: #fff; } +.mobile-login-qr svg { display: block; width: 100%; height: 100%; } + +@media (max-width: 680px) { + .mobile-login-card { grid-template-columns: 1fr; } + .mobile-login-qr { justify-self: center; } +} + + +/* ---------- 多班级:切换器 / 班级芯片 / 分组 / 成员 / 广播 ---------- */ +.class-switcher { display: flex; min-height: 42px; max-width: 220px; flex: 0 1 auto; align-items: center; gap: 8px; padding: 0 10px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); } +.class-switcher i { color: var(--muted); font-size: 13px; } +.class-switcher select { min-height: 34px; width: auto; min-width: 120px; max-width: 150px; padding: 0 6px; border: 0; background: transparent; color: var(--text); font-weight: 700; text-overflow: ellipsis; } +.class-switcher select:focus { outline: none; } +.class-switcher.current { color: var(--text); font-weight: 700; } +.class-switcher.current span { font-size: 13px; } + +.class-switcher-nav { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; } +.class-chip { display: inline-flex; min-height: 38px; align-items: center; padding: 0 15px; border: 1px solid var(--line); border-radius: 19px; background: var(--surface); color: var(--text); font-weight: 700; transition: background .18s ease, border-color .18s ease, color .18s ease; } +.class-chip:hover { border-color: var(--accent); color: var(--accent); } +.class-chip.active { border-color: var(--accent); background: var(--accent); color: #fff; } + +.group-name { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 12px; background: var(--accent-faint); color: var(--accent-strong); font-size: 12px; font-weight: 750; } +.group-name i { font-size: 12px; } +.member-row td { padding: 6px 16px 10px; background: var(--surface-soft); } +.member-list { margin: 0 0 10px; padding: 0; list-style: none; } +.member-list li { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 6px 0; border-bottom: 1px dashed var(--line); } +.member-list li:last-child { border-bottom: 0; } +.inline-form { display: flex; align-items: center; gap: 8px; } +.inline-form input[type="text"], .inline-form select { min-height: 36px; width: auto; flex: 0 1 auto; } +.inline-form button { min-height: 36px; padding: 0 12px; font-size: 13px; } +.batch-summary { margin: 0 0 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-soft); color: var(--muted); font-size: 13px; } +.broadcast-targets { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 8px; margin: 12px 0; } +.broadcast-targets .check { flex-wrap: nowrap; align-items: center; gap: 8px; padding: 9px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); white-space: nowrap; } +.broadcast-targets .check > * { white-space: nowrap; } +.broadcast-targets .check:has(input:checked) { border-color: var(--accent); background: var(--accent-faint); } +.broadcast-targets .check .group-name { padding: 2px 8px; font-size: 11px; } +.broadcast-targets .check .muted { font-size: 12px; } +.broadcast-group-label { grid-column: 1 / -1; margin: 6px 0 0; color: var(--muted); font-size: 12px; font-weight: 750; letter-spacing: .06em; } +.broadcast-result { margin: 10px 0 0; padding: 0; list-style: none; font-size: 13px; } +.broadcast-result li { display: flex; gap: 8px; padding: 4px 0; } +.broadcast-result li i { flex: 0 0 16px; } +.broadcast-result .ok { color: var(--success); } +.broadcast-result .bad { color: var(--danger); } + +/* ---------- 紧凑表单 / 复选框可见 / 分组层级 / 班头像 ---------- */ +.stack > button, .stack > a.button { justify-self: start; } +.stack > .inline-form { justify-self: start; } +.user-account-table input[type="checkbox"], +.broadcast-targets input[type="checkbox"], +input[type="checkbox"][form] { width: 18px; height: 18px; min-height: 0; padding: 0; accent-color: var(--accent); cursor: pointer; } + +.group-list { display: grid; gap: 10px; margin-top: 14px; } +.group-item { border: 1px solid var(--line); border-radius: 10px; background: var(--surface); } +.group-item > summary { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 12px 14px; cursor: pointer; list-style: none; } +.group-item > summary::-webkit-details-marker { display: none; } +.group-item > summary::before { content: "▸ "; color: var(--muted); } +.group-item[open] > summary::before { content: "▾ "; } +.group-editor { display: grid; gap: 12px; padding: 12px 14px; border-top: 1px solid var(--line); } +.group-editor .inline-form { flex-wrap: wrap; } +.group-editor .stack { gap: 10px; } +.group-editor select[multiple] { min-height: 0; } + +.class-avatar { width: 64px; height: 64px; border-radius: 12px; object-fit: cover; border: 1px solid var(--line); } +.class-avatar.small { width: 22px; height: 22px; border-radius: 50%; border: 0; vertical-align: -6px; margin-right: 4px; } +.class-chip { display: inline-flex; align-items: center; } +.class-info-body { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-start; } +.class-info-body form.stack { min-width: min(320px, 100%); } + +/* ---------- 导航分区与登录后选班 ---------- */ +.nav-divider { margin: 6px 18px; border: 0; border-top: 1px solid var(--line); } +.class-select-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; } +.class-select-card button { display: grid; width: 100%; gap: 6px; align-items: start; justify-items: start; padding: 18px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); color: var(--text); text-align: left; cursor: pointer; transition: border-color .18s ease, background .18s ease, transform .18s ease; } +.class-select-card button:hover { border-color: var(--accent); background: var(--accent-faint); transform: translateY(-2px); } +.class-select-card strong { font-size: 17px; } +.class-select-card small { color: var(--muted); } + +/* ---------- 紧凑创建行 ---------- */ +.compact-row { display: grid; grid-template-columns: minmax(200px, 1fr) auto; gap: 10px; align-items: end; } +.compact-row .stack { gap: 4px; } + +/* ---------- 重叠修复:顶栏窄屏 / 表格内嵌表单 ---------- */ +.topbar { flex-wrap: nowrap; overflow-x: auto; } +.topbar .app-search { flex: 1 1 auto; min-width: 0; } +.user-account-table td form.inline-form { min-width: 0; } +.user-account-table td form.inline-form input[type="text"] { width: 130px; } +.user-account-table td form.inline-form select { max-width: 160px; } +@media (max-width: 680px) { + .class-switcher { max-width: 150px; } + .class-switcher select { max-width: 100px; } + .class-info-body { flex-direction: column; } + .compact-row { grid-template-columns: 1fr; } +} diff --git a/server/RemoteCI.Server/wwwroot/app.js b/server/RemoteCI.Server/wwwroot/app.js index ba484cb..82989f1 100644 --- a/server/RemoteCI.Server/wwwroot/app.js +++ b/server/RemoteCI.Server/wwwroot/app.js @@ -317,15 +317,3 @@ document.querySelectorAll("[data-backup-settings-form]").forEach(form => { cadence.addEventListener("change", syncBackupFields); syncBackupFields(); }); - -document.querySelectorAll("[data-visitor-access-form]").forEach(form => { - const enabled = form.querySelector("[data-visitor-enabled]"); - const autoEnter = form.querySelector("[data-visitor-auto-enter]"); - if (!enabled || !autoEnter) return; - const syncVisitorAccess = () => { - autoEnter.disabled = !enabled.checked; - if (!enabled.checked) autoEnter.checked = false; - }; - enabled.addEventListener("change", syncVisitorAccess); - syncVisitorAccess(); -}); diff --git a/server/RemoteCI.Server/wwwroot/voice-message.js b/server/RemoteCI.Server/wwwroot/voice-message.js index e3cc8e1..bd60781 100644 --- a/server/RemoteCI.Server/wwwroot/voice-message.js +++ b/server/RemoteCI.Server/wwwroot/voice-message.js @@ -99,7 +99,12 @@ send.disabled = record.disabled = discard.disabled = true; status.textContent = "正在发送,等待 ClassIsland 回执…"; try { - const response = await fetch(form.action, { + // 批量页复用本脚本:把页面上勾选的广播目标(data-voice-query)附加到上传地址。 + const action = new URL(form.action, window.location.origin); + document.querySelectorAll("input[data-voice-query]:checked").forEach(input => { + action.searchParams.append(input.name, input.value); + }); + const response = await fetch(action, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/octet-stream", "X-CSRF-TOKEN": form.querySelector('[name="__RequestVerificationToken"]').value }, body: pcm, diff --git a/server/tests/RemoteCI.Server.Tests/ApiTests.cs b/server/tests/RemoteCI.Server.Tests/ApiTests.cs index 4689c2b..6dc3709 100644 --- a/server/tests/RemoteCI.Server.Tests/ApiTests.cs +++ b/server/tests/RemoteCI.Server.Tests/ApiTests.cs @@ -383,9 +383,8 @@ public async Task RazorWebUi_AdminCanOpenEveryConsolePage() var usersHtml = WebUtility.HtmlDecode(await browser.GetStringAsync("/Users")); Assert.Contains("角色配置", usersHtml); Assert.Contains("创建角色", usersHtml); - Assert.Contains("启用访客功能", usersHtml); Assert.Contains("自动进入访客页", usersHtml); - Assert.Contains("data-visitor-access-form", usersHtml); + Assert.Contains("班级管理", usersHtml); Assert.Contains("""class="user-account-table role-summary-table""", usersHtml); Assert.Contains("""(); - store.SaveSnapshot(new ClassStateSnapshot + store.SaveSnapshot(Classroom.DefaultId, new ClassStateSnapshot { IsNotificationPlaying = true, IsMainMenuVisible = false, @@ -421,7 +420,7 @@ public async Task RazorWebUi_ControlPageContainsActionsNotificationAndLiveVolume VolumePercent = 42, IsMuted = false, }); - store.SaveExtensions(new[] + store.SaveExtensions(Classroom.DefaultId, new[] { new ExtensionDefinition { @@ -505,7 +504,7 @@ public async Task RazorWebUi_SchedulePageExposesManualPullAndIntervalOptions() var subjectId = Guid.NewGuid(); using (var setupScope = _factory.Services.CreateScope()) { - setupScope.ServiceProvider.GetRequiredService().SaveSchedule(new ScheduleBundle + setupScope.ServiceProvider.GetRequiredService().SaveSchedule(Classroom.DefaultId, new ScheduleBundle { FromDate = "2026-08-17", Days = @@ -664,7 +663,7 @@ public async Task RazorWebUi_ShowsGlobalScheduleTaskAndDisablesDuplicatePull() { using var scope = _factory.Services.CreateScope(); var tracker = scope.ServiceProvider.GetRequiredService(); - var running = tracker.TryBegin(ScheduleSyncRequest.Create(ScheduleSyncSource.Automatic)); + var running = tracker.TryBegin(ScheduleSyncRequest.Create(ScheduleSyncSource.Automatic), Classroom.DefaultId); try { using var browser = CreateBrowserClient(); @@ -958,6 +957,9 @@ public async Task RazorWebUi_PluginActionsLiveOnOverviewAndRetryReportsResult() var overviewHtml = await browser.GetStringAsync("/"); Assert.Contains("生成配对码", overviewHtml); Assert.Contains("重新检测连接", overviewHtml); + Assert.Contains("data-mobile-login-qr", overviewHtml); + Assert.Contains("手机扫码填写服务器地址", WebUtility.HtmlDecode(overviewHtml)); + Assert.Contains(WebUtility.HtmlEncode(_factory.Server.BaseAddress.ToString().TrimEnd('/')), overviewHtml); Assert.DoesNotContain("去重试连接", overviewHtml); var retry = await PostRazorFormAsync(browser, "/?handler=RetryConnection", overviewHtml); @@ -1086,7 +1088,7 @@ public async Task RazorWebUi_NonAdminCanEditOnlyOwnExtensionWatchVisibility() { var identities = scope.ServiceProvider.GetRequiredService(); var policies = scope.ServiceProvider.GetRequiredService(); - scope.ServiceProvider.GetRequiredService().SaveExtensions([definition]); + scope.ServiceProvider.GetRequiredService().SaveExtensions(Classroom.DefaultId, [definition]); await policies.EnsureRegisteredAsync([definition]); var admin = (await identities.ListUsersAsync()).Single(x => x.Role == UserRole.Admin); await policies.UpdateAdminAsync(admin.Id, definition.Id, enabled: true, allowNonAdmin: true, showOnWatch: true); @@ -1619,7 +1621,7 @@ private static async Task LoginWebUiAsync(HttpClient browser, string username, s ["Input.Password"] = password, ["__RequestVerificationToken"] = WebUtility.HtmlDecode(match.Groups[1].Value), })); - Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode); } private static async Task PostRazorFormAsync( @@ -1696,6 +1698,26 @@ private static async Task ReceivePayloadAsync(WebSocket socket, string exp } } + [Fact] + public async Task MobileAdminApis_RolesAndVisitorRoundTrip() + { + var admin = await LoginViaAsync(_client, TestWebApplicationFactory.AdminUsername, TestWebApplicationFactory.AdminPassword); + var roles = await _client.SendAsync(TestWebApplicationFactory.Bearer(HttpMethod.Get, "/api/roles", admin.AccessToken)); + roles.EnsureSuccessStatusCode(); + var visitor = await _client.SendAsync(TestWebApplicationFactory.Bearer(HttpMethod.Get, "/api/visitor", admin.AccessToken)); + visitor.EnsureSuccessStatusCode(); + var updated = await _client.SendAsync(TestWebApplicationFactory.Bearer( + HttpMethod.Put, "/api/visitor", admin.AccessToken, + new VisitorAutoEnterBody(true))); + updated.EnsureSuccessStatusCode(); + var state = await updated.Content.ReadFromJsonAsync>(); + Assert.NotNull(state); + var reset = await _client.SendAsync(TestWebApplicationFactory.Bearer( + HttpMethod.Put, "/api/visitor", admin.AccessToken, + new VisitorAutoEnterBody(false))); + reset.EnsureSuccessStatusCode(); + } + private static async Task LoginViaAsync(HttpClient client, string username, string password) { var response = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest diff --git a/server/tests/RemoteCI.Server.Tests/ClassManagementTests.cs b/server/tests/RemoteCI.Server.Tests/ClassManagementTests.cs new file mode 100644 index 0000000..45efc4b --- /dev/null +++ b/server/tests/RemoteCI.Server.Tests/ClassManagementTests.cs @@ -0,0 +1,761 @@ +using System.Net; +using System.Net.Http.Json; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using RemoteCI.Server.Data; +using RemoteCI.Server.Pages; +using RemoteCI.Server.Services; +using RemoteCI.Shared; +using RemoteCI.Shared.Models; +using Xunit; + +namespace RemoteCI.Server.Tests; + +/// 多班级管理:班级 CRUD、批量操作、成员与按班级的权限隔离。 +public sealed class ClassManagementTests : IClassFixture +{ + private readonly TestWebApplicationFactory _factory; + + public ClassManagementTests(TestWebApplicationFactory factory) => _factory = factory; + + // ---------- StateStore 按班级分桶(纯内存单元测试) ---------- + + [Fact] + public void StateStore_BucketsAreIsolatedPerClass() + { + IStateStore store = new StateStore(); + var classA = Classroom.DefaultId; + var classB = Guid.NewGuid(); + var snapshotA = new ClassStateSnapshot { CurrentSubject = "A班数学" }; + var snapshotB = new ClassStateSnapshot { CurrentSubject = "B班语文" }; + store.SaveSnapshot(classA, snapshotA); + store.SaveSnapshot(classB, snapshotB); + + Assert.Equal("A班数学", store.GetLatestSnapshot(classA)!.CurrentSubject); + Assert.Equal("B班语文", store.GetLatestSnapshot(classB)!.CurrentSubject); + + store.SaveSchedule(classB, new ScheduleBundle { FromDate = "2026-09-26" }); + Assert.Null(store.GetLatestSchedule(classA)); + Assert.NotNull(store.GetLatestSchedule(classB)); + + store.SaveEvent(classB, new ClassEvent { Event = ClassEventKind.OnClass }); + Assert.Null(store.GetLatestEvent(classA)); + store.SaveExtensions(classA, [new ExtensionDefinition { Id = "ext-a" }]); + Assert.Null(store.GetLatestExtensions(classB)); + Assert.Single(store.GetLatestExtensions(classA)!); + } + + // ---------- 班级 CRUD 与权限 ---------- + + [Fact] + public async Task ClassCrud_OnlyAdminCanManageAndDefaultClassIsProtected() + { + var admin = await _factory.LoginAsync(); + using var client = _factory.CreateClient(); + + // 普通用户不能管理班级。 + await CreateUserAsync("plain.user", "Plain-User-Password-2026"); + var user = await _factory.LoginAsync("plain.user", "Plain-User-Password-2026"); + Assert.Equal(HttpStatusCode.Forbidden, + (await client.SendAsync(Bearer(HttpMethod.Get, "/api/classes", user.AccessToken))).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, + (await client.SendAsync(Bearer(HttpMethod.Post, "/api/classes", user.AccessToken, + new CreateClassRequest { Name = "越权班级" }))).StatusCode); + + // 管理员创建、重命名、重复名拒绝。 + var created = await client.SendAsync(Bearer(HttpMethod.Post, "/api/classes", admin.AccessToken, + new CreateClassRequest { Name = "高二(1)班" })); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + var detail = (await created.Content.ReadFromJsonAsync())!; + Assert.Equal("高二(1)班", detail.Name); + Assert.Equal(0, detail.MemberCount); + + var duplicate = await client.SendAsync(Bearer(HttpMethod.Post, "/api/classes", admin.AccessToken, + new CreateClassRequest { Name = "高二(1)班" })); + Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode); + + var renamed = await client.SendAsync(Bearer(HttpMethod.Put, $"/api/classes/{detail.Id}", admin.AccessToken, + new UpdateClassRequest { Name = "高二(2)班" })); + Assert.Equal(HttpStatusCode.NoContent, renamed.StatusCode); + + // 空名拒绝;默认班级不可删除。 + Assert.Equal(HttpStatusCode.BadRequest, + (await client.SendAsync(Bearer(HttpMethod.Post, "/api/classes", admin.AccessToken, + new CreateClassRequest { Name = " " }))).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, + (await client.SendAsync(Bearer(HttpMethod.Delete, $"/api/classes/{Classroom.DefaultId}", admin.AccessToken))).StatusCode); + + // 每班访客开关。 + var toggle = await client.SendAsync(Bearer(HttpMethod.Put, $"/api/classes/{detail.Id}/visitor", admin.AccessToken, + new ClassVisitorRequest { Enabled = true })); + Assert.Equal(HttpStatusCode.NoContent, toggle.StatusCode); + var list = (await client.SendAsync(Bearer(HttpMethod.Get, "/api/classes", admin.AccessToken))).Content; + var classes = (await list.ReadFromJsonAsync>())!; + Assert.Contains(classes, x => x.Id == detail.Id && x.VisitorEnabled); + + var deleted = await client.SendAsync(Bearer(HttpMethod.Delete, $"/api/classes/{detail.Id}", admin.AccessToken)); + Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode); + } + + // ---------- 批量操作 ---------- + + [Fact] + public async Task BatchOperations_ToggleVisitorAndDeleteReportPerClassResults() + { + var admin = await _factory.LoginAsync(); + using var client = _factory.CreateClient(); + var classA = (await CreateClassAsync(admin.AccessToken, "批量一班"))!; + var classB = (await CreateClassAsync(admin.AccessToken, "批量二班"))!; + + var enable = await client.SendAsync(Bearer(HttpMethod.Post, "/api/classes/batch", admin.AccessToken, + new BatchClassOperationRequest + { + ClassIds = [classA.Id, classB.Id, Classroom.DefaultId], + Operation = "enableVisitor", + })); + enable.EnsureSuccessStatusCode(); + var enableResult = (await enable.Content.ReadFromJsonAsync())!; + Assert.All(enableResult.Results, x => Assert.True(x.Success)); + var listed = (await (await client.SendAsync(Bearer(HttpMethod.Get, "/api/classes", admin.AccessToken))) + .Content.ReadFromJsonAsync>())!; + // 只断言本测试创建的班级与默认班级;类夹具中其他测试创建的班级不参与断言。 + Assert.All(new[] { classA.Id, classB.Id, Classroom.DefaultId }, + id => Assert.True(listed.Single(x => x.Id == id).VisitorEnabled)); + + var disable = await client.SendAsync(Bearer(HttpMethod.Post, "/api/classes/batch", admin.AccessToken, + new BatchClassOperationRequest + { + ClassIds = [classA.Id, classB.Id], + Operation = "disableVisitor", + })); + disable.EnsureSuccessStatusCode(); + var afterDisable = (await (await client.SendAsync(Bearer(HttpMethod.Get, "/api/classes", admin.AccessToken))) + .Content.ReadFromJsonAsync>())!; + Assert.All(new[] { classA.Id, classB.Id }, + id => Assert.False(afterDisable.Single(x => x.Id == id).VisitorEnabled)); + + // 默认班级删除失败但不影响其他班级;未知操作被拒绝。 + var batchDelete = await client.SendAsync(Bearer(HttpMethod.Post, "/api/classes/batch", admin.AccessToken, + new BatchClassOperationRequest + { + ClassIds = [classA.Id, Classroom.DefaultId], + Operation = "delete", + })); + batchDelete.EnsureSuccessStatusCode(); + var deleteResult = (await batchDelete.Content.ReadFromJsonAsync())!; + Assert.Equal(2, deleteResult.Results.Count); + Assert.Contains(deleteResult.Results, x => x.ClassId == classA.Id && x.Success); + Assert.Contains(deleteResult.Results, x => x.ClassId == Classroom.DefaultId && !x.Success); + var afterDelete = (await (await client.SendAsync(Bearer(HttpMethod.Get, "/api/classes", admin.AccessToken))) + .Content.ReadFromJsonAsync>())!; + Assert.DoesNotContain(afterDelete, x => x.Id == classA.Id); + Assert.Contains(afterDelete, x => x.Id == Classroom.DefaultId); + } + + // ---------- 成员管理与按班级权限隔离 ---------- + + [Fact] + public async Task Members_ClassScopedAccess_IsEnforcedPerClass() + { + var admin = await _factory.LoginAsync(); + using var client = _factory.CreateClient(); + var classB = (await CreateClassAsync(admin.AccessToken, "隔离一班"))!; + + var userId = await CreateUserAsync("iso.student", "Iso-Student-Password-2026"); + + // 新用户自动进入默认班级;把 TA 加进 B 班。 + var update = await client.SendAsync(Bearer(HttpMethod.Put, $"/api/classes/{classB.Id}/members", admin.AccessToken, + new UpdateClassMembersRequest { Members = [new ClassMemberInput { UserId = userId, RoleId = AccountRole.StudentId }] })); + Assert.Equal(HttpStatusCode.NoContent, update.StatusCode); + var members = (await client.SendAsync(Bearer(HttpMethod.Get, $"/api/classes/{classB.Id}/members", admin.AccessToken))).Content; + var memberList = (await members.ReadFromJsonAsync>())!; + Assert.Single(memberList, x => x.UserId == userId); + + var student = await _factory.LoginAsync("iso.student", "Iso-Student-Password-2026"); + var myClasses = (await client.SendAsync(Bearer(HttpMethod.Get, "/api/me/classes", student.AccessToken))).Content; + var summaries = (await myClasses.ReadFromJsonAsync>())!; + Assert.Equal(2, summaries.Count); + Assert.Contains(summaries, x => x.Id == Classroom.DefaultId); + Assert.Contains(summaries, x => x.Id == classB.Id && x.RoleName == "学生"); + + // 是 B 班成员:可以访问(无数据 404);不是 C 班成员:403。 + var classC = (await CreateClassAsync(admin.AccessToken, "隔离二班"))!; + Assert.Equal(HttpStatusCode.NotFound, + (await client.SendAsync(Bearer(HttpMethod.Get, $"/api/state?classId={classB.Id}", student.AccessToken))).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, + (await client.SendAsync(Bearer(HttpMethod.Get, $"/api/state?classId={classC.Id}", student.AccessToken))).StatusCode); + + // 成员关系移除后,B 班也变为 403。 + var clear = await client.SendAsync(Bearer(HttpMethod.Put, $"/api/classes/{classB.Id}/members", admin.AccessToken, + new UpdateClassMembersRequest { Members = [] })); + Assert.Equal(HttpStatusCode.NoContent, clear.StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, + (await client.SendAsync(Bearer(HttpMethod.Get, $"/api/state?classId={classB.Id}", student.AccessToken))).StatusCode); + } + + [Fact] + public async Task ClassAdmin_CanCommandOwnClassButNotOthers() + { + var admin = await _factory.LoginAsync(); + using var client = _factory.CreateClient(); + var classB = (await CreateClassAsync(admin.AccessToken, "班管一班"))!; + var userId = await CreateUserAsync("cls.admin", "Cls-Admin-Password-2026", AccountRole.ClassAdministratorId); + + // 班管理员默认就是默认班级的成员;同时成为 B 班班管理员。 + var put = await client.SendAsync(Bearer(HttpMethod.Put, $"/api/classes/{classB.Id}/members", admin.AccessToken, + new UpdateClassMembersRequest { Members = [new ClassMemberInput { UserId = userId, RoleId = AccountRole.ClassAdministratorId }] })); + Assert.Equal(HttpStatusCode.NoContent, put.StatusCode); + + var classAdmin = await _factory.LoginAsync("cls.admin", "Cls-Admin-Password-2026"); + var myClasses = (await client.SendAsync(Bearer(HttpMethod.Get, "/api/me/classes", classAdmin.AccessToken))).Content; + var summaries = (await myClasses.ReadFromJsonAsync>())!; + Assert.Contains(summaries, x => x.Id == classB.Id && x.Permissions!.Value.HasFlag(UserPermissions.SendNotifications)); + + // 未加入的班级:命令在权限检查阶段就被拒绝(403),不进入路由。 + var forbidden = await client.SendAsync(Bearer( + HttpMethod.Post, $"/api/commands?classId={(await CreateClassAsync(admin.AccessToken, "班管二班"))!.Id}", classAdmin.AccessToken, + new CommandMessage { Command = CommandKind.SendNotification, Notification = new NotificationRequest() })); + Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode); + } + + // ---------- WebSocket 按班级扇出 ---------- + + [Fact] + public async Task WebSocket_PluginStateForClassDoesNotReachNonMembers() + { + var admin = await _factory.LoginAsync(); + using var client = _factory.CreateClient(); + var userId = await CreateUserAsync("b.only", "B-Only-Password-2026"); + var classB = (await CreateClassAsync(admin.AccessToken, "推送一班"))!; + + // 先把 B 班成员设为该用户,再把默认班级成员清空,使该用户只属于 B 班。 + var putB = await client.SendAsync(Bearer(HttpMethod.Put, $"/api/classes/{classB.Id}/members", admin.AccessToken, + new UpdateClassMembersRequest { Members = [new ClassMemberInput { UserId = userId, RoleId = AccountRole.StudentId }] })); + Assert.Equal(HttpStatusCode.NoContent, putB.StatusCode); + var clearDefault = await client.SendAsync(Bearer(HttpMethod.Put, $"/api/classes/{Classroom.DefaultId}/members", admin.AccessToken, + new UpdateClassMembersRequest { Members = [] })); + Assert.Equal(HttpStatusCode.NoContent, clearDefault.StatusCode); + + // 默认班级插件推送状态;非成员的手表不应收到,管理员手表应收到。 + using var plugin = await ConnectPluginAsync(); + await ReceiveEnvelopeAsync(plugin, Protocol.MessageTypeSchedulePull); + using var adminWatch = await ConnectWatchAsync(); + using var memberWatch = await ConnectWatchAsync("b.only", "B-Only-Password-2026"); + + await SendAsync(plugin, Envelope.StatePush(new ClassStateSnapshot { CurrentSubject = "默认班级专属" })); + + var adminPush = await ReceivePayloadAsync(adminWatch, Protocol.MessageTypeStatePush); + Assert.Equal("默认班级专属", adminPush.CurrentSubject); + + // B 班成员连接初始化时没有 B 班数据,之后也不应收到默认班级的推送。 + await Assert.ThrowsAnyAsync( + async () => await ReceiveEnvelopeAsync(memberWatch, Protocol.MessageTypeStatePush)); + } + + // ---------- 班级分组 ---------- + + [Fact] + public async Task Groups_AdminCanManageAndAssignClasses() + { + var admin = await _factory.LoginAsync(); + using var client = _factory.CreateClient(); + + var group = (await (await client.SendAsync(Bearer(HttpMethod.Post, "/api/class-groups", admin.AccessToken, + new CreateClassGroupRequest { Name = "高一年级" }))).Content.ReadFromJsonAsync())!; + var duplicate = await client.SendAsync(Bearer(HttpMethod.Post, "/api/class-groups", admin.AccessToken, + new CreateClassGroupRequest { Name = "高一年级" })); + Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode); + + var classA = (await CreateClassAsync(admin.AccessToken, "分组一班"))!; + var classB = (await CreateClassAsync(admin.AccessToken, "分组二班"))!; + foreach (var classroom in new[] { classA, classB }) + { + var assign = await client.SendAsync(Bearer(HttpMethod.Put, $"/api/classes/{classroom.Id}/groups", admin.AccessToken, + new UpdateClassGroupsRequest { GroupIds = [group.Id] })); + Assert.Equal(HttpStatusCode.NoContent, assign.StatusCode); + } + + var listed = (await (await client.SendAsync(Bearer(HttpMethod.Get, "/api/class-groups", admin.AccessToken))) + .Content.ReadFromJsonAsync>())!; + Assert.Contains(listed, x => x.Id == group.Id && x.ClassCount == 2 && x.Depth == 1); + + var details = (await (await client.SendAsync(Bearer(HttpMethod.Get, "/api/classes", admin.AccessToken))) + .Content.ReadFromJsonAsync>())!; + Assert.All(details.Where(x => x.Id == classA.Id || x.Id == classB.Id), x => Assert.Equal(["高一年级"], x.GroupNames)); + + var rename = await client.SendAsync(Bearer(HttpMethod.Put, $"/api/class-groups/{group.Id}", admin.AccessToken, + new UpdateClassGroupRequest { Name = "高一(2026级)" })); + Assert.Equal(HttpStatusCode.NoContent, rename.StatusCode); + + // 删除分组后班级回到未分组,班级本身保留。 + var deleted = await client.SendAsync(Bearer(HttpMethod.Delete, $"/api/class-groups/{group.Id}", admin.AccessToken)); + Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode); + details = (await (await client.SendAsync(Bearer(HttpMethod.Get, "/api/classes", admin.AccessToken))) + .Content.ReadFromJsonAsync>())!; + Assert.All(details.Where(x => x.Id == classA.Id || x.Id == classB.Id), x => Assert.Empty(x.GroupNames ?? [])); + } + + [Fact] + public async Task BatchOperations_CanTargetGroups() + { + var admin = await _factory.LoginAsync(); + using var client = _factory.CreateClient(); + var group = (await (await client.SendAsync(Bearer(HttpMethod.Post, "/api/class-groups", admin.AccessToken, + new CreateClassGroupRequest { Name = "广播一年级" }))).Content.ReadFromJsonAsync())!; + var classA = (await CreateClassAsync(admin.AccessToken, "组内一班"))!; + var classB = (await CreateClassAsync(admin.AccessToken, "组内二班"))!; + foreach (var classroom in new[] { classA, classB }) + await client.SendAsync(Bearer(HttpMethod.Put, $"/api/classes/{classroom.Id}/groups", admin.AccessToken, + new UpdateClassGroupsRequest { GroupIds = [group.Id] })); + + // 按分组批量开启访客功能。 + var batch = await client.SendAsync(Bearer(HttpMethod.Post, "/api/classes/batch", admin.AccessToken, + new BatchClassOperationRequest { GroupIds = [group.Id], Operation = "enableVisitor" })); + batch.EnsureSuccessStatusCode(); + var result = (await batch.Content.ReadFromJsonAsync())!; + Assert.Equal(2, result.Results.Count(x => x.Success)); + var details = (await (await client.SendAsync(Bearer(HttpMethod.Get, "/api/classes", admin.AccessToken))) + .Content.ReadFromJsonAsync>())!; + Assert.All(details.Where(x => x.Id == classA.Id || x.Id == classB.Id), x => Assert.True(x.VisitorEnabled)); + + // 不存在的分组直接报 404,而不是静默忽略。 + var missing = await client.SendAsync(Bearer(HttpMethod.Post, "/api/classes/batch", admin.AccessToken, + new BatchClassOperationRequest { GroupIds = [Guid.NewGuid()], Operation = "enableVisitor" })); + Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); + } + + // ---------- 批量通知广播 ---------- + + [Fact] + public async Task BroadcastNotification_RoutesPerClassAndReportsResults() + { + using var plugin = await ConnectPluginAsync(); + await ReceiveEnvelopeAsync(plugin, Protocol.MessageTypeSchedulePull); + + var admin = await _factory.LoginAsync(); + using var client = _factory.CreateClient(); + var classB = (await CreateClassAsync(admin.AccessToken, "广播二班"))!; + + // 向“默认班级(插件在线)+ 二班(无插件)”广播:逐班独立回报。 + var broadcastTask = client.SendAsync(Bearer(HttpMethod.Post, "/api/commands/broadcast", admin.AccessToken, + new BroadcastCommandRequest + { + Command = CommandKind.SendNotification, + Notification = new NotificationRequest { Title = "全体注意", Message = "广播测试" }, + ClassIds = [Classroom.DefaultId, classB.Id], + })); + + // 默认班级插件收到命令后立即回执成功。 + var command = await ReceiveEnvelopeAsync(plugin, Protocol.MessageTypeCommand); + await SendAsync(plugin, new Envelope + { + Type = Protocol.MessageTypeCommandResult, + ReplyToMessageId = command.MessageId, + Payload = new CommandResult { Success = true, Code = CommandResultCodes.Ok }, + }); + var response = await broadcastTask; + response.EnsureSuccessStatusCode(); + var result = (await response.Content.ReadFromJsonAsync())!; + + Assert.Equal(2, result.Results.Count); + var defaultResult = result.Results.Single(x => x.ClassId == Classroom.DefaultId); + var classBResult = result.Results.Single(x => x.ClassId == classB.Id); + Assert.True(defaultResult.Success); + Assert.False(classBResult.Success); + Assert.Contains("插件未在线", classBResult.Message); + } + + [Fact] + public async Task BroadcastNotification_ChecksPermissionPerClass() + { + var admin = await _factory.LoginAsync(); + using var client = _factory.CreateClient(); + await CreateUserAsync("bcast.student", "Bcast-Student-Password-2026"); + + // 完全没有通知权限的学生:所有目标班级都因权限被拒,而不是泄漏插件状态。 + var student = await _factory.LoginAsync("bcast.student", "Bcast-Student-Password-2026"); + var denied = await client.SendAsync(Bearer(HttpMethod.Post, "/api/commands/broadcast", student.AccessToken, + new BroadcastCommandRequest + { + Command = CommandKind.SendNotification, + Notification = new NotificationRequest { Title = "越权广播" }, + ClassIds = [Classroom.DefaultId], + })); + denied.EnsureSuccessStatusCode(); + var result = (await denied.Content.ReadFromJsonAsync())!; + Assert.False(result.Results.Single().Success); + Assert.Contains("没有该班级的操作权限", result.Results.Single().Message); + + // 班管理员(班内含通知权限)可以广播自己管理的班级;权限通过后失败原因是插件离线。 + var classB = (await CreateClassAsync(admin.AccessToken, "广播三班"))!; + var adminUser = await CreateUserAsync("bcast.admin", "Bcast-Admin-Password-2026", AccountRole.ClassAdministratorId); + await client.SendAsync(Bearer(HttpMethod.Put, $"/api/classes/{classB.Id}/members", admin.AccessToken, + new UpdateClassMembersRequest { Members = [new ClassMemberInput { UserId = adminUser, RoleId = AccountRole.ClassAdministratorId }] })); + var classAdmin = await _factory.LoginAsync("bcast.admin", "Bcast-Admin-Password-2026"); + var allowed = await client.SendAsync(Bearer(HttpMethod.Post, "/api/commands/broadcast", classAdmin.AccessToken, + new BroadcastCommandRequest + { + Command = CommandKind.SendNotification, + Notification = new NotificationRequest { Title = "班内广播" }, + ClassIds = [classB.Id], + })); + allowed.EnsureSuccessStatusCode(); + var allowedResult = (await allowed.Content.ReadFromJsonAsync())!; + Assert.False(allowedResult.Results.Single().Success); + Assert.DoesNotContain("没有该班级的操作权限", allowedResult.Results.Single().Message); + Assert.Contains("插件未在线", allowedResult.Results.Single().Message); + } + + [Fact] + public async Task Groups_SupportHierarchyAndMultiMembership() + { + var admin = await _factory.LoginAsync(); + using var client = _factory.CreateClient(); + + var root = (await (await client.SendAsync(Bearer(HttpMethod.Post, "/api/class-groups", admin.AccessToken, + new CreateClassGroupRequest { Name = "某中学" }))).Content.ReadFromJsonAsync())!; + var child = (await (await client.SendAsync(Bearer(HttpMethod.Post, "/api/class-groups", admin.AccessToken, + new CreateClassGroupRequest { Name = "高一年级", ParentId = root.Id }))).Content.ReadFromJsonAsync())!; + var grandchild = (await (await client.SendAsync(Bearer(HttpMethod.Post, "/api/class-groups", admin.AccessToken, + new CreateClassGroupRequest { Name = "1 班组", ParentId = child.Id }))).Content.ReadFromJsonAsync())!; + + var classA = (await CreateClassAsync(admin.AccessToken, "层级一班"))!; + var classB = (await CreateClassAsync(admin.AccessToken, "层级二班"))!; + + // 同一个班级可属于多个分组。 + var multi = await client.SendAsync(Bearer(HttpMethod.Put, $"/api/classes/{classA.Id}/groups", admin.AccessToken, + new UpdateClassGroupsRequest { GroupIds = [grandchild.Id, root.Id] })); + Assert.Equal(HttpStatusCode.NoContent, multi.StatusCode); + await client.SendAsync(Bearer(HttpMethod.Put, $"/api/classes/{classB.Id}/groups", admin.AccessToken, + new UpdateClassGroupsRequest { GroupIds = [grandchild.Id] })); + + // 按根分组批量开启访客:递归展开覆盖全部子分组中的班级。 + var batch = await client.SendAsync(Bearer(HttpMethod.Post, "/api/classes/batch", admin.AccessToken, + new BatchClassOperationRequest { GroupIds = [root.Id], Operation = "enableVisitor" })); + batch.EnsureSuccessStatusCode(); + var details = (await (await client.SendAsync(Bearer(HttpMethod.Get, "/api/classes", admin.AccessToken))) + .Content.ReadFromJsonAsync>())!; + Assert.All(new[] { classA.Id, classB.Id }, + id => Assert.True(details.Single(x => x.Id == id).VisitorEnabled)); + + // 层级环被拒绝:把根分组移到自己的子分组之下。 + var cycle = await client.SendAsync(Bearer(HttpMethod.Put, $"/api/class-groups/{root.Id}", admin.AccessToken, + new UpdateClassGroupRequest { Name = "某中学", ParentId = grandchild.Id })); + Assert.Equal(HttpStatusCode.BadRequest, cycle.StatusCode); + + // 删除中间层:子分组上移到根,孙分组与班级归属不受影响。 + var removed = await client.SendAsync(Bearer(HttpMethod.Delete, $"/api/class-groups/{child.Id}", admin.AccessToken)); + Assert.Equal(HttpStatusCode.NoContent, removed.StatusCode); + var groups = (await (await client.SendAsync(Bearer(HttpMethod.Get, "/api/class-groups", admin.AccessToken))) + .Content.ReadFromJsonAsync>())!; + Assert.Equal(root.Id, groups.Single(x => x.Id == grandchild.Id).ParentId); + } + + [Fact] + public async Task ClassInfo_ClassAdminCanRenameAndSetAvatar() + { + var admin = await _factory.LoginAsync(); + using var client = _factory.CreateClient(); + var classB = (await CreateClassAsync(admin.AccessToken, "信息一班"))!; + var adminUser = await CreateUserAsync("cls.info", "Cls-Info-Password-2026", AccountRole.ClassAdministratorId); + await client.SendAsync(Bearer(HttpMethod.Put, $"/api/classes/{classB.Id}/members", admin.AccessToken, + new UpdateClassMembersRequest { Members = [new ClassMemberInput { UserId = adminUser, RoleId = AccountRole.ClassAdministratorId }] })); + + var classAdmin = await _factory.LoginAsync("cls.info", "Cls-Info-Password-2026"); + var renamed = await client.SendAsync(Bearer(HttpMethod.Put, $"/api/classes/{classB.Id}/info", classAdmin.AccessToken, + new UpdateClassRequest { Name = "信息一班(改)" })); + Assert.Equal(HttpStatusCode.NoContent, renamed.StatusCode); + var details = (await (await client.SendAsync(Bearer(HttpMethod.Get, "/api/classes", admin.AccessToken))) + .Content.ReadFromJsonAsync>())!; + Assert.Equal("信息一班(改)", details.Single(x => x.Id == classB.Id).Name); + + // 上传 PNG 头像后可匿名读取(访客页展示)。 + var png = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3, 4 }; + var uploadRequest = new HttpRequestMessage(HttpMethod.Put, $"/api/classes/{classB.Id}/avatar") + { + Headers = { Authorization = new("Bearer", classAdmin.AccessToken) }, + Content = new ByteArrayContent(png), + }; + uploadRequest.Headers.TryAddWithoutValidation("X-Avatar-Type", "image/png"); + var upload = await client.SendAsync(uploadRequest); + Assert.Equal(HttpStatusCode.NoContent, upload.StatusCode); + var anonymous = await _factory.CreateClient().GetAsync($"/api/classes/{classB.Id}/avatar"); + Assert.Equal(HttpStatusCode.OK, anonymous.StatusCode); + Assert.Equal("image/png", anonymous.Content.Headers.ContentType?.MediaType); + + // 非图片内容被拒绝。 + var rejected = await client.SendAsync(new HttpRequestMessage(HttpMethod.Put, $"/api/classes/{classB.Id}/avatar") + { + Headers = + { + Authorization = new("Bearer", classAdmin.AccessToken), + }, + Content = new ByteArrayContent(new byte[] { 1, 2, 3 }), + }); + Assert.Equal(HttpStatusCode.BadRequest, rejected.StatusCode); + + // 清除头像后 404。 + var cleared = await client.SendAsync(Bearer(HttpMethod.Delete, $"/api/classes/{classB.Id}/avatar", classAdmin.AccessToken)); + Assert.Equal(HttpStatusCode.NoContent, cleared.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await _factory.CreateClient().GetAsync($"/api/classes/{classB.Id}/avatar")).StatusCode); + + // 普通成员不能改班名。 + var studentId = await CreateUserAsync("info.student", "Info-Student-Password-2026"); + await client.SendAsync(Bearer(HttpMethod.Put, $"/api/classes/{classB.Id}/members", admin.AccessToken, + new UpdateClassMembersRequest { Members = [new ClassMemberInput { UserId = studentId, RoleId = AccountRole.StudentId }] })); + var student = await _factory.LoginAsync("info.student", "Info-Student-Password-2026"); + Assert.Equal(HttpStatusCode.Forbidden, + (await client.SendAsync(Bearer(HttpMethod.Put, $"/api/classes/{classB.Id}/info", student.AccessToken, + new UpdateClassRequest { Name = "越权改名" }))).StatusCode); + } + + [Fact] + public async Task BatchImport_V2FormatAndPendingPasswordSetup() + { + var admin = await _factory.LoginAsync(); + using var client = _factory.CreateClient(); + + // v2 行格式:ID,用户名,班级,角色,密码(密码可空=待激活;班级不存在自动创建)。 + var importText = string.Join("\n", + "imp.a,导入甲,导入一班,学生,", + "imp.b,导入乙,导入一班,班管理员,Imp-B-Password-2026", + "# 注释行", + "imp.c,导入丙,不存在的角色,"); + var imported = await client.SendAsync(Bearer(HttpMethod.Post, "/api/users/batch-import", admin.AccessToken, + new BatchImportRequest + { + Text = importText, + DefaultRoleId = AccountRole.StudentId, + })); + imported.EnsureSuccessStatusCode(); + var result = (await imported.Content.ReadFromJsonAsync())!; + Assert.Equal(2, result.Created); + Assert.Single(result.Failures, x => x.Contains("imp.c")); + + // 待激活账号空密码登录 → 返回一次性设置令牌。 + var pending = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest + { + Username = "imp.a", + Password = "", + DeviceName = "Integration Test", + }); + pending.EnsureSuccessStatusCode(); + var pendingAuth = (await pending.Content.ReadFromJsonAsync())!; + Assert.True(pendingAuth.PasswordPending); + Assert.False(string.IsNullOrEmpty(pendingAuth.SetupToken)); + + // 已设密码账号空密码登录 → 正常失败。 + var notPending = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest + { + Username = "imp.b", + Password = "", + DeviceName = "Integration Test", + }); + Assert.Equal(HttpStatusCode.Unauthorized, notPending.StatusCode); + + // 凭令牌设置密码后即可正常登录;班级与角色在导入时已就位。 + var setup = await client.PostAsJsonAsync("/api/auth/setup-password", new SetupPasswordRequest + { + Username = "imp.a", + SetupToken = pendingAuth.SetupToken!, + NewPassword = "Imp-A-Password-2026", + }); + Assert.Equal(HttpStatusCode.NoContent, setup.StatusCode); + var login = await _factory.LoginAsync("imp.a", "Imp-A-Password-2026"); + Assert.Contains(login.User!.Classes ?? [], x => x.Name == "导入一班" && x.RoleName == "学生"); + + // 错误令牌被拒绝。 + var badSetup = await client.PostAsJsonAsync("/api/auth/setup-password", new SetupPasswordRequest + { + Username = "imp.b", + SetupToken = "wrong-token", + NewPassword = "Whatever-2026", + }); + Assert.Equal(HttpStatusCode.Unauthorized, badSetup.StatusCode); + } + + // ---------- WebUI 班级切换 ---------- + + [Fact] + public async Task WebUi_SwitchClass_OnlyAllowsAccessibleClasses() + { + await using var factory = new TestWebApplicationFactory(); + using var browser = CreateBrowser(factory); + await LoginWebUiAsync(browser); + + Guid classB; + using (var scope = factory.Services.CreateScope()) + { + classB = (await scope.ServiceProvider.GetRequiredService().CreateAsync("切换二班")).Id; + } + + // 管理员可访问所有班级:切换写入 Cookie,并得到回跳。 + var accountHtml = await browser.GetStringAsync("/Account"); + Assert.Contains("aria-label=\"切换班级\"", accountHtml); + var switched = await PostRazorFormAsync(browser, "/Account?handler=SwitchClass", accountHtml, new Dictionary + { + ["classId"] = classB.ToString(), + ["returnUrl"] = "/Account", + }); + Assert.Equal(HttpStatusCode.Redirect, switched.StatusCode); + var setCookies = switched.Headers.Contains("Set-Cookie") + ? string.Join("|", switched.Headers.GetValues("Set-Cookie")) + : string.Empty; + Assert.Contains(WebPageModel.CurrentClassCookie, setCookies); + + // 普通学生只有默认班级:看不到切换器,切换到自己不属于的班级不会写 Cookie。 + await CreateUserAsync("switch.student", "Switch-Student-Password-2026", factory: factory); + using var studentBrowser = CreateBrowser(factory); + await LoginWebUiAsync(studentBrowser, "switch.student", "Switch-Student-Password-2026"); + var studentAccount = await studentBrowser.GetStringAsync("/Account"); + Assert.DoesNotContain("aria-label=\"切换班级\"", studentAccount); + var deniedSwitch = await PostRazorFormAsync(studentBrowser, "/Account?handler=SwitchClass", studentAccount, new Dictionary + { + ["classId"] = classB.ToString(), + ["returnUrl"] = "/Account", + }); + Assert.Equal(HttpStatusCode.Redirect, deniedSwitch.StatusCode); + var studentCookies = deniedSwitch.Headers.Contains("Set-Cookie") + ? string.Join("|", deniedSwitch.Headers.GetValues("Set-Cookie")) + : string.Empty; + Assert.DoesNotContain(WebPageModel.CurrentClassCookie, studentCookies); + } + + // ---------- 辅助 ---------- + + private static HttpClient CreateBrowser(TestWebApplicationFactory factory) => + factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = true, + }); + + private static async Task LoginWebUiAsync( + HttpClient browser, + string username = TestWebApplicationFactory.AdminUsername, + string password = TestWebApplicationFactory.AdminPassword) + { + var html = await browser.GetStringAsync("/Login"); + var match = System.Text.RegularExpressions.Regex.Match( + html, + "]+name=\"__RequestVerificationToken\"[^>]+value=\"([^\"]+)\"", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + Assert.True(match.Success, "登录页必须包含 CSRF 令牌"); + var response = await browser.PostAsync("/Login", new FormUrlEncodedContent(new Dictionary + { + ["Input.Username"] = username, + ["Input.Password"] = password, + ["__RequestVerificationToken"] = WebUtility.HtmlDecode(match.Groups[1].Value), + })); + Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode); + } + + private static async Task PostRazorFormAsync( + HttpClient browser, + string path, + string html, + IReadOnlyDictionary values) + { + var match = System.Text.RegularExpressions.Regex.Match( + html, + "]+name=\"__RequestVerificationToken\"[^>]+value=\"([^\"]+)\"", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + Assert.True(match.Success, "Razor 表单页必须包含 CSRF 令牌"); + var fields = new Dictionary(values) + { + ["__RequestVerificationToken"] = WebUtility.HtmlDecode(match.Groups[1].Value), + }; + return await browser.PostAsync(path, new FormUrlEncodedContent(fields)); + } + + private async Task CreateClassAsync(string token, string name) + { + var response = await _factory.CreateClient().SendAsync(Bearer( + HttpMethod.Post, "/api/classes", token, new CreateClassRequest { Name = name })); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(); + } + + private async Task CreateUserAsync( + string username, string password, Guid? roleId = null, TestWebApplicationFactory? factory = null) + { + var target = factory ?? _factory; + var admin = await target.LoginAsync(); + var response = await target.CreateClient().SendAsync(Bearer( + HttpMethod.Post, "/api/users", admin.AccessToken, + new CreateUserRequest + { + Username = username, + DisplayName = username, + Password = password, + RoleId = roleId, + })); + response.EnsureSuccessStatusCode(); + var created = (await response.Content.ReadFromJsonAsync())!; + return created.Id; + } + + private async Task ConnectPluginAsync() + { + var token = await _factory.GetPluginTokenAsync(); + return await ConnectAsync(token); + } + + private async Task ConnectWatchAsync( + string username = TestWebApplicationFactory.AdminUsername, + string password = TestWebApplicationFactory.AdminPassword) + { + var login = await _factory.LoginAsync(username, password); + return await ConnectAsync(login.AccessToken); + } + + private async Task ConnectAsync(string token) + { + var socketClient = _factory.Server.CreateWebSocketClient(); + return await socketClient.ConnectAsync( + new Uri(_factory.Server.BaseAddress, $"/ws?{Protocol.QueryToken}={Uri.EscapeDataString(token)}"), + CancellationToken.None); + } + + private static async Task SendAsync(WebSocket socket, Envelope envelope) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(envelope, JsonDefaults.Options); + await socket.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None); + } + + private static async Task ReceiveEnvelopeAsync(WebSocket socket, string expectedType) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (true) + { + var buffer = new byte[256 * 1024]; + using var message = new MemoryStream(); + WebSocketReceiveResult result; + do + { + result = await socket.ReceiveAsync(buffer, timeout.Token); + Assert.NotEqual(WebSocketMessageType.Close, result.MessageType); + message.Write(buffer, 0, result.Count); + } while (!result.EndOfMessage); + var envelope = JsonSerializer.Deserialize(Encoding.UTF8.GetString(message.ToArray()), JsonDefaults.Options)!; + if (envelope.Type == expectedType) return envelope; + } + } + + private static async Task ReceivePayloadAsync(WebSocket socket, string expectedType) + { + var envelope = await ReceiveEnvelopeAsync(socket, expectedType); + return JsonSerializer.Deserialize( + JsonSerializer.Serialize(envelope.Payload), JsonDefaults.Options)!; + } + + private static HttpRequestMessage Bearer(HttpMethod method, string path, string token, object? body = null) + { + var request = new HttpRequestMessage(method, path); + request.Headers.Authorization = new("Bearer", token); + if (body is not null) request.Content = JsonContent.Create(body); + return request; + } +} diff --git a/server/tests/RemoteCI.Server.Tests/ExtensionPolicyServiceTests.cs b/server/tests/RemoteCI.Server.Tests/ExtensionPolicyServiceTests.cs index a7fec1b..55e0424 100644 --- a/server/tests/RemoteCI.Server.Tests/ExtensionPolicyServiceTests.cs +++ b/server/tests/RemoteCI.Server.Tests/ExtensionPolicyServiceTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using RemoteCI.Server.Data; using RemoteCI.Server.Services; using RemoteCI.Shared; using RemoteCI.Shared.Models; @@ -72,7 +73,7 @@ public async Task ExtensionAccess_RequiresIndependentPermissionAndSupportsPerson Assert.Contains(definition.Id, profile!.AllowedExtensionIds!); Assert.DoesNotContain(definition.Id, profile.VisibleExtensionIds!); - var mirrored = (await identities.CreateSyncAsync()).Accounts.Single(x => x.Id == extensionUser.Id); + var mirrored = (await identities.CreateSyncAsync(Classroom.DefaultId)).Accounts.Single(x => x.Id == extensionUser.Id); Assert.Contains(definition.Id, mirrored.AllowedExtensionIds!); Assert.DoesNotContain(definition.Id, mirrored.VisibleExtensionIds!); } diff --git a/server/tests/RemoteCI.Server.Tests/ScheduleSyncTaskTrackerTests.cs b/server/tests/RemoteCI.Server.Tests/ScheduleSyncTaskTrackerTests.cs index a1589da..96db6df 100644 --- a/server/tests/RemoteCI.Server.Tests/ScheduleSyncTaskTrackerTests.cs +++ b/server/tests/RemoteCI.Server.Tests/ScheduleSyncTaskTrackerTests.cs @@ -1,3 +1,4 @@ +using RemoteCI.Server.Data; using RemoteCI.Server.Services; using RemoteCI.Shared; using RemoteCI.Shared.Models; @@ -11,8 +12,8 @@ public sealed class ScheduleSyncTaskTrackerTests public async Task ActiveTask_ReturnsBusyAndCompletionReleasesWaiter() { var tracker = new ScheduleSyncTaskTracker(); - var first = tracker.TryBegin(ScheduleSyncRequest.Create(ScheduleSyncSource.WebUi)); - var second = tracker.TryBegin(ScheduleSyncRequest.Create(ScheduleSyncSource.Automatic)); + var first = tracker.TryBegin(ScheduleSyncRequest.Create(ScheduleSyncSource.WebUi), Classroom.DefaultId); + var second = tracker.TryBegin(ScheduleSyncRequest.Create(ScheduleSyncSource.Automatic), Classroom.DefaultId); Assert.Equal(ScheduleSyncTaskState.Running, first.State); Assert.Equal(ScheduleSyncTaskState.Busy, second.State); @@ -30,7 +31,11 @@ public async Task ActiveTask_ReturnsBusyAndCompletionReleasesWaiter() }); Assert.Equal(ScheduleSyncTaskState.Completed, (await waiting).State); - Assert.Null(tracker.Current); - Assert.Equal(ScheduleSyncTaskState.Running, tracker.TryBegin(ScheduleSyncRequest.Create(ScheduleSyncSource.Watch)).State); + Assert.Null(tracker.Current(Classroom.DefaultId)); + // 其他班级的任务互不影响:默认班级结束后,新班级可立即启动自己的课表任务。 + var otherClass = Guid.NewGuid(); + Assert.Equal( + ScheduleSyncTaskState.Running, + tracker.TryBegin(ScheduleSyncRequest.Create(ScheduleSyncSource.Watch), otherClass).State); } -} \ No newline at end of file +} diff --git a/server/tests/RemoteCI.Server.Tests/VisitorAccessTests.cs b/server/tests/RemoteCI.Server.Tests/VisitorAccessTests.cs index 19cb502..cbd2783 100644 --- a/server/tests/RemoteCI.Server.Tests/VisitorAccessTests.cs +++ b/server/tests/RemoteCI.Server.Tests/VisitorAccessTests.cs @@ -3,6 +3,7 @@ using System.Text.RegularExpressions; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; +using RemoteCI.Server.Data; using RemoteCI.Server.Pages; using RemoteCI.Server.Services; using RemoteCI.Shared; @@ -39,13 +40,14 @@ public async Task VisitorPage_IsHiddenUntilEnabledAndOnlyShowsSchedule() await LoginWebUiAsync(browser, TestWebApplicationFactory.AdminUsername, TestWebApplicationFactory.AdminPassword); var usersHtml = await browser.GetStringAsync("/Users"); - Assert.Contains("启用访客功能", usersHtml); Assert.Contains("自动进入访客页", usersHtml); - Assert.Contains("disabled", Regex.Match(usersHtml, "data-visitor-auto-enter[^>]*>").Value); - var saved = await PostRazorFormAsync(browser, "/Users?handler=VisitorAccess", usersHtml, new Dictionary + // 访客开关按班级配置:在班级管理页开启默认班级的访客功能。 + var classesHtml = await browser.GetStringAsync("/Classes"); + var saved = await PostRazorFormAsync(browser, "/Classes?handler=ToggleVisitor", classesHtml, new Dictionary { - ["VisitorAccessEnabled"] = "true", + ["id"] = Classroom.DefaultId.ToString(), + ["enabled"] = "true", }); Assert.Equal(HttpStatusCode.Redirect, saved.StatusCode); @@ -53,11 +55,10 @@ public async Task VisitorPage_IsHiddenUntilEnabledAndOnlyShowsSchedule() var guestLogin = WebUtility.HtmlDecode(await guest.GetStringAsync("/Login")); Assert.Contains("仅查看", guestLogin); Assert.Contains("class=\"login-guest\"", guestLogin); - Assert.DoesNotContain("自动进入访客页", guestLogin); using (var scope = factory.Services.CreateScope()) { - scope.ServiceProvider.GetRequiredService().SaveSchedule(new ScheduleBundle + scope.ServiceProvider.GetRequiredService().SaveSchedule(Classroom.DefaultId, new ScheduleBundle { FromDate = "2026-09-13", Days = @@ -98,29 +99,118 @@ public async Task VisitorPage_IsHiddenUntilEnabledAndOnlyShowsSchedule() } [Fact] - public async Task AutoEnter_RequiresVisitorAccessAndCanReturnToLogin() + public async Task VisitorPage_CanSwitchBetweenVisitorEnabledClasses() { await using var factory = new TestWebApplicationFactory(); using var admin = CreateBrowser(factory); await LoginWebUiAsync(admin, TestWebApplicationFactory.AdminUsername, TestWebApplicationFactory.AdminPassword); + + Guid otherClassId; + using (var scope = factory.Services.CreateScope()) + { + var classrooms = scope.ServiceProvider.GetRequiredService(); + otherClassId = (await classrooms.CreateAsync("高二(2)班")).Id; + await classrooms.SetVisitorAccessAsync(Classroom.DefaultId, true); + await classrooms.SetVisitorAccessAsync(otherClassId, true); + // 只给默认班级和“高二(2)班”注入可区分的课表。 + scope.ServiceProvider.GetRequiredService().SaveSchedule(Classroom.DefaultId, new ScheduleBundle + { + FromDate = "2026-09-13", + Days = [new ScheduleDay { Date = "2026-09-14", Revision = "v-a", Enabled = true, Courses = [new CourseEntry { Index = 0, Subject = "语文", StartTime = "08:00", EndTime = "08:45", Enabled = true }] }], + }); + scope.ServiceProvider.GetRequiredService().SaveSchedule(otherClassId, new ScheduleBundle + { + FromDate = "2026-09-13", + Days = [new ScheduleDay { Date = "2026-09-14", Revision = "v-b", Enabled = true, Courses = [new CourseEntry { Index = 0, Subject = "化学", StartTime = "09:00", EndTime = "09:45", Enabled = true }] }], + }); + // 未开放访客的班级不应出现在访客页。 + await classrooms.CreateAsync("高一(3)班"); + } + + using var guest = CreateBrowser(factory); + var landing = await guest.GetAsync("/Visitor"); + Assert.Equal(HttpStatusCode.OK, landing.StatusCode); + var landingHtml = WebUtility.HtmlDecode(await landing.Content.ReadAsStringAsync()); + // 默认展示第一个开放班级的课表,且两个开放班级都可选,未开放班级不可见。 + Assert.Contains("语文", landingHtml); + Assert.Contains("高二(2)班", landingHtml); + Assert.DoesNotContain("高一(3)班", landingHtml); + + var other = await guest.GetAsync($"/Visitor?class={otherClassId}"); + Assert.Equal(HttpStatusCode.OK, other.StatusCode); + var otherHtml = WebUtility.HtmlDecode(await other.Content.ReadAsStringAsync()); + Assert.Contains("化学", otherHtml); + } + + [Fact] + public async Task VisitorPage_RedirectsToLoginWhenNoClassEnabled() + { + await using var factory = new TestWebApplicationFactory(); + using var guest = CreateBrowser(factory); + var response = await guest.GetAsync("/Visitor"); + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/Login", response.Headers.Location?.OriginalString); + } + + [Fact] + public async Task LoginPage_OffersRememberMeAndUsesPersistentCookieWhenSelected() + { + await using var factory = new TestWebApplicationFactory(); + using var browser = CreateBrowser(factory); + + var loginHtml = WebUtility.HtmlDecode(await browser.GetStringAsync("/Login")); + Assert.Contains("name=\"Input.RememberMe\"", loginHtml); + Assert.Contains("保持登录", loginHtml); + + var token = Regex.Match( + loginHtml, + "]+name=\"__RequestVerificationToken\"[^>]+value=\"([^\"]+)\"", + RegexOptions.IgnoreCase); + Assert.True(token.Success, "登录页必须包含 CSRF 令牌"); + using var response = await browser.PostAsync("/Login", new FormUrlEncodedContent(new Dictionary + { + ["Input.Username"] = TestWebApplicationFactory.AdminUsername, + ["Input.Password"] = TestWebApplicationFactory.AdminPassword, + ["Input.RememberMe"] = "true", + ["__RequestVerificationToken"] = WebUtility.HtmlDecode(token.Groups[1].Value), + })); + + Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode); + var cookies = response.Headers.GetValues("Set-Cookie"); + Assert.Contains(cookies, cookie => cookie.Contains("RemoteCI.Web=", StringComparison.Ordinal)); + Assert.Contains(cookies, cookie => cookie.Contains("expires=", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task AutoEnter_RequiresVisitorClassAndCanReturnToLogin() + { + await using var factory = new TestWebApplicationFactory(); + using var admin = CreateBrowser(factory); + await LoginWebUiAsync(admin, TestWebApplicationFactory.AdminUsername, TestWebApplicationFactory.AdminPassword); + // 没有任何班级开放访客时,即使保存了自动进入意图也不会真正进入访客页。 var usersHtml = await admin.GetStringAsync("/Users"); - var rejected = await PostRazorFormAsync(admin, "/Users?handler=VisitorAccess", usersHtml, new Dictionary + var saved = await PostRazorFormAsync(admin, "/Users?handler=VisitorAccess", usersHtml, new Dictionary { ["AutoEnterVisitorPage"] = "true", }); - Assert.Equal(HttpStatusCode.Redirect, rejected.StatusCode); + Assert.Equal(HttpStatusCode.Redirect, saved.StatusCode); using (var scope = factory.Services.CreateScope()) { - var state = await scope.ServiceProvider.GetRequiredService().GetAsync(); - Assert.False(state.Enabled); - Assert.False(state.AutoEnter); + var settings = scope.ServiceProvider.GetRequiredService(); + Assert.False(await settings.AnyVisitorClassEnabledAsync()); + } + using (var guestBefore = CreateBrowser(factory)) + { + var stay = await guestBefore.GetAsync("/Login"); + Assert.Equal(HttpStatusCode.OK, stay.StatusCode); } - usersHtml = await admin.GetStringAsync("/Users"); - var enabled = await PostRazorFormAsync(admin, "/Users?handler=VisitorAccess", usersHtml, new Dictionary + // 开放默认班级访客功能后,保存的自动进入意图立即生效。 + var classesHtml = await admin.GetStringAsync("/Classes"); + var enabled = await PostRazorFormAsync(admin, "/Classes?handler=ToggleVisitor", classesHtml, new Dictionary { - ["VisitorAccessEnabled"] = "true", - ["AutoEnterVisitorPage"] = "true", + ["id"] = Classroom.DefaultId.ToString(), + ["enabled"] = "true", }); Assert.Equal(HttpStatusCode.Redirect, enabled.StatusCode); @@ -133,9 +223,9 @@ public async Task AutoEnter_RequiresVisitorAccessAndCanReturnToLogin() Assert.Equal("/Visitor", home.Headers.Location?.OriginalString); var protectedPage = await guest.GetAsync("/Login?ReturnUrl=%2FUsers"); Assert.Equal(HttpStatusCode.OK, protectedPage.StatusCode); - var stay = await guest.GetAsync("/Login?from=visitor"); - Assert.Equal(HttpStatusCode.OK, stay.StatusCode); - var stayHtml = WebUtility.HtmlDecode(await stay.Content.ReadAsStringAsync()); + var stay2 = await guest.GetAsync("/Login?from=visitor"); + Assert.Equal(HttpStatusCode.OK, stay2.StatusCode); + var stayHtml = WebUtility.HtmlDecode(await stay2.Content.ReadAsStringAsync()); Assert.Contains("仅查看", stayHtml); Assert.Contains("登录你的账号", stayHtml); } @@ -147,19 +237,25 @@ public async Task ConfigurationBackup_PreservesVisitorAccessSettings() _ = await factory.LoginAsync(); using (var scope = factory.Services.CreateScope()) { + await scope.ServiceProvider.GetRequiredService() + .SetVisitorAccessAsync(Classroom.DefaultId, true); await scope.ServiceProvider.GetRequiredService() - .SetAsync(true, true); + .SetAutoEnterAsync(true); var snapshot = await scope.ServiceProvider.GetRequiredService().CaptureAsync(); - Assert.True(snapshot.Metadata.VisitorAccessEnabled); + Assert.Equal(3, snapshot.Version); + Assert.NotNull(snapshot.Classrooms); + Assert.Contains(snapshot.Classrooms!, x => x.Id == Classroom.DefaultId && x.VisitorAccessEnabled); Assert.True(snapshot.Metadata.AutoEnterVisitorPage); - await scope.ServiceProvider.GetRequiredService().SetAsync(false, false); + await scope.ServiceProvider.GetRequiredService() + .SetVisitorAccessAsync(Classroom.DefaultId, false); + await scope.ServiceProvider.GetRequiredService() + .SetAutoEnterAsync(false); await scope.ServiceProvider.GetRequiredService().ApplyAsync(snapshot); } using var verify = factory.Services.CreateScope(); - var restored = await verify.ServiceProvider.GetRequiredService().GetAsync(); - Assert.True(restored.Enabled); - Assert.True(restored.AutoEnter); + Assert.True(await verify.ServiceProvider.GetRequiredService().AnyVisitorClassEnabledAsync()); + Assert.True(await verify.ServiceProvider.GetRequiredService().GetAutoEnterAsync()); var legacy = JsonSerializer.Deserialize( """{"accountVersion":3,"forceSenderInTitle":true,"schedulePullIntervalMinutes":15}""", @@ -190,7 +286,7 @@ private static async Task LoginWebUiAsync(HttpClient browser, string username, s ["Input.Password"] = password, ["__RequestVerificationToken"] = WebUtility.HtmlDecode(match.Groups[1].Value), })); - Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode); } private static async Task PostRazorFormAsync( diff --git a/server/tests/RemoteCI.Server.Tests/WebSocketRelayTests.cs b/server/tests/RemoteCI.Server.Tests/WebSocketRelayTests.cs index ef7348e..1a84e2c 100644 --- a/server/tests/RemoteCI.Server.Tests/WebSocketRelayTests.cs +++ b/server/tests/RemoteCI.Server.Tests/WebSocketRelayTests.cs @@ -24,7 +24,7 @@ public async Task VoiceMessage_RelaysFullMinuteWithAuthenticatedSenderAndCorrela using var plugin = await ConnectPluginAsync(); await ReceiveEnvelopeAsync(plugin, Protocol.MessageTypeSchedulePull); await SendAsync(plugin, Envelope.PeerCapabilities(new PeerCapabilities { Capabilities = RemoteCiCapabilities.Current })); - await WaitUntilAsync(() => _factory.Services.GetRequiredService().PrimaryPluginSupports(RemoteCiCapabilities.VoiceMessageSend)); + await WaitUntilAsync(() => _factory.Services.GetRequiredService().PrimaryPluginSupports(Classroom.DefaultId, RemoteCiCapabilities.VoiceMessageSend)); using var watch = await ConnectWatchAsync(); var audio = new byte[VoiceMessageRequest.MaxBytes]; new Random(42).NextBytes(audio); @@ -51,6 +51,34 @@ public async Task VoiceMessage_RelaysFullMinuteWithAuthenticatedSenderAndCorrela Assert.True(ConvertPayload(reply.Payload).Success); } + [Fact] + public async Task MobileCommand_RelaysToPluginAndReturnsCorrelatedReply() + { + using var plugin = await ConnectPluginAsync(); + await ReceiveEnvelopeAsync(plugin, Protocol.MessageTypeSchedulePull); + using var mobile = await ConnectMobileAsync(); + await ReceivePayloadAsync(mobile, Protocol.MessageTypeAuthState); + var request = Envelope.Command(new CommandMessage + { + Command = CommandKind.TeacherComing, + }); + + await SendAsync(mobile, request); + + var forwarded = await ReceiveEnvelopeAsync(plugin, Protocol.MessageTypeCommand); + Assert.Equal(CommandKind.TeacherComing, ConvertPayload(forwarded.Payload).Command); + await SendAsync(plugin, new Envelope + { + Type = Protocol.MessageTypeCommandResult, + ReplyToMessageId = forwarded.MessageId, + Payload = new CommandResult { Success = true, Code = CommandResultCodes.Ok }, + }); + var reply = await ReceiveEnvelopeAsync(mobile, Protocol.MessageTypeCommandResult); + Assert.Equal(request.MessageId, reply.ReplyToMessageId); + Assert.True(ConvertPayload(reply.Payload).Success); + Assert.Equal(1, _factory.Services.GetRequiredService().MobileCount); + } + [Fact] public async Task VoiceMessage_RejectsMalformedAudioBeforeForwarding() { @@ -498,7 +526,7 @@ await SendAsync(plugin, Envelope.StatePush(new ClassStateSnapshot var store = factory.Services.GetRequiredService(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - while (store.GetLatestSnapshot()?.CurrentSubject != "性能回归-2") + while (store.GetLatestSnapshot(Classroom.DefaultId)?.CurrentSubject != "性能回归-2") await Task.Delay(10, timeout.Token); Assert.Equal(0, commands.Count); @@ -644,11 +672,15 @@ private async Task ConnectWatchAsync( string password = TestWebApplicationFactory.AdminPassword) => await ConnectAsync((await _factory.LoginAsync(username, password)).AccessToken); - private async Task ConnectAsync(string token) + private async Task ConnectMobileAsync() => + await ConnectAsync((await _factory.LoginAsync()).AccessToken, "mobile"); + + private async Task ConnectAsync(string token, string? clientKind = null) { - var client = _factory.Server.CreateWebSocketClient(); - return await client.ConnectAsync( - new Uri(_factory.Server.BaseAddress, $"/ws?{Protocol.QueryToken}={Uri.EscapeDataString(token)}"), + var socketClient = _factory.Server.CreateWebSocketClient(); + var clientQuery = string.IsNullOrWhiteSpace(clientKind) ? string.Empty : $"&client={Uri.EscapeDataString(clientKind)}"; + return await socketClient.ConnectAsync( + new Uri(_factory.Server.BaseAddress, $"/ws?{Protocol.QueryToken}={Uri.EscapeDataString(token)}{clientQuery}"), CancellationToken.None); } diff --git a/shared/RemoteCI.Shared/Models/ApiModels.cs b/shared/RemoteCI.Shared/Models/ApiModels.cs index e4669e6..105b432 100644 --- a/shared/RemoteCI.Shared/Models/ApiModels.cs +++ b/shared/RemoteCI.Shared/Models/ApiModels.cs @@ -45,6 +45,11 @@ public sealed class PluginCredentialInfo [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + /// 插件归属班级名称;旧版服务端为 null。 + [JsonPropertyName("className")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ClassName { get; set; } + [JsonPropertyName("createdAt")] public DateTimeOffset CreatedAt { get; set; } diff --git a/shared/RemoteCI.Shared/Models/AuthModels.cs b/shared/RemoteCI.Shared/Models/AuthModels.cs index a6c1167..20d0e17 100644 --- a/shared/RemoteCI.Shared/Models/AuthModels.cs +++ b/shared/RemoteCI.Shared/Models/AuthModels.cs @@ -44,6 +44,32 @@ public sealed class AuthResponse [JsonPropertyName("user")] public UserProfile User { get; set; } = new(); + + /// + /// 批量导入且未设置密码的账号首次登录时为 true:AccessToken 为空, + /// 客户端应改走 /api/auth/setup-password 设置密码后重新登录。 + /// + [JsonPropertyName("passwordPending")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? PasswordPending { get; set; } + + /// 一次性密码设置令牌,15 分钟有效;仅 passwordPending 为 true 时返回。 + [JsonPropertyName("setupToken")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SetupToken { get; set; } +} + +/// 首登设置密码请求:凭登录时下发的一次性令牌补设密码。 +public sealed class SetupPasswordRequest +{ + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("setupToken")] + public string SetupToken { get; set; } = string.Empty; + + [JsonPropertyName("newPassword")] + public string NewPassword { get; set; } = string.Empty; } public sealed class UserProfile : UserProfileLike @@ -89,8 +115,33 @@ public sealed class UserProfile : UserProfileLike [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? VisibleExtensionIds { get; set; } + /// 该账号可访问的班级列表;null 表示旧版服务端未下发班级信息。 + [JsonPropertyName("classes")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Classes { get; set; } + [JsonPropertyName("version")] public long Version { get; set; } + + /// + /// 以指定权限位生成身份副本:命令的 RequestedBy 必须携带目标班级的有效权限, + /// 插件端只信任消息内的权限位,局域网直连与云端路径保持一致。 + /// + public UserProfile WithPermissions(UserPermissions permissions) => new() + { + Id = Id, + Username = Username, + DisplayName = DisplayName, + Role = Role, + RoleId = RoleId, + RoleName = RoleName, + GrantedPermissions = GrantedPermissions, + Permissions = permissions, + AllowedExtensionIds = AllowedExtensionIds, + VisibleExtensionIds = VisibleExtensionIds, + Classes = Classes, + Version = Version, + }; } public sealed class AuthChallenge @@ -169,6 +220,11 @@ public sealed class AccountSync [JsonPropertyName("generatedAt")] public DateTimeOffset GeneratedAt { get; set; } + /// 镜像目标班级名称;旧版服务端不下发该字段。 + [JsonPropertyName("className")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ClassName { get; set; } + [JsonPropertyName("accounts")] public List Accounts { get; set; } = []; diff --git a/shared/RemoteCI.Shared/Models/ClassEvent.cs b/shared/RemoteCI.Shared/Models/ClassEvent.cs index 3e4decf..11b0ce2 100644 --- a/shared/RemoteCI.Shared/Models/ClassEvent.cs +++ b/shared/RemoteCI.Shared/Models/ClassEvent.cs @@ -7,6 +7,11 @@ namespace RemoteCI.Shared.Models; ///
public sealed class ClassEvent { + /// 事件归属班级;旧版服务端不下发该字段。 + [JsonPropertyName("classId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Guid? ClassId { get; set; } + /// 跨重连去重使用的稳定事件标识。 [JsonPropertyName("id")] public string Id { get; set; } = Guid.NewGuid().ToString("N"); diff --git a/shared/RemoteCI.Shared/Models/ClassModels.cs b/shared/RemoteCI.Shared/Models/ClassModels.cs new file mode 100644 index 0000000..bafcb89 --- /dev/null +++ b/shared/RemoteCI.Shared/Models/ClassModels.cs @@ -0,0 +1,290 @@ +using System.Text.Json.Serialization; + +namespace RemoteCI.Shared.Models; + +/// 登录用户可见的班级摘要;供客户端班级选择/切换使用。 +public sealed class ClassSummary +{ + [JsonPropertyName("id")] + public Guid Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// 该用户在班级中的角色名;系统管理员为“管理员”。 + [JsonPropertyName("roleName")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? RoleName { get; set; } + + /// 该用户在本班级内的有效权限(按成员角色计算)。 + [JsonPropertyName("permissions")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public UserPermissions? Permissions { get; set; } + + [JsonPropertyName("visitorEnabled")] + public bool VisitorEnabled { get; set; } + + /// 所属分组名列表;未分组为 null。 + [JsonPropertyName("groupNames")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? GroupNames { get; set; } + + /// 是否已设置班头像;客户端据此决定是否加载 /api/classes/{id}/avatar。 + [JsonPropertyName("hasAvatar")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? HasAvatar { get; set; } +} + +/// 管理员视角的班级详情。 +public sealed class ClassDetail +{ + [JsonPropertyName("id")] + public Guid Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("visitorEnabled")] + public bool VisitorEnabled { get; set; } + + [JsonPropertyName("memberCount")] + public int MemberCount { get; set; } + + [JsonPropertyName("pluginCount")] + public int PluginCount { get; set; } + + /// 所属分组 Id 列表;一个班级可属于多个分组。 + [JsonPropertyName("groupIds")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? GroupIds { get; set; } + + [JsonPropertyName("groupNames")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? GroupNames { get; set; } + + /// 是否已设置班头像。 + [JsonPropertyName("hasAvatar")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? HasAvatar { get; set; } + + [JsonPropertyName("createdAt")] + public DateTimeOffset CreatedAt { get; set; } +} + +/// +/// 班级分组(如年级、校区):支持父子层级,批量操作与广播通知按组展开时包含全部子分组中的班级。 +/// +public sealed class ClassGroupInfo +{ + [JsonPropertyName("id")] + public Guid Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// 父分组;null 表示根分组。 + [JsonPropertyName("parentId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Guid? ParentId { get; set; } + + [JsonPropertyName("parentName")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ParentName { get; set; } + + /// 层级深度:根为 1。 + [JsonPropertyName("depth")] + public int Depth { get; set; } + + /// 直接归属的班级数量(不含子分组内的班级)。 + [JsonPropertyName("classCount")] + public int ClassCount { get; set; } +} + +public sealed class CreateClassGroupRequest +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// 父分组;null 创建根分组。 + [JsonPropertyName("parentId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Guid? ParentId { get; set; } +} + +public sealed class UpdateClassGroupRequest +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// 移动分组到新的父级;null 移到根。不能移动到自身或后代。 + [JsonPropertyName("parentId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Guid? ParentId { get; set; } +} + +/// 整体替换一个分组包含的班级(仅直接归属;子分组不受影响)。 +public sealed class UpdateGroupClassesRequest +{ + [JsonPropertyName("classIds")] + public List ClassIds { get; set; } = []; +} + +/// 整体替换一个班级所属的分组。 +public sealed class UpdateClassGroupsRequest +{ + [JsonPropertyName("groupIds")] + public List GroupIds { get; set; } = []; +} + +/// 批量导入人员请求:见 UserImportService 的行格式说明。 +public sealed class BatchImportRequest +{ + [JsonPropertyName("text")] + public string Text { get; set; } = string.Empty; + + /// 3 列旧格式行使用的默认班级。 + [JsonPropertyName("defaultClassId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Guid? DefaultClassId { get; set; } + + /// 3 列旧格式行使用的默认角色;缺省为学生。 + [JsonPropertyName("defaultRoleId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Guid? DefaultRoleId { get; set; } +} + +public sealed class BatchImportResult +{ + [JsonPropertyName("created")] + public int Created { get; set; } + + [JsonPropertyName("failures")] + public List Failures { get; set; } = []; +} + +/// 把班级划入分组;groupId 为 null 表示移出分组。 +public sealed class AssignClassGroupRequest +{ + [JsonPropertyName("groupId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Guid? GroupId { get; set; } +} + +public sealed class CreateClassRequest +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; +} + +public sealed class UpdateClassRequest +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; +} + +public sealed class ClassVisitorRequest +{ + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } +} + +public sealed class ClassMemberInput +{ + [JsonPropertyName("userId")] + public Guid UserId { get; set; } + + [JsonPropertyName("roleId")] + public Guid RoleId { get; set; } +} + +/// 整体替换班级成员列表;不在列表中的现有成员会被移除。 +public sealed class UpdateClassMembersRequest +{ + [JsonPropertyName("members")] + public List Members { get; set; } = []; +} + +public sealed class ClassMemberInfo +{ + [JsonPropertyName("userId")] + public Guid UserId { get; set; } + + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = string.Empty; + + [JsonPropertyName("roleId")] + public Guid RoleId { get; set; } + + [JsonPropertyName("roleName")] + public string RoleName { get; set; } = string.Empty; +} + +public sealed class BatchClassOperationRequest +{ + [JsonPropertyName("classIds")] + public List ClassIds { get; set; } = []; + + /// 按分组批量:服务端会展开为组内全部班级后与 classIds 合并去重。 + [JsonPropertyName("groupIds")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? GroupIds { get; set; } + + /// enableVisitor / disableVisitor / delete。 + [JsonPropertyName("operation")] + public string Operation { get; set; } = string.Empty; +} + +public sealed class BatchClassOperationResult +{ + [JsonPropertyName("results")] + public List Results { get; set; } = []; +} + +/// +/// 集控广播命令:一次向多个班级(可按分组展开)发送通知、清除通知、电源或语音消息。 +/// 逐班按发送者的班内有效权限鉴权并路由到各班插件,单班失败不影响其余班级。 +/// +public sealed class BroadcastCommandRequest +{ + [JsonPropertyName("command")] + public CommandKind Command { get; set; } + + /// SendNotification 命令的通知内容。 + [JsonPropertyName("notification")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public NotificationRequest? Notification { get; set; } + + /// Power 命令的动作(关机/重启/睡眠/休眠)。 + [JsonPropertyName("powerAction")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public PowerActionKind? PowerAction { get; set; } + + /// SendVoiceMessage 命令的语音内容(base64 PCM)。 + [JsonPropertyName("voiceMessage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public VoiceMessageRequest? VoiceMessage { get; set; } + + [JsonPropertyName("classIds")] + public List ClassIds { get; set; } = []; + + /// 按分组广播:服务端展开为组内全部班级后与 classIds 合并去重。 + [JsonPropertyName("groupIds")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? GroupIds { get; set; } +} + +public sealed class BatchClassItemResult +{ + [JsonPropertyName("classId")] + public Guid ClassId { get; set; } + + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("message")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Message { get; set; } +} diff --git a/shared/RemoteCI.Shared/Models/ClassStateSnapshot.cs b/shared/RemoteCI.Shared/Models/ClassStateSnapshot.cs index f807f62..a247d9f 100644 --- a/shared/RemoteCI.Shared/Models/ClassStateSnapshot.cs +++ b/shared/RemoteCI.Shared/Models/ClassStateSnapshot.cs @@ -5,6 +5,11 @@ namespace RemoteCI.Shared.Models; /// 高频状态快照,不包含低频七日课表。 public sealed class ClassStateSnapshot { + /// 快照归属班级;旧版服务端不下发该字段。 + [JsonPropertyName("classId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Guid? ClassId { get; set; } + [JsonPropertyName("scheduleDate")] public string? ScheduleDate { get; set; } diff --git a/shared/RemoteCI.Shared/Models/Command.cs b/shared/RemoteCI.Shared/Models/Command.cs index 11d6262..8d92c38 100644 --- a/shared/RemoteCI.Shared/Models/Command.cs +++ b/shared/RemoteCI.Shared/Models/Command.cs @@ -41,6 +41,11 @@ public sealed class CommandMessage [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public Dictionary? ExtensionArgs { get; set; } + /// 命令目标班级;缺省时由服务端路由到默认班级或用户唯一成员班级。 + [JsonPropertyName("classId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Guid? ClassId { get; set; } + /// 接入端覆盖此字段,插件只信任经服务端或本地挑战认证后的身份。 [JsonPropertyName("requestedBy")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] diff --git a/shared/RemoteCI.Shared/Models/Extensions.cs b/shared/RemoteCI.Shared/Models/Extensions.cs index e9773b2..ecbbf12 100644 --- a/shared/RemoteCI.Shared/Models/Extensions.cs +++ b/shared/RemoteCI.Shared/Models/Extensions.cs @@ -34,6 +34,11 @@ public static ExtensionId Parse(string? value, string? paramName = null) ///
public sealed class ExtensionDefinition { + /// 扩展归属班级;旧版服务端不下发该字段。 + [JsonPropertyName("classId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Guid? ClassId { get; set; } + /// 全局唯一扩展 Id,命令路由与去重都使用它;格式由 统一约束。 [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; @@ -97,12 +102,17 @@ public enum ExtensionParameterType /// 扩展调用权限的统一判定,供服务端、插件和客户端保持一致。 public static class ExtensionAccess { - public static bool CanInvoke(UserProfile? user, ExtensionDefinition extension) + /// 按有效权限位与允许列表判定;供班级上下文等没有完整 Profile 的场景使用。 + public static bool CanInvoke( + UserPermissions permissions, IEnumerable? allowedExtensionIds, ExtensionDefinition extension) { - if (user is null || !user.Permissions.HasFlag(UserPermissions.RunExtensions)) return false; - return user.AllowedExtensionIds is null || user.AllowedExtensionIds.Contains(extension.Id, StringComparer.Ordinal); + if (!permissions.HasFlag(UserPermissions.RunExtensions)) return false; + return allowedExtensionIds is null || allowedExtensionIds.Contains(extension.Id, StringComparer.Ordinal); } + public static bool CanInvoke(UserProfile? user, ExtensionDefinition extension) => + CanInvoke(user?.Permissions ?? UserPermissions.None, user?.AllowedExtensionIds, extension); + public static bool IsVisibleOnWatch(UserProfile? user, ExtensionDefinition extension) => CanInvoke(user, extension) && (user!.VisibleExtensionIds is null || user.VisibleExtensionIds.Contains(extension.Id, StringComparer.Ordinal)); diff --git a/shared/RemoteCI.Shared/Models/PluginNetworkInfo.cs b/shared/RemoteCI.Shared/Models/PluginNetworkInfo.cs index f10441a..a4de2b2 100644 --- a/shared/RemoteCI.Shared/Models/PluginNetworkInfo.cs +++ b/shared/RemoteCI.Shared/Models/PluginNetworkInfo.cs @@ -13,4 +13,9 @@ public sealed class PluginNetworkInfo [JsonPropertyName("port")] public int Port { get; set; } + + /// 归属班级;缺省表示单班级部署的默认班级。 + [JsonPropertyName("classId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Guid? ClassId { get; set; } } diff --git a/shared/RemoteCI.Shared/Models/ScheduleModels.cs b/shared/RemoteCI.Shared/Models/ScheduleModels.cs index 53ad5af..2e8d766 100644 --- a/shared/RemoteCI.Shared/Models/ScheduleModels.cs +++ b/shared/RemoteCI.Shared/Models/ScheduleModels.cs @@ -13,6 +13,11 @@ public sealed class ScheduleSyncRequest [JsonPropertyName("requestedAt")] public DateTimeOffset RequestedAt { get; set; } = DateTimeOffset.UtcNow; + /// 任务目标班级;缺省表示单班级部署的默认班级。 + [JsonPropertyName("classId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Guid? ClassId { get; set; } + public static ScheduleSyncRequest Create(ScheduleSyncSource source, string? taskId = null) => new() { TaskId = string.IsNullOrWhiteSpace(taskId) ? Guid.NewGuid().ToString("N") : taskId, @@ -45,6 +50,11 @@ public sealed class ScheduleSyncStatus [JsonPropertyName("activeTaskId")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ActiveTaskId { get; set; } + + /// 任务归属班级;旧版服务端不下发该字段。 + [JsonPropertyName("classId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Guid? ClassId { get; set; } } public sealed class ScheduleBundle @@ -55,6 +65,11 @@ public sealed class ScheduleBundle [JsonPropertyName("generatedAt")] public DateTimeOffset GeneratedAt { get; set; } = DateTimeOffset.UtcNow; + /// 课表归属班级;旧版服务端不下发该字段。 + [JsonPropertyName("classId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Guid? ClassId { get; set; } + [JsonPropertyName("days")] public List Days { get; set; } = []; diff --git a/shared/RemoteCI.Shared/Protocol.cs b/shared/RemoteCI.Shared/Protocol.cs index ea39ba5..427991e 100644 --- a/shared/RemoteCI.Shared/Protocol.cs +++ b/shared/RemoteCI.Shared/Protocol.cs @@ -35,6 +35,7 @@ public enum PeerRole { Plugin = 1, Watch = 2, + Mobile = 3, } public enum UserRole @@ -232,6 +233,7 @@ public enum ScheduleSyncSource Watch = 3, Automatic = 4, Connection = 5, + Mobile = 6, } public enum ScheduleSyncTaskState diff --git a/wearos/app/src/main/java/com/remoteci/watch/data/ConnectionManager.kt b/wearos/app/src/main/java/com/remoteci/watch/data/ConnectionManager.kt index 18f3ac0..0c7332c 100644 --- a/wearos/app/src/main/java/com/remoteci/watch/data/ConnectionManager.kt +++ b/wearos/app/src/main/java/com/remoteci/watch/data/ConnectionManager.kt @@ -88,6 +88,9 @@ object ConnectionManager { /** 本机、服务端与当前主插件共同支持的功能;旧 V3 端缺少声明时按基础能力回退。 */ val availableCapabilities = MutableStateFlow(Protocol.BASELINE_CAPABILITIES) val currentUser = MutableStateFlow(null) + /** 账号可访问的班级与当前选中班级;单班级部署列表只有一个元素,界面据此隐藏切换器。 */ + val classes = MutableStateFlow>(emptyList()) + val currentClassId = MutableStateFlow(null) val snapshot = MutableStateFlow(null) val schedule = MutableStateFlow(null) val extensions = MutableStateFlow>(emptyList()) @@ -98,7 +101,7 @@ object ConnectionManager { /** 语音体积较大,在后台编码,并只接受本条消息的回执;断线时不自动重发。 */ suspend fun sendVoiceMessage(audio: ByteArray): CommandResult = withContext(Dispatchers.IO) { - if (currentUser.value?.has(Protocol.PERMISSION_SEND_VOICE_MESSAGES) != true) + if (!hasClassPermission(Protocol.PERMISSION_SEND_VOICE_MESSAGES)) return@withContext CommandResult(false, "FORBIDDEN", "没有发送语音权限") if (!supports(Protocol.CAP_VOICE_MESSAGE_SEND)) return@withContext CommandResult(false, "CAPABILITY_UNSUPPORTED", "请更新服务端和插件以支持语音") @@ -109,6 +112,7 @@ object ConnectionManager { voiceReplies[id] = reply try { val command = CommandMessage(command = Protocol.CMD_SEND_VOICE_MESSAGE, + classId = currentClassId.value, voiceMessage = VoiceMessageRequest(audioBase64 = android.util.Base64.encodeToString(audio, android.util.Base64.NO_WRAP))) val envelope = Envelope(type = Protocol.TYPE_COMMAND, messageId = id, payload = json.encodeToJsonElement(CommandMessage.serializer(), command)) @@ -141,6 +145,35 @@ object ConnectionManager { fun supports(capability: String): Boolean = capability in availableCapabilities.value + /** 统一写入用户档案:同时刷新班级列表;已选班级失效时回退到第一个可访问班级。 */ + private fun applyUserProfile(user: UserProfile?) { + currentUser.value = user + val accessible = user?.classes.orEmpty() + classes.value = accessible + val selected = currentClassId.value + currentClassId.value = + if (selected != null && accessible.any { it.id == selected }) selected + else accessible.firstOrNull()?.id + } + + /** 班级内的有效权限;服务端未下发班级信息(旧服务端)时回退到全局权限。 */ + fun hasClassPermission(permission: Int): Boolean { + val classContext = classes.value.firstOrNull { it.id == currentClassId.value } + val effective = classContext?.effectivePermissions ?: currentUser.value?.permissions ?: 0 + return effective and permission == permission + } + + /** 切换当前班级:清空旧班数据并立即拉取新班课表,之后的命令都会路由到新班级。 */ + fun switchClass(classId: String) { + if (classes.value.none { it.id == classId }) return + if (currentClassId.value == classId) return + currentClassId.value = classId + snapshot.value = null + schedule.value = null + extensions.value = emptyList() + if (state.value == State.LanConnected || state.value == State.CloudConnected) requestSchedulePull() + } + fun scanLanPlugins() { discoveryJob?.cancel() lanPlugins.value = emptyList() @@ -231,6 +264,10 @@ object ConnectionManager { try { if (plan.bootstrapCloudAuthentication) { val auth = loginCloud(settings, password!!) + if (auth.passwordPending == true) { + // 手表输入不便:待激活账号引导到网页端完成首次密码设置。 + throw IOException("该账号尚未设置密码,请先在网页端用空密码登录并设置密码") + } persist(auth) val session = sessions.load() ?: throw MissingSessionException() if (plan.preferLanAfterCloudAuthentication) { @@ -261,11 +298,11 @@ object ConnectionManager { } catch (_: AuthenticationException) { state.value = State.Error("用户名或密码错误") serverVersion.value = null - currentUser.value = null + applyUserProfile(null) } catch (_: MissingSessionException) { state.value = State.Error("请先使用账号密码登录") serverVersion.value = null - currentUser.value = null + applyUserProfile(null) } catch (error: CancellationException) { // 旧连接任务被新连接取消:直接透出,不得用旧任务的取消异常 // 覆盖新任务刚写入的 Connecting 状态或清空用户信息。 @@ -277,7 +314,7 @@ object ConnectionManager { } serverVersion.value = null // 登录虽成功但连接已失败,残留的用户信息会让界面误判为“在线”。 - currentUser.value = null + applyUserProfile(null) } } } @@ -294,7 +331,7 @@ object ConnectionManager { webSocket = null serverVersion.value = null accessToken = null - if (clearUser) currentUser.value = null + if (clearUser) applyUserProfile(null) extensions.value = emptyList() this@ConnectionManager.settings.value = null state.value = State.Idle @@ -340,7 +377,7 @@ object ConnectionManager { } lastCommandResult.value = null - val envelope = schedulePullEnvelope() + val envelope = schedulePullEnvelope(currentClassId.value) schedulePullState.value = SchedulePullState.Pulling("正在连接插件…") if (!socket.send(json.encodeToString(Envelope.serializer(), envelope))) { schedulePullState.value = SchedulePullState.Error("发送拉取请求失败,请重试") @@ -438,7 +475,8 @@ object ConnectionManager { } private fun sendCommand(command: CommandMessage, requiredPermission: Int) { - if (currentUser.value?.has(requiredPermission) != true) { + // 权限按当前班级计算(班管理员只在所属班级有管理权限),命令随班级路由。 + if (!hasClassPermission(requiredPermission)) { lastCommandResult.value = CommandResult(false, "FORBIDDEN", "权限不足") return } @@ -447,7 +485,10 @@ object ConnectionManager { Envelope( type = Protocol.TYPE_COMMAND, messageId = newMessageId(), - payload = json.encodeToJsonElement(CommandMessage.serializer(), command), + payload = json.encodeToJsonElement( + CommandMessage.serializer(), + command.copy(classId = command.classId ?: currentClassId.value), + ), ), ) } @@ -536,7 +577,7 @@ object ConnectionManager { deviceExpiresAt = auth.deviceExpiresAt, ), ) - currentUser.value = auth.user + applyUserProfile(auth.user) } private suspend fun connectWebSocket( @@ -651,20 +692,25 @@ object ConnectionManager { private fun handleEnvelope(envelope: Envelope): AuthState? = when (envelope.type) { Protocol.TYPE_AUTH_STATE -> decodePayload(envelope.payload, AuthState.serializer())?.also { auth -> - currentUser.value = if (auth.authenticated) auth.user else null + applyUserProfile(if (auth.authenticated) auth.user else null) serverVersion.value = if (auth.authenticated) auth.serverVersion else null if (!auth.authenticated) state.value = State.Error(auth.error ?: "登录已失效") } Protocol.TYPE_STATE_PUSH -> { - decodePayload(envelope.payload, ClassStateSnapshot.serializer())?.let { snapshot.value = it } + decodePayload(envelope.payload, ClassStateSnapshot.serializer())?.let { incoming -> + // 多班级:只接受当前班级(或旧服务端不带班级标识)的推送。 + if (incoming.classId == null || incoming.classId == currentClassId.value) snapshot.value = incoming + } null } Protocol.TYPE_SCHEDULE_SYNC -> { - decodePayload(envelope.payload, ScheduleBundle.serializer())?.let { - schedule.value = it - // 兼容未实现状态消息的旧插件:收到新课表本身也可作为成功终态。 - if (schedulePullState.value is SchedulePullState.Pulling) - finishSchedulePull(SchedulePullState.Success("课表拉取完成,已使用插件最新课表")) + decodePayload(envelope.payload, ScheduleBundle.serializer())?.let { incoming -> + if (incoming.classId == null || incoming.classId == currentClassId.value) { + schedule.value = incoming + // 兼容未实现状态消息的旧插件:收到新课表本身也可作为成功终态。 + if (schedulePullState.value is SchedulePullState.Pulling) + finishSchedulePull(SchedulePullState.Success("课表拉取完成,已使用插件最新课表")) + } } null } @@ -673,8 +719,11 @@ object ConnectionManager { null } Protocol.TYPE_EXTENSIONS_SYNC -> { - decodePayload(envelope.payload, ListSerializer(ExtensionDefinition.serializer())) - ?.let { extensions.value = it } + decodePayload(envelope.payload, ListSerializer(ExtensionDefinition.serializer()))?.let { incoming -> + if (incoming.firstOrNull()?.classId == null || + incoming.firstOrNull()?.classId == currentClassId.value + ) extensions.value = incoming + } null } Protocol.TYPE_SETTINGS_SYNC -> { @@ -692,7 +741,9 @@ object ConnectionManager { null } Protocol.TYPE_EVENT_NOTIFY -> { - decodePayload(envelope.payload, ClassEvent.serializer())?.let { events.tryEmit(it) } + decodePayload(envelope.payload, ClassEvent.serializer())?.let { incoming -> + if (incoming.classId == null || incoming.classId == currentClassId.value) events.tryEmit(incoming) + } null } Protocol.TYPE_COMMAND_RESULT -> { @@ -930,14 +981,14 @@ internal fun planConnection(settings: WatchSettings, password: String?): Connect allowCloudFallback = settings.cloudConnectionEnabled, ) -internal fun schedulePullEnvelope(): Envelope { +internal fun schedulePullEnvelope(classId: String? = null): Envelope { val taskId = UUID.randomUUID().toString().replace("-", "") return Envelope( type = Protocol.TYPE_SCHEDULE_PULL, messageId = taskId, payload = Json.encodeToJsonElement( ScheduleSyncRequest.serializer(), - ScheduleSyncRequest(taskId = taskId, source = Protocol.SCHEDULE_SOURCE_WATCH), + ScheduleSyncRequest(taskId = taskId, source = Protocol.SCHEDULE_SOURCE_WATCH, classId = classId), ), ) } diff --git a/wearos/app/src/main/java/com/remoteci/watch/data/Protocol.kt b/wearos/app/src/main/java/com/remoteci/watch/data/Protocol.kt index d092acb..2e1d813 100644 --- a/wearos/app/src/main/java/com/remoteci/watch/data/Protocol.kt +++ b/wearos/app/src/main/java/com/remoteci/watch/data/Protocol.kt @@ -139,6 +139,7 @@ data class CapabilitiesSync( @Serializable data class ClassStateSnapshot( + @SerialName("classId") val classId: String? = null, @SerialName("scheduleDate") val scheduleDate: String? = null, @SerialName("currentSubject") val currentSubject: String? = null, @SerialName("nextClassSubject") val nextClassSubject: String? = null, @@ -166,6 +167,7 @@ data class ClassStateSnapshot( data class ScheduleSyncRequest( @SerialName("taskId") val taskId: String, val source: Int = Protocol.SCHEDULE_SOURCE_WATCH, + @SerialName("classId") val classId: String? = null, ) @Serializable @@ -181,6 +183,7 @@ data class ScheduleSyncStatus( @Serializable data class ScheduleBundle( + @SerialName("classId") val classId: String? = null, @SerialName("fromDate") val fromDate: String = "", @SerialName("generatedAt") val generatedAt: String? = null, val days: List = emptyList(), @@ -212,6 +215,7 @@ data class SubjectEntry(val id: String, val name: String) @Serializable data class ClassEvent( + @SerialName("classId") val classId: String? = null, val id: String = "", val event: Int, val subject: String? = null, @@ -274,6 +278,7 @@ data class ConnectionBootstrapInfo( @Serializable data class CommandMessage( val command: Int, + @SerialName("classId") val classId: String? = null, @SerialName("scheduleChange") val scheduleChange: ScheduleChangeRequest? = null, val notification: NotificationRequest? = null, @SerialName("voiceMessage") val voiceMessage: VoiceMessageRequest? = null, @@ -306,6 +311,7 @@ data class CommandResult( @Serializable data class ExtensionDefinition( + @SerialName("classId") val classId: String? = null, val id: String, @SerialName("displayName") val displayName: String, val icon: String? = null, @@ -323,6 +329,19 @@ data class ExtensionParameter( val options: List = emptyList(), ) +@Serializable +data class ClassSummary( + val id: String = "", + val name: String = "", + @SerialName("roleName") val roleName: String? = null, + val permissions: Int? = null, + @SerialName("visitorEnabled") val visitorEnabled: Boolean = false, + @SerialName("groupName") val groupName: String? = null, +) { + val effectivePermissions: Int + get() = permissions ?: Protocol.PERMISSION_VIEW_CURRENT +} + @Serializable data class UserProfile( val id: String = "", @@ -333,6 +352,7 @@ data class UserProfile( val permissions: Int = Protocol.PERMISSION_VIEW_CURRENT, @SerialName("allowedExtensionIds") val allowedExtensionIds: List? = null, @SerialName("visibleExtensionIds") val visibleExtensionIds: List? = null, + val classes: List? = null, val version: Long = 0, ) { val isAdmin: Boolean get() = role == Protocol.ROLE_ADMIN @@ -358,11 +378,13 @@ data class RefreshSessionRequest( @Serializable data class AuthResponse( @SerialName("accessToken") val accessToken: String, + @SerialName("passwordPending") val passwordPending: Boolean? = null, + @SerialName("setupToken") val setupToken: String? = null, @SerialName("accessExpiresAt") val accessExpiresAt: String, @SerialName("deviceSessionId") val deviceSessionId: String, @SerialName("deviceSecret") val deviceSecret: String, @SerialName("deviceExpiresAt") val deviceExpiresAt: String, - val user: UserProfile, + val user: UserProfile = UserProfile(), ) @Serializable diff --git a/wearos/app/src/main/java/com/remoteci/watch/ui/RemoteCiApp.kt b/wearos/app/src/main/java/com/remoteci/watch/ui/RemoteCiApp.kt index 7d46640..7749019 100644 --- a/wearos/app/src/main/java/com/remoteci/watch/ui/RemoteCiApp.kt +++ b/wearos/app/src/main/java/com/remoteci/watch/ui/RemoteCiApp.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.withContext private enum class Screen { Login, + ClassPicker, Home, ScheduleOverview, ScheduleDatePicker, @@ -145,7 +146,10 @@ fun RemoteCiApp(context: Context) { } LaunchedEffect(currentUser, availableCapabilities) { val user = currentUser - if (user != null && screen == Screen.Login) screen = Screen.Home + if (user != null && screen == Screen.Login) { + // 多班级账号登录后先选班级;单班级账号直接进首页。 + screen = if (ConnectionManager.classes.value.size > 1) Screen.ClassPicker else Screen.Home + } if (user != null && (!user.has(Protocol.PERMISSION_MANAGE_SCHEDULE) || Protocol.CAP_SCHEDULE_CHANGE !in availableCapabilities) && screen in listOf( @@ -239,6 +243,10 @@ fun RemoteCiApp(context: Context) { }, ) + Screen.ClassPicker -> ClassPickerScreen( + onPicked = { screen = Screen.Home }, + ) + Screen.Home -> { val scheduleDate = displayedSnapshot?.scheduleDate ?: today.toString() val scheduleDay = displayedSchedule?.days?.firstOrNull { it.date == scheduleDate } @@ -279,6 +287,9 @@ fun RemoteCiApp(context: Context) { onRetryConnection = { if (ConnectionManager.hasSavedSession()) ConnectionManager.connect(settings) else screen = Screen.Login }, + onSwitchClass = if (ConnectionManager.classes.value.size > 1) { + { screen = Screen.ClassPicker } + } else null, ) } diff --git a/wearos/app/src/main/java/com/remoteci/watch/ui/WatchScreens.kt b/wearos/app/src/main/java/com/remoteci/watch/ui/WatchScreens.kt index 4489781..5f42055 100644 --- a/wearos/app/src/main/java/com/remoteci/watch/ui/WatchScreens.kt +++ b/wearos/app/src/main/java/com/remoteci/watch/ui/WatchScreens.kt @@ -41,17 +41,21 @@ import androidx.compose.material.icons.rounded.PowerSettingsNew import androidx.compose.material.icons.rounded.RestartAlt import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.School +import androidx.compose.material.icons.rounded.Group import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Visibility import androidx.compose.material.icons.rounded.VisibilityOff import androidx.compose.material.icons.rounded.SwapHoriz import androidx.compose.material.icons.rounded.SystemUpdate import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.Slider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -226,6 +230,7 @@ internal fun HomeScreen( onOpenNotification: () -> Unit, onOpenSettings: () -> Unit, onRetryConnection: () -> Unit, + onSwitchClass: (() -> Unit)? = null, ) { var now by remember(snapshot?.generatedAt, snapshot?.timeZoneOffsetMinutes) { mutableStateOf(pluginLocalNow(snapshot?.generatedAt, snapshot?.timeZoneOffsetMinutes, 0L, 0L)) @@ -271,6 +276,7 @@ internal fun HomeScreen( onOpenScheduleChange = onOpenScheduleChange, onOpenNotification = onOpenNotification, onOpenSettings = onOpenSettings, + onSwitchClass = onSwitchClass, ) } } @@ -414,14 +420,20 @@ private fun HomeMenuPage( onOpenScheduleChange: () -> Unit, onOpenNotification: () -> Unit, onOpenSettings: () -> Unit, + onSwitchClass: (() -> Unit)? = null, ) { - val actions = homeActionLabels(user, capabilities).map { label -> - when (label) { - "课表" -> HomeAction(label, Icons.AutoMirrored.Rounded.List, onOpenScheduleOverview) - "换课" -> HomeAction(label, Icons.Rounded.SwapHoriz, onOpenScheduleChange) - "控制" -> HomeAction(label, Icons.Rounded.Wifi, onOpenNotification) - else -> HomeAction(label, Icons.Rounded.Settings, onOpenSettings) + val actions = buildList { + if (onSwitchClass != null) { + add(HomeAction("切换班级", Icons.Rounded.Group, onSwitchClass)) } + homeActionLabels(user, capabilities).map { label -> + when (label) { + "课表" -> HomeAction(label, Icons.AutoMirrored.Rounded.List, onOpenScheduleOverview) + "换课" -> HomeAction(label, Icons.Rounded.SwapHoriz, onOpenScheduleChange) + "控制" -> HomeAction(label, Icons.Rounded.Wifi, onOpenNotification) + else -> HomeAction(label, Icons.Rounded.Settings, onOpenSettings) + } + }.forEach { add(it) } } // 首页菜单不显示“菜单”标题,让选项直接占满一屏,避免旋转翻页后还要滚动。 WatchList(title = stringResource(R.string.home_menu_title), showTitle = false) { @@ -1479,3 +1491,34 @@ internal fun pluginToday( } ?: LocalDateTime.now(offset) return base.plusNanos((nowElapsedMs - baseElapsedMs) * 1_000_000L).toLocalDate() } + +/** 登录后的班级选择屏:多班级账号先选一个进入,进入后可再次进入本屏切换。 */ +@Composable +internal fun ClassPickerScreen(onPicked: () -> Unit) { + val classes by ConnectionManager.classes.collectAsState() + val currentClassId by ConnectionManager.currentClassId.collectAsState() + Column( + modifier = Modifier.fillMaxSize().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("选择班级", style = MaterialTheme.typography.titleMedium) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + classes.forEach { classroom -> + val selected = classroom.id == currentClassId + Text( + text = (if (selected) "● " else "○ ") + classroom.name, + style = MaterialTheme.typography.bodyMedium, + color = if (selected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .clickable { + ConnectionManager.switchClass(classroom.id) + onPicked() + } + .padding(vertical = 6.dp), + ) + } + } + } +}