修复: 项目详情页三态分离与反馈一致性
- notFound/已归档/加载失败三态分离:单查兜底+归档恢复入口+加载失败重试钮 - 反馈一致性六项:同步守卫与完成提示/首屏并行加载/筛选空态文案/计数改过滤口径/路径勾选 seq 守卫/删除确认危险态 - 文件列表滚动条对齐全局范式(scrollbar-width:thin 致 Chromium 忽略定制样式) - 删除失败不再跳转(store 返布尔)/幽灵勾选清理/折叠阈值与 CSS 对齐
This commit is contained in:
+139
-31
@@ -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() {
|
||||
await store.loadProjects()
|
||||
await loadProjectTasks() // 本项目任务独立拉取,不依赖 store.loadTasks
|
||||
// A9[PD-P3-18]:同步补齐工程列表 + 灵感回溯,避免「新增/扫描工程后点同步不刷新」
|
||||
await loadModules()
|
||||
await store.loadIdeas()
|
||||
await checkPath()
|
||||
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; }
|
||||
|
||||
Reference in New Issue
Block a user