Files
DevFlow/src/views/ProjectDetail.vue
T

1637 lines
65 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="project-detail">
<!-- 加载态 -->
<div v-if="loading" class="loading-state">{{ $t('common.loading') }}</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>
<!-- 项目详情 -->
<template v-else>
<!-- 页面头部 -->
<header class="page-header">
<div class="header-left">
<router-link to="/projects" class="back-link">{{ $t('projectDetail.backToList') }}</router-link>
<h1>{{ currentProject.name }}</h1>
<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" 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>
</div>
</header>
<!-- 新建任务模态框 -->
<div v-if="showNewTaskModal" class="modal-overlay" @click.self="showNewTaskModal = false">
<div class="modal-box">
<h3 class="modal-title">{{ $t('projectDetail.newTaskTitle') }}</h3>
<div class="modal-field">
<label>{{ $t('projectDetail.taskTitleLabel') }}</label>
<input v-model="newTaskTitle" :placeholder="$t('projectDetail.taskTitlePlaceholder')" @keyup.enter="submitNewTask" />
</div>
<div class="modal-field">
<label>{{ $t('projectDetail.descLabel') }}</label>
<textarea v-model="newTaskDesc" :placeholder="$t('projectDetail.descPlaceholder')" rows="3"></textarea>
</div>
<div class="modal-field">
<label>{{ $t('projectDetail.branchLabel') }}</label>
<input v-model="newTaskBranch" placeholder="feature/xxx" />
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="showNewTaskModal = false">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" @click="submitNewTask" :disabled="submitting || !newTaskTitle.trim()">{{ $t('projectDetail.confirmCreate') }}</button>
</div>
</div>
</div>
<!-- 阶段进度条已移除(阶段流转逻辑未接通,后续接入真实状态机后重启) -->
<!-- Tab 导航(概览 / 文件浏览器) -->
<nav class="detail-tabs">
<button
class="tab-btn"
:class="{ 'tab-active': activeTab === 'overview' }"
type="button"
@click="setActiveTab('overview')"
>
{{ $t('projectDetail.tabOverview') }}
</button>
<button
class="tab-btn"
:class="{ 'tab-active': activeTab === 'files' }"
type="button"
@click="setActiveTab('files')"
>
{{ $t('fileExplorer.tabTitle') }}
</button>
<button
class="tab-btn"
:class="{ 'tab-active': activeTab === 'graph' }"
type="button"
@click="setActiveTab('graph')"
>
{{ $t('dependencyGraph.tabTitle') }}
</button>
</nav>
<!-- 文件浏览器 Tab(B5[PD-P2-12]:KeepAlive 保活 —— overview 不销毁文件 tab
展开目录/选中文件/视图模式状态;首次激活才跑 onMounted,后续激活走 onActivated 复用状态) -->
<section v-if="activeTab === 'files'" class="file-explorer-wrap">
<KeepAlive>
<FileExplorer :project-id="projectId" />
</KeepAlive>
</section>
<!-- 依赖图 Tab(B5:同样 KeepAlive 保活,避免每次切 tab 重挂载重拉) -->
<section v-else-if="activeTab === 'graph'" class="file-explorer-wrap">
<KeepAlive>
<DependencyGraph :project-id="projectId" />
</KeepAlive>
</section>
<!-- 概览 Tab:两栏(项目信息 + 任务列表),工作流日志移至下方(P1-g+ 问题10 三栏拥挤) -->
<div v-else class="detail-overview">
<div class="detail-grid">
<!-- 左栏项目信息 -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('projectDetail.infoTitle') }}</h2>
</div>
<div class="project-info" v-if="currentProject">
<!-- 代码目录(绑定 + 重定位) -->
<div class="info-item">
<span class="label">{{ $t('projectDetail.codeDirLabel') }}</span>
<div v-if="currentProject.path" class="path-row">
<span class="path-text" :title="currentProject.path">{{ currentProject.path }}</span>
<button class="btn btn-ghost btn-sm" type="button" @click="relocateDir">{{ $t('projectDetail.relocateDir') }}</button>
</div>
<div v-else class="path-row">
<span class="no-idea">{{ $t('projectDetail.noDirBound') }}</span>
<button class="btn btn-ghost btn-sm" type="button" @click="relocateDir">{{ $t('projectDetail.bindDir') }}</button>
</div>
</div>
<!-- 目录状态(仅绑定时显示) -->
<div class="info-item" v-if="currentProject.path">
<span class="label">{{ $t('projectDetail.dirStatusLabel') }}</span>
<span :class="pathExists === false ? 'path-missing' : 'path-ok'">
{{ pathExists === null ? $t('projectDetail.dirChecking') : pathExists ? $t('projectDetail.dirExists') : $t('projectDetail.dirMissing') }}
</span>
</div>
<!-- 工程管理(概览内 CRUD:新增/编辑/删除/扫描;复用 moduleApi + ConfirmDialog + arco Message
切到文件 Tab FileExplorer v-if 重挂载自动重拉,无需手动同步) -->
<div class="info-item info-block">
<span class="label">{{ $t('projectDetail.modulesLabel', { n: modules.length }) }}</span>
<div class="module-manager-card">
<!-- 标题行操作入口 -->
<div class="module-manager-header">
<button class="btn btn-primary btn-sm" type="button" @click="openAddModule">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
{{ $t('fileExplorer.addModule') }}
</button>
<button
v-if="currentProject?.path"
class="btn btn-ghost btn-sm"
type="button"
:disabled="scanning"
@click="onScanSubmodules"
>
<span v-if="scanning" class="spinner"></span>
{{ $t('fileExplorer.scanSubmodules') }}
</button>
</div>
<!-- 空态引导(无工程不隐藏,显卡片 + CTA) -->
<div v-if="modules.length === 0" class="module-empty">
<div class="module-empty-icon">📦</div>
<p class="module-empty-hint">{{ $t('projectDetail.modulesEmptyHint') }}</p>
<div class="module-empty-actions">
<button class="btn btn-primary btn-sm" type="button" @click="openAddModule">{{ $t('fileExplorer.addModule') }}</button>
<button
v-if="currentProject?.path"
class="btn btn-ghost btn-sm"
type="button"
:disabled="scanning"
@click="onScanSubmodules"
>{{ $t('fileExplorer.scanSubmodules') }}</button>
</div>
</div>
<!-- 工程列表(hover 显行内操作) -->
<div v-else class="module-list-overview">
<div v-for="m in modules" :key="m.id" class="module-row-overview">
<div class="module-row-head">
<span class="module-name-overview">{{ m.name }}</span>
<span v-if="moduleStatusOf(m) === 'archived'" class="module-status-badge module-status-archived">{{ $t('projectDetail.moduleStatusArchived') }}</span>
<span v-else class="module-status-badge module-status-active">{{ $t('projectDetail.moduleStatusActive') }}</span>
<span class="module-path-overview" :title="m.path">{{ m.path }}</span>
<span class="module-row-actions">
<button class="ai-btn-icon" :title="$t('fileExplorer.editModule')" @click="openEditModule(m)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="ai-btn-icon module-row-action-danger" :title="$t('fileExplorer.deleteCurrentModule')" @click="confirmRemoveModule(m)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</span>
</div>
<p v-if="m.description" class="module-desc-overview">{{ m.description }}</p>
<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>
</div>
</div>
<!-- 技术栈 -->
<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 projectStack" :key="t">{{ t }}</span>
</div>
</div>
<div class="info-item">
<span class="label">{{ $t('projectDetail.createdAt') }}</span>
<span>{{ formatDate(currentProject.created_at) }}</span>
</div>
<div class="info-item">
<span class="label">{{ $t('projectDetail.updatedAt') }}</span>
<span>{{ formatDate(currentProject.updated_at) }}</span>
</div>
<div class="info-item info-block">
<span class="label">{{ $t('projectDetail.description') }}</span>
<!-- B-260615-25:项目描述 Markdown 渲染,复用 useMarkdown composable( B-24 TaskDetail)
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>
<button
v-if="descCollapsible"
class="btn btn-ghost btn-sm desc-toggle"
type="button"
@click="descExpanded = !descExpanded"
>
{{ descExpanded ? $t('common.collapse') : $t('common.expand') }}
</button>
</div>
<span v-else></span>
</div>
<div class="info-item">
<span class="label">{{ $t('projectDetail.projectStatus') }}</span>
<span class="status-badge" :class="currentProject.status">{{ $t(statusLabel) }}</span>
</div>
<!-- 来源灵感卡片(晋升携带评估结论回溯): idea_id 有值时显示 -->
<div v-if="currentProject.idea_id" class="source-idea-card">
<div class="source-idea-header">
<span class="source-idea-title">{{ $t('ideas.sourceIdea') }}</span>
<router-link
v-if="sourceIdea"
class="idea-link"
:to="`/ideas/${currentProject.idea_id}`"
>
{{ sourceIdea.title }}
</router-link>
<router-link
v-else
class="idea-link idea-link-dim"
:to="`/ideas/${currentProject.idea_id}`"
>
#{{ currentProject.idea_id }}
</router-link>
</div>
<!-- sourceIdea 未找到(灵感被删/ load):提示跳转 -->
<p v-if="!sourceIdea" class="source-idea-hint">{{ $t('projectDetail.ideaDeleted') }}</p>
<!-- 已评估:显示评估结论回溯 -->
<template v-else-if="sourceAiAnalysis">
<div class="source-idea-body">
<span
class="assessment-badge"
:class="assessmentClass(sourceAiAnalysis.recommendation)"
>
{{ assessmentLabel(sourceAiAnalysis.recommendation) }}
</span>
<span class="source-final-score">
{{ $t('ideas.finalScore', { score: sourceAiAnalysis.final_score.toFixed(1) }) }}
</span>
</div>
<p v-if="sourceAiAnalysis.summary" class="source-summary">{{ sourceAiAnalysis.summary }}</p>
<!-- 多维评分条(借鉴 IdeaDetail parseScores 渲染) -->
<div v-if="sourceScores.length" class="source-scores">
<div class="score-bar-row" v-for="dim in sourceScores" :key="dim.name">
<span class="score-bar-label">{{ dim.name }}</span>
<div class="score-bar-track">
<div
class="score-bar-fill"
:class="'fill-' + scoreTier(dim.score)"
:style="{ width: Math.min(100, Math.max(0, dim.score)) + '%' }"
></div>
</div>
<span class="score-bar-value">{{ dim.score }}</span>
</div>
</div>
</template>
<!-- 灵感未评估:鼓励去评估 -->
<p v-else class="source-idea-hint">
<router-link class="idea-link" :to="`/ideas/${currentProject.idea_id}`">
{{ $t('ideas.sourceIdeaNotEvaluated') }}
</router-link>
</p>
</div>
</div>
</section>
<!-- 右栏任务列表(本项目独立 fetch,不读 store.tasks,避免 Tasks 分页污染) -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('projectDetail.taskListTitle') }}</h2>
<span class="task-count">{{ $t('projectDetail.taskCount', { n: projectTasks.length }) }}</span>
<!-- 全选(勾选后触发底部批量操作栏) -->
<label class="select-all" :title="$t('tasks.batch.selectHint')">
<input
type="checkbox"
:checked="selection.allSelected"
:indeterminate.prop="selection.someSelected"
@change="selection.toggleAll(($event.target as HTMLInputElement).checked)"
/>
{{ $t('tasks.filter.all') }}
</label>
</div>
<div class="task-filter-chips">
<button
v-for="s in taskStatusFilters"
:key="s.key"
class="filter-chip"
:class="{ 'is-active': activeTaskStatuses.length === 0 || activeTaskStatuses.includes(s.key) }"
@click="toggleTaskStatus(s.key)"
>
{{ s.icon }} {{ s.label }}
</button>
</div>
<div class="task-list">
<div
class="task-card"
v-for="task in filteredProjectTasks"
:key="task.id"
@click="router.push('/tasks/' + task.id)"
>
<div class="task-card-checkbox">
<input
type="checkbox"
:checked="selection.isSelected(task.id)"
@click.stop
@change="selection.toggle(task.id, ($event.target as HTMLInputElement).checked)"
/>
</div>
<div class="task-top">
<span class="task-title">{{ task.title }}</span>
<span class="task-status" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
</div>
<div class="task-branch" v-if="task.branch_name">
<span class="branch-icon"></span>
<span class="branch-name">{{ task.branch_name }}</span>
</div>
<div class="task-meta">
<span>{{ task.assignee ?? '—' }}</span>
<span>{{ formatDate(task.created_at) }}</span>
<span style="opacity: 0.7">{{ formatDate(task.updated_at) }}</span>
</div>
</div>
</div>
<div v-if="projectTasks.length === 0" class="empty-hint">{{ $t('projectDetail.emptyTasks') }}</div>
<!-- 批量操作栏(选中任务后浮现) -->
<TaskBatchBar
v-if="selection.count > 0"
:count="selection.count"
:busy="batch.busy"
:advance-targets="batchAdvanceTargets"
:cancel-available="batchAvail.cancel"
:defer-available="batchAvail.defer"
:resume-available="batchAvail.resume"
:cascade-estimate="batchCascadeEstimate"
:toast="batch.toast"
:assignee-suggestions="assigneeSuggestions"
@advance="batch.advanceMany([...selection.selected], $event).then(afterBatch)"
@cancel="batch.cancelMany([...selection.selected]).then(afterBatch)"
@defer="batch.deferMany([...selection.selected]).then(afterBatch)"
@resume="batch.resumeMany([...selection.selected]).then(afterBatch)"
@priority="batch.updateMany([...selection.selected], 'priority', $event).then(afterBatch)"
@assignee="batch.updateMany([...selection.selected], 'assignee', $event).then(afterBatch)"
@delete="batch.deleteMany([...selection.selected]).then(afterBatch)"
@clear="selection.clear()"
/>
</section>
</div>
<!-- B-41 工作流实时进度:最近一次执行的 DAG + 节点状态高亮(共享 workflow store 派生) -->
<section v-if="hasWfProgress" class="panel wf-progress-panel">
<div class="panel-header">
<h2>{{ $t('projectDetail.workflowProgressTitle') }}</h2>
</div>
<div class="wf-progress">
<span v-if="wfProgress.runningNode">{{ $t('taskDetail.workflowStepRunning', { node: wfProgress.runningNode }) }}</span>
<span v-else-if="wfProgress.doneCount > 0" class="wf-progress-count">
{{ $t('taskDetail.workflowStepsProgress', { done: wfProgress.doneCount, total: wfProgress.totalNodes }) }}
</span>
<span v-if="wfProgress.result === 'completed'" class="wf-progress-hint">{{ $t('taskDetail.workflowCompletedHint') }}</span>
<span v-if="wfProgress.result === 'failed'" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span>
</div>
<WorkflowDagDisplay v-if="wfDagJson" :dag-json="wfDagJson" :node-statuses="wfProgress.nodeStatuses" />
</section>
<!-- 工作流日志: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
class="btn btn-ghost btn-sm"
type="button"
@click="workflowLogCollapsed = !workflowLogCollapsed"
>
{{ workflowLogCollapsed ? $t('common.expand') : $t('common.collapse') }}
</button>
</div>
<div v-if="formattedEvents.length > 0 && !workflowLogCollapsed" class="log-list" ref="logListRef">
<div class="log-item" v-for="(evt, idx) in formattedEvents" :key="idx" :class="'log-' + evt.level">
<span class="log-time">{{ evt.time }}</span>
<span class="log-level">{{ evt.level }}</span>
<span class="log-msg">{{ evt.message }}</span>
</div>
</div>
</section>
</div>
<!-- 确认弹层删除/重定位确认替代原生 window.confirm/alert -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" :danger-label="confirmState.dangerLabel" @result="answerConfirm" />
<!-- 新增/编辑工程弹窗(概览内 CRUD,对标 FileExplorer 工程 modal) -->
<div v-if="showModuleModal" class="modal-overlay" @click.self="showModuleModal = false">
<div class="modal-box">
<h3 class="modal-title">{{ editingModule ? $t('fileExplorer.editModule') : $t('fileExplorer.addModule') }}</h3>
<div class="modal-field">
<label>{{ $t('fileExplorer.moduleName') }}</label>
<input v-model="moduleFormName" :placeholder="$t('fileExplorer.moduleNamePlaceholder')" />
</div>
<div class="modal-field">
<label>{{ $t('fileExplorer.modulePath') }}</label>
<input v-model="moduleFormPath" :placeholder="$t('fileExplorer.modulePathPlaceholder')" />
</div>
<div class="modal-field">
<label>{{ $t('fileExplorer.moduleGitUrl') }} <span class="opt-label">{{ $t('fileExplorer.optSuffix') }}</span></label>
<input v-model="moduleFormGitUrl" :placeholder="$t('fileExplorer.moduleGitUrlPlaceholder')" />
</div>
<div class="modal-field">
<label>{{ $t('fileExplorer.moduleDescription') }} <span class="opt-label">{{ $t('fileExplorer.optSuffix') }}</span></label>
<textarea v-model="moduleFormDescription" :placeholder="$t('fileExplorer.moduleDescriptionPlaceholder')" rows="2"></textarea>
</div>
<div class="modal-field">
<label>{{ $t('fileExplorer.moduleStack') }} <span class="opt-label">{{ $t('fileExplorer.optSuffix') }}</span></label>
<input v-model="moduleFormStack" :placeholder="$t('fileExplorer.moduleStackPlaceholder')" />
</div>
<div class="modal-field">
<label>{{ $t('fileExplorer.moduleStatus') }}</label>
<select v-model="moduleFormStatus">
<option value="active">{{ $t('fileExplorer.moduleStatusActive') }}</option>
<option value="archived">{{ $t('fileExplorer.moduleStatusArchived') }}</option>
</select>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="showModuleModal = false">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" :disabled="savingModule || !moduleFormName.trim() || !moduleFormPath.trim()" @click="onSaveModule">
<span v-if="savingModule" class="spinner"></span>
{{ savingModule ? $t('common.loading') : (editingModule ? $t('common.save') : $t('fileExplorer.addModule')) }}
</button>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive, 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'
import { formatDate } from '@/utils/time'
import { parseStack } from '@/utils/project'
import { parseScores as parseScoresJson, assessmentClass, assessmentLabel as assessmentLabelI18n, scoreTier } from '@/utils/ideaEval'
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 FileExplorer from '@/components/project/FileExplorer.vue'
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 { useTaskBatchSelection } from '@/composables/task/useTaskBatchSelection'
import { useTaskBatchActions } from '@/composables/task/useTaskBatchActions'
import type { ProjectId, TaskRecord, DfDataChangedPayload } from '@/api/types'
const route = useRoute()
const router = useRouter()
const store = useProjectStore()
const { t, locale } = useI18n()
const logListRef = ref<HTMLElement | null>(null)
// 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-'
const validTabs = ['overview', 'files', 'graph'] as const
type DetailTab = typeof validTabs[number]
function readStoredTab(projectId: string): DetailTab {
try {
const v = localStorage.getItem(TAB_STORAGE_PREFIX + projectId)
return (v && (validTabs as readonly string[]).includes(v)) ? (v as DetailTab) : 'overview'
} catch { return 'overview' }
}
const activeTab = ref<DetailTab>('overview')
function setActiveTab(tab: DetailTab) {
activeTab.value = tab
try { localStorage.setItem(TAB_STORAGE_PREFIX + projectId.value, tab) } catch { /* ignore */ }
}
// 描述折叠(P1-g+ 问题10③):长描述默认折叠截 4 行,展开看全文。
// 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 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 由用户主动操作,避免空态占整栏。
// 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()
// ── 当前项目 ──
// 状态文案/阶段进度统一走 ../constants/project(与 Projects/Tasks/Dashboard 一致,
// 根治此前 stageMap 含不存在的 testing 状态、三套映射互相矛盾)
const projectId = computed(() => route.params.id as string)
const currentProject = computed(() =>
store.projects.find(p => p.id === projectId.value)
)
const loading = computed(() => store.projects.length === 0 && !store.error)
// 状态文案/阶段进度统一走 ../constants/project
// B-260615-25:项目描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例),
// useRendered 封装 computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热)
const { rendered: renderedDesc, ensureLoaded } = useRendered(
() => currentProject.value?.description ?? '',
)
const stageKey = computed(() => currentProject.value?.status ?? 'planning')
const statusLabel = computed(() => projectStatusLabel(stageKey.value))
// const currentStageIndex = computed(() => projectStageInfo(stageKey.value).stepIndex)
// ── 来源灵感(晋升携带评估结论回溯)──
// ProjectRecord.idea_id 存灵感 id(promote_idea 写入)。
// sourceIdea: 从 store.ideas 反查;store 未 load 时由 onMounted 守卫补 load。
const sourceIdea = computed(() =>
store.ideas.find(i => i.id === currentProject.value?.idea_id) ?? null,
)
// ai_analysis / scores 是 JSON 字符串(对齐 IdeaDetail 现有解析模式,try/catch 容错)
interface SourceAnalysis {
recommendation: string
final_score: number
summary: string
}
const sourceAiAnalysis = computed<SourceAnalysis | null>(() => {
const idea = sourceIdea.value
if (!idea?.ai_analysis) return null
try {
const parsed = JSON.parse(idea.ai_analysis) as Record<string, unknown>
if (typeof parsed !== 'object' || parsed === null) return null
const recommendation = typeof parsed.recommendation === 'string' ? parsed.recommendation : ''
const final_score = typeof parsed.final_score === 'number' ? parsed.final_score : 0
// summary 优先顶层,回退 analyst.summary(对齐 IdeaDetail historySummary 模式)
const summary = typeof parsed.summary === 'string' ? parsed.summary
: (typeof parsed.analyst === 'object' && parsed.analyst !== null
&& typeof (parsed.analyst as Record<string, unknown>).summary === 'string'
? ((parsed.analyst as Record<string, unknown>).summary as string)
: '')
if (!recommendation && !final_score && !summary) return null
return { recommendation, final_score, summary }
} catch {
return null
}
})
// 项1 DRY:ScoreDimension/parseScores/assessmentClass/assessmentLabel 抽到 @/utils/ideaEval,
// 与 IdeaDetail 共用。sourceScores 仅保留 computed(读 sourceIdea.scores → 复用纯函数解析)。
const sourceScores = computed(() => parseScoresJson(sourceIdea.value?.scores))
// assessmentClass 直接复用 ideaEval(模板直调);assessmentLabel 需注入 t,留薄包装消除重复。
const assessmentLabel = (recommendation: string): string =>
assessmentLabelI18n(t, recommendation)
// ── 任务 ──
// P1-g+ 问题10①:projectTasks 不再读 store.tasks(被 Tasks 视图分页/筛选污染),
// 改为按 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 {
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 = []
}
}
// taskStatusLabel / taskStatusClass 由 ../constants/project 提供
// 任务状态多选过滤(排序由 TASK_STATUS_ORDER 统一驱动)
const STATUS_FILTER_META: Record<string, { icon: string; labelKey: string }> = {
todo: { icon: '📝', labelKey: 'tasks.statusFilter.todo' },
in_progress: { icon: '🔨', labelKey: 'tasks.statusFilter.in_progress' },
in_review: { icon: '👀', labelKey: 'tasks.statusFilter.in_review' },
testing: { icon: '🧪', labelKey: 'tasks.statusFilter.testing' },
done: { icon: '✅', labelKey: 'tasks.statusFilter.done' },
blocked: { icon: '🚫', labelKey: 'tasks.statusFilter.blocked' },
deferred: { icon: '⏰', labelKey: 'tasks.statusFilter.deferred' },
cancelled: { icon: '🗑️', labelKey: 'tasks.statusFilter.cancelled' },
}
const taskStatusFilters = computed(() =>
TASK_STATUS_ORDER.map(key => ({ key, icon: STATUS_FILTER_META[key].icon, label: t(STATUS_FILTER_META[key].labelKey) })),
)
const activeTaskStatuses = ref<string[]>([])
function toggleTaskStatus(key: string) {
const idx = activeTaskStatuses.value.indexOf(key)
if (idx >= 0) {
activeTaskStatuses.value.splice(idx, 1)
} else {
activeTaskStatuses.value.push(key)
}
}
const filteredProjectTasks = computed(() => {
if (activeTaskStatuses.value.length === 0) return projectTasks.value
return projectTasks.value.filter(t => activeTaskStatuses.value.includes(t.status))
})
// ── 任务批量操作(本项目任务列表);reactive 包装使模板属性自动解包 ──
const selection = reactive(useTaskBatchSelection({
scopeIds: computed(() => filteredProjectTasks.value.map(t => t.id)),
}))
const batch = reactive(useTaskBatchActions({
getTask: id => projectTasks.value.find(t => t.id === id),
onRefresh: () => loadProjectTasks(),
}))
async function afterBatch() {
selection.prune()
}
const batchAdvanceTargets = computed(() => {
const ids = [...selection.selected]
if (ids.length === 0) return []
let common: Set<string> | null = null
for (const id of ids) {
const task = projectTasks.value.find(t => t.id === id)
if (!task) continue
const legal = new Set(TASK_STATUS_TRANSITIONS[task.status] ?? [])
if (common === null) {
common = legal
} else {
const next = new Set<string>()
for (const s of common) if (legal.has(s)) next.add(s)
common = next
}
if (common.size === 0) return []
}
return taskStatusFilters.value.filter(s => common?.has(s.key))
})
const RESUME_STATES = new Set(['deferred', 'cancelled', 'blocked'])
const batchAvail = computed(() => {
let cancel = false
let defer = false
let resume = false
for (const id of selection.selected) {
const task = projectTasks.value.find(t => t.id === id)
if (!task) continue
if (TASK_STATUS_TRANSITIONS[task.status]?.includes('cancelled')) cancel = true
if (TASK_STATUS_TRANSITIONS[task.status]?.includes('deferred')) defer = true
if (RESUME_STATES.has(task.status)) resume = true
}
return { cancel, defer, resume }
})
const batchCascadeEstimate = computed(() => {
const selected = selection.selected
let n = 0
for (const id of selected) {
const task = projectTasks.value.find(t => t.id === id)
if (!task || task.parent_id) continue
for (const c of projectTasks.value) {
if (c.parent_id === id && !selected.has(c.id)) n++
}
}
return n
})
const assigneeSuggestions = computed(() => {
const set = new Set<string>()
for (const t of projectTasks.value) if (t.assignee) set.add(t.assignee)
return [...set]
})
// ── 工程列表(概览内 CRUD)──
// 多工程(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 {
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(默认活跃)。
* 仅返回 active / archived 两值,供模板按状态显 badge。
*/
function moduleStatusOf(m: ProjectModuleRecord): 'active' | 'archived' {
return m.status === 'archived' ? 'archived' : 'active'
}
// 工程 CRUD 表单状态(对标 FileExplorer 工程 modal)
const showModuleModal = ref(false)
const editingModule = ref<ProjectModuleRecord | null>(null)
const savingModule = ref(false)
const scanning = ref(false)
const moduleFormName = ref('')
const moduleFormPath = ref('')
const moduleFormGitUrl = ref('')
const moduleFormDescription = ref('')
const moduleFormStack = ref('')
const moduleFormStatus = ref<'active' | 'archived'>('active')
/** 打开新增工程弹窗(清空表单)。 */
function openAddModule() {
editingModule.value = null
moduleFormName.value = ''
moduleFormPath.value = ''
moduleFormGitUrl.value = ''
moduleFormDescription.value = ''
moduleFormStack.value = ''
moduleFormStatus.value = 'active'
showModuleModal.value = true
}
/** 打开编辑工程弹窗(回填当前工程字段;老工程 status=null 视作 active)。 */
function openEditModule(m: ProjectModuleRecord) {
editingModule.value = m
moduleFormName.value = m.name
moduleFormPath.value = m.path
moduleFormGitUrl.value = m.git_url ?? ''
moduleFormDescription.value = m.description ?? ''
moduleFormStack.value = m.stack ?? ''
moduleFormStatus.value = m.status === 'archived' ? 'archived' : 'active'
showModuleModal.value = true
}
/** 保存工程(新增/编辑统一入口;成功 toast + 关弹窗 + 重拉列表)。 */
async function onSaveModule() {
if (savingModule.value) return
if (!moduleFormName.value.trim() || !moduleFormPath.value.trim()) return
savingModule.value = true
try {
const payload = {
name: moduleFormName.value.trim(),
path: moduleFormPath.value.trim(),
gitUrl: moduleFormGitUrl.value.trim() || null,
description: moduleFormDescription.value.trim() || null,
stack: moduleFormStack.value.trim() || null,
status: moduleFormStatus.value,
}
if (editingModule.value) {
await moduleApi.updateProjectModule({ id: editingModule.value.id, ...payload })
Message.success(t('projectDetail.moduleUpdateSuccess'))
} else {
await moduleApi.addProjectModule({ projectId: projectId.value, ...payload })
Message.success(t('projectDetail.moduleAddSuccess'))
}
showModuleModal.value = false
await loadModules()
} catch (e) {
Message.error(t('common.unknownError'))
console.error('[ProjectDetail] 保存工程失败:', e)
} finally {
savingModule.value = false
}
}
/** 删除工程(二次确认后执行;复用 useConfirm 的 Promise 化 confirmDialog)。 */
async function confirmRemoveModule(m: ProjectModuleRecord) {
const ok = await confirmDialog(t('fileExplorer.removeConfirm', { name: m.name }), t('common.delete'))
if (!ok) return
try {
await moduleApi.removeProjectModule(m.id)
Message.info(t('projectDetail.moduleRemoved'))
await loadModules()
} catch (e) {
Message.error(t('common.unknownError'))
console.error('[ProjectDetail] 删除工程失败:', e)
}
}
/** 扫描项目绑定目录下的子仓库,自动创建工程记录(幂等)。 */
async function onScanSubmodules() {
if (scanning.value) return
scanning.value = true
try {
const n = await moduleApi.scanProjectModules(projectId.value)
await loadModules()
if (n > 0) Message.success(t('projectDetail.scanFound', { n }))
else Message.info(t('projectDetail.scanNone'))
} catch (e) {
Message.error(t('common.unknownError'))
console.error('[ProjectDetail] 扫描子仓库失败:', e)
} finally {
scanning.value = false
}
}
// ── 工作流 ──
// demoDag + runDemoWorkflow 已下线(R-PD-2):"script" 节点不再注册到 NodeRegistry
// 前端构造含 script 节点的 DagDef 会在后端 build_dag 失败报错。
// 工作流执行日志面板保留,实时事件展示能力不依赖 demoDag。
// 待真实工作流需求落地(独立 BuildNode + 审批链)时重建演示入口。
// ── 实时日志格式化 ──
interface LogEntry {
time: string
level: string
message: string
}
const formattedEvents = computed<LogEntry[]>(() => {
return store.liveEvents.map((evt) => {
const ev = evt.event
const type = ev.type ?? 'unknown'
const level = type.includes('error') || type.includes('fail') ? 'error'
: type.includes('warn') ? 'warn' : 'info'
// 用事件入数组时固化的时间戳(FR-C1),非 computed 重算的当前时刻
const time = new Date(evt._ts).toLocaleTimeString(locale.value, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
const message = ev.label
? `[${ev.label}] ${type}: ${ev.output ?? ev.code ?? ev.message ?? JSON.stringify(ev)}`
: `${type}: ${ev.output ?? ev.code ?? ev.message ?? JSON.stringify(ev)}`
return { time, level, message }
})
})
// ── B-41 工作流实时进度 ──
// ProjectDetail 无工作流推进入口,但审批弹窗/日志面板已接 liveEvents;此处展示最近一次
// 工作流的 DAG + 节点状态高亮(共享 workflow store 派生,与 TaskDetail 同源),
// 手动跑工作流时也能看到节点实时进度。无事件(本次会话没跑过)时整个面板不渲染。
const wfDagJson = ref('')
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(() =>
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
} catch { /* 静默 */ }
}, { immediate: true })
// ── 新建任务 ──
const showNewTaskModal = ref(false)
const newTaskTitle = ref('')
const newTaskDesc = ref('')
const newTaskBranch = ref('')
// 异步操作禁用态(IPC 期间防双击重复提交):submitNewTask 创建任务期间禁用确认按钮
const submitting = ref(false)
async function submitNewTask() {
if (!newTaskTitle.value.trim()) return
submitting.value = true
try {
const r = await store.createTask({
project_id: projectId.value as ProjectId,
title: newTaskTitle.value.trim(),
description: newTaskDesc.value.trim(),
branch_name: newTaskBranch.value.trim() || undefined,
})
if (!r) return // 失败已 toast,保持弹窗不关
// 同步到本地 ref(store.createTask 已 push 全局 tasks,但本视图不再读 store.tasks)
projectTasks.value = [r, ...projectTasks.value]
showNewTaskModal.value = false
newTaskTitle.value = ''
newTaskDesc.value = ''
newTaskBranch.value = ''
} finally {
submitting.value = false
}
}
// ── 同步 ──
async function handleSync() {
await store.loadProjects()
await loadProjectTasks() // 本项目任务独立拉取,不依赖 store.loadTasks
// A9[PD-P3-18]:同步补齐工程列表 + 灵感回溯,避免「新增/扫描工程后点同步不刷新」
await loadModules()
await store.loadIdeas()
await checkPath()
}
// ── 代码目录绑定 ──
const pathExists = ref<boolean | null>(null)
// 检查绑定目录是否存在(详情页「目录是否还在」)
async function checkPath() {
const p = currentProject.value
if (!p?.path) { pathExists.value = null; return }
try {
pathExists.value = await projectApi.checkPathExists(p.path)
} catch {
pathExists.value = null
}
}
// 重定位/绑定目录(选目录 → 查重 → 确认 → 后端重探测 stack)
async function relocateDir() {
const p = currentProject.value
if (!p) return
try {
const selected = await open({ directory: true, multiple: false })
if (!selected || Array.isArray(selected)) return
const dir = selected as string
// 查重(排除自身)
try {
const conflict = await projectApi.checkBinding(dir, p.id)
if (conflict) {
Message.warning(t('projectDetail.dirConflict', { name: conflict.name }))
return
}
} catch { /* ignore */ }
const confirmMsg = p.path
? t('projectDetail.relocateConfirmRelocate', { dir })
: t('projectDetail.relocateConfirmBind', { dir })
if (!await confirmDialog(confirmMsg)) return
await store.relocateProjectPath(p.id, dir)
await checkPath()
} catch (e: any) {
console.error('重定位失败:', e)
Message.error(t('projectDetail.relocateFailed', { msg: e?.toString() ?? t('common.unknownError') }))
}
}
// 导入历史项目(选已存在目录 → 后端创建实体+绑定+探测栈+读 README 首段一步完成)
async function handleImportDir() {
try {
const selected = await open({ directory: true, multiple: false })
if (!selected || Array.isArray(selected)) return
const dir = selected as string
if (!await confirmDialog(t('projectDetail.importConfirm', { dir }))) return
const record = await store.importProject({ path: dir })
if (!record) {
// store 已 toast 错误
if (store.error) Message.error(store.error)
return
}
Message.success(t('projectDetail.importSuccess', { name: record.name }))
// 跳转到新导入项目的详情页
router.push(`/projects/${record.id}`)
} catch (e: any) {
console.error('导入失败:', e)
Message.error(t('projectDetail.importFailed', { msg: e?.toString() ?? t('common.unknownError') }))
}
}
// ── 审批处理 ──
// 审批对话框 UI + 单/多选决策逻辑抽离至 components/project/ApprovalDialog.vue
// handleApproval/handleApprovalMulti/isMultipleSelect/multiDecisions 已迁移;
// submitting(UX-260618-17)在子组件自治,父级 submitting 仅用于 submitNewTask
// P0-C:ApprovalDialog + watch pendingApproval + handleCancelApproval 已提至 App.vue(全局),
// TaskDetail 等无 ApprovalDialog 的页面自动受益(原局部渲染致 HumanNode 超时失败)
// ── 删除项目(软删 → 回收站,可恢复)──
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)
router.push('/projects')
}
// 日志自动滚到底部(让 logListRef 不再是死 ref)
watch(formattedEvents, () => {
if (logListRef.value) logListRef.value.scrollTop = logListRef.value.scrollHeight
})
// ── 工具函数 ──
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date
// ── 生命周期 ──
onMounted(async () => {
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
// 恢复持久化的 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()
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 幂等会误返回已死句柄,共享化后必查)
store.stopEventListener()
})
</script>
<style scoped>
/* ... 现有样式 ... */
/* 加载/空态(模板顶部使用) */
.loading-state { text-align: center; padding: 40px; color: var(--df-text-dim); }
/* 覆盖全局 .empty-state:带图标+文字垂直排列,缩短 padding */
.empty-state { padding: 40px; display: flex; flex-direction: column; align-items: center; gap: 12px; }
/* ===== 项目信息样式 ===== */
.project-info {
display: flex;
flex-direction: column;
gap: var(--df-gap-grid);
}
/* B-260615-31:字段同行布局(label 固定宽 + 值占余),描述字段 info-block 保持块状)
基础 .info-item/.label/.value 已收敛至全局 components.css(DRY 收口 B-260619),
此处仅保留本组件特有覆盖。 */
.info-item a.idea-link {
color: var(--df-accent);
text-decoration: none;
font-weight: 500;
}
.info-item a.idea-link:hover {
text-decoration: underline;
}
.info-item .no-idea {
color: var(--df-text-dim);
font-style: italic;
}
/* ===== 工程管理卡片(概览内 CRUD) ===== */
.module-manager-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
}
.module-manager-header {
display: flex;
gap: 8px;
align-items: center;
}
/* 工程行:hover 显操作 + 背景反馈(原 .module-row-overview padding/border-bottom 保留) */
.module-row-overview {
position: relative;
border-radius: var(--df-radius-sm);
transition: background 0.15s var(--df-ease);
}
.module-row-overview:hover {
background: var(--df-bg-card-hover);
}
.module-row-actions {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: auto;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s var(--df-ease);
}
.module-row-overview:hover .module-row-actions {
opacity: 1;
}
/* 行内图标钮缩到 22×22(紧凑协调,覆盖全局 .ai-btn-icon 26×26) */
.module-row-actions .ai-btn-icon {
width: 22px;
height: 22px;
}
.module-row-action-danger:hover {
color: var(--df-danger);
}
/* 无 hover 设备(触屏):操作常显,可达性兜底 */
@media (hover: none) {
.module-row-actions { opacity: 1; }
}
/* 空态引导 */
.module-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 20px 12px;
text-align: center;
}
.module-empty-icon {
font-size: 28px;
opacity: 0.6;
line-height: 1;
}
.module-empty-hint {
margin: 0;
font-size: 12px;
color: var(--df-text-dim);
line-height: 1.6;
max-width: 320px;
}
.module-empty-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
/* modal 内「(可选)」小字标签(对标 FileExplorer) */
.opt-label {
font-size: 11px;
color: var(--df-text-dim);
font-weight: 400;
}
/* ===== 工程列表 ===== */
.module-list-overview {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
.module-row-overview {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 0;
border-bottom: 1px solid var(--df-border, rgba(0, 0, 0, 0.06));
min-width: 0;
}
.module-row-overview:last-child {
border-bottom: none;
}
.module-row-head {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
}
.module-name-overview {
font-size: 13px;
color: var(--df-text);
font-weight: 500;
flex-shrink: 0;
}
.module-path-overview {
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--df-text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1;
}
.module-desc-overview {
margin: 0;
font-size: 12px;
color: var(--df-text-secondary);
line-height: 1.5;
/* 限制两行,过长省略;描述通常一句话,两行覆盖 99% 场景 */
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.module-stack-overview {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.module-stack-overview .tech-tag {
font-size: 11px;
padding: 1px 6px;
}
.module-status-badge {
font-size: 11px;
padding: 1px 6px;
border-radius: 8px;
font-weight: 500;
flex-shrink: 0;
}
.module-status-active {
color: var(--df-success, #16a34a);
background: rgba(22, 163, 74, 0.1);
}
.module-status-archived {
color: var(--df-text-dim, #888);
background: rgba(136, 136, 136, 0.12);
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--df-radius-lg);
font-size: 11px;
font-weight: 500;
background: var(--df-accent-bg);
color: var(--df-accent);
}
/* ===== 代码目录 / 技术栈 ===== */
.path-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.path-text {
font-size: 12px; font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--df-text-secondary); word-break: break-all;
}
.path-ok { color: var(--df-success); font-size: 12px; }
.path-missing { color: var(--df-danger); font-size: 12px; }
.info-tags { display: flex; flex-wrap: wrap; gap: 6px; }
/* .info-tags .tech-tag 与全局 .tech-tag(components.css)逐字一致,移除局部冗余,
标签样式由全局提供。 */
/* ===== 项目描述 Markdown 渲染(B-260615-25,基础样式收敛至全局 ai-md.css) ===== */
.description.ai-md { font-size: 14px; color: var(--df-text); line-height: 1.6; }
/* P1-g+ 问题10③:描述折叠(对齐 TaskDetail 长内容)— 折叠时 max-height 截断 + 渐变遮罩 */
.description-wrap { position: relative; }
.description-wrap.is-collapsed .description {
max-height: 6em; /* 约 4 行(line-height 1.5) */
overflow: hidden;
}
.desc-toggle { margin-top: 6px; }
/* ===== 来源灵感卡片(晋升携带评估结论回溯)===== */
.source-idea-card {
margin-top: 8px;
padding: 12px 14px;
background: var(--df-bg-raised);
border: 0.5px solid var(--df-border);
border-left: 3px solid var(--df-accent);
border-radius: var(--df-radius);
}
.source-idea-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.source-idea-title { font-size: 13px; font-weight: 500; color: var(--df-text); }
.info-item a.idea-link.idea-link-dim,
.source-idea-card a.idea-link-dim { color: var(--df-text-dim); }
.source-idea-card .source-idea-hint {
font-size: 12px;
color: var(--df-text-dim);
margin: 4px 0 0;
}
.source-idea-body {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin: 6px 0;
}
.source-final-score {
font-size: 13px;
font-weight: 500;
color: var(--df-text);
}
.source-summary {
font-size: 12px;
color: var(--df-text-secondary);
line-height: 1.5;
margin: 6px 0;
}
/* assessment-badge 系列(基础 + immediate/soon/conditional/revised/defer/cancel)
已提取到 styles/components.css 全局 */
/* 多维评分条(.score-bar-* 系列)已提取到 styles/components.css 全局 */
.source-scores {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
</style>
<style scoped>
.project-detail { padding: 16px 20px 20px; display: flex; flex-direction: column; height: 100%; min-height: 0; max-height: 100%; overflow: hidden; }
/* ===== Tab 导航(Batch 10 文件浏览器) ===== */
.detail-tabs {
display: flex;
gap: 4px;
border-bottom: 0.5px solid var(--df-border);
margin-bottom: 16px;
}
.tab-btn {
padding: 8px 16px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--df-text-dim);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
margin-bottom: -0.5px;
}
.tab-btn:hover {
color: var(--df-text);
}
.tab-btn.tab-active {
color: var(--df-text);
border-bottom-color: var(--df-accent);
}
/* 文件浏览器容器(弹性填充,内部 FileExplorer 自管布局) */
.file-explorer-wrap {
flex: 1;
min-height: 0;
overflow: hidden;
}
/* .page-header / .page-header h1 已提取到 styles/global.css 全局 */
.header-left { display: flex; align-items: center; gap: 12px; }
/* .back-link / .back-link:hover 已提取到 styles/global.css 全局 */
.stage-badge {
font-size: 12px;
padding: 3px 10px;
border-radius: var(--df-radius-lg);
font-weight: 500;
}
.stage-planning { background: rgba(100,181,246,0.15); color: #64b5f6; }
.stage-in_progress { background: var(--df-accent-soft); color: var(--df-accent); }
.stage-testing { background: rgba(255,193,7,0.15); color: #ffc107; }
.stage-releasing { background: rgba(156,39,176,0.15); color: #ce93d8; }
.stage-completed { background: rgba(100,200,100,0.15); color: #64c864; }
.stage-paused { background: rgba(158,158,158,0.15); color: #9e9e9e; }
.stage-cancelled { background: rgba(244,67,54,0.15); color: #f44336; }
/* .header-actions 已提取到 styles/global.css 全局 */
/* .btn 系列(btn/btn-primary/btn-ghost/btn-sm/btn-danger)已提取到 styles/global.css 全局 */
/* ===== 概览布局(P1-g+ 问题10②:三栏拥挤 → 两栏 + 日志下方)=====
.detail-overview 整体滚动;内部 .detail-grid 两栏(项目信息 + 任务列表),
.workflow-log-panel 跨整宽放下方。无日志记录时折叠为摘要不占整栏。 */
.detail-overview {
display: flex;
flex-direction: column;
gap: var(--df-gap-page);
overflow-y: auto;
min-height: 0;
max-height: 100%;
}
/* 两栏布局 — 项目信息 + 任务列表 */
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--df-gap-page);
align-content: start;
}
.workflow-log-panel { flex: 0 0 auto; }
/* B-41 工作流实时进度面板(与 TaskDetail wf-progress 同款视觉) */
.wf-progress-panel { flex: 0 0 auto; }
.wf-progress {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 6px;
font-size: 12px;
color: var(--df-text-secondary);
}
.wf-progress-count { color: var(--df-text-dim); }
.wf-progress-hint { color: var(--df-success); }
.wf-progress-hint-fail { color: var(--df-danger); }
/* ===== 面板 ===== */
/* .panel / .panel-header 基础样式已收敛至全局 components.css(DRY 收口 B-260619),
此处仅保留本组件特有 .task-count。 */
.task-count { font-size: 12px; color: var(--df-text-dim); }
.task-filter-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
}
.task-filter-chips .filter-chip {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
border: 0.5px solid var(--df-border);
border-radius: 10px;
background: transparent;
color: var(--df-text-dim);
font-size: 10px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.task-filter-chips .filter-chip:hover {
background: var(--df-bg-card);
color: var(--df-text);
border-color: var(--df-accent);
}
.task-filter-chips .filter-chip.is-active {
background: var(--df-accent-soft);
color: var(--df-accent);
border-color: var(--df-accent);
}
/* 状态色类已全局化至 components.cssscoped 不重复定义 */
/* 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; }
/* 批量操作:全选 + 行勾选(checkbox 绝对定位,卡片内容右移避开) */
.select-all {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
font-size: 12px;
color: var(--df-text-dim);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.select-all input { width: 14px; height: 14px; accent-color: var(--df-accent); cursor: pointer; }
.task-card { position: relative; }
.task-card-checkbox {
position: absolute;
left: 2px;
top: 50%;
transform: translateY(-50%);
z-index: 1;
display: flex;
}
.task-card-checkbox input { width: 14px; height: 14px; accent-color: var(--df-accent); cursor: pointer; }
.task-top, .task-branch, .task-meta { padding-left: 18px; }
.task-card {
padding: 14px 0;
border-bottom: 0.5px solid var(--df-border);
cursor: pointer;
transition: background 0.15s;
margin: 0 -12px;
padding: 10px 12px;
border-radius: var(--df-radius);
}
.task-card:hover { background: var(--df-bg-card-hover); }
.task-card:last-child { border-bottom: none; }
.task-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.task-title { font-size: 14px; font-weight: 500; color: var(--df-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.task-status { font-size: 11px; padding: 4px 10px; border-radius: var(--df-radius-sm); }
/* 状态色类已全局化至 components.css;此处仅保留本视图特有的进度覆盖 */
.status-progress { background: var(--df-accent-soft); color: var(--df-accent); }
.task-branch {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 6px;
}
.branch-icon { color: var(--df-accent); font-size: 13px; }
.branch-name {
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--df-text-secondary);
background: var(--df-accent-bg);
padding: 1px 6px;
border-radius: var(--df-radius-sm);
}
.task-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--df-text-dim);
}
/* ===== 日志 ===== */
.log-list {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px;
max-height: 280px;
overflow-y: auto;
}
.log-item {
display: flex;
gap: 8px;
padding: 5px 0;
border-bottom: 0.5px solid rgba(42,42,74,0.5);
}
.log-item:last-child { border-bottom: none; }
.log-time { color: var(--df-text-dim); min-width: 64px; }
.log-level {
min-width: 42px;
font-weight: 500;
text-transform: uppercase;
font-size: 10px;
padding: 1px 4px;
border-radius: var(--df-radius-sm);
text-align: center;
}
.log-info .log-level { color: var(--df-info); }
.log-warn .log-level { color: var(--df-warning); }
.log-error .log-level { color: var(--df-danger); }
.log-msg { color: var(--df-text-secondary); flex: 1; }
/* ===== 项目信息(左栏 .project-info/.info-item,右栏重复面板已删)===== */
/* .modal-* 系列(modal-overlay/modal-box/modal-title/modal-field/modal-actions)
及 .btn:disabled 已提取到 styles/global.css 全局 */
.empty-hint {
text-align: center; padding: 24px 12px;
font-size: 13px; color: var(--df-text-dim);
}
/* ===== 响应式 ===== */
@media (max-width: 768px) {
.project-detail { padding: 16px; }
.detail-grid {
grid-template-columns: 1fr;
}
.log-list { max-height: 200px; }
}
</style>