From c3ec87e0cdeff0b5385ba6967bb00e394584bd1f Mon Sep 17 00:00:00 2001 From: IGNGserver <176017966+IGNGserver@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:25:51 +0800 Subject: [PATCH 01/13] feat(workflows): unify task picker with directory tree view --- .../app/ui/screens/WorkflowsV2Screen.kt | 849 +++++---- apps/web/src/App.tsx | 1617 ++++++----------- apps/web/src/WorkflowsPage.tsx | 199 +- .../task-directory-picker-modal.tsx | 510 ++++++ 4 files changed, 1562 insertions(+), 1613 deletions(-) create mode 100644 apps/web/src/components/task-directory-picker-modal.tsx diff --git a/apps/mobile/android/app/src/main/java/com/devtodo/app/ui/screens/WorkflowsV2Screen.kt b/apps/mobile/android/app/src/main/java/com/devtodo/app/ui/screens/WorkflowsV2Screen.kt index e7dc0bf..8bcb5a0 100644 --- a/apps/mobile/android/app/src/main/java/com/devtodo/app/ui/screens/WorkflowsV2Screen.kt +++ b/apps/mobile/android/app/src/main/java/com/devtodo/app/ui/screens/WorkflowsV2Screen.kt @@ -5,14 +5,21 @@ import androidx.compose.animation.* import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Surface +import com.devtodo.app.data.local.FolderEntity +import com.devtodo.app.data.local.TaskEntity + import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.AccountTree import androidx.compose.material.icons.filled.Add @@ -24,7 +31,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -36,7 +42,6 @@ import com.devtodo.app.data.local.WorkflowStageEntity import com.devtodo.app.data.local.WorkflowTaskMembershipEntity import com.devtodo.app.data.model.TaskStatus import com.devtodo.app.ui.components.* -import com.devtodo.app.ui.theme.LocalAdaptiveInfo import com.devtodo.app.ui.theme.TaskDockMotion import com.devtodo.app.ui.theme.TaskDockShapes @@ -69,386 +74,252 @@ fun WorkflowsV2Screen( var deleteWorkflow by remember { mutableStateOf(null) } var deleteStage by remember { mutableStateOf(null) } - val adaptive = LocalAdaptiveInfo.current var selectedId by rememberSaveable { mutableStateOf(null) } val selected = workflows.find { it.id == selectedId } - val chosenWorkflowId = selected?.id - - // Detail data is collected at destination level (not inside a lazy item) so - // scrolling the list cannot cancel it. Cached rows seed the first frame and - // `null` keeps "not loaded yet" distinct from "no stages". - val stages: List? = chosenWorkflowId?.let { id -> - key(id) { - remember(id) { viewModel.observeWorkflowStages(id) } - .collectAsStateWithLifecycle(initialValue = viewModel.cachedWorkflowStages(id)) - .value - } - } - val memberships: List? = chosenWorkflowId?.let { id -> - key(id) { - remember(id) { viewModel.observeWorkflowMemberships(id) } - .collectAsStateWithLifecycle(initialValue = viewModel.cachedWorkflowMemberships(id)) - .value - } - } - val listState = rememberLazyListState() - val detailListState = rememberSaveable(chosenWorkflowId, saver = LazyListState.Saver) { LazyListState() } - val kanbanRowState = rememberLazyListState() // The workflow detail lives inside this destination; back closes it first // and only then falls through to the NavHost's predictive back pop. BackHandler(enabled = selected != null) { selectedId = null } WorkspaceScaffold( - topBar = { - TopAppBar( - title = { - Text( - text = selected?.name ?: "流程看板", - fontWeight = FontWeight.Medium, - ) - }, - navigationIcon = { - IconButton(onClick = { - if (selected != null) selectedId = null else onBack?.invoke() - }) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - if (selected != null) "返回流程列表" else "返回目录", - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) - }, - bottomBar = { - if (selected == null) { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - FloatingActionIsland( - onQuickCreate = { title, result -> - viewModel.createWorkflowV2(title, result) - }, - placeholder = "输入新流程名称…", - primaryLabel = "新建流程", - fieldLabel = "流程名称", - supportingText = "把相关任务放进同一个流程", + topBar = { + TopAppBar( + title = { + Text( + text = selected?.name ?: "流程看板", + fontWeight = FontWeight.Medium, + ) + }, + navigationIcon = { + IconButton(onClick = { + if (selected != null) selectedId = null else onBack?.invoke() + }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + if (selected != null) "返回流程列表" else "返回目录", ) } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ) + ) + }, + bottomBar = { + if (selected == null) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + FloatingActionIsland( + onQuickCreate = { title, result -> + viewModel.createWorkflowV2(title, result) + }, + placeholder = "输入新流程名称…", + primaryLabel = "新建流程", + fieldLabel = "流程名称", + supportingText = "把相关任务放进同一个流程", + ) } - }, + } + } ) { padding -> - PullToRefreshBox( - isRefreshing = refreshing, - onRefresh = viewModel::pullRefresh, - modifier = Modifier.fillMaxSize().padding(padding), - ) { - if (selected != null && adaptive.useTwoPane) { - val workflow = selected - val stageList = stages.orEmpty() - val membershipList = memberships.orEmpty() - - Column(Modifier.fillMaxSize()) { - Surface( + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = viewModel::pullRefresh, + modifier = Modifier.fillMaxSize().padding(padding), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 24.dp), + ) { + if (selected == null) { + // Workflow List Overview + items(workflows, key = { it.id }) { workflow -> + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .animateItem() + .clip(TaskDockShapes.Large) + .clickable { selectedId = workflow.id }, + shape = TaskDockShapes.Large, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = adaptive.pageHorizontalPadding, vertical = 6.dp), - shape = TaskDockShapes.LargeIncreased, - color = MaterialTheme.colorScheme.surfaceContainer, + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + Surface( + shape = TaskDockShapes.Medium, + color = MaterialTheme.colorScheme.tertiaryContainer, + modifier = Modifier.size(42.dp), ) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = workflow.name, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Medium, - ) - Text( - text = "${stageList.size} 个阶段 · ${membershipList.size} 项任务流转中", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (workflow.archivedAt == null) { - FilledTonalButton( - onClick = { stageWorkflow = workflow }, - shape = TaskDockShapes.FullPill, - ) { - Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) - Text("新增阶段") - } - } - ActionMenu( - "流程操作", - listOf( - RowAction("修改名称") { - editWorkflow = workflow - editName = workflow.name - }, - RowAction("新增阶段", enabled = workflow.archivedAt == null) { - stageWorkflow = workflow - }, - RowAction("删除流程", destructive = true) { - deleteWorkflow = workflow - }, - ), + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.AccountTree, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.size(22.dp), ) } } - } - if (stages != null && stageList.isEmpty()) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - EmptyState( - icon = Icons.Default.AccountTree, - title = "当前流程暂无阶段", - description = "点击下方添加第一个阶段(如:待开始、进行中)。", - action = { - FilledTonalButton( - onClick = { stageWorkflow = workflow }, - shape = TaskDockShapes.FullPill, - ) { - Icon(Icons.Default.Add, null) - Spacer(Modifier.width(8.dp)) - Text("添加阶段") - } - }, - ) - } - } else { - LazyRow( - state = kanbanRowState, - modifier = Modifier - .fillMaxSize() - .testTag("workflow-kanban-row"), - contentPadding = PaddingValues( - start = adaptive.pageHorizontalPadding, - end = adaptive.pageHorizontalPadding, - top = 6.dp, - bottom = 24.dp, - ), - horizontalArrangement = Arrangement.spacedBy(16.dp), + Spacer(Modifier.width(14.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center, ) { - itemsIndexed(stageList, key = { _, stage -> stage.id }) { index, stage -> - Box( - modifier = Modifier - .width(340.dp) - .fillMaxHeight() - .verticalScroll(rememberScrollState()), - ) { - ExpressiveStageBlock( - workflow = workflow, - stage = stage, - stageIndex = index, - stages = stageList, - memberships = membershipList.filter { it.stageId == stage.id }, - allTasks = tasks, - folders = folders, - onMoveStage = { viewModel.moveWorkflowStageV2(stage, stageList, it) }, - onAddTask = { addTarget = AddTarget(workflow, stage) }, - onCreateTask = { createTaskStage = AddTarget(workflow, stage) }, - onEditStage = { - editStage = it - editName = it.name - }, - onDeleteStage = { deleteStage = it }, - enabled = workflow.archivedAt == null, - onMoveTask = { member, target, before, after -> - viewModel.moveWorkflowMembershipV2(member, target, before, after) - }, - onRemoveTask = { viewModel.removeWorkflowMembershipV2(it) }, - onToggleStatus = { task, status -> - viewModel.updateTaskStatus(task, status) - }, - onNavigateToDetail = onNavigateToDetail, - isKanbanColumn = true, - ) - } - } + Text( + text = workflow.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = if (workflow.archivedAt == null) "查看阶段与任务状态" else "已归档", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) } } - } else { - LazyColumn( - state = if (selected == null) listState else detailListState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 24.dp), - ) { - if (selected == null) { - // Workflow List Overview - val useGridWorkflows = adaptive.useTwoPane && workflows.size > 1 - if (useGridWorkflows) { - items(workflows.chunked(2), key = { pair -> pair.first().id }) { rowItems -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = adaptive.pageHorizontalPadding, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - rowItems.forEach { workflow -> - WorkflowOverviewCard( - workflow = workflow, - onClick = { selectedId = workflow.id }, - modifier = Modifier.weight(1f), - ) - } - if (rowItems.size == 1) { - Spacer(Modifier.weight(1f)) - } - } - } - } else { - items(workflows, key = { it.id }) { workflow -> - WorkflowOverviewCard( - workflow = workflow, - onClick = { selectedId = workflow.id }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp) - .animateItem(), - ) - } - } + } - if (workflows.isEmpty()) { - item { - EmptyState( - icon = Icons.Default.AccountTree, - title = "将任务按阶段组织为流程", - description = "例如“需求设计 → 开发中 → 验收测试”,任务状态在各处实时保持同步。", - action = { - FilledTonalButton( - onClick = { showCreate = true }, - shape = TaskDockShapes.FullPill, - ) { - Icon(Icons.Default.Add, null) - Spacer(Modifier.width(8.dp)) - Text("新建流程") - } - }, - ) + if (workflows.isEmpty()) { + item { + EmptyState( + icon = Icons.Default.AccountTree, + title = "将任务按阶段组织为流程", + description = "例如“需求设计 → 开发中 → 验收测试”,任务状态在各处实时保持同步。", + action = { + FilledTonalButton( + onClick = { showCreate = true }, + shape = TaskDockShapes.FullPill, + ) { + Icon(Icons.Default.Add, null) + Spacer(Modifier.width(8.dp)) + Text("新建流程") } } - } else { - // Workflow Detail & Stages View - item(key = selected.id) { - val workflow = selected - val stageList = stages.orEmpty() - val membershipList = memberships.orEmpty() + ) + } + } + } else { + // Workflow Detail & Stages View + item(key = selected.id) { + val workflow = selected + val stages by remember(workflow.id) { + viewModel.observeWorkflowStages(workflow.id) + }.collectAsStateWithLifecycle(initialValue = emptyList()) + val memberships by remember(workflow.id) { + viewModel.observeWorkflowMemberships(workflow.id) + }.collectAsStateWithLifecycle(initialValue = emptyList()) + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = TaskDockShapes.LargeIncreased, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = workflow.name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Medium, + ) + Text( + text = "${stages.size} 个阶段 · ${memberships.size} 项任务流转中", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - shape = TaskDockShapes.LargeIncreased, - color = MaterialTheme.colorScheme.surfaceContainer, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = workflow.name, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Medium, - ) - Text( - text = "${stageList.size} 个阶段 · ${membershipList.size} 项任务流转中", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - ActionMenu( - "流程操作", - listOf( - RowAction("修改名称") { - editWorkflow = workflow - editName = workflow.name - }, - RowAction("新增阶段", enabled = workflow.archivedAt == null) { - stageWorkflow = workflow - }, - RowAction("删除流程", destructive = true) { - deleteWorkflow = workflow - }, - ), - ) - } - } + ActionMenu( + "流程操作", + listOf( + RowAction("修改名称") { + editWorkflow = workflow + editName = workflow.name + }, + RowAction("新增阶段", enabled = workflow.archivedAt == null) { + stageWorkflow = workflow + }, + RowAction("删除流程", destructive = true) { + deleteWorkflow = workflow + }, + ), + ) + } + } - stageList.forEachIndexed { index, stage -> - ExpressiveStageBlock( - workflow = workflow, - stage = stage, - stageIndex = index, - stages = stageList, - memberships = membershipList.filter { it.stageId == stage.id }, - allTasks = tasks, - folders = folders, - onMoveStage = { viewModel.moveWorkflowStageV2(stage, stageList, it) }, - onAddTask = { addTarget = AddTarget(workflow, stage) }, - onCreateTask = { createTaskStage = AddTarget(workflow, stage) }, - onEditStage = { - editStage = it - editName = it.name - }, - onDeleteStage = { deleteStage = it }, - enabled = workflow.archivedAt == null, - onMoveTask = { member, target, before, after -> - viewModel.moveWorkflowMembershipV2(member, target, before, after) - }, - onRemoveTask = { viewModel.removeWorkflowMembershipV2(it) }, - onToggleStatus = { task, status -> - viewModel.updateTaskStatus(task, status) - }, - onNavigateToDetail = onNavigateToDetail, - ) - } + stages.forEachIndexed { index, stage -> + ExpressiveStageBlock( + workflow = workflow, + stage = stage, + stageIndex = index, + stages = stages, + memberships = memberships.filter { it.stageId == stage.id }, + allTasks = tasks, + folders = folders, + onMoveStage = { viewModel.moveWorkflowStageV2(stage, stages, it) }, + onAddTask = { addTarget = AddTarget(workflow, stage) }, + onCreateTask = { createTaskStage = AddTarget(workflow, stage) }, + onEditStage = { + editStage = it + editName = it.name + }, + onDeleteStage = { deleteStage = it }, + enabled = workflow.archivedAt == null, + onMoveTask = { member, target, before, after -> + viewModel.moveWorkflowMembershipV2(member, target, before, after) + }, + onRemoveTask = { viewModel.removeWorkflowMembershipV2(it) }, + onToggleStatus = { task, status -> + viewModel.updateTaskStatus(task, status) + }, + onNavigateToDetail = onNavigateToDetail, + ) + } - if (stages != null && stageList.isEmpty()) { - EmptyState( - icon = Icons.Default.AccountTree, - title = "当前流程暂无阶段", - description = "点击下方添加第一个阶段(如:待开始、进行中)。", - action = { - FilledTonalButton( - onClick = { stageWorkflow = workflow }, - shape = TaskDockShapes.FullPill, - ) { - Icon(Icons.Default.Add, null) - Spacer(Modifier.width(8.dp)) - Text("添加阶段") - } - }, - ) + if (stages.isEmpty()) { + EmptyState( + icon = Icons.Default.AccountTree, + title = "当前流程暂无阶段", + description = "点击下方添加第一个阶段(如:待开始、进行中)。", + action = { + FilledTonalButton( + onClick = { stageWorkflow = workflow }, + shape = TaskDockShapes.FullPill, + ) { + Icon(Icons.Default.Add, null) + Spacer(Modifier.width(8.dp)) + Text("添加阶段") } } - } + ) } } } } + } + } deleteStage?.let { stage -> AlertDialog( @@ -615,93 +486,16 @@ fun WorkflowsV2Screen( } addTarget?.let { target -> - val candidateTasks = tasks.filter { - it.archivedAt == null && it.deletedAt == null - } - AlertDialog( - onDismissRequest = { addTarget = null }, - title = { Text("添加已有任务至 ${target.stage.name}") }, - text = { - Column( - modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - candidateTasks.forEach { task -> - TextButton( - onClick = { - viewModel.addWorkflowTaskV2(target.workflow, target.stage, task) - addTarget = null - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(task.title, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - } + WorkflowDirectoryTaskPickerDialog( + stageName = target.stage.name, + allTasks = tasks, + allFolders = folders, + onDismiss = { addTarget = null }, + onTaskSelect = { task -> + viewModel.addWorkflowTaskV2(target.workflow, target.stage, task) + addTarget = null } - }, - confirmButton = { TextButton(onClick = { addTarget = null }) { Text("完成") } }, - ) - } -} - -@Composable -private fun WorkflowOverviewCard( - workflow: WorkflowEntity, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Surface( - modifier = modifier - .clip(TaskDockShapes.Large) - .clickable(onClick = onClick), - shape = TaskDockShapes.Large, - color = MaterialTheme.colorScheme.surfaceContainerLow, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - shape = TaskDockShapes.Medium, - color = MaterialTheme.colorScheme.tertiaryContainer, - modifier = Modifier.size(42.dp), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Default.AccountTree, - contentDescription = null, - tint = MaterialTheme.colorScheme.onTertiaryContainer, - modifier = Modifier.size(22.dp), - ) - } - } - - Spacer(Modifier.width(14.dp)) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center, - ) { - Text( - text = workflow.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = if (workflow.archivedAt == null) "查看阶段与任务状态" else "已归档", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - ) - } + ) } } @@ -724,7 +518,6 @@ private fun ExpressiveStageBlock( onRemoveTask: (WorkflowTaskMembershipEntity) -> Unit, onToggleStatus: (TaskEntity, TaskStatus) -> Unit, onNavigateToDetail: (String) -> Unit, - isKanbanColumn: Boolean = false, ) { val sortedMemberships = remember(memberships) { memberships.sortedWith(compareBy { it.rank.toLongOrNull() ?: 0L }.thenBy { it.id }) @@ -740,7 +533,7 @@ private fun ExpressiveStageBlock( Surface( modifier = Modifier .fillMaxWidth() - .padding(horizontal = if (isKanbanColumn) 0.dp else 16.dp, vertical = if (isKanbanColumn) 0.dp else 6.dp) + .padding(horizontal = 16.dp, vertical = 6.dp) .animateContentSize(animationSpec = TaskDockMotion.springSpatial()), shape = TaskDockShapes.LargeIncreased, color = MaterialTheme.colorScheme.surfaceContainerLow, @@ -834,3 +627,183 @@ private fun ExpressiveStageBlock( } private data class AddTarget(val workflow: WorkflowEntity, val stage: WorkflowStageEntity) + + +@Composable +private fun WorkflowDirectoryTaskPickerDialog( + stageName: String, + allTasks: List, + allFolders: List, + onDismiss: () -> Unit, + onTaskSelect: (TaskEntity) -> Unit, +) { + var query by rememberSaveable { mutableStateOf("") } + var currentFolderId by rememberSaveable { mutableStateOf(null) } + + val foldersById = remember(allFolders) { allFolders.associateBy { it.id } } + + val breadcrumbs = remember(currentFolderId, foldersById) { + val list = mutableListOf() + var curr = currentFolderId?.let { foldersById[it] } + var visited = 0 + while (curr != null && visited < allFolders.size) { + list.add(0, curr) + curr = curr.parentFolderId?.let { foldersById[it] } + visited++ + } + list + } + + val visibleFolders = remember(allFolders, currentFolderId) { + allFolders.filter { + it.archivedAt == null && it.deletedAt == null && it.parentFolderId == currentFolderId + }.sortedBy { it.title } + } + + val visibleTasks = remember(allTasks, currentFolderId, query) { + val needle = query.trim().lowercase() + if (needle.isNotEmpty()) { + allTasks.filter { + it.archivedAt == null && it.deletedAt == null && + (it.title.lowercase().contains(needle) || it.referenceId.lowercase().contains(needle)) + } + } else { + allTasks.filter { + it.archivedAt == null && it.deletedAt == null && it.parentFolderId == currentFolderId + }.sortedBy { it.title } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("添加任务到「$stageName」") }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 480.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + label = { Text("搜索任务或引用 ID") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + if (query.trim().isEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FilterChip( + selected = currentFolderId == null, + onClick = { currentFolderId = null }, + label = { Text("根目录") }, + ) + breadcrumbs.forEach { folder -> + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FilterChip( + selected = currentFolderId == folder.id, + onClick = { currentFolderId = folder.id }, + label = { Text(folder.title) }, + ) + } + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (query.trim().isEmpty()) { + visibleFolders.forEach { folder -> + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier + .fillMaxWidth() + .clickable { currentFolderId = folder.id } + .padding(vertical = 4.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.Folder, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Text( + text = folder.title, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + } + } + } + + visibleTasks.forEach { task -> + TextButton( + onClick = { onTaskSelect(task) }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = task.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = task.referenceId, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + if (visibleFolders.isEmpty() && visibleTasks.isEmpty()) { + Text( + text = if (query.trim().isNotEmpty()) "没有匹配的任务" else "此目录下没有未完成任务", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 16.dp), + ) + } + } + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text("取消") } }, + ) +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 663b4a3..ddb1cb9 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -36,17 +36,12 @@ import { Plus, Search, Settings, - ShieldCheck, Sparkles, Target, Trash2, - Undo2, Workflow as WorkflowIcon, } from 'lucide-react'; import { - Fragment, - Suspense, - lazy, useCallback, useEffect, useMemo, @@ -70,22 +65,23 @@ import { import { ApiError, getConfiguredHubOrigin, + getHubOriginLoadError, + isDesktopClient, + isDesktopShell, isNativeClient, isNativeMobileClient, - listDevices, + isWindowsDesktop, loadConfiguredHubOrigin, normalizeHubOrigin, mutationV2, requestV2, - revokeDevice, setHubOrigin, testHubConnection, - type DeviceSummary, } from './api.js'; import { DirectoryNavigation } from './components/directory-navigation.js'; -import { TaskFolderBreadcrumb } from './components/task-folder-breadcrumb.js'; import { directoryEntries } from './directory-paths.js'; -import { readLastFolderId, resolveCaptureFolder } from './folder-preference.js'; +import { readLastFolderId } from './folder-preference.js'; +import { TaskDirectoryPickerModal } from './components/task-directory-picker-modal.js'; import { useAuth } from './auth.js'; import { BrandMark } from './components/brand-mark.js'; import { @@ -127,32 +123,12 @@ import { useSwipeAction, useWindowSizeClass, type NavigationDestination, - type ButtonGroupOption, } from './components/m3e/index.js'; import { useDevice } from './device.js'; import { useDismissibleMenu } from './components/m3e/behavior.js'; -import { PwaInstallButton, PwaLifecycleNotice } from './pwa.js'; -import { ErrorBoundary, TaskDetailErrorFallback } from './components/error-boundary.js'; -import { LazyPageBoundary } from './components/lazy-page-boundary.js'; -import { TaskDetailV2Overlay } from './components/task-detail-overlay.js'; -import { - calendarDays, - formatDate, - formatMonth, - formatTime, - isLocalDate, - shiftLocalDate, - shiftMonth, - todayInTimezone, - weekdayLabels, -} from './date-utils.js'; -import { getNetworkMode, setNetworkMode, type NetworkMode } from './network-policy.js'; -import { TaskHoverPreviewRow, useTaskHoverPreview } from './components/task-hover-preview.js'; -import { notifyDataChanged, shouldReloadTaskList } from './data-changes.js'; -const TreePage = lazy(() => import('./TreePage.js').then((page) => ({ default: page.TreePage }))); -const WorkflowsPage = lazy(() => - import('./WorkflowsPage.js').then((page) => ({ default: page.WorkflowsPage })), -); +import { PwaLifecycleNotice } from './pwa.js'; +import { TaskDetailV2Overlay, TreePage } from './TreePage.js'; +import { WorkflowsPage } from './WorkflowsPage.js'; type PlacementWithTask = PlacementDto & { task: TreeTaskDto }; type PresenceState = 'entering' | 'present' | 'exiting'; @@ -165,61 +141,6 @@ function taskStatusClass(status: TaskStatus): string { return status === 'IN_PROGRESS' ? 'in-progress' : status === 'DONE' ? 'done' : 'todo'; } -/** - * Swap a placement with its nearest same-status sibling. Status-grouped lists - * only ever move a row inside its own group, but the reorder API validates the - * id list against the whole time point, so the returned order still covers - * every placement. `statusOf` carries the frozen group assignment so a move - * follows the group the row is currently displayed in. - */ -function movePlacementWithinStatus( - items: PlacementWithTask[], - placementId: string, - direction: 'up' | 'down', - statusOf: (item: PlacementWithTask) => TaskStatus = (item) => item.task.status, -): PlacementWithTask[] | null { - const index = items.findIndex((item) => item.id === placementId); - if (index < 0) return null; - const status = statusOf(items[index]!); - const step = direction === 'up' ? -1 : 1; - let target = index + step; - while (target >= 0 && target < items.length && statusOf(items[target]!) !== status) { - target += step; - } - if (target < 0 || target >= items.length) return null; - const next = [...items]; - [next[index], next[target]] = [next[target]!, next[index]!]; - return next; -} - -/** - * Freeze each placement's status-group membership for as long as the page - * stays open (or until the time point changes). Toggling a status then updates - * the row in place, and the list only re-sorts on the next visit; without this - * a completed row jumps into 已完成 the moment its circle is tapped. - */ -function useFrozenStatusGroup( - items: PlacementWithTask[], - pointId: string | undefined, -): (item: PlacementWithTask) => TaskStatus { - const frozen = useRef<{ pointId: string | undefined; byPlacement: Map }>({ - pointId, - byPlacement: new Map(), - }); - if (frozen.current.pointId !== pointId) { - frozen.current = { pointId, byPlacement: new Map() }; - } - for (const item of items) { - if (!frozen.current.byPlacement.has(item.id)) { - frozen.current.byPlacement.set(item.id, item.task.status); - } - } - return useCallback( - (item: PlacementWithTask) => frozen.current.byPlacement.get(item.id) ?? item.task.status, - [], - ); -} - /** * Overlay lifecycle helper. The exit duration comes from the M3E spring tokens * (see `scripts/motion-tokens.ts`) so a component can never unmount before its @@ -255,8 +176,13 @@ function usePresence(open: boolean, exitDuration: number = SPRING_DURATION.effec export function App() { const auth = useAuth(); + const desktopShell = isDesktopShell(); const content = - auth.status === 'loading' ? ( + desktopShell && !isDesktopClient() ? ( + + ) : desktopShell ? ( + + ) : auth.status === 'loading' ? ( ) : auth.status === 'anonymous' ? ( @@ -268,10 +194,326 @@ export function App() { typeof window !== 'undefined' && (window.location.protocol === 'http:' || window.location.protocol === 'https:'); return ( - <> + {showPwaNotice && } {content} - + + ); +} + +function DesktopChrome({ children }: { children: ReactNode }) { + const windowsDesktop = isWindowsDesktop(); + if (!windowsDesktop) return children; + return ( +
+ + ); +} + +type DesktopStartupPhase = 'checking' | 'setup' | 'waiting' | 'ready' | 'error'; + +function DesktopBridgeUnavailableScreen() { + return ( +
+
+
+ + TaskDock +
+

DESKTOP RUNTIME

+

桌面安全通道未加载

+

+ 当前窗口是桌面客户端,但安全桥接没有启动,因此无法读取或保存服务器地址。 +

+ + 请完全退出 TaskDock 后重试;如果问题持续,请重新安装最新桌面版本。 + + +
+ +
+ ); +} + +function DesktopStartup() { + const auth = useAuth(); + const [origin, setOrigin] = useState(null); + const [phase, setPhase] = useState('checking'); + const [message, setMessage] = useState(''); + + const verify = useCallback(async (candidate: string) => { + setPhase('checking'); + setMessage('正在连接服务器…'); + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 8_000); + try { + const status = await testHubConnection(candidate, controller.signal); + setPhase(status.initialized ? 'ready' : 'waiting'); + setMessage(''); + } catch (cause) { + setPhase('error'); + setMessage( + cause instanceof DOMException && cause.name === 'AbortError' + ? '连接超时,请检查地址、证书和网络。' + : cause instanceof Error + ? cause.message + : '无法连接服务器,请检查地址、证书和网络。', + ); + } finally { + window.clearTimeout(timeout); + } + }, []); + + useEffect(() => { + let active = true; + void loadConfiguredHubOrigin().then((initialOrigin) => { + if (!active) return; + setOrigin(initialOrigin); + if (initialOrigin) void verify(initialOrigin); + else { + const loadError = getHubOriginLoadError(); + setMessage(loadError?.includes('尚未配置') ? '' : (loadError ?? '')); + setPhase('setup'); + } + }); + return () => { + active = false; + }; + }, [verify]); + + useEffect(() => { + let active = true; + if (auth.status === 'anonymous') { + void loadConfiguredHubOrigin().then((currentOrigin) => { + if (!active) return; + setOrigin(currentOrigin); + if (!currentOrigin) { + const loadError = getHubOriginLoadError(); + setMessage(loadError?.includes('尚未配置') ? '' : (loadError ?? '')); + setPhase('setup'); + } + }); + } + return () => { + active = false; + }; + }, [auth.status]); + + const handleConnected = useCallback((nextOrigin: string, initialized: boolean) => { + setOrigin(nextOrigin); + setMessage(''); + setPhase(initialized ? 'ready' : 'waiting'); + }, []); + const handleChangeOrigin = useCallback(() => { + setPhase('setup'); + setMessage(''); + }, []); + const finishSwitch = useCallback( + async (nextOrigin: string, previousOrigin: string | null) => { + if (previousOrigin && previousOrigin !== nextOrigin) await auth.logout(); + }, + [auth], + ); + + if (phase === 'setup') + return ( + + ); + if (phase === 'checking') return ; + if (phase === 'error') + return ( + + ); + if (phase === 'waiting') + return ( + origin && void verify(origin)} + onChangeOrigin={handleChangeOrigin} + /> + ); + if (auth.status === 'loading') return ; + if (auth.status === 'anonymous') + return ; + return ; +} + +function HubSetupScreen({ + initialOrigin, + errorMessage, + onConnected, + afterSave, +}: { + initialOrigin: string; + errorMessage: string; + onConnected: (origin: string, initialized: boolean) => void; + afterSave?: (origin: string, previousOrigin: string | null) => Promise; +}) { + const [origin, setOrigin] = useState(initialOrigin); + const [state, setState] = useState<'idle' | 'checking' | 'error'>( + errorMessage ? 'error' : 'idle', + ); + const [message, setMessage] = useState(errorMessage); + const originEdited = useRef(false); + + useEffect(() => { + setMessage(errorMessage); + if (errorMessage) setState('error'); + }, [errorMessage]); + useEffect(() => { + if (!originEdited.current) setOrigin(initialOrigin); + }, [initialOrigin]); + + const connect = async () => { + if (state === 'checking' || !origin.trim()) return; + setState('checking'); + setMessage('正在测试连接…'); + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 8_000); + try { + const normalized = normalizeHubOrigin(origin); + const status = await testHubConnection(normalized, controller.signal); + const previousOrigin = getConfiguredHubOrigin(); + const saved = await setHubOrigin(normalized); + await afterSave?.(saved, previousOrigin); + onConnected(saved, status.initialized); + } catch (cause) { + setState('error'); + setMessage( + cause instanceof DOMException && cause.name === 'AbortError' + ? '连接超时,请检查地址、证书和网络。' + : cause instanceof Error + ? cause.message + : '无法连接服务器,请检查地址、证书和网络。', + ); + } finally { + window.clearTimeout(timeout); + } + }; + + return ( +
+
+
+ + TaskDock +
+

DESKTOP CONNECTION

+

{origin ? '连接服务器' : '配置服务器地址'}

+

+ 先连接你的 TaskDock 服务器,之后登录、同步和离线数据都会跟随这个地址。 +

+ {message && {message}} + {isHttpOrigin(origin) && ( + + HTTP 地址可以连接服务器,但密码和会话信息会明文传输;正式公网部署仍建议使用 HTTPS。 + + )} +
+ { + originEdited.current = true; + setOrigin(value); + setState('idle'); + setMessage(''); + }} + type="url" + autoComplete="url" + autoFocus={!origin} + /> +

