1637 lines
65 KiB
Vue
1637 lines
65 KiB
Vue
<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.css,scoped 不重复定义 */
|
||
|
||
/* 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>
|