修复: 项目详情批量(任务列表联动刷新/工作流降级标注/中文git乱码/子目录错误行内/零工程空态/切tab保活) + 依赖图批量(节点渲染register/环高亮/防环防重/点击选中) + 销账

This commit is contained in:
lxy
2026-08-09 21:35:13 +08:00
parent 4a9f77872e
commit 4df91ed155
15 changed files with 402 additions and 77 deletions
+123 -20
View File
@@ -77,14 +77,19 @@
</button>
</nav>
<!-- 文件浏览器 Tab -->
<!-- 文件浏览器 Tab(B5[PD-P2-12]:KeepAlive 保活 overview 不销毁文件 tab
展开目录/选中文件/视图模式状态;首次激活才跑 onMounted,后续激活走 onActivated 复用状态) -->
<section v-if="activeTab === 'files'" class="file-explorer-wrap">
<FileExplorer :project-id="projectId" />
<KeepAlive>
<FileExplorer :project-id="projectId" />
</KeepAlive>
</section>
<!-- 依赖图 Tab -->
<!-- 依赖图 Tab(B5:同样 KeepAlive 保活,避免每次切 tab 重挂载重拉) -->
<section v-else-if="activeTab === 'graph'" class="file-explorer-wrap">
<DependencyGraph :project-id="projectId" />
<KeepAlive>
<DependencyGraph :project-id="projectId" />
</KeepAlive>
</section>
<!-- 概览 Tab:两栏(项目信息 + 任务列表),工作流日志移至下方(P1-g+ 问题10 三栏拥挤) -->
@@ -174,8 +179,8 @@
</span>
</div>
<p v-if="m.description" class="module-desc-overview">{{ m.description }}</p>
<div v-if="parseStack(m.stack).length" class="module-stack-overview">
<span class="tech-tag" v-for="tag in parseStack(m.stack)" :key="tag">{{ tag }}</span>
<div v-if="(moduleStack.get(m.id) ?? []).length" class="module-stack-overview">
<span class="tech-tag" v-for="tag in moduleStack.get(m.id)" :key="tag">{{ tag }}</span>
</div>
</div>
</div>
@@ -183,10 +188,10 @@
</div>
<!-- 技术栈 -->
<div class="info-item" v-if="parseStack(currentProject.stack).length">
<div class="info-item" v-if="projectStack.length">
<span class="label">{{ $t('projectDetail.techStackLabel') }}</span>
<div class="info-tags">
<span class="tech-tag" v-for="t in parseStack(currentProject.stack)" :key="t">{{ t }}</span>
<span class="tech-tag" v-for="t in projectStack" :key="t">{{ t }}</span>
</div>
</div>
<div class="info-item">
@@ -203,6 +208,7 @@
P1-g+ 问题10③:长描述折叠(对齐 TaskDetail 长内容),默认折叠截 4 ,展开看全文 -->
<div v-if="currentProject.description" class="description-wrap" :class="{ 'is-collapsed': !descExpanded }">
<span
ref="descEl"
class="value description ai-md"
v-html="renderedDesc"
></span>
@@ -265,7 +271,7 @@
<div
class="score-bar-fill"
:class="'fill-' + scoreTier(dim.score)"
:style="{ width: dim.score + '%' }"
:style="{ width: Math.min(100, Math.max(0, dim.score)) + '%' }"
></div>
</div>
<span class="score-bar-value">{{ dim.score }}</span>
@@ -326,10 +332,13 @@
<WorkflowDagDisplay v-if="wfDagJson" :dag-json="wfDagJson" :node-statuses="wfProgress.nodeStatuses" />
</section>
<!-- 工作流日志:无记录时折叠为摘要(标题 + 计数),有记录才展开列表 -->
<section v-if="formattedEvents.length > 0 || !workflowLogCollapsed" class="panel workflow-log-panel">
<!-- 工作流日志:A2[PD-P1-3]默认折叠为摘要(标题 + 全局标注 + 计数),有记录才展开列表
不设 v-if 条件(否则空事件 + 默认折叠时整面板消失,而非折叠) -->
<section class="panel workflow-log-panel">
<div class="panel-header">
<h2>{{ $t('projectDetail.workflowLogTitle') }}</h2>
<!-- A2[PD-P1-3]:事件流无项目维度,标注全局事件避免误读为本项目日志 -->
<span class="global-event-tag">{{ $t('projectDetail.workflowLogGlobal') }}</span>
<span v-if="formattedEvents.length === 0" class="task-count">{{ $t('projectDetail.emptyWorkflowLog') }}</span>
<button
v-else
@@ -406,11 +415,12 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ref, computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
import { open } from '@tauri-apps/plugin-dialog'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { useProjectStore } from '@/stores/project'
import { projectApi, taskApi, workflowApi } from '@/api'
import { moduleApi, type ProjectModuleRecord } from '@/api/module'
@@ -425,7 +435,7 @@ import DependencyGraph from '@/components/project/DependencyGraph.vue'
import WorkflowDagDisplay from '@/components/workflow/WorkflowDagDisplay.vue'
import { useConfirm } from '@/composables/useConfirm'
import { useRendered } from '@/composables/useMarkdown'
import type { ProjectId, TaskRecord } from '@/api/types'
import type { ProjectId, TaskRecord, DfDataChangedPayload } from '@/api/types'
const route = useRoute()
const router = useRouter()
@@ -434,6 +444,12 @@ const { t, locale } = useI18n()
const logListRef = ref<HTMLElement | null>(null)
const showApprovalDialog = ref(false)
// A1[PD-P1-4]:任务列表随 AI 改动刷新 —— 本地 df-data-changed 监听(entity=task)重拉 loadProjectTasks。
// 对齐 TaskDetail B-260616-18 模式:onMounted 注册、onBeforeUnmount 释放;连发事件防抖合并,
// 且仅概览 tab 活跃(任务列表面板在概览)才刷新。
let _unlistenDataChanged: UnlistenFn | null = null
let _taskReloadTimer: ReturnType<typeof setTimeout> | null = null
// Tab 导航:概览(项目信息/任务/工作流日志) vs 文件浏览器(Batch 10)。
// P1-g+ 问题10④:activeTab 持久化 localStorage(按项目隔离,切换项目不串扰)。
const TAB_STORAGE_PREFIX = 'df-project-tab-'
@@ -452,14 +468,30 @@ function setActiveTab(tab: DetailTab) {
}
// 描述折叠(P1-g+ 问题10③):长描述默认折叠截 4 行,展开看全文。
// descCollapsible 由描述长度阈值决定(渲染后行数不可靠,用源字符数近似 ≥120 字符)。
const DESC_COLLAPSE_THRESHOLD = 120
// 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)
const descEl = ref<HTMLElement | null>(null)
const descExpanded = ref(false)
const descCollapsible = computed(() => (currentProject.value?.description?.length ?? 0) >= DESC_COLLAPSE_THRESHOLD)
const descOverflow = ref(false)
const descCollapsible = computed(() => descOverflow.value)
async function measureDescHeight() {
// descEl 是 <span>(描述内容),自身无 max-height 限制 → scrollHeight 即其真实渲染高度,
// 不受父 .description-wrap 的 max-height 裁剪影响。
await nextTick()
const el = descEl.value
if (!el) { descOverflow.value = false; return }
descOverflow.value = el.scrollHeight > DESC_COLLAPSE_PX
}
// 工作流日志折叠(P1-g+ 问题10②):无记录时折叠为摘要(标题 + emptyWorkflowLog 提示),
// 有记录默认展开。collapse 由用户主动操作,避免空态占整栏。
const workflowLogCollapsed = ref(false)
// A2[PD-P1-3]:事件流无 project_id 维度,无法按项目过滤(workflow-event 负载仅 execution_id+event,
// event 内无 task_id/project_id 映射),属全局事件。默认收起 + 标注「全局事件」,避免误读为本项目日志。
const workflowLogCollapsed = ref(true)
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
@@ -532,10 +564,18 @@ const assessmentLabel = (recommendation: string): string =>
// 改为按 project_id 走后端 list_tasks {project_id} 独立拉取,存本地 ref。
// TaskQuery.project_id 命中 idx_tasks_project_id(见 api/types.ts:191)。
const projectTasks = ref<TaskRecord[]>([])
// A4[PD-P2-2]:请求序号守卫 —— 快速切项目时旧请求晚到不覆盖新项目数据(闭包捕获 projectId,
// resolve 后比对当前 projectId 相同才赋值)。
let taskReqSeq = 0
async function loadProjectTasks() {
const seq = ++taskReqSeq
const pid = projectId.value
try {
projectTasks.value = await taskApi.list({ project_id: projectId.value })
const list = await taskApi.list({ project_id: pid })
if (seq !== taskReqSeq || pid !== projectId.value) return
projectTasks.value = list
} catch (e) {
if (seq !== taskReqSeq || pid !== projectId.value) return
console.error('加载项目任务失败:', e)
projectTasks.value = []
}
@@ -546,13 +586,24 @@ async function loadProjectTasks() {
// 多工程(monorepo/多模块)在概览内增删改 + 扫描;复用 moduleApi + ConfirmDialog(删除确认)
// + arco Message(反馈)。切到文件 Tab 时 FileExplorer 因 v-if 重挂载自动重拉,无需手动同步。
const modules = ref<ProjectModuleRecord[]>([])
// A4[PD-P2-2]:与 loadProjectTasks 同款请求序号守卫(快速切项目竞态)
let moduleReqSeq = 0
async function loadModules() {
const seq = ++moduleReqSeq
const pid = projectId.value
try {
modules.value = await moduleApi.listProjectModules(projectId.value)
const list = await moduleApi.listProjectModules(pid)
if (seq !== moduleReqSeq || pid !== projectId.value) return
modules.value = list
} catch {
if (seq !== moduleReqSeq || pid !== projectId.value) return
modules.value = []
}
}
// A10[PD-P3-13]:parseStack 模板多次调用 → computed 缓存 —— 每项 m.stack 只 JSON.parse 一次,
// 模板用 moduleStack.get(m.id) 取值;工程栈也单独缓存(切项目/增删工程后自动失效重算)。
const moduleStack = computed(() => new Map(modules.value.map((m) => [m.id, parseStack(m.stack)])))
const projectStack = computed(() => parseStack(currentProject.value?.stack))
/**
* 工程状态归一:老工程 status 为 null 视作 active(默认活跃)。
@@ -697,11 +748,19 @@ const latestWfExecId = computed(() => {
const evs = store.liveEvents
return evs.length > 0 ? evs[evs.length - 1].execution_id : null
})
// A2[PD-P1-3]:workflow-event 负载仅 { execution_id, event },event 内无 project_id/task_id 维度
// 可映射到本项目 → 无法按项目过滤。概览的「工作流实时进度」面板改为仅当本页手动触发过工作流
// (未来接推进入口时置 true)才显示,否则把其他项目的 DAG/进度误显在本项目下(降级标注,不强改事件协议)。
const wfProgressEnabled = ref(false)
const wfProgress = computed(() => store.workflowProgress(latestWfExecId.value))
const hasWfProgress = computed(() => Object.keys(wfProgress.value.nodeStatuses).length > 0)
const hasWfProgress = computed(() =>
wfProgressEnabled.value && Object.keys(wfProgress.value.nodeStatuses).length > 0
)
watch(latestWfExecId, async (id) => {
wfDagJson.value = ''
if (!id) return
// A2[PD-P1-3]:进度面板未启用(本页无工作流推进入口)时不拉 DAG,避免每次事件浪费一次 IPC
if (!wfProgressEnabled.value) return
try {
const record = await workflowApi.getExecution(id)
if (record?.dag_json) wfDagJson.value = record.dag_json
@@ -743,6 +802,9 @@ async function submitNewTask() {
async function handleSync() {
await store.loadProjects()
await loadProjectTasks() // 本项目任务独立拉取,不依赖 store.loadTasks
// A9[PD-P3-18]:同步补齐工程列表 + 灵感回溯,避免「新增/扫描工程后点同步不刷新」
await loadModules()
await store.loadIdeas()
await checkPath()
}
@@ -860,19 +922,50 @@ onMounted(async () => {
await store.loadIdeas()
await store.startEventListener()
await checkPath()
// A8[PD-P3-16]:首次渲染后测描述真实高度(纯文本描述同步渲染,renderedDesc watch 可能不触发)
await measureDescHeight()
// A1[PD-P1-4]:注册任务数据变更监听(entity=task → 防抖重拉本项目任务)
try {
_unlistenDataChanged = await listen<DfDataChangedPayload>('df-data-changed', (event) => {
if (event.payload.entity !== 'task') return
// 仅概览 tab 活跃才刷(任务列表面板在概览;切到文件/图时后台事件不浪费请求)
if (activeTab.value !== 'overview') return
if (_taskReloadTimer) clearTimeout(_taskReloadTimer)
_taskReloadTimer = setTimeout(() => { void loadProjectTasks() }, 200)
})
} catch (e) {
console.error('[ProjectDetail] 启动 df-data-changed 监听失败:', e)
}
})
// 路由切换到不同项目(URL /projects/:id 变化)时,重新拉取本项目任务 + 恢复 Tab + 检测目录
watch(projectId, async (newId) => {
if (!newId) return
activeTab.value = readStoredTab(newId)
// A5[PD-P2-5]:折叠态跨项目残留 —— 切项目重置描述展开 + 工作流日志折叠
descExpanded.value = false
workflowLogCollapsed.value = true
await loadProjectTasks()
await loadModules()
})
// A3[PD-P2-1]:概览工程列表与 FileExplorer 双向同步 —— 文件 tab 内增删工程后,切回概览自动重拉
watch(activeTab, (tab) => {
if (tab === 'overview') void loadModules()
})
// A8[PD-P3-16]:Markdown 异步渲染后 / 描述内容变化时重测折叠阈值
watch(renderedDesc, () => { void measureDescHeight() })
// 目录变更(重定位后)重新检测存在性
watch(() => currentProject.value?.path, () => { checkPath() })
onBeforeUnmount(() => {
// A1[PD-P1-4]:释放本地 df-data-changed 监听 + 防抖定时器(防切页后定时器误刷已卸载视图)
if (_taskReloadTimer) { clearTimeout(_taskReloadTimer); _taskReloadTimer = null }
if (_unlistenDataChanged) { _unlistenDataChanged(); _unlistenDataChanged = null }
})
onUnmounted(() => {
// 用 store.stopEventListener 统一停全局监听并复位句柄(直接调 unlisten() 停监听但
// 不复位 _eventUnlisten,后续 startEventListener 幂等会误返回已死句柄,共享化后必查)
@@ -1259,6 +1352,16 @@ onUnmounted(() => {
此处仅保留本组件特有 .task-count。 */
.task-count { font-size: 12px; color: var(--df-text-dim); }
/* A2[PD-P1-3]:工作流日志「全局事件」标注(次要小字标签) */
.global-event-tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 8px;
color: var(--df-text-dim);
background: rgba(136, 136, 136, 0.12);
flex-shrink: 0;
}
/* ===== 任务卡片 ===== */
.task-list { display: flex; flex-direction: column; }
.task-card {