优化: 所有剩余UI/UX待办一批完成(持久化+AuditLog+解耦+total+原12大改+P2)

持久化(P1-c):新建 usePersistedRef composable,Tasks/AuditLog/ProjectDetail 等接入 localStorage

AuditLog(P1-d):后端 list_tool_executions 加 WHERE 筛选+返 {items,total,has_more},前端对接+长列折叠+筛选持久化

数据源解耦(P1-g):ProjectDetail projectTasks 按 project_id 独立加载 + ChatInput @项目联想独立加载(不读 store.tasks 当前页)

GitChanges(12a):后端 get_module_commits 加 git rev-list --count 返 total,前端显真实总数

原12大改:Dashboard 统计卡压底行(1)/Projects 列表卡片视图(2)/project_event 埋点排序(3)/TaskDetail 重设计(4)/IdeaDetail 重设计(5)/KnowledgeDetail 重设计(6)/界面持久化+侧栏Ctrl+B+审批数字键(7)/ProjectDetail 三栏改两栏(10)

P2打磨:文件浏览器(FileTree去重/FilePreview行号.md Diff/selectedFilePath归位)/settings反馈(假保存/端口校验)/AI会话(try-catch/scrollIntoView)/后端计数(move_queue事件/timeline total/workflow分页/import_batch分块)/杂项(TopBar/ConfirmDialog键盘/CIStatus i18n/ToolResultBody/ModuleNode/ApprovalDialog全选)
This commit is contained in:
lxy
2026-08-02 13:11:06 +08:00
parent caaabf0c15
commit f736f435bc
70 changed files with 2645 additions and 576 deletions
+247 -138
View File
@@ -1,15 +1,42 @@
<template>
<div class="task-detail">
<!-- 页面头部 -->
<header class="page-header">
<!-- 页面头部(吸顶):标题 + 状态 + 优先级 在左,推进按钮在右(滚动始终可见)
Problem4 推进按钮从面板内移到 header 右侧, refresh 同区-->
<header class="page-header task-detail-header">
<div class="header-left">
<router-link to="/tasks" class="back-link">{{ $t('taskDetail.backToList') }}</router-link>
<h1>{{ task?.title ?? '...' }}</h1>
<span v-if="task" class="status-tag" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
<!-- F-04 review 轮次显示(>0 才显示,in_reviewin_progress / testingin_review 退回时后端 +1) -->
<span v-if="task && task.review_rounds > 0" class="review-rounds-badge">
{{ $t('taskDetail.reviewRounds', { n: task.review_rounds }) }}
</span>
<span v-if="task" class="priority-badge" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
</div>
<div class="header-actions">
<button class="btn btn-ghost" type="button" @click="refresh">{{ $t('taskDetail.refresh') }}</button>
<button class="btn btn-ghost btn-sm" type="button" @click="refresh">{{ $t('taskDetail.refresh') }}</button>
<!-- F-260616-06 -1 / B-41 工作流推进按钮(联动任务, target_status 后端自动选推进链模板)
与手动 advance 并存:仅当当前态的 primary 前向推进目标 {in_progress, testing, done}
( template_for 有对应模板)时显示手动 advance 仍走 advance_task IPC;工作流推进
run_workflow, AiNode/HumanNode 执行后再由后端回调推进任务
独立 loading 标志 wfAdvancing advancing 互不干扰 -->
<button
v-if="wfAdvanceAction"
type="button"
class="btn btn-sm btn-primary"
:disabled="wfAdvancing || advancing"
@click="handleWorkflowAdvance(wfAdvanceAction.target)"
>{{ wfAdvancing ? $t('taskDetail.workflowAdvancing') : $t('taskDetail.workflowAdvance') }}</button>
<!-- F-05 推进按钮:按当前 status 显示状态机合法下一态(todo/done/cancelled 终态无按钮) -->
<button
v-for="act in advanceActions"
:key="act.target"
type="button"
class="btn btn-sm"
:class="act.variant"
:disabled="advancing"
@click="handleAdvance(act.target)"
>{{ advancing ? $t('taskDetail.advancing') : $t(act.label) }}</button>
</div>
</header>
@@ -17,144 +44,111 @@
<div v-if="loading" class="empty-hint">{{ $t('taskDetail.loading') }}</div>
<div v-else-if="errorMsg" class="empty-hint error-hint"> {{ errorMsg }}</div>
<!-- 主体 -->
<!-- 主体:两栏布局(Problem4 )左栏=描述+关联信息+时间戳,右栏=任务产出+工作流
窄屏自动单列-->
<div v-else-if="task" class="detail-grid">
<section class="panel">
<div class="panel-header">
<h2>{{ $t('taskDetail.infoTitle') }}</h2>
</div>
<div class="task-info">
<div class="info-item">
<span class="label">{{ $t('taskDetail.title') }}</span>
<span class="value">{{ task.title }}</span>
</div>
<div class="info-item info-block">
<span class="label">{{ $t('taskDetail.description') }}</span>
<!-- ============ 左栏 ============ -->
<div class="left-column">
<!-- 描述(可折叠:Problem4 400px 显展开按钮) -->
<section v-if="task.description" class="panel">
<div class="panel-header"><h2>{{ $t('taskDetail.description') }}</h2></div>
<div class="description-wrap" :style="descWrapStyle">
<!-- B-24:任务描述 Markdown 渲染(## 标题 / - 列表 ),v-html useMarkdown DOMPurify sanitize -->
<span
class="value description ai-md"
v-if="task.description"
v-html="renderedDesc"
></span>
<span v-else class="value description"></span>
<span ref="descEl" class="value description ai-md" v-html="renderedDesc"></span>
</div>
<div class="info-item">
<span class="label">{{ $t('taskDetail.status') }}</span>
<span class="value">
<span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
<!-- F-04 review 轮次显示(>0 才显示,in_reviewin_progress / testingin_review 退回时后端 +1) -->
<span v-if="task.review_rounds > 0" class="review-rounds-badge">
{{ $t('taskDetail.reviewRounds', { n: task.review_rounds }) }}
<button
v-if="descCollapsible"
class="btn btn-ghost btn-sm desc-toggle"
type="button"
@click="descExpanded = !descExpanded"
>{{ descExpanded ? $t('taskDetail.collapse') : $t('taskDetail.expand') }}</button>
<!-- 折叠态淡出渐变蒙层(展开态不显) -->
<div v-if="descCollapsible && !descExpanded" class="desc-fade"></div>
</section>
<!-- 关联信息(Problem4 去重 + 空值不显行)
标题/状态/优先级已在 header 显示,这里不重复-->
<section class="panel">
<div class="panel-header"><h2>{{ $t('taskDetail.relatedTitle') }}</h2></div>
<div class="task-info">
<div class="info-item">
<span class="label">{{ $t('taskDetail.project') }}</span>
<span class="value">
<router-link v-if="task.project_id" :to="`/projects/${task.project_id}`" class="project-link">
{{ projectName }}
</router-link>
<span v-else></span>
</span>
</div>
<!-- F-260619-01:关联灵感(1对1 单向,idea_id 解析为友好 title,非裸 id) -->
<div v-if="task.idea_id" class="info-item">
<span class="label">{{ $t('taskDetail.relatedIdea') }}</span>
<span class="value">
<router-link :to="`/ideas/${task.idea_id}`" class="project-link">{{ ideaTitle }}</router-link>
</span>
</div>
<!-- 分支(有值才显) -->
<div v-if="task.branch_name" class="info-item">
<span class="label">{{ $t('taskDetail.branch') }}</span>
<span class="value">
<span class="branch-tag"><span class="branch-icon"></span>{{ task.branch_name }}</span>
</span>
</div>
<!-- 负责人(空值不显行,Problem4 ) -->
<div v-if="task.assignee" class="info-item">
<span class="label">{{ $t('taskDetail.assignee') }}</span>
<span class="value">{{ task.assignee }}</span>
</div>
<!-- 基础分支(空值不显行) -->
<div v-if="task.base_branch" class="info-item">
<span class="label">{{ $t('taskDetail.baseBranch') }}</span>
<span class="value mono">{{ task.base_branch }}</span>
</div>
<!-- 工作流定义(空值不显行) -->
<div v-if="task.workflow_def_id" class="info-item">
<span class="label">{{ $t('taskDetail.workflowDef') }}</span>
<span class="value mono">{{ task.workflow_def_id }}</span>
</div>
<!-- 创建/更新时间:最底小字(Problem4 ) -->
<div class="info-item timestamps">
<span class="timestamp">{{ $t('taskDetail.createdAt') }}: {{ formatDate(task.created_at) }}</span>
<span class="timestamp">{{ $t('taskDetail.updatedAt') }}: {{ formatDate(task.updated_at) }}</span>
</div>
</div>
</section>
</div>
<!-- ============ 右栏:任务产出 + 工作流进度 ============ -->
<div class="right-column">
<!-- F-AiNodeSelfReview: 任务产出 + AI 自审结果展示(抽至 TaskOutputCard 子组件,零行为变更) -->
<section class="panel">
<div class="panel-header"><h2>{{ $t('taskDetail.output') }}</h2></div>
<!-- 无产出时占位(任务未跑过 AiNode 产出),右栏保留产出栏框架便于后续产出出现即填入 -->
<div v-if="!task.output_json" class="empty-hint output-empty"></div>
<TaskOutputCard v-else :output-json="task.output_json" />
</section>
<!-- B-41 工作流推进轻量进度(从面板内 advance-row 提取,推进按钮已移 header) -->
<section v-if="wfAdvancing || wfProgressHint" class="panel">
<div class="panel-header"><h2>{{ $t('taskDetail.workflowAdvanceTitle') }}</h2></div>
<div class="wf-progress">
<span v-if="wfRunningNode">{{ $t('taskDetail.workflowStepRunning', { node: wfRunningNode }) }}</span>
<span v-else-if="wfDoneTotal > 0" class="wf-progress-count">
{{ $t('taskDetail.workflowStepsProgress', { done: wfDoneCount, total: wfDoneTotal }) }}
</span>
</div>
<!-- F-05 推进按钮:按当前 status 显示状态机合法下一态(todo/done/cancelled 终态无按钮) -->
<div v-if="advanceActions.length" class="info-item info-block advance-row">
<span class="label">{{ $t('taskDetail.advanceTitle') }}</span>
<div class="advance-actions">
<button
v-for="act in advanceActions"
:key="act.target"
type="button"
class="btn btn-sm"
:class="act.variant"
:disabled="advancing"
@click="handleAdvance(act.target)"
>{{ advancing ? $t('taskDetail.advancing') : $t(act.label) }}</button>
</div>
</div>
<!-- F-260616-06 -1 / B-41 工作流推进按钮(联动任务, target_status 后端自动选推进链模板)
与手动 advance 并存:仅当当前态的 primary 前向推进目标 {in_progress, testing, done}
( template_for 有对应模板)时显示手动 advance 仍走 advance_task IPC;工作流推进
run_workflow, AiNode/HumanNode 执行后再由后端回调推进任务
独立 loading 标志 wfAdvancing advancing 互不干扰 -->
<div v-if="wfAdvanceAction" class="info-item info-block advance-row">
<span class="label">{{ $t('taskDetail.workflowAdvanceTitle') }}</span>
<div class="advance-actions">
<button
type="button"
class="btn btn-sm btn-primary"
:disabled="wfAdvancing || advancing"
@click="handleWorkflowAdvance(wfAdvanceAction.target)"
>{{ wfAdvancing ? $t('taskDetail.workflowAdvancing') : $t('taskDetail.workflowAdvance') }}</button>
</div>
<!-- B-41 轻量进度(监听 workflow-event: NodeStarted/NodeCompleted/WorkflowFailed)
完整进度条/预估留后续批;完成/失败后任务态由后端回调推进,df-data-changed 自动刷新 task -->
<div v-if="wfAdvancing || wfProgressHint" class="wf-progress">
<span v-if="wfRunningNode">{{ $t('taskDetail.workflowStepRunning', { node: wfRunningNode }) }}</span>
<span v-else-if="wfDoneTotal > 0" class="wf-progress-count">
{{ $t('taskDetail.workflowStepsProgress', { done: wfDoneCount, total: wfDoneTotal }) }}
</span>
<span v-if="wfCompletedHint" class="wf-progress-hint">{{ $t('taskDetail.workflowCompletedHint') }}</span>
<span v-if="wfFailedHint" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span>
</div>
<span v-if="wfCompletedHint" class="wf-progress-hint">{{ $t('taskDetail.workflowCompletedHint') }}</span>
<span v-if="wfFailedHint" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span>
</div>
<!-- 工作流 DAG 结构 -->
<WorkflowDagDisplay v-if="wfDagJson" :dag-json="wfDagJson" :node-statuses="wfNodeStatuses" />
<div class="info-item">
<span class="label">{{ $t('taskDetail.priority') }}</span>
<span class="value">
<span class="priority-badge" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
</span>
</div>
<div class="info-item">
<span class="label">{{ $t('taskDetail.project') }}</span>
<span class="value">
<router-link v-if="task.project_id" :to="`/projects/${task.project_id}`" class="project-link">
{{ projectName }}
</router-link>
<span v-else></span>
</span>
</div>
<!-- F-260619-01:关联灵感(1对1 单向,idea_id 解析为友好 title,非裸 id) -->
<div class="info-item">
<span class="label">{{ $t('taskDetail.relatedIdea') }}</span>
<span class="value">
<router-link v-if="task.idea_id" :to="`/ideas/${task.idea_id}`" class="project-link">
{{ ideaTitle }}
</router-link>
<span v-else></span>
</span>
</div>
<div class="info-item">
<span class="label">{{ $t('taskDetail.branch') }}</span>
<span class="value">
<span v-if="task.branch_name" class="branch-tag">
<span class="branch-icon"></span>{{ task.branch_name }}
</span>
<span v-else></span>
</span>
</div>
<div class="info-item">
<span class="label">{{ $t('taskDetail.assignee') }}</span>
<span class="value">{{ task.assignee ?? '—' }}</span>
</div>
<div class="info-item">
<span class="label">{{ $t('taskDetail.baseBranch') }}</span>
<span class="value">{{ task.base_branch ?? '—' }}</span>
</div>
<div class="info-item">
<span class="label">{{ $t('taskDetail.workflowDef') }}</span>
<span class="value mono">{{ task.workflow_def_id ?? '—' }}</span>
</div>
<!-- F-AiNodeSelfReview: 任务产出 + AI 自审结果展示(抽至 TaskOutputCard 子组件,零行为变更) -->
<TaskOutputCard :output-json="task.output_json" />
<div class="info-item">
<span class="label">{{ $t('taskDetail.createdAt') }}</span>
<span class="value">{{ formatDate(task.created_at) }}</span>
</div>
<div class="info-item">
<span class="label">{{ $t('taskDetail.updatedAt') }}</span>
<span class="value">{{ formatDate(task.updated_at) }}</span>
</div>
</div>
</section>
</section>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { listen } from '@tauri-apps/api/event'
@@ -223,6 +217,37 @@ const { rendered: renderedDesc, ensureLoaded } = useRendered(
() => task.value?.description ?? '',
)
// ============================================================
// Problem4 ②:描述可折叠 — 超 400px 显展开按钮(测真实 DOM 高度)
// ------------------------------------------------------------
// 不用字符数近似(渲染后 Markdown 列表/标题行高不一,字符数与 px 无线性关系),
// 改用 descEl 实测 scrollHeight:首次渲染 + task 切换后 nextTick 测量。
// descCollapsible=true 时才显按钮;默认折叠(!descExpanded)即首次进入截 400px。
// 折叠态 max-height 由 descWrapStyle 内联注入(展开态不设上限,自适应)。
// .description-wrap 的 overflow:hidden + transition 让折叠/展开有过渡。
// 蒙层 .desc-fade 暗示折叠态下方还有内容。
const DESC_COLLAPSE_PX = 400
const descEl = ref<HTMLElement | null>(null)
const descExpanded = ref(false)
const descOverflow = ref(false) // 真实高度是否超阈值(控制按钮显隐)
const descCollapsible = computed(() => descOverflow.value)
// 折叠态 max-height 内联(过渡友好):展开态不设上限(自适应)
const descWrapStyle = computed(() =>
descExpanded.value ? {} : { maxHeight: DESC_COLLAPSE_PX + 'px' },
)
async function measureDescHeight() {
// descEl 是 <span>(描述内容),自身无 max-height 限制 → scrollHeight 即其真实渲染高度,
// 不受父 .description-wrap 的 max-height 裁剪影响(span 在父内若被裁,其 scrollHeight
// 仍反映完整内容高)。直接读无需临时改父样式。
await nextTick()
const el = descEl.value
if (!el) { descOverflow.value = false; return }
descOverflow.value = el.scrollHeight > DESC_COLLAPSE_PX
}
// F-AiNodeSelfReview: output_json 解析 + AI 产出/自审渲染已抽至 TaskOutputCard 子组件
// (components/task/TaskOutputCard.vue),父级只传 output-json prop,零行为变更。
@@ -448,6 +473,11 @@ function refresh() {
// 路由参数变化(id 变化)时重新加载
watch(taskId, () => { load() })
// Problem4 ②:切换 task / 描述渲染完成 → 重置折叠态 + 重测高度。
// renderedDesc 异步(Markdown 经 marked 渲染),内容变化后 nextTick 测真实 px。
watch(() => task.value?.id, () => { descExpanded.value = false })
watch(renderedDesc, () => { measureDescHeight() })
// B-260616-18: 数据变更联动刷新 unlistener(onMounted 注册,onBeforeUnmount 释放)
// 对齐 AiChat _unlistenToolSlow 生命周期模式。本视图绕 store 直调 taskApi.get/projectApi.list,
// 不享受 store 全局 df-data-changed 监听(该监听只刷 store.tasks 列表,不含本视图的当前 task 单体),
@@ -492,15 +522,33 @@ onBeforeUnmount(() => {
.task-detail { padding: 16px 20px 20px; }
/* page-header / btn / back-link 等已提取到 global.css 全局 */
.header-left { display: flex; align-items: center; gap: 12px; }
/* F-05 推进操作区 */
.advance-row .advance-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
.header-left { display: flex; align-items: center; gap: 12px; min-width: 0; }
.header-left h1 {
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
min-width: 0;
}
/* Problem4 ① 吸顶 header(推进按钮始终可见) */
.task-detail-header {
position: sticky;
top: 0;
z-index: 10;
background: var(--df-bg);
/* 留底部细分割线,与全局 page-header margin-bottom 协同(0.5px 边框规范) */
padding-bottom: 8px;
border-bottom: 0.5px solid var(--df-border);
/* 顶负 padding 抵消 .task-detail 的 16px 顶 padding,吸顶贴顶 */
margin: -16px -20px 0;
padding-left: 20px;
padding-right: 20px;
padding-top: 16px;
align-items: center;
}
/* header-actions 内多个推进按钮 + refresh 排列(对齐全局 .header-actions gap:10px) */
.task-detail-header .header-actions { flex-wrap: wrap; justify-content: flex-end; }
/* advance 操作区已移入 header-actions,旧 .advance-row 样式删除 */
/* B-41 工作流推进轻量进度提示 */
.wf-progress {
display: flex;
@@ -552,7 +600,21 @@ onBeforeUnmount(() => {
.priority-low { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
/* ===== 面板 ===== */
.detail-grid { display: grid; grid-template-columns: 1fr; gap: var(--df-gap-grid); }
/* Problem4 ⑤ 宽屏两栏:左栏(描述+关联信息+时间戳) / 右栏(任务产出+工作流进度)。
窄屏(<960px)单列。对齐 ProjectDetail 两栏模式但阈值更宽(产出栏更窄)。
不设 margin-top —— 全局 .page-header 已有 margin-bottom: var(--df-gap-page) 提供分隔。*/
.detail-grid {
display: grid;
grid-template-columns: 1fr 380px;
gap: var(--df-gap-page);
align-items: start; /* 两栏顶对齐,长栏不撑高短栏 */
}
.left-column, .right-column {
display: flex;
flex-direction: column;
gap: var(--df-gap-grid);
min-width: 0; /* 网格项防溢出(长 url/代码块) */
}
/* .panel / .panel-header 基础样式已收敛至全局 components.css(DRY 收口 B-260619)。 */
@@ -565,8 +627,44 @@ onBeforeUnmount(() => {
/* B-260615-31:字段同行布局(label 固定宽 + value 占余),描述字段 info-block 保持块状)
基础 .info-item/.label/.value 已收敛至全局 components.css(DRY 收口 B-260619),
此处仅保留本组件特有覆盖。 */
/* B-24 漏 white-space 覆盖致 v-html 后 HTML 标签间 \n 被 pre-wrap 渲染为空行间距——移除 pre-wrap,描述由 .ai-md 接管(line-height 1.5 对齐 AiChat li) */
.info-item .value.description { line-height: 1.5; }
/* B-24 漏 white-space 覆盖致 v-html 后 HTML 标签间 \n 被 pre-wrap 渲染为空行间距——移除 pre-wrap,描述由 .ai-md 接管(line-height 1.5 对齐 AiChat li)
Problem4 重设计:描述区从 .info-item 内移到独立 .description-wrap(可折叠容器),
故选择器改 .description-wrap。display:block 让 <span> 有块盒模型,scrollHeight 可读。*/
.description-wrap .value.description { line-height: 1.5; display: block; }
/* Problem4 ② 描述可折叠:折叠态 max-height(内联注入 400px) + overflow hidden + 过渡。
展开态 max-height none(内联不设),自然撑开。蒙层 .desc-fade 暗示下方有内容。*/
.description-wrap {
position: relative;
overflow: hidden;
transition: max-height 0.25s var(--df-ease);
}
.desc-toggle {
margin-top: 8px;
}
.desc-fade {
/* 折叠态底部渐变蒙层,提示"下方还有内容"(展开态不渲染此元素) */
position: absolute;
left: 0; right: 0; bottom: 0;
height: 32px;
background: linear-gradient(to bottom, transparent, var(--df-bg-card));
pointer-events: none;
}
/* Problem4 ⑥ 创建/更新时间最底小字(独立行,非 label/value 对) */
.info-item.timestamps {
flex-direction: row;
gap: 16px;
padding-top: var(--df-gap-grid);
border-top: 0.5px solid var(--df-border);
}
.timestamp {
font-size: 11px;
color: var(--df-text-dim);
}
/* 右栏空产出占位提示 */
.output-empty { padding: 24px 12px; }
/* F-AiNodeSelfReview output_json 区块样式已随子组件移至 TaskOutputCard.vue */
@@ -599,4 +697,15 @@ onBeforeUnmount(() => {
font-size: 13px; color: var(--df-text-dim);
}
.error-hint { color: var(--df-danger); }
/* Problem4 ⑤ 响应式:窄屏(<960px)两栏退单列,产出栏移到下方 */
@media (max-width: 960px) {
.detail-grid { grid-template-columns: 1fr; }
}
/* 窄屏 header-actions 内推进按钮可能换行,允许并压缩间距 */
@media (max-width: 720px) {
.task-detail-header { flex-wrap: wrap; }
.task-detail-header .header-left { flex: 1 1 100%; margin-bottom: 8px; }
}
</style>