Files
DevFlow/src/components/task/TaskDrawer.vue
T
lxy f67e65633b 优化: 会话入口与对话交互打磨
- 完成卡点开子代理轨迹浮层,任务抽屉关联会话跳转
- 提及片段编辑差分三向判定,不再错位
- 长回复折叠完成瞬间锚定消息顶,视口不跳
- 空编辑/技能无匹配/回填覆盖草稿均有提示确认
- 队列编辑态切会话重置,刷新快捷键不再误触
- git 变更面板去重缓存,项目卡骨架与溢出重测
2026-10-03 17:57:43 +08:00

405 lines
16 KiB
Vue

<template>
<Drawer
:visible="visible"
width="640px"
:mask="true"
:mask-closable="true"
:esc-to-close="true"
:footer="false"
@close="$emit('close')"
header-style="display:none"
>
<div class="task-drawer" v-if="task">
<!-- 标题行:标题 + 状态 + 优先级 + 推进按钮 -->
<div class="dr-header">
<div class="dr-header-left">
<h1>{{ task.title }}</h1>
<div class="dr-badges">
<span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
<span v-if="task.review_rounds > 0" class="review-rounds-badge">{{ $t('taskDetail.reviewRounds', { n: task.review_rounds }) }}</span>
<span class="priority-badge" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
</div>
</div>
<div class="dr-header-actions">
<button class="btn btn-ghost btn-sm" @click="reload">{{ $t('taskDetail.refresh') }}</button>
<button
v-for="act in advanceActions"
:key="act.target"
class="btn btn-sm"
:class="act.variant"
:disabled="advancing"
@click="handleAdvance(act.target)"
>{{ advancing ? $t('taskDetail.advancing') : $t(act.label) }}</button>
</div>
</div>
<!-- 描述 -->
<section class="panel">
<div class="panel-header"><h2>{{ $t('taskDetail.description') }}</h2></div>
<div v-if="task.description" class="description-wrap">
<span class="value description ai-md" v-html="renderedDesc"></span>
</div>
<div v-else class="empty-hint">{{ $t('taskDetail.descEmpty') }}</div>
</section>
<!-- 关联信息 -->
<section class="panel">
<div class="panel-header"><h2>{{ $t('taskDetail.relatedTitle') }}</h2></div>
<div class="task-info">
<div v-if="task.parent_id" class="info-item">
<span class="label">{{ $t('taskDetail.parentTask') }}</span>
<span class="value">
<router-link :to="`/tasks/${task.parent_id}`" class="project-link" @click="$emit('close')">{{ parentTaskTitle || task.parent_id }}</router-link>
</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" @click="$emit('close')">{{ projectName }}</router-link>
<span v-else>—</span>
</span>
</div>
<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" @click="$emit('close')">{{ ideaTitle }}</router-link>
</span>
</div>
<!-- S4 关联会话:后端审计表反查的主会话,无关联时入口隐藏;点击关抽屉 + 跳会话 -->
<div v-if="taskConversations.length > 0" class="info-item">
<span class="label">{{ $t('taskDetail.relatedConversations') }}</span>
<span class="value conv-links">
<a
v-for="c in taskConversations"
:key="c.conversation_id"
class="conv-link"
:title="`${c.tool_name} · ${formatDate(c.requested_at)}`"
@click.prevent="handleOpenConversation(c.conversation_id)"
>{{ c.title || c.conversation_id }}</a>
</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>
<div v-if="task.assignee" class="info-item">
<span class="label">{{ $t('taskDetail.assignee') }}</span>
<span class="value">{{ task.assignee }}</span>
</div>
<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>
<!-- 任务产出(折叠式) -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('taskDetail.output') }}</h2>
<button class="btn btn-ghost btn-sm" @click="outputExpanded = !outputExpanded">{{ outputExpanded ? $t('taskDetail.collapse') : $t('taskDetail.expand') }}</button>
</div>
<div v-if="outputExpanded">
<div v-if="!task.output_json" class="empty-hint">—</div>
<TaskOutputCard v-else :output-json="task.output_json" />
</div>
<div v-else class="empty-hint">{{ task.output_json ? $t('taskDetail.expand') : '—' }}</div>
</section>
<!-- 子任务面板(仅顶层任务,简化版:无批量操作) -->
<section v-if="!task.parent_id" class="panel">
<div class="panel-header">
<h2>{{ $t('taskDetail.childrenTitle') }}</h2>
<span class="subtask-count">{{ $t('taskDetail.subtaskCount', { n: children.length }) }}</span>
</div>
<div v-if="children.length > 0" class="subtask-progress">
<div class="mini-progress">
<div class="mini-progress-fill" :style="{ width: subtaskProgress.pct + '%' }"></div>
</div>
<span class="subtask-progress-text">{{ $t('taskDetail.progressTitle') }}: {{ subtaskProgress.done }}/{{ subtaskProgress.total }}</span>
</div>
<div v-if="children.length > 0" class="subtask-list">
<div v-for="child in children" :key="child.id" class="subtask-item" @click="router.push(`/tasks/${child.id}`); $emit('close')">
<span class="subtask-title">{{ child.title }}</span>
<span class="status-tag" :class="taskStatusClass(child.status)">{{ $t(taskStatusLabel(child.status)) }}</span>
<span class="priority-badge" :class="priorityClass(child.priority)">{{ priorityLabel(child.priority) }}</span>
</div>
</div>
<div v-else class="empty-hint subtask-empty">{{ $t('taskDetail.childEmpty') }}</div>
<button class="btn btn-ghost btn-sm subtask-add" @click="showSubtaskModal = true">{{ $t('taskDetail.addSubtask') }}</button>
</section>
<!-- 工作流进度(抽屉内简化:仅显示推进提示文本,不渲染完整 DAG) -->
<section v-if="wfSnap" class="panel wf-panel">
<div class="panel-header"><h2>{{ $t('taskDetail.workflowAdvanceTitle') }}</h2></div>
<div class="wf-progress">
<span v-if="wfSnap.runningNode">{{ $t('taskDetail.workflowStepRunning', { node: wfSnap.runningNode }) }}</span>
<span v-else-if="wfSnap.doneCount > 0" class="wf-progress-count">{{ $t('taskDetail.workflowStepsProgress', { done: wfSnap.doneCount, total: wfSnap.totalNodes }) }}</span>
<span v-if="wfSnap.result === 'completed'" class="wf-progress-hint">{{ $t('taskDetail.workflowCompletedHint') }}</span>
<span v-else-if="wfSnap.result === 'failed'" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span>
</div>
</section>
</div>
<!-- 加载态 -->
<div v-else-if="loading" class="empty-hint loading-hint">{{ $t('taskDetail.loading') }}</div>
<div v-else-if="errorMsg" class="empty-hint error-hint">⚠ {{ errorMsg }}</div>
<!-- 子任务新建弹窗 -->
<div class="modal-overlay" v-if="showSubtaskModal" @click.self="showSubtaskModal = false">
<div class="modal-box">
<h3>{{ $t('taskDetail.addSubtask') }}</h3>
<div class="modal-field">
<label>{{ $t('taskDetail.title') }}</label>
<input v-model="subtaskTitle" :placeholder="$t('tasks.modal.titlePlaceholder')" @keyup.enter="confirmSubtask" />
</div>
<div class="modal-field">
<label>{{ $t('taskDetail.description') }}</label>
<input v-model="subtaskDesc" :placeholder="$t('tasks.modal.descPlaceholder')" />
</div>
<div class="modal-field">
<label>{{ $t('taskDetail.priority') }}</label>
<select v-model="subtaskPriority">
<option :value="0">{{ $t('tasks.modal.priorityCritical') }}</option>
<option :value="1">{{ $t('tasks.modal.priorityHigh') }}</option>
<option :value="2">{{ $t('tasks.modal.priorityMedium') }}</option>
<option :value="3">{{ $t('tasks.modal.priorityLow') }}</option>
</select>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="showSubtaskModal = false">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" @click="confirmSubtask" :disabled="subtaskSubmitting || !subtaskTitle.trim()">{{ $t('common.confirm') }}</button>
</div>
</div>
</div>
</Drawer>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Drawer } from '@arco-design/web-vue'
import { taskApi, projectApi, ideaApi, aiApi } from '@/api'
import { useProjectStore } from '@/stores/project'
import { formatDate } from '@/utils/time'
import { useRendered } from '@/composables/useMarkdown'
import { useAiConversations } from '@/composables/ai/useAiConversations'
import { taskStatusLabel, taskStatusClass, priorityLabel, priorityClass, ADVANCE_MAP } from '@/constants/project'
import type { AdvanceAction } from '@/constants/project'
import type { TaskRecord, ProjectRecord, IdeaRecord } from '@/api/types'
import TaskOutputCard from '@/components/task/TaskOutputCard.vue'
import { useToast } from '@/composables/useToast'
const props = defineProps<{
taskId: string | null
visible: boolean
}>()
const emit = defineEmits<{
close: []
'advance-done': []
}>()
const { t } = useI18n()
const router = useRouter()
const store = useProjectStore()
const { showToast } = useToast()
const { openConversation } = useAiConversations()
// 关联会话引用(S4:审计表反查,无关联=空数组 → 入口隐藏)
type TaskConvRef = { conversation_id: string; title: string | null; tool_name: string; requested_at: string }
const taskConversations = ref<TaskConvRef[]>([])
const loading = ref(true)
const errorMsg = ref('')
const task = ref<TaskRecord | null>(null)
const projects = ref<ProjectRecord[]>([])
const ideas = ref<IdeaRecord[]>([])
const children = ref<TaskRecord[]>([])
const parentTaskTitle = ref<string | null>(null)
const advancing = ref(false)
const outputExpanded = ref(false)
// 子任务新建
const showSubtaskModal = ref(false)
const subtaskTitle = ref('')
const subtaskDesc = ref('')
const subtaskPriority = ref(2)
const subtaskSubmitting = ref(false)
const advanceActions = computed<AdvanceAction[]>(() => {
const s = task.value?.status
if (!s) return []
return ADVANCE_MAP[s] ?? []
})
// 解析
const projectName = computed(() => {
if (!task.value) return ''
return projects.value.find(p => p.id === task.value!.project_id)?.name ?? ''
})
const ideaTitle = computed(() => {
if (!task.value?.idea_id) return ''
return ideas.value.find(i => i.id === task.value!.idea_id)?.title ?? task.value!.idea_id
})
const { rendered } = useRendered(
() => task.value?.description ?? '',
)
const renderedDesc = computed(() => rendered.value)
const subtaskProgress = computed(() => {
const total = children.value.length
const done = children.value.filter(c => c.status === 'done' || c.status === 'cancelled').length
return { done, total, pct: total > 0 ? Math.round((done / total) * 100) : 0 }
})
// 工作流进度(从 store 共享,读取最近一次执行进度)
const wfSnap = computed(() => {
try {
return store.workflowProgress(null)
} catch { return null }
})
// 加载
async function load() {
const tid = props.taskId // 捕获发起时 taskId,响应回来校验未变才写入(防慢响应覆盖新任务)
if (!tid) return
loading.value = true
errorMsg.value = ''
taskConversations.value = [] // 新一轮加载先清旧引用,防切换任务后残留上一任务的会话
// 关联会话为辅助入口,旁路加载不阻塞主数据;失败静默(入口不显示即降级)
void loadTaskConversations(tid)
try {
const [t, ps, ideasList] = await Promise.all([
taskApi.get(tid),
projectApi.list(),
ideaApi.list(),
])
if (tid !== props.taskId) return // taskId 已切换,旧任务响应丢弃
task.value = t
projects.value = ps
ideas.value = ideasList
await loadChildren()
await loadParentTitle()
} catch (e: any) {
if (tid !== props.taskId) return // 旧任务请求的失败不污染当前视图
errorMsg.value = e?.toString() ?? t('common.unknownError')
} finally {
if (tid === props.taskId) loading.value = false // 新一轮 load 已接管 loading 态
}
}
/** S4:拉当前任务的关联会话(独立于主 try,查失败不拖垮抽屉);响应回来校验 taskId 未变 */
async function loadTaskConversations(tid: string) {
try {
const refs = await aiApi.listTaskConversations(tid)
if (tid !== props.taskId) return // 任务已切换,旧任务响应丢弃
taskConversations.value = refs
} catch { taskConversations.value = [] }
}
/** 点击关联会话:先关抽屉(面板在抽屉下层,不关会被遮挡),再走 openConversation 跳转 */
async function handleOpenConversation(convId: string) {
emit('close')
await openConversation(convId)
}
async function loadChildren() {
const cur = task.value
if (!cur || cur.parent_id) { children.value = []; return }
try {
children.value = await taskApi.list({ project_id: cur.project_id, parent_id: cur.id })
} catch { children.value = [] }
}
async function loadParentTitle() {
const cur = task.value
if (!cur?.parent_id) { parentTaskTitle.value = null; return }
try {
const p = await taskApi.get(cur.parent_id)
parentTaskTitle.value = p.title
} catch { parentTaskTitle.value = null }
}
function reload() { load() }
// 推进
async function handleAdvance(target: string) {
if (!task.value || advancing.value) return
advancing.value = true
try {
const updated = await taskApi.advance(task.value.id, target)
task.value = updated
showToast(t('tasks.batch.advanceResult', { n: 1 }), 'success')
emit('advance-done')
} catch (e: any) {
showToast(e?.toString() ?? t('common.unknownError'), 'error')
} finally {
advancing.value = false
}
}
watch(() => props.taskId, (id) => {
if (id) load()
})
// 子任务新建
async function confirmSubtask() {
if (!task.value || !subtaskTitle.value.trim()) return
subtaskSubmitting.value = true
try {
await taskApi.create({
project_id: task.value.project_id,
title: subtaskTitle.value.trim(),
description: subtaskDesc.value.trim(),
priority: subtaskPriority.value,
parent_id: task.value.id,
})
showSubtaskModal.value = false
subtaskTitle.value = ''
subtaskDesc.value = ''
await loadChildren()
// UX走查 P2-3:新建子任务成功此前误复用批量推进文案「已推进 N 个任务」,语义误导
showToast(t('taskDetail.subtaskCreated'), 'success')
} catch (e: any) {
showToast(e?.toString() ?? t('common.unknownError'), 'error')
} finally {
subtaskSubmitting.value = false
}
}
</script>
<style scoped>
.task-drawer { padding: 0; }
.dr-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 0.5px solid var(--df-border);
}
.dr-header-left { flex: 1; min-width: 0; }
.dr-header-left h1 {
font-size: 16px;
font-weight: 600;
color: var(--df-text);
margin: 0 0 8px;
line-height: 1.4;
word-break: break-all;
}
.dr-badges { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.dr-header-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.loading-hint { text-align: center; padding: 40px 0; }
/* 关联会话:多会话纵向排列,链接样式对齐 .project-link 语义(TaskDrawer 内无其 scoped 定义) */
.conv-links { display: flex; flex-direction: column; gap: 4px; }
.conv-link {
color: var(--df-accent);
font-weight: 500;
cursor: pointer;
word-break: break-all;
}
.conv-link:hover { text-decoration: underline; }
</style>