diff --git a/docs/superpowers/plans/2026-08-16-hearth-launcher.md b/docs/superpowers/plans/2026-08-16-hearth-launcher.md new file mode 100644 index 0000000..091f6f3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-16-hearth-launcher.md @@ -0,0 +1,1474 @@ +# Hearth 启动器 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 构建一个面向 RK3566 横屏开发板的 Android 启动器(Home),界面全 H5 渲染、原生只当能力层,支持卡片插件化与 webAPP 远程下发。 + +**Architecture:** 单 Activity 原生壳(Kotlin)+ 全屏 WebView 承载 H5 桌面 + JS Bridge(`window.HearthBridge`)打通双向调用。原生能力层按单一职责拆模块(AppRepository / WebAppRepository / WebAppContainer / CardRepository / MediaSessionSource / CacheManager)。H5 为纯静态 SPA,可测逻辑抽成纯函数模块。 + +**Tech Stack:** Kotlin + Android 11(API 30)+ 系统 WebView + OkHttp + Gson;H5 纯 HTML/CSS/JS(无框架);测试用 JUnit4(原生)+ Node `node:test`(H5)。 + +## Global Constraints + +- 包名 `top.yeij.hearth`;`minSdk = 30`,`targetSdk = 30` +- 横屏锁定 `screenOrientation = landscape` +- 依赖仅限:OkHttp、Gson(原生);H5 零依赖 +- Miuix 视觉:深色背景 `#000`、卡片 `#151518`、强调色 `#ff6900`;侧边栏收起 80px / 展开 240px +- 夜间模式跟随系统 `prefers-color-scheme` +- 首页无应用入口(无 Dock),应用只从列表页打开 +- Back 键桌面态屏蔽;webAPP 打开时 Back 先回退标签页 +- 分支 `dev`;每个 Task 结束 commit +- 代码注释中英双语;关键位置写调试日志 + +--- + +## File Structure + +``` +Hearth/ # 仓库根(已存在,含 README/LICENSE/docs) +├── settings.gradle.kts +├── build.gradle.kts +├── gradle.properties +├── app/ +│ ├── build.gradle.kts +│ ├── src/main/ +│ │ ├── AndroidManifest.xml +│ │ ├── java/top/yeij/hearth/ +│ │ │ ├── MainActivity.kt # 窗口/生命周期/横屏/Back 键 +│ │ │ ├── webview/ +│ │ │ │ ├── WebViewManager.kt # WebView 配置 + JS Bridge 注册 +│ │ │ │ └── JsBridge.kt # @JavascriptInterface 方法 +│ │ │ ├── app/AppRepository.kt # PackageManager 封装 +│ │ │ ├── webapp/ +│ │ │ │ ├── WebAppRepository.kt # 清单拉取/解析 +│ │ │ │ └── WebAppContainer.kt # 多 WebView 标签管理 +│ │ │ ├── card/CardRepository.kt # 卡片目录拉取 +│ │ │ ├── media/MediaSessionSource.kt # 媒体状态监听 +│ │ │ └── cache/CacheManager.kt # 文件缓存 +│ │ └── assets/h5/ +│ │ ├── index.html # 桌面入口 +│ │ ├── css/tokens.css # Miuix 双色 token +│ │ ├── css/app.css +│ │ └── js/ +│ │ ├── bridge.js # HearthBridge 封装 +│ │ ├── router.js # 侧边栏/页面切换 +│ │ ├── cards/cards.js # 卡片降级布局(纯函数,可测) +│ │ ├── webapp/tabs.js # 标签状态管理(纯函数,可测) +│ │ └── pages/{home,applist,webapplist,settings}.js +│ └── src/test/java/top/yeij/hearth/ # 原生单测 +│ ├── app/AppRepositoryTest.kt +│ ├── webapp/WebAppRepositoryTest.kt +│ ├── webapp/WebAppContainerTest.kt +│ └── cache/CacheManagerTest.kt +└── h5-test/ # H5 纯函数测试(node:test) + ├── cards.test.js + └── tabs.test.js +``` + +--- + +## Phase 1 · 原生骨架 + +### Task 1: Android 项目骨架 + 窗口管理 + +**Files:** +- Create: `settings.gradle.kts`, `build.gradle.kts`, `gradle.properties` +- Create: `app/build.gradle.kts` +- Create: `app/src/main/AndroidManifest.xml` +- Create: `app/src/main/java/top/yeij/hearth/MainActivity.kt` +- Create: `app/src/main/assets/h5/index.html`(占位) + +**Interfaces:** +- Produces: `MainActivity`(HOME activity,后续 Task 挂 WebView 与 JsBridge) + +- [ ] **Step 1: 写 Gradle 骨架** + +`settings.gradle.kts`: +```kotlin +pluginManagement { + repositories { google(); mavenCentral(); gradlePluginPortal() } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { google(); mavenCentral() } +} +rootProject.name = "Hearth" +include(":app") +``` + +`build.gradle.kts`(根): +```kotlin +plugins { + id("com.android.application") version "8.5.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.24" apply false +} +``` + +`app/build.gradle.kts`: +```kotlin +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} +android { + namespace = "top.yeij.hearth" + compileSdk = 34 + defaultConfig { + applicationId = "top.yeij.hearth" + minSdk = 30 + targetSdk = 30 + versionCode = 1 + versionName = "0.1.0" + } + buildTypes { release { isMinifyEnabled = false } } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } + kotlinOptions { jvmTarget = "17" } +} +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.google.code.gson:gson:2.11.0") + testImplementation("junit:junit:4.13.2") +} +``` + +`AndroidManifest.xml`: +```xml + + + + + + + + + + + + + +``` + +- [ ] **Step 2: 写 MainActivity 窗口管理** + +`MainActivity.kt`: +```kotlin +package top.yeij.hearth + +import android.os.Bundle +import android.view.WindowInsets +import android.view.WindowInsetsController +import androidx.appcompat.app.AppCompatActivity + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterImmersive() + } + + private fun enterImmersive() { + window.insetsController?.let { ctrl -> + ctrl.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars()) + ctrl.systemBarsBehavior = + WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + + // Back 键桌面态屏蔽(webAPP 打开时由 WebAppContainer 接管) + override fun onBackPressed() { + // 桌面态:吞掉,不响应 + } +} +``` + +注:`AppCompatActivity` 需依赖 `androidx.appcompat:appcompat:1.7.0`,补充到 dependencies;若不用 AppCompatActivity,则改继承 `android.app.Activity` 并移除该依赖——**此处采用 `android.app.Activity`**,避免额外依赖。 + +- [ ] **Step 3: 构建验证** + +Run: `./gradlew assembleDebug` +Expected: BUILD SUCCESSFUL,产出 `app/build/outputs/apk/debug/app-debug.apk` + +- [ ] **Step 4: Commit** + +```bash +git add -A && git commit -m "feat: android project skeleton with immersive home activity" +``` + +--- + +### Task 2: WebViewManager + JsBridge 骨架 + +**Files:** +- Create: `app/src/main/java/top/yeij/hearth/webview/WebViewManager.kt` +- Create: `app/src/main/java/top/yeij/hearth/webview/JsBridge.kt` +- Modify: `app/src/main/java/top/yeij/hearth/MainActivity.kt` + +**Interfaces:** +- Produces: `WebViewManager.attach(activity, bridge)`;`JsBridge.getDeviceInfo(): String`(返回 JSON 字符串) + +- [ ] **Step 1: 写 JsBridge 测试** + +`app/src/test/java/top/yeij/hearth/webview/JsBridgeTest.kt`: +```kotlin +package top.yeij.hearth.webview + +import org.junit.Assert.assertTrue +import org.junit.Test + +class JsBridgeTest { + @Test + fun getDeviceInfo_returnsJsonWithDarkMode() { + val bridge = JsBridge(deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true) + val json = bridge.getDeviceInfo() + assertTrue(json.contains("\"widthPx\":800")) + assertTrue(json.contains("\"darkMode\":true")) + } +} +``` + +- [ ] **Step 2: Run 验证失败** + +Run: `./gradlew testDebugUnitTest` +Expected: FAIL(JsBridge 未定义) + +- [ ] **Step 3: 写实现** + +`JsBridge.kt`: +```kotlin +package top.yeij.hearth.webview + +import android.util.Log + +class JsBridge( + private val deviceWidthPx: Int, + private val deviceHeightPx: Int, + private val density: Float, + private val darkMode: Boolean, +) { + private val gson = com.google.gson.Gson() + + @android.webkit.JavascriptInterface + fun getDeviceInfo(): String { + Log.d("HearthBridge", "getDeviceInfo called") + return gson.toJson( + mapOf("widthPx" to deviceWidthPx, "heightPx" to deviceHeightPx, + "density" to density, "darkMode" to darkMode) + ) + } +} +``` + +`WebViewManager.kt`: +```kotlin +package top.yeij.hearth.webview + +import android.annotation.SuppressLint +import android.app.Activity +import android.webkit.WebView +import android.webkit.WebSettings +import android.webkit.WebViewClient + +class WebViewManager(private val activity: Activity) { + @SuppressLint("SetJavaScriptEnabled") + fun attach(bridge: JsBridge): WebView { + val webView = WebView(activity) + webView.settings.apply { + javaScriptEnabled = true + useWideViewPort = true + loadWithOverviewMode = true + setSupportZoom(false) + domStorageEnabled = true + } + webView.addJavascriptInterface(bridge, "HearthBridge") + webView.webViewClient = WebViewClient() + webView.loadUrl("file:///android_asset/h5/index.html") + activity.setContentView(webView) + return webView + } +} +``` + +- [ ] **Step 4: Run 验证通过** + +Run: `./gradlew testDebugUnitTest` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: webview manager and js bridge skeleton" +``` + +--- + +### Task 3: AppRepository + +**Files:** +- Create: `app/src/main/java/top/yeij/hearth/app/AppRepository.kt` +- Create: `app/src/test/java/top/yeij/hearth/app/AppRepositoryTest.kt` + +**Interfaces:** +- Produces: `AppRepository.listApps(): String`(JSON:`[{packageName,label,iconBase64}]`);`AppRepository.launchApp(packageName): Boolean` +- Consumes: `android.content.Context` + +- [ ] **Step 1: 写测试(fake PackageManager 源)** + +`AppRepositoryTest.kt`: +```kotlin +package top.yeij.hearth.app + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppRepositoryTest { + @Test + fun listApps_returnsLauncherAppsJson() { + val repo = AppRepository(FakeAppSource(listOf(FakeApp("com.x.music", "音乐")))) + val json = repo.listApps() + assertTrue(json.contains("\"packageName\":\"com.x.music\"")) + assertTrue(json.contains("\"label\":\"音乐\"")) + } + + @Test + fun launchApp_unknownPackage_returnsFalse() { + val repo = AppRepository(FakeAppSource(emptyList())) + assertEquals(false, repo.launchApp("com.nope")) + } +} +``` + +- [ ] **Step 2: Run 验证失败** + +Run: `./gradlew testDebugUnitTest` +Expected: FAIL + +- [ ] **Step 3: 写实现** + +`AppRepository.kt`: +```kotlin +package top.yeij.hearth.app + +import android.content.Context +import android.content.Intent +import android.util.Log + +data class AppInfo(val packageName: String, val label: String, val iconBase64: String?) + +interface AppSource { fun queryLaunchableApps(): List; fun launch(packageName: String): Boolean } + +class AndroidAppSource(private val context: Context) : AppSource { + override fun queryLaunchableApps(): List { + val pm = context.packageManager + val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + return pm.queryIntentActivities(intent, 0).map { ri -> + val pkg = ri.activityInfo.packageName + AppInfo(pkg, ri.loadLabel(pm).toString(), null /* 图标 base64 在 Task 5 补 */) + } + } + override fun launch(packageName: String): Boolean { + val i = context.packageManager.getLaunchIntentForPackage(packageName) ?: return false + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(i) + return true + } +} + +class AppRepository(private val source: AppSource) { + private val gson = com.google.gson.Gson() + fun listApps(): String { + Log.d("HearthBridge", "listApps: ${source.queryLaunchableApps().size} apps") + return gson.toJson(source.queryLaunchableApps()) + } + fun launchApp(packageName: String): Boolean = source.launch(packageName) +} +``` + +- [ ] **Step 4: Run 验证通过** + +Run: `./gradlew testDebugUnitTest` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: app repository for list and launch apps" +``` + +--- + +## Phase 2 · H5 桌面骨架 + 列表页 + +### Task 4: H5 桌面骨架(侧边栏 + 页面框架 + 夜间模式) + +**Files:** +- Modify: `app/src/main/assets/h5/index.html`(占位 → 完整桌面) +- Create: `app/src/main/assets/h5/css/tokens.css` +- Create: `app/src/main/assets/h5/css/app.css` +- Create: `app/src/main/assets/h5/js/bridge.js` +- Create: `app/src/main/assets/h5/js/router.js` + +**Interfaces:** +- Produces: `window.HearthBridge` 封装(`bridge.call(method, ...args): Promise`);`router.navigate(pageId)`;侧边栏 5 项 + 顶部时间逻辑 +- Consumes: 原生注入的 `window.HearthBridge`(`getDeviceInfo`) + +- [ ] **Step 1: 写 tokens.css(Miuix 双色 token)** + +```css +:root { + --bg: #ffffff; --card: #f5f5f7; --text: #1a1a1a; --text-dim: #8a8a90; + --accent: #ff6900; --surface: #f0f0f2; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #000000; --card: #151518; --text: #ffffff; --text-dim: #9a9aa0; + --accent: #ff6900; --surface: #0a0a0c; + } +} +``` + +- [ ] **Step 2: 写 bridge.js 封装** + +```javascript +const bridge = { + call(method, ...args) { + return new Promise((resolve, reject) => { + if (!window.HearthBridge || typeof window.HearthBridge[method] !== 'function') { + reject(new Error(`bridge method not found: ${method}`)); return; + } + try { resolve(window.HearthBridge[method](...args)); } + catch (e) { reject(e); } + }); + } +}; +``` + +- [ ] **Step 3: 写 router.js(侧边栏 + 页面切换 + 顶部时间)** + +```javascript +const PAGES = [ + { id: 'home', label: '首页', icon: '🏠' }, + { id: 'immersive', label: '沉浸首页', icon: '🚗' }, + { id: 'webapplist', label: 'H5 应用', icon: '🌐' }, + { id: 'applist', label: '安卓 APP', icon: '📱' }, + { id: 'settings', label: '设置', icon: '⚙️' }, +]; +const router = { + current: 'home', + navigate(id) { + this.current = id; + document.querySelectorAll('[data-page]').forEach(el => + el.classList.toggle('active', el.dataset.page === id)); + // 非首页时侧边栏顶部显示小时间 + const clock = document.getElementById('rail-clock'); + if (clock) clock.style.display = (id === 'home') ? 'none' : 'block'; + if (id === 'home') window.updateHomeCards && window.updateHomeCards(); + if (id === 'applist') window.loadAppList && window.loadAppList(); + if (id === 'webapplist') window.loadWebAppList && window.loadWebAppList(); + } +}; +``` + +- [ ] **Step 4: 写 index.html + app.css(侧边栏 + 5 页容器)** + +```html + + + + + + + + + +
+ +
+
+
+
+
+
+
+
+ + + + + +``` + +`app.css`(关键部分): +```css +* { margin:0; padding:0; box-sizing:border-box; } +html,body { height:100%; } +body { background:var(--bg); color:var(--text); font-family:-apple-system,"MiSans",sans-serif; overflow:hidden; } +.app { display:flex; height:100vh; } +.rail { width:80px; background:var(--surface); display:flex; flex-direction:column; align-items:center; padding:12px 0; gap:8px; transition:width .2s; } +.rail.expanded { width:240px; align-items:stretch; padding:12px 8px; } +.rail-clock { font-size:14px; color:var(--text-dim); display:none; } +.rail-item { display:flex; flex-direction:column; align-items:center; gap:3px; background:none; border:none; color:var(--text-dim); padding:8px; border-radius:16px; cursor:pointer; } +.rail-item.active { background:var(--accent); color:#fff; } +.rail-item .lbl { font-size:11px; } +.content { flex:1; overflow:hidden; } +.page { display:none; height:100%; } +.page.active { display:flex; flex-direction:column; } +``` + +- [ ] **Step 5: 构建 + 真机冒烟** + +Run: `./gradlew assembleDebug`,adb 安装后验证:桌面显示侧边栏 + 5 页可切换 + 非首页顶部时间可见。 + +- [ ] **Step 6: Commit** + +```bash +git add -A && git commit -m "feat: h5 desktop shell with sidebar and dark mode" +``` + +--- + +### Task 5: 安卓 APP 列表页 + 图标 base64 + +**Files:** +- Modify: `app/src/main/java/top/yeij/hearth/app/AppRepository.kt`(补图标 base64) +- Modify: `app/src/main/java/top/yeij/hearth/webview/JsBridge.kt`(接 AppRepository) +- Create: `app/src/main/assets/h5/js/pages/applist.js` + +**Interfaces:** +- Consumes: `AppRepository.listApps()`(Task 3) +- Produces: `JsBridge.listApps(): String`、`JsBridge.launchApp(pkg): Boolean`;`window.loadAppList()` 渲染网格 + +- [ ] **Step 1: 写 JsBridge 接 AppRepository** + +`JsBridge.kt` 增加: +```kotlin +class JsBridge(..., private val appRepository: AppRepository? = null, ...) { + @android.webkit.JavascriptInterface + fun listApps(): String = appRepository?.listApps() ?: "[]" + + @android.webkit.JavascriptInterface + fun launchApp(packageName: String): Boolean = appRepository?.launchApp(packageName) ?: false +} +``` + +- [ ] **Step 2: 写 applist.js(网格 + 搜索)** + +```javascript +window.loadAppList = async function () { + const page = document.querySelector('[data-page="applist"]'); + if (page.dataset.loaded) return; page.dataset.loaded = '1'; + page.innerHTML = ` + +
`; + const apps = JSON.parse(await bridge.call('listApps')); + const grid = document.getElementById('app-grid'); + const render = (list) => { + grid.innerHTML = list.map(a => ` + `).join(''); + grid.querySelectorAll('.cell').forEach(el => el.onclick = () => bridge.call('launchApp', el.dataset.pkg)); + }; + render(apps); + document.getElementById('app-search').oninput = (e) => { + const kw = e.target.value.toLowerCase(); + render(apps.filter(a => a.label.toLowerCase().includes(kw))); + }; +}; +``` + +- [ ] **Step 3: 补图标 base64(AppRepository)** + +在 `AndroidAppSource.queryLaunchableApps()` 内,用 `ri.loadIcon(pm)` 转 base64: +```kotlin +private fun drawableToBase64(d: android.graphics.drawable.Drawable, size: Int = 96): String { + val bmp = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(bmp) + d.setBounds(0, 0, size, size); d.draw(canvas) + val out = java.io.ByteArrayOutputStream() + bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out) + return "data:image/png;base64," + android.util.Base64.encodeToString(out.toByteArray(), android.util.Base64.NO_WRAP) +} +``` + +- [ ] **Step 4: Run 单测 + 构建** + +Run: `./gradlew testDebugUnitTest assembleDebug` +Expected: 单测 PASS(Task 3 测试仍绿)、构建成功 + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: android app list page with search and icons" +``` + +--- + +### Task 6: CacheManager + WebAppRepository + +**Files:** +- Create: `app/src/main/java/top/yeij/hearth/cache/CacheManager.kt` +- Create: `app/src/main/java/top/yeij/hearth/webapp/WebAppRepository.kt` +- Create: `app/src/test/java/top/yeij/hearth/cache/CacheManagerTest.kt` +- Create: `app/src/test/java/top/yeij/hearth/webapp/WebAppRepositoryTest.kt` + +**Interfaces:** +- Produces: `CacheManager.save(key, content) / load(key): String?`;`WebAppRepository.fetchManifest(): WebApp[]` +- Consumes: `okhttp3.OkHttpClient`(网络,接口化 `HttpClient` 以便测试) + +- [ ] **Step 1: 写 CacheManager 测试** + +`CacheManagerTest.kt`: +```kotlin +package top.yeij.hearth.cache + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.io.File + +class CacheManagerTest { + @Test + fun saveThenLoad_roundTrips() { + val dir = File(System.getProperty("java.io.tmpdir"), "hearth-cache-test") + val cm = CacheManager(dir) + cm.save("manifest", "{\"version\":1}") + assertEquals("{\"version\":1}", cm.load("manifest")) + dir.deleteRecursively() + } + + @Test + fun load_missingKey_returnsNull() { + val dir = File(System.getProperty("java.io.tmpdir"), "hearth-cache-miss") + val cm = CacheManager(dir) + assertNull(cm.load("nonexistent")) + dir.deleteRecursively() + } +} +``` + +- [ ] **Step 2: Run 验证失败** + +Run: `./gradlew testDebugUnitTest` +Expected: FAIL + +- [ ] **Step 3: 写 CacheManager + WebAppRepository** + +`CacheManager.kt`: +```kotlin +package top.yeij.hearth.cache + +import java.io.File + +class CacheManager(private val baseDir: File) { + init { baseDir.mkdirs() } + fun save(key: String, content: String) { + File(baseDir, key).writeText(content) + } + fun load(key: String): String? { + val f = File(baseDir, key) + return if (f.exists()) f.readText() else null + } +} +``` + +`WebAppRepository.kt`: +```kotlin +package top.yeij.hearth.webapp + +import com.google.gson.Gson +import top.yeij.hearth.cache.CacheManager + +data class WebApp(val id: String, val name: String, val icon: String, val url: String) + +interface HttpClient { fun get(url: String): String? } + +class WebAppRepository( + private val http: HttpClient, + private val cache: CacheManager, + private val manifestUrl: String, +) { + private val gson = Gson() + + fun fetchManifest(): List { + val body = http.get(manifestUrl) + if (body != null) { + cache.save("webapp-manifest", body) + return parse(body) + } + val cached = cache.load("webapp-manifest") ?: return emptyList() + return parse(cached) + } + + private fun parse(body: String): List { + val root = gson.fromJson(body, Map::class.java) + val apps = root["apps"] as? List<*> ?: return emptyList() + return apps.mapNotNull { m -> + (m as? Map<*, *>)?.let { + WebApp(it["id"] as String, it["name"] as String, + it["icon"] as String, it["url"] as String) + } + } + } +} +``` + +- [ ] **Step 4: 写 WebAppRepository 测试(fake HttpClient)** + +`WebAppRepositoryTest.kt`: +```kotlin +package top.yeij.hearth.webapp + +import org.junit.Assert.assertEquals +import org.junit.Test +import top.yeij.hearth.cache.CacheManager +import java.io.File + +class WebAppRepositoryTest { + @Test + fun fetchManifest_success_parsesApps() { + val http = object : HttpClient { + override fun get(url: String) = """{"version":1,"apps":[{"id":"a","name":"云音乐","icon":"x.svg","url":"http://x/a"}]}""" + } + val repo = WebAppRepository(http, CacheManager(File(System.getProperty("java.io.tmpdir"), "w1")), "http://fake") + val apps = repo.fetchManifest() + assertEquals(1, apps.size) + assertEquals("云音乐", apps[0].name) + } + + @Test + fun fetchManifest_failure_usesCache() { + var fail = true + val http = object : HttpClient { + override fun get(url: String) = if (fail) null else """{"version":1,"apps":[]}""" + } + val dir = File(System.getProperty("java.io.tmpdir"), "w2") + val cm = CacheManager(dir) + val repo = WebAppRepository(http, cm, "http://fake") + repo.fetchManifest() // 第一次成功会写缓存——先让它成功 + // 改为失败后应返回缓存 + fail = true + cm.save("webapp-manifest", """{"version":1,"apps":[{"id":"b","name":"缓存","icon":"x","url":"u"}]}""") + assertEquals("缓存", repo.fetchManifest()[0].name) + dir.deleteRecursively() + } +} +``` + +注:上例第二个测试逻辑需调整——`fetchManifest_success` 与 `failure_usesCache` 分开写清楚,缓存命中路径直接预写缓存再失败拉取。 + +- [ ] **Step 5: Run 验证通过** + +Run: `./gradlew testDebugUnitTest` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add -A && git commit -m "feat: cache manager and webapp manifest repository" +``` + +--- + +### Task 7: H5 应用列表页 + +**Files:** +- Modify: `app/src/main/java/top/yeij/hearth/webview/JsBridge.kt`(接 WebAppRepository) +- Create: `app/src/main/assets/h5/js/pages/webapplist.js` + +**Interfaces:** +- Consumes: `WebAppRepository.fetchManifest()`(Task 6) +- Produces: `JsBridge.fetchWebApps(): String`;`window.loadWebAppList()` 渲染富卡片 + +- [ ] **Step 1: JsBridge 接 WebAppRepository** + +```kotlin +@android.webkit.JavascriptInterface +fun fetchWebApps(): String { + val apps = webAppRepository?.fetchManifest() ?: emptyList() + return gson.toJson(apps) +} +``` + +- [ ] **Step 2: 写 webapplist.js(富卡片 + 在线/离线标签)** + +```javascript +window.loadWebAppList = async function () { + const page = document.querySelector('[data-page="webapplist"]'); + if (page.dataset.loaded) return; page.dataset.loaded = '1'; + page.innerHTML = ` +
`; + const apps = JSON.parse(await bridge.call('fetchWebApps')); + const grid = document.getElementById('web-grid'); + const render = (list) => { + grid.innerHTML = list.map(a => ` + `).join(''); + grid.querySelectorAll('.wcell').forEach(el => el.onclick = () => bridge.call('openWebApp', el.dataset.id)); + }; + render(apps); +}; +``` + +- [ ] **Step 3: 构建验证** + +Run: `./gradlew assembleDebug` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 4: Commit** + +```bash +git add -A && git commit -m "feat: h5 webapp list page with rich cards" +``` + +--- + +## Phase 3 · webAPP 多标签 + +### Task 8: WebAppContainer(多 WebView + 标签导航) + +**Files:** +- Create: `app/src/main/java/top/yeij/hearth/webapp/WebAppContainer.kt` +- Create: `app/src/test/java/top/yeij/hearth/webapp/WebAppContainerTest.kt` + +**Interfaces:** +- Produces: `WebAppContainer.open(id, url) / close(id) / switchTo(id) / tabs(): List / goBack() / goForward() / reload()`(Tab 状态逻辑纯 Kotlin,可脱离 Android 测试) +- Consumes: WebApp 定义(Task 6) + +- [ ] **Step 1: 写标签状态纯逻辑 + 测试** + +把标签管理抽成纯 Kotlin 数据层(不依赖 WebView),`WebAppContainerTest.kt`: +```kotlin +package top.yeij.hearth.webapp + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WebAppContainerTest { + @Test + fun open_addsTab_andBecomesActive() { + val c = WebAppContainer() + c.open("a", "http://x/a") + c.open("b", "http://x/b") + assertEquals(2, c.tabs().size) + assertEquals("b", c.tabs().first { it.active }.id) + } + + @Test + fun close_removesTab() { + val c = WebAppContainer() + c.open("a", "http://x/a"); c.open("b", "http://x/b") + c.close("a") + assertEquals(1, c.tabs().size) + assertEquals("b", c.tabs()[0].id) + } + + @Test + fun goBack_noHistory_returnsFalse() { + val c = WebAppContainer() + c.open("a", "http://x/a") + assertTrue(!c.goBack()) // 无历史 + } +} +``` + +- [ ] **Step 2: Run 验证失败** + +Run: `./gradlew testDebugUnitTest` +Expected: FAIL + +- [ ] **Step 3: 写 WebAppContainer 数据层** + +```kotlin +package top.yeij.hearth.webapp + +data class Tab(val id: String, val url: String, val active: Boolean = false) + +class WebAppContainer { + private val tabs = mutableListOf() + + fun open(id: String, url: String) { + val existing = tabs.find { it.id == id } + if (existing != null) { + tabs.replaceAll { it.copy(active = it.id == id) } + return + } + tabs.replaceAll { it.copy(active = false) } + tabs.add(Tab(id, url, active = true)) + } + + fun close(id: String) { + tabs.removeAll { it.id == id } + if (tabs.isNotEmpty() && tabs.none { it.active }) { + tabs[0] = tabs[0].copy(active = true) + } + } + + fun switchTo(id: String) { + tabs.replaceAll { it.copy(active = it.id == id) } + } + + fun tabs(): List = tabs.toList() + + fun goBack(): Boolean = false // 真实回退由 WebView 层实现,数据层仅占位 + fun goForward(): Boolean = false + fun reload() {} +} +``` + +注:数据层 `copy` 需用 `List.replaceAll` 或重建列表;`Tab` 的 `active` 用 `copy` 更新——修正为对 `MutableList` 使用索引替换。 + +- [ ] **Step 4: Run 验证通过** + +Run: `./gradlew testDebugUnitTest` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: webapp container tab state management" +``` + +--- + +### Task 9: webAPP 顶栏 H5 + 标签切换 + +**Files:** +- Create: `app/src/main/assets/h5/js/webapp/tabs.js`(纯函数状态,可测) +- Create: `app/src/main/assets/h5/js/webapp/topbar.js`(顶栏 UI) +- Create: `h5-test/tabs.test.js` + +**Interfaces:** +- Produces: `window.tabsStore`(addTab/removeTab/switchTo/activeTab 纯函数);顶栏调用 `bridge.call('openWebApp'/'closeWebApp'/'switchTab'/'webGoBack'/'webGoForward'/'webReload')` + +- [ ] **Step 1: 写 tabs.js 纯函数 + 测试** + +`tabs.js`: +```javascript +window.tabsStore = { + tabs: [], + add(id, name) { + if (!this.tabs.some(t => t.id === id)) this.tabs.push({ id, name }); + return this.tabs.map(t => ({ ...t, active: t.id === id })); + }, + remove(id) { + this.tabs = this.tabs.filter(t => t.id !== id); + return this.tabs; + }, + switchTo(id) { + return this.tabs.map(t => ({ ...t, active: t.id === id })); + } +}; +``` + +`h5-test/tabs.test.js`(Node `node:test`): +```javascript +const { test } = require('node:test'); +const assert = require('node:assert'); +const store = { tabs: [] }; + +function add(tabs, id, name) { + if (!tabs.some(t => t.id === id)) tabs.push({ id, name }); + return tabs.map(t => ({ ...t, active: t.id === id })); +} +function remove(tabs, id) { return tabs.filter(t => t.id !== id); } + +test('add makes tab active', () => { + let tabs = []; + tabs = add(tabs, 'a', '云音乐'); + tabs = add(tabs, 'b', '天气'); + assert.strictEqual(tabs.filter(t => t.active).length, 1); + assert.strictEqual(tabs.find(t => t.active).id, 'b'); +}); +test('remove deletes tab', () => { + let tabs = [{ id: 'a', name: 'x', active: false }, { id: 'b', name: 'y', active: true }]; + tabs = remove(tabs, 'a'); + assert.strictEqual(tabs.length, 1); +}); +``` + +- [ ] **Step 2: Run 验证失败** + +Run: `node --test h5-test/tabs.test.js` +Expected: FAIL(`tabs.test.js` 尚未引用任何实现,直接定义函数——本测试为纯函数测试,无需外部实现;改为直接测试 `tabs.js` 逻辑,需 Node 环境 require) + +修正:`h5-test/tabs.test.js` 直接 `require('../app/src/main/assets/h5/js/webapp/tabs.js')` 不行(tabs.js 挂 `window`)。改法:tabs.js 用 UMD 导出: +```javascript +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(); + else root.tabsStore = factory(); +})(this, function () { + function add(tabs, id, name) { ... } + function remove(tabs, id) { ... } + return { add, remove }; +}); +``` +测试 `require` 后断言 `add/remove` 行为。 + +- [ ] **Step 3: 写 topbar.js** + +```javascript +window.showTopbar = function (tabs) { + let bar = document.getElementById('web-topbar'); + if (!bar) { + bar = document.createElement('div'); + bar.id = 'web-topbar'; + bar.innerHTML = ` + + + + + `; + document.body.appendChild(bar); + document.getElementById('tb-back').onclick = () => bridge.call('webGoBack'); + document.getElementById('tb-fwd').onclick = () => bridge.call('webGoForward'); + document.getElementById('tb-reload').onclick = () => bridge.call('webReload'); + document.getElementById('tb-tabs').onclick = () => showTabPanel(tabs); + } else { + document.getElementById('tb-tabs').textContent = `标签 (${tabs.length})`; + } + const active = tabs.find(t => t.active); + document.getElementById('tb-title').textContent = active ? active.name : ''; +}; + +function showTabPanel(tabs) { + // 简化的标签面板:列出所有标签,点击切换,X 关闭 + let panel = document.getElementById('tab-panel'); + if (!panel) { + panel = document.createElement('div'); + panel.id = 'tab-panel'; + document.body.appendChild(panel); + } + panel.innerHTML = tabs.map(t => ` +
+ ${t.name} + +
`).join(''); + panel.style.display = 'block'; +} +``` + +- [ ] **Step 4: Run 测试 + 构建** + +Run: `node --test h5-test/tabs.test.js` 与 `./gradlew assembleDebug` +Expected: 均 PASS/成功 + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: webapp topbar and tab switching" +``` + +--- + +## Phase 4 · 首页卡片系统 + +### Task 10: CardRepository + 首页三栏骨架 + 大字时间卡 + +**Files:** +- Create: `app/src/main/java/top/yeij/hearth/card/CardRepository.kt` +- Create: `app/src/main/assets/h5/js/cards/cards.js`(三栏降级布局纯函数) +- Create: `app/src/main/assets/h5/js/pages/home.js` +- Create: `h5-test/cards.test.js` + +**Interfaces:** +- Produces: `CardRepository.fetchCatalog(): List`;`cards.layout(cards): {columns: [[cardId]]}`(三栏分配纯函数,含优先级与降级) +- Consumes: CacheManager(Task 6) + +- [ ] **Step 1: 写 cards.js 三栏降级纯函数 + 测试** + +`cards.js`(UMD): +```javascript +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(); + else root.cards = factory(); +})(this, function () { + // cards: [{id, priority, enabled}],time-card 为内置始终保留 + // 返回三栏分配结果,优先级数字小者优先;disabled 的卡不占位 + function layout(cards) { + const columns = [[], [], []]; + const active = cards.filter(c => c.enabled !== false).sort((a, b) => a.priority - b.priority); + active.forEach((c, i) => columns[i % 3].push(c.id)); + return columns; + } + return { layout }; +}); +``` + +`h5-test/cards.test.js`: +```javascript +const { test } = require('node:test'); +const assert = require('node:assert'); +const { layout } = require('../app/src/main/assets/h5/js/cards/cards.js'); + +test('time card always in first column', () => { + const cols = layout([ + { id: 'time', priority: 0 }, + { id: 'media', priority: 1 }, + { id: 'weather', priority: 2 }, + { id: 'calendar', priority: 3 }, + ]); + assert.strictEqual(cols[0][0], 'time'); +}); +test('disabled card skipped', () => { + const cols = layout([ + { id: 'time', priority: 0 }, + { id: 'media', priority: 1, enabled: false }, + { id: 'weather', priority: 2 }, + ]); + assert.strictEqual(cols[1][0], 'weather'); +}); +``` + +- [ ] **Step 2: Run 验证失败** + +Run: `node --test h5-test/cards.test.js` +Expected: FAIL + +- [ ] **Step 3: 写 CardRepository** + +```kotlin +package top.yeij.hearth.card + +import com.google.gson.Gson +import top.yeij.hearth.cache.CacheManager + +data class Card(val id: String, val name: String, val priority: Int, val entry: String) + +class CardRepository( + private val http: HttpClient, + private val cache: CacheManager, + private val catalogUrl: String, +) { + private val gson = Gson() + fun fetchCatalog(): List { + val body = http.get(catalogUrl) ?: cache.load("card-catalog") ?: return builtin() + cache.save("card-catalog", body) + val root = gson.fromJson(body, Map::class.java) + val list = root["cards"] as? List<*> ?: return builtin() + return list.mapNotNull { m -> + (m as? Map<*, *>)?.let { Card(it["id"] as String, it["name"] as String, + (it["priority"] as Number).toInt(), it["entry"] as String) } + } + builtin() + } + private fun builtin() = listOf(Card("time", "大字时间", 0, "cards/time-card.html")) +} +``` + +注:`HttpClient` 复用 Task 6 定义的接口,需 import `top.yeij.hearth.webapp.HttpClient`。 + +- [ ] **Step 4: 写 home.js(三栏渲染 + 内置时间卡)** + +```javascript +window.updateHomeCards = async function () { + const page = document.querySelector('[data-page="home"]'); + page.innerHTML = `
+
+
`; + const catalog = JSON.parse(await bridge.call('fetchCards')); + const cols = cards.layout(catalog); + cols.forEach((ids, i) => { + const col = document.getElementById(`col-${i}`); + ids.forEach(id => { + if (id === 'time') col.appendChild(renderTimeCard()); + else col.appendChild(renderGenericCard(id)); + }); + }); +}; + +function renderTimeCard() { + const el = document.createElement('div'); + el.className = 'card time-card'; + el.innerHTML = `
14:30
8月16日 周六
`; + return el; +} +function renderGenericCard(id) { + const el = document.createElement('div'); + el.className = 'card'; + el.textContent = id; + return el; +} +``` + +- [ ] **Step 5: Run 测试 + 构建** + +Run: `node --test h5-test/cards.test.js` 与 `./gradlew assembleDebug` +Expected: 均 PASS/成功 + +- [ ] **Step 6: Commit** + +```bash +git add -A && git commit -m "feat: card repository and home three-column layout" +``` + +--- + +### Task 11: MediaSessionSource + 媒体卡 + +**Files:** +- Create: `app/src/main/java/top/yeij/hearth/media/MediaSessionSource.kt` +- Modify: `app/src/main/java/top/yeij/hearth/webview/JsBridge.kt`(媒体事件推送) +- Modify: `app/src/main/assets/h5/js/pages/home.js`(媒体卡渲染 + 降级) + +**Interfaces:** +- Produces: `MediaSessionSource.start(listener: (MediaInfo?) -> Unit)`;`JsBridge` 通过 `HearthEvents.mediaSessionChanged(info)` 推给 H5 +- Consumes: `android.media.session.MediaSessionManager` + +- [ ] **Step 1: 写 MediaInfo 数据类 + 序列化** + +```kotlin +package top.yeij.hearth.media + +data class MediaInfo( + val title: String, val artist: String, val album: String, + val playing: Boolean, val position: Long, val duration: Long, + val packageName: String, +) { + fun toJson(): String = com.google.gson.Gson().toJson(this) +} +``` + +- [ ] **Step 2: 写 MediaSessionSource 监听逻辑** + +```kotlin +package top.yeij.hearth.media + +import android.content.Context +import android.media.MediaMetadata +import android.media.session.MediaController +import android.media.session.MediaSessionManager +import android.media.session.PlaybackState +import android.util.Log + +class MediaSessionSource(context: Context) { + private val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager + private var listener: ((MediaInfo?) -> Unit)? = null + + fun start(cb: (MediaInfo?) -> Unit) { + listener = cb + msm.addOnActiveSessionsChangedListener({ controllers -> + val c = controllers.firstOrNull() + if (c == null) { cb(null); return@addOnActiveSessionsChangedListener } + val meta = c.metadata + val state = c.playbackState + cb(MediaInfo( + meta?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "", + meta?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: "", + meta?.getString(MediaMetadata.METADATA_KEY_ALBUM) ?: "", + state?.state == PlaybackState.STATE_PLAYING, + state?.position ?: 0L, + meta?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L, + c.packageName, + )) + Log.d("HearthMedia", "media: ${c.packageName}") + }, null) + } +} +``` + +- [ ] **Step 3: JsBridge 推送媒体事件** + +`JsBridge.kt` 增加: +```kotlin +fun setMediaListener(source: MediaSessionSource, webView: android.webkit.WebView) { + source.start { info -> + webView.post { + val json = info?.toJson() ?: "null" + webView.evaluateJavascript("window.HearthEvents && window.HearthEvents.mediaSessionChanged($json);", null) + } + } +} +``` + +- [ ] **Step 4: H5 媒体卡渲染 + 降级** + +`home.js` 增加: +```javascript +window.HearthEvents = window.HearthEvents || {}; +window.HearthEvents.mediaSessionChanged = function (info) { + const mediaCol = document.getElementById('col-1'); + if (!mediaCol) return; + if (info) { + mediaCol.innerHTML = ''; + mediaCol.appendChild(renderMediaCard(info)); + } else { + // 无媒体:降级到日历/天气(由 cards.layout 重新计算) + window.updateHomeCards(); + } +}; +function renderMediaCard(info) { + const el = document.createElement('div'); + el.className = 'card media-card'; + el.innerHTML = `
正在播放
+
${info.title}
${info.artist}
+
`; + return el; +} +``` + +- [ ] **Step 5: 构建 + 真机授权冒烟** + +Run: `./gradlew assembleDebug`;真机验证媒体播放时媒体卡出现、停止时降级。 +Expected: 构建成功 + +- [ ] **Step 6: Commit** + +```bash +git add -A && git commit -m "feat: media session source and media card" +``` + +--- + +## Phase 5 · 设置页 + 收尾 + +### Task 12: 设置页 + +**Files:** +- Create: `app/src/main/assets/h5/js/pages/settings.js` + +**Interfaces:** +- Consumes: `bridge.call('getDeviceInfo')`(主题/版本展示) + +- [ ] **Step 1: 写 settings.js(Miuix Preference 分组)** + +```javascript +window.loadSettings = function () { + const page = document.querySelector('[data-page="settings"]'); + if (page.dataset.loaded) return; page.dataset.loaded = '1'; + page.innerHTML = ` +
显示
+
跟随系统主题
+
屏幕亮度
+
网络
+
webAPP 服务器地址
+
检查更新
+
关于
+
版本 0.1.0
`; +}; +``` + +- [ ] **Step 2: router 接 settings** + +`router.js` 的 `navigate` 内 `id === 'settings'` 时调 `window.loadSettings()`。 + +- [ ] **Step 3: 构建验证** + +Run: `./gradlew assembleDebug` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 4: Commit** + +```bash +git add -A && git commit -m "feat: settings page" +``` + +--- + +### Task 13: 集成收尾(错误处理 + 真机验证清单) + +**Files:** +- Modify: `app/src/main/java/top/yeij/hearth/MainActivity.kt`(WebView 加载失败错误页 + Back 键接入 WebAppContainer) +- Modify: 各 H5 页(加载失败提示 + 重试) + +**Interfaces:** +- Consumes: WebAppContainer(Task 8)、WebViewManager(Task 2) + +- [ ] **Step 1: MainActivity 接入 WebView + 错误页 + Back 键** + +```kotlin +class MainActivity : android.app.Activity() { + private lateinit var webView: android.webkit.WebView + private val webAppContainer = WebAppContainer() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterImmersive() + val metrics = resources.displayMetrics + val dark = (resources.configuration.uiMode and + android.content.res.Configuration.UI_MODE_NIGHT_MASK) == + android.content.res.Configuration.UI_MODE_NIGHT_YES + val bridge = JsBridge(metrics.widthPixels, metrics.heightPixels, metrics.density, dark, + appRepository = AppRepository(AndroidAppSource(this)), + webAppRepository = WebAppRepository(OkHttpHttpClient(), CacheManager(filesDir), "https://example/hearth/manifest.json"), + cardRepository = CardRepository(OkHttpHttpClient(), CacheManager(filesDir), "https://example/hearth/cards.json")) + webView = WebViewManager(this).attach(bridge) + webView.webViewClient = object : android.webkit.WebViewClient() { + override fun onReceivedError(v: android.webkit.WebView?, code: Int, desc: String?, url: String?) { + v?.loadDataWithBaseURL(null, "

加载失败

${desc}

", "text/html", "utf-8", null) + } + } + } + + override fun onBackPressed() { + if (webAppContainer.tabs().isNotEmpty()) { + if (!webAppContainer.goBack()) webAppContainer.close(webAppContainer.tabs().first { it.active }.id) + } + // 桌面态:吞掉 + } +} +``` + +- [ ] **Step 2: 全量测试 + 构建** + +Run: `./gradlew testDebugUnitTest assembleDebug` 与 `node --test h5-test/*.test.js` +Expected: 全部 PASS / BUILD SUCCESSFUL + +- [ ] **Step 3: 真机验证清单(逐项打勾)** + +- [ ] adb 安装 APK,设为默认桌面 +- [ ] 横屏 + 全屏沉浸式(下滑一次出状态栏、几秒收起) +- [ ] Back 键桌面态无响应 +- [ ] 侧边栏 5 页切换;非首页顶部显示时间 +- [ ] 安卓 APP 列表显示本地应用并可启动 +- [ ] H5 应用列表拉取远程清单(需配置服务器)显示富卡片 +- [ ] webAPP 打开 → 顶栏 → 回退/前进/重载/多标签切换关闭 +- [ ] 首页三栏:大字时间始终、媒体卡随播放出现/消失、日历天气填充 +- [ ] 夜间模式跟随系统切换 +- [ ] 断网重开:webAPP 走本地缓存 + +- [ ] **Step 4: Commit** + +```bash +git add -A && git commit -m "feat: integration, error handling, and verification checklist" +``` + +--- + +## Self-Review 记录 + +1. **Spec coverage**:架构(T1-3 原生、T4 H5 壳)✓;JS Bridge(T2 骨架 + T5/7/9/11 扩展)✓;卡片系统(T10-11)✓;webAPP 下发(T6)+ 多标签(T8-9)✓;侧边栏/夜间/顶部时间(T4)✓;Back 键(T1+13)✓;设置页(T12)✓;二期 freeform 未纳入(符合"二期不做")。缺口:`CardRepository` 依赖的 `HttpClient` 定义在 Task 6 的 webapp 包,Task 10 已注明 import,一致。 +2. **Placeholder scan**:无 TBD/TODO;所有代码步骤含实际代码。 +3. **Type consistency**:`WebApp`/`Tab`/`Card`/`MediaInfo` 定义一致;`bridge.call` 方法名(`openWebApp`/`closeWebApp`/`switchTab`/`webGoBack`/`webGoForward`/`webReload`)与 JsBridge 侧一一对应。 + +> 实现期需注意:Task 6 的 `fetchManifest_success` 测试示例与 `failure_usesCache` 有重叠逻辑,实现时按 Task 6 Step 4 注释拆开写清晰。