From 153ad9c80cee52898cc2669eb6ef64a35497544e Mon Sep 17 00:00:00 2001 From: MEMZ_Edge <871412436@qq.com> Date: Thu, 17 Sep 2026 23:27:51 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E6=8E=A7=E5=88=B6=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 16 + .github/workflows/release.yml | 58 +- .gitignore | 8 + README.md | 10 +- android/app/build.gradle.kts | 143 +++ android/app/proguard-rules.pro | 7 + .../NetworkSecurityPolicyInstrumentation.kt | 43 + android/app/src/debug/AndroidManifest.xml | 4 + android/app/src/main/AndroidManifest.xml | 39 + .../java/com/remoteci/mobile/MainActivity.kt | 32 + .../remoteci/mobile/RemoteCiApplication.kt | 6 + .../java/com/remoteci/mobile/data/AdminApi.kt | 262 ++++ .../remoteci/mobile/data/ConnectionManager.kt | 979 +++++++++++++++ .../com/remoteci/mobile/data/EventHistory.kt | 23 + .../mobile/data/LanDiscoveryClient.kt | 124 ++ .../java/com/remoteci/mobile/data/Protocol.kt | 411 +++++++ .../mobile/data/SecureSessionStore.kt | 90 ++ .../com/remoteci/mobile/data/SettingsStore.kt | 160 +++ .../com/remoteci/mobile/data/SnapshotStore.kt | 38 + .../com/remoteci/mobile/data/UpdateManager.kt | 222 ++++ .../com/remoteci/mobile/data/VoiceRecorder.kt | 54 + .../mobile/notif/NotificationHelper.kt | 85 ++ .../remoteci/mobile/notif/UpdateReceiver.kt | 80 ++ .../java/com/remoteci/mobile/ui/Components.kt | 209 ++++ .../com/remoteci/mobile/ui/ControlScreens.kt | 376 ++++++ .../com/remoteci/mobile/ui/ExtensionIcons.kt | 292 +++++ .../com/remoteci/mobile/ui/HomeScreens.kt | 590 +++++++++ .../com/remoteci/mobile/ui/PeopleScreens.kt | 477 ++++++++ .../com/remoteci/mobile/ui/RemoteCiApp.kt | 182 +++ .../com/remoteci/mobile/ui/ScheduleLogic.kt | 197 +++ .../com/remoteci/mobile/ui/SettingsScreens.kt | 440 +++++++ .../main/java/com/remoteci/mobile/ui/Theme.kt | 135 +++ android/app/src/main/res/drawable/ic_stat.xml | 11 + .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 5567 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 3566 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 7792 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 12590 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 17929 bytes android/app/src/main/res/values/strings.xml | 152 +++ android/app/src/main/res/values/themes.xml | 8 + .../main/res/xml/network_security_config.xml | 14 + .../mobile/LauncherIconResourceTest.kt | 46 + .../data/AuthorizationAndNotificationTest.kt | 248 ++++ .../mobile/data/ConnectionManagerFlowTest.kt | 377 ++++++ .../mobile/data/ConnectionManagerLogicTest.kt | 92 ++ .../remoteci/mobile/data/UpdateManagerTest.kt | 193 +++ .../mobile/notif/UpdateReceiverTest.kt | 27 + .../com/remoteci/mobile/ui/RemoteCiAppTest.kt | 15 + android/build.gradle.kts | 26 + android/dev.ps1 | 62 + android/gradle.properties | 8 + android/gradle/gradle-daemon-jvm.properties | 12 + android/gradle/libs.versions.toml | 38 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 47505 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android/gradlew | 248 ++++ android/gradlew.bat | 82 ++ android/settings.gradle.kts | 24 + docs/platform-notes.md | 22 + docs/protocol.md | 10 +- .../Services/ScheduleSyncTaskCoordinator.cs | 1 + remoteci-mobile-m3e-lite.json | 1042 ++++++++++++++-- remoteci-mobile-m3e.json | 1069 +++++++++++++++-- remoteci-schedule-mobile-m3e.json | 1049 ++++++++++++++-- remoteci-schedule-mobile-v2-m3e.json | 1065 ++++++++++++++-- server/RemoteCI.Server/Pages/Index.cshtml | 13 + server/RemoteCI.Server/Pages/Index.cshtml.cs | 7 + server/RemoteCI.Server/Program.cs | 178 +++ server/RemoteCI.Server/RemoteCI.Server.csproj | 1 + .../RemoteCI.Server/Services/PeerRegistry.cs | 3 +- server/RemoteCI.Server/WebSocketHub.cs | 8 +- server/RemoteCI.Server/wwwroot/app.css | 23 + .../tests/RemoteCI.Server.Tests/ApiTests.cs | 25 + .../WebSocketRelayTests.cs | 40 +- shared/RemoteCI.Shared/Protocol.cs | 2 + 75 files changed, 11608 insertions(+), 432 deletions(-) create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/androidTest/java/com/remoteci/mobile/NetworkSecurityPolicyInstrumentation.kt create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/com/remoteci/mobile/MainActivity.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/RemoteCiApplication.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/data/AdminApi.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/data/ConnectionManager.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/data/EventHistory.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/data/LanDiscoveryClient.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/data/Protocol.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/data/SecureSessionStore.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/data/SettingsStore.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/data/SnapshotStore.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/data/UpdateManager.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/data/VoiceRecorder.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/notif/NotificationHelper.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/notif/UpdateReceiver.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/ui/Components.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/ui/ControlScreens.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/ui/ExtensionIcons.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/ui/HomeScreens.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/ui/PeopleScreens.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/ui/RemoteCiApp.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/ui/ScheduleLogic.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/ui/SettingsScreens.kt create mode 100644 android/app/src/main/java/com/remoteci/mobile/ui/Theme.kt create mode 100644 android/app/src/main/res/drawable/ic_stat.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/app/src/main/res/xml/network_security_config.xml create mode 100644 android/app/src/test/java/com/remoteci/mobile/LauncherIconResourceTest.kt create mode 100644 android/app/src/test/java/com/remoteci/mobile/data/AuthorizationAndNotificationTest.kt create mode 100644 android/app/src/test/java/com/remoteci/mobile/data/ConnectionManagerFlowTest.kt create mode 100644 android/app/src/test/java/com/remoteci/mobile/data/ConnectionManagerLogicTest.kt create mode 100644 android/app/src/test/java/com/remoteci/mobile/data/UpdateManagerTest.kt create mode 100644 android/app/src/test/java/com/remoteci/mobile/notif/UpdateReceiverTest.kt create mode 100644 android/app/src/test/java/com/remoteci/mobile/ui/RemoteCiAppTest.kt create mode 100644 android/build.gradle.kts create mode 100644 android/dev.ps1 create mode 100644 android/gradle.properties create mode 100644 android/gradle/gradle-daemon-jvm.properties create mode 100644 android/gradle/libs.versions.toml create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts 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/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..998db6a --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + 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..9959c48 --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/MainActivity.kt @@ -0,0 +1,32 @@ +package com.remoteci.mobile + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.remoteci.mobile.notif.NotificationHelper +import com.remoteci.mobile.ui.RemoteCiApp + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + NotificationHelper.ensureChannel(this) + if (Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + 1001, + ) + } + setContent { RemoteCiApp(applicationContext) } + } +} 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..ce9af1f --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/data/AdminApi.kt @@ -0,0 +1,262 @@ +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(): List = + get("/api/extensions", 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..14717a6 --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/data/ConnectionManager.kt @@ -0,0 +1,979 @@ +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() + private val lanDiscoveryClient = LanDiscoveryClient(okHttp, json) + private lateinit var sessions: SessionStorage + // 以下字段会被 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 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 (currentUser.value?.has(Protocol.PERMISSION_SEND_VOICE_MESSAGES) != true) + 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, + 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) { + if (!::sessions.isInitialized) sessions = SecureSessionStore(context.applicationContext) + } + + /** 仅供 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 + + fun scanLanPlugins() { + discoveryJob?.cancel() + lanPlugins.value = emptyList() + 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 { + 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 + 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!!) + persist(auth) + // 即使随后优先走局域网 WebSocket,WebUI 管理 API 仍需要云端访问令牌。 + // 令牌不能只在 connectCloud() 中保存,否则“局域网已连接”时所有管理页都会报未登录。 + accessToken = auth.accessToken + val session = sessions.load() ?: throw MissingSessionException() + if (plan.preferLanAfterCloudAuthentication) { + // 登录端点会把新设备会话先同步给插件;镜像传播需要时间, + // 因此短间隔重试几次 HMAC 挑战,全部失败再回退云端中转。 + repeat(6) { + delay(400) + if (connectLan(settings, session, attempt)) { + scheduleAccessRefresh(settings, auth.accessExpiresAt, attempt) + return@launch + } + } + } + if (!plan.allowCloudFallback) + throw IOException("云端认证已完成,但局域网连接失败且云端中转已关闭") + connectCloud(settings, auth, attempt) + return@launch + } + + var saved = sessions.load() ?: throw MissingSessionException() + if (settings.username.isNotBlank() && saved.username != settings.username) + throw MissingSessionException() + + // 恢复会话时先换取 WebUI 管理 API 所需的短期访问令牌,再尝试局域网直连。 + // refresh 会轮换设备密钥并同步到插件,因此直连采用刷新后的会话并短暂重试。 + val refreshed = if (plan.allowCloudFallback) refreshCloud(settings, saved) else null + if (refreshed != null) { + persist(refreshed) + accessToken = refreshed.accessToken + saved = sessions.load() ?: throw MissingSessionException() + } + var lanOk = false + if (settings.lanConnectionEnabled && lanEndpointHosts(settings).isNotEmpty()) { + repeat(if (refreshed == null) 1 else 6) { index -> + if (!lanOk) { + if (index > 0) delay(400) + lanOk = connectLan(settings, saved, attempt) + } + } + } + if (lanOk) { + refreshed?.let { scheduleAccessRefresh(settings, it.accessExpiresAt, attempt) } + return@launch + } + if (!plan.allowCloudFallback) throw IOException("局域网连接失败") + connectCloud(settings, refreshed ?: throw IOException("无法刷新云端会话"), attempt) + } catch (_: AuthenticationException) { + state.value = State.Error("用户名或密码错误") + serverVersion.value = null + currentUser.value = null + } catch (_: MissingSessionException) { + state.value = State.Error("请先使用账号密码登录") + serverVersion.value = null + currentUser.value = 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 + // 登录虽成功但连接已失败,残留的用户信息会让界面误判为“在线”。 + currentUser.value = 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) currentUser.value = 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() + 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 (currentUser.value?.has(requiredPermission) != true) { + lastCommandResult.value = CommandResult(false, "FORBIDDEN", "权限不足") + return + } + lastCommandResult.value = null + sendEnvelope( + Envelope( + type = Protocol.TYPE_COMMAND, + messageId = newMessageId(), + payload = json.encodeToJsonElement(CommandMessage.serializer(), command), + ), + ) + } + + private suspend fun connectLan( + settings: WatchSettings, + session: PersistedDeviceSession, + attempt: Int, + ): Boolean { + // 明文 ws:// 直连只允许私网/环回主机,公网候选一律跳过。 + for (host in lanEndpointHosts(settings).filter(::isCleartextSafeHost)) { + if (!connectWebSocket( + url = lanWebSocketUrl(host, settings.lanPort), + successState = State.LanConnected, + session = session, + attempt = attempt, + ) + ) 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 + } + + 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) + 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, + ), + ) + currentUser.value = auth.user + } + + private suspend fun connectWebSocket( + url: String, + successState: State, + session: PersistedDeviceSession?, + attempt: Int, + ): Boolean = try { + // 认证阶段限时:故障或恶意对端完成握手后从不回 auth_state 时(readTimeout=0 + ping 保活 + // 会让死连接永久存活),超时后按连接失败走外层回退,而不是永久停在“连接中”。 + withTimeout(AuthHandshakeTimeoutMs) { + awaitAuthenticatedSocket(url, successState, session, attempt) + } + } catch (_: TimeoutCancellationException) { + false + } + + private suspend fun awaitAuthenticatedSocket( + url: String, + successState: State, + session: PersistedDeviceSession?, + attempt: Int, + ): 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 = okHttp.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 -> + currentUser.value = 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 } + null + } + Protocol.TYPE_SCHEDULE_SYNC -> { + decodePayload(envelope.payload, ScheduleBundle.serializer())?.let { + schedule.value = it + // 兼容未实现状态消息的旧插件:收到新课表本身也可作为成功终态。 + 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 { extensions.value = it } + 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 { events.tryEmit(it) } + 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 + +/** 手动拉取课表等待插件回传的最长时间,与 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(): 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), + ), + ) +} 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/Protocol.kt b/android/app/src/main/java/com/remoteci/mobile/data/Protocol.kt new file mode 100644 index 0000000..2503269 --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/data/Protocol.kt @@ -0,0 +1,411 @@ +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("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, +) + +@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("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 = "", + 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("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("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 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 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 RefreshSessionRequest( + @SerialName("deviceSessionId") val deviceSessionId: String, + @SerialName("deviceSecret") val deviceSecret: String, +) + +@Serializable +data class AuthResponse( + @SerialName("accessToken") val accessToken: String, + @SerialName("accessExpiresAt") val accessExpiresAt: String, + @SerialName("deviceSessionId") val deviceSessionId: String, + @SerialName("deviceSecret") val deviceSecret: String, + @SerialName("deviceExpiresAt") val deviceExpiresAt: String, + val user: 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..cd81059 --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/ui/Components.kt @@ -0,0 +1,209 @@ +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, + 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..b4eef21 --- /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() }.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() }.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..9451bff --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/ui/HomeScreens.kt @@ -0,0 +1,590 @@ +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.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.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 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) + } + 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) + } + } + } +} + +@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)) { + 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..1bfaaff --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/ui/RemoteCiApp.kt @@ -0,0 +1,182 @@ +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 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 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 = { 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..417818b --- /dev/null +++ b/android/app/src/main/java/com/remoteci/mobile/ui/SettingsScreens.kt @@ -0,0 +1,440 @@ +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.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.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.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.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) }, + ) + } + } + } +} + +@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 -> + AppListItem( + title = backup.name, + supporting = "${backup.createdAt ?: ""} · ${backup.size} 字节", + leading = Icons.Rounded.Settings, + trailingText = "恢复", + onClick = { + scope.launch { + runCatching { AdminApi.restoreBackup(backup.name) } + .onSuccess { snackbar.showSnackbar("已请求恢复") } + .onFailure { snackbar.showSnackbar(it.message ?: "恢复失败") } + } + }, + ) + } + } + } + } +} 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 0000000000000000000000000000000000000000..f9ba29f8205c3e89694d8287e390437410b615d5 GIT binary patch literal 5567 zcmb_g^CA=vKMM@@YGz_WH-625Flw)0ckJ*AkOU@%y@z(53x;;cvWD<9ZI8LGe$;7Ll; z_4V9 zrukt0J%SyuW_-X>|yram=ot`70G>$e5uLOElsMvuK zYj7+UD;07pC%)WOBCa0RLXaOWz=vIlkB>*9P<*#0cEGvbAt~F02ZnfL96{7{Rjpcr z+}z(+JYMKE#gGd@0^rg3nh3iw=vPi|aB9bI)O2nWr`hw+>8ZvG?r5vKBMmdkb)~2TPIvFzIB5!&CB${g{H0ULKR#_j?i=SQ;e0+RDrsLN86ThnP zF<>V7sTIzmFeozn-C*h`7Wn`+4vt@;R4QAclUWygb5uASE>+x_mYsv6GJ0kxtcenb z!ghDR{_}gNsI08Jc0>W*)upVRD*Qn4<}{(|qa8B_gYjJHd?0tZ^0}+o|Dbu(V`~($ zxA;8;>qkZ>88ivGuOVJx#b0o(Prv!p2&E#wR#>U3F9zb^0(Xw8@NgLdk zV=dg83DPB8mCel3024@nX6t0$#9a~MBgsVAX_BXUF;o6SSjy1QNxhaHAdU^B|4i1l8{jfmL!kKY=ur=X z6=8#k03N2rI}}qu5vPHBv(@y{?>T)Amtqb(?yLZbt*$%HR-d5&&(Pl`aUY@3psQ1* zj^G<%$8pZF&2)tznfW?LFPrtStI!!sUuG8nbr1yVRC3s|D+KbYTiAD@snicuk7}WJ zl}HEZ`5yc;&7qD4$LW>JXTAs2= z#*`tCP6b>-;7`5H#O1HKp2MYxl7^KYQIjZKZE=Y3scvf76ipQBCNR8(O{jCI0vX`Y z8wG~`Tx?eMR((lHNq}Is8zcxL=}AoKJpDTUQ3?X;Wn45?4`mQbMP{W$#;>uXtl*XH zSH!&tRyxh3k`kW_`bZ?BFhvvPSwTv~o`ck((>{U)2X33eJxJju$TPb|9EdxVL_nJ> zwDez^vkS<@;QJSdLo=ts-`YV4k)<>N$Mpq*)H_)O4GUbNy+iND41YGbGPcjQTXMSV z1u%x^+#~a5WN0X#W=mD&eW%}t2sFlgV%nI$?$4&UKz~KPnTO%yDiH2 z|2^3-t$ha*_}CuL>8ctvz86kUiepObQ^Lf9Kp=Ma_qYH4ULIj6JnwEU_!HN^d#rW= z@!B7G5JQMb#_&Yik9!#Va5zvbkYLrK(Jh96gf=_m?$*|+`~HDiJZ+LGdi1KGNpSG3 zZ*g(4{FbfW1Ew&%0MXvDTn#L}G?#r|e*PEyn0&32c}e#r7eOH*mVf(=%fT;pBX*34 zRIpV+*XK#Ja5bOiR}g)A{V?P!p>J~j244YOoSZAYtRd7a&+@B+uC@zX-pXn0M)tWs zJA(cB+3aP_d|w@??y3HQpP#>!yOjQ;-OD*UQM0e8NEO^;*1)4rN6Fi@2A!E}`4>wc zDgh233gn=$Ace0Kj|fSlWjWKO&|SiEk`)j$mIOqH6jmQFF0JBoo9>Y(v<2F6p4)uwAT zH8q*1oI3R5wDDY%Bm{a5lvv{8r$r?tC?qnQnwmO_8m%Wp`8x1B{R;Wt+3Gj;=Q~r* zLQ|E=oVZa|wC^;sq~ts`m3}vZjPVwkknjh=FNpIk=O7Rr8i;yo)zSH`)~tVcIJnw= zB$HynMIvR4{ZyT9wI_yAqwF4>pdvUD(1#s0{7y;@Vewn}wE5w5>)6B5v3Su=RR6($ z_z!m?y0KU@N!NLA$uD|Y4@;*Ej|W%0u8ngHa~&F%P{ANUTDX|wc;4oR^S!@KL+_~A zNmm@>xO_I&*GcDE{r^bd^S;Mx(H%06IvvQA@?87%h=}}^p1HYsP&LC6%oWJ{D!*js zHbd5T$z3#UFmpy+ao2#E6;`>C8yV_gr+zp-nvEA8QK4RYkmj>06?l1sd)j`9=A^WB zX*hm(vzAv-Kwh)3FT`ORNr#I|X-GhN()m;)>J}BWBecKN_5r(&OJMCE7ySB)SrL$M4YT%=gzwK z#<}7>tlVpOt@RKO9Ix zICjrX0ru9(+hm-bvcG?GpGE80v_)J>%99u8`U%?C<^0PoJ;kB5^dfGAPR_+kQfG9G zGQ2^zp%E7*a*?|-jT-;OsJIm7@_|A)qj|BYTK2WCU-JC?oxqi|579Rvx@fr8@nQ!8 z0J$c2ic`sVL@`lOO%M`xDU*tVc1C^HQ}YT7e;uu2*nK_s=j+*L#m)gGb zENa|f?n|jqPEK9+a~G|b!q3{6N2I` zvQiKf)0^3xA%&)!5t%_A7gZun)gv)b*NkX@Gro2~OSX4?8=?Jh=8xDQ*(!-m%fMz| zBBqVP~R!MHSx>ZGHMo_Wxq+DY}?pVS-O^D$< zZo36MUAR<*8(`qyjg&r@nFn4hS?~onhI@=@#LpR|oS2JBD)Jm}vd15KyY0{C1Dc6~sHPm^wPPaRmysbKT+39WNru zg~rm7M)HP%AMx1zpopVOt?^)1vi!t*pW1vw?8r?FA+2 zB`ielUy8=j!Idx8V|BN(LkM-;v-I_k6UOQf&2zf1LM)39ez2a=L_6&%Zy_eT4b;T= z=2g|csV_P%Es&%0}ttVK7%EqY=wypZ)pp`+}WoeHd&iawk5^>?8FpR_i>_G05q+Ym!9k zzmyaahZVL_)jKk2#xO5xfJj6rWu+r%2PXIBS2&#&v*HVWA#svf0x zQu4Pc_$FYiH(fZO)#JquRSq;NDpp0!n80Um@zYJ@IouD8-COo544UV2Y4W{nLw7V^ z#Y`!OsYkvyjZ&Ir(qaL{du$z8cw=%itoK!yt%AbO(3CNXg<%3V+TNtM$GCuh8~OGg zM@LF7S9=`VS(4284F(;)zrl(X=0x)ab=aXB_nyqf58aClJir;KX;jK_}D7=m6+;$*V@N)M~s1p+sO#kQIAZCTp{YTdfZ5P^9 z)n$whbk*as^)Sr@SjN{cS| zxMEUe57dq9sH0y*d;H#WB<nwgxSW$nFbM|d~Kwjhe+4n#It zdMDegAO=-#V#@NL$g0C@O)B<10l>t}#pd+1ezZ8l2&8&F}yC#SV_P<7? z|7N4fxkwyNoNNIa{w<7!d%!D^r(fOrt8lWOr2I1#s+#`5cndDfOEUF#tn z1ylr2sug_|TYQJRniOL(Dk1&njDRO3TD|klhy0NK3d@%0;QY8MwbSQxYfkX0_V)aa z;B2E2<0Z7m(D>EpEC%pce3OT9-we50AvqUB{Q zbvg{`iki9M&D*swhm7+mu4Z-a)h9e)H08oeFE zBm&+@l2~_{`0&a-VRo3!;O^a12&DlqMY$G}!CF9B$-!F;NbqGZ5J3SP+hyTc)^{r){eO{7D(^ z?Sq6+vIzS(w!a+vU=%TqIlZ}5IVFP;mpGLi?JfUinI!PbzdZOH##Z1i!B*E3 zWq2|ek|X=l_1^LgEc=r!ihf^>^EC8q*s||0V6zj}t+^El2m%O5j^c@rYb>hmiHh?l9Fbv#xIY zmRTkzFk89Cn_*4CgsS!O{8T9~bm~#P%be({JW+{T9@7rHOTRQjd-CwY{PxJ%%sRZa zQa2ABZR_4~Bhli$9lHG|voi>B3k=^JfmExpvv*;3qqAEqJIqy!zgKxwv%Fo?PW4x0 zLETW!*E=Kbb+@j!ZlBGw7ESWzf@TGf#Kv(&opf^GQsHg(rdz?~QPy2|x8|Im7!C?E{FIs?O4Hs=B>qJQ>y$D+gQH+MefLUUl*&8fE?nHKBVwc0zDj={5t$ z^GRP9gFwM6+qDFJ@+b1=tXJeZjl`PO%3oX&#}qnh0p=w|87hWGKrq_|d#1G4I|@=x z&M>GiWmIhJ%ZZ7J?N~5O;qCYggSfXq$f2`BrN1KG)EgN0EFT{-E;+1edjIYBHgQgS zE~oyGp2p^D>>34E35~670>sRAt3hKcF|H)p1j$v#kN4a{2;deh^%KzBMgkdm-6OEGB6r=|2Q! z<3-A?fp^CdI{B=bhNg;z6X9iUX(eX*d;|P^b_oI`T7El|#qP`gn;C`40%kR(yd6(E zmYGq~yu7?GOifd9`##R`N7REJZ>=8liP*kj1RflP$N;ZW~26MjmBQpDlv-| zHDbM5yRBaL!~J|eob&vC&*yW_|2*hNMmh|%5Lz-aG6p?eEz@iD`ft%tUH1&6#fNLZ zRZmO9EVOXHFw75x;2Z8;mQ$4*H9Rl`2nynbJ4T>L0%cYVAg&_51OW@+;(+Exs5$}{R#4V39`Yur>d zOFt`p0-si0E(lw_;yvuPXL|q5Pl<;yvH$;x(V@1?52uTZi#w8bZ-cyTZND7aypb$R zDf7TSZM7c!@#6=ZkWPA6d1E6>oWQ53AlC_PJNK2Yusd}&?FaD^<0abmJ+N{Q2;LmV z!>9vz%Yp)){{H!~mxG=PQT$O9_W^0(c3}1olG8UxgKP$=g+RC-YZJ=K%YVU=p%}}T z-QbHUKS*`*L5gGC)XPOhs_muPT1`+i-{iKE!k_#DZC8dz%zPANrgNvWb2>5_%$D`L z01Cj3LiK3A|%OG z#xRP3`;oc%5dP6Lww$h0;s*xYctK1;`h>Lx5R}0aF0!T7)ilPcC!jX(B~*VRlaPoA z3fSs7C$+cQ%TEWExbNsFh*#vE0TV{5XJix@mT?VMoP$*)(o-bd#Kc7DpFPf|=H^DP z1zEghBOqG@-j^k$lZn9q>gQ6z+qsdC=kEn&2zse%X_{a(%)#_jj?D{}Uz8&+y{++1F$wYWL&b{VIYacvNPc3LHF4cq{ke`jpeL{L76cs zZV1HhY&k?oNQhj%Q`!BKfC#vi@SM85tt}UL2RXrM`!)zz=H7RrNyjx4BH9P}eoJwx z112qi45FBPLNI=k{Ijj?t)0w);qN@Vx}i+P`{EEOK@;-a%k8JejK+HJX(HoB|tYm zHs+)`)z;Iy&-=y(fHRUM$;E7nDpV&=;KSA<4!@n=c5d)rA1aX;&mu#)V^6ulaSoRh zGM(61a_XzS4nDa>9DVMD$hoV7zdaX|iU<5dgE8eFxj|)N-P*WAqnCZ>KsK>-+T=c@ zg!lITx=cW1FD1zgL4Ij1|H=wym(POrz|v}9{I2#qo`wBZV&u7?XkQ73H3#TnNb75B zdqg5JZJR2dc=L&;43m4z>ic{5r^)ng0LtPazHF6n+>l6_MV)oR-M~%DkiGfakoQqR zHZoK-vNto?ZMX20e5vcw`~X1Ev2JFV7r#@jb6< zV{5C|r+sPKgYwc^CB`ch* zYHIX$8C`u)tEOl)x@>wUNBQUAgM=d0h*Q$U#DoqYmW-GhG#2h5rctBHAo)zKSXx|s ztj&2iy#}Y~Ek3(z@KzZ`t8G4%t4Hs&IZ+TeoW|YWQ&Uqj?X=U9R5mm)kfaiF(A8UA zU7e)X9)+*7aQ9uUPnayn^^aWW_e|X+P_UYb2AjRB`Ca-d>T~ibDzfNag0zb}MH6wY0I&Jxy7xrYD?s3Psw=cb(e5ulI z3wroYfTp(6Ln3NwwUQnKK?F|GK402LdWN-p6(0{TB@la`3(#Q2l55Ami%W}%&D)uq z&)SB$kl9K75xKss{K-~jVi2sqcrsK(RJ7c_H){0G>a(9X-a&jzi+thTKvdoGl>S{w zrm2Y}B2=SE_k++8W5>pkX2lNVlUaU(xlk=%_;x9wqM{;L)m#RFAeX#tpr|ilm8;=2 zC4R>{`?a^Hl#8Pa+@{S-BQjDIIF3)n4hI4gsMQE(Thi~JdK=kl*4NKj8NDn_8=--r zwOzl)&d<+pu*1wjsr>3u`WftoGJf}kTaq4n`pWywW|y6=L0vOjKw7OBGbOG5m`##& zKp^z$s2;9#0_W1A4%-=Tv_`Pse_VR;d!(yW{ebh24${M{?cgMwczk>e)Q&8c`lXa< zG9u_S)mGHg?(K5hMNk-w*7po)d5OiMN*^Sy%@q74d_RTWR-nuRv(t2xghs_LhO(Fw zsNFQ_B}}Hy507M%*1jLvj$7j#J)H7dWXd0bDi9LOaZxqtvdYTXPPo3Z?m$O}rPe%N zDYy-w;MNP+upZDJcCdUJeM(|FKk6UU&xsDf1aEQJGsY4XIk4}-re@vyx3l7alCFVz zdU}Qur1NV^&W^3WX2{%8lBBDstZdSkT9jI77M>J;WRf2v5EvPvK+JpXsTFX6oAEyNV}IdSr{Go`1OmkTidDjJI^>oX6CuwYAF zF$fj~^^bhMo~1;cP4&2f$H=d(98aFBer7Z-r@o3|Qj2C&8HQTP_{{Kjc<#A$**Ebv z7Zs)rX-ntc>!y&(8~=1TyH3GI5sP4bOvjFjho+n>#7+4Jpv^*LqY2M8mh%$ZpG4BV zAwBJQYF9^jPd=Glpn>%#l`(m9+s<7ksGt-}$cG*}E^@id?CgFYP&so%t)&x=H9j4a zb_(fy93JXS+0;Q@6P$?)Onh}Y(UW?g*z z(vv1$YIT+PGj)l9Z_Zb`WwR$}w@2+%0ma12 zsPk&QiMf_GBA&OH?@ta;{w%iFVTC;KXn)7o#0oLGR^)?L>t$Mq9@BmpMrjb5T4qBh z60afgNBJ5y+jCX+D$$R%x~urO$YsC236UE&*vFw3FBiu%45s?a<=Hn4QBar{fU8^8 z0MsW2wxZeh^!3gV`CW5Q>N6OB zc?jV7g-ni%t!TT!*I!GazwHnu{%@Ye*3ylY#Q$2cRxMT4)NV-$Uq|Kd!nDD1%X=c43cvgfK@;(R`*5){gk`MkQOK9M!&Mhe?P zw%bU1Mi0E(67ewF0WF63MIOc)?a<8nZ>YAGVZ4;}_j(K-;HiuTr?T|LwTq(Ot zyh-GA1&Q^95uzOAZhMWt_m!0*h`%p7JUh*ojyQ;PZOu%)`_qC)zhg@}c1LRNL`HoP z!%`Lq`&_wy0N literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5cdcb5dcdb703561b1524a348ca42805ad48bd6d GIT binary patch literal 7792 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 f^`P*{b;zgX&nuatbR7GeJb{(;P} literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..937254860292df91e1da60565615d34476fc47e4 GIT binary patch literal 17929 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 literal 0 HcmV?d00001 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..f272256 --- /dev/null +++ b/android/app/src/test/java/com/remoteci/mobile/data/AuthorizationAndNotificationTest.kt @@ -0,0 +1,248 @@ +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 `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..7d98631 --- /dev/null +++ b/android/app/src/test/java/com/remoteci/mobile/data/ConnectionManagerFlowTest.kt @@ -0,0 +1,377 @@ +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())) + server.enqueue( + MockResponse.Builder().code(200) + .body(json.encodeToString(AuthResponse.serializer(), authResponse())).build(), + ) + 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()) + 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) + 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 0000000000000000000000000000000000000000..eddabd2eef8d94a5437d6168ff9c87a78ff725b3 GIT binary patch literal 47505 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 '} + 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/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/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/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/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..6602b37 100644 --- a/server/RemoteCI.Server/Pages/Users.cshtml +++ b/server/RemoteCI.Server/Pages/Users.cshtml @@ -13,10 +13,9 @@

访客访问

-

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

-
- - +

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

+ +
diff --git a/server/RemoteCI.Server/Pages/Users.cshtml.cs b/server/RemoteCI.Server/Pages/Users.cshtml.cs index b0d07d6..db9d222 100644 --- a/server/RemoteCI.Server/Pages/Users.cshtml.cs +++ b/server/RemoteCI.Server/Pages/Users.cshtml.cs @@ -25,7 +25,6 @@ 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; } public async Task OnGetAsync(CancellationToken ct) => await LoadAsync(ct); @@ -153,10 +152,12 @@ 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(); } @@ -192,9 +193,7 @@ 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; + AutoEnterVisitorPage = await visitorAccess.GetAutoEnterAsync(ct); return Page(); } @@ -237,9 +236,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..e34e4d4 100644 --- a/server/RemoteCI.Server/Pages/Visitor.cshtml +++ b/server/RemoteCI.Server/Pages/Visitor.cshtml @@ -9,6 +9,16 @@
返回登录 +@if (Model.VisitorClasses.Count > 1) +{ + +} @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..4fd3707 100644 --- a/server/RemoteCI.Server/Pages/WebPageModel.cs +++ b/server/RemoteCI.Server/Pages/WebPageModel.cs @@ -3,16 +3,34 @@ 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 Guid CurrentClassId => CurrentClass?.Id ?? Classroom.DefaultId; + + /// + /// 加载当前用户并解析班级上下文。permission 针对账号全局权限(如 AccessWebUi/ManageUsers); + /// 班级内的操作请用 ClassPermissions 判断。 + /// protected async Task RequireAsync(UserPermissions? permission = null) { var id = Users.GetUserId(User); @@ -25,8 +43,39 @@ 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; 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 85d1264..499d761 100644 --- a/server/RemoteCI.Server/Program.cs +++ b/server/RemoteCI.Server/Program.cs @@ -87,6 +87,15 @@ 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 +163,7 @@ await WebSocketHub.HandleAsync( context.RequestServices.GetRequiredService(), context.RequestServices.GetRequiredService(), context.RequestServices.GetRequiredService(), + context.RequestServices.GetRequiredService(), logger); }); @@ -244,45 +254,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)); }); @@ -370,15 +391,137 @@ 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(); }); +// 当前账号可访问的班级:客户端班级选择/切换的数据源。 +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}/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.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; + var result = await classrooms.BatchAsync(request, ct); + 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,11 +548,12 @@ 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, @@ -417,8 +561,12 @@ await identities.GetRoleAsync(id, ct) == UserRole.Admin) 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, }); }); @@ -461,19 +609,28 @@ await identities.GetRoleAsync(id, ct) == UserRole.Admin) try { await roles.DeleteAsync(id, ct); return Results.NoContent(); } catch (IdentityOperationException ex) { return OperationError(ex); } }); -app.MapGet("/api/visitor", async (HttpContext ctx, IdentityCoordinator identities, VisitorAccessSettings visitor, CancellationToken ct) => +// 访客设置: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(); - return Results.Ok(await visitor.GetAsync(ct)); + 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 (VisitorAccessState body, HttpContext ctx, IdentityCoordinator identities, VisitorAccessSettings visitor, CancellationToken ct) => +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(await visitor.SetAsync(body.Enabled, body.AutoEnter, ct)); + return Results.Ok(new { autoEnter = await visitor.SetAutoEnterAsync(body.AutoEnter, ct) }); }); app.MapGet("/api/settings/notifications", async (HttpContext ctx, IdentityCoordinator identities, CancellationToken ct) => { @@ -508,12 +665,16 @@ await identities.GetRoleAsync(id, ct) == UserRole.Admin) await pull.SetIntervalAsync(interval, ct); return Results.Ok(new { intervalMinutes = (int)interval }); }); -app.MapGet("/api/extensions", async (HttpContext ctx, IdentityCoordinator identities, ExtensionPolicyService policies, IStateStore store, CancellationToken ct) => +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(); - var definitions = store.GetLatestExtensions() ?? []; - var items = await policies.ListForUserAsync(principal.User.Id, principal.User.Role, principal.User.Permissions, definitions, ct); + 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, @@ -522,6 +683,7 @@ await identities.GetRoleAsync(id, ct) == UserRole.Admin) 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) => @@ -610,6 +772,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) @@ -645,5 +819,7 @@ static IResult OperationError(IdentityOperationException ex) => Results.Json( 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/Services/AccountRoleService.cs b/server/RemoteCI.Server/Services/AccountRoleService.cs index 3fefbee..766fcc3 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,6 +66,8 @@ 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); } 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..225e616 --- /dev/null +++ b/server/RemoteCI.Server/Services/ClassAccessService.cs @@ -0,0 +1,103 @@ +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.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, + }).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, 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, + }).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); + + /// 解析用户未显式选择班级时的默认班级:第一个可访问班级;没有可访问班级返回 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/ClassroomService.cs b/server/RemoteCI.Server/Services/ClassroomService.cs new file mode 100644 index 0000000..3cd3ecb --- /dev/null +++ b/server/RemoteCI.Server/Services/ClassroomService.cs @@ -0,0 +1,171 @@ +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.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, + 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 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; + } + + /// 整体替换班级成员列表;请求中引用的用户与角色必须存在。 + 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 BatchAsync( + BatchClassOperationRequest request, CancellationToken ct = default) + { + var results = new List(); + foreach (var classId in request.ClassIds.Distinct()) + { + 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 }; + } + + 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..1711aa7 100644 --- a/server/RemoteCI.Server/Services/ConfigurationArchiveService.cs +++ b/server/RemoteCI.Server/Services/ConfigurationArchiveService.cs @@ -30,22 +30,24 @@ 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 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); } public async Task CreateLocalBackupAsync(string source, CancellationToken ct = default) @@ -106,24 +108,44 @@ 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); 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)); + } + 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 +163,23 @@ 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); 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 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..60801f3 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) { @@ -37,18 +38,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 +95,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 翻译,先投影再在内存过滤。 @@ -182,7 +225,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 +246,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 +256,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 +296,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 +312,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; } @@ -323,6 +373,13 @@ public async Task CreateUserAsync(CreateUserRequest request, Cance }; user.Version = await NextVersionAsync(ct); EnsureIdentitySucceeded(await users.CreateAsync(user, request.Password)); + 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 +405,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 +484,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 +519,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 +550,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, @@ -476,7 +571,7 @@ public async Task CreateSyncAsync(CancellationToken ct = default) ServerVersion = AppVersion.Version, ServerCapabilities = RemoteCiCapabilities.Current.ToList(), GeneratedAt = now, - Accounts = accounts, + Accounts = syncedAccounts, Sessions = sessions, }; } @@ -528,13 +623,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 +705,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); @@ -707,15 +805,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 755b96a..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,7 +18,7 @@ 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; @@ -27,8 +28,25 @@ public sealed class PeerRegistry( 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) { @@ -136,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 @@ -187,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); + } /// /// 向指定插件连接发送课表拉取请求:新插件接入时的补齐拉取必须定向发给它自己, @@ -308,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/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 90e557d..ec5da8d 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) @@ -41,7 +43,8 @@ public static async Task HandleAsync( 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, @@ -72,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) @@ -97,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, @@ -148,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); } @@ -295,38 +310,39 @@ 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); + 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); + 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); + session.Store.SaveEvent(classId, value); + await session.Registry.SendEventToWatchesAsync(classId, value, ct); } return true; case Protocol.MessageTypeExtensionsSync: if (ConvertPayload>(envelope.Payload) is { } extensions) { 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: @@ -343,7 +359,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; @@ -375,10 +394,15 @@ private static async Task DispatchUserAsync( 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( source, + classId.Value, session.CancellationToken, - request?.TaskId ?? envelope.MessageId); + request.TaskId); return; default: LogUnhandled(envelope, session); @@ -392,6 +416,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, @@ -420,13 +455,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, @@ -434,32 +485,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 秒"); @@ -468,17 +521,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, "权限不足或扩展未开放"); } @@ -486,12 +541,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 @@ -519,6 +575,7 @@ private sealed class ConnectionSession( ExtensionPolicyService extensionPolicies, AuthorizationSyncService authorizationSync, ScheduleSyncService scheduleSync, + ClassAccessService classAccess, ILogger logger, WebSocket socket, Guid connectionId, @@ -531,6 +588,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/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/tests/RemoteCI.Server.Tests/ApiTests.cs b/server/tests/RemoteCI.Server.Tests/ApiTests.cs index d210246..1944275 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(); @@ -1089,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); @@ -1710,14 +1709,13 @@ public async Task MobileAdminApis_RolesAndVisitorRoundTrip() visitor.EnsureSuccessStatusCode(); var updated = await _client.SendAsync(TestWebApplicationFactory.Bearer( HttpMethod.Put, "/api/visitor", admin.AccessToken, - new VisitorAccessState(true, false))); + new VisitorAutoEnterBody(true))); updated.EnsureSuccessStatusCode(); - var state = await updated.Content.ReadFromJsonAsync(); - Assert.True(state!.Enabled); - Assert.False(state.AutoEnter); + var state = await updated.Content.ReadFromJsonAsync>(); + Assert.NotNull(state); var reset = await _client.SendAsync(TestWebApplicationFactory.Bearer( HttpMethod.Put, "/api/visitor", admin.AccessToken, - new VisitorAccessState(false, false))); + new VisitorAutoEnterBody(false))); reset.EnsureSuccessStatusCode(); } diff --git a/server/tests/RemoteCI.Server.Tests/ClassManagementTests.cs b/server/tests/RemoteCI.Server.Tests/ClassManagementTests.cs new file mode 100644 index 0000000..c8b6123 --- /dev/null +++ b/server/tests/RemoteCI.Server.Tests/ClassManagementTests.cs @@ -0,0 +1,436 @@ +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.Equal(3, listed.Count(x => x.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.Single(afterDisable.Where(x => x.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)); + } + + // ---------- 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 a2360df..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 = @@ -97,6 +98,60 @@ public async Task VisitorPage_IsHiddenUntilEnabledAndOnlyShowsSchedule() Assert.DoesNotContain("class=\"sidebar\"", visitorHtml); } + [Fact] + 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() { @@ -127,29 +182,35 @@ public async Task LoginPage_OffersRememberMeAndUsesPersistentCookieWhenSelected( } [Fact] - public async Task AutoEnter_RequiresVisitorAccessAndCanReturnToLogin() + 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); @@ -162,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); } @@ -176,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}""", diff --git a/server/tests/RemoteCI.Server.Tests/WebSocketRelayTests.cs b/server/tests/RemoteCI.Server.Tests/WebSocketRelayTests.cs index 9335012..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); @@ -526,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); 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..fef982b 100644 --- a/shared/RemoteCI.Shared/Models/AuthModels.cs +++ b/shared/RemoteCI.Shared/Models/AuthModels.cs @@ -89,6 +89,11 @@ 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; } } diff --git a/shared/RemoteCI.Shared/Models/ClassModels.cs b/shared/RemoteCI.Shared/Models/ClassModels.cs new file mode 100644 index 0000000..87c593c --- /dev/null +++ b/shared/RemoteCI.Shared/Models/ClassModels.cs @@ -0,0 +1,129 @@ +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; } +} + +/// 管理员视角的班级详情。 +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; } + + [JsonPropertyName("createdAt")] + public DateTimeOffset CreatedAt { 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; } = []; + + /// 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 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/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..03b8513 100644 --- a/shared/RemoteCI.Shared/Models/Extensions.cs +++ b/shared/RemoteCI.Shared/Models/Extensions.cs @@ -97,12 +97,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..4ea4782 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 From bdda4489049a898330fad1385ceb74785685f824 Mon Sep 17 00:00:00 2001 From: MEMZ_Edge <871412436@qq.com> Date: Sat, 26 Sep 2026 20:54:14 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat(multi-class):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=9A=E7=8F=AD=E7=BA=A7=E9=80=89=E6=8B=A9=E4=B8=8E=E6=8C=89?= =?UTF-8?q?=E7=8F=AD=E7=BA=A7=E9=9A=94=E7=A6=BB=E7=9A=84=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 服务端新增班级实体与授权镜像,登录支持班级维度权限与首登设密挑战 - 插件按班级接收授权镜像并展示归属班级名称 - 移动端新增班级选择器、班级切换与按 classId 过滤的扩展策略接口 --- .../java/com/remoteci/mobile/data/AdminApi.kt | 8 +- .../remoteci/mobile/data/ConnectionManager.kt | 128 ++- .../java/com/remoteci/mobile/data/Protocol.kt | 34 +- .../com/remoteci/mobile/ui/ControlScreens.kt | 4 +- .../com/remoteci/mobile/ui/HomeScreens.kt | 136 ++- .../com/remoteci/mobile/ui/RemoteCiApp.kt | 10 +- .../RemoteCI.Plugin/Services/AccountMirror.cs | 2 + .../Services/RemoteCiService.cs | 3 + .../SettingsPages/RemoteCiSettingsPage.cs | 10 +- .../SettingsPageTests.cs | 11 +- server/RemoteCI.Server/Data/AppDbContext.cs | 17 + server/RemoteCI.Server/Data/AppUser.cs | 7 + server/RemoteCI.Server/Data/Entities.cs | 38 + .../20260926120000_AddClassGroups.Designer.cs | 723 ++++++++++++++++ .../20260926120000_AddClassGroups.cs | 67 ++ ...0260926130000_AddClassSystemV2.Designer.cs | 781 ++++++++++++++++++ .../20260926130000_AddClassSystemV2.cs | 184 +++++ .../Migrations/AppDbContextModelSnapshot.cs | 107 ++- .../RemoteCI.Server/Pages/BatchControl.cshtml | 118 +++ .../Pages/BatchControl.cshtml.cs | 210 +++++ .../RemoteCI.Server/Pages/ClassSelect.cshtml | 38 + .../Pages/ClassSelect.cshtml.cs | 24 + server/RemoteCI.Server/Pages/Classes.cshtml | 111 ++- .../RemoteCI.Server/Pages/Classes.cshtml.cs | 82 +- server/RemoteCI.Server/Pages/Control.cshtml | 62 ++ .../RemoteCI.Server/Pages/Control.cshtml.cs | 112 ++- server/RemoteCI.Server/Pages/Login.cshtml.cs | 31 +- .../Pages/SetupPassword.cshtml | 22 + .../Pages/SetupPassword.cshtml.cs | 69 ++ .../Pages/Shared/_Layout.cshtml | 21 +- server/RemoteCI.Server/Pages/Users.cshtml | 26 + server/RemoteCI.Server/Pages/Users.cshtml.cs | 30 +- server/RemoteCI.Server/Pages/Visitor.cshtml | 10 +- server/RemoteCI.Server/Pages/WebPageModel.cs | 4 + server/RemoteCI.Server/Program.cs | 191 ++++- .../Services/AccountRoleService.cs | 10 + .../Services/ClassAccessService.cs | 31 +- .../Services/ClassBroadcastService.cs | 98 +++ .../Services/ClassroomService.cs | 294 ++++++- .../Services/ConfigurationArchiveService.cs | 22 +- .../Services/IdentityCoordinator.cs | 61 +- .../Services/UserImportService.cs | 94 +++ server/RemoteCI.Server/WebSocketHub.cs | 5 + server/RemoteCI.Server/patch_views1.py | 132 +++ server/RemoteCI.Server/wwwroot/app.css | 86 ++ .../RemoteCI.Server/wwwroot/voice-message.js | 7 +- .../ClassManagementTests.cs | 329 +++++++- shared/RemoteCI.Shared/Models/AuthModels.cs | 51 ++ shared/RemoteCI.Shared/Models/ClassEvent.cs | 5 + shared/RemoteCI.Shared/Models/ClassModels.cs | 161 ++++ .../Models/ClassStateSnapshot.cs | 5 + shared/RemoteCI.Shared/Models/Extensions.cs | 5 + .../RemoteCI.Shared/Models/ScheduleModels.cs | 5 + .../remoteci/watch/data/ConnectionManager.kt | 93 ++- .../java/com/remoteci/watch/data/Protocol.kt | 24 +- .../java/com/remoteci/watch/ui/RemoteCiApp.kt | 13 +- .../com/remoteci/watch/ui/WatchScreens.kt | 55 +- 57 files changed, 4899 insertions(+), 118 deletions(-) create mode 100644 server/RemoteCI.Server/Data/Migrations/20260926120000_AddClassGroups.Designer.cs create mode 100644 server/RemoteCI.Server/Data/Migrations/20260926120000_AddClassGroups.cs create mode 100644 server/RemoteCI.Server/Data/Migrations/20260926130000_AddClassSystemV2.Designer.cs create mode 100644 server/RemoteCI.Server/Data/Migrations/20260926130000_AddClassSystemV2.cs create mode 100644 server/RemoteCI.Server/Pages/BatchControl.cshtml create mode 100644 server/RemoteCI.Server/Pages/BatchControl.cshtml.cs create mode 100644 server/RemoteCI.Server/Pages/ClassSelect.cshtml create mode 100644 server/RemoteCI.Server/Pages/ClassSelect.cshtml.cs create mode 100644 server/RemoteCI.Server/Pages/SetupPassword.cshtml create mode 100644 server/RemoteCI.Server/Pages/SetupPassword.cshtml.cs create mode 100644 server/RemoteCI.Server/Services/ClassBroadcastService.cs create mode 100644 server/RemoteCI.Server/Services/UserImportService.cs create mode 100644 server/RemoteCI.Server/patch_views1.py 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 index ce9af1f..6b57c7e 100644 --- a/android/app/src/main/java/com/remoteci/mobile/data/AdminApi.kt +++ b/android/app/src/main/java/com/remoteci/mobile/data/AdminApi.kt @@ -63,8 +63,12 @@ object AdminApi { suspend fun setSchedulePull(intervalMinutes: Int): SchedulePullSetting = put("/api/settings/schedule-pull", SchedulePullSetting(intervalMinutes), SchedulePullSetting.serializer(), SchedulePullSetting.serializer()) - suspend fun extensions(): List = - get("/api/extensions", ListSerializer(ExtensionPolicyItem.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) } 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 index 728ff81..085173b 100644 --- a/android/app/src/main/java/com/remoteci/mobile/data/ConnectionManager.kt +++ b/android/app/src/main/java/com/remoteci/mobile/data/ConnectionManager.kt @@ -92,6 +92,11 @@ object ConnectionManager { /** 本机、服务端与当前主插件共同支持的功能;旧 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()) @@ -102,7 +107,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", "请更新服务端和插件以支持语音") @@ -113,6 +118,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)) @@ -150,6 +156,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() @@ -237,6 +272,7 @@ object ConnectionManager { state.value = State.Connecting serverVersion.value = null lastCommandResult.value = null + pendingPasswordSetup.value = null extensions.value = emptyList() // 重新连接后以服务端下发的设置快照为准,未同步前 UI 按默认开启处理。 this@ConnectionManager.settings.value = null @@ -245,7 +281,14 @@ object ConnectionManager { activeJob = scope.launch { try { if (plan.bootstrapCloudAuthentication) { - val auth = loginCloud(settings, password!!) + 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() 中保存,否则“局域网已连接”时所有管理页都会报未登录。 @@ -301,11 +344,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 状态或清空用户信息。 @@ -317,7 +360,7 @@ object ConnectionManager { } serverVersion.value = null // 登录虽成功但连接已失败,残留的用户信息会让界面误判为“在线”。 - currentUser.value = null + applyUserProfile(null) } } } @@ -334,7 +377,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 @@ -380,7 +423,7 @@ object ConnectionManager { } lastCommandResult.value = null - val envelope = schedulePullEnvelope() + val envelope = schedulePullEnvelope(currentClassId.value) schedulePullState.value = SchedulePullState.Pulling("正在连接插件…") if (!socket.send(encodeEnvelope(envelope))) { schedulePullState.value = SchedulePullState.Error("发送拉取请求失败,请重试") @@ -478,7 +521,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 } @@ -487,7 +531,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), + ), ), ) } @@ -521,6 +568,33 @@ object ConnectionManager { 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 @@ -582,7 +656,7 @@ object ConnectionManager { deviceExpiresAt = auth.deviceExpiresAt, ), ) - currentUser.value = auth.user + applyUserProfile(auth.user) } private suspend fun connectWebSocket( @@ -699,20 +773,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 } @@ -721,8 +800,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 -> { @@ -740,7 +822,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 -> { @@ -995,14 +1079,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 = protocolJson.encodeToJsonElement( ScheduleSyncRequest.serializer(), - ScheduleSyncRequest(taskId = taskId, source = Protocol.SCHEDULE_SOURCE_MOBILE), + ScheduleSyncRequest(taskId = taskId, source = Protocol.SCHEDULE_SOURCE_MOBILE, classId = classId), ), ) } diff --git a/android/app/src/main/java/com/remoteci/mobile/data/Protocol.kt b/android/app/src/main/java/com/remoteci/mobile/data/Protocol.kt index 2503269..d235c0c 100644 --- a/android/app/src/main/java/com/remoteci/mobile/data/Protocol.kt +++ b/android/app/src/main/java/com/remoteci/mobile/data/Protocol.kt @@ -152,6 +152,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, @@ -179,6 +180,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 @@ -194,6 +196,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(), @@ -226,6 +229,7 @@ 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, @@ -287,6 +291,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, @@ -320,6 +325,7 @@ data class CommandResult( @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, @@ -336,6 +342,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 = "", @@ -346,6 +365,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 @@ -362,6 +382,16 @@ data class UserProfile( @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, @@ -371,11 +401,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/android/app/src/main/java/com/remoteci/mobile/ui/ControlScreens.kt b/android/app/src/main/java/com/remoteci/mobile/ui/ControlScreens.kt index b4eef21..58543f2 100644 --- a/android/app/src/main/java/com/remoteci/mobile/ui/ControlScreens.kt +++ b/android/app/src/main/java/com/remoteci/mobile/ui/ControlScreens.kt @@ -289,7 +289,7 @@ fun ExtensionsScreen(onBack: () -> Unit) { var selected by remember { mutableStateOf(null) } var args by remember { mutableStateOf>(emptyMap()) } androidx.compose.runtime.LaunchedEffect(user) { - policies = runCatching { AdminApi.extensions() }.getOrDefault(emptyList()) + policies = runCatching { AdminApi.extensions(ConnectionManager.currentClassId.value) }.getOrDefault(emptyList()) } Column(Modifier.fillMaxSize()) { TopAppBar(title = { Text("扩展功能") }, navigationIcon = { IconButton(onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "返回") } }) @@ -322,7 +322,7 @@ fun ExtensionsScreen(onBack: () -> Unit) { Switch(policy.enabled, { scope.launch { runCatching { AdminApi.updateExtensionPolicy(policy.id, ExtensionPolicyUpdate(enabled = it, allowNonAdmin = policy.allowNonAdmin, showOnWatch = policy.showOnWatch)) } - policies = runCatching { AdminApi.extensions() }.getOrDefault(policies) + policies = runCatching { AdminApi.extensions(ConnectionManager.currentClassId.value) }.getOrDefault(policies) } }) } diff --git a/android/app/src/main/java/com/remoteci/mobile/ui/HomeScreens.kt b/android/app/src/main/java/com/remoteci/mobile/ui/HomeScreens.kt index 9451bff..0aec3b1 100644 --- a/android/app/src/main/java/com/remoteci/mobile/ui/HomeScreens.kt +++ b/android/app/src/main/java/com/remoteci/mobile/ui/HomeScreens.kt @@ -13,6 +13,7 @@ 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 @@ -35,6 +36,8 @@ 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 @@ -103,6 +106,10 @@ fun LoginScreen( 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() @@ -141,6 +148,64 @@ fun LoginScreen( 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()) @@ -182,6 +247,64 @@ fun LoginScreen( } } +/** 登录后的班级选择页:多班级账号先选一个进入,进入后仍可在首页顶部随时切换。 */ +@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( @@ -214,11 +337,14 @@ fun HomeShell( }, ) { padding -> Box(Modifier.fillMaxSize().padding(padding)) { - 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) }) + 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) }) + } } } } 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 index 96fd507..54dbd78 100644 --- a/android/app/src/main/java/com/remoteci/mobile/ui/RemoteCiApp.kt +++ b/android/app/src/main/java/com/remoteci/mobile/ui/RemoteCiApp.kt @@ -42,6 +42,7 @@ 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 @@ -148,7 +149,14 @@ fun RemoteCiApp(appContext: android.content.Context) { connection = connection, snackbar = snackbar, onSettings = { persist(it) }, - onLoggedIn = { goHome(HomeTab.Today) }, + 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, 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/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/server/RemoteCI.Server/Data/AppDbContext.cs b/server/RemoteCI.Server/Data/AppDbContext.cs index 1bca687..f06f9fd 100644 --- a/server/RemoteCI.Server/Data/AppDbContext.cs +++ b/server/RemoteCI.Server/Data/AppDbContext.cs @@ -18,6 +18,8 @@ public sealed class AppDbContext(DbContextOptions options) 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) { @@ -26,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); }); @@ -75,6 +78,20 @@ protected override void OnModelCreating(ModelBuilder builder) { 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 => { 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 0b89a58..37f96f3 100644 --- a/server/RemoteCI.Server/Data/Entities.cs +++ b/server/RemoteCI.Server/Data/Entities.cs @@ -65,9 +65,47 @@ public sealed class Classroom /// 启用后未登录访客可只读查看本班课表。 public bool VisitorAccessEnabled { 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!; } /// 用户在某个班级中的成员关系与班内角色;一个用户可属于多个班级。 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 5124b2a..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,51 @@ 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") @@ -333,6 +388,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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"); @@ -598,6 +663,35 @@ 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") @@ -663,8 +757,19 @@ 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 index 95a57cf..332d7cd 100644 --- a/server/RemoteCI.Server/Pages/Classes.cshtml +++ b/server/RemoteCI.Server/Pages/Classes.cshtml @@ -5,7 +5,7 @@ }
-

班级与访客

+

班级 · 分组 · 访客

班级管理

@@ -13,15 +13,89 @@

新建班级

每个班级对应一个教室端的 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) { @@ -37,7 +111,18 @@ - + @if (Model.Groups.Count > 0) + { + + } +