修复: 项目详情页三态分离与反馈一致性

- notFound/已归档/加载失败三态分离:单查兜底+归档恢复入口+加载失败重试钮
- 反馈一致性六项:同步守卫与完成提示/首屏并行加载/筛选空态文案/计数改过滤口径/路径勾选 seq 守卫/删除确认危险态
- 文件列表滚动条对齐全局范式(scrollbar-width:thin 致 Chromium 忽略定制样式)
- 删除失败不再跳转(store 返布尔)/幽灵勾选清理/折叠阈值与 CSS 对齐
This commit is contained in:
lxy
2026-10-15 14:27:32 +08:00
parent 795935bf6a
commit a644e93604
8 changed files with 167 additions and 71 deletions
+2
View File
@@ -104,6 +104,8 @@ export interface ProjectRecord {
path: string | null
/** 技术栈 JSON 数组字符串(如 ["rust","vue"],null=未探测) */
stack: string | null
/** 归档标记(归档功能 P2,V48)。true=归档态,退出主列表仅归档箱可见;详情页据此区分「已归档」与「不存在」 */
archived?: boolean
created_at: string
updated_at: string
/**
+5 -38
View File
@@ -809,7 +809,9 @@ async function onRemoveModule() {
overflow-y: auto;
overflow-x: hidden;
padding: 4px 4px 4px 0;
scrollbar-width: thin;
/* 滚动条样式走全局 ::-webkit-scrollbar(5px + var(--df-scrollbar) 主题色):
此处不得设 scrollbar-width —— Chromium 121+ 一旦设了标准 scrollbar-* 属性,
该元素 ::-webkit-scrollbar 定制即失效,文件列表会回退成原生样式滚动条(不随主题) */
}
.explorer-changes {
@@ -888,43 +890,8 @@ async function onRemoveModule() {
min-height: 200px;
}
/* 全局滚动条样式(薄) */
.explorer-sidebar ::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.explorer-sidebar ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: var(--df-radius-xs);
}
.explorer-sidebar ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.14);
}
.explorer-sidebar ::-webkit-scrollbar-track {
background: transparent;
}
/* 文件列表(.explorer-tree)滚动条显式对齐全局,与右侧预览区一致 */
.explorer-tree ::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.explorer-tree ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: var(--df-radius-xs);
}
.explorer-tree ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.14);
}
.explorer-tree ::-webkit-scrollbar-track {
background: transparent;
}
/* 滚动条统一样式(styles/global.css 全局 ::-webkit-scrollbar:5px + var(--df-scrollbar))已覆盖,
此处不重复声明 —— 原局部副本硬编码白色系 rgba,浅色主题下 thumb 不可见 */
/* 窄窗口自适应 */
@media (max-width: 900px) {
+1
View File
@@ -13,6 +13,7 @@ export default {
edit: 'Edit',
close: 'Close',
loading: 'Loading…',
retry: 'Retry',
unknownError: 'Unknown error',
// Collapse/expand (reusable generic verbs for description/log/future long content)
expand: 'Expand',
+7
View File
@@ -33,6 +33,13 @@ export default {
taskListTitle: '🔀 Tasks',
taskCount: '{n} tasks',
emptyTasks: 'No tasks yet',
filterEmptyTasks: 'No tasks match the current filter',
// Sync (PD5-9)
syncDone: 'Sync complete',
// PD5-4 tri-state: archived (restore from archive box)
archivedNotice: 'This project is archived and not in the project list',
restoreFromArchive: 'Restore from Archive',
restoreSuccess: 'Restored to the project list',
// Workflow log panel
workflowLogTitle: '📋 Workflow Log',
// A2[PD-P1-3]: event stream has no project_id dimension, cannot filter per project; label "global events" to avoid misreading as this project's logs
+1
View File
@@ -13,6 +13,7 @@ export default {
edit: '编辑',
close: '关闭',
loading: '加载中…',
retry: '重试',
unknownError: '未知错误',
// 折叠/展开(可复用通用词,描述折叠/日志折叠/未来长内容)
expand: '展开',
+7
View File
@@ -33,6 +33,13 @@ export default {
taskListTitle: '🔀 任务列表',
taskCount: '{n} 个任务',
emptyTasks: '暂无任务',
filterEmptyTasks: '当前筛选无任务',
// 同步(PD5-9)
syncDone: '同步完成',
// PD5-4 三态:已归档(归档箱恢复)
archivedNotice: '项目已归档,不在项目列表中',
restoreFromArchive: '从归档箱恢复',
restoreSuccess: '已恢复到项目列表',
// 工作流日志面板
workflowLogTitle: '📋 工作流日志',
// A2[PD-P1-3]:事件流无 project_id 维度,无法按项目过滤,标注「全局事件」避免误读为本项目
+5 -2
View File
@@ -54,11 +54,14 @@ export function createProjectsStore() {
return record
}
async function deleteProject(id: string) {
await runWithCatch(state, t('projects.err.deleteFailed'), async () => {
async function deleteProject(id: string): Promise<boolean> {
// 返成功布尔(失败时 runWithCatch 已置 state.error),详情页删除据此阻断失败后的误跳转
const ok = await runWithCatch(state, t('projects.err.deleteFailed'), async () => {
await projectApi.delete(id) // 软删 → 回收站(可恢复)
state.projects = state.projects.filter(p => p.id !== id)
return true
})
return ok === true
}
async function loadDeletedProjects() {
+133 -25
View File
@@ -2,11 +2,22 @@
<div class="project-detail">
<!-- 加载态 -->
<div v-if="loading" class="loading-state">{{ $t('common.loading') }}</div>
<!-- 加载失败(列表拉取报错,单列给重试,不让失败伪装成「项目不存在」) -->
<div v-else-if="loadFailed" class="empty-state">
<p>{{ store.error }}</p>
<button class="btn btn-primary" @click="retryLoad">{{ $t('common.retry') }}</button>
</div>
<!-- 项目不存在 -->
<div v-else-if="!currentProject" class="empty-state">
<p>{{ $t('projectDetail.notFound') }}</p>
<router-link to="/projects" class="btn btn-primary">{{ $t('projectDetail.backToList') }}</router-link>
</div>
<!-- 已归档(归档项目不在主列表,直链可达;给归档箱恢复入口) -->
<div v-else-if="currentProject.archived" class="empty-state">
<p>{{ $t('projectDetail.archivedNotice') }}</p>
<button class="btn btn-primary" @click="restoreFromArchive">{{ $t('projectDetail.restoreFromArchive') }}</button>
<router-link to="/projects" class="btn btn-ghost">{{ $t('projectDetail.backToList') }}</router-link>
</div>
<!-- 项目详情 -->
<template v-else>
<!-- 页面头部 -->
@@ -17,7 +28,10 @@
<span v-if="statusLabel" class="stage-badge" :class="'stage-' + stageKey">{{ $t(statusLabel) }}</span>
</div>
<div class="header-actions">
<button class="btn btn-ghost" @click="handleSync">{{ $t('projectDetail.sync') }}</button>
<button class="btn btn-ghost" :disabled="syncing" @click="handleSync">
<span v-if="syncing" class="spinner"></span>
{{ $t('projectDetail.sync') }}
</button>
<button class="btn btn-ghost" type="button" @click="handleImportDir">{{ $t('projectDetail.importDir') }}</button>
<button class="btn btn-danger" @click="handleDelete">{{ $t('projectDetail.delete') }}</button>
<button class="btn btn-primary" @click="showNewTaskModal = true">{{ $t('projectDetail.newTask') }}</button>
@@ -293,7 +307,7 @@
<section class="panel">
<div class="panel-header">
<h2>{{ $t('projectDetail.taskListTitle') }}</h2>
<span class="task-count">{{ $t('projectDetail.taskCount', { n: projectTasks.length }) }}</span>
<span class="task-count">{{ $t('projectDetail.taskCount', { n: filteredProjectTasks.length }) }}</span>
<!-- 全选(勾选后触发底部批量操作栏) -->
<label class="select-all" :title="$t('tasks.batch.selectHint')">
<input
@@ -322,7 +336,7 @@
class="task-card"
v-for="task in filteredProjectTasks"
:key="task.id"
@click="router.push('/tasks/' + task.id)"
@click="openDrawer(task.id)"
>
<div class="task-card-checkbox">
<input
@@ -348,6 +362,8 @@
</div>
</div>
<div v-if="!tasksLoading && projectTasks.length === 0" class="empty-hint">{{ $t('projectDetail.emptyTasks') }}</div>
<!-- PD5-12:筛选把列表清空后给提示,不留空白区 -->
<div v-else-if="!tasksLoading && filteredProjectTasks.length === 0" class="empty-hint">{{ $t('projectDetail.filterEmptyTasks') }}</div>
<!-- 批量操作栏(选中任务后浮现) -->
<TaskBatchBar
v-if="selection.count > 0"
@@ -418,6 +434,15 @@
<!-- 确认弹层(删除/重定位确认,替代原生 window.confirm/alert) -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" :danger-label="confirmState.dangerLabel" @result="answerConfirm" />
<!-- 任务快开抽屉(PD5-22):点任务卡就地滑出详情,替代整页跳 /tasks/:id(其返回链固定回
/tasks 会断项目上下文);与 Tasks.vue 同款接线,推进成功后重拉本项目任务列表 -->
<TaskDrawer
:task-id="drawerTaskId"
:visible="drawerVisible"
@close="closeDrawer()"
@advance-done="loadProjectTasks()"
/>
<!-- 新增/编辑工程弹窗(概览内 CRUD,对标 FileExplorer 工程 modal) -->
<div v-if="showModuleModal" class="modal-overlay" @click.self="showModuleModal = false">
<div class="modal-box">
@@ -463,7 +488,7 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, nextTick, onBeforeUnmount, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, reactive, nextTick, onBeforeUnmount, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
@@ -478,6 +503,7 @@ import { parseScores as parseScoresJson, assessmentClass, assessmentLabel as ass
import { projectStatusLabel, taskStatusLabel, taskStatusClass, TASK_STATUS_TRANSITIONS, TASK_STATUS_ORDER } from '../constants/project'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import TaskBatchBar from '@/components/task/TaskBatchBar.vue'
import TaskDrawer from '@/components/task/TaskDrawer.vue'
import FileExplorer from '@/components/project/FileExplorer.vue'
import DependencyGraph from '@/components/project/DependencyGraph.vue'
import WorkflowDagDisplay from '@/components/workflow/WorkflowDagDisplay.vue'
@@ -485,7 +511,7 @@ import { useConfirm } from '@/composables/useConfirm'
import { useRendered } from '@/composables/useMarkdown'
import { useTaskBatchSelection } from '@/composables/task/useTaskBatchSelection'
import { useTaskBatchActions } from '@/composables/task/useTaskBatchActions'
import type { ProjectId, TaskRecord, DfDataChangedPayload } from '@/api/types'
import type { ProjectId, ProjectRecord, TaskRecord, DfDataChangedPayload } from '@/api/types'
const route = useRoute()
const router = useRouter()
@@ -520,7 +546,9 @@ function setActiveTab(tab: DetailTab) {
// A8[PD-P3-16]:弃字符数近似(渲染后 Markdown 列表/标题行高不一,字符数与 px 无线性关系),
// 对齐 TaskDetail 用 descEl 实测 scrollHeight —— 首次渲染 + renderedDesc 变化后 nextTick 测量。
// descCollapsible 仅当真实渲染高度超过折叠阈值才为 true(避免短文本显无用的展开按钮)。
const DESC_COLLAPSE_PX = 96 // 折叠 max-height 6em(≈4 行 ×14px×1.6)
// 折叠阈值必须与折叠裁剪高(.description-wrap.is-collapsed 的 max-height 6em = 14px×6 = 84px)一致:
// 阈值高于裁剪高时,内容高落在 84~阈值 区间会「已被 CSS 裁掉尾部却无展开按钮」(4 行描述 89.6px 即命中)
const DESC_COLLAPSE_PX = 84
const descEl = ref<HTMLElement | null>(null)
const descExpanded = ref(false)
const descOverflow = ref(false)
@@ -551,10 +579,54 @@ const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// 状态文案/阶段进度统一走 ../constants/project(与 Projects/Tasks/Dashboard 一致,
// 根治此前 stageMap 含不存在的 testing 状态、三套映射互相矛盾)
const projectId = computed(() => route.params.id as string)
// PD5-4:列表 find 不到时单查兜底(归档项目不在主列表/列表侧带 query 时直链仍可达),
// 同时消化 projectApi.get 死代码;「已归档/加载失败/不存在」三态由模板分支单列
const fallbackProject = ref<ProjectRecord | null>(null)
const currentProject = computed(() =>
store.projects.find(p => p.id === projectId.value)
store.projects.find(p => p.id === projectId.value) ?? fallbackProject.value
)
const loading = computed(() => store.projects.length === 0 && !store.error)
// 兜底单查进行中也算加载中,避免先闪「项目不存在」
const fallbackLoading = ref(false)
const loading = computed(() =>
(store.projects.length === 0 && !store.error) || fallbackLoading.value
)
// 列表拉取失败(store.error 置位且无项目可显)单列,给重试入口
const loadFailed = computed(() => !loading.value && !currentProject.value && !!store.error)
// 序号守卫(A4 同款):快速切项目时旧单查结果晚到不覆盖新项目
let projectReqSeq = 0
async function loadFallbackProject() {
const seq = ++projectReqSeq
const pid = projectId.value
if (!pid || store.projects.some(p => p.id === pid)) { fallbackProject.value = null; return }
fallbackLoading.value = true
try {
const record = await projectApi.get(pid)
if (seq !== projectReqSeq || pid !== projectId.value) return
fallbackProject.value = record
} catch {
if (seq !== projectReqSeq || pid !== projectId.value) return
fallbackProject.value = null
} finally {
if (seq === projectReqSeq) fallbackLoading.value = false
}
}
// 加载失败重试(清 error 后重拉列表 + 兜底单查,期间 loading 态接管视图)
async function retryLoad() {
store.clearError()
await store.loadProjects()
await loadFallbackProject()
}
// 归档箱恢复(取消归档回主列表;成功后 store.projects 已刷新,currentProject 自动命中列表)
async function restoreFromArchive() {
const p = currentProject.value
if (!p) return
await store.unarchiveProject(p.id)
if (store.error) { Message.error(store.error); return }
Message.success(t('projectDetail.restoreSuccess'))
}
// 状态文案/阶段进度统一走 ../constants/project
// 项目描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例),
@@ -674,6 +746,9 @@ const batch = reactive(useTaskBatchActions({
async function afterBatch() {
selection.prune()
}
// 幽灵勾选收敛:筛选切换/外部数据变更重拉后可见范围已变,选中集中被筛掉或已删除的 id 若残留,
// 批量栏计数含不可见任务、对其执行动作会落 failed —— 范围一变即收敛(afterBatch 只覆盖批量后时机)
watch(filteredProjectTasks, () => { selection.prune() })
const batchAdvanceTargets = computed(() => {
const ids = [...selection.selected]
if (ids.length === 0) return []
@@ -725,6 +800,13 @@ const assigneeSuggestions = computed(() => {
return [...set]
})
// 任务快开抽屉状态(与 Tasks.vue 同款):打开是同步置 ref,无失败路径;
// 抽屉内数据拉取失败由 TaskDrawer 自身错误态兜底,页面行为不受影响
const drawerTaskId = ref<string | null>(null)
const drawerVisible = computed(() => drawerTaskId.value !== null)
function openDrawer(id: string) { drawerTaskId.value = id }
function closeDrawer() { drawerTaskId.value = null }
// ── 工程列表(概览内 CRUD)──
// 多工程(monorepo/多模块)在概览内增删改 + 扫描;复用 moduleApi + ConfirmDialog(删除确认)
// + arco Message(反馈)。切到文件 Tab 时 FileExplorer 因 v-if 重挂载自动重拉,无需手动同步。
@@ -920,6 +1002,7 @@ const newTaskBranch = ref('')
const submitting = ref(false)
async function submitNewTask() {
if (submitting.value) return // 重入守卫:Enter 直调绕过按钮 disabled,IPC 期间二次 Enter 会重复建任务
if (!newTaskTitle.value.trim()) return
submitting.value = true
try {
@@ -929,7 +1012,11 @@ async function submitNewTask() {
description: newTaskDesc.value.trim(),
branch_name: newTaskBranch.value.trim() || undefined,
})
if (!r) return // 失败已 toast,保持弹窗不关
if (!r) {
// store.createTask 失败只写 state.error 不弹提示(useStoreAction 统一吞错),此处补页面可见反馈
if (store.error) Message.error(store.error)
return // 保持弹窗不关,便于修正后重试
}
// 同步到本地 ref(store.createTask 已 push 全局 tasks,但本视图不再读 store.tasks)
projectTasks.value = [r, ...projectTasks.value]
showNewTaskModal.value = false
@@ -942,25 +1029,41 @@ async function submitNewTask() {
}
// ── 同步 ──
// PD5-9:busy 态防并发同步(串行 await 期间按钮可重复点)+ 完成轻提示
const syncing = ref(false)
async function handleSync() {
if (syncing.value) return
syncing.value = true
try {
await store.loadProjects()
await loadFallbackProject() // AI 工具可在别处归档本项目,列表外项目靠兜底单查刷新
await loadProjectTasks() // 本项目任务独立拉取,不依赖 store.loadTasks
// A9[PD-P3-18]:同步补齐工程列表 + 灵感回溯,避免「新增/扫描工程后点同步不刷新」
await loadModules()
await store.loadIdeas()
await checkPath()
Message.success(t('projectDetail.syncDone'))
} finally {
syncing.value = false
}
}
// ── 代码目录绑定 ──
const pathExists = ref<boolean | null>(null)
// 检查绑定目录是否存在(详情页「目录是否还在」)
// PD5-14:请求序号守卫(A4 同款)—— 快速切项目时旧检测结果晚到,不覆盖新项目目录状态
let pathReqSeq = 0
async function checkPath() {
const seq = ++pathReqSeq
const p = currentProject.value
if (!p?.path) { pathExists.value = null; return }
try {
pathExists.value = await projectApi.checkPathExists(p.path)
const exists = await projectApi.checkPathExists(p.path)
if (seq !== pathReqSeq) return
pathExists.value = exists
} catch {
if (seq !== pathReqSeq) return
pathExists.value = null
}
}
@@ -1026,8 +1129,13 @@ async function handleImportDir() {
async function handleDelete() {
const p = currentProject.value
if (!p) return
if (!await confirmDialog(t('projectDetail.confirmDelete', { name: p.name }))) return
await store.deleteProject(p.id)
if (!await confirmDialog(t('projectDetail.confirmDelete', { name: p.name }), t('common.delete'))) return
const ok = await store.deleteProject(p.id)
if (!ok) {
// 删除失败留在本页(store 已写 state.error,此处补可见提示),不能与成功同样跳走
if (store.error) Message.error(store.error)
return
}
router.push('/projects')
}
@@ -1045,13 +1153,10 @@ onMounted(async () => {
// 恢复持久化的 Tab 选择(按项目隔离)
activeTab.value = readStoredTab(projectId.value)
await store.loadProjects()
// 本项目任务独立拉取(不调 store.loadTasks,避免被 Tasks 视图分页/筛选污染)
await loadProjectTasks()
// 工程列表独立拉取(FileExplorer 文件 Tab 内 CRUD,概览只读展示)
await loadModules()
// 来源灵感回溯:项目有 idea_id 但 store.ideas 为空(本页未 load 过)时补拉,
// 否则 sourceIdea computed 永远找不到对应灵感记录
await store.loadIdeas()
// PD5-10:任务/工程/灵感/兜底单查互不依赖,并行拉取缩短首屏串行链;
// checkPath 依赖项目记录就位(列表或兜底),保持原序殿后。
// 任务独立拉取不读 store.tasks(避免被 Tasks 视图分页/筛选污染);灵感补拉供来源回溯
await Promise.all([loadProjectTasks(), loadModules(), store.loadIdeas(), loadFallbackProject()])
await store.startEventListener()
await checkPath()
// A8[PD-P3-16]:首次渲染后测描述真实高度(纯文本描述同步渲染,renderedDesc watch 可能不触发)
@@ -1073,12 +1178,16 @@ onMounted(async () => {
// 路由切换到不同项目(URL /projects/:id 变化)时,重新拉取本项目任务 + 恢复 Tab + 检测目录
watch(projectId, async (newId) => {
if (!newId) return
// 切项目清空上一项目的勾选集:参数级导航组件复用不清状态,残留选中在新项目列表查不到,批量动作全落 failed
selection.clear()
activeTab.value = readStoredTab(newId)
// A5[PD-P2-5]:折叠态跨项目残留 —— 切项目重置描述展开 + 工作流日志折叠
descExpanded.value = false
workflowLogCollapsed.value = true
await loadProjectTasks()
await loadModules()
// PD5-4:切项目后重定兜底单查(上一项目的兜底记录不能带进新项目)
await loadFallbackProject()
})
// A3[PD-P2-1]:概览工程列表与 FileExplorer 双向同步 —— 文件 tab 内增删工程后,切回概览自动重拉
@@ -1098,11 +1207,10 @@ onBeforeUnmount(() => {
if (_unlistenDataChanged) { _unlistenDataChanged(); _unlistenDataChanged = null }
})
onUnmounted(() => {
// 用 store.stopEventListener 统一停全局监听并复位句柄(直接调 unlisten() 停监听但
// 不复位 _eventUnlisten,后续 startEventListener 幂等会误返回已死句柄,共享化后必查)
store.stopEventListener()
})
// 不在此停工作流事件监听:该监听是 workflow store 的全局单例(_eventUnlisten 共享,
// 本页与 TaskDetail 幂等共用),App.vue 的全局审批弹窗/浮窗全靠它常驻收事件置 pendingApproval。
// 卸载时 stop 会误杀全局监听——用户进过一次详情页再离开,任意页面审批弹窗即全失聪,
// 故对齐 App.vue data-changed「启动后常驻」模式,监听随应用生命周期存活,页面卸载不停。
</script>
<style scoped>
@@ -1325,7 +1433,7 @@ onUnmounted(() => {
/* P1-g+ 问题10③:描述折叠(对齐 TaskDetail 长内容)— 折叠时 max-height 截断 + 渐变遮罩 */
.description-wrap { position: relative; }
.description-wrap.is-collapsed .description {
max-height: 6em; /* 约 4 行(line-height 1.5) */
max-height: 6em; /* 14px×6=84px,与脚本 DESC_COLLAPSE_PX 同源,改一处必同步另一处 */
overflow: hidden;
}
.desc-toggle { margin-top: 6px; }