Rainytoken/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt
Liuxinyu176 ffc06a38ca feat(account): 多账号体系、Sub2API token 自动刷新与面板按账号隔离
- Trae/WorkBuddy/Sub2API 支持多账号:每账号一张首页卡片、可设默认账号
  (小组件/每日自动签到仅作用于默认账号),新增账号管理页
- 每日自动签到按账号执行,签到结果弹窗汇报(含积分变动)
- 移除独立的 UsageStat 页,用量并入服务详情
- Sub2API 面板 token 过期自动用邮箱+密码重登并回写该账号,
  自动重登失败才引导手动网页登录;并发 401 按账号互斥去重
- Sub2API 面板管理(Key/分组/兑换码)按当前账号隔离
- 首页恢复每账号一张普通卡片(回退叠放卡组样式)并保留历史排序兼容
- WebView 登录自动识别成功、去掉手动确认弹窗;登录后立即刷新余额
- 修复积分占位符泄漏(%1$s /%2$s 积分)
2026-09-15 23:32:51 +08:00

857 lines
38 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.rainy.token.ui
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.rainy.token.R
import com.rainy.token.data.repository.CommandCodeUsageRepository
import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.rememberWindowSizeClass
import com.rainy.token.ui.components.DebugLogScreen
import com.rainy.token.ui.components.TipsScreen
import com.rainy.token.ui.dashboard.DashboardScreen
import com.rainy.token.ui.dashboard.DashboardViewModel
import com.rainy.token.ui.dashboard.UsageChartViewModel
import com.rainy.token.ui.dashboard.UsageDataScreen
import com.rainy.token.ui.dashboard.UsageDataViewModel
import com.rainy.token.ui.dashboard.UsageDetailScreen
import com.rainy.token.ui.dashboard.UsageOverviewScreen
import com.rainy.token.ui.dashboard.UsageViewModel
import com.rainy.token.ui.heatmap.HeatmapScreen
import com.rainy.token.ui.account.ApiManagementScreen
import com.rainy.token.ui.servicedetail.ServiceDetailScreen
import com.rainy.token.ui.settings.AccountManagementScreen
import com.rainy.token.ui.settings.CredentialEditScreen
import com.rainy.token.ui.settings.CredentialEditViewModel
import com.rainy.token.ui.settings.SettingsScreen
import com.rainy.token.ui.theme.inkMuted
import com.rainy.token.ui.theme.StrawberryPink
import com.rainy.token.ui.webview.CodexOAuthScreen
import com.rainy.token.ui.webview.WebViewLoginScreen
import com.rainy.token.ui.webview.WebViewPageScreen
import android.net.Uri
/**
* 应用导航图。
*
* 自适应策略:
* - **Compact**(< 600dp,手机):单栈 NavHost。guardedPop 时间戳围栏(200ms)防连点栈损坏,
* enter/exit/popEnter/popExit 显式使用同一套滑动过渡,避免默认淡入淡出与返回动画混用。
* - **Expanded**(≥ 840dp,平板横屏):左侧 Dashboard + 右侧 when 分支原子切换,
* 用量子路由用局部 NavHost。
*/
object Routes {
const val DASHBOARD = "dashboard"
const val SETTINGS = "settings"
const val API_MANAGEMENT = "api_management"
const val TIPS = "tips"
const val DEBUG_LOG = "debug_log"
const val CODEX_OAUTH = "codex_oauth"
const val CREDENTIAL_EDIT = "credential_edit/{type}?account={account}"
fun credentialEdit(type: ServiceType, accountId: String? = null): String =
if (accountId.isNullOrBlank()) {
"credential_edit/${type.name}"
} else {
"credential_edit/${type.name}?account=${Uri.encode(accountId)}"
}
const val ACCOUNTS = "accounts/{type}"
fun accounts(type: ServiceType) = "accounts/${type.name}"
const val WEBVIEW_LOGIN = "webview_login/{type}?account={account}"
fun webviewLogin(type: ServiceType, accountId: String? = null): String =
if (accountId.isNullOrBlank()) {
"webview_login/${type.name}"
} else {
"webview_login/${type.name}?account=${Uri.encode(accountId)}"
}
const val WEBVIEW_PAGE = "webview_page?url={url}&title={title}"
fun webviewPage(url: String, title: String) =
"webview_page?url=${Uri.encode(url)}&title=${Uri.encode(title)}"
const val SERVICE_DETAIL = "service/{type}?account={account}"
fun serviceDetail(type: ServiceType, accountId: String? = null): String =
if (accountId.isNullOrBlank()) {
"service/${type.name}"
} else {
"service/${type.name}?account=${Uri.encode(accountId)}"
}
const val USAGE_DETAIL = "usage_detail"
const val USAGE_OVERVIEW = "usage_overview"
const val USAGE_DATA = "usage_data"
const val CCGO_USAGE_DETAIL = "ccgo_usage_detail"
const val CCGO_USAGE_OVERVIEW = "ccgo_usage_overview"
const val CCGO_USAGE_DATA = "ccgo_usage_data"
const val HEATMAP = "heatmap"
}
private fun parseServiceType(typeName: String?): ServiceType =
typeName?.let {
ServiceType.fromStorageKey(it) ?: runCatching { ServiceType.valueOf(it) }.getOrNull()
} ?: ServiceType.DEEPSEEK
/** 凭据入口:多账号服务先进账号管理页,其余直达凭据编辑页。 */
private fun credentialEntry(type: ServiceType): String =
if (ServiceType.isMultiAccount(type)) Routes.accounts(type) else Routes.credentialEdit(type)
private sealed class DetailPane {
object Empty : DetailPane()
data class ServiceDetail(val type: ServiceType, val accountId: String? = null) : DetailPane()
object OCGOUsage : DetailPane()
object CCGOUsage : DetailPane()
object Settings : DetailPane()
object ApiManagement : DetailPane()
object Heatmap : DetailPane()
/** 通用外部页面(充值/订阅页),App 内 WebView 打开。 */
data class WebPage(val url: String, val title: String) : DetailPane()
}
@Composable
fun RainyTokenNavHost() {
val windowSize = rememberWindowSizeClass()
val isExpanded = windowSize.widthSizeClass ==
androidx.compose.material3.windowsizeclass.WindowWidthSizeClass.Expanded
if (isExpanded) {
ExpandedLayout()
} else {
CompactNavHost()
}
}
// ═══════════════════════════════════════════════
// Compact:单栈 NavHost —— guardedPop 围栏 + popExit/popEnter=None
// ═══════════════════════════════════════════════
/**
* 非 Compose State 的时间戳围栏。
*
* 为什么不用 mutableStateOf:
* mutableStateOf 写入会触发 CompactNavHost → NavHost 重组。若重组发生在
* AnimatedContent 过渡动画期间,会干扰内部 MutableTransitionState 状态机,
* 导致旧 composable 已移除、新 composable 进入动画被中断后未正确启动 → 空白页。
*
* 连点保护:cooldownMs 内只放行一次 pop。popBackStack() 返回 false 时 reset。
*/
private class PopGuard(
private val cooldownMs: Long = 200
) {
private var lastPopTime: Long = 0L
fun tryAcquire(): Boolean {
val now = System.currentTimeMillis()
if (now - lastPopTime < cooldownMs) return false
lastPopTime = now
return true
}
fun reset() {
lastPopTime = 0L
}
}
@Composable
private fun CompactNavHost() {
val navController = rememberNavController()
// 围栏:200ms 内只允许一次 pop;普通对象持有时间戳,不触发 NavHost 重组
val popGuard = remember { PopGuard() }
val guardedPop: () -> Unit = {
// 起始页无 previousBackStackEntry,直接跳过,避免无意义的 popBackStack() 调用
if (navController.previousBackStackEntry != null && popGuard.tryAcquire()) {
// popBackStack() 返回 false = 已在起始页,reset 围栏不浪费冷却
if (!navController.popBackStack()) {
popGuard.reset()
}
}
}
NavHost(
navController = navController,
startDestination = Routes.DASHBOARD,
enterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(220)
) + fadeIn(animationSpec = tween(90))
},
exitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Left,
animationSpec = tween(220)
) + fadeOut(animationSpec = tween(90))
},
// Navigation Compose 会把 pop 过渡绑定到 Android 13+ predictive back 手势进度,
// 因此这里使用同一套滑动动画,避免默认淡入淡出与返回动画混用。
popExitTransition = {
slideOutOfContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(220)
) + fadeOut(animationSpec = tween(90))
},
popEnterTransition = {
slideIntoContainer(
AnimatedContentTransitionScope.SlideDirection.Right,
animationSpec = tween(220)
) + fadeIn(animationSpec = tween(90))
}
) {
composable(Routes.DASHBOARD) {
val rechargeLabel = stringResource(R.string.action_recharge)
DashboardScreen(
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
onOpenApiManagement = { navController.navigate(Routes.API_MANAGEMENT) },
onOpenService = { type, accountId ->
navController.navigate(Routes.serviceDetail(type, accountId))
},
onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) },
onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) },
onOpenHeatmap = { navController.navigate(Routes.HEATMAP) },
onOpenRecharge = { type ->
ServiceConfigProvider.get(type).externalUrl?.let { url ->
navController.navigate(Routes.webviewPage(url, rechargeLabel))
}
}
)
}
composable(Routes.API_MANAGEMENT) {
val rechargeLabel = stringResource(R.string.action_recharge)
ApiManagementScreen(
onBack = guardedPop,
onConfigure = { type -> navController.navigate(credentialEntry(type)) },
onRecharge = { type ->
ServiceConfigProvider.get(type).externalUrl?.let { url ->
navController.navigate(Routes.webviewPage(url, rechargeLabel))
}
}
)
}
composable(Routes.USAGE_DETAIL) {
UsageDetailScreen(
onBack = guardedPop,
onOpenOverview = { navController.navigate(Routes.USAGE_OVERVIEW) },
onOpenData = { navController.navigate(Routes.USAGE_DATA) }
)
}
composable(Routes.USAGE_OVERVIEW) {
UsageOverviewScreen(
onBack = guardedPop
)
}
composable(Routes.USAGE_DATA) {
UsageDataScreen(
onBack = guardedPop
)
}
composable(Routes.CCGO_USAGE_DETAIL) {
val wid = CommandCodeUsageRepository.CCGO_WORKSPACE_ID
val chartVm: UsageChartViewModel = hiltViewModel(key = "ccgo_chart_$wid")
val usageVm: UsageViewModel = hiltViewModel(key = "ccgo_$wid")
LaunchedEffect(Unit) {
usageVm.setWorkspace(wid)
chartVm.setWorkspace(wid)
}
UsageDetailScreen(
onBack = guardedPop,
onOpenOverview = { navController.navigate(Routes.CCGO_USAGE_OVERVIEW) },
onOpenData = { navController.navigate(Routes.CCGO_USAGE_DATA) },
viewModel = chartVm,
clearViewModel = usageVm
)
}
composable(Routes.CCGO_USAGE_OVERVIEW) {
val key = "ccgo_${CommandCodeUsageRepository.CCGO_WORKSPACE_ID}"
val ovVm: UsageViewModel = hiltViewModel(key = key)
LaunchedEffect(Unit) { ovVm.setWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID) }
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 = guardedPop,
viewModel = dataVm,
autoLoad = false
)
}
composable(Routes.SETTINGS) {
SettingsScreen(
onBack = guardedPop,
onEditCredential = { type -> navController.navigate(credentialEntry(type)) },
onManageAccounts = { type -> navController.navigate(Routes.accounts(type)) },
onOpenTips = { navController.navigate(Routes.TIPS) },
onOpenDebugLog = { navController.navigate(Routes.DEBUG_LOG) }
)
}
composable(Routes.ACCOUNTS) { backStackEntry ->
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
AccountManagementScreen(
service = type,
onBack = guardedPop,
onEditAccount = { svc, accountId ->
navController.navigate(Routes.credentialEdit(svc, accountId))
}
)
}
composable(Routes.TIPS) {
TipsScreen(onBack = guardedPop)
}
composable(Routes.DEBUG_LOG) {
DebugLogScreen(onBack = guardedPop)
}
composable(Routes.CODEX_OAUTH) {
CodexOAuthScreen(
onBack = guardedPop,
onSuccess = guardedPop
)
}
composable(
route = Routes.CREDENTIAL_EDIT,
arguments = listOf(
navArgument("type") { type = NavType.StringType },
navArgument("account") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { backStackEntry ->
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
val accountId = backStackEntry.arguments?.getString("account")
CredentialEditScreen(
service = type,
accountId = accountId,
onBack = guardedPop,
onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) },
onWebViewLoginSuccess = { },
onStartCodexOAuth = { navController.navigate(Routes.CODEX_OAUTH) }
)
}
composable(
route = Routes.WEBVIEW_LOGIN,
arguments = listOf(
navArgument("type") { type = NavType.StringType },
navArgument("account") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { backStackEntry ->
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
val accountId = backStackEntry.arguments?.getString("account")
WebViewLoginScreen(
service = type,
accountId = accountId,
onBack = guardedPop,
onLoginSucceeded = { _, effectiveId ->
// 把实际写入的账号 ID 回传给上一层(凭据编辑页),新建账号模式用于回填
if (!effectiveId.isNullOrBlank()) {
navController.previousBackStackEntry?.savedStateHandle?.set(
CredentialEditViewModel.KEY_LOGIN_ACCOUNT_ID, effectiveId
)
}
// 登录成功后返回上一层(详情页或凭据编辑页),由 ON_RESUME 刷新内容
if (!navController.popBackStack(Routes.SERVICE_DETAIL, inclusive = false)) {
guardedPop()
}
}
)
}
composable(
route = Routes.SERVICE_DETAIL,
arguments = listOf(
navArgument("type") { type = NavType.StringType },
navArgument("account") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { backStackEntry ->
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
val accountId = backStackEntry.arguments?.getString("account")
val rechargeLabel = stringResource(R.string.action_recharge)
ServiceDetailScreen(
service = type,
accountId = accountId,
onBack = guardedPop,
onSwitchAccount = { svc, acc ->
// 切换账号时替换当前详情页(避免同路由栈堆积)
navController.navigate(Routes.serviceDetail(svc, acc)) {
popUpTo(Routes.SERVICE_DETAIL) { inclusive = true }
}
},
onConfigureCredential = { svc -> navController.navigate(credentialEntry(svc)) },
onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) },
onOpenRecharge = { svc ->
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
navController.navigate(Routes.webviewPage(url, rechargeLabel))
}
}
)
}
composable(
route = Routes.WEBVIEW_PAGE,
arguments = listOf(
navArgument("url") { type = NavType.StringType },
navArgument("title") { type = NavType.StringType }
)
) { backStackEntry ->
val url = backStackEntry.arguments?.getString("url").orEmpty()
val title = backStackEntry.arguments?.getString("title").orEmpty()
WebViewPageScreen(url = url, title = title, onBack = guardedPop)
}
composable(Routes.HEATMAP) {
HeatmapScreen(onBack = guardedPop)
}
}
}
// ═══════════════════════════════════════════════
// Expanded:手动双窗格
// ═══════════════════════════════════════════════
@Composable
private fun ExpandedLayout() {
var detailPane by remember { mutableStateOf<DetailPane>(DetailPane.Empty) }
val dashboardVm: DashboardViewModel = hiltViewModel()
// 宽屏布局下 DashboardScreen 永久挂载,不会收到 ON_RESUME。
// 当详情面板关闭时手动触发 Dashboard 刷新凭据状态。
var prevPane by remember { mutableStateOf<DetailPane>(DetailPane.Empty) }
LaunchedEffect(detailPane) {
if (prevPane != DetailPane.Empty && detailPane == DetailPane.Empty) {
dashboardVm.reloadLocalState()
}
prevPane = detailPane
}
val rechargeLabel = stringResource(R.string.action_recharge)
Row(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.weight(0.35f)
.fillMaxHeight()
) {
DashboardScreen(
onOpenSettings = { detailPane = DetailPane.Settings },
onOpenApiManagement = { detailPane = DetailPane.ApiManagement },
onOpenService = { type, accountId ->
detailPane = DetailPane.ServiceDetail(type, accountId)
},
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage },
onOpenHeatmap = { detailPane = DetailPane.Heatmap },
onOpenRecharge = { type ->
ServiceConfigProvider.get(type).externalUrl?.let { url ->
detailPane = DetailPane.WebPage(url = url, title = rechargeLabel)
}
},
viewModel = dashboardVm
)
}
VerticalDivider(
thickness = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant
)
Box(
modifier = Modifier
.weight(0.65f)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.background)
) {
ExpandedDetailPane(
pane = detailPane,
onClose = { detailPane = DetailPane.Empty },
onSwitchAccount = { svc, acc ->
detailPane = DetailPane.ServiceDetail(svc, acc)
},
onCredentialEditClosed = { dashboardVm.reloadLocalState() }
)
}
}
}
@Composable
private fun ExpandedDetailPane(
pane: DetailPane,
onClose: () -> Unit,
onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> },
onCredentialEditClosed: () -> Unit = {}
) {
when (pane) {
is DetailPane.Empty -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(stringResource(R.string.nav_empty_title), style = MaterialTheme.typography.titleMedium, color = inkMuted())
Text(stringResource(R.string.nav_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
}
}
is DetailPane.ServiceDetail -> {
// key(pane.type + accountId):切换服务/账号时重建嵌套导航栈,避免停留在上一个页面
key("${pane.type.name}:${pane.accountId.orEmpty()}") {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "detail",
popExitTransition = { ExitTransition.None }
) {
composable("detail") {
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
ServiceDetailScreen(
service = pane.type,
accountId = pane.accountId,
onBack = onClose,
onSwitchAccount = onSwitchAccount,
onConfigureCredential = { onClose() },
onStartWebViewLogin = { _, _ -> },
onOpenRecharge = { svc ->
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
navController.navigate(Routes.webviewPage(url, rechargeLabel))
}
}
)
}
composable(
route = Routes.WEBVIEW_PAGE,
arguments = listOf(
navArgument("url") { type = NavType.StringType },
navArgument("title") { type = NavType.StringType }
)
) { entry ->
WebViewPageScreen(
url = entry.arguments?.getString("url").orEmpty(),
title = entry.arguments?.getString("title").orEmpty(),
onBack = { navController.popBackStack() }
)
}
}
}
}
is DetailPane.OCGOUsage -> {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "chart",
popExitTransition = { ExitTransition.None }
) {
composable("chart") {
UsageDetailScreen(
onBack = onClose,
onOpenOverview = { navController.navigate("overview") },
onOpenData = { navController.navigate("data") }
)
}
composable("overview") {
UsageOverviewScreen(onBack = { navController.popBackStack() }, 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")
LaunchedEffect(Unit) {
usageVm.setWorkspace(wid)
chartVm.setWorkspace(wid)
}
NavHost(
navController = navController,
startDestination = "chart",
popExitTransition = { ExitTransition.None }
) {
composable("chart") {
UsageDetailScreen(
onBack = onClose,
onOpenOverview = { navController.navigate("overview") },
onOpenData = { navController.navigate("data") },
viewModel = chartVm,
clearViewModel = usageVm
)
}
composable("overview") {
val key = "ccgo_$wid"
val ovVm: UsageViewModel = hiltViewModel(key = key)
LaunchedEffect(Unit) { ovVm.setWorkspace(wid) }
UsageOverviewScreen(onBack = { navController.popBackStack() }, viewModel = ovVm, autoLoad = false)
}
composable("data") {
val key = "ccgo_$wid"
val dataVm: UsageDataViewModel = hiltViewModel(key = key)
LaunchedEffect(Unit) { dataVm.setWorkspace(wid) }
UsageDataScreen(onBack = { navController.popBackStack() }, viewModel = dataVm, autoLoad = false)
}
}
}
is DetailPane.Heatmap -> {
HeatmapScreen(onBack = onClose)
}
is DetailPane.WebPage -> {
WebViewPageScreen(
url = pane.url,
title = pane.title,
onBack = onClose
)
}
is DetailPane.ApiManagement -> {
val apiNavController = rememberNavController()
// 从凭据编辑/WebView 返回到 api_mgmt_main 时刷新 Dashboard 凭据状态
val routeStack by apiNavController.currentBackStackEntryAsState()
val currentRoute = routeStack?.destination?.route
var prevRoute by remember { mutableStateOf("api_mgmt_main") }
LaunchedEffect(currentRoute) {
if (prevRoute != currentRoute && currentRoute == "api_mgmt_main") {
onCredentialEditClosed()
}
prevRoute = currentRoute ?: "api_mgmt_main"
}
NavHost(
navController = apiNavController,
startDestination = "api_mgmt_main",
popExitTransition = { ExitTransition.None }
) {
composable("api_mgmt_main") {
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
ApiManagementScreen(
onBack = onClose,
onConfigure = { type -> apiNavController.navigate(credentialEntry(type)) },
onRecharge = { type ->
ServiceConfigProvider.get(type).externalUrl?.let { url ->
apiNavController.navigate(Routes.webviewPage(url, rechargeLabel))
}
}
)
}
composable(Routes.ACCOUNTS) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
AccountManagementScreen(
service = type,
onBack = { apiNavController.popBackStack() },
onEditAccount = { svc, accountId ->
apiNavController.navigate(Routes.credentialEdit(svc, accountId))
}
)
}
composable(
route = Routes.CREDENTIAL_EDIT,
arguments = listOf(
navArgument("type") { type = NavType.StringType },
navArgument("account") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
val accountId = entry.arguments?.getString("account")
CredentialEditScreen(
service = type,
accountId = accountId,
onBack = { apiNavController.popBackStack() },
onStartWebViewLogin = { svc, acc ->
apiNavController.navigate(Routes.webviewLogin(svc, acc))
},
onWebViewLoginSuccess = { },
onStartCodexOAuth = { apiNavController.navigate(Routes.CODEX_OAUTH) }
)
}
composable(
route = Routes.WEBVIEW_LOGIN,
arguments = listOf(
navArgument("type") { type = NavType.StringType },
navArgument("account") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
val accountId = entry.arguments?.getString("account")
WebViewLoginScreen(
service = type,
accountId = accountId,
onBack = { apiNavController.popBackStack() },
onLoginSucceeded = { _, effectiveId ->
if (!effectiveId.isNullOrBlank()) {
apiNavController.previousBackStackEntry?.savedStateHandle?.set(
CredentialEditViewModel.KEY_LOGIN_ACCOUNT_ID, effectiveId
)
}
apiNavController.popBackStack()
}
)
}
composable(Routes.CODEX_OAUTH) {
CodexOAuthScreen(
onBack = { apiNavController.popBackStack() },
onSuccess = { apiNavController.popBackStack() }
)
}
composable(
route = Routes.WEBVIEW_PAGE,
arguments = listOf(
navArgument("url") { type = NavType.StringType },
navArgument("title") { type = NavType.StringType }
)
) { entry ->
WebViewPageScreen(
url = entry.arguments?.getString("url").orEmpty(),
title = entry.arguments?.getString("title").orEmpty(),
onBack = { apiNavController.popBackStack() }
)
}
}
}
is DetailPane.Settings -> {
val settingsNavController = rememberNavController()
// 监听嵌套 NavHost 路由变化:当从凭据编辑页/WebView 返回 settings_main 时
// 通知 Dashboard 刷新凭据状态(宽屏布局 Dashboard 永久挂载不产生 ON_RESUME)
val routeStack by settingsNavController.currentBackStackEntryAsState()
val currentRoute = routeStack?.destination?.route
var prevRoute by remember { mutableStateOf("settings_main") }
LaunchedEffect(currentRoute) {
if (prevRoute != currentRoute && currentRoute == "settings_main") {
onCredentialEditClosed()
}
prevRoute = currentRoute ?: "settings_main"
}
NavHost(
navController = settingsNavController,
startDestination = "settings_main",
popExitTransition = { ExitTransition.None }
) {
composable("settings_main") {
SettingsScreen(
onBack = onClose,
onEditCredential = { type ->
settingsNavController.navigate(credentialEntry(type))
},
onManageAccounts = { type ->
settingsNavController.navigate(Routes.accounts(type))
},
onOpenTips = { settingsNavController.navigate("tips") },
onOpenDebugLog = { settingsNavController.navigate("debug_log") }
)
}
composable(Routes.ACCOUNTS) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
AccountManagementScreen(
service = type,
onBack = { settingsNavController.popBackStack() },
onEditAccount = { svc, accountId ->
settingsNavController.navigate(Routes.credentialEdit(svc, accountId))
}
)
}
composable("tips") {
TipsScreen(onBack = { settingsNavController.popBackStack() })
}
composable("debug_log") {
DebugLogScreen(onBack = { settingsNavController.popBackStack() })
}
composable(Routes.CODEX_OAUTH) {
CodexOAuthScreen(
onBack = { settingsNavController.popBackStack() },
onSuccess = { settingsNavController.popBackStack() }
)
}
composable(
route = Routes.CREDENTIAL_EDIT,
arguments = listOf(
navArgument("type") { type = NavType.StringType },
navArgument("account") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
val accountId = entry.arguments?.getString("account")
CredentialEditScreen(
service = type,
accountId = accountId,
onBack = { settingsNavController.popBackStack() },
onStartWebViewLogin = { svc, acc ->
settingsNavController.navigate(Routes.webviewLogin(svc, acc))
},
onWebViewLoginSuccess = { },
onStartCodexOAuth = { settingsNavController.navigate(Routes.CODEX_OAUTH) }
)
}
composable(
route = Routes.WEBVIEW_LOGIN,
arguments = listOf(
navArgument("type") { type = NavType.StringType },
navArgument("account") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
val accountId = entry.arguments?.getString("account")
WebViewLoginScreen(
service = type,
accountId = accountId,
onBack = { settingsNavController.popBackStack() },
onLoginSucceeded = { _, effectiveId ->
if (!effectiveId.isNullOrBlank()) {
settingsNavController.previousBackStackEntry?.savedStateHandle?.set(
CredentialEditViewModel.KEY_LOGIN_ACCOUNT_ID, effectiveId
)
}
settingsNavController.popBackStack()
}
)
}
}
}
}
}