+ 例如 https://todo.example.com、http://your-hub.example:48731 或 + http://localhost:3000。HTTP 地址会显示安全提示。 +

+ +
+
+ +
+ ); +} + +function HubWaitingScreen({ + origin, + onRetry, + onChangeOrigin, +}: { + origin: string; + onRetry: () => void; + onChangeOrigin: () => void; +}) { + return ( +
+
+
+ + TaskDock +
+

DESKTOP CONNECTION

+

等待服务器准备就绪

+

服务器已经连接,但管理员还没有完成首次初始化。

+ + 请让管理员在服务器端完成首次初始化,完成后点击“重新检查”即可登录。 + +
+ 当前服务器 + {origin} +
+
+ + +
+
+ +
); } @@ -280,14 +522,12 @@ function AuthenticatedApp() { const location = useLocation(); const navigate = useNavigate(); const { isPhone, isTablet } = useDevice(); - const windowSizeClass = useWindowSizeClass(); - const sizeClass = isPhone ? 'compact' : windowSizeClass; + const sizeClass = useWindowSizeClass(); const pageWrapRef = useRef(null); const [commandOpen, setCommandOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [logoutConfirmOpen, setLogoutConfirmOpen] = useState(false); const [toast, setToast] = useState(''); - const [globalCaptureOpen, setGlobalCaptureOpen] = useState(false); const [selectedTask, setSelectedTask] = useSearchParams(); // Only reset scroll when the top-level route changes. Moving between @@ -328,55 +568,6 @@ function AuthenticatedApp() { setSelectedTask(next); }, [selectedTask, setSelectedTask]); - // Handle shortcut URL actions such as ?action=new-task - useEffect(() => { - const params = new URLSearchParams(location.search); - if (params.get('action') === 'new-task') { - setGlobalCaptureOpen(true); - params.delete('action'); - navigate({ search: params.toString() }, { replace: true }); - } - }, [location.search, navigate]); - - const handleGlobalCaptureSubmit = async (title: string) => { - let targetFolderId: string | null = null; - try { - const { items } = await requestV2<{ items: FolderDto[] }>('/folders'); - targetFolderId = resolveCaptureFolder({ - folders: items ?? [], - defaultCaptureTarget: auth.settings?.defaultCaptureTarget, - activeFolderId: null, - }); - } catch { - // Fallback to root if folder fetch fails - targetFolderId = null; - } - - const { task: createdTask } = (await mutationV2('POST', '/tasks', { - parentFolderId: targetFolderId, - title, - })) as { task: TreeTaskDto }; - - // If currently on /today, also place into today's timeline - if (location.pathname.startsWith('/today')) { - try { - const localDate = todayInTimezone(auth.settings?.timezone ?? 'Asia/Shanghai'); - const point = (await mutationV2('POST', '/time-points/date', { - localDate, - })) as TimePointDto; - await mutationV2('POST', '/placements', { - taskId: createdTask.id, - timePointId: point.id, - }); - } catch { - // Placement failure non-blocking - } - } - - window.dispatchEvent(new Event('devtodo:data-changed')); - setToast('已创建任务'); - }; - useEffect(() => { const listener = (event: KeyboardEvent) => { if ( @@ -445,21 +636,6 @@ function AuthenticatedApp() { /** Navigating to a folder detail should still highlight 目录. */ const activeForExpanded = activeExpanded || '/tree'; - const activeTabletSegment = useMemo(() => { - if (location.pathname.startsWith('/today')) return '/today'; - if (location.pathname.startsWith('/tree')) return '/tree'; - if (location.pathname.startsWith('/workflows')) return '/workflows'; - if (location.pathname.startsWith('/time')) return '/time'; - return ''; - }, [location.pathname]); - - const tabletNavOptions: ButtonGroupOption[] = [ - { value: '/today', label: '今日', icon: }, - { value: '/tree', label: '目录', icon: }, - { value: '/workflows', label: '流程', icon: }, - { value: '/time/calendar', label: '日历', icon: }, - ]; - const drawerHeader = (
@@ -501,7 +677,7 @@ function AuthenticatedApp() {
- {(sizeClass === 'medium' || (isTablet && sizeClass !== 'compact')) && ( + {(sizeClass === 'medium' || (isTablet && sizeClass === 'expanded')) && ( } - footer={ - setLogoutConfirmOpen(true)} - > - - - } /> )} - {!isCompactShell(sizeClass) && !isTablet && sizeClass !== 'medium' && ( - /* Expanded and up on desktop: the drawer is persistent, so it is never modal. */ - undefined} - modal={false} - side="start" - label="主导航" - destinations={navItems} - activeTo={activeForExpanded} - onNavigate={navigate} - linkAs={RouterLinkAdapter} - header={drawerHeader} - footer={drawerFooter} - className="m3e-drawer-layer--persistent" - > - - - )} + {!isCompactShell(sizeClass) && + (!isTablet || sizeClass !== 'expanded') && + sizeClass !== 'medium' && ( + /* Expanded and up on desktop: the drawer is persistent, so it is never modal. */ + undefined} + modal={false} + side="start" + label="主导航" + destinations={navItems} + activeTo={activeForExpanded} + onNavigate={navigate} + linkAs={RouterLinkAdapter} + header={drawerHeader} + footer={drawerFooter} + className="m3e-drawer-layer--persistent" + > + + + )}
) : undefined } - content={ - isTablet && activeTabletSegment ? ( -
- navigate(to)} - /> -
- ) : undefined - } actions={ <> - {isTablet && ( - setGlobalCaptureOpen(true)} - > - - - )} {sizeClass !== 'compact' ? (
- - }> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } - /> - } /> - } - /> - } /> - setCommandOpen(true)} />} - /> - } /> - - - + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + setCommandOpen(true)} />} + /> + } /> +
- - selectedTask.get('task') ? ( - - ) : null - } - > - window.dispatchEvent(new Event('devtodo:data-changed'))} - /> - + window.dispatchEvent(new Event('devtodo:data-changed'))} + />
{isCompactShell(sizeClass) && ( @@ -702,13 +832,6 @@ function AuthenticatedApp() { /> {toast && setToast('')} />} - setGlobalCaptureOpen(false)} - title="快速新建任务" - placeholder="准备做点什么?" - onSubmit={handleGlobalCaptureSubmit} - /> : ; } -function LoginScreen({ initialized }: { initialized: boolean }) { +function LoginScreen({ + initialized, + onChangeHub, +}: { + initialized: boolean; + onChangeHub?: () => void; +}) { const auth = useAuth(); const nativeClient = isNativeClient(); const mobileClient = isNativeMobileClient(); + const desktopClient = isDesktopClient(); const [hubInitialized, setHubInitialized] = useState(initialized); const [hubOrigin, setHubOriginValue] = useState(getConfiguredHubOrigin() ?? ''); const [username, setUsername] = useState(''); @@ -823,7 +953,7 @@ function LoginScreen({ initialized }: { initialized: boolean }) { setError('服务器尚未准备好,请让管理员完成首次初始化。'); return; } - await auth.login(username, password, nativeClient ? 'Android' : '浏览器'); + await auth.login(username, password, desktopClient ? '桌面端' : '浏览器'); } catch (cause) { setError(cause instanceof ApiError ? cause.message : '操作失败,请检查网络和输入'); } finally { @@ -833,7 +963,6 @@ function LoginScreen({ initialized }: { initialized: boolean }) { return (
-
TaskDock @@ -841,6 +970,15 @@ function LoginScreen({ initialized }: { initialized: boolean }) {

PERSONAL DEV WORKSPACE

{hubInitialized ? '欢迎回来' : '等待服务器准备就绪'}

一个任务本体,多处安排。离线时也能继续捕获和整理。

+ {desktopClient && onChangeHub && ( +
+ 当前服务器 + {hubOrigin} + +
+ )} {!hubInitialized && ( 请让管理员在服务器端完成首次初始化,完成后刷新此页面即可登录。 @@ -1003,7 +1141,6 @@ function MorePage({ onOpenSearch }: { onOpenSearch: () => void }) { return (
- @@ -1607,12 +1737,14 @@ function TodayPage({ onOpenTask }: { onOpenTask: (id: string) => void }) { ) : null} - {showTaskPicker && pickerPointId && ( + {showTaskPicker && data.point && ( setShowTaskPicker(false)} onAdded={() => { setShowTaskPicker(false); + void reload(); + window.dispatchEvent(new Event('devtodo:data-changed')); }} /> )} @@ -1635,7 +1767,7 @@ function TodayPage({ onOpenTask }: { onOpenTask: (id: string) => void }) { title="添加今日任务" placeholder="今天要做什么?" onSubmit={async (title) => { - const point = await ensureTodayPoint(); + if (!data.point) return; // Create task first const { task: createdTask } = (await mutationV2('POST', '/tasks', { parentFolderId: null, @@ -1644,10 +1776,10 @@ function TodayPage({ onOpenTask }: { onOpenTask: (id: string) => void }) { // Place into today's timePoint await mutationV2('POST', '/placements', { taskId: createdTask.id, - timePointId: point.id, + timePointId: data.point.id, }); await reload(); - notifyDataChanged(reload); + window.dispatchEvent(new Event('devtodo:data-changed')); }} />
@@ -1692,7 +1824,7 @@ function EventsPage() { ids: next.map((point) => point.id), }); await reload(); - notifyDataChanged(reload); + window.dispatchEvent(new Event('devtodo:data-changed')); } catch (cause) { setActionError(cause instanceof ApiError ? cause.message : '事件排序失败,请重试'); } @@ -1952,16 +2084,13 @@ function EventPage({ onOpenTask }: { onOpenTask: (id: string) => void }) { }, [eventId]); useEffect(() => { void load(); - const listener = (event: Event) => { - if (shouldReloadTaskList(event, load)) void load(); - }; + const listener = () => void load(); window.addEventListener('devtodo:data-changed', listener); return () => { loadSequence.current += 1; window.removeEventListener('devtodo:data-changed', listener); }; }, [load]); - const statusOf = useFrozenStatusGroup(items, point?.id); if (loadedEventId !== eventId && loadError) return (
@@ -1989,38 +2118,23 @@ function EventPage({ onOpenTask }: { onOpenTask: (id: string) => void }) { } }; const movePlacement = async (placementId: string, direction: 'up' | 'down') => { - const next = movePlacementWithinStatus(items, placementId, direction, statusOf); - if (!next) return; + const index = items.findIndex((item) => item.id === placementId); + const targetIndex = direction === 'up' ? index - 1 : index + 1; + if (index < 0 || targetIndex < 0 || targetIndex >= items.length) return; + const next = [...items]; + [next[index], next[targetIndex]] = [next[targetIndex]!, next[index]!]; try { await mutationV2('POST', `/time-points/${point.id}/placements/reorder`, { ids: next.map((item) => item.id), }); await load(); - notifyDataChanged(load); + window.dispatchEvent(new Event('devtodo:data-changed')); } catch (cause) { setActionError(cause instanceof ApiError ? cause.message : '安排排序失败,请重试'); } }; - const toggleReached = async () => { - if (actionBusy) return; - setActionBusy(true); - setActionError(''); - try { - await mutationV2( - 'POST', - `/time-points/${point.id}/${point.reachedAt ? 'unreach' : 'reach'}`, - { baseVersion: point.version }, - ); - await load(); - notifyDataChanged(load); - } catch (cause) { - setActionError(cause instanceof ApiError ? cause.message : '操作失败'); - } finally { - setActionBusy(false); - } - }; return ( -
+
void }) { - )} -
- ); -} - function EventTargetField({ points, value, @@ -2825,6 +2879,7 @@ async function moveDraggedPlacement(event: ReactDragEvent, targetTi if (!taskId) return; if (!placementId) { await mutationV2('POST', '/placements', { taskId, timePointId: targetTimePointId }); + window.dispatchEvent(new Event('devtodo:data-changed')); return; } const detail = await requestV2<{ placements: PlacementDto[] }>(`/tasks/${taskId}`); @@ -2834,6 +2889,7 @@ async function moveDraggedPlacement(event: ReactDragEvent, targetTi timePointId: targetTimePointId, baseVersion: placement.version, }); + window.dispatchEvent(new Event('devtodo:data-changed')); } function AddTaskModal({ @@ -2845,472 +2901,49 @@ function AddTaskModal({ onClose: () => void; onAdded: () => void; }) { - const { isTouch } = useDevice(); - const [query, setQuery] = useState(''); - const [folderId, setFolderId] = useState(null); - const [items, setItems] = useState([]); - const [path, setPath] = useState>>([]); - const [searchIndex, setSearchIndex] = useState<{ - tasks: TreeTaskDto[]; - folders: FolderDto[]; - } | null>(null); - const [error, setError] = useState(''); - const [pickerError, setPickerError] = useState(''); - const [searchError, setSearchError] = useState(''); - const [searchRetry, setSearchRetry] = useState(0); - const [pickerLoading, setPickerLoading] = useState(false); - const [selectedTasks, setSelectedTasks] = useState>(() => new Map()); - const [existingTaskIds, setExistingTaskIds] = useState>(() => new Set()); - const [addingSelected, setAddingSelected] = useState(false); - const pickerLoadSequence = useRef(0); - const existingLoadSequence = useRef(0); - const searchLoadSequence = useRef(0); - const loadedPickerFolder = useRef(undefined); - - const loadPicker = useCallback(async () => { - const sequence = ++pickerLoadSequence.current; - setPickerLoading(true); - setPickerError(''); - try { - const queryString = folderId - ? `?parentFolderId=${encodeURIComponent(folderId)}` - : '?parentFolderId=root'; - const [children, nextPath] = await Promise.all([ - requestV2<{ items: TreeItemDto[] }>(`/tree/children${queryString}`), - folderId - ? requestV2<{ items: Array> }>( - `/folders/${encodeURIComponent(folderId)}/path`, - ) - : Promise.resolve({ items: [] }), - ]); - if (sequence !== pickerLoadSequence.current) return; - loadedPickerFolder.current = folderId; - setItems(children.items); - setPath(nextPath.items); - } catch (cause) { - if (sequence === pickerLoadSequence.current) - setPickerError(cause instanceof ApiError ? cause.message : '任务目录加载失败,请重试'); - } finally { - if (sequence === pickerLoadSequence.current) setPickerLoading(false); - } - }, [folderId]); - - /** - * The tasks already placed on this time point. They stay visible in the - * picker as checked, disabled rows so nobody re-adds a task that is on the - * list already. A failed refresh keeps the previous snapshot: the picker is - * still usable without it. - */ - const loadExistingPlacements = useCallback(async () => { - const sequence = ++existingLoadSequence.current; - try { - const response = await requestV2<{ items: PlacementWithTask[] }>( - `/time-points/${pointId}/placements`, - ); - if (sequence !== existingLoadSequence.current) return; - setExistingTaskIds(new Set(response.items.map((item) => item.taskId))); - } catch { - /* Keep the last snapshot; the picker still works without it. */ - } - }, [pointId]); - - useEffect(() => { - void loadPicker(); - void loadExistingPlacements(); - const listener = (event: Event) => { - if (!shouldReloadTaskList(event)) return; - // Keep the last search results mounted while the index revalidates. - setSearchError(''); - setSearchRetry((count) => count + 1); - void loadPicker(); - void loadExistingPlacements(); - }; - window.addEventListener('devtodo:data-changed', listener); - return () => window.removeEventListener('devtodo:data-changed', listener); - }, [loadExistingPlacements, loadPicker]); - - const hasSearchQuery = Boolean(query.trim()); - useEffect(() => { - if (!hasSearchQuery) return; - let cancelled = false; - const sequence = ++searchLoadSequence.current; - const timeout = window.setTimeout(() => { - setSearchError(''); - void Promise.all([ - requestV2<{ items: TreeTaskDto[] }>('/tasks'), - requestV2<{ items: FolderDto[] }>('/folders'), - ]) - .then(([taskResult, folderResult]) => { - if (cancelled || sequence !== searchLoadSequence.current) return; - setSearchIndex({ tasks: taskResult.items, folders: folderResult.items }); - }) - .catch((cause) => { - if (cancelled || sequence !== searchLoadSequence.current) return; - setSearchError(cause instanceof ApiError ? cause.message : '搜索索引加载失败,请重试'); - }); - }, 180); - return () => { - cancelled = true; - window.clearTimeout(timeout); - if (sequence === searchLoadSequence.current) searchLoadSequence.current += 1; - }; - }, [hasSearchQuery, searchRetry]); - - const activeItems = useMemo( - () => - items.filter( - (item) => item.kind === 'FOLDER' || (!item.task.deletedAt && item.task.status !== 'DONE'), - ), - [items], - ); - const searchFoldersById = useMemo(() => { - const byId = new Map(); - for (const folder of searchIndex?.folders ?? []) byId.set(folder.id, folder); - return byId; - }, [searchIndex]); - const taskLocation = useCallback( - (task: TreeTaskDto) => { - const titles: string[] = []; - let parentId = task.parentFolderId; - let visited = 0; - while (parentId && visited < (searchIndex?.folders.length ?? 0)) { - const folder = searchFoldersById.get(parentId); - if (!folder) break; - titles.unshift(folder.title); - parentId = folder.parentFolderId; - visited += 1; - } - return titles.join(' / ') || '根目录'; - }, - [searchFoldersById, searchIndex?.folders.length], - ); - const matchingTasks = useMemo(() => { - const needle = query.trim().toLocaleLowerCase(); - if (!needle || !searchIndex) return []; - return searchIndex.tasks.filter( - (task) => - !task.deletedAt && - task.status !== 'DONE' && - (task.title.toLocaleLowerCase().includes(needle) || - task.referenceId.toLocaleLowerCase().includes(needle) || - taskLocation(task).toLocaleLowerCase().includes(needle)), - ); - }, [query, searchIndex, taskLocation]); - const searchLoading = Boolean(query.trim()) && !searchIndex && !searchError; - - const openFolder = (id: string | null) => { - setQuery(''); - setFolderId(id); - }; - const toggleTaskSelection = (task: TreeTaskDto) => { - setSelectedTasks((current) => { - const next = new Map(current); - if (next.has(task.id)) next.delete(task.id); - else next.set(task.id, task); - return next; - }); - }; - const addSelected = async () => { - if (addingSelected || selectedTasks.size === 0) return; - const tasks = [...selectedTasks.values()]; - setAddingSelected(true); - setError(''); - const addedIds = new Set(); - const failures: unknown[] = []; - try { - for (const task of tasks) { - try { - await mutationV2('POST', '/placements', { taskId: task.id, timePointId: pointId }); - addedIds.add(task.id); - } catch (cause) { - failures.push(cause); - } - } - if (addedIds.size) { - setSelectedTasks((current) => new Map([...current].filter(([id]) => !addedIds.has(id)))); - setExistingTaskIds((current) => new Set([...current, ...addedIds])); - window.dispatchEvent(new Event('devtodo:data-changed')); - } - if (failures.length) { - const cause = failures[0]; - const detail = cause instanceof ApiError ? cause.message : '请检查后重试'; - setError(`已加入 ${addedIds.size} 项,${failures.length} 项失败:${detail}`); - } else { - onAdded(); - } - } finally { - setAddingSelected(false); - } - }; - const renderTaskRow = (task: TreeTaskDto, showLocation: boolean) => { - const selected = selectedTasks.has(task.id); - const alreadyAdded = existingTaskIds.has(task.id); - return ( - - {(anchorProps) => ( - {taskLocation(task)} - ) : undefined - } - leading={ - - } - trailing={ - alreadyAdded ? ( - - ) : ( - !selected && - ); - }; - const renderTreeItem = (item: TreeItemDto) => - item.kind === 'FOLDER' ? ( - } - headline={item.folder.title} - supporting={ - <> - - - {item.aggregate.doneCount}/{item.aggregate.totalCount} 已完成 - - - } - trailing={
{isCompactShell(sizeClass) && ( @@ -822,6 +702,13 @@ function AuthenticatedApp() { /> {toast && setToast('')} />} + setGlobalCaptureOpen(false)} + title="快速新建任务" + placeholder="准备做点什么?" + onSubmit={handleGlobalCaptureSubmit} + /> : ; } -function LoginScreen({ - initialized, - onChangeHub, -}: { - initialized: boolean; - onChangeHub?: () => void; -}) { +function LoginScreen({ initialized }: { initialized: boolean }) { const auth = useAuth(); const nativeClient = isNativeClient(); const mobileClient = isNativeMobileClient(); - const desktopClient = isDesktopClient(); const [hubInitialized, setHubInitialized] = useState(initialized); const [hubOrigin, setHubOriginValue] = useState(getConfiguredHubOrigin() ?? ''); const [username, setUsername] = useState(''); @@ -943,7 +823,7 @@ function LoginScreen({ setError('服务器尚未准备好,请让管理员完成首次初始化。'); return; } - await auth.login(username, password, desktopClient ? '桌面端' : '浏览器'); + await auth.login(username, password, nativeClient ? 'Android' : '浏览器'); } catch (cause) { setError(cause instanceof ApiError ? cause.message : '操作失败,请检查网络和输入'); } finally { @@ -953,6 +833,7 @@ function LoginScreen({ return (
+
TaskDock @@ -960,15 +841,6 @@ function LoginScreen({

PERSONAL DEV WORKSPACE

{hubInitialized ? '欢迎回来' : '等待服务器准备就绪'}

一个任务本体,多处安排。离线时也能继续捕获和整理。

- {desktopClient && onChangeHub && ( -
- 当前服务器 - {hubOrigin} - -
- )} {!hubInitialized && ( 请让管理员在服务器端完成首次初始化,完成后刷新此页面即可登录。 @@ -1131,6 +1003,7 @@ function MorePage({ onOpenSearch }: { onOpenSearch: () => void }) { return (
+ @@ -1727,14 +1607,12 @@ function TodayPage({ onOpenTask }: { onOpenTask: (id: string) => void }) { ) : null} - {showTaskPicker && data.point && ( + {showTaskPicker && pickerPointId && ( setShowTaskPicker(false)} onAdded={() => { setShowTaskPicker(false); - void reload(); - window.dispatchEvent(new Event('devtodo:data-changed')); }} /> )} @@ -1757,7 +1635,7 @@ function TodayPage({ onOpenTask }: { onOpenTask: (id: string) => void }) { title="添加今日任务" placeholder="今天要做什么?" onSubmit={async (title) => { - if (!data.point) return; + const point = await ensureTodayPoint(); // Create task first const { task: createdTask } = (await mutationV2('POST', '/tasks', { parentFolderId: null, @@ -1766,10 +1644,10 @@ function TodayPage({ onOpenTask }: { onOpenTask: (id: string) => void }) { // Place into today's timePoint await mutationV2('POST', '/placements', { taskId: createdTask.id, - timePointId: data.point.id, + timePointId: point.id, }); await reload(); - window.dispatchEvent(new Event('devtodo:data-changed')); + notifyDataChanged(reload); }} />
@@ -1814,7 +1692,7 @@ function EventsPage() { ids: next.map((point) => point.id), }); await reload(); - window.dispatchEvent(new Event('devtodo:data-changed')); + notifyDataChanged(reload); } catch (cause) { setActionError(cause instanceof ApiError ? cause.message : '事件排序失败,请重试'); } @@ -2074,13 +1952,16 @@ function EventPage({ onOpenTask }: { onOpenTask: (id: string) => void }) { }, [eventId]); useEffect(() => { void load(); - const listener = () => void load(); + const listener = (event: Event) => { + if (shouldReloadTaskList(event, load)) void load(); + }; window.addEventListener('devtodo:data-changed', listener); return () => { loadSequence.current += 1; window.removeEventListener('devtodo:data-changed', listener); }; }, [load]); + const statusOf = useFrozenStatusGroup(items, point?.id); if (loadedEventId !== eventId && loadError) return (
@@ -2108,23 +1989,38 @@ function EventPage({ onOpenTask }: { onOpenTask: (id: string) => void }) { } }; const movePlacement = async (placementId: string, direction: 'up' | 'down') => { - const index = items.findIndex((item) => item.id === placementId); - const targetIndex = direction === 'up' ? index - 1 : index + 1; - if (index < 0 || targetIndex < 0 || targetIndex >= items.length) return; - const next = [...items]; - [next[index], next[targetIndex]] = [next[targetIndex]!, next[index]!]; + const next = movePlacementWithinStatus(items, placementId, direction, statusOf); + if (!next) return; try { await mutationV2('POST', `/time-points/${point.id}/placements/reorder`, { ids: next.map((item) => item.id), }); await load(); - window.dispatchEvent(new Event('devtodo:data-changed')); + notifyDataChanged(load); } catch (cause) { setActionError(cause instanceof ApiError ? cause.message : '安排排序失败,请重试'); } }; + const toggleReached = async () => { + if (actionBusy) return; + setActionBusy(true); + setActionError(''); + try { + await mutationV2( + 'POST', + `/time-points/${point.id}/${point.reachedAt ? 'unreach' : 'reach'}`, + { baseVersion: point.version }, + ); + await load(); + notifyDataChanged(load); + } catch (cause) { + setActionError(cause instanceof ApiError ? cause.message : '操作失败'); + } finally { + setActionBusy(false); + } + }; return ( -
+
void }) { + )} +
+ ); +} + function EventTargetField({ points, value, @@ -2869,7 +2825,6 @@ async function moveDraggedPlacement(event: ReactDragEvent, targetTi if (!taskId) return; if (!placementId) { await mutationV2('POST', '/placements', { taskId, timePointId: targetTimePointId }); - window.dispatchEvent(new Event('devtodo:data-changed')); return; } const detail = await requestV2<{ placements: PlacementDto[] }>(`/tasks/${taskId}`); @@ -2879,7 +2834,6 @@ async function moveDraggedPlacement(event: ReactDragEvent, targetTi timePointId: targetTimePointId, baseVersion: placement.version, }); - window.dispatchEvent(new Event('devtodo:data-changed')); } function AddTaskModal({ @@ -2925,15 +2879,25 @@ function AddTaskModal({ function CalendarPage({ onOpenTask }: { onOpenTask: (id: string) => void }) { const { settings } = useAuth(); const params = useParams(); - const initial = params.localDate ?? todayInTimezone(settings?.timezone ?? 'Asia/Shanghai'); + const fallbackDate = todayInTimezone(settings?.timezone); + // A bookmark or notification may carry a stale/invalid date. Fall back to + // today instead of letting calendarDays()/Intl throw during render. + const initial = isLocalDate(params.localDate) ? params.localDate : fallbackDate; const [month, setMonth] = useState(initial.slice(0, 7)); - const [selected, setSelected] = useState(params.localDate ?? initial); + const [selected, setSelected] = useState(initial); const [items, setItems] = useState([]); const [point, setPoint] = useState(null); const [showAdd, setShowAdd] = useState(false); const [dateCounts, setDateCounts] = useState>({}); const [detailError, setDetailError] = useState(''); const [loadingSelected, setLoadingSelected] = useState(true); + /** + * The date whose placements are on screen. A background sync re-reads the + * selected date in place; only a real date switch (or the first load) may + * swap the list for the loading state, because that swap remounts every row + * and replays its entry animation. + */ + const [loadedSelectedDate, setLoadedSelectedDate] = useState(null); const selectedLoadId = useRef(0); const days = useMemo( () => calendarDays(month, settings?.weekStartsOn ?? 1), @@ -3005,10 +2969,11 @@ function CalendarPage({ onOpenTask }: { onOpenTask: (id: string) => void }) { if (loadId !== selectedLoadId.current) return; setPoint(nextPoint); setItems(result.items); + setLoadedSelectedDate(selected); } catch (cause) { if (loadId !== selectedLoadId.current) return; - setPoint(null); - setItems([]); + // A failed background read must not discard the day already being used. + // Real date switches clear the previous data in selectDate(). setDetailError(cause instanceof ApiError ? cause.message : '日期任务加载失败'); } finally { if (loadId === selectedLoadId.current) setLoadingSelected(false); @@ -3016,25 +2981,25 @@ function CalendarPage({ onOpenTask }: { onOpenTask: (id: string) => void }) { }, [selected]); useEffect(() => { void loadSelected(); - const listener = () => void loadSelected(); + const listener = (event: Event) => { + if (shouldReloadTaskList(event, loadSelected)) void loadSelected(); + }; window.addEventListener('devtodo:data-changed', listener); return () => { selectedLoadId.current += 1; window.removeEventListener('devtodo:data-changed', listener); }; }, [loadSelected]); + const statusOf = useFrozenStatusGroup(items, point?.id); const movePlacement = async (placementId: string, direction: 'up' | 'down') => { if (!point) return; - const index = items.findIndex((item) => item.id === placementId); - const targetIndex = direction === 'up' ? index - 1 : index + 1; - if (index < 0 || targetIndex < 0 || targetIndex >= items.length) return; - const next = [...items]; - [next[index], next[targetIndex]] = [next[targetIndex]!, next[index]!]; + const next = movePlacementWithinStatus(items, placementId, direction, statusOf); + if (!next) return; await mutationV2('POST', `/time-points/${point.id}/placements/reorder`, { ids: next.map((item) => item.id), }); await loadSelected(); - window.dispatchEvent(new Event('devtodo:data-changed')); + notifyDataChanged(loadSelected); }; return (
@@ -3104,21 +3069,23 @@ function CalendarPage({ onOpenTask }: { onOpenTask: (id: string) => void }) {
- {detailError ? ( - void loadSelected()} /> - ) : loadingSelected ? ( + {detailError && void loadSelected()} />} + {detailError && loadedSelectedDate !== selected ? null : loadingSelected && + loadedSelectedDate !== selected ? ( ) : ( void }) { onClose={() => setShowAdd(false)} onAdded={() => { setShowAdd(false); - window.dispatchEvent(new Event('devtodo:data-changed')); }} /> )} @@ -3144,6 +3110,7 @@ function CalendarPage({ onOpenTask }: { onOpenTask: (id: string) => void }) { function SettingsPage() { const auth = useAuth(); + const [networkMode, updateNetworkMode] = useState(getNetworkMode); const [timezone, setTimezone] = useState(auth.settings?.timezone ?? 'Asia/Shanghai'); const [weekStartsOn, setWeekStartsOn] = useState<0 | 1>(auth.settings?.weekStartsOn ?? 1); const [defaultCaptureTarget, setDefaultCaptureTarget] = useState<'ROOT' | 'RECENT_FOLDER'>( @@ -3151,6 +3118,7 @@ function SettingsPage() { ); const [saved, setSaved] = useState(''); const [error, setError] = useState(''); + const [accountSecurityOpened, setAccountSecurityOpened] = useState(false); const save = async (event: FormEvent) => { event.preventDefault(); if (!auth.settings) return; @@ -3172,8 +3140,29 @@ function SettingsPage() { return (
+ {isNativeClient() && }
+
+

本机网络策略

+ + } + onOpenChange={(open) => { + if (open) setAccountSecurityOpened(true); + }} + > + {accountSecurityOpened ? : null} + +
+ ); +} + +function DeviceSettingsSection() { + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [deviceToRevoke, setDeviceToRevoke] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + setDevices(await listDevices()); + setError(''); + } catch (cause) { + setError(cause instanceof ApiError ? cause.message : '无法加载已登录设备'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const activeDevices = devices.filter((device) => !device.revokedAt); + return ( +
+

已登录设备

+

+ 常用设备会一直保持登录;默认连续 365 天未使用才会失效。撤销后该设备需要重新登录。 +

+ {error && {error}} + {loading ? ( + + ) : activeDevices.length === 0 ? ( +

没有可显示的设备。

+ ) : ( +
+ {activeDevices.map((device) => ( +
+
+ {device.name} + + {device.platform} · 最近使用 {formatDeviceTime(device.lastSeenAt)} + +
+ {device.current ? ( + + ) : ( + + )} +
+ ))} +
+ )} + setDeviceToRevoke(null)} + onConfirm={async () => { + if (!deviceToRevoke) return; + await revokeDevice(deviceToRevoke.id); + setDevices((current) => current.filter((device) => device.id !== deviceToRevoke.id)); + }} + />
); } +function formatDeviceTime(value: string): string { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString(); +} + function HubSettingsSection() { const auth = useAuth(); const [origin, setOrigin] = useState(getConfiguredHubOrigin() ?? ''); @@ -3597,73 +3675,3 @@ function breadcrumb(pathname: string): string { if (pathname.startsWith('/settings')) return '设置'; return 'TaskDock'; } -function todayInTimezone(timezone: string): string { - const parts = new Intl.DateTimeFormat('en-CA', { - timeZone: timezone, - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).formatToParts(new Date()); - const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); - return `${values['year']}-${values['month']}-${values['day']}`; -} -function formatDate(localDate: string): string { - const date = new Date(`${localDate}T12:00:00`); - return new Intl.DateTimeFormat('zh-CN', { - month: 'long', - day: 'numeric', - weekday: 'long', - }).format(date); -} -function formatMonth(month: string): string { - const date = new Date(`${month}-01T12:00:00`); - return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long' }).format(date); -} -function formatTime(value: string): string { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return new Intl.DateTimeFormat('zh-CN', { - month: 'numeric', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }).format(date); -} -function shiftMonth(month: string, delta: number): string { - const [year, monthNumber] = month.split('-').map(Number); - const date = new Date(Date.UTC(year!, monthNumber! - 1 + delta, 1)); - return date.toISOString().slice(0, 7); -} -function shiftLocalDate(localDate: string, delta: number): string { - const date = new Date(`${localDate}T12:00:00Z`); - date.setUTCDate(date.getUTCDate() + delta); - return date.toISOString().slice(0, 10); -} -function weekdayLabels(weekStartsOn: 0 | 1): string[] { - const mondayFirst = ['一', '二', '三', '四', '五', '六', '日']; - return weekStartsOn === 1 ? mondayFirst : ['日', ...mondayFirst.slice(0, 6)]; -} -interface CalendarDay { - date: string; - label: number; - inMonth: boolean; - taskCount: number; -} -function calendarDays(month: string, weekStartsOn: 0 | 1 = 1): CalendarDay[] { - const [year, monthNumber] = month.split('-').map(Number); - const first = new Date(Date.UTC(year!, monthNumber! - 1, 1)); - const offset = weekStartsOn === 1 ? (first.getUTCDay() + 6) % 7 : first.getUTCDay(); - const daysInMonth = new Date(Date.UTC(year!, monthNumber!, 0)).getUTCDate(); - const previousDays = new Date(Date.UTC(year!, monthNumber! - 1, 0)).getUTCDate(); - return Array.from({ length: 42 }, (_, index) => { - const dayIndex = index - offset + 1; - const date = new Date(Date.UTC(year!, monthNumber! - 1, dayIndex)); - const inMonth = dayIndex >= 1 && dayIndex <= daysInMonth; - return { - date: date.toISOString().slice(0, 10), - label: inMonth ? dayIndex : dayIndex < 1 ? previousDays + dayIndex : dayIndex - daysInMonth, - inMonth, - taskCount: 0, - }; - }); -} From bf8f5cc32d9c51df30676c83428818345e4dd1f4 Mon Sep 17 00:00:00 2001 From: IGNGserver <176017966+IGNGserver@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:37:20 +0800 Subject: [PATCH 06/13] fix(web): remove unused TreeItemDto, ListChecksIcon and TaskHoverPreviewRow in App.tsx --- apps/web/src/App.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index bce9da0..06eef81 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,7 +4,6 @@ import type { TaskStatus, TimePointDto, TimePointPlacementCountDto, - TreeItemDto, TreeTaskDto, V2SettingsDto, } from '@devtodo/contracts'; @@ -27,7 +26,6 @@ import { Command, Copy, LayoutList, - ListChecks as ListChecksIcon, LoaderCircle, LogOut, Menu, From 054850a9237b26043e5bc900793ae501351ff666 Mon Sep 17 00:00:00 2001 From: IGNGserver <176017966+IGNGserver@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:39:43 +0800 Subject: [PATCH 07/13] fix(web): remove unused TaskHoverPreviewRow import from App.tsx --- apps/web/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 06eef81..18474cd 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -145,7 +145,7 @@ import { weekdayLabels, } from './date-utils.js'; import { getNetworkMode, setNetworkMode, type NetworkMode } from './network-policy.js'; -import { TaskHoverPreviewRow, useTaskHoverPreview } from './components/task-hover-preview.js'; +import { useTaskHoverPreview } from './components/task-hover-preview.js'; import { notifyDataChanged, shouldReloadTaskList } from './data-changes.js'; const TreePage = lazy(() => import('./TreePage.js').then((page) => ({ default: page.TreePage }))); const WorkflowsPage = lazy(() => From 3d338e7ed41690e15a2e1c041c6d51efefe39e2d Mon Sep 17 00:00:00 2001 From: IGNGserver <176017966+IGNGserver@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:42:50 +0800 Subject: [PATCH 08/13] fix(web): add TaskDirectoryPickerModal import to App.tsx --- apps/web/src/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 18474cd..f1a9b87 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,5 @@ import type { +import { TaskDirectoryPickerModal } from './components/task-directory-picker-modal.js'; FolderDto, PlacementDto, TaskStatus, From b4685327a46f668e2f4220d0fecfa058b688ba86 Mon Sep 17 00:00:00 2001 From: IGNGserver <176017966+IGNGserver@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:43:11 +0800 Subject: [PATCH 09/13] fix(web): place TaskDirectoryPickerModal import after contracts type block --- apps/web/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f1a9b87..853a013 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,5 +1,4 @@ import type { -import { TaskDirectoryPickerModal } from './components/task-directory-picker-modal.js'; FolderDto, PlacementDto, TaskStatus, @@ -8,6 +7,7 @@ import { TaskDirectoryPickerModal } from './components/task-directory-picker-mod TreeTaskDto, V2SettingsDto, } from '@devtodo/contracts'; +import { TaskDirectoryPickerModal } from './components/task-directory-picker-modal.js'; import { isMergeableConflictCommand, readUpgradePending, From 06b800dec5d2ae2c81ebec16859fd571fcd81af0 Mon Sep 17 00:00:00 2001 From: IGNGserver <176017966+IGNGserver@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:47:59 +0800 Subject: [PATCH 10/13] fix(mobile): resolve conflicting imports and nullable referenceId in WorkflowsV2Screen --- .../java/com/devtodo/app/ui/screens/WorkflowsV2Screen.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/mobile/android/app/src/main/java/com/devtodo/app/ui/screens/WorkflowsV2Screen.kt b/apps/mobile/android/app/src/main/java/com/devtodo/app/ui/screens/WorkflowsV2Screen.kt index 8bcb5a0..755dfbd 100644 --- a/apps/mobile/android/app/src/main/java/com/devtodo/app/ui/screens/WorkflowsV2Screen.kt +++ b/apps/mobile/android/app/src/main/java/com/devtodo/app/ui/screens/WorkflowsV2Screen.kt @@ -17,8 +17,6 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.FilterChip import androidx.compose.material3.Surface -import com.devtodo.app.data.local.FolderEntity -import com.devtodo.app.data.local.TaskEntity import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.AccountTree @@ -35,8 +33,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.devtodo.app.data.local.FolderEntity -import com.devtodo.app.data.local.TaskEntity import com.devtodo.app.data.local.WorkflowEntity import com.devtodo.app.data.local.WorkflowStageEntity import com.devtodo.app.data.local.WorkflowTaskMembershipEntity @@ -665,7 +661,7 @@ private fun WorkflowDirectoryTaskPickerDialog( if (needle.isNotEmpty()) { allTasks.filter { it.archivedAt == null && it.deletedAt == null && - (it.title.lowercase().contains(needle) || it.referenceId.lowercase().contains(needle)) + (it.title.lowercase().contains(needle) || (it.referenceId?.lowercase()?.contains(needle) == true)) } } else { allTasks.filter { @@ -785,7 +781,7 @@ private fun WorkflowDirectoryTaskPickerDialog( modifier = Modifier.weight(1f), ) Text( - text = task.referenceId, + text = task.referenceId.orEmpty(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) From c772a1ec8f6ba541cc6ac81e27da40c30ba433eb Mon Sep 17 00:00:00 2001 From: IGNGserver <176017966+IGNGserver@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:50:41 +0800 Subject: [PATCH 11/13] fix(mobile): use for loops instead of forEach in Composable dialog --- .../java/com/devtodo/app/ui/screens/WorkflowsV2Screen.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/mobile/android/app/src/main/java/com/devtodo/app/ui/screens/WorkflowsV2Screen.kt b/apps/mobile/android/app/src/main/java/com/devtodo/app/ui/screens/WorkflowsV2Screen.kt index 755dfbd..7797b32 100644 --- a/apps/mobile/android/app/src/main/java/com/devtodo/app/ui/screens/WorkflowsV2Screen.kt +++ b/apps/mobile/android/app/src/main/java/com/devtodo/app/ui/screens/WorkflowsV2Screen.kt @@ -33,6 +33,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.devtodo.app.data.local.FolderEntity +import com.devtodo.app.data.local.TaskEntity import com.devtodo.app.data.local.WorkflowEntity import com.devtodo.app.data.local.WorkflowStageEntity import com.devtodo.app.data.local.WorkflowTaskMembershipEntity @@ -624,7 +626,6 @@ private fun ExpressiveStageBlock( private data class AddTarget(val workflow: WorkflowEntity, val stage: WorkflowStageEntity) - @Composable private fun WorkflowDirectoryTaskPickerDialog( stageName: String, @@ -702,7 +703,7 @@ private fun WorkflowDirectoryTaskPickerDialog( onClick = { currentFolderId = null }, label = { Text("根目录") }, ) - breadcrumbs.forEach { folder -> + for (folder in breadcrumbs) { Icon( Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, @@ -726,7 +727,7 @@ private fun WorkflowDirectoryTaskPickerDialog( verticalArrangement = Arrangement.spacedBy(4.dp), ) { if (query.trim().isEmpty()) { - visibleFolders.forEach { folder -> + for (folder in visibleFolders) { Surface( shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), @@ -764,7 +765,7 @@ private fun WorkflowDirectoryTaskPickerDialog( } } - visibleTasks.forEach { task -> + for (task in visibleTasks) { TextButton( onClick = { onTaskSelect(task) }, modifier = Modifier.fillMaxWidth(), From cefd1505d6a714df06695cf2e265300401f65118 Mon Sep 17 00:00:00 2001 From: IGNGserver <176017966+IGNGserver@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:08:05 +0800 Subject: [PATCH 12/13] fix(picker): restore full baseline picker features and align E2E hooks --- apps/web/src/App.tsx | 466 +++++++++++++++++- .../task-directory-picker-modal.tsx | 131 +++-- 2 files changed, 523 insertions(+), 74 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 853a013..663b4a3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,10 +4,10 @@ import type { TaskStatus, TimePointDto, TimePointPlacementCountDto, + TreeItemDto, TreeTaskDto, V2SettingsDto, } from '@devtodo/contracts'; -import { TaskDirectoryPickerModal } from './components/task-directory-picker-modal.js'; import { isMergeableConflictCommand, readUpgradePending, @@ -27,6 +27,7 @@ import { Command, Copy, LayoutList, + ListChecks as ListChecksIcon, LoaderCircle, LogOut, Menu, @@ -146,7 +147,7 @@ import { weekdayLabels, } from './date-utils.js'; import { getNetworkMode, setNetworkMode, type NetworkMode } from './network-policy.js'; -import { useTaskHoverPreview } from './components/task-hover-preview.js'; +import { TaskHoverPreviewRow, useTaskHoverPreview } from './components/task-hover-preview.js'; import { notifyDataChanged, shouldReloadTaskList } from './data-changes.js'; const TreePage = lazy(() => import('./TreePage.js').then((page) => ({ default: page.TreePage }))); const WorkflowsPage = lazy(() => @@ -2844,34 +2845,447 @@ function AddTaskModal({ onClose: () => void; onAdded: () => void; }) { + const { isTouch } = useDevice(); + const [query, setQuery] = useState(''); + const [folderId, setFolderId] = useState(null); + const [items, setItems] = useState([]); + const [path, setPath] = useState>>([]); + const [searchIndex, setSearchIndex] = useState<{ + tasks: TreeTaskDto[]; + folders: FolderDto[]; + } | null>(null); + const [error, setError] = useState(''); + const [pickerError, setPickerError] = useState(''); + const [searchError, setSearchError] = useState(''); + const [searchRetry, setSearchRetry] = useState(0); + const [pickerLoading, setPickerLoading] = useState(false); + const [selectedTasks, setSelectedTasks] = useState>(() => new Map()); + const [existingTaskIds, setExistingTaskIds] = useState>(() => new Set()); + const [addingSelected, setAddingSelected] = useState(false); + const pickerLoadSequence = useRef(0); + const existingLoadSequence = useRef(0); + const searchLoadSequence = useRef(0); + const loadedPickerFolder = useRef(undefined); + + const loadPicker = useCallback(async () => { + const sequence = ++pickerLoadSequence.current; + setPickerLoading(true); + setPickerError(''); + try { + const queryString = folderId + ? `?parentFolderId=${encodeURIComponent(folderId)}` + : '?parentFolderId=root'; + const [children, nextPath] = await Promise.all([ + requestV2<{ items: TreeItemDto[] }>(`/tree/children${queryString}`), + folderId + ? requestV2<{ items: Array> }>( + `/folders/${encodeURIComponent(folderId)}/path`, + ) + : Promise.resolve({ items: [] }), + ]); + if (sequence !== pickerLoadSequence.current) return; + loadedPickerFolder.current = folderId; + setItems(children.items); + setPath(nextPath.items); + } catch (cause) { + if (sequence === pickerLoadSequence.current) + setPickerError(cause instanceof ApiError ? cause.message : '任务目录加载失败,请重试'); + } finally { + if (sequence === pickerLoadSequence.current) setPickerLoading(false); + } + }, [folderId]); + + /** + * The tasks already placed on this time point. They stay visible in the + * picker as checked, disabled rows so nobody re-adds a task that is on the + * list already. A failed refresh keeps the previous snapshot: the picker is + * still usable without it. + */ + const loadExistingPlacements = useCallback(async () => { + const sequence = ++existingLoadSequence.current; + try { + const response = await requestV2<{ items: PlacementWithTask[] }>( + `/time-points/${pointId}/placements`, + ); + if (sequence !== existingLoadSequence.current) return; + setExistingTaskIds(new Set(response.items.map((item) => item.taskId))); + } catch { + /* Keep the last snapshot; the picker still works without it. */ + } + }, [pointId]); + + useEffect(() => { + void loadPicker(); + void loadExistingPlacements(); + const listener = (event: Event) => { + if (!shouldReloadTaskList(event)) return; + // Keep the last search results mounted while the index revalidates. + setSearchError(''); + setSearchRetry((count) => count + 1); + void loadPicker(); + void loadExistingPlacements(); + }; + window.addEventListener('devtodo:data-changed', listener); + return () => window.removeEventListener('devtodo:data-changed', listener); + }, [loadExistingPlacements, loadPicker]); + + const hasSearchQuery = Boolean(query.trim()); + useEffect(() => { + if (!hasSearchQuery) return; + let cancelled = false; + const sequence = ++searchLoadSequence.current; + const timeout = window.setTimeout(() => { + setSearchError(''); + void Promise.all([ + requestV2<{ items: TreeTaskDto[] }>('/tasks'), + requestV2<{ items: FolderDto[] }>('/folders'), + ]) + .then(([taskResult, folderResult]) => { + if (cancelled || sequence !== searchLoadSequence.current) return; + setSearchIndex({ tasks: taskResult.items, folders: folderResult.items }); + }) + .catch((cause) => { + if (cancelled || sequence !== searchLoadSequence.current) return; + setSearchError(cause instanceof ApiError ? cause.message : '搜索索引加载失败,请重试'); + }); + }, 180); + return () => { + cancelled = true; + window.clearTimeout(timeout); + if (sequence === searchLoadSequence.current) searchLoadSequence.current += 1; + }; + }, [hasSearchQuery, searchRetry]); + + const activeItems = useMemo( + () => + items.filter( + (item) => item.kind === 'FOLDER' || (!item.task.deletedAt && item.task.status !== 'DONE'), + ), + [items], + ); + const searchFoldersById = useMemo(() => { + const byId = new Map(); + for (const folder of searchIndex?.folders ?? []) byId.set(folder.id, folder); + return byId; + }, [searchIndex]); + const taskLocation = useCallback( + (task: TreeTaskDto) => { + const titles: string[] = []; + let parentId = task.parentFolderId; + let visited = 0; + while (parentId && visited < (searchIndex?.folders.length ?? 0)) { + const folder = searchFoldersById.get(parentId); + if (!folder) break; + titles.unshift(folder.title); + parentId = folder.parentFolderId; + visited += 1; + } + return titles.join(' / ') || '根目录'; + }, + [searchFoldersById, searchIndex?.folders.length], + ); + const matchingTasks = useMemo(() => { + const needle = query.trim().toLocaleLowerCase(); + if (!needle || !searchIndex) return []; + return searchIndex.tasks.filter( + (task) => + !task.deletedAt && + task.status !== 'DONE' && + (task.title.toLocaleLowerCase().includes(needle) || + task.referenceId.toLocaleLowerCase().includes(needle) || + taskLocation(task).toLocaleLowerCase().includes(needle)), + ); + }, [query, searchIndex, taskLocation]); + const searchLoading = Boolean(query.trim()) && !searchIndex && !searchError; + + const openFolder = (id: string | null) => { + setQuery(''); + setFolderId(id); + }; + const toggleTaskSelection = (task: TreeTaskDto) => { + setSelectedTasks((current) => { + const next = new Map(current); + if (next.has(task.id)) next.delete(task.id); + else next.set(task.id, task); + return next; + }); + }; + const addSelected = async () => { + if (addingSelected || selectedTasks.size === 0) return; + const tasks = [...selectedTasks.values()]; + setAddingSelected(true); + setError(''); + const addedIds = new Set(); + const failures: unknown[] = []; + try { + for (const task of tasks) { + try { + await mutationV2('POST', '/placements', { taskId: task.id, timePointId: pointId }); + addedIds.add(task.id); + } catch (cause) { + failures.push(cause); + } + } + if (addedIds.size) { + setSelectedTasks((current) => new Map([...current].filter(([id]) => !addedIds.has(id)))); + setExistingTaskIds((current) => new Set([...current, ...addedIds])); + window.dispatchEvent(new Event('devtodo:data-changed')); + } + if (failures.length) { + const cause = failures[0]; + const detail = cause instanceof ApiError ? cause.message : '请检查后重试'; + setError(`已加入 ${addedIds.size} 项,${failures.length} 项失败:${detail}`); + } else { + onAdded(); + } + } finally { + setAddingSelected(false); + } + }; + const renderTaskRow = (task: TreeTaskDto, showLocation: boolean) => { + const selected = selectedTasks.has(task.id); + const alreadyAdded = existingTaskIds.has(task.id); + return ( + + {(anchorProps) => ( + {taskLocation(task)} + ) : undefined + } + leading={ + + } + trailing={ + alreadyAdded ? ( + + ) : ( + !selected && + ); + }; + const renderTreeItem = (item: TreeItemDto) => + item.kind === 'FOLDER' ? ( + } + headline={item.folder.title} + supporting={ + <> + + + {item.aggregate.doneCount}/{item.aggregate.totalCount} 已完成 + + + } + trailing={