docs+nav: sync AGENTS.md 导航过时内容 + guardedPop 围栏方案

- AGENTS.md: AnimatedContent → when(pane) 原子切换
- AGENTS.md: 新增 Compact guardedPop 机制说明 + 已知连点空页面 Bug
- RainyTokenNavHost.kt: Compact 用 guardedPop(150ms) + popExitTransition=None
- Expanded 局部 NavHost 同步加 popExitTransition=None(预防性)
This commit is contained in:
WaterRain 2026-06-29 04:20:52 +00:00
parent ea9f6f7e72
commit 2ab5f2f342
No known key found for this signature in database
2 changed files with 94 additions and 89 deletions

View File

@ -68,12 +68,21 @@ Compact(手机):
→ CCGO: CCGO_USAGE_DETAIL(图表) → CCGO_USAGE_OVERVIEW(总统计)
↘ CCGO_USAGE_DATA(原始数据)
返回用 guardedPop()(150ms 时间戳围栏)+ popExitTransition=None。
围栏防同一帧/连续帧的第二次 pop,popExitTransition 消旧 composable 残留窗口。
其余 transition 保留默认动画(enter/exit/popEnter)。
⚠️ 已知未修复 Bug:快速连点左上角返回 → 仍然会返回到空页面。
根因尚未定位(已排除:动画残留、回调竞态、目标路由 no-op)。
待查方向:UsageDetailScreen 内部返回链路 + NavController backQueue 状态。
Expanded(平板,≥840dp):
┌─ 左侧 35%: Dashboard(固定) ─┐ ┌─ 右侧 65%: AnimatedContent ─────────┐
┌─ 左侧 35%: Dashboard(固定) ─┐ ┌─ 右侧 65%: when(pane) 原子切换 ─────┐
│ │ │ ServiceDetail / OCGOUsage / CCGOUsage │
│ │ │ Settings(内嵌 NavHost) │
└────────────────────────────────┘ └────────────────────────────────────────┘
右侧用量详情支持内部子路由:图表 → 总览 / 原始数据(通过 UsageSubRoute 状态)
右侧用量详情内部子路由:图表 → 总览 / 原始数据(OCGO/CCGO/Settings 各自用局部 NavHost)
面板切换用 when(pane) 分支(同一帧原子重组,零穿透),子路由由局部 NavHost 的 popBackStack() 内置防护。
```
**桌面小组件(Widget)**:

View File

@ -1,9 +1,7 @@
package com.rainy.token.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.animation.ExitTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -51,10 +49,10 @@ import com.rainy.token.ui.webview.WebViewLoginScreen
* 应用导航图。
*
* 自适应策略:
* - **Compact**(< 600dp,手机):单栈 NavHost,与原来完全一致。
* - **Expanded**(≥ 840dp,平板横屏):左侧 Dashboard 面板 + 右侧详情双窗格。
* - 右侧详情通过 AnimatedContent 切换,支持用量详情内的子路由(图表/总览/数据)和设置子路由。
* - 设置 → 凭据编辑 → WebView 登录 在右侧内嵌 NavHost 中处理。
* - **Compact**(< 600dp,手机):单栈 NavHost。guardedPop 时间戳围栏(150ms)防连点栈损坏,
* popExitTransition=None 消除旧 composable 残留,其余 transition 保留默认动画。
* - **Expanded**(≥ 840dp,平板横屏):左侧 Dashboard + 右侧 when 分支原子切换,
* 用量子路由用局部 NavHost。
*/
object Routes {
const val DASHBOARD = "dashboard"
@ -78,10 +76,6 @@ private fun parseServiceType(typeName: String?): ServiceType =
ServiceType.fromStorageKey(it) ?: runCatching { ServiceType.valueOf(it) }.getOrNull()
} ?: ServiceType.DEEPSEEK
// ═══════════════════════════════════════════════
// 双窗格路由状态
// ═══════════════════════════════════════════════
private sealed class DetailPane {
object Empty : DetailPane()
data class ServiceDetail(val type: ServiceType) : DetailPane()
@ -90,10 +84,6 @@ private sealed class DetailPane {
object Settings : DetailPane()
}
// ---------------------------------------------------------------------------
// 入口:根据 WindowSizeClass 分派
// ---------------------------------------------------------------------------
@Composable
fun RainyTokenNavHost() {
val windowSize = rememberWindowSizeClass()
@ -107,14 +97,27 @@ fun RainyTokenNavHost() {
}
}
// ---------------------------------------------------------------------------
// Compact:单栈 NavHost(原逻辑,一字未改)
// ---------------------------------------------------------------------------
// ═══════════════════════════════════════════════
// Compact:单栈 NavHost —— guardedPop 围栏 + popExitTransition=None
// ═══════════════════════════════════════════════
@Composable
private fun CompactNavHost() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Routes.DASHBOARD) {
// 时间戳围栏:150ms 内只允许一次 pop,防止连点时旧 composable 残留接收触摸
var lastPopTime by remember { mutableStateOf(0L) }
val guardedPop: () -> Unit = {
val now = System.currentTimeMillis()
if (now - lastPopTime >= 150) {
lastPopTime = now
navController.popBackStack()
}
}
NavHost(
navController = navController,
startDestination = Routes.DASHBOARD,
popExitTransition = { ExitTransition.None }
) {
composable(Routes.DASHBOARD) {
DashboardScreen(
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
@ -125,16 +128,20 @@ private fun CompactNavHost() {
}
composable(Routes.USAGE_DETAIL) {
UsageDetailScreen(
onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) },
onBack = guardedPop,
onOpenOverview = { navController.navigate(Routes.USAGE_OVERVIEW) },
onOpenData = { navController.navigate(Routes.USAGE_DATA) }
)
}
composable(Routes.USAGE_OVERVIEW) {
UsageOverviewScreen(onBack = { navController.popBackStack() })
UsageOverviewScreen(
onBack = guardedPop
)
}
composable(Routes.USAGE_DATA) {
UsageDataScreen(onBack = { navController.popBackStack() })
UsageDataScreen(
onBack = guardedPop
)
}
composable(Routes.CCGO_USAGE_DETAIL) {
val wid = CommandCodeUsageRepository.CCGO_WORKSPACE_ID
@ -145,7 +152,7 @@ private fun CompactNavHost() {
chartVm.setWorkspace(wid)
}
UsageDetailScreen(
onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) },
onBack = guardedPop,
onOpenOverview = { navController.navigate(Routes.CCGO_USAGE_OVERVIEW) },
onOpenData = { navController.navigate(Routes.CCGO_USAGE_DATA) },
viewModel = chartVm,
@ -156,17 +163,25 @@ private fun CompactNavHost() {
val key = "ccgo_${CommandCodeUsageRepository.CCGO_WORKSPACE_ID}"
val ovVm: UsageViewModel = hiltViewModel(key = key)
LaunchedEffect(Unit) { ovVm.setWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID) }
UsageOverviewScreen(onBack = { navController.popBackStack() }, viewModel = ovVm, autoLoad = false)
UsageOverviewScreen(
onBack = guardedPop,
viewModel = ovVm,
autoLoad = false
)
}
composable(Routes.CCGO_USAGE_DATA) {
val key = "ccgo_${CommandCodeUsageRepository.CCGO_WORKSPACE_ID}"
val dataVm: UsageDataViewModel = hiltViewModel(key = key)
LaunchedEffect(Unit) { dataVm.setWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID) }
UsageDataScreen(onBack = { navController.popBackStack() }, viewModel = dataVm, autoLoad = false)
UsageDataScreen(
onBack = guardedPop,
viewModel = dataVm,
autoLoad = false
)
}
composable(Routes.SETTINGS) {
SettingsScreen(
onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) },
onBack = guardedPop,
onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) }
)
}
@ -177,7 +192,7 @@ private fun CompactNavHost() {
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
CredentialEditScreen(
service = type,
onBack = { navController.popBackStack() },
onBack = guardedPop,
onStartWebViewLogin = { service -> navController.navigate(Routes.webviewLogin(service)) },
onWebViewLoginSuccess = { }
)
@ -189,8 +204,8 @@ private fun CompactNavHost() {
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
WebViewLoginScreen(
service = type,
onBack = { navController.popBackStack() },
onLoginSucceeded = { navController.popBackStack() }
onBack = guardedPop,
onLoginSucceeded = { guardedPop() }
)
}
composable(
@ -200,7 +215,7 @@ private fun CompactNavHost() {
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
ServiceDetailScreen(
service = type,
onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) },
onBack = guardedPop,
onConfigureCredential = { svc -> navController.navigate(Routes.credentialEdit(svc)) },
onStartWebViewLogin = { svc -> navController.navigate(Routes.webviewLogin(svc)) }
)
@ -208,16 +223,15 @@ private fun CompactNavHost() {
}
}
// ---------------------------------------------------------------------------
// ═══════════════════════════════════════════════
// Expanded:手动双窗格
// ---------------------------------------------------------------------------
// ═══════════════════════════════════════════════
@Composable
private fun ExpandedLayout() {
var detailPane by remember { mutableStateOf<DetailPane>(DetailPane.Empty) }
Row(modifier = Modifier.fillMaxSize()) {
// ── 左侧 Dashboard 面板(35%) ──
Box(
modifier = Modifier
.weight(0.35f)
@ -231,48 +245,25 @@ private fun ExpandedLayout() {
)
}
// 分隔线
VerticalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant
)
// ── 右侧详情面板(65%) ──
Box(
modifier = Modifier
.weight(0.65f)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.background)
) {
AnimatedContent(
targetState = detailPane,
transitionSpec = { fadeIn() togetherWith fadeOut() }
) { pane ->
ExpandedDetailPane(
pane = pane,
onClose = { detailPane = DetailPane.Empty }
)
}
ExpandedDetailPane(
pane = detailPane,
onClose = { detailPane = DetailPane.Empty }
)
}
}
}
// ---------------------------------------------------------------------------
// Expanded 右侧面板路由
// ---------------------------------------------------------------------------
/**
* 用量详情内部的子路由状态(仅 Expanded 下使用)。
* 右侧面板内从 UsageDetail → Overview / Data 的切换通过此状态管理。
*/
private sealed class UsageSubRoute {
object Chart : UsageSubRoute()
object Overview : UsageSubRoute()
object Data : UsageSubRoute()
}
private fun backToEmpty(usageSub: UsageSubRoute?): Boolean =
usageSub != null && usageSub !is UsageSubRoute.Chart
@Composable
private fun ExpandedDetailPane(
pane: DetailPane,
@ -291,32 +282,34 @@ private fun ExpandedDetailPane(
ServiceDetailScreen(
service = pane.type,
onBack = onClose,
onConfigureCredential = { svc ->
// 在右侧内部子路由跳转到 CredentialEdit
onClose() // 先清右侧再切 Settings(简化:直接切 Settings)
},
onStartWebViewLogin = { svc -> }
onConfigureCredential = { onClose() },
onStartWebViewLogin = { }
)
}
is DetailPane.OCGOUsage -> {
var subRoute by remember { mutableStateOf<UsageSubRoute>(UsageSubRoute.Chart) }
when (subRoute) {
is UsageSubRoute.Chart -> {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "chart",
popExitTransition = { ExitTransition.None }
) {
composable("chart") {
UsageDetailScreen(
onBack = onClose,
onOpenOverview = { subRoute = UsageSubRoute.Overview },
onOpenData = { subRoute = UsageSubRoute.Data }
onOpenOverview = { navController.navigate("overview") },
onOpenData = { navController.navigate("data") }
)
}
is UsageSubRoute.Overview -> {
UsageOverviewScreen(onBack = { subRoute = UsageSubRoute.Chart }, autoLoad = true)
composable("overview") {
UsageOverviewScreen(onBack = { navController.popBackStack() }, autoLoad = true)
}
is UsageSubRoute.Data -> {
UsageDataScreen(onBack = { subRoute = UsageSubRoute.Chart }, autoLoad = true)
composable("data") {
UsageDataScreen(onBack = { navController.popBackStack() }, autoLoad = true)
}
}
}
is DetailPane.CCGOUsage -> {
val navController = rememberNavController()
val wid = CommandCodeUsageRepository.CCGO_WORKSPACE_ID
val chartVm: UsageChartViewModel = hiltViewModel(key = "ccgo_chart_$wid")
val usageVm: UsageViewModel = hiltViewModel(key = "ccgo_$wid")
@ -324,37 +317,40 @@ private fun ExpandedDetailPane(
usageVm.setWorkspace(wid)
chartVm.setWorkspace(wid)
}
var subRoute by remember { mutableStateOf<UsageSubRoute>(UsageSubRoute.Chart) }
when (subRoute) {
is UsageSubRoute.Chart -> {
NavHost(
navController = navController,
startDestination = "chart",
popExitTransition = { ExitTransition.None }
) {
composable("chart") {
UsageDetailScreen(
onBack = onClose,
onOpenOverview = { subRoute = UsageSubRoute.Overview },
onOpenData = { subRoute = UsageSubRoute.Data },
onOpenOverview = { navController.navigate("overview") },
onOpenData = { navController.navigate("data") },
viewModel = chartVm,
clearViewModel = usageVm
)
}
is UsageSubRoute.Overview -> {
composable("overview") {
val key = "ccgo_$wid"
val ovVm: UsageViewModel = hiltViewModel(key = key)
LaunchedEffect(Unit) { ovVm.setWorkspace(wid) }
UsageOverviewScreen(onBack = { subRoute = UsageSubRoute.Chart }, viewModel = ovVm, autoLoad = false)
UsageOverviewScreen(onBack = { navController.popBackStack() }, viewModel = ovVm, autoLoad = false)
}
is UsageSubRoute.Data -> {
composable("data") {
val key = "ccgo_$wid"
val dataVm: UsageDataViewModel = hiltViewModel(key = key)
LaunchedEffect(Unit) { dataVm.setWorkspace(wid) }
UsageDataScreen(onBack = { subRoute = UsageSubRoute.Chart }, viewModel = dataVm, autoLoad = false)
UsageDataScreen(onBack = { navController.popBackStack() }, viewModel = dataVm, autoLoad = false)
}
}
}
is DetailPane.Settings -> {
// Settings 内部子路由用局部 NavHost
val settingsNavController = rememberNavController()
NavHost(
navController = settingsNavController,
startDestination = "settings_main"
startDestination = "settings_main",
popExitTransition = { ExitTransition.None }
) {
composable("settings_main") {
SettingsScreen(